using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { private Func, Task> CreatePlanDecisionCallback() { return async (planSummary, options) => { // 도구 실행 승인(확인/건너뛰기/취소)은 간결한 별도 다이얼로그로 처리 var isToolApproval = options.Contains("확인") && !options.Contains("승인"); if (isToolApproval) { // ToolApprovalWindow.Show는 Dispatcher에서 동기 호출된다. // 장시간 미응답 시 에이전트 루프가 멈추는 것을 방지하기 위해 10분 가드를 두고, // 타임아웃 발생 시 CancellationToken으로 다이얼로그 자체를 닫아 UI를 정리한다. using var toolTimeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); var toolTcs = new TaskCompletionSource(); _ = Dispatcher.InvokeAsync(() => { try { var r = ToolApprovalWindow.Show(this, planSummary, options, toolTimeoutCts.Token); toolTcs.TrySetResult(r); } catch (Exception ex) { toolTcs.TrySetException(ex); } }); var toolResult = await toolTcs.Task; if (toolTimeoutCts.IsCancellationRequested && string.IsNullOrEmpty(toolResult)) { // 타임아웃 — 안전한 선택(중단)으로 폴백 return options.Contains("중단") ? "중단" : (options.Contains("취소") ? "취소" : "건너뛰기"); } return toolResult; } // 계획 승인은 PlanViewerV2로 처리 var tcs = new TaskCompletionSource(); var steps = ExtractPlanSteps(planSummary); await Dispatcher.InvokeAsync(() => { _pendingPlanSummary = planSummary; _pendingPlanSteps = steps.ToList(); EnsurePlanViewerWindow(); _planViewerWindow?.LoadPlan(planSummary, steps, tcs); ShowPlanButton(true); AddDecisionButtons(tcs, options); // 플랜 창 자동 표시 (사용자가 직접 BtnPlanViewer 클릭 안 해도 됨) if (_planViewerWindow != null && IsPlanWindowAlive()) { PlanWindow?.Show(); PlanWindow?.Activate(); } }); var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); if (completed != tcs.Task) { await Dispatcher.InvokeAsync(() => { PlanWindow?.Hide(); ResetPendingPlanPresentation(); }); return "취소"; } var result = await tcs.Task; var agentDecision = result; if (result == null) { agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix); } else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase) && !string.Equals(result, "확인", StringComparison.OrdinalIgnoreCase) && !string.Equals(result, "건너뛰기", StringComparison.OrdinalIgnoreCase) && !string.Equals(result, "중단", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(result)) { agentDecision = $"수정 요청: {result.Trim()}"; } // 승인/취소/수정 모두 계획 창 닫고 상태 초기화 await Dispatcher.InvokeAsync(() => { PlanWindow?.Hide(); ResetPendingPlanPresentation(); }); return agentDecision; }; } private void EnsurePlanViewerWindow() { if (_planViewerWindow != null && IsPlanWindowAlive()) return; var v2 = new PlanViewerWindowV2(this); v2.Closing += (_, e) => { e.Cancel = true; v2.Hide(); }; _planViewerWindow = v2; } private bool IsPlanWindowAlive() => IsWindowAlive(_planViewerWindow as Window); private Window? PlanWindow => _planViewerWindow as Window; private void ShowPlanButton(bool show) { // 레거시: 이전 세션에서 동적 주입된 MoodIconPanel 칩 정리 try { for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--) { if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn") { // separator 먼저 제거 (인덱스 시프트 전) int sepIdx = i - 1; MoodIconPanel.Children.RemoveAt(i); // PlanBtn 제거 if (sepIdx >= 0 && sepIdx < MoodIconPanel.Children.Count && MoodIconPanel.Children[sepIdx] is Border sep && sep.Tag?.ToString() == "PlanSep") MoodIconPanel.Children.RemoveAt(sepIdx); break; } } } catch { // 레거시 정리 실패 시에도 버튼 토글은 반드시 실행 } // StatusBar의 XAML 선언 계획 버튼 토글 BtnPlanViewer.Visibility = show ? Visibility.Visible : Visibility.Collapsed; } private void BtnPlanViewer_Click(object sender, MouseButtonEventArgs e) { e.Handled = true; if (string.IsNullOrWhiteSpace(_pendingPlanSummary) && _pendingPlanSteps.Count == 0) return; EnsurePlanViewerWindow(); if (_planViewerWindow != null && IsPlanWindowAlive()) { if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText) || _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty) || !_planViewerWindow.Steps.SequenceEqual(_pendingPlanSteps)) { _planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps); } PlanWindow?.Show(); PlanWindow?.Activate(); } } /// 계획 버튼 호버 효과 초기화 (Loaded에서 호출). private void InitPlanButtonHover() { var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var normalBg = TryFindResource("HintBackground") as Brush ?? Brushes.Transparent; BtnPlanViewer.MouseEnter += (_, _) => BtnPlanViewer.Background = hoverBg; BtnPlanViewer.MouseLeave += (_, _) => BtnPlanViewer.Background = normalBg; } private void UpdatePlanViewerStep(AgentEvent evt) { if (_planViewerWindow == null || !IsPlanWindowAlive()) return; if (evt.StepCurrent > 0) _planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); } private void CompletePlanViewer() { if (_planViewerWindow != null && IsPlanWindowAlive()) _planViewerWindow.MarkComplete(); ResetPendingPlanPresentation(); } private void ResetPendingPlanPresentation() { _pendingPlanSummary = null; _pendingPlanSteps.Clear(); ShowPlanButton(false); } /// document_plan 결과 텍스트에서 계획 단계(섹션 목록)를 추출합니다. private static List ExtractPlanSteps(string planText) { if (string.IsNullOrWhiteSpace(planText)) return new List { "문서 계획 검토" }; // 1) 번호 매긴 단계 (기존 TaskDecomposer) var numbered = TaskDecomposer.ExtractSteps(planText); if (numbered.Count >= 2) return numbered; var sections = new List(); // 2) JSON "heading" 필드 추출 (ExecuteSinglePassWithData 경로) var headingMatches = System.Text.RegularExpressions.Regex.Matches( planText, @"""heading""\s*:\s*""([^""]+)"""); foreach (System.Text.RegularExpressions.Match m in headingMatches) sections.Add(m.Groups[1].Value); if (sections.Count >= 2) return sections; // 3) HTML

태그 (ExecuteWithHtmlScaffold 경로) sections.Clear(); var h2Matches = System.Text.RegularExpressions.Regex.Matches( planText, @"

([^<]+)

"); foreach (System.Text.RegularExpressions.Match m in h2Matches) sections.Add(m.Groups[1].Value); if (sections.Count >= 2) return sections; // 4) Markdown ## 헤딩 sections.Clear(); var mdMatches = System.Text.RegularExpressions.Regex.Matches( planText, @"(?:^|\n)##\s+(.+?)(?:\n|$)"); foreach (System.Text.RegularExpressions.Match m in mdMatches) { var heading = m.Groups[1].Value.Trim(); if (!heading.StartsWith("[") && !heading.Contains("즉시 실행")) sections.Add(heading); } if (sections.Count >= 2) return sections; // 5) 폴백 return new List { "문서 계획 검토" }; } private static bool IsWindowAlive(Window? w) { if (w == null) return false; try { var _ = w.IsVisible; return true; } catch { return false; } } }