using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Threading; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { // ─── Claude 스타일 펄스 닷 + 단계 표시 (입력창 위) ────────────────────────── private System.Windows.Media.Animation.Storyboard? _pulseDotStoryboard; private System.Windows.Media.Animation.Storyboard? _statusDiamondStoryboard; private readonly System.Collections.Generic.List _activeSubItems = new(); private string? _currentSubItemCategory; // 현재 세부 항목 카테고리 (변경 시 초기화) private const int MaxStatusSubItems = 6; // ShowStreamingStatusBar → 펄스 닷 바로 위임 (플로팅 상태 바 표시 안 함) private void ShowStreamingStatusBar(string message, string? iconCode = null) => ShowPulseDots(message, iconCode); private void HideStreamingStatusBar() => HidePulseDots(); private void UpdateStreamingStatusBar(string message, string? iconCode = null) => UpdatePulseDotsText(message, iconCode); // ─── 입력창 위 펄스 닷 애니메이션 ────────────────────────────────────────── private void ShowPulseDots(string? message = null, string? iconCode = null, string? detail = null) { if (PulseDotBar == null) return; if (PulseDotStatusText != null) PulseDotStatusText.Text = message ?? "생각하는 중..."; ClearStatusSubItems(); PulseDotBar.Visibility = Visibility.Visible; StartStatusDiamondAnimation(); if (_pulseDotStoryboard != null) return; // 이미 실행 중 var sb = new System.Windows.Media.Animation.Storyboard(); var dots = new System.Windows.Shapes.Ellipse[] { PulseDot1, PulseDot2, PulseDot3 }; const double cycleSecs = 1.2; for (int i = 0; i < dots.Length; i++) { var animX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames { BeginTime = TimeSpan.FromSeconds(i * 0.2), RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, Duration = new System.Windows.Duration(TimeSpan.FromSeconds(cycleSecs)), }; animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.Zero))); animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( 1.0, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.4))) { EasingFunction = new System.Windows.Media.Animation.SineEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } }); animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.8))) { EasingFunction = new System.Windows.Media.Animation.SineEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } }); animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame( 0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs)))); System.Windows.Media.Animation.Storyboard.SetTarget(animX, dots[i]); System.Windows.Media.Animation.Storyboard.SetTargetProperty(animX, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(animX); } _pulseDotStoryboard = sb; _pulseDotStoryboard.Begin(); } private void HidePulseDots() { if (PulseDotBar == null) return; _pulseDotStoryboard?.Stop(); _pulseDotStoryboard = null; if (PulseDot1 != null) PulseDot1.Opacity = 0.3; if (PulseDot2 != null) PulseDot2.Opacity = 0.3; if (PulseDot3 != null) PulseDot3.Opacity = 0.3; StopStatusDiamondAnimation(); ClearStatusSubItems(); _currentSubItemCategory = null; PulseDotBar.Visibility = Visibility.Collapsed; } // ─── 채팅창 내 에이전트 라이브 진행 카드 ───────────────────────────────────── // ─── 미니 다이아몬드 아이콘 애니메이션 ─────────────────────────────────────── private void StartStatusDiamondAnimation() { if (_statusDiamondStoryboard != null) return; if (StatusIconScale == null || StatusPixelBlue == null) return; var sb = new System.Windows.Media.Animation.Storyboard(); // 심장 박동 스케일 펄스 (부드러운 단일 박동 — 레이아웃 부하 경감) var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames { RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, Duration = new System.Windows.Duration(TimeSpan.FromSeconds(3.0)), }; var beatTimes = new (double t, double v)[] { (0.00, 1.00), (0.18, 1.15), (0.40, 1.00), (3.00, 1.00), }; foreach (var (t, v) in beatTimes) { scaleAnimX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(v, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(t))) { EasingFunction = new System.Windows.Media.Animation.QuadraticEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut } }); } System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimX, StatusIconScale); System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimX, new PropertyPath("ScaleX")); sb.Children.Add(scaleAnimX); var scaleAnimY = scaleAnimX.Clone(); System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimY, StatusIconScale); System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY")); sb.Children.Add(scaleAnimY); // 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남, 부드럽게) var pixels = new System.Windows.Shapes.Rectangle?[] { StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed }; for (int i = 0; i < pixels.Length; i++) { if (pixels[i] == null) continue; var fade = new System.Windows.Media.Animation.DoubleAnimation( fromValue: 1.0, toValue: 0.55, duration: new System.Windows.Duration(TimeSpan.FromSeconds(1.2))) { AutoReverse = true, RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, BeginTime = TimeSpan.FromSeconds(i * 0.30), EasingFunction = new System.Windows.Media.Animation.SineEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut }, }; System.Windows.Media.Animation.Storyboard.SetTarget(fade, pixels[i]); System.Windows.Media.Animation.Storyboard.SetTargetProperty(fade, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(fade); } _statusDiamondStoryboard = sb; _statusDiamondStoryboard.Begin(); } private void StopStatusDiamondAnimation() { _statusDiamondStoryboard?.Stop(); _statusDiamondStoryboard = null; if (StatusIconScale != null) { StatusIconScale.ScaleX = 1; StatusIconScale.ScaleY = 1; } if (StatusPixelBlue != null) StatusPixelBlue.Opacity = 1; if (StatusPixelGreen1 != null) StatusPixelGreen1.Opacity = 1; if (StatusPixelGreen2 != null) StatusPixelGreen2.Opacity = 1; if (StatusPixelRed != null) StatusPixelRed.Opacity = 1; } // ─── 세부 항목 서브텍스트 관리 ────────────────────────────────────────────── private void AddStatusSubItem(string text, string? category = null) { if (PulseDotSubItems == null) return; if (_activeSubItems.Contains(text)) return; // 중복 방지 // 카테고리 변경 시 기존 항목 초기화 if (category != null && category != _currentSubItemCategory) { ClearStatusSubItems(); _currentSubItemCategory = category; } _activeSubItems.Add(text); if (_activeSubItems.Count > MaxStatusSubItems) { _activeSubItems.RemoveAt(0); if (PulseDotSubItems.Children.Count > 0) PulseDotSubItems.Children.RemoveAt(0); } var secondary = TryFindResource("SecondaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Gray; var tb = new TextBlock { Text = $"› {text}", FontSize = 12.5, FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"), Foreground = secondary, Opacity = 0.60, LineHeight = 18, TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = 380, Margin = new Thickness(0, 0, 0, 1), }; PulseDotSubItems.Children.Add(tb); } private void ClearStatusSubItems() { _activeSubItems.Clear(); PulseDotSubItems?.Children.Clear(); } private void UpdatePulseDotsText(string message, string? iconCode = null, string? detail = null, bool clearSubItems = false, string? subItemCategory = null) { if (PulseDotBar?.Visibility != Visibility.Visible) return; if (PulseDotStatusText != null) PulseDotStatusText.Text = message; if (clearSubItems) ClearStatusSubItems(); if (!string.IsNullOrEmpty(detail)) AddStatusSubItem(detail, subItemCategory); } // 레거시 호환 (단일 세부 텍스트 — 현재는 서브 아이템 목록으로 대체) private void SetPulseDotDetail(string? detail) { if (!string.IsNullOrEmpty(detail)) AddStatusSubItem(detail); else ClearStatusSubItems(); } // 도구 이름 → (상태 메시지, MDL2 아이콘 코드, 카테고리) 변환 private static (string message, string icon, string category) GetStatusInfoForTool(string toolName) => toolName.ToLowerInvariant() switch { "document_read" => ("문서 읽는 중", "\uE8A5", "read"), "file_read" => ("파일 읽는 중", "\uE8A5", "read"), "folder_map" => ("폴더 구조 파악 중", "\uE8B7", "folder"), "file_write" or "html_create" or "md_create" or "docx_create" or "xlsx_create" or "csv_create" or "script_create" or "pptx_create" => ("파일 작성 중", "\uE8A3", "write"), "file_edit" => ("파일 수정 중", "\uE70F", "edit"), "terminal" => ("명령 실행 중", "\uE756", "terminal"), "web_search" => ("웹 검색 중", "\uE721", "search"), "document_plan" => ("작업 계획 수립 중", "\uE8F1", "plan"), "diff_preview" => ("변경 사항 검토 중", "\uE8A9", "diff"), "code_run" or "run_script" => ("코드 실행 중", "\uE756", "run"), "git_commit" or "git_push" or "git_pull" or "git_status" or "git_diff" => ("Git 작업 중", "\uE8A7", "git"), _ => ("작업 실행 중", "\uE8A7", "misc"), }; // 도구별 서브 아이템 접미사 ("> filename.txt 읽고 분석 중") private static string GetToolSubItemSuffix(string toolName) => toolName.ToLowerInvariant() switch { "document_read" or "file_read" => "읽고 분석 중", "folder_map" => "구조 파악 중", "file_write" or "html_create" or "md_create" or "docx_create" or "xlsx_create" or "csv_create" or "script_create" or "pptx_create" => "작성 중", "file_edit" => "수정 중", "terminal" => "실행 중", "web_search" => "검색 중", "code_run" or "run_script" => "실행 중", "diff_preview" => "비교 중", "git_commit" => "커밋 중", "git_push" => "푸시 중", "git_pull" => "풀 중", _ => "처리 중", }; // 도구 결과 수신 후 표시할 메시지 private static string GetToolResultMessage(string toolName) => toolName.ToLowerInvariant() switch { "document_read" or "file_read" => "내용 분석 중", "folder_map" => "구조 파악 중", "web_search" => "검색 결과 검토 중", "file_write" or "html_create" or "md_create" or "docx_create" or "xlsx_create" or "csv_create" or "script_create" or "pptx_create" => "작성 완료, 검토 중", "terminal" or "code_run" or "run_script" => "실행 결과 분석 중", "git_commit" or "git_push" or "git_pull" => "Git 결과 확인 중", _ => "결과 처리 중", }; // 하위 호환 (단순 문자열만 필요한 경우) private static string GetStatusMessageForTool(string toolName) => GetStatusInfoForTool(toolName).message + "..."; private void TouchLiveAgentProgressHints() { _lastAgentProgressEventAt = DateTime.UtcNow; } private void AgentProgressHintTimer_Tick(object? sender, EventArgs e) { if (!_streamingTabs.Contains(_activeTab) || !GetAgentLoop(_activeTab).IsRunning) { StopLiveAgentProgressHints(); return; } // _streamingTabs.Contains(_activeTab)가 위에서 이미 검증됨 — _streamRunTab은 다른 탭 시작 시 덮어써질 수 있으므로 _activeTab 사용 var runTab = _activeTab; if (!string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) && !string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) { UpdateLiveAgentProgressHint(null); return; } var idle = DateTime.UtcNow - _lastAgentProgressEventAt; TryGetStreamingElapsed(out var elapsed); string? summary = null; var toolName = "agent_wait"; if (_pendingPostCompaction && idle >= TimeSpan.FromSeconds(2)) { toolName = "context_compaction"; summary = idle >= TimeSpan.FromSeconds(12) ? "컨텍스트를 압축한 뒤 응답을 정리하는 중입니다..." : "컨텍스트를 압축하고 있습니다..."; UpdateStreamingStatusBar("컨텍스트 정리 중...", "\uE72C"); } else if (idle >= TimeSpan.FromSeconds(90)) { summary = "대용량 컨텍스트 처리 중입니다... (최대 3분 소요될 수 있습니다)"; UpdateStreamingStatusBar("대용량 컨텍스트 처리 중...", "\uE895"); } else if (idle >= TimeSpan.FromSeconds(30)) { summary = "모델이 응답을 준비하는 중입니다..."; UpdateStreamingStatusBar("모델이 응답 준비 중...", "\uE895"); } else if (idle >= TimeSpan.FromSeconds(12)) { summary = "응답을 정리하는 중입니다..."; } else if (idle >= TimeSpan.FromSeconds(5)) { summary = "생각을 정리하는 중입니다..."; } else if (elapsed >= TimeSpan.FromSeconds(4)) { summary = "작업을 진행하는 중입니다..."; } UpdateLiveAgentProgressHint(summary, toolName); } private void UpdateLiveAgentProgressHint(string? summary, string toolName = "") { var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim(); var currentSummary = _liveAgentProgressHint?.Summary; var currentToolName = _liveAgentProgressHint?.ToolName ?? ""; var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L; var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab)); var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab)); var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000; var nextElapsedBucket = elapsedMs / 3000; var currentTokenBucket = ((_liveAgentProgressHint?.InputTokens ?? 0) + (_liveAgentProgressHint?.OutputTokens ?? 0)) / 500; var nextTokenBucket = (inputTokens + outputTokens) / 500; if (string.Equals(currentSummary, normalizedSummary, StringComparison.Ordinal) && string.Equals(currentToolName, toolName, StringComparison.Ordinal) && currentElapsedBucket == nextElapsedBucket && currentTokenBucket == nextTokenBucket) return; _liveAgentProgressHint = normalizedSummary == null ? null : new AgentEvent { Timestamp = DateTime.Now, RunId = _appState.AgentRun.RunId, Type = AgentEventType.Thinking, ToolName = toolName, Summary = normalizedSummary, ElapsedMs = elapsedMs, InputTokens = (int)inputTokens, OutputTokens = (int)outputTokens, }; // 스트리밍 중 프로그레스 힌트 변경으로 인한 ScheduleExecutionHistoryRender 호출 차단 // 힌트 업데이트만으로 전체 트랜스크립트를 재렌더하면 UI 스레드 멈춤 발생 // 타이머가 자연스럽게 다음 렌더 사이클에서 힌트를 반영함 } private AgentEvent? GetLiveAgentProgressHint() { if (_liveAgentProgressHint == null) return null; var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return null; return new AgentEvent { Timestamp = _liveAgentProgressHint.Timestamp, RunId = _liveAgentProgressHint.RunId, Type = _liveAgentProgressHint.Type, ToolName = _liveAgentProgressHint.ToolName, Summary = _liveAgentProgressHint.Summary, FilePath = _liveAgentProgressHint.FilePath, Success = _liveAgentProgressHint.Success, StepCurrent = _liveAgentProgressHint.StepCurrent, StepTotal = _liveAgentProgressHint.StepTotal, Steps = _liveAgentProgressHint.Steps, ElapsedMs = _liveAgentProgressHint.ElapsedMs, InputTokens = _liveAgentProgressHint.InputTokens, OutputTokens = _liveAgentProgressHint.OutputTokens, ToolInput = _liveAgentProgressHint.ToolInput, Iteration = _liveAgentProgressHint.Iteration, }; } private static bool ShouldRenderProgressEventWhenHistoryCollapsed(AgentEvent evt) { if (evt.Type == AgentEventType.Complete || evt.Type == AgentEventType.Error) return true; if (evt.Type == AgentEventType.Thinking) { if (string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) || string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) return true; if (!string.IsNullOrWhiteSpace(evt.Summary)) return true; } return IsProcessFeedEvent(evt); } private void OnSubAgentStatusChanged(SubAgentStatusEvent evt) { Dispatcher.BeginInvoke(() => { try { _appState.ApplySubAgentStatus(evt); ScheduleTaskSummaryRefresh(); } catch (Exception ex) { LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}"); } }); } private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab) { lock (_convLock) { var session = _appState.ChatSession; if (session == null) return; var result = _chatEngine.AppendAgentRun( session, _storage, _currentConversation, _activeTab, targetTab, evt, status, summary); _currentConversation = result.CurrentConversation; ScheduleConversationPersist(result.UpdatedConversation); } } private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab) { lock (_convLock) { var session = _appState.ChatSession; if (session == null) return; var result = _chatEngine.AppendExecutionEvent( session, _storage, _currentConversation, _activeTab, targetTab, evt); _currentConversation = result.CurrentConversation; ScheduleConversationPersist(result.UpdatedConversation); } } private void SyncAppStateWithCurrentConversation() { ChatConversation? conv; lock (_convLock) conv = _currentConversation; _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); _appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory); _appState.RestoreRecentTasks(conv?.ExecutionEvents); ApplyConversationListPreferences(conv); UpdateTaskSummaryIndicators(); } private static string GetRunStatusLabel(string? status) => status switch { "completed" => "완료", "failed" => "실패", "paused" => "일시중지", _ => "진행 중", }; private static string GetTaskStatusLabel(string? status) => status switch { "completed" => "완료", "failed" => "실패", "blocked" => "재시도 대기", "waiting" => "승인 대기", "cancelled" => "중단", _ => "진행 중", }; private string GetAgentItemDisplayName(string? rawName, bool slashPrefix = false) => GetTranscriptDisplayName(rawName, slashPrefix); private static bool IsTranscriptToolLikeEvent(AgentEvent evt) => evt.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.SkillCall || (!string.IsNullOrWhiteSpace(evt.ToolName) && evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied); private string BuildAgentEventSummaryText(AgentEvent evt, string displayName) => GetTranscriptEventSummary(evt, displayName); private IEnumerable FilterTaskSummaryItems(IEnumerable tasks) => _taskSummaryTaskFilter switch { "permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)), "queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)), "hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)), "subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)), "tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)), _ => tasks, }; private Border CreateTaskSummaryFilterChip(string key, string label) { var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase); var chip = new Border { Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"), BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(8, 4, 8, 4), Margin = new Thickness(0, 0, 5, 5), Cursor = Cursors.Hand, Child = new TextBlock { Text = label, FontSize = 10.5, Foreground = active ? BrushFromHex("#4338CA") : secondaryText, } }; chip.MouseLeftButtonUp += (_, _) => { _taskSummaryTaskFilter = key; if (_taskSummaryTarget != null) ShowTaskSummaryPopup(); }; return chip; } private static Brush GetRunStatusBrush(string? status) => status switch { "completed" => BrushFromHex("#166534"), "failed" => BrushFromHex("#B91C1C"), "paused" => BrushFromHex("#B45309"), _ => BrushFromHex("#1D4ED8"), }; private static string ShortRunId(string? runId) { if (string.IsNullOrWhiteSpace(runId)) return "main"; return runId.Length <= 8 ? runId : runId[..8]; } // ─── Task Decomposition UI ──────────────────────────────────────────── private Border? _planningCard; private StackPanel? _planStepsPanel; private ProgressBar? _planProgressBar; private TextBlock? _planProgressText; private TextBlock? _planToggleText; /// 작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바). private void AddPlanningCard(AgentEvent evt) { var steps = evt.Steps!; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC"); var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC"); var card = new Border { Background = hintBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(8, 6, 8, 6), Margin = new Thickness(8, 2, 248, 5), HorizontalAlignment = HorizontalAlignment.Left, MaxWidth = GetMessageMaxWidth(), }; var sp = new StackPanel(); // 헤더 var header = new Grid { Margin = new Thickness(0, 0, 0, 4) }; header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var headerLeft = new StackPanel { Orientation = Orientation.Horizontal }; headerLeft.Children.Add(new TextBlock { Text = "\uE9D5", // plan icon FontFamily = s_segoeIconFont, FontSize = 8, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center }); headerLeft.Children.Add(new TextBlock { Text = $"계획 {steps.Count}단계", FontSize = 9, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0), }); header.Children.Add(headerLeft); var toggleWrap = new Border { Background = Brushes.Transparent, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), Padding = new Thickness(6, 2, 6, 2), Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, }; _planToggleText = new TextBlock { Text = steps.Count > 0 ? "펼치기" : "", FontSize = 8.25, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, }; toggleWrap.Child = _planToggleText; Grid.SetColumn(toggleWrap, 1); header.Children.Add(toggleWrap); sp.Children.Add(header); // 진행률 바 var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 6) }; 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 = 3, Foreground = accentBrush, Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"), 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 = 8, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, Margin = new Thickness(5, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(_planProgressText, 1); progressGrid.Children.Add(_planProgressText); sp.Children.Add(progressGrid); // 단계 목록 _planStepsPanel = new StackPanel { Visibility = Visibility.Collapsed, }; for (int i = 0; i < steps.Count; i++) { var stepRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 0), Tag = i, // 인덱스 저장 }; stepRow.Children.Add(new TextBlock { Text = "○", // 빈 원 (미완료) FontSize = 8.5, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), Tag = "status", }); stepRow.Children.Add(new TextBlock { Text = $"{i + 1}. {steps[i]}", FontSize = 8.75, Foreground = primaryText, TextWrapping = TextWrapping.Wrap, MaxWidth = Math.Max(280, GetMessageMaxWidth() - 68), VerticalAlignment = VerticalAlignment.Center, }); _planStepsPanel.Children.Add(stepRow); } sp.Children.Add(_planStepsPanel); toggleWrap.MouseLeftButtonUp += (_, _) => { if (_planStepsPanel == null || _planToggleText == null) return; var expanded = _planStepsPanel.Visibility == Visibility.Visible; _planStepsPanel.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible; _planToggleText.Text = expanded ? "펼치기" : "접기"; toggleWrap.Background = expanded ? Brushes.Transparent : BrushFromHex("#F8FAFC"); }; card.Child = sp; _planningCard = card; // 페이드인 card.Opacity = 0; card.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); AddTranscriptElement(card); } /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. private void AddDecisionButtons(TaskCompletionSource tcs, List options) { var expressionLevel = GetAgentUiExpressionLevel(); var showDetailedCopy = expressionLevel != "simple"; var showRichHint = expressionLevel == "rich"; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var accentColor = accentBrush is SolidColorBrush solidAccent ? solidAccent.Color : Color.FromRgb(0x4B, 0x5E, 0xFC); var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var surfaceBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x12, 0x14, 0x1F)); var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x20, accentColor.R, accentColor.G, accentColor.B)); var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B)); var accentTintBrush = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)); var successBrush = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); var dangerBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); var container = new Border { Margin = expressionLevel == "simple" ? new Thickness(40, 2, 120, 6) : new Thickness(40, 2, 80, 6), HorizontalAlignment = HorizontalAlignment.Left, MaxWidth = expressionLevel == "simple" ? 460 : 560, Background = surfaceBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(14, 12, 14, 12), }; var outerStack = new StackPanel(); outerStack.Children.Add(new TextBlock { Text = "실행 계획 승인 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = primaryText, }); if (showDetailedCopy) { outerStack.Children.Add(new TextBlock { Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.", FontSize = 11.5, Foreground = secondaryText, Margin = new Thickness(0, 2, 0, 8), TextWrapping = TextWrapping.Wrap, }); } if (showDetailedCopy && options.Count > 0) { var optionCandidates = new List(); foreach (var option in options) { if (string.IsNullOrWhiteSpace(option)) continue; optionCandidates.Add(option.Trim()); if (optionCandidates.Count >= 3) break; } var optionHint = string.Join(" · ", optionCandidates); if (!string.IsNullOrWhiteSpace(optionHint)) { outerStack.Children.Add(new TextBlock { Text = $"선택지: {optionHint}", FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 8), TextWrapping = TextWrapping.Wrap, }); } } if (showRichHint) { outerStack.Children.Add(new TextBlock { Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.", FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 8), TextWrapping = TextWrapping.Wrap, }); } // 버튼 행 var btnRow = new WrapPanel { HorizontalAlignment = HorizontalAlignment.Left, ItemHeight = 38, }; // 승인 버튼 (강조) var approveBtn = new Border { Background = accentBrush, CornerRadius = new CornerRadius(18), Padding = new Thickness(16, 9, 16, 9), Margin = new Thickness(0, 0, 8, 8), Cursor = Cursors.Hand, }; var approveSp = new StackPanel { Orientation = Orientation.Horizontal }; approveSp.Children.Add(new TextBlock { Text = "\uE73E", FontFamily = s_segoeIconFont, 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; ApplyHoverScaleAnimation(approveBtn, 1.02); approveBtn.MouseLeftButtonUp += (_, _) => { CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush); tcs.TrySetResult(null); // null = 승인 }; btnRow.Children.Add(approveBtn); // 수정 요청 버튼 var editBtn = new Border { Background = itemBg, CornerRadius = new CornerRadius(18), Padding = new Thickness(14, 9, 14, 9), Margin = new Thickness(0, 0, 8, 8), 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 = s_segoeIconFont, FontSize = 11, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), }); editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); editBtn.Child = editSp; ApplyMenuItemHover(editBtn); // 수정 요청용 텍스트 입력 패널 (초기 숨김) var editInputPanel = new Border { Visibility = Visibility.Collapsed, Background = itemBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12), Padding = new Thickness(12, 10, 12, 10), Margin = new Thickness(0, 4, 0, 0), }; var editInputStack = new StackPanel(); editInputStack.Children.Add(new TextBlock { Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:", 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 = surfaceBrush, Foreground = primaryText, CaretBrush = primaryText, 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.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, LineHeight = 18, }; 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(18), Padding = new Thickness(14, 9, 14, 9), Margin = new Thickness(0, 0, 0, 8), 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 = s_segoeIconFont, 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); AddTranscriptElement(container); ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 var capturedOuterStack = outerStack; var capturedAccent = accentBrush; _ = tcs.Task.ContinueWith(t => { if (t.IsFaulted || t.IsCanceled) return; 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); } /// 의사결정 버튼을 숨기고 결과 라벨로 교체합니다. private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) { outerStack.Children.Clear(); // 부모 컨테이너(Border)의 테두리·배경 제거 — 승인 후 깔끔하게 if (outerStack.Parent is Border containerBorder) { containerBorder.BorderThickness = new Thickness(0); containerBorder.Background = Brushes.Transparent; containerBorder.Padding = new Thickness(0, 2, 0, 2); } var resultLabel = new TextBlock { Text = resultText, FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = fg, Opacity = 0.8, LineHeight = 18, Margin = new Thickness(40, 2, 0, 2), }; outerStack.Children.Add(resultLabel); } // ════════════════════════════════════════════════════════════ // 후속 작업 제안 칩 (suggest_actions) // ════════════════════════════════════════════════════════════ /// suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다. private void RenderSuggestActionChips(string jsonSummary) { List<(string label, string command)> actions = new(); try { 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 preview = string.Join(", ", actions.Take(3).Select(static action => action.label)); var suffix = actions.Count > 3 ? $" 외 {actions.Count - 3}개" : ""; ShowToast($"다음 작업 제안 준비됨: {preview}{suffix}"); } // ════════════════════════════════════════════════════════════ // 피드백 학습 반영 (J) // ════════════════════════════════════════════════════════════ /// 피드백 컨텍스트 캐시 — 1분간 유효. private string? _feedbackContextCache; private DateTime _feedbackContextCacheExpiry; /// 최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다. /// /// 성능 최적화: LoadAllMeta() + Load() x20 은 모든 .axchat 파일을 복호화하므로 /// 매 전송마다 호출하면 수 초~10초 이상 UI를 블로킹합니다. /// 결과를 1분간 캐시하고, 현재 대화의 피드백만 즉시 반영합니다. /// private string BuildFeedbackContext() { // 캐시가 유효하면 즉시 반환 — 디스크 I/O 없음 if (_feedbackContextCache != null && DateTime.UtcNow < _feedbackContextCacheExpiry) return _feedbackContextCache; try { // 현재 대화에서만 피드백 수집 — 디스크 I/O 없이 메모리 내 처리 var likedPatterns = new List(); var dislikedPatterns = new List(); ChatConversation? currentConv; lock (_convLock) currentConv = _currentConversation; if (currentConv != null) { foreach (var msg in currentConv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null)) { 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) { // 비동기 캐시 갱신 — UI 블로킹 없음 _ = Task.Run(() => RefreshFeedbackCacheBackground()); // 이전 캐시가 있으면 반환, 없으면 빈 문자열 return _feedbackContextCache ?? ""; } var result = FormatFeedbackPatterns(likedPatterns, dislikedPatterns); _feedbackContextCache = result; _feedbackContextCacheExpiry = DateTime.UtcNow.AddMinutes(1); return result; } catch { return ""; } } /// 백그라운드에서 전체 대화 피드백을 수집하여 캐시를 갱신합니다. private void RefreshFeedbackCacheBackground() { try { var metaList = _storage.LoadAllMeta() .OrderByDescending(m => m.UpdatedAt) .Take(10) .ToList(); var liked = new List(); var disliked = new List(); foreach (var meta in metaList) { var conv = _storage.Load(meta.Id); if (conv == null) continue; foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null)) { var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? ""; if (msg.Feedback == "like") liked.Add(preview); else if (msg.Feedback == "dislike") disliked.Add(preview); } // 충분한 피드백을 모았으면 중단 if (liked.Count + disliked.Count >= 10) break; } var result = FormatFeedbackPatterns(liked, disliked); _feedbackContextCache = result; _feedbackContextCacheExpiry = DateTime.UtcNow.AddMinutes(1); } catch { /* 백그라운드 실패 무시 */ } } private static string FormatFeedbackPatterns(List likedPatterns, List dislikedPatterns) { 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(); } /// 진행률 바와 단계 상태를 업데이트합니다. 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; } } } } /// Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다. 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 sealed class ReviewSignalSummary { public int P0 { get; init; } public int P1 { get; init; } public int P2 { get; init; } public int P3 { get; init; } public bool HasFixed { get; init; } public bool HasUnfixed { get; init; } public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed; } private static ReviewSignalSummary ExtractReviewSignals(string? text) { if (string.IsNullOrWhiteSpace(text)) return new ReviewSignalSummary(); var source = text!; var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈"); var hasFixed = ContainsWholeWord(source, "fixed") || ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료"); return new ReviewSignalSummary { P0 = CountToken(source, "P0"), P1 = CountToken(source, "P1"), P2 = CountToken(source, "P2"), P3 = CountToken(source, "P3"), HasUnfixed = hasUnfixed, HasFixed = hasFixed, }; } private static int CountToken(string source, string token) { if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token)) return 0; var count = 0; var index = 0; while (index < source.Length) { var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase); if (hit < 0) break; var before = hit == 0 ? ' ' : source[hit - 1]; var afterIndex = hit + token.Length; var after = afterIndex >= source.Length ? ' ' : source[afterIndex]; if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after)) count++; index = hit + token.Length; } return count; } private static bool ContainsAny(string source, params string[] needles) { foreach (var needle in needles) { if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0) return true; } return false; } private static bool ContainsWholeWord(string source, string token) { if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token)) return false; var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b"; return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); } private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary) { if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase)) return true; if (!string.IsNullOrWhiteSpace(toolName) && (toolName.Contains("review", StringComparison.OrdinalIgnoreCase) || toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase))) return true; if (!string.IsNullOrWhiteSpace(title) && title.Contains("review", StringComparison.OrdinalIgnoreCase)) return true; if (!string.IsNullOrWhiteSpace(summary) && (summary.Contains("P0", StringComparison.OrdinalIgnoreCase) || summary.Contains("P1", StringComparison.OrdinalIgnoreCase) || summary.Contains("P2", StringComparison.OrdinalIgnoreCase) || summary.Contains("P3", StringComparison.OrdinalIgnoreCase))) return true; return false; } private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex) { return new Border { Background = BrushFromHex(bgHex), BorderBrush = BrushFromHex(borderHex), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(999), Margin = new Thickness(0, 0, 6, 0), Padding = new Thickness(8, 2, 8, 2), Child = new TextBlock { Text = text, FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = BrushFromHex(fgHex), } }; } private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary) { if (!IsReviewContext(kind, toolName, title, summary)) return null; var signals = ExtractReviewSignals(summary); if (!signals.HasAny) return null; var row = new WrapPanel { Margin = new Thickness(0, 6, 0, 0), VerticalAlignment = VerticalAlignment.Center, }; if (signals.P0 > 0) row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5")); if (signals.P1 > 0) row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74")); if (signals.P2 > 0) row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A")); if (signals.P3 > 0) row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD")); if (signals.HasFixed) row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC")); if (signals.HasUnfixed) row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5")); return row.Children.Count == 0 ? null : row; } /// 파일 빠른 작업 버튼 패널을 생성합니다. private StackPanel BuildFileQuickActions(string filePath) { var panel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }; var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6"); var accentBrush = new SolidColorBrush(accentColor); Border MakeBtn(string mdlIcon, string tooltip, Action action) { var icon = new TextBlock { Text = mdlIcon, FontFamily = s_segoeIconFont, FontSize = 10, Foreground = accentBrush, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; var btn = new Border { Child = icon, Background = Brushes.Transparent, CornerRadius = new CornerRadius(4), Width = 22, Height = 22, Margin = new Thickness(0, 0, 2, 0), Cursor = Cursors.Hand, ToolTip = tooltip, }; 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); } catch { } })); return panel; } }