사내 모드에서 process/build_run/open_external 경로의 외부 접근 차단 범위를 강화했습니다. http_tool과 외부 URI 차단에 더해 curl, Invoke-WebRequest 같은 네트워크성 명령과 build_run custom 실행을 내부 정책으로 막아 실제 동작이 정책 선언과 더 가깝게 맞춰지도록 했습니다. ChatWindow의 '이번 실행 동안 허용' 승인 규칙을 run-scope로 변경했습니다. 탭 실행 시작과 종료 시 승인 캐시를 초기화하고 같은 실행 안에서만 동일 범위 접근을 재질문 없이 재사용하도록 정리해 창 수명 동안 규칙이 남던 문제를 줄였습니다. 권한 건너뛰기 관련 UI/상태 문구를 실제 동작과 맞췄고, OperationModePolicyTests·OperationModeReadinessTests·AgentLoopE2ETests·LlmOperationModeTests를 통해 권한 정책과 사내 모드 차단 회귀를 검증했습니다. dotnet build 경고 0 / 오류 0, 권한 관련 테스트 49건 통과를 확인했습니다.
9005 lines
369 KiB
C#
9005 lines
369 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 System.IO;
|
||
using Microsoft.Win32;
|
||
using AxCopilot.Models;
|
||
using AxCopilot.Services;
|
||
using AxCopilot.Services.Agent;
|
||
using AxCopilot.ViewModels;
|
||
|
||
namespace AxCopilot.Views;
|
||
|
||
/// <summary>AX Agent 창. 데스크톱 코파일럿 스타일 — 사이드바 + 카테고리 분류 + 타임라인.</summary>
|
||
public partial class ChatWindow : Window
|
||
{
|
||
private const string UnifiedAdminPassword = "axgo123!";
|
||
private bool IsOverlayEncryptionEnabled => _settings.Settings.Llm.EncryptionEnabled;
|
||
private readonly ISettingsService _settings;
|
||
private readonly IChatStorageService _storage;
|
||
private readonly DraftQueueProcessorService _draftQueueProcessor = new();
|
||
private readonly ILlmService _llm;
|
||
private readonly ToolRegistry _toolRegistry;
|
||
private readonly Dictionary<string, AgentLoopService> _agentLoops = new();
|
||
private readonly AxAgentExecutionEngine _chatEngine;
|
||
private readonly IModelRouterService _router;
|
||
private readonly AppStateService _appState;
|
||
private readonly object _convLock = new();
|
||
private readonly Dictionary<string, FrameworkElement> _runBannerAnchors = new(StringComparer.OrdinalIgnoreCase);
|
||
private ChatConversation? _currentConversation;
|
||
private string? _runningDraftId;
|
||
private readonly HashSet<string> _streamingTabs = [];
|
||
private readonly Dictionary<string, CancellationTokenSource> _tabStreamCts = new();
|
||
private bool _isStreaming => _streamingTabs.Count > 0;
|
||
private bool _sidebarVisible = true;
|
||
private double _sidebarExpandedWidth = 262;
|
||
private bool _isInWindowMoveSizeLoop;
|
||
private bool _pendingResponsiveLayoutRefresh;
|
||
private bool _pendingHiddenExecutionHistoryRender;
|
||
private bool _pendingBackgroundTaskSummaryRefresh;
|
||
private bool _pendingBackgroundInputUiRefresh;
|
||
private bool _pendingBackgroundAgentUiEventFlush;
|
||
private DispatcherTimer? _settingsSaveTimer;
|
||
private CacheMode? _cachedRootCacheModeBeforeMove;
|
||
private string _selectedCategory = ""; // "" = 전체
|
||
private readonly Dictionary<string, string> _tabSelectedCategory = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["Chat"] = "",
|
||
["Cowork"] = "",
|
||
["Code"] = "",
|
||
};
|
||
private readonly Dictionary<string, bool> _tabSidebarVisible = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["Chat"] = true,
|
||
["Cowork"] = true,
|
||
["Code"] = true,
|
||
};
|
||
private readonly Dictionary<string, bool> _overlaySectionExpandedStates = new(StringComparer.OrdinalIgnoreCase);
|
||
private bool _failedOnlyFilter;
|
||
private bool _runningOnlyFilter;
|
||
/// <summary>아카이브 필터: null=일반만, true=아카이브만, false=전체</summary>
|
||
private bool? _archiveFilter = null;
|
||
private int _failedConversationCount;
|
||
private int _runningConversationCount;
|
||
private int _spotlightConversationCount;
|
||
private bool _sortConversationsByRecent = false;
|
||
private bool _isInlineSettingsSyncing;
|
||
private string? _streamRunTab;
|
||
private bool _forceClose = false; // 앱 종료 시 진짜 닫기 플래그
|
||
|
||
// 스트리밍 UI — 커서 깜빡임 + 로딩 아이콘
|
||
private readonly DispatcherTimer _cursorTimer;
|
||
private bool _cursorVisible = true;
|
||
private TextBlock? _activeStreamText;
|
||
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
|
||
private readonly char[] _streamDisplayBuffer = new char[256 * 1024]; // 256KB 재사용 버퍼 (타이핑 표시용)
|
||
private int _streamDisplayBufferLen; // 버퍼에 기록된 실제 길이
|
||
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
|
||
private bool _aiIconPulseStopped; // 펄스 1회만 중지
|
||
private DispatcherTimer? _activeSpinnerTimer; // 스트리밍 유니코드 스피너 타이머
|
||
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
|
||
private IPlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
|
||
private Border? _userAskCard; // transcript 내 질문 카드
|
||
private string? _pendingPlanSummary;
|
||
private List<string> _pendingPlanSteps = new();
|
||
// 현재 실행 중인 에이전트 스텝 누적 (통합 진행 카드용)
|
||
private readonly List<AgentEvent> _currentRunProgressSteps = new();
|
||
private bool _userScrolled; // 사용자가 위로 스크롤했는지
|
||
private long _lastScrollTick; // 스크롤 스로틀링용 (Environment.TickCount64)
|
||
// 메시지 버블 캐시: messageId → 렌더링된 UIElement (재생성 방지)
|
||
private readonly Dictionary<string, UIElement> _elementCache = new(StringComparer.Ordinal);
|
||
// 증분 렌더링: 이전 렌더링의 타임라인 키 목록 (변경 감지용)
|
||
private List<string> _lastRenderedTimelineKeys = new();
|
||
private int _lastRenderedHiddenCount;
|
||
// 스트리밍 중 불필요한 재렌더링 방지용 카운터
|
||
private int _lastRenderedMessageCount;
|
||
private int _lastRenderedEventCount;
|
||
private bool _lastRenderedShowHistory;
|
||
private readonly Dictionary<string, HashSet<string>> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly object _sessionPermissionRulesLock = new();
|
||
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
|
||
// 경과 시간 표시
|
||
private readonly DispatcherTimer _elapsedTimer;
|
||
private DateTime _streamStartTime;
|
||
private TextBlock? _elapsedLabel;
|
||
|
||
// 에이전트 루프 라이브 카드 (채팅창 내 처리 과정 실시간 표시)
|
||
private StackPanel? _agentLiveContainer;
|
||
private TextBlock? _agentLiveStatusText;
|
||
private StackPanel? _agentLiveSubItems;
|
||
#pragma warning disable CS0414 // V1 레거시 — RemoveAgentLiveCard에서 null 초기화용
|
||
private TextBlock? _agentLiveElapsedText;
|
||
#pragma warning restore CS0414
|
||
private readonly List<string> _agentLiveSubItemTexts = new();
|
||
private string? _agentLiveCurrentCategory;
|
||
private DispatcherTimer? _agentLiveElapsedTimer;
|
||
|
||
// 타이핑 효과
|
||
private readonly DispatcherTimer _typingTimer;
|
||
private readonly DispatcherTimer _gitRefreshTimer;
|
||
private readonly DispatcherTimer _conversationSearchTimer;
|
||
private readonly DispatcherTimer _inputUiRefreshTimer;
|
||
private readonly DispatcherTimer _executionHistoryRenderTimer;
|
||
private readonly DispatcherTimer _taskSummaryRefreshTimer;
|
||
private readonly DispatcherTimer _conversationPersistTimer;
|
||
private readonly DispatcherTimer _agentUiEventTimer;
|
||
private readonly DispatcherTimer _agentProgressHintTimer;
|
||
private readonly DispatcherTimer _tokenUsagePopupCloseTimer;
|
||
private readonly DispatcherTimer _responsiveLayoutTimer;
|
||
private CancellationTokenSource? _workspaceSkillRefreshCts;
|
||
private int _workspaceSkillRefreshVersion;
|
||
private CancellationTokenSource? _gitStatusRefreshCts;
|
||
private int _displayedLength; // 현재 화면에 표시된 글자 수
|
||
private ResourceDictionary? _agentThemeDictionary;
|
||
private bool _isOverlaySettingsSyncing;
|
||
private string? _currentGitBranchName;
|
||
private string? _currentGitTooltip;
|
||
private string? _currentGitRoot;
|
||
private int _currentGitChangedFileCount;
|
||
private int _currentGitInsertions;
|
||
private int _currentGitDeletions;
|
||
private List<string> _currentGitBranches = new();
|
||
private string? _currentGitUpstreamStatus;
|
||
private readonly List<string> _recentGitBranches = new();
|
||
private string _gitBranchSearchText = "";
|
||
private StackPanel? _selectedMessageActionBar;
|
||
private Border? _selectedMessageBorder;
|
||
private bool _isRefreshingFromSettings;
|
||
private int? _lastCompactionBeforeTokens;
|
||
private int? _lastCompactionAfterTokens;
|
||
private DateTime? _lastCompactionAt;
|
||
private bool _lastCompactionWasAutomatic;
|
||
private string _lastCompactionStageSummary = "";
|
||
private int _sessionCompactionCount;
|
||
private int _sessionAutomaticCompactionCount;
|
||
private int _sessionManualCompactionCount;
|
||
private int _sessionCompactionSavedTokens;
|
||
private int _sessionMemoryCompactionCount;
|
||
private int _sessionMicrocompactBoundaryCount;
|
||
private int _sessionSnipCompactionCount;
|
||
private bool _pendingPostCompaction;
|
||
private int _sessionPostCompactionResponseCount;
|
||
private int _sessionPostCompactionPromptTokens;
|
||
private int _sessionPostCompactionCompletionTokens;
|
||
private bool _pendingExecutionHistoryAutoScroll;
|
||
private readonly Dictionary<string, ChatConversation> _pendingConversationPersists = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly HashSet<string> _expandedDraftQueueTabs = new(StringComparer.OrdinalIgnoreCase);
|
||
private AgentEvent? _pendingAgentUiEvent;
|
||
private AgentEvent? _liveAgentProgressHint;
|
||
private DateTime _lastAgentProgressEventAt = DateTime.UtcNow;
|
||
private double _lastResponsiveComposerWidth;
|
||
private double _lastResponsiveMessageWidth;
|
||
private bool IsLightweightLiveProgressMode(string? runTab = null)
|
||
{
|
||
var tab = string.IsNullOrWhiteSpace(runTab) ? (_streamRunTab ?? _activeTab) : runTab!;
|
||
if (!string.Equals(tab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(tab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
return false;
|
||
|
||
ChatConversation? conversation;
|
||
lock (_convLock)
|
||
conversation = _currentConversation;
|
||
|
||
return !(conversation?.ShowExecutionHistory ?? true);
|
||
}
|
||
|
||
private void ApplyQuickActionVisual(Button button, bool active, string activeBg, string activeFg)
|
||
{
|
||
if (button?.Content is not string text)
|
||
return;
|
||
|
||
button.Background = active ? BrushFromHex(activeBg) : Brushes.Transparent;
|
||
button.BorderBrush = active ? BrushFromHex(activeBg) : Brushes.Transparent;
|
||
button.BorderThickness = new Thickness(active ? 1 : 0);
|
||
button.Foreground = active ? BrushFromHex(activeFg) : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||
button.Content = text;
|
||
}
|
||
|
||
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 int AgentRunCount { get; init; }
|
||
public int FailedAgentRunCount { get; init; }
|
||
public string LastAgentRunSummary { get; init; } = "";
|
||
public DateTime? LastFailedAt { get; init; }
|
||
public DateTime? LastCompletedAt { get; init; }
|
||
public bool IsRunning { get; init; }
|
||
public string WorkFolder { get; init; } = "";
|
||
public bool Archived { get; init; }
|
||
}
|
||
|
||
/// <summary>MVVM ViewModel — XAML 바인딩 대상.</summary>
|
||
internal ChatWindowViewModel ViewModel { get; }
|
||
|
||
public ChatWindow(SettingsService settings)
|
||
{
|
||
ViewModel = new ChatWindowViewModel();
|
||
DataContext = ViewModel;
|
||
InitializeComponent();
|
||
InitializeTranscriptHost();
|
||
_appState = (System.Windows.Application.Current as App)?.AppState ?? new AppStateService();
|
||
_settings = settings;
|
||
_settings.SettingsChanged += Settings_SettingsChanged;
|
||
_storage = ServiceLocator.Get<IChatStorageService>();
|
||
_llm = ServiceLocator.Get<ILlmService>();
|
||
_router = ServiceLocator.Get<IModelRouterService>();
|
||
_toolRegistry = ToolRegistry.CreateDefault();
|
||
_chatEngine = new AxAgentExecutionEngine();
|
||
// 탭별 독립 에이전트 루프 생성 — Chat/Cowork/Code 각각 병렬 실행 가능
|
||
foreach (var tab in new[] { "Chat", "Cowork", "Code" })
|
||
_agentLoops[tab] = CreateAgentLoopForTab(tab, settings);
|
||
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
|
||
// 에이전트 이벤트 백그라운드 프로세서 시작 (대화 변이·저장을 UI 스레드에서 분리)
|
||
StartAgentEventProcessor();
|
||
|
||
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
|
||
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
|
||
_folderDataUsage = "none";
|
||
|
||
_cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||
_cursorTimer.Tick += CursorTimer_Tick;
|
||
|
||
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||
_elapsedTimer.Tick += ElapsedTimer_Tick;
|
||
|
||
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80) };
|
||
_typingTimer.Tick += TypingTimer_Tick;
|
||
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
|
||
_gitRefreshTimer.Tick += async (_, _) =>
|
||
{
|
||
_gitRefreshTimer.Stop();
|
||
await RefreshGitBranchStatusAsync();
|
||
};
|
||
_conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||
_conversationSearchTimer.Tick += (_, _) =>
|
||
{
|
||
_conversationSearchTimer.Stop();
|
||
RefreshConversationList();
|
||
};
|
||
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||
_inputUiRefreshTimer.Tick += (_, _) =>
|
||
{
|
||
_inputUiRefreshTimer.Stop();
|
||
_inputUiRefreshTimer.Interval = _isStreaming
|
||
? (IsLightweightLiveProgressMode()
|
||
? TimeSpan.FromMilliseconds(2000)
|
||
: TimeSpan.FromMilliseconds(1500))
|
||
: TimeSpan.FromMilliseconds(250);
|
||
RefreshContextUsageVisual();
|
||
RefreshDraftQueueUi();
|
||
};
|
||
_executionHistoryRenderTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) };
|
||
_executionHistoryRenderTimer.Tick += (_, _) =>
|
||
{
|
||
_executionHistoryRenderTimer.Stop();
|
||
// 스트리밍 중에는 재렌더링 빈도를 크게 줄여 UI 부하 감소
|
||
// Claude Desktop은 React virtual DOM으로 diff만 적용하지만
|
||
// WPF는 전체 시각적 트리를 재구성하므로 간격이 넉넉해야 합니다
|
||
_executionHistoryRenderTimer.Interval = _isStreaming
|
||
? (IsLightweightLiveProgressMode()
|
||
? TimeSpan.FromMilliseconds(8000)
|
||
: TimeSpan.FromMilliseconds(5000))
|
||
: TimeSpan.FromMilliseconds(350);
|
||
// 스크롤 수정: 자동 스크롤이 필요하고 사용자가 위로 스크롤하지 않은 경우
|
||
// preserveViewport=false로 렌더하여 하단 스크롤 → viewport 보존과 충돌 방지
|
||
// (preserveViewport=true는 Background priority로 뷰포트 복원을 예약하는데,
|
||
// AutoScrollIfNeeded가 직접 ScrollToEnd하면 뷰포트 복원이 나중에 덮어씀)
|
||
var wantsAutoScroll = _pendingExecutionHistoryAutoScroll && !_userScrolled;
|
||
_pendingExecutionHistoryAutoScroll = false;
|
||
RenderMessages(preserveViewport: !wantsAutoScroll);
|
||
};
|
||
_taskSummaryRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
|
||
_taskSummaryRefreshTimer.Tick += (_, _) =>
|
||
{
|
||
_taskSummaryRefreshTimer.Stop();
|
||
_taskSummaryRefreshTimer.Interval = _isStreaming
|
||
? (IsLightweightLiveProgressMode()
|
||
? TimeSpan.FromMilliseconds(3000)
|
||
: TimeSpan.FromMilliseconds(2000))
|
||
: TimeSpan.FromMilliseconds(120);
|
||
UpdateTaskSummaryIndicators();
|
||
};
|
||
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
|
||
_conversationPersistTimer.Tick += (_, _) =>
|
||
{
|
||
_conversationPersistTimer.Stop();
|
||
FlushPendingConversationPersists();
|
||
};
|
||
_agentUiEventTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
|
||
_agentUiEventTimer.Tick += (_, _) =>
|
||
{
|
||
_agentUiEventTimer.Stop();
|
||
_agentUiEventTimer.Interval = _isStreaming
|
||
? (IsLightweightLiveProgressMode()
|
||
? TimeSpan.FromMilliseconds(2000)
|
||
: TimeSpan.FromMilliseconds(1500))
|
||
: TimeSpan.FromMilliseconds(200);
|
||
FlushPendingAgentUiEvent();
|
||
};
|
||
_agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||
_agentProgressHintTimer.Tick += AgentProgressHintTimer_Tick;
|
||
_tokenUsagePopupCloseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
|
||
_tokenUsagePopupCloseTimer.Tick += (_, _) =>
|
||
{
|
||
_tokenUsagePopupCloseTimer.Stop();
|
||
CloseTokenUsagePopupIfIdle();
|
||
};
|
||
_responsiveLayoutTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||
_responsiveLayoutTimer.Tick += (_, _) =>
|
||
{
|
||
_responsiveLayoutTimer.Stop();
|
||
// 스트리밍 중 전체 메시지 재렌더링은 UI 부하가 크므로 연기
|
||
if (_isStreaming)
|
||
{
|
||
_pendingResponsiveLayoutRefresh = true;
|
||
return;
|
||
}
|
||
UpdateTopicPresetScrollMode();
|
||
if (UpdateResponsiveChatLayout())
|
||
RenderMessages(preserveViewport: true);
|
||
};
|
||
|
||
KeyDown += ChatWindow_KeyDown;
|
||
MouseMove += (_, _) =>
|
||
{
|
||
if (_isInWindowMoveSizeLoop)
|
||
return;
|
||
|
||
if (TokenUsagePopup?.IsOpen == true)
|
||
CloseTokenUsagePopupIfIdle();
|
||
};
|
||
PreviewMouseDown += (_, _) =>
|
||
{
|
||
if (TokenUsagePopup != null)
|
||
TokenUsagePopup.IsOpen = false;
|
||
};
|
||
Deactivated += (_, _) =>
|
||
{
|
||
if (TokenUsagePopup != null)
|
||
TokenUsagePopup.IsOpen = false;
|
||
};
|
||
Activated += (_, _) => { FlushDeferredUiRefreshIfNeeded(); EnsureEmptyStateConsistency(); };
|
||
IsVisibleChanged += (_, _) => FlushDeferredUiRefreshIfNeeded();
|
||
WindowState previousWindowState = WindowState.Normal;
|
||
StateChanged += (_, _) =>
|
||
{
|
||
FlushDeferredUiRefreshIfNeeded();
|
||
// 최소화 → 복원 시에만 렌더 — 그 외 상태 변경(최대화 등)에서는 불필요한 재렌더 방지
|
||
var currentState = WindowState;
|
||
if (previousWindowState == WindowState.Minimized
|
||
&& currentState != WindowState.Minimized
|
||
&& IsVisible
|
||
&& !_isStreaming) // 스트리밍 중에는 렌더 스킵 (EmptyState 복원 방지)
|
||
{
|
||
InvalidateTimelineCache();
|
||
RenderMessages();
|
||
}
|
||
previousWindowState = currentState;
|
||
};
|
||
UpdateConversationFailureFilterUi();
|
||
UpdateConversationSortUi();
|
||
UpdateConversationRunningFilterUi();
|
||
Loaded += (_, _) =>
|
||
{
|
||
ApplyAgentThemeResources();
|
||
|
||
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
|
||
SetupUserInfo();
|
||
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
|
||
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||
UpdateAnalyzerButtonVisibility();
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
ApplyExpressionLevelUi();
|
||
UpdateSidebarModeMenu();
|
||
RefreshContextUsageVisual();
|
||
UpdateTopicPresetScrollMode();
|
||
UpdateResponsiveChatLayout();
|
||
UpdateInputBoxHeight();
|
||
InputBox.Focus();
|
||
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
|
||
// A-1: 패널 이벤트 위임 1회 초기화 — 개별 람다 대신 부모 레벨에서 처리
|
||
InitConversationPanelDelegation();
|
||
InitTopicPanelDelegation();
|
||
InitPreviewSplitButtonHover();
|
||
InitPlanButtonHover();
|
||
|
||
// 앱 시작 시 이전 세션에서 남은 고착 스트리밍 상태 정리
|
||
_streamingTabs.Clear();
|
||
_tabStreamCts.Clear();
|
||
ViewModel.IsStreaming = false;
|
||
_streamRunTab = null;
|
||
|
||
// 실행이력 상세도 레이블 + 아이콘 초기화
|
||
if (ExecutionLogLabel != null)
|
||
{
|
||
var initLogLabel = (_settings.Settings.Llm.AgentLogLevel ?? "detailed") switch
|
||
{
|
||
"hidden" => "숨김",
|
||
"simple" => "간략",
|
||
"debug" => "디버그",
|
||
_ => "상세",
|
||
};
|
||
ExecutionLogLabel.Text = $"실행이력 · {initLogLabel}";
|
||
}
|
||
if (ExecutionLogIcon != null)
|
||
ExecutionLogIcon.Text = "\uE8F8";
|
||
|
||
Dispatcher.BeginInvoke(new Action(() =>
|
||
{
|
||
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
|
||
RestoreLastConversations();
|
||
BuildTopicButtons();
|
||
|
||
// 마스코트 캐릭터가 "none"이 아니면 앱 시작 시 GIF 미리 로드 (화면에 안 보여도)
|
||
var mascotLvl = (_settings.Settings.Llm.Code?.MascotLevel ?? "none").Trim().ToLowerInvariant();
|
||
if (mascotLvl != "none")
|
||
InitializeMascot();
|
||
RefreshConversationList();
|
||
UpdateResponsiveChatLayout();
|
||
UpdateTaskSummaryIndicators();
|
||
ScheduleGitBranchRefresh();
|
||
|
||
// 데이터 정리 (디스크 I/O)
|
||
_ = Task.Run(() =>
|
||
{
|
||
var retention = _settings.Settings.Llm.RetentionDays;
|
||
if (retention > 0) _storage.PurgeExpired(retention);
|
||
_storage.PurgeForDiskSpace();
|
||
});
|
||
}), System.Windows.Threading.DispatcherPriority.Loaded);
|
||
|
||
// 입력 바 포커스 글로우 효과
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var inputFocusBorderBrush = TryFindResource("InputFocusBorderColor") as Brush ?? borderBrush;
|
||
InputBox.GotFocus += (_, _) => InputBorder.BorderBrush = inputFocusBorderBrush;
|
||
InputBox.LostFocus += (_, _) => InputBorder.BorderBrush = borderBrush;
|
||
|
||
// 드래그 앤 드롭 파일 첨부 — Claude Desktop 스타일
|
||
InputBorder.AllowDrop = true;
|
||
Brush? _dragOverOriginalBorder = null;
|
||
InputBorder.DragEnter += (_, de) =>
|
||
{
|
||
if (de.Data.GetDataPresent(DataFormats.FileDrop))
|
||
{
|
||
_dragOverOriginalBorder = InputBorder.BorderBrush;
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
InputBorder.BorderBrush = accent;
|
||
InputBorder.BorderThickness = new Thickness(2);
|
||
}
|
||
};
|
||
InputBorder.DragLeave += (_, _) =>
|
||
{
|
||
if (_dragOverOriginalBorder != null)
|
||
{
|
||
InputBorder.BorderBrush = _dragOverOriginalBorder;
|
||
InputBorder.BorderThickness = new Thickness(1);
|
||
_dragOverOriginalBorder = null;
|
||
}
|
||
};
|
||
InputBorder.DragOver += (_, de) =>
|
||
{
|
||
de.Effects = de.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||
de.Handled = true;
|
||
};
|
||
InputBorder.Drop += (_, de) =>
|
||
{
|
||
// 드래그 오버 하이라이트 복원
|
||
if (_dragOverOriginalBorder != null)
|
||
{
|
||
InputBorder.BorderBrush = _dragOverOriginalBorder;
|
||
InputBorder.BorderThickness = new Thickness(1);
|
||
_dragOverOriginalBorder = null;
|
||
}
|
||
|
||
if (de.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
|
||
{
|
||
// AI 액션 팝업은 Cowork/Code 탭에서만, 그 외에는 항상 단순 첨부
|
||
var enableAi = _settings.Settings.Llm.EnableDragDropAiActions
|
||
&& _activeTab is "Cowork" or "Code";
|
||
if (enableAi && files.Length <= 5)
|
||
ShowDropActionMenu(files);
|
||
else
|
||
foreach (var f in files) AddAttachedFile(f);
|
||
|
||
InputBox.Focus();
|
||
}
|
||
};
|
||
|
||
// 스킬 시스템 초기화
|
||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||
{
|
||
SkillService.EnsureSkillFolder();
|
||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
}
|
||
|
||
// 슬래시 명령어 칩 닫기 (× 버튼)
|
||
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);
|
||
ApplyHoverScaleAnimation(BtnSend, 1.12);
|
||
ApplyHoverScaleAnimation(BtnStop, 1.12);
|
||
};
|
||
SizeChanged += (_, _) =>
|
||
{
|
||
if (_isInWindowMoveSizeLoop)
|
||
{
|
||
_pendingResponsiveLayoutRefresh = true;
|
||
return;
|
||
}
|
||
|
||
_responsiveLayoutTimer.Stop();
|
||
_responsiveLayoutTimer.Start();
|
||
};
|
||
Closed += (_, _) =>
|
||
{
|
||
_settings.SettingsChanged -= Settings_SettingsChanged;
|
||
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
|
||
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
|
||
|
||
// 모든 DispatcherTimer 명시적 Stop — auto-stop에만 의존하지 않음
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
_conversationSearchTimer.Stop();
|
||
_inputUiRefreshTimer.Stop();
|
||
_responsiveLayoutTimer.Stop();
|
||
_gitRefreshTimer.Stop();
|
||
_executionHistoryRenderTimer.Stop();
|
||
_taskSummaryRefreshTimer.Stop();
|
||
_conversationPersistTimer.Stop();
|
||
_agentUiEventTimer.Stop();
|
||
_agentProgressHintTimer.Stop();
|
||
_tokenUsagePopupCloseTimer.Stop();
|
||
_smoothScrollTimer?.Stop();
|
||
_sidebarAnimTimer?.Stop();
|
||
_fileBrowserRefreshTimer?.Stop();
|
||
|
||
StopRainbowGlow();
|
||
StopAgentEventProcessor();
|
||
_llm.Dispose();
|
||
};
|
||
}
|
||
|
||
private void Settings_SettingsChanged(object? sender, EventArgs e)
|
||
{
|
||
if (_forceClose || !IsLoaded || _isRefreshingFromSettings)
|
||
return;
|
||
|
||
Dispatcher.BeginInvoke(new Action(() =>
|
||
{
|
||
if (_forceClose || !IsLoaded)
|
||
return;
|
||
|
||
_isRefreshingFromSettings = true;
|
||
try
|
||
{
|
||
RefreshFromSavedSettings();
|
||
}
|
||
finally
|
||
{
|
||
_isRefreshingFromSettings = false;
|
||
}
|
||
}), DispatcherPriority.Input);
|
||
}
|
||
|
||
private bool IsPermissionAutoApprovedForSession(string tab, string toolName, string target)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
|
||
return false;
|
||
|
||
var normalizedTarget = target.Trim();
|
||
var pathLikeTool = IsPathLikePermissionTool(toolName);
|
||
List<string> rulesSnapshot;
|
||
lock (_sessionPermissionRulesLock)
|
||
{
|
||
if (!_sessionPermissionRules.TryGetValue(tab, out var rules) || rules.Count == 0)
|
||
return false;
|
||
rulesSnapshot = rules.ToList();
|
||
}
|
||
|
||
foreach (var rule in rulesSnapshot)
|
||
{
|
||
var pivot = rule.IndexOf('|');
|
||
if (pivot <= 0 || pivot >= rule.Length - 1)
|
||
continue;
|
||
|
||
var ruleTool = rule[..pivot];
|
||
var ruleTarget = rule[(pivot + 1)..];
|
||
if (!string.Equals(ruleTool, toolName, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
if (pathLikeTool)
|
||
{
|
||
if (normalizedTarget.StartsWith(ruleTarget, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
}
|
||
else if (string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static string NormalizePermissionTarget(string toolName, string target, string? workspaceFolder)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(target))
|
||
return target;
|
||
|
||
var normalizedTarget = target.Trim();
|
||
if (!IsPathLikePermissionTool(toolName))
|
||
return normalizedTarget;
|
||
|
||
try
|
||
{
|
||
if (System.IO.Path.IsPathRooted(normalizedTarget))
|
||
return System.IO.Path.GetFullPath(normalizedTarget);
|
||
|
||
if (!string.IsNullOrWhiteSpace(workspaceFolder))
|
||
return System.IO.Path.GetFullPath(System.IO.Path.Combine(workspaceFolder, normalizedTarget));
|
||
|
||
return System.IO.Path.GetFullPath(normalizedTarget);
|
||
}
|
||
catch
|
||
{
|
||
return normalizedTarget;
|
||
}
|
||
}
|
||
|
||
private void RememberPermissionRuleForSession(string tab, string toolName, string target)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
|
||
return;
|
||
|
||
var normalizedTarget = target.Trim();
|
||
var scopedTarget = normalizedTarget;
|
||
|
||
if (IsPathLikePermissionTool(toolName))
|
||
{
|
||
try
|
||
{
|
||
if (System.IO.Path.IsPathRooted(normalizedTarget))
|
||
{
|
||
var full = System.IO.Path.GetFullPath(normalizedTarget);
|
||
var directory = System.IO.Path.GetDirectoryName(full);
|
||
if (!string.IsNullOrWhiteSpace(directory))
|
||
scopedTarget = directory.TrimEnd('\\', '/') + System.IO.Path.DirectorySeparatorChar;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Ignore invalid path and fall back to exact target matching.
|
||
}
|
||
}
|
||
|
||
lock (_sessionPermissionRulesLock)
|
||
{
|
||
if (!_sessionPermissionRules.TryGetValue(tab, out var rules))
|
||
{
|
||
rules = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
_sessionPermissionRules[tab] = rules;
|
||
}
|
||
|
||
rules.Add($"{toolName}|{scopedTarget}");
|
||
}
|
||
}
|
||
|
||
private void ResetPermissionRulesForRun(string tab)
|
||
{
|
||
lock (_sessionPermissionRulesLock)
|
||
{
|
||
_sessionPermissionRules.Remove(tab);
|
||
}
|
||
}
|
||
|
||
private static bool IsPathLikePermissionTool(string toolName)
|
||
{
|
||
var normalized = toolName.Trim().ToLowerInvariant();
|
||
return normalized.Contains("file", StringComparison.Ordinal)
|
||
|| normalized.Contains("edit", StringComparison.Ordinal)
|
||
|| normalized.Contains("write", StringComparison.Ordinal);
|
||
}
|
||
|
||
/// <summary>경로가 지정 폴더 외부인지 확인합니다.</summary>
|
||
private static bool IsPathOutsideFolder(string path, string folder)
|
||
{
|
||
try
|
||
{
|
||
var fullPath = System.IO.Path.IsPathRooted(path)
|
||
? System.IO.Path.GetFullPath(path).TrimEnd('\\', '/')
|
||
: System.IO.Path.GetFullPath(System.IO.Path.Combine(folder, path)).TrimEnd('\\', '/');
|
||
var fullFolder = System.IO.Path.GetFullPath(folder).TrimEnd('\\', '/');
|
||
return !fullPath.StartsWith(fullFolder, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <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()
|
||
{
|
||
// 백그라운드 이벤트 프로세서 종료 (미저장 대화 플러시됨)
|
||
StopAgentEventProcessor();
|
||
|
||
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
|
||
lock (_convLock)
|
||
{
|
||
ChatSession?.SaveCurrentConversation(_storage, _activeTab);
|
||
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
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 (GetTranscriptScrollableHeight() <= 1)
|
||
{
|
||
_userScrolled = false;
|
||
return;
|
||
}
|
||
|
||
// 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지
|
||
if (Math.Abs(e.ExtentHeightChange) > 0.5)
|
||
return;
|
||
|
||
var atBottom = GetTranscriptVerticalOffset() >= GetTranscriptScrollableHeight() - 120;
|
||
_userScrolled = !atBottom;
|
||
if (ScrollToBottomFab != null)
|
||
ScrollToBottomFab.Visibility = _userScrolled ? Visibility.Visible : Visibility.Collapsed;
|
||
}
|
||
|
||
private void ScrollToBottomFab_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
ForceScrollToEnd();
|
||
}
|
||
|
||
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 = GetTranscriptScrollableHeight();
|
||
var currentOffset = GetTranscriptVerticalOffset();
|
||
var diff = targetOffset - currentOffset;
|
||
|
||
// 스트리밍 중이거나 차이가 작으면 즉시 이동 (UI 부하 최소화)
|
||
if (diff <= 60 || _isStreaming)
|
||
{
|
||
ScrollTranscriptToEnd();
|
||
return;
|
||
}
|
||
|
||
// 부드럽게 스크롤 (DoubleAnimation)
|
||
var animation = new DoubleAnimation
|
||
{
|
||
From = currentOffset,
|
||
To = targetOffset,
|
||
Duration = TimeSpan.FromMilliseconds(200),
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
|
||
};
|
||
animation.Completed += (_, _) => ScrollTranscriptToVerticalOffset(targetOffset);
|
||
|
||
// ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간
|
||
// 기존 스크롤 애니메이션이 진행 중이면 즉시 종료
|
||
_smoothScrollTimer?.Stop();
|
||
var startTime = DateTime.UtcNow;
|
||
_smoothScrollStartOffset = currentOffset;
|
||
_smoothScrollDiff = diff;
|
||
_smoothScrollStartTime = startTime;
|
||
if (_smoothScrollTimer == null)
|
||
{
|
||
_smoothScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(32) }; // ~30fps (충분히 부드러움)
|
||
_smoothScrollTimer.Tick += SmoothScrollTimer_Tick;
|
||
}
|
||
_smoothScrollTimer.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;
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
|
||
else
|
||
_currentConversation.Title = newTitle;
|
||
}
|
||
|
||
ViewModel.ChatTitle = newTitle;
|
||
try
|
||
{
|
||
if (ChatSession == null)
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv != null) _storage.Save(conv);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Warn($"제목 변경 저장 실패: {ex.Message}");
|
||
}
|
||
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 = s_segoeIconFont,
|
||
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")
|
||
{
|
||
// 코워크: 작업 유형 기반 필터
|
||
var presets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets);
|
||
var categoryItems = presets
|
||
.Select(p => (Category: p.Category, Label: p.Label, Symbol: p.Symbol, Color: p.Color))
|
||
.Concat(_storage.LoadAllMeta()
|
||
.Where(c => string.Equals(NormalizeTabName(c.Tab), "Cowork", StringComparison.OrdinalIgnoreCase))
|
||
.Select(c => (Category: c.Category?.Trim() ?? "", Label: c.Category?.Trim() ?? "", Symbol: "\uE8BD", Color: "#6B7280")))
|
||
.Where(item => !string.IsNullOrWhiteSpace(item.Category))
|
||
.DistinctBy(item => item.Category, StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(item => item.Label, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
foreach (var categoryItem in categoryItems)
|
||
{
|
||
var capturedCategory = categoryItem.Category;
|
||
var brush = BrushFromHex(categoryItem.Color);
|
||
stack.Children.Add(CreateCatItem(categoryItem.Symbol, categoryItem.Label, brush,
|
||
string.Equals(_selectedCategory, capturedCategory, StringComparison.OrdinalIgnoreCase),
|
||
() => { _selectedCategory = capturedCategory; UpdateCategoryLabel(); RefreshConversationList(); }));
|
||
}
|
||
}
|
||
else if (_activeTab == "Code")
|
||
{
|
||
// 코드: 워크스페이스 기반 필터
|
||
var workspaces = _storage.LoadAllMeta()
|
||
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
.Select(c => c.WorkFolder?.Trim() ?? "")
|
||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(path => System.IO.Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
|
||
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
foreach (var workspace in workspaces)
|
||
{
|
||
var displayName = System.IO.Path.GetFileName(workspace.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
|
||
if (string.IsNullOrWhiteSpace(displayName))
|
||
displayName = workspace;
|
||
|
||
var capturedWorkspace = workspace;
|
||
var item = CreateCatItem("\uE8B7", displayName, accentBrush,
|
||
string.Equals(_selectedCategory, capturedWorkspace, StringComparison.OrdinalIgnoreCase),
|
||
() => { _selectedCategory = capturedWorkspace; UpdateCategoryLabel(); RefreshConversationList(); });
|
||
item.ToolTip = workspace;
|
||
stack.Children.Add(item);
|
||
}
|
||
}
|
||
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" => "모든 작업 유형",
|
||
"Code" => "모든 프로젝트",
|
||
_ => "주제 선택",
|
||
};
|
||
CategoryIcon.Text = "\uE8BD";
|
||
}
|
||
else if (_activeTab == "Code")
|
||
{
|
||
var displayName = System.IO.Path.GetFileName(_selectedCategory.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
|
||
CategoryLabel.Text = string.IsNullOrWhiteSpace(displayName) ? _selectedCategory : displayName;
|
||
CategoryIcon.Text = "\uE8B7";
|
||
}
|
||
else if (_activeTab == "Cowork")
|
||
{
|
||
var preset = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
|
||
.FirstOrDefault(p => string.Equals(p.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase));
|
||
CategoryLabel.Text = preset?.Label ?? _selectedCategory;
|
||
CategoryIcon.Text = preset?.Symbol ?? "\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";
|
||
}
|
||
}
|
||
}
|
||
|
||
private void UpdateSidebarModeMenu()
|
||
{
|
||
if (SidebarChatMenu == null || SidebarCoworkMenu == null || SidebarCodeMenu == null)
|
||
return;
|
||
|
||
// 좌측 필터는 상단 공통 드롭다운 하나만 유지한다.
|
||
// 탭별 중복 메뉴(주제/작업 유형/워크스페이스)는 항상 숨긴다.
|
||
if (SidebarChatMenu.Visibility != Visibility.Collapsed) SidebarChatMenu.Visibility = Visibility.Collapsed;
|
||
if (SidebarCoworkMenu.Visibility != Visibility.Collapsed) SidebarCoworkMenu.Visibility = Visibility.Collapsed;
|
||
if (SidebarCodeMenu.Visibility != Visibility.Collapsed) SidebarCodeMenu.Visibility = Visibility.Collapsed;
|
||
|
||
if (SidebarModeBadgeTitle != null && SidebarModeBadgeIcon != null)
|
||
{
|
||
if (_activeTab == "Cowork")
|
||
{
|
||
SidebarModeBadgeTitle.Text = "Cowork 메뉴";
|
||
SidebarModeBadgeIcon.Text = "\uE8FD";
|
||
}
|
||
else if (_activeTab == "Code")
|
||
{
|
||
SidebarModeBadgeTitle.Text = "Code 메뉴";
|
||
SidebarModeBadgeIcon.Text = "\uE943";
|
||
}
|
||
else
|
||
{
|
||
SidebarModeBadgeTitle.Text = "Chat 메뉴";
|
||
SidebarModeBadgeIcon.Text = "\uE8BD";
|
||
}
|
||
}
|
||
|
||
if (SidebarChatRunningState != null)
|
||
SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF";
|
||
}
|
||
|
||
private void SidebarChatAll_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_selectedCategory = "";
|
||
UpdateCategoryLabel();
|
||
RefreshConversationList();
|
||
BuildTopicButtons();
|
||
// 현재 대화에 메시지가 있으면 EmptyState를 표시하지 않음
|
||
if (EmptyState != null)
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
EmptyState.Visibility = (conv?.Messages?.Count ?? 0) > 0
|
||
? Visibility.Collapsed
|
||
: Visibility.Visible;
|
||
}
|
||
if (TopicButtonPanel != null)
|
||
TopicButtonPanel.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
private void SidebarChatRunning_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
=> BtnRunningOnlyFilter_Click(this, new RoutedEventArgs());
|
||
|
||
private void SidebarCoworkCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
=> BtnCategoryDrop_Click(this, new RoutedEventArgs());
|
||
|
||
private void SidebarCoworkPreset_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
BuildTopicButtons();
|
||
ShowToast("프리셋 카드가 갱신되었습니다.");
|
||
}
|
||
|
||
private void SidebarCoworkExecution_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
=> BtnToggleExecutionLog_Click(this, new RoutedEventArgs());
|
||
|
||
private void SidebarCodeCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
=> BtnCategoryDrop_Click(this, new RoutedEventArgs());
|
||
|
||
private void SidebarCodeLanguage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
BuildCodeBottomBar();
|
||
ShowToast("코드 옵션 메뉴를 갱신했습니다.");
|
||
}
|
||
|
||
private void SidebarCodeFiles_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (FileBrowserPanel == null)
|
||
return;
|
||
|
||
var visible = FileBrowserPanel.Visibility == Visibility.Visible;
|
||
FileBrowserPanel.Visibility = visible ? Visibility.Collapsed : Visibility.Visible;
|
||
if (!visible)
|
||
BuildFileTree();
|
||
}
|
||
|
||
// ─── 창 컨트롤 ──────────────────────────────────────────────────────
|
||
|
||
// 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 Uri? _lastAppliedThemeUri; // 마지막으로 적용된 테마 URI (중복 교체 방지)
|
||
|
||
private void ApplyAgentThemeResources()
|
||
{
|
||
var themeUri = BuildAgentThemeDictionaryUri();
|
||
|
||
// 같은 테마가 이미 적용되어 있으면 ResourceDictionary 교체 스킵
|
||
// — Remove+Insert가 WPF 템플릿 재평가 → RadioButton Checked 이벤트 재발화 →
|
||
// 탭 전환 무한 루프 + UI 멈춤의 근본 원인이었음
|
||
if (_lastAppliedThemeUri != null
|
||
&& Uri.Equals(_lastAppliedThemeUri, themeUri)
|
||
&& _agentThemeDictionary != null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var isThemeSwitch = _lastAppliedThemeUri != null;
|
||
|
||
try
|
||
{
|
||
if (_agentThemeDictionary != null)
|
||
Resources.MergedDictionaries.Remove(_agentThemeDictionary);
|
||
|
||
_agentThemeDictionary = new ResourceDictionary
|
||
{
|
||
Source = themeUri,
|
||
};
|
||
Resources.MergedDictionaries.Insert(0, _agentThemeDictionary);
|
||
_lastAppliedThemeUri = themeUri;
|
||
}
|
||
catch
|
||
{
|
||
// 테마 로드 실패 시 기본 리소스 유지
|
||
return;
|
||
}
|
||
|
||
// 테마 전환 시 메시지 버블은 TryFindResource로 Brush 인스턴스를 스냅샷 캡처하므로
|
||
// 기존 렌더된 TextBlock의 Foreground가 옛 Brush를 그대로 유지한다.
|
||
// → _elementCache를 비우고 RenderMessages()로 재구성하여 새 테마 색상을 적용.
|
||
if (isThemeSwitch)
|
||
{
|
||
try
|
||
{
|
||
_elementCache.Clear();
|
||
RenderMessages(preserveViewport: true);
|
||
}
|
||
catch
|
||
{
|
||
// 재렌더 실패는 치명적이지 않음 — 다음 자연스러운 재렌더 시 복구됨
|
||
}
|
||
}
|
||
}
|
||
|
||
private Uri BuildAgentThemeDictionaryUri()
|
||
{
|
||
var mode = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
|
||
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claude").Trim().ToLowerInvariant() switch
|
||
{
|
||
"codex" => "Codex",
|
||
"nord" => "Nord",
|
||
"ember" => "Ember",
|
||
"slate" => "Slate",
|
||
"claude" => "Claude",
|
||
_ => "Claw",
|
||
};
|
||
var effectiveMode = mode switch
|
||
{
|
||
"light" => "Light",
|
||
"dark" => "Dark",
|
||
_ => "System",
|
||
};
|
||
|
||
var candidate = $"pack://application:,,,/Themes/Agent{preset}{effectiveMode}.xaml";
|
||
return new Uri(candidate);
|
||
}
|
||
|
||
private static bool IsSystemDarkTheme()
|
||
{
|
||
try
|
||
{
|
||
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
|
||
return key?.GetValue("AppsUseLightTheme") is int v && v == 0;
|
||
}
|
||
catch
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||
{
|
||
if (msg == 0x0231) // WM_ENTERSIZEMOVE
|
||
{
|
||
BeginWindowMoveSizeLoop();
|
||
}
|
||
else if (msg == 0x0232) // WM_EXITSIZEMOVE
|
||
{
|
||
EndWindowMoveSizeLoop();
|
||
}
|
||
|
||
// 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 DispatcherTimer[] GetSuspendableTimers() => new[]
|
||
{
|
||
_cursorTimer, _elapsedTimer, _typingTimer, _gitRefreshTimer,
|
||
_conversationSearchTimer, _inputUiRefreshTimer, _executionHistoryRenderTimer,
|
||
_taskSummaryRefreshTimer, _conversationPersistTimer, _agentUiEventTimer,
|
||
_agentProgressHintTimer, _tokenUsagePopupCloseTimer, _responsiveLayoutTimer,
|
||
};
|
||
private readonly List<DispatcherTimer> _timersRunningBeforeMove = new();
|
||
|
||
private void BeginWindowMoveSizeLoop()
|
||
{
|
||
if (_isInWindowMoveSizeLoop)
|
||
return;
|
||
|
||
_isInWindowMoveSizeLoop = true;
|
||
_pendingResponsiveLayoutRefresh = false;
|
||
|
||
// 비필수 타이머 일시 정지 → 드래그 중 UI 부하 최소화
|
||
_timersRunningBeforeMove.Clear();
|
||
foreach (var t in GetSuspendableTimers())
|
||
{
|
||
if (t.IsEnabled)
|
||
{
|
||
_timersRunningBeforeMove.Add(t);
|
||
t.Stop();
|
||
}
|
||
}
|
||
|
||
// Storyboard 일시 정지
|
||
_pulseDotStoryboard?.Pause();
|
||
_statusDiamondStoryboard?.Pause();
|
||
|
||
if (Content is UIElement rootElement)
|
||
{
|
||
_cachedRootCacheModeBeforeMove = rootElement.CacheMode;
|
||
rootElement.CacheMode = new BitmapCache();
|
||
}
|
||
}
|
||
|
||
private void EndWindowMoveSizeLoop()
|
||
{
|
||
if (!_isInWindowMoveSizeLoop)
|
||
return;
|
||
|
||
_isInWindowMoveSizeLoop = false;
|
||
|
||
if (Content is UIElement rootElement)
|
||
rootElement.CacheMode = _cachedRootCacheModeBeforeMove;
|
||
|
||
_cachedRootCacheModeBeforeMove = null;
|
||
|
||
// 타이머 복원
|
||
foreach (var t in _timersRunningBeforeMove)
|
||
t.Start();
|
||
_timersRunningBeforeMove.Clear();
|
||
|
||
// Storyboard 복원
|
||
_pulseDotStoryboard?.Resume();
|
||
_statusDiamondStoryboard?.Resume();
|
||
|
||
if (_pendingResponsiveLayoutRefresh)
|
||
{
|
||
_pendingResponsiveLayoutRefresh = false;
|
||
UpdateTopicPresetScrollMode();
|
||
if (UpdateResponsiveChatLayout())
|
||
RenderMessages(preserveViewport: true);
|
||
}
|
||
}
|
||
|
||
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 ChatSessionStateService? ChatSession => _appState.ChatSession;
|
||
|
||
private void SaveCurrentTabConversationId()
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
ChatSession?.SaveCurrentConversation(_storage, _activeTab);
|
||
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
// 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용)
|
||
SaveLastConversations();
|
||
}
|
||
|
||
/// <summary>탭 전환 전 스트리밍 중이면 즉시 중단합니다.</summary>
|
||
private void StopStreamingIfActive()
|
||
{
|
||
if (!_isStreaming) return;
|
||
// 모든 탭 스트리밍 중단
|
||
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
StopLiveAgentProgressHints();
|
||
StopRainbowGlow();
|
||
HideStickyProgress();
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_cachedStreamContent = "";
|
||
_streamingTabs.Clear();
|
||
ViewModel.IsStreaming = false;
|
||
_streamRunTab = null;
|
||
_streamStartTime = default;
|
||
BtnSend.IsEnabled = true;
|
||
BtnStop.Visibility = Visibility.Collapsed;
|
||
BtnPause.Visibility = Visibility.Collapsed;
|
||
PauseIcon.Text = "\uE769"; // 리셋
|
||
BtnSend.Visibility = Visibility.Visible;
|
||
foreach (var cts in _tabStreamCts.Values) cts.Dispose();
|
||
_tabStreamCts.Clear();
|
||
SetStatusIdle();
|
||
}
|
||
|
||
private void RefreshStreamingControlsForActiveTab()
|
||
{
|
||
var isOwningTab = _streamingTabs.Contains(_activeTab);
|
||
|
||
// 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨
|
||
BtnSend.IsEnabled = true;
|
||
BtnSend.Visibility = Visibility.Visible;
|
||
BtnStop.Visibility = isOwningTab ? Visibility.Visible : Visibility.Collapsed;
|
||
BtnPause.Visibility = isOwningTab && (_activeTab == "Cowork" || _activeTab == "Code")
|
||
? Visibility.Visible
|
||
: Visibility.Collapsed;
|
||
if (!isOwningTab) PauseIcon.Text = "\uE769";
|
||
|
||
// 스트리밍 중인 탭이 아니면 펄스 닷·상태 바 숨김 (다른 탭 작업 상태가 보이지 않도록)
|
||
// 스트리밍 탭으로 복귀 시 자동 복원
|
||
if (PulseDotBar != null)
|
||
{
|
||
if (!isOwningTab)
|
||
PulseDotBar.Visibility = Visibility.Collapsed;
|
||
else if (_streamingTabs.Count > 0)
|
||
PulseDotBar.Visibility = Visibility.Visible;
|
||
}
|
||
}
|
||
|
||
private bool _isUpdatingTab; // UpdateTabUI 재진입 방지 플래그
|
||
private long _lastTabUpdateTicks; // UpdateTabUI 완료 시각 — 비동기 Checked 이벤트 재발화 차단용
|
||
private const long TabSwitchCooldownMs = 250; // ResourceDictionary 교체 후 비동기 이벤트 무시 기간
|
||
|
||
/// <summary>비동기 Checked 재발화 여부 검사. true이면 무시해야 함.</summary>
|
||
private bool IsTabSwitchSuppressed(string tabName)
|
||
{
|
||
if (_isUpdatingTab) return true;
|
||
if (_activeTab == tabName) return true;
|
||
// ApplyAgentThemeResources의 ResourceDictionary 교체가 WPF RadioButton Checked를
|
||
// 비동기로 재발화하는 문제 방지 — UpdateTabUI 완료 후 짧은 시간 내 도착하는 이벤트 무시
|
||
if (Environment.TickCount64 - _lastTabUpdateTicks < TabSwitchCooldownMs) return true;
|
||
return false;
|
||
}
|
||
|
||
private void TabChat_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (IsTabSwitchSuppressed("Chat"))
|
||
{
|
||
Services.LogService.Debug($"[TabSwitch] Chat_Checked SUPPRESSED (activeTab={_activeTab}, updating={_isUpdatingTab}, cooldown={Environment.TickCount64 - _lastTabUpdateTicks}ms)");
|
||
return;
|
||
}
|
||
Services.LogService.Info($"[TabSwitch] Chat ← {_activeTab}, streaming={_isStreaming}, emptyState={EmptyState.Visibility}");
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = "Chat";
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
Services.LogService.Info($"[TabSwitch] Chat DONE, emptyState={EmptyState.Visibility}");
|
||
}
|
||
|
||
private void TabCowork_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (IsTabSwitchSuppressed("Cowork"))
|
||
{
|
||
Services.LogService.Debug($"[TabSwitch] Cowork_Checked SUPPRESSED (activeTab={_activeTab}, updating={_isUpdatingTab}, cooldown={Environment.TickCount64 - _lastTabUpdateTicks}ms)");
|
||
return;
|
||
}
|
||
Services.LogService.Info($"[TabSwitch] Cowork ← {_activeTab}, streaming={_isStreaming}, emptyState={EmptyState.Visibility}");
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = "Cowork";
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
Services.LogService.Info($"[TabSwitch] Cowork DONE, emptyState={EmptyState.Visibility}");
|
||
}
|
||
|
||
private void TabCode_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (IsTabSwitchSuppressed("Code"))
|
||
{
|
||
Services.LogService.Debug($"[TabSwitch] Code_Checked SUPPRESSED (activeTab={_activeTab}, updating={_isUpdatingTab}, cooldown={Environment.TickCount64 - _lastTabUpdateTicks}ms)");
|
||
return;
|
||
}
|
||
Services.LogService.Info($"[TabSwitch] Code ← {_activeTab}, streaming={_isStreaming}, emptyState={EmptyState.Visibility}");
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = "Code";
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
Services.LogService.Info($"[TabSwitch] Code DONE, emptyState={EmptyState.Visibility}");
|
||
}
|
||
|
||
/// <summary>탭별로 마지막으로 활성화된 대화 ID를 기억.</summary>
|
||
private readonly Dictionary<string, string?> _tabConversationId = new()
|
||
{
|
||
["Chat"] = null, ["Cowork"] = null, ["Code"] = null,
|
||
};
|
||
|
||
/// <summary>
|
||
/// ApplyAgentThemeResources 후 올바른 탭 RadioButton을 강제 체크합니다.
|
||
/// ResourceDictionary 교체로 WPF가 비동기 Checked 이벤트를 발화하는데,
|
||
/// 해당 이벤트가 도착했을 때 이미 올바른 RadioButton이 체크되어 있으면
|
||
/// Checked 핸들러의 "if (_activeTab == X) return;" 가드에 의해 무시됩니다.
|
||
/// </summary>
|
||
private void ForceCheckActiveTabRadioButton()
|
||
{
|
||
var target = _activeTab switch
|
||
{
|
||
"Chat" => TabChat,
|
||
"Code" => TabCode,
|
||
_ => TabCowork,
|
||
};
|
||
if (target != null && target.IsChecked != true)
|
||
target.IsChecked = true;
|
||
}
|
||
|
||
private void SyncTabConversationIdsFromSession()
|
||
{
|
||
var session = ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
foreach (var key in _tabConversationId.Keys.ToList())
|
||
_tabConversationId[key] = session.GetConversationId(key);
|
||
}
|
||
|
||
private void SyncTabConversationIdsToSession()
|
||
{
|
||
var session = ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
foreach (var kv in _tabConversationId)
|
||
session.RememberConversation(kv.Key, kv.Value);
|
||
}
|
||
|
||
private void UpdateTabUI()
|
||
{
|
||
if (_isUpdatingTab) return;
|
||
_isUpdatingTab = true;
|
||
try
|
||
{
|
||
ViewModel.ActiveTab = _activeTab;
|
||
ApplyAgentThemeResources();
|
||
// ApplyAgentThemeResources가 ResourceDictionary를 교체하면
|
||
// WPF가 RadioButton Checked 이벤트를 비동기로 재발화합니다.
|
||
// 올바른 탭 RadioButton을 강제 체크하여 비동기 이벤트가 도착해도
|
||
// 이미 같은 탭이므로 Checked 핸들러의 if(_activeTab==X) return; 에 걸려 무시됩니다.
|
||
ForceCheckActiveTabRadioButton();
|
||
ApplyExpressionLevelUi();
|
||
ApplySidebarStateForActiveTab(animated: false);
|
||
RefreshStreamingControlsForActiveTab();
|
||
if (CurrentTabTitle != null)
|
||
{
|
||
CurrentTabTitle.Text = _activeTab switch
|
||
{
|
||
"Cowork" => "AX Agent · Cowork",
|
||
"Code" => "AX Agent · Code",
|
||
_ => "AX Agent · Chat",
|
||
};
|
||
}
|
||
|
||
// 폴더 바는 Cowork/Code 탭에서만 표시
|
||
if (FolderBar != null)
|
||
{
|
||
var folderShouldShow = _activeTab != "Chat";
|
||
if (folderShouldShow && FolderBar.Visibility != Visibility.Visible)
|
||
{
|
||
FolderBar.Opacity = 0;
|
||
FolderBar.Visibility = Visibility.Visible;
|
||
var fadeIn = new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))
|
||
{
|
||
EasingFunction = new System.Windows.Media.Animation.QuadraticEase()
|
||
};
|
||
FolderBar.BeginAnimation(OpacityProperty, fadeIn);
|
||
}
|
||
else if (!folderShouldShow)
|
||
{
|
||
FolderBar.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
|
||
// 탭별 입력 안내 문구
|
||
RefreshInputWatermarkText();
|
||
|
||
// 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용)
|
||
ApplyTabDefaultPermission();
|
||
|
||
// 포맷/디자인 드롭다운은 Cowork 탭에서만 표시
|
||
if (_activeTab == "Cowork")
|
||
{
|
||
BuildBottomBar();
|
||
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
else if (_activeTab == "Code")
|
||
{
|
||
// Code 탭: 언어 선택기
|
||
BuildCodeBottomBar();
|
||
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
else
|
||
{
|
||
MoodIconPanel.Children.Clear();
|
||
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
|
||
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
// 탭별 프리셋 버튼 재구성
|
||
BuildTopicButtons();
|
||
UpdateSidebarModeMenu();
|
||
|
||
// 현재 대화를 해당 탭 대화로 전환
|
||
SwitchToTabConversation();
|
||
|
||
// Cowork/Code 탭 전환 시 팁 표시
|
||
ShowRandomTip();
|
||
|
||
// 탭 전환 시 해당 탭의 대기 중인 대기열 항목 자동 재개
|
||
// (다른 탭 실행 중 이 탭의 이전 draft가 완료됐을 때 다음 항목이 시작되지 않은 경우)
|
||
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), System.Windows.Threading.DispatcherPriority.Background);
|
||
}
|
||
finally
|
||
{
|
||
_isUpdatingTab = false;
|
||
_lastTabUpdateTicks = Environment.TickCount64;
|
||
}
|
||
}
|
||
|
||
private void PersistPerTabUiState()
|
||
{
|
||
_tabSelectedCategory[_activeTab] = _selectedCategory;
|
||
_tabSidebarVisible[_activeTab] = _sidebarVisible;
|
||
}
|
||
|
||
private void RestorePerTabUiState()
|
||
{
|
||
if (_tabSelectedCategory.TryGetValue(_activeTab, out var category))
|
||
_selectedCategory = category ?? "";
|
||
else
|
||
_selectedCategory = "";
|
||
UpdateCategoryLabel();
|
||
|
||
if (_tabSidebarVisible.TryGetValue(_activeTab, out var visible))
|
||
_sidebarVisible = visible;
|
||
else
|
||
_sidebarVisible = true;
|
||
}
|
||
|
||
private string GetAgentUiExpressionLevel()
|
||
{
|
||
var raw = _settings.Settings.Llm.AgentUiExpressionLevel;
|
||
return (raw ?? "balanced").Trim().ToLowerInvariant() switch
|
||
{
|
||
"rich" => "rich",
|
||
"simple" => "simple",
|
||
_ => "balanced",
|
||
};
|
||
}
|
||
|
||
private void ApplyExpressionLevelUi()
|
||
{
|
||
var level = GetAgentUiExpressionLevel();
|
||
|
||
if (InputBox != null)
|
||
{
|
||
InputBox.FontSize = level == "simple" ? 13 : 14;
|
||
InputBox.MaxHeight = level switch
|
||
{
|
||
"rich" => 120,
|
||
"simple" => 96,
|
||
_ => 108,
|
||
};
|
||
UpdateInputBoxHeight();
|
||
}
|
||
|
||
if (InputWatermark != null)
|
||
{
|
||
InputWatermark.FontSize = level == "simple" ? 13 : 14;
|
||
InputWatermark.Opacity = level == "rich" ? 0.8 : 0.7;
|
||
}
|
||
|
||
if (InlineSettingsHintText != null)
|
||
InlineSettingsHintText.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible;
|
||
|
||
if (InlineSettingsQuickActions != null)
|
||
InlineSettingsQuickActions.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible;
|
||
|
||
if (_failedOnlyFilter)
|
||
{
|
||
_failedOnlyFilter = false;
|
||
RefreshConversationList();
|
||
}
|
||
}
|
||
|
||
private void SwitchToTabConversation()
|
||
{
|
||
// 진행 중인 EmptyState 숨김 애니메이션 무효화 —
|
||
// 이전 탭의 Completed 콜백이 새 탭의 EmptyState를 잘못 숨기는 것을 방지
|
||
++_emptyStateAnimationToken;
|
||
|
||
Services.LogService.Info($"[SwitchTab] START tab={_activeTab}, emptyState={EmptyState.Visibility}, streaming={_isStreaming}");
|
||
|
||
// 현재 활성 탭이 스트리밍 중일 때만 전환 차단 —
|
||
// 다른 탭이 백그라운드 스트리밍 중이어도 활성 탭 대화는 정상 로드
|
||
if (_streamingTabs.Contains(_activeTab))
|
||
{
|
||
Services.LogService.Info($"[SwitchTab] BLOCKED — 활성 탭 스트리밍 중: activeTab={_activeTab}");
|
||
return;
|
||
}
|
||
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
|
||
Services.LogService.Info($"[SwitchTab] LoadOrCreate: tab={_activeTab}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, " +
|
||
$"msgCount={conv.Messages?.Count ?? 0}, convTab={conv.Tab}");
|
||
|
||
ChatConversation? cur;
|
||
lock (_convLock) cur = _currentConversation;
|
||
var curId = cur?.Id?[..Math.Min(8, cur?.Id?.Length ?? 0)] ?? "null";
|
||
|
||
if (cur != null && string.Equals(cur.Id, conv.Id, StringComparison.Ordinal))
|
||
{
|
||
Services.LogService.Info($"[SwitchTab] SAME_CONV path: curId={curId}, calling RenderMessages");
|
||
SyncTabConversationIdsFromSession();
|
||
UpdateChatTitle();
|
||
UpdateFolderBar();
|
||
RenderMessages();
|
||
Services.LogService.Info($"[SwitchTab] SAME_CONV done, emptyState={EmptyState.Visibility}");
|
||
return;
|
||
}
|
||
|
||
Services.LogService.Info($"[SwitchTab] DIFF_CONV path: curId={curId} → newId={conv.Id[..Math.Min(8, conv.Id.Length)]}");
|
||
lock (_convLock) _currentConversation = conv;
|
||
SyncTabConversationIdsFromSession();
|
||
SaveLastConversations();
|
||
ClearTranscriptElements();
|
||
InvalidateTimelineCache();
|
||
RenderMessages();
|
||
Services.LogService.Info($"[SwitchTab] DIFF_CONV done, emptyState={EmptyState.Visibility}");
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
UpdateFolderBar();
|
||
return;
|
||
}
|
||
|
||
// 기억된 대화가 없으면 새 대화
|
||
Services.LogService.Info($"[SwitchTab] NO_SESSION path: creating fresh conversation");
|
||
lock (_convLock)
|
||
{
|
||
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
|
||
?? new ChatConversation { Tab = _activeTab };
|
||
}
|
||
ClearTranscriptElements();
|
||
InvalidateTimelineCache();
|
||
ShowEmptyState();
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
UpdateFolderBar();
|
||
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
|
||
Services.LogService.Info($"[SwitchTab] NO_SESSION done, emptyState={EmptyState.Visibility}");
|
||
}
|
||
|
||
// ─── 작업 폴더 ─────────────────────────────────────────────────────────
|
||
|
||
private readonly List<string> _attachedFiles = new();
|
||
private readonly List<ImageAttachment> _pendingImages = new();
|
||
|
||
private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu();
|
||
private void BtnFolderSelect_Click(object sender, RoutedEventArgs e) => ShowFolderMenu();
|
||
private void BtnFolderSelect_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu();
|
||
|
||
/// <summary>채팅이 시작되었는지 (메시지가 1개 이상 있는지) 확인합니다.</summary>
|
||
private bool HasChatStarted()
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
return _currentConversation != null && _currentConversation.Messages.Count > 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>경로 텍스트 클릭 — 대화 전: 폴더 메뉴 / 대화 후: 클립보드 복사.</summary>
|
||
private void FolderPathLabel_CopyClick(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (!HasChatStarted())
|
||
{
|
||
// 대화 시작 전 → 폴더 변경 가능
|
||
ShowFolderMenu();
|
||
return;
|
||
}
|
||
|
||
// 대화 시작 후 → 경로 복사만
|
||
var path = GetCurrentWorkFolder();
|
||
if (string.IsNullOrWhiteSpace(path)) return;
|
||
try { Clipboard.SetText(path); }
|
||
catch { }
|
||
ShowToast("경로가 복사되었습니다.", "\uE8C8", 1500);
|
||
}
|
||
|
||
|
||
/// <summary>폴더 미선택 시 폴더 선택 버튼을 시각적으로 강조합니다.</summary>
|
||
private void HighlightFolderSelectButton()
|
||
{
|
||
if (BtnFolderSelect == null) return;
|
||
|
||
ShowToast("작업 폴더를 먼저 선택하세요.", "\uED25", 3000);
|
||
|
||
// 버튼 테두리를 강조색으로 깜빡임
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var originalBorder = BtnFolderSelect.BorderBrush;
|
||
|
||
var flash = new System.Windows.Media.Animation.ColorAnimation
|
||
{
|
||
From = ((SolidColorBrush)(TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray)).Color,
|
||
To = ((SolidColorBrush)accentBrush).Color,
|
||
Duration = TimeSpan.FromMilliseconds(300),
|
||
AutoReverse = true,
|
||
RepeatBehavior = new System.Windows.Media.Animation.RepeatBehavior(3),
|
||
};
|
||
flash.Completed += (_, _) => BtnFolderSelect.BorderBrush = originalBorder;
|
||
var animBrush = new SolidColorBrush();
|
||
animBrush.BeginAnimation(SolidColorBrush.ColorProperty, flash);
|
||
BtnFolderSelect.BorderBrush = animBrush;
|
||
}
|
||
/// <summary>폴더 선택 여부에 따라 UI 모드를 전환합니다.
|
||
/// 대화 WorkFolder가 비어있으면 "폴더 선택" 버튼, 설정되어 있으면 경로 텍스트.</summary>
|
||
private void UpdateFolderSelectButtonStyle()
|
||
{
|
||
if (BtnFolderSelect == null || FolderPathLabel == null) return;
|
||
|
||
// 대화 자체에 폴더가 설정되어 있는지 확인 (설정 폴백 제외)
|
||
bool convHasFolder;
|
||
lock (_convLock)
|
||
{
|
||
convHasFolder = _currentConversation != null
|
||
&& !string.IsNullOrWhiteSpace(_currentConversation.WorkFolder);
|
||
}
|
||
|
||
BtnFolderSelect.Visibility = convHasFolder ? Visibility.Collapsed : Visibility.Visible;
|
||
FolderPathLabel.Visibility = convHasFolder ? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
// ToolTip: 대화 시작 여부에 따라 전환
|
||
if (convHasFolder)
|
||
{
|
||
FolderPathLabel.ToolTip = HasChatStarted()
|
||
? "클릭하면 경로가 복사됩니다"
|
||
: "클릭하면 작업 폴더를 변경합니다";
|
||
}
|
||
}
|
||
|
||
private void FolderMenuSearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
RenderFolderMenuItems(null);
|
||
}
|
||
|
||
private void ShowFolderMenu()
|
||
{
|
||
RenderFolderMenuItems(null);
|
||
FolderMenuPopup.IsOpen = true;
|
||
}
|
||
|
||
private async void RenderFolderMenuItems(string? searchText)
|
||
{
|
||
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 hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#2A2A2A");
|
||
|
||
// 현재 대화에 실제 설정된 폴더만 (설정 폴백 아님)
|
||
string? convFolder = null;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null && !string.IsNullOrWhiteSpace(_currentConversation.WorkFolder))
|
||
convFolder = _currentConversation.WorkFolder;
|
||
}
|
||
|
||
// 최근 폴더 + 대화 이력에서 수집 (중복 제거, 최대 15개)
|
||
var recentFolders = _settings.Settings.Llm.RecentWorkFolders
|
||
.Where(p => IsPathAllowed(p) && Directory.Exists(p))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Take(15)
|
||
.ToList();
|
||
|
||
// ── 버벅임 수정: _storage.LoadAllMeta()는 모든 .axchat 파일을 복호화하는 무거운 동기 작업.
|
||
// UI 스레드에서 호출하면 폴더 버튼 클릭 시 수초 멈춤 발생. Task.Run으로 오프로드.
|
||
var activeTab = _activeTab;
|
||
var conversationFolders = await System.Threading.Tasks.Task.Run(() =>
|
||
{
|
||
try
|
||
{
|
||
return _storage.LoadAllMeta()
|
||
.Where(c => string.Equals(NormalizeTabName(c.Tab), activeTab, StringComparison.OrdinalIgnoreCase))
|
||
.Select(c => c.WorkFolder?.Trim() ?? "")
|
||
.Where(p => !string.IsNullOrWhiteSpace(p) && Directory.Exists(p))
|
||
.ToList();
|
||
}
|
||
catch { return new List<string>(); }
|
||
});
|
||
|
||
// IsPathAllowed는 UI 스레드 리소스 접근 가능성 있어 UI 스레드로 돌아와서 필터링
|
||
conversationFolders = conversationFolders.Where(IsPathAllowed).ToList();
|
||
|
||
var allFolders = recentFolders
|
||
.Concat(conversationFolders)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Take(15)
|
||
.ToList();
|
||
|
||
// 검색 필터
|
||
if (!string.IsNullOrWhiteSpace(searchText))
|
||
allFolders = allFolders
|
||
.Where(f => f.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
.ToList();
|
||
|
||
// 컴팩트한 폴더 행 추가
|
||
foreach (var folder in allFolders)
|
||
{
|
||
var isActive = convFolder != null
|
||
&& folder.Equals(convFolder, StringComparison.OrdinalIgnoreCase);
|
||
var row = CreateCompactFolderRow(folder, isActive, accentBrush, primaryText, secondaryText, hoverBg);
|
||
FolderMenuItems.Children.Add(row);
|
||
}
|
||
|
||
if (allFolders.Count == 0)
|
||
{
|
||
FolderMenuItems.Children.Add(new TextBlock
|
||
{
|
||
Text = "최근 작업 폴더가 없습니다.",
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 6, 10, 6),
|
||
});
|
||
}
|
||
|
||
// 구분선
|
||
FolderMenuItems.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(4, 4, 4, 4),
|
||
Opacity = 0.25,
|
||
});
|
||
|
||
var browseBorder = CreatePopupMenuRow(
|
||
"\uE710",
|
||
"다른 폴더 선택",
|
||
string.Empty,
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
FolderMenuPopup.IsOpen = false;
|
||
BrowseWorkFolder();
|
||
});
|
||
browseBorder.Margin = new Thickness(0);
|
||
browseBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
FolderMenuPopup.IsOpen = false;
|
||
};
|
||
FolderMenuItems.Children.Add(browseBorder);
|
||
}
|
||
|
||
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 Border CreateCompactFolderRow(string folder, bool isActive, Brush accentBrush, Brush primaryText, Brush secondaryText, Brush hoverBg)
|
||
{
|
||
var folderName = System.IO.Path.GetFileName(folder.TrimEnd('\\', '/'));
|
||
if (string.IsNullOrEmpty(folderName)) folderName = folder;
|
||
|
||
var row = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(4),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Cursor = Cursors.Hand,
|
||
Margin = new Thickness(2, 1, 2, 1),
|
||
};
|
||
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
|
||
// 체크마크 (선택된 폴더만)
|
||
if (isActive)
|
||
{
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE73E",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
}
|
||
|
||
// 폴더 아이콘
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uED25",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = isActive ? accentBrush : secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
|
||
// 폴더명
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = folderName,
|
||
FontSize = 12,
|
||
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
|
||
Foreground = isActive ? accentBrush : primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
MaxWidth = 200,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
});
|
||
|
||
// 전체 경로 (축약)
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = folder,
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
Opacity = 0.6,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(8, 0, 0, 0),
|
||
MaxWidth = 220,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
});
|
||
|
||
row.Child = sp;
|
||
row.ToolTip = folder;
|
||
|
||
// 호버 효과
|
||
row.MouseEnter += (_, _) => row.Background = hoverBg;
|
||
row.MouseLeave += (_, _) => row.Background = Brushes.Transparent;
|
||
|
||
// 클릭
|
||
row.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
FolderMenuPopup.IsOpen = false;
|
||
SetWorkFolder(folder);
|
||
};
|
||
|
||
// 우클릭 → 컨텍스트 메뉴
|
||
var capturedPath = folder;
|
||
row.MouseRightButtonUp += (_, re) =>
|
||
{
|
||
re.Handled = true;
|
||
ShowRecentFolderContextMenu(capturedPath);
|
||
};
|
||
|
||
return row;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
|
||
ViewModel.WorkFolder = path;
|
||
|
||
ChatConversation? convToPersist = null;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null && (_activeTab is "Cowork" or "Code"))
|
||
_currentConversation = ChatSession?.EnsureCurrentConversation(_activeTab) ?? new ChatConversation { Tab = _activeTab };
|
||
|
||
if (_currentConversation != null)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
// storage=null 전달 → UI 스레드에서 동기 Save 생략.
|
||
// 실제 저장은 아래 Task.Run으로 비동기 수행.
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = path, storage: null);
|
||
else
|
||
_currentConversation.WorkFolder = path;
|
||
convToPersist = _currentConversation;
|
||
}
|
||
}
|
||
|
||
// 폴더 변경은 UpdatedAt을 갱신하는 실제 변경이므로 저장은 필요. 단 백그라운드로.
|
||
if (convToPersist != null)
|
||
{
|
||
var copy = convToPersist;
|
||
_ = System.Threading.Tasks.Task.Run(() =>
|
||
{
|
||
try { _storage.Save(copy); }
|
||
catch (Exception ex) { Services.LogService.Debug($"WorkFolder 저장 실패: {ex.Message}"); }
|
||
});
|
||
}
|
||
|
||
// 최근 폴더 목록에 추가 (차단 경로 제외)
|
||
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);
|
||
// 탭별 전용 경로에도 기록
|
||
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
_settings.Settings.Llm.CodeWorkFolder = path;
|
||
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||
_settings.Settings.Llm.CoworkWorkFolder = path;
|
||
_settings.Settings.Llm.WorkFolder = path;
|
||
ScheduleSettingsSave();
|
||
RefreshContextUsageVisual();
|
||
RefreshInputWatermarkText();
|
||
UpdateFolderSelectButtonStyle();
|
||
ScheduleGitBranchRefresh();
|
||
|
||
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
|
||
}
|
||
|
||
private string GetCurrentWorkFolder()
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder))
|
||
return _currentConversation.WorkFolder;
|
||
}
|
||
|
||
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.IsNullOrWhiteSpace(_settings.Settings.Llm.CodeWorkFolder))
|
||
return _settings.Settings.Llm.CodeWorkFolder;
|
||
|
||
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.IsNullOrWhiteSpace(_settings.Settings.Llm.CoworkWorkFolder))
|
||
return _settings.Settings.Llm.CoworkWorkFolder;
|
||
|
||
return _settings.Settings.Llm.WorkFolder;
|
||
}
|
||
|
||
private void EnsureSkillSystemLoadedForCurrentWorkspace()
|
||
{
|
||
if (!_settings.Settings.Llm.EnableSkillSystem)
|
||
return;
|
||
|
||
SkillService.EnsureSkillFolder();
|
||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder(), _settings.Settings.Llm.AdditionalSkillFolders);
|
||
}
|
||
|
||
private void ScheduleWorkspaceSkillRefresh(bool reset = false)
|
||
{
|
||
if (!_settings.Settings.Llm.EnableSkillSystem)
|
||
return;
|
||
|
||
var version = Interlocked.Increment(ref _workspaceSkillRefreshVersion);
|
||
_workspaceSkillRefreshCts?.Cancel();
|
||
_workspaceSkillRefreshCts?.Dispose();
|
||
_workspaceSkillRefreshCts = new CancellationTokenSource();
|
||
var ct = _workspaceSkillRefreshCts.Token;
|
||
|
||
var skillFolder = _settings.Settings.Llm.SkillsFolderPath;
|
||
var workFolder = GetCurrentWorkFolder();
|
||
var additionalFolders = (_settings.Settings.Llm.AdditionalSkillFolders ?? [])
|
||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||
.ToArray();
|
||
|
||
_ = Task.Run(() =>
|
||
{
|
||
try
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
SkillService.EnsureSkillFolder();
|
||
SkillService.LoadSkills(skillFolder, workFolder, additionalFolders);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
Dispatcher.BeginInvoke(new Action(() =>
|
||
{
|
||
if (ct.IsCancellationRequested || version != _workspaceSkillRefreshVersion)
|
||
return;
|
||
|
||
ApplyConditionalSkillActivation(reset, workFolder);
|
||
}), DispatcherPriority.Background);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Debug($"작업 폴더 스킬 재로드 실패: {ex.Message}");
|
||
}
|
||
}, ct);
|
||
}
|
||
|
||
private void ApplyConditionalSkillActivation(bool reset = false, string? cwd = null)
|
||
{
|
||
if (!_settings.Settings.Llm.EnableSkillSystem)
|
||
return;
|
||
|
||
cwd ??= GetCurrentWorkFolder();
|
||
if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd))
|
||
return;
|
||
|
||
if (reset)
|
||
SkillService.ResetConditionalSkillActivation();
|
||
|
||
SkillService.ActivateConditionalSkillsForPaths(_attachedFiles, cwd);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로
|
||
/// 조건부 paths 스킬 활성화를 갱신합니다.
|
||
/// </summary>
|
||
private void UpdateConditionalSkillActivation(bool reset = false, bool reloadSkillSources = false)
|
||
{
|
||
if (!_settings.Settings.Llm.EnableSkillSystem)
|
||
return;
|
||
|
||
if (reloadSkillSources)
|
||
{
|
||
ScheduleWorkspaceSkillRefresh(reset);
|
||
return;
|
||
}
|
||
|
||
ApplyConditionalSkillActivation(reset);
|
||
}
|
||
|
||
private void BtnFolderClear_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var currentFolder = GetCurrentWorkFolder();
|
||
ViewModel.WorkFolder = "";
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = "", _storage);
|
||
else
|
||
_currentConversation.WorkFolder = "";
|
||
}
|
||
}
|
||
|
||
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
_settings.Settings.Llm.CodeWorkFolder = "";
|
||
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||
_settings.Settings.Llm.CoworkWorkFolder = "";
|
||
|
||
if (!string.IsNullOrWhiteSpace(currentFolder)
|
||
&& string.Equals(_settings.Settings.Llm.WorkFolder, currentFolder, StringComparison.OrdinalIgnoreCase))
|
||
_settings.Settings.Llm.WorkFolder = "";
|
||
|
||
ScheduleSettingsSave();
|
||
RefreshContextUsageVisual();
|
||
RefreshInputWatermarkText();
|
||
UpdateFolderSelectButtonStyle();
|
||
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
|
||
}
|
||
|
||
/// <summary>현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용.</summary>
|
||
private void LoadConversationSettings()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
var llm = _settings.Settings.Llm;
|
||
|
||
var fallbackPermission = _activeTab == "Chat"
|
||
? PermissionModeCatalog.Deny
|
||
: PermissionModeCatalog.NormalizeGlobalMode(llm.DefaultAgentPermission);
|
||
var conversationPermission = !string.IsNullOrWhiteSpace(conv?.Permission)
|
||
? PermissionModeCatalog.NormalizeGlobalMode(conv.Permission)
|
||
: fallbackPermission;
|
||
_settings.Settings.Llm.FilePermission = conversationPermission;
|
||
|
||
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||
_selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern";
|
||
}
|
||
|
||
private void LoadCompactionMetricsFromConversation()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
|
||
_sessionCompactionCount = conv?.CompactionCount ?? 0;
|
||
_sessionAutomaticCompactionCount = conv?.AutomaticCompactionCount ?? 0;
|
||
_sessionManualCompactionCount = conv?.ManualCompactionCount ?? 0;
|
||
_sessionCompactionSavedTokens = conv?.CompactionSavedTokens ?? 0;
|
||
_sessionMemoryCompactionCount = conv?.SessionMemoryCompactionCount ?? 0;
|
||
_sessionMicrocompactBoundaryCount = conv?.MicrocompactBoundaryCount ?? 0;
|
||
_sessionSnipCompactionCount = conv?.SnipCompactionCount ?? 0;
|
||
_pendingPostCompaction = conv?.PendingPostCompaction ?? false;
|
||
_sessionPostCompactionResponseCount = conv?.PostCompactionResponseCount ?? 0;
|
||
_sessionPostCompactionPromptTokens = conv?.PostCompactionPromptTokens ?? 0;
|
||
_sessionPostCompactionCompletionTokens = conv?.PostCompactionCompletionTokens ?? 0;
|
||
_lastCompactionAt = conv?.LastCompactionAt;
|
||
_lastCompactionWasAutomatic = conv?.LastCompactionWasAutomatic ?? false;
|
||
_lastCompactionBeforeTokens = conv?.LastCompactionBeforeTokens;
|
||
_lastCompactionAfterTokens = conv?.LastCompactionAfterTokens;
|
||
_lastCompactionStageSummary = conv?.LastCompactionStageSummary ?? "";
|
||
}
|
||
|
||
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
|
||
/// <summary>
|
||
/// 설정 저장을 250ms 디바운스하여 비동기 실행합니다.
|
||
/// UI 스레드에서 동기 File I/O를 제거하여 앱 멈춤을 방지합니다.
|
||
/// </summary>
|
||
private void ScheduleSettingsSave()
|
||
{
|
||
_settingsSaveTimer?.Stop();
|
||
if (_settingsSaveTimer == null)
|
||
{
|
||
_settingsSaveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||
_settingsSaveTimer.Tick += (_, _) =>
|
||
{
|
||
_settingsSaveTimer.Stop();
|
||
_ = _settings.SaveAsync();
|
||
};
|
||
}
|
||
_settingsSaveTimer.Start();
|
||
}
|
||
|
||
private void SaveConversationSettings()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null) return;
|
||
try
|
||
{
|
||
var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||
var dataUsage = GetAutomaticFolderDataUsage();
|
||
var mood = _selectedMood ?? (_settings.Settings.Llm.DefaultMood ?? "modern");
|
||
var outputFormat = conv.OutputFormat ?? "auto";
|
||
|
||
var unchanged =
|
||
string.Equals(conv.Permission ?? "", normalizedPermission, StringComparison.OrdinalIgnoreCase) &&
|
||
string.Equals(conv.DataUsage ?? "", dataUsage, StringComparison.OrdinalIgnoreCase) &&
|
||
string.Equals(conv.Mood ?? "", mood, StringComparison.OrdinalIgnoreCase) &&
|
||
string.Equals(conv.OutputFormat ?? "auto", outputFormat, StringComparison.OrdinalIgnoreCase);
|
||
|
||
if (unchanged)
|
||
return;
|
||
|
||
conv.Permission = normalizedPermission;
|
||
conv.DataUsage = dataUsage;
|
||
conv.Mood = mood;
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
lock (_convLock)
|
||
_currentConversation = session.SaveConversationSettings(_activeTab, normalizedPermission, dataUsage, outputFormat, mood, _storage);
|
||
}
|
||
else
|
||
{
|
||
_storage.Save(conv);
|
||
}
|
||
}
|
||
catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||
}
|
||
|
||
/// <summary>
|
||
/// SaveConversationSettings의 메모리 전용 버전 — 디스크 저장 없이 필드만 반영.
|
||
/// 프리셋 클릭처럼 UI 응답성이 중요한 경로에서 사용. 저장은 호출자가 비동기로 책임진다.
|
||
/// </summary>
|
||
private void ApplyConversationSettingsInMemory()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null) return;
|
||
try
|
||
{
|
||
var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||
var dataUsage = GetAutomaticFolderDataUsage();
|
||
var mood = _selectedMood ?? (_settings.Settings.Llm.DefaultMood ?? "modern");
|
||
var outputFormat = conv.OutputFormat ?? "auto";
|
||
|
||
conv.Permission = normalizedPermission;
|
||
conv.DataUsage = dataUsage;
|
||
conv.Mood = mood;
|
||
conv.OutputFormat = outputFormat;
|
||
}
|
||
catch (Exception ex) { Services.LogService.Debug($"대화 설정 인메모리 반영 실패: {ex.Message}"); }
|
||
}
|
||
|
||
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
|
||
|
||
private string _lastPermissionBannerMode = "";
|
||
|
||
private bool TryApplyPermissionModeFromAction(string action, out string appliedMode)
|
||
{
|
||
appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||
var next = action switch
|
||
{
|
||
"ask" => PermissionModeCatalog.Default,
|
||
"default" => PermissionModeCatalog.Default,
|
||
"acceptedits" => PermissionModeCatalog.AcceptEdits,
|
||
"accept" => PermissionModeCatalog.AcceptEdits,
|
||
"auto" => PermissionModeCatalog.AcceptEdits,
|
||
"plan" => PermissionModeCatalog.Plan,
|
||
"bypass" => PermissionModeCatalog.BypassPermissions,
|
||
"bypasspermissions" => PermissionModeCatalog.BypassPermissions,
|
||
"fullauto" => PermissionModeCatalog.BypassPermissions,
|
||
"dontask" => PermissionModeCatalog.BypassPermissions,
|
||
"silent" => PermissionModeCatalog.BypassPermissions,
|
||
_ => null,
|
||
};
|
||
|
||
if (string.IsNullOrWhiteSpace(next))
|
||
return false;
|
||
|
||
_settings.Settings.Llm.FilePermission = next!;
|
||
ScheduleSettingsSave();
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdatePermissionUI();
|
||
SaveConversationSettings();
|
||
RefreshInlineSettingsPanel();
|
||
appliedMode = next!;
|
||
return true;
|
||
}
|
||
|
||
private string BuildPermissionStatusText()
|
||
{
|
||
ChatConversation? currentConversation;
|
||
lock (_convLock) currentConversation = _currentConversation;
|
||
var summary = _appState.GetPermissionSummary(currentConversation);
|
||
var mode = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
|
||
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||
var overrides = summary.TopOverrides.Count > 0
|
||
? string.Join(", ", summary.TopOverrides.Take(3).Select(x => $"{x.Key}:{PermissionModeCatalog.ToDisplayLabel(x.Value)}"))
|
||
: "없음";
|
||
return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)}\n운영 모드: {operationMode}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개\n예외 미리보기: {overrides}";
|
||
}
|
||
|
||
private void OpenPermissionPanelFromSlash(string command, string usageText)
|
||
{
|
||
BtnPermission_Click(this, new RoutedEventArgs());
|
||
AppendLocalSlashResult(_activeTab, command, $"권한 설정 팝업을 열었습니다. ({usageText})");
|
||
}
|
||
|
||
// ──── 데이터 활용 수준 메뉴 ────
|
||
|
||
private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||
{
|
||
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||
}
|
||
|
||
private string GetAutomaticFolderDataUsage()
|
||
=> _activeTab switch
|
||
{
|
||
"Code" => "active",
|
||
"Cowork" => "passive",
|
||
_ => "none",
|
||
};
|
||
|
||
/// <summary>Cowork/Code 탭 진입 시 설정의 기본 권한을 적용.</summary>
|
||
private void ApplyTabDefaultPermission()
|
||
{
|
||
if (_activeTab == "Chat")
|
||
{
|
||
// Chat 탭: 경고 배너 숨기고 기본 제한 모드로 복원
|
||
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.Deny;
|
||
UpdatePermissionUI();
|
||
return;
|
||
}
|
||
var defaultPerm = PermissionModeCatalog.NormalizeGlobalMode(_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();
|
||
UpdateConditionalSkillActivation();
|
||
}
|
||
|
||
private void RemoveAttachedFile(string filePath)
|
||
{
|
||
_attachedFiles.Remove(filePath);
|
||
RefreshAttachedFilesUI();
|
||
UpdateConditionalSkillActivation();
|
||
}
|
||
|
||
private void RefreshAttachedFilesUI()
|
||
{
|
||
AttachedFilesPanel.Items.Clear();
|
||
if (_attachedFiles.Count == 0)
|
||
{
|
||
if (_pendingImages.Count == 0)
|
||
AttachedFilesPanel.Visibility = Visibility.Collapsed;
|
||
return;
|
||
}
|
||
|
||
AttachedFilesPanel.Visibility = Visibility.Visible;
|
||
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var primaryBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
|
||
var borderBrush = TryFindResource("SeparatorColor") as Brush ?? Brushes.LightGray;
|
||
|
||
foreach (var file in _attachedFiles.ToList())
|
||
{
|
||
var fileName = System.IO.Path.GetFileName(file);
|
||
var capturedFile = file;
|
||
|
||
// 파일 크기 계산
|
||
string sizeText = "";
|
||
try
|
||
{
|
||
var fi = new System.IO.FileInfo(file);
|
||
sizeText = fi.Length switch
|
||
{
|
||
< 1024 => $"{fi.Length}B",
|
||
< 1024 * 1024 => $"{fi.Length / 1024.0:F0}KB",
|
||
_ => $"{fi.Length / (1024.0 * 1024):F1}MB",
|
||
};
|
||
}
|
||
catch { }
|
||
|
||
// 파일 유형별 아이콘
|
||
var ext = System.IO.Path.GetExtension(file).ToLowerInvariant();
|
||
var icon = ext switch
|
||
{
|
||
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" or ".go" or ".rs" or ".rb" or ".swift"
|
||
=> "\uE943", // Code
|
||
".json" or ".xml" or ".yaml" or ".yml" or ".csv" or ".tsv"
|
||
=> "\uE9F9", // Database/data
|
||
".html" or ".htm" or ".css" or ".scss"
|
||
=> "\uEB41", // Web
|
||
".md" or ".txt" or ".log"
|
||
=> "\uE8A5", // Document
|
||
".pdf" or ".docx" or ".doc" or ".xlsx" or ".xls" or ".pptx" or ".ppt"
|
||
=> "\uEA90", // Office
|
||
".zip" or ".7z" or ".rar" or ".tar" or ".gz"
|
||
=> "\uE8DE", // Archive
|
||
_ => "\uE8A5", // Generic file
|
||
};
|
||
|
||
var chip = new Border
|
||
{
|
||
Background = hintBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 6, 4, 6),
|
||
Margin = new Thickness(0, 0, 6, 4),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
// 파일 아이콘
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon, FontFamily = s_segoeIconFont, FontSize = 13,
|
||
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
// 파일명 + 크기 (세로 배치)
|
||
var nameStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||
nameStack.Children.Add(new TextBlock
|
||
{
|
||
Text = fileName, FontSize = 11.5, Foreground = primaryBrush,
|
||
MaxWidth = 180, TextTrimming = TextTrimming.CharacterEllipsis,
|
||
ToolTip = file,
|
||
});
|
||
if (!string.IsNullOrEmpty(sizeText))
|
||
{
|
||
nameStack.Children.Add(new TextBlock
|
||
{
|
||
Text = sizeText, FontSize = 10, Foreground = secondaryBrush,
|
||
Opacity = 0.7,
|
||
});
|
||
}
|
||
sp.Children.Add(nameStack);
|
||
|
||
var removeBtn = new Button
|
||
{
|
||
Content = new TextBlock { Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 9, Foreground = secondaryBrush },
|
||
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
|
||
Cursor = Cursors.Hand, Padding = new Thickness(6, 2, 4, 2), Margin = new Thickness(4, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
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;
|
||
_tabSidebarVisible[_activeTab] = _sidebarVisible;
|
||
ApplySidebarStateForActiveTab(animated: true);
|
||
}
|
||
|
||
private void ApplySidebarStateForActiveTab(bool animated)
|
||
{
|
||
var targetVisible = _tabSidebarVisible.TryGetValue(_activeTab, out var visible) ? visible : true;
|
||
_sidebarVisible = targetVisible;
|
||
|
||
if (_sidebarVisible)
|
||
{
|
||
IconBarColumn.Width = new GridLength(0);
|
||
IconBarPanel.Visibility = Visibility.Collapsed;
|
||
SidebarPanel.Visibility = Visibility.Visible;
|
||
SidebarResizeSplitter.Visibility = Visibility.Visible;
|
||
|
||
var targetWidth = Math.Max(190, _sidebarExpandedWidth);
|
||
|
||
if (animated)
|
||
{
|
||
SidebarPanel.Opacity = 0;
|
||
AnimateSidebar(0, targetWidth, () => SidebarColumn.MinWidth = 168);
|
||
var fadeIn = new System.Windows.Media.Animation.DoubleAnimation(1, TimeSpan.FromMilliseconds(200))
|
||
{
|
||
BeginTime = TimeSpan.FromMilliseconds(80)
|
||
};
|
||
SidebarPanel.BeginAnimation(OpacityProperty, fadeIn);
|
||
}
|
||
else
|
||
{
|
||
SidebarPanel.Opacity = 1;
|
||
SidebarColumn.MinWidth = 168;
|
||
SidebarColumn.Width = new GridLength(targetWidth);
|
||
}
|
||
return;
|
||
}
|
||
|
||
var currentWidth = SidebarColumn.ActualWidth > 0 ? SidebarColumn.ActualWidth : SidebarColumn.Width.Value;
|
||
if (currentWidth > 0)
|
||
_sidebarExpandedWidth = Math.Max(190, currentWidth);
|
||
|
||
SidebarColumn.MinWidth = 0;
|
||
if (animated)
|
||
{
|
||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(0, TimeSpan.FromMilliseconds(150));
|
||
SidebarPanel.BeginAnimation(OpacityProperty, fadeOut);
|
||
AnimateSidebar(currentWidth > 0 ? currentWidth : _sidebarExpandedWidth, 0, () =>
|
||
{
|
||
SidebarPanel.Visibility = Visibility.Collapsed;
|
||
SidebarPanel.BeginAnimation(OpacityProperty, null);
|
||
SidebarPanel.Opacity = 1;
|
||
SidebarResizeSplitter.Visibility = Visibility.Collapsed;
|
||
IconBarColumn.Width = new GridLength(52);
|
||
IconBarPanel.Visibility = Visibility.Visible;
|
||
});
|
||
}
|
||
else
|
||
{
|
||
SidebarColumn.Width = new GridLength(0);
|
||
SidebarPanel.Visibility = Visibility.Collapsed;
|
||
SidebarResizeSplitter.Visibility = Visibility.Collapsed;
|
||
IconBarColumn.Width = new GridLength(52);
|
||
IconBarPanel.Visibility = Visibility.Visible;
|
||
}
|
||
}
|
||
|
||
private void SidebarResizeSplitter_DragCompleted(object sender, DragCompletedEventArgs e)
|
||
{
|
||
if (!_sidebarVisible)
|
||
return;
|
||
|
||
var currentWidth = SidebarColumn.ActualWidth > 0 ? SidebarColumn.ActualWidth : SidebarColumn.Width.Value;
|
||
_sidebarExpandedWidth = Math.Clamp(currentWidth, 190, 420);
|
||
SidebarColumn.Width = new GridLength(_sidebarExpandedWidth);
|
||
}
|
||
|
||
private void AnimateSidebar(double from, double to, Action? onComplete = null)
|
||
{
|
||
var duration = 200.0;
|
||
var start = DateTime.UtcNow;
|
||
_sidebarAnimTimer?.Stop();
|
||
_sidebarAnimFrom = from;
|
||
_sidebarAnimTo = to;
|
||
_sidebarAnimStart = start;
|
||
_sidebarAnimDuration = duration;
|
||
_sidebarAnimComplete = onComplete;
|
||
if (_sidebarAnimTimer == null)
|
||
{
|
||
_sidebarAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(32) };
|
||
_sidebarAnimTimer.Tick += SidebarAnimTimer_Tick;
|
||
}
|
||
_sidebarAnimTimer.Start();
|
||
}
|
||
|
||
// ─── 대화 목록 ────────────────────────────────────────────────────────
|
||
|
||
|
||
// ─── 검색 ────────────────────────────────────────────────────────────
|
||
|
||
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
_conversationSearchTimer.Stop();
|
||
_conversationSearchTimer.Start();
|
||
}
|
||
|
||
// ─── 사이드바 애니메이션 (재사용 타이머) ──────────────────────────────
|
||
|
||
private DispatcherTimer? _sidebarAnimTimer;
|
||
private double _sidebarAnimFrom, _sidebarAnimTo, _sidebarAnimDuration;
|
||
private DateTime _sidebarAnimStart;
|
||
private Action? _sidebarAnimComplete;
|
||
|
||
private void SidebarAnimTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
var elapsed = (DateTime.UtcNow - _sidebarAnimStart).TotalMilliseconds;
|
||
var t = Math.Min(elapsed / _sidebarAnimDuration, 1.0);
|
||
t = 1 - (1 - t) * (1 - t);
|
||
SidebarColumn.Width = new GridLength(_sidebarAnimFrom + (_sidebarAnimTo - _sidebarAnimFrom) * t);
|
||
if (elapsed >= _sidebarAnimDuration)
|
||
{
|
||
_sidebarAnimTimer?.Stop();
|
||
SidebarColumn.Width = new GridLength(_sidebarAnimTo);
|
||
_sidebarAnimComplete?.Invoke();
|
||
}
|
||
}
|
||
|
||
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()
|
||
{
|
||
ChatConversation? conversation;
|
||
lock (_convLock)
|
||
{
|
||
conversation = _currentConversation;
|
||
var title = conversation?.Title ?? "";
|
||
ViewModel.ChatTitle = title;
|
||
}
|
||
|
||
UpdateSelectedPresetGuide(conversation);
|
||
}
|
||
|
||
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
|
||
|
||
private const int TimelineRenderPageSize = 180;
|
||
private const int TimelineStreamingRenderLimit = 96;
|
||
private const int TimelineLightweightStreamingRenderLimit = 60;
|
||
private string? _lastRenderedConversationId;
|
||
private int _timelineRenderLimit = TimelineRenderPageSize;
|
||
private int _emptyStateAnimationToken; // EmptyState 페이드 애니메이션 무효화 토큰
|
||
|
||
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
|
||
|
||
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
|
||
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
|
||
private string GetCheckStyle()
|
||
{
|
||
var theme = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant();
|
||
return theme switch
|
||
{
|
||
"dark" or "system" => "circle",
|
||
"light" => "roundrect",
|
||
_ => "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);
|
||
}
|
||
}
|
||
|
||
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
|
||
|
||
private void StopAiIconPulse()
|
||
{
|
||
_activeSpinnerTimer?.Stop();
|
||
_activeSpinnerTimer = null;
|
||
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;
|
||
// 커서 상태만 토글 — 버퍼에 이미 기록된 텍스트의 마지막 커서 문자만 교체
|
||
if (_activeStreamText != null && _displayedLength > 0 && _streamDisplayBufferLen > 0)
|
||
{
|
||
var cursorChar = _cursorVisible ? '\u258c' : ' ';
|
||
_streamDisplayBuffer[_streamDisplayBufferLen - 1] = cursorChar;
|
||
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, _streamDisplayBufferLen);
|
||
}
|
||
}
|
||
|
||
private void ElapsedTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
var sec = TryGetStreamingElapsed(out var elapsed)
|
||
? Math.Max(0, (int)elapsed.TotalSeconds)
|
||
: 0;
|
||
if (_elapsedLabel != null)
|
||
_elapsedLabel.Text = $"{sec}s";
|
||
|
||
// 하단 상태바 시간 갱신
|
||
if (StatusElapsed != null)
|
||
{
|
||
StatusElapsed.Text = $"{sec}초";
|
||
StatusElapsed.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
// 입력창 위 스트리밍 메트릭 레이블 갱신 (경과 시간 반영)
|
||
if (_isStreaming)
|
||
{
|
||
var cumIn = (int)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab));
|
||
var cumOut = (int)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab));
|
||
UpdateStreamMetricsLabel(cumIn, cumOut);
|
||
}
|
||
}
|
||
|
||
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 > 1000) step = pending / 4;
|
||
else if (pending > 300) step = Math.Min(Math.Max(30, pending / 4), 120);
|
||
else if (pending > 120) step = Math.Min(Math.Max(15, pending / 6), 40);
|
||
else if (pending > 24) step = Math.Min(12, pending);
|
||
else step = Math.Min(4, pending);
|
||
|
||
_displayedLength += step;
|
||
|
||
// 재사용 버퍼에 표시할 텍스트 + 커서를 직접 기록 (string.Concat 할당 제거)
|
||
var displayLen = _displayedLength;
|
||
var cursorChar = _cursorVisible ? '\u258c' : ' ';
|
||
var needed = displayLen + 1;
|
||
if (needed <= _streamDisplayBuffer.Length)
|
||
{
|
||
_cachedStreamContent.CopyTo(0, _streamDisplayBuffer, 0, displayLen);
|
||
_streamDisplayBuffer[displayLen] = cursorChar;
|
||
_streamDisplayBufferLen = needed;
|
||
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, needed);
|
||
}
|
||
else
|
||
{
|
||
// 버퍼 초과 시 fallback (256KB 이상 응답)
|
||
_activeStreamText.Text = string.Concat(_cachedStreamContent.AsSpan(0, displayLen), cursorChar.ToString());
|
||
}
|
||
|
||
// 스크롤은 150ms마다 한 번만 (레이아웃 재계산 빈도 감소)
|
||
if (!_userScrolled)
|
||
{
|
||
var now = Environment.TickCount64;
|
||
if (now - _lastScrollTick >= 150)
|
||
{
|
||
_lastScrollTick = now;
|
||
ScrollTranscriptToVerticalOffset(GetTranscriptScrollableHeight());
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── 전송 ──────────────────────────────────────────────────────────────
|
||
|
||
public void SendInitialMessage(string message)
|
||
{
|
||
StartNewConversation();
|
||
InputBox.Text = message;
|
||
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
|
||
}
|
||
|
||
private void StartNewConversation()
|
||
{
|
||
Services.LogService.Info($"[NewConv] 시작: activeTab={_activeTab}, streaming={_isStreaming}, " +
|
||
$"streamingTabs=[{string.Join(",", _streamingTabs)}]");
|
||
// 현재 대화가 있으면 저장 후 새 대화 시작
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.SaveCurrentConversation(_storage, _activeTab);
|
||
session.ClearCurrentConversation(_activeTab);
|
||
_currentConversation = session.CreateFreshConversation(_activeTab, _settings);
|
||
SyncTabConversationIdsFromSession();
|
||
Services.LogService.Info($"[NewConv] 생성완료: newId={_currentConversation.Id[..Math.Min(8, _currentConversation.Id.Length)]}, " +
|
||
$"tabIds=[Chat={_tabConversationId.GetValueOrDefault("Chat")?[..Math.Min(8, _tabConversationId.GetValueOrDefault("Chat")?.Length ?? 0)] ?? "null"}, " +
|
||
$"Cowork={_tabConversationId.GetValueOrDefault("Cowork")?[..Math.Min(8, _tabConversationId.GetValueOrDefault("Cowork")?.Length ?? 0)] ?? "null"}, " +
|
||
$"Code={_tabConversationId.GetValueOrDefault("Code")?[..Math.Min(8, _tabConversationId.GetValueOrDefault("Code")?.Length ?? 0)] ?? "null"}]");
|
||
}
|
||
else
|
||
{
|
||
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
|
||
try { _storage.Save(_currentConversation); } catch { }
|
||
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
|
||
?? new ChatConversation { Tab = _activeTab };
|
||
}
|
||
}
|
||
// 탭 기억 초기화 (새 대화이므로)
|
||
_tabConversationId[_activeTab] = null;
|
||
SyncTabConversationIdsToSession();
|
||
ClearTranscriptElements();
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
LoadConversationSettings();
|
||
LoadCompactionMetricsFromConversation();
|
||
SyncAppStateWithCurrentConversation();
|
||
UpdateChatTitle();
|
||
UpdateFolderBar();
|
||
UpdateSelectedPresetGuide();
|
||
UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true);
|
||
RenderMessages();
|
||
RefreshConversationList();
|
||
BuildTopicButtons();
|
||
RefreshDraftQueueUi();
|
||
if (_activeTab == "Cowork") BuildBottomBar();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 마지막 활성 탭과 대화를 복원합니다.
|
||
/// 저장된 대화가 있으면 불러오고, 없으면 새 대화를 생성합니다.
|
||
/// </summary>
|
||
private void RestoreLastConversations()
|
||
{
|
||
var session = ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
// 마지막 활성 탭 복원
|
||
_activeTab = NormalizeTabName(session.ActiveTab);
|
||
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase))
|
||
TabChat.IsChecked = true;
|
||
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||
TabCowork.IsChecked = true;
|
||
else if (TabCode.IsEnabled)
|
||
TabCode.IsChecked = true;
|
||
|
||
// 항상 새 대화로 시작 (이전 대화는 대화 목록에서 선택)
|
||
// 현재 대화가 있으면 저장 후 새로 시작
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null)
|
||
session.SaveCurrentConversation(_storage, _activeTab);
|
||
_currentConversation = session.CreateFreshConversation(_activeTab, _settings);
|
||
}
|
||
|
||
RenderMessages();
|
||
UpdateChatTitle();
|
||
UpdateFolderBar();
|
||
LoadConversationSettings();
|
||
LoadCompactionMetricsFromConversation();
|
||
SyncAppStateWithCurrentConversation();
|
||
UpdateSelectedPresetGuide();
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
RefreshConversationList();
|
||
RefreshDraftQueueUi();
|
||
if (_activeTab == "Cowork") BuildBottomBar();
|
||
}
|
||
|
||
/// <summary>현재 _tabConversationId를 설정에 저장합니다.</summary>
|
||
private void SaveLastConversations()
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
SyncTabConversationIdsToSession();
|
||
session.ActiveTab = _activeTab;
|
||
session.Save(_settings);
|
||
SyncTabConversationIdsFromSession();
|
||
return;
|
||
}
|
||
|
||
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;
|
||
ScheduleSettingsSave();
|
||
}
|
||
|
||
private void BtnSend_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
// Press animation
|
||
if (BtnSend.RenderTransform is ScaleTransform st)
|
||
{
|
||
var shrink = new System.Windows.Media.Animation.DoubleAnimation(0.96, TimeSpan.FromMilliseconds(80));
|
||
shrink.Completed += (_, _) =>
|
||
{
|
||
var grow = new System.Windows.Media.Animation.DoubleAnimation(1.0, TimeSpan.FromMilliseconds(120))
|
||
{
|
||
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
|
||
};
|
||
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
|
||
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
|
||
};
|
||
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
|
||
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
|
||
}
|
||
QueueComposerDraft(priority: "now", explicitKind: null, startImmediatelyWhenIdle: true);
|
||
}
|
||
|
||
private void InputBox_GotFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
// Shadow deepens on focus
|
||
if (InputBorder?.Effect is System.Windows.Media.Effects.DropShadowEffect shadow)
|
||
{
|
||
var anim = new System.Windows.Media.Animation.DoubleAnimation(0.14, TimeSpan.FromMilliseconds(200))
|
||
{
|
||
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
|
||
};
|
||
shadow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, anim);
|
||
}
|
||
// Border color + thickness transitions to focus state
|
||
if (InputBorder != null)
|
||
{
|
||
InputBorder.SetResourceReference(Border.BorderBrushProperty, "InputFocusBorderColor");
|
||
InputBorder.BorderThickness = new Thickness(1.2);
|
||
}
|
||
}
|
||
|
||
private void InputBox_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
// Shadow returns to normal
|
||
if (InputBorder?.Effect is System.Windows.Media.Effects.DropShadowEffect shadow)
|
||
{
|
||
var anim = new System.Windows.Media.Animation.DoubleAnimation(0.06, TimeSpan.FromMilliseconds(200))
|
||
{
|
||
EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
|
||
};
|
||
shadow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, anim);
|
||
}
|
||
// Border color + thickness returns to default
|
||
if (InputBorder != null)
|
||
{
|
||
InputBorder.SetResourceReference(Border.BorderBrushProperty, "BorderColor");
|
||
InputBorder.BorderThickness = new Thickness(0.5);
|
||
}
|
||
}
|
||
|
||
private void InputBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||
{
|
||
if (TryHandleSlashNavigationKey(e))
|
||
return;
|
||
|
||
// Escape: 미리보기 패널 닫기
|
||
if (e.Key == Key.Escape && PreviewPanel.Visibility == Visibility.Visible)
|
||
{
|
||
HidePreviewPanel();
|
||
e.Handled = true;
|
||
return;
|
||
}
|
||
|
||
// Alt+Up: Pop last queued message back to editor
|
||
if (e.Key == Key.Up && Keyboard.Modifiers == ModifierKeys.Alt)
|
||
{
|
||
e.Handled = true;
|
||
PopLastQueuedDraftToEditor();
|
||
return;
|
||
}
|
||
|
||
// Shift+Tab: 권한 모드 순환 (Claude Code 스타일)
|
||
if (e.Key == Key.Tab && Keyboard.Modifiers == ModifierKeys.Shift)
|
||
{
|
||
e.Handled = true;
|
||
CyclePermissionMode();
|
||
return;
|
||
}
|
||
|
||
if (e.Key == Key.Tab && TryAcceptTopFileMentionSuggestion())
|
||
{
|
||
e.Handled = true;
|
||
return;
|
||
}
|
||
|
||
// Ctrl+V: 클립보드 이미지 붙여넣기
|
||
if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
|
||
{
|
||
if (TryPasteClipboardImage())
|
||
{
|
||
e.Handled = true;
|
||
return;
|
||
}
|
||
// 이미지가 아니면 기본 텍스트 붙여넣기로 위임
|
||
}
|
||
|
||
// IME 조합 중(한글 등) Enter → 조합 확정만 수행, 전송하지 않음
|
||
// e.Key == ImeProcessed이고 실제 키가 Enter이면 IME 조합 확정 중이므로 무시
|
||
if (e.Key == Key.ImeProcessed && e.ImeProcessedKey == Key.Enter)
|
||
return;
|
||
|
||
if (e.Key == Key.Enter)
|
||
{
|
||
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
|
||
{
|
||
// Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임)
|
||
return;
|
||
}
|
||
|
||
// 슬래시 팝업이 열려 있으면 선택된 항목 실행
|
||
if (SlashPopup.IsOpen && _slashPalette.SelectedIndex >= 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;
|
||
}
|
||
|
||
// Ctrl+Enter → 즉시 실행 대기열, Enter → 다음 대기열
|
||
e.Handled = true;
|
||
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
|
||
{
|
||
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
|
||
return;
|
||
}
|
||
|
||
QueueComposerDraft(priority: "next", explicitKind: null, startImmediatelyWhenIdle: true);
|
||
}
|
||
}
|
||
|
||
/// <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 = s_segoeIconFont, 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 = s_segoeIconFont, 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 readonly SlashPaletteState _slashPalette = new();
|
||
private readonly Dictionary<int, FrameworkElement> _slashVisibleItemByAbsoluteIndex = new();
|
||
private readonly List<int> _slashVisibleAbsoluteOrder = new();
|
||
|
||
// ── 슬래시 명령어 (탭별 분류) ──
|
||
|
||
private void InputBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
UpdateWatermarkVisibility();
|
||
UpdateInputBoxHeight();
|
||
ScheduleInputUiRefresh();
|
||
var text = InputBox.Text;
|
||
// 파일 멘션 제안은 매 키입력마다 정규식 + 후보 필터링을 수행하므로
|
||
// 120ms 디바운스로 타이핑 중 버벅임을 완화
|
||
ScheduleFileMentionRefresh(text);
|
||
|
||
// 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제
|
||
if (_slashPalette.ActiveCommand != 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 slashEntries = SlashCommandCatalog.MatchBuiltinCommands(text, isDev)
|
||
.Select(match => (
|
||
match.Cmd,
|
||
match.Label,
|
||
match.IsSkill,
|
||
Priority: SlashCommandCatalog.GetBuiltInCommandPriority(match.Cmd)))
|
||
.ToList();
|
||
|
||
// 스킬 슬래시 명령어 매칭 (탭별 필터)
|
||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||
{
|
||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||
var skillMatches = SkillService.MatchSlashCommand(text)
|
||
.Where(s => s.IsVisibleInTab(_activeTab))
|
||
.Select(s => (Cmd: "/" + s.Name,
|
||
Label: BuildSlashSkillLabel(s),
|
||
IsSkill: true,
|
||
Priority: SkillService.GetSkillSourcePriority(s.SourceScope)));
|
||
slashEntries.AddRange(skillMatches);
|
||
}
|
||
|
||
var matches = SlashCommandCatalog.ComposeMatches(slashEntries);
|
||
if (matches.Count > 0)
|
||
{
|
||
_slashPalette.Matches = matches;
|
||
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(matches);
|
||
RenderSlashPage();
|
||
SlashPopup.IsOpen = true;
|
||
ScheduleInputUiRefresh();
|
||
return;
|
||
}
|
||
}
|
||
SlashPopup.IsOpen = false;
|
||
ScheduleInputUiRefresh();
|
||
}
|
||
|
||
private static void SyncLatestAssistantMessage(ChatConversation conv, string content)
|
||
{
|
||
if (conv.Messages.Count == 0)
|
||
return;
|
||
|
||
for (var i = conv.Messages.Count - 1; i >= 0; i--)
|
||
{
|
||
var message = conv.Messages[i];
|
||
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
message.Content = content;
|
||
return;
|
||
}
|
||
}
|
||
|
||
private void ScheduleInputUiRefresh()
|
||
{
|
||
if (_inputUiRefreshTimer == null)
|
||
return;
|
||
|
||
if (IsBackgroundUiThrottleActive())
|
||
{
|
||
_pendingBackgroundInputUiRefresh = true;
|
||
return;
|
||
}
|
||
|
||
if (IsLightweightLiveProgressMode() && _inputUiRefreshTimer.IsEnabled)
|
||
return;
|
||
|
||
_inputUiRefreshTimer.Stop();
|
||
_inputUiRefreshTimer.Start();
|
||
}
|
||
|
||
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
|
||
private async Task<(string? slashSystem, string userText)> ParseSlashCommandAsync(string input, CancellationToken ct = default)
|
||
{
|
||
var trimmed = input.TrimStart();
|
||
if (trimmed.StartsWith("/"))
|
||
{
|
||
var firstSpace = trimmed.IndexOf(' ');
|
||
var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim();
|
||
var isDev = _activeTab is "Cowork" or "Code";
|
||
var exactEntries = new List<(string Cmd, string Label, bool IsSkill, int Priority)>();
|
||
|
||
if (SlashCommandCatalog.TryGetEntry(commandToken, out var entry)
|
||
&& (entry.Tab == "all" || (isDev && entry.Tab == "dev")))
|
||
{
|
||
exactEntries.Add((
|
||
Cmd: commandToken,
|
||
Label: entry.Label,
|
||
IsSkill: false,
|
||
Priority: SlashCommandCatalog.GetBuiltInCommandPriority(commandToken)));
|
||
}
|
||
|
||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||
{
|
||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||
exactEntries.AddRange(
|
||
SkillService.MatchSlashCommand(commandToken)
|
||
.Where(s => s.IsVisibleInTab(_activeTab))
|
||
.Where(s => string.Equals("/" + s.Name, commandToken, StringComparison.OrdinalIgnoreCase))
|
||
.Select(s => (
|
||
Cmd: "/" + s.Name,
|
||
Label: BuildSlashSkillLabel(s),
|
||
IsSkill: true,
|
||
Priority: SkillService.GetSkillSourcePriority(s.SourceScope))));
|
||
}
|
||
|
||
var preferredCommand = SlashCommandCatalog.ResolvePreferredCommand(commandToken, exactEntries);
|
||
if (preferredCommand.HasValue && preferredCommand.Value.IsSkill)
|
||
{
|
||
var compiled = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct);
|
||
if (compiled != null)
|
||
return (compiled.SystemPrompt, compiled.DisplayText);
|
||
}
|
||
|
||
if (preferredCommand.HasValue
|
||
&& !preferredCommand.Value.IsSkill
|
||
&& SlashCommandCatalog.TryGetEntry(commandToken, out entry))
|
||
{
|
||
// __HELP__는 특수 처리 (ParseSlashCommand에서는 무시)
|
||
if (entry.SystemPrompt == "__HELP__") return (null, input);
|
||
var rest = firstSpace >= 0 ? trimmed[(firstSpace + 1)..].Trim() : "";
|
||
return (entry.SystemPrompt, string.IsNullOrEmpty(rest) ? commandToken : rest);
|
||
}
|
||
}
|
||
|
||
// 스킬 명령어 매칭
|
||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||
var compiledInvocation = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct);
|
||
if (compiledInvocation != null)
|
||
return (compiledInvocation.SystemPrompt, compiledInvocation.DisplayText);
|
||
|
||
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(Services.CodeLanguageCatalog.CodeExtensions, StringComparer.OrdinalIgnoreCase);
|
||
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)
|
||
{
|
||
if (files == null || files.Length == 0) return;
|
||
// 파일 유형 판별
|
||
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 = s_segoeIconFont,
|
||
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)
|
||
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
|
||
};
|
||
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 = s_segoeIconFont,
|
||
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 = s_segoeIconFont, 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 = s_segoeIconFont, 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,
|
||
("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."),
|
||
("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다. (/status test: 연결 진단)"),
|
||
("/new", "새 대화를 시작합니다."),
|
||
("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."),
|
||
("/model", "모델 선택 패널을 엽니다."),
|
||
("/permissions", "권한 설정 패널을 엽니다."),
|
||
("/allowed-tools", "허용 도구(권한) 패널을 엽니다."),
|
||
("/settings", "AX Agent 설정 창을 엽니다."),
|
||
("/stats", "최근 호출의 토큰 통계를 표시합니다."),
|
||
("/cost", "최근 호출의 추정 비용을 표시합니다."),
|
||
("/export", "현재 대화를 파일로 내보냅니다."),
|
||
("/clear", "현재 대화를 정리하고 새 대화를 시작합니다."));
|
||
|
||
AddHelpSection(contentPanel, "작업/운영 명령어", "에이전트 운영 명령", fg, fg2, accent, itemBg, hoverBg,
|
||
("/config", "설정 점검 및 권장안을 제시합니다."),
|
||
("/context", "현재 목표/제약/다음 액션을 정리합니다."),
|
||
("/session", "세션 핵심 맥락과 다음 할 일을 요약합니다."),
|
||
("/usage", "사용 효율을 높이는 팁을 제시합니다."),
|
||
("/rename", "현재 대화 이름을 즉시 변경합니다."),
|
||
("/feedback", "마지막 응답에 대한 수정 피드백 입력 패널을 엽니다."),
|
||
("/skills", "스킬 시스템/브라우저를 엽니다."),
|
||
("/sandbox-toggle", "권한 모드를 순환 전환합니다."),
|
||
("/statusline", "현재 상태를 한 줄로 요약해 표시합니다."),
|
||
("/heapdump", "메모리 사용 현황을 진단합니다."),
|
||
("/passes", "반복/패스 관련 설정 프리셋을 순환 전환합니다."),
|
||
("/chrome", "인자 없으면 진단, 인자 있으면 브라우저 작업 실행 경로로 라우팅합니다."),
|
||
("/stickers", "빠른 상태 스티커 세트를 보여줍니다."),
|
||
("/thinkback", "최근 대화 맥락을 요약 회고합니다."),
|
||
("/thinkback-play", "회고 내용을 바탕으로 다음 실행 플랜을 제시합니다."),
|
||
("/theme", "테마/표현 관련 설정으로 이동합니다."),
|
||
("/output-style", "출력 스타일 가이드를 제시합니다."),
|
||
("/keybindings", "단축키 효율화 팁을 제시합니다."),
|
||
("/privacy-settings", "보안/개인정보 관점 설정 점검표를 제시합니다."),
|
||
("/rate-limit-options", "요청 한도 대응 전략을 제시합니다."));
|
||
|
||
// 개발 명령어 섹션
|
||
AddHelpSection(contentPanel, "개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg,
|
||
("/review", "변경 코드를 리뷰하고 리스크를 찾습니다."),
|
||
("/commit", "변경사항을 확인하고 승인 후 실제 커밋을 실행합니다. (files: 지정 지원)"),
|
||
("/ultrareview", "더 엄격한 리뷰 기준으로 치명 리스크를 우선 점검합니다."),
|
||
("/security-review", "보안 중심으로 취약점과 개선안을 점검합니다."),
|
||
("/pr", "변경사항을 PR 설명 형식으로 정리합니다."),
|
||
("/pr-comments", "리뷰 코멘트 형태의 개선 의견을 작성합니다."),
|
||
("/test", "테스트 생성/개선 방향을 제시합니다."),
|
||
("/verify", "빌드/테스트/리스크 점검까지 포함한 검증 모드로 실행합니다."),
|
||
("/structure", "프로젝트 구조를 분석합니다."),
|
||
("/build", "빌드 및 오류 분석을 진행합니다."),
|
||
("/search", "코드베이스 검색을 수행합니다."),
|
||
("/diff", "현재 diff의 핵심 변경점/리스크를 요약합니다."),
|
||
("/doctor", "프로젝트/환경 점검 체크를 수행합니다."));
|
||
|
||
AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg,
|
||
("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"),
|
||
("/agents", "에이전트 분담 전략 제시"),
|
||
("/plugin", "플러그인 구성 점검"),
|
||
("/reload-plugins", "플러그인 재로드 점검"),
|
||
("/install-github-app", "GitHub 앱 연동 안내"),
|
||
("/install-slack-app", "Slack 앱 연동 안내"),
|
||
("/remote-env", "원격 환경 연결 점검"),
|
||
("/ide", "IDE 연동 점검"),
|
||
("/terminal-setup", "터미널 초기 구성 점검"));
|
||
|
||
// 스킬 명령어 섹션
|
||
var skills = SkillService.Skills;
|
||
if (skills.Count > 0)
|
||
{
|
||
var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray();
|
||
AddHelpSection(contentPanel, "스킬 명령어", $"{skills.Count}개 로드됨 — 기본 스킬 폴더와 추가 폴더에서 함께 불러옵니다.", 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 ExecuteManualCompactAsync(string commandText, string runTab)
|
||
{
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null)
|
||
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
var userMsg = new ChatMessage { Role = "user", Content = commandText };
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(runTab, userMsg, useForTitle: true);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(userMsg);
|
||
}
|
||
}
|
||
|
||
SaveLastConversations();
|
||
_storage.Save(conv);
|
||
ChatSession?.RememberConversation(runTab, conv.Id);
|
||
UpdateChatTitle();
|
||
InputBox.Text = "";
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
InvalidateTimelineCache();
|
||
RenderMessages(preserveViewport: false);
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
var working = conv.Messages.ToList();
|
||
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
|
||
working,
|
||
_llm,
|
||
llm.MaxContextTokens,
|
||
llm.EnableProactiveContextCompact,
|
||
llm.ContextCompactTriggerPercent,
|
||
true,
|
||
CancellationToken.None);
|
||
var beforeTokens = compactResult.BeforeTokens;
|
||
var afterTokens = compactResult.AfterTokens;
|
||
RecordCompactionStats(compactResult, wasAutomatic: false);
|
||
|
||
if (compactResult.Changed)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
conv.Messages = working;
|
||
}
|
||
}
|
||
|
||
var assistantText = compactResult.Changed
|
||
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}\n- 단계: {compactResult.StageSummary}\n- 절감량: {compactResult.SavedTokens:N0} tokens"
|
||
: "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
_chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
|
||
_currentConversation = session?.CurrentConversation ?? conv;
|
||
conv = _currentConversation!;
|
||
}
|
||
|
||
SaveLastConversations();
|
||
_storage.Save(conv);
|
||
InvalidateTimelineCache();
|
||
RenderMessages(preserveViewport: false);
|
||
if (StatusTokens != null)
|
||
StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}";
|
||
SetStatus(compactResult.Changed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false);
|
||
RefreshContextUsageVisual();
|
||
RefreshConversationList();
|
||
UpdateTaskSummaryIndicators();
|
||
}
|
||
|
||
private void AppendLocalSlashResult(string runTab, string commandText, string assistantText)
|
||
{
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null)
|
||
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
var userMsg = new ChatMessage { Role = "user", Content = commandText };
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(runTab, userMsg, useForTitle: true);
|
||
_chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(userMsg);
|
||
_chatEngine.CommitAssistantMessage(null, conv, runTab, assistantText, storage: _storage);
|
||
}
|
||
}
|
||
|
||
SaveLastConversations();
|
||
_storage.Save(conv);
|
||
ChatSession?.RememberConversation(runTab, conv.Id);
|
||
UpdateChatTitle();
|
||
InputBox.Text = "";
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
InvalidateTimelineCache();
|
||
RenderMessages(preserveViewport: false);
|
||
}
|
||
|
||
private string BuildSlashStatusText()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var tab = _activeTab;
|
||
var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
|
||
var service = llm.Service?.ToLowerInvariant() switch
|
||
{
|
||
"gemini" => "Gemini",
|
||
"sigmoid" or "claude" => "Claude",
|
||
"vllm" => "vLLM",
|
||
_ => "Ollama",
|
||
};
|
||
var model = GetCurrentModelDisplayName();
|
||
var folder = GetCurrentWorkFolder();
|
||
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
|
||
return
|
||
$"현재 상태\n" +
|
||
$"- 탭: {tab}\n" +
|
||
$"- 모델: {service} · {model}\n" +
|
||
$"- 권한: {permission}\n" +
|
||
$"- 작업 폴더: {folderText}\n" +
|
||
$"- 스트리밍: {(llm.Streaming ? "ON" : "OFF")}\n" +
|
||
$"- 컨텍스트 토큰: {llm.MaxContextTokens:N0}";
|
||
}
|
||
|
||
private string BuildSlashStatsText()
|
||
{
|
||
var usage = _llm.LastTokenUsage;
|
||
if (usage == null)
|
||
return "최근 호출 토큰 통계가 아직 없습니다. 대화를 한 번 실행한 뒤 다시 시도하세요.";
|
||
return
|
||
$"최근 호출 토큰\n" +
|
||
$"- 입력: {usage.PromptTokens:N0}\n" +
|
||
$"- 출력: {usage.CompletionTokens:N0}\n" +
|
||
$"- 합계: {usage.TotalTokens:N0}";
|
||
}
|
||
|
||
private string BuildSlashCostText()
|
||
{
|
||
var usage = _llm.LastTokenUsage;
|
||
if (usage == null)
|
||
return "최근 호출 토큰 정보가 없어 비용을 계산할 수 없습니다.";
|
||
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
|
||
usage.PromptTokens,
|
||
usage.CompletionTokens,
|
||
_settings.Settings.Llm.Service,
|
||
GetCurrentModelDisplayName());
|
||
var total = inCost + outCost;
|
||
return
|
||
$"최근 호출 추정 비용\n" +
|
||
$"- 입력 비용: {Services.TokenEstimator.FormatCost(inCost)}\n" +
|
||
$"- 출력 비용: {Services.TokenEstimator.FormatCost(outCost)}\n" +
|
||
$"- 합계: {Services.TokenEstimator.FormatCost(total)}";
|
||
}
|
||
|
||
private string BuildSlashStatuslineText()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var (_, model) = _llm.GetCurrentModelInfo();
|
||
var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
|
||
var folder = GetCurrentWorkFolder();
|
||
var folderName = string.IsNullOrWhiteSpace(folder) ? "NoFolder" : System.IO.Path.GetFileName(folder.TrimEnd('\\', '/'));
|
||
var historyCount = _currentConversation?.Messages.Count ?? 0;
|
||
return $"[{_activeTab}] {ServiceLabel(llm.Service)}/{model} · {permission} · msg {historyCount} · folder {folderName}";
|
||
}
|
||
|
||
private static string MaskEndpoint(string endpoint)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(endpoint))
|
||
return "(미설정)";
|
||
|
||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||
return endpoint;
|
||
|
||
return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : ":" + uri.Port)}";
|
||
}
|
||
|
||
private static string NormalizeServiceLabel(string service)
|
||
{
|
||
return service.Trim().ToLowerInvariant() switch
|
||
{
|
||
"vllm" => "vLLM",
|
||
"gemini" => "Gemini",
|
||
"sigmoid" => "Claude",
|
||
_ => "Ollama",
|
||
};
|
||
}
|
||
|
||
private async Task<string> BuildLlmRuntimeDiagnosisAsync()
|
||
{
|
||
var snapshot = _llm.GetRuntimeConnectionSnapshot();
|
||
var (ok, message) = await _llm.TestConnectionAsync();
|
||
|
||
var lines = new List<string>
|
||
{
|
||
"LLM 런타임 진단",
|
||
$"- 서비스: {NormalizeServiceLabel(snapshot.Service)}",
|
||
$"- 모델: {snapshot.Model}",
|
||
$"- 엔드포인트: {MaskEndpoint(snapshot.Endpoint)}",
|
||
$"- API 키: {(snapshot.HasApiKey ? "설정됨" : "미설정")}",
|
||
};
|
||
|
||
if (string.Equals(snapshot.Service, "vllm", StringComparison.OrdinalIgnoreCase))
|
||
lines.Add($"- SSL 인증서 검증 우회: {(snapshot.AllowInsecureTls ? "ON" : "OFF")}");
|
||
|
||
lines.Add($"- 연결 결과: {(ok ? "성공" : "실패")} ({message})");
|
||
return string.Join("\n", lines);
|
||
}
|
||
|
||
private bool IsMcpServerEnabled(McpServerEntry server)
|
||
{
|
||
if (_sessionMcpEnabledOverrides.TryGetValue(server.Name ?? "", out var overridden))
|
||
return overridden;
|
||
return server.Enabled;
|
||
}
|
||
|
||
private bool IsChromeMcpCandidate(McpServerEntry server)
|
||
{
|
||
if (!IsMcpServerEnabled(server))
|
||
return false;
|
||
|
||
return
|
||
(server.Name?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
|
||
(server.Command?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
|
||
(server.Url?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
|
||
server.Args.Any(a => a.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) >= 0);
|
||
}
|
||
|
||
private async Task<string> BuildChromeRuntimeDiagnosisAsync(CancellationToken ct = default)
|
||
{
|
||
var servers = _settings.Settings.Llm.McpServers ?? [];
|
||
var matches = servers.Where(IsChromeMcpCandidate).ToList();
|
||
if (matches.Count == 0)
|
||
return "Chrome MCP가 구성되지 않았습니다. 설정에서 MCP 서버를 추가/활성화한 뒤 다시 /chrome 를 실행하세요.";
|
||
|
||
var lines = new List<string>
|
||
{
|
||
"Chrome MCP 런타임 진단",
|
||
$"- 후보 서버: {matches.Count}개"
|
||
};
|
||
|
||
var connectedCount = 0;
|
||
var totalTools = 0;
|
||
foreach (var server in matches)
|
||
{
|
||
var serverName = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
|
||
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
|
||
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
lines.Add($"- {serverName}: {transport} 전송은 현재 앱의 런타임 직접 진단 미지원 (구성만 확인)");
|
||
continue;
|
||
}
|
||
|
||
using var client = new McpClientService(BuildEffectiveMcpServer(server));
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
|
||
|
||
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
|
||
if (!connected)
|
||
{
|
||
lines.Add($"- {serverName}: 연결 실패");
|
||
continue;
|
||
}
|
||
|
||
connectedCount++;
|
||
var tools = client.Tools;
|
||
totalTools += tools.Count;
|
||
lines.Add($"- {serverName}: 연결 성공 (도구 {tools.Count}개)");
|
||
|
||
var toolPreview = tools
|
||
.Select(t => t.Name)
|
||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||
.Take(6)
|
||
.ToList();
|
||
if (toolPreview.Count > 0)
|
||
lines.Add($" 도구: {string.Join(", ", toolPreview)}");
|
||
}
|
||
|
||
if (connectedCount == 0)
|
||
{
|
||
lines.Add("진단 결과: 연결 가능한 Chrome MCP 서버가 없습니다.");
|
||
lines.Add("확인 항목: command/args 경로, 실행 권한, Node/NPM 설치 상태, 서버 실행 로그");
|
||
return string.Join("\n", lines);
|
||
}
|
||
|
||
lines.Add($"진단 결과: {connectedCount}개 서버 연결 성공, 총 도구 {totalTools}개 확인");
|
||
lines.Add("다음 단계: /mcp 또는 /mcp reconnect <서버명> 으로 상태를 갱신하세요.");
|
||
return string.Join("\n", lines);
|
||
}
|
||
|
||
private async Task<(bool Ready, List<string> ServerNames, List<string> ToolNames)> ProbeChromeToolingAsync(CancellationToken ct = default)
|
||
{
|
||
var servers = _settings.Settings.Llm.McpServers ?? [];
|
||
var matches = servers.Where(IsChromeMcpCandidate).ToList();
|
||
var readyServers = new List<string>();
|
||
var toolNames = new List<string>();
|
||
if (matches.Count == 0)
|
||
return (false, readyServers, toolNames);
|
||
|
||
foreach (var server in matches)
|
||
{
|
||
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
|
||
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
using var client = new McpClientService(BuildEffectiveMcpServer(server));
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
|
||
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
|
||
if (!connected || client.Tools.Count == 0)
|
||
continue;
|
||
|
||
readyServers.Add(string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name);
|
||
toolNames.AddRange(client.Tools.Select(t => $"mcp_{t.ServerName}_{t.Name}"));
|
||
}
|
||
|
||
return (readyServers.Count > 0, readyServers, toolNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||
}
|
||
|
||
internal static string BuildChromeExecutionSystemPrompt(string userRequest, IEnumerable<string> serverNames, IEnumerable<string> toolNames)
|
||
{
|
||
var serversText = string.Join(", ", serverNames.Take(6));
|
||
var toolList = toolNames.Take(16).ToList();
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("You are executing a browser automation task via MCP tools.");
|
||
sb.AppendLine($"Target request: {userRequest}");
|
||
sb.AppendLine($"Preferred MCP servers: {serversText}");
|
||
if (toolList.Count > 0)
|
||
sb.AppendLine($"Preferred tools: {string.Join(", ", toolList)}");
|
||
sb.AppendLine("Rules:");
|
||
sb.AppendLine("1) Prioritize the preferred MCP browser tools first.");
|
||
sb.AppendLine("2) If URL scheme is missing, default to https://.");
|
||
sb.AppendLine("3) Execute only the minimum required steps.");
|
||
sb.AppendLine("4) Return concise evidence (visited URL, key page text, or action result).");
|
||
sb.AppendLine("5) If browser tooling is unavailable, explain blocker and suggest /chrome or /mcp reconnect.");
|
||
return sb.ToString();
|
||
}
|
||
|
||
internal static string BuildVerifySystemPrompt(string request)
|
||
{
|
||
var objective = string.IsNullOrWhiteSpace(request) || string.Equals(request.Trim(), "/verify", StringComparison.OrdinalIgnoreCase)
|
||
? "현재 변경사항의 품질 검증을 수행하세요."
|
||
: request.Trim();
|
||
return
|
||
"You are in verification mode.\n" +
|
||
$"Verification target: {objective}\n" +
|
||
"Required flow:\n" +
|
||
"1) Inspect current changes and identify risky files.\n" +
|
||
"2) Run build and tests with appropriate tools.\n" +
|
||
"3) If failures occur, identify root cause and patch minimally.\n" +
|
||
"4) Re-run verification until pass or clear blocker.\n" +
|
||
"5) Return a structured report in Korean exactly with sections:\n" +
|
||
" [검증결과] PASS|FAIL\n" +
|
||
" [실행요약] (build/test/review 실행 내역)\n" +
|
||
" [변경파일] (수정한 파일 목록)\n" +
|
||
" [잔여리스크] (남은 위험 또는 없음)\n";
|
||
}
|
||
|
||
internal static (string action, string argument) ParseGenericAction(string displayText, string command)
|
||
{
|
||
var raw = (displayText ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, command, StringComparison.OrdinalIgnoreCase))
|
||
return ("open", "");
|
||
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
if (parts.Length == 0)
|
||
return ("open", "");
|
||
if (parts.Length == 1)
|
||
return (parts[0].Trim().ToLowerInvariant(), "");
|
||
return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim());
|
||
}
|
||
|
||
internal static (List<string> SelectedFiles, string CommitMessage) ParseCommitCommandInput(string displayText)
|
||
{
|
||
var raw = (displayText ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "/commit", StringComparison.OrdinalIgnoreCase))
|
||
return (new List<string>(), "");
|
||
|
||
if (!raw.StartsWith("files:", StringComparison.OrdinalIgnoreCase))
|
||
return (new List<string>(), raw);
|
||
|
||
var body = raw["files:".Length..].Trim();
|
||
var split = body.Split(new[] { "::" }, 2, StringSplitOptions.None);
|
||
var filesPart = split[0].Trim();
|
||
var msgPart = split.Length >= 2 ? split[1].Trim() : "";
|
||
|
||
var files = filesPart
|
||
.Split([','], StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(x => x.Trim())
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
return (files, msgPart);
|
||
}
|
||
|
||
private static string? FindGitExecutablePath()
|
||
{
|
||
try
|
||
{
|
||
var psi = new System.Diagnostics.ProcessStartInfo("where.exe", "git")
|
||
{
|
||
RedirectStandardOutput = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true,
|
||
};
|
||
using var proc = System.Diagnostics.Process.Start(psi);
|
||
if (proc == null)
|
||
return null;
|
||
var output = proc.StandardOutput.ReadToEnd().Trim();
|
||
proc.WaitForExit(5000);
|
||
return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim();
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static string? ResolveGitRoot(string? workFolder)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(workFolder) || !System.IO.Directory.Exists(workFolder))
|
||
return null;
|
||
var dir = new System.IO.DirectoryInfo(workFolder);
|
||
while (dir != null)
|
||
{
|
||
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
|
||
return dir.FullName;
|
||
dir = dir.Parent;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunGitAsync(
|
||
string gitPath,
|
||
string workDir,
|
||
IEnumerable<string> args,
|
||
CancellationToken ct = default)
|
||
{
|
||
var psi = new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = gitPath,
|
||
WorkingDirectory = workDir,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true,
|
||
StandardOutputEncoding = System.Text.Encoding.UTF8,
|
||
StandardErrorEncoding = System.Text.Encoding.UTF8,
|
||
};
|
||
foreach (var arg in args)
|
||
psi.ArgumentList.Add(arg);
|
||
|
||
using var proc = System.Diagnostics.Process.Start(psi);
|
||
if (proc == null)
|
||
return (-1, "", "git 프로세스를 시작하지 못했습니다.");
|
||
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
||
var stderrTask = proc.StandardError.ReadToEndAsync(timeoutCts.Token);
|
||
await proc.WaitForExitAsync(timeoutCts.Token);
|
||
var stdout = await stdoutTask;
|
||
var stderr = await stderrTask;
|
||
return (proc.ExitCode, stdout, stderr);
|
||
}
|
||
|
||
private async Task<string> ExecuteCommitWithApprovalAsync(string? displayText, CancellationToken ct = default)
|
||
{
|
||
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return "커밋 실행은 Cowork/Code 탭에서만 지원됩니다.";
|
||
}
|
||
|
||
var folder = GetCurrentWorkFolder();
|
||
var gitRoot = ResolveGitRoot(folder);
|
||
if (string.IsNullOrWhiteSpace(gitRoot))
|
||
return "Git 저장소를 찾지 못했습니다. 작업 폴더를 Git 프로젝트로 설정해 주세요.";
|
||
|
||
var gitPath = FindGitExecutablePath();
|
||
if (string.IsNullOrWhiteSpace(gitPath))
|
||
return "Git 실행 파일을 찾지 못했습니다. Git 설치 및 PATH를 확인해 주세요.";
|
||
|
||
var status = await RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct).ConfigureAwait(false);
|
||
if (status.ExitCode != 0)
|
||
return $"git status 실패:\n{status.StdErr.Trim()}";
|
||
|
||
var changedLines = status.StdOut
|
||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.ToList();
|
||
if (changedLines.Count == 0)
|
||
return "커밋할 변경사항이 없습니다.";
|
||
|
||
var (selectedFilesRaw, parsedMessage) = ParseCommitCommandInput(displayText ?? "");
|
||
var changedPathSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var line in changedLines)
|
||
{
|
||
var path = line.Length > 3 ? line[3..].Trim() : line.Trim();
|
||
if (string.IsNullOrWhiteSpace(path))
|
||
continue;
|
||
if (path.Contains("->", StringComparison.Ordinal))
|
||
{
|
||
var renameParts = path.Split(new[] { "->" }, 2, StringSplitOptions.None);
|
||
if (renameParts.Length == 2)
|
||
{
|
||
var before = renameParts[0].Trim();
|
||
var after = renameParts[1].Trim();
|
||
if (!string.IsNullOrWhiteSpace(before)) changedPathSet.Add(before);
|
||
if (!string.IsNullOrWhiteSpace(after)) changedPathSet.Add(after);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
changedPathSet.Add(path);
|
||
}
|
||
|
||
var selectedFiles = selectedFilesRaw.Count == 0
|
||
? changedPathSet.ToList()
|
||
: selectedFilesRaw
|
||
.Where(p => changedPathSet.Contains(p))
|
||
.ToList();
|
||
|
||
if (selectedFiles.Count == 0)
|
||
{
|
||
if (selectedFilesRaw.Count > 0)
|
||
return "지정한 파일이 현재 변경 목록에 없습니다. /commit files:<경로1,경로2> :: <메시지> 형식을 확인해 주세요.";
|
||
return "커밋할 변경 파일을 찾지 못했습니다.";
|
||
}
|
||
|
||
var commitMessage = string.IsNullOrWhiteSpace(parsedMessage)
|
||
? $"작업: 변경사항 반영 ({selectedFiles.Count}개 파일)"
|
||
: parsedMessage;
|
||
|
||
var previewFiles = selectedFiles
|
||
.Take(8)
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.ToList();
|
||
var previewText = previewFiles.Count == 0
|
||
? "(파일 목록 없음)"
|
||
: string.Join("\n", previewFiles.Select(f => $"- {f}"));
|
||
|
||
var confirm = CustomMessageBox.Show(
|
||
$"다음 내용으로 커밋을 진행할까요?\n\n저장소: {gitRoot}\n메시지: {commitMessage}\n\n커밋 대상 파일(일부):\n{previewText}",
|
||
"커밋 승인",
|
||
MessageBoxButton.YesNo,
|
||
MessageBoxImage.Question);
|
||
if (confirm != MessageBoxResult.Yes)
|
||
return "커밋 실행을 취소했습니다.";
|
||
|
||
var addArgs = new List<string> { "add" };
|
||
if (selectedFilesRaw.Count == 0)
|
||
{
|
||
addArgs.Add("-A");
|
||
}
|
||
else
|
||
{
|
||
addArgs.Add("--");
|
||
addArgs.AddRange(selectedFiles);
|
||
}
|
||
|
||
var add = await RunGitAsync(gitPath, gitRoot, addArgs, ct).ConfigureAwait(false);
|
||
if (add.ExitCode != 0)
|
||
return $"git add 실패:\n{add.StdErr.Trim()}";
|
||
|
||
var commit = await RunGitAsync(gitPath, gitRoot, new[] { "commit", "-m", commitMessage }, ct).ConfigureAwait(false);
|
||
if (commit.ExitCode != 0)
|
||
{
|
||
var err = string.IsNullOrWhiteSpace(commit.StdErr) ? commit.StdOut : commit.StdErr;
|
||
return $"git commit 실패:\n{err.Trim()}";
|
||
}
|
||
|
||
var head = await RunGitAsync(gitPath, gitRoot, new[] { "log", "--oneline", "-1" }, ct).ConfigureAwait(false);
|
||
var headLine = (head.StdOut ?? "").Trim();
|
||
return
|
||
$"커밋이 완료되었습니다.\n" +
|
||
$"- 메시지: {commitMessage}\n" +
|
||
$"- 커밋 파일 수: {selectedFiles.Count}\n" +
|
||
(string.IsNullOrWhiteSpace(headLine) ? "" : $"- HEAD: {headLine}\n") +
|
||
"- 원격 반영은 사용자가 직접 push 해주세요.\n" +
|
||
"- 팁: /commit files:path1,path2 :: 메시지 형태로 부분 커밋할 수 있습니다.";
|
||
}
|
||
|
||
internal static (string action, string target) ParseMcpAction(string displayText)
|
||
{
|
||
var text = (displayText ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(text) || string.Equals(text, "/mcp", StringComparison.OrdinalIgnoreCase))
|
||
return ("status", "");
|
||
|
||
var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
if (parts.Length == 0)
|
||
return ("status", "");
|
||
|
||
var action = parts[0].Trim().ToLowerInvariant();
|
||
var target = parts.Length >= 2 ? string.Join(' ', parts.Skip(1)).Trim() : "";
|
||
return action switch
|
||
{
|
||
"enable" => ("enable", target),
|
||
"disable" => ("disable", target),
|
||
"reconnect" => ("reconnect", target),
|
||
"add" => ("add", target),
|
||
"remove" => ("remove", target),
|
||
"reset" => ("reset", target),
|
||
"login" => ("login", target),
|
||
"logout" => ("logout", target),
|
||
"status" => ("status", target),
|
||
_ => ("help", text),
|
||
};
|
||
}
|
||
|
||
internal static (bool success, string serverTarget, string token, string error) ParseMcpLoginTarget(string target)
|
||
{
|
||
var raw = (target ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
return (false, "", "", "사용법: /mcp login <서버명> <토큰>");
|
||
|
||
var firstSpace = raw.IndexOf(' ');
|
||
if (firstSpace <= 0 || firstSpace >= raw.Length - 1)
|
||
return (false, "", "", "토큰이 누락되었습니다. 사용법: /mcp login <서버명> <토큰>");
|
||
|
||
var serverTarget = raw[..firstSpace].Trim();
|
||
var token = raw[(firstSpace + 1)..].Trim();
|
||
if (string.IsNullOrWhiteSpace(serverTarget))
|
||
return (false, "", "", "서버명이 비어 있습니다.");
|
||
if (string.IsNullOrWhiteSpace(token))
|
||
return (false, "", "", "토큰이 비어 있습니다.");
|
||
|
||
return (true, serverTarget, token, "");
|
||
}
|
||
|
||
internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target)
|
||
{
|
||
var raw = (target ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
return (false, null, "사용법: /mcp add <서버명> :: stdio <명령> [인자...] | /mcp add <서버명> :: sse <URL>");
|
||
|
||
var sep = raw.IndexOf("::", StringComparison.Ordinal);
|
||
if (sep < 0)
|
||
return (false, null, "추가 형식이 올바르지 않습니다. 구분자 `::` 를 사용하세요.\n예: /mcp add chrome :: stdio node server.js");
|
||
|
||
var name = raw[..sep].Trim();
|
||
var spec = raw[(sep + 2)..].Trim();
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
return (false, null, "서버명이 비어 있습니다. /mcp add <서버명> :: ... 형식으로 입력하세요.");
|
||
if (string.IsNullOrWhiteSpace(spec))
|
||
return (false, null, "연결 정보가 비어 있습니다. stdio 또는 sse 설정을 입력하세요.");
|
||
|
||
var tokens = TokenizeCommand(spec);
|
||
if (tokens.Count < 2)
|
||
return (false, null, "연결 정보가 부족합니다. 예: stdio node server.js 또는 sse https://host/sse");
|
||
|
||
var transport = tokens[0].Trim().ToLowerInvariant();
|
||
if (transport == "stdio")
|
||
{
|
||
var command = tokens[1].Trim();
|
||
if (string.IsNullOrWhiteSpace(command))
|
||
return (false, null, "stdio 방식은 실행 명령(command)이 필요합니다.");
|
||
|
||
var args = tokens.Count > 2 ? tokens.Skip(2).ToList() : new List<string>();
|
||
return (true, new McpServerEntry
|
||
{
|
||
Name = name,
|
||
Transport = "stdio",
|
||
Command = command,
|
||
Args = args,
|
||
Enabled = true,
|
||
}, "");
|
||
}
|
||
|
||
if (transport == "sse")
|
||
{
|
||
var url = string.Join(" ", tokens.Skip(1)).Trim();
|
||
if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed))
|
||
return (false, null, $"유효하지 않은 SSE URL 입니다: {url}");
|
||
if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||
!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||
return (false, null, "SSE URL은 http 또는 https 스킴만 지원합니다.");
|
||
|
||
return (true, new McpServerEntry
|
||
{
|
||
Name = name,
|
||
Transport = "sse",
|
||
Url = url,
|
||
Enabled = true,
|
||
}, "");
|
||
}
|
||
|
||
return (false, null, $"지원하지 않는 transport 입니다: {transport}. 사용 가능 값: stdio, sse");
|
||
}
|
||
|
||
internal static List<string> TokenizeCommand(string input)
|
||
{
|
||
var text = input ?? "";
|
||
var tokens = new List<string>();
|
||
var sb = new System.Text.StringBuilder();
|
||
var inQuote = false;
|
||
|
||
foreach (var ch in text)
|
||
{
|
||
if (ch == '"')
|
||
{
|
||
inQuote = !inQuote;
|
||
continue;
|
||
}
|
||
|
||
if (!inQuote && char.IsWhiteSpace(ch))
|
||
{
|
||
if (sb.Length == 0) continue;
|
||
tokens.Add(sb.ToString());
|
||
sb.Clear();
|
||
continue;
|
||
}
|
||
|
||
sb.Append(ch);
|
||
}
|
||
|
||
if (sb.Length > 0)
|
||
tokens.Add(sb.ToString());
|
||
|
||
return tokens;
|
||
}
|
||
|
||
private async Task<string> BuildMcpRuntimeStatusTextAsync(
|
||
IEnumerable<McpServerEntry>? source = null,
|
||
bool runtimeCheck = true,
|
||
CancellationToken ct = default)
|
||
{
|
||
var servers = (source ?? _settings.Settings.Llm.McpServers ?? []).ToList();
|
||
if (servers.Count == 0)
|
||
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 /mcp 를 다시 실행하세요.";
|
||
|
||
var lines = new List<string>
|
||
{
|
||
"MCP 상태",
|
||
$"- 전체: {servers.Count}개",
|
||
$"- 활성(세션 기준): {servers.Count(IsMcpServerEnabled)}개"
|
||
};
|
||
|
||
foreach (var server in servers.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
|
||
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
|
||
var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : "";
|
||
if (!IsMcpServerEnabled(server))
|
||
{
|
||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||
continue;
|
||
}
|
||
|
||
if (!runtimeCheck)
|
||
{
|
||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||
continue;
|
||
}
|
||
|
||
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||
continue;
|
||
}
|
||
|
||
using var client = new McpClientService(BuildEffectiveMcpServer(server));
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
|
||
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
|
||
if (!connected)
|
||
{
|
||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||
continue;
|
||
}
|
||
|
||
var toolCount = client.Tools.Count;
|
||
var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount);
|
||
lines.Add($"- {name} [{transport}] : {statusLabel}{authSuffix}");
|
||
}
|
||
|
||
lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset | login <서버명> <토큰> | logout <서버명|all>");
|
||
return string.Join("\n", lines);
|
||
}
|
||
|
||
internal static string ResolveMcpDisplayStatus(bool isEnabled, string transport, bool runtimeCheck, bool connected, int? toolCount)
|
||
{
|
||
var normalizedTransport = string.IsNullOrWhiteSpace(transport) ? "stdio" : transport.Trim().ToLowerInvariant();
|
||
if (!isEnabled)
|
||
return "Disabled";
|
||
|
||
if (!runtimeCheck)
|
||
return "Enabled";
|
||
|
||
if (!string.Equals(normalizedTransport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||
return "Configured";
|
||
|
||
if (!connected)
|
||
return "Disconnected";
|
||
|
||
if ((toolCount ?? 0) <= 0)
|
||
return "NeedsAuth (도구 0개)";
|
||
|
||
return $"Connected (도구 {toolCount}개)";
|
||
}
|
||
|
||
private string ResolveMcpServerName(IEnumerable<McpServerEntry> servers, string inputName)
|
||
{
|
||
var trimmed = inputName.Trim();
|
||
if (string.IsNullOrWhiteSpace(trimmed))
|
||
return "";
|
||
var exact = servers.FirstOrDefault(s => string.Equals(s.Name, trimmed, StringComparison.OrdinalIgnoreCase));
|
||
if (exact != null)
|
||
return exact.Name;
|
||
var partial = servers.FirstOrDefault(s => (s.Name?.IndexOf(trimmed, StringComparison.OrdinalIgnoreCase) ?? -1) >= 0);
|
||
return partial?.Name ?? "";
|
||
}
|
||
|
||
private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server)
|
||
{
|
||
var clone = new McpServerEntry
|
||
{
|
||
Name = server.Name,
|
||
Command = server.Command,
|
||
Args = server.Args?.ToList() ?? new List<string>(),
|
||
Env = new Dictionary<string, string>(server.Env ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
|
||
Enabled = server.Enabled,
|
||
Transport = server.Transport,
|
||
Url = server.Url,
|
||
};
|
||
|
||
var key = clone.Name ?? "";
|
||
if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token))
|
||
clone.Env["MCP_AUTH_TOKEN"] = token;
|
||
|
||
return clone;
|
||
}
|
||
|
||
private async Task<string> HandleMcpSlashAsync(string displayText, CancellationToken ct = default)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.McpServers ??= new List<McpServerEntry>();
|
||
var servers = llm.McpServers;
|
||
var (action, target) = ParseMcpAction(displayText);
|
||
if (action == "help")
|
||
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
|
||
|
||
if (action == "status")
|
||
return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
|
||
|
||
if (action == "reset")
|
||
{
|
||
var changed = _sessionMcpEnabledOverrides.Count;
|
||
_sessionMcpEnabledOverrides.Clear();
|
||
_sessionMcpAuthTokens.Clear();
|
||
return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
if (action == "login")
|
||
{
|
||
var (ok, serverTarget, token, error) = ParseMcpLoginTarget(target);
|
||
if (!ok)
|
||
return error;
|
||
|
||
var resolved = ResolveMcpServerName(servers, serverTarget);
|
||
if (string.IsNullOrWhiteSpace(resolved))
|
||
return $"로그인 대상 서버를 찾지 못했습니다: {serverTarget}";
|
||
|
||
_sessionMcpAuthTokens[resolved] = token;
|
||
return $"MCP 세션 토큰을 설정했습니다: {resolved}\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
if (action == "logout")
|
||
{
|
||
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var removed = _sessionMcpAuthTokens.Count;
|
||
_sessionMcpAuthTokens.Clear();
|
||
return $"모든 MCP 세션 토큰을 제거했습니다. ({removed}개)\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
var resolved = ResolveMcpServerName(servers, target);
|
||
if (string.IsNullOrWhiteSpace(resolved))
|
||
return $"로그아웃 대상 서버를 찾지 못했습니다: {target}";
|
||
|
||
var removedOne = _sessionMcpAuthTokens.Remove(resolved);
|
||
return $"MCP 세션 토큰 제거: {resolved} ({(removedOne ? 1 : 0)}개)\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
if (action == "add")
|
||
{
|
||
var (ok, entry, error) = ParseMcpAddTarget(target);
|
||
if (!ok || entry == null)
|
||
return error;
|
||
|
||
var duplicate = servers.Any(s => string.Equals(s.Name, entry.Name, StringComparison.OrdinalIgnoreCase));
|
||
if (duplicate)
|
||
return $"동일한 이름의 MCP 서버가 이미 존재합니다: {entry.Name}\n기존 항목을 수정하거나 /mcp remove {entry.Name} 후 다시 추가하세요.";
|
||
|
||
servers.Add(entry);
|
||
ScheduleSettingsSave();
|
||
_sessionMcpEnabledOverrides.Remove(entry.Name);
|
||
|
||
return $"MCP 서버를 추가했습니다: {entry.Name} ({entry.Transport})\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
if (servers.Count == 0)
|
||
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요.";
|
||
|
||
if (action == "remove")
|
||
{
|
||
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var removed = servers.Count;
|
||
servers.Clear();
|
||
_sessionMcpEnabledOverrides.Clear();
|
||
_sessionMcpAuthTokens.Clear();
|
||
ScheduleSettingsSave();
|
||
return $"MCP 서버를 모두 제거했습니다. ({removed}개)";
|
||
}
|
||
|
||
var resolved = ResolveMcpServerName(servers, target);
|
||
if (string.IsNullOrWhiteSpace(resolved))
|
||
return $"제거 대상 서버를 찾지 못했습니다: {target}";
|
||
|
||
var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
|
||
_sessionMcpEnabledOverrides.Remove(resolved);
|
||
_sessionMcpAuthTokens.Remove(resolved);
|
||
ScheduleSettingsSave();
|
||
return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
if (action is "enable" or "disable")
|
||
{
|
||
var newEnabled = action == "enable";
|
||
int changed;
|
||
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
changed = 0;
|
||
foreach (var server in servers.Where(s => IsMcpServerEnabled(s) != newEnabled))
|
||
{
|
||
_sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled;
|
||
changed++;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var resolved = ResolveMcpServerName(servers, target);
|
||
if (string.IsNullOrWhiteSpace(resolved))
|
||
return $"대상 서버를 찾지 못했습니다: {target}";
|
||
var server = servers.First(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
|
||
changed = IsMcpServerEnabled(server) == newEnabled ? 0 : 1;
|
||
_sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled;
|
||
}
|
||
|
||
var status = newEnabled ? "활성화" : "비활성화";
|
||
return $"MCP 서버 {status} 완료 ({changed}개 변경, 현재 세션에만 적용)\n" +
|
||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
if (action == "reconnect")
|
||
{
|
||
List<McpServerEntry> targetServers;
|
||
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
targetServers = servers.Where(IsMcpServerEnabled).ToList();
|
||
}
|
||
else
|
||
{
|
||
var resolved = ResolveMcpServerName(servers, target);
|
||
if (string.IsNullOrWhiteSpace(resolved))
|
||
return $"재연결 대상 서버를 찾지 못했습니다: {target}";
|
||
targetServers = servers.Where(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)).ToList();
|
||
}
|
||
|
||
if (targetServers.Count == 0)
|
||
return "재연결할 활성 MCP 서버가 없습니다.";
|
||
|
||
return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
|
||
}
|
||
|
||
private string BuildSlashHeapDumpText()
|
||
{
|
||
var proc = System.Diagnostics.Process.GetCurrentProcess();
|
||
var managedBytes = GC.GetTotalMemory(forceFullCollection: false);
|
||
var workingSet = proc.WorkingSet64;
|
||
var privateBytes = proc.PrivateMemorySize64;
|
||
return
|
||
$"메모리 진단 (heapdump)\n" +
|
||
$"- Managed Heap: {managedBytes / 1024.0 / 1024.0:F1} MB\n" +
|
||
$"- Working Set: {workingSet / 1024.0 / 1024.0:F1} MB\n" +
|
||
$"- Private Bytes: {privateBytes / 1024.0 / 1024.0:F1} MB\n" +
|
||
$"- GC Gen0/1/2: {GC.CollectionCount(0)}/{GC.CollectionCount(1)}/{GC.CollectionCount(2)}";
|
||
}
|
||
|
||
private string CyclePassPreset()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var current = llm.MaxAgentIterations <= 16 ? 16 : llm.MaxAgentIterations <= 25 ? 25 : 40;
|
||
var next = current switch
|
||
{
|
||
16 => 25,
|
||
25 => 40,
|
||
_ => 16,
|
||
};
|
||
llm.MaxAgentIterations = next;
|
||
ScheduleSettingsSave();
|
||
return $"Agent Pass 프리셋을 {next}로 변경했습니다.";
|
||
}
|
||
|
||
private string BuildThinkbackSummaryText()
|
||
{
|
||
var conv = _currentConversation;
|
||
if (conv == null || conv.Messages.Count == 0)
|
||
return "회고할 대화가 없습니다.";
|
||
|
||
var recent = conv.Messages
|
||
.Where(m => m.Role is "user" or "assistant")
|
||
.TakeLast(10)
|
||
.ToList();
|
||
if (recent.Count == 0)
|
||
return "회고할 대화가 없습니다.";
|
||
|
||
var userCount = recent.Count(m => m.Role == "user");
|
||
var assistantCount = recent.Count(m => m.Role == "assistant");
|
||
var latestRun = _appState.GetLatestConversationRun(conv.AgentRunHistory);
|
||
var highlights = recent
|
||
.TakeLast(4)
|
||
.Select(m => $"- {m.Role}: {TruncateForStatus((m.Content ?? "").Replace("\r", " ").Replace("\n", " "), 80)}");
|
||
|
||
return
|
||
$"최근 대화 회고\n" +
|
||
$"- 최근 메시지: {recent.Count}개 (user {userCount}, assistant {assistantCount})\n" +
|
||
$"- 최근 실행 상태: {(latestRun == null ? "기록 없음" : $"{latestRun.Status} · {TruncateForStatus(latestRun.Summary, 40)}")}\n" +
|
||
string.Join("\n", highlights);
|
||
}
|
||
|
||
private string BuildThinkbackPlayText()
|
||
{
|
||
var conv = _currentConversation;
|
||
if (conv == null || conv.Messages.Count == 0)
|
||
return "재생할 대화 이력이 없습니다.";
|
||
|
||
var lastUser = conv.Messages.LastOrDefault(m => m.Role == "user")?.Content ?? "(없음)";
|
||
var lastAssistant = conv.Messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? "(없음)";
|
||
var folder = GetCurrentWorkFolder();
|
||
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
|
||
return
|
||
$"회고 기반 실행 플랜\n" +
|
||
$"1. 마지막 사용자 요청 재확인: {TruncateForStatus(lastUser.Replace('\n', ' '), 72)}\n" +
|
||
$"2. 마지막 응답의 보강 포인트 점검: {TruncateForStatus(lastAssistant.Replace('\n', ' '), 72)}\n" +
|
||
$"3. 작업 폴더 기준 실행/검증: {TruncateForStatus(folderText, 52)}\n" +
|
||
$"4. 부족한 근거/테스트 보강 후 다음 답변 생성";
|
||
}
|
||
|
||
private void OpenSkillsFromSlash()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
if (!llm.EnableSkillSystem)
|
||
{
|
||
llm.EnableSkillSystem = true;
|
||
SkillService.EnsureSkillFolder();
|
||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder(), llm.AdditionalSkillFolders);
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
ScheduleSettingsSave();
|
||
_appState.LoadFromSettings(_settings);
|
||
RefreshInlineSettingsPanel();
|
||
}
|
||
|
||
OpenCommandSkillBrowser("/");
|
||
}
|
||
|
||
private string TogglePermissionModeFromSlash()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.FilePermission = NextPermission(llm.FilePermission);
|
||
ScheduleSettingsSave();
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdatePermissionUI();
|
||
SaveConversationSettings();
|
||
RefreshInlineSettingsPanel();
|
||
return PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
|
||
}
|
||
|
||
private bool PromptRenameConversationFromSlash(out string renamedTitle)
|
||
{
|
||
renamedTitle = "";
|
||
ChatConversation? conv;
|
||
lock (_convLock)
|
||
{
|
||
_currentConversation ??= ChatSession?.EnsureCurrentConversation(_activeTab) ?? new ChatConversation { Tab = _activeTab };
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
var currentTitle = string.IsNullOrWhiteSpace(conv.Title) ? "새 대화" : conv.Title;
|
||
var dlg = new Views.InputDialog("대화 이름 변경", "새 대화 이름:", currentTitle) { Owner = this };
|
||
if (dlg.ShowDialog() != true)
|
||
return false;
|
||
|
||
var newTitle = dlg.ResponseText.Trim();
|
||
if (string.IsNullOrWhiteSpace(newTitle) || string.Equals(newTitle, currentTitle, StringComparison.Ordinal))
|
||
return false;
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
|
||
conv = _currentConversation;
|
||
}
|
||
else if (conv != null)
|
||
{
|
||
conv.Title = newTitle;
|
||
_storage.Save(conv);
|
||
}
|
||
}
|
||
|
||
SaveLastConversations();
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
renamedTitle = newTitle;
|
||
return true;
|
||
}
|
||
|
||
private async Task SendMessageAsync(string? directText = null)
|
||
{
|
||
var useComposerInput = directText == null;
|
||
var rawText = (directText ?? InputBox.Text).Trim();
|
||
|
||
// 슬래시 칩이 활성화된 경우 명령어 앞에 붙임
|
||
var text = useComposerInput && _slashPalette.ActiveCommand != null
|
||
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
|
||
: rawText;
|
||
if (useComposerInput)
|
||
HideSlashChip(restoreText: false);
|
||
|
||
if (string.IsNullOrEmpty(text)) return;
|
||
|
||
// 스트리밍 가드: 이전 세션에서 정리되지 않은 고착 상태 감지 및 자동 복구
|
||
if (_streamingTabs.Contains(_activeTab))
|
||
{
|
||
// CTS가 없거나 이미 취소/해제된 경우 → 고착 상태 → 강제 정리 후 진행
|
||
if (!_tabStreamCts.TryGetValue(_activeTab, out var existingCts)
|
||
|| existingCts.IsCancellationRequested)
|
||
{
|
||
Services.LogService.Warn($"고착된 스트리밍 상태 감지 (탭={_activeTab}). 강제 정리합니다.");
|
||
ResetStreamingUiState(_activeTab);
|
||
}
|
||
else
|
||
{
|
||
// 실제로 스트리밍 중 → 전송 차단
|
||
return;
|
||
}
|
||
}
|
||
|
||
// placeholder 정리
|
||
ClearPromptCardPlaceholder();
|
||
|
||
// 슬래시 명령어 처리
|
||
var (slashSystem, displayText) = await ParseSlashCommandAsync(text, CancellationToken.None);
|
||
|
||
if (slashSystem == null
|
||
&& _settings.Settings.Llm.EnableSkillSystem
|
||
&& !text.TrimStart().StartsWith("/", StringComparison.Ordinal))
|
||
{
|
||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||
var autoSkillPrompt = await SkillService.BuildProactiveSkillSystemPromptAsync(displayText, _activeTab, GetCurrentWorkFolder(), CancellationToken.None);
|
||
if (!string.IsNullOrWhiteSpace(autoSkillPrompt))
|
||
slashSystem = autoSkillPrompt;
|
||
}
|
||
|
||
if (string.Equals(slashSystem, "__CLEAR__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/clear", "현재 대화를 정리하고 새 대화를 시작합니다.");
|
||
StartNewConversation();
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__NEW__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/new", "새 대화를 시작합니다.");
|
||
StartNewConversation();
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__RESET__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/reset", "현재 세션 컨텍스트를 초기화하고 새 대화를 시작합니다.");
|
||
StartNewConversation();
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__STATUS__", StringComparison.Ordinal))
|
||
{
|
||
var (statusAction, _) = ParseGenericAction(displayText ?? "", "/status");
|
||
if (statusAction is "test" or "diag" or "connection" or "connect")
|
||
{
|
||
var diagnosis = await BuildLlmRuntimeDiagnosisAsync();
|
||
AppendLocalSlashResult(_activeTab, "/status", diagnosis);
|
||
return;
|
||
}
|
||
|
||
AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText() + "\n(연결 점검: /status test)");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal))
|
||
{
|
||
var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions");
|
||
if (TryApplyPermissionModeFromAction(permAction, out var appliedMode))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(appliedMode)}({appliedMode})로 변경했습니다.\n{BuildPermissionStatusText()}");
|
||
return;
|
||
}
|
||
|
||
if (permAction == "status")
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|accept|plan|bypass|status");
|
||
return;
|
||
}
|
||
|
||
OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|accept|plan|bypass|status");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal))
|
||
{
|
||
var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools");
|
||
if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(allowedMode)}({allowedMode})로 변경했습니다.\n{BuildPermissionStatusText()}");
|
||
return;
|
||
}
|
||
|
||
if (toolAction == "status")
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|accept|plan|bypass|status");
|
||
return;
|
||
}
|
||
|
||
OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|accept|plan|bypass|status");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal))
|
||
{
|
||
BtnModelSelector_Click(this, new RoutedEventArgs());
|
||
AppendLocalSlashResult(_activeTab, "/model", "모델 선택 패널을 열었습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__SETTINGS__", StringComparison.Ordinal))
|
||
{
|
||
var (settingsAction, _) = ParseGenericAction(displayText ?? "", "/settings");
|
||
if (settingsAction == "model")
|
||
{
|
||
BtnModelSelector_Click(this, new RoutedEventArgs());
|
||
AppendLocalSlashResult(_activeTab, "/settings", "모델 선택 패널을 열었습니다.");
|
||
return;
|
||
}
|
||
|
||
if (settingsAction == "permissions")
|
||
{
|
||
OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions");
|
||
return;
|
||
}
|
||
|
||
if (settingsAction == "mcp")
|
||
{
|
||
OpenAgentSettingsWindow();
|
||
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. MCP 항목에서 서버를 관리하세요.");
|
||
return;
|
||
}
|
||
|
||
if (settingsAction == "theme")
|
||
{
|
||
OpenAgentSettingsWindow();
|
||
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. 테마 항목을 확인하세요.");
|
||
return;
|
||
}
|
||
|
||
OpenAgentSettingsWindow();
|
||
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. (사용법: /settings model|permissions|mcp|theme)");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__THEME__", StringComparison.Ordinal))
|
||
{
|
||
OpenAgentSettingsWindow();
|
||
AppendLocalSlashResult(_activeTab, "/theme", "설정 창을 열었습니다. AX Agent 테마를 변경할 수 있습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__STATS__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/stats", BuildSlashStatsText());
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__COST__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/cost", BuildSlashCostText());
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__EXPORT__", StringComparison.Ordinal))
|
||
{
|
||
ExportConversation();
|
||
AppendLocalSlashResult(_activeTab, "/export", "현재 대화를 내보냈습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__STATUSLINE__", StringComparison.Ordinal))
|
||
{
|
||
var line = BuildSlashStatuslineText();
|
||
SetStatus(line, spinning: false);
|
||
AppendLocalSlashResult(_activeTab, "/statusline", line);
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__HEAPDUMP__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/heapdump", BuildSlashHeapDumpText());
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__PASSES__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/passes", CyclePassPreset());
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__CHROME__", StringComparison.Ordinal))
|
||
{
|
||
var chromeInput = (displayText ?? "").Trim();
|
||
var hasArgs = !string.IsNullOrWhiteSpace(chromeInput)
|
||
&& !string.Equals(chromeInput, "/chrome", StringComparison.OrdinalIgnoreCase);
|
||
if (!hasArgs)
|
||
{
|
||
var diagnosis = await BuildChromeRuntimeDiagnosisAsync();
|
||
AppendLocalSlashResult(_activeTab, "/chrome", diagnosis);
|
||
return;
|
||
}
|
||
|
||
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/chrome",
|
||
"브라우저 제어 실행은 Cowork/Code 탭에서 지원됩니다. 탭 전환 후 다시 실행해 주세요.\n" +
|
||
$"예: /chrome {chromeInput}");
|
||
return;
|
||
}
|
||
|
||
var probe = await ProbeChromeToolingAsync();
|
||
if (!probe.Ready)
|
||
{
|
||
var reconnectResult = await HandleMcpSlashAsync("/mcp reconnect all");
|
||
probe = await ProbeChromeToolingAsync();
|
||
if (probe.Ready)
|
||
{
|
||
slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames);
|
||
text = chromeInput;
|
||
SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비(재연결 성공)", spinning: false);
|
||
AppendLocalSlashResult(_activeTab, "/chrome", "사전 점검에서 연결이 부족해 /mcp reconnect all을 자동 실행했고 재시도에 성공했습니다.");
|
||
goto CHROME_ROUTING_READY;
|
||
}
|
||
|
||
var diagnosis = await BuildChromeRuntimeDiagnosisAsync();
|
||
AppendLocalSlashResult(_activeTab, "/chrome",
|
||
"브라우저 MCP 도구가 준비되지 않아 실행을 시작하지 못했습니다.\n" +
|
||
"자동 재시도(/mcp reconnect all) 결과:\n" + reconnectResult + "\n\n" + diagnosis);
|
||
return;
|
||
}
|
||
|
||
slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames);
|
||
text = chromeInput;
|
||
SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비", spinning: false);
|
||
CHROME_ROUTING_READY:;
|
||
}
|
||
if (string.Equals(slashSystem, "__MCP__", StringComparison.Ordinal))
|
||
{
|
||
var mcpResult = await HandleMcpSlashAsync(displayText ?? "");
|
||
AppendLocalSlashResult(_activeTab, "/mcp", mcpResult);
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__STICKERS__", StringComparison.Ordinal))
|
||
{
|
||
var queueSummary = _appState.GetDraftQueueSummary(_activeTab);
|
||
var activeCount = _appState.ActiveTasks.Count;
|
||
AppendLocalSlashResult(_activeTab, "/stickers",
|
||
"빠른 상태 스티커\n" +
|
||
$"- [RUN] 진행중 작업 {activeCount}\n" +
|
||
$"- [QUEUE] 대기 {queueSummary.QueuedCount}\n" +
|
||
$"- [BLOCK] 승인 대기 {queueSummary.BlockedCount}\n" +
|
||
"- [DONE] 완료 후 /statusline 으로 상태 확인");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__THINKBACK__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/thinkback", BuildThinkbackSummaryText());
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__THINKBACK_PLAY__", StringComparison.Ordinal))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/thinkback-play", BuildThinkbackPlayText());
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__SKILLS__", StringComparison.Ordinal))
|
||
{
|
||
OpenSkillsFromSlash();
|
||
AppendLocalSlashResult(_activeTab, "/skills", "스킬 브라우저를 열었습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__SANDBOX_TOGGLE__", StringComparison.Ordinal))
|
||
{
|
||
var mode = TogglePermissionModeFromSlash();
|
||
AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(mode)}({mode})로 변경했습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__RENAME__", StringComparison.Ordinal))
|
||
{
|
||
if (PromptRenameConversationFromSlash(out var renamedTitle))
|
||
AppendLocalSlashResult(_activeTab, "/rename", $"대화 이름을 \"{renamedTitle}\"로 변경했습니다.");
|
||
else
|
||
AppendLocalSlashResult(_activeTab, "/rename", "이름 변경을 취소했습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__FEEDBACK__", StringComparison.Ordinal))
|
||
{
|
||
ChatConversation? currentConv;
|
||
lock (_convLock)
|
||
currentConv = _currentConversation;
|
||
var hasAssistant = currentConv?.Messages.Any(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) == true;
|
||
if (!hasAssistant)
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/feedback", "피드백할 이전 응답이 없습니다. 먼저 대화를 진행한 뒤 다시 시도하세요.");
|
||
return;
|
||
}
|
||
|
||
ShowRetryWithFeedbackInput();
|
||
AppendLocalSlashResult(_activeTab, "/feedback", "수정 피드백 입력 패널을 열었습니다.");
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__VERIFY__", StringComparison.Ordinal))
|
||
{
|
||
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
AppendLocalSlashResult(_activeTab, "/verify", "검증 모드는 Cowork/Code 탭에서만 실행할 수 있습니다.");
|
||
return;
|
||
}
|
||
|
||
slashSystem = BuildVerifySystemPrompt(displayText ?? "");
|
||
text = string.IsNullOrWhiteSpace(displayText) ? "현재 변경사항 검증 실행" : displayText;
|
||
SetStatus("검증 모드 실행 준비...", spinning: false);
|
||
}
|
||
if (string.Equals(slashSystem, "__COMMIT__", StringComparison.Ordinal))
|
||
{
|
||
var commitResult = await ExecuteCommitWithApprovalAsync(displayText, _tabStreamCts.TryGetValue(_activeTab, out var commitCts) ? commitCts.Token : CancellationToken.None);
|
||
AppendLocalSlashResult(_activeTab, "/commit", commitResult);
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__COMPACT__", StringComparison.Ordinal))
|
||
{
|
||
await ExecuteManualCompactAsync("/compact", _activeTab);
|
||
return;
|
||
}
|
||
|
||
// Cowork/Code 탭: 작업 폴더 미선택 시 실행 차단
|
||
// 대화 자체에 WorkFolder가 설정되어 있는지 확인 (전역 설정 fallback은 사용하지 않음)
|
||
if (_activeTab is "Cowork" or "Code")
|
||
{
|
||
string? convWorkFolder;
|
||
lock (_convLock) convWorkFolder = _currentConversation?.WorkFolder;
|
||
if (string.IsNullOrWhiteSpace(convWorkFolder) || !System.IO.Directory.Exists(convWorkFolder))
|
||
{
|
||
HighlightFolderSelectButton();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Code 탭 + 읽기 전용 권한: 코드 개발이 어려우므로 사용자에게 확인
|
||
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var currentPerm = Services.Agent.PermissionModeCatalog.NormalizeGlobalMode(
|
||
_settings.Settings.Llm.FilePermission);
|
||
if (string.Equals(currentPerm, Services.Agent.PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var result = MessageBox.Show(
|
||
"현재 읽기 전용 모드입니다.\n코드 개발 시 파일 수정이 필요할 수 있어 작업이 제한됩니다.\n\n그래도 진행하시겠습니까?",
|
||
"읽기 전용 권한 확인",
|
||
MessageBoxButton.YesNo,
|
||
MessageBoxImage.Warning);
|
||
if (result != MessageBoxResult.Yes)
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처
|
||
var originTab = _activeTab;
|
||
var runTab = originTab;
|
||
var queuedDraftId = _runningDraftId;
|
||
var draftSucceeded = false;
|
||
var draftCancelled = false;
|
||
string? draftFailure = null;
|
||
var lastAutoSaveUtc = DateTime.UtcNow;
|
||
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null)
|
||
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
// 첨부 파일 경로를 사용자 메시지에 명시 (대화 기록에서 파일 참조가 보이도록)
|
||
var displayText2 = text;
|
||
if (_attachedFiles.Count > 0)
|
||
{
|
||
var fileLines = string.Join("\n", _attachedFiles.Select(f => $"📎 {f}"));
|
||
displayText2 = $"{fileLines}\n\n{text}";
|
||
}
|
||
|
||
var userMsg = new ChatMessage { Role = "user", Content = displayText2 };
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(runTab, userMsg, useForTitle: true);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(userMsg);
|
||
if (conv.Messages.Count(m => m.Role == "user") == 1)
|
||
conv.Title = text.Length > 30 ? text[..30] + "…" : text;
|
||
}
|
||
}
|
||
|
||
// ── UI 먼저 갱신: 사용자 메시지를 즉시 화면에 표시 ──
|
||
Services.LogService.Info($"[SendMsg] 사용자 메시지 전송: tab={runTab}, msgCount={conv.Messages.Count}, convId={conv.Id?[..Math.Min(8, conv.Id?.Length ?? 0)]}");
|
||
UpdateChatTitle();
|
||
if (useComposerInput)
|
||
{
|
||
InputBox.Text = "";
|
||
UpdateInputBoxHeight();
|
||
}
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
StopMascotAnimation();
|
||
InvalidateTimelineCache(); // 메시지 추가 직후 캐시 무효화 — 새 메시지 반영 보장
|
||
try
|
||
{
|
||
RenderMessages(preserveViewport: false); // 사용자 메시지 전송 시 스크롤 하단 이동 포함 재렌더
|
||
}
|
||
catch (Exception renderEx)
|
||
{
|
||
Services.LogService.Error($"[SendMsg] RenderMessages 실패: {renderEx.Message}\n{renderEx.StackTrace}");
|
||
}
|
||
// RenderMessages가 EmptyState를 Visible로 되돌릴 수 있으므로 강제 재설정
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
|
||
// ── 디스크 I/O는 UI 갱신 후 비동기 실행 (UI 프리징 방지) ──
|
||
var convForPersist = conv;
|
||
_ = Dispatcher.InvokeAsync(() =>
|
||
{
|
||
try
|
||
{
|
||
SaveLastConversations();
|
||
PersistConversationSnapshot(originTab, convForPersist, "대화 중간 저장 실패");
|
||
}
|
||
catch { /* 초기 저장 실패는 무시 — 중간 저장에서 재시도 */ }
|
||
}, System.Windows.Threading.DispatcherPriority.Background);
|
||
|
||
// 대화 통계 기록
|
||
Services.UsageStatisticsService.RecordChat(runTab);
|
||
|
||
ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동
|
||
PlayRainbowGlow(); // 무지개 글로우 애니메이션
|
||
|
||
// 스트리밍 상태를 미리 설정 — PrepareExecution/CondenseAsync 비동기 대기 중
|
||
// EmptyState가 다시 Visible로 되돌려지는 것을 방지합니다.
|
||
// catch 블록에서 ResetStreamingUiState가 호출되므로 예외 시에도 정리됩니다.
|
||
_streamingTabs.Add(runTab);
|
||
ViewModel.IsStreaming = true;
|
||
Services.LogService.Info($"[SendMsg] 스트리밍 준비 시작: tab={runTab}");
|
||
|
||
// 스트리밍 중 불필요한 타이머 일시 정지 — UI 스레드 부하 대폭 감소
|
||
_gitRefreshTimer.Stop();
|
||
_conversationSearchTimer.Stop();
|
||
_responsiveLayoutTimer.Stop();
|
||
_tokenUsagePopupCloseTimer.Stop();
|
||
SetStatus("응답 준비 중...", spinning: true);
|
||
|
||
// 준비 단계(Prepare/Condense)에서도 취소할 수 있도록 CTS를 미리 생성
|
||
// ExecutePreparedTurnAsync 진입 시 새 CTS로 교체됩니다.
|
||
var prepareCts = new CancellationTokenSource();
|
||
_tabStreamCts[runTab] = prepareCts;
|
||
|
||
var assistantContent = string.Empty;
|
||
|
||
// ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ──
|
||
ModelRouteResult? routeResult = null;
|
||
|
||
try
|
||
{
|
||
List<ChatMessage> sendMessages;
|
||
|
||
// 첨부 파일 컨텍스트 삽입
|
||
string? fileContext = null;
|
||
if (_attachedFiles.Count > 0)
|
||
{
|
||
fileContext = BuildFileContextPrompt();
|
||
if (!string.IsNullOrEmpty(fileContext))
|
||
{
|
||
// 전송용 메시지에는 엔진이 적용하고, 원본 대화에는 첨부 기록만 남김
|
||
}
|
||
userMsg.AttachedFiles = _attachedFiles.ToList();
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
}
|
||
|
||
// ── 이미지 첨부 ──
|
||
List<ImageAttachment>? outboundImages = null;
|
||
if (_pendingImages.Count > 0)
|
||
{
|
||
userMsg.Images = _pendingImages.ToList();
|
||
outboundImages = _pendingImages.ToList();
|
||
_pendingImages.Clear();
|
||
AttachedFilesPanel.Items.Clear();
|
||
if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
// 시스템 프롬프트 빌드는 디스크 I/O(프로젝트 규칙, 메모리, 피드백)를 포함하므로
|
||
// UI 스레드 블로킹 방지를 위해 백그라운드에서 실행
|
||
var capturedConv = conv;
|
||
var capturedSlashSystem = slashSystem;
|
||
var capturedFileContext = fileContext;
|
||
var capturedImages = outboundImages;
|
||
var capturedRunTab = runTab;
|
||
var preparedExecution = await Task.Run(() =>
|
||
PrepareExecutionForConversation(
|
||
capturedConv,
|
||
capturedRunTab,
|
||
capturedSlashSystem,
|
||
capturedFileContext,
|
||
capturedImages));
|
||
var executionMode = preparedExecution.Mode;
|
||
sendMessages = preparedExecution.Messages;
|
||
|
||
// ── 전송 전 컨텍스트 사전 압축 ──
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
|
||
sendMessages,
|
||
_llm,
|
||
llm.MaxContextTokens,
|
||
llm.EnableProactiveContextCompact,
|
||
llm.ContextCompactTriggerPercent,
|
||
false,
|
||
_tabStreamCts.TryGetValue(runTab, out var compactCts) ? compactCts.Token : CancellationToken.None);
|
||
if (compactResult.Changed)
|
||
{
|
||
RecordCompactionStats(compactResult, wasAutomatic: true);
|
||
SetStatus($"컨텍스트를 사전 정리했습니다 · {compactResult.BeforeTokens:N0} → {compactResult.AfterTokens:N0} tokens", spinning: true);
|
||
RefreshContextUsageVisual();
|
||
}
|
||
}
|
||
|
||
// ── 자동 모델 라우팅 ──
|
||
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);
|
||
}
|
||
}
|
||
|
||
var executionOutcome = await ExecutePreparedTurnAsync(
|
||
conv,
|
||
runTab,
|
||
originTab,
|
||
preparedExecution,
|
||
"응답 생성 중...");
|
||
conv = executionOutcome.Conversation;
|
||
assistantContent = executionOutcome.AssistantContent;
|
||
draftSucceeded = executionOutcome.DraftSucceeded;
|
||
draftCancelled = executionOutcome.DraftCancelled;
|
||
draftFailure = executionOutcome.DraftFailure;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// 사용자가 준비 단계에서 취소 → 조용히 정리
|
||
ResetStreamingUiState(runTab);
|
||
draftCancelled = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// PrepareExecution / CondenseAsync 단계에서 예외 발생 시
|
||
// ExecutePreparedTurnAsync에 진입하지 못해 ResetStreamingUiState가 호출되지 않음
|
||
// → 스트리밍 상태 정리 + 에러 표시
|
||
Services.LogService.Error($"[SendMsg] 메시지 전송 중 오류: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
|
||
ResetStreamingUiState(runTab);
|
||
ShowToast($"오류: {ex.Message}", "\uE783", 3500);
|
||
draftFailure = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
// 자동 라우팅 오버라이드 해제
|
||
if (routeResult != null)
|
||
{
|
||
_llm.ClearRouteOverride();
|
||
UpdateModelLabel();
|
||
}
|
||
|
||
// 준비 단계 CTS 정리 (ExecutePreparedTurnAsync가 새 CTS로 교체했거나, 예외로 미진입)
|
||
prepareCts.Dispose();
|
||
|
||
// 중단된 타이머 복원 (스트리밍 상태와 무관하게 항상)
|
||
if (!_isStreaming)
|
||
{
|
||
_gitRefreshTimer.Start();
|
||
_conversationSearchTimer.Start();
|
||
_responsiveLayoutTimer.Start();
|
||
}
|
||
}
|
||
FinalizeQueuedDraft(originTab, queuedDraftId, draftSucceeded, draftCancelled, draftFailure);
|
||
}
|
||
|
||
private AgentLoopService CreateAgentLoopForTab(string tab, SettingsService settings)
|
||
{
|
||
return new AgentLoopService(_llm, _toolRegistry, settings)
|
||
{
|
||
Dispatcher = action =>
|
||
{
|
||
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
|
||
// Background 우선순위로 UI 입력/렌더를 먼저 흘려보내고 에이전트 이벤트를 비동기 전달.
|
||
appDispatcher.BeginInvoke(action, System.Windows.Threading.DispatcherPriority.Background);
|
||
},
|
||
AskPermissionCallback = async (toolName, filePath) =>
|
||
{
|
||
_agentLoops.TryGetValue(tab, out var tabLoop);
|
||
var wsFolder = tabLoop?.RuntimeWorkFolderOverride;
|
||
if (string.IsNullOrWhiteSpace(wsFolder))
|
||
wsFolder = _currentConversation?.WorkFolder ?? "";
|
||
|
||
var resolvedTarget = NormalizePermissionTarget(toolName, filePath, wsFolder);
|
||
|
||
if (IsPermissionAutoApprovedForSession(tab, toolName, resolvedTarget))
|
||
return true;
|
||
|
||
PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject;
|
||
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
|
||
await appDispatcher.InvokeAsync(() =>
|
||
{
|
||
AgentLoopService.PermissionPromptPreview? preview = null;
|
||
if (tabLoop != null &&
|
||
tabLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview))
|
||
preview = pendingPreview;
|
||
|
||
// 사내모드 + 워크스페이스 외부 경로 접근 시 안내 문구 표시
|
||
string? notice = null;
|
||
if (Services.OperationModePolicy.IsInternal(_settings.Settings)
|
||
&& !string.IsNullOrWhiteSpace(filePath))
|
||
{
|
||
if (!string.IsNullOrEmpty(wsFolder) && IsPathOutsideFolder(filePath, wsFolder))
|
||
notice = "사내 모드에서는 지정된 경로 외의 접근 시 무조건 승인이 필요합니다.";
|
||
}
|
||
|
||
decision = PermissionRequestWindow.Show(this, toolName, resolvedTarget, preview, notice);
|
||
});
|
||
|
||
if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession)
|
||
RememberPermissionRuleForSession(tab, toolName, resolvedTarget);
|
||
|
||
return decision != PermissionRequestWindow.PermissionPromptResult.Reject;
|
||
},
|
||
UserAskCallback = ShowInlineUserAskAsync,
|
||
};
|
||
}
|
||
|
||
private AgentLoopService GetAgentLoop(string tab) =>
|
||
_agentLoops.TryGetValue(tab, out var loop) ? loop : _agentLoops.Values.FirstOrDefault()
|
||
?? throw new InvalidOperationException($"No agent loop registered for tab '{tab}'");
|
||
|
||
private async Task<string> RunAgentLoopAsync(
|
||
string runTab,
|
||
string originTab,
|
||
ChatConversation conversation,
|
||
IReadOnlyList<ChatMessage> sendMessages,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
OpenWorkflowAnalyzerIfEnabled();
|
||
|
||
_tabCumulativeInputTokens[runTab] = 0;
|
||
_tabCumulativeOutputTokens[runTab] = 0;
|
||
ResetPermissionRulesForRun(runTab);
|
||
|
||
var loop = GetAgentLoop(runTab);
|
||
// 클로저로 runTab 캡처 — 동시에 여러 탭이 실행될 때도 이벤트가 올바른 탭에 귀속됨
|
||
void agentEventHandler(AgentEvent evt) => OnAgentEvent(evt, runTab);
|
||
loop.EventOccurred += agentEventHandler;
|
||
loop.UserDecisionCallback = CreatePlanDecisionCallback();
|
||
// 채팅창 내 라이브 진행 카드 표시
|
||
ShowAgentLiveCard(runTab);
|
||
try
|
||
{
|
||
loop.ActiveTab = runTab;
|
||
loop.RuntimeWorkFolderOverride = conversation.WorkFolder;
|
||
// 에이전트 루프를 백그라운드 스레드에서 실행 — UI 스레드 블록 방지
|
||
// RunAsync 내부의 _llm.SendAsync가 SynchronizationContext로 UI 스레드에
|
||
// 복귀할 때 WPF 메시지 펌프를 독점하여 창 드래그/최소화가 안 되는 문제 해결
|
||
var msgList = sendMessages.ToList();
|
||
var response = await Task.Run(() => loop.RunAsync(msgList, cancellationToken));
|
||
if (_settings.Settings.Llm.NotifyOnComplete)
|
||
{
|
||
var title = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
|
||
? "AX Code Agent"
|
||
: "AX Cowork Agent";
|
||
var message = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
|
||
? "코드 작업이 완료되었습니다."
|
||
: "코워크 작업이 완료되었습니다.";
|
||
Services.NotificationService.Notify(title, message);
|
||
}
|
||
|
||
// 코워크 완료 후 문서 자동 처리 (설정에 따라)
|
||
if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var onComplete = (_settings.Settings.Llm.CoworkOnComplete ?? "none").Trim().ToLowerInvariant();
|
||
if (onComplete != "none")
|
||
{
|
||
var lastFilePath = loop.Events
|
||
.Where(ev => ev.Success && !string.IsNullOrWhiteSpace(ev.FilePath) && System.IO.File.Exists(ev.FilePath))
|
||
.Select(ev => ev.FilePath!)
|
||
.LastOrDefault();
|
||
if (!string.IsNullOrEmpty(lastFilePath))
|
||
{
|
||
await Dispatcher.InvokeAsync(() =>
|
||
{
|
||
if (onComplete == "open")
|
||
{
|
||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(lastFilePath) { UseShellExecute = true }); } catch { }
|
||
}
|
||
else if (onComplete == "preview")
|
||
{
|
||
ShowPreviewPanel(lastFilePath);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return response;
|
||
}
|
||
finally
|
||
{
|
||
ResetPermissionRulesForRun(runTab);
|
||
loop.RuntimeWorkFolderOverride = null;
|
||
loop.EventOccurred -= agentEventHandler;
|
||
loop.UserDecisionCallback = null;
|
||
}
|
||
}
|
||
|
||
private AxAgentExecutionEngine.PreparedExecution PrepareExecutionForConversation(
|
||
ChatConversation conversation,
|
||
string runTab,
|
||
string? slashSystem,
|
||
string? fileContext = null,
|
||
IReadOnlyList<ImageAttachment>? images = null)
|
||
{
|
||
var coworkSystem = string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
? BuildCoworkSystemPrompt()
|
||
: null;
|
||
var codeSystem = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
|
||
? BuildCodeSystemPrompt()
|
||
: null;
|
||
var (resolvedService, _) = _llm.GetCurrentModelInfo();
|
||
|
||
return _chatEngine.PrepareExecution(
|
||
conversation,
|
||
runTab,
|
||
_settings.Settings.Llm.Streaming,
|
||
resolvedService,
|
||
conversation.SystemCommand,
|
||
slashSystem,
|
||
coworkSystem,
|
||
codeSystem,
|
||
fileContext,
|
||
images);
|
||
}
|
||
|
||
private void ResetStreamingUiState(string? finishedTab = null)
|
||
{
|
||
var tab = finishedTab ?? _streamRunTab ?? "";
|
||
_streamingTabs.Remove(tab);
|
||
if (_tabStreamCts.Remove(tab, out var doneCts))
|
||
doneCts.Dispose();
|
||
_streamRunTab = _streamingTabs.Count > 0 ? _streamingTabs.First() : null;
|
||
|
||
// Cowork/Code 탭 작업 완료 시, 앱이 포커스 없으면 Windows 알림
|
||
NotifyTaskCompletionIfBackground(tab);
|
||
|
||
// 탭별로 타이머·글로우 등 공유 상태는 마지막 탭 완료 시에만 정리
|
||
if (_streamingTabs.Count == 0)
|
||
{
|
||
ViewModel.IsStreaming = false; // ViewModel 상태 동기화 (CanSend 등 파생 프로퍼티 갱신)
|
||
FlushPendingConversationPersists();
|
||
FlushPendingAgentUiEvent();
|
||
StopLiveAgentProgressHints();
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
_executionHistoryRenderTimer.Stop();
|
||
_pendingExecutionHistoryAutoScroll = false;
|
||
_taskSummaryRefreshTimer.Stop();
|
||
_conversationPersistTimer.Stop();
|
||
_agentUiEventTimer.Stop();
|
||
HideStickyProgress();
|
||
StopRainbowGlow();
|
||
ResetLiveProgressCardCache(); // 라이브 진행 카드 캐시 초기화
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_cachedStreamContent = "";
|
||
_streamStartTime = default;
|
||
SetStatusIdle();
|
||
UpdateStreamMetricsLabel(); // 스트리밍 종료 시 메트릭 레이블 숨김
|
||
|
||
// 스트리밍 종료 — 캐시 무효화만 수행
|
||
// FinalizeConversationTurn()이 직후 RenderMessages()를 호출하므로 여기서는 렌더하지 않음
|
||
InvalidateTimelineCache();
|
||
|
||
// 지연된 레이아웃 갱신 수행
|
||
if (_pendingResponsiveLayoutRefresh)
|
||
{
|
||
_pendingResponsiveLayoutRefresh = false;
|
||
UpdateTopicPresetScrollMode();
|
||
UpdateResponsiveChatLayout();
|
||
}
|
||
}
|
||
|
||
RefreshStreamingControlsForActiveTab();
|
||
|
||
// 스트리밍 종료 후 EmptyState 일관성 강제 검사 — 메시지가 있으면 절대 보이지 않도록
|
||
EnsureEmptyStateConsistency();
|
||
}
|
||
|
||
/// <summary>앱이 포커스를 갖고 있지 않을 때 Cowork/Code 탭 작업 완료를 Windows 알림으로 표시합니다.</summary>
|
||
private void NotifyTaskCompletionIfBackground(string finishedTab)
|
||
{
|
||
// Chat 탭은 단순 대화이므로 알림 불필요
|
||
if (!string.Equals(finishedTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(finishedTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||
return;
|
||
|
||
// 앱이 포커스 중이면 알림 불필요 (사용자가 이미 결과를 보고 있음)
|
||
if (IsActive) return;
|
||
|
||
// 대화 제목/미리보기를 알림 본문으로 사용
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
var title = conv?.Title?.Trim();
|
||
if (string.IsNullOrWhiteSpace(title) || string.Equals(title, "새 대화", StringComparison.OrdinalIgnoreCase))
|
||
title = finishedTab;
|
||
|
||
var preview = "";
|
||
if (conv?.Messages?.Count > 0)
|
||
{
|
||
var lastAssistant = conv.Messages.LastOrDefault(m =>
|
||
string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase));
|
||
if (lastAssistant != null)
|
||
{
|
||
preview = lastAssistant.Content?.Trim() ?? "";
|
||
if (preview.Length > 120) preview = preview[..120] + "…";
|
||
}
|
||
}
|
||
|
||
var body = string.IsNullOrWhiteSpace(preview)
|
||
? $"{finishedTab} 작업이 완료되었습니다."
|
||
: preview;
|
||
|
||
Services.NotificationService.Notify($"✅ {title}", body);
|
||
}
|
||
|
||
/// <summary>EmptyState 표시 상태가 대화 내용과 일치하는지 확인하고, 불일치 시 보정합니다.</summary>
|
||
private void EnsureEmptyStateConsistency()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
var hasMessages = (conv?.Messages?.Count ?? 0) > 0;
|
||
if (hasMessages && EmptyState.Visibility == Visibility.Visible)
|
||
{
|
||
HideEmptyState(animate: false);
|
||
InvalidateTimelineCache();
|
||
RenderMessages(preserveViewport: false);
|
||
}
|
||
else if (!hasMessages && !_isStreaming && EmptyState.Visibility != Visibility.Visible)
|
||
{
|
||
// 빈 대화인데 EmptyState가 숨겨져 있으면 표시 (탭 전환 레이스 보정)
|
||
ShowEmptyState();
|
||
}
|
||
}
|
||
|
||
private void FinalizeConversationTurn(string rememberTab, ChatConversation conversation)
|
||
{
|
||
bool isCurrentConversation;
|
||
lock (_convLock)
|
||
{
|
||
// 새 대화 시작 등으로 _currentConversation이 다른 대화로 변경된 경우,
|
||
// 이전 스트리밍 결과로 현재 대화를 덮어쓰지 않음.
|
||
isCurrentConversation = _currentConversation == null
|
||
|| string.Equals(_currentConversation.Id, conversation.Id, StringComparison.Ordinal);
|
||
if (isCurrentConversation)
|
||
_currentConversation = conversation;
|
||
}
|
||
|
||
if (isCurrentConversation)
|
||
{
|
||
RenderMessages(preserveViewport: true);
|
||
AutoScrollIfNeeded();
|
||
PersistConversationSnapshot(rememberTab, conversation, "대화 저장 실패");
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
else
|
||
{
|
||
// 현재 대화가 변경된 경우에도 이전 대화 결과는 저장함 (UI 업데이트는 하지 않음)
|
||
try { _storage.Save(conversation); } catch { }
|
||
}
|
||
RefreshConversationList();
|
||
}
|
||
|
||
private void FinalizeQueuedDraft(string originTab, string? queuedDraftId, bool draftSucceeded, bool draftCancelled, string? draftFailure)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(queuedDraftId))
|
||
return;
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
if (draftSucceeded)
|
||
{
|
||
_draftQueueProcessor.Complete(session, originTab, queuedDraftId, _storage, _appState.TaskRuns);
|
||
_draftQueueProcessor.ClearCompleted(session, originTab, _storage); // 완료 항목 즉시 자동 제거
|
||
}
|
||
else
|
||
_draftQueueProcessor.HandleFailure(session, originTab, queuedDraftId, draftFailure, draftCancelled, 3, _storage, _appState.TaskRuns);
|
||
|
||
_currentConversation = session.CurrentConversation ?? _currentConversation;
|
||
}
|
||
|
||
if (string.Equals(_runningDraftId, queuedDraftId, StringComparison.OrdinalIgnoreCase))
|
||
_runningDraftId = null;
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
if (!draftCancelled && string.Equals(originTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background);
|
||
}
|
||
|
||
private sealed record PreparedTurnOutcome(
|
||
ChatConversation Conversation,
|
||
string AssistantContent,
|
||
bool DraftSucceeded,
|
||
bool DraftCancelled,
|
||
string? DraftFailure);
|
||
|
||
private async Task<PreparedTurnOutcome> ExecutePreparedTurnAsync(
|
||
ChatConversation conversation,
|
||
string runTab,
|
||
string rememberTab,
|
||
AxAgentExecutionEngine.PreparedExecution preparedExecution,
|
||
string busyStatus)
|
||
{
|
||
_streamingTabs.Add(runTab);
|
||
ViewModel.IsStreaming = true;
|
||
_streamRunTab = runTab;
|
||
var streamCts = new CancellationTokenSource();
|
||
_tabStreamCts[runTab] = streamCts;
|
||
var streamToken = streamCts.Token;
|
||
Services.LogService.Info($"[Exec] ExecutePreparedTurnAsync 진입: tab={runTab}, mode={preparedExecution.Mode}");
|
||
RefreshStreamingControlsForActiveTab();
|
||
StartLiveAgentProgressHints();
|
||
UpdateStreamMetricsLabel();
|
||
ForceScrollToEnd();
|
||
|
||
var assistantContent = string.Empty;
|
||
var draftSucceeded = false;
|
||
var draftCancelled = false;
|
||
string? draftFailure = null;
|
||
var responseElapsedMs = 0L;
|
||
var promptTokens = 0;
|
||
var completionTokens = 0;
|
||
string? assistantMetaRunId = null;
|
||
|
||
_activeStreamText = null;
|
||
_cachedStreamContent = "";
|
||
_displayedLength = 0;
|
||
_cursorVisible = true;
|
||
_aiIconPulseStopped = false;
|
||
_streamStartTime = DateTime.UtcNow;
|
||
_elapsedTimer.Start();
|
||
SetStatus(busyStatus, spinning: true);
|
||
|
||
StackPanel? streamingContainer = null;
|
||
TextBlock? streamingText = null;
|
||
|
||
try
|
||
{
|
||
if (!preparedExecution.Mode.UseAgentLoop && preparedExecution.Mode.UseStreamingTransport)
|
||
{
|
||
streamingContainer = CreateStreamingContainer(out var createdStreamText);
|
||
streamingText = createdStreamText;
|
||
_activeStreamText = streamingText;
|
||
_cachedStreamContent = "";
|
||
_displayedLength = 0;
|
||
_cursorVisible = true;
|
||
AddTranscriptElement(streamingContainer);
|
||
ForceScrollToEnd();
|
||
_cursorTimer.Start();
|
||
_typingTimer.Start();
|
||
ShowStreamingStatusBar("생각하는 중...");
|
||
|
||
// Bug fix: 스트리밍 루프를 백그라운드 스레드에서 실행하여 UI 프리징 방지
|
||
// (Cowork/Code는 이미 Task.Run으로 실행 중이나, Chat은 UI 스레드에서 실행되고 있었음)
|
||
var streamSb = new System.Text.StringBuilder(4096);
|
||
var messagesList = preparedExecution.Messages.ToList();
|
||
var dispatcher = Dispatcher;
|
||
|
||
assistantContent = await Task.Run(async () =>
|
||
{
|
||
var lastSyncTick = Environment.TickCount64;
|
||
await foreach (var chunk in _llm.StreamAsync(messagesList, streamToken))
|
||
{
|
||
if (string.IsNullOrEmpty(chunk))
|
||
continue;
|
||
|
||
streamSb.Append(chunk);
|
||
var now = Environment.TickCount64;
|
||
if (now - lastSyncTick >= 30)
|
||
{
|
||
var snapshot = streamSb.ToString();
|
||
lastSyncTick = now;
|
||
_ = dispatcher.InvokeAsync(() =>
|
||
{
|
||
_cachedStreamContent = snapshot;
|
||
if (_activeStreamText != null && _displayedLength == 0)
|
||
_activeStreamText.Text = _cursorVisible ? "\u258c" : " ";
|
||
}, DispatcherPriority.Normal);
|
||
}
|
||
}
|
||
return streamSb.ToString();
|
||
}, streamToken).ConfigureAwait(true);
|
||
|
||
_cachedStreamContent = assistantContent;
|
||
}
|
||
else
|
||
{
|
||
var response = await _chatEngine.ExecutePreparedAsync(
|
||
preparedExecution,
|
||
(messages, token) => RunAgentLoopAsync(runTab, rememberTab, conversation, messages, token),
|
||
(messages, token) => _llm.SendAsync(messages.ToList(), token),
|
||
streamToken);
|
||
assistantContent = response ?? string.Empty;
|
||
}
|
||
|
||
responseElapsedMs = GetStreamingElapsedMsOrZero();
|
||
assistantMetaRunId = _appState.AgentRun.RunId;
|
||
var usage = _llm.LastTokenUsage;
|
||
if (usage != null)
|
||
{
|
||
var cumIn = _tabCumulativeInputTokens.GetValueOrDefault(runTab);
|
||
var cumOut = _tabCumulativeOutputTokens.GetValueOrDefault(runTab);
|
||
if (runTab is "Cowork" or "Code" && (cumIn > 0 || cumOut > 0))
|
||
{
|
||
promptTokens = (int)Math.Max(0, cumIn);
|
||
completionTokens = (int)Math.Max(0, cumOut);
|
||
}
|
||
else
|
||
{
|
||
promptTokens = Math.Max(0, usage.PromptTokens);
|
||
completionTokens = Math.Max(0, usage.CompletionTokens);
|
||
}
|
||
}
|
||
StopAiIconPulse();
|
||
_cachedStreamContent = assistantContent;
|
||
draftSucceeded = true;
|
||
|
||
if (streamingContainer != null && streamingText != null)
|
||
{
|
||
FinalizeStreamingContainer(streamingContainer, streamingText, assistantContent);
|
||
}
|
||
else if (preparedExecution.Mode.UseAgentLoop)
|
||
{
|
||
// Claude Code 스타일: 에이전트 루프 완료 시 assistant 말풍선 표시하지 않음
|
||
// 실행이력 + 완료 요약 카드만 표시 (사용자 입력만 말풍선으로)
|
||
RemoveAgentLiveCard();
|
||
}
|
||
else
|
||
{
|
||
RemoveAgentLiveCard();
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
RemoveAgentLiveCard();
|
||
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, cancelled: true);
|
||
assistantContent = finalized.Content;
|
||
draftCancelled = finalized.Cancelled;
|
||
draftFailure = finalized.FailureReason;
|
||
if (streamingContainer != null && streamingText != null)
|
||
FinalizeStreamingContainer(streamingContainer, streamingText, assistantContent);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
RemoveAgentLiveCard();
|
||
Services.LogService.Debug($"에이전트 실행 실패: {ex}");
|
||
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, ex);
|
||
assistantContent = finalized.Content;
|
||
draftFailure = finalized.FailureReason;
|
||
ShowToast("실패한 요청은 작업 요약에서 다시 시도할 수 있습니다.", "\uE783", 2600);
|
||
if (streamingContainer != null && streamingText != null)
|
||
FinalizeStreamingContainer(streamingContainer, streamingText, assistantContent);
|
||
}
|
||
finally
|
||
{
|
||
ResetStreamingUiState(runTab);
|
||
}
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
assistantContent = _chatEngine.FinalizeAssistantTurn(
|
||
session,
|
||
conversation,
|
||
runTab,
|
||
assistantContent,
|
||
promptTokens,
|
||
completionTokens,
|
||
responseElapsedMs,
|
||
assistantMetaRunId,
|
||
_storage);
|
||
_currentConversation = session?.CurrentConversation ?? conversation;
|
||
conversation = _currentConversation!;
|
||
}
|
||
|
||
// Bug fix: 스트리밍 컨테이너가 남아있으면 RenderMessages가 동일 메시지를 다시 렌더링하여
|
||
// 중복 버블이 발생함 → FinalizeConversationTurn 전에 스트리밍 컨테이너 제거
|
||
if (streamingContainer != null)
|
||
RemoveTranscriptElement(streamingContainer);
|
||
|
||
FinalizeConversationTurn(rememberTab, conversation);
|
||
return new PreparedTurnOutcome(conversation, assistantContent, draftSucceeded, draftCancelled, draftFailure);
|
||
}
|
||
|
||
private async Task ShowTypedAssistantPreviewAsync(string finalContent, CancellationToken ct)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(finalContent))
|
||
return;
|
||
|
||
// 라이브 카드 제거 (최종 메시지 버블 표시 전)
|
||
RemoveAgentLiveCard();
|
||
|
||
var container = CreateStreamingContainer(out var streamText);
|
||
if (container == null || streamText == null)
|
||
return;
|
||
|
||
// 공유 필드 사용 금지 — 탭 동시 완료 시 서로 덮어쓰는 것을 막기 위해
|
||
// _activeStreamText / _cachedStreamContent / _displayedLength / 공유 타이머 미사용
|
||
// 모든 상태를 로컬 변수로 관리하고 await Task.Delay로 UI 스레드 양보
|
||
var displayedLength = 0;
|
||
var cursorVisible = true;
|
||
AddTranscriptElement(container);
|
||
ForceScrollToEnd();
|
||
streamText.Text = "\u258c";
|
||
|
||
var deadline = DateTime.UtcNow.AddMilliseconds(Math.Clamp(finalContent.Length * 18, 1400, 6500));
|
||
try
|
||
{
|
||
while (displayedLength < finalContent.Length && DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
|
||
{
|
||
await Task.Delay(20, ct);
|
||
|
||
var pending = finalContent.Length - displayedLength;
|
||
int step;
|
||
if (pending > 300) step = Math.Min(Math.Max(8, pending / 12), 18);
|
||
else if (pending > 120) step = Math.Min(Math.Max(5, pending / 14), 10);
|
||
else if (pending > 24) step = Math.Min(4, pending);
|
||
else step = Math.Min(2, pending);
|
||
|
||
displayedLength = Math.Min(displayedLength + step, finalContent.Length);
|
||
cursorVisible = !cursorVisible;
|
||
streamText.Text = finalContent[..displayedLength] + (cursorVisible ? "\u258c" : " ");
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
return;
|
||
}
|
||
|
||
streamText.Text = finalContent;
|
||
FinalizeStreamingContainer(container, streamText, finalContent);
|
||
}
|
||
|
||
private void ScheduleExecutionHistoryRender(bool autoScroll = true)
|
||
{
|
||
_pendingExecutionHistoryAutoScroll |= autoScroll;
|
||
if (!IsLoaded || !IsVisible || WindowState == WindowState.Minimized)
|
||
{
|
||
_pendingHiddenExecutionHistoryRender = true;
|
||
return;
|
||
}
|
||
// 스트리밍 중 타이머가 이미 실행 중이면 재시작하지 않음 (Stop+Start 루프 방지)
|
||
// 이전에는 매 이벤트마다 Stop→Start로 타이머가 영원히 지연되는 문제가 있었음
|
||
if (_isStreaming && _executionHistoryRenderTimer.IsEnabled)
|
||
return;
|
||
|
||
_executionHistoryRenderTimer.Stop();
|
||
_executionHistoryRenderTimer.Start();
|
||
}
|
||
|
||
private void FlushDeferredUiRefreshIfNeeded()
|
||
{
|
||
if (!IsLoaded || !IsVisible || WindowState == WindowState.Minimized)
|
||
return;
|
||
|
||
if (_pendingHiddenExecutionHistoryRender)
|
||
{
|
||
_pendingHiddenExecutionHistoryRender = false;
|
||
_executionHistoryRenderTimer.Stop();
|
||
_executionHistoryRenderTimer.Start();
|
||
}
|
||
|
||
if (_pendingBackgroundTaskSummaryRefresh)
|
||
{
|
||
_pendingBackgroundTaskSummaryRefresh = false;
|
||
_taskSummaryRefreshTimer.Stop();
|
||
_taskSummaryRefreshTimer.Start();
|
||
}
|
||
|
||
if (_pendingBackgroundInputUiRefresh)
|
||
{
|
||
_pendingBackgroundInputUiRefresh = false;
|
||
_inputUiRefreshTimer.Stop();
|
||
_inputUiRefreshTimer.Start();
|
||
}
|
||
|
||
if (_pendingBackgroundAgentUiEventFlush)
|
||
{
|
||
_pendingBackgroundAgentUiEventFlush = false;
|
||
_agentUiEventTimer.Stop();
|
||
_agentUiEventTimer.Start();
|
||
}
|
||
|
||
}
|
||
|
||
private void ScheduleTaskSummaryRefresh()
|
||
{
|
||
if (IsBackgroundUiThrottleActive())
|
||
{
|
||
_pendingBackgroundTaskSummaryRefresh = true;
|
||
return;
|
||
}
|
||
|
||
if (IsLightweightLiveProgressMode() && _taskSummaryRefreshTimer.IsEnabled)
|
||
return;
|
||
|
||
_taskSummaryRefreshTimer.Stop();
|
||
_taskSummaryRefreshTimer.Start();
|
||
}
|
||
|
||
private void ScheduleConversationPersist(ChatConversation? conversation)
|
||
{
|
||
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
|
||
return;
|
||
|
||
_pendingConversationPersists[conversation.Id] = conversation;
|
||
_conversationPersistTimer.Stop();
|
||
_conversationPersistTimer.Start();
|
||
}
|
||
|
||
private void ScheduleAgentUiEvent(AgentEvent evt)
|
||
{
|
||
_pendingAgentUiEvent = evt;
|
||
if (IsBackgroundUiThrottleActive())
|
||
{
|
||
_pendingBackgroundAgentUiEventFlush = true;
|
||
return;
|
||
}
|
||
|
||
if (IsLightweightLiveProgressMode() && _agentUiEventTimer.IsEnabled)
|
||
return;
|
||
|
||
_agentUiEventTimer.Stop();
|
||
_agentUiEventTimer.Start();
|
||
}
|
||
|
||
private bool IsBackgroundUiThrottleActive()
|
||
{
|
||
if (!IsLoaded)
|
||
return true;
|
||
|
||
if (!IsVisible || WindowState == WindowState.Minimized)
|
||
return true;
|
||
|
||
return !IsActive;
|
||
}
|
||
|
||
private void FlushPendingAgentUiEvent()
|
||
{
|
||
var evt = _pendingAgentUiEvent;
|
||
_pendingAgentUiEvent = null;
|
||
if (evt == null)
|
||
return;
|
||
|
||
UpdateStatusBar(evt);
|
||
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();
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void PersistConversationSnapshot(string rememberTab, ChatConversation conversation, string failureLabel)
|
||
{
|
||
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
|
||
return;
|
||
|
||
_pendingConversationPersists.Remove(conversation.Id);
|
||
try
|
||
{
|
||
_storage.Save(conversation);
|
||
ChatSession?.RememberConversation(rememberTab, conversation.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Debug($"{failureLabel}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void FlushPendingConversationPersists()
|
||
{
|
||
if (_pendingConversationPersists.Count == 0)
|
||
return;
|
||
|
||
// 대화 저장(디스크 I/O)을 백그라운드로 이동하여 UI 스레드 블로킹 방지
|
||
var snapshot = _pendingConversationPersists.Values.ToList();
|
||
_pendingConversationPersists.Clear();
|
||
|
||
Task.Run(() =>
|
||
{
|
||
foreach (var conversation in snapshot)
|
||
{
|
||
try
|
||
{
|
||
_storage.Save(conversation);
|
||
_appState.ChatSession?.RememberConversation(conversation.Tab ?? "Chat", conversation.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Debug($"대화 지연 저장 실패: {ex.Message}");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다.</summary>
|
||
private void OpenWorkflowAnalyzerIfEnabled()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
if (!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.Activate();
|
||
}
|
||
|
||
// 타임라인 탭으로 전환 (새 실행 시작)
|
||
_analyzerWindow.SwitchToTimelineTab();
|
||
|
||
// 모든 탭 루프에 구독 (중복 방지) — 워크플로우 분석기는 전 탭 이벤트를 표시
|
||
foreach (var loop in _agentLoops.Values)
|
||
{
|
||
loop.EventOccurred -= _analyzerWindow.OnAgentEvent;
|
||
loop.EventOccurred += _analyzerWindow.OnAgentEvent;
|
||
}
|
||
}
|
||
|
||
/// <summary>워크플로우 분석기 버튼의 표시 상태를 갱신합니다.</summary>
|
||
private void UpdateAnalyzerButtonVisibility()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
BtnShowAnalyzer.Visibility = llm.WorkflowVisualizer
|
||
? Visibility.Visible : Visibility.Collapsed;
|
||
}
|
||
|
||
private void SyncWorkflowVisualizerWindow()
|
||
{
|
||
UpdateAnalyzerButtonVisibility();
|
||
if (_settings.Settings.Llm.WorkflowVisualizer)
|
||
{
|
||
OpenWorkflowAnalyzerIfEnabled();
|
||
}
|
||
else if (_analyzerWindow != null && _analyzerWindow.IsVisible)
|
||
{
|
||
_analyzerWindow.Hide();
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
// 에이전트 이벤트 구독 (모든 탭 루프)
|
||
foreach (var loop in _agentLoops.Values)
|
||
{
|
||
loop.EventOccurred -= _analyzerWindow.OnAgentEvent;
|
||
loop.EventOccurred += _analyzerWindow.OnAgentEvent;
|
||
}
|
||
_analyzerWindow.Show();
|
||
}
|
||
else if (!_analyzerWindow.IsVisible)
|
||
{
|
||
_analyzerWindow.Show();
|
||
_analyzerWindow.Activate();
|
||
}
|
||
else
|
||
{
|
||
_analyzerWindow.Activate();
|
||
}
|
||
}
|
||
|
||
/// <summary>탭별 에이전트 루프 누적 토큰 (하단 바 표시용)</summary>
|
||
private readonly Dictionary<string, long> _tabCumulativeInputTokens = new();
|
||
private readonly Dictionary<string, long> _tabCumulativeOutputTokens = new();
|
||
|
||
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, string runTab)
|
||
{
|
||
TouchLiveAgentProgressHints();
|
||
var eventTab = runTab;
|
||
|
||
// V2 라이브 카드 실시간 업데이트
|
||
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)
|
||
&& IsAgentLiveCardEligibleTab(runTab))
|
||
{
|
||
EnsureAgentLiveCardVisible(runTab);
|
||
UpdateAgentLiveCardV2(evt);
|
||
}
|
||
|
||
// ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ───────────
|
||
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (evt.Type is AgentEventType.Complete or AgentEventType.Error)
|
||
{
|
||
HideStreamingStatusBar();
|
||
FlushPendingAgentUiEvent();
|
||
}
|
||
else if (PulseDotBar?.Visibility == Visibility.Visible)
|
||
{
|
||
var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab);
|
||
UpdateStreamingStatusBar(
|
||
liveStatus.Message,
|
||
detail: liveStatus.Detail,
|
||
subItemCategory: liveStatus.Category);
|
||
}
|
||
}
|
||
|
||
// 현재 실행 중인 스텝 추적 (통합 진행 카드용)
|
||
if (IsProcessFeedEvent(evt) && string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_currentRunProgressSteps.Add(evt);
|
||
if (_currentRunProgressSteps.Count > 8)
|
||
_currentRunProgressSteps.RemoveAt(0);
|
||
}
|
||
|
||
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
|
||
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
|
||
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
|
||
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
|
||
var isActiveRunTab = string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase);
|
||
var lightweightLiveMode = isActiveRunTab
|
||
&& !shouldShowExecutionHistory
|
||
&& IsLightweightLiveProgressMode(eventTab);
|
||
var shouldRender = isActiveRunTab && (
|
||
shouldShowExecutionHistory
|
||
|| (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt))
|
||
|| evt.Type == AgentEventType.Complete
|
||
|| evt.Type == AgentEventType.Error
|
||
|| (evt.Type == AgentEventType.ToolResult && evt.Success && IsDocumentCreationTool(evt.ToolName)));
|
||
EnqueueAgentEventWork(evt, eventTab, shouldRender);
|
||
|
||
// ── 3단계: 경량 상태 추적 (UI 스레드) ───────────────────────────────
|
||
_appState.ApplyAgentEvent(evt);
|
||
|
||
// 탭별 토큰 누적 — 활성 탭 것만 하단 바에 표시
|
||
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
|
||
{
|
||
_tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens;
|
||
_tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens;
|
||
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]);
|
||
}
|
||
|
||
ScheduleAgentUiEvent(evt);
|
||
if (!lightweightLiveMode
|
||
|| evt.Type is AgentEventType.Complete
|
||
or AgentEventType.Error
|
||
or AgentEventType.Planning
|
||
or AgentEventType.PermissionRequest
|
||
or AgentEventType.ToolResult)
|
||
{
|
||
ScheduleTaskSummaryRefresh();
|
||
}
|
||
}
|
||
|
||
private void StartLiveAgentProgressHints()
|
||
{
|
||
_lastAgentProgressEventAt = DateTime.UtcNow;
|
||
_currentRunProgressSteps.Clear();
|
||
var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!;
|
||
if (IsAgentLiveCardEligibleTab(runTab))
|
||
{
|
||
EnsureAgentLiveCardVisible(runTab);
|
||
var initialStatus = AgentStatusNarrativeCatalog.BuildInitial(runTab);
|
||
UpdateLiveAgentProgressHint(initialStatus.Message, "agent_wait");
|
||
ShowStreamingStatusBar(initialStatus.Message, detail: initialStatus.Detail);
|
||
}
|
||
else
|
||
{
|
||
RemoveAgentLiveCard(animated: false);
|
||
UpdateLiveAgentProgressHint(null);
|
||
ShowStreamingStatusBar("생각하는 중...");
|
||
}
|
||
_agentProgressHintTimer.Stop();
|
||
_agentProgressHintTimer.Start();
|
||
}
|
||
|
||
private void StopLiveAgentProgressHints()
|
||
{
|
||
_agentProgressHintTimer.Stop();
|
||
UpdateLiveAgentProgressHint(null);
|
||
HideStreamingStatusBar();
|
||
}
|
||
|
||
// ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────
|
||
|
||
private void BtnTemplateSelector_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var templates = _settings.Settings.Llm.PromptTemplates;
|
||
TemplateItems.Items.Clear();
|
||
var search = TemplateSearchBox?.Text?.Trim() ?? "";
|
||
|
||
if (templates == null || templates.Count == 0)
|
||
{
|
||
TemplateEmptyHint.Visibility = Visibility.Visible;
|
||
TemplatePopup.IsOpen = true;
|
||
return;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(search))
|
||
{
|
||
templates = templates
|
||
.Where(t => t.Name.Contains(search, StringComparison.OrdinalIgnoreCase)
|
||
|| t.Content.Contains(search, StringComparison.OrdinalIgnoreCase))
|
||
.ToList();
|
||
}
|
||
|
||
TemplateEmptyHint.Visibility = templates.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||
if (templates.Count == 0)
|
||
{
|
||
TemplateEmptyHint.Text = "검색 결과가 없습니다.";
|
||
TemplatePopup.IsOpen = true;
|
||
return;
|
||
}
|
||
TemplateEmptyHint.Text = "등록된 템플릿이 없습니다. 설정에서 추가하세요.";
|
||
|
||
var favoriteSet = new HashSet<string>(_settings.Settings.Llm.FavoritePromptTemplates ?? [], StringComparer.OrdinalIgnoreCase);
|
||
var recentList = _settings.Settings.Llm.RecentPromptTemplates ?? [];
|
||
var recentRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
for (var i = 0; i < recentList.Count; i++)
|
||
{
|
||
var key = recentList[i]?.Trim();
|
||
if (!string.IsNullOrWhiteSpace(key) && !recentRank.ContainsKey(key))
|
||
recentRank[key] = i;
|
||
}
|
||
|
||
void AddSectionHeader(string title)
|
||
{
|
||
TemplateItems.Items.Add(new Border
|
||
{
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||
Padding = new Thickness(10, 8, 10, 6),
|
||
Child = new TextBlock
|
||
{
|
||
Text = title,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
}
|
||
});
|
||
}
|
||
|
||
void AddTemplateRow(PromptTemplate tpl, bool isFavorite, bool isRecent)
|
||
{
|
||
var item = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||
CornerRadius = new CornerRadius(0),
|
||
Padding = new Thickness(10, 10, 10, 10),
|
||
Margin = new Thickness(0, 0, 0, 0),
|
||
Cursor = System.Windows.Input.Cursors.Hand,
|
||
Tag = tpl.Content,
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
var titleRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||
titleRow.Children.Add(new TextBlock
|
||
{
|
||
Text = tpl.Name,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = (Brush)FindResource("PrimaryText"),
|
||
});
|
||
if (isFavorite)
|
||
{
|
||
titleRow.Children.Add(new TextBlock
|
||
{
|
||
Text = " 고정",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#B45309"),
|
||
});
|
||
}
|
||
else if (isRecent)
|
||
{
|
||
titleRow.Children.Add(new TextBlock
|
||
{
|
||
Text = " 최근",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#2563EB"),
|
||
});
|
||
}
|
||
stack.Children.Add(titleRow);
|
||
var preview = tpl.Content.Length > 60 ? tpl.Content[..60] + "…" : tpl.Content;
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = preview,
|
||
FontSize = 10.5,
|
||
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("ItemHoverBackground");
|
||
};
|
||
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)
|
||
{
|
||
RegisterRecentPromptTemplate(tpl.Name);
|
||
InputBox.Text = content;
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
InputBox.Focus();
|
||
TemplatePopup.IsOpen = false;
|
||
}
|
||
};
|
||
|
||
item.MouseRightButtonUp += (_, e) =>
|
||
{
|
||
e.Handled = true;
|
||
ToggleFavoritePromptTemplate(tpl.Name);
|
||
BtnTemplateSelector_Click(this, new RoutedEventArgs());
|
||
};
|
||
|
||
TemplateItems.Items.Add(item);
|
||
}
|
||
|
||
var favorites = templates.Where(t => favoriteSet.Contains(t.Name)).ToList();
|
||
var recents = templates.Where(t => !favoriteSet.Contains(t.Name) && recentRank.ContainsKey(t.Name))
|
||
.OrderBy(t => recentRank[t.Name])
|
||
.ToList();
|
||
var remaining = templates.Where(t => !favoriteSet.Contains(t.Name) && !recentRank.ContainsKey(t.Name))
|
||
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
if (favorites.Count > 0)
|
||
{
|
||
AddSectionHeader("고정");
|
||
foreach (var tpl in favorites)
|
||
AddTemplateRow(tpl, true, false);
|
||
}
|
||
if (recents.Count > 0)
|
||
{
|
||
AddSectionHeader("최근");
|
||
foreach (var tpl in recents)
|
||
AddTemplateRow(tpl, false, true);
|
||
}
|
||
if (remaining.Count > 0)
|
||
{
|
||
AddSectionHeader("전체");
|
||
foreach (var tpl in remaining)
|
||
AddTemplateRow(tpl, false, false);
|
||
}
|
||
|
||
TemplatePopup.IsOpen = true;
|
||
}
|
||
|
||
private void TemplateSearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
if (TemplatePopup?.IsOpen == true)
|
||
BtnTemplateSelector_Click(this, new RoutedEventArgs());
|
||
}
|
||
|
||
private void RegisterRecentPromptTemplate(string name)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
return;
|
||
|
||
var recent = _settings.Settings.Llm.RecentPromptTemplates ??= new List<string>();
|
||
recent.RemoveAll(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
|
||
recent.Insert(0, name);
|
||
if (recent.Count > 10)
|
||
recent.RemoveRange(10, recent.Count - 10);
|
||
ScheduleSettingsSave();
|
||
}
|
||
|
||
private void ToggleFavoritePromptTemplate(string name)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
return;
|
||
|
||
var favorites = _settings.Settings.Llm.FavoritePromptTemplates ??= new List<string>();
|
||
var existing = favorites.FirstOrDefault(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
|
||
if (existing != null)
|
||
favorites.Remove(existing);
|
||
else
|
||
favorites.Add(name);
|
||
ScheduleSettingsSave();
|
||
}
|
||
|
||
|
||
// ─── 미리보기 패널 (탭 기반) ─────────────────────────────────────────────
|
||
|
||
|
||
// ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
|
||
|
||
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 void BtnCompactNow_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isStreaming)
|
||
{
|
||
SetStatus("응답 생성 중에는 압축을 실행할 수 없습니다", spinning: false);
|
||
return;
|
||
}
|
||
|
||
_ = ExecuteManualCompactAsync("/compact", _activeTab);
|
||
}
|
||
|
||
private async void BtnGitBranch_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(_currentGitBranchName) || GitBranchPopup == null || GitBranchItems == null)
|
||
return;
|
||
|
||
if (GitBranchPopup.IsOpen)
|
||
{
|
||
GitBranchPopup.IsOpen = false;
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
await RefreshGitBranchStatusAsync();
|
||
_gitBranchSearchText = "";
|
||
if (GitBranchSearchBox != null)
|
||
GitBranchSearchBox.Text = "";
|
||
BuildGitBranchPopup();
|
||
GitBranchPopup.IsOpen = true;
|
||
GitBranchSearchBox?.Focus();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Error($"Git branch refresh failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void GitBranchSearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
_gitBranchSearchText = GitBranchSearchBox?.Text?.Trim() ?? "";
|
||
if (GitBranchPopup?.IsOpen == true)
|
||
BuildGitBranchPopup();
|
||
}
|
||
|
||
private void TrackRecentGitBranch(string? branchName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(branchName))
|
||
return;
|
||
|
||
_recentGitBranches.RemoveAll(branch => string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase));
|
||
_recentGitBranches.Insert(0, branchName);
|
||
if (_recentGitBranches.Count > 6)
|
||
_recentGitBranches.RemoveRange(6, _recentGitBranches.Count - 6);
|
||
}
|
||
|
||
private static string BuildUsageModelKey(string? service, string? model)
|
||
{
|
||
var normalizedService = (service ?? "").Trim().ToLowerInvariant();
|
||
var normalizedModel = (model ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel))
|
||
return "";
|
||
return $"{normalizedService}:{normalizedModel}";
|
||
}
|
||
|
||
private static long GetUsageValue(Dictionary<string, long>? source, string key)
|
||
{
|
||
if (source == null || string.IsNullOrWhiteSpace(key))
|
||
return 0;
|
||
return source.TryGetValue(key, out var value) ? value : 0;
|
||
}
|
||
|
||
private string BuildTopModelUsageSummary(DailyUsageStats todayUsage)
|
||
{
|
||
if (todayUsage.ModelPromptTokens.Count == 0 && todayUsage.ModelCompletionTokens.Count == 0)
|
||
return "";
|
||
|
||
var totals = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var pair in todayUsage.ModelPromptTokens)
|
||
totals[pair.Key] = pair.Value;
|
||
foreach (var pair in todayUsage.ModelCompletionTokens)
|
||
totals[pair.Key] = totals.TryGetValue(pair.Key, out var current) ? current + pair.Value : pair.Value;
|
||
|
||
var topEntries = totals
|
||
.OrderByDescending(pair => pair.Value)
|
||
.Take(3)
|
||
.Select(pair =>
|
||
{
|
||
var label = pair.Key.Replace(":", " · ");
|
||
var postPrompt = GetUsageValue(todayUsage.PostCompactionPromptTokens, pair.Key);
|
||
var postCompletion = GetUsageValue(todayUsage.PostCompactionCompletionTokens, pair.Key);
|
||
var postTotal = postPrompt + postCompletion;
|
||
return postTotal > 0
|
||
? $"- {label}: {FormatTokenCount(pair.Value)} tokens (compact 이후 {FormatTokenCount(postTotal)})"
|
||
: $"- {label}: {FormatTokenCount(pair.Value)} tokens";
|
||
})
|
||
.ToList();
|
||
|
||
return topEntries.Count == 0
|
||
? ""
|
||
: "\n오늘 상위 모델\n" + string.Join("\n", topEntries);
|
||
}
|
||
|
||
private static string FormatTokenCount(long value)
|
||
{
|
||
if (value <= int.MaxValue)
|
||
return Services.TokenEstimator.Format((int)value);
|
||
|
||
if (value >= 1_000_000)
|
||
return $"{value / 1_000_000d:0.#}M";
|
||
if (value >= 1_000)
|
||
return $"{value / 1_000d:0.#}K";
|
||
return value.ToString("N0");
|
||
}
|
||
|
||
private double _lastArcRatio = -1;
|
||
|
||
private void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius)
|
||
{
|
||
ratio = Math.Clamp(ratio, 0, 0.9999);
|
||
|
||
// 비율 변화가 1% 미만이면 렌더링 생략 (250ms마다 호출되므로 불필요한 재생성 방지)
|
||
if (Math.Abs(ratio - _lastArcRatio) < 0.01)
|
||
return;
|
||
_lastArcRatio = ratio;
|
||
|
||
if (ratio <= 0)
|
||
{
|
||
path.Data = Geometry.Empty;
|
||
return;
|
||
}
|
||
|
||
var start = GetCirclePoint(centerX, centerY, radius, -90);
|
||
var end = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90);
|
||
var figure = new PathFigure
|
||
{
|
||
StartPoint = start,
|
||
IsClosed = false,
|
||
IsFilled = false,
|
||
};
|
||
figure.Segments.Add(new ArcSegment
|
||
{
|
||
Point = end,
|
||
Size = new Size(radius, radius),
|
||
SweepDirection = SweepDirection.Clockwise,
|
||
IsLargeArc = ratio >= 0.5,
|
||
});
|
||
path.Data = new PathGeometry(new[] { figure });
|
||
}
|
||
|
||
private static void PositionThresholdMarker(FrameworkElement marker, double ratio, double centerX, double centerY, double radius, double halfSize)
|
||
{
|
||
ratio = Math.Clamp(ratio, 0, 0.9999);
|
||
var point = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90);
|
||
Canvas.SetLeft(marker, point.X - halfSize);
|
||
Canvas.SetTop(marker, point.Y - halfSize);
|
||
}
|
||
|
||
private static Point GetCirclePoint(double centerX, double centerY, double radius, double angleDegrees)
|
||
{
|
||
var radians = angleDegrees * Math.PI / 180.0;
|
||
return new Point(
|
||
centerX + radius * Math.Cos(radians),
|
||
centerY + radius * Math.Sin(radians));
|
||
}
|
||
|
||
private void RecordCompactionStats(ContextCompactionResult result, bool wasAutomatic)
|
||
{
|
||
var beforeTokens = Math.Max(0, result.BeforeTokens);
|
||
var afterTokens = Math.Max(0, result.AfterTokens);
|
||
var savedTokens = Math.Max(0, beforeTokens - afterTokens);
|
||
|
||
_lastCompactionBeforeTokens = beforeTokens;
|
||
_lastCompactionAfterTokens = afterTokens;
|
||
_lastCompactionAt = DateTime.Now;
|
||
_lastCompactionWasAutomatic = wasAutomatic;
|
||
_lastCompactionStageSummary = result.StageSummary;
|
||
_sessionCompactionCount++;
|
||
_sessionCompactionSavedTokens += savedTokens;
|
||
_sessionMemoryCompactionCount += result.SessionMemoryApplied ? 1 : 0;
|
||
_sessionMicrocompactBoundaryCount += result.MicrocompactBoundaryCount;
|
||
_sessionSnipCompactionCount += result.SnippedMessageCount + result.CollapsedBoundaryCount;
|
||
_pendingPostCompaction = true;
|
||
if (wasAutomatic)
|
||
_sessionAutomaticCompactionCount++;
|
||
else
|
||
_sessionManualCompactionCount++;
|
||
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null)
|
||
return;
|
||
|
||
conv.CompactionCount = _sessionCompactionCount;
|
||
conv.AutomaticCompactionCount = _sessionAutomaticCompactionCount;
|
||
conv.ManualCompactionCount = _sessionManualCompactionCount;
|
||
conv.CompactionSavedTokens = _sessionCompactionSavedTokens;
|
||
conv.SessionMemoryCompactionCount = _sessionMemoryCompactionCount;
|
||
conv.MicrocompactBoundaryCount = _sessionMicrocompactBoundaryCount;
|
||
conv.SnipCompactionCount = _sessionSnipCompactionCount;
|
||
conv.LastCompactionAt = _lastCompactionAt;
|
||
conv.LastCompactionWasAutomatic = wasAutomatic;
|
||
conv.LastCompactionBeforeTokens = beforeTokens;
|
||
conv.LastCompactionAfterTokens = afterTokens;
|
||
conv.LastCompactionStageSummary = _lastCompactionStageSummary;
|
||
conv.PendingPostCompaction = true;
|
||
try { _storage.Save(conv); } catch { }
|
||
}
|
||
|
||
private void ConsumePostCompactionUsageIfNeeded(int promptTokens, int completionTokens)
|
||
{
|
||
if (!_pendingPostCompaction)
|
||
return;
|
||
|
||
_pendingPostCompaction = false;
|
||
_sessionPostCompactionResponseCount++;
|
||
_sessionPostCompactionPromptTokens += Math.Max(0, promptTokens);
|
||
_sessionPostCompactionCompletionTokens += Math.Max(0, completionTokens);
|
||
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null)
|
||
return;
|
||
|
||
conv.PendingPostCompaction = false;
|
||
conv.PostCompactionResponseCount = _sessionPostCompactionResponseCount;
|
||
conv.PostCompactionPromptTokens = _sessionPostCompactionPromptTokens;
|
||
conv.PostCompactionCompletionTokens = _sessionPostCompactionCompletionTokens;
|
||
try { _storage.Save(conv); } catch { }
|
||
}
|
||
|
||
private void ScheduleGitBranchRefresh(int delayMs = 400)
|
||
{
|
||
if (BtnGitBranch == null)
|
||
return;
|
||
|
||
_gitRefreshTimer.Stop();
|
||
_gitRefreshTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(100, delayMs));
|
||
_gitRefreshTimer.Start();
|
||
}
|
||
|
||
private async Task RefreshGitBranchStatusAsync()
|
||
{
|
||
var folder = GetCurrentWorkFolder();
|
||
if (_activeTab == "Chat" || string.IsNullOrWhiteSpace(folder))
|
||
{
|
||
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
|
||
return;
|
||
}
|
||
|
||
var gitRoot = ResolveGitRoot(folder);
|
||
var gitPath = FindGitExecutablePath();
|
||
if (string.IsNullOrWhiteSpace(gitRoot) || string.IsNullOrWhiteSpace(gitPath))
|
||
{
|
||
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
|
||
return;
|
||
}
|
||
|
||
_gitStatusRefreshCts?.Cancel();
|
||
_gitStatusRefreshCts?.Dispose();
|
||
_gitStatusRefreshCts = new CancellationTokenSource();
|
||
var ct = _gitStatusRefreshCts.Token;
|
||
|
||
try
|
||
{
|
||
var branchTask = RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, ct);
|
||
var statusTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct);
|
||
var diffTask = RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat", "HEAD" }, ct);
|
||
var branchesTask = RunGitAsync(gitPath, gitRoot, new[] { "branch", "--format", "%(refname:short)" }, ct);
|
||
var upstreamTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "-sb" }, ct);
|
||
await Task.WhenAll(branchTask, statusTask, diffTask, branchesTask, upstreamTask);
|
||
|
||
var branchResult = await branchTask;
|
||
var statusResult = await statusTask;
|
||
var diffResult = await diffTask;
|
||
var branchesResult = await branchesTask;
|
||
var upstreamResult = await upstreamTask;
|
||
|
||
if (branchResult.ExitCode != 0 || statusResult.ExitCode != 0)
|
||
{
|
||
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
|
||
return;
|
||
}
|
||
|
||
var branchName = branchResult.StdOut.Trim();
|
||
if (string.IsNullOrWhiteSpace(branchName))
|
||
branchName = "detached";
|
||
|
||
var fileCount = statusResult.StdOut
|
||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Length;
|
||
|
||
var branches = branchesResult.ExitCode == 0
|
||
? branchesResult.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(x => x.Trim())
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(x => x.Equals(branchName, StringComparison.OrdinalIgnoreCase) ? "" : x, StringComparer.OrdinalIgnoreCase)
|
||
.ToList()
|
||
: new List<string>();
|
||
|
||
var diffText = diffResult.ExitCode == 0 ? diffResult.StdOut : "";
|
||
if (string.IsNullOrWhiteSpace(diffText))
|
||
{
|
||
var fallbackDiff = await RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat" }, ct);
|
||
diffText = fallbackDiff.ExitCode == 0 ? fallbackDiff.StdOut : "";
|
||
}
|
||
|
||
var insertions = ParseGitShortStat(diffText, "insertion");
|
||
var deletions = ParseGitShortStat(diffText, "deletion");
|
||
|
||
var filesText = fileCount == 0 && insertions == 0 && deletions == 0
|
||
? "깨끗함"
|
||
: $"{fileCount:N0}개 파일";
|
||
var addedText = insertions > 0 ? $"+{insertions:N0}" : "";
|
||
var deletedText = deletions > 0 ? $"-{deletions:N0}" : "";
|
||
var tooltip = $"브랜치: {branchName}\n작업 폴더: {gitRoot}\n변경 파일: {fileCount:N0}\n추가 라인: +{insertions:N0}\n삭제 라인: -{deletions:N0}";
|
||
_currentGitRoot = gitRoot;
|
||
_currentGitChangedFileCount = fileCount;
|
||
_currentGitInsertions = insertions;
|
||
_currentGitDeletions = deletions;
|
||
_currentGitBranches = branches;
|
||
_currentGitUpstreamStatus = ExtractGitUpstreamStatus(upstreamResult.StdOut);
|
||
|
||
UpdateGitBranchUi(branchName, filesText, addedText, deletedText, tooltip, Visibility.Visible);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
}
|
||
catch
|
||
{
|
||
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
|
||
}
|
||
}
|
||
|
||
private static int ParseGitShortStat(string text, string unit)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return 0;
|
||
|
||
var match = System.Text.RegularExpressions.Regex.Match(
|
||
text,
|
||
$@"(\d+)\s+{unit}",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
return match.Success && int.TryParse(match.Groups[1].Value, out var value) ? value : 0;
|
||
}
|
||
|
||
private static string? ExtractGitUpstreamStatus(string text)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return null;
|
||
|
||
var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? "";
|
||
var start = firstLine.IndexOf('[', StringComparison.Ordinal);
|
||
var end = firstLine.IndexOf(']', StringComparison.Ordinal);
|
||
if (start >= 0 && end > start)
|
||
return firstLine[(start + 1)..end];
|
||
return null;
|
||
}
|
||
|
||
private async Task SwitchGitBranchAsync(string branchName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(branchName) || string.IsNullOrWhiteSpace(_currentGitRoot))
|
||
return;
|
||
|
||
var gitPath = FindGitExecutablePath();
|
||
if (string.IsNullOrWhiteSpace(gitPath))
|
||
return;
|
||
|
||
try
|
||
{
|
||
var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", branchName }, CancellationToken.None);
|
||
if (result.ExitCode != 0)
|
||
{
|
||
CustomMessageBox.Show($"브랜치 전환에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
return;
|
||
}
|
||
|
||
TrackRecentGitBranch(branchName);
|
||
GitBranchPopup.IsOpen = false;
|
||
await RefreshGitBranchStatusAsync();
|
||
SetStatus($"브랜치 전환: {branchName}", spinning: false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CustomMessageBox.Show($"브랜치 전환 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
}
|
||
|
||
private async Task CreateGitBranchAsync()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(_currentGitRoot))
|
||
return;
|
||
|
||
var dlg = new Views.InputDialog("새 브랜치 생성", "브랜치 이름:", "", "feature/my-change") { Owner = this };
|
||
if (dlg.ShowDialog() != true)
|
||
return;
|
||
|
||
var branchName = (dlg.ResponseText ?? "").Trim();
|
||
if (string.IsNullOrWhiteSpace(branchName))
|
||
return;
|
||
|
||
var gitPath = FindGitExecutablePath();
|
||
if (string.IsNullOrWhiteSpace(gitPath))
|
||
return;
|
||
|
||
try
|
||
{
|
||
var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", "-c", branchName }, CancellationToken.None);
|
||
if (result.ExitCode != 0)
|
||
{
|
||
CustomMessageBox.Show($"브랜치 생성에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
return;
|
||
}
|
||
|
||
TrackRecentGitBranch(branchName);
|
||
GitBranchPopup.IsOpen = false;
|
||
await RefreshGitBranchStatusAsync();
|
||
SetStatus($"새 브랜치 생성: {branchName}", spinning: false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
CustomMessageBox.Show($"브랜치 생성 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
}
|
||
|
||
private static string TruncateForStatus(string? text, int max = 40)
|
||
{
|
||
if (string.IsNullOrEmpty(text)) return "";
|
||
return text.Length <= max ? text : text[..max] + "…";
|
||
}
|
||
|
||
private DraftQueueItem? EnqueueDraftRequest(string text, string priority, string? explicitKind = null)
|
||
{
|
||
DraftQueueItem? queuedItem = null;
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session != null)
|
||
_currentConversation = (queuedItem = session.EnqueueDraft(
|
||
_activeTab,
|
||
text,
|
||
priority,
|
||
_storage,
|
||
InferDraftKind(text, explicitKind))) != null
|
||
? session.CurrentConversation
|
||
: _currentConversation;
|
||
}
|
||
|
||
return queuedItem;
|
||
}
|
||
|
||
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
|
||
private Popup? _taskSummaryPopup;
|
||
private UIElement? _taskSummaryTarget;
|
||
private string _taskSummaryTaskFilter = "all";
|
||
|
||
private void BtnDraftEnqueue_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var text = InputBox?.Text?.Trim() ?? "";
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return;
|
||
|
||
QueueComposerDraft(
|
||
priority: Keyboard.Modifiers.HasFlag(ModifierKeys.Control) ? "now" : "next",
|
||
explicitKind: "steering",
|
||
startImmediatelyWhenIdle: true);
|
||
}
|
||
|
||
private void BtnDraftEdit_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (InputBox == null)
|
||
return;
|
||
|
||
InputBox.Focus();
|
||
InputBox.CaretIndex = InputBox.Text?.Length ?? 0;
|
||
}
|
||
|
||
private void BtnDraftClear_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (InputBox == null)
|
||
return;
|
||
|
||
InputBox.Clear();
|
||
InputBox.Focus();
|
||
UpdateInputBoxHeight();
|
||
RefreshDraftQueueUi();
|
||
}
|
||
|
||
private void QueueDraftForImmediateRun(string draftId)
|
||
{
|
||
if (_streamingTabs.Contains(_activeTab))
|
||
{
|
||
// 실행 중이면 큐에 대기 — 현재 작업 완료 후 자동 실행
|
||
ShowToast("메시지가 대기열에 추가되었습니다. 현재 작업 완료 후 실행됩니다.", "\uE8AB");
|
||
return;
|
||
}
|
||
|
||
StartNextQueuedDraftIfAny(draftId);
|
||
}
|
||
|
||
private void ResetDraftInQueue(string draftId)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null && session.ResetDraftToQueued(_activeTab, draftId, _storage))
|
||
_currentConversation = session.CurrentConversation;
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
}
|
||
|
||
private void RemoveDraftFromQueue(string draftId)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null && session.RemoveDraft(_activeTab, draftId, _storage))
|
||
_currentConversation = session.CurrentConversation;
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
}
|
||
|
||
private void ClearCompletedDrafts()
|
||
{
|
||
int removed;
|
||
lock (_convLock)
|
||
{
|
||
removed = _draftQueueProcessor.ClearCompleted(ChatSession, _activeTab, _storage);
|
||
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
if (removed > 0)
|
||
ShowToast($"완료된 대기열 {removed}개 정리됨");
|
||
}
|
||
|
||
private void ClearFailedDrafts()
|
||
{
|
||
int removed;
|
||
lock (_convLock)
|
||
{
|
||
removed = _draftQueueProcessor.ClearFailed(ChatSession, _activeTab, _storage);
|
||
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
if (removed > 0)
|
||
ShowToast($"실패한 대기열 {removed}개 정리됨");
|
||
}
|
||
|
||
private void StartNextQueuedDraftIfAny(string? preferredDraftId = null)
|
||
{
|
||
if (_streamingTabs.Contains(_activeTab))
|
||
return;
|
||
|
||
DraftQueueItem? next = null;
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
next = _draftQueueProcessor.TryStartNext(session, _activeTab, _storage, preferredDraftId, _appState.TaskRuns);
|
||
if (next == null)
|
||
{
|
||
// 대기열이 비었을 때 "대기열에 추가됐습니다" 토스트가 남아있으면 즉시 숨김
|
||
HideToast();
|
||
return;
|
||
}
|
||
|
||
_runningDraftId = next.Id;
|
||
_currentConversation = session?.CurrentConversation ?? _currentConversation;
|
||
}
|
||
|
||
HideToast(); // 대기열 실행 시작 — "추가됐습니다" 알림 즉시 숨김
|
||
RefreshDraftQueueUi();
|
||
var draftRunTab = _activeTab; // 탭 전환 대비: 실행 시점의 탭 캡처
|
||
_ = SendMessageAsync(next.Text).ContinueWith(t =>
|
||
{
|
||
if (t.IsFaulted)
|
||
{
|
||
Services.LogService.Error($"대기열 메시지 전송 실패: {t.Exception?.InnerException?.Message}");
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
// 스트리밍 상태 정리 — 실패로 교착 방지
|
||
ResetStreamingUiState(draftRunTab);
|
||
RefreshDraftQueueUi();
|
||
});
|
||
}
|
||
}, TaskScheduler.Default);
|
||
}
|
||
|
||
private void OpenCommandSkillBrowser(string seedInput)
|
||
{
|
||
if (InputBox == null)
|
||
return;
|
||
|
||
if (!string.IsNullOrEmpty(_slashPalette.ActiveCommand))
|
||
HideSlashChip();
|
||
|
||
InputBox.Text = seedInput;
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
InputBox.Focus();
|
||
}
|
||
|
||
private void BtnToggleExecutionLog_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
bool visible;
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
visible = session?.ToggleExecutionHistory(_activeTab, _storage) ?? !(_currentConversation?.ShowExecutionHistory ?? true);
|
||
if (session != null)
|
||
_currentConversation = session.CurrentConversation;
|
||
}
|
||
if (ExecutionLogLabel != null)
|
||
{
|
||
var logLevelLabel = _settings.Settings.Llm.AgentLogLevel switch
|
||
{
|
||
"hidden" => "숨김",
|
||
"simple" => "간략",
|
||
"debug" => "디버그",
|
||
_ => "상세",
|
||
};
|
||
ExecutionLogLabel.Text = $"실행이력 · {logLevelLabel}";
|
||
}
|
||
if (ExecutionLogIcon != null)
|
||
ExecutionLogIcon.Text = visible ? "\uE8F8" : "\uE946";
|
||
|
||
RenderMessages();
|
||
}
|
||
|
||
private bool CanRetryCurrentConversation()
|
||
{
|
||
return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation());
|
||
}
|
||
|
||
private List<ChatExecutionEvent> GetExecutionEventsForRun(string? runId, int take = 3)
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, eventTake: take).Events;
|
||
}
|
||
|
||
private static bool IsBranchContextMessage(string? content)
|
||
{
|
||
return !string.IsNullOrWhiteSpace(content)
|
||
&& content.StartsWith("이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다.", StringComparison.Ordinal);
|
||
}
|
||
|
||
private AppStateService.AgentRunState? GetAgentRunStateById(string? runId)
|
||
=> _appState.GetAgentRunById(runId);
|
||
|
||
private AppStateService.AgentRunState? GetLatestBranchContextRun()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
return _appState.GetLatestConversationRun(conv?.AgentRunHistory);
|
||
}
|
||
|
||
private List<string> GetBranchContextFilePaths(string? runId, int take = 3)
|
||
{
|
||
return GetExecutionEventFilePaths(runId, take);
|
||
}
|
||
|
||
private List<string> GetExecutionEventFilePaths(string? runId, int take = 2)
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, fileTake: take).FilePaths;
|
||
}
|
||
|
||
private AppStateService.RunPlanHistoryState GetRunPlanHistory(string? runId)
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
return _appState.GetRunPlanHistory(conv?.ExecutionEvents, runId);
|
||
}
|
||
|
||
private Border? BuildRunPlanHistoryCard(AppStateService.RunPlanHistoryState history)
|
||
{
|
||
if (!history.HasAny)
|
||
return null;
|
||
|
||
var panel = new StackPanel();
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "계획 히스토리",
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#1E3A8A"),
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
});
|
||
|
||
panel.Children.Add(BuildPlanHistorySection("원안", history.OriginalSummary, history.OriginalSteps));
|
||
panel.Children.Add(BuildPlanHistorySection("수정안", history.RevisedSummary, history.RevisedSteps));
|
||
panel.Children.Add(BuildPlanHistorySection("최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps));
|
||
|
||
var diffSection = BuildPlanDiffSection(history.OriginalSteps, history.FinalApprovedSteps);
|
||
if (diffSection != null)
|
||
panel.Children.Add(diffSection);
|
||
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex("#EFF6FF"),
|
||
BorderBrush = BrushFromHex("#BFDBFE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
Child = panel,
|
||
};
|
||
}
|
||
|
||
private static Border BuildPlanHistorySection(string title, string? summary, IReadOnlyList<string> steps)
|
||
{
|
||
var section = new StackPanel();
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = title,
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
});
|
||
|
||
var hasContent = false;
|
||
if (!string.IsNullOrWhiteSpace(summary))
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = summary,
|
||
FontSize = 10,
|
||
Foreground = Brushes.DimGray,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
hasContent = true;
|
||
}
|
||
|
||
if (steps.Count > 0)
|
||
{
|
||
foreach (var step in steps.Take(4))
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = $"• {step}",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#334155"),
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
if (steps.Count > 4)
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = $"• ... 외 {steps.Count - 4}개 단계",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#64748B"),
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
});
|
||
}
|
||
hasContent = true;
|
||
}
|
||
|
||
if (!hasContent)
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = "기록 없음",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#64748B"),
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
});
|
||
}
|
||
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex("#F8FAFC"),
|
||
BorderBrush = BrushFromHex("#DBEAFE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(6, 5, 6, 5),
|
||
Margin = new Thickness(0, 0, 0, 4),
|
||
Child = section,
|
||
};
|
||
}
|
||
|
||
private Border? BuildPlanDiffSection(IReadOnlyList<string> originalSteps, IReadOnlyList<string> finalSteps)
|
||
{
|
||
if (originalSteps.Count == 0 || finalSteps.Count == 0)
|
||
return null;
|
||
|
||
var originalNormalized = originalSteps
|
||
.Where(step => !string.IsNullOrWhiteSpace(step))
|
||
.Select(step => step.Trim())
|
||
.ToList();
|
||
var finalNormalized = finalSteps
|
||
.Where(step => !string.IsNullOrWhiteSpace(step))
|
||
.Select(step => step.Trim())
|
||
.ToList();
|
||
|
||
var originalTokens = BuildPlanStepTokens(originalNormalized);
|
||
var finalTokens = BuildPlanStepTokens(finalNormalized);
|
||
var originalTokenMap = originalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal);
|
||
var finalTokenMap = finalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal);
|
||
|
||
var added = finalTokens
|
||
.Where(token => !originalTokenMap.ContainsKey(token.Id))
|
||
.Select(token => token.Text)
|
||
.ToList();
|
||
var removed = originalTokens
|
||
.Where(token => !finalTokenMap.ContainsKey(token.Id))
|
||
.Select(token => token.Text)
|
||
.ToList();
|
||
var moved = GetMovedPlanSteps(originalTokens, finalTokenMap);
|
||
if (added.Count == 0 && removed.Count == 0 && moved.Count == 0)
|
||
return null;
|
||
|
||
var section = new StackPanel();
|
||
var header = new Grid();
|
||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
header.Children.Add(new TextBlock
|
||
{
|
||
Text = "변경 포인트 (원안 ↔ 최종승인안)",
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#1E3A8A"),
|
||
});
|
||
var severity = GetPlanDiffSeverity(added.Count, removed.Count, moved.Count, originalNormalized.Count, finalNormalized.Count);
|
||
var severityBadge = new Border
|
||
{
|
||
Background = BrushFromHex(severity.BgHex),
|
||
BorderBrush = BrushFromHex(severity.BorderHex),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(6, 1, 6, 1),
|
||
Child = new TextBlock
|
||
{
|
||
Text = severity.Label,
|
||
FontSize = 9.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex(severity.FgHex),
|
||
}
|
||
};
|
||
Grid.SetColumn(severityBadge, 1);
|
||
header.Children.Add(severityBadge);
|
||
section.Children.Add(header);
|
||
|
||
foreach (var step in added.Take(4))
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = $"+ {step}",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#166534"),
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
foreach (var step in removed.Take(4))
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = $"- {step}",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#991B1B"),
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
foreach (var item in moved.Take(4))
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = $"~ {item.Step} ({item.FromIndex + 1} -> {item.ToIndex + 1})",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
var hiddenCount = Math.Max(0, added.Count - 4) + Math.Max(0, removed.Count - 4) + Math.Max(0, moved.Count - 4);
|
||
if (hiddenCount > 0)
|
||
{
|
||
section.Children.Add(new TextBlock
|
||
{
|
||
Text = $"... 외 {hiddenCount}개 변경",
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#64748B"),
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
});
|
||
}
|
||
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex("#F8FAFC"),
|
||
BorderBrush = BrushFromHex("#BFDBFE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(6, 5, 6, 5),
|
||
Margin = new Thickness(0, 0, 0, 2),
|
||
Child = section,
|
||
};
|
||
}
|
||
|
||
private static List<(string Step, int FromIndex, int ToIndex)> GetMovedPlanSteps(
|
||
IReadOnlyList<PlanStepToken> originalTokens,
|
||
IReadOnlyDictionary<string, PlanStepToken> finalTokenMap)
|
||
{
|
||
var moved = new List<(string Step, int FromIndex, int ToIndex)>();
|
||
foreach (var token in originalTokens)
|
||
{
|
||
if (!finalTokenMap.TryGetValue(token.Id, out var finalToken))
|
||
continue;
|
||
if (token.Index == finalToken.Index)
|
||
continue;
|
||
|
||
moved.Add((finalToken.Text, token.Index, finalToken.Index));
|
||
}
|
||
|
||
return moved
|
||
.OrderBy(item => item.ToIndex)
|
||
.ToList();
|
||
}
|
||
|
||
private static List<PlanStepToken> BuildPlanStepTokens(IReadOnlyList<string> steps)
|
||
{
|
||
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
var tokens = new List<PlanStepToken>(steps.Count);
|
||
|
||
for (var i = 0; i < steps.Count; i++)
|
||
{
|
||
var text = steps[i].Trim();
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
continue;
|
||
|
||
var normalizedKey = text.ToUpperInvariant();
|
||
counters.TryGetValue(normalizedKey, out var currentCount);
|
||
var nextCount = currentCount + 1;
|
||
counters[normalizedKey] = nextCount;
|
||
|
||
tokens.Add(new PlanStepToken($"{normalizedKey}#{nextCount}", text, i));
|
||
}
|
||
|
||
return tokens;
|
||
}
|
||
|
||
private readonly record struct PlanStepToken(string Id, string Text, int Index);
|
||
|
||
private (string Label, string BgHex, string BorderHex, string FgHex) GetPlanDiffSeverity(
|
||
int addedCount,
|
||
int removedCount,
|
||
int movedCount,
|
||
int originalCount,
|
||
int finalCount)
|
||
{
|
||
var totalChanges = addedCount + removedCount + movedCount;
|
||
var baseSize = Math.Max(1, Math.Max(originalCount, finalCount));
|
||
var ratio = (double)totalChanges / baseSize;
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
var mediumCount = llm.PlanDiffSeverityMediumCount > 0 ? llm.PlanDiffSeverityMediumCount : 2;
|
||
var highCount = llm.PlanDiffSeverityHighCount > 0 ? llm.PlanDiffSeverityHighCount : 5;
|
||
if (highCount < mediumCount)
|
||
highCount = mediumCount;
|
||
|
||
var mediumRatioPercent = llm.PlanDiffSeverityMediumRatioPercent > 0 ? llm.PlanDiffSeverityMediumRatioPercent : 25;
|
||
var highRatioPercent = llm.PlanDiffSeverityHighRatioPercent > 0 ? llm.PlanDiffSeverityHighRatioPercent : 60;
|
||
mediumRatioPercent = Math.Clamp(mediumRatioPercent, 1, 100);
|
||
highRatioPercent = Math.Clamp(highRatioPercent, mediumRatioPercent, 100);
|
||
var mediumRatio = mediumRatioPercent / 100.0;
|
||
var highRatio = highRatioPercent / 100.0;
|
||
|
||
if (totalChanges >= highCount || ratio >= highRatio)
|
||
return ("대폭", "#FEF2F2", "#FCA5A5", "#991B1B");
|
||
if (totalChanges >= mediumCount || ratio >= mediumRatio)
|
||
return ("중간", "#FFF7ED", "#FDBA74", "#9A3412");
|
||
return ("경미", "#ECFDF5", "#86EFAC", "#166534");
|
||
}
|
||
|
||
private void OpenRunFilePath(string path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
|
||
return;
|
||
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
|
||
if (PreviewWindow.IsOpen)
|
||
PreviewWindow.RefreshIfOpen(path);
|
||
else
|
||
TryShowPreview(path);
|
||
}
|
||
|
||
private void OpenRunFileExternal(string path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
|
||
return;
|
||
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = path,
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
private void RevealRunFileInFolder(string path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
|
||
return;
|
||
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); } catch { }
|
||
}
|
||
|
||
private string GetRunPrimaryTool(string runId)
|
||
{
|
||
return GetExecutionEventsForRun(runId, 20)
|
||
.Where(evt => !string.IsNullOrWhiteSpace(evt.ToolName))
|
||
.GroupBy(evt => evt.ToolName!, StringComparer.OrdinalIgnoreCase)
|
||
.OrderByDescending(g => g.Count())
|
||
.Select(g => g.Key)
|
||
.FirstOrDefault() ?? "";
|
||
}
|
||
|
||
private static string GetFollowUpAssetHint(IEnumerable<string> files)
|
||
{
|
||
var ext = files
|
||
.Select(path => System.IO.Path.GetExtension(path)?.ToLowerInvariant())
|
||
.FirstOrDefault(e => !string.IsNullOrWhiteSpace(e));
|
||
|
||
return ext switch
|
||
{
|
||
".html" or ".htm" => "브라우저에서 렌더링과 레이아웃까지 함께 점검해줘.",
|
||
".md" => "문서 흐름과 섹션 구성을 함께 다듬어줘.",
|
||
".csv" or ".xlsx" => "데이터 구조와 컬럼 구성이 맞는지도 함께 확인해줘.",
|
||
".docx" => "문서 형식과 본문 구성을 같이 점검해줘.",
|
||
".cs" or ".ts" or ".js" or ".py" => "코드 영향 범위와 후속 수정 포인트를 같이 점검해줘.",
|
||
_ => "결과물과 관련 파일을 먼저 검토한 뒤 이어서 진행해줘.",
|
||
};
|
||
}
|
||
|
||
private string BuildFollowUpLeadForRun(AppStateService.AgentRunState run)
|
||
{
|
||
var primaryTool = GetRunPrimaryTool(run.RunId);
|
||
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
|
||
var assetHint = GetFollowUpAssetHint(relatedFiles);
|
||
return primaryTool switch
|
||
{
|
||
"file_write" or "file_edit" or "html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create"
|
||
=> $"방금 만든 결과물을 검토하고, 필요한 후속 수정이나 마무리 작업을 이어서 진행해줘. {assetHint}",
|
||
"code_review" or "review"
|
||
=> "방금 검토한 내용을 바탕으로 우선순위 높은 이슈부터 후속 조치를 이어서 진행해줘. 수정이 필요하면 바로 반영 방향까지 포함해줘.",
|
||
"grep" or "glob" or "folder_map"
|
||
=> "방금 찾은 내용과 구조를 바탕으로 다음 분석 단계를 이어서 진행해줘. 필요하면 관련 파일을 직접 열어 근거를 더 확인해줘.",
|
||
"process" or "build_run"
|
||
=> "방금 실행한 명령 결과를 바탕으로 오류나 남은 작업을 정리하고, 필요한 다음 조치를 이어서 진행해줘.",
|
||
_ => "방금 완료한 작업을 이어서 진행해줘.",
|
||
};
|
||
}
|
||
|
||
private string BuildBranchHintFromRun(AppStateService.AgentRunState run)
|
||
{
|
||
var primaryTool = GetRunPrimaryTool(run.RunId);
|
||
var baseHint = primaryTool switch
|
||
{
|
||
"file_write" or "file_edit" => "수정안",
|
||
"html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create" => "결과 확장",
|
||
"code_review" or "review" => "리뷰 후속",
|
||
"grep" or "glob" or "folder_map" => "탐색 후속",
|
||
_ => TruncateForStatus(run.Summary, 16),
|
||
};
|
||
return string.IsNullOrWhiteSpace(baseHint) ? "후속안" : baseHint;
|
||
}
|
||
|
||
private string BuildFollowUpPromptFromRun(AppStateService.AgentRunState run)
|
||
{
|
||
var summary = string.IsNullOrWhiteSpace(run.Summary)
|
||
? "방금 완료한 작업"
|
||
: run.Summary.Trim();
|
||
var recentEvents = GetExecutionEventsForRun(run.RunId, 3);
|
||
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
|
||
var planHistory = GetRunPlanHistory(run.RunId);
|
||
var lead = BuildFollowUpLeadForRun(run);
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine(lead);
|
||
sb.AppendLine();
|
||
sb.AppendLine("[이전 결과 요약]");
|
||
sb.AppendLine(summary);
|
||
|
||
if (relatedFiles.Count > 0)
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("[관련 파일]");
|
||
foreach (var path in relatedFiles)
|
||
sb.AppendLine($"- {path}");
|
||
}
|
||
|
||
if (recentEvents.Count > 0)
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("[최근 실행 로그]");
|
||
foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp))
|
||
sb.AppendLine($"- {_appState.FormatExecutionEventLine(evt)}");
|
||
}
|
||
|
||
if (planHistory.HasAny)
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("[계획 히스토리]");
|
||
AppendPlanHistoryLines(sb, planHistory, bulletPrefix: "- ");
|
||
}
|
||
|
||
sb.AppendLine();
|
||
sb.AppendLine(GetFollowUpAssetHint(relatedFiles));
|
||
return sb.ToString().Trim();
|
||
}
|
||
|
||
private string BuildBranchContextMessageFromRun(AppStateService.AgentRunState run)
|
||
{
|
||
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
|
||
var recentEvents = GetExecutionEventsForRun(run.RunId, 3);
|
||
var planHistory = GetRunPlanHistory(run.RunId);
|
||
var primaryTool = GetRunPrimaryTool(run.RunId);
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine($"이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다. ({BuildBranchHintFromRun(run)})");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"- 실행 요약: {(string.IsNullOrWhiteSpace(run.Summary) ? "요약 없음" : run.Summary.Trim())}");
|
||
if (!string.IsNullOrWhiteSpace(primaryTool))
|
||
sb.AppendLine($"- 주요 도구: {primaryTool}");
|
||
|
||
if (relatedFiles.Count > 0)
|
||
{
|
||
sb.AppendLine("- 관련 파일:");
|
||
foreach (var path in relatedFiles)
|
||
sb.AppendLine($" - {path}");
|
||
}
|
||
|
||
if (recentEvents.Count > 0)
|
||
{
|
||
sb.AppendLine("- 최근 실행 로그:");
|
||
foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp))
|
||
sb.AppendLine($" - {_appState.FormatExecutionEventLine(evt)}");
|
||
}
|
||
|
||
if (planHistory.HasAny)
|
||
{
|
||
sb.AppendLine("- 계획 히스토리:");
|
||
AppendPlanHistoryLines(sb, planHistory, bulletPrefix: " - ");
|
||
}
|
||
|
||
sb.AppendLine();
|
||
sb.AppendLine("이 문맥을 바탕으로 여기서 별도의 후속 작업을 이어가면 됩니다.");
|
||
return sb.ToString().Trim();
|
||
}
|
||
|
||
private static void AppendPlanHistoryLines(System.Text.StringBuilder sb, AppStateService.RunPlanHistoryState history, string bulletPrefix)
|
||
{
|
||
AppendPlanHistoryLine(sb, bulletPrefix, "원안", history.OriginalSummary, history.OriginalSteps);
|
||
AppendPlanHistoryLine(sb, bulletPrefix, "수정안", history.RevisedSummary, history.RevisedSteps);
|
||
AppendPlanHistoryLine(sb, bulletPrefix, "최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps);
|
||
}
|
||
|
||
private static void AppendPlanHistoryLine(
|
||
System.Text.StringBuilder sb,
|
||
string bulletPrefix,
|
||
string label,
|
||
string? summary,
|
||
IReadOnlyList<string> steps)
|
||
{
|
||
var stepPreview = steps
|
||
.Where(step => !string.IsNullOrWhiteSpace(step))
|
||
.Take(3)
|
||
.ToList();
|
||
|
||
if (string.IsNullOrWhiteSpace(summary) && stepPreview.Count == 0)
|
||
{
|
||
sb.AppendLine($"{bulletPrefix}{label}: 기록 없음");
|
||
return;
|
||
}
|
||
|
||
var summaryPart = string.IsNullOrWhiteSpace(summary) ? "" : summary.Trim();
|
||
var stepPart = stepPreview.Count == 0 ? "" : string.Join(" | ", stepPreview);
|
||
if (!string.IsNullOrWhiteSpace(summaryPart) && !string.IsNullOrWhiteSpace(stepPart))
|
||
sb.AppendLine($"{bulletPrefix}{label}: {summaryPart} ({stepPart})");
|
||
else if (!string.IsNullOrWhiteSpace(summaryPart))
|
||
sb.AppendLine($"{bulletPrefix}{label}: {summaryPart}");
|
||
else
|
||
sb.AppendLine($"{bulletPrefix}{label}: {stepPart}");
|
||
}
|
||
|
||
private void EnqueueFollowUpFromRun(AppStateService.AgentRunState run)
|
||
{
|
||
var prompt = BuildFollowUpPromptFromRun(run);
|
||
EnqueueDraftRequest(prompt, "next", "followup");
|
||
|
||
RefreshDraftQueueUi();
|
||
ShowToast("후속 작업이 대기열에 추가되었습니다.");
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
}
|
||
|
||
private void ScrollToRunInTimeline(string runId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(runId))
|
||
return;
|
||
|
||
if (_runBannerAnchors.TryGetValue(runId, out var target))
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
target.BringIntoView();
|
||
target.Focusable = true;
|
||
target.Focus();
|
||
}, DispatcherPriority.Background);
|
||
return;
|
||
}
|
||
|
||
ShowToast("현재 화면에서 해당 실행 로그를 찾지 못했습니다.", "\uE783");
|
||
}
|
||
|
||
private void BranchConversationFromRun(AppStateService.AgentRunState run)
|
||
{
|
||
ChatConversation? source;
|
||
lock (_convLock) source = _currentConversation;
|
||
if (source == null || source.Messages.Count == 0)
|
||
return;
|
||
|
||
var atIndex = source.Messages.FindLastIndex(m => m.Role == "assistant");
|
||
if (atIndex < 0)
|
||
atIndex = source.Messages.Count - 1;
|
||
|
||
ForkConversation(
|
||
source,
|
||
atIndex,
|
||
BuildBranchHintFromRun(run),
|
||
BuildBranchContextMessageFromRun(run),
|
||
run.RunId);
|
||
|
||
var prompt = BuildFollowUpPromptFromRun(run);
|
||
EnqueueDraftRequest(prompt, "next", "followup");
|
||
|
||
RefreshDraftQueueUi();
|
||
ShowToast("새 분기 대화가 생성되고 후속 작업이 대기열에 추가되었습니다.");
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
}
|
||
|
||
private string? GetLastUserMessageFromConversation()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
return conv?.Messages.LastOrDefault(m => m.Role == "user")?.Content;
|
||
}
|
||
|
||
private void RetryLastUserMessageFromConversation()
|
||
{
|
||
var lastUserMessage = GetLastUserMessageFromConversation();
|
||
if (string.IsNullOrWhiteSpace(lastUserMessage))
|
||
return;
|
||
|
||
if (_streamingTabs.Contains(_activeTab))
|
||
{
|
||
EnqueueDraftRequest(lastUserMessage, "now", "direct");
|
||
|
||
RefreshDraftQueueUi();
|
||
ShowToast("현재 작업 뒤에 같은 요청을 다시 실행하도록 대기열에 추가했습니다.");
|
||
return;
|
||
}
|
||
|
||
_ = SendMessageAsync(lastUserMessage);
|
||
}
|
||
|
||
private void FocusCurrentConversation()
|
||
{
|
||
if (InputBox == null)
|
||
return;
|
||
|
||
InputBox.Focus();
|
||
InputBox.CaretIndex = InputBox.Text?.Length ?? 0;
|
||
}
|
||
|
||
private void ShowRunningConversationsOnly()
|
||
{
|
||
_runningOnlyFilter = true;
|
||
UpdateConversationRunningFilterUi();
|
||
PersistConversationListPreferences();
|
||
RefreshConversationList();
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
}
|
||
|
||
private void ShowSubAgentTasksOnly()
|
||
{
|
||
_taskSummaryTaskFilter = "subagent";
|
||
if (_taskSummaryTarget != null)
|
||
ShowTaskSummaryPopup();
|
||
}
|
||
|
||
private static string? TryGetDraftIdFromQueueTask(TaskRunStore.TaskRun task)
|
||
{
|
||
if (!string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase) ||
|
||
string.IsNullOrWhiteSpace(task.Id))
|
||
return null;
|
||
|
||
var parts = task.Id.Split(':');
|
||
return parts.Length >= 3 ? parts[^1] : null;
|
||
}
|
||
|
||
private static string? TryGetToolNameFromPermissionTask(TaskRunStore.TaskRun task)
|
||
{
|
||
if (!string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) ||
|
||
string.IsNullOrWhiteSpace(task.Id))
|
||
return null;
|
||
|
||
var parts = task.Id.Split(':');
|
||
return parts.Length >= 2 ? parts[^1] : null;
|
||
}
|
||
|
||
private void RetryQueueTask(TaskRunStore.TaskRun task)
|
||
{
|
||
var draftId = TryGetDraftIdFromQueueTask(task);
|
||
if (string.IsNullOrWhiteSpace(draftId))
|
||
return;
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.ResetDraftToQueued(_activeTab, draftId, _storage);
|
||
_currentConversation = session.CurrentConversation ?? _currentConversation;
|
||
}
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
StartNextQueuedDraftIfAny(draftId);
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
}
|
||
|
||
private void RemoveQueueTask(TaskRunStore.TaskRun task)
|
||
{
|
||
var draftId = TryGetDraftIdFromQueueTask(task);
|
||
if (string.IsNullOrWhiteSpace(draftId))
|
||
return;
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.RemoveDraft(_activeTab, draftId, _storage);
|
||
_currentConversation = session.CurrentConversation ?? _currentConversation;
|
||
}
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
}
|
||
|
||
private void ApplyPermissionOverrideAndRefreshTaskPopup(TaskRunStore.TaskRun task, string? mode)
|
||
{
|
||
var toolName = TryGetToolNameFromPermissionTask(task);
|
||
if (string.IsNullOrWhiteSpace(toolName))
|
||
return;
|
||
|
||
SetToolPermissionOverride(toolName, mode);
|
||
if (_taskSummaryTarget != null)
|
||
ShowTaskSummaryPopup();
|
||
}
|
||
|
||
private Button CreateTaskSummaryActionButton(
|
||
string label,
|
||
string bg,
|
||
string border,
|
||
string fg,
|
||
RoutedEventHandler onClick,
|
||
bool trailingMargin = true)
|
||
{
|
||
var button = new Button
|
||
{
|
||
Content = label,
|
||
FontSize = 8.25,
|
||
MinHeight = 20,
|
||
Padding = new Thickness(6, 2, 6, 2),
|
||
Margin = trailingMargin ? new Thickness(0, 0, 3, 0) : new Thickness(0),
|
||
Background = BrushFromHex(bg),
|
||
BorderBrush = BrushFromHex(border),
|
||
BorderThickness = new Thickness(1),
|
||
Foreground = BrushFromHex(fg),
|
||
Cursor = Cursors.Hand,
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
};
|
||
button.Click += onClick;
|
||
return button;
|
||
}
|
||
|
||
private WrapPanel? BuildTaskSummaryActionRow(TaskRunStore.TaskRun task, bool active)
|
||
{
|
||
if (string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var actions = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
};
|
||
|
||
var primaryButton = CreateTaskSummaryActionButton(
|
||
active ? "지금 실행" : "다시 실행",
|
||
"#EEF2FF",
|
||
"#C7D2FE",
|
||
"#3730A3",
|
||
(_, _) => RetryQueueTask(task));
|
||
actions.Children.Add(primaryButton);
|
||
|
||
var secondaryButton = CreateTaskSummaryActionButton(
|
||
active ? "큐에서 제거" : "정리",
|
||
"#F8FAFC",
|
||
"#CBD5E1",
|
||
"#334155",
|
||
(_, _) => RemoveQueueTask(task),
|
||
trailingMargin: false);
|
||
actions.Children.Add(secondaryButton);
|
||
return actions;
|
||
}
|
||
|
||
if (string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var actions = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
};
|
||
|
||
Button BuildPermissionButton(string label, string bg, string border, string fg, string? mode, bool margin = true)
|
||
{
|
||
return CreateTaskSummaryActionButton(
|
||
label, bg, border, fg,
|
||
(_, _) => ApplyPermissionOverrideAndRefreshTaskPopup(task, mode),
|
||
trailingMargin: margin);
|
||
}
|
||
|
||
actions.Children.Add(BuildPermissionButton("권한 요청", "#EFF6FF", "#BFDBFE", "#1D4ED8", PermissionModeCatalog.Default));
|
||
actions.Children.Add(BuildPermissionButton("편집 자동 승인", "#ECFDF5", "#BBF7D0", "#166534", PermissionModeCatalog.AcceptEdits));
|
||
actions.Children.Add(BuildPermissionButton("권한 건너뛰기", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.BypassPermissions));
|
||
actions.Children.Add(BuildPermissionButton("해제", "#F3F4F6", "#D1D5DB", "#374151", null, margin: false));
|
||
return actions;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private Border BuildHookSummaryCard(AppStateService.HookEventState hook)
|
||
{
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var hookCardStack = new StackPanel();
|
||
hookCardStack.Children.Add(new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 0, 0, 2),
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "\uE756",
|
||
FontFamily = s_segoeIconFont,
|
||
FontSize = 9,
|
||
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = "훅",
|
||
FontSize = 9,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
|
||
}
|
||
}
|
||
});
|
||
hookCardStack.Children.Add(new TextBlock
|
||
{
|
||
Text = _appState.FormatHookEventLine(hook),
|
||
FontSize = 8.75,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"),
|
||
});
|
||
|
||
var hookActionRow = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
};
|
||
|
||
var hookFilterButton = CreateTaskSummaryActionButton(
|
||
"훅만 보기",
|
||
"#F8FAFC",
|
||
"#CBD5E1",
|
||
"#334155",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryTaskFilter = "hook";
|
||
if (_taskSummaryTarget != null)
|
||
ShowTaskSummaryPopup();
|
||
});
|
||
hookActionRow.Children.Add(hookFilterButton);
|
||
|
||
if (!string.IsNullOrWhiteSpace(hook.RunId))
|
||
{
|
||
var capturedRunId = hook.RunId;
|
||
var timelineButton = CreateTaskSummaryActionButton(
|
||
"관련 로그로 이동",
|
||
hook.Success ? "#EEF2FF" : "#FEF2F2",
|
||
hook.Success ? "#C7D2FE" : "#FCA5A5",
|
||
hook.Success ? "#3730A3" : "#991B1B",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
ScrollToRunInTimeline(capturedRunId);
|
||
},
|
||
trailingMargin: false);
|
||
hookActionRow.Children.Add(timelineButton);
|
||
}
|
||
|
||
hookCardStack.Children.Add(hookActionRow);
|
||
|
||
return new Border
|
||
{
|
||
Background = hook.Success ? BrushFromHex("#F8FAFC") : BrushFromHex("#FEF2F2"),
|
||
BorderBrush = hook.Success ? BrushFromHex("#E2E8F0") : BrushFromHex("#FECACA"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(7),
|
||
Padding = new Thickness(7, 4, 7, 4),
|
||
Margin = new Thickness(6, 0, 6, 3),
|
||
Child = hookCardStack
|
||
};
|
||
}
|
||
|
||
private Border BuildActiveBackgroundSummaryCard(IReadOnlyList<BackgroundJobService.BackgroundJobState> activeBackgroundJobs, int activeBackgroundCount)
|
||
{
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var activeBackgroundStack = new StackPanel();
|
||
activeBackgroundStack.Children.Add(new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "\uE9F9",
|
||
FontFamily = s_segoeIconFont,
|
||
FontSize = 9,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"백그라운드 {activeBackgroundCount}",
|
||
FontSize = 9,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
FontWeight = FontWeights.SemiBold,
|
||
}
|
||
}
|
||
});
|
||
|
||
foreach (var job in activeBackgroundJobs)
|
||
{
|
||
activeBackgroundStack.Children.Add(new TextBlock
|
||
{
|
||
Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 40)}",
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
FontSize = 8.75,
|
||
Foreground = secondaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex("#EFF6FF"),
|
||
BorderBrush = BrushFromHex("#BFDBFE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(7),
|
||
Padding = new Thickness(7, 4, 7, 4),
|
||
Margin = new Thickness(6, 0, 6, 4),
|
||
Child = activeBackgroundStack
|
||
};
|
||
}
|
||
|
||
private Border BuildRecentBackgroundJobCard(BackgroundJobService.BackgroundJobState job)
|
||
{
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var jobCardStack = new StackPanel();
|
||
var isFailed = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase);
|
||
jobCardStack.Children.Add(new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 0, 0, 2),
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "\uE823",
|
||
FontFamily = s_segoeIconFont,
|
||
FontSize = 9,
|
||
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}",
|
||
FontSize = 9,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
|
||
}
|
||
}
|
||
});
|
||
jobCardStack.Children.Add(new TextBlock
|
||
{
|
||
Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 48)}",
|
||
FontSize = 8.75,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = isFailed
|
||
? BrushFromHex("#991B1B")
|
||
: secondaryText,
|
||
});
|
||
|
||
if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
|
||
{
|
||
var retryBackgroundButton = CreateTaskSummaryActionButton(
|
||
"다시 시도",
|
||
"#FEF2F2",
|
||
"#FCA5A5",
|
||
"#991B1B",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
RetryLastUserMessageFromConversation();
|
||
},
|
||
trailingMargin: false);
|
||
retryBackgroundButton.Margin = new Thickness(0, 5, 0, 0);
|
||
jobCardStack.Children.Add(retryBackgroundButton);
|
||
}
|
||
|
||
return new Border
|
||
{
|
||
Background = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase)
|
||
? BrushFromHex("#FEF2F2")
|
||
: BrushFromHex("#F8FAFC"),
|
||
BorderBrush = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase)
|
||
? BrushFromHex("#FECACA")
|
||
: BrushFromHex("#E2E8F0"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(7),
|
||
Padding = new Thickness(7, 4, 7, 4),
|
||
Margin = new Thickness(6, 0, 6, 3),
|
||
Child = jobCardStack
|
||
};
|
||
}
|
||
|
||
private static (Brush Background, Brush Border, Brush Foreground) GetPermissionModePalette(string? mode)
|
||
{
|
||
var normalized = PermissionModeCatalog.NormalizeGlobalMode(mode);
|
||
return normalized switch
|
||
{
|
||
var x when string.Equals(x, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase)
|
||
=> (BrushFromHex("#ECFDF5"), BrushFromHex("#86EFAC"), BrushFromHex("#166534")),
|
||
var x when string.Equals(x, PermissionModeCatalog.AcceptEdits, StringComparison.OrdinalIgnoreCase)
|
||
=> (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
|
||
var x when string.Equals(x, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase)
|
||
=> (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
|
||
var x when string.Equals(x, PermissionModeCatalog.BypassPermissions, StringComparison.OrdinalIgnoreCase)
|
||
=> (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")),
|
||
_ => (BrushFromHex("#F8FAFC"), BrushFromHex("#CBD5E1"), BrushFromHex("#334155")),
|
||
};
|
||
}
|
||
|
||
private static (string Icon, Brush Color) GetTaskKindVisual(string? kind)
|
||
{
|
||
var normalized = kind?.Trim().ToLowerInvariant() ?? "";
|
||
return normalized switch
|
||
{
|
||
"permission" => ("\uE72E", BrushFromHex("#1D4ED8")),
|
||
"queue" => ("\uE14C", BrushFromHex("#7C3AED")),
|
||
"tool" => ("\uE90F", BrushFromHex("#A16207")),
|
||
"hook" => ("\uE756", BrushFromHex("#0F766E")),
|
||
"subagent" => ("\uE902", BrushFromHex("#2563EB")),
|
||
_ => ("\uE946", BrushFromHex("#475569")),
|
||
};
|
||
}
|
||
|
||
private static (string Label, Brush Background, Brush Border, Brush Foreground) GetPermissionEventStatusDisplay(string? status)
|
||
{
|
||
var normalized = status?.Trim().ToLowerInvariant() ?? "";
|
||
return normalized switch
|
||
{
|
||
"denied" => ("차단", BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
|
||
"granted" => ("허용", BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
|
||
"approved" => ("승인", BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
|
||
"auto" => ("자동 허용", BrushFromHex("#FFF7ED"), BrushFromHex("#FED7AA"), BrushFromHex("#9A3412")),
|
||
_ => ("확인 대기", BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
|
||
};
|
||
}
|
||
|
||
private void AddTaskSummaryPermissionSection(StackPanel panel, ChatConversation? currentConversation)
|
||
{
|
||
var permissionSummary = _appState.GetPermissionSummary(currentConversation);
|
||
var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(permissionSummary.EffectiveMode);
|
||
var (bg, border, fg) = GetPermissionModePalette(normalizedMode);
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
|
||
var content = new StackPanel();
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = $"현재 권한 · {PermissionModeCatalog.ToDisplayLabel(normalizedMode)}",
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = fg,
|
||
});
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = permissionSummary.Description,
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText,
|
||
});
|
||
content.Children.Add(new TextBlock
|
||
{
|
||
Text = $"기본 {PermissionModeCatalog.ToDisplayLabel(permissionSummary.DefaultMode)} · 예외 {permissionSummary.OverrideCount}개",
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
Foreground = primaryText,
|
||
});
|
||
|
||
panel.Children.Add(new Border
|
||
{
|
||
Background = bg,
|
||
BorderBrush = border,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(9, 7, 9, 7),
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
Child = content,
|
||
});
|
||
}
|
||
|
||
private void AddTaskSummaryPermissionHistorySection(StackPanel panel)
|
||
{
|
||
var recentPermissions = _appState.GetRecentPermissionEvents(2);
|
||
if (recentPermissions.Count == 0)
|
||
return;
|
||
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "최근 권한 이력",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 0, 10, 4),
|
||
});
|
||
|
||
foreach (var permission in recentPermissions)
|
||
{
|
||
var (statusLabel, statusBg, statusBorder, statusFg) = GetPermissionEventStatusDisplay(permission.Status);
|
||
var card = new StackPanel();
|
||
card.Children.Add(new TextBlock
|
||
{
|
||
Text = $"{permission.Timestamp:HH:mm:ss} · {permission.ToolName}",
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText,
|
||
FontSize = 10.5,
|
||
});
|
||
card.Children.Add(new TextBlock
|
||
{
|
||
Text = statusLabel,
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
Foreground = statusFg,
|
||
FontWeight = FontWeights.SemiBold,
|
||
FontSize = 10.5,
|
||
});
|
||
if (!string.IsNullOrWhiteSpace(permission.Summary))
|
||
{
|
||
card.Children.Add(new TextBlock
|
||
{
|
||
Text = TruncateForStatus(permission.Summary, 80),
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
Foreground = secondaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
FontSize = 10,
|
||
});
|
||
}
|
||
|
||
panel.Children.Add(new Border
|
||
{
|
||
Background = statusBg,
|
||
BorderBrush = statusBorder,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(9, 6, 9, 6),
|
||
Margin = new Thickness(8, 0, 8, 5),
|
||
Child = card,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void AddTaskSummaryHookSection(StackPanel panel)
|
||
{
|
||
var recentHooks = _appState.GetRecentHookEvents(3);
|
||
if (recentHooks.Count == 0)
|
||
return;
|
||
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "최근 훅 이력",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = Brushes.DimGray,
|
||
Margin = new Thickness(10, 0, 10, 4),
|
||
});
|
||
|
||
foreach (var hook in recentHooks)
|
||
panel.Children.Add(BuildHookSummaryCard(hook));
|
||
}
|
||
|
||
private void AddTaskSummaryBackgroundSection(StackPanel panel)
|
||
{
|
||
var activeBackgroundJobs = _appState.GetActiveBackgroundJobs(2);
|
||
var activeBackgroundCount = _appState.GetBackgroundJobSummary().ActiveCount;
|
||
|
||
if (activeBackgroundCount > 0)
|
||
panel.Children.Add(BuildActiveBackgroundSummaryCard(activeBackgroundJobs, activeBackgroundCount));
|
||
}
|
||
|
||
private void AddTaskSummaryObservabilitySections(StackPanel panel, ChatConversation? currentConversation)
|
||
{
|
||
if (!string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase))
|
||
return;
|
||
|
||
AddTaskSummaryPermissionSection(panel, currentConversation);
|
||
AddTaskSummaryBackgroundSection(panel);
|
||
}
|
||
|
||
// ─── 공유 정적 리소스 (FontFamily/Brush 반복 할당 방지) ──────────────────
|
||
private static readonly FontFamily s_segoeIconFont = new("Segoe MDL2 Assets");
|
||
private static readonly FontFamily s_segoeUiFont = new("Segoe UI, Malgun Gothic");
|
||
|
||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, System.Windows.Media.SolidColorBrush> _brushCache = new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
|
||
{
|
||
return _brushCache.GetOrAdd(hex, static h =>
|
||
{
|
||
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(h)!;
|
||
var brush = new System.Windows.Media.SolidColorBrush(c);
|
||
brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화
|
||
return brush;
|
||
});
|
||
}
|
||
}
|