15129 lines
628 KiB
C#
15129 lines
628 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;
|
||
|
||
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 SettingsService _settings;
|
||
private readonly ChatStorageService _storage;
|
||
private readonly DraftQueueProcessorService _draftQueueProcessor = new();
|
||
private readonly LlmService _llm;
|
||
private readonly ToolRegistry _toolRegistry;
|
||
private readonly AgentLoopService _agentLoop;
|
||
private readonly AxAgentExecutionEngine _chatEngine;
|
||
private readonly ModelRouterService _router;
|
||
private AppStateService _appState => (System.Windows.Application.Current as App)?.AppState ?? new AppStateService();
|
||
private readonly object _convLock = new();
|
||
private readonly Dictionary<string, FrameworkElement> _runBannerAnchors = new(StringComparer.OrdinalIgnoreCase);
|
||
private ChatConversation? _currentConversation;
|
||
private string? _runningDraftId;
|
||
private CancellationTokenSource? _streamCts;
|
||
private bool _isStreaming;
|
||
private bool _sidebarVisible = true;
|
||
private double _sidebarExpandedWidth = 262;
|
||
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;
|
||
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 TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
|
||
private bool _aiIconPulseStopped; // 펄스 1회만 중지
|
||
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
|
||
private PlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
|
||
private Border? _userAskCard; // transcript 내 질문 카드
|
||
private string? _pendingPlanSummary;
|
||
private List<string> _pendingPlanSteps = new();
|
||
private bool _userScrolled; // 사용자가 위로 스크롤했는지
|
||
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
|
||
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 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 _tokenUsagePopupCloseTimer;
|
||
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 double _lastResponsiveComposerWidth;
|
||
private double _lastResponsiveMessageWidth;
|
||
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 ChatWindow(SettingsService settings)
|
||
{
|
||
InitializeComponent();
|
||
_settings = settings;
|
||
_settings.SettingsChanged += Settings_SettingsChanged;
|
||
_storage = new ChatStorageService();
|
||
_llm = new LlmService(settings);
|
||
_router = new ModelRouterService(settings);
|
||
_toolRegistry = ToolRegistry.CreateDefault();
|
||
_chatEngine = new AxAgentExecutionEngine();
|
||
_agentLoop = new AgentLoopService(_llm, _toolRegistry, settings)
|
||
{
|
||
Dispatcher = action =>
|
||
{
|
||
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
|
||
appDispatcher.Invoke(action);
|
||
},
|
||
AskPermissionCallback = async (toolName, filePath) =>
|
||
{
|
||
if (IsPermissionAutoApprovedForSession(toolName, filePath))
|
||
return true;
|
||
|
||
PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject;
|
||
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
|
||
await appDispatcher.InvokeAsync(() =>
|
||
{
|
||
AgentLoopService.PermissionPromptPreview? preview = null;
|
||
if (_agentLoop != null && _agentLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview))
|
||
preview = pendingPreview;
|
||
decision = PermissionRequestWindow.Show(this, toolName, filePath, preview);
|
||
});
|
||
|
||
if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession)
|
||
RememberPermissionRuleForSession(toolName, filePath);
|
||
|
||
return decision != PermissionRequestWindow.PermissionPromptResult.Reject;
|
||
},
|
||
UserAskCallback = ShowInlineUserAskAsync,
|
||
};
|
||
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
|
||
|
||
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
|
||
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
|
||
_folderDataUsage = "none";
|
||
|
||
_cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) };
|
||
_cursorTimer.Tick += CursorTimer_Tick;
|
||
|
||
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||
_elapsedTimer.Tick += ElapsedTimer_Tick;
|
||
|
||
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(12) };
|
||
_typingTimer.Tick += TypingTimer_Tick;
|
||
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
|
||
_gitRefreshTimer.Tick += async (_, _) =>
|
||
{
|
||
_gitRefreshTimer.Stop();
|
||
await RefreshGitBranchStatusAsync();
|
||
};
|
||
_conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
|
||
_conversationSearchTimer.Tick += (_, _) =>
|
||
{
|
||
_conversationSearchTimer.Stop();
|
||
RefreshConversationList();
|
||
};
|
||
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) };
|
||
_inputUiRefreshTimer.Tick += (_, _) =>
|
||
{
|
||
_inputUiRefreshTimer.Stop();
|
||
RefreshContextUsageVisual();
|
||
RefreshDraftQueueUi();
|
||
};
|
||
_executionHistoryRenderTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
|
||
_executionHistoryRenderTimer.Tick += (_, _) =>
|
||
{
|
||
_executionHistoryRenderTimer.Stop();
|
||
RenderMessages(preserveViewport: true);
|
||
if (_pendingExecutionHistoryAutoScroll)
|
||
AutoScrollIfNeeded();
|
||
_pendingExecutionHistoryAutoScroll = false;
|
||
};
|
||
_taskSummaryRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
|
||
_taskSummaryRefreshTimer.Tick += (_, _) =>
|
||
{
|
||
_taskSummaryRefreshTimer.Stop();
|
||
UpdateTaskSummaryIndicators();
|
||
};
|
||
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
|
||
_conversationPersistTimer.Tick += (_, _) =>
|
||
{
|
||
_conversationPersistTimer.Stop();
|
||
FlushPendingConversationPersists();
|
||
};
|
||
_agentUiEventTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) };
|
||
_agentUiEventTimer.Tick += (_, _) =>
|
||
{
|
||
_agentUiEventTimer.Stop();
|
||
FlushPendingAgentUiEvent();
|
||
};
|
||
_tokenUsagePopupCloseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
|
||
_tokenUsagePopupCloseTimer.Tick += (_, _) =>
|
||
{
|
||
_tokenUsagePopupCloseTimer.Stop();
|
||
CloseTokenUsagePopupIfIdle();
|
||
};
|
||
|
||
KeyDown += ChatWindow_KeyDown;
|
||
MouseMove += (_, _) =>
|
||
{
|
||
if (TokenUsagePopup?.IsOpen == true)
|
||
CloseTokenUsagePopupIfIdle();
|
||
};
|
||
PreviewMouseDown += (_, _) =>
|
||
{
|
||
if (TokenUsagePopup != null)
|
||
TokenUsagePopup.IsOpen = false;
|
||
};
|
||
Deactivated += (_, _) =>
|
||
{
|
||
if (TokenUsagePopup != null)
|
||
TokenUsagePopup.IsOpen = false;
|
||
};
|
||
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();
|
||
MessageScroll.ScrollChanged += MessageScroll_ScrollChanged;
|
||
|
||
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
|
||
BuildTopicButtons();
|
||
RestoreLastConversations();
|
||
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.ApplicationIdle);
|
||
|
||
// 입력 바 포커스 글로우 효과
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
InputBox.GotFocus += (_, _) => InputBorder.BorderBrush = accentBrush;
|
||
InputBox.LostFocus += (_, _) => InputBorder.BorderBrush = borderBrush;
|
||
|
||
// 드래그 앤 드롭 파일 첨부 + AI 액션 팝업
|
||
InputBorder.AllowDrop = true;
|
||
InputBorder.DragOver += (_, de) =>
|
||
{
|
||
de.Effects = de.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||
de.Handled = true;
|
||
};
|
||
InputBorder.Drop += (_, de) =>
|
||
{
|
||
if (de.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
|
||
{
|
||
var enableAi = _settings.Settings.Llm.EnableDragDropAiActions;
|
||
if (enableAi && files.Length <= 5)
|
||
ShowDropActionMenu(files);
|
||
else
|
||
foreach (var f in files) AddAttachedFile(f);
|
||
}
|
||
};
|
||
|
||
// 스킬 시스템 초기화
|
||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||
{
|
||
SkillService.EnsureSkillFolder();
|
||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath);
|
||
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);
|
||
ApplyHoverBounceAnimation(BtnTemplateSelector, -1.5);
|
||
ApplyHoverScaleAnimation(BtnSend, 1.12);
|
||
ApplyHoverScaleAnimation(BtnStop, 1.12);
|
||
};
|
||
SizeChanged += (_, _) =>
|
||
{
|
||
UpdateTopicPresetScrollMode();
|
||
if (UpdateResponsiveChatLayout())
|
||
RenderMessages(preserveViewport: true);
|
||
};
|
||
Closed += (_, _) =>
|
||
{
|
||
_settings.SettingsChanged -= Settings_SettingsChanged;
|
||
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
|
||
_streamCts?.Cancel();
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
_conversationSearchTimer.Stop();
|
||
_inputUiRefreshTimer.Stop();
|
||
_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 toolName, string target)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
|
||
return false;
|
||
|
||
var normalizedTarget = target.Trim();
|
||
var pathLikeTool = IsPathLikePermissionTool(toolName);
|
||
foreach (var rule in _sessionPermissionRules)
|
||
{
|
||
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 void RememberPermissionRuleForSession(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.
|
||
}
|
||
}
|
||
|
||
_sessionPermissionRules.Add($"{toolName}|{scopedTarget}");
|
||
}
|
||
|
||
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>
|
||
/// X 버튼으로 닫을 때 창을 숨기기만 합니다 (재사용으로 다음 번 빠르게 열림).
|
||
/// 앱 종료 시에는 ForceClose()를 사용합니다.
|
||
/// </summary>
|
||
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
|
||
{
|
||
if (!_forceClose)
|
||
{
|
||
e.Cancel = true;
|
||
Hide();
|
||
return;
|
||
}
|
||
base.OnClosing(e);
|
||
}
|
||
|
||
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
|
||
public void ForceClose()
|
||
{
|
||
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
|
||
lock (_convLock)
|
||
{
|
||
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 (MessageScroll.ScrollableHeight <= 1)
|
||
{
|
||
_userScrolled = false;
|
||
return;
|
||
}
|
||
|
||
// 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지
|
||
if (Math.Abs(e.ExtentHeightChange) > 0.5)
|
||
return;
|
||
|
||
var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40;
|
||
_userScrolled = !atBottom;
|
||
}
|
||
|
||
private void AutoScrollIfNeeded()
|
||
{
|
||
if (!_userScrolled)
|
||
SmoothScrollToEnd();
|
||
}
|
||
|
||
/// <summary>새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋).</summary>
|
||
private void ForceScrollToEnd()
|
||
{
|
||
_userScrolled = false;
|
||
Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background);
|
||
}
|
||
|
||
/// <summary>부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다.</summary>
|
||
private void SmoothScrollToEnd()
|
||
{
|
||
var targetOffset = MessageScroll.ScrollableHeight;
|
||
var currentOffset = MessageScroll.VerticalOffset;
|
||
var diff = targetOffset - currentOffset;
|
||
|
||
// 차이가 작으면 즉시 이동 (깜빡임 방지)
|
||
if (diff <= 60)
|
||
{
|
||
MessageScroll.ScrollToEnd();
|
||
return;
|
||
}
|
||
|
||
// 부드럽게 스크롤 (DoubleAnimation)
|
||
var animation = new DoubleAnimation
|
||
{
|
||
From = currentOffset,
|
||
To = targetOffset,
|
||
Duration = TimeSpan.FromMilliseconds(200),
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
|
||
};
|
||
animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset);
|
||
|
||
// ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간
|
||
var startTime = DateTime.UtcNow;
|
||
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps
|
||
EventHandler tickHandler = null!;
|
||
tickHandler = (_, _) =>
|
||
{
|
||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||
var progress = Math.Min(elapsed / 200.0, 1.0);
|
||
var eased = 1.0 - Math.Pow(1.0 - progress, 3);
|
||
var offset = currentOffset + diff * eased;
|
||
MessageScroll.ScrollToVerticalOffset(offset);
|
||
|
||
if (progress >= 1.0)
|
||
{
|
||
timer.Stop();
|
||
timer.Tick -= tickHandler;
|
||
}
|
||
};
|
||
timer.Tick += tickHandler;
|
||
timer.Start();
|
||
}
|
||
|
||
// ─── 대화 제목 인라인 편집 ──────────────────────────────────────────
|
||
|
||
private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null) return;
|
||
}
|
||
ChatTitle.Visibility = Visibility.Collapsed;
|
||
ChatTitleEdit.Text = ChatTitle.Text;
|
||
ChatTitleEdit.Visibility = Visibility.Visible;
|
||
ChatTitleEdit.Focus();
|
||
ChatTitleEdit.SelectAll();
|
||
}
|
||
|
||
private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit();
|
||
private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e)
|
||
{
|
||
if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; }
|
||
if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; }
|
||
}
|
||
|
||
private void CommitTitleEdit()
|
||
{
|
||
var newTitle = ChatTitleEdit.Text.Trim();
|
||
ChatTitleEdit.Visibility = Visibility.Collapsed;
|
||
ChatTitle.Visibility = Visibility.Visible;
|
||
|
||
if (string.IsNullOrEmpty(newTitle)) return;
|
||
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null) return;
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
|
||
else
|
||
_currentConversation.Title = newTitle;
|
||
}
|
||
|
||
ChatTitle.Text = newTitle;
|
||
try { if (ChatSession == null) { ChatConversation conv; lock (_convLock) conv = _currentConversation!; _storage.Save(conv); } } catch { }
|
||
RefreshConversationList();
|
||
}
|
||
|
||
private void CancelTitleEdit()
|
||
{
|
||
ChatTitleEdit.Visibility = Visibility.Collapsed;
|
||
ChatTitle.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
// ─── 카테고리 드롭다운 ──────────────────────────────────────────────
|
||
|
||
private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
|
||
var popup = new Popup
|
||
{
|
||
StaysOpen = false,
|
||
AllowsTransparency = true,
|
||
PopupAnimation = PopupAnimation.Fade,
|
||
PlacementTarget = BtnCategoryDrop,
|
||
Placement = PlacementMode.Bottom,
|
||
HorizontalOffset = 0,
|
||
VerticalOffset = 4,
|
||
};
|
||
|
||
var container = new Border
|
||
{
|
||
Background = bgBrush,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(6),
|
||
MinWidth = 180,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
|
||
},
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
|
||
Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick)
|
||
{
|
||
var item = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(0, 1, 0, 1),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var g = new Grid();
|
||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
|
||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
|
||
|
||
var iconTb = new TextBlock
|
||
{
|
||
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(iconTb, 0);
|
||
g.Children.Add(iconTb);
|
||
|
||
var textTb = new TextBlock
|
||
{
|
||
Text = text, FontSize = 12.5, Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(textTb, 1);
|
||
g.Children.Add(textTb);
|
||
|
||
if (isSelected)
|
||
{
|
||
var check = CreateSimpleCheck(accentBrush, 14);
|
||
Grid.SetColumn(check, 2);
|
||
g.Children.Add(check);
|
||
}
|
||
|
||
item.Child = g;
|
||
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); };
|
||
return item;
|
||
}
|
||
|
||
Border CreateSep() => new()
|
||
{
|
||
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
|
||
};
|
||
|
||
// 전체 보기
|
||
var allLabel = _activeTab switch
|
||
{
|
||
"Cowork" => "모든 작업 유형",
|
||
"Code" => "모든 프로젝트",
|
||
_ => "모든 주제",
|
||
};
|
||
stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText,
|
||
string.IsNullOrEmpty(_selectedCategory),
|
||
() => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); }));
|
||
|
||
stack.Children.Add(CreateSep());
|
||
|
||
if (_activeTab == "Cowork")
|
||
{
|
||
// 코워크: 작업 유형 기반 필터
|
||
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();
|
||
if (EmptyState != null)
|
||
EmptyState.Visibility = 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 void ApplyAgentThemeResources()
|
||
{
|
||
var themeUri = BuildAgentThemeDictionaryUri();
|
||
|
||
try
|
||
{
|
||
if (_agentThemeDictionary != null)
|
||
Resources.MergedDictionaries.Remove(_agentThemeDictionary);
|
||
|
||
_agentThemeDictionary = new ResourceDictionary
|
||
{
|
||
Source = themeUri,
|
||
};
|
||
Resources.MergedDictionaries.Insert(0, _agentThemeDictionary);
|
||
}
|
||
catch
|
||
{
|
||
// 테마 로드 실패 시 기본 리소스 유지
|
||
}
|
||
}
|
||
|
||
private Uri BuildAgentThemeDictionaryUri()
|
||
{
|
||
var mode = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
|
||
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claw").Trim().ToLowerInvariant() switch
|
||
{
|
||
"codex" => "Codex",
|
||
"nord" => "Nord",
|
||
"ember" => "Ember",
|
||
"slate" => "Slate",
|
||
_ => "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)
|
||
{
|
||
// WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보
|
||
if (msg == 0x0024)
|
||
{
|
||
var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
|
||
var workArea = screen.WorkingArea;
|
||
var monitor = screen.Bounds;
|
||
|
||
var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd);
|
||
var dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0;
|
||
|
||
// MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36)
|
||
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx
|
||
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy
|
||
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x
|
||
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y
|
||
handled = true;
|
||
}
|
||
return IntPtr.Zero;
|
||
}
|
||
|
||
private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||
private void BtnMaximize_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
||
MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘
|
||
}
|
||
private void BtnClose_Click(object sender, RoutedEventArgs e) => Close();
|
||
|
||
// ─── 탭 전환 ──────────────────────────────────────────────────────────
|
||
|
||
private string _activeTab = "Chat";
|
||
|
||
private 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;
|
||
// 스트리밍 중단
|
||
_streamCts?.Cancel();
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
StopRainbowGlow();
|
||
HideStickyProgress();
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_cachedStreamContent = "";
|
||
_isStreaming = false;
|
||
BtnSend.IsEnabled = true;
|
||
BtnStop.Visibility = Visibility.Collapsed;
|
||
BtnPause.Visibility = Visibility.Collapsed;
|
||
PauseIcon.Text = "\uE769"; // 리셋
|
||
BtnSend.Visibility = Visibility.Visible;
|
||
_streamCts?.Dispose();
|
||
_streamCts = null;
|
||
SetStatusIdle();
|
||
}
|
||
|
||
private void TabChat_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_activeTab == "Chat") return;
|
||
StopStreamingIfActive();
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = "Chat";
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
}
|
||
|
||
private void TabCowork_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_activeTab == "Cowork") return;
|
||
StopStreamingIfActive();
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = "Cowork";
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
}
|
||
|
||
private void TabCode_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_activeTab == "Code") return;
|
||
StopStreamingIfActive();
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = "Code";
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
}
|
||
|
||
/// <summary>탭별로 마지막으로 활성화된 대화 ID를 기억.</summary>
|
||
private readonly Dictionary<string, string?> _tabConversationId = new()
|
||
{
|
||
["Chat"] = null, ["Cowork"] = null, ["Code"] = null,
|
||
};
|
||
|
||
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()
|
||
{
|
||
ApplyAgentThemeResources();
|
||
ApplyExpressionLevelUi();
|
||
ApplySidebarStateForActiveTab(animated: false);
|
||
if (CurrentTabTitle != null)
|
||
{
|
||
CurrentTabTitle.Text = _activeTab switch
|
||
{
|
||
"Cowork" => "AX Agent · Cowork",
|
||
"Code" => "AX Agent · Code",
|
||
_ => "AX Agent · Chat",
|
||
};
|
||
}
|
||
|
||
// 폴더 바는 Cowork/Code 탭에서만 표시
|
||
if (FolderBar != null)
|
||
FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
// 탭별 입력 안내 문구
|
||
if (InputWatermark != null)
|
||
{
|
||
InputWatermark.Text = _activeTab switch
|
||
{
|
||
"Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)",
|
||
"Code" => "코드 관련 작업을 요청하세요...",
|
||
_ => _promptCardPlaceholder,
|
||
};
|
||
}
|
||
|
||
// 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용)
|
||
ApplyTabDefaultPermission();
|
||
|
||
// 포맷/디자인 드롭다운은 Cowork 탭에서만 표시
|
||
if (_activeTab == "Cowork")
|
||
{
|
||
BuildBottomBar();
|
||
if (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();
|
||
}
|
||
|
||
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 (BtnTemplateSelector != null)
|
||
{
|
||
BtnTemplateSelector.Padding = level == "simple"
|
||
? new Thickness(8, 4, 8, 4)
|
||
: new Thickness(9, 4, 9, 4);
|
||
}
|
||
|
||
if (_failedOnlyFilter)
|
||
{
|
||
_failedOnlyFilter = false;
|
||
RefreshConversationList();
|
||
}
|
||
}
|
||
|
||
private void SwitchToTabConversation()
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
|
||
lock (_convLock) _currentConversation = conv;
|
||
SyncTabConversationIdsFromSession();
|
||
SaveLastConversations();
|
||
MessagePanel.Children.Clear();
|
||
RenderMessages();
|
||
EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible;
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
UpdateFolderBar();
|
||
return;
|
||
}
|
||
|
||
// 기억된 대화가 없으면 새 대화
|
||
lock (_convLock)
|
||
{
|
||
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
|
||
?? new ChatConversation { Tab = _activeTab };
|
||
}
|
||
MessagePanel.Children.Clear();
|
||
EmptyState.Visibility = Visibility.Visible;
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
UpdateFolderBar();
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
}
|
||
|
||
// ─── 작업 폴더 ─────────────────────────────────────────────────────────
|
||
|
||
private readonly List<string> _attachedFiles = new();
|
||
private readonly List<ImageAttachment> _pendingImages = new();
|
||
|
||
private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu();
|
||
private void FolderMenuSearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
RenderFolderMenuItems(null);
|
||
}
|
||
|
||
private void ShowFolderMenu()
|
||
{
|
||
RenderFolderMenuItems(null);
|
||
FolderMenuPopup.IsOpen = true;
|
||
}
|
||
|
||
private 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 maxDisplay = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
|
||
var currentFolder = GetCurrentWorkFolder();
|
||
var conversationFolders = _storage.LoadAllMeta()
|
||
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
.Select(c => c.WorkFolder?.Trim() ?? "")
|
||
.Where(p => IsPathAllowed(p) && Directory.Exists(p));
|
||
var recentFolders = _settings.Settings.Llm.RecentWorkFolders
|
||
.Where(p => IsPathAllowed(p) && System.IO.Directory.Exists(p))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var workspaceFolders = recentFolders
|
||
.Concat(conversationFolders)
|
||
.Concat(string.IsNullOrWhiteSpace(currentFolder) ? Enumerable.Empty<string>() : new[] { currentFolder })
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Take(maxDisplay * 2)
|
||
.ToList();
|
||
|
||
var filteredRecent = recentFolders
|
||
.Take(maxDisplay)
|
||
.ToList();
|
||
|
||
void AddWorkspaceRows(IEnumerable<string> folders)
|
||
{
|
||
foreach (var folder in folders)
|
||
{
|
||
var isActive = folder.Equals(currentFolder, StringComparison.OrdinalIgnoreCase);
|
||
var displayName = System.IO.Path.GetFileName(folder);
|
||
if (string.IsNullOrEmpty(displayName)) displayName = folder;
|
||
var detailText = folder;
|
||
var itemBorder = CreatePopupMenuRow(
|
||
"\uE8B7",
|
||
displayName,
|
||
detailText,
|
||
isActive,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
FolderMenuPopup.IsOpen = false;
|
||
SetWorkFolder(folder);
|
||
});
|
||
itemBorder.ToolTip = folder;
|
||
var capturedPath = folder;
|
||
// 우클릭 → 컨텍스트 메뉴 (삭제, 폴더 열기)
|
||
itemBorder.MouseRightButtonUp += (_, re) =>
|
||
{
|
||
re.Handled = true;
|
||
ShowRecentFolderContextMenu(capturedPath);
|
||
};
|
||
FolderMenuItems.Children.Add(itemBorder);
|
||
}
|
||
}
|
||
|
||
if (filteredRecent.Count > 0)
|
||
{
|
||
AddWorkspaceRows(filteredRecent);
|
||
}
|
||
|
||
var remainingFolders = workspaceFolders
|
||
.Where(path => !filteredRecent.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||
.ToList();
|
||
|
||
if (remainingFolders.Count > 0)
|
||
{
|
||
FolderMenuItems.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(6, 6, 6, 10),
|
||
Opacity = 0.35,
|
||
});
|
||
AddWorkspaceRows(remainingFolders);
|
||
}
|
||
|
||
if (filteredRecent.Count == 0 && remainingFolders.Count == 0)
|
||
{
|
||
FolderMenuItems.Children.Add(new TextBlock
|
||
{
|
||
Text = "최근 작업 폴더가 없습니다.",
|
||
FontSize = 12,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 8, 10, 12),
|
||
});
|
||
}
|
||
|
||
FolderMenuItems.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(6, 8, 6, 10),
|
||
Opacity = 0.35,
|
||
});
|
||
|
||
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 static bool IsPathAllowed(string path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||
// C:\ 루트 차단
|
||
var normalized = path.TrimEnd('\\', '/');
|
||
if (normalized.Equals("C:", StringComparison.OrdinalIgnoreCase)) return false;
|
||
// "Document" 포함 경로 차단 (대소문자 무시)
|
||
if (path.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0) return false;
|
||
return true;
|
||
}
|
||
|
||
private void SetWorkFolder(string path)
|
||
{
|
||
// 루트 드라이브 전체를 작업공간으로 설정하는 것을 차단
|
||
// 예: "C:\", "D:\", "E:\" 등
|
||
var fullPath = System.IO.Path.GetFullPath(path);
|
||
var root = System.IO.Path.GetPathRoot(fullPath);
|
||
if (!string.IsNullOrEmpty(root) && fullPath.TrimEnd('\\', '/').Equals(root.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ShowToast($"드라이브 루트({root})는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", "\uE783", 3000);
|
||
return;
|
||
}
|
||
|
||
FolderPathLabel.Text = path;
|
||
FolderPathLabel.ToolTip = path;
|
||
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = path, _storage);
|
||
else
|
||
_currentConversation.WorkFolder = path;
|
||
}
|
||
}
|
||
|
||
// 최근 폴더 목록에 추가 (차단 경로 제외)
|
||
var recent = _settings.Settings.Llm.RecentWorkFolders;
|
||
recent.RemoveAll(p => !IsPathAllowed(p));
|
||
recent.Remove(path);
|
||
recent.Insert(0, path);
|
||
var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
|
||
if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent);
|
||
_settings.Settings.Llm.WorkFolder = path;
|
||
_settings.Save();
|
||
RefreshContextUsageVisual();
|
||
ScheduleGitBranchRefresh();
|
||
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
}
|
||
|
||
private string GetCurrentWorkFolder()
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder))
|
||
return _currentConversation.WorkFolder;
|
||
}
|
||
return _settings.Settings.Llm.WorkFolder;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로
|
||
/// 조건부 paths 스킬 활성화를 갱신합니다.
|
||
/// </summary>
|
||
private void UpdateConditionalSkillActivation(bool reset = false)
|
||
{
|
||
if (!_settings.Settings.Llm.EnableSkillSystem) return;
|
||
var cwd = GetCurrentWorkFolder();
|
||
if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd)) return;
|
||
if (reset) SkillService.ResetConditionalSkillActivation();
|
||
SkillService.ActivateConditionalSkillsForPaths(_attachedFiles, cwd);
|
||
}
|
||
|
||
private void BtnFolderClear_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
FolderPathLabel.Text = "폴더를 선택하세요";
|
||
FolderPathLabel.ToolTip = null;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = "", _storage);
|
||
else
|
||
_currentConversation.WorkFolder = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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>
|
||
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}"); }
|
||
}
|
||
|
||
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
|
||
|
||
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!;
|
||
_settings.Save();
|
||
_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)
|
||
{
|
||
AttachedFilesPanel.Visibility = Visibility.Collapsed;
|
||
return;
|
||
}
|
||
|
||
AttachedFilesPanel.Visibility = Visibility.Visible;
|
||
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
|
||
|
||
foreach (var file in _attachedFiles.ToList())
|
||
{
|
||
var fileName = System.IO.Path.GetFileName(file);
|
||
var capturedFile = file;
|
||
|
||
var chip = new Border
|
||
{
|
||
Background = hintBg,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(8, 4, 4, 4),
|
||
Margin = new Thickness(0, 0, 4, 4),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE8A5", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 10,
|
||
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = fileName, FontSize = 11, Foreground = secondaryBrush,
|
||
VerticalAlignment = VerticalAlignment.Center, MaxWidth = 150, TextTrimming = TextTrimming.CharacterEllipsis,
|
||
ToolTip = file,
|
||
});
|
||
var removeBtn = new Button
|
||
{
|
||
Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush },
|
||
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
|
||
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
|
||
};
|
||
removeBtn.Click += (_, _) => RemoveAttachedFile(capturedFile);
|
||
sp.Children.Add(removeBtn);
|
||
chip.Child = sp;
|
||
AttachedFilesPanel.Items.Add(chip);
|
||
}
|
||
}
|
||
|
||
/// <summary>첨부 파일 내용을 시스템 메시지로 변환합니다.</summary>
|
||
private string BuildFileContextPrompt()
|
||
{
|
||
if (_attachedFiles.Count == 0) return "";
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("\n[첨부 파일 컨텍스트]");
|
||
|
||
foreach (var file in _attachedFiles)
|
||
{
|
||
try
|
||
{
|
||
var ext = System.IO.Path.GetExtension(file).ToLowerInvariant();
|
||
var isBinary = ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz"
|
||
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp" or ".svg"
|
||
or ".pdf" or ".docx" or ".xlsx" or ".pptx" or ".doc" or ".xls" or ".ppt"
|
||
or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv" or ".wav" or ".flac"
|
||
or ".psd" or ".ai" or ".sketch" or ".fig"
|
||
or ".msi" or ".iso" or ".img" or ".bin" or ".dat" or ".db" or ".sqlite";
|
||
if (isBinary)
|
||
{
|
||
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (바이너리 파일, 내용 생략) ---");
|
||
continue;
|
||
}
|
||
|
||
var content = System.IO.File.ReadAllText(file);
|
||
// 최대 8000자로 제한
|
||
if (content.Length > 8000)
|
||
content = content[..8000] + "\n... (이하 생략)";
|
||
|
||
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} ---");
|
||
sb.AppendLine(content);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (읽기 실패: {ex.Message}) ---");
|
||
}
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
private void ResizeGrip_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e)
|
||
{
|
||
var newW = Width + e.HorizontalChange;
|
||
var newH = Height + e.VerticalChange;
|
||
if (newW >= MinWidth) Width = newW;
|
||
if (newH >= MinHeight) Height = newH;
|
||
}
|
||
|
||
// ─── 사이드바 토글 ───────────────────────────────────────────────────
|
||
|
||
private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_sidebarVisible = !_sidebarVisible;
|
||
_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)
|
||
{
|
||
AnimateSidebar(0, targetWidth, () => SidebarColumn.MinWidth = 168);
|
||
}
|
||
else
|
||
{
|
||
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)
|
||
{
|
||
AnimateSidebar(currentWidth > 0 ? currentWidth : _sidebarExpandedWidth, 0, () =>
|
||
{
|
||
SidebarPanel.Visibility = Visibility.Collapsed;
|
||
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;
|
||
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
|
||
EventHandler tickHandler = null!;
|
||
tickHandler = (_, _) =>
|
||
{
|
||
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
|
||
var t = Math.Min(elapsed / duration, 1.0);
|
||
t = 1 - (1 - t) * (1 - t);
|
||
SidebarColumn.Width = new GridLength(from + (to - from) * t);
|
||
if (elapsed >= duration)
|
||
{
|
||
timer.Stop();
|
||
timer.Tick -= tickHandler;
|
||
SidebarColumn.Width = new GridLength(to);
|
||
onComplete?.Invoke();
|
||
}
|
||
};
|
||
timer.Tick += tickHandler;
|
||
timer.Start();
|
||
}
|
||
|
||
// ─── 대화 목록 ────────────────────────────────────────────────────────
|
||
|
||
|
||
// ─── 검색 ────────────────────────────────────────────────────────────
|
||
|
||
private void SidebarSearchTrigger_MouseEnter(object sender, MouseEventArgs e)
|
||
{
|
||
if (SidebarSearchTrigger != null)
|
||
SidebarSearchTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
if (SidebarSearchShortcutHint != null)
|
||
SidebarSearchShortcutHint.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
private void SidebarSearchTrigger_MouseLeave(object sender, MouseEventArgs e)
|
||
{
|
||
if (SidebarSearchTrigger != null)
|
||
SidebarSearchTrigger.Background = Brushes.Transparent;
|
||
if (SidebarSearchShortcutHint != null)
|
||
SidebarSearchShortcutHint.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void SidebarSearchTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
OpenSidebarSearch();
|
||
}
|
||
|
||
private void SidebarNewChatTrigger_MouseEnter(object sender, MouseEventArgs e)
|
||
{
|
||
if (SidebarNewChatTrigger != null)
|
||
SidebarNewChatTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
if (SidebarNewChatShortcutHint != null)
|
||
SidebarNewChatShortcutHint.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
private void SidebarNewChatTrigger_MouseLeave(object sender, MouseEventArgs e)
|
||
{
|
||
if (SidebarNewChatTrigger != null)
|
||
SidebarNewChatTrigger.Background = Brushes.Transparent;
|
||
if (SidebarNewChatShortcutHint != null)
|
||
SidebarNewChatShortcutHint.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void SidebarNewChatTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
BtnNewChat_Click(sender, new RoutedEventArgs());
|
||
}
|
||
|
||
private void BtnSidebarSearchClose_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
CloseSidebarSearch(clearText: true);
|
||
}
|
||
|
||
private void OpenSidebarSearch()
|
||
{
|
||
if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null)
|
||
return;
|
||
|
||
SidebarSearchTrigger.Visibility = Visibility.Collapsed;
|
||
SidebarSearchEditor.Visibility = Visibility.Visible;
|
||
SidebarSearchEditor.Opacity = 0;
|
||
SidebarSearchEditorScale.ScaleX = 0.85;
|
||
|
||
var duration = TimeSpan.FromMilliseconds(160);
|
||
SidebarSearchEditor.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, duration));
|
||
SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(0.85, 1, duration)
|
||
{
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||
});
|
||
|
||
Dispatcher.BeginInvoke(new Action(() =>
|
||
{
|
||
SearchBox?.Focus();
|
||
SearchBox?.SelectAll();
|
||
}), DispatcherPriority.Background);
|
||
}
|
||
|
||
private void CloseSidebarSearch(bool clearText)
|
||
{
|
||
if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null)
|
||
return;
|
||
|
||
if (clearText && SearchBox != null && !string.IsNullOrEmpty(SearchBox.Text))
|
||
SearchBox.Text = "";
|
||
|
||
var duration = TimeSpan.FromMilliseconds(140);
|
||
var opacityAnim = new DoubleAnimation(1, 0, duration);
|
||
opacityAnim.Completed += (_, _) =>
|
||
{
|
||
SidebarSearchEditor.Visibility = Visibility.Collapsed;
|
||
SidebarSearchTrigger.Visibility = Visibility.Visible;
|
||
SidebarSearchTrigger.Background = Brushes.Transparent;
|
||
if (SidebarSearchShortcutHint != null)
|
||
SidebarSearchShortcutHint.Visibility = Visibility.Collapsed;
|
||
};
|
||
SidebarSearchEditor.BeginAnimation(OpacityProperty, opacityAnim);
|
||
SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(1, 0.85, duration)
|
||
{
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
|
||
});
|
||
}
|
||
|
||
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
_conversationSearchTimer.Stop();
|
||
_conversationSearchTimer.Start();
|
||
}
|
||
|
||
private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { }
|
||
|
||
private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_runningOnlyFilter = false;
|
||
UpdateConversationRunningFilterUi();
|
||
PersistConversationListPreferences();
|
||
RefreshConversationList();
|
||
}
|
||
|
||
private void BtnQuickRunningFilter_Click(object sender, RoutedEventArgs e)
|
||
=> BtnRunningOnlyFilter_Click(sender, e);
|
||
|
||
private void BtnQuickHotSort_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_sortConversationsByRecent = false;
|
||
UpdateConversationSortUi();
|
||
PersistConversationListPreferences();
|
||
RefreshConversationList();
|
||
}
|
||
|
||
private void BtnConversationSort_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_sortConversationsByRecent = !_sortConversationsByRecent;
|
||
UpdateConversationSortUi();
|
||
PersistConversationListPreferences();
|
||
RefreshConversationList();
|
||
}
|
||
|
||
private void UpdateConversationFailureFilterUi()
|
||
{
|
||
_failedOnlyFilter = false;
|
||
UpdateSidebarModeMenu();
|
||
}
|
||
|
||
private void UpdateConversationRunningFilterUi()
|
||
{
|
||
if (BtnRunningOnlyFilter == null || RunningOnlyFilterLabel == null)
|
||
return;
|
||
|
||
BtnRunningOnlyFilter.Background = _runningOnlyFilter
|
||
? BrushFromHex("#DBEAFE")
|
||
: Brushes.Transparent;
|
||
BtnRunningOnlyFilter.BorderBrush = _runningOnlyFilter
|
||
? BrushFromHex("#93C5FD")
|
||
: Brushes.Transparent;
|
||
BtnRunningOnlyFilter.BorderThickness = _runningOnlyFilter
|
||
? new Thickness(1)
|
||
: new Thickness(0);
|
||
RunningOnlyFilterLabel.Foreground = _runningOnlyFilter
|
||
? BrushFromHex("#1D4ED8")
|
||
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||
RunningOnlyFilterLabel.Text = _runningConversationCount > 0
|
||
? $"진행 {_runningConversationCount}"
|
||
: "진행";
|
||
BtnRunningOnlyFilter.ToolTip = _runningOnlyFilter
|
||
? "실행 중인 대화만 표시 중"
|
||
: _runningConversationCount > 0
|
||
? $"현재 실행 중인 대화 {_runningConversationCount}개 보기"
|
||
: "현재 실행 중인 대화만 보기";
|
||
UpdateSidebarModeMenu();
|
||
}
|
||
|
||
private void UpdateConversationSortUi()
|
||
{
|
||
if (BtnConversationSort == null || ConversationSortLabel == null)
|
||
return;
|
||
|
||
ConversationSortLabel.Text = _sortConversationsByRecent ? "최근" : "활동";
|
||
BtnConversationSort.Background = _sortConversationsByRecent
|
||
? BrushFromHex("#EFF6FF")
|
||
: BrushFromHex("#F8FAFC");
|
||
BtnConversationSort.BorderBrush = _sortConversationsByRecent
|
||
? BrushFromHex("#93C5FD")
|
||
: BrushFromHex("#E2E8F0");
|
||
BtnConversationSort.BorderThickness = new Thickness(1);
|
||
ConversationSortLabel.Foreground = _sortConversationsByRecent
|
||
? BrushFromHex("#1D4ED8")
|
||
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||
BtnConversationSort.ToolTip = _sortConversationsByRecent
|
||
? "최신 업데이트 순으로 보는 중"
|
||
: "에이전트 활동량과 실패를 우선으로 보는 중";
|
||
}
|
||
|
||
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;
|
||
ChatTitle.Text = conversation?.Title ?? "";
|
||
}
|
||
|
||
UpdateSelectedPresetGuide(conversation);
|
||
}
|
||
|
||
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
|
||
|
||
private const int TimelineRenderPageSize = 180;
|
||
private string? _lastRenderedConversationId;
|
||
private int _timelineRenderLimit = TimelineRenderPageSize;
|
||
|
||
private void RenderMessages(bool preserveViewport = false)
|
||
{
|
||
var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0;
|
||
var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0;
|
||
|
||
MessagePanel.Children.Clear();
|
||
_runBannerAnchors.Clear();
|
||
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
||
|
||
var visibleMessages = GetVisibleTimelineMessages(conv);
|
||
var visibleEvents = GetVisibleTimelineEvents(conv);
|
||
|
||
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
||
{
|
||
EmptyState.Visibility = Visibility.Visible;
|
||
return;
|
||
}
|
||
|
||
if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_lastRenderedConversationId = conv.Id;
|
||
_timelineRenderLimit = TimelineRenderPageSize;
|
||
}
|
||
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
|
||
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
|
||
var hiddenCount = Math.Max(0, orderedTimeline.Count - _timelineRenderLimit);
|
||
if (hiddenCount > 0)
|
||
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
|
||
|
||
foreach (var item in orderedTimeline.Skip(hiddenCount))
|
||
item.Render();
|
||
|
||
if (!preserveViewport)
|
||
{
|
||
_ = Dispatcher.InvokeAsync(() =>
|
||
{
|
||
if (MessageScroll != null)
|
||
MessageScroll.ScrollToEnd();
|
||
}, DispatcherPriority.Background);
|
||
return;
|
||
}
|
||
|
||
_ = Dispatcher.InvokeAsync(() =>
|
||
{
|
||
if (MessageScroll == null)
|
||
return;
|
||
|
||
var newScrollableHeight = MessageScroll.ScrollableHeight;
|
||
var delta = newScrollableHeight - previousScrollableHeight;
|
||
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
|
||
MessageScroll.ScrollToVerticalOffset(targetOffset);
|
||
}, DispatcherPriority.Background);
|
||
}
|
||
|
||
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
|
||
|
||
/// <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()
|
||
{
|
||
if (_aiIconPulseStopped || _activeAiIcon == null) return;
|
||
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
|
||
_activeAiIcon.Opacity = 1.0;
|
||
_activeAiIcon = null;
|
||
_aiIconPulseStopped = true;
|
||
}
|
||
|
||
private void CursorTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
_cursorVisible = !_cursorVisible;
|
||
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
|
||
if (_activeStreamText != null && _displayedLength > 0)
|
||
{
|
||
var displayed = _cachedStreamContent.Length > 0
|
||
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
|
||
: "";
|
||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||
}
|
||
}
|
||
|
||
private void ElapsedTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||
var sec = (int)elapsed.TotalSeconds;
|
||
if (_elapsedLabel != null)
|
||
_elapsedLabel.Text = $"{sec}s";
|
||
|
||
// 하단 상태바 시간 갱신
|
||
if (StatusElapsed != null)
|
||
{
|
||
StatusElapsed.Text = $"{sec}초";
|
||
StatusElapsed.Visibility = Visibility.Visible;
|
||
}
|
||
}
|
||
|
||
private void TypingTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
|
||
|
||
var targetLen = _cachedStreamContent.Length;
|
||
if (_displayedLength >= targetLen) return;
|
||
|
||
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
|
||
var pending = targetLen - _displayedLength;
|
||
int step;
|
||
if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
|
||
else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
|
||
else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
|
||
|
||
_displayedLength += step;
|
||
|
||
var displayed = _cachedStreamContent[.._displayedLength];
|
||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||
|
||
// 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
|
||
if (!_userScrolled)
|
||
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
|
||
}
|
||
|
||
// ─── 전송 ──────────────────────────────────────────────────────────────
|
||
|
||
public void SendInitialMessage(string message)
|
||
{
|
||
StartNewConversation();
|
||
InputBox.Text = message;
|
||
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
|
||
}
|
||
|
||
private void StartNewConversation()
|
||
{
|
||
// 현재 대화가 있으면 저장 후 새 대화 시작
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.SaveCurrentConversation(_storage, _activeTab);
|
||
session.ClearCurrentConversation(_activeTab);
|
||
_currentConversation = session.CreateFreshConversation(_activeTab, _settings);
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
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();
|
||
MessagePanel.Children.Clear();
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
LoadConversationSettings();
|
||
LoadCompactionMetricsFromConversation();
|
||
SyncAppStateWithCurrentConversation();
|
||
UpdateChatTitle();
|
||
UpdateFolderBar();
|
||
UpdateSelectedPresetGuide();
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
RenderMessages();
|
||
RefreshConversationList();
|
||
RefreshDraftQueueUi();
|
||
if (_activeTab == "Cowork") BuildBottomBar();
|
||
}
|
||
|
||
/// <summary>설정에 저장된 탭별 마지막 대화 ID를 복원하고, 현재 탭의 대화를 로드합니다.</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;
|
||
|
||
SyncTabConversationIdsFromSession();
|
||
var hasRememberedConversation = !string.IsNullOrEmpty(session.GetConversationId(_activeTab));
|
||
if (!hasRememberedConversation)
|
||
{
|
||
var latestMeta = _storage.LoadAllMeta()
|
||
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
.OrderByDescending(c => c.Pinned)
|
||
.ThenByDescending(c => c.UpdatedAt)
|
||
.FirstOrDefault();
|
||
if (latestMeta != null)
|
||
{
|
||
session.RememberConversation(_activeTab, latestMeta.Id);
|
||
SyncTabConversationIdsFromSession();
|
||
hasRememberedConversation = true;
|
||
}
|
||
}
|
||
|
||
if (hasRememberedConversation)
|
||
{
|
||
var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
|
||
lock (_convLock) _currentConversation = conv;
|
||
MessagePanel.Children.Clear();
|
||
RenderMessages();
|
||
EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible;
|
||
UpdateChatTitle();
|
||
UpdateFolderBar();
|
||
LoadConversationSettings();
|
||
SyncAppStateWithCurrentConversation();
|
||
RefreshConversationList();
|
||
RefreshDraftQueueUi();
|
||
SaveLastConversations();
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
try { _settings.Save(); } catch { }
|
||
}
|
||
|
||
private void BtnSend_Click(object sender, RoutedEventArgs e)
|
||
=> QueueComposerDraft(priority: "now", explicitKind: null, startImmediatelyWhenIdle: true);
|
||
|
||
private void InputBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||
{
|
||
if (TryHandleSlashNavigationKey(e))
|
||
return;
|
||
|
||
// Ctrl+V: 클립보드 이미지 붙여넣기
|
||
if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
|
||
{
|
||
if (TryPasteClipboardImage())
|
||
{
|
||
e.Handled = true;
|
||
return;
|
||
}
|
||
// 이미지가 아니면 기본 텍스트 붙여넣기로 위임
|
||
}
|
||
|
||
if (e.Key == Key.Enter)
|
||
{
|
||
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
|
||
{
|
||
// Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임)
|
||
return;
|
||
}
|
||
|
||
// 슬래시 팝업이 열려 있으면 선택된 항목 실행
|
||
if (SlashPopup.IsOpen && _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 = new FontFamily("Segoe MDL2 Assets"), FontSize = 16,
|
||
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(2, 0, 4, 0),
|
||
});
|
||
}
|
||
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = attachment.FileName, FontSize = 10, Foreground = secondaryBrush,
|
||
VerticalAlignment = VerticalAlignment.Center, MaxWidth = 100,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
});
|
||
|
||
var capturedAttachment = attachment;
|
||
var capturedChip = chip;
|
||
var removeBtn = new Button
|
||
{
|
||
Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush },
|
||
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
|
||
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
|
||
};
|
||
removeBtn.Click += (_, _) =>
|
||
{
|
||
_pendingImages.Remove(capturedAttachment);
|
||
AttachedFilesPanel.Items.Remove(capturedChip);
|
||
if (_pendingImages.Count == 0 && _attachedFiles.Count == 0)
|
||
AttachedFilesPanel.Visibility = Visibility.Collapsed;
|
||
};
|
||
sp.Children.Add(removeBtn);
|
||
chip.Child = sp;
|
||
|
||
AttachedFilesPanel.Items.Add(chip);
|
||
AttachedFilesPanel.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
// ─── 슬래시 명령어 ────────────────────────────────────────────────────
|
||
|
||
// ── 슬래시 명령어 팝업 상태 ──
|
||
private 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;
|
||
|
||
// 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제
|
||
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 matches = SlashCommandCatalog.MatchBuiltinCommands(text, isDev);
|
||
|
||
// 스킬 슬래시 명령어 매칭 (탭별 필터)
|
||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||
{
|
||
var skillMatches = SkillService.MatchSlashCommand(text)
|
||
.Where(s => s.IsVisibleInTab(_activeTab))
|
||
.Select(s => (Cmd: "/" + s.Name,
|
||
Label: BuildSlashSkillLabel(s),
|
||
IsSkill: true, Available: s.IsAvailable));
|
||
foreach (var sm in skillMatches)
|
||
matches.Add((sm.Cmd, sm.Label, sm.IsSkill));
|
||
}
|
||
|
||
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;
|
||
|
||
_inputUiRefreshTimer.Stop();
|
||
_inputUiRefreshTimer.Start();
|
||
}
|
||
|
||
private static string BuildSlashSkillLabel(SkillDefinition skill)
|
||
{
|
||
var badge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase)
|
||
? "[FORK]"
|
||
: "[DIRECT]";
|
||
var baseLabel = $"{badge} {skill.Label}";
|
||
return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}";
|
||
}
|
||
|
||
private bool GetSlashSectionExpanded(string sectionKey, bool defaultValue = true)
|
||
{
|
||
var map = _settings.Settings.Llm.SlashPaletteSections;
|
||
if (map != null && map.TryGetValue(sectionKey, out var expanded))
|
||
return expanded;
|
||
return defaultValue;
|
||
}
|
||
|
||
private void SetSlashSectionExpanded(string sectionKey, bool expanded)
|
||
{
|
||
var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||
map[sectionKey] = expanded;
|
||
try { _settings.Save(); } catch { }
|
||
}
|
||
|
||
private bool AreAllSlashSectionsExpanded()
|
||
{
|
||
var commandsExpanded = GetSlashSectionExpanded("slash_commands", true);
|
||
var skillsExpanded = GetSlashSectionExpanded("slash_skills", true);
|
||
return commandsExpanded && skillsExpanded;
|
||
}
|
||
|
||
private void BtnSlashToggleGroups_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var expandAll = !AreAllSlashSectionsExpanded();
|
||
SetSlashSectionExpanded("slash_commands", expandAll);
|
||
SetSlashSectionExpanded("slash_skills", expandAll);
|
||
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
|
||
RenderSlashPage();
|
||
}
|
||
|
||
private void BtnSlashReset_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.FavoriteSlashCommands.Clear();
|
||
_settings.Settings.Llm.RecentSlashCommands.Clear();
|
||
try { _settings.Save(); } catch { }
|
||
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
|
||
RenderSlashPage();
|
||
}
|
||
|
||
private Dictionary<string, int> BuildRecentSlashRankMap()
|
||
{
|
||
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var recent = _settings.Settings.Llm.RecentSlashCommands;
|
||
for (var i = 0; i < recent.Count; i++)
|
||
{
|
||
var key = recent[i]?.Trim();
|
||
if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key))
|
||
continue;
|
||
map[key] = i; // index 낮을수록 최근
|
||
}
|
||
return map;
|
||
}
|
||
|
||
private Dictionary<string, int> BuildFavoriteSlashRankMap()
|
||
{
|
||
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
var fav = _settings.Settings.Llm.FavoriteSlashCommands;
|
||
var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30);
|
||
for (var i = 0; i < fav.Count; i++)
|
||
{
|
||
if (i >= maxFavorites)
|
||
break;
|
||
var key = fav[i]?.Trim();
|
||
if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key))
|
||
continue;
|
||
map[key] = i; // index 낮을수록 우선
|
||
}
|
||
return map;
|
||
}
|
||
|
||
private void RegisterRecentSlashCommand(string cmd)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(cmd))
|
||
return;
|
||
var recent = _settings.Settings.Llm.RecentSlashCommands;
|
||
var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentSlashCommands, 5, 50);
|
||
recent.RemoveAll(x => string.Equals(x, cmd, StringComparison.OrdinalIgnoreCase));
|
||
recent.Insert(0, cmd);
|
||
if (recent.Count > maxRecent)
|
||
recent.RemoveRange(maxRecent, recent.Count - maxRecent);
|
||
try { _settings.Save(); } catch { }
|
||
}
|
||
|
||
private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches)
|
||
{
|
||
var commandExpanded = GetSlashSectionExpanded("slash_commands", true);
|
||
var skillExpanded = GetSlashSectionExpanded("slash_skills", true);
|
||
for (var i = 0; i < matches.Count; i++)
|
||
{
|
||
var visible = matches[i].IsSkill ? skillExpanded : commandExpanded;
|
||
if (visible)
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
private bool IsSlashItemVisibleByIndex(int index)
|
||
{
|
||
if (index < 0 || index >= _slashPalette.Matches.Count)
|
||
return false;
|
||
var item = _slashPalette.Matches[index];
|
||
return item.IsSkill
|
||
? GetSlashSectionExpanded("slash_skills", true)
|
||
: GetSlashSectionExpanded("slash_commands", true);
|
||
}
|
||
|
||
private IReadOnlyList<int> GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder;
|
||
|
||
/// <summary>현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다.</summary>
|
||
private void RenderSlashPage()
|
||
{
|
||
SlashItems.Items.Clear();
|
||
_slashVisibleItemByAbsoluteIndex.Clear();
|
||
_slashVisibleAbsoluteOrder.Clear();
|
||
var total = _slashPalette.Matches.Count;
|
||
var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill);
|
||
var totalCommands = total - totalSkills;
|
||
var favoriteRank = BuildFavoriteSlashRankMap();
|
||
var recentRank = BuildRecentSlashRankMap();
|
||
var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant();
|
||
|
||
SlashPopupTitle.Text = "명령 및 스킬";
|
||
SlashPopupHint.Text = expressionLevel switch
|
||
{
|
||
"simple" => $"명령 {totalCommands} · 스킬 {totalSkills}",
|
||
"rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행 · 방향키 이동",
|
||
_ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행",
|
||
};
|
||
|
||
var commandsExpanded = GetSlashSectionExpanded("slash_commands", true);
|
||
var skillsExpanded = GetSlashSectionExpanded("slash_skills", true);
|
||
if (SlashToggleGroupsLabel != null)
|
||
SlashToggleGroupsLabel.Text = (commandsExpanded && skillsExpanded) ? "전체 접기" : "전체 펼치기";
|
||
|
||
Border CreateSlashSectionHeader(string key, string title, int count, bool expanded)
|
||
{
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
|
||
var header = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = Brushes.Transparent,
|
||
BorderThickness = new Thickness(0),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
Margin = new Thickness(0, 4, 0, 2),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
grid.Children.Add(new TextBlock
|
||
{
|
||
Text = expanded ? "\uE70D" : "\uE76C",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
var titleText = new TextBlock
|
||
{
|
||
Text = $"{title} {count}",
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(titleText, 1);
|
||
grid.Children.Add(titleText);
|
||
var metaText = new TextBlock
|
||
{
|
||
Text = expanded ? "접기" : "펼치기",
|
||
FontSize = 9.5,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(metaText, 2);
|
||
grid.Children.Add(metaText);
|
||
|
||
header.Child = grid;
|
||
header.MouseEnter += (_, _) => header.Background = hoverBrushItem;
|
||
header.MouseLeave += (_, _) => header.Background = Brushes.Transparent;
|
||
header.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
SetSlashSectionExpanded(key, !expanded);
|
||
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
|
||
RenderSlashPage();
|
||
};
|
||
return header;
|
||
}
|
||
|
||
void AddSlashItem(int i)
|
||
{
|
||
var (cmd, label, isSkill) = _slashPalette.Matches[i];
|
||
var isFavorite = favoriteRank.ContainsKey(cmd);
|
||
var isRecent = recentRank.ContainsKey(cmd);
|
||
var capturedCmd = cmd;
|
||
var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null;
|
||
var skillAvailable = skillDef?.IsAvailable ?? true;
|
||
|
||
var absoluteIndex = i;
|
||
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
|
||
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(8, 9, 8, 9),
|
||
Margin = new Thickness(0, 0, 0, 0),
|
||
Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow,
|
||
Opacity = skillAvailable ? 1.0 : 0.5,
|
||
};
|
||
var itemGrid = new Grid();
|
||
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var leftStack = new StackPanel();
|
||
var titleRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||
titleRow.Children.Add(new TextBlock
|
||
{
|
||
Text = isSkill ? "\uE768" : "\uE9CE",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = skillAvailable ? accent : secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
titleRow.Children.Add(new TextBlock
|
||
{
|
||
Text = cmd,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = skillAvailable ? primaryText : secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
if (isFavorite)
|
||
{
|
||
titleRow.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex("#FEF3C7"),
|
||
BorderBrush = BrushFromHex("#F59E0B"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(5, 0, 5, 0),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = "핀",
|
||
FontSize = 9.5,
|
||
Foreground = BrushFromHex("#92400E"),
|
||
}
|
||
});
|
||
}
|
||
if (isRecent)
|
||
{
|
||
titleRow.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex("#EEF2FF"),
|
||
BorderBrush = BrushFromHex("#C7D2FE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(5, 0, 5, 0),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = "최근",
|
||
FontSize = 9.5,
|
||
Foreground = BrushFromHex("#3730A3"),
|
||
}
|
||
});
|
||
}
|
||
leftStack.Children.Add(titleRow);
|
||
leftStack.Children.Add(new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(20, 2, 0, 0),
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
});
|
||
Grid.SetColumn(leftStack, 0);
|
||
itemGrid.Children.Add(leftStack);
|
||
|
||
var pinToggle = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(6, 4, 6, 4),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Cursor = Cursors.Hand,
|
||
Child = new TextBlock
|
||
{
|
||
Text = isFavorite ? "\uE77A" : "\uE718",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = isFavorite ? BrushFromHex("#B45309") : secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
ToolTip = isFavorite ? "핀 해제" : "핀 고정",
|
||
};
|
||
pinToggle.MouseEnter += (_, _) => pinToggle.Background = hoverBrushItem;
|
||
pinToggle.MouseLeave += (_, _) => pinToggle.Background = Brushes.Transparent;
|
||
pinToggle.MouseLeftButtonDown += (s, e) =>
|
||
{
|
||
e.Handled = true;
|
||
ToggleSlashFavorite(capturedCmd);
|
||
};
|
||
Grid.SetColumn(pinToggle, 1);
|
||
itemGrid.Children.Add(pinToggle);
|
||
|
||
item.Child = itemGrid;
|
||
|
||
if (skillAvailable)
|
||
{
|
||
item.MouseEnter += (_, _) =>
|
||
{
|
||
_slashPalette.SelectedIndex = absoluteIndex;
|
||
UpdateSlashSelectionVisualState();
|
||
};
|
||
item.MouseLeave += (_, _) => UpdateSlashSelectionVisualState();
|
||
item.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
_slashPalette.SelectedIndex = absoluteIndex;
|
||
ExecuteSlashSelectedItem();
|
||
};
|
||
}
|
||
|
||
SlashItems.Items.Add(item);
|
||
_slashVisibleItemByAbsoluteIndex[absoluteIndex] = item;
|
||
_slashVisibleAbsoluteOrder.Add(absoluteIndex);
|
||
}
|
||
|
||
SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded));
|
||
if (commandsExpanded)
|
||
{
|
||
var commandIndices = Enumerable.Range(0, total)
|
||
.Where(i => !_slashPalette.Matches[i].IsSkill)
|
||
.OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue)
|
||
.ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue)
|
||
.ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase);
|
||
foreach (var i in commandIndices)
|
||
{
|
||
AddSlashItem(i);
|
||
}
|
||
}
|
||
|
||
SlashItems.Items.Add(CreateSlashSectionHeader("slash_skills", "스킬", totalSkills, skillsExpanded));
|
||
if (skillsExpanded)
|
||
{
|
||
var skillIndices = Enumerable.Range(0, total)
|
||
.Where(i => _slashPalette.Matches[i].IsSkill)
|
||
.OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue)
|
||
.ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue)
|
||
.ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase);
|
||
foreach (var i in skillIndices)
|
||
{
|
||
AddSlashItem(i);
|
||
}
|
||
}
|
||
|
||
var visibleCommandCount = commandsExpanded ? totalCommands : 0;
|
||
var visibleSkillCount = skillsExpanded ? totalSkills : 0;
|
||
if (visibleCommandCount + visibleSkillCount == 0)
|
||
{
|
||
SlashPopupFooter.Text = "모든 그룹이 접혀 있습니다 · 우측 상단에서 전체 펼치기";
|
||
}
|
||
else
|
||
{
|
||
SlashPopupFooter.Text = $"Enter 실행 · ↑↓/PgUp/PgDn 이동 · Home/End · Esc 닫기 · 표시 {visibleCommandCount + visibleSkillCount}/{total}";
|
||
}
|
||
|
||
UpdateSlashSelectionVisualState();
|
||
EnsureSlashSelectionVisible();
|
||
}
|
||
|
||
/// <summary>슬래시 팝업 마우스 휠 스크롤 처리.</summary>
|
||
private void SlashPopup_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||
{
|
||
e.Handled = true;
|
||
SlashPopup_ScrollByDelta(e.Delta);
|
||
}
|
||
|
||
private void MoveSlashSelection(int direction)
|
||
{
|
||
var visibleOrder = GetVisibleSlashOrderedIndices();
|
||
if (visibleOrder.Count == 0)
|
||
return;
|
||
|
||
var currentPosition = -1;
|
||
for (var i = 0; i < visibleOrder.Count; i++)
|
||
{
|
||
if (visibleOrder[i] != _slashPalette.SelectedIndex)
|
||
continue;
|
||
currentPosition = i;
|
||
break;
|
||
}
|
||
if (currentPosition < 0)
|
||
{
|
||
_slashPalette.SelectedIndex = visibleOrder[0];
|
||
return;
|
||
}
|
||
|
||
if (direction < 0 && currentPosition > 0)
|
||
_slashPalette.SelectedIndex = visibleOrder[currentPosition - 1];
|
||
else if (direction > 0 && currentPosition < visibleOrder.Count - 1)
|
||
_slashPalette.SelectedIndex = visibleOrder[currentPosition + 1];
|
||
}
|
||
|
||
private int? FindSlashIndexClosestToViewportTop()
|
||
{
|
||
if (SlashScrollViewer == null || _slashVisibleAbsoluteOrder.Count == 0)
|
||
return null;
|
||
|
||
var bestIndex = -1;
|
||
var bestDistance = double.MaxValue;
|
||
foreach (var absoluteIndex in _slashVisibleAbsoluteOrder)
|
||
{
|
||
if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(absoluteIndex, out var item))
|
||
continue;
|
||
|
||
try
|
||
{
|
||
var bounds = item.TransformToAncestor(SlashScrollViewer)
|
||
.TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight));
|
||
|
||
// 뷰포트 상단에 가장 가까운 가시 항목을 선택 기준으로 사용.
|
||
var distance = Math.Abs(bounds.Top);
|
||
if (distance < bestDistance && bounds.Bottom >= 0)
|
||
{
|
||
bestDistance = distance;
|
||
bestIndex = absoluteIndex;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 레이아웃 갱신 중 transform 예외는 무시.
|
||
}
|
||
}
|
||
|
||
return bestIndex >= 0 ? bestIndex : null;
|
||
}
|
||
|
||
/// <summary>슬래시 팝업을 Delta 방향으로 스크롤합니다.</summary>
|
||
private void SlashPopup_ScrollByDelta(int delta)
|
||
{
|
||
if (_slashPalette.Matches.Count == 0)
|
||
return;
|
||
|
||
if (GetVisibleSlashOrderedIndices().Count == 0)
|
||
{
|
||
if (SlashScrollViewer != null)
|
||
SlashScrollViewer.ScrollToVerticalOffset(Math.Max(0, SlashScrollViewer.VerticalOffset - delta / 3.0));
|
||
return;
|
||
}
|
||
|
||
// 터치패드/마우스 환경 모두에서 체감이 유사하도록 스크롤뷰도 함께 이동.
|
||
if (SlashScrollViewer != null)
|
||
{
|
||
var target = Math.Max(0, Math.Min(
|
||
SlashScrollViewer.ScrollableHeight,
|
||
SlashScrollViewer.VerticalOffset - (delta / 3.0)));
|
||
SlashScrollViewer.ScrollToVerticalOffset(target);
|
||
}
|
||
|
||
var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(delta) / 120.0));
|
||
var direction = delta > 0 ? -1 : 1;
|
||
for (var i = 0; i < steps; i++)
|
||
MoveSlashSelection(direction);
|
||
|
||
var viewportTopIndex = FindSlashIndexClosestToViewportTop();
|
||
if (viewportTopIndex.HasValue)
|
||
_slashPalette.SelectedIndex = viewportTopIndex.Value;
|
||
|
||
UpdateSlashSelectionVisualState();
|
||
EnsureSlashSelectionVisible();
|
||
}
|
||
|
||
/// <summary>키보드로 선택된 슬래시 아이템을 실행합니다.</summary>
|
||
private void ExecuteSlashSelectedItem()
|
||
{
|
||
var absoluteIdx = _slashPalette.SelectedIndex;
|
||
if (absoluteIdx < 0 || absoluteIdx >= _slashPalette.Matches.Count) return;
|
||
|
||
var (cmd, _, isSkill) = _slashPalette.Matches[absoluteIdx];
|
||
var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null;
|
||
var skillAvailable = skillDef?.IsAvailable ?? true;
|
||
if (!skillAvailable) return;
|
||
RegisterRecentSlashCommand(cmd);
|
||
|
||
SlashPopup.IsOpen = false;
|
||
_slashPalette.SelectedIndex = -1;
|
||
|
||
if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
InputBox.Text = "";
|
||
ShowSlashHelpWindow();
|
||
return;
|
||
}
|
||
ShowSlashChip(cmd);
|
||
InputBox.Focus();
|
||
}
|
||
|
||
private void EnsureSlashSelectionVisible()
|
||
{
|
||
if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0)
|
||
return;
|
||
|
||
if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(_slashPalette.SelectedIndex, out var item))
|
||
return;
|
||
|
||
if (!IsVisualDescendantOf(item, SlashScrollViewer))
|
||
return;
|
||
|
||
Rect bounds;
|
||
try
|
||
{
|
||
bounds = item.TransformToAncestor(SlashScrollViewer)
|
||
.TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight));
|
||
}
|
||
catch
|
||
{
|
||
// 렌더 트리 갱신 중에는 transform이 실패할 수 있어 조용히 무시.
|
||
return;
|
||
}
|
||
|
||
if (bounds.Top < 0)
|
||
SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + bounds.Top - 8);
|
||
else if (bounds.Bottom > SlashScrollViewer.ViewportHeight)
|
||
SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + (bounds.Bottom - SlashScrollViewer.ViewportHeight) + 8);
|
||
}
|
||
|
||
private static bool IsVisualDescendantOf(DependencyObject? child, DependencyObject? parent)
|
||
{
|
||
if (child == null || parent == null)
|
||
return false;
|
||
|
||
var current = child;
|
||
while (current != null)
|
||
{
|
||
if (ReferenceEquals(current, parent))
|
||
return true;
|
||
current = VisualTreeHelper.GetParent(current);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void UpdateSlashSelectionVisualState()
|
||
{
|
||
if (_slashVisibleItemByAbsoluteIndex.Count == 0)
|
||
return;
|
||
|
||
var selectedIndex = _slashPalette.SelectedIndex;
|
||
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
|
||
foreach (var (absoluteIndex, element) in _slashVisibleItemByAbsoluteIndex)
|
||
{
|
||
if (element is not Border border)
|
||
continue;
|
||
|
||
var selected = absoluteIndex == selectedIndex;
|
||
border.Background = selected ? hoverBrushItem : Brushes.Transparent;
|
||
border.BorderBrush = selected ? accent : borderBrush;
|
||
border.BorderThickness = selected ? new Thickness(2, 0, 0, 1) : new Thickness(0, 0, 0, 1);
|
||
}
|
||
}
|
||
|
||
/// <summary>슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다.</summary>
|
||
private void ToggleSlashFavorite(string cmd)
|
||
{
|
||
var favs = _settings.Settings.Llm.FavoriteSlashCommands;
|
||
var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30);
|
||
var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase));
|
||
if (existing != null)
|
||
favs.Remove(existing);
|
||
else
|
||
{
|
||
favs.Add(cmd);
|
||
if (favs.Count > maxFavorites)
|
||
favs.RemoveRange(maxFavorites, favs.Count - maxFavorites);
|
||
}
|
||
|
||
_settings.Save();
|
||
|
||
if (SlashPopup.IsOpen)
|
||
{
|
||
RenderSlashPage();
|
||
return;
|
||
}
|
||
|
||
// 팝업이 닫힌 경우에만 TextChanged 트리거
|
||
var currentText = InputBox.Text;
|
||
InputBox.TextChanged -= InputBox_TextChanged;
|
||
InputBox.Text = "";
|
||
InputBox.TextChanged += InputBox_TextChanged;
|
||
InputBox.Text = currentText;
|
||
}
|
||
|
||
/// <summary>슬래시 명령어 칩을 표시하고 InputBox를 비웁니다.</summary>
|
||
private void ShowSlashChip(string cmd)
|
||
{
|
||
_slashPalette.ActiveCommand = cmd;
|
||
SlashChipText.Text = cmd;
|
||
SlashCommandChip.Visibility = Visibility.Visible;
|
||
|
||
// 칩 너비 측정 후 InputBox 왼쪽 여백 조정
|
||
SlashCommandChip.UpdateLayout();
|
||
var chipRight = SlashCommandChip.Margin.Left + SlashCommandChip.ActualWidth + 6;
|
||
InputBox.Padding = new Thickness(chipRight, 10, 14, 10);
|
||
InputBox.Text = "";
|
||
}
|
||
|
||
/// <summary>슬래시 명령어 칩을 숨깁니다.</summary>
|
||
/// <param name="restoreText">true이면 InputBox에 명령어 텍스트를 복원합니다.</param>
|
||
private void HideSlashChip(bool restoreText = false)
|
||
{
|
||
if (_slashPalette.ActiveCommand == null) return;
|
||
var prev = _slashPalette.ActiveCommand;
|
||
_slashPalette.ActiveCommand = null;
|
||
SlashCommandChip.Visibility = Visibility.Collapsed;
|
||
InputBox.Padding = new Thickness(14, 10, 14, 10);
|
||
if (restoreText)
|
||
{
|
||
InputBox.Text = prev + " ";
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
}
|
||
}
|
||
|
||
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
|
||
private (string? slashSystem, string userText) ParseSlashCommand(string input)
|
||
{
|
||
var trimmed = input.TrimStart();
|
||
if (trimmed.StartsWith("/"))
|
||
{
|
||
var firstSpace = trimmed.IndexOf(' ');
|
||
var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim();
|
||
if (SlashCommandCatalog.TryGetEntry(commandToken, out var 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);
|
||
}
|
||
}
|
||
|
||
// 스킬 명령어 매칭
|
||
var matchedSkill = SkillService.MatchSlashInvocation(input);
|
||
if (matchedSkill != null)
|
||
{
|
||
var slashCmd = "/" + matchedSkill.Name;
|
||
var rest = input[slashCmd.Length..].Trim();
|
||
var runtimePolicy = SkillService.BuildRuntimeDirective(matchedSkill);
|
||
var mergedPrompt = string.IsNullOrWhiteSpace(runtimePolicy)
|
||
? matchedSkill.SystemPrompt
|
||
: $"{matchedSkill.SystemPrompt}\n\n{runtimePolicy}";
|
||
return (mergedPrompt, string.IsNullOrEmpty(rest) ? matchedSkill.Label : rest);
|
||
}
|
||
|
||
return (null, input);
|
||
}
|
||
|
||
// ─── 드래그 앤 드롭 AI 액션 팝업 ─────────────────────────────────────
|
||
|
||
private static readonly Dictionary<string, List<(string Label, string Icon, string Prompt)>> DropActions = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["code"] =
|
||
[
|
||
("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."),
|
||
("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."),
|
||
("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."),
|
||
("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."),
|
||
],
|
||
["document"] =
|
||
[
|
||
("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."),
|
||
("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."),
|
||
("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."),
|
||
],
|
||
["data"] =
|
||
[
|
||
("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."),
|
||
("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."),
|
||
("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"),
|
||
],
|
||
["image"] =
|
||
[
|
||
("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."),
|
||
("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."),
|
||
],
|
||
};
|
||
|
||
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||
{ ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" };
|
||
private static readonly HashSet<string> DataExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||
{ ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" };
|
||
// ImageExtensions는 이미지 첨부 영역(line ~1323)에서 정의됨 — 재사용
|
||
|
||
private Popup? _dropActionPopup;
|
||
|
||
private void ShowDropActionMenu(string[] files)
|
||
{
|
||
// 파일 유형 판별
|
||
var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant();
|
||
string category;
|
||
if (CodeExtensions.Contains(ext)) category = "code";
|
||
else if (DataExtensions.Contains(ext)) category = "data";
|
||
else if (ImageExtensions.Contains(ext)) category = "image";
|
||
else category = "document";
|
||
|
||
var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"];
|
||
|
||
// 팝업 생성
|
||
_dropActionPopup?.SetValue(Popup.IsOpenProperty, false);
|
||
|
||
var panel = new StackPanel();
|
||
// 헤더
|
||
var header = new TextBlock
|
||
{
|
||
Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" 외 {files.Length - 1}개" : "")}",
|
||
FontSize = 11,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(12, 8, 12, 6),
|
||
};
|
||
panel.Children.Add(header);
|
||
|
||
// 액션 항목
|
||
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(24, 255, 255, 255));
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
var textBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
|
||
foreach (var (label, icon, prompt) in actions)
|
||
{
|
||
var capturedPrompt = prompt;
|
||
var row = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(12, 7, 12, 7),
|
||
Margin = new Thickness(4, 1, 4, 1),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var stack = new StackPanel { Orientation = Orientation.Horizontal };
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13, Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold,
|
||
Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
row.Child = stack;
|
||
|
||
row.MouseEnter += (_, _) => row.Background = hoverBrush;
|
||
row.MouseLeave += (_, _) => row.Background = Brushes.Transparent;
|
||
row.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
|
||
foreach (var f in files) AddAttachedFile(f);
|
||
InputBox.Text = capturedPrompt;
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
InputBox.Focus();
|
||
if (_settings.Settings.Llm.DragDropAutoSend)
|
||
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 = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
attachStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "첨부만", FontSize = 13,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
attachOnly.Child = attachStack;
|
||
attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush;
|
||
attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent;
|
||
attachOnly.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
|
||
foreach (var f in files) AddAttachedFile(f);
|
||
InputBox.Focus();
|
||
};
|
||
panel.Children.Add(attachOnly);
|
||
|
||
var container = new Border
|
||
{
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46)),
|
||
CornerRadius = new CornerRadius(12),
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(4, 4, 4, 6),
|
||
Child = panel,
|
||
MinWidth = 200,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
|
||
},
|
||
};
|
||
|
||
_dropActionPopup = new Popup
|
||
{
|
||
PlacementTarget = InputBorder,
|
||
Placement = PlacementMode.Top,
|
||
StaysOpen = false,
|
||
AllowsTransparency = true,
|
||
PopupAnimation = PopupAnimation.Fade,
|
||
Child = container,
|
||
};
|
||
_dropActionPopup.IsOpen = true;
|
||
}
|
||
|
||
// ─── /help 도움말 창 ─────────────────────────────────────────────────
|
||
|
||
private void ShowSlashHelpWindow()
|
||
{
|
||
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46));
|
||
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var fg2 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(40, 255, 255, 255));
|
||
|
||
var win = new Window
|
||
{
|
||
Title = "AX Agent — 슬래시 명령어 도움말",
|
||
Width = 560, Height = 640, MinWidth = 440, MinHeight = 500,
|
||
WindowStyle = WindowStyle.None, AllowsTransparency = true,
|
||
Background = Brushes.Transparent, ResizeMode = ResizeMode.CanResize,
|
||
WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this,
|
||
Icon = Icon,
|
||
};
|
||
|
||
var mainBorder = new Border
|
||
{
|
||
Background = bg, CornerRadius = new CornerRadius(16),
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(1), Margin = new Thickness(10),
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3 },
|
||
};
|
||
|
||
var rootGrid = new Grid();
|
||
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(52) });
|
||
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||
|
||
// 헤더
|
||
var headerBorder = new Border
|
||
{
|
||
CornerRadius = new CornerRadius(16, 16, 0, 0),
|
||
Background = new LinearGradientBrush(
|
||
Color.FromRgb(26, 27, 46), Color.FromRgb(59, 78, 204),
|
||
new Point(0, 0), new Point(1, 1)),
|
||
Padding = new Thickness(20, 0, 20, 0),
|
||
};
|
||
var headerGrid = new Grid();
|
||
var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||
headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center });
|
||
headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center });
|
||
headerGrid.Children.Add(headerStack);
|
||
|
||
var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center };
|
||
closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
|
||
closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255));
|
||
closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent;
|
||
closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); };
|
||
headerGrid.Children.Add(closeBtn);
|
||
headerBorder.Child = headerGrid;
|
||
Grid.SetRow(headerBorder, 0);
|
||
rootGrid.Children.Add(headerBorder);
|
||
|
||
// 콘텐츠
|
||
var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(20, 14, 20, 20) };
|
||
var contentPanel = new StackPanel();
|
||
|
||
// 설명
|
||
contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 });
|
||
|
||
// 공통 명령어 섹션
|
||
AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg,
|
||
("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."),
|
||
("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다. (/status test: 연결 진단)"),
|
||
("/new", "새 대화를 시작합니다."),
|
||
("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."),
|
||
("/model", "모델 선택 패널을 엽니다."),
|
||
("/permissions", "권한 설정 패널을 엽니다."),
|
||
("/allowed-tools", "허용 도구(권한) 패널을 엽니다."),
|
||
("/settings", "AX Agent 설정 창을 엽니다."),
|
||
("/stats", "최근 호출의 토큰 통계를 표시합니다."),
|
||
("/cost", "최근 호출의 추정 비용을 표시합니다."),
|
||
("/export", "현재 대화를 파일로 내보냅니다."),
|
||
("/clear", "현재 대화를 정리하고 새 대화를 시작합니다."));
|
||
|
||
AddHelpSection(contentPanel, "작업/운영 명령어", "claw-code 명령 체계를 참고한 운영 명령", 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}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems);
|
||
}
|
||
|
||
// 사용 팁
|
||
contentPanel.Children.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), Margin = new Thickness(0, 12, 0, 12) });
|
||
var tipPanel = new StackPanel();
|
||
tipPanel.Children.Add(new TextBlock { Text = "사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) });
|
||
var tips = new[]
|
||
{
|
||
"/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.",
|
||
"파일을 드래그하면 유형별 AI 액션 팝업이 나타납니다.",
|
||
"스킬 파일(*.skill.md)을 추가하면 나만의 워크플로우를 만들 수 있습니다.",
|
||
"Cowork/Code 탭에서 에이전트가 도구를 활용하여 더 강력한 작업을 수행합니다.",
|
||
};
|
||
foreach (var tip in tips)
|
||
{
|
||
tipPanel.Children.Add(new TextBlock { Text = $"• {tip}", FontSize = 12, Foreground = fg2, Margin = new Thickness(8, 2, 0, 2), TextWrapping = TextWrapping.Wrap, LineHeight = 18 });
|
||
}
|
||
contentPanel.Children.Add(tipPanel);
|
||
|
||
scroll.Content = contentPanel;
|
||
Grid.SetRow(scroll, 1);
|
||
rootGrid.Children.Add(scroll);
|
||
|
||
mainBorder.Child = rootGrid;
|
||
win.Content = mainBorder;
|
||
// 헤더 영역에서만 드래그 이동 (닫기 버튼 클릭 방해 방지)
|
||
headerBorder.MouseLeftButtonDown += (_, me) => { try { win.DragMove(); } catch { } };
|
||
win.ShowDialog();
|
||
}
|
||
|
||
private static void AddHelpSection(StackPanel parent, string title, string subtitle,
|
||
Brush fg, Brush fg2, Brush accent, Brush itemBg, Brush hoverBg,
|
||
params (string Cmd, string Desc)[] items)
|
||
{
|
||
parent.Children.Add(new TextBlock { Text = title, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) });
|
||
parent.Children.Add(new TextBlock { Text = subtitle, FontSize = 11, Foreground = fg2, Margin = new Thickness(0, 0, 0, 8) });
|
||
|
||
foreach (var (cmd, desc) in items)
|
||
{
|
||
var row = new Border { Background = itemBg, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 3, 0, 3) };
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
|
||
var cmdText = new TextBlock { Text = cmd, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = accent, VerticalAlignment = VerticalAlignment.Center, FontFamily = new FontFamily("Consolas") };
|
||
Grid.SetColumn(cmdText, 0);
|
||
grid.Children.Add(cmdText);
|
||
|
||
var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap };
|
||
Grid.SetColumn(descText, 1);
|
||
grid.Children.Add(descText);
|
||
|
||
row.Child = grid;
|
||
row.MouseEnter += (_, _) => row.Background = hoverBg;
|
||
row.MouseLeave += (_, _) => row.Background = itemBg;
|
||
parent.Children.Add(row);
|
||
}
|
||
}
|
||
|
||
private async Task ExecuteManualCompactAsync(string commandText, string runTab)
|
||
{
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null)
|
||
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
|
||
conv = _currentConversation;
|
||
if (runTab is "Cowork" or "Code")
|
||
conv.ShowExecutionHistory = false;
|
||
}
|
||
|
||
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;
|
||
RenderMessages(preserveViewport: true);
|
||
ForceScrollToEnd();
|
||
|
||
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);
|
||
RenderMessages(preserveViewport: true);
|
||
ForceScrollToEnd();
|
||
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;
|
||
RenderMessages(preserveViewport: true);
|
||
ForceScrollToEnd();
|
||
}
|
||
|
||
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 <= 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);
|
||
_settings.Save();
|
||
_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();
|
||
_settings.Save();
|
||
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);
|
||
_settings.Save();
|
||
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;
|
||
_settings.Save();
|
||
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);
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
RefreshInlineSettingsPanel();
|
||
}
|
||
|
||
OpenCommandSkillBrowser("/");
|
||
}
|
||
|
||
private string TogglePermissionModeFromSlash()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.FilePermission = NextPermission(llm.FilePermission);
|
||
_settings.Save();
|
||
_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) || _isStreaming) return;
|
||
|
||
// placeholder 정리
|
||
ClearPromptCardPlaceholder();
|
||
|
||
// 슬래시 명령어 처리
|
||
var (slashSystem, displayText) = ParseSlashCommand(text);
|
||
|
||
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, _streamCts?.Token ?? CancellationToken.None);
|
||
AppendLocalSlashResult(_activeTab, "/commit", commitResult);
|
||
return;
|
||
}
|
||
if (string.Equals(slashSystem, "__COMPACT__", StringComparison.Ordinal))
|
||
{
|
||
await ExecuteManualCompactAsync("/compact", _activeTab);
|
||
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 userMsg = new ChatMessage { Role = "user", Content = text };
|
||
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;
|
||
}
|
||
}
|
||
SaveLastConversations();
|
||
void TryPersistConversation(bool force = false)
|
||
{
|
||
if (!force && (DateTime.UtcNow - lastAutoSaveUtc).TotalSeconds < 1.2)
|
||
return;
|
||
|
||
lastAutoSaveUtc = DateTime.UtcNow;
|
||
PersistConversationSnapshot(originTab, conv, "대화 중간 저장 실패");
|
||
}
|
||
TryPersistConversation(force: true);
|
||
|
||
UpdateChatTitle();
|
||
if (useComposerInput)
|
||
{
|
||
InputBox.Text = "";
|
||
UpdateInputBoxHeight();
|
||
}
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
RenderMessages(preserveViewport: true);
|
||
|
||
// 대화 통계 기록
|
||
Services.UsageStatisticsService.RecordChat(runTab);
|
||
|
||
ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동
|
||
PlayRainbowGlow(); // 무지개 글로우 애니메이션
|
||
|
||
_isStreaming = true;
|
||
_streamRunTab = runTab;
|
||
BtnSend.IsEnabled = false;
|
||
BtnSend.Visibility = Visibility.Collapsed;
|
||
BtnStop.Visibility = Visibility.Visible;
|
||
if (runTab == "Cowork" || runTab == "Code")
|
||
BtnPause.Visibility = Visibility.Visible;
|
||
_streamCts = new CancellationTokenSource();
|
||
|
||
var assistantContent = string.Empty;
|
||
_activeStreamText = null;
|
||
_cachedStreamContent = "";
|
||
_displayedLength = 0;
|
||
_cursorVisible = true;
|
||
_aiIconPulseStopped = false;
|
||
_streamStartTime = DateTime.UtcNow;
|
||
_elapsedTimer.Start();
|
||
SetStatus("응답 생성 중...", spinning: true);
|
||
|
||
// ── 자동 모델 라우팅 (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;
|
||
}
|
||
|
||
var preparedExecution = PrepareExecutionForConversation(
|
||
conv,
|
||
runTab,
|
||
slashSystem,
|
||
fileContext,
|
||
outboundImages);
|
||
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,
|
||
_streamCts!.Token);
|
||
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;
|
||
}
|
||
finally
|
||
{
|
||
// 자동 라우팅 오버라이드 해제
|
||
if (routeResult != null)
|
||
{
|
||
_llm.ClearRouteOverride();
|
||
UpdateModelLabel();
|
||
}
|
||
}
|
||
FinalizeQueuedDraft(originTab, queuedDraftId, draftSucceeded, draftCancelled, draftFailure);
|
||
}
|
||
|
||
private async Task<string> RunAgentLoopAsync(
|
||
string runTab,
|
||
string originTab,
|
||
ChatConversation conversation,
|
||
IReadOnlyList<ChatMessage> sendMessages,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
OpenWorkflowAnalyzerIfEnabled();
|
||
|
||
_agentCumulativeInputTokens = 0;
|
||
_agentCumulativeOutputTokens = 0;
|
||
|
||
_agentLoop.EventOccurred += OnAgentEvent;
|
||
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
|
||
try
|
||
{
|
||
_agentLoop.ActiveTab = runTab;
|
||
var response = await _agentLoop.RunAsync(sendMessages.ToList(), 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);
|
||
}
|
||
|
||
return response;
|
||
}
|
||
finally
|
||
{
|
||
_agentLoop.EventOccurred -= OnAgentEvent;
|
||
_agentLoop.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()
|
||
{
|
||
FlushPendingConversationPersists();
|
||
FlushPendingAgentUiEvent();
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
_executionHistoryRenderTimer.Stop();
|
||
_pendingExecutionHistoryAutoScroll = false;
|
||
_taskSummaryRefreshTimer.Stop();
|
||
_conversationPersistTimer.Stop();
|
||
_agentUiEventTimer.Stop();
|
||
HideStickyProgress();
|
||
StopRainbowGlow();
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_cachedStreamContent = "";
|
||
_isStreaming = false;
|
||
BtnSend.IsEnabled = true;
|
||
BtnStop.Visibility = Visibility.Collapsed;
|
||
BtnSend.Visibility = Visibility.Visible;
|
||
_streamCts?.Dispose();
|
||
_streamCts = null;
|
||
_streamRunTab = null;
|
||
SetStatusIdle();
|
||
}
|
||
|
||
private void FinalizeConversationTurn(string rememberTab, ChatConversation conversation)
|
||
{
|
||
lock (_convLock)
|
||
_currentConversation = conversation;
|
||
|
||
RenderMessages(preserveViewport: true);
|
||
AutoScrollIfNeeded();
|
||
|
||
PersistConversationSnapshot(rememberTab, conversation, "대화 저장 실패");
|
||
SyncTabConversationIdsFromSession();
|
||
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);
|
||
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)
|
||
{
|
||
_isStreaming = true;
|
||
_streamRunTab = runTab;
|
||
BtnSend.IsEnabled = false;
|
||
BtnSend.Visibility = Visibility.Collapsed;
|
||
BtnStop.Visibility = Visibility.Visible;
|
||
if (runTab == "Cowork" || runTab == "Code")
|
||
BtnPause.Visibility = Visibility.Visible;
|
||
_streamCts = new CancellationTokenSource();
|
||
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);
|
||
|
||
try
|
||
{
|
||
var response = await _chatEngine.ExecutePreparedAsync(
|
||
preparedExecution,
|
||
(messages, token) => RunAgentLoopAsync(runTab, rememberTab, conversation, messages, token),
|
||
(messages, token) => _llm.SendAsync(messages.ToList(), token),
|
||
_streamCts.Token);
|
||
assistantContent = response;
|
||
responseElapsedMs = Math.Max(0, (long)(DateTime.UtcNow - _streamStartTime).TotalMilliseconds);
|
||
assistantMetaRunId = _appState.AgentRun.RunId;
|
||
var usage = _llm.LastTokenUsage;
|
||
if (usage != null)
|
||
{
|
||
if (runTab is "Cowork" or "Code" && (_agentCumulativeInputTokens > 0 || _agentCumulativeOutputTokens > 0))
|
||
{
|
||
promptTokens = Math.Max(0, _agentCumulativeInputTokens);
|
||
completionTokens = Math.Max(0, _agentCumulativeOutputTokens);
|
||
}
|
||
else
|
||
{
|
||
promptTokens = Math.Max(0, usage.PromptTokens);
|
||
completionTokens = Math.Max(0, usage.CompletionTokens);
|
||
}
|
||
}
|
||
StopAiIconPulse();
|
||
_cachedStreamContent = response;
|
||
draftSucceeded = true;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, cancelled: true);
|
||
assistantContent = finalized.Content;
|
||
draftCancelled = finalized.Cancelled;
|
||
draftFailure = finalized.FailureReason;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, ex);
|
||
assistantContent = finalized.Content;
|
||
draftFailure = finalized.FailureReason;
|
||
ShowToast("실패한 요청은 작업 요약에서 다시 시도할 수 있습니다.", "\uE783", 2600);
|
||
}
|
||
finally
|
||
{
|
||
ResetStreamingUiState();
|
||
}
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
assistantContent = _chatEngine.FinalizeAssistantTurn(
|
||
session,
|
||
conversation,
|
||
runTab,
|
||
assistantContent,
|
||
promptTokens,
|
||
completionTokens,
|
||
responseElapsedMs,
|
||
assistantMetaRunId,
|
||
_storage);
|
||
_currentConversation = session?.CurrentConversation ?? conversation;
|
||
conversation = _currentConversation!;
|
||
}
|
||
|
||
FinalizeConversationTurn(rememberTab, conversation);
|
||
return new PreparedTurnOutcome(conversation, assistantContent, draftSucceeded, draftCancelled, draftFailure);
|
||
}
|
||
|
||
private void ScheduleExecutionHistoryRender(bool autoScroll = true)
|
||
{
|
||
_pendingExecutionHistoryAutoScroll |= autoScroll;
|
||
_executionHistoryRenderTimer.Stop();
|
||
_executionHistoryRenderTimer.Start();
|
||
}
|
||
|
||
private void ScheduleTaskSummaryRefresh()
|
||
{
|
||
_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;
|
||
_agentUiEventTimer.Stop();
|
||
_agentUiEventTimer.Start();
|
||
}
|
||
|
||
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);
|
||
|
||
if (!PreviewWindow.IsOpen)
|
||
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;
|
||
|
||
foreach (var conversation in _pendingConversationPersists.Values.ToList())
|
||
{
|
||
PersistConversationSnapshot(conversation.Tab ?? _activeTab, conversation, "대화 지연 저장 실패");
|
||
}
|
||
|
||
_pendingConversationPersists.Clear();
|
||
}
|
||
|
||
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
|
||
|
||
private string BuildCoworkSystemPrompt()
|
||
{
|
||
var workFolder = GetCurrentWorkFolder();
|
||
var llm = _settings.Settings.Llm;
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
|
||
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
|
||
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
|
||
sb.AppendLine("Only present a step-by-step plan when the user explicitly asks for a plan or when the task is too ambiguous to execute safely.");
|
||
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the real artifact.");
|
||
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
|
||
sb.AppendLine("Do not stop after a single step. Continue autonomously until the request is completed or a concrete blocker (permission denial, missing dependency, hard error) is encountered.");
|
||
sb.AppendLine("When adapting external references, rewrite names/structure/comments to AX Copilot style. Avoid clone-like outputs.");
|
||
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates.");
|
||
sb.AppendLine("IMPORTANT: For reports, proposals, analyses, and manuals with multiple sections:");
|
||
sb.AppendLine(" 1. Decide the document structure internally first.");
|
||
sb.AppendLine(" 2. Use document_plan only when it improves the actual output, not as a mandatory user-facing approval step.");
|
||
sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, or file_write.");
|
||
sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only.");
|
||
sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated.");
|
||
|
||
// 문서 품질 검증 루프
|
||
sb.AppendLine("\n## Document Quality Review");
|
||
sb.AppendLine("After creating any document (html_create, docx_create, excel_create, etc.), you MUST perform a self-review:");
|
||
sb.AppendLine("1. Use file_read to read the generated file and verify the content is complete");
|
||
sb.AppendLine("2. Check for logical errors: incorrect dates, inconsistent data, missing sections, broken formatting");
|
||
sb.AppendLine("3. Verify all requested topics/sections from the user's original request are covered");
|
||
sb.AppendLine("4. If issues found, fix them using file_write or file_edit, then re-verify");
|
||
sb.AppendLine("5. Report the review result to the user: what was checked and whether corrections were made");
|
||
|
||
// 문서 포맷 변환 지원
|
||
sb.AppendLine("\n## Format Conversion");
|
||
sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):");
|
||
sb.AppendLine("1. Use file_read or document_read to read the source file content");
|
||
sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)");
|
||
sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible");
|
||
|
||
// 사용자 지정 출력 포맷
|
||
var fmt = llm.DefaultOutputFormat;
|
||
if (!string.IsNullOrEmpty(fmt) && fmt != "auto")
|
||
{
|
||
var fmtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["xlsx"] = "Excel (.xlsx) using excel_create",
|
||
["docx"] = "Word (.docx) using docx_create",
|
||
["html"] = "HTML (.html) using html_create",
|
||
["md"] = "Markdown (.md) using markdown_create",
|
||
["csv"] = "CSV (.csv) using csv_create",
|
||
};
|
||
if (fmtMap.TryGetValue(fmt, out var fmtDesc))
|
||
sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise.");
|
||
}
|
||
|
||
// 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내
|
||
if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern")
|
||
sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template.");
|
||
else
|
||
sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.");
|
||
|
||
if (!string.IsNullOrEmpty(workFolder))
|
||
sb.AppendLine($"Current work folder: {workFolder}");
|
||
sb.AppendLine($"File permission mode: {llm.FilePermission}");
|
||
sb.Append(BuildSubAgentDelegationSection(false));
|
||
|
||
// 폴더 데이터 활용 지침 (사용자 옵션이 아닌 탭별 자동 정책)
|
||
switch (GetAutomaticFolderDataUsage())
|
||
{
|
||
case "active":
|
||
sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available.");
|
||
sb.AppendLine("Before creating reports, use folder_map to scan the work folder structure. " +
|
||
"Then EVALUATE whether each document is RELEVANT to the user's current request topic. " +
|
||
"Only use document_read on files that are clearly related to the conversation subject. " +
|
||
"Do NOT read or reference files that are unrelated to the user's request, even if they exist in the folder. " +
|
||
"In your planning step, list which files you plan to read and explain WHY they are relevant.");
|
||
break;
|
||
case "passive":
|
||
sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " +
|
||
"Only read folder documents when the user explicitly asks you to reference or use them.");
|
||
break;
|
||
default: // "none"
|
||
sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path.");
|
||
break;
|
||
}
|
||
|
||
// 프리셋 시스템 프롬프트가 있으면 추가
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand))
|
||
sb.AppendLine("\n" + _currentConversation.SystemCommand);
|
||
}
|
||
|
||
// 프로젝트 문맥 파일 (AGENTS.md) 주입
|
||
sb.Append(LoadProjectContext(workFolder));
|
||
|
||
// 프로젝트 규칙 (.ax/rules/) 자동 주입
|
||
sb.Append(BuildProjectRulesSection(workFolder));
|
||
|
||
// 에이전트 메모리 주입
|
||
sb.Append(BuildMemorySection(workFolder));
|
||
|
||
// 피드백 학습 컨텍스트 주입
|
||
sb.Append(BuildFeedbackContext());
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
private string BuildCodeSystemPrompt()
|
||
{
|
||
var workFolder = GetCurrentWorkFolder();
|
||
var llm = _settings.Settings.Llm;
|
||
var code = llm.Code;
|
||
var sb = new System.Text.StringBuilder();
|
||
|
||
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
|
||
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
|
||
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool.");
|
||
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
|
||
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
|
||
|
||
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
|
||
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
|
||
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
|
||
sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + file_read to deeply understand the code you'll modify.");
|
||
sb.AppendLine(" - Always check callers/references: grep for function/class names to find all usage points.");
|
||
sb.AppendLine(" - Read test files related to the code you're changing to understand expected behavior.");
|
||
sb.AppendLine("4. PLAN: Build an internal execution outline and impact assessment before editing.");
|
||
sb.AppendLine(" - Present the outline explicitly only when the user asks for a plan or the change is clearly high risk.");
|
||
sb.AppendLine(" - Explain WHY each change is needed and what could break.");
|
||
sb.AppendLine(" - If you touch shared services, interfaces, models, controllers, view-models, app startup, or dependency registration, treat it as a high-impact change and call out the impact clearly.");
|
||
sb.AppendLine("5. IMPLEMENT: Apply changes using file_edit (preferred — shows diff). Use file_write only for new files.");
|
||
sb.AppendLine(" - Make the MINIMUM changes needed. Don't refactor unrelated code.");
|
||
sb.AppendLine(" - Prefer file_edit with replace_all=false for precision edits.");
|
||
sb.AppendLine("6. VERIFY: Run build_run action='build' then action='test'. Compare results with baseline.");
|
||
sb.AppendLine(" - If tests fail that passed before, fix immediately.");
|
||
sb.AppendLine(" - If build fails, analyze error output and correct.");
|
||
sb.AppendLine(" - Re-read every edited file and verify impacted callers/references before finishing.");
|
||
sb.AppendLine(" - Do not stop at a passing build if request coverage, edge cases, or changed call sites are still unverified.");
|
||
sb.AppendLine(" - For high-impact changes, you must verify both references/callers and build/test evidence before finishing.");
|
||
sb.AppendLine("7. GIT: Use git_tool to check status, create diff, and optionally commit.");
|
||
sb.AppendLine("8. REPORT: Summarize changes, test results, and any remaining concerns.");
|
||
|
||
sb.AppendLine("\n## Development Environment");
|
||
sb.AppendLine("Use dev_env_detect to check installed IDEs, runtimes, and build tools before running commands.");
|
||
sb.AppendLine("IMPORTANT: Do NOT attempt to install compilers, IDEs, or build tools. Only use what is already installed.");
|
||
|
||
// 패키지 저장소 정보
|
||
sb.AppendLine("\n## Package Repositories");
|
||
if (!string.IsNullOrEmpty(code.NexusBaseUrl))
|
||
sb.AppendLine($"Enterprise Nexus: {code.NexusBaseUrl}");
|
||
sb.AppendLine($"NuGet (.NET): {code.NugetSource}");
|
||
sb.AppendLine($"PyPI/Conda (Python): {code.PypiSource}");
|
||
sb.AppendLine($"Maven (Java): {code.MavenSource}");
|
||
sb.AppendLine($"npm (JavaScript): {code.NpmSource}");
|
||
sb.AppendLine("When adding dependencies, use these repository URLs.");
|
||
|
||
// IDE 정보
|
||
if (!string.IsNullOrEmpty(code.PreferredIdePath))
|
||
sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}");
|
||
|
||
// 사용자 선택 개발 언어
|
||
if (_selectedLanguage != "auto")
|
||
{
|
||
var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage };
|
||
sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation.");
|
||
}
|
||
|
||
// 언어별 가이드라인
|
||
sb.AppendLine("\n## Language Guidelines");
|
||
sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions.");
|
||
sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred.");
|
||
sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide.");
|
||
sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines.");
|
||
sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API.");
|
||
|
||
// 코드 품질 + 안전 수칙
|
||
sb.AppendLine("\n## Code Quality & Safety");
|
||
sb.AppendLine("- NEVER delete or overwrite files without user confirmation.");
|
||
sb.AppendLine("- ALWAYS read a file before editing it. Don't guess contents.");
|
||
sb.AppendLine("- Prefer file_edit over file_write for existing files (shows diff).");
|
||
sb.AppendLine("- When porting/referencing external code, do not copy verbatim. Rename and re-structure to match AX Copilot conventions.");
|
||
sb.AppendLine("- Use grep to find ALL references before renaming/removing anything.");
|
||
sb.AppendLine("- After editing, re-open the changed files and nearby callers to verify the final state, not just the patch intent.");
|
||
sb.AppendLine("- Treat verification as incomplete unless you can cite build/test or direct file-read evidence.");
|
||
sb.AppendLine("- If unsure about a change's impact, ask the user first.");
|
||
sb.AppendLine("- For large refactors, do them incrementally with build verification between steps.");
|
||
sb.AppendLine("- Use git_tool action='diff' to review your changes before committing.");
|
||
|
||
sb.AppendLine("\n## Lint & Format");
|
||
sb.AppendLine("After code changes, check for available linters:");
|
||
sb.AppendLine("- Python: ruff, black, flake8, pylint");
|
||
sb.AppendLine("- JavaScript: eslint, prettier");
|
||
sb.AppendLine("- C#: dotnet format");
|
||
sb.AppendLine("- C++: clang-format");
|
||
sb.AppendLine("Run the appropriate linter via process tool if detected by dev_env_detect.");
|
||
|
||
if (!string.IsNullOrEmpty(workFolder))
|
||
sb.AppendLine($"\nCurrent work folder: {workFolder}");
|
||
sb.AppendLine($"File permission mode: {llm.FilePermission}");
|
||
sb.Append(BuildSubAgentDelegationSection(true));
|
||
|
||
// 폴더 데이터 활용
|
||
sb.AppendLine("\nFolder Data Usage = ACTIVE. Use folder_map and file_read to understand the codebase.");
|
||
sb.AppendLine("Analyze project structure before making changes. Read relevant files to understand context.");
|
||
|
||
// 프리셋 시스템 프롬프트
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation?.SystemCommand is { Length: > 0 } sysCmd)
|
||
sb.AppendLine("\n" + sysCmd);
|
||
}
|
||
|
||
// 프로젝트 문맥 파일 (AGENTS.md) 주입
|
||
sb.Append(LoadProjectContext(workFolder));
|
||
|
||
// 프로젝트 규칙 (.ax/rules/) 자동 주입
|
||
sb.Append(BuildProjectRulesSection(workFolder));
|
||
|
||
// 에이전트 메모리 주입
|
||
sb.Append(BuildMemorySection(workFolder));
|
||
|
||
// 피드백 학습 컨텍스트 주입
|
||
sb.Append(BuildFeedbackContext());
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static string BuildSubAgentDelegationSection(bool codeMode)
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("\n## Sub-Agent Delegation");
|
||
sb.AppendLine("Use spawn_agent only for bounded side investigations that can run in parallel with your main work.");
|
||
sb.AppendLine("Good delegation targets: impact analysis, reference/caller search, test-file discovery, diff review, and bug root-cause investigation.");
|
||
sb.AppendLine("Do not delegate the final editing decision, the final report, or blocking work that you must inspect immediately yourself.");
|
||
sb.AppendLine("When spawning a sub-agent, give a concrete task with the exact question, likely file/module scope, and the output shape you want.");
|
||
sb.AppendLine("Expected sub-agent result shape: conclusion, files checked, key evidence, recommended next action, risks/unknowns.");
|
||
sb.AppendLine("Use wait_agents only when you are ready to integrate the result. Keep doing useful local work while the sub-agent runs.");
|
||
if (codeMode)
|
||
{
|
||
sb.AppendLine("For code tasks, prefer delegating read-only investigations such as caller mapping, related test discovery, or build/test failure triage.");
|
||
sb.AppendLine("For high-impact code changes, delegate at least one focused investigation for callers/references or related tests before finalizing.");
|
||
}
|
||
else
|
||
{
|
||
sb.AppendLine("For cowork tasks, prefer delegating fact gathering, source cross-checking, or evidence collection while the main agent continues planning.");
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>프로젝트 규칙 (.ax/rules/)을 시스템 프롬프트 섹션으로 포맷합니다.</summary>
|
||
private string BuildProjectRulesSection(string? workFolder)
|
||
{
|
||
if (string.IsNullOrEmpty(workFolder)) return "";
|
||
if (!_settings.Settings.Llm.EnableProjectRules) return "";
|
||
|
||
try
|
||
{
|
||
var rules = Services.Agent.ProjectRulesService.LoadRules(workFolder);
|
||
if (rules.Count == 0) return "";
|
||
|
||
// 컨텍스트별 필터링: Cowork=document, Code=always (기본)
|
||
var when = _activeTab == "Code" ? "always" : "always";
|
||
var filtered = Services.Agent.ProjectRulesService.FilterRules(rules, when);
|
||
return Services.Agent.ProjectRulesService.FormatForSystemPrompt(filtered);
|
||
}
|
||
catch
|
||
{
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/// <summary>에이전트 메모리를 시스템 프롬프트 섹션으로 포맷합니다.</summary>
|
||
private string BuildMemorySection(string? workFolder)
|
||
{
|
||
if (!_settings.Settings.Llm.EnableAgentMemory) return "";
|
||
|
||
var app = System.Windows.Application.Current as App;
|
||
var memService = app?.MemoryService;
|
||
if (memService == null || memService.Count == 0) return "";
|
||
|
||
// 메모리를 로드 (작업 폴더 변경 시 재로드)
|
||
memService.Load(workFolder ?? "");
|
||
|
||
var all = memService.All;
|
||
if (all.Count == 0) return "";
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("\n## 프로젝트 메모리 (이전 대화에서 학습한 내용)");
|
||
sb.AppendLine("아래는 이전 대화에서 학습한 규칙과 선호도입니다. 작업 시 참고하세요.");
|
||
sb.AppendLine("새로운 규칙이나 선호도를 발견하면 memory 도구의 save 액션으로 저장하세요.");
|
||
sb.AppendLine("사용자가 이전 학습 내용과 다른 지시를 하면 memory 도구의 delete 후 새로 save 하세요.\n");
|
||
|
||
foreach (var group in all.GroupBy(e => e.Type))
|
||
{
|
||
var label = group.Key switch
|
||
{
|
||
"rule" => "프로젝트 규칙",
|
||
"preference" => "사용자 선호",
|
||
"fact" => "프로젝트 사실",
|
||
"correction" => "이전 교정",
|
||
_ => group.Key,
|
||
};
|
||
sb.AppendLine($"[{label}]");
|
||
foreach (var e in group.OrderByDescending(e => e.UseCount).Take(15))
|
||
sb.AppendLine($"- {e.Content}");
|
||
sb.AppendLine();
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다.</summary>
|
||
private void OpenWorkflowAnalyzerIfEnabled()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
if (!llm.DevMode || !llm.WorkflowVisualizer) return;
|
||
|
||
if (_analyzerWindow == null)
|
||
{
|
||
// 새로 생성
|
||
_analyzerWindow = new WorkflowAnalyzerWindow();
|
||
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
|
||
// 테마 리소스 전달
|
||
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
|
||
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
|
||
_analyzerWindow.Show();
|
||
}
|
||
else if (!_analyzerWindow.IsVisible)
|
||
{
|
||
// Hide()로 숨겨진 창 → 기존 내용 유지한 채 다시 표시
|
||
_analyzerWindow.Show();
|
||
_analyzerWindow.Activate();
|
||
}
|
||
else
|
||
{
|
||
// 이미 보이는 상태 → 새 에이전트 실행을 위해 초기화 후 활성화
|
||
_analyzerWindow.Reset();
|
||
_analyzerWindow.Activate();
|
||
}
|
||
|
||
// 타임라인 탭으로 전환 (새 실행 시작)
|
||
_analyzerWindow.SwitchToTimelineTab();
|
||
|
||
// 이벤트 구독 (중복 방지)
|
||
_agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent;
|
||
_agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent;
|
||
}
|
||
|
||
/// <summary>워크플로우 분석기 버튼의 표시 상태를 갱신합니다.</summary>
|
||
private void UpdateAnalyzerButtonVisibility()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
BtnShowAnalyzer.Visibility = (llm.DevMode && llm.WorkflowVisualizer)
|
||
? Visibility.Visible : Visibility.Collapsed;
|
||
}
|
||
|
||
/// <summary>워크플로우 분석기 창을 수동으로 열거나 포커스합니다 (하단 바 버튼).</summary>
|
||
private void BtnShowAnalyzer_Click(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (_analyzerWindow == null)
|
||
{
|
||
_analyzerWindow = new WorkflowAnalyzerWindow();
|
||
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
|
||
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
|
||
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
|
||
// 에이전트 이벤트 구독
|
||
_agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent;
|
||
_agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent;
|
||
_analyzerWindow.Show();
|
||
}
|
||
else if (!_analyzerWindow.IsVisible)
|
||
{
|
||
_analyzerWindow.Show();
|
||
_analyzerWindow.Activate();
|
||
}
|
||
else
|
||
{
|
||
_analyzerWindow.Activate();
|
||
}
|
||
}
|
||
|
||
/// <summary>에이전트 루프 동안 누적 토큰 (하단 바 표시용)</summary>
|
||
private int _agentCumulativeInputTokens;
|
||
private int _agentCumulativeOutputTokens;
|
||
|
||
private static readonly HashSet<string> WriteToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"file_write", "file_edit", "html_create", "xlsx_create",
|
||
"docx_create", "csv_create", "md_create", "script_create",
|
||
"diff_preview", "open_external",
|
||
};
|
||
|
||
private void OnAgentEvent(AgentEvent evt)
|
||
{
|
||
var eventTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!;
|
||
|
||
// 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다.
|
||
// 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다.
|
||
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
|
||
AppendConversationExecutionEvent(evt, eventTab);
|
||
if (shouldShowExecutionHistory
|
||
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
ScheduleExecutionHistoryRender(autoScroll: true);
|
||
|
||
_appState.ApplyAgentEvent(evt);
|
||
if (evt.Type == AgentEventType.Complete)
|
||
AppendConversationAgentRun(evt, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, eventTab);
|
||
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
|
||
AppendConversationAgentRun(evt, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, eventTab);
|
||
|
||
// 하단 바 토큰 누적 업데이트 (에이전트 루프 전체 합계)
|
||
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
|
||
{
|
||
_agentCumulativeInputTokens += evt.InputTokens;
|
||
_agentCumulativeOutputTokens += evt.OutputTokens;
|
||
UpdateStatusTokens(_agentCumulativeInputTokens, _agentCumulativeOutputTokens);
|
||
}
|
||
|
||
ScheduleAgentUiEvent(evt);
|
||
|
||
ScheduleTaskSummaryRefresh();
|
||
}
|
||
|
||
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
|
||
{
|
||
Dispatcher.Invoke(() =>
|
||
{
|
||
_appState.ApplySubAgentStatus(evt);
|
||
ScheduleTaskSummaryRefresh();
|
||
});
|
||
}
|
||
|
||
private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
var result = _chatEngine.AppendAgentRun(
|
||
session,
|
||
_storage,
|
||
_currentConversation,
|
||
_activeTab,
|
||
targetTab,
|
||
evt,
|
||
status,
|
||
summary);
|
||
_currentConversation = result.CurrentConversation;
|
||
ScheduleConversationPersist(result.UpdatedConversation);
|
||
}
|
||
}
|
||
|
||
private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
var result = _chatEngine.AppendExecutionEvent(
|
||
session,
|
||
_storage,
|
||
_currentConversation,
|
||
_activeTab,
|
||
targetTab,
|
||
evt);
|
||
_currentConversation = result.CurrentConversation;
|
||
ScheduleConversationPersist(result.UpdatedConversation);
|
||
}
|
||
}
|
||
|
||
private void SyncAppStateWithCurrentConversation()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
|
||
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
||
_appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory);
|
||
_appState.RestoreRecentTasks(conv?.ExecutionEvents);
|
||
ApplyConversationListPreferences(conv);
|
||
UpdateTaskSummaryIndicators();
|
||
}
|
||
|
||
private void ApplyConversationListPreferences(ChatConversation? conv)
|
||
{
|
||
_failedOnlyFilter = false;
|
||
_runningOnlyFilter = false;
|
||
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
|
||
UpdateConversationFailureFilterUi();
|
||
UpdateConversationRunningFilterUi();
|
||
UpdateConversationSortUi();
|
||
}
|
||
|
||
private void PersistConversationListPreferences()
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
session.SaveConversationListPreferences(_activeTab, _failedOnlyFilter, _runningOnlyFilter, _sortConversationsByRecent, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
}
|
||
}
|
||
|
||
private static string GetRunStatusLabel(string? status)
|
||
=> status switch
|
||
{
|
||
"completed" => "완료",
|
||
"failed" => "실패",
|
||
"paused" => "일시중지",
|
||
_ => "진행 중",
|
||
};
|
||
|
||
private static string GetTaskStatusLabel(string? status)
|
||
=> status switch
|
||
{
|
||
"completed" => "완료",
|
||
"failed" => "실패",
|
||
"blocked" => "재시도 대기",
|
||
"waiting" => "승인 대기",
|
||
"cancelled" => "중단",
|
||
_ => "진행 중",
|
||
};
|
||
|
||
private string GetAgentItemDisplayName(string? rawName, bool slashPrefix = false)
|
||
=> GetTranscriptDisplayName(rawName, slashPrefix);
|
||
|
||
private static bool IsTranscriptToolLikeEvent(AgentEvent evt)
|
||
=> evt.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.SkillCall
|
||
|| (!string.IsNullOrWhiteSpace(evt.ToolName)
|
||
&& evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied);
|
||
|
||
private string BuildAgentEventSummaryText(AgentEvent evt, string displayName)
|
||
=> GetTranscriptEventSummary(evt, displayName);
|
||
|
||
private IEnumerable<TaskRunStore.TaskRun> FilterTaskSummaryItems(IEnumerable<TaskRunStore.TaskRun> tasks)
|
||
=> _taskSummaryTaskFilter switch
|
||
{
|
||
"permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)),
|
||
"queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)),
|
||
"hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)),
|
||
"subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)),
|
||
"tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)),
|
||
_ => tasks,
|
||
};
|
||
|
||
private Border CreateTaskSummaryFilterChip(string key, string label)
|
||
{
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase);
|
||
var chip = new Border
|
||
{
|
||
Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"),
|
||
BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(0, 0, 5, 5),
|
||
Cursor = Cursors.Hand,
|
||
Child = new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 10.5,
|
||
Foreground = active ? BrushFromHex("#4338CA") : secondaryText,
|
||
}
|
||
};
|
||
chip.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
_taskSummaryTaskFilter = key;
|
||
if (_taskSummaryTarget != null)
|
||
ShowTaskSummaryPopup();
|
||
};
|
||
return chip;
|
||
}
|
||
|
||
private static Brush GetRunStatusBrush(string? status)
|
||
=> status switch
|
||
{
|
||
"completed" => BrushFromHex("#166534"),
|
||
"failed" => BrushFromHex("#B91C1C"),
|
||
"paused" => BrushFromHex("#B45309"),
|
||
_ => BrushFromHex("#1D4ED8"),
|
||
};
|
||
|
||
private static string ShortRunId(string? runId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(runId))
|
||
return "main";
|
||
|
||
return runId.Length <= 8 ? runId : runId[..8];
|
||
}
|
||
|
||
// ─── Task Decomposition UI ────────────────────────────────────────────
|
||
|
||
private Border? _planningCard;
|
||
private StackPanel? _planStepsPanel;
|
||
private ProgressBar? _planProgressBar;
|
||
private TextBlock? _planProgressText;
|
||
private TextBlock? _planToggleText;
|
||
|
||
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
|
||
private void AddPlanningCard(AgentEvent evt)
|
||
{
|
||
var steps = evt.Steps!;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC");
|
||
|
||
var card = new Border
|
||
{
|
||
Background = hintBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
Margin = new Thickness(8, 2, 248, 5),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
MaxWidth = GetMessageMaxWidth(),
|
||
};
|
||
|
||
var sp = new StackPanel();
|
||
|
||
// 헤더
|
||
var header = new Grid { Margin = new Thickness(0, 0, 0, 4) };
|
||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var headerLeft = new StackPanel { Orientation = Orientation.Horizontal };
|
||
headerLeft.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE9D5", // plan icon
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 8,
|
||
Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center
|
||
});
|
||
headerLeft.Children.Add(new TextBlock
|
||
{
|
||
Text = $"계획 {steps.Count}단계",
|
||
FontSize = 9, FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(4, 0, 0, 0),
|
||
});
|
||
header.Children.Add(headerLeft);
|
||
|
||
var toggleWrap = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(6, 2, 6, 2),
|
||
Cursor = Cursors.Hand,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
_planToggleText = new TextBlock
|
||
{
|
||
Text = steps.Count > 0 ? "펼치기" : "",
|
||
FontSize = 8.25,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
};
|
||
toggleWrap.Child = _planToggleText;
|
||
Grid.SetColumn(toggleWrap, 1);
|
||
header.Children.Add(toggleWrap);
|
||
sp.Children.Add(header);
|
||
|
||
// 진행률 바
|
||
var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 6) };
|
||
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
_planProgressBar = new ProgressBar
|
||
{
|
||
Minimum = 0,
|
||
Maximum = steps.Count,
|
||
Value = 0,
|
||
Height = 3,
|
||
Foreground = accentBrush,
|
||
Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
// Remove the default border on ProgressBar
|
||
_planProgressBar.BorderThickness = new Thickness(0);
|
||
Grid.SetColumn(_planProgressBar, 0);
|
||
progressGrid.Children.Add(_planProgressBar);
|
||
|
||
_planProgressText = new TextBlock
|
||
{
|
||
Text = "0%",
|
||
FontSize = 8, FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(5, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(_planProgressText, 1);
|
||
progressGrid.Children.Add(_planProgressText);
|
||
sp.Children.Add(progressGrid);
|
||
|
||
// 단계 목록
|
||
_planStepsPanel = new StackPanel
|
||
{
|
||
Visibility = Visibility.Collapsed,
|
||
};
|
||
for (int i = 0; i < steps.Count; i++)
|
||
{
|
||
var stepRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
Tag = i, // 인덱스 저장
|
||
};
|
||
|
||
stepRow.Children.Add(new TextBlock
|
||
{
|
||
Text = "○", // 빈 원 (미완료)
|
||
FontSize = 8.5,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
Tag = "status",
|
||
});
|
||
stepRow.Children.Add(new TextBlock
|
||
{
|
||
Text = $"{i + 1}. {steps[i]}",
|
||
FontSize = 8.75,
|
||
Foreground = primaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
MaxWidth = Math.Max(280, GetMessageMaxWidth() - 68),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
_planStepsPanel.Children.Add(stepRow);
|
||
}
|
||
sp.Children.Add(_planStepsPanel);
|
||
|
||
toggleWrap.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
if (_planStepsPanel == null || _planToggleText == null)
|
||
return;
|
||
|
||
var expanded = _planStepsPanel.Visibility == Visibility.Visible;
|
||
_planStepsPanel.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible;
|
||
_planToggleText.Text = expanded ? "펼치기" : "접기";
|
||
toggleWrap.Background = expanded ? Brushes.Transparent : BrushFromHex("#F8FAFC");
|
||
};
|
||
|
||
card.Child = sp;
|
||
_planningCard = card;
|
||
|
||
// 페이드인
|
||
card.Opacity = 0;
|
||
card.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
|
||
|
||
MessagePanel.Children.Add(card);
|
||
}
|
||
|
||
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
|
||
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> options)
|
||
{
|
||
var expressionLevel = GetAgentUiExpressionLevel();
|
||
var showDetailedCopy = expressionLevel != "simple";
|
||
var showRichHint = expressionLevel == "rich";
|
||
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var accentColor = ((SolidColorBrush)accentBrush).Color;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B));
|
||
|
||
var container = new Border
|
||
{
|
||
Margin = expressionLevel == "simple"
|
||
? new Thickness(40, 2, 120, 6)
|
||
: new Thickness(40, 2, 80, 6),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
MaxWidth = expressionLevel == "simple" ? 460 : 560,
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(12, 10, 12, 10),
|
||
};
|
||
|
||
var outerStack = new StackPanel();
|
||
|
||
outerStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "실행 계획 승인 요청",
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
});
|
||
if (showDetailedCopy)
|
||
{
|
||
outerStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.",
|
||
FontSize = 11.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 2, 0, 8),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
if (showDetailedCopy && options.Count > 0)
|
||
{
|
||
var optionCandidates = new List<string>();
|
||
foreach (var option in options)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(option))
|
||
continue;
|
||
|
||
optionCandidates.Add(option.Trim());
|
||
if (optionCandidates.Count >= 3)
|
||
break;
|
||
}
|
||
var optionHint = string.Join(" · ", optionCandidates);
|
||
if (!string.IsNullOrWhiteSpace(optionHint))
|
||
{
|
||
outerStack.Children.Add(new TextBlock
|
||
{
|
||
Text = $"선택지: {optionHint}",
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
}
|
||
if (showRichHint)
|
||
{
|
||
outerStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.",
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
// 버튼 행
|
||
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) };
|
||
|
||
// 승인 버튼 (강조)
|
||
var approveBtn = new Border
|
||
{
|
||
Background = accentBrush,
|
||
CornerRadius = new CornerRadius(16),
|
||
Padding = new Thickness(16, 7, 16, 7),
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var approveSp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
approveSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
|
||
Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
|
||
});
|
||
approveSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "승인 후 실행",
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = Brushes.White
|
||
});
|
||
approveBtn.Child = approveSp;
|
||
ApplyMenuItemHover(approveBtn);
|
||
approveBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush);
|
||
tcs.TrySetResult(null); // null = 승인
|
||
};
|
||
btnRow.Children.Add(approveBtn);
|
||
|
||
// 수정 요청 버튼
|
||
var editBtn = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(16),
|
||
Padding = new Thickness(14, 7, 14, 7),
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
Cursor = Cursors.Hand,
|
||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
|
||
BorderThickness = new Thickness(1),
|
||
};
|
||
var editSp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
editSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
|
||
Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
|
||
});
|
||
editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush });
|
||
editBtn.Child = editSp;
|
||
ApplyMenuItemHover(editBtn);
|
||
|
||
// 수정 요청용 텍스트 입력 패널 (초기 숨김)
|
||
var editInputPanel = new Border
|
||
{
|
||
Visibility = Visibility.Collapsed,
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
var editInputStack = new StackPanel();
|
||
editInputStack.Children.Add(new TextBlock
|
||
{
|
||
Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:",
|
||
FontSize = 11.5, Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
});
|
||
var editTextBox = new TextBox
|
||
{
|
||
MinHeight = 36,
|
||
MaxHeight = 100,
|
||
AcceptsReturn = true,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
FontSize = 12.5,
|
||
Background = itemBg,
|
||
Foreground = primaryText,
|
||
CaretBrush = primaryText,
|
||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
};
|
||
editInputStack.Children.Add(editTextBox);
|
||
|
||
var submitEditBtn = new Border
|
||
{
|
||
Background = accentBrush,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(12, 5, 12, 5),
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
Cursor = Cursors.Hand,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
};
|
||
submitEditBtn.Child = new TextBlock
|
||
{
|
||
Text = "피드백 전송",
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = Brushes.White
|
||
};
|
||
ApplyHoverScaleAnimation(submitEditBtn, 1.05);
|
||
submitEditBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
var feedback = editTextBox.Text.Trim();
|
||
if (string.IsNullOrEmpty(feedback)) return;
|
||
CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush);
|
||
tcs.TrySetResult(feedback);
|
||
};
|
||
editInputStack.Children.Add(submitEditBtn);
|
||
editInputPanel.Child = editInputStack;
|
||
|
||
editBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible
|
||
? Visibility.Collapsed : Visibility.Visible;
|
||
if (editInputPanel.Visibility == Visibility.Visible)
|
||
editTextBox.Focus();
|
||
};
|
||
btnRow.Children.Add(editBtn);
|
||
|
||
// 취소 버튼
|
||
var cancelBtn = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(16),
|
||
Padding = new Thickness(14, 7, 14, 7),
|
||
Cursor = Cursors.Hand,
|
||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)),
|
||
BorderThickness = new Thickness(1),
|
||
};
|
||
var cancelSp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
cancelSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
|
||
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
|
||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
|
||
});
|
||
cancelSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold,
|
||
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
|
||
});
|
||
cancelBtn.Child = cancelSp;
|
||
ApplyMenuItemHover(cancelBtn);
|
||
cancelBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
CollapseDecisionButtons(outerStack, "✕ 취소됨",
|
||
new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)));
|
||
tcs.TrySetResult("취소");
|
||
};
|
||
btnRow.Children.Add(cancelBtn);
|
||
|
||
outerStack.Children.Add(btnRow);
|
||
outerStack.Children.Add(editInputPanel);
|
||
container.Child = outerStack;
|
||
|
||
// 슬라이드 + 페이드 등장 애니메이션
|
||
ApplyMessageEntryAnimation(container);
|
||
MessagePanel.Children.Add(container);
|
||
ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동
|
||
|
||
// PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기
|
||
var capturedOuterStack = outerStack;
|
||
var capturedAccent = accentBrush;
|
||
_ = tcs.Task.ContinueWith(t =>
|
||
{
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
// 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우)
|
||
if (capturedOuterStack.Children.Count <= 1) return;
|
||
|
||
var label = t.Result == null ? "✓ 승인됨"
|
||
: t.Result == "취소" ? "✕ 취소됨"
|
||
: "✎ 수정 요청됨";
|
||
var fg = t.Result == "취소"
|
||
? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
|
||
: capturedAccent;
|
||
CollapseDecisionButtons(capturedOuterStack, label, fg);
|
||
});
|
||
}, TaskScheduler.Default);
|
||
}
|
||
|
||
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
|
||
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
|
||
{
|
||
outerStack.Children.Clear();
|
||
var resultLabel = new TextBlock
|
||
{
|
||
Text = resultText,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = fg,
|
||
Opacity = 0.8,
|
||
Margin = new Thickness(0, 2, 0, 2),
|
||
};
|
||
outerStack.Children.Add(resultLabel);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 후속 작업 제안 칩 (suggest_actions)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
|
||
private void RenderSuggestActionChips(string jsonSummary)
|
||
{
|
||
List<(string label, string command)> actions = new();
|
||
try
|
||
{
|
||
if (jsonSummary.Contains("\"label\""))
|
||
{
|
||
using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary);
|
||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||
{
|
||
foreach (var item in doc.RootElement.EnumerateArray())
|
||
{
|
||
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
|
||
var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label;
|
||
if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd));
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 줄바꿈 형식: "1. label → command"
|
||
foreach (var line in jsonSummary.Split('\n'))
|
||
{
|
||
var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' ');
|
||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||
var parts = trimmed.Split('→', ':', '—');
|
||
if (parts.Length >= 2)
|
||
actions.Add((parts[0].Trim(), parts[1].Trim()));
|
||
else if (!string.IsNullOrEmpty(trimmed))
|
||
actions.Add((trimmed, trimmed));
|
||
}
|
||
}
|
||
}
|
||
catch { return; }
|
||
|
||
if (actions.Count == 0) return;
|
||
var preview = string.Join(", ", actions.Take(3).Select(static action => action.label));
|
||
var suffix = actions.Count > 3 ? $" 외 {actions.Count - 3}개" : "";
|
||
ShowToast($"다음 작업 제안 준비됨: {preview}{suffix}");
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 피드백 학습 반영 (J)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다.</summary>
|
||
private string BuildFeedbackContext()
|
||
{
|
||
try
|
||
{
|
||
var recentConversations = _storage.LoadAllMeta()
|
||
.OrderByDescending(m => m.UpdatedAt)
|
||
.Take(20)
|
||
.ToList();
|
||
|
||
var likedPatterns = new List<string>();
|
||
var dislikedPatterns = new List<string>();
|
||
|
||
foreach (var meta in recentConversations)
|
||
{
|
||
var conv = _storage.Load(meta.Id);
|
||
if (conv == null) continue;
|
||
|
||
foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null))
|
||
{
|
||
// 첫 50자로 패턴 파악
|
||
var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? "";
|
||
if (msg.Feedback == "like")
|
||
likedPatterns.Add(preview);
|
||
else if (msg.Feedback == "dislike")
|
||
dislikedPatterns.Add(preview);
|
||
}
|
||
}
|
||
|
||
if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0)
|
||
return "";
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("\n[사용자 선호도 참고]");
|
||
if (likedPatterns.Count > 0)
|
||
{
|
||
sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):");
|
||
foreach (var p in likedPatterns.Take(5))
|
||
sb.AppendLine($" - \"{p}...\"");
|
||
}
|
||
if (dislikedPatterns.Count > 0)
|
||
{
|
||
sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):");
|
||
foreach (var p in dislikedPatterns.Take(5))
|
||
sb.AppendLine($" - \"{p}...\"");
|
||
}
|
||
sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요.");
|
||
return sb.ToString();
|
||
}
|
||
catch { return ""; }
|
||
}
|
||
|
||
/// <summary>진행률 바와 단계 상태를 업데이트합니다.</summary>
|
||
private void UpdateProgressBar(AgentEvent evt)
|
||
{
|
||
if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null)
|
||
return;
|
||
|
||
var stepIdx = evt.StepCurrent - 1; // 0-based
|
||
var total = evt.StepTotal;
|
||
|
||
// 진행률 바 업데이트
|
||
_planProgressBar.Value = evt.StepCurrent;
|
||
var pct = (int)((double)evt.StepCurrent / total * 100);
|
||
_planProgressText.Text = $"{pct}%";
|
||
|
||
// 이전 단계 완료 표시 + 현재 단계 강조
|
||
for (int i = 0; i < _planStepsPanel.Children.Count; i++)
|
||
{
|
||
if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2)
|
||
{
|
||
var statusTb = row.Children[0] as TextBlock;
|
||
var textTb = row.Children[1] as TextBlock;
|
||
if (statusTb == null || textTb == null) continue;
|
||
|
||
if (i < stepIdx)
|
||
{
|
||
// 완료
|
||
statusTb.Text = "●";
|
||
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A"));
|
||
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280"));
|
||
}
|
||
else if (i == stepIdx)
|
||
{
|
||
// 현재 진행 중
|
||
statusTb.Text = "◉";
|
||
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC"));
|
||
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B"));
|
||
textTb.FontWeight = FontWeights.SemiBold;
|
||
}
|
||
else
|
||
{
|
||
// 대기
|
||
statusTb.Text = "○";
|
||
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF"));
|
||
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563"));
|
||
textTb.FontWeight = FontWeights.Normal;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다.</summary>
|
||
private static UIElement BuildDiffView(string text)
|
||
{
|
||
var panel = new StackPanel
|
||
{
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")),
|
||
MaxWidth = 520,
|
||
};
|
||
|
||
var diffStarted = false;
|
||
foreach (var rawLine in text.Split('\n'))
|
||
{
|
||
var line = rawLine.TrimEnd('\r');
|
||
|
||
// diff 헤더 전의 일반 텍스트
|
||
if (!diffStarted && !line.StartsWith("--- "))
|
||
{
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = line,
|
||
FontSize = 11,
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
|
||
FontFamily = new FontFamily("Consolas"),
|
||
Margin = new Thickness(0, 0, 0, 1),
|
||
});
|
||
continue;
|
||
}
|
||
diffStarted = true;
|
||
|
||
string bgHex, fgHex;
|
||
if (line.StartsWith("---") || line.StartsWith("+++"))
|
||
{
|
||
bgHex = "#F3F4F6"; fgHex = "#374151";
|
||
}
|
||
else if (line.StartsWith("@@"))
|
||
{
|
||
bgHex = "#EFF6FF"; fgHex = "#3B82F6";
|
||
}
|
||
else if (line.StartsWith("+"))
|
||
{
|
||
bgHex = "#ECFDF5"; fgHex = "#059669";
|
||
}
|
||
else if (line.StartsWith("-"))
|
||
{
|
||
bgHex = "#FEF2F2"; fgHex = "#DC2626";
|
||
}
|
||
else
|
||
{
|
||
bgHex = "Transparent"; fgHex = "#6B7280";
|
||
}
|
||
|
||
var tb = new TextBlock
|
||
{
|
||
Text = line,
|
||
FontSize = 10.5,
|
||
FontFamily = new FontFamily("Consolas"),
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
|
||
Padding = new Thickness(4, 1, 4, 1),
|
||
};
|
||
if (bgHex != "Transparent")
|
||
tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex));
|
||
|
||
panel.Children.Add(tb);
|
||
}
|
||
|
||
return panel;
|
||
}
|
||
|
||
private sealed class ReviewSignalSummary
|
||
{
|
||
public int P0 { get; init; }
|
||
public int P1 { get; init; }
|
||
public int P2 { get; init; }
|
||
public int P3 { get; init; }
|
||
public bool HasFixed { get; init; }
|
||
public bool HasUnfixed { get; init; }
|
||
public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed;
|
||
}
|
||
|
||
private static ReviewSignalSummary ExtractReviewSignals(string? text)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return new ReviewSignalSummary();
|
||
|
||
var source = text!;
|
||
var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈");
|
||
var hasFixed = ContainsWholeWord(source, "fixed")
|
||
|| ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료");
|
||
|
||
return new ReviewSignalSummary
|
||
{
|
||
P0 = CountToken(source, "P0"),
|
||
P1 = CountToken(source, "P1"),
|
||
P2 = CountToken(source, "P2"),
|
||
P3 = CountToken(source, "P3"),
|
||
HasUnfixed = hasUnfixed,
|
||
HasFixed = hasFixed,
|
||
};
|
||
}
|
||
|
||
private static int CountToken(string source, string token)
|
||
{
|
||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token))
|
||
return 0;
|
||
|
||
var count = 0;
|
||
var index = 0;
|
||
while (index < source.Length)
|
||
{
|
||
var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase);
|
||
if (hit < 0)
|
||
break;
|
||
|
||
var before = hit == 0 ? ' ' : source[hit - 1];
|
||
var afterIndex = hit + token.Length;
|
||
var after = afterIndex >= source.Length ? ' ' : source[afterIndex];
|
||
if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after))
|
||
count++;
|
||
|
||
index = hit + token.Length;
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
private static bool ContainsAny(string source, params string[] needles)
|
||
{
|
||
foreach (var needle in needles)
|
||
{
|
||
if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static bool ContainsWholeWord(string source, string token)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token))
|
||
return false;
|
||
|
||
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b";
|
||
return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
}
|
||
|
||
private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(toolName) &&
|
||
(toolName.Contains("review", StringComparison.OrdinalIgnoreCase) ||
|
||
toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase)))
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(title) &&
|
||
title.Contains("review", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(summary) &&
|
||
(summary.Contains("P0", StringComparison.OrdinalIgnoreCase) ||
|
||
summary.Contains("P1", StringComparison.OrdinalIgnoreCase) ||
|
||
summary.Contains("P2", StringComparison.OrdinalIgnoreCase) ||
|
||
summary.Contains("P3", StringComparison.OrdinalIgnoreCase)))
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex)
|
||
{
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex(bgHex),
|
||
BorderBrush = BrushFromHex(borderHex),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
Padding = new Thickness(8, 2, 8, 2),
|
||
Child = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex(fgHex),
|
||
}
|
||
};
|
||
}
|
||
|
||
private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary)
|
||
{
|
||
if (!IsReviewContext(kind, toolName, title, summary))
|
||
return null;
|
||
|
||
var signals = ExtractReviewSignals(summary);
|
||
if (!signals.HasAny)
|
||
return null;
|
||
|
||
var row = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
|
||
if (signals.P0 > 0)
|
||
row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5"));
|
||
if (signals.P1 > 0)
|
||
row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74"));
|
||
if (signals.P2 > 0)
|
||
row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A"));
|
||
if (signals.P3 > 0)
|
||
row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD"));
|
||
|
||
if (signals.HasFixed)
|
||
row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC"));
|
||
if (signals.HasUnfixed)
|
||
row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5"));
|
||
|
||
return row.Children.Count == 0 ? null : row;
|
||
}
|
||
|
||
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
|
||
private StackPanel BuildFileQuickActions(string filePath)
|
||
{
|
||
var panel = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(8, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
|
||
var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6");
|
||
var accentBrush = new SolidColorBrush(accentColor);
|
||
|
||
Border MakeBtn(string mdlIcon, string tooltip, Action action)
|
||
{
|
||
var icon = new TextBlock
|
||
{
|
||
Text = mdlIcon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10,
|
||
Foreground = accentBrush,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
var btn = new Border
|
||
{
|
||
Child = icon,
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(4),
|
||
Width = 22,
|
||
Height = 22,
|
||
Margin = new Thickness(0, 0, 2, 0),
|
||
Cursor = Cursors.Hand,
|
||
ToolTip = tooltip,
|
||
};
|
||
btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); };
|
||
btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
btn.MouseLeftButtonUp += (_, _) => action();
|
||
return btn;
|
||
}
|
||
|
||
// 프리뷰 (지원 확장자만)
|
||
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
|
||
if (_previewableExtensions.Contains(ext))
|
||
{
|
||
var path1 = filePath;
|
||
panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1)));
|
||
}
|
||
|
||
// 외부 열기
|
||
var path2 = filePath;
|
||
panel.Children.Add(MakeBtn("\uE8A7", "열기", () =>
|
||
{
|
||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { }
|
||
}));
|
||
|
||
// 폴더 열기
|
||
var path3 = filePath;
|
||
panel.Children.Add(MakeBtn("\uED25", "폴더", () =>
|
||
{
|
||
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { }
|
||
}));
|
||
|
||
// 경로 복사
|
||
var path4 = filePath;
|
||
panel.Children.Add(MakeBtn("\uE8C8", "복사", () =>
|
||
{
|
||
try { Clipboard.SetText(path4); } catch { }
|
||
}));
|
||
|
||
return panel;
|
||
}
|
||
|
||
// ─── 응답 재생성 ──────────────────────────────────────────────────────
|
||
|
||
private async Task RegenerateLastAsync()
|
||
{
|
||
if (_isStreaming) return;
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null) return;
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
// 마지막 assistant 메시지 제거
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.RemoveLastAssistantMessage(_activeTab, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
|
||
{
|
||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||
}
|
||
}
|
||
|
||
RenderMessages(preserveViewport: true);
|
||
AutoScrollIfNeeded();
|
||
|
||
// 재전송
|
||
await SendRegenerateAsync(conv);
|
||
}
|
||
|
||
/// <summary>"수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.</summary>
|
||
private void ShowRetryWithFeedbackInput()
|
||
{
|
||
if (_isStreaming) return;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
var container = new Border
|
||
{
|
||
Margin = new Thickness(40, 4, 40, 8),
|
||
Padding = new Thickness(14, 10, 14, 10),
|
||
CornerRadius = new CornerRadius(12),
|
||
Background = itemBg,
|
||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = "어떻게 수정하면 좋을지 알려주세요:",
|
||
FontSize = 12,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
});
|
||
|
||
var textBox = new TextBox
|
||
{
|
||
MinHeight = 38,
|
||
MaxHeight = 80,
|
||
AcceptsReturn = true,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
FontSize = 13,
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
|
||
Foreground = primaryText,
|
||
CaretBrush = primaryText,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(10, 6, 10, 6),
|
||
};
|
||
stack.Children.Add(textBox);
|
||
|
||
var btnRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
|
||
var sendBtn = new Border
|
||
{
|
||
Background = accentBrush,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(14, 6, 14, 6),
|
||
Cursor = Cursors.Hand,
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
};
|
||
sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
|
||
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
||
sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||
sendBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
var feedback = textBox.Text.Trim();
|
||
if (string.IsNullOrEmpty(feedback)) return;
|
||
MessagePanel.Children.Remove(container);
|
||
_ = RetryWithFeedbackAsync(feedback);
|
||
};
|
||
|
||
var cancelBtn = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(12, 6, 12, 6),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText };
|
||
cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container);
|
||
|
||
btnRow.Children.Add(cancelBtn);
|
||
btnRow.Children.Add(sendBtn);
|
||
stack.Children.Add(btnRow);
|
||
container.Child = stack;
|
||
|
||
ApplyMessageEntryAnimation(container);
|
||
MessagePanel.Children.Add(container);
|
||
ForceScrollToEnd();
|
||
textBox.Focus();
|
||
}
|
||
|
||
/// <summary>사용자 피드백과 함께 마지막 응답을 재생성합니다.</summary>
|
||
private async Task RetryWithFeedbackAsync(string feedback)
|
||
{
|
||
if (_isStreaming) return;
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null) return;
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
// 마지막 assistant 메시지 제거
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.RemoveLastAssistantMessage(_activeTab, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
|
||
{
|
||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||
}
|
||
}
|
||
|
||
// 피드백을 사용자 메시지로 추가
|
||
var feedbackMsg = new ChatMessage
|
||
{
|
||
Role = "user",
|
||
Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요."
|
||
};
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(_activeTab, feedbackMsg, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(feedbackMsg);
|
||
}
|
||
}
|
||
|
||
RenderMessages(preserveViewport: true);
|
||
AutoScrollIfNeeded();
|
||
|
||
// 재전송
|
||
await SendRegenerateAsync(conv);
|
||
}
|
||
|
||
private async Task SendRegenerateAsync(ChatConversation conv)
|
||
{
|
||
var runTab = NormalizeTabName(conv.Tab);
|
||
AxAgentExecutionEngine.PreparedExecution preparedExecution;
|
||
lock (_convLock)
|
||
preparedExecution = PrepareExecutionForConversation(conv, runTab, null);
|
||
await ExecutePreparedTurnAsync(
|
||
conv,
|
||
runTab,
|
||
conv.Tab ?? _activeTab,
|
||
preparedExecution,
|
||
"에이전트 작업 중...");
|
||
}
|
||
|
||
/// <summary>채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다.</summary>
|
||
private double GetMessageMaxWidth()
|
||
{
|
||
var hostWidth = _lastResponsiveComposerWidth;
|
||
if (hostWidth < 100)
|
||
hostWidth = ComposerShell?.ActualWidth ?? 0;
|
||
if (hostWidth < 100)
|
||
hostWidth = MessageScroll?.ActualWidth ?? 0;
|
||
if (hostWidth < 100)
|
||
hostWidth = 1120;
|
||
|
||
var maxW = hostWidth - 44;
|
||
return Math.Clamp(maxW, 320, 760);
|
||
}
|
||
|
||
private bool UpdateResponsiveChatLayout()
|
||
{
|
||
var viewportWidth = MessageScroll?.ActualWidth ?? 0;
|
||
if (viewportWidth < 200)
|
||
viewportWidth = ActualWidth;
|
||
if (viewportWidth < 200)
|
||
return false;
|
||
|
||
// claw-code처럼 메시지 축과 입력축이 같은 중심선을 공유하도록,
|
||
// 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다.
|
||
var contentWidth = Math.Max(360, viewportWidth - 24);
|
||
var messageWidth = Math.Clamp(contentWidth * 0.9, 360, 960);
|
||
var composerWidth = Math.Clamp(contentWidth * 0.86, 360, 900);
|
||
|
||
if (contentWidth < 760)
|
||
{
|
||
messageWidth = Math.Clamp(contentWidth - 10, 344, 820);
|
||
composerWidth = Math.Clamp(contentWidth - 14, 340, 780);
|
||
}
|
||
|
||
var changed = false;
|
||
if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1)
|
||
{
|
||
_lastResponsiveMessageWidth = messageWidth;
|
||
if (MessagePanel != null)
|
||
MessagePanel.MaxWidth = messageWidth;
|
||
if (EmptyState != null)
|
||
EmptyState.MaxWidth = messageWidth;
|
||
changed = true;
|
||
}
|
||
|
||
if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 1)
|
||
{
|
||
_lastResponsiveComposerWidth = composerWidth;
|
||
if (ComposerShell != null)
|
||
{
|
||
ComposerShell.Width = composerWidth;
|
||
ComposerShell.MaxWidth = composerWidth;
|
||
}
|
||
changed = true;
|
||
}
|
||
|
||
return changed;
|
||
}
|
||
|
||
private StackPanel CreateStreamingContainer(out TextBlock streamText)
|
||
{
|
||
var msgMaxWidth = GetMessageMaxWidth();
|
||
var container = new StackPanel
|
||
{
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Width = msgMaxWidth,
|
||
MaxWidth = msgMaxWidth,
|
||
Margin = new Thickness(0, 3, 0, 3),
|
||
Opacity = 0,
|
||
RenderTransform = new TranslateTransform(0, 10)
|
||
};
|
||
|
||
// 컨테이너 페이드인 + 슬라이드 업
|
||
container.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
|
||
((TranslateTransform)container.RenderTransform).BeginAnimation(
|
||
TranslateTransform.YProperty,
|
||
new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
|
||
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
|
||
|
||
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) };
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
|
||
var aiIcon = new TextBlock
|
||
{
|
||
Text = "\uE945", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8,
|
||
Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||
VerticalAlignment = VerticalAlignment.Center
|
||
};
|
||
// AI 아이콘 펄스 애니메이션 (응답 대기 중)
|
||
aiIcon.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
|
||
{ AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
|
||
EasingFunction = new SineEase() });
|
||
_activeAiIcon = aiIcon;
|
||
Grid.SetColumn(aiIcon, 0);
|
||
headerGrid.Children.Add(aiIcon);
|
||
|
||
var (streamAgentName, _, _) = GetAgentIdentity();
|
||
var aiNameTb = new TextBlock
|
||
{
|
||
Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
|
||
};
|
||
Grid.SetColumn(aiNameTb, 1);
|
||
headerGrid.Children.Add(aiNameTb);
|
||
|
||
// 실시간 경과 시간 (헤더 우측)
|
||
_elapsedLabel = new TextBlock
|
||
{
|
||
Text = "0s",
|
||
FontSize = 9.5,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Opacity = 0.5,
|
||
};
|
||
Grid.SetColumn(_elapsedLabel, 2);
|
||
headerGrid.Children.Add(_elapsedLabel);
|
||
|
||
container.Children.Add(headerGrid);
|
||
|
||
var streamCard = new Border
|
||
{
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(13, 10, 13, 10)
|
||
};
|
||
streamText = new TextBlock
|
||
{
|
||
Text = "\u258c",
|
||
FontSize = 12.5,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
LineHeight = 20,
|
||
};
|
||
streamCard.Child = streamText;
|
||
container.Children.Add(streamCard);
|
||
return container;
|
||
}
|
||
|
||
// ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
|
||
|
||
private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
|
||
{
|
||
finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent;
|
||
|
||
// 스트리밍 plaintext 카드 제거
|
||
if (streamText.Parent is Border streamCard)
|
||
container.Children.Remove(streamCard);
|
||
else
|
||
container.Children.Remove(streamText);
|
||
|
||
// 마크다운 렌더링
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
||
|
||
var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||
mdPanel.Margin = new Thickness(0, 0, 0, 4);
|
||
mdPanel.Opacity = 0;
|
||
var mdCard = new Border
|
||
{
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(13, 10, 13, 10),
|
||
Child = mdPanel,
|
||
};
|
||
container.Children.Add(mdCard);
|
||
mdPanel.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
|
||
|
||
// 액션 버튼 바 + 토큰 표시
|
||
var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var capturedContent = finalContent;
|
||
var actionBar = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Margin = new Thickness(2, 2, 0, 0),
|
||
Opacity = 0
|
||
};
|
||
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
|
||
{
|
||
try { Clipboard.SetText(capturedContent); } catch { }
|
||
}));
|
||
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
|
||
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
|
||
AddLinkedFeedbackButtons(actionBar, btnColor, message);
|
||
|
||
container.Children.Add(actionBar);
|
||
container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
|
||
container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
|
||
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
|
||
|
||
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
|
||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||
var elapsedText = elapsed.TotalSeconds < 60
|
||
? $"{elapsed.TotalSeconds:0.#}s"
|
||
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
|
||
|
||
var usage = _llm.LastTokenUsage;
|
||
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
|
||
var isAgentTab = _activeTab is "Cowork" or "Code";
|
||
var (usageService, usageModel) = _llm.GetCurrentModelInfo();
|
||
var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
|
||
? _agentCumulativeInputTokens
|
||
: usage?.PromptTokens ?? 0;
|
||
var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
|
||
? _agentCumulativeOutputTokens
|
||
: usage?.CompletionTokens ?? 0;
|
||
|
||
var wasPostCompactionResponse = _pendingPostCompaction;
|
||
if (displayInput > 0 || displayOutput > 0)
|
||
{
|
||
UpdateStatusTokens(displayInput, displayOutput);
|
||
Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse);
|
||
ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput);
|
||
}
|
||
string tokenText;
|
||
if (displayInput > 0 || displayOutput > 0)
|
||
tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
|
||
else if (usage != null)
|
||
tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
|
||
else
|
||
tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
|
||
|
||
var postCompactSuffix = wasPostCompactionResponse ? " · compact 직후" : "";
|
||
var metaText = new TextBlock
|
||
{
|
||
Text = $"{elapsedText} · {tokenText}{postCompactSuffix}",
|
||
FontSize = 9.5,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
Opacity = 0.55,
|
||
};
|
||
container.Children.Add(metaText);
|
||
|
||
// Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
|
||
var chips = ParseSuggestionChips(finalContent);
|
||
if (chips.Count > 0)
|
||
{
|
||
var chipPanel = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 8, 0, 4),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
};
|
||
foreach (var (num, label) in chips)
|
||
{
|
||
var chipBorder = new Border
|
||
{
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(16),
|
||
Padding = new Thickness(14, 7, 14, 7),
|
||
Margin = new Thickness(0, 0, 8, 6),
|
||
Cursor = Cursors.Hand,
|
||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||
RenderTransform = new ScaleTransform(1, 1),
|
||
};
|
||
chipBorder.Child = new TextBlock
|
||
{
|
||
Text = $"{num}. {label}",
|
||
FontSize = 12.5,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||
};
|
||
|
||
var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
|
||
chipBorder.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{ st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
|
||
};
|
||
chipBorder.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
|
||
};
|
||
|
||
var capturedLabel = $"{num}. {label}";
|
||
var capturedPanel = chipPanel;
|
||
chipBorder.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
// 칩 패널 제거 (1회용)
|
||
if (capturedPanel.Parent is Panel parent)
|
||
parent.Children.Remove(capturedPanel);
|
||
// 선택한 옵션을 사용자 메시지로 전송
|
||
InputBox.Text = capturedLabel;
|
||
_ = SendMessageAsync();
|
||
};
|
||
chipPanel.Children.Add(chipBorder);
|
||
}
|
||
container.Children.Add(chipPanel);
|
||
}
|
||
}
|
||
|
||
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
|
||
private static List<(string Num, string Label)> ParseSuggestionChips(string content)
|
||
{
|
||
var chips = new List<(string, string)>();
|
||
if (string.IsNullOrEmpty(content)) return chips;
|
||
|
||
var lines = content.Split('\n');
|
||
// 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
|
||
var candidates = new List<(string, string)>();
|
||
var lastBlockStart = -1;
|
||
|
||
for (int i = 0; i < lines.Length; i++)
|
||
{
|
||
var line = lines[i].Trim();
|
||
// "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
|
||
var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
|
||
if (m.Success)
|
||
{
|
||
if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
|
||
{
|
||
if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
|
||
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
|
||
}
|
||
else
|
||
{
|
||
// 새로운 블록 시작
|
||
lastBlockStart = i;
|
||
candidates.Clear();
|
||
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
|
||
}
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(line))
|
||
{
|
||
// 번호 목록이 아닌 줄이 나오면 블록 리셋
|
||
lastBlockStart = -1;
|
||
candidates.Clear();
|
||
}
|
||
// 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
|
||
}
|
||
|
||
// 2개 이상 선택지, 10개 이하일 때만 chips로 표시
|
||
if (candidates.Count >= 2 && candidates.Count <= 10)
|
||
chips.AddRange(candidates);
|
||
|
||
return chips;
|
||
}
|
||
|
||
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
|
||
private static string FormatTokenCount(int count) => count switch
|
||
{
|
||
>= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
|
||
>= 1_000 => $"{count / 1_000.0:0.#}k",
|
||
_ => count.ToString(),
|
||
};
|
||
|
||
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
|
||
private static int EstimateTokenCount(string text)
|
||
{
|
||
if (string.IsNullOrEmpty(text)) return 0;
|
||
// 한국어 문자 비율에 따라 가중
|
||
int cjk = 0;
|
||
foreach (var c in text)
|
||
if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
|
||
double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
|
||
double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
|
||
return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
|
||
}
|
||
|
||
// ─── 생성 중지 ──────────────────────────────────────────────────────
|
||
|
||
private void StopGeneration()
|
||
{
|
||
_streamCts?.Cancel();
|
||
}
|
||
|
||
// ─── 대화 내보내기 ──────────────────────────────────────────────────
|
||
|
||
// ─── 대화 분기 (Fork) ──────────────────────────────────────────────
|
||
|
||
private void ForkConversation(
|
||
ChatConversation source,
|
||
int atIndex,
|
||
string? branchHint = null,
|
||
string? branchContextMessage = null,
|
||
string? branchContextRunId = null)
|
||
{
|
||
var branchCount = _storage.LoadAllMeta()
|
||
.Count(m => m.ParentId == source.Id) + 1;
|
||
var fork = ChatSession?.CreateBranchConversation(source, atIndex, branchCount, branchHint, branchContextMessage, branchContextRunId)
|
||
?? new ChatConversation
|
||
{
|
||
Title = source.Title,
|
||
Tab = source.Tab,
|
||
Category = source.Category,
|
||
WorkFolder = source.WorkFolder,
|
||
SystemCommand = source.SystemCommand,
|
||
ParentId = source.Id,
|
||
BranchLabel = $"분기 {branchCount}",
|
||
BranchAtIndex = atIndex,
|
||
};
|
||
|
||
try
|
||
{
|
||
_storage.Save(fork);
|
||
ShowToast($"분기 생성: {fork.Title}");
|
||
|
||
// 분기 대화로 전환
|
||
lock (_convLock)
|
||
{
|
||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, fork, _storage) ?? fork;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
ChatTitle.Text = fork.Title;
|
||
RenderMessages();
|
||
RefreshConversationList();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ShowToast($"분기 실패: {ex.Message}", "\uE783");
|
||
}
|
||
}
|
||
|
||
// ─── 커맨드 팔레트 ─────────────────────────────────────────────────
|
||
|
||
private void OpenCommandPalette()
|
||
{
|
||
var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this };
|
||
palette.ShowDialog();
|
||
}
|
||
|
||
private void ExecuteCommand(string commandId)
|
||
{
|
||
switch (commandId)
|
||
{
|
||
case "tab:chat": TabChat.IsChecked = true; break;
|
||
case "tab:cowork": TabCowork.IsChecked = true; break;
|
||
case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break;
|
||
case "new_conversation": StartNewConversation(); break;
|
||
case "search_conversation": ToggleMessageSearch(); break;
|
||
case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break;
|
||
case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break;
|
||
case "open_statistics": new StatisticsWindow().Show(); break;
|
||
case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break;
|
||
case "toggle_devmode":
|
||
var llm = _settings.Settings.Llm;
|
||
llm.DevMode = !llm.DevMode;
|
||
_settings.Save();
|
||
UpdateAnalyzerButtonVisibility();
|
||
ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
|
||
break;
|
||
case "open_audit_log":
|
||
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
|
||
break;
|
||
case "paste_clipboard":
|
||
try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { }
|
||
break;
|
||
case "export_conversation": ExportConversation(); break;
|
||
}
|
||
}
|
||
|
||
private void ExportConversation()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null || conv.Messages.Count == 0) return;
|
||
|
||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||
{
|
||
FileName = $"{conv.Title}",
|
||
DefaultExt = ".md",
|
||
Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt"
|
||
};
|
||
if (dlg.ShowDialog() != true) return;
|
||
|
||
var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant();
|
||
string content;
|
||
|
||
if (ext == ".json")
|
||
{
|
||
content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions
|
||
{
|
||
WriteIndented = true,
|
||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||
});
|
||
}
|
||
else if (dlg.FileName.EndsWith(".pdf.html"))
|
||
{
|
||
// PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시
|
||
content = PdfExportService.BuildHtml(conv);
|
||
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
|
||
PdfExportService.OpenInBrowser(dlg.FileName);
|
||
ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다");
|
||
return;
|
||
}
|
||
else if (ext == ".html")
|
||
{
|
||
content = ExportToHtml(conv);
|
||
}
|
||
else
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine($"# {conv.Title}");
|
||
sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_");
|
||
sb.AppendLine();
|
||
|
||
foreach (var msg in conv.Messages)
|
||
{
|
||
if (msg.Role == "system") continue;
|
||
var label = msg.Role == "user" ? "**사용자**" : "**AI**";
|
||
sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})");
|
||
sb.AppendLine();
|
||
sb.AppendLine(msg.Content);
|
||
if (msg.AttachedFiles is { Count: > 0 })
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_");
|
||
}
|
||
sb.AppendLine();
|
||
sb.AppendLine("---");
|
||
sb.AppendLine();
|
||
}
|
||
content = sb.ToString();
|
||
}
|
||
|
||
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
|
||
}
|
||
|
||
private static string ExportToHtml(ChatConversation conv)
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
|
||
sb.AppendLine($"<title>{System.Net.WebUtility.HtmlEncode(conv.Title)}</title>");
|
||
sb.AppendLine("<style>body{font-family:'Segoe UI',sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}");
|
||
sb.AppendLine(".msg{margin:12px 0;padding:12px 16px;border-radius:12px}.user{background:#2d2d5e;margin-left:60px}.ai{background:#1e1e3a;margin-right:60px}");
|
||
sb.AppendLine(".meta{font-size:11px;color:#888;margin-bottom:6px}.content{white-space:pre-wrap;line-height:1.6}");
|
||
sb.AppendLine("h1{text-align:center;color:#8b6dff}pre{background:#111;padding:12px;border-radius:8px;overflow-x:auto}</style></head><body>");
|
||
sb.AppendLine($"<h1>{System.Net.WebUtility.HtmlEncode(conv.Title)}</h1>");
|
||
sb.AppendLine($"<p style='text-align:center;color:#888'>생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}</p>");
|
||
|
||
foreach (var msg in conv.Messages)
|
||
{
|
||
if (msg.Role == "system") continue;
|
||
var cls = msg.Role == "user" ? "user" : "ai";
|
||
var label = msg.Role == "user" ? "사용자" : "AI";
|
||
sb.AppendLine($"<div class='msg {cls}'>");
|
||
sb.AppendLine($"<div class='meta'>{label} · {msg.Timestamp:HH:mm}</div>");
|
||
sb.AppendLine($"<div class='content'>{System.Net.WebUtility.HtmlEncode(msg.Content)}</div>");
|
||
sb.AppendLine("</div>");
|
||
}
|
||
|
||
sb.AppendLine("</body></html>");
|
||
return sb.ToString();
|
||
}
|
||
|
||
// ─── 버튼 이벤트 ──────────────────────────────────────────────────────
|
||
|
||
private void ChatWindow_KeyDown(object sender, KeyEventArgs e)
|
||
{
|
||
var mod = Keyboard.Modifiers;
|
||
|
||
// Ctrl 단축키
|
||
if (mod == ModifierKeys.Control)
|
||
{
|
||
switch (e.Key)
|
||
{
|
||
case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break;
|
||
case Key.W: Close(); e.Handled = true; break;
|
||
case Key.E: ExportConversation(); e.Handled = true; break;
|
||
case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break;
|
||
case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break;
|
||
case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break;
|
||
case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break;
|
||
case Key.F: ToggleMessageSearch(); e.Handled = true; break;
|
||
case Key.K: OpenSidebarSearch(); e.Handled = true; break;
|
||
case Key.D1: TabChat.IsChecked = true; e.Handled = true; break;
|
||
case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break;
|
||
case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break;
|
||
}
|
||
}
|
||
|
||
// Ctrl+Shift 단축키
|
||
if (mod == (ModifierKeys.Control | ModifierKeys.Shift))
|
||
{
|
||
switch (e.Key)
|
||
{
|
||
case Key.C:
|
||
// 마지막 AI 응답 복사
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv != null)
|
||
{
|
||
var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant");
|
||
if (lastAi != null)
|
||
try { Clipboard.SetText(lastAi.Content); } catch { }
|
||
}
|
||
e.Handled = true;
|
||
break;
|
||
case Key.R:
|
||
// 마지막 응답 재생성
|
||
_ = RegenerateLastAsync();
|
||
e.Handled = true;
|
||
break;
|
||
case Key.D:
|
||
// 모든 대화 삭제
|
||
BtnDeleteAll_Click(this, new RoutedEventArgs());
|
||
e.Handled = true;
|
||
break;
|
||
case Key.P:
|
||
// 커맨드 팔레트
|
||
OpenCommandPalette();
|
||
e.Handled = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Escape: 검색 바 닫기 또는 스트리밍 중지
|
||
if (e.Key == Key.Escape)
|
||
{
|
||
if (SidebarSearchEditor?.Visibility == Visibility.Visible) { CloseSidebarSearch(clearText: true); e.Handled = true; }
|
||
else if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; }
|
||
else if (_isStreaming) { StopGeneration(); e.Handled = true; }
|
||
}
|
||
|
||
// 슬래시 명령 팝업 키 처리
|
||
if (TryHandleSlashNavigationKey(e))
|
||
return;
|
||
|
||
if (PermissionPopup.IsOpen && e.Key == Key.Escape)
|
||
{
|
||
PermissionPopup.IsOpen = false;
|
||
e.Handled = true;
|
||
}
|
||
}
|
||
|
||
private bool TryHandleSlashNavigationKey(KeyEventArgs e)
|
||
{
|
||
if (!SlashPopup.IsOpen)
|
||
return false;
|
||
|
||
switch (e.Key)
|
||
{
|
||
case Key.Escape:
|
||
SlashPopup.IsOpen = false;
|
||
_slashPalette.SelectedIndex = -1;
|
||
e.Handled = true;
|
||
return true;
|
||
case Key.Up:
|
||
SlashPopup_ScrollByDelta(120);
|
||
e.Handled = true;
|
||
return true;
|
||
case Key.Down:
|
||
SlashPopup_ScrollByDelta(-120);
|
||
e.Handled = true;
|
||
return true;
|
||
case Key.PageUp:
|
||
SlashPopup_ScrollByDelta(600);
|
||
e.Handled = true;
|
||
return true;
|
||
case Key.PageDown:
|
||
SlashPopup_ScrollByDelta(-600);
|
||
e.Handled = true;
|
||
return true;
|
||
case Key.Home:
|
||
{
|
||
var visible = GetVisibleSlashOrderedIndices();
|
||
_slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches);
|
||
UpdateSlashSelectionVisualState();
|
||
EnsureSlashSelectionVisible();
|
||
e.Handled = true;
|
||
return true;
|
||
}
|
||
case Key.End:
|
||
{
|
||
var visible = GetVisibleSlashOrderedIndices();
|
||
_slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches);
|
||
UpdateSlashSelectionVisualState();
|
||
EnsureSlashSelectionVisible();
|
||
e.Handled = true;
|
||
return true;
|
||
}
|
||
case Key.Tab when _slashPalette.SelectedIndex >= 0:
|
||
case Key.Enter when _slashPalette.SelectedIndex >= 0:
|
||
ExecuteSlashSelectedItem();
|
||
e.Handled = true;
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration();
|
||
|
||
private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||
{
|
||
if (_agentLoop.IsPaused)
|
||
{
|
||
_agentLoop.Resume();
|
||
PauseIcon.Text = "\uE769"; // 일시정지 아이콘
|
||
BtnPause.ToolTip = "일시정지";
|
||
}
|
||
else
|
||
{
|
||
_ = _agentLoop.PauseAsync();
|
||
PauseIcon.Text = "\uE768"; // 재생 아이콘
|
||
BtnPause.ToolTip = "재개";
|
||
}
|
||
}
|
||
private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation();
|
||
|
||
// ─── 메시지 내 검색 (Ctrl+F) ─────────────────────────────────────────
|
||
|
||
private List<int> _searchMatchIndices = new();
|
||
private int _searchCurrentIndex = -1;
|
||
|
||
private void ToggleMessageSearch()
|
||
{
|
||
if (MessageSearchBar.Visibility == Visibility.Visible)
|
||
CloseMessageSearch();
|
||
else
|
||
{
|
||
MessageSearchBar.Visibility = Visibility.Visible;
|
||
SearchTextBox.Focus();
|
||
SearchTextBox.SelectAll();
|
||
}
|
||
}
|
||
|
||
private void CloseMessageSearch()
|
||
{
|
||
MessageSearchBar.Visibility = Visibility.Collapsed;
|
||
SearchTextBox.Text = "";
|
||
SearchResultCount.Text = "";
|
||
_searchMatchIndices.Clear();
|
||
_searchCurrentIndex = -1;
|
||
// 하이라이트 제거
|
||
ClearSearchHighlights();
|
||
}
|
||
|
||
private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
var query = SearchTextBox.Text.Trim();
|
||
if (string.IsNullOrEmpty(query))
|
||
{
|
||
SearchResultCount.Text = "";
|
||
_searchMatchIndices.Clear();
|
||
_searchCurrentIndex = -1;
|
||
ClearSearchHighlights();
|
||
return;
|
||
}
|
||
|
||
// 현재 대화의 메시지에서 검색
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null) return;
|
||
|
||
_searchMatchIndices.Clear();
|
||
for (int i = 0; i < conv.Messages.Count; i++)
|
||
{
|
||
if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||
_searchMatchIndices.Add(i);
|
||
}
|
||
|
||
if (_searchMatchIndices.Count > 0)
|
||
{
|
||
_searchCurrentIndex = 0;
|
||
SearchResultCount.Text = $"1/{_searchMatchIndices.Count}";
|
||
HighlightSearchResult();
|
||
}
|
||
else
|
||
{
|
||
_searchCurrentIndex = -1;
|
||
SearchResultCount.Text = "결과 없음";
|
||
}
|
||
}
|
||
|
||
private void SearchPrev_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_searchMatchIndices.Count == 0) return;
|
||
_searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count;
|
||
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
|
||
HighlightSearchResult();
|
||
}
|
||
|
||
private void SearchNext_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_searchMatchIndices.Count == 0) return;
|
||
_searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count;
|
||
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
|
||
HighlightSearchResult();
|
||
}
|
||
|
||
private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch();
|
||
|
||
private void HighlightSearchResult()
|
||
{
|
||
if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return;
|
||
var msgIndex = _searchMatchIndices[_searchCurrentIndex];
|
||
|
||
// MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤
|
||
// 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로
|
||
// (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동
|
||
if (msgIndex < MessagePanel.Children.Count)
|
||
{
|
||
var element = MessagePanel.Children[msgIndex] as FrameworkElement;
|
||
element?.BringIntoView();
|
||
}
|
||
else if (MessagePanel.Children.Count > 0)
|
||
{
|
||
// 범위 밖이면 마지막 자식으로 이동
|
||
(MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView();
|
||
}
|
||
}
|
||
|
||
private void ClearSearchHighlights()
|
||
{
|
||
// 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요
|
||
}
|
||
|
||
// ─── 메시지 우클릭 컨텍스트 메뉴 ───────────────────────────────────────
|
||
|
||
private void ShowMessageContextMenu(string content, string role)
|
||
{
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
|
||
var (popup, panel) = CreateThemedPopupMenu();
|
||
|
||
// 복사
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () =>
|
||
{
|
||
try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { }
|
||
}));
|
||
|
||
// 마크다운 복사
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () =>
|
||
{
|
||
try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { }
|
||
}));
|
||
|
||
// 인용하여 답장
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () =>
|
||
{
|
||
var quote = content.Length > 200 ? content[..200] + "..." : content;
|
||
var lines = quote.Split('\n');
|
||
var quoted = string.Join("\n", lines.Select(l => $"> {l}"));
|
||
InputBox.Text = quoted + "\n\n";
|
||
InputBox.Focus();
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
}));
|
||
|
||
AddPopupMenuSeparator(panel, borderBrush);
|
||
|
||
// 재생성 (AI 응답만)
|
||
if (role == "assistant")
|
||
{
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync()));
|
||
}
|
||
|
||
// 대화 분기 (Fork)
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () =>
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null) return;
|
||
|
||
var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content);
|
||
if (idx < 0) return;
|
||
|
||
ForkConversation(conv, idx);
|
||
}));
|
||
|
||
AddPopupMenuSeparator(panel, borderBrush);
|
||
|
||
// 이후 메시지 모두 삭제
|
||
var msgContent = content;
|
||
var msgRole = role;
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () =>
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null) return;
|
||
|
||
var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent);
|
||
if (idx < 0) return;
|
||
|
||
var removeCount = conv.Messages.Count - idx;
|
||
if (CustomMessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?",
|
||
"메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||
return;
|
||
|
||
conv.Messages.RemoveRange(idx, removeCount);
|
||
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||
RenderMessages();
|
||
ShowToast($"{removeCount}개 메시지 삭제됨");
|
||
}));
|
||
|
||
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
|
||
}
|
||
|
||
// ─── 팁 알림 ──────────────────────────────────────────────────────
|
||
|
||
private static readonly string[] Tips =
|
||
[
|
||
"💡 작업 폴더에 AGENTS.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.",
|
||
"💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.",
|
||
"💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.",
|
||
"💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.",
|
||
"💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.",
|
||
"💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.",
|
||
"💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.",
|
||
"💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.",
|
||
"💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.",
|
||
"💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.",
|
||
"💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.",
|
||
"💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)",
|
||
"💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.",
|
||
"💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.",
|
||
"💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.",
|
||
"💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.",
|
||
"💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.",
|
||
"💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.",
|
||
"💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.",
|
||
"💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.",
|
||
"💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.",
|
||
"💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.",
|
||
"💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!",
|
||
"💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.",
|
||
];
|
||
private int _tipIndex;
|
||
private DispatcherTimer? _tipDismissTimer;
|
||
|
||
private void ShowRandomTip()
|
||
{
|
||
if (!_settings.Settings.Llm.ShowTips) return;
|
||
if (_activeTab != "Cowork" && _activeTab != "Code") return;
|
||
|
||
var tip = Tips[_tipIndex % Tips.Length];
|
||
_tipIndex++;
|
||
|
||
// 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상)
|
||
ShowTip(tip);
|
||
}
|
||
|
||
private void ShowTip(string message)
|
||
{
|
||
_tipDismissTimer?.Stop();
|
||
|
||
ToastText.Text = message;
|
||
ToastIcon.Text = "\uE82F"; // 전구 아이콘
|
||
ToastBorder.Visibility = Visibility.Visible;
|
||
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
|
||
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
|
||
|
||
var duration = _settings.Settings.Llm.TipDurationSeconds;
|
||
if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음)
|
||
|
||
_tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) };
|
||
_tipDismissTimer.Tick += (_, _) =>
|
||
{
|
||
_tipDismissTimer.Stop();
|
||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
|
||
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
|
||
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
|
||
};
|
||
_tipDismissTimer.Start();
|
||
}
|
||
|
||
// ─── 프로젝트 문맥 파일 (AGENTS.md) ──────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 작업 폴더에 AGENTS.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다.
|
||
/// 프로젝트 로컬 컨텍스트 규약 파일(AGENTS.md) 형식을 사용합니다.
|
||
/// </summary>
|
||
private static string LoadProjectContext(string workFolder)
|
||
{
|
||
if (string.IsNullOrEmpty(workFolder)) return "";
|
||
|
||
// AGENTS.md 탐색 (작업 폴더 → 상위 폴더 순, 레거시 AX.md 폴백)
|
||
var searchDir = workFolder;
|
||
for (int i = 0; i < 3; i++) // 최대 3단계 상위까지
|
||
{
|
||
if (string.IsNullOrEmpty(searchDir)) break;
|
||
var agentsPath = System.IO.Path.Combine(searchDir, "AGENTS.md");
|
||
var legacyPath = System.IO.Path.Combine(searchDir, "AX.md");
|
||
var filePath = System.IO.File.Exists(agentsPath) ? agentsPath : legacyPath;
|
||
if (System.IO.File.Exists(filePath))
|
||
{
|
||
try
|
||
{
|
||
var content = System.IO.File.ReadAllText(filePath);
|
||
if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)";
|
||
var sourceName = System.IO.Path.GetFileName(filePath);
|
||
return $"\n## Project Context (from {sourceName})\n{content}\n";
|
||
}
|
||
catch { }
|
||
}
|
||
searchDir = System.IO.Directory.GetParent(searchDir)?.FullName;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
// ─── 무지개 글로우 애니메이션 ─────────────────────────────────────────
|
||
|
||
private DispatcherTimer? _rainbowTimer;
|
||
private DateTime _rainbowStartTime;
|
||
|
||
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
|
||
private void PlayRainbowGlow()
|
||
{
|
||
if (!_settings.Settings.Llm.EnableChatRainbowGlow) return;
|
||
|
||
_rainbowTimer?.Stop();
|
||
_rainbowStartTime = DateTime.UtcNow;
|
||
|
||
// 페이드인 (빠르게)
|
||
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
|
||
new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150)));
|
||
|
||
// 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속
|
||
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
|
||
_rainbowTimer.Tick += (_, _) =>
|
||
{
|
||
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
|
||
|
||
// 그라데이션 오프셋 회전
|
||
var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게)
|
||
var brush = InputGlowBorder.BorderBrush as LinearGradientBrush;
|
||
if (brush == null) return;
|
||
|
||
// 시작/끝점 회전 (원형 이동)
|
||
var angle = shift * Math.PI * 2;
|
||
brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle));
|
||
brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle));
|
||
};
|
||
_rainbowTimer.Start();
|
||
}
|
||
|
||
/// <summary>레인보우 글로우 효과를 페이드아웃하며 중지합니다.</summary>
|
||
private void StopRainbowGlow()
|
||
{
|
||
_rainbowTimer?.Stop();
|
||
_rainbowTimer = null;
|
||
if (InputGlowBorder.Opacity > 0)
|
||
{
|
||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
|
||
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
|
||
fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0;
|
||
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
|
||
}
|
||
}
|
||
|
||
// ─── 토스트 알림 ──────────────────────────────────────────────────────
|
||
|
||
private DispatcherTimer? _toastHideTimer;
|
||
|
||
private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000)
|
||
{
|
||
_toastHideTimer?.Stop();
|
||
|
||
ToastText.Text = message;
|
||
ToastIcon.Text = icon;
|
||
ToastBorder.Visibility = Visibility.Visible;
|
||
|
||
// 페이드인
|
||
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
|
||
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
|
||
|
||
// 자동 숨기기
|
||
_toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) };
|
||
_toastHideTimer.Tick += (_, _) =>
|
||
{
|
||
_toastHideTimer.Stop();
|
||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
|
||
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
|
||
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
|
||
};
|
||
_toastHideTimer.Start();
|
||
}
|
||
|
||
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
|
||
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
|
||
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
|
||
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
|
||
|
||
/// <summary>하단 바를 구성합니다 (Cowork 작업 제어 중심).</summary>
|
||
private void BuildBottomBar()
|
||
{
|
||
MoodIconPanel.Children.Clear();
|
||
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
/// <summary>Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심.</summary>
|
||
private void BuildCodeBottomBar()
|
||
{
|
||
MoodIconPanel.Children.Clear();
|
||
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private Border CreateWorkspaceFolderBarButton()
|
||
{
|
||
var currentFolder = GetCurrentWorkFolder();
|
||
var label = string.IsNullOrWhiteSpace(currentFolder)
|
||
? "워크스페이스"
|
||
: TruncateForStatus(Path.GetFileName(currentFolder.TrimEnd('\\', '/')), 18);
|
||
var tooltip = string.IsNullOrWhiteSpace(currentFolder)
|
||
? "워크스페이스 선택"
|
||
: $"워크스페이스 선택\n현재: {currentFolder}";
|
||
return CreateFolderBarButton("\uE8B7", label, tooltip, "#4B5EFC");
|
||
}
|
||
|
||
private string GetWorktreeModeLabel()
|
||
{
|
||
var folder = GetCurrentWorkFolder();
|
||
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||
return "로컬";
|
||
|
||
var root = WorktreeStateStore.ResolveRoot(folder);
|
||
var active = WorktreeStateStore.Load(root).Active;
|
||
return string.Equals(Path.GetFullPath(active), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)
|
||
? "로컬"
|
||
: "워크트리";
|
||
}
|
||
|
||
private List<string> GetAvailableWorkspaceVariants(string root, string? active)
|
||
{
|
||
var variants = new List<string>();
|
||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||
return variants;
|
||
|
||
try
|
||
{
|
||
var parent = Directory.GetParent(root)?.FullName ?? root;
|
||
var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||
variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-wt-*"));
|
||
variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-copy-*"));
|
||
}
|
||
catch
|
||
{
|
||
// ignore discovery failures
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(active) && Directory.Exists(active))
|
||
variants.Add(active);
|
||
|
||
return variants
|
||
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||
.Where(path => !string.Equals(Path.GetFullPath(path), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderByDescending(path => string.Equals(Path.GetFullPath(path), Path.GetFullPath(active ?? ""), StringComparison.OrdinalIgnoreCase))
|
||
.ThenByDescending(path => Directory.GetLastWriteTime(path))
|
||
.Take(8)
|
||
.ToList();
|
||
}
|
||
|
||
private void SwitchToWorkspace(string targetPath, string rootPath)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(targetPath) || !Directory.Exists(targetPath))
|
||
return;
|
||
|
||
if (!string.IsNullOrWhiteSpace(rootPath))
|
||
{
|
||
var state = WorktreeStateStore.Load(rootPath);
|
||
state.Active = targetPath;
|
||
WorktreeStateStore.Save(rootPath, state);
|
||
}
|
||
|
||
SetWorkFolder(targetPath);
|
||
ShowToast(string.Equals(targetPath, rootPath, StringComparison.OrdinalIgnoreCase) ? "로컬 워크스페이스로 전환했습니다." : "워크트리로 전환했습니다.");
|
||
}
|
||
|
||
private async Task CreateCurrentBranchWorktreeAsync()
|
||
{
|
||
var currentFolder = GetCurrentWorkFolder();
|
||
if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder))
|
||
return;
|
||
|
||
var root = WorktreeStateStore.ResolveRoot(currentFolder);
|
||
var gitRoot = ResolveGitRoot(root);
|
||
if (!string.IsNullOrWhiteSpace(gitRoot))
|
||
{
|
||
await CreateGitWorktreeAsync(gitRoot);
|
||
return;
|
||
}
|
||
|
||
var copied = CreateWorkspaceCopy(root);
|
||
SwitchToWorkspace(copied, root);
|
||
}
|
||
|
||
private async Task CreateGitWorktreeAsync(string gitRoot)
|
||
{
|
||
var gitPath = FindGitExecutablePath();
|
||
if (string.IsNullOrWhiteSpace(gitPath))
|
||
return;
|
||
|
||
var branchResult = await RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, CancellationToken.None);
|
||
var branchName = branchResult.ExitCode == 0 ? branchResult.StdOut.Trim() : "worktree";
|
||
if (string.IsNullOrWhiteSpace(branchName))
|
||
branchName = "worktree";
|
||
|
||
var safeBranch = string.Concat(branchName.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-');
|
||
if (string.IsNullOrWhiteSpace(safeBranch))
|
||
safeBranch = "worktree";
|
||
|
||
var parent = Directory.GetParent(gitRoot)?.FullName ?? gitRoot;
|
||
var repoName = Path.GetFileName(gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||
var suffix = DateTime.Now.ToString("MMddHHmm");
|
||
var worktreePath = Path.Combine(parent, $"{repoName}-wt-{safeBranch}-{suffix}");
|
||
var worktreeBranch = $"ax/{safeBranch}-{suffix}";
|
||
|
||
var addResult = await RunGitAsync(gitPath, gitRoot, new[] { "worktree", "add", "-b", worktreeBranch, worktreePath, branchName }, CancellationToken.None);
|
||
if (addResult.ExitCode != 0)
|
||
{
|
||
CustomMessageBox.Show($"워크트리 생성에 실패했습니다.\n{addResult.StdErr.Trim()}", "워크트리", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
return;
|
||
}
|
||
|
||
SwitchToWorkspace(worktreePath, gitRoot);
|
||
await RefreshGitBranchStatusAsync();
|
||
}
|
||
|
||
private string CreateWorkspaceCopy(string root)
|
||
{
|
||
var parent = Directory.GetParent(root)?.FullName ?? root;
|
||
var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||
var copyPath = Path.Combine(parent, $"{repoName}-copy-{DateTime.Now:MMddHHmm}");
|
||
CopyDirectoryRecursive(root, copyPath, skipGitMetadata: true);
|
||
return copyPath;
|
||
}
|
||
|
||
private static void CopyDirectoryRecursive(string source, string destination, bool skipGitMetadata)
|
||
{
|
||
Directory.CreateDirectory(destination);
|
||
|
||
foreach (var file in Directory.GetFiles(source))
|
||
{
|
||
var name = Path.GetFileName(file);
|
||
if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
File.Copy(file, Path.Combine(destination, name), overwrite: true);
|
||
}
|
||
|
||
foreach (var directory in Directory.GetDirectories(source))
|
||
{
|
||
var name = Path.GetFileName(directory);
|
||
if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
CopyDirectoryRecursive(directory, Path.Combine(destination, name), skipGitMetadata);
|
||
}
|
||
}
|
||
|
||
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
|
||
private void AppendLogLevelButton()
|
||
{
|
||
// 구분선
|
||
MoodIconPanel.Children.Add(new Border
|
||
{
|
||
Width = 1, Height = 18,
|
||
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(4, 0, 4, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "simple";
|
||
var levelLabel = currentLevel switch
|
||
{
|
||
"debug" => "디버그",
|
||
"detailed" => "상세",
|
||
_ => "간략",
|
||
};
|
||
var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669");
|
||
logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); };
|
||
try { RegisterName("BtnLogLevelMenu", logBtn); } catch { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch { } }
|
||
MoodIconPanel.Children.Add(logBtn);
|
||
}
|
||
|
||
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
|
||
private void ShowLogLevelMenu()
|
||
{
|
||
FormatMenuItems.Children.Clear();
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
|
||
var levels = new (string Key, string Label, string Desc)[]
|
||
{
|
||
("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"),
|
||
("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"),
|
||
("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"),
|
||
};
|
||
|
||
var current = _settings.Settings.Llm.AgentLogLevel ?? "simple";
|
||
|
||
foreach (var (key, label, desc) in levels)
|
||
{
|
||
var isActive = current == key;
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 13,
|
||
Foreground = isActive ? accentBrush : primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = desc,
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
var item = new Border
|
||
{
|
||
Child = sp,
|
||
Padding = new Thickness(12, 8, 12, 8),
|
||
CornerRadius = new CornerRadius(6),
|
||
Background = Brushes.Transparent,
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg;
|
||
item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent;
|
||
item.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
_settings.Settings.Llm.AgentLogLevel = key;
|
||
_settings.Save();
|
||
FormatMenuPopup.IsOpen = false;
|
||
if (_activeTab == "Cowork") BuildBottomBar();
|
||
else if (_activeTab == "Code") BuildCodeBottomBar();
|
||
};
|
||
FormatMenuItems.Children.Add(item);
|
||
}
|
||
|
||
try
|
||
{
|
||
var target = FindName("BtnLogLevelMenu") as UIElement;
|
||
if (target != null) FormatMenuPopup.PlacementTarget = target;
|
||
}
|
||
catch { }
|
||
FormatMenuPopup.IsOpen = true;
|
||
}
|
||
|
||
private void ShowLanguageMenu()
|
||
{
|
||
FormatMenuItems.Children.Clear();
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
|
||
var languages = new (string Key, string Label, string Icon)[]
|
||
{
|
||
("auto", "자동 감지", "🔧"),
|
||
("python", "Python", "🐍"),
|
||
("java", "Java", "☕"),
|
||
("csharp", "C# (.NET)", "🔷"),
|
||
("cpp", "C/C++", "⚙"),
|
||
("javascript", "JavaScript / Vue", "🌐"),
|
||
};
|
||
|
||
foreach (var (key, label, icon) in languages)
|
||
{
|
||
var isActive = _selectedLanguage == key;
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
|
||
sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
|
||
sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal });
|
||
|
||
var itemBorder = new Border
|
||
{
|
||
Child = sp, Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand,
|
||
Padding = new Thickness(8, 7, 12, 7),
|
||
};
|
||
ApplyMenuItemHover(itemBorder);
|
||
|
||
var capturedKey = key;
|
||
itemBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
FormatMenuPopup.IsOpen = false;
|
||
_selectedLanguage = capturedKey;
|
||
BuildCodeBottomBar();
|
||
};
|
||
FormatMenuItems.Children.Add(itemBorder);
|
||
}
|
||
|
||
if (FindName("BtnLangMenu") is UIElement langTarget)
|
||
FormatMenuPopup.PlacementTarget = langTarget;
|
||
FormatMenuPopup.IsOpen = true;
|
||
}
|
||
|
||
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
|
||
private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null)
|
||
{
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB");
|
||
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||
var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText;
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
|
||
if (mdlIcon != null)
|
||
{
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = mdlIcon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = iconColor,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
}
|
||
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 12,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
var chip = new Border
|
||
{
|
||
Child = sp,
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = borderColor,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Padding = new Thickness(10, 5, 10, 5),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
Cursor = Cursors.Hand,
|
||
ToolTip = tooltip,
|
||
};
|
||
chip.MouseEnter += (_, _) => chip.Background = hoverBackground;
|
||
chip.MouseLeave += (_, _) => chip.Background = Brushes.Transparent;
|
||
return chip;
|
||
}
|
||
|
||
|
||
private static string GetFormatLabel(string key) => key switch
|
||
{
|
||
"xlsx" => "Excel",
|
||
"html" => "HTML 보고서",
|
||
"docx" => "Word",
|
||
"md" => "Markdown",
|
||
"csv" => "CSV",
|
||
_ => "AI 자동",
|
||
};
|
||
|
||
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
|
||
private (string Name, string Symbol, string Color) GetAgentIdentity()
|
||
{
|
||
string? category = null;
|
||
lock (_convLock)
|
||
{
|
||
category = _currentConversation?.Category;
|
||
}
|
||
|
||
return category switch
|
||
{
|
||
// Cowork 프리셋 카테고리
|
||
"보고서" => ("보고서 에이전트", "◆", "#3B82F6"),
|
||
"데이터" => ("데이터 분석 에이전트", "◆", "#10B981"),
|
||
"문서" => ("문서 작성 에이전트", "◆", "#6366F1"),
|
||
"논문" => ("논문 분석 에이전트", "◆", "#6366F1"),
|
||
"파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"),
|
||
"자동화" => ("자동화 에이전트", "◆", "#EF4444"),
|
||
// Code 프리셋 카테고리
|
||
"코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"),
|
||
"리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"),
|
||
"코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"),
|
||
"보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"),
|
||
"테스트" => ("테스트 에이전트", "◆", "#F59E0B"),
|
||
// Chat 카테고리
|
||
"연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"),
|
||
"시스템" => ("시스템 에이전트", "◆", "#64748B"),
|
||
"수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"),
|
||
"제품분석" => ("제품분석 에이전트", "◆", "#EC4899"),
|
||
"경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"),
|
||
"인사" => ("인사 관리 에이전트", "◆", "#14B8A6"),
|
||
"제조기술" => ("제조기술 에이전트", "◆", "#F97316"),
|
||
"재무" => ("재무 분석 에이전트", "◆", "#6366F1"),
|
||
_ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"),
|
||
_ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"),
|
||
_ => ("AX 에이전트", "◆", "#4B5EFC"),
|
||
};
|
||
}
|
||
|
||
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
|
||
private void ShowFormatMenu()
|
||
{
|
||
FormatMenuItems.Children.Clear();
|
||
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var currentFormat = _settings.Settings.Llm.DefaultOutputFormat ?? "auto";
|
||
|
||
var formats = new (string Key, string Label, string Icon, string Color)[]
|
||
{
|
||
("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"),
|
||
("xlsx", "Excel", "\uE9F9", "#217346"),
|
||
("html", "HTML 보고서", "\uE12B", "#E44D26"),
|
||
("docx", "Word", "\uE8A5", "#2B579A"),
|
||
("md", "Markdown", "\uE943", "#6B7280"),
|
||
("csv", "CSV", "\uE9D9", "#10B981"),
|
||
};
|
||
|
||
foreach (var (key, label, icon, color) in formats)
|
||
{
|
||
var isActive = key == currentFormat;
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
|
||
// 커스텀 체크 아이콘
|
||
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
|
||
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13,
|
||
Foreground = BrushFromHex(color),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label, FontSize = 13,
|
||
Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
var itemBorder = new Border
|
||
{
|
||
Child = sp,
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(8, 7, 12, 7),
|
||
};
|
||
ApplyMenuItemHover(itemBorder);
|
||
|
||
var capturedKey = key;
|
||
itemBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
FormatMenuPopup.IsOpen = false;
|
||
_settings.Settings.Llm.DefaultOutputFormat = capturedKey;
|
||
_settings.Save();
|
||
RefreshOverlaySettingsPanel();
|
||
BuildBottomBar();
|
||
};
|
||
|
||
FormatMenuItems.Children.Add(itemBorder);
|
||
}
|
||
|
||
// PlacementTarget을 동적 등록된 버튼으로 설정
|
||
if (FormatMenuPopup.PlacementTarget == null && FindName("BtnFormatMenu") is UIElement formatTarget)
|
||
FormatMenuPopup.PlacementTarget = formatTarget;
|
||
FormatMenuPopup.IsOpen = true;
|
||
}
|
||
|
||
private void BtnOverlayDefaultOutputFormat_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (sender is UIElement element)
|
||
FormatMenuPopup.PlacementTarget = element;
|
||
ShowFormatMenu();
|
||
}
|
||
|
||
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
|
||
private void ShowMoodMenu()
|
||
{
|
||
MoodMenuItems.Children.Clear();
|
||
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
// 2열 갤러리 그리드
|
||
var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 };
|
||
|
||
foreach (var mood in TemplateService.AllMoods)
|
||
{
|
||
var isActive = _selectedMood == mood.Key;
|
||
var isCustom = _settings.Settings.Llm.CustomMoods.Any(cm => cm.Key == mood.Key);
|
||
var colors = TemplateService.GetMoodColors(mood.Key);
|
||
|
||
// 미니 프리뷰 카드
|
||
var previewCard = new Border
|
||
{
|
||
Width = 160, Height = 80,
|
||
CornerRadius = new CornerRadius(6),
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Background)),
|
||
BorderBrush = isActive ? accentBrush : new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)),
|
||
BorderThickness = new Thickness(isActive ? 2 : 1),
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
Margin = new Thickness(2),
|
||
};
|
||
|
||
var previewContent = new StackPanel();
|
||
// 헤딩 라인
|
||
previewContent.Children.Add(new Border
|
||
{
|
||
Width = 60, Height = 6, CornerRadius = new CornerRadius(2),
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.PrimaryText)),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Margin = new Thickness(0, 0, 0, 4),
|
||
});
|
||
// 악센트 라인
|
||
previewContent.Children.Add(new Border
|
||
{
|
||
Width = 40, Height = 3, CornerRadius = new CornerRadius(1),
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Accent)),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
});
|
||
// 텍스트 라인들
|
||
for (int i = 0; i < 3; i++)
|
||
{
|
||
previewContent.Children.Add(new Border
|
||
{
|
||
Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1),
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.SecondaryText)) { Opacity = 0.5 },
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Margin = new Thickness(0, 0, 0, 3),
|
||
});
|
||
}
|
||
// 미니 카드 영역
|
||
var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
|
||
for (int i = 0; i < 2; i++)
|
||
{
|
||
cardRow.Children.Add(new Border
|
||
{
|
||
Width = 28, Height = 14, CornerRadius = new CornerRadius(2),
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.CardBg)),
|
||
BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)),
|
||
BorderThickness = new Thickness(0.5),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
}
|
||
previewContent.Children.Add(cardRow);
|
||
previewCard.Child = previewContent;
|
||
|
||
// 무드 라벨
|
||
var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) };
|
||
var labelRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||
labelRow.Children.Add(new TextBlock
|
||
{
|
||
Text = mood.Icon, FontSize = 12,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
labelRow.Children.Add(new TextBlock
|
||
{
|
||
Text = mood.Label, FontSize = 11.5,
|
||
Foreground = primaryText,
|
||
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
if (isActive)
|
||
{
|
||
labelRow.Children.Add(new TextBlock
|
||
{
|
||
Text = " ✓", FontSize = 11,
|
||
Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
}
|
||
labelPanel.Children.Add(labelRow);
|
||
|
||
// 전체 카드 래퍼
|
||
var cardWrapper = new Border
|
||
{
|
||
CornerRadius = new CornerRadius(8),
|
||
Background = Brushes.Transparent,
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(4),
|
||
Margin = new Thickness(2),
|
||
};
|
||
var wrapperContent = new StackPanel();
|
||
wrapperContent.Children.Add(previewCard);
|
||
wrapperContent.Children.Add(labelPanel);
|
||
cardWrapper.Child = wrapperContent;
|
||
|
||
// 호버
|
||
cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
|
||
cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
|
||
var capturedMood = mood;
|
||
cardWrapper.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
MoodMenuPopup.IsOpen = false;
|
||
_selectedMood = capturedMood.Key;
|
||
_settings.Settings.Llm.DefaultMood = capturedMood.Key;
|
||
_settings.Save();
|
||
SaveConversationSettings();
|
||
RefreshOverlaySettingsPanel();
|
||
BuildBottomBar();
|
||
};
|
||
|
||
// 커스텀 무드: 우클릭
|
||
if (isCustom)
|
||
{
|
||
cardWrapper.MouseRightButtonUp += (s, e) =>
|
||
{
|
||
e.Handled = true;
|
||
MoodMenuPopup.IsOpen = false;
|
||
ShowCustomMoodContextMenu(s as Border, capturedMood.Key);
|
||
};
|
||
}
|
||
|
||
grid.Children.Add(cardWrapper);
|
||
}
|
||
|
||
MoodMenuItems.Children.Add(grid);
|
||
|
||
// ── 구분선 + 추가 버튼 ──
|
||
MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle
|
||
{
|
||
Height = 1,
|
||
Fill = borderBrush,
|
||
Margin = new Thickness(8, 4, 8, 4),
|
||
Opacity = 0.4,
|
||
});
|
||
|
||
var addSp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
addSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE710",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(4, 0, 8, 0),
|
||
});
|
||
addSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "커스텀 무드 추가",
|
||
FontSize = 13,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
var addBorder = new Border
|
||
{
|
||
Child = addSp,
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(8, 6, 12, 6),
|
||
};
|
||
ApplyMenuItemHover(addBorder);
|
||
addBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
MoodMenuPopup.IsOpen = false;
|
||
ShowCustomMoodDialog();
|
||
};
|
||
MoodMenuItems.Children.Add(addBorder);
|
||
|
||
if (MoodMenuPopup.PlacementTarget == null && FindName("BtnMoodMenu") is UIElement moodTarget)
|
||
MoodMenuPopup.PlacementTarget = moodTarget;
|
||
MoodMenuPopup.IsOpen = true;
|
||
}
|
||
|
||
private void BtnOverlayDefaultMood_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (sender is UIElement element)
|
||
MoodMenuPopup.PlacementTarget = element;
|
||
ShowMoodMenu();
|
||
}
|
||
|
||
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
|
||
private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null)
|
||
{
|
||
bool isEdit = existing != null;
|
||
var dlg = new CustomMoodDialog(
|
||
existingKey: existing?.Key ?? "",
|
||
existingLabel: existing?.Label ?? "",
|
||
existingIcon: existing?.Icon ?? "🎯",
|
||
existingDesc: existing?.Description ?? "",
|
||
existingCss: existing?.Css ?? "")
|
||
{
|
||
Owner = this,
|
||
};
|
||
|
||
if (dlg.ShowDialog() == true)
|
||
{
|
||
if (isEdit)
|
||
{
|
||
existing!.Label = dlg.MoodLabel;
|
||
existing.Icon = dlg.MoodIcon;
|
||
existing.Description = dlg.MoodDescription;
|
||
existing.Css = dlg.MoodCss;
|
||
}
|
||
else
|
||
{
|
||
_settings.Settings.Llm.CustomMoods.Add(new Models.CustomMoodEntry
|
||
{
|
||
Key = dlg.MoodKey,
|
||
Label = dlg.MoodLabel,
|
||
Icon = dlg.MoodIcon,
|
||
Description = dlg.MoodDescription,
|
||
Css = dlg.MoodCss,
|
||
});
|
||
}
|
||
_settings.Save();
|
||
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
|
||
BuildBottomBar();
|
||
}
|
||
}
|
||
|
||
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
|
||
private void ShowCustomMoodContextMenu(Border? anchor, string moodKey)
|
||
{
|
||
if (anchor == null) return;
|
||
|
||
var popup = new System.Windows.Controls.Primitives.Popup
|
||
{
|
||
PlacementTarget = anchor,
|
||
Placement = System.Windows.Controls.Primitives.PlacementMode.Right,
|
||
StaysOpen = false, AllowsTransparency = true,
|
||
};
|
||
|
||
var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
var menuBorder = new Border
|
||
{
|
||
Background = menuBg,
|
||
CornerRadius = new CornerRadius(10),
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(4),
|
||
MinWidth = 120,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
|
||
},
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
|
||
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
|
||
editItem.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
popup.IsOpen = false;
|
||
var entry = _settings.Settings.Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey);
|
||
if (entry != null) ShowCustomMoodDialog(entry);
|
||
};
|
||
stack.Children.Add(editItem);
|
||
|
||
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
|
||
deleteItem.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
popup.IsOpen = false;
|
||
var result = CustomMessageBox.Show(
|
||
$"이 디자인 무드를 삭제하시겠습니까?",
|
||
"무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||
if (result == MessageBoxResult.Yes)
|
||
{
|
||
_settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey);
|
||
if (_selectedMood == moodKey) _selectedMood = "modern";
|
||
_settings.Save();
|
||
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
|
||
BuildBottomBar();
|
||
}
|
||
};
|
||
stack.Children.Add(deleteItem);
|
||
|
||
menuBorder.Child = stack;
|
||
popup.Child = menuBorder;
|
||
popup.IsOpen = true;
|
||
}
|
||
|
||
|
||
private string? _promptCardPlaceholder;
|
||
|
||
private void ShowPlaceholder()
|
||
{
|
||
if (string.IsNullOrEmpty(_promptCardPlaceholder)) return;
|
||
InputWatermark.Text = _promptCardPlaceholder;
|
||
InputWatermark.Visibility = Visibility.Visible;
|
||
InputBox.Text = "";
|
||
InputBox.Focus();
|
||
}
|
||
|
||
private void UpdateWatermarkVisibility()
|
||
{
|
||
// 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지)
|
||
if (_slashPalette.ActiveCommand != null)
|
||
{
|
||
InputWatermark.Visibility = Visibility.Collapsed;
|
||
return;
|
||
}
|
||
|
||
if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text))
|
||
InputWatermark.Visibility = Visibility.Visible;
|
||
else
|
||
InputWatermark.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void ClearPromptCardPlaceholder()
|
||
{
|
||
_promptCardPlaceholder = null;
|
||
InputWatermark.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void BtnSettings_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
OpenAgentSettingsWindow();
|
||
}
|
||
|
||
// ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────
|
||
|
||
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);
|
||
_settings.Save();
|
||
}
|
||
|
||
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);
|
||
_settings.Save();
|
||
}
|
||
|
||
// ─── 모델 전환 ──────────────────────────────────────────────────────
|
||
|
||
// Gemini/Claude 사전 정의 모델 목록
|
||
private static readonly (string Id, string Label)[] GeminiModels =
|
||
{
|
||
("gemini-2.5-pro", "Gemini 2.5 Pro"),
|
||
("gemini-2.5-flash", "Gemini 2.5 Flash"),
|
||
("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
|
||
("gemini-2.0-flash", "Gemini 2.0 Flash"),
|
||
("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite"),
|
||
};
|
||
private static readonly (string Id, string Label)[] ClaudeModels =
|
||
{
|
||
(string.Concat("cl", "aude-opus-4-6"), "Claude Opus 4.6"),
|
||
(string.Concat("cl", "aude-sonnet-4-6"), "Claude Sonnet 4.6"),
|
||
(string.Concat("cl", "aude-haiku-4-5-20251001"), "Claude Haiku 4.5"),
|
||
(string.Concat("cl", "aude-sonnet-4-5-20250929"), "Claude Sonnet 4.5"),
|
||
(string.Concat("cl", "aude-opus-4-20250514"), "Claude Opus 4"),
|
||
};
|
||
|
||
/// <summary>현재 선택된 모델의 표시명을 반환합니다.</summary>
|
||
private string GetCurrentModelDisplayName()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var service = llm.Service.ToLowerInvariant();
|
||
|
||
if (service is "ollama" or "vllm")
|
||
{
|
||
// 등록 모델에서 별칭 찾기
|
||
var registered = llm.RegisteredModels
|
||
.FirstOrDefault(rm => rm.EncryptedModelName == llm.Model);
|
||
if (registered != null) return registered.Alias;
|
||
return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : "••••";
|
||
}
|
||
|
||
if (service == "gemini")
|
||
{
|
||
var m = GeminiModels.FirstOrDefault(g => g.Id == llm.Model);
|
||
return m.Label ?? llm.Model;
|
||
}
|
||
if (service is "sigmoid" or "cl" + "aude")
|
||
{
|
||
var m = ClaudeModels.FirstOrDefault(c => c.Id == llm.Model);
|
||
return m.Label ?? llm.Model;
|
||
}
|
||
return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : llm.Model;
|
||
}
|
||
|
||
private void UpdateModelLabel()
|
||
{
|
||
var service = _settings.Settings.Llm.Service.ToLowerInvariant();
|
||
var serviceLabel = service switch
|
||
{
|
||
"gemini" => "Gemini",
|
||
"sigmoid" or "cl" + "aude" => "Claude",
|
||
"vllm" => "vLLM",
|
||
_ => "Ollama",
|
||
};
|
||
var model = GetCurrentModelDisplayName();
|
||
const int maxLen = 24;
|
||
if (model.Length > maxLen)
|
||
model = model[..(maxLen - 1)] + "…";
|
||
ModelLabel.Text = string.IsNullOrWhiteSpace(model) ? serviceLabel : model;
|
||
if (BtnModelSelector != null)
|
||
BtnModelSelector.ToolTip = $"현재 모델: {GetCurrentModelDisplayName()}\n서비스: {serviceLabel}";
|
||
}
|
||
|
||
private static string NextReasoning(string current) => (current ?? "normal").ToLowerInvariant() switch
|
||
{
|
||
"minimal" => "normal",
|
||
"normal" => "detailed",
|
||
_ => "minimal",
|
||
};
|
||
private static string ReasoningLabel(string value) => (value ?? "normal").ToLowerInvariant() switch
|
||
{
|
||
"minimal" => "낮음",
|
||
"detailed" => "높음",
|
||
_ => "중간",
|
||
};
|
||
private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch
|
||
{
|
||
"deny" => "Default",
|
||
"default" => "AcceptEdits",
|
||
"acceptedits" => "Plan",
|
||
"plan" => "BypassPermissions",
|
||
"bypasspermissions" => "Default",
|
||
_ => "Default",
|
||
};
|
||
private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch
|
||
{
|
||
"gemini" => "Gemini",
|
||
"sigmoid" or "cl" + "aude" => "Claude",
|
||
"vllm" => "vLLM",
|
||
_ => "Ollama",
|
||
};
|
||
|
||
private List<(string Id, string Label)> GetModelCandidates(string service)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var normalized = (service ?? "ollama").ToLowerInvariant();
|
||
|
||
if (normalized is "ollama" or "vllm")
|
||
{
|
||
return llm.RegisteredModels
|
||
.Where(rm => string.Equals(rm.Service, normalized, StringComparison.OrdinalIgnoreCase))
|
||
.Select(rm => (rm.EncryptedModelName, rm.Alias))
|
||
.ToList();
|
||
}
|
||
|
||
if (normalized == "gemini")
|
||
return GeminiModels.Select(m => (m.Id, m.Label)).ToList();
|
||
if (normalized is "sigmoid" or "cl" + "aude")
|
||
return ClaudeModels.Select(m => (m.Id, m.Label)).ToList();
|
||
|
||
return [];
|
||
}
|
||
|
||
private static bool SupportsOverlayRegisteredModels(string service)
|
||
=> string.Equals(service, "ollama", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(service, "vllm", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private void RefreshInlineSettingsPanel()
|
||
{
|
||
if (InlineSettingsPanel == null) return;
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
var service = (llm.Service ?? "ollama").ToLowerInvariant();
|
||
var models = GetModelCandidates(service);
|
||
|
||
_isInlineSettingsSyncing = true;
|
||
try
|
||
{
|
||
if (InlineServiceCardPanel != null)
|
||
InlineServiceCardPanel.Children.Clear();
|
||
if (InlineModelListPanel != null)
|
||
InlineModelListPanel.Children.Clear();
|
||
|
||
CmbInlineService.Items.Clear();
|
||
foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" })
|
||
{
|
||
CmbInlineService.Items.Add(new ComboBoxItem
|
||
{
|
||
Content = ServiceLabel(svc),
|
||
Tag = svc,
|
||
});
|
||
}
|
||
var normalizedService = service == "sigmoid" ? string.Concat("cl", "aude") : service;
|
||
CmbInlineService.SelectedIndex = Math.Max(0, new[] { "ollama", "vllm", "gemini", "claude" }.ToList().IndexOf(normalizedService));
|
||
|
||
BuildInlineServiceCards(normalizedService);
|
||
|
||
CmbInlineModel.Items.Clear();
|
||
if (models.Count == 0)
|
||
{
|
||
CmbInlineModel.Items.Add(new ComboBoxItem { Content = "등록된 모델 없음", IsEnabled = false });
|
||
CmbInlineModel.SelectedIndex = 0;
|
||
}
|
||
else
|
||
{
|
||
foreach (var (id, label) in models)
|
||
{
|
||
CmbInlineModel.Items.Add(new ComboBoxItem
|
||
{
|
||
Content = label,
|
||
Tag = id,
|
||
});
|
||
}
|
||
|
||
var selectedIndex = models.FindIndex(m => m.Id == llm.Model);
|
||
CmbInlineModel.SelectedIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||
BuildInlineModelRows(models, llm.Model);
|
||
}
|
||
|
||
BtnInlineFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐");
|
||
BtnInlineReasoning.Content = GetQuickActionLabel("추론", ReasoningLabel(llm.AgentDecisionLevel));
|
||
BtnInlinePermission.Content = GetQuickActionLabel("권한", PermissionModeCatalog.ToDisplayLabel(llm.FilePermission));
|
||
BtnInlineSkill.Content = $"스킬 · {(llm.EnableSkillSystem ? "On" : "Off")}";
|
||
BtnInlineCommandBrowser.Content = "명령/스킬 브라우저";
|
||
|
||
var mcpTotal = llm.McpServers?.Count ?? 0;
|
||
var mcpEnabled = llm.McpServers?.Count(x => x.Enabled) ?? 0;
|
||
BtnInlineMcp.Content = $"MCP 상태 · {mcpEnabled}/{mcpTotal}";
|
||
|
||
ApplyQuickActionVisual(BtnInlineFastMode, llm.FreeTierMode, "#ECFDF5", "#166534");
|
||
ApplyQuickActionVisual(BtnInlineReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8");
|
||
ApplyQuickActionVisual(BtnInlinePermission,
|
||
!string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase),
|
||
"#FFF7ED",
|
||
"#C2410C");
|
||
}
|
||
finally
|
||
{
|
||
_isInlineSettingsSyncing = false;
|
||
}
|
||
}
|
||
|
||
private void BuildInlineServiceCards(string selectedService)
|
||
{
|
||
if (InlineServiceCardPanel == null)
|
||
return;
|
||
|
||
foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" })
|
||
{
|
||
var isActive = string.Equals(svc, selectedService, StringComparison.OrdinalIgnoreCase);
|
||
var card = new Border
|
||
{
|
||
Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent,
|
||
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : (TryFindResource("BorderColor") as Brush ?? Brushes.Gray),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(10, 6, 10, 6),
|
||
Margin = new Thickness(0, 0, 6, 6),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var text = new TextBlock
|
||
{
|
||
Text = ServiceLabel(svc),
|
||
FontSize = 10.5,
|
||
Foreground = isActive ? BrushFromHex("#1D4ED8") : (TryFindResource("PrimaryText") as Brush ?? Brushes.White),
|
||
};
|
||
card.Child = text;
|
||
var capturedService = svc;
|
||
card.MouseEnter += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
card.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
};
|
||
card.MouseLeave += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
card.Background = Brushes.Transparent;
|
||
};
|
||
card.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
if (_isInlineSettingsSyncing)
|
||
return;
|
||
_settings.Settings.Llm.Service = capturedService;
|
||
_settings.Save();
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
};
|
||
InlineServiceCardPanel.Children.Add(card);
|
||
}
|
||
}
|
||
|
||
private void BuildInlineModelRows(List<(string Id, string Label)> models, string? selectedModel)
|
||
{
|
||
if (InlineModelListPanel == null)
|
||
return;
|
||
|
||
foreach (var (id, label) in models.Take(8))
|
||
{
|
||
var isActive = string.Equals(id, selectedModel, StringComparison.OrdinalIgnoreCase);
|
||
var row = new Border
|
||
{
|
||
Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent,
|
||
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"),
|
||
BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1),
|
||
Padding = new Thickness(8, 8, 8, 8),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
var labelText = new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 11.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||
};
|
||
grid.Children.Add(labelText);
|
||
var stateText = new TextBlock
|
||
{
|
||
Text = isActive ? "사용 중" : "선택",
|
||
FontSize = 10,
|
||
Foreground = isActive ? BrushFromHex("#2563EB") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(stateText, 1);
|
||
grid.Children.Add(stateText);
|
||
row.Child = grid;
|
||
var capturedId = id;
|
||
var capturedLabel = label;
|
||
row.MouseEnter += (_, _) =>
|
||
{
|
||
row.Background = BrushFromHex("#F8FAFC");
|
||
row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0");
|
||
};
|
||
row.MouseLeave += (_, _) =>
|
||
{
|
||
row.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent;
|
||
row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB");
|
||
};
|
||
row.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
if (_isInlineSettingsSyncing)
|
||
return;
|
||
_settings.Settings.Llm.Model = capturedId;
|
||
_settings.Save();
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
SetStatus($"모델 전환: {capturedLabel}", spinning: false);
|
||
};
|
||
InlineModelListPanel.Children.Add(row);
|
||
}
|
||
}
|
||
|
||
private void BtnModelSelector_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
RefreshInlineSettingsPanel();
|
||
InlineSettingsPanel.IsOpen = !InlineSettingsPanel.IsOpen;
|
||
|
||
if (InlineSettingsPanel.IsOpen)
|
||
{
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
if (CmbInlineModel.Items.Count > 0)
|
||
CmbInlineModel.Focus();
|
||
else
|
||
InputBox.Focus();
|
||
}, DispatcherPriority.Input);
|
||
}
|
||
}
|
||
|
||
private void OpenAgentSettingsWindow()
|
||
{
|
||
RefreshOverlaySettingsPanel();
|
||
AgentSettingsOverlay.Visibility = Visibility.Visible;
|
||
InlineSettingsPanel.IsOpen = false;
|
||
SetOverlaySection("basic");
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
if (OverlayNavBasic != null)
|
||
OverlayNavBasic.Focus();
|
||
else
|
||
InputBox.Focus();
|
||
}, DispatcherPriority.Input);
|
||
}
|
||
|
||
public void OpenAgentSettingsFromExternal()
|
||
{
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
Show();
|
||
Activate();
|
||
OpenAgentSettingsWindow();
|
||
}, DispatcherPriority.Input);
|
||
}
|
||
|
||
public void RefreshFromSavedSettings()
|
||
{
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
ApplyAgentThemeResources();
|
||
LoadConversationSettings();
|
||
UpdatePermissionUI();
|
||
UpdateDataUsageUI();
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
RefreshOverlaySettingsPanel();
|
||
RefreshContextUsageVisual();
|
||
UpdateTabUI();
|
||
BuildBottomBar();
|
||
RefreshDraftQueueUi();
|
||
RefreshConversationList();
|
||
}, DispatcherPriority.Input);
|
||
}
|
||
|
||
private void BtnOverlaySettingsClose_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
ApplyOverlaySettingsChanges(showToast: false, closeOverlay: true);
|
||
}
|
||
|
||
private void ApplyOverlaySettingsChanges(bool showToast, bool closeOverlay)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
|
||
_settings.Settings.AiEnabled = true;
|
||
llm.EnableProactiveContextCompact = ChkOverlayEnableProactiveCompact?.IsChecked == true;
|
||
llm.EnableSkillSystem = ChkOverlayEnableSkillSystem?.IsChecked == true;
|
||
llm.EnableToolHooks = ChkOverlayEnableToolHooks?.IsChecked == true;
|
||
llm.EnableHookInputMutation = ChkOverlayEnableHookInputMutation?.IsChecked == true;
|
||
llm.EnableHookPermissionUpdate = ChkOverlayEnableHookPermissionUpdate?.IsChecked == true;
|
||
llm.EnableCoworkVerification = ChkOverlayEnableCoworkVerification?.IsChecked == true;
|
||
llm.Code.EnableCodeVerification = ChkOverlayEnableCodeVerification?.IsChecked == true;
|
||
llm.Code.EnableCodeReview = ChkOverlayEnableCodeReview?.IsChecked == true;
|
||
llm.EnableImageInput = ChkOverlayEnableImageInput?.IsChecked == true;
|
||
llm.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true;
|
||
llm.EnableProjectRules = ChkOverlayEnableProjectRules?.IsChecked == true;
|
||
llm.EnableAgentMemory = ChkOverlayEnableAgentMemory?.IsChecked == true;
|
||
llm.Code.EnableWorktreeTools = ChkOverlayEnableWorktreeTools?.IsChecked == true;
|
||
llm.Code.EnableTeamTools = ChkOverlayEnableTeamTools?.IsChecked == true;
|
||
llm.Code.EnableCronTools = ChkOverlayEnableCronTools?.IsChecked == true;
|
||
llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer?.IsChecked == true;
|
||
llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats?.IsChecked == true;
|
||
llm.EnableAuditLog = ChkOverlayEnableAuditLog?.IsChecked == true;
|
||
|
||
CommitOverlayEndpointInput(normalizeOnInvalid: true);
|
||
CommitOverlayApiKeyInput();
|
||
CommitOverlayModelInput(normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, llm.ContextCompactTriggerPercent, 10, 95, value => llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayMaxContextTokens, llm.MaxContextTokens, 1024, 1_000_000, value => llm.MaxContextTokens = value, normalizeOnInvalid: true);
|
||
CommitOverlayTemperatureInput(normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, llm.MaxAgentIterations, 1, 200, value => llm.MaxAgentIterations = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, llm.FreeTierDelaySeconds, 0, 60, value => llm.FreeTierDelaySeconds = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayMaxSubAgents, llm.MaxSubAgents, 1, 10, value => llm.MaxSubAgents = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, llm.ToolHookTimeoutMs, 3000, 30000, value => llm.ToolHookTimeoutMs = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, llm.MaxFavoriteSlashCommands, 1, 30, value => llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, llm.MaxRecentSlashCommands, 5, 50, value => llm.MaxRecentSlashCommands = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, llm.PlanDiffSeverityMediumCount, 1, 999, value => llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, llm.PlanDiffSeverityHighCount, 1, 999, value => llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: true);
|
||
CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: true);
|
||
if (TxtOverlayPdfExportPath != null)
|
||
llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim();
|
||
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
if (closeOverlay)
|
||
AgentSettingsOverlay.Visibility = Visibility.Collapsed;
|
||
if (showToast)
|
||
ShowToast("AX Agent 설정이 저장되었습니다.");
|
||
InputBox.Focus();
|
||
}
|
||
|
||
private void PersistOverlaySettingsState(bool refreshOverlayDeferredInputs)
|
||
{
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
ApplyAgentThemeResources();
|
||
UpdatePermissionUI();
|
||
UpdateDataUsageUI();
|
||
SaveConversationSettings();
|
||
RefreshInlineSettingsPanel();
|
||
UpdateModelLabel();
|
||
UpdateTabUI();
|
||
RefreshOverlayVisualState(refreshOverlayDeferredInputs);
|
||
}
|
||
|
||
private void RefreshOverlayVisualState(bool loadDeferredInputs)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var service = NormalizeOverlayService(llm.Service);
|
||
var models = GetModelCandidates(service);
|
||
|
||
_isOverlaySettingsSyncing = true;
|
||
try
|
||
{
|
||
if (CmbOverlayService != null)
|
||
{
|
||
CmbOverlayService.Items.Clear();
|
||
foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" })
|
||
{
|
||
CmbOverlayService.Items.Add(new ComboBoxItem
|
||
{
|
||
Content = ServiceLabel(svc),
|
||
Tag = svc
|
||
});
|
||
}
|
||
|
||
CmbOverlayService.SelectedItem = CmbOverlayService.Items
|
||
.OfType<ComboBoxItem>()
|
||
.FirstOrDefault(i => string.Equals(i.Tag as string, service, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
if (CmbOverlayModel != null)
|
||
{
|
||
CmbOverlayModel.Items.Clear();
|
||
foreach (var model in models)
|
||
{
|
||
CmbOverlayModel.Items.Add(new ComboBoxItem
|
||
{
|
||
Content = model.Label,
|
||
Tag = model.Id
|
||
});
|
||
}
|
||
|
||
CmbOverlayModel.SelectedItem = CmbOverlayModel.Items
|
||
.OfType<ComboBoxItem>()
|
||
.FirstOrDefault(i => string.Equals(i.Tag as string, llm.Model, StringComparison.OrdinalIgnoreCase));
|
||
|
||
if (CmbOverlayModel.SelectedItem == null && CmbOverlayModel.Items.Count > 0)
|
||
CmbOverlayModel.SelectedIndex = 0;
|
||
}
|
||
|
||
if (OverlaySelectedServiceText != null)
|
||
OverlaySelectedServiceText.Text = ServiceLabel(service);
|
||
if (OverlaySelectedModelText != null)
|
||
OverlaySelectedModelText.Text = string.IsNullOrWhiteSpace(llm.Model)
|
||
? "미선택"
|
||
: (models.FirstOrDefault(m => string.Equals(m.Id, llm.Model, StringComparison.OrdinalIgnoreCase)).Label ?? llm.Model);
|
||
|
||
PopulateOverlayMoodCombo();
|
||
|
||
if (loadDeferredInputs)
|
||
{
|
||
if (ChkOverlayAiEnabled != null)
|
||
ChkOverlayAiEnabled.IsChecked = true;
|
||
if (TxtOverlayServiceEndpoint != null)
|
||
TxtOverlayServiceEndpoint.Text = GetOverlayServiceEndpoint(service);
|
||
if (TxtOverlayServiceApiKey != null)
|
||
TxtOverlayServiceApiKey.Password = GetOverlayServiceApiKey(service);
|
||
if (TxtOverlayContextCompactTriggerPercent != null)
|
||
TxtOverlayContextCompactTriggerPercent.Text = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95).ToString();
|
||
if (TxtOverlayMaxContextTokens != null)
|
||
TxtOverlayMaxContextTokens.Text = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000).ToString();
|
||
if (TxtOverlayTemperature != null)
|
||
TxtOverlayTemperature.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
|
||
if (SldOverlayTemperature != null)
|
||
SldOverlayTemperature.Value = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1);
|
||
if (TxtOverlayTemperatureValue != null)
|
||
TxtOverlayTemperatureValue.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
|
||
if (TxtOverlayMaxRetryOnError != null)
|
||
TxtOverlayMaxRetryOnError.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString();
|
||
if (SldOverlayMaxRetryOnError != null)
|
||
SldOverlayMaxRetryOnError.Value = Math.Clamp(llm.MaxRetryOnError, 0, 10);
|
||
if (TxtOverlayMaxRetryOnErrorValue != null)
|
||
TxtOverlayMaxRetryOnErrorValue.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString();
|
||
if (TxtOverlayMaxAgentIterations != null)
|
||
TxtOverlayMaxAgentIterations.Text = Math.Clamp(llm.MaxAgentIterations, 1, 200).ToString();
|
||
if (SldOverlayMaxAgentIterations != null)
|
||
SldOverlayMaxAgentIterations.Value = Math.Clamp(llm.MaxAgentIterations, 1, 100);
|
||
if (TxtOverlayMaxAgentIterationsValue != null)
|
||
TxtOverlayMaxAgentIterationsValue.Text = Math.Clamp(llm.MaxAgentIterations, 1, 100).ToString();
|
||
if (TxtOverlayFreeTierDelaySeconds != null)
|
||
TxtOverlayFreeTierDelaySeconds.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString();
|
||
if (SldOverlayFreeTierDelaySeconds != null)
|
||
SldOverlayFreeTierDelaySeconds.Value = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60);
|
||
if (TxtOverlayFreeTierDelaySecondsValue != null)
|
||
TxtOverlayFreeTierDelaySecondsValue.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString();
|
||
if (TxtOverlayMaxSubAgents != null)
|
||
TxtOverlayMaxSubAgents.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString();
|
||
if (SldOverlayMaxSubAgents != null)
|
||
SldOverlayMaxSubAgents.Value = Math.Clamp(llm.MaxSubAgents, 1, 10);
|
||
if (TxtOverlayMaxSubAgentsValue != null)
|
||
TxtOverlayMaxSubAgentsValue.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString();
|
||
if (TxtOverlayToolHookTimeoutMs != null)
|
||
TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString();
|
||
if (SldOverlayToolHookTimeoutMs != null)
|
||
SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000);
|
||
if (TxtOverlayToolHookTimeoutMsValue != null)
|
||
TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s";
|
||
if (TxtOverlayMaxFavoriteSlashCommands != null)
|
||
TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
|
||
if (SldOverlayMaxFavoriteSlashCommands != null)
|
||
SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30);
|
||
if (TxtOverlayMaxFavoriteSlashCommandsValue != null)
|
||
TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
|
||
if (TxtOverlayMaxRecentSlashCommands != null)
|
||
TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
|
||
if (SldOverlayMaxRecentSlashCommands != null)
|
||
SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50);
|
||
if (TxtOverlayMaxRecentSlashCommandsValue != null)
|
||
TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
|
||
if (TxtOverlayPlanDiffMediumCount != null)
|
||
TxtOverlayPlanDiffMediumCount.Text = Math.Clamp(llm.PlanDiffSeverityMediumCount, 1, 999).ToString();
|
||
if (TxtOverlayPlanDiffHighCount != null)
|
||
TxtOverlayPlanDiffHighCount.Text = Math.Clamp(llm.PlanDiffSeverityHighCount, 1, 999).ToString();
|
||
if (TxtOverlayPlanDiffMediumRatio != null)
|
||
TxtOverlayPlanDiffMediumRatio.Text = Math.Clamp(llm.PlanDiffSeverityMediumRatioPercent, 1, 100).ToString();
|
||
if (TxtOverlayPlanDiffHighRatio != null)
|
||
TxtOverlayPlanDiffHighRatio.Text = Math.Clamp(llm.PlanDiffSeverityHighRatioPercent, 1, 100).ToString();
|
||
if (TxtOverlayPdfExportPath != null)
|
||
TxtOverlayPdfExportPath.Text = llm.PdfExportPath ?? "";
|
||
if (ChkOverlayEnableProactiveCompact != null)
|
||
ChkOverlayEnableProactiveCompact.IsChecked = llm.EnableProactiveContextCompact;
|
||
if (ChkOverlayEnableSkillSystem != null)
|
||
ChkOverlayEnableSkillSystem.IsChecked = llm.EnableSkillSystem;
|
||
if (ChkOverlayEnableToolHooks != null)
|
||
ChkOverlayEnableToolHooks.IsChecked = llm.EnableToolHooks;
|
||
if (ChkOverlayEnableHookInputMutation != null)
|
||
ChkOverlayEnableHookInputMutation.IsChecked = llm.EnableHookInputMutation;
|
||
if (ChkOverlayEnableHookPermissionUpdate != null)
|
||
ChkOverlayEnableHookPermissionUpdate.IsChecked = llm.EnableHookPermissionUpdate;
|
||
if (ChkOverlayEnableCoworkVerification != null)
|
||
ChkOverlayEnableCoworkVerification.IsChecked = llm.EnableCoworkVerification;
|
||
if (ChkOverlayEnableCodeVerification != null)
|
||
ChkOverlayEnableCodeVerification.IsChecked = llm.Code.EnableCodeVerification;
|
||
if (ChkOverlayEnableCodeReview != null)
|
||
ChkOverlayEnableCodeReview.IsChecked = llm.Code.EnableCodeReview;
|
||
if (ChkOverlayEnableImageInput != null)
|
||
ChkOverlayEnableImageInput.IsChecked = llm.EnableImageInput;
|
||
if (ChkOverlayEnableParallelTools != null)
|
||
ChkOverlayEnableParallelTools.IsChecked = llm.EnableParallelTools;
|
||
if (ChkOverlayEnableProjectRules != null)
|
||
ChkOverlayEnableProjectRules.IsChecked = llm.EnableProjectRules;
|
||
if (ChkOverlayEnableAgentMemory != null)
|
||
ChkOverlayEnableAgentMemory.IsChecked = llm.EnableAgentMemory;
|
||
if (ChkOverlayEnableWorktreeTools != null)
|
||
ChkOverlayEnableWorktreeTools.IsChecked = llm.Code.EnableWorktreeTools;
|
||
if (ChkOverlayEnableTeamTools != null)
|
||
ChkOverlayEnableTeamTools.IsChecked = llm.Code.EnableTeamTools;
|
||
if (ChkOverlayEnableCronTools != null)
|
||
ChkOverlayEnableCronTools.IsChecked = llm.Code.EnableCronTools;
|
||
if (ChkOverlayWorkflowVisualizer != null)
|
||
ChkOverlayWorkflowVisualizer.IsChecked = llm.WorkflowVisualizer;
|
||
if (ChkOverlayShowTotalCallStats != null)
|
||
ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats;
|
||
if (ChkOverlayEnableAuditLog != null)
|
||
ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog;
|
||
}
|
||
|
||
RefreshOverlayThemeCards();
|
||
RefreshOverlayServiceCards();
|
||
RefreshOverlayModeButtons();
|
||
RefreshOverlayTokenPresetCards();
|
||
RefreshOverlayServiceFieldLabels(service);
|
||
RefreshOverlayServiceFieldVisibility(service);
|
||
BuildOverlayRegisteredModelsPanel(service);
|
||
RefreshOverlayAdvancedChoiceButtons();
|
||
RefreshOverlayRetentionButtons();
|
||
RefreshOverlayStorageSummary();
|
||
}
|
||
finally
|
||
{
|
||
_isOverlaySettingsSyncing = false;
|
||
}
|
||
}
|
||
|
||
private static string NormalizeOverlayService(string? service)
|
||
=> string.Equals(service, "sigmoid", StringComparison.OrdinalIgnoreCase) ? "claude" : (service ?? "ollama").Trim().ToLowerInvariant();
|
||
|
||
private void CommitOverlayEndpointInput(bool normalizeOnInvalid)
|
||
{
|
||
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
|
||
var endpoint = TxtOverlayServiceEndpoint?.Text.Trim() ?? "";
|
||
ClearOverlayValidation(TxtOverlayServiceEndpoint);
|
||
switch (service)
|
||
{
|
||
case "ollama":
|
||
_settings.Settings.Llm.OllamaEndpoint = endpoint;
|
||
_settings.Settings.Llm.Endpoint = endpoint;
|
||
break;
|
||
case "vllm":
|
||
_settings.Settings.Llm.VllmEndpoint = endpoint;
|
||
_settings.Settings.Llm.Endpoint = endpoint;
|
||
break;
|
||
default:
|
||
_settings.Settings.Llm.Endpoint = endpoint;
|
||
break;
|
||
}
|
||
|
||
if (normalizeOnInvalid && TxtOverlayServiceEndpoint != null)
|
||
TxtOverlayServiceEndpoint.Text = endpoint;
|
||
}
|
||
|
||
private void CommitOverlayApiKeyInput()
|
||
{
|
||
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
|
||
var apiKey = TxtOverlayServiceApiKey?.Password ?? "";
|
||
switch (service)
|
||
{
|
||
case "ollama":
|
||
_settings.Settings.Llm.OllamaApiKey = apiKey;
|
||
_settings.Settings.Llm.ApiKey = apiKey;
|
||
break;
|
||
case "vllm":
|
||
_settings.Settings.Llm.VllmApiKey = apiKey;
|
||
_settings.Settings.Llm.ApiKey = apiKey;
|
||
break;
|
||
case "gemini":
|
||
_settings.Settings.Llm.GeminiApiKey = apiKey;
|
||
_settings.Settings.Llm.ApiKey = apiKey;
|
||
break;
|
||
default:
|
||
_settings.Settings.Llm.ClaudeApiKey = apiKey;
|
||
_settings.Settings.Llm.ApiKey = apiKey;
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void CommitOverlayModelInput(bool normalizeOnInvalid)
|
||
{
|
||
if (TxtOverlayModelInput == null || TxtOverlayModelInput.Visibility != Visibility.Visible)
|
||
return;
|
||
|
||
var value = TxtOverlayModelInput?.Text.Trim() ?? "";
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
{
|
||
MarkOverlayValidation(TxtOverlayModelInput, "모델명을 입력하세요.");
|
||
if (normalizeOnInvalid && TxtOverlayModelInput != null)
|
||
{
|
||
TxtOverlayModelInput.Text = _settings.Settings.Llm.Model ?? "";
|
||
ClearOverlayValidation(TxtOverlayModelInput);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
ClearOverlayValidation(TxtOverlayModelInput);
|
||
_settings.Settings.Llm.Model = value;
|
||
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
|
||
switch (service)
|
||
{
|
||
case "ollama":
|
||
_settings.Settings.Llm.OllamaModel = value;
|
||
break;
|
||
case "vllm":
|
||
_settings.Settings.Llm.VllmModel = value;
|
||
break;
|
||
case "gemini":
|
||
_settings.Settings.Llm.GeminiModel = value;
|
||
break;
|
||
default:
|
||
_settings.Settings.Llm.ClaudeModel = value;
|
||
break;
|
||
}
|
||
}
|
||
|
||
private bool CommitOverlayNumericInput(TextBox? textBox, int currentValue, int min, int max, Action<int> applyValue, bool normalizeOnInvalid)
|
||
{
|
||
if (textBox == null)
|
||
return false;
|
||
|
||
if (!int.TryParse(textBox.Text?.Trim(), out var parsed))
|
||
{
|
||
MarkOverlayValidation(textBox, $"{min}~{max} 사이 숫자를 입력하세요.");
|
||
if (normalizeOnInvalid)
|
||
{
|
||
textBox.Text = Math.Clamp(currentValue, min, max).ToString();
|
||
ClearOverlayValidation(textBox);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
parsed = Math.Clamp(parsed, min, max);
|
||
applyValue(parsed);
|
||
textBox.Text = parsed.ToString();
|
||
ClearOverlayValidation(textBox);
|
||
return true;
|
||
}
|
||
|
||
private LlmSettings? TryGetOverlayLlmSettings()
|
||
=> _settings?.Settings?.Llm;
|
||
|
||
private void MarkOverlayValidation(Control? control, string message)
|
||
{
|
||
if (control == null)
|
||
return;
|
||
|
||
control.BorderBrush = BrushFromHex("#DC2626");
|
||
control.ToolTip = message;
|
||
}
|
||
|
||
private void ClearOverlayValidation(Control? control)
|
||
{
|
||
if (control == null)
|
||
return;
|
||
|
||
control.BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
control.ClearValue(ToolTipProperty);
|
||
}
|
||
|
||
private void CommitOverlayModelSelection(string modelId)
|
||
{
|
||
_settings.Settings.Llm.Model = modelId;
|
||
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
|
||
switch (service)
|
||
{
|
||
case "ollama":
|
||
_settings.Settings.Llm.OllamaModel = modelId;
|
||
break;
|
||
case "vllm":
|
||
_settings.Settings.Llm.VllmModel = modelId;
|
||
break;
|
||
case "gemini":
|
||
_settings.Settings.Llm.GeminiModel = modelId;
|
||
break;
|
||
default:
|
||
_settings.Settings.Llm.ClaudeModel = modelId;
|
||
break;
|
||
}
|
||
|
||
if (TxtOverlayModelInput != null && TxtOverlayModelInput.Visibility == Visibility.Visible)
|
||
{
|
||
TxtOverlayModelInput.Text = modelId;
|
||
ClearOverlayValidation(TxtOverlayModelInput);
|
||
}
|
||
}
|
||
|
||
private void ChkOverlayAiEnabled_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
_settings.Settings.AiEnabled = true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void ChkOverlayVllmAllowInsecureTls_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
// vLLM SSL 우회는 모델 등록 단계에서만 관리합니다.
|
||
}
|
||
|
||
private void TxtOverlayServiceEndpoint_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
CommitOverlayEndpointInput(normalizeOnInvalid: false);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayServiceApiKey_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
CommitOverlayApiKeyInput();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayModelInput_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
CommitOverlayModelInput(normalizeOnInvalid: false);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void RefreshOverlayAdvancedChoiceButtons()
|
||
{
|
||
// ToggleSwitch 기반으로 바뀌면서 별도 버튼 시각 동기화는 사용하지 않습니다.
|
||
}
|
||
|
||
private void TxtOverlayContextCompactTriggerPercent_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, _settings.Settings.Llm.ContextCompactTriggerPercent, 10, 95, value => _settings.Settings.Llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayMaxContextTokens_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayMaxContextTokens, _settings.Settings.Llm.MaxContextTokens, 1024, 1_000_000, value => _settings.Settings.Llm.MaxContextTokens = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private bool CommitOverlayTemperatureInput(bool normalizeOnInvalid)
|
||
{
|
||
if (TxtOverlayTemperature == null)
|
||
return false;
|
||
|
||
var raw = TxtOverlayTemperature.Text.Trim();
|
||
if (!double.TryParse(raw, out var parsed))
|
||
{
|
||
ClearOverlayValidation(TxtOverlayTemperature);
|
||
if (normalizeOnInvalid)
|
||
TxtOverlayTemperature.Text = Math.Round(Math.Clamp(_settings.Settings.Llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
|
||
return false;
|
||
}
|
||
|
||
var normalized = Math.Round(Math.Clamp(parsed, 0.0, 2.0), 1);
|
||
var changed = Math.Abs(_settings.Settings.Llm.Temperature - normalized) > 0.0001;
|
||
_settings.Settings.Llm.Temperature = normalized;
|
||
ClearOverlayValidation(TxtOverlayTemperature);
|
||
if (normalizeOnInvalid || changed)
|
||
TxtOverlayTemperature.Text = normalized.ToString("0.0");
|
||
return changed;
|
||
}
|
||
|
||
private void TxtOverlayTemperature_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayTemperatureInput(normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayTemperature_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = Math.Round(Math.Clamp(e.NewValue, 0.0, 2.0), 1);
|
||
llm.Temperature = value;
|
||
if (TxtOverlayTemperature != null)
|
||
TxtOverlayTemperature.Text = value.ToString("0.0");
|
||
if (TxtOverlayTemperatureValue != null)
|
||
TxtOverlayTemperatureValue.Text = value.ToString("0.0");
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayMaxRetryOnError_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 10));
|
||
llm.MaxRetryOnError = value;
|
||
if (TxtOverlayMaxRetryOnError != null)
|
||
TxtOverlayMaxRetryOnError.Text = value.ToString();
|
||
if (TxtOverlayMaxRetryOnErrorValue != null)
|
||
TxtOverlayMaxRetryOnErrorValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayMaxAgentIterations_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 100));
|
||
llm.MaxAgentIterations = value;
|
||
if (TxtOverlayMaxAgentIterations != null)
|
||
TxtOverlayMaxAgentIterations.Text = value.ToString();
|
||
if (TxtOverlayMaxAgentIterationsValue != null)
|
||
TxtOverlayMaxAgentIterationsValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayFreeTierDelaySeconds_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 60));
|
||
llm.FreeTierDelaySeconds = value;
|
||
if (TxtOverlayFreeTierDelaySeconds != null)
|
||
TxtOverlayFreeTierDelaySeconds.Text = value.ToString();
|
||
if (TxtOverlayFreeTierDelaySecondsValue != null)
|
||
TxtOverlayFreeTierDelaySecondsValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayMaxSubAgents_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 10));
|
||
llm.MaxSubAgents = value;
|
||
if (TxtOverlayMaxSubAgents != null)
|
||
TxtOverlayMaxSubAgents.Text = value.ToString();
|
||
if (TxtOverlayMaxSubAgentsValue != null)
|
||
TxtOverlayMaxSubAgentsValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayToolHookTimeoutMs_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 3000, 30000));
|
||
llm.ToolHookTimeoutMs = value;
|
||
if (TxtOverlayToolHookTimeoutMs != null)
|
||
TxtOverlayToolHookTimeoutMs.Text = value.ToString();
|
||
if (TxtOverlayToolHookTimeoutMsValue != null)
|
||
TxtOverlayToolHookTimeoutMsValue.Text = $"{value / 1000}s";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlaySlashPopupPageSize_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 3, 20));
|
||
llm.SlashPopupPageSize = value;
|
||
if (TxtOverlaySlashPopupPageSize != null)
|
||
TxtOverlaySlashPopupPageSize.Text = value.ToString();
|
||
if (TxtOverlaySlashPopupPageSizeValue != null)
|
||
TxtOverlaySlashPopupPageSizeValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayMaxFavoriteSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 30));
|
||
llm.MaxFavoriteSlashCommands = value;
|
||
if (TxtOverlayMaxFavoriteSlashCommands != null)
|
||
TxtOverlayMaxFavoriteSlashCommands.Text = value.ToString();
|
||
if (TxtOverlayMaxFavoriteSlashCommandsValue != null)
|
||
TxtOverlayMaxFavoriteSlashCommandsValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void SldOverlayMaxRecentSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var llm = TryGetOverlayLlmSettings();
|
||
if (_isOverlaySettingsSyncing || llm == null)
|
||
return;
|
||
|
||
var value = (int)Math.Round(Math.Clamp(e.NewValue, 5, 50));
|
||
llm.MaxRecentSlashCommands = value;
|
||
if (TxtOverlayMaxRecentSlashCommands != null)
|
||
TxtOverlayMaxRecentSlashCommands.Text = value.ToString();
|
||
if (TxtOverlayMaxRecentSlashCommandsValue != null)
|
||
TxtOverlayMaxRecentSlashCommandsValue.Text = value.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayCompactPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value))
|
||
return;
|
||
|
||
_settings.Settings.Llm.ContextCompactTriggerPercent = Math.Clamp(value, 10, 95);
|
||
if (TxtOverlayContextCompactTriggerPercent != null)
|
||
TxtOverlayContextCompactTriggerPercent.Text = _settings.Settings.Llm.ContextCompactTriggerPercent.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayContextPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value))
|
||
return;
|
||
|
||
_settings.Settings.Llm.MaxContextTokens = Math.Clamp(value, 1024, 1_000_000);
|
||
if (TxtOverlayMaxContextTokens != null)
|
||
TxtOverlayMaxContextTokens.Text = _settings.Settings.Llm.MaxContextTokens.ToString();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayMaxRetryOnError_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, _settings.Settings.Llm.MaxRetryOnError, 0, 10, value => _settings.Settings.Llm.MaxRetryOnError = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayMaxAgentIterations_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, _settings.Settings.Llm.MaxAgentIterations, 1, 200, value => _settings.Settings.Llm.MaxAgentIterations = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayFreeTierDelaySeconds_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, _settings.Settings.Llm.FreeTierDelaySeconds, 0, 60, value => _settings.Settings.Llm.FreeTierDelaySeconds = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayMaxSubAgents_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayMaxSubAgents, _settings.Settings.Llm.MaxSubAgents, 1, 10, value => _settings.Settings.Llm.MaxSubAgents = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlaySlashPopupPageSize_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlaySlashPopupPageSize, _settings.Settings.Llm.SlashPopupPageSize, 3, 20, value => _settings.Settings.Llm.SlashPopupPageSize = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayToolHookTimeoutMs_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, _settings.Settings.Llm.ToolHookTimeoutMs, 3000, 30000, value => _settings.Settings.Llm.ToolHookTimeoutMs = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayMaxFavoriteSlashCommands_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, _settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30, value => _settings.Settings.Llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayMaxRecentSlashCommands_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, _settings.Settings.Llm.MaxRecentSlashCommands, 5, 50, value => _settings.Settings.Llm.MaxRecentSlashCommands = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayPdfExportPath_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || TxtOverlayPdfExportPath == null)
|
||
return;
|
||
|
||
_settings.Settings.Llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayPlanDiffMediumCount_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, _settings.Settings.Llm.PlanDiffSeverityMediumCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayPlanDiffHighCount_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, _settings.Settings.Llm.PlanDiffSeverityHighCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayPlanDiffMediumRatio_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void TxtOverlayPlanDiffHighRatio_LostFocus(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing)
|
||
return;
|
||
|
||
if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: false))
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayBrowseSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
var dlg = new System.Windows.Forms.FolderBrowserDialog
|
||
{
|
||
Description = "스킬 파일이 있는 폴더를 선택하세요",
|
||
ShowNewFolderButton = true,
|
||
};
|
||
|
||
var current = _settings.Settings.Llm.SkillsFolderPath;
|
||
if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current))
|
||
dlg.SelectedPath = current;
|
||
|
||
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK)
|
||
return;
|
||
|
||
_settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath;
|
||
SkillService.LoadSkills(dlg.SelectedPath);
|
||
RefreshOverlayEtcPanels();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayOpenSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
var folder = !string.IsNullOrWhiteSpace(_settings.Settings.Llm.SkillsFolderPath) && Directory.Exists(_settings.Settings.Llm.SkillsFolderPath)
|
||
? _settings.Settings.Llm.SkillsFolderPath
|
||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills");
|
||
|
||
if (!Directory.Exists(folder))
|
||
Directory.CreateDirectory(folder);
|
||
|
||
try { System.Diagnostics.Process.Start("explorer.exe", folder); } catch { }
|
||
}
|
||
|
||
private void OverlayAddHookBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
=> ShowOverlayHookEditDialog(null, -1);
|
||
|
||
private void OverlayOpenAuditLogBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
|
||
}
|
||
|
||
private void ChkOverlayEnableDragDropAiActions_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || ChkOverlayEnableDragDropAiActions == null)
|
||
return;
|
||
|
||
_settings.Settings.Llm.EnableDragDropAiActions = ChkOverlayEnableDragDropAiActions.IsChecked == true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void ChkOverlayDragDropAutoSend_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || ChkOverlayDragDropAutoSend == null)
|
||
return;
|
||
|
||
_settings.Settings.Llm.DragDropAutoSend = ChkOverlayDragDropAutoSend.IsChecked == true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void ChkOverlayWorkflowVisualizer_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || ChkOverlayWorkflowVisualizer == null)
|
||
return;
|
||
|
||
_settings.Settings.Llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer.IsChecked == true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void ChkOverlayShowTotalCallStats_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || ChkOverlayShowTotalCallStats == null)
|
||
return;
|
||
|
||
_settings.Settings.Llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats.IsChecked == true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void ChkOverlayEnableAuditLog_Changed(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || ChkOverlayEnableAuditLog == null)
|
||
return;
|
||
|
||
_settings.Settings.Llm.EnableAuditLog = ChkOverlayEnableAuditLog.IsChecked == true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayNav_Checked(object sender, RoutedEventArgs e)
|
||
{
|
||
if (sender is not RadioButton rb || rb.Tag is not string tag)
|
||
return;
|
||
|
||
SetOverlaySection(tag);
|
||
}
|
||
|
||
private void SetOverlaySection(string tag)
|
||
{
|
||
if (OverlaySectionService == null || OverlaySectionQuick == null || OverlaySectionDetail == null)
|
||
return;
|
||
|
||
var section = string.IsNullOrWhiteSpace(tag) ? "basic" : tag.Trim().ToLowerInvariant();
|
||
var showBasic = section == "basic";
|
||
var showChat = section == "chat";
|
||
var showShared = section == "shared";
|
||
var showCowork = section == "cowork";
|
||
var showCode = section == "code";
|
||
var showDev = section == "dev";
|
||
var showTools = section == "tools";
|
||
var showSkill = section == "skill";
|
||
var showBlock = section == "block";
|
||
|
||
OverlaySectionService.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
|
||
OverlaySectionQuick.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed;
|
||
OverlaySectionDetail.Visibility = Visibility.Visible;
|
||
|
||
var headingTitle = section switch
|
||
{
|
||
"chat" => "채팅 설정",
|
||
"shared" => "코워크/코드 공통 설정",
|
||
"cowork" => "코워크 설정",
|
||
"code" => "코드 설정",
|
||
"dev" => "개발자 설정",
|
||
"tools" => "도구 설정",
|
||
"skill" => "스킬 설정",
|
||
"block" => "차단 설정",
|
||
_ => "공통 설정"
|
||
};
|
||
var headingDescription = section switch
|
||
{
|
||
"chat" => "Chat 탭에서 쓰는 입력/내보내기 같은 채팅 전용 설정입니다.",
|
||
"shared" => "Cowork와 Code에서 함께 쓰는 문맥/압축 관련 기본 설정입니다.",
|
||
"cowork" => "문서/업무 협업 흐름에 맞춘 코워크 전용 설정입니다.",
|
||
"code" => "코드 작업, 검증, 개발 도구 사용에 맞춘 설정입니다.",
|
||
"dev" => "실행 이력, 감사, 시각화 같은 개발자용 설정입니다.",
|
||
"tools" => "AX Agent가 사용할 도구와 훅 동작을 관리합니다.",
|
||
"skill" => "슬래시 스킬, 스킬 폴더, 폴백 모델, MCP 연결을 관리합니다.",
|
||
"block" => "에이전트가 접근하거나 수정하면 안 되는 경로와 형식을 관리합니다.",
|
||
_ => "Chat, Cowork, Code에서 공통으로 쓰는 기본 설정입니다."
|
||
};
|
||
|
||
if (OverlayTopHeadingTitle != null)
|
||
OverlayTopHeadingTitle.Text = headingTitle;
|
||
if (OverlayTopHeadingDescription != null)
|
||
OverlayTopHeadingDescription.Text = headingDescription;
|
||
if (OverlayAnchorCommon != null)
|
||
OverlayAnchorCommon.Text = headingTitle;
|
||
|
||
if (OverlayAiEnabledRow != null)
|
||
OverlayAiEnabledRow.Visibility = Visibility.Collapsed;
|
||
if (OverlayThemePanel != null)
|
||
OverlayThemePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayThemeStylePanel != null)
|
||
OverlayThemeStylePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayDefaultOutputFormatRow != null)
|
||
OverlayDefaultOutputFormatRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayDefaultMoodRow != null)
|
||
OverlayDefaultMoodRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayPdfExportPathRow != null)
|
||
OverlayPdfExportPathRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleImageInput != null)
|
||
OverlayToggleImageInput.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayModelEditorPanel != null)
|
||
OverlayModelEditorPanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayAnchorPermission != null)
|
||
OverlayAnchorPermission.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayTlsRow != null)
|
||
OverlayTlsRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayAnchorAdvanced != null)
|
||
OverlayAnchorAdvanced.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed;
|
||
if (TxtOverlayContextCompactTriggerPercent != null)
|
||
TxtOverlayContextCompactTriggerPercent.Visibility = Visibility.Collapsed;
|
||
if (OverlayMaxContextTokensRow != null)
|
||
OverlayMaxContextTokensRow.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayTemperatureRow != null)
|
||
OverlayTemperatureRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayMaxRetryRow != null)
|
||
OverlayMaxRetryRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayMaxAgentIterationsRow != null)
|
||
OverlayMaxAgentIterationsRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayDeveloperRuntimePanel != null)
|
||
OverlayDeveloperRuntimePanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayDeveloperExtraPanel != null)
|
||
OverlayDeveloperExtraPanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayAdvancedTogglePanel != null)
|
||
OverlayAdvancedTogglePanel.Visibility = showDev || showCowork || showCode || showTools || showSkill ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToolsInfoPanel != null)
|
||
OverlayToolsInfoPanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToolsRuntimePanel != null)
|
||
OverlayToolsRuntimePanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToolRegistrySection != null)
|
||
OverlayToolRegistrySection.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlaySkillInfoPanel != null)
|
||
OverlaySkillInfoPanel.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlaySkillRuntimePanel != null)
|
||
OverlaySkillRuntimePanel.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayBlockInfoPanel != null)
|
||
OverlayBlockInfoPanel.Visibility = showBlock ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayBlockRuntimePanel != null)
|
||
OverlayBlockRuntimePanel.Visibility = showBlock ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleProactiveCompact != null)
|
||
OverlayToggleProactiveCompact.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleSkillSystem != null)
|
||
OverlayToggleSkillSystem.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleToolHooks != null)
|
||
OverlayToggleToolHooks.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleHookInputMutation != null)
|
||
OverlayToggleHookInputMutation.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleHookPermissionUpdate != null)
|
||
OverlayToggleHookPermissionUpdate.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleCoworkVerification != null)
|
||
OverlayToggleCoworkVerification.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleCodeVerification != null)
|
||
OverlayToggleCodeVerification.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleCodeReview != null)
|
||
OverlayToggleCodeReview.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleParallelTools != null)
|
||
OverlayToggleParallelTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleProjectRules != null)
|
||
OverlayToggleProjectRules.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleAgentMemory != null)
|
||
OverlayToggleAgentMemory.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleWorktreeTools != null)
|
||
OverlayToggleWorktreeTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleTeamTools != null)
|
||
OverlayToggleTeamTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayToggleCronTools != null)
|
||
OverlayToggleCronTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
if (showTools || showSkill || showBlock)
|
||
RefreshOverlayEtcPanels();
|
||
}
|
||
|
||
private void RefreshOverlaySettingsPanel()
|
||
{
|
||
RefreshOverlayVisualState(loadDeferredInputs: true);
|
||
RefreshOverlayEtcPanels();
|
||
}
|
||
|
||
private void RefreshOverlayRetentionButtons()
|
||
{
|
||
ApplyOverlayRetentionButtonState(BtnOverlayRetention7, _settings.Settings.Llm.RetentionDays == 7);
|
||
ApplyOverlayRetentionButtonState(BtnOverlayRetention30, _settings.Settings.Llm.RetentionDays == 30);
|
||
ApplyOverlayRetentionButtonState(BtnOverlayRetention90, _settings.Settings.Llm.RetentionDays == 90);
|
||
ApplyOverlayRetentionButtonState(BtnOverlayRetentionUnlimited, _settings.Settings.Llm.RetentionDays == 0);
|
||
}
|
||
|
||
private void ApplyOverlayRetentionButtonState(Button? button, bool selected)
|
||
{
|
||
if (button == null)
|
||
return;
|
||
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||
var border = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
|
||
var hint = TryFindResource("HintBackground") as Brush ?? Brushes.Transparent;
|
||
var primary = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondary = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
|
||
button.Background = selected ? hint : Brushes.Transparent;
|
||
button.BorderBrush = selected ? accent : border;
|
||
button.BorderThickness = new Thickness(1);
|
||
button.Foreground = selected ? accent : primary;
|
||
button.FontWeight = selected ? FontWeights.SemiBold : FontWeights.Normal;
|
||
button.Cursor = Cursors.Hand;
|
||
}
|
||
|
||
private void RefreshOverlayStorageSummary()
|
||
{
|
||
if (OverlayStorageSummaryText == null || OverlayStorageDriveText == null)
|
||
return;
|
||
|
||
var report = StorageAnalyzer.Analyze();
|
||
var appTotal = report.Conversations + report.AuditLogs + report.Logs + report.CodeIndex + report.EmbeddingDb + report.ClipboardHistory + report.Plugins + report.Skills + report.Settings;
|
||
OverlayStorageSummaryText.Text = $"앱 전체 사용량: {FormatStorageBytes(appTotal)}";
|
||
|
||
if (!string.IsNullOrWhiteSpace(report.DriveLabel) && report.DriveTotalSpace > 0)
|
||
{
|
||
var used = report.DriveTotalSpace - report.DriveFreeSpace;
|
||
var percent = report.DriveTotalSpace == 0 ? 0 : (int)Math.Round((double)used / report.DriveTotalSpace * 100);
|
||
OverlayStorageDriveText.Text = $"{report.DriveLabel} · 사용 {percent}% · 여유 {FormatStorageBytes(report.DriveFreeSpace)}";
|
||
}
|
||
else
|
||
{
|
||
OverlayStorageDriveText.Text = "로컬 앱 데이터 폴더 기준 사용량입니다.";
|
||
}
|
||
}
|
||
|
||
private void BtnOverlayRetention_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (sender is not FrameworkElement element)
|
||
return;
|
||
|
||
var retainDays = element.Name switch
|
||
{
|
||
"BtnOverlayRetention7" => 7,
|
||
"BtnOverlayRetention30" => 30,
|
||
"BtnOverlayRetention90" => 90,
|
||
"BtnOverlayRetentionUnlimited" => 0,
|
||
_ => _settings.Settings.Llm.RetentionDays
|
||
};
|
||
|
||
_settings.Settings.Llm.RetentionDays = retainDays;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
RefreshOverlayRetentionButtons();
|
||
}
|
||
|
||
private void BtnOverlayStorageRefresh_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
RefreshOverlayStorageSummary();
|
||
}
|
||
|
||
private void BtnOverlayDeleteAllConversations_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
BtnDeleteAll_Click(sender, e);
|
||
RefreshOverlayStorageSummary();
|
||
}
|
||
|
||
private void BtnOverlayStorageCleanup_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var retainDays = Math.Max(0, _settings.Settings.Llm.RetentionDays);
|
||
var cleanedBytes = StorageAnalyzer.Cleanup(
|
||
retainDays,
|
||
cleanConversations: false,
|
||
cleanAuditLogs: true,
|
||
cleanLogs: true,
|
||
cleanCodeIndex: true,
|
||
cleanClipboard: true);
|
||
|
||
RefreshOverlayStorageSummary();
|
||
|
||
CustomMessageBox.Show(
|
||
cleanedBytes > 0
|
||
? $"저장 공간을 정리했습니다.\n확보된 공간: {StorageAnalyzer.FormatSize(cleanedBytes)}"
|
||
: "정리할 항목이 없었습니다.",
|
||
"저장 공간 정리",
|
||
MessageBoxButton.OK,
|
||
MessageBoxImage.Information);
|
||
}
|
||
|
||
private static string FormatStorageBytes(long bytes)
|
||
{
|
||
if (bytes >= 1024L * 1024 * 1024)
|
||
return $"{bytes / 1024.0 / 1024 / 1024:F1} GB";
|
||
if (bytes >= 1024L * 1024)
|
||
return $"{bytes / 1024.0 / 1024:F1} MB";
|
||
if (bytes >= 1024L)
|
||
return $"{bytes / 1024.0:F0} KB";
|
||
return $"{bytes} B";
|
||
}
|
||
|
||
private void RefreshOverlayEtcPanels()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
|
||
if (OverlaySkillsFolderPathText != null)
|
||
{
|
||
var defaultFolder = System.IO.Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||
"AxCopilot",
|
||
"skills");
|
||
OverlaySkillsFolderPathText.Text = string.IsNullOrWhiteSpace(llm.SkillsFolderPath)
|
||
? defaultFolder
|
||
: llm.SkillsFolderPath.Trim();
|
||
}
|
||
|
||
if (TxtOverlaySlashPopupPageSize != null)
|
||
TxtOverlaySlashPopupPageSize.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString();
|
||
if (SldOverlaySlashPopupPageSize != null)
|
||
SldOverlaySlashPopupPageSize.Value = Math.Clamp(llm.SlashPopupPageSize, 3, 20);
|
||
if (TxtOverlaySlashPopupPageSizeValue != null)
|
||
TxtOverlaySlashPopupPageSizeValue.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString();
|
||
if (TxtOverlayToolHookTimeoutMs != null)
|
||
TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString();
|
||
if (SldOverlayToolHookTimeoutMs != null)
|
||
SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000);
|
||
if (TxtOverlayToolHookTimeoutMsValue != null)
|
||
TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s";
|
||
if (TxtOverlayMaxFavoriteSlashCommands != null)
|
||
TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
|
||
if (SldOverlayMaxFavoriteSlashCommands != null)
|
||
SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30);
|
||
if (TxtOverlayMaxFavoriteSlashCommandsValue != null)
|
||
TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
|
||
if (TxtOverlayMaxRecentSlashCommands != null)
|
||
TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
|
||
if (SldOverlayMaxRecentSlashCommands != null)
|
||
SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50);
|
||
if (TxtOverlayMaxRecentSlashCommandsValue != null)
|
||
TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
|
||
if (ChkOverlayEnableDragDropAiActions != null)
|
||
ChkOverlayEnableDragDropAiActions.IsChecked = llm.EnableDragDropAiActions;
|
||
if (ChkOverlayDragDropAutoSend != null)
|
||
ChkOverlayDragDropAutoSend.IsChecked = llm.DragDropAutoSend;
|
||
|
||
BuildOverlayBlockedItems();
|
||
BuildOverlayHookCards();
|
||
BuildOverlaySkillListPanel();
|
||
BuildOverlayFallbackModelsPanel();
|
||
BuildOverlayMcpServerCards();
|
||
BuildOverlayToolRegistryPanel();
|
||
}
|
||
|
||
private void BuildOverlayBlockedItems()
|
||
{
|
||
if (OverlayBlockedPathsPanel != null)
|
||
{
|
||
OverlayBlockedPathsPanel.Children.Clear();
|
||
foreach (var path in _settings.Settings.Llm.BlockedPaths.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||
{
|
||
OverlayBlockedPathsPanel.Children.Add(new Border
|
||
{
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(0, 0, 0, 4),
|
||
Child = new TextBlock
|
||
{
|
||
Text = path,
|
||
FontSize = 11.5,
|
||
FontFamily = new FontFamily("Consolas, Malgun Gothic"),
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if (OverlayBlockedExtensionsPanel != null)
|
||
{
|
||
OverlayBlockedExtensionsPanel.Children.Clear();
|
||
foreach (var ext in _settings.Settings.Llm.BlockedExtensions.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||
{
|
||
OverlayBlockedExtensionsPanel.Children.Add(new Border
|
||
{
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(0, 0, 6, 6),
|
||
Child = new TextBlock
|
||
{
|
||
Text = ext,
|
||
FontSize = 11.5,
|
||
FontFamily = new FontFamily("Consolas, Malgun Gothic"),
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
private void BuildOverlaySkillListPanel()
|
||
{
|
||
if (OverlaySkillListPanel == null)
|
||
return;
|
||
|
||
OverlaySkillListPanel.Children.Clear();
|
||
var skills = SkillService.Skills.ToList();
|
||
if (skills.Count == 0)
|
||
{
|
||
OverlaySkillListPanel.Children.Add(new TextBlock
|
||
{
|
||
Text = "로드된 스킬이 없습니다.",
|
||
FontSize = 11,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
|
||
});
|
||
return;
|
||
}
|
||
|
||
var unavailable = skills
|
||
.Where(skill => !skill.IsAvailable)
|
||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var autoSkills = skills
|
||
.Where(skill => skill.IsAvailable && (!skill.UserInvocable || !string.IsNullOrWhiteSpace(skill.Paths)))
|
||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var directSkills = skills
|
||
.Where(skill => skill.IsAvailable && skill.UserInvocable && string.IsNullOrWhiteSpace(skill.Paths))
|
||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
AddOverlaySkillSection("overlay-skill-direct", "직접 호출 스킬", "슬래시(/)로 직접 실행하는 스킬입니다.", directSkills, "#2563EB");
|
||
AddOverlaySkillSection("overlay-skill-auto", "자동/조건부 스킬", "조건에 따라 자동으로 붙거나 보조적으로 동작하는 스킬입니다.", autoSkills, "#0F766E");
|
||
AddOverlaySkillSection("overlay-skill-unavailable", "현재 사용 불가", "필요한 런타임이 없어 지금은 호출되지 않는 스킬입니다.", unavailable, "#9A3412");
|
||
}
|
||
|
||
private void AddOverlaySkillSection(string key, string title, string subtitle, List<SkillDefinition> skills, string accentHex)
|
||
{
|
||
if (OverlaySkillListPanel == null || skills.Count == 0)
|
||
return;
|
||
|
||
var body = new StackPanel();
|
||
foreach (var skill in skills)
|
||
{
|
||
var card = new Border
|
||
{
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = "/" + skill.Name,
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black
|
||
});
|
||
|
||
if (!string.IsNullOrWhiteSpace(skill.Label) &&
|
||
!string.Equals(skill.Label.Trim(), skill.Name, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = skill.Label.Trim(),
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
FontSize = 11,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
|
||
});
|
||
}
|
||
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = string.IsNullOrWhiteSpace(skill.Description)
|
||
? (string.IsNullOrWhiteSpace(skill.Label) ? skill.Name : skill.Label)
|
||
: skill.Description,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 11,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
|
||
});
|
||
|
||
card.Child = stack;
|
||
body.Children.Add(card);
|
||
}
|
||
|
||
OverlaySkillListPanel.Children.Add(CreateOverlayCollapsibleSection(
|
||
key,
|
||
$"{title} ({skills.Count})",
|
||
subtitle,
|
||
body,
|
||
defaultExpanded: false,
|
||
accentHex: accentHex));
|
||
}
|
||
|
||
private void BuildOverlayFallbackModelsPanel()
|
||
{
|
||
if (OverlayFallbackModelsPanel == null)
|
||
return;
|
||
|
||
OverlayFallbackModelsPanel.Children.Clear();
|
||
var llm = _settings.Settings.Llm;
|
||
var sections = new[]
|
||
{
|
||
("ollama", "Ollama"),
|
||
("vllm", "vLLM"),
|
||
("gemini", "Gemini"),
|
||
("claude", "Claude")
|
||
};
|
||
|
||
foreach (var (service, label) in sections)
|
||
{
|
||
var candidates = GetModelCandidates(service);
|
||
OverlayFallbackModelsPanel.Children.Add(new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 11.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black,
|
||
Margin = new Thickness(0, 0, 0, 6)
|
||
});
|
||
|
||
if (candidates.Count == 0)
|
||
{
|
||
OverlayFallbackModelsPanel.Children.Add(new TextBlock
|
||
{
|
||
Text = "등록된 모델 없음",
|
||
FontSize = 10.5,
|
||
Margin = new Thickness(8, 0, 0, 8),
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
|
||
});
|
||
continue;
|
||
}
|
||
|
||
foreach (var candidate in candidates)
|
||
{
|
||
var enabled = llm.FallbackModels.Any(x => x.Equals(candidate.Id, StringComparison.OrdinalIgnoreCase));
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var nameText = new TextBlock
|
||
{
|
||
Text = candidate.Label,
|
||
FontSize = 11.5,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black
|
||
};
|
||
var stateText = new TextBlock
|
||
{
|
||
Text = enabled ? "사용" : "미사용",
|
||
FontSize = 10.5,
|
||
Foreground = enabled
|
||
? (TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue)
|
||
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
|
||
Margin = new Thickness(12, 0, 0, 0)
|
||
};
|
||
Grid.SetColumn(stateText, 1);
|
||
grid.Children.Add(nameText);
|
||
grid.Children.Add(stateText);
|
||
|
||
OverlayFallbackModelsPanel.Children.Add(new Border
|
||
{
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
Child = grid
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
private void BuildOverlayMcpServerCards()
|
||
{
|
||
if (OverlayMcpServerListPanel == null)
|
||
return;
|
||
|
||
OverlayMcpServerListPanel.Children.Clear();
|
||
var servers = _settings.Settings.Llm.McpServers;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
|
||
var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.White;
|
||
var cardBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||
|
||
if (servers == null || servers.Count == 0)
|
||
{
|
||
OverlayMcpServerListPanel.Children.Add(new TextBlock
|
||
{
|
||
Text = "등록된 MCP 서버가 없습니다.",
|
||
FontSize = 11,
|
||
Foreground = secondaryText
|
||
});
|
||
return;
|
||
}
|
||
|
||
Border CreateActionChip(string text, Brush foreground, Action onClick)
|
||
{
|
||
var border = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = Brushes.Transparent,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
|
||
var label = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = foreground,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
|
||
border.Child = label;
|
||
border.MouseEnter += (_, _) =>
|
||
{
|
||
border.Background = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(20, 0, 0, 0));
|
||
border.BorderBrush = borderBrush;
|
||
};
|
||
border.MouseLeave += (_, _) =>
|
||
{
|
||
border.Background = Brushes.Transparent;
|
||
border.BorderBrush = Brushes.Transparent;
|
||
};
|
||
border.MouseLeftButtonUp += (_, _) => onClick();
|
||
return border;
|
||
}
|
||
|
||
for (int index = 0; index < servers.Count; index++)
|
||
{
|
||
var server = servers[index];
|
||
var card = new Border
|
||
{
|
||
Background = cardBackground,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(12, 10, 12, 10),
|
||
Margin = new Thickness(0, 0, 0, 6)
|
||
};
|
||
|
||
var root = new StackPanel();
|
||
var header = new Grid();
|
||
header.ColumnDefinitions.Add(new ColumnDefinition());
|
||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var title = new TextBlock
|
||
{
|
||
Text = server.Name,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center
|
||
};
|
||
header.Children.Add(title);
|
||
|
||
var actions = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
};
|
||
actions.Children.Add(CreateActionChip(server.Enabled ? "비활성화" : "활성화", accentBrush, () =>
|
||
{
|
||
_settings.Settings.Llm.McpServers[index].Enabled = !_settings.Settings.Llm.McpServers[index].Enabled;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
BuildOverlayMcpServerCards();
|
||
}));
|
||
actions.Children.Add(CreateActionChip("삭제", BrushFromHex("#DC2626"), () =>
|
||
{
|
||
var result = CustomMessageBox.Show($"'{server.Name}' 서버를 삭제하시겠습니까?", "MCP 서버 삭제",
|
||
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||
if (result != MessageBoxResult.Yes)
|
||
return;
|
||
|
||
_settings.Settings.Llm.McpServers.RemoveAt(index);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
BuildOverlayMcpServerCards();
|
||
}));
|
||
Grid.SetColumn(actions, 1);
|
||
header.Children.Add(actions);
|
||
root.Children.Add(header);
|
||
|
||
var commandCard = new Border
|
||
{
|
||
Background = itemBackground,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
Child = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = server.Command,
|
||
FontSize = 10.8,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = primaryText,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = server.Enabled ? "활성 상태" : "비활성 상태",
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
}
|
||
}
|
||
}
|
||
};
|
||
root.Children.Add(commandCard);
|
||
card.Child = root;
|
||
OverlayMcpServerListPanel.Children.Add(card);
|
||
}
|
||
}
|
||
|
||
private void BtnOverlayAddMcpServer_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var nameDialog = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server")
|
||
{
|
||
Owner = this
|
||
};
|
||
if (nameDialog.ShowDialog() != true || string.IsNullOrWhiteSpace(nameDialog.ResponseText))
|
||
return;
|
||
|
||
var commandDialog = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @modelcontextprotocol/server-filesystem")
|
||
{
|
||
Owner = this
|
||
};
|
||
if (commandDialog.ShowDialog() != true || string.IsNullOrWhiteSpace(commandDialog.ResponseText))
|
||
return;
|
||
|
||
_settings.Settings.Llm.McpServers.Add(new Models.McpServerEntry
|
||
{
|
||
Name = nameDialog.ResponseText.Trim(),
|
||
Command = commandDialog.ResponseText.Trim(),
|
||
Enabled = true,
|
||
});
|
||
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
BuildOverlayMcpServerCards();
|
||
}
|
||
|
||
private void BuildOverlayToolRegistryPanel()
|
||
{
|
||
if (OverlayToolRegistryPanel == null)
|
||
return;
|
||
|
||
OverlayToolRegistryPanel.Children.Clear();
|
||
var grouped = _toolRegistry.All
|
||
.GroupBy(tool => GetOverlayToolCategory(tool.Name))
|
||
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
foreach (var group in grouped)
|
||
{
|
||
var body = new StackPanel();
|
||
foreach (var tool in group.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
var isDisabled = _settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase);
|
||
var row = new Border
|
||
{
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
};
|
||
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var rowStack = new StackPanel { Margin = new Thickness(0, 0, 12, 0) };
|
||
rowStack.Children.Add(new TextBlock
|
||
{
|
||
Text = tool.Name,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = isDisabled
|
||
? (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray)
|
||
: (TryFindResource("PrimaryText") as Brush ?? Brushes.Black)
|
||
});
|
||
rowStack.Children.Add(new TextBlock
|
||
{
|
||
Text = (isDisabled ? "비활성" : "활성") + " · " + tool.Description,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 10.8,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
|
||
});
|
||
grid.Children.Add(rowStack);
|
||
|
||
var toggle = new CheckBox
|
||
{
|
||
IsChecked = !isDisabled,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Style = TryFindResource("ToggleSwitch") as Style,
|
||
};
|
||
toggle.Checked += (_, _) =>
|
||
{
|
||
_settings.Settings.Llm.DisabledTools.RemoveAll(name => string.Equals(name, tool.Name, StringComparison.OrdinalIgnoreCase));
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
RefreshOverlayEtcPanels();
|
||
};
|
||
toggle.Unchecked += (_, _) =>
|
||
{
|
||
if (!_settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase))
|
||
_settings.Settings.Llm.DisabledTools.Add(tool.Name);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
RefreshOverlayEtcPanels();
|
||
};
|
||
Grid.SetColumn(toggle, 1);
|
||
grid.Children.Add(toggle);
|
||
|
||
row.Child = grid;
|
||
body.Children.Add(row);
|
||
}
|
||
|
||
OverlayToolRegistryPanel.Children.Add(CreateOverlayCollapsibleSection(
|
||
"overlay-tool-" + group.Key,
|
||
$"{group.Key} ({group.Count()})",
|
||
"카테고리별 도구 목록입니다. 펼치면 상세 이름과 사용 여부를 바로 바꿀 수 있습니다.",
|
||
body,
|
||
defaultExpanded: false,
|
||
accentHex: "#4F46E5"));
|
||
}
|
||
}
|
||
|
||
private void BuildOverlayHookCards()
|
||
{
|
||
if (OverlayHookListPanel == null)
|
||
return;
|
||
|
||
OverlayHookListPanel.Children.Clear();
|
||
|
||
var hooks = _settings.Settings.Llm.AgentHooks;
|
||
if (hooks.Count == 0)
|
||
{
|
||
OverlayHookListPanel.Children.Add(new TextBlock
|
||
{
|
||
Text = "등록된 훅이 없습니다.",
|
||
FontSize = 11,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
|
||
});
|
||
return;
|
||
}
|
||
|
||
var body = new StackPanel();
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||
|
||
for (int i = 0; i < hooks.Count; i++)
|
||
{
|
||
var hook = hooks[i];
|
||
var idx = i;
|
||
var card = new Border
|
||
{
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
};
|
||
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var toggle = new CheckBox
|
||
{
|
||
IsChecked = hook.Enabled,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
Style = TryFindResource("ToggleSwitch") as Style,
|
||
};
|
||
toggle.Checked += (_, _) =>
|
||
{
|
||
hook.Enabled = true;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
};
|
||
toggle.Unchecked += (_, _) =>
|
||
{
|
||
hook.Enabled = false;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
};
|
||
Grid.SetColumn(toggle, 0);
|
||
grid.Children.Add(toggle);
|
||
|
||
var info = new StackPanel();
|
||
var header = new StackPanel { Orientation = Orientation.Horizontal };
|
||
header.Children.Add(new TextBlock
|
||
{
|
||
Text = hook.Name,
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
});
|
||
header.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex(hook.Timing == "pre" ? "#FFEDD5" : "#DCFCE7"),
|
||
BorderBrush = BrushFromHex(hook.Timing == "pre" ? "#FDBA74" : "#86EFAC"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(5),
|
||
Padding = new Thickness(5, 1, 5, 1),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = hook.Timing == "pre" ? "PRE" : "POST",
|
||
FontSize = 9.5,
|
||
FontWeight = FontWeights.Bold,
|
||
Foreground = BrushFromHex(hook.Timing == "pre" ? "#9A3412" : "#166534"),
|
||
}
|
||
});
|
||
if (!string.IsNullOrWhiteSpace(hook.ToolName) && hook.ToolName != "*")
|
||
{
|
||
header.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex("#EEF2FF"),
|
||
BorderBrush = BrushFromHex("#C7D2FE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(5),
|
||
Padding = new Thickness(5, 1, 5, 1),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = hook.ToolName,
|
||
FontSize = 9.5,
|
||
Foreground = BrushFromHex("#3730A3"),
|
||
}
|
||
});
|
||
}
|
||
info.Children.Add(header);
|
||
info.Children.Add(new TextBlock
|
||
{
|
||
Text = Path.GetFileName(hook.ScriptPath),
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 11,
|
||
Foreground = secondaryText
|
||
});
|
||
if (!string.IsNullOrWhiteSpace(hook.Arguments))
|
||
{
|
||
info.Children.Add(new TextBlock
|
||
{
|
||
Text = hook.Arguments,
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
FontSize = 10.5,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText
|
||
});
|
||
}
|
||
Grid.SetColumn(info, 1);
|
||
grid.Children.Add(info);
|
||
|
||
var editBtn = new Border
|
||
{
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(6),
|
||
Margin = new Thickness(6, 0, 2, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Child = new TextBlock
|
||
{
|
||
Text = "\uE70F",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = accentBrush,
|
||
}
|
||
};
|
||
editBtn.MouseLeftButtonUp += (_, _) => ShowOverlayHookEditDialog(hooks[idx], idx);
|
||
Grid.SetColumn(editBtn, 2);
|
||
grid.Children.Add(editBtn);
|
||
|
||
var deleteBtn = new Border
|
||
{
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(6),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Child = new TextBlock
|
||
{
|
||
Text = "\uE74D",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = BrushFromHex("#DC2626"),
|
||
}
|
||
};
|
||
deleteBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
hooks.RemoveAt(idx);
|
||
BuildOverlayHookCards();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
};
|
||
Grid.SetColumn(deleteBtn, 3);
|
||
grid.Children.Add(deleteBtn);
|
||
|
||
card.Child = grid;
|
||
body.Children.Add(card);
|
||
}
|
||
|
||
OverlayHookListPanel.Children.Add(CreateOverlayCollapsibleSection(
|
||
"overlay-hooks",
|
||
$"등록된 훅 ({hooks.Count})",
|
||
"도구 실행 전후에 연결되는 스크립트입니다.",
|
||
body,
|
||
defaultExpanded: false,
|
||
accentHex: "#0F766E"));
|
||
}
|
||
|
||
private Border CreateOverlayCollapsibleSection(string key, string title, string subtitle, UIElement content, bool defaultExpanded, string accentHex)
|
||
{
|
||
var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
|
||
var launcherBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var expanded = _overlaySectionExpandedStates.TryGetValue(key, out var stored) ? stored : defaultExpanded;
|
||
|
||
var bodyBorder = new Border
|
||
{
|
||
Margin = new Thickness(0, 10, 0, 0),
|
||
Child = content,
|
||
Visibility = expanded ? Visibility.Visible : Visibility.Collapsed
|
||
};
|
||
|
||
var caret = new TextBlock
|
||
{
|
||
Text = expanded ? "\uE70D" : "\uE76C",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center
|
||
};
|
||
|
||
var headerGrid = new Grid();
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var headerText = new StackPanel();
|
||
headerText.Children.Add(new TextBlock
|
||
{
|
||
Text = title,
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText
|
||
});
|
||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||
{
|
||
headerText.Children.Add(new TextBlock
|
||
{
|
||
Text = subtitle,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 10.8,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText
|
||
});
|
||
}
|
||
headerGrid.Children.Add(headerText);
|
||
Grid.SetColumn(caret, 1);
|
||
headerGrid.Children.Add(caret);
|
||
|
||
var headerBorder = new Border
|
||
{
|
||
Background = launcherBackground,
|
||
BorderBrush = BrushFromHex(accentHex),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Cursor = Cursors.Hand,
|
||
Child = headerGrid
|
||
};
|
||
|
||
void Toggle()
|
||
{
|
||
var nextExpanded = bodyBorder.Visibility != Visibility.Visible;
|
||
bodyBorder.Visibility = nextExpanded ? Visibility.Visible : Visibility.Collapsed;
|
||
caret.Text = nextExpanded ? "\uE70D" : "\uE76C";
|
||
_overlaySectionExpandedStates[key] = nextExpanded;
|
||
}
|
||
|
||
headerBorder.MouseLeftButtonUp += (_, _) => Toggle();
|
||
|
||
return new Border
|
||
{
|
||
Background = itemBackground,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(10),
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
Child = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
headerBorder,
|
||
bodyBorder
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
private static TextBlock CreateOverlayPlaceholder(string text, Brush foreground, string? currentValue)
|
||
{
|
||
return new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 13,
|
||
Foreground = foreground,
|
||
Opacity = 0.45,
|
||
IsHitTestVisible = false,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Padding = new Thickness(14, 8, 14, 8),
|
||
Visibility = string.IsNullOrEmpty(currentValue) ? Visibility.Visible : Visibility.Collapsed,
|
||
};
|
||
}
|
||
|
||
private void ShowOverlayHookEditDialog(AgentHookEntry? existing, int index)
|
||
{
|
||
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? BrushFromHex("#FFFFFF");
|
||
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
|
||
var isNew = existing == null;
|
||
var dlg = new Window
|
||
{
|
||
Title = isNew ? "훅 추가" : "훅 편집",
|
||
Width = 420,
|
||
SizeToContent = SizeToContent.Height,
|
||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||
Owner = this,
|
||
ResizeMode = ResizeMode.NoResize,
|
||
WindowStyle = WindowStyle.None,
|
||
AllowsTransparency = true,
|
||
Background = Brushes.Transparent,
|
||
ShowInTaskbar = false
|
||
};
|
||
|
||
var border = new Border
|
||
{
|
||
Background = bgBrush,
|
||
CornerRadius = new CornerRadius(12),
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(20)
|
||
};
|
||
var stack = new StackPanel();
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = isNew ? "훅 추가" : "훅 편집",
|
||
FontSize = 15,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = fgBrush,
|
||
Margin = new Thickness(0, 0, 0, 14)
|
||
});
|
||
|
||
dlg.KeyDown += (_, e) => { if (e.Key == Key.Escape) dlg.Close(); };
|
||
|
||
stack.Children.Add(new TextBlock { Text = "훅 이름", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 4) });
|
||
var nameBox = new TextBox
|
||
{
|
||
Text = existing?.Name ?? "",
|
||
FontSize = 13,
|
||
Foreground = fgBrush,
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
Padding = new Thickness(12, 8, 12, 8)
|
||
};
|
||
var nameHolder = CreateOverlayPlaceholder("예: 코드 리뷰 후 알림", subFgBrush, existing?.Name);
|
||
nameBox.TextChanged += (_, _) => nameHolder.Visibility = string.IsNullOrEmpty(nameBox.Text) ? Visibility.Visible : Visibility.Collapsed;
|
||
var nameGrid = new Grid();
|
||
nameGrid.Children.Add(nameBox);
|
||
nameGrid.Children.Add(nameHolder);
|
||
stack.Children.Add(nameGrid);
|
||
|
||
stack.Children.Add(new TextBlock { Text = "대상 도구 (* = 모든 도구)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
|
||
var toolBox = new TextBox
|
||
{
|
||
Text = existing?.ToolName ?? "*",
|
||
FontSize = 13,
|
||
Foreground = fgBrush,
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
Padding = new Thickness(12, 8, 12, 8)
|
||
};
|
||
var toolHolder = CreateOverlayPlaceholder("예: file_write, grep_tool", subFgBrush, existing?.ToolName ?? "*");
|
||
toolBox.TextChanged += (_, _) => toolHolder.Visibility = string.IsNullOrEmpty(toolBox.Text) ? Visibility.Visible : Visibility.Collapsed;
|
||
var toolGrid = new Grid();
|
||
toolGrid.Children.Add(toolBox);
|
||
toolGrid.Children.Add(toolHolder);
|
||
stack.Children.Add(toolGrid);
|
||
|
||
stack.Children.Add(new TextBlock { Text = "실행 타이밍", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
|
||
var timingPanel = new StackPanel { Orientation = Orientation.Horizontal };
|
||
var preRadio = new RadioButton
|
||
{
|
||
Content = "Pre (실행 전)",
|
||
Foreground = fgBrush,
|
||
FontSize = 13,
|
||
Margin = new Thickness(0, 0, 16, 0),
|
||
IsChecked = (existing?.Timing ?? "post") == "pre"
|
||
};
|
||
var postRadio = new RadioButton
|
||
{
|
||
Content = "Post (실행 후)",
|
||
Foreground = fgBrush,
|
||
FontSize = 13,
|
||
IsChecked = (existing?.Timing ?? "post") != "pre"
|
||
};
|
||
timingPanel.Children.Add(preRadio);
|
||
timingPanel.Children.Add(postRadio);
|
||
stack.Children.Add(timingPanel);
|
||
|
||
stack.Children.Add(new TextBlock { Text = "스크립트 경로 (.bat / .cmd / .ps1)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
|
||
var pathGrid = new Grid();
|
||
pathGrid.ColumnDefinitions.Add(new ColumnDefinition());
|
||
pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
var pathInnerGrid = new Grid();
|
||
var pathBox = new TextBox
|
||
{
|
||
Text = existing?.ScriptPath ?? "",
|
||
FontSize = 13,
|
||
Foreground = fgBrush,
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
Padding = new Thickness(12, 8, 12, 8)
|
||
};
|
||
var pathHolder = CreateOverlayPlaceholder("예: C:\\scripts\\review-notify.bat", subFgBrush, existing?.ScriptPath);
|
||
pathBox.TextChanged += (_, _) => pathHolder.Visibility = string.IsNullOrEmpty(pathBox.Text) ? Visibility.Visible : Visibility.Collapsed;
|
||
pathInnerGrid.Children.Add(pathBox);
|
||
pathInnerGrid.Children.Add(pathHolder);
|
||
pathGrid.Children.Add(pathInnerGrid);
|
||
|
||
var browseBtn = new Border
|
||
{
|
||
Background = itemBg,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(10, 6, 10, 6),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Cursor = Cursors.Hand,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Child = new TextBlock
|
||
{
|
||
Text = "...",
|
||
FontSize = 13,
|
||
Foreground = accentBrush
|
||
}
|
||
};
|
||
browseBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
var ofd = new OpenFileDialog
|
||
{
|
||
Filter = "스크립트 파일|*.bat;*.cmd;*.ps1|모든 파일|*.*",
|
||
Title = "훅 스크립트 선택",
|
||
};
|
||
if (ofd.ShowDialog() == true)
|
||
pathBox.Text = ofd.FileName;
|
||
};
|
||
Grid.SetColumn(browseBtn, 1);
|
||
pathGrid.Children.Add(browseBtn);
|
||
stack.Children.Add(pathGrid);
|
||
|
||
stack.Children.Add(new TextBlock { Text = "추가 인수 (선택)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
|
||
var argsBox = new TextBox
|
||
{
|
||
Text = existing?.Arguments ?? "",
|
||
FontSize = 13,
|
||
Foreground = fgBrush,
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
Padding = new Thickness(12, 8, 12, 8)
|
||
};
|
||
var argsHolder = CreateOverlayPlaceholder("예: --verbose --output log.txt", subFgBrush, existing?.Arguments);
|
||
argsBox.TextChanged += (_, _) => argsHolder.Visibility = string.IsNullOrEmpty(argsBox.Text) ? Visibility.Visible : Visibility.Collapsed;
|
||
var argsGrid = new Grid();
|
||
argsGrid.Children.Add(argsBox);
|
||
argsGrid.Children.Add(argsHolder);
|
||
stack.Children.Add(argsGrid);
|
||
|
||
var btnRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
Margin = new Thickness(0, 16, 0, 0)
|
||
};
|
||
var cancelBorder = new Border
|
||
{
|
||
Background = itemBg,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(16, 8, 16, 8),
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
Cursor = Cursors.Hand,
|
||
Child = new TextBlock { Text = "취소", FontSize = 13, Foreground = subFgBrush }
|
||
};
|
||
cancelBorder.MouseLeftButtonUp += (_, _) => dlg.Close();
|
||
btnRow.Children.Add(cancelBorder);
|
||
|
||
var saveBorder = new Border
|
||
{
|
||
Background = accentBrush,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(16, 8, 16, 8),
|
||
Cursor = Cursors.Hand,
|
||
Child = new TextBlock
|
||
{
|
||
Text = isNew ? "추가" : "저장",
|
||
FontSize = 13,
|
||
Foreground = Brushes.White,
|
||
FontWeight = FontWeights.SemiBold
|
||
}
|
||
};
|
||
saveBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(pathBox.Text))
|
||
{
|
||
CustomMessageBox.Show("훅 이름과 스크립트 경로를 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
return;
|
||
}
|
||
|
||
var entry = new AgentHookEntry
|
||
{
|
||
Name = nameBox.Text.Trim(),
|
||
ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(),
|
||
Timing = preRadio.IsChecked == true ? "pre" : "post",
|
||
ScriptPath = pathBox.Text.Trim(),
|
||
Arguments = argsBox.Text.Trim(),
|
||
Enabled = existing?.Enabled ?? true,
|
||
};
|
||
|
||
var hooks = _settings.Settings.Llm.AgentHooks;
|
||
if (isNew)
|
||
hooks.Add(entry);
|
||
else if (index >= 0 && index < hooks.Count)
|
||
hooks[index] = entry;
|
||
|
||
BuildOverlayHookCards();
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
dlg.Close();
|
||
};
|
||
btnRow.Children.Add(saveBorder);
|
||
stack.Children.Add(btnRow);
|
||
|
||
border.Child = stack;
|
||
dlg.Content = border;
|
||
dlg.ShowDialog();
|
||
}
|
||
|
||
private static string GetOverlayToolCategory(string toolName)
|
||
{
|
||
return toolName switch
|
||
{
|
||
"file_read" or "file_write" or "file_edit" or "glob" or "grep_tool" or "folder_map" or "document_read" or "file_manage" or "file_info" or "multi_read"
|
||
=> "파일/검색",
|
||
"process" or "build_run" or "dev_env_detect" or "snippet_runner"
|
||
=> "프로세스/빌드",
|
||
"search_codebase" or "code_search" or "code_review" or "lsp" or "test_loop" or "git_tool" or "project_rules" or "project_rule" or "diff_preview"
|
||
=> "코드 분석",
|
||
"excel_create" or "docx_create" or "csv_create" or "markdown_create" or "html_create" or "chart_create" or "batch_create" or "pptx_create" or "document_review" or "format_convert" or "document_planner" or "document_assembler" or "template_render"
|
||
=> "문서 생성",
|
||
"json_tool" or "regex_tool" or "diff_tool" or "base64_tool" or "hash_tool" or "datetime_tool" or "math_tool" or "xml_tool" or "sql_tool" or "data_pivot" or "text_summarize"
|
||
=> "데이터 처리",
|
||
"clipboard_tool" or "notify_tool" or "env_tool" or "zip_tool" or "http_tool" or "open_external" or "image_analyze" or "file_watch"
|
||
=> "시스템/환경",
|
||
"spawn_agent" or "wait_agents" or "memory" or "skill_manager" or "user_ask" or "task_tracker" or "todo_write" or "task_create" or "task_get" or "task_list" or "task_update" or "task_stop" or "task_output" or "enter_plan_mode" or "exit_plan_mode" or "enter_worktree" or "exit_worktree" or "team_create" or "team_delete" or "cron_create" or "cron_delete" or "cron_list" or "suggest_actions" or "checkpoint" or "playbook"
|
||
=> "에이전트",
|
||
_ => "기타"
|
||
};
|
||
}
|
||
|
||
private void CmbOverlayService_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service)
|
||
return;
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
llm.Service = service;
|
||
var candidates = GetModelCandidates(service);
|
||
var preferredModel = service switch
|
||
{
|
||
"ollama" => llm.OllamaModel,
|
||
"vllm" => llm.VllmModel,
|
||
"gemini" => llm.GeminiModel,
|
||
_ => llm.ClaudeModel
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(preferredModel))
|
||
llm.Model = preferredModel;
|
||
else if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model))
|
||
llm.Model = candidates[0].Id;
|
||
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
|
||
}
|
||
|
||
private void CmbOverlayModel_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId)
|
||
return;
|
||
|
||
CommitOverlayModelSelection(modelId);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void RefreshOverlayThemeCards()
|
||
{
|
||
var selected = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant();
|
||
SetOverlayCardSelection(OverlayThemeSystemCard, selected == "system");
|
||
SetOverlayCardSelection(OverlayThemeLightCard, selected == "light");
|
||
SetOverlayCardSelection(OverlayThemeDarkCard, selected == "dark");
|
||
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claw").ToLowerInvariant();
|
||
SetOverlayCardSelection(OverlayThemeStyleClawCard, preset == "claw");
|
||
SetOverlayCardSelection(OverlayThemeStyleCodexCard, preset == "codex");
|
||
SetOverlayCardSelection(OverlayThemeStyleNordCard, preset == "nord");
|
||
SetOverlayCardSelection(OverlayThemeStyleEmberCard, preset == "ember");
|
||
SetOverlayCardSelection(OverlayThemeStyleSlateCard, preset == "slate");
|
||
}
|
||
|
||
private void RefreshOverlayServiceCards()
|
||
{
|
||
var service = (_settings.Settings.Llm.Service ?? "ollama").ToLowerInvariant();
|
||
SetOverlayCardSelection(OverlaySvcOllamaCard, service == "ollama");
|
||
SetOverlayCardSelection(OverlaySvcVllmCard, service == "vllm");
|
||
SetOverlayCardSelection(OverlaySvcGeminiCard, service == "gemini");
|
||
SetOverlayCardSelection(OverlaySvcClaudeCard, service is "claude" or "sigmoid");
|
||
}
|
||
|
||
private void RefreshOverlayTokenPresetCards()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var compact = llm.ContextCompactTriggerPercent switch
|
||
{
|
||
<= 60 => 60,
|
||
<= 70 => 70,
|
||
<= 80 => 80,
|
||
_ => 90
|
||
};
|
||
SetOverlayCardSelection(OverlayCompact60Card, compact == 60);
|
||
SetOverlayCardSelection(OverlayCompact70Card, compact == 70);
|
||
SetOverlayCardSelection(OverlayCompact80Card, compact == 80);
|
||
SetOverlayCardSelection(OverlayCompact90Card, compact == 90);
|
||
|
||
var context = llm.MaxContextTokens switch
|
||
{
|
||
<= 4096 => 4096,
|
||
<= 16384 => 16384,
|
||
<= 32768 => 32768,
|
||
<= 65536 => 65536,
|
||
<= 131072 => 131072,
|
||
<= 262144 => 262144,
|
||
_ => 1_000_000
|
||
};
|
||
SetOverlayCardSelection(OverlayContext4KCard, context == 4096);
|
||
SetOverlayCardSelection(OverlayContext16KCard, context == 16384);
|
||
SetOverlayCardSelection(OverlayContext32KCard, context == 32768);
|
||
SetOverlayCardSelection(OverlayContext64KCard, context == 65536);
|
||
SetOverlayCardSelection(OverlayContext128KCard, context == 131072);
|
||
SetOverlayCardSelection(OverlayContext256KCard, context == 262144);
|
||
SetOverlayCardSelection(OverlayContext1MCard, context == 1_000_000);
|
||
}
|
||
|
||
private void RefreshOverlayModeButtons()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
SelectComboTag(CmbOverlayOperationMode, OperationModePolicy.Normalize(_settings.Settings.OperationMode));
|
||
SelectComboTag(CmbOverlayPermission, PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission));
|
||
SelectComboTag(CmbOverlayReasoning, llm.AgentDecisionLevel);
|
||
SelectComboTag(CmbOverlayFastMode, llm.FreeTierMode ? "on" : "off");
|
||
SelectComboTag(CmbOverlayDefaultOutputFormat, llm.DefaultOutputFormat ?? "auto");
|
||
SelectComboTag(CmbOverlayDefaultMood, _selectedMood ?? llm.DefaultMood ?? "modern");
|
||
SelectComboTag(CmbOverlayAgentLogLevel, llm.AgentLogLevel ?? "simple");
|
||
UpdateDataUsageUI();
|
||
}
|
||
|
||
private static void SelectComboTag(ComboBox? combo, string? tag)
|
||
{
|
||
if (combo == null) return;
|
||
var normalized = (tag ?? "").Trim();
|
||
combo.SelectedItem = combo.Items
|
||
.OfType<ComboBoxItem>()
|
||
.FirstOrDefault(item => string.Equals(item.Tag as string, normalized, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
private void PopulateOverlayMoodCombo()
|
||
{
|
||
if (CmbOverlayDefaultMood == null)
|
||
return;
|
||
|
||
var selected = _selectedMood ?? _settings.Settings.Llm.DefaultMood ?? "modern";
|
||
CmbOverlayDefaultMood.Items.Clear();
|
||
foreach (var mood in TemplateService.AllMoods)
|
||
{
|
||
CmbOverlayDefaultMood.Items.Add(new ComboBoxItem
|
||
{
|
||
Content = $"{mood.Icon} {mood.Label}",
|
||
Tag = mood.Key
|
||
});
|
||
}
|
||
|
||
SelectComboTag(CmbOverlayDefaultMood, selected);
|
||
}
|
||
|
||
private static string GetQuickActionLabel(string title, string value)
|
||
=> $"{title} · {value}";
|
||
|
||
private void RefreshOverlayServiceFieldLabels(string service)
|
||
{
|
||
if (OverlayEndpointLabel == null || OverlayEndpointHint == null || OverlayApiKeyLabel == null || OverlayApiKeyHint == null)
|
||
return;
|
||
|
||
switch (service)
|
||
{
|
||
case "ollama":
|
||
OverlayEndpointLabel.Text = "Ollama 서버 주소";
|
||
OverlayEndpointHint.Text = "사내 로컬 Ollama 기본 주소를 입력합니다.";
|
||
OverlayApiKeyLabel.Text = "Ollama API 키";
|
||
OverlayApiKeyHint.Text = "사내 게이트웨이를 쓰는 경우에만 입력합니다.";
|
||
break;
|
||
case "vllm":
|
||
OverlayEndpointLabel.Text = "vLLM 서버 주소";
|
||
OverlayEndpointHint.Text = "OpenAI 호환 엔드포인트 주소를 입력합니다.";
|
||
OverlayApiKeyLabel.Text = "vLLM API 키";
|
||
OverlayApiKeyHint.Text = "사내 인증 게이트웨이를 쓰는 경우에만 입력합니다.";
|
||
break;
|
||
case "gemini":
|
||
OverlayEndpointLabel.Text = "기본 서버 주소";
|
||
OverlayEndpointHint.Text = "Gemini는 내부 기본 주소를 사용합니다.";
|
||
OverlayApiKeyLabel.Text = "Gemini API 키";
|
||
OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다.";
|
||
break;
|
||
default:
|
||
OverlayEndpointLabel.Text = "기본 서버 주소";
|
||
OverlayEndpointHint.Text = "Claude는 내부 기본 주소를 사용합니다.";
|
||
OverlayApiKeyLabel.Text = "Claude API 키";
|
||
OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다.";
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void RefreshOverlayServiceFieldVisibility(string service)
|
||
{
|
||
if (OverlayEndpointFieldPanel == null || OverlayApiKeyFieldPanel == null)
|
||
return;
|
||
|
||
var hideEndpoint = string.Equals(service, "gemini", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(service, "claude", StringComparison.OrdinalIgnoreCase);
|
||
|
||
OverlayEndpointFieldPanel.Visibility = hideEndpoint ? Visibility.Collapsed : Visibility.Visible;
|
||
OverlayApiKeyFieldPanel.Margin = hideEndpoint ? new Thickness(0) : new Thickness(6, 0, 0, 0);
|
||
Grid.SetColumn(OverlayApiKeyFieldPanel, hideEndpoint ? 0 : 1);
|
||
Grid.SetColumnSpan(OverlayApiKeyFieldPanel, hideEndpoint ? 2 : 1);
|
||
}
|
||
|
||
private string GetOverlayServiceEndpoint(string service)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
return service switch
|
||
{
|
||
"ollama" => llm.OllamaEndpoint ?? "",
|
||
"vllm" => llm.VllmEndpoint ?? "",
|
||
"gemini" => llm.Endpoint ?? "",
|
||
"claude" or "sigmoid" => llm.Endpoint ?? "",
|
||
_ => llm.Endpoint ?? ""
|
||
};
|
||
}
|
||
|
||
private string GetOverlayServiceApiKey(string service)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
return service switch
|
||
{
|
||
"ollama" => llm.OllamaApiKey ?? "",
|
||
"vllm" => llm.VllmApiKey ?? "",
|
||
"gemini" => llm.GeminiApiKey ?? "",
|
||
"claude" or "sigmoid" => llm.ClaudeApiKey ?? "",
|
||
_ => llm.ApiKey ?? ""
|
||
};
|
||
}
|
||
|
||
private void BuildOverlayRegisteredModelsPanel(string service)
|
||
{
|
||
if (OverlayRegisteredModelsPanel == null || OverlayRegisteredModelsHeader == null || BtnOverlayAddModel == null)
|
||
return;
|
||
|
||
var normalized = NormalizeOverlayService(service);
|
||
var supportsRegistered = SupportsOverlayRegisteredModels(normalized);
|
||
OverlayRegisteredModelsHeader.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed;
|
||
OverlayRegisteredModelsPanel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed;
|
||
BtnOverlayAddModel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
OverlayRegisteredModelsPanel.Children.Clear();
|
||
if (!supportsRegistered)
|
||
return;
|
||
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.White;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||
|
||
var models = _settings.Settings.Llm.RegisteredModels
|
||
.Where(m => string.Equals(m.Service, normalized, StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(m => m.Alias, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
if (models.Count == 0)
|
||
{
|
||
OverlayRegisteredModelsPanel.Children.Add(new Border
|
||
{
|
||
CornerRadius = new CornerRadius(10),
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Background = Brushes.Transparent,
|
||
Padding = new Thickness(12, 10, 12, 10),
|
||
Child = new TextBlock
|
||
{
|
||
Text = "등록된 모델이 없습니다. `모델 추가`로 사내 모델을 먼저 등록하세요.",
|
||
FontSize = 11.5,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText,
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
foreach (var model in models)
|
||
{
|
||
var decryptedModelName = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled);
|
||
var displayName = string.IsNullOrWhiteSpace(model.Alias) ? decryptedModelName : model.Alias;
|
||
var endpointText = string.IsNullOrWhiteSpace(model.Endpoint) ? "기본 서버 사용" : model.Endpoint;
|
||
var authLabel = string.Equals(model.AuthType, "cp4d", StringComparison.OrdinalIgnoreCase) ? "CP4D" : "Bearer";
|
||
var isActive = string.Equals(model.EncryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(decryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase);
|
||
|
||
var row = new Border
|
||
{
|
||
CornerRadius = new CornerRadius(10),
|
||
BorderBrush = isActive ? accentBrush : borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Background = isActive ? (TryFindResource("HintBackground") as Brush ?? BrushFromHex("#EFF6FF")) : itemBg,
|
||
Padding = new Thickness(12, 10, 12, 10),
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
};
|
||
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var info = new StackPanel();
|
||
info.Children.Add(new TextBlock
|
||
{
|
||
Text = displayName,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
});
|
||
info.Children.Add(new TextBlock
|
||
{
|
||
Text = string.IsNullOrWhiteSpace(decryptedModelName) ? "(모델명 없음)" : decryptedModelName,
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
});
|
||
info.Children.Add(new TextBlock
|
||
{
|
||
Text = $"엔드포인트: {endpointText} · 인증: {authLabel}",
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 10.5,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText,
|
||
});
|
||
Grid.SetColumn(info, 0);
|
||
grid.Children.Add(info);
|
||
|
||
var actions = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
VerticalAlignment = VerticalAlignment.Top,
|
||
};
|
||
|
||
Border CreateAction(string text, Action onClick, Brush foreground)
|
||
{
|
||
var label = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 11.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = foreground,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
|
||
var action = new Border
|
||
{
|
||
Cursor = Cursors.Hand,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Background = Brushes.Transparent,
|
||
Child = label,
|
||
};
|
||
action.MouseEnter += (_, _) =>
|
||
{
|
||
action.Background = hoverBg;
|
||
};
|
||
action.MouseLeave += (_, _) =>
|
||
{
|
||
action.Background = Brushes.Transparent;
|
||
};
|
||
action.MouseLeftButtonUp += (_, _) => onClick();
|
||
return action;
|
||
}
|
||
|
||
actions.Children.Add(CreateAction("선택", () =>
|
||
{
|
||
CommitOverlayModelSelection(model.EncryptedModelName);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}, accentBrush));
|
||
|
||
actions.Children.Add(CreateAction("편집", () =>
|
||
{
|
||
EditOverlayRegisteredModel(model);
|
||
BuildOverlayRegisteredModelsPanel(service);
|
||
}, primaryText));
|
||
actions.Children.Add(CreateAction("삭제", () =>
|
||
{
|
||
DeleteOverlayRegisteredModel(model);
|
||
}, BrushFromHex("#DC2626")));
|
||
|
||
Grid.SetColumn(actions, 1);
|
||
grid.Children.Add(actions);
|
||
|
||
row.Child = grid;
|
||
row.MouseEnter += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
row.Background = hoverBg;
|
||
};
|
||
row.MouseLeave += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
row.Background = itemBg;
|
||
};
|
||
|
||
OverlayRegisteredModelsPanel.Children.Add(row);
|
||
}
|
||
}
|
||
|
||
private void BtnOverlayAddModel_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
|
||
if (!SupportsOverlayRegisteredModels(service))
|
||
return;
|
||
|
||
var dlg = new ModelRegistrationDialog(service) { Owner = this };
|
||
if (dlg.ShowDialog() != true)
|
||
return;
|
||
|
||
_settings.Settings.Llm.RegisteredModels.Add(new RegisteredModel
|
||
{
|
||
Alias = dlg.ModelAlias,
|
||
EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled),
|
||
Service = service,
|
||
Endpoint = dlg.Endpoint,
|
||
ApiKey = dlg.ApiKey,
|
||
AllowInsecureTls = dlg.AllowInsecureTls,
|
||
AuthType = dlg.AuthType,
|
||
Cp4dUrl = dlg.Cp4dUrl,
|
||
Cp4dUsername = dlg.Cp4dUsername,
|
||
Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled),
|
||
});
|
||
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
|
||
}
|
||
|
||
private void EditOverlayRegisteredModel(RegisteredModel model)
|
||
{
|
||
var currentModel = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled);
|
||
var cp4dPassword = Services.CryptoService.DecryptIfEnabled(model.Cp4dPassword ?? "", IsOverlayEncryptionEnabled);
|
||
var service = NormalizeOverlayService(model.Service);
|
||
|
||
var dlg = new ModelRegistrationDialog(
|
||
service,
|
||
model.Alias,
|
||
currentModel,
|
||
model.Endpoint,
|
||
model.ApiKey,
|
||
model.AllowInsecureTls,
|
||
model.AuthType ?? "bearer",
|
||
model.Cp4dUrl ?? "",
|
||
model.Cp4dUsername ?? "",
|
||
cp4dPassword)
|
||
{ Owner = this };
|
||
|
||
if (dlg.ShowDialog() != true)
|
||
return;
|
||
|
||
model.Alias = dlg.ModelAlias;
|
||
model.EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled);
|
||
model.Service = service;
|
||
model.Endpoint = dlg.Endpoint;
|
||
model.ApiKey = dlg.ApiKey;
|
||
model.AllowInsecureTls = dlg.AllowInsecureTls;
|
||
model.AuthType = dlg.AuthType;
|
||
model.Cp4dUrl = dlg.Cp4dUrl;
|
||
model.Cp4dUsername = dlg.Cp4dUsername;
|
||
model.Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled);
|
||
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
|
||
}
|
||
|
||
private void DeleteOverlayRegisteredModel(RegisteredModel model)
|
||
{
|
||
var result = CustomMessageBox.Show($"'{model.Alias}' 모델을 삭제하시겠습니까?", "모델 삭제",
|
||
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||
if (result != MessageBoxResult.Yes)
|
||
return;
|
||
|
||
_settings.Settings.Llm.RegisteredModels.Remove(model);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
|
||
}
|
||
|
||
private void SetOverlayCardSelection(Border border, bool selected)
|
||
{
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||
var normal = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
border.BorderBrush = selected ? accent : normal;
|
||
border.Background = selected
|
||
? (TryFindResource("HintBackground") as Brush ?? Brushes.Transparent)
|
||
: Brushes.Transparent;
|
||
}
|
||
|
||
private void OverlayThemeSystemCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentTheme = "system";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeLightCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentTheme = "light";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeDarkCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentTheme = "dark";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeStyleClawCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentThemePreset = "claw";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeStyleCodexCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentThemePreset = "codex";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeStyleNordCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentThemePreset = "nord";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeStyleEmberCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentThemePreset = "ember";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlayThemeStyleSlateCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.AgentThemePreset = "slate";
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void OverlaySvcOllamaCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("ollama");
|
||
private void OverlaySvcVllmCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("vllm");
|
||
private void OverlaySvcGeminiCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("gemini");
|
||
private void OverlaySvcClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("claude");
|
||
|
||
private void SetOverlayService(string service)
|
||
{
|
||
_settings.Settings.Llm.Service = service;
|
||
var llm = _settings.Settings.Llm;
|
||
var candidates = GetModelCandidates(service);
|
||
var preferredModel = service switch
|
||
{
|
||
"ollama" => llm.OllamaModel,
|
||
"vllm" => llm.VllmModel,
|
||
"gemini" => llm.GeminiModel,
|
||
_ => llm.ClaudeModel
|
||
};
|
||
llm.Model = !string.IsNullOrWhiteSpace(preferredModel)
|
||
? preferredModel
|
||
: candidates.FirstOrDefault().Id ?? llm.Model;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
|
||
RefreshOverlayVisualState(loadDeferredInputs: true);
|
||
}
|
||
|
||
private void BtnOverlayOperationMode_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var next = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode
|
||
? OperationModePolicy.InternalMode
|
||
: OperationModePolicy.ExternalMode;
|
||
|
||
if (!PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요."))
|
||
return;
|
||
|
||
_settings.Settings.OperationMode = next;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void BtnOverlayFolderDataUsage_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||
}
|
||
|
||
private void CmbOverlayFastMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayFastMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
_settings.Settings.Llm.FreeTierMode = string.Equals(tag, "on", StringComparison.OrdinalIgnoreCase);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void CmbOverlayReasoning_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayReasoning.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
_settings.Settings.Llm.AgentDecisionLevel = tag;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void CmbOverlayPermission_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayPermission.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
var normalized = PermissionModeCatalog.NormalizeGlobalMode(tag);
|
||
var llm = _settings.Settings.Llm;
|
||
llm.FilePermission = normalized;
|
||
llm.DefaultAgentPermission = normalized;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void CmbOverlayDefaultOutputFormat_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayDefaultOutputFormat.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
_settings.Settings.Llm.DefaultOutputFormat = tag;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void CmbOverlayDefaultMood_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayDefaultMood.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
_selectedMood = tag;
|
||
_settings.Settings.Llm.DefaultMood = tag;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void CmbOverlayOperationMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayOperationMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
var normalized = OperationModePolicy.Normalize(tag);
|
||
var current = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||
if (string.Equals(normalized, current, StringComparison.OrdinalIgnoreCase))
|
||
return;
|
||
|
||
if (string.Equals(normalized, OperationModePolicy.ExternalMode, StringComparison.OrdinalIgnoreCase) &&
|
||
!PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요."))
|
||
{
|
||
RefreshOverlayModeButtons();
|
||
return;
|
||
}
|
||
|
||
_settings.Settings.OperationMode = normalized;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void CmbOverlayFolderDataUsage_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||
}
|
||
|
||
private void CmbOverlayAgentLogLevel_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isOverlaySettingsSyncing || CmbOverlayAgentLogLevel.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
|
||
return;
|
||
|
||
_settings.Settings.Llm.AgentLogLevel = tag;
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
}
|
||
|
||
private void BtnOpenFullSettings_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (System.Windows.Application.Current is App app)
|
||
app.OpenSettingsFromChat();
|
||
}
|
||
|
||
private bool PromptOverlayPasswordDialog(string title, string header, string message)
|
||
{
|
||
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
|
||
|
||
var dlg = new Window
|
||
{
|
||
Title = title,
|
||
Width = 340,
|
||
SizeToContent = SizeToContent.Height,
|
||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||
Owner = this,
|
||
ResizeMode = ResizeMode.NoResize,
|
||
WindowStyle = WindowStyle.None,
|
||
AllowsTransparency = true,
|
||
Background = Brushes.Transparent,
|
||
ShowInTaskbar = false,
|
||
};
|
||
|
||
var border = new Border
|
||
{
|
||
Background = bgBrush,
|
||
CornerRadius = new CornerRadius(12),
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(20),
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
stack.Children.Add(new TextBlock { Text = header, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12) });
|
||
stack.Children.Add(new TextBlock { Text = message, FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6) });
|
||
|
||
var pwBox = new PasswordBox
|
||
{
|
||
FontSize = 14,
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
Background = itemBg,
|
||
Foreground = fgBrush,
|
||
BorderBrush = borderBrush,
|
||
PasswordChar = '*',
|
||
};
|
||
stack.Children.Add(pwBox);
|
||
|
||
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
|
||
var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) };
|
||
cancelBtn.Click += (_, _) => dlg.DialogResult = false;
|
||
btnRow.Children.Add(cancelBtn);
|
||
|
||
var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true };
|
||
okBtn.Click += (_, _) =>
|
||
{
|
||
if (pwBox.Password == UnifiedAdminPassword)
|
||
dlg.DialogResult = true;
|
||
else
|
||
{
|
||
pwBox.Clear();
|
||
pwBox.Focus();
|
||
}
|
||
};
|
||
btnRow.Children.Add(okBtn);
|
||
stack.Children.Add(btnRow);
|
||
|
||
border.Child = stack;
|
||
dlg.Content = border;
|
||
dlg.Loaded += (_, _) => pwBox.Focus();
|
||
return dlg.ShowDialog() == true;
|
||
}
|
||
|
||
private static int ParseOverlayInt(string? text, int fallback, int min, int max)
|
||
{
|
||
if (!int.TryParse(text, out var value))
|
||
value = fallback;
|
||
return Math.Clamp(value, min, max);
|
||
}
|
||
|
||
private void BtnInlineSettingsClose_Click(object sender, RoutedEventArgs e)
|
||
=> InlineSettingsPanel.IsOpen = false;
|
||
|
||
private void CmbInlineService_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isInlineSettingsSyncing || CmbInlineService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service)
|
||
return;
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
llm.Service = service;
|
||
|
||
var candidates = GetModelCandidates(service);
|
||
if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model))
|
||
llm.Model = candidates[0].Id;
|
||
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
}
|
||
|
||
private void CmbInlineModel_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (_isInlineSettingsSyncing || CmbInlineModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId)
|
||
return;
|
||
|
||
_settings.Settings.Llm.Model = modelId;
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
}
|
||
|
||
private void BtnInlineFastMode_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_settings.Settings.Llm.FreeTierMode = !_settings.Settings.Llm.FreeTierMode;
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
RefreshInlineSettingsPanel();
|
||
RefreshOverlayVisualState(loadDeferredInputs: false);
|
||
}
|
||
|
||
private void BtnInlineReasoning_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.AgentDecisionLevel = NextReasoning(llm.AgentDecisionLevel);
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
RefreshInlineSettingsPanel();
|
||
RefreshOverlayVisualState(loadDeferredInputs: false);
|
||
}
|
||
|
||
private void BtnInlinePermission_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.FilePermission = NextPermission(llm.FilePermission);
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdatePermissionUI();
|
||
SaveConversationSettings();
|
||
RefreshInlineSettingsPanel();
|
||
RefreshOverlayVisualState(loadDeferredInputs: false);
|
||
}
|
||
|
||
private void BtnInlineSkill_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.EnableSkillSystem = !llm.EnableSkillSystem;
|
||
if (llm.EnableSkillSystem)
|
||
{
|
||
SkillService.EnsureSkillFolder();
|
||
SkillService.LoadSkills(llm.SkillsFolderPath);
|
||
UpdateConditionalSkillActivation(reset: true);
|
||
}
|
||
|
||
_settings.Save();
|
||
_appState.LoadFromSettings(_settings);
|
||
RefreshInlineSettingsPanel();
|
||
|
||
if (llm.EnableSkillSystem)
|
||
OpenCommandSkillBrowser("/");
|
||
}
|
||
|
||
private void BtnInlineCommandBrowser_Click(object sender, RoutedEventArgs e)
|
||
=> OpenCommandSkillBrowser("/");
|
||
|
||
private void BtnInlineMcp_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var app = System.Windows.Application.Current as App;
|
||
app?.OpenSettingsFromChat();
|
||
}
|
||
|
||
private void BtnNewChat_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
StartNewConversation();
|
||
InputBox.Focus();
|
||
}
|
||
|
||
public void ResumeConversation(string conversationId)
|
||
{
|
||
var conv = _storage.Load(conversationId);
|
||
if (conv != null)
|
||
{
|
||
var targetTab = NormalizeTabName(conv.Tab);
|
||
if (!string.Equals(_activeTab, targetTab, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
StopStreamingIfActive();
|
||
SaveCurrentTabConversationId();
|
||
PersistPerTabUiState();
|
||
_activeTab = targetTab;
|
||
RestorePerTabUiState();
|
||
UpdateTabUI();
|
||
|
||
if (string.Equals(targetTab, "Chat", StringComparison.OrdinalIgnoreCase))
|
||
TabChat.IsChecked = true;
|
||
else if (string.Equals(targetTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||
TabCowork.IsChecked = true;
|
||
else if (TabCode.IsEnabled)
|
||
TabCode.IsChecked = true;
|
||
}
|
||
|
||
lock (_convLock)
|
||
{
|
||
conv.Tab = targetTab;
|
||
_currentConversation = ChatSession?.SetCurrentConversation(targetTab, conv, _storage) ?? conv;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
SaveLastConversations();
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
RenderMessages();
|
||
UpdateFolderBar();
|
||
RefreshDraftQueueUi();
|
||
}
|
||
InputBox.Focus();
|
||
}
|
||
|
||
private static string NormalizeTabName(string? tab)
|
||
{
|
||
var normalized = (tab ?? "").Trim();
|
||
if (string.IsNullOrEmpty(normalized))
|
||
return "Chat";
|
||
|
||
if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase))
|
||
return "Cowork";
|
||
|
||
var canonical = new string(normalized
|
||
.Where(char.IsLetterOrDigit)
|
||
.ToArray())
|
||
.ToLowerInvariant();
|
||
|
||
if (canonical is "cowork" or "coworkcode" or "coworkcodetab")
|
||
return "Cowork";
|
||
|
||
if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase)
|
||
|| canonical is "code" or "codetab")
|
||
return "Code";
|
||
|
||
return "Chat";
|
||
}
|
||
|
||
public void StartNewAndFocus()
|
||
{
|
||
StartNewConversation();
|
||
InputBox.Focus();
|
||
}
|
||
|
||
private void BtnDeleteAll_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var result = CustomMessageBox.Show(
|
||
"저장된 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
|
||
"대화 전체 삭제",
|
||
MessageBoxButton.YesNo,
|
||
MessageBoxImage.Warning);
|
||
|
||
if (result != MessageBoxResult.Yes) return;
|
||
|
||
_storage.DeleteAll();
|
||
lock (_convLock)
|
||
{
|
||
ChatSession?.ClearCurrentConversation(_activeTab);
|
||
_currentConversation = null;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
MessagePanel.Children.Clear();
|
||
EmptyState.Visibility = Visibility.Visible;
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
}
|
||
|
||
// ─── 미리보기 패널 (탭 기반) ─────────────────────────────────────────────
|
||
|
||
|
||
// ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
|
||
|
||
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;
|
||
}
|
||
|
||
await RefreshGitBranchStatusAsync();
|
||
_gitBranchSearchText = "";
|
||
if (GitBranchSearchBox != null)
|
||
GitBranchSearchBox.Text = "";
|
||
BuildGitBranchPopup();
|
||
GitBranchPopup.IsOpen = true;
|
||
GitBranchSearchBox?.Focus();
|
||
}
|
||
|
||
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 static void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius)
|
||
{
|
||
ratio = Math.Clamp(ratio, 0, 0.9999);
|
||
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 (_isStreaming)
|
||
{
|
||
ShowToast("현재 작업이 끝난 뒤 실행할 수 있습니다.", "\uE783");
|
||
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 (_isStreaming)
|
||
return;
|
||
|
||
DraftQueueItem? next = null;
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
next = _draftQueueProcessor.TryStartNext(session, _activeTab, _storage, preferredDraftId, _appState.TaskRuns);
|
||
if (next == null)
|
||
return;
|
||
|
||
_runningDraftId = next.Id;
|
||
_currentConversation = session?.CurrentConversation ?? _currentConversation;
|
||
}
|
||
|
||
RefreshDraftQueueUi();
|
||
_ = SendMessageAsync(next.Text);
|
||
}
|
||
|
||
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)
|
||
ExecutionLogLabel.Text = visible ? "실행 로그 켜짐" : "실행 로그 숨김";
|
||
if (ExecutionLogIcon != null)
|
||
ExecutionLogIcon.Text = visible ? "\uE946" : "\uE8F8";
|
||
|
||
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 (_isStreaming)
|
||
{
|
||
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 = new FontFamily("Segoe MDL2 Assets"),
|
||
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 = new FontFamily("Segoe MDL2 Assets"),
|
||
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 = new FontFamily("Segoe MDL2 Assets"),
|
||
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);
|
||
}
|
||
|
||
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
|
||
{
|
||
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!;
|
||
return new System.Windows.Media.SolidColorBrush(c);
|
||
}
|
||
}
|
||
|
||
|