10210 lines
422 KiB
C#
10210 lines
422 KiB
C#
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);
|
||
}
|
||
}
|