Files
AX-Copilot-Codex/src2/ChatWindow.xaml.cs

10210 lines
422 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>AX Agent 창. Claude Desktop 스타일 — 사이드바 + 카테고리 분류 + 타임라인.</summary>
public partial class ChatWindow : Window
{
private readonly SettingsService _settings;
private readonly ChatStorageService _storage;
private readonly LlmService _llm;
private readonly ToolRegistry _toolRegistry;
private readonly AgentLoopService _agentLoop;
private readonly ModelRouterService _router;
private readonly object _convLock = new();
private ChatConversation? _currentConversation;
private CancellationTokenSource? _streamCts;
private bool _isStreaming;
private bool _sidebarVisible = true;
private string _selectedCategory = ""; // "" = 전체
private bool _forceClose = false; // 앱 종료 시 진짜 닫기 플래그
// 스트리밍 UI — 커서 깜빡임 + 로딩 아이콘
private readonly DispatcherTimer _cursorTimer;
private bool _cursorVisible = true;
private TextBlock? _activeStreamText;
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
private bool _aiIconPulseStopped; // 펄스 1회만 중지
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
private PlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
private bool _userScrolled; // 사용자가 위로 스크롤했는지
// 경과 시간 표시
private readonly DispatcherTimer _elapsedTimer;
private DateTime _streamStartTime;
private TextBlock? _elapsedLabel;
// 타이핑 효과
private readonly DispatcherTimer _typingTimer;
private int _displayedLength; // 현재 화면에 표시된 글자 수
private sealed class ConversationMeta
{
public string Id { get; init; } = "";
public string Title { get; init; } = "";
public string UpdatedAtText { get; init; } = "";
public bool Pinned { get; init; }
public string Category { get; init; } = ChatCategory.General;
public string Symbol { get; init; } = "\uE8BD";
public string ColorHex { get; init; } = "#6B7280";
public string Tab { get; init; } = "Chat";
public DateTime UpdatedAt { get; init; }
/// <summary>첫 사용자 메시지 요약 (검색용, 최대 100자).</summary>
public string Preview { get; init; } = "";
/// <summary>분기 원본 대화 ID. null이면 원본 대화.</summary>
public string? ParentId { get; init; }
}
public ChatWindow(SettingsService settings)
{
InitializeComponent();
_settings = settings;
_storage = new ChatStorageService();
_llm = new LlmService(settings);
_router = new ModelRouterService(settings);
_toolRegistry = ToolRegistry.CreateDefault();
_agentLoop = new AgentLoopService(_llm, _toolRegistry, settings)
{
Dispatcher = action => System.Windows.Application.Current.Dispatcher.Invoke(action),
AskPermissionCallback = async (toolName, filePath) =>
{
var result = MessageBoxResult.None;
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
result = CustomMessageBox.Show(
$"도구 '{toolName}'이(가) 다음 파일에 접근하려 합니다:\n\n{filePath}\n\n허용하시겠습니까?",
"AX Agent — 권한 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
});
return result == MessageBoxResult.Yes;
},
UserAskCallback = async (question, options, defaultValue) =>
{
string? response = null;
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
response = UserAskDialog.Show(question, options, defaultValue);
});
return response;
},
};
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "active";
_cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) };
_cursorTimer.Tick += CursorTimer_Tick;
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_elapsedTimer.Tick += ElapsedTimer_Tick;
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(12) };
_typingTimer.Tick += TypingTimer_Tick;
KeyDown += ChatWindow_KeyDown;
Loaded += (_, _) =>
{
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
SetupUserInfo();
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = _settings.Settings.Llm.FolderDataUsage ?? "active";
UpdateAnalyzerButtonVisibility();
UpdateModelLabel();
InputBox.Focus();
MessageScroll.ScrollChanged += MessageScroll_ScrollChanged;
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
Dispatcher.BeginInvoke(() =>
{
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildTopicButtons();
RestoreLastConversations();
RefreshConversationList();
// 데이터 정리 (디스크 I/O)
_ = Task.Run(() =>
{
var retention = _settings.Settings.Llm.RetentionDays;
if (retention > 0) _storage.PurgeExpired(retention);
_storage.PurgeForDiskSpace();
});
}, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
// 입력 바 포커스 글로우 효과
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
InputBox.GotFocus += (_, _) => InputBorder.BorderBrush = accentBrush;
InputBox.LostFocus += (_, _) => InputBorder.BorderBrush = borderBrush;
// 드래그 앤 드롭 파일 첨부 + AI 액션 팝업
InputBorder.AllowDrop = true;
InputBorder.DragOver += (_, de) =>
{
de.Effects = de.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
de.Handled = true;
};
InputBorder.Drop += (_, de) =>
{
if (de.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
{
var enableAi = _settings.Settings.Llm.EnableDragDropAiActions;
if (enableAi && files.Length <= 5)
ShowDropActionMenu(files);
else
foreach (var f in files) AddAttachedFile(f);
}
};
// 스킬 시스템 초기화
if (_settings.Settings.Llm.EnableSkillSystem)
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath);
}
// 슬래시 팝업 네비게이션 버튼
SlashNavUp.MouseLeftButtonUp += (_, _) =>
{
_slashPageOffset = Math.Max(0, _slashPageOffset - SlashPageSize);
RenderSlashPage();
};
SlashNavDown.MouseLeftButtonUp += (_, _) =>
{
_slashPageOffset = Math.Min(_slashAllMatches.Count - 1,
_slashPageOffset + SlashPageSize);
RenderSlashPage();
};
// 슬래시 명령어 칩 닫기 (× 버튼)
SlashChipClose.MouseLeftButtonUp += (_, _) =>
{
HideSlashChip(restoreText: true);
InputBox.Focus();
};
// InputBox에서 슬래시 팝업 열린 상태로 마우스 휠 → 팝업 스크롤
InputBox.PreviewMouseWheel += (_, me) =>
{
if (!SlashPopup.IsOpen) return;
me.Handled = true;
SlashPopup_ScrollByDelta(me.Delta);
};
// 탭 UI 초기 상태
UpdateFolderBar();
// 호버 애니메이션 — 독립 공간이 있는 버튼에만 Scale 적용
// (GhostBtn 스타일 버튼은 XAML에서 배경색+opacity 호버 처리)
ApplyHoverBounceAnimation(BtnModelSelector);
ApplyHoverBounceAnimation(BtnTemplateSelector, -1.5);
ApplyHoverScaleAnimation(BtnSend, 1.12);
ApplyHoverScaleAnimation(BtnStop, 1.12);
};
Closed += (_, _) =>
{
_streamCts?.Cancel();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_llm.Dispose();
};
}
/// <summary>
/// X 버튼으로 닫을 때 창을 숨기기만 합니다 (재사용으로 다음 번 빠르게 열림).
/// 앱 종료 시에는 ForceClose()를 사용합니다.
/// </summary>
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (!_forceClose)
{
e.Cancel = true;
Hide();
return;
}
base.OnClosing(e);
}
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
public void ForceClose()
{
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
lock (_convLock)
{
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
{
_tabConversationId[_activeTab] = _currentConversation.Id;
try { _storage.Save(_currentConversation); } catch { }
}
}
SaveLastConversations();
_forceClose = true;
Close();
}
// ─── 사용자 정보 ────────────────────────────────────────────────────
private void SetupUserInfo()
{
var userName = Environment.UserName;
// AD\, AD/, AD: 접두사 제거
var cleanName = userName;
foreach (var sep in new[] { '\\', '/', ':' })
{
var idx = cleanName.LastIndexOf(sep);
if (idx >= 0) cleanName = cleanName[(idx + 1)..];
}
var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U";
var pcName = Environment.MachineName;
UserInitialSidebar.Text = initial;
UserInitialIconBar.Text = initial;
UserNameText.Text = cleanName;
UserPcText.Text = pcName;
BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})";
}
// ─── 스크롤 동작 ──────────────────────────────────────────────────
private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
// 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥
if (MessageScroll.ScrollableHeight <= 1)
{
_userScrolled = false;
return;
}
// 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지
if (Math.Abs(e.ExtentHeightChange) > 0.5)
return;
var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40;
_userScrolled = !atBottom;
}
private void AutoScrollIfNeeded()
{
if (!_userScrolled)
SmoothScrollToEnd();
}
/// <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 { }
RefreshConversationList();
}
private void CancelTitleEdit()
{
ChatTitleEdit.Visibility = Visibility.Collapsed;
ChatTitle.Visibility = Visibility.Visible;
}
// ─── 카테고리 드롭다운 ──────────────────────────────────────────────
private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var popup = new Popup
{
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
PlacementTarget = BtnCategoryDrop,
Placement = PlacementMode.Bottom,
HorizontalOffset = 0,
VerticalOffset = 4,
};
var container = new Border
{
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
MinWidth = 180,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
},
};
var stack = new StackPanel();
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 = new FontFamily("Segoe MDL2 Assets"),
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, _settings.Settings.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 = _settings.Settings.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(); }));
}
}
container.Child = stack;
popup.Child = container;
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, _settings.Settings.Llm.CustomPresets);
var match = presets.FirstOrDefault(p => p.Category == _selectedCategory);
if (match != null)
{
CategoryLabel.Text = match.Label;
CategoryIcon.Text = match.Symbol;
}
else
{
CategoryLabel.Text = _selectedCategory;
CategoryIcon.Text = "\uE8BD";
}
}
}
// ─── 창 컨트롤 ──────────────────────────────────────────────────────
// WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var source = System.Windows.Interop.HwndSource.FromHwnd(
new System.Windows.Interop.WindowInteropHelper(this).Handle);
source?.AddHook(WndProc);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보
if (msg == 0x0024)
{
var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
var workArea = screen.WorkingArea;
var monitor = screen.Bounds;
var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd);
var dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0;
// MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36)
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y
handled = true;
}
return IntPtr.Zero;
}
private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void BtnMaximize_Click(object sender, RoutedEventArgs e)
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘
}
private void BtnClose_Click(object sender, RoutedEventArgs e) => Close();
// ─── 탭 전환 ──────────────────────────────────────────────────────────
private string _activeTab = "Chat";
private void SaveCurrentTabConversationId()
{
lock (_convLock)
{
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
{
_tabConversationId[_activeTab] = _currentConversation.Id;
// 탭 전환 시 현재 대화를 즉시 저장 (스트리밍 중이어도 진행 중인 내용 보존)
try { _storage.Save(_currentConversation); } catch { }
}
}
// 탭별 마지막 대화 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();
}
private void TabCowork_Checked(object sender, RoutedEventArgs e)
{
if (_activeTab == "Cowork") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
_activeTab = "Cowork";
_selectedCategory = ""; UpdateCategoryLabel();
UpdateTabUI();
}
private void TabCode_Checked(object sender, RoutedEventArgs e)
{
if (_activeTab == "Code") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
_activeTab = "Code";
_selectedCategory = ""; UpdateCategoryLabel();
UpdateTabUI();
}
/// <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 (_settings.Settings.Llm.ShowFileBrowser && FileBrowserPanel != null)
{
FileBrowserPanel.Visibility = Visibility.Visible;
BuildFileTree();
}
}
else if (_activeTab == "Code")
{
// Code 탭: 언어 선택기 + 파일 탐색기
BuildCodeBottomBar();
if (_settings.Settings.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 SwitchToTabConversation()
{
// 이전 탭의 대화 저장
lock (_convLock)
{
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
{
try { _storage.Save(_currentConversation); } catch { }
}
}
// 현재 탭에 기억된 대화가 있으면 복원
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 = _settings.Settings.Llm.WorkFolder;
if (!string.IsNullOrEmpty(workFolder) && _activeTab != "Chat")
_currentConversation.WorkFolder = workFolder;
}
MessagePanel.Children.Clear();
EmptyState.Visibility = Visibility.Visible;
_attachedFiles.Clear();
RefreshAttachedFilesUI();
UpdateChatTitle();
RefreshConversationList();
UpdateFolderBar();
}
// ─── 작업 폴더 ─────────────────────────────────────────────────────────
private readonly List<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 = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
// 최근 폴더 목록
var maxDisplay = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
var recentFolders = _settings.Settings.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 = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
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 = new FontFamily("Segoe MDL2 Assets"),
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 = _settings.Settings.Llm.RecentWorkFolders;
recent.RemoveAll(p => !IsPathAllowed(p));
recent.Remove(path);
recent.Insert(0, path);
var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent);
_settings.Settings.Llm.WorkFolder = path;
_settings.Save();
}
private string GetCurrentWorkFolder()
{
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder))
return _currentConversation.WorkFolder;
}
return _settings.Settings.Llm.WorkFolder;
}
/// <summary>테마에 맞는 ContextMenu를 생성합니다.</summary>
private ContextMenu CreateThemedContextMenu()
{
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
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 = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
void AddItem(string icon, string label, Action action)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
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 { }
});
AddItem("\uE8C8", "경로 복사", () =>
{
try { Clipboard.SetText(folderPath); } catch { }
});
menu.Items.Add(new Separator());
AddItem("\uE74D", "목록에서 삭제", () =>
{
_settings.Settings.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 = _settings.Settings.Llm;
if (conv != null && conv.Permission != null)
_settings.Settings.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 = _settings.Settings.Llm.FilePermission;
conv.DataUsage = _folderDataUsage;
conv.Mood = _selectedMood;
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
}
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
private void BtnPermission_Click(object sender, RoutedEventArgs e)
{
if (PermissionPopup == null) return;
PermissionItems.Children.Clear();
var levels = new (string Level, string Sym, string Desc, string Color)[] {
("Ask", "\uE8D7", "매번 확인 — 파일 접근 시 사용자에게 묻습니다", "#4B5EFC"),
("Auto", "\uE73E", "자동 허용 — 파일을 자동으로 읽고 씁니다", "#DD6B20"),
("Deny", "\uE711", "접근 차단 — 파일 접근을 허용하지 않습니다", "#C50F1F"),
};
var current = _settings.Settings.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 = new FontFamily("Segoe MDL2 Assets"), 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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 220,
});
sp.Children.Add(textStack);
btn.Content = sp;
var capturedLevel = level;
btn.Click += (_, _) =>
{
_settings.Settings.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 = _settings.Settings.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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
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 = new FontFamily("Segoe MDL2 Assets"), 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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
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 모드로 복원
_settings.Settings.Llm.FilePermission = "Ask";
UpdatePermissionUI();
return;
}
var defaultPerm = _settings.Settings.Llm.DefaultAgentPermission;
if (!string.IsNullOrEmpty(defaultPerm))
{
_settings.Settings.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) && _settings.Settings.Llm.EnableImageInput)
{
var maxKb = _settings.Settings.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 { 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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
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 = new FontFamily("Segoe MDL2 Assets"), 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 = new FontFamily("Segoe MDL2 Assets"), 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 DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
EventHandler tickHandler = null!;
tickHandler = (_, _) =>
{
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
var t = Math.Min(elapsed / duration, 1.0);
t = 1 - (1 - t) * (1 - t);
SidebarColumn.Width = new GridLength(from + (to - from) * t);
if (elapsed >= duration)
{
timer.Stop();
timer.Tick -= tickHandler;
SidebarColumn.Width = new GridLength(to);
onComplete?.Invoke();
}
};
timer.Tick += tickHandler;
timer.Start();
}
// ─── 대화 목록 ────────────────────────────────────────────────────────
public void RefreshConversationList()
{
var metas = _storage.LoadAllMeta();
// 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원)
var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
.Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets))
.Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.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 = _settings.Settings.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 = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
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 = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); }
catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; }
}
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 = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = iconBrush,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(icon, 0);
grid.Children.Add(icon);
// 제목 + 날짜 (선택 시 약간 밝게)
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray;
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 = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray)
},
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 = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
border.BorderThickness = new Thickness(2, 0, 0, 0);
}
border.Child = grid;
// 호버 이벤트 — 배경 + 미세 확대
border.RenderTransformOrigin = new Point(0.5, 0.5);
border.RenderTransform = new ScaleTransform(1, 1);
var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC));
border.MouseEnter += (_, _) =>
{
if (!isSelected)
border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF));
catBtn.Visibility = Visibility.Visible;
var st = border.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
};
border.MouseLeave += (_, _) =>
{
if (!isSelected)
border.Background = Brushes.Transparent;
catBtn.Visibility = Visibility.Collapsed;
var st = border.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
};
// 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환
border.MouseLeftButtonDown += (_, _) =>
{
try
{
if (isSelected)
{
// 이미 선택된 대화 → 제목 편집 모드
EnterTitleEditMode(title, item.Id, titleColor);
return;
}
// 스트리밍 중이면 취소
if (_isStreaming)
{
_streamCts?.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_isStreaming = false;
}
var conv = _storage.Load(item.Id);
if (conv != null)
{
// Tab 보정
if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab;
lock (_convLock) _currentConversation = conv;
_tabConversationId[_activeTab] = conv.Id;
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
}
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
};
// 우클릭 → 대화 관리 메뉴 바로 표시
border.MouseRightButtonUp += (_, me) =>
{
me.Handled = true;
// 선택되지 않은 대화를 우클릭하면 먼저 선택
if (!isSelected)
{
var conv = _storage.Load(item.Id);
if (conv != null)
{
if (string.IsNullOrEmpty(conv.Tab)) conv.Tab = _activeTab;
lock (_convLock) _currentConversation = conv;
_tabConversationId[_activeTab] = conv.Id;
UpdateChatTitle();
RenderMessages();
}
}
// Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
};
ConversationPanel.Children.Add(border);
}
// ─── 대화 제목 인라인 편집 ────────────────────────────────────────────
private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor)
{
try
{
// titleTb가 이미 부모에서 분리된 경우(편집 중) 무시
var parent = titleTb.Parent as StackPanel;
if (parent == null) return;
var idx = parent.Children.IndexOf(titleTb);
if (idx < 0) return;
var editBox = new TextBox
{
Text = titleTb.Text,
FontSize = 12.5,
Foreground = titleColor,
Background = Brushes.Transparent,
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
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 { /* 부모가 이미 해제된 경우 무시 */ }
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 { /* 부모가 이미 해제된 경우 무시 */ }
}
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 bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var popup = new Popup
{
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Placement = PlacementMode.MousePoint,
};
var container = new Border
{
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
},
};
var stack = new StackPanel();
// 메뉴 항목 헬퍼
Border CreateMenuItem(string icon, string text, Brush iconColor, 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) });
var iconTb = new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
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);
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 CreateSeparator() => new()
{
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
};
// 고정/해제
stack.Children.Add(CreateMenuItem(
isPinned ? "\uE77A" : "\uE718",
isPinned ? "고정 해제" : "상단 고정",
TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
() =>
{
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 = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
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, _settings.Settings.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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(catColor));
infoSp.Children.Add(new TextBlock
{
Text = catSymbol, FontFamily = new FontFamily("Segoe MDL2 Assets"),
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
{
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 = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
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 = new FontFamily("Segoe MDL2 Assets"),
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();
}));
container.Child = stack;
popup.Child = container;
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 ?? "";
}
}
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
private void RenderMessages()
{
MessagePanel.Children.Clear();
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0)
{
EmptyState.Visibility = Visibility.Visible;
return;
}
EmptyState.Visibility = Visibility.Collapsed;
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg);
}
_ = Dispatcher.InvokeAsync(() => MessageScroll.ScrollToEnd(), DispatcherPriority.Background);
}
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
{
var isUser = role == "user";
if (isUser)
{
// 사용자: 우측 정렬, 악센트 배경 + 편집 버튼
var wrapper = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
MaxWidth = 540,
Margin = new Thickness(120, 6, 40, 6),
};
var bubble = new Border
{
Background = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
CornerRadius = new CornerRadius(16, 4, 16, 16),
Padding = new Thickness(16, 10, 16, 10),
Child = new TextBlock
{
Text = content,
FontSize = 13.5,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap,
LineHeight = 21,
}
};
wrapper.Children.Add(bubble);
// 액션 버튼 바 (복사 + 편집, hover 시 표시)
var userActionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Opacity = 0,
Margin = new Thickness(0, 2, 0, 0),
};
var capturedUserContent = content;
var userBtnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
{
try { Clipboard.SetText(capturedUserContent); } catch { }
}));
userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor,
() => EnterEditMode(wrapper, capturedUserContent)));
// 타임스탬프 + 액션 바
var userBottomBar = new Grid { Margin = new Thickness(0, 2, 0, 0) };
var timestamp = message?.Timestamp ?? DateTime.Now;
userBottomBar.Children.Add(new TextBlock
{
Text = timestamp.ToString("HH:mm"),
FontSize = 10, Opacity = 0.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
userBottomBar.Children.Add(userActionBar);
wrapper.Children.Add(userBottomBar);
wrapper.MouseEnter += (_, _) => userActionBar.Opacity = 1;
wrapper.MouseLeave += (_, _) => userActionBar.Opacity = 0;
// 우클릭 → 메시지 컨텍스트 메뉴
var userContent = content;
wrapper.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowMessageContextMenu(userContent, "user");
};
if (animate) ApplyMessageEntryAnimation(wrapper);
MessagePanel.Children.Add(wrapper);
}
else
{
// 어시스턴트: 좌측 정렬, 다크 배경
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = GetMessageMaxWidth(),
Margin = new Thickness(40, 8, 80, 8)
};
if (animate) ApplyMessageEntryAnimation(container);
// AI 에이전트 이름 + 아이콘
var (agentName, agentSymbol, agentColor) = GetAgentIdentity();
var agentBrush = BrushFromHex(agentColor);
var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
// 다이아몬드 심볼 아이콘 (회전 애니메이션)
var iconBlock = new TextBlock
{
Text = "◆",
FontSize = 13,
Foreground = agentBrush,
VerticalAlignment = VerticalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new RotateTransform(0),
};
if (animate)
{
var spin = new System.Windows.Media.Animation.DoubleAnimation
{
From = 0, To = 360,
Duration = TimeSpan.FromSeconds(1.2),
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
};
((RotateTransform)iconBlock.RenderTransform).BeginAnimation(RotateTransform.AngleProperty, spin);
}
headerSp.Children.Add(iconBlock);
headerSp.Children.Add(new TextBlock
{
Text = agentName,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = agentBrush,
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
container.Children.Add(headerSp);
// 마크다운 렌더링 (파일 경로 강조 설정 연동)
var app = System.Windows.Application.Current as App;
MarkdownRenderer.EnableFilePathHighlight =
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
var mdPanel = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
mdPanel.Margin = new Thickness(0, 0, 0, 4);
container.Children.Add(mdPanel);
// 액션 버튼 바 (복사 / 좋아요 / 싫어요)
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 6, 0, 0)
};
var btnColor = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
var btnHoverColor = new SolidColorBrush(Color.FromRgb(0x8B, 0x90, 0xB0));
var capturedContent = content;
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
{
try { Clipboard.SetText(capturedContent); } catch { }
}));
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
AddLinkedFeedbackButtons(actionBar, btnColor, message);
// 타임스탬프
var aiTimestamp = message?.Timestamp ?? DateTime.Now;
actionBar.Children.Add(new TextBlock
{
Text = aiTimestamp.ToString("HH:mm"),
FontSize = 10, Opacity = 0.5,
Foreground = btnColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
});
container.Children.Add(actionBar);
// 우클릭 → 메시지 컨텍스트 메뉴
var aiContent = content;
container.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowMessageContextMenu(aiContent, "assistant");
};
MessagePanel.Children.Add(container);
}
}
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
private string GetCheckStyle()
{
var theme = (_settings.Settings.Launcher.Theme ?? "system").ToLowerInvariant();
return theme switch
{
"dark" or "system" => "circle", // 원 + 체크마크, 바운스
"oled" => "glow", // 네온 글로우 원, 페이드인
"light" => "roundrect", // 둥근 사각형, 슬라이드인
"nord" => "diamond", // 다이아몬드(마름모), 스무스 스케일
"catppuccin" => "pill", // 필 모양, 스프링 바운스
"monokai" => "square", // 정사각형, 퀵 팝
"sepia" => "stamp", // 도장 스타일 원, 회전 등장
"alfred" => "minimal", // 미니멀 원, 우아한 페이드
"alfredlight" => "minimal", // 미니멀 원, 우아한 페이드
_ => "circle",
};
}
private FrameworkElement CreateCheckIcon(bool isChecked, Brush? accentBrush = null)
{
var accent = accentBrush ?? TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
// 심플 V 체크 — 선택 시 컬러 V, 미선택 시 빈 공간
if (isChecked)
{
return CreateSimpleCheck(accent, 14);
}
// 미선택: 동일 크기 빈 공간 (정렬 유지)
return new System.Windows.Shapes.Rectangle
{
Width = 14, Height = 14,
Fill = Brushes.Transparent,
Margin = new Thickness(0, 0, 10, 0),
};
}
/// <summary>ScaleTransform 바운스/스케일 애니메이션 헬퍼.</summary>
private static void AnimateScale(FrameworkElement el, double from, double to, int ms, IEasingFunction ease)
{
if (el.RenderTransform is TransformGroup tg)
{
var st = tg.Children.OfType<ScaleTransform>().FirstOrDefault();
if (st != null)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
st.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
st.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
return;
}
}
if (el.RenderTransform is ScaleTransform scale)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
}
/// <summary>마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 살짝 확대하는 호버 애니메이션.
/// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다.
/// 독립적 공간이 있는 버튼에만 적용하세요.
/// </summary>
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
{
// Loaded 이벤트에서 실행해야 XAML Style의 봉인된 Transform을 안전하게 교체 가능
void EnsureTransform()
{
element.RenderTransformOrigin = new Point(0.5, 0.5);
// 봉인(frozen)된 Transform이면 새로 생성하여 교체
if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new ScaleTransform(1, 1);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
};
}
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.
/// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다.
/// </summary>
private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5)
{
void EnsureTransform()
{
if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new TranslateTransform(0, 0);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 10 } });
};
}
/// <summary>심플한 V 체크 아이콘을 생성합니다 (디자인 통일용).</summary>
private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14)
{
return new System.Windows.Shapes.Path
{
Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"),
Stroke = color,
StrokeThickness = 2,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round,
StrokeLineJoin = PenLineJoin.Round,
Width = size,
Height = size,
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center,
};
}
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
private static void ApplyMenuItemHover(Border item)
{
var originalBg = item.Background?.Clone() ?? Brushes.Transparent;
if (originalBg.CanFreeze) originalBg.Freeze();
item.RenderTransformOrigin = new Point(0.5, 0.5);
item.RenderTransform = new ScaleTransform(1, 1);
item.MouseEnter += (s, _) =>
{
if (s is Border b)
{
// 원래 배경이 투명이면 반투명 흰색, 아니면 밝기 변경
if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20)
b.Opacity = 0.85;
else
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
var st = item.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)));
};
item.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Opacity = 1.0;
b.Background = originalBg;
}
var st = item.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)));
};
}
private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick)
{
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var icon = new TextBlock
{
Text = symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
btn.MouseEnter += (_, _) => icon.Foreground = hoverBrush;
btn.MouseLeave += (_, _) => icon.Foreground = foreground;
btn.Click += (_, _) => onClick();
ApplyHoverScaleAnimation(btn, 1.15);
return btn;
}
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
Action? resetSibling = null, Action<Action>? registerReset = null)
{
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var isActive = message?.Feedback == feedbackType;
var icon = new TextBlock
{
Text = isActive ? filled : outline,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = isActive ? activeColor : normalColor,
VerticalAlignment = VerticalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1)
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
// 상대 버튼이 리셋할 수 있도록 등록
registerReset?.Invoke(() =>
{
isActive = false;
icon.Text = outline;
icon.Foreground = normalColor;
});
btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; };
btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; };
btn.Click += (_, _) =>
{
isActive = !isActive;
icon.Text = isActive ? filled : outline;
icon.Foreground = isActive ? activeColor : normalColor;
// 상호 배타: 활성화 시 반대쪽 리셋
if (isActive) resetSibling?.Invoke();
// 피드백 상태 저장
if (message != null)
{
message.Feedback = isActive ? feedbackType : null;
try
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null) _storage.Save(conv);
}
catch { }
}
// 바운스 애니메이션
var scale = (ScaleTransform)icon.RenderTransform;
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
};
return btn;
}
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
{
// resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조
Action? resetLikeAction = null;
Action? resetDislikeAction = null;
var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like",
resetSibling: () => resetDislikeAction?.Invoke(),
registerReset: reset => resetLikeAction = reset);
var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike",
resetSibling: () => resetLikeAction?.Invoke(),
registerReset: reset => resetDislikeAction = reset);
actionBar.Children.Add(likeBtn);
actionBar.Children.Add(dislikeBtn);
}
// ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
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 = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray,
CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
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 = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
// 취소 버튼
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 메시지 찾기
// UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨
// 데이터 모델에서 해당 위치의 user 메시지 찾기
int userMsgIdx = -1;
int uiIdx = 0;
lock (_convLock)
{
for (int i = 0; i < conv.Messages.Count; i++)
{
if (conv.Messages[i].Role == "system") continue;
if (uiIdx == bubbleIndex)
{
userMsgIdx = i;
break;
}
uiIdx++;
}
}
if (userMsgIdx < 0) return;
// 데이터 모델에서 편집된 메시지 이후 모두 제거
lock (_convLock)
{
conv.Messages[userMsgIdx].Content = newText;
while (conv.Messages.Count > userMsgIdx + 1)
conv.Messages.RemoveAt(conv.Messages.Count - 1);
}
// UI에서 편집된 버블 이후 모두 제거
while (MessagePanel.Children.Count > bubbleIndex + 1)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 편집된 메시지를 새 버블로 교체
MessagePanel.Children.RemoveAt(bubbleIndex);
AddMessageBubble("user", newText, animate: false);
// 마지막 위치에 삽입되도록 조정 (AddMessageBubble은 끝에 추가됨)
// bubbleIndex가 끝이 아니면 이동 — 이 경우 이후가 다 제거되었으므로 끝에 추가됨
// AI 재응답
await SendRegenerateAsync(conv);
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
RefreshConversationList();
}
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
private void StopAiIconPulse()
{
if (_aiIconPulseStopped || _activeAiIcon == null) return;
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
_activeAiIcon.Opacity = 1.0;
_activeAiIcon = null;
_aiIconPulseStopped = true;
}
private void CursorTimer_Tick(object? sender, EventArgs e)
{
_cursorVisible = !_cursorVisible;
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
if (_activeStreamText != null && _displayedLength > 0)
{
var displayed = _cachedStreamContent.Length > 0
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
: "";
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
}
}
private void ElapsedTimer_Tick(object? sender, EventArgs e)
{
var elapsed = DateTime.UtcNow - _streamStartTime;
var sec = (int)elapsed.TotalSeconds;
if (_elapsedLabel != null)
_elapsedLabel.Text = $"{sec}s";
// 하단 상태바 시간 갱신
if (StatusElapsed != null)
StatusElapsed.Text = $"{sec}초";
}
private void TypingTimer_Tick(object? sender, EventArgs e)
{
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
var targetLen = _cachedStreamContent.Length;
if (_displayedLength >= targetLen) return;
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
var pending = targetLen - _displayedLength;
int step;
if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
_displayedLength += step;
var displayed = _cachedStreamContent[.._displayedLength];
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
// 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
if (!_userScrolled)
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
}
// ─── 전송 ──────────────────────────────────────────────────────────────
public void SendInitialMessage(string message)
{
StartNewConversation();
InputBox.Text = message;
_ = SendMessageAsync();
}
private void StartNewConversation()
{
// 현재 대화가 있으면 저장 후 새 대화 시작
lock (_convLock)
{
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
try { _storage.Save(_currentConversation); } catch { }
_currentConversation = new ChatConversation { Tab = _activeTab };
// 작업 폴더가 설정에 있으면 새 대화에 자동 연결 (Cowork/Code 탭)
var workFolder = _settings.Settings.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 = _settings.Settings.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;
}
_settings.Settings.Llm.LastConversationIds = dict;
try { _settings.Save(); } catch { }
}
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 (!_settings.Settings.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 = _settings.Settings.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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
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 = new FontFamily("Segoe MDL2 Assets"), 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 = new FontFamily("Segoe MDL2 Assets"), 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 int SlashPageSize => Math.Clamp(_settings.Settings.Llm.SlashPopupPageSize, 3, 20);
private List<(string Cmd, string Label, bool IsSkill)> _slashAllMatches = [];
private int _slashPageOffset = 0;
private int _slashSelectedIndex = -1; // 팝업 내 키보드 선택 인덱스 (페이지 내 상대)
// ── 슬래시 명령어 칩 ──
private string? _activeSlashCmd = null;
// ── 슬래시 명령어 (탭별 분류) ──
/// <summary>공통 슬래시 명령어 — 모든 탭에서 사용 가능.</summary>
private static readonly Dictionary<string, (string Label, string SystemPrompt, string Tab)> SlashCommands = new(StringComparer.OrdinalIgnoreCase)
{
// 공통
["/summary"] = ("Summary", "사용자가 제공한 내용을 핵심 포인트 위주로 간결하게 요약해 주세요. 불릿 포인트 형식을 사용하세요.", "all"),
["/translate"] = ("Translate", "사용자가 제공한 텍스트를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요.", "all"),
["/explain"] = ("Explain", "사용자가 제공한 내용을 쉽고 자세하게 설명해 주세요. 필요하면 예시를 포함하세요.", "all"),
["/fix"] = ("Fix", "사용자가 제공한 텍스트의 맞춤법, 문법, 자연스러운 표현을 교정해 주세요. 수정 사항을 명확히 표시하세요.", "all"),
// Cowork/Code 전용
["/review"] = ("Code Review", "작업 폴더의 git diff를 분석하여 코드 리뷰를 수행해 주세요. code_review 도구를 사용하세요.", "dev"),
["/pr"] = ("PR Summary", "작업 폴더의 변경사항을 PR 설명 형식으로 요약해 주세요. code_review(action: pr_summary) 도구를 사용하세요.", "dev"),
["/test"] = ("Test", "작업 폴더의 코드에 대한 단위 테스트를 생성해 주세요. test_loop 도구를 사용하세요.", "dev"),
["/structure"] = ("Structure", "작업 폴더의 프로젝트 구조를 분석하고 설명해 주세요. folder_map 도구를 사용하세요.", "dev"),
["/build"] = ("Build", "작업 폴더의 프로젝트를 빌드해 주세요. build_run 도구를 사용하세요.", "dev"),
["/search"] = ("Search", "작업 폴더에서 관련 코드를 검색해 주세요. search_codebase 도구를 사용하세요.", "dev"),
// 특수
["/help"] = ("Help", "__HELP__", "all"),
};
private void InputBox_TextChanged(object sender, TextChangedEventArgs e)
{
UpdateWatermarkVisibility();
var text = InputBox.Text;
// 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제
if (_activeSlashCmd != null && text.StartsWith("/"))
HideSlashChip(restoreText: false);
if (text.StartsWith("/") && !text.Contains(' '))
{
// 탭별 필터링: Chat → "all"만, Cowork/Code → "all" + "dev"
bool isDev = _activeTab is "Cowork" or "Code";
// 내장 슬래시 명령어 매칭 (탭 필터)
var matches = SlashCommands
.Where(kv => kv.Key.StartsWith(text, StringComparison.OrdinalIgnoreCase))
.Where(kv => kv.Value.Tab == "all" || (isDev && kv.Value.Tab == "dev"))
.Select(kv => (Cmd: kv.Key, Label: kv.Value.Label, IsSkill: false))
.ToList();
// 스킬 슬래시 명령어 매칭 (탭별 필터)
if (_settings.Settings.Llm.EnableSkillSystem)
{
var skillMatches = SkillService.MatchSlashCommand(text)
.Where(s => s.IsVisibleInTab(_activeTab))
.Select(s => (Cmd: "/" + s.Name,
Label: s.IsAvailable ? s.Label : $"{s.Label} {s.UnavailableHint}",
IsSkill: true, Available: s.IsAvailable));
foreach (var sm in skillMatches)
matches.Add((sm.Cmd, sm.Label, sm.IsSkill));
}
if (matches.Count > 0)
{
// 즐겨찾기를 상단에 고정 정렬
var favorites = _settings.Settings.Llm.FavoriteSlashCommands;
if (favorites.Count > 0)
{
matches = matches
.OrderByDescending(m => favorites.Contains(m.Cmd, StringComparer.OrdinalIgnoreCase))
.ToList();
}
_slashAllMatches = matches;
_slashPageOffset = 0;
_slashSelectedIndex = -1;
RenderSlashPage();
SlashPopup.IsOpen = true;
return;
}
}
SlashPopup.IsOpen = false;
}
/// <summary>현재 페이지의 슬래시 명령어 항목을 렌더링합니다.</summary>
private void RenderSlashPage()
{
SlashItems.Items.Clear();
var total = _slashAllMatches.Count;
var start = _slashPageOffset;
var end = Math.Min(start + SlashPageSize, total);
// 위 화살표
if (start > 0)
{
SlashNavUp.Visibility = Visibility.Visible;
SlashNavUpText.Text = $"▲ 위로 {start}개";
}
else
SlashNavUp.Visibility = Visibility.Collapsed;
// 아이템 렌더링
for (int i = start; i < end; i++)
{
var (cmd, label, isSkill) = _slashAllMatches[i];
var capturedCmd = cmd;
var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null;
var skillAvailable = skillDef?.IsAvailable ?? true;
var isFav = _settings.Settings.Llm.FavoriteSlashCommands
.Contains(cmd, StringComparer.OrdinalIgnoreCase);
var pageLocalIndex = i - start;
var isSelected = pageLocalIndex == _slashSelectedIndex;
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
var item = new Border
{
Background = isSelected ? hoverBrushItem : Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 1, 0, 1),
Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow,
Opacity = skillAvailable ? 1.0 : 0.5,
};
var itemGrid = new Grid();
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var stack = new StackPanel { Orientation = Orientation.Horizontal };
if (isSkill)
{
stack.Children.Add(new TextBlock
{
Text = "\uE768 ",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = skillAvailable
? TryFindResource("AccentColor") as Brush ?? Brushes.Blue
: TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
}
if (isFav)
{
stack.Children.Add(new TextBlock
{
Text = "\uE735 ",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 2, 0),
});
}
stack.Children.Add(new TextBlock
{
Text = cmd,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = skillAvailable
? TryFindResource("AccentColor") as Brush ?? Brushes.Blue
: TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(new TextBlock
{
Text = $" — {label}",
FontSize = 12,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
Grid.SetColumn(stack, 0);
itemGrid.Children.Add(stack);
// 즐겨찾기 토글 별 아이콘
var favCapturedCmd = cmd;
var favBtn = new Border
{
Width = 24, Height = 24,
CornerRadius = new CornerRadius(4),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = isFav ? "\uE735" : "\uE734",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = isFav
? new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B))
: TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Opacity = isFav ? 1.0 : 0.4,
},
};
favBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
favBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
favBtn.MouseLeftButtonDown += (_, me) =>
{
me.Handled = true; // 아이템 클릭 이벤트 방지
ToggleSlashFavorite(favCapturedCmd);
};
Grid.SetColumn(favBtn, 1);
itemGrid.Children.Add(favBtn);
item.Child = itemGrid;
if (skillAvailable)
{
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
item.MouseEnter += (_, _) => item.Background = hoverBrush;
item.MouseLeave += (_, _) => item.Background = Brushes.Transparent;
item.MouseLeftButtonDown += (_, _) =>
{
SlashPopup.IsOpen = false;
if (capturedCmd.Equals("/help", StringComparison.OrdinalIgnoreCase))
{
InputBox.Text = "";
ShowSlashHelpWindow();
return;
}
// 칩 표시: 명령어를 칩으로, InputBox는 빈 텍스트로
ShowSlashChip(capturedCmd);
InputBox.Focus();
};
}
SlashItems.Items.Add(item);
}
// 아래 화살표
if (end < total)
{
SlashNavDown.Visibility = Visibility.Visible;
SlashNavDownText.Text = $"▼ 아래로 {total - end}개";
}
else
SlashNavDown.Visibility = Visibility.Collapsed;
}
/// <summary>슬래시 팝업 마우스 휠 스크롤 처리.</summary>
private void SlashPopup_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
e.Handled = true;
SlashPopup_ScrollByDelta(e.Delta);
}
/// <summary>슬래시 팝업을 Delta 방향으로 스크롤합니다.</summary>
private void SlashPopup_ScrollByDelta(int delta)
{
if (_slashAllMatches.Count == 0) return;
var pageItemCount = Math.Min(SlashPageSize, _slashAllMatches.Count - _slashPageOffset);
if (delta > 0) // 위로 스크롤 (Up 키)
{
if (_slashSelectedIndex > 0)
_slashSelectedIndex--;
else if (_slashSelectedIndex == 0 && _slashPageOffset > 0)
{
_slashPageOffset = Math.Max(0, _slashPageOffset - 1);
_slashSelectedIndex = 0;
}
}
else // 아래로 스크롤 (Down 키)
{
if (_slashSelectedIndex < 0)
{
// 초기 상태: 첫 번째 항목 선택
_slashSelectedIndex = 0;
}
else if (_slashSelectedIndex < pageItemCount - 1)
_slashSelectedIndex++;
else if (_slashPageOffset + SlashPageSize < _slashAllMatches.Count)
{
_slashPageOffset++;
_slashSelectedIndex = Math.Min(SlashPageSize - 1,
_slashAllMatches.Count - _slashPageOffset - 1);
}
}
RenderSlashPage();
}
/// <summary>키보드로 선택된 슬래시 아이템을 실행합니다.</summary>
private void ExecuteSlashSelectedItem()
{
var absoluteIdx = _slashPageOffset + _slashSelectedIndex;
if (absoluteIdx < 0 || absoluteIdx >= _slashAllMatches.Count) return;
var (cmd, _, isSkill) = _slashAllMatches[absoluteIdx];
var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null;
var skillAvailable = skillDef?.IsAvailable ?? true;
if (!skillAvailable) return;
SlashPopup.IsOpen = false;
_slashSelectedIndex = -1;
if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase))
{
InputBox.Text = "";
ShowSlashHelpWindow();
return;
}
ShowSlashChip(cmd);
InputBox.Focus();
}
/// <summary>슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다.</summary>
private void ToggleSlashFavorite(string cmd)
{
var favs = _settings.Settings.Llm.FavoriteSlashCommands;
var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase));
if (existing != null)
favs.Remove(existing);
else
favs.Add(cmd);
_settings.Save();
// 팝업 새로고침: TextChanged 이벤트를 트리거하여 팝업 재렌더링
var currentText = InputBox.Text;
InputBox.TextChanged -= InputBox_TextChanged;
InputBox.Text = "";
InputBox.TextChanged += InputBox_TextChanged;
InputBox.Text = currentText;
}
/// <summary>슬래시 명령어 칩을 표시하고 InputBox를 비웁니다.</summary>
private void ShowSlashChip(string cmd)
{
_activeSlashCmd = cmd;
SlashChipText.Text = cmd;
SlashCommandChip.Visibility = Visibility.Visible;
// 칩 너비 측정 후 InputBox 왼쪽 여백 조정
SlashCommandChip.UpdateLayout();
var chipRight = SlashCommandChip.Margin.Left + SlashCommandChip.ActualWidth + 6;
InputBox.Padding = new Thickness(chipRight, 10, 14, 10);
InputBox.Text = "";
}
/// <summary>슬래시 명령어 칩을 숨깁니다.</summary>
/// <param name="restoreText">true이면 InputBox에 명령어 텍스트를 복원합니다.</param>
private void HideSlashChip(bool restoreText = false)
{
if (_activeSlashCmd == null) return;
var prev = _activeSlashCmd;
_activeSlashCmd = null;
SlashCommandChip.Visibility = Visibility.Collapsed;
InputBox.Padding = new Thickness(14, 10, 14, 10);
if (restoreText)
{
InputBox.Text = prev + " ";
InputBox.CaretIndex = InputBox.Text.Length;
}
}
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
private static (string? slashSystem, string userText) ParseSlashCommand(string input)
{
// 내장 명령어 우선
foreach (var (cmd, (_, prompt, _)) in SlashCommands)
{
if (input.StartsWith(cmd, StringComparison.OrdinalIgnoreCase))
{
// __HELP__는 특수 처리 (ParseSlashCommand에서는 무시)
if (prompt == "__HELP__") return (null, input);
var rest = input[cmd.Length..].Trim();
return (prompt, string.IsNullOrEmpty(rest) ? cmd : rest);
}
}
// 스킬 명령어 매칭
foreach (var skill in SkillService.Skills)
{
var slashCmd = "/" + skill.Name;
if (input.StartsWith(slashCmd, StringComparison.OrdinalIgnoreCase))
{
var rest = input[slashCmd.Length..].Trim();
return (skill.SystemPrompt, string.IsNullOrEmpty(rest) ? skill.Label : rest);
}
}
return (null, input);
}
// ─── 드래그 앤 드롭 AI 액션 팝업 ─────────────────────────────────────
private static readonly Dictionary<string, List<(string Label, string Icon, string Prompt)>> DropActions = new(StringComparer.OrdinalIgnoreCase)
{
["code"] =
[
("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."),
("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."),
("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."),
("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."),
],
["document"] =
[
("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."),
("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."),
("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."),
],
["data"] =
[
("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."),
("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."),
("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"),
],
["image"] =
[
("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."),
("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."),
],
};
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" };
private static readonly HashSet<string> DataExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" };
// ImageExtensions는 이미지 첨부 영역(line ~1323)에서 정의됨 — 재사용
private Popup? _dropActionPopup;
private void ShowDropActionMenu(string[] files)
{
// 파일 유형 판별
var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant();
string category;
if (CodeExtensions.Contains(ext)) category = "code";
else if (DataExtensions.Contains(ext)) category = "data";
else if (ImageExtensions.Contains(ext)) category = "image";
else category = "document";
var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"];
// 팝업 생성
_dropActionPopup?.SetValue(Popup.IsOpenProperty, false);
var panel = new StackPanel();
// 헤더
var header = new TextBlock
{
Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" {files.Length - 1}" : "")}",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(12, 8, 12, 6),
};
panel.Children.Add(header);
// 액션 항목
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(24, 255, 255, 255));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var textBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
foreach (var (label, icon, prompt) in actions)
{
var capturedPrompt = prompt;
var row = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(4, 1, 4, 1),
Cursor = Cursors.Hand,
};
var stack = new StackPanel { Orientation = Orientation.Horizontal };
stack.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
stack.Children.Add(new TextBlock
{
Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center,
});
row.Child = stack;
row.MouseEnter += (_, _) => row.Background = hoverBrush;
row.MouseLeave += (_, _) => row.Background = Brushes.Transparent;
row.MouseLeftButtonUp += (_, _) =>
{
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
foreach (var f in files) AddAttachedFile(f);
InputBox.Text = capturedPrompt;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
if (_settings.Settings.Llm.DragDropAutoSend)
_ = SendMessageAsync();
};
panel.Children.Add(row);
}
// "첨부만" 항목
var attachOnly = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(4, 1, 4, 1),
Cursor = Cursors.Hand,
};
var attachStack = new StackPanel { Orientation = Orientation.Horizontal };
attachStack.Children.Add(new TextBlock
{
Text = "\uE723",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
attachStack.Children.Add(new TextBlock
{
Text = "첨부만", FontSize = 13,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
attachOnly.Child = attachStack;
attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush;
attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent;
attachOnly.MouseLeftButtonUp += (_, _) =>
{
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
foreach (var f in files) AddAttachedFile(f);
InputBox.Focus();
};
panel.Children.Add(attachOnly);
var container = new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46)),
CornerRadius = new CornerRadius(12),
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
Padding = new Thickness(4, 4, 4, 6),
Child = panel,
MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
},
};
_dropActionPopup = new Popup
{
PlacementTarget = InputBorder,
Placement = PlacementMode.Top,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Child = container,
};
_dropActionPopup.IsOpen = true;
}
// ─── /help 도움말 창 ─────────────────────────────────────────────────
private void ShowSlashHelpWindow()
{
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46));
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var fg2 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(40, 255, 255, 255));
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 = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
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 = new FontFamily("Segoe MDL2 Assets"), 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 = new FontFamily("Segoe MDL2 Assets"), 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 { } };
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 = new FontFamily("Consolas") };
Grid.SetColumn(cmdText, 0);
grid.Children.Add(cmdText);
var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap };
Grid.SetColumn(descText, 1);
grid.Children.Add(descText);
row.Child = grid;
row.MouseEnter += (_, _) => row.Background = hoverBg;
row.MouseLeave += (_, _) => row.Background = itemBg;
parent.Children.Add(row);
}
}
private async Task SendMessageAsync()
{
var rawText = InputBox.Text.Trim();
// 슬래시 칩이 활성화된 경우 명령어 앞에 붙임
var text = _activeSlashCmd != null
? (_activeSlashCmd + " " + rawText).Trim()
: rawText;
HideSlashChip(restoreText: false);
if (string.IsNullOrEmpty(text) || _isStreaming) return;
// placeholder 정리
ClearPromptCardPlaceholder();
// 슬래시 명령어 처리
var (slashSystem, displayText) = ParseSlashCommand(text);
// 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처
var originTab = _activeTab;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null) _currentConversation = new ChatConversation { Tab = _activeTab };
conv = _currentConversation;
}
var userMsg = new ChatMessage { Role = "user", Content = text };
lock (_convLock) conv.Messages.Add(userMsg);
if (conv.Messages.Count(m => m.Role == "user") == 1)
conv.Title = text.Length > 30 ? text[..30] + "…" : text;
UpdateChatTitle();
AddMessageBubble("user", text);
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
// 대화 통계 기록
Services.UsageStatisticsService.RecordChat(_activeTab);
ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동
PlayRainbowGlow(); // 무지개 글로우 애니메이션
_isStreaming = true;
BtnSend.IsEnabled = false;
BtnSend.Visibility = Visibility.Collapsed;
BtnStop.Visibility = Visibility.Visible;
if (_activeTab == "Cowork" || _activeTab == "Code")
BtnPause.Visibility = Visibility.Visible;
_streamCts = new CancellationTokenSource();
var assistantMsg = new ChatMessage { Role = "assistant", Content = "" };
lock (_convLock) conv.Messages.Add(assistantMsg);
// 어시스턴트 스트리밍 컨테이너
var streamContainer = CreateStreamingContainer(out var streamText);
MessagePanel.Children.Add(streamContainer);
ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동
var sb = new System.Text.StringBuilder();
_activeStreamText = streamText;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_cursorTimer.Start();
_typingTimer.Start();
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus("응답 생성 중...", spinning: true);
// ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ──
ModelRouteResult? routeResult = null;
try
{
List<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 (_settings.Settings.Llm.EnableAutoRouter)
{
routeResult = _router.Route(text);
if (routeResult != null)
{
_llm.PushRouteOverride(routeResult.Service, routeResult.Model);
SetStatus($"라우팅: {routeResult.DetectedIntent} → {routeResult.DisplayName}", spinning: true);
}
}
if (_activeTab == "Cowork")
{
// 워크플로우 분석기 자동 열기
OpenWorkflowAnalyzerIfEnabled();
// 누적 토큰 초기화
_agentCumulativeInputTokens = 0;
_agentCumulativeOutputTokens = 0;
// 코워크 탭: 에이전트 루프 사용
_agentLoop.EventOccurred += OnAgentEvent;
// 사용자 의사결정 콜백 — PlanViewerWindow로 계획 표시
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
try
{
// 코워크 시스템 프롬프트 삽입
var coworkSystem = BuildCoworkSystemPrompt();
if (!string.IsNullOrEmpty(coworkSystem))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = coworkSystem });
_agentLoop.ActiveTab = _activeTab;
var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token);
sb.Append(response);
assistantMsg.Content = response;
StopAiIconPulse();
_cachedStreamContent = response;
// 완료 알림
if (_settings.Settings.Llm.NotifyOnComplete)
Services.NotificationService.Notify("AX Cowork Agent", "코워크 작업이 완료되었습니다.");
}
finally
{
_agentLoop.EventOccurred -= OnAgentEvent;
_agentLoop.UserDecisionCallback = null;
}
}
else if (_activeTab == "Code")
{
// 워크플로우 분석기 자동 열기
OpenWorkflowAnalyzerIfEnabled();
// 누적 토큰 초기화
_agentCumulativeInputTokens = 0;
_agentCumulativeOutputTokens = 0;
// Code 탭: 에이전트 루프 사용 (Cowork과 동일 패턴)
_agentLoop.EventOccurred += OnAgentEvent;
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
try
{
var codeSystem = BuildCodeSystemPrompt();
if (!string.IsNullOrEmpty(codeSystem))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = codeSystem });
_agentLoop.ActiveTab = "Code";
var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token);
sb.Append(response);
assistantMsg.Content = response;
StopAiIconPulse();
_cachedStreamContent = response;
// 완료 알림
if (_settings.Settings.Llm.NotifyOnComplete)
Services.NotificationService.Notify("AX Code Agent", "코드 작업이 완료되었습니다.");
}
finally
{
_agentLoop.EventOccurred -= OnAgentEvent;
_agentLoop.UserDecisionCallback = null;
}
}
else if (_settings.Settings.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();
}
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
private string BuildCoworkSystemPrompt()
{
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
sb.AppendLine("Always explain your plan step by step BEFORE executing tools. After creating files, summarize what was created.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates.");
sb.AppendLine("IMPORTANT: When asked to create a document with multiple sections (reports, proposals, analyses, etc.), you MUST:");
sb.AppendLine(" 1. First, plan the document: decide the exact sections (headings), their order, and key points for each section based on the topic.");
sb.AppendLine(" 2. Call document_plan with sections_hint = your planned section titles (comma-separated). Example: sections_hint=\"회사 개요, 사업 현황, 재무 분석, SWOT, 전략 제언, 결론\"");
sb.AppendLine(" This ensures the document structure matches YOUR plan, not a generic template.");
sb.AppendLine(" 3. Then immediately call html_create (or docx_create/file_write) using the scaffold from document_plan.");
sb.AppendLine(" 4. Write actual detailed content for EVERY section — no skipping, no placeholders, no minimal content.");
sb.AppendLine(" 5. Do NOT call html_create directly without document_plan for multi-section documents.");
// 문서 품질 검증 루프
sb.AppendLine("\n## Document Quality Review");
sb.AppendLine("After creating any document (html_create, docx_create, excel_create, etc.), you MUST perform a self-review:");
sb.AppendLine("1. Use file_read to read the generated file and verify the content is complete");
sb.AppendLine("2. Check for logical errors: incorrect dates, inconsistent data, missing sections, broken formatting");
sb.AppendLine("3. Verify all requested topics/sections from the user's original request are covered");
sb.AppendLine("4. If issues found, fix them using file_write or file_edit, then re-verify");
sb.AppendLine("5. Report the review result to the user: what was checked and whether corrections were made");
// 문서 포맷 변환 지원
sb.AppendLine("\n## Format Conversion");
sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):");
sb.AppendLine("1. Use file_read or document_read to read the source file content");
sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)");
sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible");
// 사용자 지정 출력 포맷
var fmt = llm.DefaultOutputFormat;
if (!string.IsNullOrEmpty(fmt) && fmt != "auto")
{
var fmtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["xlsx"] = "Excel (.xlsx) using excel_create",
["docx"] = "Word (.docx) using docx_create",
["html"] = "HTML (.html) using html_create",
["md"] = "Markdown (.md) using markdown_create",
["csv"] = "CSV (.csv) using csv_create",
};
if (fmtMap.TryGetValue(fmt, out var fmtDesc))
sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise.");
}
// 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내
if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern")
sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template.");
else
sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.");
if (!string.IsNullOrEmpty(workFolder))
sb.AppendLine($"Current work folder: {workFolder}");
sb.AppendLine($"File permission mode: {llm.FilePermission}");
// 폴더 데이터 활용 지침
switch (_folderDataUsage)
{
case "active":
sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available.");
sb.AppendLine("Before creating reports, use folder_map to scan the work folder structure. " +
"Then EVALUATE whether each document is RELEVANT to the user's current request topic. " +
"Only use document_read on files that are clearly related to the conversation subject. " +
"Do NOT read or reference files that are unrelated to the user's request, even if they exist in the folder. " +
"In your planning step, list which files you plan to read and explain WHY they are relevant.");
break;
case "passive":
sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " +
"Only read folder documents when the user explicitly asks you to reference or use them.");
break;
default: // "none"
sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path.");
break;
}
// 프리셋 시스템 프롬프트가 있으면 추가
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand))
sb.AppendLine("\n" + _currentConversation.SystemCommand);
}
// 프로젝트 문맥 파일 (AX.md) 주입
sb.Append(LoadProjectContext(workFolder));
// 프로젝트 규칙 (.ax/rules/) 자동 주입
sb.Append(BuildProjectRulesSection(workFolder));
// 에이전트 메모리 주입
sb.Append(BuildMemorySection(workFolder));
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
}
private string BuildCodeSystemPrompt()
{
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var code = llm.Code;
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + file_read to deeply understand the code you'll modify.");
sb.AppendLine(" - Always check callers/references: grep for function/class names to find all usage points.");
sb.AppendLine(" - Read test files related to the code you're changing to understand expected behavior.");
sb.AppendLine("4. PLAN: Present your analysis + impact assessment. List ALL files that will be modified.");
sb.AppendLine(" - Explain WHY each change is needed and what could break.");
sb.AppendLine(" - Wait for user approval before proceeding.");
sb.AppendLine("5. IMPLEMENT: Apply changes using file_edit (preferred — shows diff). Use file_write only for new files.");
sb.AppendLine(" - Make the MINIMUM changes needed. Don't refactor unrelated code.");
sb.AppendLine(" - Prefer file_edit with replace_all=false for precision edits.");
sb.AppendLine("6. VERIFY: Run build_run action='build' then action='test'. Compare results with baseline.");
sb.AppendLine(" - If tests fail that passed before, fix immediately.");
sb.AppendLine(" - If build fails, analyze error output and correct.");
sb.AppendLine("7. GIT: Use git_tool to check status, create diff, and optionally commit.");
sb.AppendLine("8. REPORT: Summarize changes, test results, and any remaining concerns.");
sb.AppendLine("\n## Development Environment");
sb.AppendLine("Use dev_env_detect to check installed IDEs, runtimes, and build tools before running commands.");
sb.AppendLine("IMPORTANT: Do NOT attempt to install compilers, IDEs, or build tools. Only use what is already installed.");
// 패키지 저장소 정보
sb.AppendLine("\n## Package Repositories");
if (!string.IsNullOrEmpty(code.NexusBaseUrl))
sb.AppendLine($"Enterprise Nexus: {code.NexusBaseUrl}");
sb.AppendLine($"NuGet (.NET): {code.NugetSource}");
sb.AppendLine($"PyPI/Conda (Python): {code.PypiSource}");
sb.AppendLine($"Maven (Java): {code.MavenSource}");
sb.AppendLine($"npm (JavaScript): {code.NpmSource}");
sb.AppendLine("When adding dependencies, use these repository URLs.");
// IDE 정보
if (!string.IsNullOrEmpty(code.PreferredIdePath))
sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}");
// 사용자 선택 개발 언어
if (_selectedLanguage != "auto")
{
var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage };
sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation.");
}
// 언어별 가이드라인
sb.AppendLine("\n## Language Guidelines");
sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions.");
sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred.");
sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide.");
sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines.");
sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API.");
// 코드 품질 + 안전 수칙
sb.AppendLine("\n## Code Quality & Safety");
sb.AppendLine("- NEVER delete or overwrite files without user confirmation.");
sb.AppendLine("- ALWAYS read a file before editing it. Don't guess contents.");
sb.AppendLine("- Prefer file_edit over file_write for existing files (shows diff).");
sb.AppendLine("- Use grep to find ALL references before renaming/removing anything.");
sb.AppendLine("- If unsure about a change's impact, ask the user first.");
sb.AppendLine("- For large refactors, do them incrementally with build verification between steps.");
sb.AppendLine("- Use git_tool action='diff' to review your changes before committing.");
sb.AppendLine("\n## Lint & Format");
sb.AppendLine("After code changes, check for available linters:");
sb.AppendLine("- Python: ruff, black, flake8, pylint");
sb.AppendLine("- JavaScript: eslint, prettier");
sb.AppendLine("- C#: dotnet format");
sb.AppendLine("- C++: clang-format");
sb.AppendLine("Run the appropriate linter via process tool if detected by dev_env_detect.");
if (!string.IsNullOrEmpty(workFolder))
sb.AppendLine($"\nCurrent work folder: {workFolder}");
sb.AppendLine($"File permission mode: {llm.FilePermission}");
// 폴더 데이터 활용
sb.AppendLine("\nFolder Data Usage = ACTIVE. Use folder_map and file_read to understand the codebase.");
sb.AppendLine("Analyze project structure before making changes. Read relevant files to understand context.");
// 프리셋 시스템 프롬프트
lock (_convLock)
{
if (_currentConversation?.SystemCommand is { Length: > 0 } sysCmd)
sb.AppendLine("\n" + sysCmd);
}
// 프로젝트 문맥 파일 (AX.md) 주입
sb.Append(LoadProjectContext(workFolder));
// 프로젝트 규칙 (.ax/rules/) 자동 주입
sb.Append(BuildProjectRulesSection(workFolder));
// 에이전트 메모리 주입
sb.Append(BuildMemorySection(workFolder));
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
}
/// <summary>프로젝트 규칙 (.ax/rules/)을 시스템 프롬프트 섹션으로 포맷합니다.</summary>
private string BuildProjectRulesSection(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return "";
if (!_settings.Settings.Llm.EnableProjectRules) return "";
try
{
var rules = Services.Agent.ProjectRulesService.LoadRules(workFolder);
if (rules.Count == 0) return "";
// 컨텍스트별 필터링: Cowork=document, Code=always (기본)
var when = _activeTab == "Code" ? "always" : "always";
var filtered = Services.Agent.ProjectRulesService.FilterRules(rules, when);
return Services.Agent.ProjectRulesService.FormatForSystemPrompt(filtered);
}
catch
{
return "";
}
}
/// <summary>에이전트 메모리를 시스템 프롬프트 섹션으로 포맷합니다.</summary>
private string BuildMemorySection(string? workFolder)
{
if (!_settings.Settings.Llm.EnableAgentMemory) return "";
var app = System.Windows.Application.Current as App;
var memService = app?.MemoryService;
if (memService == null || memService.Count == 0) return "";
// 메모리를 로드 (작업 폴더 변경 시 재로드)
memService.Load(workFolder ?? "");
var all = memService.All;
if (all.Count == 0) return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n## 프로젝트 메모리 (이전 대화에서 학습한 내용)");
sb.AppendLine("아래는 이전 대화에서 학습한 규칙과 선호도입니다. 작업 시 참고하세요.");
sb.AppendLine("새로운 규칙이나 선호도를 발견하면 memory 도구의 save 액션으로 저장하세요.");
sb.AppendLine("사용자가 이전 학습 내용과 다른 지시를 하면 memory 도구의 delete 후 새로 save 하세요.\n");
foreach (var group in all.GroupBy(e => e.Type))
{
var label = group.Key switch
{
"rule" => "프로젝트 규칙",
"preference" => "사용자 선호",
"fact" => "프로젝트 사실",
"correction" => "이전 교정",
_ => group.Key,
};
sb.AppendLine($"[{label}]");
foreach (var e in group.OrderByDescending(e => e.UseCount).Take(15))
sb.AppendLine($"- {e.Content}");
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다.</summary>
private void OpenWorkflowAnalyzerIfEnabled()
{
var llm = _settings.Settings.Llm;
if (!llm.DevMode || !llm.WorkflowVisualizer) return;
if (_analyzerWindow == null)
{
// 새로 생성
_analyzerWindow = new WorkflowAnalyzerWindow();
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
// 테마 리소스 전달
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
_analyzerWindow.Show();
}
else if (!_analyzerWindow.IsVisible)
{
// Hide()로 숨겨진 창 → 기존 내용 유지한 채 다시 표시
_analyzerWindow.Show();
_analyzerWindow.Activate();
}
else
{
// 이미 보이는 상태 → 새 에이전트 실행을 위해 초기화 후 활성화
_analyzerWindow.Reset();
_analyzerWindow.Activate();
}
// 타임라인 탭으로 전환 (새 실행 시작)
_analyzerWindow.SwitchToTimelineTab();
// 이벤트 구독 (중복 방지)
_agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent;
_agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent;
}
/// <summary>워크플로우 분석기 버튼의 표시 상태를 갱신합니다.</summary>
private void UpdateAnalyzerButtonVisibility()
{
var llm = _settings.Settings.Llm;
BtnShowAnalyzer.Visibility = (llm.DevMode && llm.WorkflowVisualizer)
? Visibility.Visible : Visibility.Collapsed;
}
/// <summary>워크플로우 분석기 창을 수동으로 열거나 포커스합니다 (하단 바 버튼).</summary>
private void BtnShowAnalyzer_Click(object sender, MouseButtonEventArgs e)
{
if (_analyzerWindow == null)
{
_analyzerWindow = new WorkflowAnalyzerWindow();
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
// 에이전트 이벤트 구독
_agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent;
_agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent;
_analyzerWindow.Show();
}
else if (!_analyzerWindow.IsVisible)
{
_analyzerWindow.Show();
_analyzerWindow.Activate();
}
else
{
_analyzerWindow.Activate();
}
}
/// <summary>에이전트 루프 동안 누적 토큰 (하단 바 표시용)</summary>
private int _agentCumulativeInputTokens;
private int _agentCumulativeOutputTokens;
private static readonly HashSet<string> WriteToolNames = new(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "html_create", "xlsx_create",
"docx_create", "csv_create", "md_create", "script_create",
"diff_preview", "open_external",
};
private void OnAgentEvent(AgentEvent evt)
{
// 에이전트 이벤트를 채팅 UI에 표시 (도구 호출/결과 배너)
AddAgentEventBanner(evt);
AutoScrollIfNeeded();
// 하단 상태바 업데이트
UpdateStatusBar(evt);
// 하단 바 토큰 누적 업데이트 (에이전트 루프 전체 합계)
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
{
_agentCumulativeInputTokens += evt.InputTokens;
_agentCumulativeOutputTokens += evt.OutputTokens;
UpdateStatusTokens(_agentCumulativeInputTokens, _agentCumulativeOutputTokens);
}
// 스티키 진행률 바 업데이트
UpdateAgentProgressBar(evt);
// 계획 뷰어 단계 갱신
if (evt.StepCurrent > 0 && evt.StepTotal > 0)
UpdatePlanViewerStep(evt);
if (evt.Type == AgentEventType.Complete)
CompletePlanViewer();
// 파일 탐색기 자동 새로고침
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath))
RefreshFileTreeIfVisible();
// suggest_actions 도구 결과 → 후속 작업 칩 표시
if (evt.Type == AgentEventType.ToolResult && evt.ToolName == "suggest_actions" && evt.Success)
RenderSuggestActionChips(evt.Summary);
// 파일 생성/수정 결과가 있으면 미리보기 자동 표시 또는 갱신
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath) &&
(evt.Type == AgentEventType.ToolResult || evt.Type == AgentEventType.Complete) &&
WriteToolNames.Contains(evt.ToolName))
{
var autoPreview = _settings.Settings.Llm.AutoPreview;
if (autoPreview == "auto")
{
// 별도 창 미리보기: 이미 열린 파일이면 새로고침, 아니면 새 탭 추가
if (PreviewWindow.IsOpen)
PreviewWindow.RefreshIfOpen(evt.FilePath);
else
TryShowPreview(evt.FilePath);
// 새 파일이면 항상 표시
if (!PreviewWindow.IsOpen)
TryShowPreview(evt.FilePath);
}
}
}
// ─── Task Decomposition UI ────────────────────────────────────────────
private Border? _planningCard;
private StackPanel? _planStepsPanel;
private ProgressBar? _planProgressBar;
private TextBlock? _planProgressText;
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
private void AddPlanningCard(AgentEvent evt)
{
var steps = evt.Steps!;
var card = new Border
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F0F4FF")),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(40, 4, 80, 4),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = 560,
};
var sp = new StackPanel();
// 헤더
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
header.Children.Add(new TextBlock
{
Text = "\uE9D5", // plan icon
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
header.Children.Add(new TextBlock
{
Text = $"작업 계획 — {steps.Count}단계",
FontSize = 12.5, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3730A3")),
VerticalAlignment = VerticalAlignment.Center,
});
sp.Children.Add(header);
// 진행률 바
var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 8) };
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
_planProgressBar = new ProgressBar
{
Minimum = 0,
Maximum = steps.Count,
Value = 0,
Height = 4,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC")),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#D0D5FF")),
VerticalAlignment = VerticalAlignment.Center,
};
// Remove the default border on ProgressBar
_planProgressBar.BorderThickness = new Thickness(0);
Grid.SetColumn(_planProgressBar, 0);
progressGrid.Children.Add(_planProgressBar);
_planProgressText = new TextBlock
{
Text = "0%",
FontSize = 10.5, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC")),
Margin = new Thickness(8, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(_planProgressText, 1);
progressGrid.Children.Add(_planProgressText);
sp.Children.Add(progressGrid);
// 단계 목록
_planStepsPanel = new StackPanel();
for (int i = 0; i < steps.Count; i++)
{
var stepRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 1, 0, 1),
Tag = i, // 인덱스 저장
};
stepRow.Children.Add(new TextBlock
{
Text = "○", // 빈 원 (미완료)
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
Tag = "status",
});
stepRow.Children.Add(new TextBlock
{
Text = $"{i + 1}. {steps[i]}",
FontSize = 11.5,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
TextWrapping = TextWrapping.Wrap,
MaxWidth = 480,
VerticalAlignment = VerticalAlignment.Center,
});
_planStepsPanel.Children.Add(stepRow);
}
sp.Children.Add(_planStepsPanel);
card.Child = sp;
_planningCard = card;
// 페이드인
card.Opacity = 0;
card.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
MessagePanel.Children.Add(card);
}
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> options)
{
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var accentColor = ((SolidColorBrush)accentBrush).Color;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var container = new Border
{
Margin = new Thickness(40, 2, 80, 6),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = 560,
};
var outerStack = new StackPanel();
// 버튼 행
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) };
// 승인 버튼 (강조)
var approveBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(16, 7, 16, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
};
var approveSp = new StackPanel { Orientation = Orientation.Horizontal };
approveSp.Children.Add(new TextBlock
{
Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
approveSp.Children.Add(new TextBlock { Text = "승인", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White });
approveBtn.Child = approveSp;
ApplyMenuItemHover(approveBtn);
approveBtn.MouseLeftButtonUp += (_, _) =>
{
CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush);
tcs.TrySetResult(null); // null = 승인
};
btnRow.Children.Add(approveBtn);
// 수정 요청 버튼
var editBtn = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
};
var editSp = new StackPanel { Orientation = Orientation.Horizontal };
editSp.Children.Add(new TextBlock
{
Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
editSp.Children.Add(new TextBlock { Text = "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush });
editBtn.Child = editSp;
ApplyMenuItemHover(editBtn);
// 수정 요청용 텍스트 입력 패널 (초기 숨김)
var editInputPanel = new Border
{
Visibility = Visibility.Collapsed,
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F8F9FC")),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 8, 0, 0),
};
var editInputStack = new StackPanel();
editInputStack.Children.Add(new TextBlock
{
Text = "수정 사항을 입력하세요:",
FontSize = 11.5, Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6),
});
var editTextBox = new TextBox
{
MinHeight = 36,
MaxHeight = 100,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 12.5,
Background = Brushes.White,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 6, 8, 6),
};
editInputStack.Children.Add(editTextBox);
var submitEditBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 5, 12, 5),
Margin = new Thickness(0, 6, 0, 0),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
};
submitEditBtn.Child = new TextBlock { Text = "전송", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
ApplyHoverScaleAnimation(submitEditBtn, 1.05);
submitEditBtn.MouseLeftButtonUp += (_, _) =>
{
var feedback = editTextBox.Text.Trim();
if (string.IsNullOrEmpty(feedback)) return;
CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush);
tcs.TrySetResult(feedback);
};
editInputStack.Children.Add(submitEditBtn);
editInputPanel.Child = editInputStack;
editBtn.MouseLeftButtonUp += (_, _) =>
{
editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible
? Visibility.Collapsed : Visibility.Visible;
if (editInputPanel.Visibility == Visibility.Visible)
editTextBox.Focus();
};
btnRow.Children.Add(editBtn);
// 취소 버튼
var cancelBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)),
BorderThickness = new Thickness(1),
};
var cancelSp = new StackPanel { Orientation = Orientation.Horizontal };
cancelSp.Children.Add(new TextBlock
{
Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
cancelSp.Children.Add(new TextBlock
{
Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
});
cancelBtn.Child = cancelSp;
ApplyMenuItemHover(cancelBtn);
cancelBtn.MouseLeftButtonUp += (_, _) =>
{
CollapseDecisionButtons(outerStack, "✕ 취소됨",
new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)));
tcs.TrySetResult("취소");
};
btnRow.Children.Add(cancelBtn);
outerStack.Children.Add(btnRow);
outerStack.Children.Add(editInputPanel);
container.Child = outerStack;
// 슬라이드 + 페이드 등장 애니메이션
ApplyMessageEntryAnimation(container);
MessagePanel.Children.Add(container);
ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동
// PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기
var capturedOuterStack = outerStack;
var capturedAccent = accentBrush;
_ = tcs.Task.ContinueWith(t =>
{
Dispatcher.BeginInvoke(() =>
{
// 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우)
if (capturedOuterStack.Children.Count <= 1) return;
var label = t.Result == null ? "✓ 승인됨"
: t.Result == "취소" ? "✕ 취소됨"
: "✎ 수정 요청됨";
var fg = t.Result == "취소"
? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
: capturedAccent;
CollapseDecisionButtons(capturedOuterStack, label, fg);
});
}, TaskScheduler.Default);
}
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
{
outerStack.Children.Clear();
var resultLabel = new TextBlock
{
Text = resultText,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = fg,
Opacity = 0.8,
Margin = new Thickness(0, 2, 0, 2),
};
outerStack.Children.Add(resultLabel);
}
// ════════════════════════════════════════════════════════════
// 실행 계획 뷰어 (PlanViewerWindow) 연동
// ════════════════════════════════════════════════════════════
/// <summary>PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다.</summary>
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
{
return async (planSummary, options) =>
{
var tcs = new TaskCompletionSource<string?>();
var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary);
await Dispatcher.InvokeAsync(() =>
{
// PlanViewerWindow 생성 또는 재사용
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow))
{
_planViewerWindow = new PlanViewerWindow();
_planViewerWindow.Closing += (_, e) =>
{
e.Cancel = true;
_planViewerWindow.Hide();
};
}
// 계획 표시 + 승인 대기
_planViewerWindow.ShowPlanAsync(planSummary, steps, tcs);
// 채팅 창에 간략 배너 추가 + 인라인 승인 버튼도 표시
AddDecisionButtons(tcs, options);
// 하단 바 계획 버튼 표시
ShowPlanButton(true);
});
// 5분 타임아웃
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
if (completed != tcs.Task)
{
await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide());
return "취소";
}
var result = await tcs.Task;
// 승인된 경우 — 실행 모드로 전환
if (result == null) // null = 승인
{
await Dispatcher.InvokeAsync(() =>
{
_planViewerWindow?.SwitchToExecutionMode();
_planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기
});
}
else
{
await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide());
}
return result;
};
}
/// <summary>하단 바에 계획 보기 버튼을 표시/숨김합니다.</summary>
private void ShowPlanButton(bool show)
{
if (!show)
{
// 계획 버튼 제거
for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--)
{
if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn")
{
// 앞의 구분선도 제거
if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep")
MoodIconPanel.Children.RemoveAt(i - 1);
if (i < MoodIconPanel.Children.Count)
MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1));
break;
}
}
return;
}
// 이미 있으면 무시
foreach (var child in MoodIconPanel.Children)
{
if (child is Border b && b.Tag?.ToString() == "PlanBtn") return;
}
// 구분선
var separator = new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
Tag = "PlanSep",
};
MoodIconPanel.Children.Add(separator);
// 계획 버튼
var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981");
planBtn.Tag = "PlanBtn";
planBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
{
_planViewerWindow.Show();
_planViewerWindow.Activate();
}
};
MoodIconPanel.Children.Add(planBtn);
}
/// <summary>계획 뷰어에서 현재 실행 단계를 갱신합니다.</summary>
private void UpdatePlanViewerStep(AgentEvent evt)
{
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return;
if (evt.StepCurrent > 0)
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based
}
/// <summary>계획 실행 완료를 뷰어에 알립니다.</summary>
private void CompletePlanViewer()
{
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
_planViewerWindow.MarkComplete();
ShowPlanButton(false);
}
private static bool IsWindowAlive(Window? w)
{
if (w == null) return false;
try { var _ = w.IsVisible; return true; }
catch { return false; }
}
// ════════════════════════════════════════════════════════════
// 후속 작업 제안 칩 (suggest_actions)
// ════════════════════════════════════════════════════════════
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
private void RenderSuggestActionChips(string jsonSummary)
{
// JSON에서 액션 목록 파싱 시도
List<(string label, string command)> actions = new();
try
{
// summary 형식: "label: command" 줄바꿈 구분 또는 JSON
if (jsonSummary.Contains("\"label\""))
{
using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var item in doc.RootElement.EnumerateArray())
{
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label;
if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd));
}
}
}
else
{
// 줄바꿈 형식: "1. label → command"
foreach (var line in jsonSummary.Split('\n'))
{
var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' ');
if (string.IsNullOrEmpty(trimmed)) continue;
var parts = trimmed.Split('→', ':', '—');
if (parts.Length >= 2)
actions.Add((parts[0].Trim(), parts[1].Trim()));
else if (!string.IsNullOrEmpty(trimmed))
actions.Add((trimmed, trimmed));
}
}
}
catch { return; }
if (actions.Count == 0) return;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var container = new Border
{
Margin = new Thickness(40, 4, 40, 8),
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var headerStack = new StackPanel { Margin = new Thickness(0, 0, 0, 6) };
headerStack.Children.Add(new TextBlock
{
Text = "💡 다음 작업 제안:",
FontSize = 12,
Foreground = secondaryText,
});
var chipPanel = new WrapPanel { Margin = new Thickness(0, 2, 0, 0) };
foreach (var (label, command) in actions.Take(5))
{
var capturedCmd = command;
var chip = new Border
{
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 6),
Cursor = Cursors.Hand,
Background = new SolidColorBrush(Color.FromArgb(0x15,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B)),
BorderThickness = new Thickness(1),
};
chip.Child = new TextBlock
{
Text = label,
FontSize = 12.5,
Foreground = accentBrush,
FontWeight = FontWeights.SemiBold,
};
chip.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
chip.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
chip.MouseLeftButtonUp += (_, _) =>
{
// 칩 패널 제거 후 해당 명령 실행
MessagePanel.Children.Remove(container);
if (capturedCmd.StartsWith("/"))
{
InputBox.Text = capturedCmd + " ";
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
}
else
{
InputBox.Text = capturedCmd;
_ = SendMessageAsync();
}
};
chipPanel.Children.Add(chip);
}
var outerStack = new StackPanel();
outerStack.Children.Add(headerStack);
outerStack.Children.Add(chipPanel);
container.Child = outerStack;
ApplyMessageEntryAnimation(container);
MessagePanel.Children.Add(container);
ForceScrollToEnd();
}
// ════════════════════════════════════════════════════════════
// 피드백 학습 반영 (J)
// ════════════════════════════════════════════════════════════
/// <summary>최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다.</summary>
private string BuildFeedbackContext()
{
try
{
var recentConversations = _storage.LoadAllMeta()
.OrderByDescending(m => m.UpdatedAt)
.Take(20)
.ToList();
var likedPatterns = new List<string>();
var dislikedPatterns = new List<string>();
foreach (var meta in recentConversations)
{
var conv = _storage.Load(meta.Id);
if (conv == null) continue;
foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null))
{
// 첫 50자로 패턴 파악
var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? "";
if (msg.Feedback == "like")
likedPatterns.Add(preview);
else if (msg.Feedback == "dislike")
dislikedPatterns.Add(preview);
}
}
if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0)
return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n[사용자 선호도 참고]");
if (likedPatterns.Count > 0)
{
sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):");
foreach (var p in likedPatterns.Take(5))
sb.AppendLine($" - \"{p}...\"");
}
if (dislikedPatterns.Count > 0)
{
sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):");
foreach (var p in dislikedPatterns.Take(5))
sb.AppendLine($" - \"{p}...\"");
}
sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요.");
return sb.ToString();
}
catch { return ""; }
}
/// <summary>진행률 바와 단계 상태를 업데이트합니다.</summary>
private void UpdateProgressBar(AgentEvent evt)
{
if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null)
return;
var stepIdx = evt.StepCurrent - 1; // 0-based
var total = evt.StepTotal;
// 진행률 바 업데이트
_planProgressBar.Value = evt.StepCurrent;
var pct = (int)((double)evt.StepCurrent / total * 100);
_planProgressText.Text = $"{pct}%";
// 이전 단계 완료 표시 + 현재 단계 강조
for (int i = 0; i < _planStepsPanel.Children.Count; i++)
{
if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2)
{
var statusTb = row.Children[0] as TextBlock;
var textTb = row.Children[1] as TextBlock;
if (statusTb == null || textTb == null) continue;
if (i < stepIdx)
{
// 완료
statusTb.Text = "●";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280"));
}
else if (i == stepIdx)
{
// 현재 진행 중
statusTb.Text = "◉";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B"));
textTb.FontWeight = FontWeights.SemiBold;
}
else
{
// 대기
statusTb.Text = "○";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563"));
textTb.FontWeight = FontWeights.Normal;
}
}
}
}
/// <summary>Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다.</summary>
private static UIElement BuildDiffView(string text)
{
var panel = new StackPanel
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")),
MaxWidth = 520,
};
var diffStarted = false;
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
// diff 헤더 전의 일반 텍스트
if (!diffStarted && !line.StartsWith("--- "))
{
panel.Children.Add(new TextBlock
{
Text = line,
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
FontFamily = new FontFamily("Consolas"),
Margin = new Thickness(0, 0, 0, 1),
});
continue;
}
diffStarted = true;
string bgHex, fgHex;
if (line.StartsWith("---") || line.StartsWith("+++"))
{
bgHex = "#F3F4F6"; fgHex = "#374151";
}
else if (line.StartsWith("@@"))
{
bgHex = "#EFF6FF"; fgHex = "#3B82F6";
}
else if (line.StartsWith("+"))
{
bgHex = "#ECFDF5"; fgHex = "#059669";
}
else if (line.StartsWith("-"))
{
bgHex = "#FEF2F2"; fgHex = "#DC2626";
}
else
{
bgHex = "Transparent"; fgHex = "#6B7280";
}
var tb = new TextBlock
{
Text = line,
FontSize = 10.5,
FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
Padding = new Thickness(4, 1, 4, 1),
};
if (bgHex != "Transparent")
tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex));
panel.Children.Add(tb);
}
return panel;
}
private void AddAgentEventBanner(AgentEvent evt)
{
var logLevel = _settings.Settings.Llm.AgentLogLevel;
// Planning 이벤트는 단계 목록 카드로 별도 렌더링
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
{
AddPlanningCard(evt);
return;
}
// StepStart 이벤트는 진행률 바 업데이트
if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0)
{
UpdateProgressBar(evt);
return;
}
// simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시)
if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall)
return;
// 전체 통계 이벤트는 별도 색상 (보라색 계열)
var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats";
var (icon, label, bgHex, fgHex) = isTotalStats
? ("\uE9D2", "Total Stats", "#F3EEFF", "#7C3AED")
: evt.Type switch
{
AgentEventType.Thinking => ("\uE8BD", "Thinking", "#F0F0FF", "#6B7BC4"),
AgentEventType.ToolCall => ("\uE8A7", evt.ToolName, "#EEF6FF", "#3B82F6"),
AgentEventType.ToolResult => ("\uE73E", evt.ToolName, "#EEF9EE", "#16A34A"),
AgentEventType.SkillCall => ("\uE8A5", evt.ToolName, "#FFF7ED", "#EA580C"),
AgentEventType.Error => ("\uE783", "Error", "#FEF2F2", "#DC2626"),
AgentEventType.Complete => ("\uE930", "Complete", "#F0FFF4", "#15803D"),
AgentEventType.StepDone => ("\uE73E", "Step Done", "#EEF9EE", "#16A34A"),
AgentEventType.Paused => ("\uE769", "Paused", "#FFFBEB", "#D97706"),
AgentEventType.Resumed => ("\uE768", "Resumed", "#ECFDF5", "#059669"),
_ => ("\uE946", "Agent", "#F5F5F5", "#6B7280"),
};
var banner = new Border
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex)),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 8, 12, 8),
Margin = new Thickness(40, 2, 40, 2),
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var sp = new StackPanel();
// 헤더: Grid로 좌측(아이콘+라벨) / 우측(타이밍+토큰) 분리 고정
var headerGrid = new Grid();
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
// 좌측: 아이콘 + 라벨
var headerLeft = new StackPanel { Orientation = Orientation.Horizontal };
headerLeft.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
headerLeft.Children.Add(new TextBlock
{
Text = label,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
VerticalAlignment = VerticalAlignment.Center,
});
Grid.SetColumn(headerLeft, 0);
// 우측: 소요 시간 + 토큰 배지 (항상 우측 끝에 고정)
var headerRight = new StackPanel { Orientation = Orientation.Horizontal };
if (logLevel != "simple" && evt.ElapsedMs > 0)
{
headerRight.Children.Add(new TextBlock
{
Text = evt.ElapsedMs < 1000 ? $"{evt.ElapsedMs}ms" : $"{evt.ElapsedMs / 1000.0:F1}s",
FontSize = 10,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
});
}
if (logLevel != "simple" && (evt.InputTokens > 0 || evt.OutputTokens > 0))
{
var tokenText = evt.InputTokens > 0 && evt.OutputTokens > 0
? $"{evt.InputTokens}→{evt.OutputTokens}t"
: evt.InputTokens > 0 ? $"↑{evt.InputTokens}t" : $"↓{evt.OutputTokens}t";
headerRight.Children.Add(new Border
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F0F0F5")),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = tokenText,
FontSize = 9.5,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#8B8FA3")),
FontFamily = new FontFamily("Consolas"),
},
});
}
Grid.SetColumn(headerRight, 1);
headerGrid.Children.Add(headerLeft);
headerGrid.Children.Add(headerRight);
// header 변수를 headerLeft로 설정 (이후 expandIcon 추가 시 사용)
var header = headerLeft;
sp.Children.Add(headerGrid);
// simple 모드: 요약 한 줄만 표시 (접기 없음)
if (logLevel == "simple")
{
if (!string.IsNullOrEmpty(evt.Summary))
{
var shortSummary = evt.Summary.Length > 100
? evt.Summary[..100] + "…"
: evt.Summary;
sp.Children.Add(new TextBlock
{
Text = shortSummary,
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")),
TextWrapping = TextWrapping.NoWrap,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(0, 2, 0, 0),
});
}
}
// detailed/debug 모드: 기존 접이식 표시
else if (!string.IsNullOrEmpty(evt.Summary))
{
var summaryText = evt.Summary;
var isExpandable = (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.ToolResult)
&& summaryText.Length > 60;
if (isExpandable)
{
// 첫 줄만 표시하고 클릭하면 전체 내용 펼침
var shortText = summaryText.Length > 80 ? summaryText[..80] + "..." : summaryText;
var summaryTb = new TextBlock
{
Text = shortText,
FontSize = 11.5,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 3, 0, 0),
Cursor = Cursors.Hand,
};
// Diff가 포함된 경우 색상 하이라이팅 적용
var hasDiff = summaryText.Contains("--- ") && summaryText.Contains("+++ ");
UIElement fullContent;
if (hasDiff)
{
fullContent = BuildDiffView(summaryText);
}
else
{
fullContent = new TextBlock
{
Text = summaryText,
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")),
TextWrapping = TextWrapping.Wrap,
FontFamily = new FontFamily("Consolas"),
};
}
fullContent.Visibility = Visibility.Collapsed;
((FrameworkElement)fullContent).Margin = new Thickness(0, 4, 0, 0);
// 펼침/접기 토글
var expandIcon = new TextBlock
{
Text = "\uE70D", // ChevronDown
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 9,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
header.Children.Add(expandIcon);
var isExpanded = false;
banner.MouseLeftButtonDown += (_, _) =>
{
isExpanded = !isExpanded;
fullContent.Visibility = isExpanded ? Visibility.Visible : Visibility.Collapsed;
summaryTb.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible;
expandIcon.Text = isExpanded ? "\uE70E" : "\uE70D"; // ChevronUp : ChevronDown
};
sp.Children.Add(summaryTb);
sp.Children.Add(fullContent);
}
else
{
sp.Children.Add(new TextBlock
{
Text = summaryText,
FontSize = 11.5,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 3, 0, 0),
});
}
}
// debug 모드: ToolInput 파라미터 표시
if (logLevel == "debug" && !string.IsNullOrEmpty(evt.ToolInput))
{
sp.Children.Add(new Border
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F8F8FC")),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 4, 0, 0),
Child = new TextBlock
{
Text = evt.ToolInput.Length > 500 ? evt.ToolInput[..500] + "…" : evt.ToolInput,
FontSize = 10,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#7C7F93")),
FontFamily = new FontFamily("Consolas"),
TextWrapping = TextWrapping.Wrap,
},
});
}
// 파일 경로 배너 (Claude 스타일)
if (!string.IsNullOrEmpty(evt.FilePath))
{
var pathBorder = new Border
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F8FAFC")),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 4, 0, 0),
};
var pathPanel = new StackPanel { Orientation = Orientation.Horizontal };
pathPanel.Children.Add(new TextBlock
{
Text = "\uE8B7", // folder icon
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
pathPanel.Children.Add(new TextBlock
{
Text = evt.FilePath,
FontSize = 10.5,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280")),
FontFamily = new FontFamily("Consolas"),
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
});
// 빠른 작업 버튼들
var quickActions = BuildFileQuickActions(evt.FilePath);
pathPanel.Children.Add(quickActions);
pathBorder.Child = pathPanel;
sp.Children.Add(pathBorder);
}
banner.Child = sp;
// Total Stats 배너 클릭 → 워크플로우 분석기 병목 분석 탭 열기
if (isTotalStats)
{
banner.Cursor = Cursors.Hand;
banner.ToolTip = "클릭하여 병목 분석 보기";
banner.MouseLeftButtonUp += (_, _) =>
{
OpenWorkflowAnalyzerIfEnabled();
_analyzerWindow?.SwitchToBottleneckTab();
_analyzerWindow?.Activate();
};
}
// 페이드인 애니메이션
banner.Opacity = 0;
banner.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
MessagePanel.Children.Add(banner);
}
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
private StackPanel BuildFileQuickActions(string filePath)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6");
var accentBrush = new SolidColorBrush(accentColor);
Border MakeBtn(string mdlIcon, string label, Action action)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = mdlIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 9,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 3, 0),
});
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 10,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
});
var btn = new Border
{
Child = sp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 2, 5, 2),
Cursor = Cursors.Hand,
};
btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); };
btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
btn.MouseLeftButtonUp += (_, _) => action();
return btn;
}
// 프리뷰 (지원 확장자만)
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
if (_previewableExtensions.Contains(ext))
{
var path1 = filePath;
panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1)));
}
// 외부 열기
var path2 = filePath;
panel.Children.Add(MakeBtn("\uE8A7", "열기", () =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { }
}));
// 폴더 열기
var path3 = filePath;
panel.Children.Add(MakeBtn("\uED25", "폴더", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { }
}));
// 경로 복사
var path4 = filePath;
panel.Children.Add(MakeBtn("\uE8C8", "복사", () =>
{
try
{
Clipboard.SetText(path4);
// 1.5초 피드백: "복사됨" 표시
if (panel.Children[^1] is Border lastBtn && lastBtn.Child is StackPanel lastSp)
{
var origLabel = lastSp.Children.OfType<TextBlock>().LastOrDefault();
if (origLabel != null)
{
var prev = origLabel.Text;
origLabel.Text = "복사됨 ✓";
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
timer.Tick += (_, _) => { origLabel.Text = prev; timer.Stop(); };
timer.Start();
}
}
}
catch { }
}));
return panel;
}
// ─── 응답 재생성 ──────────────────────────────────────────────────────
private async Task RegenerateLastAsync()
{
if (_isStreaming) return;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null) return;
conv = _currentConversation;
}
// 마지막 assistant 메시지 제거
lock (_convLock)
{
if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
conv.Messages.RemoveAt(conv.Messages.Count - 1);
}
// UI에서 마지막 AI 응답 제거
if (MessagePanel.Children.Count > 0)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 재전송
await SendRegenerateAsync(conv);
}
/// <summary>"수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.</summary>
private void ShowRetryWithFeedbackInput()
{
if (_isStreaming) return;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var container = new Border
{
Margin = new Thickness(40, 4, 40, 8),
Padding = new Thickness(14, 10, 14, 10),
CornerRadius = new CornerRadius(12),
Background = itemBg,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = "어떻게 수정하면 좋을지 알려주세요:",
FontSize = 12,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6),
});
var textBox = new TextBox
{
MinHeight = 38,
MaxHeight = 80,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 13,
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
Foreground = primaryText,
CaretBrush = primaryText,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 6, 10, 6),
};
stack.Children.Add(textBox);
var btnRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 8, 0, 0),
};
var sendBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 6, 14, 6),
Cursor = Cursors.Hand,
Margin = new Thickness(6, 0, 0, 0),
};
sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
sendBtn.MouseLeftButtonUp += (_, _) =>
{
var feedback = textBox.Text.Trim();
if (string.IsNullOrEmpty(feedback)) return;
MessagePanel.Children.Remove(container);
_ = RetryWithFeedbackAsync(feedback);
};
var cancelBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 6, 12, 6),
Cursor = Cursors.Hand,
};
cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText };
cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container);
btnRow.Children.Add(cancelBtn);
btnRow.Children.Add(sendBtn);
stack.Children.Add(btnRow);
container.Child = stack;
ApplyMessageEntryAnimation(container);
MessagePanel.Children.Add(container);
ForceScrollToEnd();
textBox.Focus();
}
/// <summary>사용자 피드백과 함께 마지막 응답을 재생성합니다.</summary>
private async Task RetryWithFeedbackAsync(string feedback)
{
if (_isStreaming) return;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null) return;
conv = _currentConversation;
}
// 마지막 assistant 메시지 제거
lock (_convLock)
{
if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
conv.Messages.RemoveAt(conv.Messages.Count - 1);
}
// UI에서 마지막 AI 응답 제거
if (MessagePanel.Children.Count > 0)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 피드백을 사용자 메시지로 추가
var feedbackMsg = new ChatMessage
{
Role = "user",
Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요."
};
lock (_convLock) conv.Messages.Add(feedbackMsg);
// 피드백 메시지 UI 표시
AddMessageBubble("user", $"[수정 요청] {feedback}", true);
// 재전송
await SendRegenerateAsync(conv);
}
private async Task SendRegenerateAsync(ChatConversation conv)
{
_isStreaming = true;
BtnSend.IsEnabled = false;
BtnSend.Visibility = Visibility.Collapsed;
BtnStop.Visibility = Visibility.Visible;
_streamCts = new CancellationTokenSource();
var assistantMsg = new ChatMessage { Role = "assistant", Content = "" };
lock (_convLock) conv.Messages.Add(assistantMsg);
var streamContainer = CreateStreamingContainer(out var streamText);
MessagePanel.Children.Add(streamContainer);
ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동
var sb = new System.Text.StringBuilder();
_activeStreamText = streamText;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_cursorTimer.Start();
_typingTimer.Start();
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus("에이전트 작업 중...", spinning: true);
try
{
List<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 });
await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token))
{
sb.Append(chunk);
StopAiIconPulse();
_cachedStreamContent = sb.ToString();
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
}
_cachedStreamContent = sb.ToString();
assistantMsg.Content = _cachedStreamContent;
// 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms)
var drainStart2 = DateTime.UtcNow;
while (_displayedLength < _cachedStreamContent.Length
&& (DateTime.UtcNow - drainStart2).TotalMilliseconds < 600)
{
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
}
}
catch (OperationCanceledException)
{
if (sb.Length == 0) sb.Append("(취소됨)");
assistantMsg.Content = sb.ToString();
}
catch (Exception ex)
{
var errMsg = $"⚠ 오류: {ex.Message}";
sb.Clear(); sb.Append(errMsg);
assistantMsg.Content = errMsg;
AddRetryButton();
}
finally
{
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리
StopRainbowGlow(); // 레인보우 글로우 종료
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_isStreaming = false;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnSend.Visibility = Visibility.Visible;
_streamCts?.Dispose();
_streamCts = null;
SetStatusIdle();
}
FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg);
AutoScrollIfNeeded();
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
_tabConversationId[conv.Tab ?? _activeTab] = conv.Id;
RefreshConversationList();
}
/// <summary>메시지 버블의 MaxWidth를 창 너비에 비례하여 계산합니다 (최소 500, 최대 1200).</summary>
private double GetMessageMaxWidth()
{
var scrollWidth = MessageScroll.ActualWidth;
if (scrollWidth < 100) scrollWidth = 700; // 초기화 전 기본값
// 좌우 마진(40+80=120)을 빼고 전체의 90%
var maxW = (scrollWidth - 120) * 0.90;
return Math.Clamp(maxW, 500, 1200);
}
private StackPanel CreateStreamingContainer(out TextBlock streamText)
{
var msgMaxWidth = GetMessageMaxWidth();
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(40, 8, 80, 8),
Opacity = 0,
RenderTransform = new TranslateTransform(0, 10)
};
// 컨테이너 페이드인 + 슬라이드 업
container.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
((TranslateTransform)container.RenderTransform).BeginAnimation(
TranslateTransform.YProperty,
new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var aiIcon = new TextBlock
{
Text = "\uE8BD", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 12,
Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
VerticalAlignment = VerticalAlignment.Center
};
// AI 아이콘 펄스 애니메이션 (응답 대기 중)
aiIcon.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
{ AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new SineEase() });
_activeAiIcon = aiIcon;
Grid.SetColumn(aiIcon, 0);
headerGrid.Children.Add(aiIcon);
var (streamAgentName, _, _) = GetAgentIdentity();
var aiNameTb = new TextBlock
{
Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(aiNameTb, 1);
headerGrid.Children.Add(aiNameTb);
// 실시간 경과 시간 (헤더 우측)
_elapsedLabel = new TextBlock
{
Text = "0s",
FontSize = 10.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Opacity = 0.5,
};
Grid.SetColumn(_elapsedLabel, 2);
headerGrid.Children.Add(_elapsedLabel);
container.Children.Add(headerGrid);
streamText = new TextBlock
{
Text = "\u258c", // 블록 커서만 표시 (첫 청크 전)
FontSize = 13.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextWrapping = TextWrapping.Wrap, LineHeight = 22,
};
container.Children.Add(streamText);
return container;
}
// ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
{
// 스트리밍 plaintext 블록 제거
container.Children.Remove(streamText);
// 마크다운 렌더링
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
mdPanel.Margin = new Thickness(0, 0, 0, 4);
mdPanel.Opacity = 0;
container.Children.Add(mdPanel);
mdPanel.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
// 액션 버튼 바 + 토큰 표시
var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var capturedContent = finalContent;
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 6, 0, 0)
};
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
{
try { Clipboard.SetText(capturedContent); } catch { }
}));
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
AddLinkedFeedbackButtons(actionBar, btnColor, message);
container.Children.Add(actionBar);
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
var elapsed = DateTime.UtcNow - _streamStartTime;
var elapsedText = elapsed.TotalSeconds < 60
? $"{elapsed.TotalSeconds:0.#}s"
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
var usage = _llm.LastTokenUsage;
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
var isAgentTab = _activeTab is "Cowork" or "Code";
var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
? _agentCumulativeInputTokens
: usage?.PromptTokens ?? 0;
var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
? _agentCumulativeOutputTokens
: usage?.CompletionTokens ?? 0;
if (displayInput > 0 || displayOutput > 0)
{
UpdateStatusTokens(displayInput, displayOutput);
Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput);
}
string tokenText;
if (displayInput > 0 || displayOutput > 0)
tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
else if (usage != null)
tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
else
tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
var metaText = new TextBlock
{
Text = $"{elapsedText} · {tokenText}",
FontSize = 10.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 6, 0, 0),
Opacity = 0.6,
};
container.Children.Add(metaText);
// Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
var chips = ParseSuggestionChips(finalContent);
if (chips.Count > 0)
{
var chipPanel = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 4),
HorizontalAlignment = HorizontalAlignment.Left,
};
foreach (var (num, label) in chips)
{
var chipBorder = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 6),
Cursor = Cursors.Hand,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
chipBorder.Child = new TextBlock
{
Text = $"{num}. {label}",
FontSize = 12.5,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
};
var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
chipBorder.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
};
chipBorder.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
};
var capturedLabel = $"{num}. {label}";
var capturedPanel = chipPanel;
chipBorder.MouseLeftButtonDown += (_, _) =>
{
// 칩 패널 제거 (1회용)
if (capturedPanel.Parent is Panel parent)
parent.Children.Remove(capturedPanel);
// 선택한 옵션을 사용자 메시지로 전송
InputBox.Text = capturedLabel;
_ = SendMessageAsync();
};
chipPanel.Children.Add(chipBorder);
}
container.Children.Add(chipPanel);
}
}
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
private static List<(string Num, string Label)> ParseSuggestionChips(string content)
{
var chips = new List<(string, string)>();
if (string.IsNullOrEmpty(content)) return chips;
var lines = content.Split('\n');
// 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
var candidates = new List<(string, string)>();
var lastBlockStart = -1;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
if (m.Success)
{
if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
{
if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
}
else
{
// 새로운 블록 시작
lastBlockStart = i;
candidates.Clear();
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
}
}
else if (!string.IsNullOrWhiteSpace(line))
{
// 번호 목록이 아닌 줄이 나오면 블록 리셋
lastBlockStart = -1;
candidates.Clear();
}
// 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
}
// 2개 이상 선택지, 10개 이하일 때만 chips로 표시
if (candidates.Count >= 2 && candidates.Count <= 10)
chips.AddRange(candidates);
return chips;
}
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
private static string FormatTokenCount(int count) => count switch
{
>= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
>= 1_000 => $"{count / 1_000.0:0.#}k",
_ => count.ToString(),
};
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
private static int EstimateTokenCount(string text)
{
if (string.IsNullOrEmpty(text)) return 0;
// 한국어 문자 비율에 따라 가중
int cjk = 0;
foreach (var c in text)
if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
}
// ─── 생성 중지 ──────────────────────────────────────────────────────
private void StopGeneration()
{
_streamCts?.Cancel();
}
// ─── 대화 내보내기 ──────────────────────────────────────────────────
// ─── 대화 분기 (Fork) ──────────────────────────────────────────────
private void ForkConversation(ChatConversation source, int atIndex)
{
var branchCount = _storage.LoadAllMeta()
.Count(m => m.ParentId == source.Id) + 1;
var fork = new ChatConversation
{
Title = $"{source.Title} (분기 {branchCount})",
Tab = source.Tab,
Category = source.Category,
WorkFolder = source.WorkFolder,
SystemCommand = source.SystemCommand,
ParentId = source.Id,
BranchLabel = $"분기 {branchCount}",
BranchAtIndex = atIndex,
};
// 분기 시점까지의 메시지 복제
for (int i = 0; i <= atIndex && i < source.Messages.Count; i++)
{
var m = source.Messages[i];
fork.Messages.Add(new ChatMessage
{
Role = m.Role,
Content = m.Content,
Timestamp = m.Timestamp,
});
}
try
{
_storage.Save(fork);
ShowToast($"분기 생성: {fork.Title}");
// 분기 대화로 전환
lock (_convLock) _currentConversation = fork;
ChatTitle.Text = fork.Title;
RenderMessages();
RefreshConversationList();
}
catch (Exception ex)
{
ShowToast($"분기 실패: {ex.Message}", "\uE783");
}
}
// ─── 커맨드 팔레트 ─────────────────────────────────────────────────
private void OpenCommandPalette()
{
var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this };
palette.ShowDialog();
}
private void ExecuteCommand(string commandId)
{
switch (commandId)
{
case "tab:chat": TabChat.IsChecked = true; break;
case "tab:cowork": TabCowork.IsChecked = true; break;
case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break;
case "new_conversation": StartNewConversation(); break;
case "search_conversation": ToggleMessageSearch(); break;
case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break;
case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break;
case "open_statistics": new StatisticsWindow().Show(); break;
case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break;
case "toggle_devmode":
var llm = _settings.Settings.Llm;
llm.DevMode = !llm.DevMode;
_settings.Save();
UpdateAnalyzerButtonVisibility();
ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
break;
case "open_audit_log":
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
break;
case "paste_clipboard":
try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { }
break;
case "export_conversation": ExportConversation(); break;
}
}
private void ExportConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0) return;
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"{conv.Title}",
DefaultExt = ".md",
Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt"
};
if (dlg.ShowDialog() != true) return;
var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant();
string content;
if (ext == ".json")
{
content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
}
else if (dlg.FileName.EndsWith(".pdf.html"))
{
// PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시
content = PdfExportService.BuildHtml(conv);
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
PdfExportService.OpenInBrowser(dlg.FileName);
ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다");
return;
}
else if (ext == ".html")
{
content = ExportToHtml(conv);
}
else
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# {conv.Title}");
sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_");
sb.AppendLine();
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
var label = msg.Role == "user" ? "**사용자**" : "**AI**";
sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})");
sb.AppendLine();
sb.AppendLine(msg.Content);
if (msg.AttachedFiles is { Count: > 0 })
{
sb.AppendLine();
sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_");
}
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
content = sb.ToString();
}
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
}
private static string ExportToHtml(ChatConversation conv)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
sb.AppendLine($"<title>{System.Net.WebUtility.HtmlEncode(conv.Title)}</title>");
sb.AppendLine("<style>body{font-family:'Segoe UI',sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}");
sb.AppendLine(".msg{margin:12px 0;padding:12px 16px;border-radius:12px}.user{background:#2d2d5e;margin-left:60px}.ai{background:#1e1e3a;margin-right:60px}");
sb.AppendLine(".meta{font-size:11px;color:#888;margin-bottom:6px}.content{white-space:pre-wrap;line-height:1.6}");
sb.AppendLine("h1{text-align:center;color:#8b6dff}pre{background:#111;padding:12px;border-radius:8px;overflow-x:auto}</style></head><body>");
sb.AppendLine($"<h1>{System.Net.WebUtility.HtmlEncode(conv.Title)}</h1>");
sb.AppendLine($"<p style='text-align:center;color:#888'>생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}</p>");
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
var cls = msg.Role == "user" ? "user" : "ai";
var label = msg.Role == "user" ? "사용자" : "AI";
sb.AppendLine($"<div class='msg {cls}'>");
sb.AppendLine($"<div class='meta'>{label} · {msg.Timestamp:HH:mm}</div>");
sb.AppendLine($"<div class='content'>{System.Net.WebUtility.HtmlEncode(msg.Content)}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</body></html>");
return sb.ToString();
}
// ─── 버튼 이벤트 ──────────────────────────────────────────────────────
private void ChatWindow_KeyDown(object sender, KeyEventArgs e)
{
var mod = Keyboard.Modifiers;
// Ctrl 단축키
if (mod == ModifierKeys.Control)
{
switch (e.Key)
{
case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.W: Close(); e.Handled = true; break;
case Key.E: ExportConversation(); e.Handled = true; break;
case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break;
case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.F: ToggleMessageSearch(); e.Handled = true; break;
case Key.D1: TabChat.IsChecked = true; e.Handled = true; break;
case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break;
case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break;
}
}
// Ctrl+Shift 단축키
if (mod == (ModifierKeys.Control | ModifierKeys.Shift))
{
switch (e.Key)
{
case Key.C:
// 마지막 AI 응답 복사
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null)
{
var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant");
if (lastAi != null)
try { Clipboard.SetText(lastAi.Content); } catch { }
}
e.Handled = true;
break;
case Key.R:
// 마지막 응답 재생성
_ = RegenerateLastAsync();
e.Handled = true;
break;
case Key.D:
// 모든 대화 삭제
BtnDeleteAll_Click(this, new RoutedEventArgs());
e.Handled = true;
break;
case Key.P:
// 커맨드 팔레트
OpenCommandPalette();
e.Handled = true;
break;
}
}
// Escape: 검색 바 닫기 또는 스트리밍 중지
if (e.Key == Key.Escape)
{
if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; }
else if (_isStreaming) { StopGeneration(); e.Handled = true; }
}
// 슬래시 명령 팝업 키 처리
if (SlashPopup.IsOpen)
{
if (e.Key == Key.Escape)
{
SlashPopup.IsOpen = false;
_slashSelectedIndex = -1;
e.Handled = true;
}
else if (e.Key == Key.Up)
{
SlashPopup_ScrollByDelta(120); // 위로 1칸
e.Handled = true;
}
else if (e.Key == Key.Down)
{
SlashPopup_ScrollByDelta(-120); // 아래로 1칸
e.Handled = true;
}
else if (e.Key == Key.Enter && _slashSelectedIndex >= 0)
{
e.Handled = true;
ExecuteSlashSelectedItem();
}
}
}
private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration();
private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (_agentLoop.IsPaused)
{
_agentLoop.Resume();
PauseIcon.Text = "\uE769"; // 일시정지 아이콘
BtnPause.ToolTip = "일시정지";
}
else
{
_ = _agentLoop.PauseAsync();
PauseIcon.Text = "\uE768"; // 재생 아이콘
BtnPause.ToolTip = "재개";
}
}
private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation();
// ─── 메시지 내 검색 (Ctrl+F) ─────────────────────────────────────────
private List<int> _searchMatchIndices = new();
private int _searchCurrentIndex = -1;
private void ToggleMessageSearch()
{
if (MessageSearchBar.Visibility == Visibility.Visible)
CloseMessageSearch();
else
{
MessageSearchBar.Visibility = Visibility.Visible;
SearchTextBox.Focus();
SearchTextBox.SelectAll();
}
}
private void CloseMessageSearch()
{
MessageSearchBar.Visibility = Visibility.Collapsed;
SearchTextBox.Text = "";
SearchResultCount.Text = "";
_searchMatchIndices.Clear();
_searchCurrentIndex = -1;
// 하이라이트 제거
ClearSearchHighlights();
}
private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
var query = SearchTextBox.Text.Trim();
if (string.IsNullOrEmpty(query))
{
SearchResultCount.Text = "";
_searchMatchIndices.Clear();
_searchCurrentIndex = -1;
ClearSearchHighlights();
return;
}
// 현재 대화의 메시지에서 검색
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
_searchMatchIndices.Clear();
for (int i = 0; i < conv.Messages.Count; i++)
{
if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase))
_searchMatchIndices.Add(i);
}
if (_searchMatchIndices.Count > 0)
{
_searchCurrentIndex = 0;
SearchResultCount.Text = $"1/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
else
{
_searchCurrentIndex = -1;
SearchResultCount.Text = "결과 없음";
}
}
private void SearchPrev_Click(object sender, RoutedEventArgs e)
{
if (_searchMatchIndices.Count == 0) return;
_searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count;
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
private void SearchNext_Click(object sender, RoutedEventArgs e)
{
if (_searchMatchIndices.Count == 0) return;
_searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count;
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch();
private void HighlightSearchResult()
{
if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return;
var msgIndex = _searchMatchIndices[_searchCurrentIndex];
// MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤
// 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로
// (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동
if (msgIndex < MessagePanel.Children.Count)
{
var element = MessagePanel.Children[msgIndex] as FrameworkElement;
element?.BringIntoView();
}
else if (MessagePanel.Children.Count > 0)
{
// 범위 밖이면 마지막 자식으로 이동
(MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView();
}
}
private void ClearSearchHighlights()
{
// 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요
}
// ─── 에러 복구 재시도 버튼 ──────────────────────────────────────────────
private void AddRetryButton()
{
Dispatcher.Invoke(() =>
{
var retryBorder = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 8, 12, 8),
Margin = new Thickness(40, 4, 80, 4),
HorizontalAlignment = HorizontalAlignment.Left,
Cursor = System.Windows.Input.Cursors.Hand,
};
var retrySp = new StackPanel { Orientation = Orientation.Horizontal };
retrySp.Children.Add(new TextBlock
{
Text = "\uE72C", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
});
retrySp.Children.Add(new TextBlock
{
Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
VerticalAlignment = VerticalAlignment.Center,
});
retryBorder.Child = retrySp;
retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); };
retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); };
retryBorder.MouseLeftButtonUp += (_, _) =>
{
lock (_convLock)
{
if (_currentConversation != null)
{
var lastIdx = _currentConversation.Messages.Count - 1;
if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant")
_currentConversation.Messages.RemoveAt(lastIdx);
}
}
_ = RegenerateLastAsync();
};
MessagePanel.Children.Add(retryBorder);
ForceScrollToEnd();
});
}
// ─── 메시지 우클릭 컨텍스트 메뉴 ───────────────────────────────────────
private void ShowMessageContextMenu(string content, string role)
{
var menu = CreateThemedContextMenu();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
void AddItem(string icon, string label, Action action)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 12, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) };
mi.Click += (_, _) => action();
menu.Items.Add(mi);
}
// 복사
AddItem("\uE8C8", "텍스트 복사", () =>
{
try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { }
});
// 마크다운 복사
AddItem("\uE943", "마크다운 복사", () =>
{
try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { }
});
// 인용하여 답장
AddItem("\uE97A", "인용하여 답장", () =>
{
var quote = content.Length > 200 ? content[..200] + "..." : content;
var lines = quote.Split('\n');
var quoted = string.Join("\n", lines.Select(l => $"> {l}"));
InputBox.Text = quoted + "\n\n";
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text.Length;
});
menu.Items.Add(new Separator());
// 재생성 (AI 응답만)
if (role == "assistant")
{
AddItem("\uE72C", "응답 재생성", () => _ = RegenerateLastAsync());
}
// 대화 분기 (Fork)
AddItem("\uE8A5", "여기서 분기", () =>
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content);
if (idx < 0) return;
ForkConversation(conv, idx);
});
menu.Items.Add(new Separator());
// 이후 메시지 모두 삭제
var msgContent = content;
var msgRole = role;
AddItem("\uE74D", "이후 메시지 모두 삭제", () =>
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent);
if (idx < 0) return;
var removeCount = conv.Messages.Count - idx;
if (MessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?",
"메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
return;
conv.Messages.RemoveRange(idx, removeCount);
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
RenderMessages();
ShowToast($"{removeCount}개 메시지 삭제됨");
});
menu.IsOpen = true;
}
// ─── 팁 알림 ──────────────────────────────────────────────────────
private static readonly string[] Tips =
[
"💡 작업 폴더에 AX.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.",
"💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.",
"💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.",
"💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.",
"💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.",
"💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.",
"💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.",
"💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.",
"💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.",
"💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.",
"💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.",
"💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)",
"💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.",
"💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.",
"💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.",
"💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.",
"💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.",
"💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.",
"💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.",
"💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.",
"💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.",
"💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url)) .",
"💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!",
"💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.",
];
private int _tipIndex;
private DispatcherTimer? _tipDismissTimer;
private void ShowRandomTip()
{
if (!_settings.Settings.Llm.ShowTips) return;
if (_activeTab != "Cowork" && _activeTab != "Code") return;
var tip = Tips[_tipIndex % Tips.Length];
_tipIndex++;
// 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상)
ShowTip(tip);
}
private void ShowTip(string message)
{
_tipDismissTimer?.Stop();
ToastText.Text = message;
ToastIcon.Text = "\uE82F"; // 전구 아이콘
ToastBorder.Visibility = Visibility.Visible;
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
var duration = _settings.Settings.Llm.TipDurationSeconds;
if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음)
_tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) };
_tipDismissTimer.Tick += (_, _) =>
{
_tipDismissTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
_tipDismissTimer.Start();
}
// ─── 프로젝트 문맥 파일 (AX.md) ──────────────────────────────────
/// <summary>
/// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다.
/// Claude Code와 동일한 파일명/형식을 사용합니다.
/// </summary>
private static string LoadProjectContext(string workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return "";
// AX.md 탐색 (작업 폴더 → 상위 폴더 순)
var searchDir = workFolder;
for (int i = 0; i < 3; i++) // 최대 3단계 상위까지
{
if (string.IsNullOrEmpty(searchDir)) break;
var filePath = System.IO.Path.Combine(searchDir, "AX.md");
if (System.IO.File.Exists(filePath))
{
try
{
var content = System.IO.File.ReadAllText(filePath);
if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)";
return $"\n## Project Context (from AX.md)\n{content}\n";
}
catch { }
}
searchDir = System.IO.Directory.GetParent(searchDir)?.FullName;
}
return "";
}
// ─── 무지개 글로우 애니메이션 ─────────────────────────────────────────
private DispatcherTimer? _rainbowTimer;
private DateTime _rainbowStartTime;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
private void PlayRainbowGlow()
{
if (!_settings.Settings.Llm.EnableChatRainbowGlow) return;
_rainbowTimer?.Stop();
_rainbowStartTime = DateTime.UtcNow;
// 페이드인 (빠르게)
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150)));
// 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
_rainbowTimer.Tick += (_, _) =>
{
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
// 그라데이션 오프셋 회전
var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게)
var brush = InputGlowBorder.BorderBrush as LinearGradientBrush;
if (brush == null) return;
// 시작/끝점 회전 (원형 이동)
var angle = shift * Math.PI * 2;
brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle));
brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle));
};
_rainbowTimer.Start();
}
/// <summary>레인보우 글로우 효과를 페이드아웃하며 중지합니다.</summary>
private void StopRainbowGlow()
{
_rainbowTimer?.Stop();
_rainbowTimer = null;
if (InputGlowBorder.Opacity > 0)
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0;
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────
private DispatcherTimer? _toastHideTimer;
private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000)
{
_toastHideTimer?.Stop();
ToastText.Text = message;
ToastIcon.Text = icon;
ToastBorder.Visibility = Visibility.Visible;
// 페이드인
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
// 자동 숨기기
_toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) };
_toastHideTimer.Tick += (_, _) =>
{
_toastHideTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
_toastHideTimer.Start();
}
// ─── 대화 주제 버튼 ──────────────────────────────────────────────────
/// <summary>프리셋에서 대화 주제 버튼을 동적으로 생성합니다.</summary>
private void BuildTopicButtons()
{
TopicButtonPanel.Children.Clear();
TopicButtonPanel.Visibility = Visibility.Visible;
// 탭별 EmptyState 텍스트
if (_activeTab == "Cowork" || _activeTab == "Code")
{
if (EmptyStateTitle != null) EmptyStateTitle.Text = "작업 유형을 선택하세요";
if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code"
? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다"
: "에이전트가 상세한 데이터를 작성합니다";
}
else
{
if (EmptyStateTitle != null) EmptyStateTitle.Text = "대화 주제를 선택하세요";
if (EmptyStateDesc != null) EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다";
}
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
foreach (var preset in presets)
{
var capturedPreset = preset;
var btnColor = BrushFromHex(preset.Color);
var border = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 12, 14, 12),
Margin = new Thickness(4, 4, 4, 8),
Cursor = Cursors.Hand,
Width = 120,
Height = 105,
ClipToBounds = true,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
// 아이콘 컨테이너 (원형 배경 + 펄스 애니메이션)
var iconCircle = new Border
{
Width = 40, Height = 40,
CornerRadius = new CornerRadius(20),
Background = new SolidColorBrush(((SolidColorBrush)btnColor).Color) { Opacity = 0.15 },
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 10),
};
var iconTb = new TextBlock
{
Text = preset.Symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = btnColor,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
iconCircle.Child = iconTb;
stack.Children.Add(iconCircle);
// 제목
stack.Children.Add(new TextBlock
{
Text = preset.Label,
FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
});
// 설명
stack.Children.Add(new TextBlock
{
Text = preset.Description,
FontSize = 9, TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxHeight = 28,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 2, 0, 0),
TextAlignment = TextAlignment.Center,
});
// 커스텀 프리셋: 좌측 상단 뱃지
if (capturedPreset.IsCustom)
{
var grid = new Grid();
grid.Children.Add(stack);
var badge = new Border
{
Width = 16, Height = 16,
CornerRadius = new CornerRadius(4),
Background = new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xFF, 0xFF)),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(2, 2, 0, 0),
ToolTip = "커스텀 프리셋",
};
badge.Child = new TextBlock
{
Text = "\uE710", // + 아이콘
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 8,
Foreground = btnColor,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
grid.Children.Add(badge);
border.Child = grid;
}
else
{
border.Child = stack;
}
// 호버 애니메이션 — 스케일 1.05x + 밝기 변경
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var normalBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
border.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{
st.ScaleX = 1.03; st.ScaleY = 1.03;
b.Background = hoverBg;
}
};
border.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{
st.ScaleX = 1.0; st.ScaleY = 1.0;
b.Background = normalBg;
}
};
// 클릭 → 해당 주제로 새 대화 시작
border.MouseLeftButtonDown += (_, _) => SelectTopic(capturedPreset);
// 커스텀 프리셋: 우클릭 메뉴 (편집/삭제)
if (capturedPreset.IsCustom)
{
border.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
ShowCustomPresetContextMenu(s as Border, capturedPreset);
};
}
TopicButtonPanel.Children.Add(border);
}
// "기타" 자유 입력 버튼 추가
{
var etcColor = BrushFromHex("#6B7280"); // 회색
var etcBorder = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 12, 14, 12),
Margin = new Thickness(4, 4, 4, 8),
Cursor = Cursors.Hand,
Width = 120,
Height = 105,
ClipToBounds = true,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
var etcStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
var etcIconCircle = new Border
{
Width = 40, Height = 40,
CornerRadius = new CornerRadius(20),
Background = new SolidColorBrush(((SolidColorBrush)etcColor).Color) { Opacity = 0.15 },
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 10),
};
etcIconCircle.Child = new TextBlock
{
Text = "\uE70F", // Edit 아이콘
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = etcColor,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
etcStack.Children.Add(etcIconCircle);
etcStack.Children.Add(new TextBlock
{
Text = "기타",
FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
});
etcStack.Children.Add(new TextBlock
{
Text = "프리셋 없이 자유롭게 대화합니다",
FontSize = 9, TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxHeight = 28,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 2, 0, 0),
TextAlignment = TextAlignment.Center,
});
etcBorder.Child = etcStack;
var hoverBg2 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var normalBg2 = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
etcBorder.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg2; }
};
etcBorder.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = normalBg2; }
};
etcBorder.MouseLeftButtonDown += (_, _) =>
{
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
};
TopicButtonPanel.Children.Add(etcBorder);
}
// ── "+" 커스텀 프리셋 추가 버튼 ──
{
var addColor = BrushFromHex("#6366F1");
var addBorder = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 12, 14, 12),
Margin = new Thickness(4, 4, 4, 8),
Cursor = Cursors.Hand,
Width = 120, Height = 105,
ClipToBounds = true,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1.5),
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
// 점선 효과를 위한 Dashes
if (addBorder.BorderBrush is SolidColorBrush scb)
{
var dashPen = new Pen(scb, 1.5) { DashStyle = DashStyles.Dash };
}
var addStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
// + 아이콘
var plusIcon = new TextBlock
{
Text = "\uE710",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 24,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 8, 0, 8),
};
addStack.Children.Add(plusIcon);
addStack.Children.Add(new TextBlock
{
Text = "프리셋 추가",
FontSize = 12,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
});
addBorder.Child = addStack;
var hoverBg3 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
addBorder.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg3; }
};
addBorder.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = Brushes.Transparent; }
};
addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog();
TopicButtonPanel.Children.Add(addBorder);
}
}
// ─── 커스텀 프리셋 관리 ─────────────────────────────────────────────
/// <summary>커스텀 프리셋 추가 다이얼로그를 표시합니다.</summary>
private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null)
{
bool isEdit = existing != null;
var dlg = new CustomPresetDialog(
existingName: existing?.Label ?? "",
existingDesc: existing?.Description ?? "",
existingPrompt: existing?.SystemPrompt ?? "",
existingColor: existing?.Color ?? "#6366F1",
existingSymbol: existing?.Symbol ?? "\uE713",
existingTab: existing?.Tab ?? _activeTab)
{
Owner = this,
};
if (dlg.ShowDialog() == true)
{
if (isEdit)
{
existing!.Label = dlg.PresetName;
existing.Description = dlg.PresetDescription;
existing.SystemPrompt = dlg.PresetSystemPrompt;
existing.Color = dlg.PresetColor;
existing.Symbol = dlg.PresetSymbol;
existing.Tab = dlg.PresetTab;
}
else
{
_settings.Settings.Llm.CustomPresets.Add(new Models.CustomPresetEntry
{
Label = dlg.PresetName,
Description = dlg.PresetDescription,
SystemPrompt = dlg.PresetSystemPrompt,
Color = dlg.PresetColor,
Symbol = dlg.PresetSymbol,
Tab = dlg.PresetTab,
});
}
_settings.Save();
BuildTopicButtons();
}
}
/// <summary>커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset)
{
if (anchor == null || preset.CustomId == null) return;
var popup = new System.Windows.Controls.Primitives.Popup
{
PlacementTarget = anchor,
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
StaysOpen = false,
AllowsTransparency = true,
};
var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var menuBorder = new Border
{
Background = menuBg,
CornerRadius = new CornerRadius(10),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(4),
MinWidth = 120,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
// 편집 버튼
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
editItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var entry = _settings.Settings.Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId);
if (entry != null) ShowCustomPresetDialog(entry);
};
stack.Children.Add(editItem);
// 삭제 버튼
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
deleteItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var result = CustomMessageBox.Show(
$"'{preset.Label}' 프리셋을 삭제하시겠습니까?",
"프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
{
_settings.Settings.Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId);
_settings.Save();
BuildTopicButtons();
}
};
stack.Children.Add(deleteItem);
menuBorder.Child = stack;
popup.Child = menuBorder;
popup.IsOpen = true;
}
/// <summary>컨텍스트 메뉴 항목을 생성합니다.</summary>
private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg)
{
var item = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 6, 14, 6),
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = fg,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 13, Foreground = fg,
VerticalAlignment = VerticalAlignment.Center,
});
item.Child = sp;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
return item;
}
/// <summary>대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.</summary>
private void SelectTopic(Services.TopicPreset preset)
{
bool hasMessages;
lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0;
// 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존)
bool hasInput = !string.IsNullOrEmpty(InputBox.Text);
bool keepConversation = hasMessages || hasInput;
if (!keepConversation)
{
// 메시지도 입력 텍스트도 없으면 새 대화 시작
StartNewConversation();
}
// 프리셋 적용 (기존 대화에도 프리셋 변경 가능)
lock (_convLock)
{
if (_currentConversation != null)
{
_currentConversation.SystemCommand = preset.SystemPrompt;
_currentConversation.Category = preset.Category;
}
}
if (!keepConversation)
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
if (!string.IsNullOrEmpty(preset.Placeholder))
{
_promptCardPlaceholder = preset.Placeholder;
if (!keepConversation) ShowPlaceholder();
}
if (keepConversation)
ShowToast($"프리셋 변경: {preset.Label}");
// Cowork 탭: 하단 바 갱신
if (_activeTab == "Cowork")
BuildBottomBar();
}
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
/// <summary>하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).</summary>
private void BuildBottomBar()
{
MoodIconPanel.Children.Clear();
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
// ── 포맷 버튼 ──
var currentFormat = _settings.Settings.Llm.DefaultOutputFormat ?? "auto";
var formatLabel = GetFormatLabel(currentFormat);
var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6");
formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); };
// Name 등록 (Popup PlacementTarget용)
try { RegisterName("BtnFormatMenu", formatBtn); } catch { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch { } }
MoodIconPanel.Children.Add(formatBtn);
// 구분선
MoodIconPanel.Children.Add(new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
});
// ── 디자인 버튼 (소극 스타일) ──
var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood);
var moodLabel = currentMood?.Label ?? "모던";
var moodIcon = currentMood?.Icon ?? "🔷";
var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택");
moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); };
try { RegisterName("BtnMoodMenu", moodBtn); } catch { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch { } }
MoodIconPanel.Children.Add(moodBtn);
// 구분선
MoodIconPanel.Children.Add(new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
});
// ── 파일 탐색기 토글 버튼 ──
var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
MoodIconPanel.Children.Add(fileBrowserBtn);
// ── 실행 이력 상세도 버튼 ──
AppendLogLevelButton();
// 구분선 표시
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
}
/// <summary>Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.</summary>
private void BuildCodeBottomBar()
{
MoodIconPanel.Children.Clear();
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
// 개발 언어 선택 버튼
var langLabel = _selectedLanguage switch
{
"python" => "🐍 Python",
"java" => "☕ Java",
"csharp" => "🔷 C#",
"cpp" => "⚙ C++",
"javascript" => "🌐 JavaScript",
_ => "🔧 자동 감지",
};
var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택");
langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); };
try { RegisterName("BtnLangMenu", langBtn); } catch { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch { } }
MoodIconPanel.Children.Add(langBtn);
// 구분선
MoodIconPanel.Children.Add(new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
});
// 파일 탐색기 토글
var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
MoodIconPanel.Children.Add(fileBrowserBtn);
// ── 실행 이력 상세도 버튼 ──
AppendLogLevelButton();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
}
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
private void AppendLogLevelButton()
{
// 구분선
MoodIconPanel.Children.Add(new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
});
var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "simple";
var levelLabel = currentLevel switch
{
"debug" => "디버그",
"detailed" => "상세",
_ => "간략",
};
var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669");
logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); };
try { RegisterName("BtnLogLevelMenu", logBtn); } catch { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch { } }
MoodIconPanel.Children.Add(logBtn);
}
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
private void ShowLogLevelMenu()
{
FormatMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var levels = new (string Key, string Label, string Desc)[]
{
("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"),
("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"),
("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"),
};
var current = _settings.Settings.Llm.AgentLogLevel ?? "simple";
foreach (var (key, label, desc) in levels)
{
var isActive = current == key;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 13,
Foreground = isActive ? accentBrush : primaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = desc,
FontSize = 10,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var item = new Border
{
Child = sp,
Padding = new Thickness(12, 8, 12, 8),
CornerRadius = new CornerRadius(6),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
};
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg;
item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent;
item.MouseLeftButtonUp += (_, _) =>
{
_settings.Settings.Llm.AgentLogLevel = key;
_settings.Save();
FormatMenuPopup.IsOpen = false;
if (_activeTab == "Cowork") BuildBottomBar();
else if (_activeTab == "Code") BuildCodeBottomBar();
};
FormatMenuItems.Children.Add(item);
}
try
{
var target = FindName("BtnLogLevelMenu") as UIElement;
if (target != null) FormatMenuPopup.PlacementTarget = target;
}
catch { }
FormatMenuPopup.IsOpen = true;
}
private void ShowLanguageMenu()
{
FormatMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var languages = new (string Key, string Label, string Icon)[]
{
("auto", "자동 감지", "🔧"),
("python", "Python", "🐍"),
("java", "Java", "☕"),
("csharp", "C# (.NET)", "🔷"),
("cpp", "C/C++", "⚙"),
("javascript", "JavaScript / Vue", "🌐"),
};
foreach (var (key, label, icon) in languages)
{
var isActive = _selectedLanguage == key;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal });
var itemBorder = new Border
{
Child = sp, Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand,
Padding = new Thickness(8, 7, 12, 7),
};
ApplyMenuItemHover(itemBorder);
var capturedKey = key;
itemBorder.MouseLeftButtonUp += (_, _) =>
{
FormatMenuPopup.IsOpen = false;
_selectedLanguage = capturedKey;
BuildCodeBottomBar();
};
FormatMenuItems.Children.Add(itemBorder);
}
if (FindName("BtnLangMenu") is UIElement langTarget)
FormatMenuPopup.PlacementTarget = langTarget;
FormatMenuPopup.IsOpen = true;
}
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
if (mdlIcon != null)
{
sp.Children.Add(new TextBlock
{
Text = mdlIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
}
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 12,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
return new Border
{
Child = sp,
Background = Brushes.Transparent,
Padding = new Thickness(6, 4, 6, 4),
Cursor = Cursors.Hand,
ToolTip = tooltip,
};
}
private static string GetFormatLabel(string key) => key switch
{
"xlsx" => "Excel",
"html" => "HTML 보고서",
"docx" => "Word",
"md" => "Markdown",
"csv" => "CSV",
_ => "AI 자동",
};
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
private (string Name, string Symbol, string Color) GetAgentIdentity()
{
string? category = null;
lock (_convLock)
{
category = _currentConversation?.Category;
}
return category switch
{
// Cowork 프리셋 카테고리
"보고서" => ("보고서 에이전트", "◆", "#3B82F6"),
"데이터" => ("데이터 분석 에이전트", "◆", "#10B981"),
"문서" => ("문서 작성 에이전트", "◆", "#6366F1"),
"논문" => ("논문 분석 에이전트", "◆", "#6366F1"),
"파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"),
"자동화" => ("자동화 에이전트", "◆", "#EF4444"),
// Code 프리셋 카테고리
"코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"),
"리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"),
"코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"),
"보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"),
"테스트" => ("테스트 에이전트", "◆", "#F59E0B"),
// Chat 카테고리
"연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"),
"시스템" => ("시스템 에이전트", "◆", "#64748B"),
"수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"),
"제품분석" => ("제품분석 에이전트", "◆", "#EC4899"),
"경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"),
"인사" => ("인사 관리 에이전트", "◆", "#14B8A6"),
"제조기술" => ("제조기술 에이전트", "◆", "#F97316"),
"재무" => ("재무 분석 에이전트", "◆", "#6366F1"),
_ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"),
_ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"),
_ => ("AX 에이전트", "◆", "#4B5EFC"),
};
}
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
private void ShowFormatMenu()
{
FormatMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var currentFormat = _settings.Settings.Llm.DefaultOutputFormat ?? "auto";
var formats = new (string Key, string Label, string Icon, string Color)[]
{
("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"),
("xlsx", "Excel", "\uE9F9", "#217346"),
("html", "HTML 보고서", "\uE12B", "#E44D26"),
("docx", "Word", "\uE8A5", "#2B579A"),
("md", "Markdown", "\uE943", "#6B7280"),
("csv", "CSV", "\uE9D9", "#10B981"),
};
foreach (var (key, label, icon, color) in formats)
{
var isActive = key == currentFormat;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
// 커스텀 체크 아이콘
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = BrushFromHex(color),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 13,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var itemBorder = new Border
{
Child = sp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 7, 12, 7),
};
ApplyMenuItemHover(itemBorder);
var capturedKey = key;
itemBorder.MouseLeftButtonUp += (_, _) =>
{
FormatMenuPopup.IsOpen = false;
_settings.Settings.Llm.DefaultOutputFormat = capturedKey;
_settings.Save();
BuildBottomBar();
};
FormatMenuItems.Children.Add(itemBorder);
}
// PlacementTarget을 동적 등록된 버튼으로 설정
if (FindName("BtnFormatMenu") is UIElement formatTarget)
FormatMenuPopup.PlacementTarget = formatTarget;
FormatMenuPopup.IsOpen = true;
}
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
private void ShowMoodMenu()
{
MoodMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
// 2열 갤러리 그리드
var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 };
foreach (var mood in TemplateService.AllMoods)
{
var isActive = _selectedMood == mood.Key;
var isCustom = _settings.Settings.Llm.CustomMoods.Any(cm => cm.Key == mood.Key);
var colors = TemplateService.GetMoodColors(mood.Key);
// 미니 프리뷰 카드
var previewCard = new Border
{
Width = 160, Height = 80,
CornerRadius = new CornerRadius(6),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Background)),
BorderBrush = isActive ? accentBrush : new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)),
BorderThickness = new Thickness(isActive ? 2 : 1),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(2),
};
var previewContent = new StackPanel();
// 헤딩 라인
previewContent.Children.Add(new Border
{
Width = 60, Height = 6, CornerRadius = new CornerRadius(2),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.PrimaryText)),
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 0, 0, 4),
});
// 악센트 라인
previewContent.Children.Add(new Border
{
Width = 40, Height = 3, CornerRadius = new CornerRadius(1),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Accent)),
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 0, 0, 6),
});
// 텍스트 라인들
for (int i = 0; i < 3; i++)
{
previewContent.Children.Add(new Border
{
Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.SecondaryText)) { Opacity = 0.5 },
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 0, 0, 3),
});
}
// 미니 카드 영역
var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
for (int i = 0; i < 2; i++)
{
cardRow.Children.Add(new Border
{
Width = 28, Height = 14, CornerRadius = new CornerRadius(2),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.CardBg)),
BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)),
BorderThickness = new Thickness(0.5),
Margin = new Thickness(0, 0, 4, 0),
});
}
previewContent.Children.Add(cardRow);
previewCard.Child = previewContent;
// 무드 라벨
var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) };
var labelRow = new StackPanel { Orientation = Orientation.Horizontal };
labelRow.Children.Add(new TextBlock
{
Text = mood.Icon, FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
labelRow.Children.Add(new TextBlock
{
Text = mood.Label, FontSize = 11.5,
Foreground = primaryText,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
VerticalAlignment = VerticalAlignment.Center,
});
if (isActive)
{
labelRow.Children.Add(new TextBlock
{
Text = " ✓", FontSize = 11,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
});
}
labelPanel.Children.Add(labelRow);
// 전체 카드 래퍼
var cardWrapper = new Border
{
CornerRadius = new CornerRadius(8),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Padding = new Thickness(4),
Margin = new Thickness(2),
};
var wrapperContent = new StackPanel();
wrapperContent.Children.Add(previewCard);
wrapperContent.Children.Add(labelPanel);
cardWrapper.Child = wrapperContent;
// 호버
cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
var capturedMood = mood;
cardWrapper.MouseLeftButtonUp += (_, _) =>
{
MoodMenuPopup.IsOpen = false;
_selectedMood = capturedMood.Key;
_settings.Settings.Llm.DefaultMood = capturedMood.Key;
_settings.Save();
BuildBottomBar();
};
// 커스텀 무드: 우클릭
if (isCustom)
{
cardWrapper.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
MoodMenuPopup.IsOpen = false;
ShowCustomMoodContextMenu(s as Border, capturedMood.Key);
};
}
grid.Children.Add(cardWrapper);
}
MoodMenuItems.Children.Add(grid);
// ── 구분선 + 추가 버튼 ──
MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle
{
Height = 1,
Fill = borderBrush,
Margin = new Thickness(8, 4, 8, 4),
Opacity = 0.4,
});
var addSp = new StackPanel { Orientation = Orientation.Horizontal };
addSp.Children.Add(new TextBlock
{
Text = "\uE710",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 8, 0),
});
addSp.Children.Add(new TextBlock
{
Text = "커스텀 무드 추가",
FontSize = 13,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var addBorder = new Border
{
Child = addSp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 6, 12, 6),
};
ApplyMenuItemHover(addBorder);
addBorder.MouseLeftButtonUp += (_, _) =>
{
MoodMenuPopup.IsOpen = false;
ShowCustomMoodDialog();
};
MoodMenuItems.Children.Add(addBorder);
if (FindName("BtnMoodMenu") is UIElement moodTarget)
MoodMenuPopup.PlacementTarget = moodTarget;
MoodMenuPopup.IsOpen = true;
}
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null)
{
bool isEdit = existing != null;
var dlg = new CustomMoodDialog(
existingKey: existing?.Key ?? "",
existingLabel: existing?.Label ?? "",
existingIcon: existing?.Icon ?? "🎯",
existingDesc: existing?.Description ?? "",
existingCss: existing?.Css ?? "")
{
Owner = this,
};
if (dlg.ShowDialog() == true)
{
if (isEdit)
{
existing!.Label = dlg.MoodLabel;
existing.Icon = dlg.MoodIcon;
existing.Description = dlg.MoodDescription;
existing.Css = dlg.MoodCss;
}
else
{
_settings.Settings.Llm.CustomMoods.Add(new Models.CustomMoodEntry
{
Key = dlg.MoodKey,
Label = dlg.MoodLabel,
Icon = dlg.MoodIcon,
Description = dlg.MoodDescription,
Css = dlg.MoodCss,
});
}
_settings.Save();
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildBottomBar();
}
}
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
private void ShowCustomMoodContextMenu(Border? anchor, string moodKey)
{
if (anchor == null) return;
var popup = new System.Windows.Controls.Primitives.Popup
{
PlacementTarget = anchor,
Placement = System.Windows.Controls.Primitives.PlacementMode.Right,
StaysOpen = false, AllowsTransparency = true,
};
var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var menuBorder = new Border
{
Background = menuBg,
CornerRadius = new CornerRadius(10),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(4),
MinWidth = 120,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
editItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var entry = _settings.Settings.Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey);
if (entry != null) ShowCustomMoodDialog(entry);
};
stack.Children.Add(editItem);
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
deleteItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var result = CustomMessageBox.Show(
$"이 디자인 무드를 삭제하시겠습니까?",
"무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
{
_settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey);
if (_selectedMood == moodKey) _selectedMood = "modern";
_settings.Save();
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildBottomBar();
}
};
stack.Children.Add(deleteItem);
menuBorder.Child = stack;
popup.Child = menuBorder;
popup.IsOpen = true;
}
private string? _promptCardPlaceholder;
private void ShowPlaceholder()
{
if (string.IsNullOrEmpty(_promptCardPlaceholder)) return;
InputWatermark.Text = _promptCardPlaceholder;
InputWatermark.Visibility = Visibility.Visible;
InputBox.Text = "";
InputBox.Focus();
}
private void UpdateWatermarkVisibility()
{
// 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지)
if (_activeSlashCmd != null)
{
InputWatermark.Visibility = Visibility.Collapsed;
return;
}
if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text))
InputWatermark.Visibility = Visibility.Visible;
else
InputWatermark.Visibility = Visibility.Collapsed;
}
private void ClearPromptCardPlaceholder()
{
_promptCardPlaceholder = null;
InputWatermark.Visibility = Visibility.Collapsed;
}
private void BtnSettings_Click(object sender, RoutedEventArgs e)
{
if (System.Windows.Application.Current is App app)
app.OpenSettingsFromChat();
}
// ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────
private void BtnTemplateSelector_Click(object sender, RoutedEventArgs e)
{
var templates = _settings.Settings.Llm.PromptTemplates;
TemplateItems.Items.Clear();
if (templates == null || templates.Count == 0)
{
TemplateEmptyHint.Visibility = Visibility.Visible;
TemplatePopup.IsOpen = true;
return;
}
TemplateEmptyHint.Visibility = Visibility.Collapsed;
foreach (var tpl in templates)
{
var item = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(2),
Cursor = System.Windows.Input.Cursors.Hand,
Tag = tpl.Content,
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = tpl.Name,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = (Brush)FindResource("PrimaryText"),
});
var preview = tpl.Content.Length > 60 ? tpl.Content[..60] + "…" : tpl.Content;
stack.Children.Add(new TextBlock
{
Text = preview,
FontSize = 11,
Foreground = (Brush)FindResource("SecondaryText"),
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(0, 2, 0, 0),
});
item.Child = stack;
item.MouseEnter += (s, _) =>
{
if (s is Border b) b.Background = (Brush)FindResource("ItemBackground");
};
item.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = Brushes.Transparent;
};
item.MouseLeftButtonUp += (s, _) =>
{
if (s is Border b && b.Tag is string content)
{
InputBox.Text = content;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
TemplatePopup.IsOpen = false;
}
};
TemplateItems.Items.Add(item);
}
TemplatePopup.IsOpen = true;
}
// ─── 모델 전환 ──────────────────────────────────────────────────────
// Gemini/Claude 사전 정의 모델 목록
private static readonly (string Id, string Label)[] GeminiModels =
{
("gemini-2.5-pro", "Gemini 2.5 Pro"),
("gemini-2.5-flash", "Gemini 2.5 Flash"),
("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
("gemini-2.0-flash", "Gemini 2.0 Flash"),
("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite"),
};
private static readonly (string Id, string Label)[] ClaudeModels =
{
("claude-opus-4-6", "Claude Opus 4.6"),
("claude-sonnet-4-6", "Claude Sonnet 4.6"),
("claude-haiku-4-5-20251001", "Claude Haiku 4.5"),
("claude-sonnet-4-5-20250929", "Claude Sonnet 4.5"),
("claude-opus-4-20250514", "Claude Opus 4"),
};
/// <summary>현재 선택된 모델의 표시명을 반환합니다.</summary>
private string GetCurrentModelDisplayName()
{
var llm = _settings.Settings.Llm;
var service = llm.Service.ToLowerInvariant();
if (service is "ollama" or "vllm")
{
// 등록 모델에서 별칭 찾기
var registered = llm.RegisteredModels
.FirstOrDefault(rm => rm.EncryptedModelName == llm.Model);
if (registered != null) return registered.Alias;
return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : "••••";
}
if (service == "gemini")
{
var m = GeminiModels.FirstOrDefault(g => g.Id == llm.Model);
return m.Label ?? llm.Model;
}
if (service == "claude")
{
var m = ClaudeModels.FirstOrDefault(c => c.Id == llm.Model);
return m.Label ?? llm.Model;
}
return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : llm.Model;
}
private void UpdateModelLabel()
{
var service = _settings.Settings.Llm.Service.ToLowerInvariant();
var serviceLabel = service switch
{
"gemini" => "Gemini",
"claude" => "Claude",
"vllm" => "vLLM",
_ => "Ollama",
};
ModelLabel.Text = $"{serviceLabel} · {GetCurrentModelDisplayName()}";
}
private void BtnModelSelector_Click(object sender, RoutedEventArgs e)
{
var llm = _settings.Settings.Llm;
// 팝업 열기 전 원본 서비스/모델 백업 — 모델 미선택 후 닫으면 롤백
var originalService = llm.Service;
var originalModel = llm.Model;
var modelConfirmed = false;
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var checkColor = new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69));
var activeSvcBg = new SolidColorBrush(Color.FromArgb(0x18, 0x4B, 0x5E, 0xFC));
var popup = new Popup
{
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
PlacementTarget = BtnModelSelector,
Placement = PlacementMode.Top,
};
var container = new Border
{
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
MinWidth = 240,
MaxHeight = 460,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
},
};
var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, MaxHeight = 440 };
var stack = new StackPanel();
// 모델 목록을 담을 패널 (서비스 변경 시 동적 재구성)
var modelSection = new StackPanel();
Border CreateMenuItem(string text, bool isChecked, Action onClick, bool closeOnClick = true)
{
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(1, GridUnitType.Star) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
var textTb = new TextBlock
{
Text = text, FontSize = 13, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(textTb, 0);
g.Children.Add(textTb);
if (isChecked)
{
var checkIcon = new Canvas { Width = 14, Height = 14, VerticalAlignment = VerticalAlignment.Center };
checkIcon.Children.Add(new System.Windows.Shapes.Path
{
Data = Geometry.Parse("M 2 7 L 5.5 10.5 L 12 4"),
Stroke = checkColor, StrokeThickness = 2,
StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round,
});
Grid.SetColumn(checkIcon, 1);
g.Children.Add(checkIcon);
}
item.Child = g;
// 호버 시 배경색 + 살짝 확대
item.RenderTransformOrigin = new Point(0.5, 0.5);
item.RenderTransform = new ScaleTransform(1, 1);
item.MouseEnter += (s, _) =>
{
if (s is Border b) b.Background = hoverBg;
var st = item.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)));
};
item.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = Brushes.Transparent;
var st = item.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)));
};
item.MouseLeftButtonUp += (_, _) =>
{
if (closeOnClick) popup.IsOpen = false;
onClick();
};
return item;
}
Border CreateSeparator() => new()
{
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
};
// 모델 목록 빌드 함수 (서비스 변경 시 호출)
void RebuildModelList(string service)
{
modelSection.Children.Clear();
modelSection.Children.Add(new TextBlock
{
Text = "모델", FontSize = 11, Foreground = secondaryText,
Margin = new Thickness(10, 4, 0, 4), FontWeight = FontWeights.SemiBold,
});
if (service is "ollama" or "vllm")
{
var registered = llm.RegisteredModels.Where(rm => rm.Service == service).ToList();
if (registered.Count == 0)
{
modelSection.Children.Add(new TextBlock
{
Text = "등록된 모델 없음 — 설정에서 추가",
FontSize = 11.5, Foreground = secondaryText, FontStyle = FontStyles.Italic,
Margin = new Thickness(10, 4, 10, 4),
});
}
else
{
foreach (var rm in registered)
{
var capturedEnc = rm.EncryptedModelName;
modelSection.Children.Add(CreateMenuItem(rm.Alias, llm.Model == rm.EncryptedModelName, () =>
{
_settings.Settings.Llm.Model = capturedEnc;
_settings.Save();
modelConfirmed = true;
UpdateModelLabel();
}));
}
}
}
else if (service == "gemini")
{
foreach (var (id, label) in GeminiModels)
{
var capturedId = id;
modelSection.Children.Add(CreateMenuItem(label, llm.Model == id, () =>
{
_settings.Settings.Llm.Model = capturedId;
_settings.Save();
modelConfirmed = true;
UpdateModelLabel();
}));
}
}
else if (service == "claude")
{
foreach (var (id, label) in ClaudeModels)
{
var capturedId = id;
modelSection.Children.Add(CreateMenuItem(label, llm.Model == id, () =>
{
_settings.Settings.Llm.Model = capturedId;
_settings.Save();
modelConfirmed = true;
UpdateModelLabel();
}));
}
}
}
// 서비스 헤더
stack.Children.Add(new TextBlock
{
Text = "서비스", FontSize = 11, Foreground = secondaryText,
Margin = new Thickness(10, 4, 0, 4), FontWeight = FontWeights.SemiBold,
});
// 서비스 버튼들을 담을 패널 (체크마크 갱신용)
var serviceItems = new StackPanel();
var services = new[] { ("ollama", "Ollama"), ("vllm", "vLLM"), ("gemini", "Gemini"), ("claude", "Claude") };
void BuildServiceItems()
{
serviceItems.Children.Clear();
var currentSvc = llm.Service.ToLowerInvariant();
foreach (var (svc, label) in services)
{
var capturedSvc = svc;
serviceItems.Children.Add(CreateMenuItem(label, currentSvc == svc, () =>
{
// 서비스만 임시 반영 — Save는 모델 선택 시에만 호출
_settings.Settings.Llm.Service = capturedSvc;
UpdateModelLabel();
// 서비스 변경 후 모델 목록 + 서비스 체크마크 갱신 (팝업은 열린 채로)
BuildServiceItems();
RebuildModelList(capturedSvc);
}, closeOnClick: false));
}
}
BuildServiceItems();
stack.Children.Add(serviceItems);
stack.Children.Add(CreateSeparator());
// 모델 목록 초기 빌드
RebuildModelList(llm.Service.ToLowerInvariant());
stack.Children.Add(modelSection);
// 팝업이 닫힐 때 모델 미선택이면 서비스/모델 원래대로 롤백
popup.Closed += (_, _) =>
{
if (!modelConfirmed)
{
_settings.Settings.Llm.Service = originalService;
_settings.Settings.Llm.Model = originalModel;
UpdateModelLabel();
}
};
scroll.Content = stack;
container.Child = scroll;
popup.Child = container;
popup.IsOpen = true;
}
private void BtnNewChat_Click(object sender, RoutedEventArgs e)
{
StartNewConversation();
InputBox.Focus();
}
public void ResumeConversation(string conversationId)
{
var conv = _storage.Load(conversationId);
if (conv != null)
{
lock (_convLock) _currentConversation = conv;
UpdateChatTitle();
RefreshConversationList();
RenderMessages();
UpdateFolderBar();
}
InputBox.Focus();
}
public void StartNewAndFocus()
{
StartNewConversation();
InputBox.Focus();
}
private void BtnDeleteAll_Click(object sender, RoutedEventArgs e)
{
var result = CustomMessageBox.Show(
"저장된 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
"대화 전체 삭제",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
_storage.DeleteAll();
lock (_convLock) _currentConversation = null;
MessagePanel.Children.Clear();
EmptyState.Visibility = Visibility.Visible;
UpdateChatTitle();
RefreshConversationList();
}
// ─── 미리보기 패널 (탭 기반) ─────────────────────────────────────────────
private static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
};
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
private readonly List<string> _previewTabs = new();
private string? _activePreviewTab;
private void TryShowPreview(string filePath)
{
if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath))
return;
// 별도 커스텀 창으로 미리보기 (WebView2 HWND airspace 문제 근본 해결)
PreviewWindow.ShowPreview(filePath, _selectedMood);
}
private void ShowPreviewPanel(string filePath)
{
// 탭에 없으면 추가
if (!_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
_previewTabs.Add(filePath);
_activePreviewTab = filePath;
// 패널 열기
if (PreviewColumn.Width.Value < 100)
{
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
}
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
BtnPreviewToggle.Visibility = Visibility.Visible;
RebuildPreviewTabs();
LoadPreviewContent(filePath);
}
/// <summary>탭 바 UI를 다시 구성합니다.</summary>
private void RebuildPreviewTabs()
{
PreviewTabPanel.Children.Clear();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
foreach (var tabPath in _previewTabs)
{
var fileName = System.IO.Path.GetFileName(tabPath);
var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase);
var tabBorder = new Border
{
Background = isActive
? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF))
: Brushes.Transparent,
BorderBrush = isActive ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
Padding = new Thickness(8, 6, 4, 6),
Cursor = Cursors.Hand,
MaxWidth = _previewTabs.Count <= 3 ? 200 : (_previewTabs.Count <= 5 ? 140 : 100),
};
var tabContent = new StackPanel { Orientation = Orientation.Horizontal };
// 파일명
tabContent.Children.Add(new TextBlock
{
Text = fileName,
FontSize = 11,
Foreground = isActive ? primaryText : secondaryText,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = tabBorder.MaxWidth - 30,
ToolTip = tabPath,
});
// 닫기 버튼 (x) — 활성 탭은 항상 표시, 비활성 탭은 호버 시에만 표시
var closeFg = isActive ? primaryText : secondaryText;
var closeBtnText = new TextBlock
{
Text = "\uE711",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = closeFg,
VerticalAlignment = VerticalAlignment.Center,
};
var closeBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(3),
Padding = new Thickness(3, 2, 3, 2),
Margin = new Thickness(5, 0, 0, 0),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
// 비활성 탭은 초기에 숨김, 활성 탭은 항상 표시
Visibility = isActive ? Visibility.Visible : Visibility.Hidden,
Child = closeBtnText,
};
var closePath = tabPath;
closeBtn.MouseEnter += (s, _) =>
{
if (s is Border b)
{
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
if (b.Child is TextBlock tb) tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
}
};
closeBtn.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Background = Brushes.Transparent;
if (b.Child is TextBlock tb) tb.Foreground = closeFg;
}
};
closeBtn.Tag = "close"; // 닫기 버튼 식별용
closeBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true; // 부모 탭 클릭 이벤트 차단
ClosePreviewTab(closePath);
};
tabContent.Children.Add(closeBtn);
tabBorder.Child = tabContent;
// 탭 클릭 → 활성화 (MouseLeftButtonUp 사용: 닫기 버튼의 PreviewMouseLeftButtonDown보다 늦게 실행되어 충돌 방지)
var clickPath = tabPath;
tabBorder.MouseLeftButtonUp += (_, e) =>
{
if (e.Handled) return;
e.Handled = true;
_activePreviewTab = clickPath;
RebuildPreviewTabs();
LoadPreviewContent(clickPath);
};
// 우클릭 → 컨텍스트 메뉴
var ctxPath = tabPath;
tabBorder.MouseRightButtonUp += (_, e) =>
{
e.Handled = true;
ShowPreviewTabContextMenu(ctxPath);
};
// 더블클릭 → 별도 창에서 보기
var dblPath = tabPath;
tabBorder.MouseLeftButtonDown += (_, e) =>
{
if (e.Handled) return;
if (e.ClickCount == 2)
{
e.Handled = true;
OpenPreviewPopupWindow(dblPath);
}
};
// 호버 효과 — 비활성 탭에서 배경 강조 + 닫기 버튼 표시
var capturedIsActive = isActive;
var capturedCloseBtn = closeBtn;
tabBorder.MouseEnter += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
// 비활성 탭도 호버 시 닫기 버튼 표시
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Visible;
};
tabBorder.MouseLeave += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = Brushes.Transparent;
// 비활성 탭 호버 해제 시 닫기 버튼 숨김
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Hidden;
};
PreviewTabPanel.Children.Add(tabBorder);
// 탭 사이 구분선
if (tabPath != _previewTabs[^1])
{
PreviewTabPanel.Children.Add(new Border
{
Width = 1, Height = 14,
Background = borderBrush,
Margin = new Thickness(0, 4, 0, 4),
VerticalAlignment = VerticalAlignment.Center,
});
}
}
}
private void ClosePreviewTab(string filePath)
{
_previewTabs.Remove(filePath);
if (_previewTabs.Count == 0)
{
HidePreviewPanel();
return;
}
// 닫힌 탭이 활성 탭이면 마지막 탭으로 전환
if (string.Equals(filePath, _activePreviewTab, StringComparison.OrdinalIgnoreCase))
{
_activePreviewTab = _previewTabs[^1];
LoadPreviewContent(_activePreviewTab);
}
RebuildPreviewTabs();
}
private async void LoadPreviewContent(string filePath)
{
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
// 모든 콘텐츠 숨기기
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
PreviewEmpty.Visibility = Visibility.Collapsed;
if (!System.IO.File.Exists(filePath))
{
PreviewEmpty.Text = "파일을 찾을 수 없습니다";
PreviewEmpty.Visibility = Visibility.Visible;
return;
}
try
{
switch (ext)
{
case ".html":
case ".htm":
await EnsureWebViewInitializedAsync();
PreviewWebView.Source = new Uri(filePath);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".csv":
LoadCsvPreview(filePath);
PreviewDataGrid.Visibility = Visibility.Visible;
break;
case ".md":
await EnsureWebViewInitializedAsync();
var mdText = System.IO.File.ReadAllText(filePath);
if (mdText.Length > 50000) mdText = mdText[..50000];
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood);
PreviewWebView.NavigateToString(mdHtml);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".txt":
case ".json":
case ".xml":
case ".log":
var text = System.IO.File.ReadAllText(filePath);
if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)";
PreviewTextBlock.Text = text;
PreviewTextScroll.Visibility = Visibility.Visible;
break;
default:
PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다";
PreviewEmpty.Visibility = Visibility.Visible;
break;
}
}
catch (Exception ex)
{
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
PreviewTextScroll.Visibility = Visibility.Visible;
}
}
private bool _webViewInitialized;
private static readonly string WebView2DataFolder =
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "WebView2");
private async Task EnsureWebViewInitializedAsync()
{
if (_webViewInitialized) return;
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await PreviewWebView.EnsureCoreWebView2Async(env);
_webViewInitialized = true;
}
catch (Exception ex)
{
Services.LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
}
}
private void LoadCsvPreview(string filePath)
{
try
{
var lines = System.IO.File.ReadAllLines(filePath);
if (lines.Length == 0) return;
var dt = new System.Data.DataTable();
var headers = ParseCsvLine(lines[0]);
foreach (var h in headers)
dt.Columns.Add(h);
var maxRows = Math.Min(lines.Length, 501);
for (int i = 1; i < maxRows; i++)
{
var vals = ParseCsvLine(lines[i]);
var row = dt.NewRow();
for (int j = 0; j < Math.Min(vals.Length, headers.Length); j++)
row[j] = vals[j];
dt.Rows.Add(row);
}
PreviewDataGrid.ItemsSource = dt.DefaultView;
}
catch (Exception ex)
{
PreviewTextBlock.Text = $"CSV 로드 오류: {ex.Message}";
PreviewTextScroll.Visibility = Visibility.Visible;
PreviewDataGrid.Visibility = Visibility.Collapsed;
}
}
private static string[] ParseCsvLine(string line)
{
var fields = new System.Collections.Generic.List<string>();
var current = new System.Text.StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (inQuotes)
{
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
{
current.Append('"');
i++;
}
else if (c == '"')
inQuotes = false;
else
current.Append(c);
}
else
{
if (c == '"')
inQuotes = true;
else if (c == ',')
{
fields.Add(current.ToString());
current.Clear();
}
else
current.Append(c);
}
}
fields.Add(current.ToString());
return fields.ToArray();
}
private void HidePreviewPanel()
{
_previewTabs.Clear();
_activePreviewTab = null;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
try { if (_webViewInitialized) PreviewWebView.CoreWebView2?.NavigateToString("<html></html>"); } catch { }
}
/// <summary>프리뷰 탭 바 클릭 시 WebView2에서 포커스를 회수 (HWND airspace 문제 방지).</summary>
private void PreviewTabBar_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
// WebView2가 포커스를 잡고 있으면 WPF 버튼 클릭이 무시될 수 있으므로 포커스를 강제 이동
if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin)
{
var border = sender as Border;
border?.Focus();
}
}
private void BtnClosePreview_Click(object sender, RoutedEventArgs e)
{
HidePreviewPanel();
BtnPreviewToggle.Visibility = Visibility.Collapsed;
}
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
{
if (PreviewPanel.Visibility == Visibility.Visible)
{
// 숨기기 (탭은 유지)
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
}
else if (_previewTabs.Count > 0)
{
// 다시 열기
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
RebuildPreviewTabs();
if (_activePreviewTab != null) LoadPreviewContent(_activePreviewTab);
}
}
private void BtnOpenExternal_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_activePreviewTab) || !System.IO.File.Exists(_activePreviewTab))
return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _activePreviewTab,
UseShellExecute = true,
});
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
}
}
/// <summary>프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
private Popup? _previewTabPopup;
private void ShowPreviewTabContextMenu(string filePath)
{
// 기존 팝업 닫기
if (_previewTabPopup != null) _previewTabPopup.IsOpen = false;
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var stack = new StackPanel();
void AddItem(string icon, string iconColor, string label, Action action)
{
var itemBorder = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 7, 16, 7),
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor)
? secondaryText
: new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 13, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
itemBorder.Child = sp;
itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
itemBorder.MouseLeftButtonUp += (_, _) =>
{
_previewTabPopup!.IsOpen = false;
action();
};
stack.Children.Add(itemBorder);
}
void AddSeparator()
{
stack.Children.Add(new Border
{
Height = 1,
Background = borderBrush,
Margin = new Thickness(8, 3, 8, 3),
});
}
AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = filePath, UseShellExecute = true,
});
}
catch { }
});
AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); }
catch { }
});
AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath));
AddSeparator();
AddItem("\uE8C8", "", "경로 복사", () =>
{
try { Clipboard.SetText(filePath); } catch { }
});
AddSeparator();
AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath));
if (_previewTabs.Count > 1)
{
AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () =>
{
var keep = filePath;
_previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase));
_activePreviewTab = keep;
RebuildPreviewTabs();
LoadPreviewContent(keep);
});
}
var popupBorder = new Border
{
Background = bg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(4, 6, 4, 6),
MinWidth = 180,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4,
Color = Colors.Black,
},
Child = stack,
};
_previewTabPopup = new Popup
{
Child = popupBorder,
Placement = PlacementMode.MousePoint,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
};
_previewTabPopup.IsOpen = true;
}
/// <summary>프리뷰를 별도 팝업 창에서 엽니다.</summary>
private void OpenPreviewPopupWindow(string filePath)
{
if (!System.IO.File.Exists(filePath)) return;
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
var fileName = System.IO.Path.GetFileName(filePath);
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var win = new Window
{
Title = $"미리보기 — {fileName}",
Width = 900,
Height = 700,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Background = bg,
};
FrameworkElement content;
switch (ext)
{
case ".html":
case ".htm":
var wv = new Microsoft.Web.WebView2.Wpf.WebView2();
wv.Loaded += async (_, _) =>
{
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await wv.EnsureCoreWebView2Async(env);
wv.Source = new Uri(filePath);
}
catch { }
};
content = wv;
break;
case ".md":
var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2();
var mdMood = _selectedMood;
mdWv.Loaded += async (_, _) =>
{
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await mdWv.EnsureCoreWebView2Async(env);
var mdSrc = System.IO.File.ReadAllText(filePath);
if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000];
var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
mdWv.NavigateToString(html);
}
catch { }
};
content = mdWv;
break;
case ".csv":
var dg = new System.Windows.Controls.DataGrid
{
AutoGenerateColumns = true,
IsReadOnly = true,
Background = Brushes.Transparent,
Foreground = Brushes.White,
BorderThickness = new Thickness(0),
FontSize = 12,
};
try
{
var lines = System.IO.File.ReadAllLines(filePath);
if (lines.Length > 0)
{
var dt = new System.Data.DataTable();
var headers = ParseCsvLine(lines[0]);
foreach (var h in headers) dt.Columns.Add(h);
for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
{
var vals = ParseCsvLine(lines[i]);
var row = dt.NewRow();
for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++)
row[j] = vals[j];
dt.Rows.Add(row);
}
dg.ItemsSource = dt.DefaultView;
}
}
catch { }
content = dg;
break;
default:
var text = System.IO.File.ReadAllText(filePath);
if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)";
var sv = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Padding = new Thickness(20),
Content = new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap,
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
Foreground = fg,
},
};
content = sv;
break;
}
win.Content = content;
win.Show();
}
// ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
private DateTime _progressStartTime;
private DispatcherTimer? _progressElapsedTimer;
private void UpdateAgentProgressBar(AgentEvent evt)
{
switch (evt.Type)
{
case AgentEventType.Planning when evt.Steps is { Count: > 0 }:
ShowStickyProgress(evt.Steps.Count);
break;
case AgentEventType.StepStart when evt.StepTotal > 0:
UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary);
break;
case AgentEventType.Complete:
HideStickyProgress();
break;
}
}
private void ShowStickyProgress(int totalSteps)
{
_progressStartTime = DateTime.Now;
AgentProgressBar.Visibility = Visibility.Visible;
ProgressIcon.Text = "\uE768"; // play
ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})";
ProgressPercent.Text = "0%";
ProgressElapsed.Text = "0:00";
ProgressFill.Width = 0;
// 경과 시간 타이머
_progressElapsedTimer?.Stop();
_progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_progressElapsedTimer.Tick += (_, _) =>
{
var elapsed = DateTime.Now - _progressStartTime;
ProgressElapsed.Text = elapsed.TotalHours >= 1
? elapsed.ToString(@"h\:mm\:ss")
: elapsed.ToString(@"m\:ss");
};
_progressElapsedTimer.Start();
}
private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription)
{
if (AgentProgressBar.Visibility != Visibility.Visible) return;
var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0;
ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})";
ProgressPercent.Text = $"{(int)(pct * 100)}%";
// 프로그레스 바 너비 애니메이션
var parentBorder = ProgressFill.Parent as Border;
if (parentBorder != null)
{
var targetWidth = parentBorder.ActualWidth * pct;
var anim = new System.Windows.Media.Animation.DoubleAnimation(
ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase(),
};
ProgressFill.BeginAnimation(WidthProperty, anim);
}
}
private void HideStickyProgress()
{
_progressElapsedTimer?.Stop();
_progressElapsedTimer = null;
if (AgentProgressBar.Visibility != Visibility.Visible) return;
// 완료 표시 후 페이드아웃
ProgressIcon.Text = "\uE930"; // check
ProgressStepLabel.Text = "작업 완료";
ProgressPercent.Text = "100%";
// 프로그레스 바 100%
var parentBorder = ProgressFill.Parent as Border;
if (parentBorder != null)
{
var anim = new System.Windows.Media.Animation.DoubleAnimation(
ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200));
ProgressFill.BeginAnimation(WidthProperty, anim);
}
// 3초 후 숨기기
var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
hideTimer.Tick += (_, _) =>
{
hideTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) =>
{
AgentProgressBar.Visibility = Visibility.Collapsed;
AgentProgressBar.Opacity = 1;
ProgressFill.BeginAnimation(WidthProperty, null);
ProgressFill.Width = 0;
};
AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
hideTimer.Start();
}
// ─── 파일 탐색기 ──────────────────────────────────────────────────────
private static readonly HashSet<string> _ignoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
".cache", ".next", ".nuxt", "coverage", ".terraform",
};
private DispatcherTimer? _fileBrowserRefreshTimer;
private void ToggleFileBrowser()
{
if (FileBrowserPanel.Visibility == Visibility.Visible)
{
FileBrowserPanel.Visibility = Visibility.Collapsed;
_settings.Settings.Llm.ShowFileBrowser = false;
}
else
{
FileBrowserPanel.Visibility = Visibility.Visible;
_settings.Settings.Llm.ShowFileBrowser = true;
BuildFileTree();
}
_settings.Save();
}
private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e)
{
var folder = GetCurrentWorkFolder();
if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch { }
}
private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e)
{
FileBrowserPanel.Visibility = Visibility.Collapsed;
}
private void BuildFileTree()
{
FileTreeView.Items.Clear();
var folder = GetCurrentWorkFolder();
if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder))
{
FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
return;
}
FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}";
var count = 0;
PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
}
private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count)
{
if (depth > 4 || count > 200) return;
// 디렉터리
try
{
foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
{
if (count > 200) break;
if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue;
count++;
var dirItem = new TreeViewItem
{
Header = CreateFileTreeHeader("\uED25", subDir.Name, null),
Tag = subDir.FullName,
IsExpanded = depth < 1,
};
// 지연 로딩: 더미 자식 → 펼칠 때 실제 로드
if (depth < 3)
{
dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미
var capturedDir = subDir;
var capturedDepth = depth;
dirItem.Expanded += (s, _) =>
{
if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
{
ti.Items.Clear();
int c = 0;
PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
}
};
}
else
{
PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count);
}
items.Add(dirItem);
}
}
catch { }
// 파일
try
{
foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
{
if (count > 200) break;
count++;
var ext = file.Extension.ToLowerInvariant();
var icon = GetFileIcon(ext);
var size = FormatFileSize(file.Length);
var fileItem = new TreeViewItem
{
Header = CreateFileTreeHeader(icon, file.Name, size),
Tag = file.FullName,
};
// 더블클릭 → 프리뷰
var capturedPath = file.FullName;
fileItem.MouseDoubleClick += (s, e) =>
{
e.Handled = true;
TryShowPreview(capturedPath);
};
// 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음)
fileItem.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
if (s is TreeViewItem ti) ti.IsSelected = true;
ShowFileTreeContextMenu(capturedPath);
};
items.Add(fileItem);
}
}
catch { }
}
private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 5, 0),
});
sp.Children.Add(new TextBlock
{
Text = name,
FontSize = 11.5,
VerticalAlignment = VerticalAlignment.Center,
});
if (sizeText != null)
{
sp.Children.Add(new TextBlock
{
Text = $" {sizeText}",
FontSize = 10,
Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)),
VerticalAlignment = VerticalAlignment.Center,
});
}
return sp;
}
private static string GetFileIcon(string ext) => ext switch
{
".html" or ".htm" => "\uEB41",
".xlsx" or ".xls" => "\uE9F9",
".docx" or ".doc" => "\uE8A5",
".pdf" => "\uEA90",
".csv" => "\uE80A",
".md" => "\uE70B",
".json" or ".xml" => "\uE943",
".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F",
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943",
".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756",
".txt" or ".log" => "\uE8A5",
_ => "\uE7C3",
};
private static string FormatFileSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
};
private void ShowFileTreeContextMenu(string filePath)
{
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C));
var popup = new Popup
{
StaysOpen = false, AllowsTransparency = true, PopupAnimation = PopupAnimation.Fade,
Placement = PlacementMode.MousePoint,
};
var panel = new StackPanel { Margin = new Thickness(2) };
var container = new Border
{
Background = bg, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10), Padding = new Thickness(6), MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
Color = Colors.Black, Direction = 270,
},
Child = panel,
};
popup.Child = container;
void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = iconColor ?? secondaryText,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 12.5, Foreground = labelColor ?? primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var item = new Border
{
Child = sp, Background = Brushes.Transparent,
CornerRadius = new CornerRadius(7), Cursor = Cursors.Hand,
Padding = new Thickness(10, 8, 14, 8), Margin = new Thickness(0, 1, 0, 1),
};
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; action(); };
panel.Children.Add(item);
}
void AddSep()
{
panel.Children.Add(new Border
{
Height = 1, Margin = new Thickness(10, 4, 10, 4),
Background = borderBrush, Opacity = 0.3,
});
}
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
if (_previewableExtensions.Contains(ext))
AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch { }
});
AddItem("\uED25", "폴더에서 보기", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch { }
});
AddItem("\uE8C8", "경로 복사", () =>
{
try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch { }
});
AddSep();
// 이름 변경
AddItem("\uE8AC", "이름 변경", () =>
{
var dir = System.IO.Path.GetDirectoryName(filePath) ?? "";
var oldName = System.IO.Path.GetFileName(filePath);
var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
{
var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim());
try
{
System.IO.File.Move(filePath, newPath);
BuildFileTree();
ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
}
catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); }
}
});
// 삭제
AddItem("\uE74D", "삭제", () =>
{
var result = MessageBox.Show(
$"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}",
"파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (result == MessageBoxResult.Yes)
{
try
{
System.IO.File.Delete(filePath);
BuildFileTree();
ShowToast("파일 삭제됨");
}
catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); }
}
}, dangerBrush, dangerBrush);
// Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; },
System.Windows.Threading.DispatcherPriority.Input);
}
/// <summary>에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.</summary>
private void RefreshFileTreeIfVisible()
{
if (FileBrowserPanel.Visibility != Visibility.Visible) return;
// 디바운스: 500ms 내 중복 호출 방지
_fileBrowserRefreshTimer?.Stop();
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_fileBrowserRefreshTimer.Tick += (_, _) =>
{
_fileBrowserRefreshTimer.Stop();
BuildFileTree();
};
_fileBrowserRefreshTimer.Start();
}
// ─── 하단 상태바 ──────────────────────────────────────────────────────
private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard;
private void UpdateStatusBar(AgentEvent evt)
{
var toolLabel = evt.ToolName switch
{
"file_read" or "document_read" => "파일 읽기",
"file_write" => "파일 쓰기",
"file_edit" => "파일 수정",
"html_create" => "HTML 생성",
"xlsx_create" => "Excel 생성",
"docx_create" => "Word 생성",
"csv_create" => "CSV 생성",
"md_create" => "Markdown 생성",
"folder_map" => "폴더 탐색",
"glob" => "파일 검색",
"grep" => "내용 검색",
"process" => "명령 실행",
_ => evt.ToolName,
};
switch (evt.Type)
{
case AgentEventType.Thinking:
SetStatus("생각 중...", spinning: true);
break;
case AgentEventType.Planning:
SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true);
break;
case AgentEventType.ToolCall:
SetStatus($"{toolLabel} 실행 중...", spinning: true);
break;
case AgentEventType.ToolResult:
SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false);
break;
case AgentEventType.StepStart:
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true);
break;
case AgentEventType.StepDone:
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true);
break;
case AgentEventType.SkillCall:
SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true);
break;
case AgentEventType.Complete:
SetStatus("작업 완료", spinning: false);
StopStatusAnimation();
break;
case AgentEventType.Error:
SetStatus("오류 발생", spinning: false);
StopStatusAnimation();
break;
case AgentEventType.Paused:
SetStatus("⏸ 일시정지", spinning: false);
break;
case AgentEventType.Resumed:
SetStatus("▶ 재개됨", spinning: true);
break;
}
}
private void SetStatus(string text, bool spinning)
{
if (StatusLabel != null) StatusLabel.Text = text;
if (spinning) StartStatusAnimation();
}
private void StartStatusAnimation()
{
if (_statusSpinStoryboard != null) return;
var anim = new System.Windows.Media.Animation.DoubleAnimation
{
From = 0, To = 360,
Duration = TimeSpan.FromSeconds(2),
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
};
_statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard();
System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond);
System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim,
new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)"));
_statusSpinStoryboard.Children.Add(anim);
_statusSpinStoryboard.Begin();
}
private void StopStatusAnimation()
{
_statusSpinStoryboard?.Stop();
_statusSpinStoryboard = null;
}
private void SetStatusIdle()
{
StopStatusAnimation();
if (StatusLabel != null) StatusLabel.Text = "대기 중";
if (StatusElapsed != null) StatusElapsed.Text = "";
if (StatusTokens != null) StatusTokens.Text = "";
}
private void UpdateStatusTokens(int inputTokens, int outputTokens)
{
if (StatusTokens == null) return;
var llm = _settings.Settings.Llm;
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
inputTokens, outputTokens, llm.Service, llm.Model);
var totalCost = inCost + outCost;
var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : "";
StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}";
}
private static string TruncateForStatus(string? text, int max = 40)
{
if (string.IsNullOrEmpty(text)) return "";
return text.Length <= max ? text : text[..max] + "…";
}
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
{
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex);
return new System.Windows.Media.SolidColorBrush(c);
}
}