Some checks failed
Release Gate / gate (push) Has been cancelled
- AX Agent 하단 컨텍스트 카드 툴팁에 최근 압축 시각, 자동/수동 여부, 압축 전후 토큰, 절감량을 다시 볼 수 있는 compact 이력을 추가함 - 수동 /compact와 전송 전 자동 컨텍스트 압축이 모두 같은 compaction 통계 기록 경로를 사용하도록 정리해 결과를 이후 UI에서도 확인할 수 있게 보강함 - README와 docs/DEVELOPMENT.md에 2026-04-04 23:28 (KST) 기준 이력을 반영함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
19467 lines
796 KiB
C#
19467 lines
796 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 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 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 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 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 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 string _folderMenuSearchText = "";
|
||
|
||
// 경과 시간 표시
|
||
private readonly DispatcherTimer _elapsedTimer;
|
||
private DateTime _streamStartTime;
|
||
private TextBlock? _elapsedLabel;
|
||
|
||
// 타이핑 효과
|
||
private readonly DispatcherTimer _typingTimer;
|
||
private readonly DispatcherTimer _gitRefreshTimer;
|
||
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 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();
|
||
_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 = async (question, options, defaultValue) =>
|
||
{
|
||
string? response = null;
|
||
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
|
||
await appDispatcher.InvokeAsync(() =>
|
||
{
|
||
response = UserAskDialog.Show(question, options, defaultValue);
|
||
});
|
||
return response;
|
||
},
|
||
};
|
||
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
|
||
|
||
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
|
||
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
|
||
_folderDataUsage = settings.Settings.Llm.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();
|
||
};
|
||
|
||
KeyDown += ChatWindow_KeyDown;
|
||
UpdateConversationFailureFilterUi();
|
||
UpdateConversationSortUi();
|
||
UpdateConversationRunningFilterUi();
|
||
Loaded += (_, _) =>
|
||
{
|
||
ApplyAgentThemeResources();
|
||
|
||
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
|
||
SetupUserInfo();
|
||
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
|
||
_folderDataUsage = _settings.Settings.Llm.FolderDataUsage ?? "none";
|
||
UpdateAnalyzerButtonVisibility();
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
ApplyExpressionLevelUi();
|
||
UpdateSidebarModeMenu();
|
||
RefreshContextUsageVisual();
|
||
InputBox.Focus();
|
||
MessageScroll.ScrollChanged += MessageScroll_ScrollChanged;
|
||
|
||
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
|
||
BuildTopicButtons();
|
||
RestoreLastConversations();
|
||
RefreshConversationList();
|
||
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);
|
||
};
|
||
Closed += (_, _) =>
|
||
{
|
||
_settings.SettingsChanged -= Settings_SettingsChanged;
|
||
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
|
||
_streamCts?.Cancel();
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.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" or "Code" => "모든 프로젝트",
|
||
_ => "모든 주제",
|
||
};
|
||
stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText,
|
||
string.IsNullOrEmpty(_selectedCategory),
|
||
() => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); }));
|
||
|
||
stack.Children.Add(CreateSep());
|
||
|
||
if (_activeTab == "Cowork" || _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" or "Code" => "모든 프로젝트", _ => "주제 선택" };
|
||
CategoryIcon.Text = "\uE8BD";
|
||
}
|
||
else if (_activeTab == "Cowork" || _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 (_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;
|
||
|
||
var chatVisible = _activeTab == "Chat" ? Visibility.Visible : Visibility.Collapsed;
|
||
var coworkVisible = _activeTab == "Cowork" ? Visibility.Visible : Visibility.Collapsed;
|
||
var codeVisible = _activeTab == "Code" ? Visibility.Visible : Visibility.Collapsed;
|
||
if (SidebarChatMenu.Visibility != chatVisible) SidebarChatMenu.Visibility = chatVisible;
|
||
if (SidebarCoworkMenu.Visibility != coworkVisible) SidebarCoworkMenu.Visibility = coworkVisible;
|
||
if (SidebarCodeMenu.Visibility != codeVisible) SidebarCodeMenu.Visibility = codeVisible;
|
||
|
||
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",
|
||
"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" => 220,
|
||
"simple" => 120,
|
||
_ => 160,
|
||
};
|
||
}
|
||
|
||
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)
|
||
{
|
||
_folderMenuSearchText = FolderMenuSearchBox?.Text?.Trim() ?? "";
|
||
RenderFolderMenuItems(_folderMenuSearchText);
|
||
}
|
||
|
||
private void ShowFolderMenu()
|
||
{
|
||
_folderMenuSearchText = FolderMenuSearchBox?.Text?.Trim() ?? "";
|
||
RenderFolderMenuItems(_folderMenuSearchText);
|
||
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 query = (searchText ?? "").Trim();
|
||
|
||
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)
|
||
.Where(path =>
|
||
string.IsNullOrWhiteSpace(query)
|
||
|| path.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||
|| System.IO.Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase))
|
||
.Take(maxDisplay * 3)
|
||
.ToList();
|
||
|
||
var filteredRecent = recentFolders
|
||
.Where(path =>
|
||
string.IsNullOrWhiteSpace(query)
|
||
|| path.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||
|| System.IO.Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase))
|
||
.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 = isActive ? $"현재 선택 · {folder}" : folder;
|
||
var itemBorder = CreatePopupMenuRow(
|
||
isActive ? "\uE73E" : "\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)
|
||
{
|
||
FolderMenuItems.Children.Add(CreatePopupSectionLabel($"최근 워크스페이스 · {filteredRecent.Count}", new Thickness(10, 6, 10, 4)));
|
||
AddWorkspaceRows(filteredRecent);
|
||
}
|
||
|
||
var remainingFolders = workspaceFolders
|
||
.Where(path => !filteredRecent.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||
.ToList();
|
||
|
||
if (remainingFolders.Count > 0)
|
||
{
|
||
var workspaceLabel = filteredRecent.Count > 0
|
||
? $"전체 워크스페이스 · {remainingFolders.Count}"
|
||
: $"워크스페이스 · {remainingFolders.Count}";
|
||
FolderMenuItems.Children.Add(CreatePopupSectionLabel(workspaceLabel, new Thickness(10, 6, 10, 4)));
|
||
AddWorkspaceRows(remainingFolders);
|
||
|
||
FolderMenuItems.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(8, 4, 8, 4),
|
||
Opacity = 0.5,
|
||
});
|
||
}
|
||
else if (filteredRecent.Count == 0)
|
||
{
|
||
FolderMenuItems.Children.Add(new TextBlock
|
||
{
|
||
Text = "검색 결과가 없습니다.",
|
||
FontSize = 11.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 8, 10, 10),
|
||
});
|
||
}
|
||
|
||
// 폴더 찾아보기 버튼
|
||
var browseSp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
browseSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uED25",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 14,
|
||
Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
browseSp.Children.Add(new TextBlock
|
||
{
|
||
Text = "폴더 찾아보기...",
|
||
FontSize = 14,
|
||
Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
var browseBorder = new Border
|
||
{
|
||
Child = browseSp,
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
};
|
||
browseBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); };
|
||
browseBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
browseBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
FolderMenuPopup.IsOpen = false;
|
||
BrowseWorkFolder();
|
||
};
|
||
FolderMenuItems.Children.Add(browseBorder);
|
||
}
|
||
|
||
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 Popup? _sharedContextPopup;
|
||
|
||
private (Popup Popup, StackPanel Panel) CreateThemedPopupMenu(
|
||
UIElement? placementTarget = null,
|
||
PlacementMode placement = PlacementMode.MousePoint,
|
||
double minWidth = 200)
|
||
{
|
||
_sharedContextPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
|
||
var bg = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||
var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var panel = new StackPanel { Margin = new Thickness(2) };
|
||
var container = new Border
|
||
{
|
||
Background = bg,
|
||
BorderBrush = border,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(6),
|
||
MinWidth = minWidth,
|
||
Child = panel,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 16,
|
||
ShadowDepth = 3,
|
||
Opacity = 0.18,
|
||
Color = Colors.Black,
|
||
Direction = 270,
|
||
},
|
||
};
|
||
|
||
var popup = new Popup
|
||
{
|
||
Child = container,
|
||
StaysOpen = false,
|
||
AllowsTransparency = true,
|
||
PopupAnimation = PopupAnimation.Fade,
|
||
Placement = placement,
|
||
PlacementTarget = placementTarget,
|
||
};
|
||
|
||
_sharedContextPopup = popup;
|
||
return (popup, panel);
|
||
}
|
||
|
||
private Border CreatePopupMenuItem(
|
||
Popup popup,
|
||
string icon,
|
||
string label,
|
||
Brush iconBrush,
|
||
Brush labelBrush,
|
||
Brush hoverBrush,
|
||
Action action)
|
||
{
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12.5,
|
||
Foreground = iconBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 9, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 12.5,
|
||
Foreground = labelBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
var item = new Border
|
||
{
|
||
Child = sp,
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(10, 7, 12, 7),
|
||
Margin = new Thickness(0, 1, 0, 1),
|
||
};
|
||
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBrush; };
|
||
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
item.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
popup.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
action();
|
||
};
|
||
|
||
return item;
|
||
}
|
||
|
||
private static void AddPopupMenuSeparator(Panel panel, Brush brush)
|
||
{
|
||
panel.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Margin = new Thickness(10, 4, 10, 4),
|
||
Background = brush,
|
||
Opacity = 0.35,
|
||
});
|
||
}
|
||
|
||
/// <summary>최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||
private void ShowRecentFolderContextMenu(string folderPath)
|
||
{
|
||
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 warningBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
|
||
var (popup, panel) = CreateThemedPopupMenu();
|
||
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uED25", "폴더 열기", secondaryText, primaryText, hoverBg, () =>
|
||
{
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = folderPath,
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch { }
|
||
}));
|
||
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "경로 복사", secondaryText, primaryText, hoverBg, () =>
|
||
{
|
||
try { Clipboard.SetText(folderPath); } catch { }
|
||
}));
|
||
|
||
AddPopupMenuSeparator(panel, borderBrush);
|
||
|
||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "목록에서 삭제", warningBrush, warningBrush, hoverBg, () =>
|
||
{
|
||
_settings.Settings.Llm.RecentWorkFolders.RemoveAll(
|
||
p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase));
|
||
_settings.Save();
|
||
// 메뉴 새로고침
|
||
if (FolderMenuPopup.IsOpen)
|
||
ShowFolderMenu();
|
||
}));
|
||
|
||
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
|
||
}
|
||
|
||
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 = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
private void UpdateFolderBar()
|
||
{
|
||
if (FolderBar == null) return;
|
||
if (_activeTab == "Chat")
|
||
{
|
||
FolderBar.Visibility = Visibility.Collapsed;
|
||
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
|
||
RefreshContextUsageVisual();
|
||
return;
|
||
}
|
||
FolderBar.Visibility = Visibility.Visible;
|
||
var folder = GetCurrentWorkFolder();
|
||
if (!string.IsNullOrEmpty(folder))
|
||
{
|
||
FolderPathLabel.Text = folder;
|
||
FolderPathLabel.ToolTip = folder;
|
||
}
|
||
else
|
||
{
|
||
FolderPathLabel.Text = "폴더를 선택하세요";
|
||
FolderPathLabel.ToolTip = null;
|
||
}
|
||
// 대화별 설정 복원 (없으면 전역 기본값)
|
||
LoadConversationSettings();
|
||
UpdatePermissionUI();
|
||
UpdateDataUsageUI();
|
||
RefreshContextUsageVisual();
|
||
ScheduleGitBranchRefresh();
|
||
}
|
||
|
||
/// <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 = conv?.DataUsage ?? llm.FolderDataUsage ?? "none";
|
||
_selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern";
|
||
}
|
||
|
||
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
|
||
private void SaveConversationSettings()
|
||
{
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv == null) return;
|
||
try
|
||
{
|
||
conv.Permission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||
conv.DataUsage = _folderDataUsage;
|
||
conv.Mood = _selectedMood;
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
lock (_convLock)
|
||
_currentConversation = session.SaveConversationSettings(_activeTab, _settings.Settings.Llm.FilePermission, _folderDataUsage, conv.OutputFormat, _selectedMood, _storage);
|
||
}
|
||
else
|
||
{
|
||
_storage.Save(conv);
|
||
}
|
||
}
|
||
catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||
}
|
||
|
||
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
|
||
|
||
private void BtnPermission_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (PermissionPopup == null) return;
|
||
PermissionItems.Children.Clear();
|
||
|
||
ChatConversation? currentConversation;
|
||
lock (_convLock) currentConversation = _currentConversation;
|
||
var summary = _appState.GetPermissionSummary(currentConversation);
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
|
||
Border CreateCollapsibleSection(string sectionKey, string icon, string title, UIElement content, bool expanded, string accentHex = "#334155")
|
||
{
|
||
var body = new Border
|
||
{
|
||
Margin = new Thickness(0, 5, 0, 0),
|
||
Visibility = expanded ? Visibility.Visible : Visibility.Collapsed,
|
||
Child = content,
|
||
};
|
||
var caret = new TextBlock
|
||
{
|
||
Text = expanded ? "\uE70D" : "\uE76C",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
|
||
var headerGrid = new Grid();
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
headerGrid.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10.5,
|
||
Foreground = BrushFromHex(accentHex),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
var titleBlock = new TextBlock
|
||
{
|
||
Text = title,
|
||
FontSize = 9.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(titleBlock, 1);
|
||
headerGrid.Children.Add(titleBlock);
|
||
Grid.SetColumn(caret, 2);
|
||
headerGrid.Children.Add(caret);
|
||
|
||
var headerBorder = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(8, 5, 8, 5),
|
||
Cursor = Cursors.Hand,
|
||
Focusable = true,
|
||
Child = headerGrid,
|
||
};
|
||
KeyboardNavigation.SetIsTabStop(headerBorder, true);
|
||
headerBorder.MouseEnter += (_, _) => headerBorder.Background = BrushFromHex("#F8FAFC");
|
||
headerBorder.MouseLeave += (_, _) => headerBorder.Background = Brushes.Transparent;
|
||
void ToggleSection()
|
||
{
|
||
var show = body.Visibility != Visibility.Visible;
|
||
body.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
|
||
caret.Text = show ? "\uE70D" : "\uE76C";
|
||
SetPermissionPopupSectionExpanded(sectionKey, show);
|
||
}
|
||
headerBorder.MouseLeftButtonUp += (_, _) => ToggleSection();
|
||
headerBorder.KeyDown += (_, ke) =>
|
||
{
|
||
if (ke.Key is Key.Enter or Key.Space)
|
||
{
|
||
ke.Handled = true;
|
||
ToggleSection();
|
||
}
|
||
};
|
||
|
||
return 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(0, 2, 0, 2),
|
||
Margin = new Thickness(0, 0, 0, 2),
|
||
Child = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
headerBorder,
|
||
body,
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
var summaryCard = new StackPanel
|
||
{
|
||
Margin = new Thickness(0, 0, 0, 4),
|
||
Children =
|
||
{
|
||
CreateFlatPopupRow(
|
||
"\uE946",
|
||
$"현재 모드 · {PermissionModeCatalog.ToDisplayLabel(summary.EffectiveMode)}",
|
||
summary.Description,
|
||
string.Equals(summary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) ? "#C2410C" :
|
||
string.Equals(summary.RiskLevel, "locked", StringComparison.OrdinalIgnoreCase) ? "#475569" : "#4338CA",
|
||
false,
|
||
null),
|
||
CreateFlatPopupRow(
|
||
"\uE8D7",
|
||
$"기본값 · {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)}",
|
||
$"예외 {summary.OverrideCount}개",
|
||
"#64748B",
|
||
false,
|
||
null)
|
||
}
|
||
};
|
||
|
||
StackPanel? overrideSection = null;
|
||
if (summary.TopOverrides.Count > 0)
|
||
{
|
||
var overrideWrap = new StackPanel
|
||
{
|
||
Margin = new Thickness(0, 0, 0, 5),
|
||
};
|
||
|
||
foreach (var overrideEntry in summary.TopOverrides)
|
||
{
|
||
overrideWrap.Children.Add(CreateFlatPopupRow(
|
||
"\uE72E",
|
||
overrideEntry.Key,
|
||
PermissionModeCatalog.ToDisplayLabel(overrideEntry.Value),
|
||
"#2563EB",
|
||
false,
|
||
null));
|
||
}
|
||
|
||
overrideSection = new StackPanel
|
||
{
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "도구별 예외",
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(2, 0, 0, 3),
|
||
},
|
||
overrideWrap
|
||
}
|
||
};
|
||
}
|
||
|
||
var latestDenied = _appState.GetLatestDeniedPermission();
|
||
Border? deniedCard = null;
|
||
if (latestDenied != null)
|
||
{
|
||
var deniedStack = new StackPanel();
|
||
deniedStack.Children.Add(new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 0, 0, 2),
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "\uEA39",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10.5,
|
||
Foreground = BrushFromHex("#991B1B"),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = "최근 권한 거부",
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#991B1B"),
|
||
}
|
||
}
|
||
});
|
||
deniedStack.Children.Add(new TextBlock
|
||
{
|
||
Text = _appState.FormatPermissionEventLine(latestDenied),
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex("#991B1B"),
|
||
Margin = new Thickness(0, 0, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
LineHeight = 14,
|
||
MaxWidth = 250,
|
||
});
|
||
|
||
if (!string.IsNullOrWhiteSpace(latestDenied.ToolName))
|
||
{
|
||
deniedStack.Children.Add(new TextBlock
|
||
{
|
||
Text = $"도구 {latestDenied.ToolName}에 바로 적용",
|
||
FontSize = 9.5,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
});
|
||
|
||
var actionRow = new StackPanel
|
||
{
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
};
|
||
|
||
actionRow.Children.Add(CreateFlatPopupRow(
|
||
"\uE711",
|
||
"읽기 전용",
|
||
"이 도구의 쓰기 작업을 차단합니다",
|
||
"#991B1B",
|
||
true,
|
||
() => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny); RefreshPermissionPopup(); }));
|
||
actionRow.Children.Add(CreateFlatPopupRow(
|
||
"\uE8D7",
|
||
"권한 요청",
|
||
"실행 전 항상 확인받도록 되돌립니다",
|
||
"#1D4ED8",
|
||
true,
|
||
() => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default); RefreshPermissionPopup(); }));
|
||
actionRow.Children.Add(CreateFlatPopupRow(
|
||
"\uE73E",
|
||
"편집 자동 승인",
|
||
"이 도구의 편집 작업을 자동 허용합니다",
|
||
"#166534",
|
||
true,
|
||
() => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits); RefreshPermissionPopup(); }));
|
||
actionRow.Children.Add(CreateFlatPopupRow(
|
||
"\uE74D",
|
||
"예외 해제",
|
||
"도구별 예외를 제거하고 기본값을 사용합니다",
|
||
"#374151",
|
||
true,
|
||
() => { SetToolPermissionOverride(latestDenied.ToolName!, null); RefreshPermissionPopup(); }));
|
||
deniedStack.Children.Add(actionRow);
|
||
}
|
||
|
||
deniedCard = 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, 8, 8, 8),
|
||
Margin = new Thickness(0, 0, 0, 4),
|
||
Child = deniedStack,
|
||
};
|
||
}
|
||
|
||
var coreLevels = PermissionModePresentationCatalog.Ordered.ToList();
|
||
var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||
void AddPermissionRows(Panel container, IEnumerable<PermissionModePresentation> levels)
|
||
{
|
||
foreach (var item in levels)
|
||
{
|
||
var level = item.Mode;
|
||
var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase);
|
||
var rowBorder = new Border
|
||
{
|
||
Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent,
|
||
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"),
|
||
BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1),
|
||
CornerRadius = new CornerRadius(0),
|
||
Padding = new Thickness(8, 8, 8, 8),
|
||
Margin = new Thickness(0, 0, 0, 0),
|
||
Cursor = Cursors.Hand,
|
||
Focusable = true,
|
||
};
|
||
KeyboardNavigation.SetIsTabStop(rowBorder, true);
|
||
|
||
var row = new Grid();
|
||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
row.Children.Add(new TextBlock
|
||
{
|
||
Text = item.Icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = BrushFromHex(item.ColorHex),
|
||
Margin = new Thickness(1, 0, 8, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
var textStack = new StackPanel();
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = item.Title,
|
||
FontSize = 11.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||
});
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = item.Description,
|
||
FontSize = 10.5,
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
LineHeight = 15,
|
||
MaxWidth = 240,
|
||
});
|
||
Grid.SetColumn(textStack, 1);
|
||
row.Children.Add(textStack);
|
||
|
||
var check = new TextBlock
|
||
{
|
||
Text = isActive ? "\uE73E" : "",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.Bold,
|
||
Foreground = BrushFromHex("#2563EB"),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(8, 0, 1, 0),
|
||
};
|
||
Grid.SetColumn(check, 2);
|
||
row.Children.Add(check);
|
||
|
||
rowBorder.Child = row;
|
||
rowBorder.MouseEnter += (_, _) =>
|
||
{
|
||
rowBorder.Background = BrushFromHex("#F8FAFC");
|
||
rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0");
|
||
};
|
||
rowBorder.MouseLeave += (_, _) =>
|
||
{
|
||
rowBorder.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent;
|
||
rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB");
|
||
};
|
||
|
||
var capturedLevel = level;
|
||
void ApplyPermission()
|
||
{
|
||
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel);
|
||
try { _settings.Save(); } catch { }
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdatePermissionUI();
|
||
SaveConversationSettings();
|
||
RefreshInlineSettingsPanel();
|
||
RefreshOverlayModeButtons();
|
||
PermissionPopup.IsOpen = false;
|
||
}
|
||
rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission();
|
||
rowBorder.KeyDown += (_, ke) =>
|
||
{
|
||
if (ke.Key is Key.Enter or Key.Space)
|
||
{
|
||
ke.Handled = true;
|
||
ApplyPermission();
|
||
}
|
||
};
|
||
|
||
container.Children.Add(rowBorder);
|
||
}
|
||
}
|
||
|
||
PermissionItems.Children.Add(CreatePopupSectionLabel("핵심 권한 모드"));
|
||
AddPermissionRows(PermissionItems, coreLevels);
|
||
|
||
// claw-code 기준 UX 정렬: 기본 화면은 핵심 모드 중심, 부가 정보는 단일 상세 섹션으로 제공.
|
||
var detailsPanel = new StackPanel();
|
||
detailsPanel.Children.Add(summaryCard);
|
||
if (overrideSection != null)
|
||
detailsPanel.Children.Add(overrideSection);
|
||
if (deniedCard != null)
|
||
detailsPanel.Children.Add(deniedCard);
|
||
|
||
PermissionItems.Children.Add(CreateCollapsibleSection(
|
||
"permission_details",
|
||
"\uE946",
|
||
"상세 정보",
|
||
detailsPanel,
|
||
expanded: GetPermissionPopupSectionExpanded("permission_details", false)));
|
||
|
||
PermissionPopup.IsOpen = true;
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
TryFocusFirstPermissionElement(PermissionItems);
|
||
}, DispatcherPriority.Input);
|
||
}
|
||
|
||
private static bool TryFocusFirstPermissionElement(DependencyObject root)
|
||
{
|
||
if (root is UIElement ui && ui.Focusable && ui.IsEnabled && ui.Visibility == Visibility.Visible)
|
||
return ui.Focus();
|
||
|
||
var childCount = VisualTreeHelper.GetChildrenCount(root);
|
||
for (var i = 0; i < childCount; i++)
|
||
{
|
||
var child = VisualTreeHelper.GetChild(root, i);
|
||
if (TryFocusFirstPermissionElement(child))
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void SetToolPermissionOverride(string toolName, string? mode)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(toolName)) return;
|
||
var toolPermissions = _settings.Settings.Llm.ToolPermissions ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
var existingKey = toolPermissions.Keys.FirstOrDefault(x => string.Equals(x, toolName, StringComparison.OrdinalIgnoreCase));
|
||
|
||
if (string.IsNullOrWhiteSpace(mode))
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(existingKey))
|
||
toolPermissions.Remove(existingKey!);
|
||
}
|
||
else
|
||
{
|
||
toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode);
|
||
}
|
||
|
||
try { _settings.Save(); } catch { }
|
||
_appState.LoadFromSettings(_settings);
|
||
UpdatePermissionUI();
|
||
SaveConversationSettings();
|
||
}
|
||
|
||
private void RefreshPermissionPopup()
|
||
{
|
||
if (PermissionPopup == null) return;
|
||
BtnPermission_Click(this, new RoutedEventArgs());
|
||
}
|
||
|
||
private string _lastPermissionBannerMode = "";
|
||
|
||
private bool GetPermissionPopupSectionExpanded(string sectionKey, bool defaultValue = false)
|
||
{
|
||
var map = _settings.Settings.Llm.PermissionPopupSections;
|
||
if (map != null && map.TryGetValue(sectionKey, out var expanded))
|
||
return expanded;
|
||
return defaultValue;
|
||
}
|
||
|
||
private void SetPermissionPopupSectionExpanded(string sectionKey, bool expanded)
|
||
{
|
||
var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||
map[sectionKey] = expanded;
|
||
try { _settings.Save(); } catch { }
|
||
}
|
||
|
||
private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (PermissionTopBanner != null)
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void UpdatePermissionUI()
|
||
{
|
||
if (PermissionLabel == null || PermissionIcon == null) return;
|
||
ChatConversation? currentConversation;
|
||
lock (_convLock) currentConversation = _currentConversation;
|
||
var summary = _appState.GetPermissionSummary(currentConversation);
|
||
var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
|
||
PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm);
|
||
PermissionIcon.Text = perm switch
|
||
{
|
||
"AcceptEdits" => "\uE73E",
|
||
"Plan" => "\uE7C3",
|
||
"BypassPermissions" => "\uE7BA",
|
||
"Deny" => "\uE711",
|
||
_ => "\uE8D7",
|
||
};
|
||
if (BtnPermission != null)
|
||
{
|
||
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||
BtnPermission.ToolTip = $"{summary.Description}\n운영 모드: {operationMode}\n기본값 {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개";
|
||
}
|
||
|
||
if (!string.Equals(_lastPermissionBannerMode, perm, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_lastPermissionBannerMode = perm;
|
||
}
|
||
|
||
// 모드별 색상 + 상단 권한 배너 표시
|
||
if (perm == PermissionModeCatalog.AcceptEdits)
|
||
{
|
||
var activeColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
|
||
PermissionLabel.Foreground = activeColor;
|
||
PermissionIcon.Foreground = activeColor;
|
||
if (PermissionTopBanner != null)
|
||
{
|
||
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
|
||
PermissionTopBannerIcon.Text = "\uE73E";
|
||
PermissionTopBannerIcon.Foreground = activeColor;
|
||
PermissionTopBannerTitle.Text = "현재 권한 모드 · 편집 자동 승인";
|
||
PermissionTopBannerTitle.Foreground = BrushFromHex("#166534");
|
||
PermissionTopBannerText.Text = "모든 파일 편집을 자동 승인합니다. 명령 실행은 계속 확인합니다.";
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
else if (perm == PermissionModeCatalog.Deny)
|
||
{
|
||
var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
|
||
PermissionLabel.Foreground = denyColor;
|
||
PermissionIcon.Foreground = denyColor;
|
||
if (PermissionTopBanner != null)
|
||
{
|
||
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
|
||
PermissionTopBannerIcon.Text = "\uE73E";
|
||
PermissionTopBannerIcon.Foreground = denyColor;
|
||
PermissionTopBannerTitle.Text = "현재 권한 모드 · 읽기 전용";
|
||
PermissionTopBannerTitle.Foreground = denyColor;
|
||
PermissionTopBannerText.Text = "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.";
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
else if (perm == PermissionModeCatalog.BypassPermissions)
|
||
{
|
||
var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C));
|
||
PermissionLabel.Foreground = autoColor;
|
||
PermissionIcon.Foreground = autoColor;
|
||
if (PermissionTopBanner != null)
|
||
{
|
||
PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74");
|
||
PermissionTopBannerIcon.Text = "\uE814";
|
||
PermissionTopBannerIcon.Foreground = autoColor;
|
||
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기";
|
||
PermissionTopBannerTitle.Foreground = autoColor;
|
||
PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요.";
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var defaultFg = BrushFromHex("#2563EB");
|
||
var iconFg = perm switch
|
||
{
|
||
"Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)),
|
||
_ => new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)),
|
||
};
|
||
PermissionLabel.Foreground = defaultFg;
|
||
PermissionIcon.Foreground = iconFg;
|
||
if (PermissionTopBanner != null)
|
||
{
|
||
if (perm == PermissionModeCatalog.Plan)
|
||
{
|
||
PermissionTopBanner.BorderBrush = BrushFromHex("#C7D2FE");
|
||
PermissionTopBannerIcon.Text = "\uE7C3";
|
||
PermissionTopBannerIcon.Foreground = BrushFromHex("#4338CA");
|
||
PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드";
|
||
PermissionTopBannerTitle.Foreground = BrushFromHex("#4338CA");
|
||
PermissionTopBannerText.Text = "변경 전에 계획을 먼저 만들고 승인 흐름을 우선합니다.";
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
else if (perm == PermissionModeCatalog.Default)
|
||
{
|
||
PermissionTopBanner.BorderBrush = BrushFromHex("#BFDBFE");
|
||
PermissionTopBannerIcon.Text = "\uE8D7";
|
||
PermissionTopBannerIcon.Foreground = BrushFromHex("#1D4ED8");
|
||
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 요청";
|
||
PermissionTopBannerTitle.Foreground = BrushFromHex("#1D4ED8");
|
||
PermissionTopBannerText.Text = "변경하기 전에 항상 확인합니다.";
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
else
|
||
{
|
||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
if (DataUsagePopup == null) return;
|
||
DataUsageItems.Children.Clear();
|
||
|
||
var options = new (string Key, string Sym, string Label, string Desc, string Color, string CheckColor)[]
|
||
{
|
||
("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽기만 포함해 참조하지 않습니다", "#6B7280", "#6B7280"),
|
||
("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706", "#D97706"),
|
||
("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10", "#107C10"),
|
||
};
|
||
|
||
foreach (var (key, sym, label, desc, color, checkColor) in options)
|
||
{
|
||
var isActive = key.Equals(_folderDataUsage, 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),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(8, 9, 8, 9),
|
||
Margin = new Thickness(0),
|
||
};
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
var checkIcon = CreateCheckIcon(isActive);
|
||
if (checkIcon is TextBlock checkText)
|
||
checkText.Foreground = isActive ? BrushFromHex(checkColor) : Brushes.Transparent;
|
||
sp.Children.Add(checkIcon);
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = sym, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14,
|
||
Foreground = BrushFromHex(color),
|
||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
|
||
});
|
||
var textStack = new StackPanel();
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = label, FontSize = 11.5, FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex(color),
|
||
});
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = desc, FontSize = 10.5,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
MaxWidth = 240,
|
||
});
|
||
sp.Children.Add(textStack);
|
||
row.Child = sp;
|
||
|
||
var capturedKey = key;
|
||
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.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
_folderDataUsage = capturedKey;
|
||
UpdateDataUsageUI();
|
||
SaveConversationSettings();
|
||
RefreshOverlayModeButtons();
|
||
DataUsagePopup.IsOpen = false;
|
||
};
|
||
DataUsageItems.Children.Add(row);
|
||
}
|
||
DataUsagePopup.IsOpen = true;
|
||
}
|
||
|
||
private void UpdateDataUsageUI()
|
||
{
|
||
if (DataUsageLabel == null || DataUsageIcon == null) return;
|
||
var (label, icon, color) = _folderDataUsage switch
|
||
{
|
||
"none" => ("미활용", "\uE8D8", "#6B7280"),
|
||
"passive" => ("소극", "\uE8FD", "#D97706"),
|
||
_ => ("적극", "\uE9F5", "#107C10"),
|
||
};
|
||
DataUsageLabel.Text = label;
|
||
DataUsageIcon.Text = icon;
|
||
DataUsageIcon.Foreground = BrushFromHex(color);
|
||
if (BtnDataUsage != null)
|
||
{
|
||
BtnDataUsage.Background = Brushes.Transparent;
|
||
BtnDataUsage.BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
BtnDataUsage.BorderThickness = new Thickness(1);
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
ToggleSidebarIcon.Text = "\uE76B";
|
||
|
||
if (animated)
|
||
{
|
||
AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200);
|
||
}
|
||
else
|
||
{
|
||
SidebarColumn.MinWidth = 200;
|
||
SidebarColumn.Width = new GridLength(270);
|
||
}
|
||
return;
|
||
}
|
||
|
||
SidebarColumn.MinWidth = 0;
|
||
ToggleSidebarIcon.Text = "\uE76C";
|
||
if (animated)
|
||
{
|
||
AnimateSidebar(270, 0, () =>
|
||
{
|
||
SidebarPanel.Visibility = Visibility.Collapsed;
|
||
IconBarColumn.Width = new GridLength(52);
|
||
IconBarPanel.Visibility = Visibility.Visible;
|
||
});
|
||
}
|
||
else
|
||
{
|
||
SidebarColumn.Width = new GridLength(0);
|
||
SidebarPanel.Visibility = Visibility.Collapsed;
|
||
IconBarColumn.Width = new GridLength(52);
|
||
IconBarPanel.Visibility = Visibility.Visible;
|
||
}
|
||
}
|
||
|
||
private void AnimateSidebar(double from, double to, Action? onComplete = null)
|
||
{
|
||
var duration = 200.0;
|
||
var start = DateTime.UtcNow;
|
||
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
|
||
EventHandler tickHandler = null!;
|
||
tickHandler = (_, _) =>
|
||
{
|
||
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
|
||
var t = Math.Min(elapsed / duration, 1.0);
|
||
t = 1 - (1 - t) * (1 - t);
|
||
SidebarColumn.Width = new GridLength(from + (to - from) * t);
|
||
if (elapsed >= duration)
|
||
{
|
||
timer.Stop();
|
||
timer.Tick -= tickHandler;
|
||
SidebarColumn.Width = new GridLength(to);
|
||
onComplete?.Invoke();
|
||
}
|
||
};
|
||
timer.Tick += tickHandler;
|
||
timer.Start();
|
||
}
|
||
|
||
// ─── 대화 목록 ────────────────────────────────────────────────────────
|
||
|
||
public void RefreshConversationList()
|
||
{
|
||
var metas = _storage.LoadAllMeta();
|
||
// 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원)
|
||
var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
|
||
.Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets))
|
||
.Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.Llm.CustomPresets));
|
||
var presetMap = new Dictionary<string, (string Symbol, string Color)>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var p in allPresets)
|
||
presetMap.TryAdd(p.Category, (p.Symbol, p.Color));
|
||
|
||
var items = metas.Select(c =>
|
||
{
|
||
var symbol = ChatCategory.GetSymbol(c.Category);
|
||
var color = ChatCategory.GetColor(c.Category);
|
||
// ChatCategory 기본값이면 프리셋에서 검색
|
||
if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General)
|
||
{
|
||
if (presetMap.TryGetValue(c.Category, out var pm))
|
||
{
|
||
symbol = pm.Symbol;
|
||
color = pm.Color;
|
||
}
|
||
}
|
||
var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory);
|
||
return new ConversationMeta
|
||
{
|
||
Id = c.Id,
|
||
Title = c.Title,
|
||
Pinned = c.Pinned,
|
||
Category = c.Category,
|
||
Symbol = symbol,
|
||
ColorHex = color,
|
||
Tab = NormalizeTabName(c.Tab),
|
||
UpdatedAtText = FormatDate(c.UpdatedAt),
|
||
UpdatedAt = c.UpdatedAt,
|
||
Preview = c.Preview ?? "",
|
||
ParentId = c.ParentId,
|
||
AgentRunCount = runSummary.AgentRunCount,
|
||
FailedAgentRunCount = runSummary.FailedAgentRunCount,
|
||
LastAgentRunSummary = runSummary.LastAgentRunSummary,
|
||
LastFailedAt = runSummary.LastFailedAt,
|
||
LastCompletedAt = runSummary.LastCompletedAt,
|
||
WorkFolder = c.WorkFolder ?? "",
|
||
IsRunning = _currentConversation?.Id == c.Id
|
||
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
|
||
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase),
|
||
};
|
||
}).ToList();
|
||
|
||
// 탭 필터 — 현재 활성 탭의 대화만 표시
|
||
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList();
|
||
// 탭 전환 과정에서 저장된 "빈 새 대화" 노이즈 항목은 목록에서 숨김
|
||
items = items.Where(i =>
|
||
i.Pinned
|
||
|| !string.IsNullOrWhiteSpace(i.ParentId)
|
||
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|
||
|| !string.IsNullOrWhiteSpace(i.Preview)
|
||
|| i.AgentRunCount > 0
|
||
|| i.FailedAgentRunCount > 0
|
||
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)
|
||
).ToList();
|
||
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
|
||
_runningConversationCount = items.Count(i => i.IsRunning);
|
||
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
|
||
UpdateConversationFailureFilterUi();
|
||
UpdateConversationRunningFilterUi();
|
||
UpdateConversationQuickStripUi();
|
||
|
||
// 상단 필터 적용
|
||
if (_activeTab == "Cowork" || _activeTab == "Code")
|
||
{
|
||
if (!string.IsNullOrEmpty(_selectedCategory))
|
||
{
|
||
items = items.Where(i => string.Equals(i.WorkFolder, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (_selectedCategory == "__custom__")
|
||
{
|
||
// 커스텀 프리셋으로 만든 대화만 표시
|
||
var customCats = _settings.Settings.Llm.CustomPresets
|
||
.Select(c => $"custom_{c.Id}").ToHashSet();
|
||
items = items.Where(i => customCats.Contains(i.Category)).ToList();
|
||
}
|
||
else if (!string.IsNullOrEmpty(_selectedCategory))
|
||
{
|
||
items = items.Where(i => i.Category == _selectedCategory).ToList();
|
||
}
|
||
}
|
||
|
||
// 검색 필터 (제목 + 내용 미리보기)
|
||
var search = SearchBox?.Text?.Trim() ?? "";
|
||
if (!string.IsNullOrEmpty(search))
|
||
items = items.Where(i =>
|
||
i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||
i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||
i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase)
|
||
).ToList();
|
||
|
||
if (_runningOnlyFilter)
|
||
items = items.Where(i => i.IsRunning).ToList();
|
||
|
||
items = (_sortConversationsByRecent
|
||
? items.OrderByDescending(i => i.Pinned)
|
||
.ThenByDescending(i => i.UpdatedAt)
|
||
.ThenByDescending(i => i.FailedAgentRunCount > 0)
|
||
.ThenByDescending(i => i.AgentRunCount)
|
||
: items.OrderByDescending(i => i.Pinned)
|
||
.ThenByDescending(i => i.FailedAgentRunCount > 0)
|
||
.ThenByDescending(i => i.AgentRunCount)
|
||
.ThenByDescending(i => i.UpdatedAt))
|
||
.ToList();
|
||
|
||
RenderConversationList(items);
|
||
}
|
||
|
||
private const int ConversationPageSize = 50;
|
||
private List<ConversationMeta>? _pendingConversations;
|
||
|
||
private void RenderConversationList(List<ConversationMeta> items)
|
||
{
|
||
ConversationPanel.Children.Clear();
|
||
_pendingConversations = null;
|
||
|
||
if (items.Count == 0)
|
||
{
|
||
var emptyText = _activeTab switch
|
||
{
|
||
"Cowork" => "Cowork 탭 대화가 없습니다",
|
||
"Code" => "Code 탭 대화가 없습니다",
|
||
_ => "Chat 탭 대화가 없습니다",
|
||
};
|
||
var empty = new TextBlock
|
||
{
|
||
Text = emptyText,
|
||
FontSize = 12,
|
||
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Margin = new Thickness(0, 20, 0, 0)
|
||
};
|
||
ConversationPanel.Children.Add(empty);
|
||
return;
|
||
}
|
||
|
||
var spotlightItems = BuildConversationSpotlightItems(items);
|
||
if (spotlightItems.Count > 0)
|
||
{
|
||
AddGroupHeader("집중 필요");
|
||
foreach (var item in spotlightItems)
|
||
AddConversationItem(item);
|
||
|
||
ConversationPanel.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Margin = new Thickness(10, 8, 10, 4),
|
||
Background = BrushFromHex("#E5E7EB"),
|
||
Opacity = 0.7,
|
||
});
|
||
}
|
||
|
||
var allOrdered = new List<(string Group, ConversationMeta Item)>();
|
||
foreach (var item in items)
|
||
allOrdered.Add((GetConversationDateGroup(item.UpdatedAt), item));
|
||
|
||
// 첫 페이지만 렌더링
|
||
var firstPage = allOrdered.Take(ConversationPageSize).ToList();
|
||
string? lastGroup = null;
|
||
foreach (var (group, item) in firstPage)
|
||
{
|
||
if (group != lastGroup) { AddGroupHeader(group); lastGroup = group; }
|
||
AddConversationItem(item);
|
||
}
|
||
|
||
// 나머지가 있으면 "더 보기" 버튼
|
||
if (allOrdered.Count > ConversationPageSize)
|
||
{
|
||
_pendingConversations = items;
|
||
AddLoadMoreButton(allOrdered.Count - ConversationPageSize);
|
||
}
|
||
}
|
||
|
||
private void AddLoadMoreButton(int remaining)
|
||
{
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var btn = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(8, 10, 8, 10),
|
||
Margin = new Thickness(6, 4, 6, 4),
|
||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||
};
|
||
var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = $"더 보기 ({remaining}개 남음)",
|
||
FontSize = 12,
|
||
Foreground = accentBrush,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
});
|
||
btn.Child = sp;
|
||
btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
|
||
btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
btn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
// 전체 목록 렌더링
|
||
if (_pendingConversations != null)
|
||
{
|
||
var all = _pendingConversations;
|
||
_pendingConversations = null;
|
||
ConversationPanel.Children.Clear();
|
||
|
||
string? lastGroup = null;
|
||
foreach (var item in all)
|
||
{
|
||
var group = GetConversationDateGroup(item.UpdatedAt);
|
||
if (!string.Equals(lastGroup, group, StringComparison.Ordinal))
|
||
{
|
||
AddGroupHeader(group);
|
||
lastGroup = group;
|
||
}
|
||
|
||
AddConversationItem(item);
|
||
}
|
||
}
|
||
};
|
||
ConversationPanel.Children.Add(btn);
|
||
}
|
||
|
||
private static string GetConversationDateGroup(DateTime updatedAt)
|
||
{
|
||
var today = DateTime.Today;
|
||
var date = updatedAt.Date;
|
||
if (date == today)
|
||
return "오늘";
|
||
if (date == today.AddDays(-1))
|
||
return "어제";
|
||
return "이전";
|
||
}
|
||
|
||
private List<ConversationMeta> BuildConversationSpotlightItems(List<ConversationMeta> items)
|
||
{
|
||
if (_failedOnlyFilter || _runningOnlyFilter)
|
||
return new List<ConversationMeta>();
|
||
|
||
var search = SearchBox?.Text?.Trim() ?? "";
|
||
if (!string.IsNullOrEmpty(search))
|
||
return new List<ConversationMeta>();
|
||
|
||
return items
|
||
.Where(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3)
|
||
.OrderByDescending(i => i.FailedAgentRunCount)
|
||
.ThenByDescending(i => i.AgentRunCount)
|
||
.ThenByDescending(i => i.UpdatedAt)
|
||
.Take(3)
|
||
.ToList();
|
||
}
|
||
|
||
private void AddGroupHeader(string text)
|
||
{
|
||
var header = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
|
||
Margin = new Thickness(8, 10, 0, 3)
|
||
};
|
||
ConversationPanel.Children.Add(header);
|
||
}
|
||
|
||
private void AddConversationItem(ConversationMeta item)
|
||
{
|
||
var isSelected = false;
|
||
lock (_convLock)
|
||
isSelected = _currentConversation?.Id == item.Id;
|
||
|
||
var isBranch = !string.IsNullOrEmpty(item.ParentId);
|
||
var border = new Border
|
||
{
|
||
Background = isSelected
|
||
? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC))
|
||
: Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = isBranch ? new Thickness(14, 1, 0, 1) : new Thickness(0, 1, 0, 1),
|
||
Cursor = Cursors.Hand
|
||
};
|
||
|
||
var grid = new Grid();
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
// 카테고리 아이콘 (고정 시 핀 아이콘, 그 외 카테고리 색상)
|
||
Brush iconBrush;
|
||
if (item.Pinned)
|
||
iconBrush = Brushes.Orange;
|
||
else
|
||
{
|
||
try { iconBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); }
|
||
catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; }
|
||
}
|
||
var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol;
|
||
if (!string.IsNullOrEmpty(item.ParentId)) iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6)); // 분기: 보라색
|
||
var icon = new TextBlock
|
||
{
|
||
Text = iconText,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13,
|
||
Foreground = iconBrush,
|
||
VerticalAlignment = VerticalAlignment.Center
|
||
};
|
||
Grid.SetColumn(icon, 0);
|
||
grid.Children.Add(icon);
|
||
|
||
// 제목 + 날짜 (선택 시 약간 밝게)
|
||
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray;
|
||
|
||
var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||
var title = new TextBlock
|
||
{
|
||
Text = item.Title,
|
||
FontSize = 12,
|
||
Foreground = titleColor,
|
||
TextTrimming = TextTrimming.CharacterEllipsis
|
||
};
|
||
var date = new TextBlock
|
||
{
|
||
Text = item.UpdatedAtText,
|
||
FontSize = 9.5,
|
||
Foreground = dateColor,
|
||
Margin = new Thickness(0, 1, 0, 0)
|
||
};
|
||
stack.Children.Add(title);
|
||
stack.Children.Add(date);
|
||
if (item.IsRunning)
|
||
{
|
||
stack.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex("#DBEAFE"),
|
||
BorderBrush = BrushFromHex("#93C5FD"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(6, 1, 6, 1),
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Child = new TextBlock
|
||
{
|
||
Text = _appState.ActiveTasks.Count > 0
|
||
? $"진행 중 {_appState.ActiveTasks.Count}"
|
||
: "진행 중",
|
||
FontSize = 9,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
}
|
||
});
|
||
}
|
||
if (item.AgentRunCount > 0)
|
||
{
|
||
var runSummaryPanel = new DockPanel
|
||
{
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
LastChildFill = true,
|
||
};
|
||
|
||
if (item.FailedAgentRunCount > 0 && item.LastFailedAt.HasValue)
|
||
{
|
||
var failedBadge = new Border
|
||
{
|
||
Background = BrushFromHex("#FEF2F2"),
|
||
BorderBrush = BrushFromHex("#FECACA"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(6, 1, 6, 1),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Child = new TextBlock
|
||
{
|
||
Text = $"실패 {FormatDate(item.LastFailedAt.Value)}",
|
||
FontSize = 9,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#991B1B"),
|
||
}
|
||
};
|
||
DockPanel.SetDock(failedBadge, Dock.Right);
|
||
runSummaryPanel.Children.Add(failedBadge);
|
||
}
|
||
else if (item.LastCompletedAt.HasValue)
|
||
{
|
||
var completedBadge = new Border
|
||
{
|
||
Background = BrushFromHex("#ECFDF5"),
|
||
BorderBrush = BrushFromHex("#BBF7D0"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(6, 1, 6, 1),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Child = new TextBlock
|
||
{
|
||
Text = $"성공 {FormatDate(item.LastCompletedAt.Value)}",
|
||
FontSize = 9,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#166534"),
|
||
}
|
||
};
|
||
DockPanel.SetDock(completedBadge, Dock.Right);
|
||
runSummaryPanel.Children.Add(completedBadge);
|
||
}
|
||
|
||
var runSummaryText = new TextBlock
|
||
{
|
||
Text = item.FailedAgentRunCount > 0
|
||
? $"실행 {item.AgentRunCount} · 실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}"
|
||
: $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 32)}",
|
||
FontSize = 9.5,
|
||
Foreground = item.FailedAgentRunCount > 0
|
||
? BrushFromHex("#B91C1C")
|
||
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
TextTrimming = TextTrimming.CharacterEllipsis
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(item.LastAgentRunSummary))
|
||
{
|
||
runSummaryText.ToolTip = item.FailedAgentRunCount > 0
|
||
? $"최근 실패 포함\n{item.LastAgentRunSummary}"
|
||
: item.LastAgentRunSummary;
|
||
}
|
||
runSummaryPanel.Children.Add(runSummaryText);
|
||
stack.Children.Add(runSummaryPanel);
|
||
}
|
||
Grid.SetColumn(stack, 1);
|
||
grid.Children.Add(stack);
|
||
|
||
// 카테고리 변경 버튼 (호버 시 표시)
|
||
var catBtn = new Button
|
||
{
|
||
Content = new TextBlock
|
||
{
|
||
Text = "\uE70F", // Edit
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10,
|
||
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray)
|
||
},
|
||
Background = Brushes.Transparent,
|
||
BorderThickness = new Thickness(0),
|
||
Cursor = Cursors.Hand,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Visibility = Visibility.Collapsed,
|
||
Padding = new Thickness(4),
|
||
ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경"
|
||
};
|
||
var capturedId = item.Id;
|
||
catBtn.Click += (_, _) => ShowConversationMenu(capturedId);
|
||
Grid.SetColumn(catBtn, 2);
|
||
grid.Children.Add(catBtn);
|
||
|
||
// 선택 시 좌측 액센트 바
|
||
if (isSelected)
|
||
{
|
||
border.BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
border.BorderThickness = new Thickness(2, 0, 0, 0);
|
||
}
|
||
|
||
border.Child = grid;
|
||
|
||
// 호버 이벤트 — 배경 + 미세 확대
|
||
border.RenderTransformOrigin = new Point(0.5, 0.5);
|
||
border.RenderTransform = new ScaleTransform(1, 1);
|
||
var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC));
|
||
border.MouseEnter += (_, _) =>
|
||
{
|
||
if (!isSelected)
|
||
border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF));
|
||
catBtn.Visibility = Visibility.Visible;
|
||
var st = border.RenderTransform as ScaleTransform;
|
||
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
|
||
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
|
||
};
|
||
border.MouseLeave += (_, _) =>
|
||
{
|
||
if (!isSelected)
|
||
border.Background = Brushes.Transparent;
|
||
catBtn.Visibility = Visibility.Collapsed;
|
||
var st = border.RenderTransform as ScaleTransform;
|
||
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
|
||
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
|
||
};
|
||
|
||
// 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환
|
||
border.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
try
|
||
{
|
||
if (isSelected)
|
||
{
|
||
// 이미 선택된 대화 → 제목 편집 모드
|
||
EnterTitleEditMode(title, item.Id, titleColor);
|
||
return;
|
||
}
|
||
// 스트리밍 중이면 취소
|
||
if (_isStreaming)
|
||
{
|
||
_streamCts?.Cancel();
|
||
_cursorTimer.Stop();
|
||
_typingTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_isStreaming = false;
|
||
}
|
||
var conv = _storage.Load(item.Id);
|
||
if (conv != null)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
SaveLastConversations();
|
||
UpdateChatTitle();
|
||
RenderMessages();
|
||
RefreshConversationList();
|
||
RefreshDraftQueueUi();
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogService.Error($"대화 전환 오류: {ex.Message}");
|
||
}
|
||
};
|
||
|
||
// 우클릭 → 대화 관리 메뉴 바로 표시
|
||
border.MouseRightButtonUp += (_, me) =>
|
||
{
|
||
me.Handled = true;
|
||
// 선택되지 않은 대화를 우클릭하면 먼저 선택
|
||
if (!isSelected)
|
||
{
|
||
var conv = _storage.Load(item.Id);
|
||
if (conv != null)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||
SyncTabConversationIdsFromSession();
|
||
}
|
||
SaveLastConversations();
|
||
UpdateChatTitle();
|
||
RenderMessages();
|
||
RefreshDraftQueueUi();
|
||
}
|
||
}
|
||
// Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기
|
||
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
|
||
};
|
||
|
||
ConversationPanel.Children.Add(border);
|
||
}
|
||
|
||
// ─── 대화 제목 인라인 편집 ────────────────────────────────────────────
|
||
|
||
private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor)
|
||
{
|
||
try
|
||
{
|
||
// titleTb가 이미 부모에서 분리된 경우(편집 중) 무시
|
||
var parent = titleTb.Parent as StackPanel;
|
||
if (parent == null) return;
|
||
|
||
var idx = parent.Children.IndexOf(titleTb);
|
||
if (idx < 0) return;
|
||
|
||
var editBox = new TextBox
|
||
{
|
||
Text = titleTb.Text,
|
||
FontSize = 12.5,
|
||
Foreground = titleColor,
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||
CaretBrush = titleColor,
|
||
Padding = new Thickness(0),
|
||
Margin = new Thickness(0),
|
||
};
|
||
|
||
// 안전하게 자식 교체: 먼저 제거 후 삽입
|
||
parent.Children.RemoveAt(idx);
|
||
parent.Children.Insert(idx, editBox);
|
||
|
||
var committed = false;
|
||
void CommitEdit()
|
||
{
|
||
if (committed) return;
|
||
committed = true;
|
||
|
||
var newTitle = editBox.Text.Trim();
|
||
if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text;
|
||
|
||
titleTb.Text = newTitle;
|
||
// editBox가 아직 parent에 있는지 확인 후 교체
|
||
try
|
||
{
|
||
var currentIdx = parent.Children.IndexOf(editBox);
|
||
if (currentIdx >= 0)
|
||
{
|
||
parent.Children.RemoveAt(currentIdx);
|
||
parent.Children.Insert(currentIdx, titleTb);
|
||
}
|
||
}
|
||
catch { /* 부모가 이미 해제된 경우 무시 */ }
|
||
|
||
var conv = _storage.Load(conversationId);
|
||
if (conv != null)
|
||
{
|
||
conv.Title = newTitle;
|
||
_storage.Save(conv);
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation?.Id == conversationId)
|
||
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage) ?? _currentConversation;
|
||
}
|
||
UpdateChatTitle();
|
||
}
|
||
}
|
||
|
||
void CancelEdit()
|
||
{
|
||
if (committed) return;
|
||
committed = true;
|
||
try
|
||
{
|
||
var currentIdx = parent.Children.IndexOf(editBox);
|
||
if (currentIdx >= 0)
|
||
{
|
||
parent.Children.RemoveAt(currentIdx);
|
||
parent.Children.Insert(currentIdx, titleTb);
|
||
}
|
||
}
|
||
catch { /* 부모가 이미 해제된 경우 무시 */ }
|
||
}
|
||
|
||
editBox.KeyDown += (_, ke) =>
|
||
{
|
||
if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); }
|
||
if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); }
|
||
};
|
||
editBox.LostFocus += (_, _) => CommitEdit();
|
||
|
||
editBox.Focus();
|
||
editBox.SelectAll();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogService.Error($"제목 편집 오류: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ─── 카테고리 변경 팝업 ──────────────────────────────────────────────
|
||
|
||
private void ShowConversationMenu(string conversationId)
|
||
{
|
||
var conv = _storage.Load(conversationId);
|
||
var isPinned = conv?.Pinned ?? false;
|
||
|
||
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
|
||
|
||
var popup = new Popup
|
||
{
|
||
StaysOpen = false,
|
||
AllowsTransparency = true,
|
||
PopupAnimation = PopupAnimation.Fade,
|
||
Placement = PlacementMode.MousePoint,
|
||
};
|
||
|
||
var container = new Border
|
||
{
|
||
Background = bgBrush,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(6),
|
||
MinWidth = 200,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
|
||
},
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
|
||
// 메뉴 항목 헬퍼
|
||
Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick)
|
||
{
|
||
var item = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(0, 1, 0, 1),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var g = new Grid();
|
||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
|
||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
|
||
var iconTb = new TextBlock
|
||
{
|
||
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(iconTb, 0);
|
||
g.Children.Add(iconTb);
|
||
|
||
var textTb = new TextBlock
|
||
{
|
||
Text = text, FontSize = 12.5, Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(textTb, 1);
|
||
g.Children.Add(textTb);
|
||
|
||
item.Child = g;
|
||
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); };
|
||
return item;
|
||
}
|
||
|
||
Border CreateSeparator() => new()
|
||
{
|
||
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
|
||
};
|
||
|
||
// 고정/해제
|
||
stack.Children.Add(CreateMenuItem(
|
||
isPinned ? "\uE77A" : "\uE718",
|
||
isPinned ? "고정 해제" : "상단 고정",
|
||
TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||
() =>
|
||
{
|
||
var c = _storage.Load(conversationId);
|
||
if (c != null)
|
||
{
|
||
c.Pinned = !c.Pinned;
|
||
_storage.Save(c);
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation?.Id == conversationId)
|
||
{
|
||
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current => current.Pinned = c.Pinned, _storage) ?? _currentConversation;
|
||
}
|
||
}
|
||
RefreshConversationList();
|
||
}
|
||
}));
|
||
|
||
// 이름 변경
|
||
stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () =>
|
||
{
|
||
// 대화 목록에서 해당 항목 찾아서 편집 모드 진입
|
||
foreach (UIElement child in ConversationPanel.Children)
|
||
{
|
||
if (child is Border b && b.Child is Grid g)
|
||
{
|
||
foreach (UIElement gc in g.Children)
|
||
{
|
||
if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
|
||
{
|
||
// title과 매칭
|
||
if (conv != null && tb.Text == conv.Title)
|
||
{
|
||
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
EnterTitleEditMode(tb, conversationId, titleColor);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}));
|
||
|
||
// Cowork/Code 탭: 작업 유형 읽기 전용 표시
|
||
if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null)
|
||
{
|
||
var catKey = conv.Category ?? ChatCategory.General;
|
||
// ChatCategory 또는 프리셋에서 아이콘/라벨 검색
|
||
string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280";
|
||
var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey);
|
||
if (chatCat != default && chatCat.Key != ChatCategory.General)
|
||
{
|
||
catSymbol = chatCat.Symbol; catLabel = chatCat.Label; catColor = chatCat.Color;
|
||
}
|
||
else
|
||
{
|
||
var preset = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets)
|
||
.FirstOrDefault(p => p.Category == catKey);
|
||
if (preset != null)
|
||
{
|
||
catSymbol = preset.Symbol; catLabel = preset.Label; catColor = preset.Color;
|
||
}
|
||
}
|
||
|
||
stack.Children.Add(CreateSeparator());
|
||
var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) };
|
||
try
|
||
{
|
||
var catBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(catColor));
|
||
infoSp.Children.Add(new TextBlock
|
||
{
|
||
Text = catSymbol, FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12, Foreground = catBrush,
|
||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
infoSp.Children.Add(new TextBlock
|
||
{
|
||
Text = catLabel, FontSize = 12, Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
}
|
||
catch
|
||
{
|
||
infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText });
|
||
}
|
||
stack.Children.Add(infoSp);
|
||
}
|
||
|
||
// Chat 탭만 분류 변경 표시 (Cowork/Code 탭은 분류 불필요)
|
||
var showCategorySection = _activeTab == "Chat";
|
||
|
||
if (showCategorySection)
|
||
{
|
||
stack.Children.Add(CreateSeparator());
|
||
|
||
// 분류 헤더
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = "분류 변경",
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 4, 0, 4),
|
||
FontWeight = FontWeights.SemiBold,
|
||
});
|
||
|
||
var currentCategory = conv?.Category ?? ChatCategory.General;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
|
||
foreach (var (key, label, symbol, color) in ChatCategory.All)
|
||
{
|
||
var capturedKey = key;
|
||
var isCurrentCat = capturedKey == currentCategory;
|
||
|
||
// 카테고리 항목 (체크 표시 포함)
|
||
var catItem = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(0, 1, 0, 1),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var catGrid = new Grid();
|
||
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
|
||
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
|
||
|
||
var catIcon = new TextBlock
|
||
{
|
||
Text = symbol, FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12, Foreground = BrushFromHex(color), VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(catIcon, 0);
|
||
catGrid.Children.Add(catIcon);
|
||
|
||
var catText = new TextBlock
|
||
{
|
||
Text = label, FontSize = 12.5, Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal,
|
||
};
|
||
Grid.SetColumn(catText, 1);
|
||
catGrid.Children.Add(catText);
|
||
|
||
if (isCurrentCat)
|
||
{
|
||
var check = CreateSimpleCheck(accentBrush, 14);
|
||
Grid.SetColumn(check, 2);
|
||
catGrid.Children.Add(check);
|
||
}
|
||
|
||
catItem.Child = catGrid;
|
||
catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||
catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
catItem.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
popup.IsOpen = false;
|
||
var c = _storage.Load(conversationId);
|
||
if (c != null)
|
||
{
|
||
c.Category = capturedKey;
|
||
var preset = Services.PresetService.GetByCategory(capturedKey);
|
||
if (preset != null)
|
||
c.SystemCommand = preset.SystemPrompt;
|
||
_storage.Save(c);
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation?.Id == conversationId)
|
||
{
|
||
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current =>
|
||
{
|
||
current.Category = capturedKey;
|
||
if (preset != null)
|
||
current.SystemCommand = preset.SystemPrompt;
|
||
}, _storage) ?? _currentConversation;
|
||
}
|
||
}
|
||
// 현재 대화의 카테고리가 변경되면 입력 안내 문구도 갱신
|
||
bool isCurrent;
|
||
lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; }
|
||
if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder))
|
||
{
|
||
_promptCardPlaceholder = preset.Placeholder;
|
||
UpdateWatermarkVisibility();
|
||
if (string.IsNullOrEmpty(InputBox.Text))
|
||
{
|
||
InputWatermark.Text = preset.Placeholder;
|
||
InputWatermark.Visibility = Visibility.Visible;
|
||
}
|
||
}
|
||
else if (isCurrent)
|
||
{
|
||
ClearPromptCardPlaceholder();
|
||
}
|
||
RefreshConversationList();
|
||
}
|
||
};
|
||
stack.Children.Add(catItem);
|
||
}
|
||
} // end showCategorySection
|
||
|
||
stack.Children.Add(CreateSeparator());
|
||
|
||
// 삭제
|
||
stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () =>
|
||
{
|
||
var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제",
|
||
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||
if (result != MessageBoxResult.Yes) return;
|
||
_storage.Delete(conversationId);
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation?.Id == conversationId)
|
||
{
|
||
_currentConversation = null;
|
||
MessagePanel.Children.Clear();
|
||
EmptyState.Visibility = Visibility.Visible;
|
||
UpdateChatTitle();
|
||
}
|
||
}
|
||
RefreshConversationList();
|
||
}));
|
||
|
||
container.Child = stack;
|
||
popup.Child = container;
|
||
popup.IsOpen = true;
|
||
}
|
||
|
||
// ─── 검색 ────────────────────────────────────────────────────────────
|
||
|
||
private void 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)
|
||
{
|
||
RefreshConversationList();
|
||
}
|
||
|
||
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()
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
ChatTitle.Text = _currentConversation?.Title ?? "";
|
||
}
|
||
}
|
||
|
||
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
|
||
|
||
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 = conv?.Messages?.Where(msg => msg.Role != "system").ToList() ?? new List<ChatMessage>();
|
||
var visibleEvents = (conv?.ShowExecutionHistory ?? true)
|
||
? conv?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>()
|
||
: new List<ChatExecutionEvent>();
|
||
|
||
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 timeline = new List<(DateTime Timestamp, int Order, Action Render)>();
|
||
foreach (var msg in visibleMessages)
|
||
timeline.Add((msg.Timestamp, 0, () => AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg)));
|
||
|
||
foreach (var executionEvent in visibleEvents)
|
||
{
|
||
var restoredEvent = ToAgentEvent(executionEvent);
|
||
timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
|
||
}
|
||
|
||
var orderedTimeline = timeline.OrderBy(x => x.Timestamp).ThenBy(x => x.Order).ToList();
|
||
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);
|
||
}
|
||
|
||
private Border CreateTimelineLoadMoreCard(int hiddenCount)
|
||
{
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155");
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B");
|
||
|
||
var loadMoreBtn = new Button
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(10, 5, 10, 5),
|
||
Cursor = System.Windows.Input.Cursors.Hand,
|
||
Foreground = primaryText,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
};
|
||
loadMoreBtn.Template = BuildMinimalIconButtonTemplate();
|
||
loadMoreBtn.Content = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "\uE70D",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"이전 대화 {hiddenCount:N0}개 더 보기",
|
||
FontSize = 11.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
}
|
||
}
|
||
};
|
||
loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg;
|
||
loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent;
|
||
loadMoreBtn.Click += (_, _) =>
|
||
{
|
||
_timelineRenderLimit += TimelineRenderPageSize;
|
||
RenderMessages(preserveViewport: true);
|
||
};
|
||
|
||
return new Border
|
||
{
|
||
CornerRadius = new CornerRadius(16),
|
||
Margin = new Thickness(0, 2, 0, 12),
|
||
Padding = new Thickness(0),
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = Brushes.Transparent,
|
||
BorderThickness = new Thickness(0),
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Child = loadMoreBtn,
|
||
};
|
||
}
|
||
|
||
private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent)
|
||
{
|
||
var parsedType = Enum.TryParse<AgentEventType>(executionEvent.Type, out var eventType)
|
||
? eventType
|
||
: AgentEventType.Thinking;
|
||
|
||
return new AgentEvent
|
||
{
|
||
Timestamp = executionEvent.Timestamp,
|
||
RunId = executionEvent.RunId,
|
||
Type = parsedType,
|
||
ToolName = executionEvent.ToolName,
|
||
Summary = executionEvent.Summary,
|
||
FilePath = executionEvent.FilePath,
|
||
Success = executionEvent.Success,
|
||
StepCurrent = executionEvent.StepCurrent,
|
||
StepTotal = executionEvent.StepTotal,
|
||
Steps = executionEvent.Steps,
|
||
ElapsedMs = executionEvent.ElapsedMs,
|
||
InputTokens = executionEvent.InputTokens,
|
||
OutputTokens = executionEvent.OutputTokens,
|
||
};
|
||
}
|
||
|
||
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
|
||
{
|
||
var isUser = role == "user";
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var hintBg = TryFindResource("HintBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var userBubbleBg = hintBg;
|
||
var assistantBubbleBg = itemBg;
|
||
|
||
if (isUser)
|
||
{
|
||
// 사용자: 우측 정렬, 얇고 단정한 카드
|
||
var wrapper = new StackPanel
|
||
{
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
MaxWidth = GetMessageMaxWidth(),
|
||
Margin = new Thickness(150, 3, 16, 3),
|
||
};
|
||
|
||
var bubble = new Border
|
||
{
|
||
Background = userBubbleBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(13, 9, 13, 9),
|
||
Child = new TextBlock
|
||
{
|
||
Text = content,
|
||
TextAlignment = TextAlignment.Left,
|
||
FontSize = 12.5,
|
||
Foreground = primaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
LineHeight = 20,
|
||
}
|
||
};
|
||
wrapper.Children.Add(bubble);
|
||
|
||
// 액션 버튼 바 (복사 + 편집, hover 시 표시)
|
||
var userActionBar = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
Opacity = 0,
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
};
|
||
var capturedUserContent = content;
|
||
var userBtnColor = secondaryText;
|
||
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
|
||
{
|
||
try { Clipboard.SetText(capturedUserContent); } catch { }
|
||
}));
|
||
userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor,
|
||
() => EnterEditMode(wrapper, capturedUserContent)));
|
||
|
||
// 타임스탬프 + 액션 바
|
||
var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) };
|
||
var timestamp = message?.Timestamp ?? DateTime.Now;
|
||
userBottomBar.Children.Add(new TextBlock
|
||
{
|
||
Text = timestamp.ToString("HH:mm"),
|
||
FontSize = 9, Opacity = 0.52,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
userBottomBar.Children.Add(userActionBar);
|
||
wrapper.Children.Add(userBottomBar);
|
||
wrapper.MouseEnter += (_, _) => ShowMessageActionBar(userActionBar);
|
||
wrapper.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(userActionBar);
|
||
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
|
||
|
||
// 우클릭 → 메시지 컨텍스트 메뉴
|
||
var userContent = content;
|
||
wrapper.MouseRightButtonUp += (_, re) =>
|
||
{
|
||
re.Handled = true;
|
||
ShowMessageContextMenu(userContent, "user");
|
||
};
|
||
|
||
if (animate) ApplyMessageEntryAnimation(wrapper);
|
||
MessagePanel.Children.Add(wrapper);
|
||
}
|
||
else
|
||
{
|
||
// 어시스턴트: 좌측 정렬, 정돈된 카드
|
||
var container = new StackPanel
|
||
{
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
MaxWidth = GetMessageMaxWidth(),
|
||
Margin = new Thickness(10, 3, 150, 3)
|
||
};
|
||
if (animate) ApplyMessageEntryAnimation(container);
|
||
|
||
// AI 에이전트 이름 + 아이콘
|
||
var (agentName, _, _) = GetAgentIdentity();
|
||
var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 2) };
|
||
|
||
var iconBlock = new TextBlock
|
||
{
|
||
Text = "\uE945",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 8,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
headerSp.Children.Add(iconBlock);
|
||
|
||
headerSp.Children.Add(new TextBlock
|
||
{
|
||
Text = agentName,
|
||
FontSize = 9,
|
||
FontWeight = FontWeights.Medium,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(4, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
container.Children.Add(headerSp);
|
||
|
||
var contentCard = new Border
|
||
{
|
||
Background = assistantBubbleBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(13, 10, 13, 10),
|
||
};
|
||
var contentStack = new StackPanel();
|
||
|
||
// 마크다운 렌더링 (파일 경로 강조 설정 연동)
|
||
var app = System.Windows.Application.Current as App;
|
||
MarkdownRenderer.EnableFilePathHighlight =
|
||
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
||
if (IsBranchContextMessage(content))
|
||
{
|
||
var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun();
|
||
var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3);
|
||
var branchCard = new Border
|
||
{
|
||
Background = hintBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(12, 10, 12, 10),
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
};
|
||
var branchStack = new StackPanel();
|
||
branchStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "분기 컨텍스트",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
});
|
||
var branchMd = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||
branchStack.Children.Add(branchMd);
|
||
if (branchFiles.Count > 0)
|
||
{
|
||
var filesWrap = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
foreach (var path in branchFiles)
|
||
{
|
||
var fileButton = new Button
|
||
{
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(8, 3, 8, 3),
|
||
Margin = new Thickness(0, 0, 6, 6),
|
||
Cursor = Cursors.Hand,
|
||
ToolTip = path,
|
||
Content = new TextBlock
|
||
{
|
||
Text = System.IO.Path.GetFileName(path),
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
}
|
||
};
|
||
var capturedPath = path;
|
||
fileButton.Click += (_, _) => OpenRunFilePath(capturedPath);
|
||
filesWrap.Children.Add(fileButton);
|
||
}
|
||
branchStack.Children.Add(filesWrap);
|
||
}
|
||
|
||
if (branchRun != null)
|
||
{
|
||
var actionsWrap = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
|
||
var followUpButton = new Button
|
||
{
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(8, 3, 8, 3),
|
||
Margin = new Thickness(0, 0, 6, 6),
|
||
Cursor = Cursors.Hand,
|
||
Content = new TextBlock
|
||
{
|
||
Text = "후속 작업 큐에 넣기",
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
}
|
||
};
|
||
var capturedBranchRun = branchRun;
|
||
followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun);
|
||
actionsWrap.Children.Add(followUpButton);
|
||
|
||
var timelineButton = new Button
|
||
{
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(8, 3, 8, 3),
|
||
Margin = new Thickness(0, 0, 6, 6),
|
||
Cursor = Cursors.Hand,
|
||
Content = new TextBlock
|
||
{
|
||
Text = "관련 로그로 이동",
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
}
|
||
};
|
||
timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId);
|
||
actionsWrap.Children.Add(timelineButton);
|
||
|
||
branchStack.Children.Add(actionsWrap);
|
||
}
|
||
branchCard.Child = branchStack;
|
||
contentStack.Children.Add(branchCard);
|
||
}
|
||
else
|
||
{
|
||
var mdPanel = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||
contentStack.Children.Add(mdPanel);
|
||
}
|
||
contentCard.Child = contentStack;
|
||
container.Children.Add(contentCard);
|
||
|
||
// 액션 버튼 바 (복사 / 좋아요 / 싫어요)
|
||
var actionBar = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Margin = new Thickness(2, 2, 0, 0),
|
||
Opacity = 0
|
||
};
|
||
|
||
var btnColor = secondaryText;
|
||
var capturedContent = content;
|
||
|
||
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
|
||
{
|
||
try { Clipboard.SetText(capturedContent); } catch { }
|
||
}));
|
||
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
|
||
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
|
||
AddLinkedFeedbackButtons(actionBar, btnColor, message);
|
||
|
||
// 타임스탬프
|
||
var aiTimestamp = message?.Timestamp ?? DateTime.Now;
|
||
actionBar.Children.Add(new TextBlock
|
||
{
|
||
Text = aiTimestamp.ToString("HH:mm"),
|
||
FontSize = 9, Opacity = 0.52,
|
||
Foreground = btnColor,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
});
|
||
|
||
container.Children.Add(actionBar);
|
||
container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
|
||
container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
|
||
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
|
||
|
||
// 우클릭 → 메시지 컨텍스트 메뉴
|
||
var aiContent = content;
|
||
container.MouseRightButtonUp += (_, re) =>
|
||
{
|
||
re.Handled = true;
|
||
ShowMessageContextMenu(aiContent, "assistant");
|
||
};
|
||
|
||
MessagePanel.Children.Add(container);
|
||
}
|
||
}
|
||
|
||
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
|
||
|
||
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
|
||
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
|
||
private string GetCheckStyle()
|
||
{
|
||
var theme = (_settings.Settings.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);
|
||
}
|
||
}
|
||
|
||
/// <summary>마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다.</summary>
|
||
/// <summary>
|
||
/// 마우스 오버 시 살짝 확대하는 호버 애니메이션.
|
||
/// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다.
|
||
/// 독립적 공간이 있는 버튼에만 적용하세요.
|
||
/// </summary>
|
||
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
|
||
{
|
||
// Loaded 이벤트에서 실행해야 XAML Style의 봉인된 Transform을 안전하게 교체 가능
|
||
void EnsureTransform()
|
||
{
|
||
element.RenderTransformOrigin = new Point(0.5, 0.5);
|
||
// 봉인(frozen)된 Transform이면 새로 생성하여 교체
|
||
if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen)
|
||
element.RenderTransform = new ScaleTransform(1, 1);
|
||
}
|
||
|
||
element.Loaded += (_, _) => EnsureTransform();
|
||
|
||
element.MouseEnter += (_, _) =>
|
||
{
|
||
EnsureTransform();
|
||
var st = (ScaleTransform)element.RenderTransform;
|
||
var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150))
|
||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
|
||
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
|
||
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
|
||
};
|
||
element.MouseLeave += (_, _) =>
|
||
{
|
||
EnsureTransform();
|
||
var st = (ScaleTransform)element.RenderTransform;
|
||
var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200))
|
||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
|
||
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
|
||
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
|
||
};
|
||
}
|
||
|
||
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다.</summary>
|
||
/// <summary>
|
||
/// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.
|
||
/// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다.
|
||
/// </summary>
|
||
private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5)
|
||
{
|
||
void EnsureTransform()
|
||
{
|
||
if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen)
|
||
element.RenderTransform = new TranslateTransform(0, 0);
|
||
}
|
||
|
||
element.Loaded += (_, _) => EnsureTransform();
|
||
|
||
element.MouseEnter += (_, _) =>
|
||
{
|
||
EnsureTransform();
|
||
var tt = (TranslateTransform)element.RenderTransform;
|
||
tt.BeginAnimation(TranslateTransform.YProperty,
|
||
new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200))
|
||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||
};
|
||
element.MouseLeave += (_, _) =>
|
||
{
|
||
EnsureTransform();
|
||
var tt = (TranslateTransform)element.RenderTransform;
|
||
tt.BeginAnimation(TranslateTransform.YProperty,
|
||
new DoubleAnimation(0, TimeSpan.FromMilliseconds(250))
|
||
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 10 } });
|
||
};
|
||
}
|
||
|
||
/// <summary>심플한 V 체크 아이콘을 생성합니다 (디자인 통일용).</summary>
|
||
private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14)
|
||
{
|
||
return new System.Windows.Shapes.Path
|
||
{
|
||
Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"),
|
||
Stroke = color,
|
||
StrokeThickness = 2,
|
||
StrokeStartLineCap = PenLineCap.Round,
|
||
StrokeEndLineCap = PenLineCap.Round,
|
||
StrokeLineJoin = PenLineJoin.Round,
|
||
Width = size,
|
||
Height = size,
|
||
Margin = new Thickness(0, 0, 10, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
}
|
||
|
||
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
|
||
private static void ApplyMenuItemHover(Border item)
|
||
{
|
||
var originalBg = item.Background?.Clone() ?? Brushes.Transparent;
|
||
if (originalBg.CanFreeze) originalBg.Freeze();
|
||
item.RenderTransformOrigin = new Point(0.5, 0.5);
|
||
item.RenderTransform = new ScaleTransform(1, 1);
|
||
item.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b)
|
||
{
|
||
// 원래 배경이 투명이면 반투명 흰색, 아니면 밝기 변경
|
||
if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20)
|
||
b.Opacity = 0.85;
|
||
else
|
||
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
}
|
||
var st = item.RenderTransform as ScaleTransform;
|
||
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
|
||
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
|
||
};
|
||
item.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b)
|
||
{
|
||
b.Opacity = 1.0;
|
||
b.Background = originalBg;
|
||
}
|
||
var st = item.RenderTransform as ScaleTransform;
|
||
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
|
||
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
|
||
};
|
||
}
|
||
|
||
private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick)
|
||
{
|
||
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var icon = new TextBlock
|
||
{
|
||
Text = symbol,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10.5,
|
||
Foreground = foreground,
|
||
VerticalAlignment = VerticalAlignment.Center
|
||
};
|
||
var label = new TextBlock
|
||
{
|
||
Text = tooltip,
|
||
FontSize = 10.5,
|
||
Foreground = foreground,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(4, 0, 0, 0),
|
||
};
|
||
var content = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Children = { icon, label }
|
||
};
|
||
var btn = new Button
|
||
{
|
||
Content = content,
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
ToolTip = tooltip
|
||
};
|
||
btn.Template = BuildMinimalIconButtonTemplate();
|
||
btn.MouseEnter += (_, _) =>
|
||
{
|
||
icon.Foreground = hoverBrush;
|
||
label.Foreground = hoverBrush;
|
||
btn.Background = hoverBg;
|
||
};
|
||
btn.MouseLeave += (_, _) =>
|
||
{
|
||
icon.Foreground = foreground;
|
||
label.Foreground = foreground;
|
||
btn.Background = Brushes.Transparent;
|
||
};
|
||
btn.Click += (_, _) => onClick();
|
||
ApplyHoverScaleAnimation(btn, 1.04);
|
||
return btn;
|
||
}
|
||
|
||
private void ShowMessageActionBar(StackPanel actionBar)
|
||
{
|
||
if (actionBar == null)
|
||
return;
|
||
|
||
actionBar.Opacity = 1;
|
||
}
|
||
|
||
private void HideMessageActionBarIfNotSelected(StackPanel actionBar)
|
||
{
|
||
if (actionBar == null)
|
||
return;
|
||
|
||
if (!ReferenceEquals(_selectedMessageActionBar, actionBar))
|
||
actionBar.Opacity = 0;
|
||
}
|
||
|
||
private void SelectMessageActionBar(StackPanel actionBar, Border? messageBorder = null)
|
||
{
|
||
if (_selectedMessageActionBar != null && !ReferenceEquals(_selectedMessageActionBar, actionBar))
|
||
_selectedMessageActionBar.Opacity = 0;
|
||
|
||
if (_selectedMessageBorder != null && !ReferenceEquals(_selectedMessageBorder, messageBorder))
|
||
ApplyMessageSelectionStyle(_selectedMessageBorder, false);
|
||
|
||
_selectedMessageActionBar = actionBar;
|
||
_selectedMessageActionBar.Opacity = 1;
|
||
_selectedMessageBorder = messageBorder;
|
||
if (_selectedMessageBorder != null)
|
||
ApplyMessageSelectionStyle(_selectedMessageBorder, true);
|
||
}
|
||
|
||
private void ApplyMessageSelectionStyle(Border border, bool selected)
|
||
{
|
||
if (border == null)
|
||
return;
|
||
|
||
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var defaultBorder = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
border.BorderBrush = selected ? accent : defaultBorder;
|
||
border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(1);
|
||
border.Effect = selected
|
||
? new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 16,
|
||
ShadowDepth = 0,
|
||
Opacity = 0.10,
|
||
Color = Colors.Black,
|
||
}
|
||
: null;
|
||
}
|
||
|
||
private static ControlTemplate BuildMinimalIconButtonTemplate()
|
||
{
|
||
var template = new ControlTemplate(typeof(Button));
|
||
var border = new FrameworkElementFactory(typeof(Border));
|
||
border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty));
|
||
border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty));
|
||
border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty));
|
||
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(8));
|
||
border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty));
|
||
var presenter = new FrameworkElementFactory(typeof(ContentPresenter));
|
||
presenter.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||
presenter.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||
border.AppendChild(presenter);
|
||
template.VisualTree = border;
|
||
return template;
|
||
}
|
||
|
||
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
|
||
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
|
||
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
|
||
Action? resetSibling = null, Action<Action>? registerReset = null)
|
||
{
|
||
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var isActive = message?.Feedback == feedbackType;
|
||
var icon = new TextBlock
|
||
{
|
||
Text = isActive ? filled : outline,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = isActive ? activeColor : normalColor,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||
RenderTransform = new ScaleTransform(1, 1)
|
||
};
|
||
var btn = new Button
|
||
{
|
||
Content = icon,
|
||
Background = Brushes.Transparent,
|
||
BorderThickness = new Thickness(0),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(6, 4, 6, 4),
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
ToolTip = tooltip
|
||
};
|
||
// 상대 버튼이 리셋할 수 있도록 등록
|
||
registerReset?.Invoke(() =>
|
||
{
|
||
isActive = false;
|
||
icon.Text = outline;
|
||
icon.Foreground = normalColor;
|
||
});
|
||
btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; };
|
||
btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; };
|
||
btn.Click += (_, _) =>
|
||
{
|
||
isActive = !isActive;
|
||
icon.Text = isActive ? filled : outline;
|
||
icon.Foreground = isActive ? activeColor : normalColor;
|
||
|
||
// 상호 배타: 활성화 시 반대쪽 리셋
|
||
if (isActive) resetSibling?.Invoke();
|
||
|
||
// 피드백 상태 저장
|
||
if (message != null)
|
||
{
|
||
try
|
||
{
|
||
var feedback = isActive ? feedbackType : null;
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
session.UpdateMessageFeedback(_activeTab, message, feedback, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
message.Feedback = feedback;
|
||
ChatConversation? conv;
|
||
lock (_convLock) conv = _currentConversation;
|
||
if (conv != null) _storage.Save(conv);
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
// 바운스 애니메이션
|
||
var scale = (ScaleTransform)icon.RenderTransform;
|
||
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
|
||
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } };
|
||
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
|
||
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
|
||
};
|
||
return btn;
|
||
}
|
||
|
||
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
|
||
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
|
||
{
|
||
// resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조
|
||
Action? resetLikeAction = null;
|
||
Action? resetDislikeAction = null;
|
||
|
||
var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor,
|
||
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like",
|
||
resetSibling: () => resetDislikeAction?.Invoke(),
|
||
registerReset: reset => resetLikeAction = reset);
|
||
var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor,
|
||
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike",
|
||
resetSibling: () => resetLikeAction?.Invoke(),
|
||
registerReset: reset => resetDislikeAction = reset);
|
||
|
||
actionBar.Children.Add(likeBtn);
|
||
actionBar.Children.Add(dislikeBtn);
|
||
}
|
||
|
||
// ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
|
||
|
||
private static void ApplyMessageEntryAnimation(FrameworkElement element)
|
||
{
|
||
element.Opacity = 0;
|
||
element.RenderTransform = new TranslateTransform(0, 16);
|
||
element.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350))
|
||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||
((TranslateTransform)element.RenderTransform).BeginAnimation(
|
||
TranslateTransform.YProperty,
|
||
new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400))
|
||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||
}
|
||
|
||
// ─── 메시지 편집 ──────────────────────────────────────────────────────
|
||
|
||
private bool _isEditing; // 편집 모드 중복 방지
|
||
|
||
private void EnterEditMode(StackPanel wrapper, string originalText)
|
||
{
|
||
if (_isStreaming || _isEditing) return;
|
||
_isEditing = true;
|
||
|
||
// wrapper 위치(인덱스) 기억
|
||
var idx = MessagePanel.Children.IndexOf(wrapper);
|
||
if (idx < 0) { _isEditing = false; return; }
|
||
|
||
// 편집 UI 생성
|
||
var editPanel = new StackPanel
|
||
{
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
MaxWidth = 540,
|
||
Margin = wrapper.Margin,
|
||
};
|
||
|
||
var editBox = new TextBox
|
||
{
|
||
Text = originalText,
|
||
FontSize = 13.5,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray,
|
||
CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||
BorderThickness = new Thickness(1.5),
|
||
Padding = new Thickness(14, 10, 14, 10),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
AcceptsReturn = false,
|
||
MaxHeight = 200,
|
||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||
};
|
||
// 둥근 모서리
|
||
var editBorder = new Border
|
||
{
|
||
CornerRadius = new CornerRadius(14),
|
||
Child = editBox,
|
||
ClipToBounds = true,
|
||
};
|
||
editPanel.Children.Add(editBorder);
|
||
|
||
// 버튼 바
|
||
var btnBar = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
};
|
||
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
|
||
// 취소 버튼
|
||
var cancelBtn = new Button
|
||
{
|
||
Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush },
|
||
Background = Brushes.Transparent,
|
||
BorderThickness = new Thickness(0),
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(12, 5, 12, 5),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
};
|
||
cancelBtn.Click += (_, _) =>
|
||
{
|
||
_isEditing = false;
|
||
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||
MessagePanel.Children[idx] = wrapper; // 원래 버블 복원
|
||
};
|
||
btnBar.Children.Add(cancelBtn);
|
||
|
||
// 전송 버튼
|
||
var sendBtn = new Button
|
||
{
|
||
Cursor = Cursors.Hand,
|
||
Padding = new Thickness(0),
|
||
};
|
||
sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(
|
||
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
|
||
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
|
||
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
|
||
"</Border></ControlTemplate>");
|
||
sendBtn.Click += (_, _) =>
|
||
{
|
||
var newText = editBox.Text.Trim();
|
||
if (!string.IsNullOrEmpty(newText))
|
||
_ = SubmitEditAsync(idx, newText);
|
||
};
|
||
btnBar.Children.Add(sendBtn);
|
||
|
||
editPanel.Children.Add(btnBar);
|
||
|
||
// 기존 wrapper → editPanel 교체
|
||
MessagePanel.Children[idx] = editPanel;
|
||
|
||
// Enter 키로도 전송
|
||
editBox.KeyDown += (_, ke) =>
|
||
{
|
||
if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None)
|
||
{
|
||
ke.Handled = true;
|
||
var newText = editBox.Text.Trim();
|
||
if (!string.IsNullOrEmpty(newText))
|
||
_ = SubmitEditAsync(idx, newText);
|
||
}
|
||
if (ke.Key == Key.Escape)
|
||
{
|
||
ke.Handled = true;
|
||
_isEditing = false;
|
||
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||
MessagePanel.Children[idx] = wrapper;
|
||
}
|
||
};
|
||
|
||
editBox.Focus();
|
||
editBox.SelectAll();
|
||
}
|
||
|
||
private async Task SubmitEditAsync(int bubbleIndex, string newText)
|
||
{
|
||
_isEditing = false;
|
||
if (_isStreaming) return;
|
||
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null) return;
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
// bubbleIndex에 해당하는 user 메시지 찾기
|
||
// UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨
|
||
// 데이터 모델에서 해당 위치의 user 메시지 찾기
|
||
int userMsgIdx = -1;
|
||
int uiIdx = 0;
|
||
lock (_convLock)
|
||
{
|
||
for (int i = 0; i < conv.Messages.Count; i++)
|
||
{
|
||
if (conv.Messages[i].Role == "system") continue;
|
||
if (uiIdx == bubbleIndex)
|
||
{
|
||
userMsgIdx = i;
|
||
break;
|
||
}
|
||
uiIdx++;
|
||
}
|
||
}
|
||
|
||
if (userMsgIdx < 0) return;
|
||
|
||
// 데이터 모델에서 편집된 메시지 이후 모두 제거
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages[userMsgIdx].Content = newText;
|
||
while (conv.Messages.Count > userMsgIdx + 1)
|
||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||
}
|
||
}
|
||
|
||
// UI에서 편집된 버블 이후 모두 제거
|
||
while (MessagePanel.Children.Count > bubbleIndex + 1)
|
||
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
|
||
|
||
// 편집된 메시지를 새 버블로 교체
|
||
MessagePanel.Children.RemoveAt(bubbleIndex);
|
||
AddMessageBubble("user", newText, animate: false);
|
||
|
||
// 마지막 위치에 삽입되도록 조정 (AddMessageBubble은 끝에 추가됨)
|
||
// bubbleIndex가 끝이 아니면 이동 — 이 경우 이후가 다 제거되었으므로 끝에 추가됨
|
||
|
||
// AI 재응답
|
||
await SendRegenerateAsync(conv);
|
||
try { if (ChatSession == null) _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||
RefreshConversationList();
|
||
}
|
||
|
||
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
|
||
|
||
private void StopAiIconPulse()
|
||
{
|
||
if (_aiIconPulseStopped || _activeAiIcon == null) return;
|
||
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
|
||
_activeAiIcon.Opacity = 1.0;
|
||
_activeAiIcon = null;
|
||
_aiIconPulseStopped = true;
|
||
}
|
||
|
||
private void CursorTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
_cursorVisible = !_cursorVisible;
|
||
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
|
||
if (_activeStreamText != null && _displayedLength > 0)
|
||
{
|
||
var displayed = _cachedStreamContent.Length > 0
|
||
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
|
||
: "";
|
||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||
}
|
||
}
|
||
|
||
private void ElapsedTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||
var sec = (int)elapsed.TotalSeconds;
|
||
if (_elapsedLabel != null)
|
||
_elapsedLabel.Text = $"{sec}s";
|
||
|
||
// 하단 상태바 시간 갱신
|
||
if (StatusElapsed != null)
|
||
StatusElapsed.Text = $"{sec}초";
|
||
}
|
||
|
||
private void TypingTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
|
||
|
||
var targetLen = _cachedStreamContent.Length;
|
||
if (_displayedLength >= targetLen) return;
|
||
|
||
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
|
||
var pending = targetLen - _displayedLength;
|
||
int step;
|
||
if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
|
||
else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
|
||
else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
|
||
|
||
_displayedLength += step;
|
||
|
||
var displayed = _cachedStreamContent[.._displayedLength];
|
||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||
|
||
// 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
|
||
if (!_userScrolled)
|
||
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
|
||
}
|
||
|
||
// ─── 전송 ──────────────────────────────────────────────────────────────
|
||
|
||
public void SendInitialMessage(string message)
|
||
{
|
||
StartNewConversation();
|
||
InputBox.Text = message;
|
||
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.LoadOrCreateConversation(_activeTab, _storage, _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();
|
||
EmptyState.Visibility = Visibility.Visible;
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
UpdateChatTitle();
|
||
RefreshConversationList();
|
||
UpdateFolderBar();
|
||
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();
|
||
RefreshContextUsageVisual();
|
||
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;
|
||
RefreshDraftQueueUi();
|
||
return;
|
||
}
|
||
}
|
||
SlashPopup.IsOpen = false;
|
||
RefreshDraftQueueUi();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
AddMessageBubble("user", commandText);
|
||
InputBox.Text = "";
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
ForceScrollToEnd();
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
var beforeTokens = Services.TokenEstimator.EstimateMessages(conv.Messages);
|
||
var working = conv.Messages.ToList();
|
||
var condensed = await ContextCondenser.CondenseIfNeededAsync(
|
||
working,
|
||
_llm,
|
||
llm.MaxContextTokens,
|
||
llm.EnableProactiveContextCompact,
|
||
llm.ContextCompactTriggerPercent,
|
||
true,
|
||
CancellationToken.None);
|
||
var afterTokens = Services.TokenEstimator.EstimateMessages(working);
|
||
RecordCompactionStats(beforeTokens, afterTokens, wasAutomatic: false);
|
||
|
||
if (condensed)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
conv.Messages = working;
|
||
}
|
||
}
|
||
|
||
var assistantText = condensed
|
||
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}"
|
||
: "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
|
||
|
||
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(runTab, assistantMsg);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(assistantMsg);
|
||
}
|
||
}
|
||
|
||
SaveLastConversations();
|
||
_storage.Save(conv);
|
||
AddMessageBubble("assistant", assistantText);
|
||
ForceScrollToEnd();
|
||
if (StatusTokens != null)
|
||
StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}";
|
||
SetStatus(condensed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", 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 };
|
||
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(runTab, userMsg, useForTitle: true);
|
||
session.AppendMessage(runTab, assistantMsg);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(userMsg);
|
||
conv.Messages.Add(assistantMsg);
|
||
}
|
||
}
|
||
|
||
SaveLastConversations();
|
||
_storage.Save(conv);
|
||
ChatSession?.RememberConversation(runTab, conv.Id);
|
||
UpdateChatTitle();
|
||
AddMessageBubble("user", commandText);
|
||
AddMessageBubble("assistant", assistantText);
|
||
InputBox.Text = "";
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
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()
|
||
{
|
||
var rawText = InputBox.Text.Trim();
|
||
|
||
// 슬래시 칩이 활성화된 경우 명령어 앞에 붙임
|
||
var text = _slashPalette.ActiveCommand != null
|
||
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
|
||
: rawText;
|
||
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;
|
||
try
|
||
{
|
||
_storage.Save(conv);
|
||
ChatSession?.RememberConversation(originTab, conv.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Debug($"대화 중간 저장 실패: {ex.Message}");
|
||
}
|
||
}
|
||
TryPersistConversation(force: true);
|
||
|
||
UpdateChatTitle();
|
||
AddMessageBubble("user", text);
|
||
InputBox.Text = "";
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
|
||
// 대화 통계 기록
|
||
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 assistantMsg = new ChatMessage { Role = "assistant", Content = "" };
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(runTab, assistantMsg);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(assistantMsg);
|
||
}
|
||
}
|
||
TryPersistConversation(force: true);
|
||
|
||
// 어시스턴트 스트리밍 컨테이너
|
||
var streamContainer = CreateStreamingContainer(out var streamText);
|
||
MessagePanel.Children.Add(streamContainer);
|
||
ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
_activeStreamText = streamText;
|
||
_cachedStreamContent = "";
|
||
_displayedLength = 0;
|
||
_cursorVisible = true;
|
||
_aiIconPulseStopped = false;
|
||
_cursorTimer.Start();
|
||
_typingTimer.Start();
|
||
_streamStartTime = DateTime.UtcNow;
|
||
_elapsedTimer.Start();
|
||
SetStatus("응답 생성 중...", spinning: true);
|
||
|
||
// ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ──
|
||
ModelRouteResult? routeResult = null;
|
||
|
||
try
|
||
{
|
||
List<ChatMessage> sendMessages;
|
||
lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList();
|
||
|
||
// 시스템 명령어가 있으면 삽입
|
||
if (!string.IsNullOrEmpty(conv.SystemCommand))
|
||
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand });
|
||
|
||
// 슬래시 명령어 시스템 프롬프트 삽입
|
||
if (!string.IsNullOrEmpty(slashSystem))
|
||
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = slashSystem });
|
||
|
||
// 첨부 파일 컨텍스트 삽입
|
||
if (_attachedFiles.Count > 0)
|
||
{
|
||
var fileContext = BuildFileContextPrompt();
|
||
if (!string.IsNullOrEmpty(fileContext))
|
||
{
|
||
var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user");
|
||
if (lastUserIdx >= 0)
|
||
sendMessages[lastUserIdx] = new ChatMessage { Role = "user", Content = sendMessages[lastUserIdx].Content + fileContext };
|
||
}
|
||
// 첨부 파일 목록 기록 후 항상 정리 (파일 읽기 실패해도)
|
||
userMsg.AttachedFiles = _attachedFiles.ToList();
|
||
_attachedFiles.Clear();
|
||
RefreshAttachedFilesUI();
|
||
}
|
||
|
||
// ── 이미지 첨부 ──
|
||
if (_pendingImages.Count > 0)
|
||
{
|
||
userMsg.Images = _pendingImages.ToList();
|
||
// 마지막 사용자 메시지에 이미지 데이터 연결
|
||
var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user");
|
||
if (lastUserIdx >= 0)
|
||
sendMessages[lastUserIdx] = new ChatMessage
|
||
{
|
||
Role = "user",
|
||
Content = sendMessages[lastUserIdx].Content,
|
||
Images = _pendingImages.ToList(),
|
||
};
|
||
_pendingImages.Clear();
|
||
AttachedFilesPanel.Items.Clear();
|
||
if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
// ── 전송 전 컨텍스트 사전 압축 ──
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var beforeCompactTokens = Services.TokenEstimator.EstimateMessages(sendMessages);
|
||
var condensed = await ContextCondenser.CondenseIfNeededAsync(
|
||
sendMessages,
|
||
_llm,
|
||
llm.MaxContextTokens,
|
||
llm.EnableProactiveContextCompact,
|
||
llm.ContextCompactTriggerPercent,
|
||
false,
|
||
_streamCts!.Token);
|
||
if (condensed)
|
||
{
|
||
var afterCompactTokens = Services.TokenEstimator.EstimateMessages(sendMessages);
|
||
RecordCompactionStats(beforeCompactTokens, afterCompactTokens, wasAutomatic: true);
|
||
SetStatus("컨텍스트를 사전 정리했습니다", 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);
|
||
}
|
||
}
|
||
|
||
if (runTab == "Cowork")
|
||
{
|
||
// 워크플로우 분석기 자동 열기
|
||
OpenWorkflowAnalyzerIfEnabled();
|
||
|
||
// 누적 토큰 초기화
|
||
_agentCumulativeInputTokens = 0;
|
||
_agentCumulativeOutputTokens = 0;
|
||
|
||
// 코워크 탭: 에이전트 루프 사용
|
||
_agentLoop.EventOccurred += OnAgentEvent;
|
||
// 사용자 의사결정 콜백 — PlanViewerWindow로 계획 표시
|
||
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
|
||
try
|
||
{
|
||
// 코워크 시스템 프롬프트 삽입
|
||
var coworkSystem = BuildCoworkSystemPrompt();
|
||
if (!string.IsNullOrEmpty(coworkSystem))
|
||
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = coworkSystem });
|
||
|
||
_agentLoop.ActiveTab = runTab;
|
||
var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token);
|
||
sb.Append(response);
|
||
assistantMsg.Content = response;
|
||
StopAiIconPulse();
|
||
_cachedStreamContent = response;
|
||
TryPersistConversation(force: true);
|
||
|
||
// 완료 알림
|
||
if (_settings.Settings.Llm.NotifyOnComplete)
|
||
Services.NotificationService.Notify("AX Cowork Agent", "코워크 작업이 완료되었습니다.");
|
||
}
|
||
finally
|
||
{
|
||
_agentLoop.EventOccurred -= OnAgentEvent;
|
||
_agentLoop.UserDecisionCallback = null;
|
||
}
|
||
}
|
||
else if (runTab == "Code")
|
||
{
|
||
// 워크플로우 분석기 자동 열기
|
||
OpenWorkflowAnalyzerIfEnabled();
|
||
|
||
// 누적 토큰 초기화
|
||
_agentCumulativeInputTokens = 0;
|
||
_agentCumulativeOutputTokens = 0;
|
||
|
||
// Code 탭: 에이전트 루프 사용 (Cowork과 동일 패턴)
|
||
_agentLoop.EventOccurred += OnAgentEvent;
|
||
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
|
||
try
|
||
{
|
||
var codeSystem = BuildCodeSystemPrompt();
|
||
if (!string.IsNullOrEmpty(codeSystem))
|
||
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = codeSystem });
|
||
|
||
_agentLoop.ActiveTab = runTab;
|
||
var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token);
|
||
sb.Append(response);
|
||
assistantMsg.Content = response;
|
||
StopAiIconPulse();
|
||
_cachedStreamContent = response;
|
||
TryPersistConversation(force: true);
|
||
|
||
// 완료 알림
|
||
if (_settings.Settings.Llm.NotifyOnComplete)
|
||
Services.NotificationService.Notify("AX Code Agent", "코드 작업이 완료되었습니다.");
|
||
}
|
||
finally
|
||
{
|
||
_agentLoop.EventOccurred -= OnAgentEvent;
|
||
_agentLoop.UserDecisionCallback = null;
|
||
}
|
||
}
|
||
else if (_settings.Settings.Llm.Streaming)
|
||
{
|
||
await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token))
|
||
{
|
||
sb.Append(chunk);
|
||
StopAiIconPulse();
|
||
_cachedStreamContent = sb.ToString();
|
||
assistantMsg.Content = _cachedStreamContent;
|
||
TryPersistConversation();
|
||
// UI 스레드에 제어 양보 — DispatcherTimer가 화면 갱신할 수 있도록
|
||
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||
}
|
||
_cachedStreamContent = sb.ToString();
|
||
assistantMsg.Content = _cachedStreamContent;
|
||
|
||
// 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms)
|
||
var drainStart = DateTime.UtcNow;
|
||
while (_displayedLength < _cachedStreamContent.Length
|
||
&& (DateTime.UtcNow - drainStart).TotalMilliseconds < 600)
|
||
{
|
||
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var response = await _llm.SendAsync(sendMessages, _streamCts.Token);
|
||
sb.Append(response);
|
||
assistantMsg.Content = response;
|
||
}
|
||
|
||
draftSucceeded = true;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
if (sb.Length == 0) sb.Append("(취소됨)");
|
||
assistantMsg.Content = sb.ToString();
|
||
draftCancelled = true;
|
||
draftFailure = "사용자가 작업을 중단했습니다.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errMsg = $"⚠ 오류: {ex.Message}";
|
||
sb.Clear(); sb.Append(errMsg);
|
||
assistantMsg.Content = errMsg;
|
||
AddRetryButton();
|
||
draftFailure = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
// 자동 라우팅 오버라이드 해제
|
||
if (routeResult != null)
|
||
{
|
||
_llm.ClearRouteOverride();
|
||
UpdateModelLabel();
|
||
}
|
||
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리
|
||
StopRainbowGlow(); // 레인보우 글로우 종료
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_cachedStreamContent = "";
|
||
_isStreaming = false;
|
||
BtnSend.IsEnabled = true;
|
||
BtnStop.Visibility = Visibility.Collapsed;
|
||
BtnSend.Visibility = Visibility.Visible;
|
||
_streamCts?.Dispose();
|
||
_streamCts = null;
|
||
_streamRunTab = null;
|
||
SetStatusIdle();
|
||
}
|
||
|
||
// 스트리밍 plaintext → 마크다운 렌더링으로 교체
|
||
FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg);
|
||
AutoScrollIfNeeded();
|
||
|
||
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||
ChatSession?.RememberConversation(originTab, conv.Id);
|
||
SyncTabConversationIdsFromSession();
|
||
RefreshConversationList();
|
||
if (!string.IsNullOrWhiteSpace(queuedDraftId))
|
||
{
|
||
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.IsNullOrWhiteSpace(queuedDraftId) && string.Equals(originTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background);
|
||
}
|
||
|
||
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
|
||
|
||
private string BuildCoworkSystemPrompt()
|
||
{
|
||
var workFolder = GetCurrentWorkFolder();
|
||
var llm = _settings.Settings.Llm;
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
|
||
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
|
||
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
|
||
sb.AppendLine("Always explain your plan step by step BEFORE executing tools. After creating files, summarize what was created.");
|
||
sb.AppendLine("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: When asked to create a document with multiple sections (reports, proposals, analyses, etc.), you MUST:");
|
||
sb.AppendLine(" 1. First, plan the document: decide the exact sections (headings), their order, and key points for each section based on the topic.");
|
||
sb.AppendLine(" 2. Call document_plan with sections_hint = your planned section titles (comma-separated). Example: sections_hint=\"회사 개요, 사업 현황, 재무 분석, SWOT, 전략 제언, 결론\"");
|
||
sb.AppendLine(" This ensures the document structure matches YOUR plan, not a generic template.");
|
||
sb.AppendLine(" 3. Then immediately call html_create (or docx_create/file_write) using the scaffold from document_plan.");
|
||
sb.AppendLine(" 4. Write actual detailed content for EVERY section — no skipping, no placeholders, no minimal content.");
|
||
sb.AppendLine(" 5. Do NOT call html_create directly without document_plan for multi-section documents.");
|
||
|
||
// 문서 품질 검증 루프
|
||
sb.AppendLine("\n## Document Quality Review");
|
||
sb.AppendLine("After creating any document (html_create, docx_create, excel_create, etc.), you MUST perform a self-review:");
|
||
sb.AppendLine("1. Use file_read to read the generated file and verify the content is complete");
|
||
sb.AppendLine("2. Check for logical errors: incorrect dates, inconsistent data, missing sections, broken formatting");
|
||
sb.AppendLine("3. Verify all requested topics/sections from the user's original request are covered");
|
||
sb.AppendLine("4. If issues found, fix them using file_write or file_edit, then re-verify");
|
||
sb.AppendLine("5. Report the review result to the user: what was checked and whether corrections were made");
|
||
|
||
// 문서 포맷 변환 지원
|
||
sb.AppendLine("\n## Format Conversion");
|
||
sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):");
|
||
sb.AppendLine("1. Use file_read or document_read to read the source file content");
|
||
sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)");
|
||
sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible");
|
||
|
||
// 사용자 지정 출력 포맷
|
||
var fmt = llm.DefaultOutputFormat;
|
||
if (!string.IsNullOrEmpty(fmt) && fmt != "auto")
|
||
{
|
||
var fmtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["xlsx"] = "Excel (.xlsx) using excel_create",
|
||
["docx"] = "Word (.docx) using docx_create",
|
||
["html"] = "HTML (.html) using html_create",
|
||
["md"] = "Markdown (.md) using markdown_create",
|
||
["csv"] = "CSV (.csv) using csv_create",
|
||
};
|
||
if (fmtMap.TryGetValue(fmt, out var fmtDesc))
|
||
sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise.");
|
||
}
|
||
|
||
// 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내
|
||
if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern")
|
||
sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template.");
|
||
else
|
||
sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.");
|
||
|
||
if (!string.IsNullOrEmpty(workFolder))
|
||
sb.AppendLine($"Current work folder: {workFolder}");
|
||
sb.AppendLine($"File permission mode: {llm.FilePermission}");
|
||
sb.Append(BuildSubAgentDelegationSection(false));
|
||
|
||
// 폴더 데이터 활용 지침
|
||
switch (_folderDataUsage)
|
||
{
|
||
case "active":
|
||
sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available.");
|
||
sb.AppendLine("Before creating reports, use folder_map to scan the work folder structure. " +
|
||
"Then EVALUATE whether each document is RELEVANT to the user's current request topic. " +
|
||
"Only use document_read on files that are clearly related to the conversation subject. " +
|
||
"Do NOT read or reference files that are unrelated to the user's request, even if they exist in the folder. " +
|
||
"In your planning step, list which files you plan to read and explain WHY they are relevant.");
|
||
break;
|
||
case "passive":
|
||
sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " +
|
||
"Only read folder documents when the user explicitly asks you to reference or use them.");
|
||
break;
|
||
default: // "none"
|
||
sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path.");
|
||
break;
|
||
}
|
||
|
||
// 프리셋 시스템 프롬프트가 있으면 추가
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand))
|
||
sb.AppendLine("\n" + _currentConversation.SystemCommand);
|
||
}
|
||
|
||
// 프로젝트 문맥 파일 (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: Present your analysis + impact assessment. List ALL files that will be modified.");
|
||
sb.AppendLine(" - Explain WHY each change is needed and what could break.");
|
||
sb.AppendLine(" - If you touch shared services, interfaces, models, controllers, view-models, app startup, or dependency registration, treat it as a high-impact change.");
|
||
sb.AppendLine(" - Wait for user approval before proceeding.");
|
||
sb.AppendLine("5. IMPLEMENT: Apply changes using file_edit (preferred — shows diff). Use file_write only for new files.");
|
||
sb.AppendLine(" - Make the MINIMUM changes needed. Don't refactor unrelated code.");
|
||
sb.AppendLine(" - Prefer file_edit with replace_all=false for precision edits.");
|
||
sb.AppendLine("6. VERIFY: Run build_run action='build' then action='test'. Compare results with baseline.");
|
||
sb.AppendLine(" - If tests fail that passed before, fix immediately.");
|
||
sb.AppendLine(" - If build fails, analyze error output and correct.");
|
||
sb.AppendLine(" - 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!;
|
||
|
||
// 에이전트 이벤트를 채팅 UI에 표시 (도구 호출/결과 배너)
|
||
if (string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||
AddAgentEventBanner(evt);
|
||
AppendConversationExecutionEvent(evt, eventTab);
|
||
AutoScrollIfNeeded();
|
||
|
||
// 하단 상태바 업데이트
|
||
UpdateStatusBar(evt);
|
||
_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);
|
||
}
|
||
|
||
// 스티키 진행률 바 업데이트
|
||
UpdateAgentProgressBar(evt);
|
||
|
||
// 계획 뷰어 단계 갱신
|
||
if (evt.StepCurrent > 0 && evt.StepTotal > 0)
|
||
UpdatePlanViewerStep(evt);
|
||
if (evt.Type == AgentEventType.Complete)
|
||
CompletePlanViewer();
|
||
|
||
// 파일 탐색기 자동 새로고침
|
||
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath))
|
||
RefreshFileTreeIfVisible();
|
||
|
||
// suggest_actions 도구 결과 → 후속 작업 칩 표시
|
||
if (evt.Type == AgentEventType.ToolResult && evt.ToolName == "suggest_actions" && evt.Success)
|
||
RenderSuggestActionChips(evt.Summary);
|
||
|
||
// 파일 생성/수정 결과가 있으면 미리보기 자동 표시 또는 갱신
|
||
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath) &&
|
||
(evt.Type == AgentEventType.ToolResult || evt.Type == AgentEventType.Complete) &&
|
||
WriteToolNames.Contains(evt.ToolName))
|
||
{
|
||
var autoPreview = _settings.Settings.Llm.AutoPreview;
|
||
if (autoPreview == "auto")
|
||
{
|
||
// 별도 창 미리보기: 이미 열린 파일이면 새로고침, 아니면 새 탭 추가
|
||
if (PreviewWindow.IsOpen)
|
||
PreviewWindow.RefreshIfOpen(evt.FilePath);
|
||
else
|
||
TryShowPreview(evt.FilePath);
|
||
|
||
// 새 파일이면 항상 표시
|
||
if (!PreviewWindow.IsOpen)
|
||
TryShowPreview(evt.FilePath);
|
||
}
|
||
}
|
||
|
||
UpdateTaskSummaryIndicators();
|
||
}
|
||
|
||
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
|
||
{
|
||
Dispatcher.Invoke(() =>
|
||
{
|
||
_appState.ApplySubAgentStatus(evt);
|
||
UpdateTaskSummaryIndicators();
|
||
});
|
||
}
|
||
|
||
private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
var normalizedTarget = NormalizeTabName(targetTab);
|
||
var normalizedActive = NormalizeTabName(_activeTab);
|
||
if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_currentConversation = session.AppendAgentRun(normalizedTarget, evt, status, summary, _storage);
|
||
return;
|
||
}
|
||
|
||
var activeSnapshot = _currentConversation;
|
||
var previousSessionConversation = session.CurrentConversation;
|
||
session.AppendAgentRun(normalizedTarget, evt, status, summary, _storage);
|
||
|
||
if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
session.CurrentConversation = activeSnapshot;
|
||
_currentConversation = activeSnapshot;
|
||
}
|
||
else if (previousSessionConversation != null
|
||
&& string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
session.CurrentConversation = previousSessionConversation;
|
||
_currentConversation = previousSessionConversation;
|
||
}
|
||
else
|
||
{
|
||
var activeId = session.GetConversationId(normalizedActive);
|
||
var activeConv = string.IsNullOrWhiteSpace(activeId) ? null : _storage.Load(activeId);
|
||
if (activeConv != null)
|
||
{
|
||
session.CurrentConversation = activeConv;
|
||
_currentConversation = activeConv;
|
||
}
|
||
else
|
||
{
|
||
var fallback = session.LoadOrCreateConversation(normalizedActive, _storage, _settings);
|
||
session.CurrentConversation = fallback;
|
||
_currentConversation = fallback;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab)
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session == null)
|
||
return;
|
||
|
||
var normalizedTarget = NormalizeTabName(targetTab);
|
||
var normalizedActive = NormalizeTabName(_activeTab);
|
||
if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_currentConversation = session.AppendExecutionEvent(normalizedTarget, evt, _storage);
|
||
return;
|
||
}
|
||
|
||
var activeSnapshot = _currentConversation;
|
||
var previousSessionConversation = session.CurrentConversation;
|
||
session.AppendExecutionEvent(normalizedTarget, evt, _storage);
|
||
|
||
if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
session.CurrentConversation = activeSnapshot;
|
||
_currentConversation = activeSnapshot;
|
||
}
|
||
else if (previousSessionConversation != null
|
||
&& string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
session.CurrentConversation = previousSessionConversation;
|
||
_currentConversation = previousSessionConversation;
|
||
}
|
||
else
|
||
{
|
||
var activeId = session.GetConversationId(normalizedActive);
|
||
var activeConv = string.IsNullOrWhiteSpace(activeId) ? null : _storage.Load(activeId);
|
||
if (activeConv != null)
|
||
{
|
||
session.CurrentConversation = activeConv;
|
||
_currentConversation = activeConv;
|
||
}
|
||
else
|
||
{
|
||
var fallback = session.LoadOrCreateConversation(normalizedActive, _storage, _settings);
|
||
session.CurrentConversation = fallback;
|
||
_currentConversation = fallback;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 void UpdateTaskSummaryIndicators()
|
||
{
|
||
var status = _appState.GetOperationalStatus(_activeTab);
|
||
|
||
if (RuntimeActivityBadge != null)
|
||
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
|
||
? Visibility.Visible
|
||
: Visibility.Collapsed;
|
||
|
||
if (RuntimeActivityLabel != null)
|
||
RuntimeActivityLabel.Text = status.RuntimeLabel;
|
||
|
||
if (LastCompletedLabel != null)
|
||
{
|
||
LastCompletedLabel.Text = status.LastCompletedText;
|
||
LastCompletedLabel.Visibility = status.ShowLastCompleted ? Visibility.Visible : Visibility.Collapsed;
|
||
}
|
||
|
||
if (ConversationStatusStrip != null && ConversationStatusStripLabel != null)
|
||
{
|
||
if (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Visible;
|
||
ConversationStatusStrip.Background = BrushFromHex("#FFF7ED");
|
||
ConversationStatusStrip.BorderBrush = BrushFromHex("#FDBA74");
|
||
ConversationStatusStripLabel.Foreground = BrushFromHex("#C2410C");
|
||
ConversationStatusStripLabel.Text = status.StripText;
|
||
}
|
||
else if (string.Equals(status.StripKind, "running", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Visible;
|
||
ConversationStatusStrip.Background = BrushFromHex("#DBEAFE");
|
||
ConversationStatusStrip.BorderBrush = BrushFromHex("#93C5FD");
|
||
ConversationStatusStripLabel.Foreground = BrushFromHex("#1D4ED8");
|
||
ConversationStatusStripLabel.Text = status.StripText;
|
||
}
|
||
else if (string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Visible;
|
||
ConversationStatusStrip.Background = BrushFromHex("#FEF2F2");
|
||
ConversationStatusStrip.BorderBrush = BrushFromHex("#FECACA");
|
||
ConversationStatusStripLabel.Foreground = BrushFromHex("#991B1B");
|
||
ConversationStatusStripLabel.Text = status.StripText;
|
||
}
|
||
else if (string.Equals(status.StripKind, "queue", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Visible;
|
||
ConversationStatusStrip.Background = BrushFromHex("#F5F3FF");
|
||
ConversationStatusStrip.BorderBrush = BrushFromHex("#C4B5FD");
|
||
ConversationStatusStripLabel.Foreground = BrushFromHex("#6D28D9");
|
||
ConversationStatusStripLabel.Text = status.StripText;
|
||
}
|
||
else if (string.Equals(status.StripKind, "queue_blocked", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Visible;
|
||
ConversationStatusStrip.Background = BrushFromHex("#FFFBEB");
|
||
ConversationStatusStrip.BorderBrush = BrushFromHex("#FCD34D");
|
||
ConversationStatusStripLabel.Foreground = BrushFromHex("#B45309");
|
||
ConversationStatusStripLabel.Text = status.StripText;
|
||
}
|
||
else if (string.Equals(status.StripKind, "background", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Visible;
|
||
ConversationStatusStrip.Background = BrushFromHex("#EFF6FF");
|
||
ConversationStatusStrip.BorderBrush = BrushFromHex("#BFDBFE");
|
||
ConversationStatusStripLabel.Foreground = BrushFromHex("#1D4ED8");
|
||
ConversationStatusStripLabel.Text = status.StripText;
|
||
}
|
||
else
|
||
{
|
||
ConversationStatusStrip.Visibility = Visibility.Collapsed;
|
||
ConversationStatusStripLabel.Text = "";
|
||
}
|
||
}
|
||
|
||
UpdateConversationQuickStripUi();
|
||
}
|
||
|
||
private void UpdateConversationQuickStripUi()
|
||
{
|
||
if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null
|
||
|| BtnQuickRunningFilter == null || BtnQuickHotSort == null)
|
||
return;
|
||
|
||
var hasQuickSignal = _runningConversationCount > 0
|
||
|| _spotlightConversationCount > 0;
|
||
|
||
ConversationQuickStrip.Visibility = hasQuickSignal
|
||
? Visibility.Visible
|
||
: Visibility.Collapsed;
|
||
|
||
QuickRunningLabel.Text = _runningConversationCount > 0 ? $"진행 {_runningConversationCount}" : "진행";
|
||
QuickHotLabel.Text = _spotlightConversationCount > 0 ? $"활동 {_spotlightConversationCount}" : "활동";
|
||
|
||
BtnQuickRunningFilter.Background = _runningOnlyFilter ? BrushFromHex("#DBEAFE") : BrushFromHex("#F8FAFC");
|
||
BtnQuickRunningFilter.BorderBrush = _runningOnlyFilter ? BrushFromHex("#93C5FD") : BrushFromHex("#E5E7EB");
|
||
BtnQuickRunningFilter.BorderThickness = new Thickness(1);
|
||
QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||
|
||
BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC");
|
||
BtnQuickHotSort.BorderBrush = !_sortConversationsByRecent ? BrushFromHex("#C4B5FD") : BrushFromHex("#E5E7EB");
|
||
BtnQuickHotSort.BorderThickness = new Thickness(1);
|
||
QuickHotLabel.Foreground = !_sortConversationsByRecent ? BrushFromHex("#6D28D9") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||
}
|
||
|
||
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 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;
|
||
|
||
/// <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 itemBg = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#FFFFFF");
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC");
|
||
|
||
var card = new Border
|
||
{
|
||
Background = itemBg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(18),
|
||
Padding = new Thickness(16, 14, 16, 14),
|
||
Margin = new Thickness(20, 6, 170, 10),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
MaxWidth = GetMessageMaxWidth(),
|
||
};
|
||
|
||
var sp = new StackPanel();
|
||
|
||
// 헤더
|
||
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||
header.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE9D5", // plan icon
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
header.Children.Add(new TextBlock
|
||
{
|
||
Text = $"{steps.Count}개의 작업 완료 중 0",
|
||
FontSize = 11.5, FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
sp.Children.Add(header);
|
||
|
||
// 진행률 바
|
||
var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 8) };
|
||
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
_planProgressBar = new ProgressBar
|
||
{
|
||
Minimum = 0,
|
||
Maximum = steps.Count,
|
||
Value = 0,
|
||
Height = 4,
|
||
Foreground = 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 = 10.5, FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(8, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(_planProgressText, 1);
|
||
progressGrid.Children.Add(_planProgressText);
|
||
sp.Children.Add(progressGrid);
|
||
|
||
// 단계 목록
|
||
_planStepsPanel = new StackPanel();
|
||
for (int i = 0; i < steps.Count; i++)
|
||
{
|
||
var stepRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 1, 0, 1),
|
||
Tag = i, // 인덱스 저장
|
||
};
|
||
|
||
stepRow.Children.Add(new TextBlock
|
||
{
|
||
Text = "○", // 빈 원 (미완료)
|
||
FontSize = 11,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
Tag = "status",
|
||
});
|
||
stepRow.Children.Add(new TextBlock
|
||
{
|
||
Text = $"{i + 1}. {steps[i]}",
|
||
FontSize = 11.5,
|
||
Foreground = primaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
MaxWidth = Math.Max(320, GetMessageMaxWidth() - 60),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
|
||
_planStepsPanel.Children.Add(stepRow);
|
||
}
|
||
sp.Children.Add(_planStepsPanel);
|
||
|
||
card.Child = sp;
|
||
_planningCard = card;
|
||
|
||
// 페이드인
|
||
card.Opacity = 0;
|
||
card.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
|
||
|
||
MessagePanel.Children.Add(card);
|
||
}
|
||
|
||
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
|
||
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> options)
|
||
{
|
||
var 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);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 실행 계획 뷰어 (PlanViewerWindow) 연동
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다.</summary>
|
||
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
|
||
{
|
||
return async (planSummary, options) =>
|
||
{
|
||
var tcs = new TaskCompletionSource<string?>();
|
||
var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary);
|
||
|
||
await Dispatcher.InvokeAsync(() =>
|
||
{
|
||
// PlanViewerWindow 생성 또는 재사용
|
||
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow))
|
||
{
|
||
_planViewerWindow = new PlanViewerWindow();
|
||
_planViewerWindow.Closing += (_, e) =>
|
||
{
|
||
e.Cancel = true;
|
||
_planViewerWindow.Hide();
|
||
};
|
||
}
|
||
|
||
// 계획 표시 + 승인 대기
|
||
_planViewerWindow.ShowPlanAsync(planSummary, steps, tcs);
|
||
|
||
// 채팅 창에 간략 배너 추가 + 인라인 승인 버튼도 표시
|
||
AddDecisionButtons(tcs, options);
|
||
|
||
// 하단 바 계획 버튼 표시
|
||
ShowPlanButton(true);
|
||
});
|
||
|
||
// 5분 타임아웃
|
||
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
|
||
if (completed != tcs.Task)
|
||
{
|
||
await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide());
|
||
return "취소";
|
||
}
|
||
|
||
var result = await tcs.Task;
|
||
var agentDecision = result;
|
||
if (result == null)
|
||
{
|
||
agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix);
|
||
}
|
||
else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.Equals(result, "승인", StringComparison.OrdinalIgnoreCase)
|
||
&& !string.IsNullOrWhiteSpace(result))
|
||
{
|
||
agentDecision = $"수정 요청: {result.Trim()}";
|
||
}
|
||
|
||
// 승인된 경우 — 실행 모드로 전환
|
||
if (result == null) // null = 승인
|
||
{
|
||
await Dispatcher.InvokeAsync(() =>
|
||
{
|
||
_planViewerWindow?.SwitchToExecutionMode();
|
||
_planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기
|
||
});
|
||
}
|
||
else
|
||
{
|
||
await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide());
|
||
}
|
||
|
||
return agentDecision;
|
||
};
|
||
}
|
||
|
||
/// <summary>하단 바에 계획 보기 버튼을 표시/숨김합니다.</summary>
|
||
private void ShowPlanButton(bool show)
|
||
{
|
||
if (!show)
|
||
{
|
||
// 계획 버튼 제거
|
||
for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--)
|
||
{
|
||
if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn")
|
||
{
|
||
// 앞의 구분선도 제거
|
||
if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep")
|
||
MoodIconPanel.Children.RemoveAt(i - 1);
|
||
if (i < MoodIconPanel.Children.Count)
|
||
MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1));
|
||
break;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 이미 있으면 무시
|
||
foreach (var child in MoodIconPanel.Children)
|
||
{
|
||
if (child is Border b && b.Tag?.ToString() == "PlanBtn") return;
|
||
}
|
||
|
||
// 구분선
|
||
var separator = new Border
|
||
{
|
||
Width = 1, Height = 18,
|
||
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
|
||
Margin = new Thickness(4, 0, 4, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Tag = "PlanSep",
|
||
};
|
||
MoodIconPanel.Children.Add(separator);
|
||
|
||
// 계획 버튼
|
||
var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981");
|
||
planBtn.Tag = "PlanBtn";
|
||
planBtn.MouseLeftButtonUp += (_, e) =>
|
||
{
|
||
e.Handled = true;
|
||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||
{
|
||
_planViewerWindow.Show();
|
||
_planViewerWindow.Activate();
|
||
}
|
||
};
|
||
MoodIconPanel.Children.Add(planBtn);
|
||
}
|
||
|
||
/// <summary>계획 뷰어에서 현재 실행 단계를 갱신합니다.</summary>
|
||
private void UpdatePlanViewerStep(AgentEvent evt)
|
||
{
|
||
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return;
|
||
if (evt.StepCurrent > 0)
|
||
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based
|
||
}
|
||
|
||
/// <summary>계획 실행 완료를 뷰어에 알립니다.</summary>
|
||
private void CompletePlanViewer()
|
||
{
|
||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||
_planViewerWindow.MarkComplete();
|
||
ShowPlanButton(false);
|
||
}
|
||
|
||
private static bool IsWindowAlive(Window? w)
|
||
{
|
||
if (w == null) return false;
|
||
try { var _ = w.IsVisible; return true; }
|
||
catch { return false; }
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 후속 작업 제안 칩 (suggest_actions)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
|
||
private void RenderSuggestActionChips(string jsonSummary)
|
||
{
|
||
// JSON에서 액션 목록 파싱 시도
|
||
List<(string label, string command)> actions = new();
|
||
try
|
||
{
|
||
// summary 형식: "label: command" 줄바꿈 구분 또는 JSON
|
||
if (jsonSummary.Contains("\"label\""))
|
||
{
|
||
using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary);
|
||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||
{
|
||
foreach (var item in doc.RootElement.EnumerateArray())
|
||
{
|
||
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
|
||
var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label;
|
||
if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd));
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 줄바꿈 형식: "1. label → command"
|
||
foreach (var line in jsonSummary.Split('\n'))
|
||
{
|
||
var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' ');
|
||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||
var parts = trimmed.Split('→', ':', '—');
|
||
if (parts.Length >= 2)
|
||
actions.Add((parts[0].Trim(), parts[1].Trim()));
|
||
else if (!string.IsNullOrEmpty(trimmed))
|
||
actions.Add((trimmed, trimmed));
|
||
}
|
||
}
|
||
}
|
||
catch { return; }
|
||
|
||
if (actions.Count == 0) return;
|
||
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
|
||
var container = new Border
|
||
{
|
||
Margin = new Thickness(40, 4, 40, 8),
|
||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||
};
|
||
|
||
var headerStack = new StackPanel { Margin = new Thickness(0, 0, 0, 6) };
|
||
headerStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "💡 다음 작업 제안:",
|
||
FontSize = 12,
|
||
Foreground = secondaryText,
|
||
});
|
||
|
||
var chipPanel = new WrapPanel { Margin = new Thickness(0, 2, 0, 0) };
|
||
|
||
foreach (var (label, command) in actions.Take(5))
|
||
{
|
||
var capturedCmd = command;
|
||
var chip = new Border
|
||
{
|
||
CornerRadius = new CornerRadius(16),
|
||
Padding = new Thickness(14, 7, 14, 7),
|
||
Margin = new Thickness(0, 0, 8, 6),
|
||
Cursor = Cursors.Hand,
|
||
Background = new SolidColorBrush(Color.FromArgb(0x15,
|
||
((SolidColorBrush)accentBrush).Color.R,
|
||
((SolidColorBrush)accentBrush).Color.G,
|
||
((SolidColorBrush)accentBrush).Color.B)),
|
||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40,
|
||
((SolidColorBrush)accentBrush).Color.R,
|
||
((SolidColorBrush)accentBrush).Color.G,
|
||
((SolidColorBrush)accentBrush).Color.B)),
|
||
BorderThickness = new Thickness(1),
|
||
};
|
||
chip.Child = new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 12.5,
|
||
Foreground = accentBrush,
|
||
FontWeight = FontWeights.SemiBold,
|
||
};
|
||
chip.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||
chip.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||
chip.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
// 칩 패널 제거 후 해당 명령 실행
|
||
MessagePanel.Children.Remove(container);
|
||
if (capturedCmd.StartsWith("/"))
|
||
{
|
||
InputBox.Text = capturedCmd + " ";
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
InputBox.Focus();
|
||
}
|
||
else
|
||
{
|
||
InputBox.Text = capturedCmd;
|
||
_ = SendMessageAsync();
|
||
}
|
||
};
|
||
chipPanel.Children.Add(chip);
|
||
}
|
||
|
||
var outerStack = new StackPanel();
|
||
outerStack.Children.Add(headerStack);
|
||
outerStack.Children.Add(chipPanel);
|
||
container.Child = outerStack;
|
||
|
||
ApplyMessageEntryAnimation(container);
|
||
MessagePanel.Children.Add(container);
|
||
ForceScrollToEnd();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 피드백 학습 반영 (J)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다.</summary>
|
||
private string BuildFeedbackContext()
|
||
{
|
||
try
|
||
{
|
||
var recentConversations = _storage.LoadAllMeta()
|
||
.OrderByDescending(m => m.UpdatedAt)
|
||
.Take(20)
|
||
.ToList();
|
||
|
||
var likedPatterns = new List<string>();
|
||
var dislikedPatterns = new List<string>();
|
||
|
||
foreach (var meta in recentConversations)
|
||
{
|
||
var conv = _storage.Load(meta.Id);
|
||
if (conv == null) continue;
|
||
|
||
foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null))
|
||
{
|
||
// 첫 50자로 패턴 파악
|
||
var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? "";
|
||
if (msg.Feedback == "like")
|
||
likedPatterns.Add(preview);
|
||
else if (msg.Feedback == "dislike")
|
||
dislikedPatterns.Add(preview);
|
||
}
|
||
}
|
||
|
||
if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0)
|
||
return "";
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("\n[사용자 선호도 참고]");
|
||
if (likedPatterns.Count > 0)
|
||
{
|
||
sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):");
|
||
foreach (var p in likedPatterns.Take(5))
|
||
sb.AppendLine($" - \"{p}...\"");
|
||
}
|
||
if (dislikedPatterns.Count > 0)
|
||
{
|
||
sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):");
|
||
foreach (var p in dislikedPatterns.Take(5))
|
||
sb.AppendLine($" - \"{p}...\"");
|
||
}
|
||
sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요.");
|
||
return sb.ToString();
|
||
}
|
||
catch { return ""; }
|
||
}
|
||
|
||
/// <summary>진행률 바와 단계 상태를 업데이트합니다.</summary>
|
||
private void UpdateProgressBar(AgentEvent evt)
|
||
{
|
||
if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null)
|
||
return;
|
||
|
||
var stepIdx = evt.StepCurrent - 1; // 0-based
|
||
var total = evt.StepTotal;
|
||
|
||
// 진행률 바 업데이트
|
||
_planProgressBar.Value = evt.StepCurrent;
|
||
var pct = (int)((double)evt.StepCurrent / total * 100);
|
||
_planProgressText.Text = $"{pct}%";
|
||
|
||
// 이전 단계 완료 표시 + 현재 단계 강조
|
||
for (int i = 0; i < _planStepsPanel.Children.Count; i++)
|
||
{
|
||
if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2)
|
||
{
|
||
var statusTb = row.Children[0] as TextBlock;
|
||
var textTb = row.Children[1] as TextBlock;
|
||
if (statusTb == null || textTb == null) continue;
|
||
|
||
if (i < stepIdx)
|
||
{
|
||
// 완료
|
||
statusTb.Text = "●";
|
||
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A"));
|
||
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280"));
|
||
}
|
||
else if (i == stepIdx)
|
||
{
|
||
// 현재 진행 중
|
||
statusTb.Text = "◉";
|
||
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC"));
|
||
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B"));
|
||
textTb.FontWeight = FontWeights.SemiBold;
|
||
}
|
||
else
|
||
{
|
||
// 대기
|
||
statusTb.Text = "○";
|
||
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF"));
|
||
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563"));
|
||
textTb.FontWeight = FontWeights.Normal;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다.</summary>
|
||
private static UIElement BuildDiffView(string text)
|
||
{
|
||
var panel = new StackPanel
|
||
{
|
||
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")),
|
||
MaxWidth = 520,
|
||
};
|
||
|
||
var diffStarted = false;
|
||
foreach (var rawLine in text.Split('\n'))
|
||
{
|
||
var line = rawLine.TrimEnd('\r');
|
||
|
||
// diff 헤더 전의 일반 텍스트
|
||
if (!diffStarted && !line.StartsWith("--- "))
|
||
{
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = line,
|
||
FontSize = 11,
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
|
||
FontFamily = new FontFamily("Consolas"),
|
||
Margin = new Thickness(0, 0, 0, 1),
|
||
});
|
||
continue;
|
||
}
|
||
diffStarted = true;
|
||
|
||
string bgHex, fgHex;
|
||
if (line.StartsWith("---") || line.StartsWith("+++"))
|
||
{
|
||
bgHex = "#F3F4F6"; fgHex = "#374151";
|
||
}
|
||
else if (line.StartsWith("@@"))
|
||
{
|
||
bgHex = "#EFF6FF"; fgHex = "#3B82F6";
|
||
}
|
||
else if (line.StartsWith("+"))
|
||
{
|
||
bgHex = "#ECFDF5"; fgHex = "#059669";
|
||
}
|
||
else if (line.StartsWith("-"))
|
||
{
|
||
bgHex = "#FEF2F2"; fgHex = "#DC2626";
|
||
}
|
||
else
|
||
{
|
||
bgHex = "Transparent"; fgHex = "#6B7280";
|
||
}
|
||
|
||
var tb = new TextBlock
|
||
{
|
||
Text = line,
|
||
FontSize = 10.5,
|
||
FontFamily = new FontFamily("Consolas"),
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
|
||
Padding = new Thickness(4, 1, 4, 1),
|
||
};
|
||
if (bgHex != "Transparent")
|
||
tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex));
|
||
|
||
panel.Children.Add(tb);
|
||
}
|
||
|
||
return panel;
|
||
}
|
||
|
||
private sealed class ReviewSignalSummary
|
||
{
|
||
public int P0 { get; init; }
|
||
public int P1 { get; init; }
|
||
public int P2 { get; init; }
|
||
public int P3 { get; init; }
|
||
public bool HasFixed { get; init; }
|
||
public bool HasUnfixed { get; init; }
|
||
public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed;
|
||
}
|
||
|
||
private static ReviewSignalSummary ExtractReviewSignals(string? text)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return new ReviewSignalSummary();
|
||
|
||
var source = text!;
|
||
var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈");
|
||
var hasFixed = ContainsWholeWord(source, "fixed")
|
||
|| ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료");
|
||
|
||
return new ReviewSignalSummary
|
||
{
|
||
P0 = CountToken(source, "P0"),
|
||
P1 = CountToken(source, "P1"),
|
||
P2 = CountToken(source, "P2"),
|
||
P3 = CountToken(source, "P3"),
|
||
HasUnfixed = hasUnfixed,
|
||
HasFixed = hasFixed,
|
||
};
|
||
}
|
||
|
||
private static int CountToken(string source, string token)
|
||
{
|
||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token))
|
||
return 0;
|
||
|
||
var count = 0;
|
||
var index = 0;
|
||
while (index < source.Length)
|
||
{
|
||
var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase);
|
||
if (hit < 0)
|
||
break;
|
||
|
||
var before = hit == 0 ? ' ' : source[hit - 1];
|
||
var afterIndex = hit + token.Length;
|
||
var after = afterIndex >= source.Length ? ' ' : source[afterIndex];
|
||
if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after))
|
||
count++;
|
||
|
||
index = hit + token.Length;
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
private static bool ContainsAny(string source, params string[] needles)
|
||
{
|
||
foreach (var needle in needles)
|
||
{
|
||
if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static bool ContainsWholeWord(string source, string token)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token))
|
||
return false;
|
||
|
||
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b";
|
||
return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
}
|
||
|
||
private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(toolName) &&
|
||
(toolName.Contains("review", StringComparison.OrdinalIgnoreCase) ||
|
||
toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase)))
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(title) &&
|
||
title.Contains("review", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(summary) &&
|
||
(summary.Contains("P0", StringComparison.OrdinalIgnoreCase) ||
|
||
summary.Contains("P1", StringComparison.OrdinalIgnoreCase) ||
|
||
summary.Contains("P2", StringComparison.OrdinalIgnoreCase) ||
|
||
summary.Contains("P3", StringComparison.OrdinalIgnoreCase)))
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex)
|
||
{
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex(bgHex),
|
||
BorderBrush = BrushFromHex(borderHex),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
Padding = new Thickness(8, 2, 8, 2),
|
||
Child = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex(fgHex),
|
||
}
|
||
};
|
||
}
|
||
|
||
private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary)
|
||
{
|
||
if (!IsReviewContext(kind, toolName, title, summary))
|
||
return null;
|
||
|
||
var signals = ExtractReviewSignals(summary);
|
||
if (!signals.HasAny)
|
||
return null;
|
||
|
||
var row = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
|
||
if (signals.P0 > 0)
|
||
row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5"));
|
||
if (signals.P1 > 0)
|
||
row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74"));
|
||
if (signals.P2 > 0)
|
||
row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A"));
|
||
if (signals.P3 > 0)
|
||
row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD"));
|
||
|
||
if (signals.HasFixed)
|
||
row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC"));
|
||
if (signals.HasUnfixed)
|
||
row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5"));
|
||
|
||
return row.Children.Count == 0 ? null : row;
|
||
}
|
||
|
||
private void AddAgentEventBanner(AgentEvent evt)
|
||
{
|
||
var logLevel = _settings.Settings.Llm.AgentLogLevel;
|
||
|
||
// Planning 이벤트는 단계 목록 카드로 별도 렌더링
|
||
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
|
||
{
|
||
AddPlanningCard(evt);
|
||
return;
|
||
}
|
||
|
||
// StepStart 이벤트는 진행률 바 업데이트
|
||
if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0)
|
||
{
|
||
UpdateProgressBar(evt);
|
||
return;
|
||
}
|
||
|
||
// simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시)
|
||
if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall)
|
||
return;
|
||
|
||
// 전체 통계 이벤트는 별도 색상 (보라색 계열)
|
||
var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats";
|
||
|
||
var (icon, label, bgHex, fgHex) = isTotalStats
|
||
? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED")
|
||
: evt.Type switch
|
||
{
|
||
AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"),
|
||
AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true),
|
||
AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false),
|
||
AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"),
|
||
AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary),
|
||
AgentEventType.ToolCall => ("\uE8A7", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 실행" : evt.ToolName, "#EEF6FF", "#3B82F6"),
|
||
AgentEventType.ToolResult => ("\uE73E", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 완료" : evt.ToolName, "#EEF9EE", "#16A34A"),
|
||
AgentEventType.SkillCall => ("\uE8A5", string.IsNullOrWhiteSpace(evt.ToolName) ? "스킬 실행" : evt.ToolName, "#FFF7ED", "#EA580C"),
|
||
AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"),
|
||
AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"),
|
||
AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"),
|
||
AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"),
|
||
AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"),
|
||
_ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"),
|
||
};
|
||
|
||
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 borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
||
var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex));
|
||
|
||
var banner = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = Brushes.Transparent,
|
||
BorderThickness = new Thickness(0),
|
||
CornerRadius = new CornerRadius(0),
|
||
Padding = new Thickness(0),
|
||
Margin = new Thickness(24, 2, 24, 2),
|
||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(evt.RunId))
|
||
_runBannerAnchors[evt.RunId] = banner;
|
||
|
||
var sp = new StackPanel();
|
||
|
||
// 헤더: 얇은 실행 줄 형태
|
||
var headerGrid = new Grid();
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
// 좌측: 아이콘 + 라벨
|
||
var headerLeft = new StackPanel { Orientation = Orientation.Horizontal };
|
||
headerLeft.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10.5,
|
||
Foreground = accentBrush,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
headerLeft.Children.Add(new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
Grid.SetColumn(headerLeft, 0);
|
||
|
||
// 우측: 소요 시간 + 토큰 배지 (항상 우측 끝에 고정)
|
||
var headerRight = new StackPanel { Orientation = Orientation.Horizontal };
|
||
if (logLevel != "simple" && evt.ElapsedMs > 0)
|
||
{
|
||
headerRight.Children.Add(new TextBlock
|
||
{
|
||
Text = evt.ElapsedMs < 1000 ? $"{evt.ElapsedMs}ms" : $"{evt.ElapsedMs / 1000.0:F1}s",
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(8, 0, 0, 0),
|
||
});
|
||
}
|
||
if (logLevel != "simple" && (evt.InputTokens > 0 || evt.OutputTokens > 0))
|
||
{
|
||
var tokenText = evt.InputTokens > 0 && evt.OutputTokens > 0
|
||
? $"{evt.InputTokens}→{evt.OutputTokens}t"
|
||
: evt.InputTokens > 0 ? $"↑{evt.InputTokens}t" : $"↓{evt.OutputTokens}t";
|
||
headerRight.Children.Add(new Border
|
||
{
|
||
Background = hintBg,
|
||
BorderBrush = borderColor,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Padding = new Thickness(5, 1, 5, 1),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Child = new TextBlock
|
||
{
|
||
Text = tokenText,
|
||
FontSize = 9.5,
|
||
Foreground = secondaryText,
|
||
FontFamily = new FontFamily("Consolas"),
|
||
},
|
||
});
|
||
}
|
||
Grid.SetColumn(headerRight, 1);
|
||
|
||
headerGrid.Children.Add(headerLeft);
|
||
headerGrid.Children.Add(headerRight);
|
||
|
||
// header 변수를 headerLeft로 설정 (이후 expandIcon 추가 시 사용)
|
||
var header = headerLeft;
|
||
|
||
sp.Children.Add(headerGrid);
|
||
|
||
// simple 모드: 요약 한 줄만 표시 (본문 로그)
|
||
if (logLevel == "simple")
|
||
{
|
||
if (!string.IsNullOrEmpty(evt.Summary))
|
||
{
|
||
var shortSummary = evt.Summary.Length > 100
|
||
? evt.Summary[..100] + "…"
|
||
: evt.Summary;
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = shortSummary,
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
TextWrapping = TextWrapping.NoWrap,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
Margin = new Thickness(16, 2, 0, 0),
|
||
});
|
||
}
|
||
}
|
||
// detailed/debug 모드: 실행 줄 아래에 얕은 설명만 표시
|
||
else if (!string.IsNullOrEmpty(evt.Summary))
|
||
{
|
||
var summaryText = evt.Summary.Length > 180 ? evt.Summary[..180] + "…" : evt.Summary;
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = summaryText,
|
||
FontSize = 10.5,
|
||
Foreground = primaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Margin = new Thickness(16, 2, 0, 0),
|
||
});
|
||
}
|
||
|
||
var reviewChipRow = BuildReviewSignalChipRow(
|
||
kind: null,
|
||
toolName: evt.ToolName,
|
||
title: label,
|
||
summary: evt.Summary);
|
||
if (reviewChipRow != null)
|
||
{
|
||
reviewChipRow.Margin = new Thickness(16, 4, 0, 0);
|
||
sp.Children.Add(reviewChipRow);
|
||
}
|
||
|
||
// debug 모드: ToolInput 파라미터 표시
|
||
if (logLevel == "debug" && !string.IsNullOrEmpty(evt.ToolInput))
|
||
{
|
||
sp.Children.Add(new Border
|
||
{
|
||
Background = hintBg,
|
||
BorderBrush = borderColor,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 4, 8, 4),
|
||
Margin = new Thickness(16, 4, 0, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = evt.ToolInput.Length > 500 ? evt.ToolInput[..500] + "…" : evt.ToolInput,
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
FontFamily = new FontFamily("Consolas"),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
},
|
||
});
|
||
}
|
||
|
||
// 파일 경로 배너 (AX Agent 스타일)
|
||
if (!string.IsNullOrEmpty(evt.FilePath))
|
||
{
|
||
var pathBorder = new Border
|
||
{
|
||
Background = hintBg,
|
||
BorderBrush = borderColor,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 5, 8, 5),
|
||
Margin = new Thickness(16, 4, 0, 0),
|
||
};
|
||
|
||
var pathGrid = new Grid();
|
||
pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
|
||
var left = new StackPanel { Orientation = Orientation.Vertical };
|
||
var fileName = System.IO.Path.GetFileName(evt.FilePath);
|
||
var dirName = System.IO.Path.GetDirectoryName(evt.FilePath) ?? "";
|
||
|
||
var topRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||
topRow.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE8B7", // folder icon
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10,
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 4, 0),
|
||
});
|
||
topRow.Children.Add(new TextBlock
|
||
{
|
||
Text = string.IsNullOrWhiteSpace(fileName) ? evt.FilePath : fileName,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#374151")),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
});
|
||
left.Children.Add(topRow);
|
||
if (!string.IsNullOrWhiteSpace(dirName))
|
||
{
|
||
left.Children.Add(new TextBlock
|
||
{
|
||
Text = dirName,
|
||
FontSize = 9.5,
|
||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
|
||
FontFamily = new FontFamily("Consolas"),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
});
|
||
}
|
||
Grid.SetColumn(left, 0);
|
||
pathGrid.Children.Add(left);
|
||
|
||
// 빠른 작업 버튼들
|
||
var quickActions = BuildFileQuickActions(evt.FilePath);
|
||
Grid.SetColumn(quickActions, 1);
|
||
pathGrid.Children.Add(quickActions);
|
||
|
||
pathBorder.Child = pathGrid;
|
||
sp.Children.Add(pathBorder);
|
||
}
|
||
|
||
banner.Child = sp;
|
||
|
||
// Total Stats 배너 클릭 → 워크플로우 분석기 병목 분석 탭 열기
|
||
if (isTotalStats)
|
||
{
|
||
banner.Cursor = Cursors.Hand;
|
||
banner.ToolTip = "클릭하여 병목 분석 보기";
|
||
banner.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
OpenWorkflowAnalyzerIfEnabled();
|
||
_analyzerWindow?.SwitchToBottleneckTab();
|
||
_analyzerWindow?.Activate();
|
||
};
|
||
}
|
||
|
||
// 페이드인 애니메이션
|
||
banner.Opacity = 0;
|
||
banner.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
|
||
|
||
MessagePanel.Children.Add(banner);
|
||
}
|
||
|
||
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
|
||
private StackPanel BuildFileQuickActions(string filePath)
|
||
{
|
||
var panel = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(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);
|
||
}
|
||
}
|
||
|
||
// UI에서 마지막 AI 응답 제거
|
||
if (MessagePanel.Children.Count > 0)
|
||
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
|
||
|
||
// 재전송
|
||
await SendRegenerateAsync(conv);
|
||
}
|
||
|
||
/// <summary>"수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.</summary>
|
||
private void ShowRetryWithFeedbackInput()
|
||
{
|
||
if (_isStreaming) return;
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var itemBg = TryFindResource("ItemBackground") as Brush
|
||
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
var container = new Border
|
||
{
|
||
Margin = new Thickness(40, 4, 40, 8),
|
||
Padding = new Thickness(14, 10, 14, 10),
|
||
CornerRadius = new CornerRadius(12),
|
||
Background = itemBg,
|
||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = "어떻게 수정하면 좋을지 알려주세요:",
|
||
FontSize = 12,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 0, 0, 6),
|
||
});
|
||
|
||
var textBox = new TextBox
|
||
{
|
||
MinHeight = 38,
|
||
MaxHeight = 80,
|
||
AcceptsReturn = true,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
FontSize = 13,
|
||
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
|
||
Foreground = primaryText,
|
||
CaretBrush = primaryText,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(10, 6, 10, 6),
|
||
};
|
||
stack.Children.Add(textBox);
|
||
|
||
var btnRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
|
||
var sendBtn = new Border
|
||
{
|
||
Background = accentBrush,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(14, 6, 14, 6),
|
||
Cursor = Cursors.Hand,
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
};
|
||
sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
|
||
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
||
sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||
sendBtn.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
var feedback = textBox.Text.Trim();
|
||
if (string.IsNullOrEmpty(feedback)) return;
|
||
MessagePanel.Children.Remove(container);
|
||
_ = RetryWithFeedbackAsync(feedback);
|
||
};
|
||
|
||
var cancelBtn = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(12, 6, 12, 6),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText };
|
||
cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container);
|
||
|
||
btnRow.Children.Add(cancelBtn);
|
||
btnRow.Children.Add(sendBtn);
|
||
stack.Children.Add(btnRow);
|
||
container.Child = stack;
|
||
|
||
ApplyMessageEntryAnimation(container);
|
||
MessagePanel.Children.Add(container);
|
||
ForceScrollToEnd();
|
||
textBox.Focus();
|
||
}
|
||
|
||
/// <summary>사용자 피드백과 함께 마지막 응답을 재생성합니다.</summary>
|
||
private async Task RetryWithFeedbackAsync(string feedback)
|
||
{
|
||
if (_isStreaming) return;
|
||
ChatConversation conv;
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation == null) return;
|
||
conv = _currentConversation;
|
||
}
|
||
|
||
// 마지막 assistant 메시지 제거
|
||
lock (_convLock)
|
||
{
|
||
if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
|
||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||
}
|
||
|
||
// UI에서 마지막 AI 응답 제거
|
||
if (MessagePanel.Children.Count > 0)
|
||
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
|
||
|
||
// 피드백을 사용자 메시지로 추가
|
||
var feedbackMsg = new ChatMessage
|
||
{
|
||
Role = "user",
|
||
Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요."
|
||
};
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(_activeTab, feedbackMsg, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(feedbackMsg);
|
||
}
|
||
}
|
||
|
||
// 피드백 메시지 UI 표시
|
||
AddMessageBubble("user", $"[수정 요청] {feedback}", true);
|
||
|
||
// 재전송
|
||
await SendRegenerateAsync(conv);
|
||
}
|
||
|
||
private async Task SendRegenerateAsync(ChatConversation conv)
|
||
{
|
||
_isStreaming = true;
|
||
BtnSend.IsEnabled = false;
|
||
BtnSend.Visibility = Visibility.Collapsed;
|
||
BtnStop.Visibility = Visibility.Visible;
|
||
_streamCts = new CancellationTokenSource();
|
||
|
||
var assistantMsg = new ChatMessage { Role = "assistant", Content = "" };
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.AppendMessage(_activeTab, assistantMsg, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
conv = _currentConversation!;
|
||
}
|
||
else
|
||
{
|
||
conv.Messages.Add(assistantMsg);
|
||
}
|
||
}
|
||
|
||
var streamContainer = CreateStreamingContainer(out var streamText);
|
||
MessagePanel.Children.Add(streamContainer);
|
||
ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
_activeStreamText = streamText;
|
||
_cachedStreamContent = "";
|
||
_displayedLength = 0;
|
||
_cursorVisible = true;
|
||
_aiIconPulseStopped = false;
|
||
_cursorTimer.Start();
|
||
_typingTimer.Start();
|
||
_streamStartTime = DateTime.UtcNow;
|
||
_elapsedTimer.Start();
|
||
SetStatus("에이전트 작업 중...", spinning: true);
|
||
|
||
try
|
||
{
|
||
List<ChatMessage> sendMessages;
|
||
lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList();
|
||
if (!string.IsNullOrEmpty(conv.SystemCommand))
|
||
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand });
|
||
|
||
await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token))
|
||
{
|
||
sb.Append(chunk);
|
||
StopAiIconPulse();
|
||
_cachedStreamContent = sb.ToString();
|
||
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||
}
|
||
_cachedStreamContent = sb.ToString();
|
||
assistantMsg.Content = _cachedStreamContent;
|
||
|
||
// 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms)
|
||
var drainStart2 = DateTime.UtcNow;
|
||
while (_displayedLength < _cachedStreamContent.Length
|
||
&& (DateTime.UtcNow - drainStart2).TotalMilliseconds < 600)
|
||
{
|
||
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
if (sb.Length == 0) sb.Append("(취소됨)");
|
||
assistantMsg.Content = sb.ToString();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errMsg = $"⚠ 오류: {ex.Message}";
|
||
sb.Clear(); sb.Append(errMsg);
|
||
assistantMsg.Content = errMsg;
|
||
AddRetryButton();
|
||
}
|
||
finally
|
||
{
|
||
_cursorTimer.Stop();
|
||
_elapsedTimer.Stop();
|
||
_typingTimer.Stop();
|
||
HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리
|
||
StopRainbowGlow(); // 레인보우 글로우 종료
|
||
_activeStreamText = null;
|
||
_elapsedLabel = null;
|
||
_cachedStreamContent = "";
|
||
_isStreaming = false;
|
||
BtnSend.IsEnabled = true;
|
||
BtnStop.Visibility = Visibility.Collapsed;
|
||
BtnSend.Visibility = Visibility.Visible;
|
||
_streamCts?.Dispose();
|
||
_streamCts = null;
|
||
SetStatusIdle();
|
||
}
|
||
|
||
FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg);
|
||
AutoScrollIfNeeded();
|
||
|
||
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||
ChatSession?.RememberConversation(conv.Tab ?? _activeTab, conv.Id);
|
||
SyncTabConversationIdsFromSession();
|
||
RefreshConversationList();
|
||
}
|
||
|
||
/// <summary>메시지 버블의 MaxWidth를 창 너비에 비례하여 계산합니다 (더 넓은 본문 레이아웃).</summary>
|
||
private double GetMessageMaxWidth()
|
||
{
|
||
var scrollWidth = MessageScroll.ActualWidth;
|
||
if (scrollWidth < 100) scrollWidth = 700; // 초기화 전 기본값
|
||
// 좌우 여백을 더 줄여 본문과 composer가 넓게 보이도록 조정
|
||
var maxW = (scrollWidth - 56) * 0.975;
|
||
return Math.Clamp(maxW, 620, 1560);
|
||
}
|
||
|
||
private StackPanel CreateStreamingContainer(out TextBlock streamText)
|
||
{
|
||
var msgMaxWidth = GetMessageMaxWidth();
|
||
var container = new StackPanel
|
||
{
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Width = msgMaxWidth,
|
||
MaxWidth = msgMaxWidth,
|
||
Margin = new Thickness(10, 3, 150, 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)
|
||
{
|
||
// 스트리밍 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 displayInput = isAgentTab && _agentCumulativeInputTokens > 0
|
||
? _agentCumulativeInputTokens
|
||
: usage?.PromptTokens ?? 0;
|
||
var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
|
||
? _agentCumulativeOutputTokens
|
||
: usage?.CompletionTokens ?? 0;
|
||
|
||
if (displayInput > 0 || displayOutput > 0)
|
||
{
|
||
UpdateStatusTokens(displayInput, displayOutput);
|
||
Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput);
|
||
}
|
||
string tokenText;
|
||
if (displayInput > 0 || displayOutput > 0)
|
||
tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
|
||
else if (usage != null)
|
||
tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
|
||
else
|
||
tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
|
||
|
||
var metaText = new TextBlock
|
||
{
|
||
Text = $"{elapsedText} · {tokenText}",
|
||
FontSize = 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 AddRetryButton()
|
||
{
|
||
Dispatcher.Invoke(() =>
|
||
{
|
||
var retryBorder = new Border
|
||
{
|
||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(12, 8, 12, 8),
|
||
Margin = new Thickness(40, 4, 80, 4),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
Cursor = System.Windows.Input.Cursors.Hand,
|
||
};
|
||
var retrySp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
retrySp.Children.Add(new TextBlock
|
||
{
|
||
Text = "\uE72C", FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
|
||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
retrySp.Children.Add(new TextBlock
|
||
{
|
||
Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold,
|
||
Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
retryBorder.Child = retrySp;
|
||
retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); };
|
||
retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); };
|
||
retryBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
session.RemoveLastAssistantMessage(_activeTab, _storage);
|
||
_currentConversation = session.CurrentConversation;
|
||
}
|
||
else if (_currentConversation != null)
|
||
{
|
||
var lastIdx = _currentConversation.Messages.Count - 1;
|
||
if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant")
|
||
_currentConversation.Messages.RemoveAt(lastIdx);
|
||
}
|
||
}
|
||
_ = RegenerateLastAsync();
|
||
};
|
||
MessagePanel.Children.Add(retryBorder);
|
||
ForceScrollToEnd();
|
||
});
|
||
}
|
||
|
||
// ─── 메시지 우클릭 컨텍스트 메뉴 ───────────────────────────────────────
|
||
|
||
private void ShowMessageContextMenu(string content, string role)
|
||
{
|
||
var 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>프리셋에서 대화 주제 버튼을 동적으로 생성합니다.</summary>
|
||
private void BuildTopicButtons()
|
||
{
|
||
TopicButtonPanel.Children.Clear();
|
||
|
||
TopicButtonPanel.Visibility = Visibility.Visible;
|
||
|
||
// 탭별 EmptyState 텍스트
|
||
if (_activeTab == "Cowork" || _activeTab == "Code")
|
||
{
|
||
if (EmptyStateTitle != null) EmptyStateTitle.Text = "작업 유형을 선택하세요";
|
||
if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code"
|
||
? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다"
|
||
: "에이전트가 상세한 데이터를 작성합니다";
|
||
}
|
||
else
|
||
{
|
||
if (EmptyStateTitle != null) EmptyStateTitle.Text = "대화 주제를 선택하세요";
|
||
if (EmptyStateDesc != null) EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다";
|
||
}
|
||
|
||
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
|
||
|
||
foreach (var preset in presets)
|
||
{
|
||
var capturedPreset = preset;
|
||
var btnColor = BrushFromHex(preset.Color);
|
||
|
||
var border = new Border
|
||
{
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(14, 14, 14, 14),
|
||
Margin = new Thickness(6, 6, 6, 10),
|
||
Cursor = Cursors.Hand,
|
||
Width = 124,
|
||
Height = 108,
|
||
ClipToBounds = true,
|
||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||
RenderTransform = new ScaleTransform(1, 1),
|
||
};
|
||
|
||
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
|
||
|
||
// 아이콘 컨테이너 (원형 배경 + 펄스 애니메이션)
|
||
var iconCircle = new Border
|
||
{
|
||
Width = 40, Height = 40,
|
||
CornerRadius = new CornerRadius(20),
|
||
Background = new SolidColorBrush(((SolidColorBrush)btnColor).Color) { Opacity = 0.15 },
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 0, 12),
|
||
};
|
||
var iconTb = new TextBlock
|
||
{
|
||
Text = preset.Symbol,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 18,
|
||
Foreground = btnColor,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
iconCircle.Child = iconTb;
|
||
stack.Children.Add(iconCircle);
|
||
|
||
// 제목
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = preset.Label,
|
||
FontSize = 13, FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
});
|
||
|
||
// 설명
|
||
stack.Children.Add(new TextBlock
|
||
{
|
||
Text = preset.Description,
|
||
FontSize = 9, TextWrapping = TextWrapping.Wrap,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
MaxHeight = 28,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
TextAlignment = TextAlignment.Center,
|
||
});
|
||
|
||
// 커스텀 프리셋: 좌측 상단 뱃지
|
||
if (capturedPreset.IsCustom)
|
||
{
|
||
var grid = new Grid();
|
||
grid.Children.Add(stack);
|
||
var badge = new Border
|
||
{
|
||
Width = 16, Height = 16,
|
||
CornerRadius = new CornerRadius(4),
|
||
Background = new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xFF, 0xFF)),
|
||
HorizontalAlignment = HorizontalAlignment.Left,
|
||
VerticalAlignment = VerticalAlignment.Top,
|
||
Margin = new Thickness(2, 2, 0, 0),
|
||
ToolTip = "커스텀 프리셋",
|
||
};
|
||
badge.Child = new TextBlock
|
||
{
|
||
Text = "\uE710", // + 아이콘
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 8,
|
||
Foreground = btnColor,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
grid.Children.Add(badge);
|
||
border.Child = grid;
|
||
}
|
||
else
|
||
{
|
||
border.Child = stack;
|
||
}
|
||
|
||
// 호버 애니메이션 — 스케일 1.05x + 밝기 변경
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
var normalBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
|
||
border.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{
|
||
st.ScaleX = 1.03; st.ScaleY = 1.03;
|
||
b.Background = hoverBg;
|
||
}
|
||
};
|
||
border.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{
|
||
st.ScaleX = 1.0; st.ScaleY = 1.0;
|
||
b.Background = normalBg;
|
||
}
|
||
};
|
||
|
||
// 클릭 → 해당 주제로 새 대화 시작
|
||
border.MouseLeftButtonDown += (_, _) => SelectTopic(capturedPreset);
|
||
|
||
// 커스텀 프리셋: 우클릭 메뉴 (편집/삭제)
|
||
if (capturedPreset.IsCustom)
|
||
{
|
||
border.MouseRightButtonUp += (s, e) =>
|
||
{
|
||
e.Handled = true;
|
||
ShowCustomPresetContextMenu(s as Border, capturedPreset);
|
||
};
|
||
}
|
||
|
||
TopicButtonPanel.Children.Add(border);
|
||
}
|
||
|
||
// "기타" 자유 입력 버튼 추가
|
||
{
|
||
var etcColor = BrushFromHex("#6B7280"); // 회색
|
||
var etcBorder = new Border
|
||
{
|
||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(14, 14, 14, 14),
|
||
Margin = new Thickness(6, 6, 6, 10),
|
||
Cursor = Cursors.Hand,
|
||
Width = 124,
|
||
Height = 108,
|
||
ClipToBounds = true,
|
||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||
RenderTransform = new ScaleTransform(1, 1),
|
||
};
|
||
|
||
var etcStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
|
||
|
||
var etcIconCircle = new Border
|
||
{
|
||
Width = 40, Height = 40,
|
||
CornerRadius = new CornerRadius(20),
|
||
Background = new SolidColorBrush(((SolidColorBrush)etcColor).Color) { Opacity = 0.15 },
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 0, 12),
|
||
};
|
||
etcIconCircle.Child = new TextBlock
|
||
{
|
||
Text = "\uE70F", // Edit 아이콘
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 18,
|
||
Foreground = etcColor,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
etcStack.Children.Add(etcIconCircle);
|
||
|
||
etcStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "기타",
|
||
FontSize = 13, FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
});
|
||
etcStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "프리셋 없이 자유롭게 대화합니다",
|
||
FontSize = 9, TextWrapping = TextWrapping.Wrap,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
MaxHeight = 28,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
TextAlignment = TextAlignment.Center,
|
||
});
|
||
|
||
etcBorder.Child = etcStack;
|
||
|
||
var hoverBg2 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
var normalBg2 = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
|
||
etcBorder.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{ st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg2; }
|
||
};
|
||
etcBorder.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = normalBg2; }
|
||
};
|
||
|
||
etcBorder.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
InputBox.Focus();
|
||
};
|
||
TopicButtonPanel.Children.Add(etcBorder);
|
||
}
|
||
|
||
// ── "+" 커스텀 프리셋 추가 버튼 ──
|
||
{
|
||
var addColor = BrushFromHex("#6366F1");
|
||
var addBorder = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(14, 14, 14, 14),
|
||
Margin = new Thickness(6, 6, 6, 10),
|
||
Cursor = Cursors.Hand,
|
||
Width = 124, Height = 108,
|
||
ClipToBounds = true,
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
||
BorderThickness = new Thickness(1.5),
|
||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||
RenderTransform = new ScaleTransform(1, 1),
|
||
};
|
||
// 점선 효과를 위한 Dashes
|
||
if (addBorder.BorderBrush is SolidColorBrush scb)
|
||
{
|
||
var dashPen = new Pen(scb, 1.5) { DashStyle = DashStyles.Dash };
|
||
}
|
||
|
||
var addStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
|
||
|
||
// + 아이콘
|
||
var plusIcon = new TextBlock
|
||
{
|
||
Text = "\uE710",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 24,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Margin = new Thickness(0, 8, 0, 8),
|
||
};
|
||
addStack.Children.Add(plusIcon);
|
||
|
||
addStack.Children.Add(new TextBlock
|
||
{
|
||
Text = "프리셋 추가",
|
||
FontSize = 12,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
});
|
||
|
||
addBorder.Child = addStack;
|
||
|
||
var hoverBg3 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
addBorder.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{ st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg3; }
|
||
};
|
||
addBorder.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
||
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = Brushes.Transparent; }
|
||
};
|
||
addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog();
|
||
TopicButtonPanel.Children.Add(addBorder);
|
||
}
|
||
}
|
||
|
||
// ─── 커스텀 프리셋 관리 ─────────────────────────────────────────────
|
||
|
||
/// <summary>커스텀 프리셋 추가 다이얼로그를 표시합니다.</summary>
|
||
private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null)
|
||
{
|
||
bool isEdit = existing != null;
|
||
var dlg = new CustomPresetDialog(
|
||
existingName: existing?.Label ?? "",
|
||
existingDesc: existing?.Description ?? "",
|
||
existingPrompt: existing?.SystemPrompt ?? "",
|
||
existingColor: existing?.Color ?? "#6366F1",
|
||
existingSymbol: existing?.Symbol ?? "\uE713",
|
||
existingTab: existing?.Tab ?? _activeTab)
|
||
{
|
||
Owner = this,
|
||
};
|
||
|
||
if (dlg.ShowDialog() == true)
|
||
{
|
||
if (isEdit)
|
||
{
|
||
existing!.Label = dlg.PresetName;
|
||
existing.Description = dlg.PresetDescription;
|
||
existing.SystemPrompt = dlg.PresetSystemPrompt;
|
||
existing.Color = dlg.PresetColor;
|
||
existing.Symbol = dlg.PresetSymbol;
|
||
existing.Tab = dlg.PresetTab;
|
||
}
|
||
else
|
||
{
|
||
_settings.Settings.Llm.CustomPresets.Add(new Models.CustomPresetEntry
|
||
{
|
||
Label = dlg.PresetName,
|
||
Description = dlg.PresetDescription,
|
||
SystemPrompt = dlg.PresetSystemPrompt,
|
||
Color = dlg.PresetColor,
|
||
Symbol = dlg.PresetSymbol,
|
||
Tab = dlg.PresetTab,
|
||
});
|
||
}
|
||
_settings.Save();
|
||
BuildTopicButtons();
|
||
}
|
||
}
|
||
|
||
/// <summary>커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||
private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset)
|
||
{
|
||
if (anchor == null || preset.CustomId == null) return;
|
||
|
||
var popup = new System.Windows.Controls.Primitives.Popup
|
||
{
|
||
PlacementTarget = anchor,
|
||
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
|
||
StaysOpen = false,
|
||
AllowsTransparency = true,
|
||
};
|
||
|
||
var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
var menuBorder = new Border
|
||
{
|
||
Background = menuBg,
|
||
CornerRadius = new CornerRadius(10),
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
Padding = new Thickness(4),
|
||
MinWidth = 120,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
|
||
},
|
||
};
|
||
|
||
var stack = new StackPanel();
|
||
|
||
// 편집 버튼
|
||
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
|
||
editItem.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
popup.IsOpen = false;
|
||
var entry = _settings.Settings.Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId);
|
||
if (entry != null) ShowCustomPresetDialog(entry);
|
||
};
|
||
stack.Children.Add(editItem);
|
||
|
||
// 삭제 버튼
|
||
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
|
||
deleteItem.MouseLeftButtonDown += (_, _) =>
|
||
{
|
||
popup.IsOpen = false;
|
||
var result = CustomMessageBox.Show(
|
||
$"'{preset.Label}' 프리셋을 삭제하시겠습니까?",
|
||
"프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||
if (result == MessageBoxResult.Yes)
|
||
{
|
||
_settings.Settings.Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId);
|
||
_settings.Save();
|
||
BuildTopicButtons();
|
||
}
|
||
};
|
||
stack.Children.Add(deleteItem);
|
||
|
||
menuBorder.Child = stack;
|
||
popup.Child = menuBorder;
|
||
popup.IsOpen = true;
|
||
}
|
||
|
||
/// <summary>컨텍스트 메뉴 항목을 생성합니다.</summary>
|
||
private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg)
|
||
{
|
||
var item = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(10, 6, 14, 6),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13, Foreground = fg,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label, FontSize = 13, Foreground = fg,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
item.Child = sp;
|
||
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
|
||
return item;
|
||
}
|
||
|
||
/// <summary>대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.</summary>
|
||
private void SelectTopic(Services.TopicPreset preset)
|
||
{
|
||
bool hasMessages;
|
||
lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0;
|
||
|
||
// 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존)
|
||
bool hasInput = !string.IsNullOrEmpty(InputBox.Text);
|
||
bool keepConversation = hasMessages || hasInput;
|
||
|
||
if (!keepConversation)
|
||
{
|
||
// 메시지도 입력 텍스트도 없으면 새 대화 시작
|
||
StartNewConversation();
|
||
}
|
||
|
||
// 프리셋 적용 (기존 대화에도 프리셋 변경 가능)
|
||
lock (_convLock)
|
||
{
|
||
if (_currentConversation != null)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
{
|
||
_currentConversation = session.UpdateConversationMetadata(_activeTab, c =>
|
||
{
|
||
c.SystemCommand = preset.SystemPrompt;
|
||
c.Category = preset.Category;
|
||
}, _storage);
|
||
}
|
||
else
|
||
{
|
||
_currentConversation.SystemCommand = preset.SystemPrompt;
|
||
_currentConversation.Category = preset.Category;
|
||
}
|
||
}
|
||
}
|
||
|
||
UpdateCategoryLabel();
|
||
SaveConversationSettings();
|
||
RefreshConversationList();
|
||
if (EmptyState != null)
|
||
EmptyState.Visibility = Visibility.Collapsed;
|
||
|
||
InputBox.Focus();
|
||
|
||
if (!string.IsNullOrEmpty(preset.Placeholder))
|
||
{
|
||
_promptCardPlaceholder = preset.Placeholder;
|
||
if (!keepConversation) ShowPlaceholder();
|
||
}
|
||
|
||
if (keepConversation)
|
||
ShowToast($"프리셋 변경: {preset.Label}");
|
||
|
||
// Cowork 탭: 하단 바 갱신
|
||
if (_activeTab == "Cowork")
|
||
BuildBottomBar();
|
||
}
|
||
|
||
|
||
|
||
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
|
||
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
|
||
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
|
||
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
|
||
|
||
/// <summary>하단 바를 구성합니다 (Cowork 작업 제어 중심).</summary>
|
||
private void BuildBottomBar()
|
||
{
|
||
MoodIconPanel.Children.Clear();
|
||
|
||
// ── 파일 탐색기 토글 버튼 ──
|
||
var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
|
||
fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
|
||
MoodIconPanel.Children.Add(fileBrowserBtn);
|
||
|
||
// ── 실행 이력 상세도 버튼 ──
|
||
AppendLogLevelButton();
|
||
|
||
// 구분선 표시
|
||
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
/// <summary>Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심.</summary>
|
||
private void BuildCodeBottomBar()
|
||
{
|
||
MoodIconPanel.Children.Clear();
|
||
|
||
var localBtn = CreateFolderBarButton("\uED25", "로컬", "원본 워크스페이스로 전환", "#6B7280");
|
||
localBtn.MouseLeftButtonUp += (_, e) =>
|
||
{
|
||
e.Handled = true;
|
||
var currentFolder = GetCurrentWorkFolder();
|
||
if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder))
|
||
{
|
||
ShowFolderMenu();
|
||
return;
|
||
}
|
||
|
||
var root = WorktreeStateStore.ResolveRoot(currentFolder);
|
||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||
{
|
||
ShowFolderMenu();
|
||
return;
|
||
}
|
||
|
||
SwitchToWorkspace(root, root);
|
||
};
|
||
MoodIconPanel.Children.Add(localBtn);
|
||
|
||
var worktreeBtn = CreateFolderBarButton("\uE8B7", "워크트리", "분리된 작업 복사본 생성 또는 전환", "#2563EB");
|
||
worktreeBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowWorktreeMenu(worktreeBtn); };
|
||
MoodIconPanel.Children.Add(worktreeBtn);
|
||
|
||
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
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 ShowWorktreeMenu(UIElement placementTarget)
|
||
{
|
||
var (popup, panel) = CreateThemedPopupMenu(placementTarget, PlacementMode.Top, 320);
|
||
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 currentFolder = GetCurrentWorkFolder();
|
||
var root = string.IsNullOrWhiteSpace(currentFolder) ? "" : WorktreeStateStore.ResolveRoot(currentFolder);
|
||
var active = string.IsNullOrWhiteSpace(root) ? currentFolder : WorktreeStateStore.Load(root).Active;
|
||
var variants = GetAvailableWorkspaceVariants(root, active);
|
||
|
||
panel.Children.Add(CreatePopupSummaryStrip(new[]
|
||
{
|
||
("모드", string.Equals(active, root, StringComparison.OrdinalIgnoreCase) ? "로컬" : "워크트리", "#F8FAFC", "#E2E8F0", "#475569"),
|
||
("변형", variants.Count.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"),
|
||
}));
|
||
panel.Children.Add(CreatePopupSectionLabel("현재 작업 위치", new Thickness(8, 6, 8, 4)));
|
||
|
||
panel.Children.Add(CreatePopupMenuRow(
|
||
"\uED25",
|
||
"로컬",
|
||
string.IsNullOrWhiteSpace(root) ? "현재 워크스페이스" : root,
|
||
!string.IsNullOrWhiteSpace(root) && string.Equals(active, root, StringComparison.OrdinalIgnoreCase),
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
popup.IsOpen = false;
|
||
if (!string.IsNullOrWhiteSpace(root))
|
||
SwitchToWorkspace(root, root);
|
||
}));
|
||
|
||
if (!string.IsNullOrWhiteSpace(active) && !string.Equals(active, root, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
panel.Children.Add(CreatePopupMenuRow(
|
||
"\uE7BA",
|
||
Path.GetFileName(active),
|
||
active,
|
||
true,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
popup.IsOpen = false;
|
||
SwitchToWorkspace(active, root);
|
||
}));
|
||
}
|
||
|
||
if (variants.Count > 0)
|
||
{
|
||
panel.Children.Add(CreatePopupSectionLabel($"워크트리 / 복사본 · {variants.Count}", new Thickness(8, 10, 8, 4)));
|
||
foreach (var variant in variants)
|
||
{
|
||
var isActive = !string.IsNullOrWhiteSpace(active) &&
|
||
string.Equals(Path.GetFullPath(variant), Path.GetFullPath(active), StringComparison.OrdinalIgnoreCase);
|
||
panel.Children.Add(CreatePopupMenuRow(
|
||
"\uE8B7",
|
||
Path.GetFileName(variant),
|
||
isActive ? $"현재 선택 · {variant}" : variant,
|
||
isActive,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
popup.IsOpen = false;
|
||
SwitchToWorkspace(variant, root);
|
||
}));
|
||
}
|
||
}
|
||
|
||
panel.Children.Add(CreatePopupSectionLabel("새 작업 위치", new Thickness(8, 10, 8, 4)));
|
||
|
||
panel.Children.Add(CreatePopupMenuRow(
|
||
"\uE943",
|
||
"현재 브랜치로 워크트리 생성",
|
||
"Git 저장소면 분리된 작업 복사본을 만들고 전환합니다",
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
popup.IsOpen = false;
|
||
_ = CreateCurrentBranchWorktreeAsync();
|
||
}));
|
||
|
||
popup.IsOpen = true;
|
||
}
|
||
|
||
private Border CreatePopupMenuRow(
|
||
string icon,
|
||
string title,
|
||
string description,
|
||
bool selected,
|
||
Brush accentBrush,
|
||
Brush secondaryText,
|
||
Brush primaryText,
|
||
Action? onClick)
|
||
{
|
||
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB");
|
||
var hintBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||
var row = new Border
|
||
{
|
||
Background = selected ? hintBackground : Brushes.Transparent,
|
||
BorderBrush = borderColor,
|
||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Cursor = Cursors.Hand,
|
||
Margin = new Thickness(0),
|
||
};
|
||
|
||
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 });
|
||
|
||
var iconBlock = new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12,
|
||
Foreground = selected ? accentBrush : secondaryText,
|
||
Margin = new Thickness(0, 1, 8, 0),
|
||
VerticalAlignment = VerticalAlignment.Top,
|
||
};
|
||
grid.Children.Add(iconBlock);
|
||
|
||
var textStack = new StackPanel();
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = title,
|
||
FontSize = 12,
|
||
FontWeight = selected ? FontWeights.SemiBold : FontWeights.Medium,
|
||
Foreground = primaryText,
|
||
});
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = description,
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
Grid.SetColumn(textStack, 1);
|
||
grid.Children.Add(textStack);
|
||
|
||
if (selected)
|
||
{
|
||
var check = CreateSimpleCheck(accentBrush, 14);
|
||
Grid.SetColumn(check, 2);
|
||
grid.Children.Add(check);
|
||
}
|
||
|
||
row.Child = grid;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||
row.MouseEnter += (_, _) => row.Background = selected ? hintBackground : hoverBg;
|
||
row.MouseLeave += (_, _) => row.Background = selected ? hintBackground : Brushes.Transparent;
|
||
row.MouseLeftButtonUp += (_, _) => onClick?.Invoke();
|
||
return row;
|
||
}
|
||
|
||
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 = 26;
|
||
if (model.Length > maxLen)
|
||
model = model[..(maxLen - 1)] + "…";
|
||
ModelLabel.Text = $"{serviceLabel} · {model}";
|
||
}
|
||
|
||
private static string NextPlanMode(string current) => (current ?? "off").ToLowerInvariant() switch
|
||
{
|
||
"off" => "auto",
|
||
"auto" => "always",
|
||
_ => "off",
|
||
};
|
||
private static string PlanModeLabel(string value) => (value ?? "off").ToLowerInvariant() switch
|
||
{
|
||
"always" => "항상 계획",
|
||
"auto" => "자동 계획",
|
||
_ => "끄기",
|
||
};
|
||
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 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();
|
||
InlineModelChipPanel.Children.Clear();
|
||
if (models.Count == 0)
|
||
{
|
||
CmbInlineModel.Items.Add(new ComboBoxItem { Content = "등록된 모델 없음", IsEnabled = false });
|
||
CmbInlineModel.SelectedIndex = 0;
|
||
if (InlineModelChipPanel != null)
|
||
InlineModelChipPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
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);
|
||
|
||
if (InlineModelChipPanel != null)
|
||
{
|
||
InlineModelChipPanel.Visibility = Visibility.Visible;
|
||
foreach (var (id, label) in models.Take(6))
|
||
{
|
||
var isActive = string.Equals(id, llm.Model, StringComparison.OrdinalIgnoreCase);
|
||
var chip = 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, 5, 10, 5),
|
||
Margin = new Thickness(0, 0, 6, 6),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var text = new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 10.5,
|
||
Foreground = isActive
|
||
? BrushFromHex("#1D4ED8")
|
||
: (TryFindResource("PrimaryText") as Brush ?? Brushes.White),
|
||
};
|
||
chip.Child = text;
|
||
var capturedId = id;
|
||
var capturedLabel = label;
|
||
chip.MouseEnter += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
chip.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
};
|
||
chip.MouseLeave += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
chip.Background = Brushes.Transparent;
|
||
};
|
||
chip.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
llm.Model = capturedId;
|
||
_settings.Save();
|
||
UpdateModelLabel();
|
||
RefreshInlineSettingsPanel();
|
||
SetStatus($"모델 전환: {capturedLabel}", spinning: false);
|
||
};
|
||
InlineModelChipPanel.Children.Add(chip);
|
||
}
|
||
}
|
||
}
|
||
|
||
BtnInlineFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐");
|
||
BtnInlineReasoning.Content = GetQuickActionLabel("추론", ReasoningLabel(llm.AgentDecisionLevel));
|
||
BtnInlinePlanMode.Content = GetQuickActionLabel("계획", PlanModeLabel(llm.PlanMode));
|
||
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(BtnInlinePlanMode, !string.Equals(llm.PlanMode, "off", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#4338CA");
|
||
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("common");
|
||
Dispatcher.BeginInvoke(() =>
|
||
{
|
||
if (OverlayNavCommon != null)
|
||
OverlayNavCommon.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 = ChkOverlayAiEnabled?.IsChecked == 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.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true;
|
||
llm.FolderDataUsage = _folderDataUsage;
|
||
|
||
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);
|
||
CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true);
|
||
|
||
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);
|
||
|
||
if (loadDeferredInputs)
|
||
{
|
||
if (ChkOverlayAiEnabled != null)
|
||
ChkOverlayAiEnabled.IsChecked = _settings.Settings.AiEnabled;
|
||
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 (TxtOverlayMaxRetryOnError != null)
|
||
TxtOverlayMaxRetryOnError.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString();
|
||
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 (ChkOverlayEnableParallelTools != null)
|
||
ChkOverlayEnableParallelTools.IsChecked = llm.EnableParallelTools;
|
||
}
|
||
|
||
if (BtnOverlayDefaultOutputFormat != null)
|
||
BtnOverlayDefaultOutputFormat.Content = $"AI 자동 · {GetFormatLabel(llm.DefaultOutputFormat ?? "auto")}";
|
||
if (BtnOverlayDefaultMood != null)
|
||
{
|
||
var mood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == (_selectedMood ?? llm.DefaultMood ?? "modern"));
|
||
BtnOverlayDefaultMood.Content = mood == null ? "모던" : $"{mood.Icon} {mood.Label}";
|
||
}
|
||
|
||
RefreshOverlayThemeCards();
|
||
RefreshOverlayServiceCards();
|
||
RefreshOverlayModeButtons();
|
||
RefreshOverlayServiceFieldLabels(service);
|
||
BuildOverlayModelChips(service);
|
||
RefreshOverlayAdvancedChoiceButtons();
|
||
}
|
||
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 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 = ChkOverlayAiEnabled?.IsChecked == 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 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 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) ? "common" : tag.Trim().ToLowerInvariant();
|
||
var showCommon = section == "common";
|
||
var showService = section == "service";
|
||
var showPermission = section == "permission";
|
||
var showAdvanced = section == "advanced";
|
||
|
||
OverlaySectionService.Visibility = showCommon || showService ? Visibility.Visible : Visibility.Collapsed;
|
||
OverlaySectionQuick.Visibility = showCommon || showPermission ? Visibility.Visible : Visibility.Collapsed;
|
||
OverlaySectionDetail.Visibility = Visibility.Visible;
|
||
|
||
if (OverlayAnchorCommon != null)
|
||
OverlayAnchorCommon.Text = section switch
|
||
{
|
||
"service" => "서비스 설정",
|
||
"permission" => "권한 설정",
|
||
"advanced" => "고급 설정",
|
||
_ => "일반 설정"
|
||
};
|
||
|
||
if (OverlayAiEnabledRow != null)
|
||
OverlayAiEnabledRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayThemePanel != null)
|
||
OverlayThemePanel.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayThemeStylePanel != null)
|
||
OverlayThemeStylePanel.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayDefaultOutputFormatRow != null)
|
||
OverlayDefaultOutputFormatRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayDefaultMoodRow != null)
|
||
OverlayDefaultMoodRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayModelEditorPanel != null)
|
||
OverlayModelEditorPanel.Visibility = showCommon || showService ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayAnchorPermission != null)
|
||
OverlayAnchorPermission.Visibility = showPermission ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayFolderDataUsageRow != null)
|
||
OverlayFolderDataUsageRow.Visibility = showCommon || showPermission ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayTlsRow != null)
|
||
OverlayTlsRow.Visibility = showCommon || showService || showPermission ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayAnchorAdvanced != null)
|
||
OverlayAnchorAdvanced.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayMaxContextTokensRow != null)
|
||
OverlayMaxContextTokensRow.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayMaxRetryRow != null)
|
||
OverlayMaxRetryRow.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
|
||
if (OverlayAdvancedTogglePanel != null)
|
||
OverlayAdvancedTogglePanel.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
|
||
}
|
||
|
||
private void RefreshOverlaySettingsPanel()
|
||
{
|
||
RefreshOverlayVisualState(loadDeferredInputs: true);
|
||
}
|
||
|
||
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(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 RefreshOverlayModeButtons()
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
var operationModeLabel = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode
|
||
? "사외 모드"
|
||
: "사내 모드";
|
||
var dataUsageLabel = _folderDataUsage switch
|
||
{
|
||
"active" => "적극 활용",
|
||
"passive" => "소극 활용",
|
||
_ => "활용하지 않음",
|
||
};
|
||
var permissionLabel = PermissionModeCatalog.ToDisplayLabel(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission));
|
||
var planLabel = PlanModeLabel(llm.PlanMode);
|
||
var reasoningLabel = ReasoningLabel(llm.AgentDecisionLevel);
|
||
|
||
BtnOverlayOperationMode.Content = GetQuickActionLabel("모드", operationModeLabel);
|
||
BtnOverlayFolderDataUsage.Content = GetQuickActionLabel("데이터", dataUsageLabel);
|
||
BtnOverlayPermission.Content = GetQuickActionLabel("권한", permissionLabel);
|
||
BtnOverlayPlanMode.Content = GetQuickActionLabel("계획", planLabel);
|
||
BtnOverlayReasoning.Content = GetQuickActionLabel("추론", reasoningLabel);
|
||
if (BtnOverlayFastMode != null)
|
||
{
|
||
BtnOverlayFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐");
|
||
ApplyQuickActionVisual(BtnOverlayFastMode, llm.FreeTierMode, "#ECFDF5", "#166534");
|
||
}
|
||
ApplyQuickActionVisual(BtnOverlayReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8");
|
||
ApplyQuickActionVisual(BtnOverlayPlanMode, !string.Equals(llm.PlanMode, "off", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#4338CA");
|
||
ApplyQuickActionVisual(BtnOverlayPermission,
|
||
!string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase),
|
||
"#FFF7ED",
|
||
"#C2410C");
|
||
UpdateDataUsageUI();
|
||
}
|
||
|
||
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 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 BuildOverlayModelChips(string service)
|
||
{
|
||
if (OverlayModelChipPanel == null)
|
||
return;
|
||
|
||
OverlayModelChipPanel.Children.Clear();
|
||
foreach (var model in GetModelCandidates(service))
|
||
{
|
||
var captured = model.Id;
|
||
var isActive = string.Equals(model.Id, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase);
|
||
var border = new Border
|
||
{
|
||
Cursor = Cursors.Hand,
|
||
CornerRadius = new CornerRadius(8),
|
||
BorderThickness = new Thickness(1),
|
||
BorderBrush = isActive
|
||
? BrushFromHex("#C7D2FE")
|
||
: (TryFindResource("BorderColor") as Brush ?? Brushes.Gray),
|
||
Background = isActive
|
||
? BrushFromHex("#EEF2FF")
|
||
: Brushes.Transparent,
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(0, 0, 8, 8),
|
||
Child = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = model.Label,
|
||
FontSize = 11.5,
|
||
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
|
||
Foreground = isActive
|
||
? BrushFromHex("#1D4ED8")
|
||
: (TryFindResource("PrimaryText") as Brush ?? Brushes.Black),
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = model.Id,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
FontSize = 10,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
}
|
||
}
|
||
}
|
||
};
|
||
border.MouseEnter += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
};
|
||
border.MouseLeave += (_, _) =>
|
||
{
|
||
if (!isActive)
|
||
border.Background = Brushes.Transparent;
|
||
};
|
||
border.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
CommitOverlayModelSelection(captured);
|
||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||
};
|
||
OverlayModelChipPanel.Children.Add(border);
|
||
}
|
||
}
|
||
|
||
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 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);
|
||
}
|
||
|
||
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 = _folderDataUsage switch
|
||
{
|
||
"none" => "passive",
|
||
"passive" => "active",
|
||
_ => "none",
|
||
};
|
||
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 BtnInlinePlanMode_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var llm = _settings.Settings.Llm;
|
||
llm.PlanMode = NextPlanMode(llm.PlanMode);
|
||
_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 static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
|
||
};
|
||
|
||
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
|
||
private readonly List<string> _previewTabs = new();
|
||
private string? _activePreviewTab;
|
||
|
||
private void TryShowPreview(string filePath)
|
||
{
|
||
if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath))
|
||
return;
|
||
|
||
// 별도 커스텀 창으로 미리보기 (WebView2 HWND airspace 문제 근본 해결)
|
||
PreviewWindow.ShowPreview(filePath, _selectedMood);
|
||
}
|
||
|
||
private void ShowPreviewPanel(string filePath)
|
||
{
|
||
// 탭에 없으면 추가
|
||
if (!_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
|
||
_previewTabs.Add(filePath);
|
||
|
||
_activePreviewTab = filePath;
|
||
|
||
// 패널 열기
|
||
if (PreviewColumn.Width.Value < 100)
|
||
{
|
||
PreviewColumn.Width = new GridLength(420);
|
||
SplitterColumn.Width = new GridLength(5);
|
||
}
|
||
PreviewPanel.Visibility = Visibility.Visible;
|
||
PreviewSplitter.Visibility = Visibility.Visible;
|
||
BtnPreviewToggle.Visibility = Visibility.Visible;
|
||
|
||
RebuildPreviewTabs();
|
||
LoadPreviewContent(filePath);
|
||
}
|
||
|
||
/// <summary>탭 바 UI를 다시 구성합니다.</summary>
|
||
private void RebuildPreviewTabs()
|
||
{
|
||
PreviewTabPanel.Children.Clear();
|
||
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
foreach (var tabPath in _previewTabs)
|
||
{
|
||
var fileName = System.IO.Path.GetFileName(tabPath);
|
||
var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase);
|
||
|
||
var tabBorder = new Border
|
||
{
|
||
Background = isActive
|
||
? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF))
|
||
: Brushes.Transparent,
|
||
BorderBrush = isActive ? accentBrush : Brushes.Transparent,
|
||
BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
|
||
Padding = new Thickness(8, 6, 4, 6),
|
||
Cursor = Cursors.Hand,
|
||
MaxWidth = _previewTabs.Count <= 3 ? 200 : (_previewTabs.Count <= 5 ? 140 : 100),
|
||
};
|
||
|
||
var tabContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||
|
||
// 파일명
|
||
tabContent.Children.Add(new TextBlock
|
||
{
|
||
Text = fileName,
|
||
FontSize = 11,
|
||
Foreground = isActive ? primaryText : secondaryText,
|
||
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
MaxWidth = tabBorder.MaxWidth - 30,
|
||
ToolTip = tabPath,
|
||
});
|
||
|
||
// 닫기 버튼 (x) — 활성 탭은 항상 표시, 비활성 탭은 호버 시에만 표시
|
||
var closeFg = isActive ? primaryText : secondaryText;
|
||
var closeBtnText = new TextBlock
|
||
{
|
||
Text = "\uE711",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10,
|
||
Foreground = closeFg,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
var closeBtn = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(3),
|
||
Padding = new Thickness(3, 2, 3, 2),
|
||
Margin = new Thickness(5, 0, 0, 0),
|
||
Cursor = Cursors.Hand,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
// 비활성 탭은 초기에 숨김, 활성 탭은 항상 표시
|
||
Visibility = isActive ? Visibility.Visible : Visibility.Hidden,
|
||
Child = closeBtnText,
|
||
};
|
||
|
||
var closePath = tabPath;
|
||
closeBtn.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b)
|
||
{
|
||
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
|
||
if (b.Child is TextBlock tb) tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
|
||
}
|
||
};
|
||
closeBtn.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b)
|
||
{
|
||
b.Background = Brushes.Transparent;
|
||
if (b.Child is TextBlock tb) tb.Foreground = closeFg;
|
||
}
|
||
};
|
||
closeBtn.Tag = "close"; // 닫기 버튼 식별용
|
||
closeBtn.MouseLeftButtonUp += (_, e) =>
|
||
{
|
||
e.Handled = true; // 부모 탭 클릭 이벤트 차단
|
||
ClosePreviewTab(closePath);
|
||
};
|
||
|
||
tabContent.Children.Add(closeBtn);
|
||
tabBorder.Child = tabContent;
|
||
|
||
// 탭 클릭 → 활성화 (MouseLeftButtonUp 사용: 닫기 버튼의 PreviewMouseLeftButtonDown보다 늦게 실행되어 충돌 방지)
|
||
var clickPath = tabPath;
|
||
tabBorder.MouseLeftButtonUp += (_, e) =>
|
||
{
|
||
if (e.Handled) return;
|
||
e.Handled = true;
|
||
_activePreviewTab = clickPath;
|
||
RebuildPreviewTabs();
|
||
LoadPreviewContent(clickPath);
|
||
};
|
||
|
||
// 우클릭 → 컨텍스트 메뉴
|
||
var ctxPath = tabPath;
|
||
tabBorder.MouseRightButtonUp += (_, e) =>
|
||
{
|
||
e.Handled = true;
|
||
ShowPreviewTabContextMenu(ctxPath);
|
||
};
|
||
|
||
// 더블클릭 → 별도 창에서 보기
|
||
var dblPath = tabPath;
|
||
tabBorder.MouseLeftButtonDown += (_, e) =>
|
||
{
|
||
if (e.Handled) return;
|
||
if (e.ClickCount == 2)
|
||
{
|
||
e.Handled = true;
|
||
OpenPreviewPopupWindow(dblPath);
|
||
}
|
||
};
|
||
|
||
// 호버 효과 — 비활성 탭에서 배경 강조 + 닫기 버튼 표시
|
||
var capturedIsActive = isActive;
|
||
var capturedCloseBtn = closeBtn;
|
||
tabBorder.MouseEnter += (s, _) =>
|
||
{
|
||
if (s is Border b && !capturedIsActive)
|
||
b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
|
||
// 비활성 탭도 호버 시 닫기 버튼 표시
|
||
if (!capturedIsActive)
|
||
capturedCloseBtn.Visibility = Visibility.Visible;
|
||
};
|
||
tabBorder.MouseLeave += (s, _) =>
|
||
{
|
||
if (s is Border b && !capturedIsActive)
|
||
b.Background = Brushes.Transparent;
|
||
// 비활성 탭 호버 해제 시 닫기 버튼 숨김
|
||
if (!capturedIsActive)
|
||
capturedCloseBtn.Visibility = Visibility.Hidden;
|
||
};
|
||
|
||
PreviewTabPanel.Children.Add(tabBorder);
|
||
|
||
// 탭 사이 구분선
|
||
if (tabPath != _previewTabs[^1])
|
||
{
|
||
PreviewTabPanel.Children.Add(new Border
|
||
{
|
||
Width = 1, Height = 14,
|
||
Background = borderBrush,
|
||
Margin = new Thickness(0, 4, 0, 4),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
private void ClosePreviewTab(string filePath)
|
||
{
|
||
_previewTabs.Remove(filePath);
|
||
|
||
if (_previewTabs.Count == 0)
|
||
{
|
||
HidePreviewPanel();
|
||
return;
|
||
}
|
||
|
||
// 닫힌 탭이 활성 탭이면 마지막 탭으로 전환
|
||
if (string.Equals(filePath, _activePreviewTab, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_activePreviewTab = _previewTabs[^1];
|
||
LoadPreviewContent(_activePreviewTab);
|
||
}
|
||
|
||
RebuildPreviewTabs();
|
||
}
|
||
|
||
private async void LoadPreviewContent(string filePath)
|
||
{
|
||
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
|
||
|
||
// 모든 콘텐츠 숨기기
|
||
PreviewWebView.Visibility = Visibility.Collapsed;
|
||
PreviewTextScroll.Visibility = Visibility.Collapsed;
|
||
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||
PreviewEmpty.Visibility = Visibility.Collapsed;
|
||
|
||
if (!System.IO.File.Exists(filePath))
|
||
{
|
||
PreviewEmpty.Text = "파일을 찾을 수 없습니다";
|
||
PreviewEmpty.Visibility = Visibility.Visible;
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
switch (ext)
|
||
{
|
||
case ".html":
|
||
case ".htm":
|
||
await EnsureWebViewInitializedAsync();
|
||
PreviewWebView.Source = new Uri(filePath);
|
||
PreviewWebView.Visibility = Visibility.Visible;
|
||
break;
|
||
|
||
case ".csv":
|
||
LoadCsvPreview(filePath);
|
||
PreviewDataGrid.Visibility = Visibility.Visible;
|
||
break;
|
||
|
||
case ".md":
|
||
await EnsureWebViewInitializedAsync();
|
||
var mdText = System.IO.File.ReadAllText(filePath);
|
||
if (mdText.Length > 50000) mdText = mdText[..50000];
|
||
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood);
|
||
PreviewWebView.NavigateToString(mdHtml);
|
||
PreviewWebView.Visibility = Visibility.Visible;
|
||
break;
|
||
|
||
case ".txt":
|
||
case ".json":
|
||
case ".xml":
|
||
case ".log":
|
||
var text = System.IO.File.ReadAllText(filePath);
|
||
if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)";
|
||
PreviewTextBlock.Text = text;
|
||
PreviewTextScroll.Visibility = Visibility.Visible;
|
||
break;
|
||
|
||
default:
|
||
PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다";
|
||
PreviewEmpty.Visibility = Visibility.Visible;
|
||
break;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
|
||
PreviewTextScroll.Visibility = Visibility.Visible;
|
||
}
|
||
}
|
||
|
||
private bool _webViewInitialized;
|
||
private static readonly string WebView2DataFolder =
|
||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||
"AxCopilot", "WebView2");
|
||
|
||
private async Task EnsureWebViewInitializedAsync()
|
||
{
|
||
if (_webViewInitialized) return;
|
||
try
|
||
{
|
||
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
|
||
userDataFolder: WebView2DataFolder);
|
||
await PreviewWebView.EnsureCoreWebView2Async(env);
|
||
_webViewInitialized = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Services.LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void LoadCsvPreview(string filePath)
|
||
{
|
||
try
|
||
{
|
||
var lines = System.IO.File.ReadAllLines(filePath);
|
||
if (lines.Length == 0) return;
|
||
|
||
var dt = new System.Data.DataTable();
|
||
var headers = ParseCsvLine(lines[0]);
|
||
foreach (var h in headers)
|
||
dt.Columns.Add(h);
|
||
|
||
var maxRows = Math.Min(lines.Length, 501);
|
||
for (int i = 1; i < maxRows; i++)
|
||
{
|
||
var vals = ParseCsvLine(lines[i]);
|
||
var row = dt.NewRow();
|
||
for (int j = 0; j < Math.Min(vals.Length, headers.Length); j++)
|
||
row[j] = vals[j];
|
||
dt.Rows.Add(row);
|
||
}
|
||
|
||
PreviewDataGrid.ItemsSource = dt.DefaultView;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
PreviewTextBlock.Text = $"CSV 로드 오류: {ex.Message}";
|
||
PreviewTextScroll.Visibility = Visibility.Visible;
|
||
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
|
||
private static string[] ParseCsvLine(string line)
|
||
{
|
||
var fields = new System.Collections.Generic.List<string>();
|
||
var current = new System.Text.StringBuilder();
|
||
bool inQuotes = false;
|
||
|
||
for (int i = 0; i < line.Length; i++)
|
||
{
|
||
char c = line[i];
|
||
if (inQuotes)
|
||
{
|
||
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
|
||
{
|
||
current.Append('"');
|
||
i++;
|
||
}
|
||
else if (c == '"')
|
||
inQuotes = false;
|
||
else
|
||
current.Append(c);
|
||
}
|
||
else
|
||
{
|
||
if (c == '"')
|
||
inQuotes = true;
|
||
else if (c == ',')
|
||
{
|
||
fields.Add(current.ToString());
|
||
current.Clear();
|
||
}
|
||
else
|
||
current.Append(c);
|
||
}
|
||
}
|
||
fields.Add(current.ToString());
|
||
return fields.ToArray();
|
||
}
|
||
|
||
private void HidePreviewPanel()
|
||
{
|
||
_previewTabs.Clear();
|
||
_activePreviewTab = null;
|
||
PreviewColumn.Width = new GridLength(0);
|
||
SplitterColumn.Width = new GridLength(0);
|
||
PreviewPanel.Visibility = Visibility.Collapsed;
|
||
PreviewSplitter.Visibility = Visibility.Collapsed;
|
||
PreviewWebView.Visibility = Visibility.Collapsed;
|
||
PreviewTextScroll.Visibility = Visibility.Collapsed;
|
||
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||
try { if (_webViewInitialized) PreviewWebView.CoreWebView2?.NavigateToString("<html></html>"); } catch { }
|
||
}
|
||
|
||
/// <summary>프리뷰 탭 바 클릭 시 WebView2에서 포커스를 회수 (HWND airspace 문제 방지).</summary>
|
||
private void PreviewTabBar_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||
{
|
||
// WebView2가 포커스를 잡고 있으면 WPF 버튼 클릭이 무시될 수 있으므로 포커스를 강제 이동
|
||
if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin)
|
||
{
|
||
var border = sender as Border;
|
||
border?.Focus();
|
||
}
|
||
}
|
||
|
||
private void BtnClosePreview_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
HidePreviewPanel();
|
||
BtnPreviewToggle.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (PreviewPanel.Visibility == Visibility.Visible)
|
||
{
|
||
// 숨기기 (탭은 유지)
|
||
PreviewPanel.Visibility = Visibility.Collapsed;
|
||
PreviewSplitter.Visibility = Visibility.Collapsed;
|
||
PreviewColumn.Width = new GridLength(0);
|
||
SplitterColumn.Width = new GridLength(0);
|
||
}
|
||
else if (_previewTabs.Count > 0)
|
||
{
|
||
// 다시 열기
|
||
PreviewPanel.Visibility = Visibility.Visible;
|
||
PreviewSplitter.Visibility = Visibility.Visible;
|
||
PreviewColumn.Width = new GridLength(420);
|
||
SplitterColumn.Width = new GridLength(5);
|
||
RebuildPreviewTabs();
|
||
if (_activePreviewTab != null) LoadPreviewContent(_activePreviewTab);
|
||
}
|
||
}
|
||
|
||
private void BtnOpenExternal_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (string.IsNullOrEmpty(_activePreviewTab) || !System.IO.File.Exists(_activePreviewTab))
|
||
return;
|
||
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = _activePreviewTab,
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||
private Popup? _previewTabPopup;
|
||
|
||
private void ShowPreviewTabContextMenu(string filePath)
|
||
{
|
||
// 기존 팝업 닫기
|
||
if (_previewTabPopup != null) _previewTabPopup.IsOpen = false;
|
||
|
||
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
|
||
var stack = new StackPanel();
|
||
|
||
void AddItem(string icon, string iconColor, string label, Action action)
|
||
{
|
||
var itemBorder = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(6),
|
||
Padding = new Thickness(10, 7, 16, 7),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor)
|
||
? secondaryText
|
||
: new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor)),
|
||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label, FontSize = 13, Foreground = primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
itemBorder.Child = sp;
|
||
itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||
itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
itemBorder.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
_previewTabPopup!.IsOpen = false;
|
||
action();
|
||
};
|
||
stack.Children.Add(itemBorder);
|
||
}
|
||
|
||
void AddSeparator()
|
||
{
|
||
stack.Children.Add(new Border
|
||
{
|
||
Height = 1,
|
||
Background = borderBrush,
|
||
Margin = new Thickness(8, 3, 8, 3),
|
||
});
|
||
}
|
||
|
||
AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () =>
|
||
{
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = filePath, UseShellExecute = true,
|
||
});
|
||
}
|
||
catch { }
|
||
});
|
||
|
||
AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
|
||
{
|
||
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); }
|
||
catch { }
|
||
});
|
||
|
||
AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath));
|
||
|
||
AddSeparator();
|
||
|
||
AddItem("\uE8C8", "", "경로 복사", () =>
|
||
{
|
||
try { Clipboard.SetText(filePath); } catch { }
|
||
});
|
||
|
||
AddSeparator();
|
||
|
||
AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath));
|
||
|
||
if (_previewTabs.Count > 1)
|
||
{
|
||
AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () =>
|
||
{
|
||
var keep = filePath;
|
||
_previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase));
|
||
_activePreviewTab = keep;
|
||
RebuildPreviewTabs();
|
||
LoadPreviewContent(keep);
|
||
});
|
||
}
|
||
|
||
var popupBorder = new Border
|
||
{
|
||
Background = bg,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(4, 6, 4, 6),
|
||
MinWidth = 180,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4,
|
||
Color = Colors.Black,
|
||
},
|
||
Child = stack,
|
||
};
|
||
|
||
_previewTabPopup = new Popup
|
||
{
|
||
Child = popupBorder,
|
||
Placement = PlacementMode.MousePoint,
|
||
StaysOpen = false,
|
||
AllowsTransparency = true,
|
||
PopupAnimation = PopupAnimation.Fade,
|
||
};
|
||
_previewTabPopup.IsOpen = true;
|
||
}
|
||
|
||
/// <summary>프리뷰를 별도 팝업 창에서 엽니다.</summary>
|
||
private void OpenPreviewPopupWindow(string filePath)
|
||
{
|
||
if (!System.IO.File.Exists(filePath)) return;
|
||
|
||
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
|
||
var fileName = System.IO.Path.GetFileName(filePath);
|
||
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
|
||
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
|
||
var win = new Window
|
||
{
|
||
Title = $"미리보기 — {fileName}",
|
||
Width = 900,
|
||
Height = 700,
|
||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||
Background = bg,
|
||
};
|
||
|
||
FrameworkElement content;
|
||
|
||
switch (ext)
|
||
{
|
||
case ".html":
|
||
case ".htm":
|
||
var wv = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||
wv.Loaded += async (_, _) =>
|
||
{
|
||
try
|
||
{
|
||
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
|
||
userDataFolder: WebView2DataFolder);
|
||
await wv.EnsureCoreWebView2Async(env);
|
||
wv.Source = new Uri(filePath);
|
||
}
|
||
catch { }
|
||
};
|
||
content = wv;
|
||
break;
|
||
|
||
case ".md":
|
||
var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||
var mdMood = _selectedMood;
|
||
mdWv.Loaded += async (_, _) =>
|
||
{
|
||
try
|
||
{
|
||
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
|
||
userDataFolder: WebView2DataFolder);
|
||
await mdWv.EnsureCoreWebView2Async(env);
|
||
var mdSrc = System.IO.File.ReadAllText(filePath);
|
||
if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000];
|
||
var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
|
||
mdWv.NavigateToString(html);
|
||
}
|
||
catch { }
|
||
};
|
||
content = mdWv;
|
||
break;
|
||
|
||
case ".csv":
|
||
var dg = new System.Windows.Controls.DataGrid
|
||
{
|
||
AutoGenerateColumns = true,
|
||
IsReadOnly = true,
|
||
Background = Brushes.Transparent,
|
||
Foreground = Brushes.White,
|
||
BorderThickness = new Thickness(0),
|
||
FontSize = 12,
|
||
};
|
||
try
|
||
{
|
||
var lines = System.IO.File.ReadAllLines(filePath);
|
||
if (lines.Length > 0)
|
||
{
|
||
var dt = new System.Data.DataTable();
|
||
var headers = ParseCsvLine(lines[0]);
|
||
foreach (var h in headers) dt.Columns.Add(h);
|
||
for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
|
||
{
|
||
var vals = ParseCsvLine(lines[i]);
|
||
var row = dt.NewRow();
|
||
for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++)
|
||
row[j] = vals[j];
|
||
dt.Rows.Add(row);
|
||
}
|
||
dg.ItemsSource = dt.DefaultView;
|
||
}
|
||
}
|
||
catch { }
|
||
content = dg;
|
||
break;
|
||
|
||
default:
|
||
var text = System.IO.File.ReadAllText(filePath);
|
||
if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)";
|
||
var sv = new ScrollViewer
|
||
{
|
||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||
Padding = new Thickness(20),
|
||
Content = new TextBlock
|
||
{
|
||
Text = text,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
FontFamily = new FontFamily("Consolas"),
|
||
FontSize = 13,
|
||
Foreground = fg,
|
||
},
|
||
};
|
||
content = sv;
|
||
break;
|
||
}
|
||
|
||
win.Content = content;
|
||
win.Show();
|
||
}
|
||
|
||
// ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
|
||
|
||
private DateTime _progressStartTime;
|
||
private DispatcherTimer? _progressElapsedTimer;
|
||
|
||
private void UpdateAgentProgressBar(AgentEvent evt)
|
||
{
|
||
switch (evt.Type)
|
||
{
|
||
case AgentEventType.Planning when evt.Steps is { Count: > 0 }:
|
||
ShowStickyProgress(evt.Steps.Count);
|
||
break;
|
||
|
||
case AgentEventType.StepStart when evt.StepTotal > 0:
|
||
UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary);
|
||
break;
|
||
|
||
case AgentEventType.Complete:
|
||
HideStickyProgress();
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void ShowStickyProgress(int totalSteps)
|
||
{
|
||
_progressStartTime = DateTime.Now;
|
||
AgentProgressBar.Visibility = Visibility.Visible;
|
||
ProgressIcon.Text = "\uE768"; // play
|
||
ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})";
|
||
ProgressPercent.Text = "0%";
|
||
ProgressElapsed.Text = "0:00";
|
||
ProgressFill.Width = 0;
|
||
|
||
// 경과 시간 타이머
|
||
_progressElapsedTimer?.Stop();
|
||
_progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||
_progressElapsedTimer.Tick += (_, _) =>
|
||
{
|
||
var elapsed = DateTime.Now - _progressStartTime;
|
||
ProgressElapsed.Text = elapsed.TotalHours >= 1
|
||
? elapsed.ToString(@"h\:mm\:ss")
|
||
: elapsed.ToString(@"m\:ss");
|
||
};
|
||
_progressElapsedTimer.Start();
|
||
}
|
||
|
||
private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription)
|
||
{
|
||
if (AgentProgressBar.Visibility != Visibility.Visible) return;
|
||
|
||
var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0;
|
||
ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})";
|
||
ProgressPercent.Text = $"{(int)(pct * 100)}%";
|
||
|
||
// 프로그레스 바 너비 애니메이션
|
||
var parentBorder = ProgressFill.Parent as Border;
|
||
if (parentBorder != null)
|
||
{
|
||
var targetWidth = parentBorder.ActualWidth * pct;
|
||
var anim = new System.Windows.Media.Animation.DoubleAnimation(
|
||
ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300))
|
||
{
|
||
EasingFunction = new System.Windows.Media.Animation.QuadraticEase(),
|
||
};
|
||
ProgressFill.BeginAnimation(WidthProperty, anim);
|
||
}
|
||
}
|
||
|
||
private void HideStickyProgress()
|
||
{
|
||
_progressElapsedTimer?.Stop();
|
||
_progressElapsedTimer = null;
|
||
|
||
if (AgentProgressBar.Visibility != Visibility.Visible) return;
|
||
|
||
// 완료 표시 후 페이드아웃
|
||
ProgressIcon.Text = "\uE930"; // check
|
||
ProgressStepLabel.Text = "작업 완료";
|
||
ProgressPercent.Text = "100%";
|
||
|
||
// 프로그레스 바 100%
|
||
var parentBorder = ProgressFill.Parent as Border;
|
||
if (parentBorder != null)
|
||
{
|
||
var anim = new System.Windows.Media.Animation.DoubleAnimation(
|
||
ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200));
|
||
ProgressFill.BeginAnimation(WidthProperty, anim);
|
||
}
|
||
|
||
// 3초 후 숨기기
|
||
var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||
hideTimer.Tick += (_, _) =>
|
||
{
|
||
hideTimer.Stop();
|
||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
|
||
fadeOut.Completed += (_, _) =>
|
||
{
|
||
AgentProgressBar.Visibility = Visibility.Collapsed;
|
||
AgentProgressBar.Opacity = 1;
|
||
ProgressFill.BeginAnimation(WidthProperty, null);
|
||
ProgressFill.Width = 0;
|
||
};
|
||
AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut);
|
||
};
|
||
hideTimer.Start();
|
||
}
|
||
|
||
// ─── 파일 탐색기 ──────────────────────────────────────────────────────
|
||
|
||
private static readonly HashSet<string> _ignoredDirs = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
|
||
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
|
||
".cache", ".next", ".nuxt", "coverage", ".terraform",
|
||
};
|
||
|
||
private DispatcherTimer? _fileBrowserRefreshTimer;
|
||
|
||
private void ToggleFileBrowser()
|
||
{
|
||
if (FileBrowserPanel.Visibility == Visibility.Visible)
|
||
{
|
||
FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||
_settings.Settings.Llm.ShowFileBrowser = false;
|
||
}
|
||
else
|
||
{
|
||
FileBrowserPanel.Visibility = Visibility.Visible;
|
||
_settings.Settings.Llm.ShowFileBrowser = true;
|
||
BuildFileTree();
|
||
}
|
||
_settings.Save();
|
||
}
|
||
|
||
private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
|
||
|
||
private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var folder = GetCurrentWorkFolder();
|
||
if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return;
|
||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch { }
|
||
}
|
||
|
||
private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private void BuildFileTree()
|
||
{
|
||
FileTreeView.Items.Clear();
|
||
var folder = GetCurrentWorkFolder();
|
||
if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder))
|
||
{
|
||
FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
|
||
return;
|
||
}
|
||
|
||
FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}";
|
||
var count = 0;
|
||
PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
|
||
}
|
||
|
||
private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count)
|
||
{
|
||
if (depth > 4 || count > 200) return;
|
||
|
||
// 디렉터리
|
||
try
|
||
{
|
||
foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
|
||
{
|
||
if (count > 200) break;
|
||
if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue;
|
||
|
||
count++;
|
||
var dirItem = new TreeViewItem
|
||
{
|
||
Header = CreateFileTreeHeader("\uED25", subDir.Name, null),
|
||
Tag = subDir.FullName,
|
||
IsExpanded = depth < 1,
|
||
};
|
||
|
||
// 지연 로딩: 더미 자식 → 펼칠 때 실제 로드
|
||
if (depth < 3)
|
||
{
|
||
dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미
|
||
var capturedDir = subDir;
|
||
var capturedDepth = depth;
|
||
dirItem.Expanded += (s, _) =>
|
||
{
|
||
if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
|
||
{
|
||
ti.Items.Clear();
|
||
int c = 0;
|
||
PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
|
||
}
|
||
};
|
||
}
|
||
else
|
||
{
|
||
PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count);
|
||
}
|
||
|
||
items.Add(dirItem);
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
// 파일
|
||
try
|
||
{
|
||
foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
|
||
{
|
||
if (count > 200) break;
|
||
count++;
|
||
|
||
var ext = file.Extension.ToLowerInvariant();
|
||
var icon = GetFileIcon(ext);
|
||
var size = FormatFileSize(file.Length);
|
||
|
||
var fileItem = new TreeViewItem
|
||
{
|
||
Header = CreateFileTreeHeader(icon, file.Name, size),
|
||
Tag = file.FullName,
|
||
};
|
||
|
||
// 더블클릭 → 프리뷰
|
||
var capturedPath = file.FullName;
|
||
fileItem.MouseDoubleClick += (s, e) =>
|
||
{
|
||
e.Handled = true;
|
||
TryShowPreview(capturedPath);
|
||
};
|
||
|
||
// 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음)
|
||
fileItem.MouseRightButtonUp += (s, e) =>
|
||
{
|
||
e.Handled = true;
|
||
if (s is TreeViewItem ti) ti.IsSelected = true;
|
||
ShowFileTreeContextMenu(capturedPath);
|
||
};
|
||
|
||
items.Add(fileItem);
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText)
|
||
{
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 5, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = name,
|
||
FontSize = 11.5,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
if (sizeText != null)
|
||
{
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = $" {sizeText}",
|
||
FontSize = 10,
|
||
Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
}
|
||
return sp;
|
||
}
|
||
|
||
private static string GetFileIcon(string ext) => ext switch
|
||
{
|
||
".html" or ".htm" => "\uEB41",
|
||
".xlsx" or ".xls" => "\uE9F9",
|
||
".docx" or ".doc" => "\uE8A5",
|
||
".pdf" => "\uEA90",
|
||
".csv" => "\uE80A",
|
||
".md" => "\uE70B",
|
||
".json" or ".xml" => "\uE943",
|
||
".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F",
|
||
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943",
|
||
".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756",
|
||
".txt" or ".log" => "\uE8A5",
|
||
_ => "\uE7C3",
|
||
};
|
||
|
||
private static string FormatFileSize(long bytes) => bytes switch
|
||
{
|
||
< 1024 => $"{bytes} B",
|
||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||
};
|
||
|
||
private void ShowFileTreeContextMenu(string filePath)
|
||
{
|
||
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C));
|
||
|
||
var popup = new Popup
|
||
{
|
||
StaysOpen = false, AllowsTransparency = true, PopupAnimation = PopupAnimation.Fade,
|
||
Placement = PlacementMode.MousePoint,
|
||
};
|
||
var panel = new StackPanel { Margin = new Thickness(2) };
|
||
var container = new Border
|
||
{
|
||
Background = bg, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10), Padding = new Thickness(6), MinWidth = 200,
|
||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||
{
|
||
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
|
||
Color = Colors.Black, Direction = 270,
|
||
},
|
||
Child = panel,
|
||
};
|
||
popup.Child = container;
|
||
|
||
void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null)
|
||
{
|
||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 13, Foreground = iconColor ?? secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
|
||
});
|
||
sp.Children.Add(new TextBlock
|
||
{
|
||
Text = label, FontSize = 12.5, Foreground = labelColor ?? primaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
var item = new Border
|
||
{
|
||
Child = sp, Background = Brushes.Transparent,
|
||
CornerRadius = new CornerRadius(7), Cursor = Cursors.Hand,
|
||
Padding = new Thickness(10, 8, 14, 8), Margin = new Thickness(0, 1, 0, 1),
|
||
};
|
||
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||
item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; action(); };
|
||
panel.Children.Add(item);
|
||
}
|
||
|
||
void AddSep()
|
||
{
|
||
panel.Children.Add(new Border
|
||
{
|
||
Height = 1, Margin = new Thickness(10, 4, 10, 4),
|
||
Background = borderBrush, Opacity = 0.3,
|
||
});
|
||
}
|
||
|
||
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
|
||
if (_previewableExtensions.Contains(ext))
|
||
AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
|
||
|
||
AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
|
||
{
|
||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch { }
|
||
});
|
||
AddItem("\uED25", "폴더에서 보기", () =>
|
||
{
|
||
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch { }
|
||
});
|
||
AddItem("\uE8C8", "경로 복사", () =>
|
||
{
|
||
try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch { }
|
||
});
|
||
|
||
AddSep();
|
||
|
||
// 이름 변경
|
||
AddItem("\uE8AC", "이름 변경", () =>
|
||
{
|
||
var dir = System.IO.Path.GetDirectoryName(filePath) ?? "";
|
||
var oldName = System.IO.Path.GetFileName(filePath);
|
||
var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
|
||
if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
|
||
{
|
||
var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim());
|
||
try
|
||
{
|
||
System.IO.File.Move(filePath, newPath);
|
||
BuildFileTree();
|
||
ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
|
||
}
|
||
catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); }
|
||
}
|
||
});
|
||
|
||
// 삭제
|
||
AddItem("\uE74D", "삭제", () =>
|
||
{
|
||
var result = CustomMessageBox.Show(
|
||
$"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}",
|
||
"파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||
if (result == MessageBoxResult.Yes)
|
||
{
|
||
try
|
||
{
|
||
System.IO.File.Delete(filePath);
|
||
BuildFileTree();
|
||
ShowToast("파일 삭제됨");
|
||
}
|
||
catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); }
|
||
}
|
||
}, dangerBrush, dangerBrush);
|
||
|
||
// Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음
|
||
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; },
|
||
System.Windows.Threading.DispatcherPriority.Input);
|
||
}
|
||
|
||
/// <summary>에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.</summary>
|
||
private void RefreshFileTreeIfVisible()
|
||
{
|
||
if (FileBrowserPanel.Visibility != Visibility.Visible) return;
|
||
|
||
// 디바운스: 500ms 내 중복 호출 방지
|
||
_fileBrowserRefreshTimer?.Stop();
|
||
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||
_fileBrowserRefreshTimer.Tick += (_, _) =>
|
||
{
|
||
_fileBrowserRefreshTimer.Stop();
|
||
BuildFileTree();
|
||
};
|
||
_fileBrowserRefreshTimer.Start();
|
||
}
|
||
|
||
// ─── 하단 상태바 ──────────────────────────────────────────────────────
|
||
|
||
private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard;
|
||
|
||
private void UpdateStatusBar(AgentEvent evt)
|
||
{
|
||
var toolLabel = evt.ToolName switch
|
||
{
|
||
"file_read" or "document_read" => "파일 읽기",
|
||
"file_write" => "파일 쓰기",
|
||
"file_edit" => "파일 수정",
|
||
"html_create" => "HTML 생성",
|
||
"xlsx_create" => "Excel 생성",
|
||
"docx_create" => "Word 생성",
|
||
"csv_create" => "CSV 생성",
|
||
"md_create" => "Markdown 생성",
|
||
"folder_map" => "폴더 탐색",
|
||
"glob" => "파일 검색",
|
||
"grep" => "내용 검색",
|
||
"process" => "명령 실행",
|
||
_ => evt.ToolName,
|
||
};
|
||
|
||
switch (evt.Type)
|
||
{
|
||
case AgentEventType.Thinking:
|
||
SetStatus("생각 중...", spinning: true);
|
||
break;
|
||
case AgentEventType.Planning:
|
||
SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true);
|
||
break;
|
||
case AgentEventType.PermissionRequest:
|
||
SetStatus($"권한 확인 중: {toolLabel}", spinning: false);
|
||
break;
|
||
case AgentEventType.PermissionGranted:
|
||
SetStatus($"권한 승인됨: {toolLabel}", spinning: false);
|
||
break;
|
||
case AgentEventType.PermissionDenied:
|
||
SetStatus($"권한 거부됨: {toolLabel}", spinning: false);
|
||
StopStatusAnimation();
|
||
break;
|
||
case AgentEventType.Decision:
|
||
SetStatus(GetDecisionStatusText(evt.Summary), spinning: IsDecisionPending(evt.Summary));
|
||
break;
|
||
case AgentEventType.ToolCall:
|
||
SetStatus($"{toolLabel} 실행 중...", spinning: true);
|
||
break;
|
||
case AgentEventType.ToolResult:
|
||
SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false);
|
||
break;
|
||
case AgentEventType.StepStart:
|
||
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true);
|
||
break;
|
||
case AgentEventType.StepDone:
|
||
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true);
|
||
break;
|
||
case AgentEventType.SkillCall:
|
||
SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true);
|
||
break;
|
||
case AgentEventType.Complete:
|
||
SetStatus("작업 완료", spinning: false);
|
||
StopStatusAnimation();
|
||
break;
|
||
case AgentEventType.Error:
|
||
SetStatus("오류 발생", spinning: false);
|
||
StopStatusAnimation();
|
||
break;
|
||
case AgentEventType.Paused:
|
||
SetStatus("⏸ 일시정지", spinning: false);
|
||
break;
|
||
case AgentEventType.Resumed:
|
||
SetStatus("▶ 재개됨", spinning: true);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void SetStatus(string text, bool spinning)
|
||
{
|
||
if (StatusLabel != null) StatusLabel.Text = text;
|
||
if (spinning) StartStatusAnimation();
|
||
}
|
||
|
||
private static (string icon, string label, string bgHex, string fgHex) GetDecisionBadgeMeta(string? summary)
|
||
{
|
||
if (IsDecisionApproved(summary))
|
||
return ("\uE73E", "계획 승인", "#ECFDF5", "#059669");
|
||
if (IsDecisionRejected(summary))
|
||
return ("\uE783", "계획 반려", "#FEF2F2", "#DC2626");
|
||
return ("\uE70F", "계획 확인", "#FFF7ED", "#C2410C");
|
||
}
|
||
|
||
private static (string icon, string label, string bgHex, string fgHex) GetPermissionBadgeMeta(string? toolName, bool pending)
|
||
{
|
||
var tool = toolName?.Trim().ToLowerInvariant() ?? "";
|
||
|
||
if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell"))
|
||
return pending
|
||
? ("\uE756", "명령 권한 요청", "#FEF2F2", "#DC2626")
|
||
: ("\uE73E", "명령 권한 허용", "#ECFDF5", "#059669");
|
||
|
||
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
||
return pending
|
||
? ("\uE774", "네트워크 권한 요청", "#FFF7ED", "#C2410C")
|
||
: ("\uE73E", "네트워크 권한 허용", "#ECFDF5", "#059669");
|
||
|
||
if (tool.Contains("file"))
|
||
return pending
|
||
? ("\uE8A5", "파일 권한 요청", "#FFF7ED", "#C2410C")
|
||
: ("\uE73E", "파일 권한 허용", "#ECFDF5", "#059669");
|
||
|
||
return pending
|
||
? ("\uE897", "권한 요청", "#FFF7ED", "#C2410C")
|
||
: ("\uE73E", "권한 허용", "#ECFDF5", "#059669");
|
||
}
|
||
|
||
private static bool IsDecisionPending(string? summary)
|
||
{
|
||
var text = summary?.Trim() ?? "";
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return true;
|
||
|
||
return text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase)
|
||
|| text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static bool IsDecisionApproved(string? summary)
|
||
{
|
||
var text = summary?.Trim() ?? "";
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return false;
|
||
|
||
return text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static bool IsDecisionRejected(string? summary)
|
||
{
|
||
var text = summary?.Trim() ?? "";
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return false;
|
||
|
||
return text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase)
|
||
|| text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase)
|
||
|| text.Contains("취소", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static string GetDecisionStatusText(string? summary)
|
||
{
|
||
if (IsDecisionPending(summary))
|
||
return "계획 승인 대기 중";
|
||
if (IsDecisionApproved(summary))
|
||
return "계획 승인됨 — 실행 시작";
|
||
if (IsDecisionRejected(summary))
|
||
return "계획 반려됨 — 계획 재작성";
|
||
return string.IsNullOrWhiteSpace(summary) ? "사용자 의사결정 대기 중" : TruncateForStatus(summary);
|
||
}
|
||
|
||
private void StartStatusAnimation()
|
||
{
|
||
if (_statusSpinStoryboard != null) return;
|
||
|
||
var anim = new System.Windows.Media.Animation.DoubleAnimation
|
||
{
|
||
From = 0, To = 360,
|
||
Duration = TimeSpan.FromSeconds(2),
|
||
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
|
||
};
|
||
|
||
_statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard();
|
||
System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond);
|
||
System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim,
|
||
new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)"));
|
||
_statusSpinStoryboard.Children.Add(anim);
|
||
_statusSpinStoryboard.Begin();
|
||
}
|
||
|
||
private void StopStatusAnimation()
|
||
{
|
||
_statusSpinStoryboard?.Stop();
|
||
_statusSpinStoryboard = null;
|
||
}
|
||
|
||
private void SetStatusIdle()
|
||
{
|
||
StopStatusAnimation();
|
||
if (StatusLabel != null) StatusLabel.Text = "대기 중";
|
||
if (StatusElapsed != null) StatusElapsed.Text = "";
|
||
if (StatusTokens != null) StatusTokens.Text = "";
|
||
RefreshContextUsageVisual();
|
||
ScheduleGitBranchRefresh(250);
|
||
}
|
||
|
||
private void UpdateStatusTokens(int inputTokens, int outputTokens)
|
||
{
|
||
if (StatusTokens == null) return;
|
||
var llm = _settings.Settings.Llm;
|
||
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
|
||
inputTokens, outputTokens, llm.Service, llm.Model);
|
||
var totalCost = inCost + outCost;
|
||
var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : "";
|
||
StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}";
|
||
RefreshContextUsageVisual();
|
||
}
|
||
|
||
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 void RefreshContextUsageVisual()
|
||
{
|
||
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
|
||
|| TokenUsageSummaryText == null || TokenUsageHintText == null
|
||
|| TokenUsageThresholdMarker == null || CompactNowLabel == null)
|
||
return;
|
||
|
||
var llm = _settings.Settings.Llm;
|
||
var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000);
|
||
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
|
||
var triggerRatio = triggerPercent / 100.0;
|
||
|
||
int messageTokens;
|
||
lock (_convLock)
|
||
messageTokens = _currentConversation?.Messages?.Count > 0
|
||
? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages)
|
||
: 0;
|
||
|
||
var draftText = InputBox?.Text ?? "";
|
||
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
|
||
var currentTokens = Math.Max(0, messageTokens + draftTokens);
|
||
var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens);
|
||
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||
Brush progressBrush = accentBrush;
|
||
string summary;
|
||
string compactLabel;
|
||
|
||
if (usageRatio >= 1.0)
|
||
{
|
||
progressBrush = Brushes.IndianRed;
|
||
summary = "컨텍스트 한도 초과";
|
||
compactLabel = "지금 압축";
|
||
}
|
||
else if (usageRatio >= triggerRatio)
|
||
{
|
||
progressBrush = Brushes.DarkOrange;
|
||
summary = llm.EnableProactiveContextCompact ? "곧 자동 압축" : "압축 임계 도달";
|
||
compactLabel = "압축 권장";
|
||
}
|
||
else if (usageRatio >= triggerRatio * 0.7)
|
||
{
|
||
progressBrush = Brushes.Goldenrod;
|
||
summary = "컨텍스트 사용 증가";
|
||
compactLabel = "미리 압축";
|
||
}
|
||
else
|
||
{
|
||
summary = "컨텍스트 여유";
|
||
compactLabel = "압축";
|
||
}
|
||
|
||
TokenUsageArc.Stroke = progressBrush;
|
||
TokenUsageThresholdMarker.Fill = progressBrush;
|
||
var percentText = $"{Math.Round(usageRatio * 100):0}%";
|
||
TokenUsagePercentText.Text = percentText;
|
||
TokenUsageSummaryText.Text = $"컨텍스트 {percentText}";
|
||
TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}";
|
||
CompactNowLabel.Text = compactLabel;
|
||
var compactHistory = _lastCompactionAt.HasValue && _lastCompactionBeforeTokens.HasValue && _lastCompactionAfterTokens.HasValue
|
||
? $"\n최근 압축: {(_lastCompactionWasAutomatic ? "자동" : "수동")} · {_lastCompactionAt.Value:HH:mm:ss}\n" +
|
||
$"절감: {_lastCompactionBeforeTokens.Value:N0} → {_lastCompactionAfterTokens.Value:N0} tokens " +
|
||
$"(-{Math.Max(0, _lastCompactionBeforeTokens.Value - _lastCompactionAfterTokens.Value):N0}, " +
|
||
$"{Services.TokenEstimator.Format(_lastCompactionBeforeTokens.Value)} → {Services.TokenEstimator.Format(_lastCompactionAfterTokens.Value)})"
|
||
: "";
|
||
|
||
TokenUsageCard.ToolTip =
|
||
$"상태: {summary}\n" +
|
||
$"사용량: {currentTokens:N0} / {maxContextTokens:N0} tokens ({percentText})\n" +
|
||
$"간단 표기: {Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}\n" +
|
||
$"자동 압축 시작: {triggerPercent}%\n" +
|
||
$"현재 입력 초안 포함" +
|
||
compactHistory;
|
||
|
||
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 18, 18, 14);
|
||
PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 18, 18, 14, 3);
|
||
}
|
||
|
||
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(int beforeTokens, int afterTokens, bool wasAutomatic)
|
||
{
|
||
_lastCompactionBeforeTokens = Math.Max(0, beforeTokens);
|
||
_lastCompactionAfterTokens = Math.Max(0, afterTokens);
|
||
_lastCompactionAt = DateTime.Now;
|
||
_lastCompactionWasAutomatic = wasAutomatic;
|
||
}
|
||
|
||
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 void UpdateGitBranchUi(string? branchName, string filesText, string addedText, string deletedText, string tooltip, Visibility visibility)
|
||
{
|
||
Dispatcher.Invoke(() =>
|
||
{
|
||
_currentGitBranchName = branchName;
|
||
_currentGitTooltip = tooltip;
|
||
|
||
if (BtnGitBranch != null)
|
||
{
|
||
BtnGitBranch.Visibility = visibility;
|
||
BtnGitBranch.ToolTip = string.IsNullOrWhiteSpace(tooltip) ? "현재 Git 브랜치 상태" : tooltip;
|
||
}
|
||
|
||
if (GitBranchLabel != null)
|
||
GitBranchLabel.Text = string.IsNullOrWhiteSpace(branchName) ? "브랜치 없음" : branchName;
|
||
if (GitBranchFilesText != null)
|
||
GitBranchFilesText.Text = filesText;
|
||
if (GitBranchAddedText != null)
|
||
GitBranchAddedText.Text = addedText;
|
||
if (GitBranchDeletedText != null)
|
||
GitBranchDeletedText.Text = deletedText;
|
||
if (GitBranchSeparator != null)
|
||
GitBranchSeparator.Visibility = visibility;
|
||
});
|
||
}
|
||
|
||
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 void BuildGitBranchPopup()
|
||
{
|
||
if (GitBranchItems == null)
|
||
return;
|
||
|
||
GitBranchItems.Children.Clear();
|
||
|
||
var gitRoot = _currentGitRoot ?? ResolveGitRoot(GetCurrentWorkFolder());
|
||
var branchName = _currentGitBranchName ?? "detached";
|
||
var tooltip = _currentGitTooltip ?? "";
|
||
var fileText = GitBranchFilesText?.Text ?? "";
|
||
var addedText = GitBranchAddedText?.Text ?? "";
|
||
var deletedText = GitBranchDeletedText?.Text ?? "";
|
||
var query = (_gitBranchSearchText ?? "").Trim();
|
||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
|
||
GitBranchItems.Children.Add(CreatePopupSummaryStrip(new[]
|
||
{
|
||
("브랜치", string.IsNullOrWhiteSpace(branchName) ? "없음" : branchName, "#F8FAFC", "#E2E8F0", "#475569"),
|
||
("파일", string.IsNullOrWhiteSpace(fileText) ? "0" : fileText, "#EFF6FF", "#BFDBFE", "#1D4ED8"),
|
||
("최근", _recentGitBranches.Count.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"),
|
||
}));
|
||
GitBranchItems.Children.Add(CreatePopupSectionLabel("현재 브랜치", new Thickness(8, 6, 8, 4)));
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
"\uE943",
|
||
branchName,
|
||
string.IsNullOrWhiteSpace(fileText) ? "현재 브랜치" : fileText,
|
||
true,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() => { }));
|
||
|
||
if (!string.IsNullOrWhiteSpace(addedText) || !string.IsNullOrWhiteSpace(deletedText))
|
||
{
|
||
var stats = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(8, 2, 8, 8),
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(addedText))
|
||
stats.Children.Add(CreateMetricPill(addedText, "#16A34A"));
|
||
if (!string.IsNullOrWhiteSpace(deletedText))
|
||
stats.Children.Add(CreateMetricPill(deletedText, "#DC2626"));
|
||
GitBranchItems.Children.Add(stats);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(gitRoot))
|
||
{
|
||
GitBranchItems.Children.Add(CreatePopupSectionLabel("저장소", new Thickness(8, 6, 8, 4)));
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
"\uED25",
|
||
System.IO.Path.GetFileName(gitRoot.TrimEnd('\\', '/')),
|
||
gitRoot,
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() => { }));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(_currentGitUpstreamStatus))
|
||
{
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
"\uE8AB",
|
||
"업스트림",
|
||
_currentGitUpstreamStatus!,
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() => { }));
|
||
}
|
||
|
||
GitBranchItems.Children.Add(CreatePopupSectionLabel("빠른 작업", new Thickness(8, 10, 8, 4)));
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
"\uE8C8",
|
||
"상태 요약 복사",
|
||
"브랜치, 변경 파일, 추가/삭제 라인 복사",
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() =>
|
||
{
|
||
try { Clipboard.SetText(tooltip); } catch { }
|
||
GitBranchPopup.IsOpen = false;
|
||
}));
|
||
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
"\uE72B",
|
||
"새로고침",
|
||
"Git 상태를 다시 조회합니다",
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
async () =>
|
||
{
|
||
await RefreshGitBranchStatusAsync();
|
||
BuildGitBranchPopup();
|
||
}));
|
||
|
||
var filteredBranches = _currentGitBranches
|
||
.Where(branch => string.IsNullOrWhiteSpace(query)
|
||
|| branch.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||
.Take(20)
|
||
.ToList();
|
||
|
||
var recentBranches = _recentGitBranches
|
||
.Where(branch => _currentGitBranches.Any(current => string.Equals(current, branch, StringComparison.OrdinalIgnoreCase)))
|
||
.Where(branch => string.IsNullOrWhiteSpace(query)
|
||
|| branch.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||
.Take(5)
|
||
.ToList();
|
||
|
||
if (recentBranches.Count > 0)
|
||
{
|
||
var recentSectionLabel = string.IsNullOrWhiteSpace(query)
|
||
? $"최근 전환 · {recentBranches.Count}"
|
||
: $"최근 전환 · {recentBranches.Count}";
|
||
GitBranchItems.Children.Add(CreatePopupSectionLabel(recentSectionLabel, new Thickness(8, 10, 8, 4)));
|
||
|
||
foreach (var branch in recentBranches)
|
||
{
|
||
var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase);
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
isCurrent ? "\uE73E" : "\uE8FD",
|
||
branch,
|
||
isCurrent ? "현재 브랜치" : "최근 사용 브랜치",
|
||
isCurrent,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
isCurrent ? null : () => _ = SwitchGitBranchAsync(branch)));
|
||
}
|
||
}
|
||
|
||
if (_currentGitBranches.Count > 0)
|
||
{
|
||
var branchSectionLabel = string.IsNullOrWhiteSpace(query)
|
||
? $"브랜치 전환 · {_currentGitBranches.Count}"
|
||
: $"브랜치 전환 · {filteredBranches.Count}/{_currentGitBranches.Count}";
|
||
GitBranchItems.Children.Add(CreatePopupSectionLabel(branchSectionLabel, new Thickness(8, 10, 8, 4)));
|
||
|
||
foreach (var branch in filteredBranches)
|
||
{
|
||
if (recentBranches.Any(recent => string.Equals(recent, branch, StringComparison.OrdinalIgnoreCase)))
|
||
continue;
|
||
|
||
var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase);
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
isCurrent ? "\uE73E" : "\uE943",
|
||
branch,
|
||
isCurrent ? "현재 브랜치" : "이 브랜치로 전환",
|
||
isCurrent,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
isCurrent ? null : () => _ = SwitchGitBranchAsync(branch)));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(query) && filteredBranches.Count == 0)
|
||
{
|
||
GitBranchItems.Children.Add(new TextBlock
|
||
{
|
||
Text = "검색 결과가 없습니다.",
|
||
FontSize = 11.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 6, 10, 10),
|
||
});
|
||
}
|
||
}
|
||
|
||
GitBranchItems.Children.Add(CreatePopupSectionLabel("브랜치 작업", new Thickness(8, 10, 8, 4)));
|
||
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||
"\uE710",
|
||
"새 브랜치 생성",
|
||
"현재 작업 기준으로 새 브랜치를 만들고 전환합니다",
|
||
false,
|
||
accentBrush,
|
||
secondaryText,
|
||
primaryText,
|
||
() => _ = CreateGitBranchAsync()));
|
||
}
|
||
|
||
private TextBlock CreatePopupSectionLabel(string text, Thickness? margin = null)
|
||
{
|
||
return new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||
Margin = margin ?? new Thickness(8, 8, 8, 4),
|
||
};
|
||
}
|
||
|
||
private UIElement CreatePopupSummaryStrip(IEnumerable<(string Label, string Value, string BgHex, string BorderHex, string FgHex)> items)
|
||
{
|
||
var wrap = new WrapPanel
|
||
{
|
||
Margin = new Thickness(8, 6, 8, 6),
|
||
};
|
||
|
||
foreach (var item in items)
|
||
{
|
||
wrap.Children.Add(CreateMetricPill($"{item.Label} {item.Value}", item.FgHex, item.BgHex, item.BorderHex));
|
||
}
|
||
|
||
return wrap;
|
||
}
|
||
|
||
private Border CreateMetricPill(string text, string colorHex)
|
||
=> CreateMetricPill(text, colorHex, $"{colorHex}18", $"{colorHex}44");
|
||
|
||
private Border CreateMetricPill(string text, string colorHex, string bgHex, string borderHex)
|
||
{
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex(bgHex),
|
||
BorderBrush = BrushFromHex(borderHex),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Padding = new Thickness(8, 3, 8, 3),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex(colorHex),
|
||
}
|
||
};
|
||
}
|
||
|
||
private Border CreateFlatPopupRow(string icon, string title, string description, string colorHex, bool clickable, Action? onClick)
|
||
{
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||
var borderColor = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
var border = new Border
|
||
{
|
||
Background = Brushes.Transparent,
|
||
BorderBrush = borderColor,
|
||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||
Padding = new Thickness(8, 9, 8, 9),
|
||
Cursor = clickable ? Cursors.Hand : Cursors.Arrow,
|
||
Focusable = clickable,
|
||
};
|
||
KeyboardNavigation.SetIsTabStop(border, clickable);
|
||
|
||
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 = icon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = BrushFromHex(colorHex),
|
||
VerticalAlignment = VerticalAlignment.Top,
|
||
Margin = new Thickness(0, 1, 10, 0),
|
||
});
|
||
|
||
var textStack = new StackPanel();
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = title,
|
||
FontSize = 12,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
});
|
||
if (!string.IsNullOrWhiteSpace(description))
|
||
{
|
||
textStack.Children.Add(new TextBlock
|
||
{
|
||
Text = description,
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
Grid.SetColumn(textStack, 1);
|
||
grid.Children.Add(textStack);
|
||
if (clickable)
|
||
{
|
||
var chevron = new TextBlock
|
||
{
|
||
Text = "\uE76C",
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(8, 0, 0, 0),
|
||
};
|
||
Grid.SetColumn(chevron, 2);
|
||
grid.Children.Add(chevron);
|
||
}
|
||
border.Child = grid;
|
||
|
||
if (clickable && onClick != null)
|
||
{
|
||
border.MouseEnter += (_, _) => border.Background = hoverBrush;
|
||
border.MouseLeave += (_, _) => border.Background = Brushes.Transparent;
|
||
border.MouseLeftButtonUp += (_, _) => onClick();
|
||
border.KeyDown += (_, ke) =>
|
||
{
|
||
if (ke.Key is Key.Enter or Key.Space)
|
||
{
|
||
ke.Handled = true;
|
||
onClick();
|
||
}
|
||
};
|
||
}
|
||
|
||
return border;
|
||
}
|
||
|
||
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 string BuildComposerDraftText()
|
||
{
|
||
var rawText = InputBox?.Text?.Trim() ?? "";
|
||
return _slashPalette.ActiveCommand != null
|
||
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
|
||
: rawText;
|
||
}
|
||
|
||
private static string InferDraftKind(string text, string? explicitKind = null)
|
||
{
|
||
var trimmed = text?.Trim() ?? "";
|
||
var requestedKind = explicitKind?.Trim().ToLowerInvariant();
|
||
|
||
if (requestedKind is "followup" or "steering")
|
||
return requestedKind;
|
||
|
||
if (trimmed.StartsWith("/", StringComparison.OrdinalIgnoreCase))
|
||
return "command";
|
||
|
||
if (requestedKind is "direct" or "message")
|
||
return requestedKind;
|
||
|
||
if (trimmed.StartsWith("steer:", StringComparison.OrdinalIgnoreCase) ||
|
||
trimmed.StartsWith("@steer ", StringComparison.OrdinalIgnoreCase) ||
|
||
trimmed.StartsWith("조정:", StringComparison.OrdinalIgnoreCase))
|
||
return "steering";
|
||
|
||
return "message";
|
||
}
|
||
|
||
private void QueueComposerDraft(string priority = "next", string? explicitKind = null, bool startImmediatelyWhenIdle = true)
|
||
{
|
||
if (InputBox == null)
|
||
return;
|
||
|
||
var text = BuildComposerDraftText();
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
return;
|
||
|
||
if (_isStreaming && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase))
|
||
priority = "next";
|
||
|
||
HideSlashChip(restoreText: false);
|
||
ClearPromptCardPlaceholder();
|
||
|
||
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;
|
||
}
|
||
|
||
InputBox.Clear();
|
||
InputBox.Focus();
|
||
RefreshDraftQueueUi();
|
||
|
||
if (queuedItem == null)
|
||
return;
|
||
|
||
if (!_isStreaming && startImmediatelyWhenIdle)
|
||
{
|
||
StartNextQueuedDraftIfAny(queuedItem.Id);
|
||
return;
|
||
}
|
||
|
||
var toast = queuedItem.Kind switch
|
||
{
|
||
"command" => "명령이 대기열에 추가되었습니다.",
|
||
"direct" => "직접 실행 요청이 대기열에 추가되었습니다.",
|
||
"steering" => "조정 요청이 대기열에 추가되었습니다.",
|
||
"followup" => "후속 작업이 대기열에 추가되었습니다.",
|
||
_ => "메시지가 대기열에 추가되었습니다.",
|
||
};
|
||
ShowToast(toast);
|
||
}
|
||
|
||
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
|
||
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();
|
||
RefreshDraftQueueUi();
|
||
}
|
||
|
||
private void RefreshDraftQueueUi()
|
||
{
|
||
if (DraftPreviewCard == null || DraftPreviewText == null || DraftQueuePanel == null || BtnDraftEnqueue == null)
|
||
return;
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = ChatSession;
|
||
if (session != null)
|
||
_draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage);
|
||
}
|
||
|
||
var inputText = InputBox?.Text?.Trim() ?? "";
|
||
var hasInput = !string.IsNullOrWhiteSpace(inputText);
|
||
var summary = _appState.GetDraftQueueSummary(_activeTab);
|
||
var items = _appState.GetDraftQueueItems(_activeTab);
|
||
|
||
DraftPreviewCard.Visibility = hasInput ? Visibility.Visible : Visibility.Collapsed;
|
||
BtnDraftEnqueue.IsEnabled = hasInput;
|
||
DraftPreviewText.Text = hasInput
|
||
? $"{TruncateForStatus(inputText, 96)}{(summary.TotalCount > 0 ? $" · 대기 {summary.QueuedCount} · 실행 {summary.RunningCount}" : "")}"
|
||
: "";
|
||
|
||
RebuildDraftQueuePanel(items);
|
||
}
|
||
|
||
private void RebuildDraftQueuePanel(IReadOnlyList<DraftQueueItem> items)
|
||
{
|
||
if (DraftQueuePanel == null)
|
||
return;
|
||
|
||
DraftQueuePanel.Children.Clear();
|
||
|
||
var visibleItems = items
|
||
.OrderBy(GetDraftStateRank)
|
||
.ThenBy(GetDraftPriorityRank)
|
||
.ThenBy(x => x.CreatedAt)
|
||
.ToList();
|
||
|
||
if (visibleItems.Count == 0)
|
||
{
|
||
DraftQueuePanel.Visibility = Visibility.Collapsed;
|
||
return;
|
||
}
|
||
|
||
DraftQueuePanel.Visibility = Visibility.Visible;
|
||
var summary = _appState.GetDraftQueueSummary(_activeTab);
|
||
DraftQueuePanel.Children.Add(CreateDraftQueueSummaryStrip(summary));
|
||
const int maxPerSection = 3;
|
||
var runningItems = visibleItems
|
||
.Where(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
|
||
.Take(maxPerSection)
|
||
.ToList();
|
||
var queuedItems = visibleItems
|
||
.Where(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
|
||
.Take(maxPerSection)
|
||
.ToList();
|
||
var blockedItems = visibleItems
|
||
.Where(IsDraftBlocked)
|
||
.Take(maxPerSection)
|
||
.ToList();
|
||
var completedItems = visibleItems
|
||
.Where(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
|
||
.Take(maxPerSection)
|
||
.ToList();
|
||
var failedItems = visibleItems
|
||
.Where(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
|
||
.Take(maxPerSection)
|
||
.ToList();
|
||
|
||
AddDraftQueueSection("실행 중", runningItems, summary.RunningCount);
|
||
AddDraftQueueSection("다음 작업", queuedItems, summary.QueuedCount);
|
||
AddDraftQueueSection("보류", blockedItems, summary.BlockedCount);
|
||
AddDraftQueueSection("완료", completedItems, summary.CompletedCount);
|
||
AddDraftQueueSection("실패", failedItems, summary.FailedCount);
|
||
|
||
if (summary.CompletedCount > 0 || summary.FailedCount > 0)
|
||
{
|
||
var footer = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
};
|
||
|
||
if (summary.CompletedCount > 0)
|
||
footer.Children.Add(CreateDraftQueueActionButton($"완료 정리 {summary.CompletedCount}", ClearCompletedDrafts, BrushFromHex("#ECFDF5")));
|
||
|
||
if (summary.FailedCount > 0)
|
||
footer.Children.Add(CreateDraftQueueActionButton($"실패 정리 {summary.FailedCount}", ClearFailedDrafts, BrushFromHex("#FEF2F2")));
|
||
|
||
DraftQueuePanel.Children.Add(footer);
|
||
}
|
||
}
|
||
|
||
private void AddDraftQueueSection(string label, IReadOnlyList<DraftQueueItem> items, int totalCount)
|
||
{
|
||
if (DraftQueuePanel == null || totalCount <= 0)
|
||
return;
|
||
|
||
DraftQueuePanel.Children.Add(CreateDraftQueueSectionLabel($"{label} · {totalCount}"));
|
||
foreach (var item in items)
|
||
DraftQueuePanel.Children.Add(CreateDraftQueueCard(item));
|
||
|
||
if (totalCount > items.Count)
|
||
{
|
||
DraftQueuePanel.Children.Add(new TextBlock
|
||
{
|
||
Text = $"추가 항목 {totalCount - items.Count}개",
|
||
Margin = new Thickness(8, -2, 0, 8),
|
||
FontSize = 10.5,
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#7A7F87"),
|
||
});
|
||
}
|
||
}
|
||
|
||
private UIElement CreateDraftQueueSummaryStrip(AppStateService.DraftQueueSummaryState summary)
|
||
{
|
||
var wrap = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
};
|
||
|
||
if (summary.RunningCount > 0)
|
||
wrap.Children.Add(CreateQueueSummaryPill("실행 중", summary.RunningCount.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"));
|
||
if (summary.QueuedCount > 0)
|
||
wrap.Children.Add(CreateQueueSummaryPill("다음", summary.QueuedCount.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"));
|
||
if (summary.BlockedCount > 0)
|
||
wrap.Children.Add(CreateQueueSummaryPill("보류", summary.BlockedCount.ToString(), "#FFF7ED", "#FDBA74", "#C2410C"));
|
||
if (summary.CompletedCount > 0)
|
||
wrap.Children.Add(CreateQueueSummaryPill("완료", summary.CompletedCount.ToString(), "#ECFDF5", "#BBF7D0", "#166534"));
|
||
if (summary.FailedCount > 0)
|
||
wrap.Children.Add(CreateQueueSummaryPill("실패", summary.FailedCount.ToString(), "#FEF2F2", "#FECACA", "#991B1B"));
|
||
|
||
if (wrap.Children.Count == 0)
|
||
wrap.Children.Add(CreateQueueSummaryPill("대기열", "0", "#F8FAFC", "#E2E8F0", "#475569"));
|
||
|
||
return wrap;
|
||
}
|
||
|
||
private Border CreateQueueSummaryPill(string label, string value, string bgHex, string borderHex, string fgHex)
|
||
{
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex(bgHex),
|
||
BorderBrush = BrushFromHex(borderHex),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Padding = new Thickness(8, 3, 8, 3),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
Child = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = label,
|
||
FontSize = 10,
|
||
Foreground = BrushFromHex(fgHex),
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $" {value}",
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex(fgHex),
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
private TextBlock CreateDraftQueueSectionLabel(string text)
|
||
{
|
||
return new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"),
|
||
};
|
||
}
|
||
|
||
private Border CreateDraftQueueCard(DraftQueueItem item)
|
||
{
|
||
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
|
||
var neutralSurface = BrushFromHex("#F5F6F8");
|
||
var (kindIcon, kindForeground) = GetDraftKindVisual(item);
|
||
var (stateBackground, stateBorder, stateForeground) = GetDraftStateBadgeColors(item);
|
||
var (priorityBackground, priorityBorder, priorityForeground) = GetDraftPriorityBadgeColors(item.Priority);
|
||
|
||
var container = new Border
|
||
{
|
||
Background = background,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(14),
|
||
Padding = new Thickness(12, 10, 12, 10),
|
||
Margin = new Thickness(0, 0, 0, 8),
|
||
};
|
||
|
||
var root = new Grid();
|
||
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
container.Child = root;
|
||
|
||
var left = new StackPanel();
|
||
Grid.SetColumn(left, 0);
|
||
root.Children.Add(left);
|
||
|
||
var header = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
};
|
||
header.Children.Add(new TextBlock
|
||
{
|
||
Text = kindIcon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = kindForeground,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
});
|
||
header.Children.Add(CreateDraftQueueBadge(GetDraftKindLabel(item), BrushFromHex("#F8FAFC"), borderBrush, kindForeground));
|
||
header.Children.Add(CreateDraftQueueBadge(GetDraftStateLabel(item), stateBackground, stateBorder, stateForeground));
|
||
header.Children.Add(CreateDraftQueueBadge(GetDraftPriorityLabel(item.Priority), priorityBackground, priorityBorder, priorityForeground));
|
||
left.Children.Add(header);
|
||
|
||
left.Children.Add(new TextBlock
|
||
{
|
||
Text = item.Text,
|
||
FontSize = 12.5,
|
||
Foreground = primaryText,
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
MaxWidth = 520,
|
||
});
|
||
|
||
var meta = $"{item.CreatedAt:HH:mm}";
|
||
if (item.AttemptCount > 0)
|
||
meta += $" · 시도 {item.AttemptCount}";
|
||
if (item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now)
|
||
meta += $" · 재시도 {item.NextRetryAt.Value:HH:mm:ss}";
|
||
if (!string.IsNullOrWhiteSpace(item.LastError))
|
||
meta += $" · {TruncateForStatus(item.LastError, 36)}";
|
||
|
||
left.Children.Add(new TextBlock
|
||
{
|
||
Text = meta,
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(0, 6, 0, 0),
|
||
});
|
||
|
||
var actions = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
VerticalAlignment = VerticalAlignment.Top,
|
||
};
|
||
Grid.SetColumn(actions, 1);
|
||
root.Children.Add(actions);
|
||
|
||
if (!string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
|
||
actions.Children.Add(CreateDraftQueueActionButton("실행", () => QueueDraftForImmediateRun(item.Id)));
|
||
|
||
if (string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ||
|
||
string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
actions.Children.Add(CreateDraftQueueActionButton("대기", () => ResetDraftInQueue(item.Id), neutralSurface));
|
||
}
|
||
|
||
actions.Children.Add(CreateDraftQueueActionButton("삭제", () => RemoveDraftFromQueue(item.Id), neutralSurface));
|
||
return container;
|
||
}
|
||
|
||
private Border CreateDraftQueueBadge(string text, Brush background, Brush borderBrush, Brush foreground)
|
||
{
|
||
return new Border
|
||
{
|
||
Background = background,
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(999),
|
||
Padding = new Thickness(7, 2, 7, 2),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
Child = new TextBlock
|
||
{
|
||
Text = text,
|
||
FontSize = 10,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = foreground,
|
||
}
|
||
};
|
||
}
|
||
|
||
private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null)
|
||
{
|
||
var btn = new Button
|
||
{
|
||
Content = label,
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
Padding = new Thickness(10, 5, 10, 5),
|
||
MinWidth = 48,
|
||
FontSize = 11,
|
||
Background = background ?? BrushFromHex("#EEF2FF"),
|
||
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#D4D4D8"),
|
||
BorderThickness = new Thickness(1),
|
||
Foreground = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827"),
|
||
Cursor = Cursors.Hand,
|
||
};
|
||
btn.Click += (_, _) => onClick();
|
||
return btn;
|
||
}
|
||
|
||
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 || InputBox == null)
|
||
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;
|
||
}
|
||
|
||
InputBox.Text = next.Text;
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
InputBox.Focus();
|
||
RefreshDraftQueueUi();
|
||
_ = SendMessageAsync();
|
||
}
|
||
|
||
private static int GetDraftStateRank(DraftQueueItem item)
|
||
=> string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0
|
||
: IsDraftBlocked(item) ? 1
|
||
: string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) ? 2
|
||
: string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ? 3
|
||
: 4;
|
||
|
||
private static int GetDraftPriorityRank(DraftQueueItem item)
|
||
=> item.Priority?.ToLowerInvariant() switch
|
||
{
|
||
"now" => 0,
|
||
"next" => 1,
|
||
_ => 2,
|
||
};
|
||
|
||
private static string GetDraftPriorityLabel(string? priority)
|
||
=> priority?.ToLowerInvariant() switch
|
||
{
|
||
"now" => "지금",
|
||
"later" => "나중",
|
||
_ => "다음",
|
||
};
|
||
|
||
private static string GetDraftKindLabel(DraftQueueItem item)
|
||
=> item.Kind?.ToLowerInvariant() switch
|
||
{
|
||
"followup" => "후속 작업",
|
||
"steering" => "조정",
|
||
"command" => "명령",
|
||
"direct" => "직접 실행",
|
||
_ => "메시지",
|
||
};
|
||
|
||
private (string Icon, Brush Foreground) GetDraftKindVisual(DraftQueueItem item)
|
||
=> item.Kind?.ToLowerInvariant() switch
|
||
{
|
||
"followup" => ("\uE8A5", BrushFromHex("#0F766E")),
|
||
"steering" => ("\uE7C3", BrushFromHex("#B45309")),
|
||
"command" => ("\uE756", BrushFromHex("#7C3AED")),
|
||
"direct" => ("\uE8A7", BrushFromHex("#2563EB")),
|
||
_ => ("\uE8BD", BrushFromHex("#475569")),
|
||
};
|
||
|
||
private static string GetDraftStateLabel(DraftQueueItem item)
|
||
=> IsDraftBlocked(item) ? "재시도 대기"
|
||
: item.State?.ToLowerInvariant() switch
|
||
{
|
||
"running" => "실행 중",
|
||
"failed" => "실패",
|
||
"completed" => "완료",
|
||
_ => "대기",
|
||
};
|
||
|
||
private Brush GetDraftStateBrush(DraftQueueItem item)
|
||
=> IsDraftBlocked(item) ? BrushFromHex("#B45309")
|
||
: item.State?.ToLowerInvariant() switch
|
||
{
|
||
"running" => BrushFromHex("#2563EB"),
|
||
"failed" => BrushFromHex("#DC2626"),
|
||
"completed" => BrushFromHex("#059669"),
|
||
_ => BrushFromHex("#7C3AED"),
|
||
};
|
||
|
||
private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item)
|
||
=> IsDraftBlocked(item)
|
||
? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C"))
|
||
: item.State?.ToLowerInvariant() switch
|
||
{
|
||
"running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")),
|
||
"failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
|
||
"completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
|
||
_ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")),
|
||
};
|
||
|
||
private static (Brush Background, Brush Border, Brush Foreground) GetDraftPriorityBadgeColors(string? priority)
|
||
=> priority?.ToLowerInvariant() switch
|
||
{
|
||
"now" => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
|
||
"later" => (BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
|
||
_ => (BrushFromHex("#FEF3C7"), BrushFromHex("#FDE68A"), BrushFromHex("#92400E")),
|
||
};
|
||
|
||
private static bool IsDraftBlocked(DraftQueueItem item)
|
||
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase)
|
||
&& item.NextRetryAt.HasValue
|
||
&& item.NextRetryAt.Value > DateTime.Now;
|
||
|
||
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 void RuntimeTaskSummary_Click(object sender, MouseButtonEventArgs e)
|
||
{
|
||
e.Handled = true;
|
||
_taskSummaryTarget = sender as UIElement ?? RuntimeActivityBadge;
|
||
ShowTaskSummaryPopup();
|
||
}
|
||
|
||
private void ShowTaskSummaryPopup()
|
||
{
|
||
if (_taskSummaryTarget == null)
|
||
return;
|
||
|
||
if (_taskSummaryPopup != null)
|
||
_taskSummaryPopup.IsOpen = false;
|
||
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var popupBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||
var panel = new StackPanel { Margin = new Thickness(2) };
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "작업 요약",
|
||
FontSize = 12.5,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
Margin = new Thickness(10, 8, 10, 4),
|
||
});
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "현재 실행/권한/작업 흐름",
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(10, 0, 10, 6),
|
||
});
|
||
var taskFilterRow = new WrapPanel
|
||
{
|
||
Margin = new Thickness(8, 0, 8, 8),
|
||
};
|
||
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("all", "전체"));
|
||
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("permission", "권한"));
|
||
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("queue", "대기"));
|
||
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("tool", "도구"));
|
||
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("subagent", "서브"));
|
||
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("hook", "훅"));
|
||
panel.Children.Add(taskFilterRow);
|
||
|
||
ChatConversation? currentConversation;
|
||
lock (_convLock) currentConversation = _currentConversation;
|
||
AddTaskSummaryObservabilitySections(panel, currentConversation);
|
||
|
||
if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId))
|
||
{
|
||
var currentRun = new Border
|
||
{
|
||
Background = BrushFromHex("#F8FAFC"),
|
||
BorderBrush = BrushFromHex("#E2E8F0"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(8, 0, 8, 8),
|
||
Child = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = $"현재 실행 run {ShortRunId(_appState.AgentRun.RunId)}",
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = primaryText,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · iteration {_appState.AgentRun.LastIteration}",
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
Foreground = GetRunStatusBrush(_appState.AgentRun.Status),
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = Brushes.DimGray,
|
||
}
|
||
}
|
||
}
|
||
};
|
||
panel.Children.Add(currentRun);
|
||
}
|
||
|
||
var recentAgentRuns = _appState.GetRecentAgentRuns(3);
|
||
if (recentAgentRuns.Count > 0)
|
||
{
|
||
var latestFailedRun = _appState.GetLatestFailedRun();
|
||
if (latestFailedRun != null)
|
||
{
|
||
panel.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex("#FEF2F2"),
|
||
BorderBrush = BrushFromHex("#FECACA"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10),
|
||
Padding = new Thickness(10, 8, 10, 8),
|
||
Margin = new Thickness(8, 0, 8, 8),
|
||
Child = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = $"최근 실패 원인 · run {ShortRunId(latestFailedRun.RunId)}",
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = BrushFromHex("#991B1B"),
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"{latestFailedRun.UpdatedAt:HH:mm:ss} · iteration {latestFailedRun.LastIteration}",
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
Foreground = BrushFromHex("#B45309"),
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = string.IsNullOrWhiteSpace(latestFailedRun.Summary) ? "요약 없음" : latestFailedRun.Summary,
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText,
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "최근 에이전트 실행",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = Brushes.DimGray,
|
||
Margin = new Thickness(10, 0, 10, 4),
|
||
});
|
||
|
||
foreach (var run in recentAgentRuns)
|
||
{
|
||
var runEvents = GetExecutionEventsForRun(run.RunId);
|
||
var runFilePaths = GetExecutionEventFilePaths(run.RunId);
|
||
var runDisplay = _appState.GetRunDisplay(run);
|
||
var runCardStack = new StackPanel
|
||
{
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = runDisplay.HeaderText,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = GetRunStatusBrush(run.Status),
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = runDisplay.MetaText,
|
||
Margin = new Thickness(0, 2, 0, 0),
|
||
Foreground = secondaryText,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = TruncateForStatus(runDisplay.SummaryText, 140),
|
||
Margin = new Thickness(0, 3, 0, 0),
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = secondaryText,
|
||
}
|
||
}
|
||
};
|
||
|
||
if (runEvents.Count > 0 || runFilePaths.Count > 0)
|
||
{
|
||
var activitySummary = new StackPanel();
|
||
activitySummary.Children.Add(new TextBlock
|
||
{
|
||
Text = $"실행 로그 {runEvents.Count} · 관련 파일 {runFilePaths.Count}",
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
});
|
||
|
||
if (!string.IsNullOrWhiteSpace(run.RunId))
|
||
{
|
||
var capturedRunId = run.RunId;
|
||
var timelineButton = CreateTaskSummaryActionButton(
|
||
"타임라인 보기",
|
||
"#F8FAFC",
|
||
"#CBD5E1",
|
||
"#334155",
|
||
(_, _) => ScrollToRunInTimeline(capturedRunId),
|
||
trailingMargin: false);
|
||
timelineButton.Margin = new Thickness(0, 6, 0, 0);
|
||
activitySummary.Children.Add(timelineButton);
|
||
}
|
||
|
||
runCardStack.Children.Add(new Border
|
||
{
|
||
Background = BrushFromHex("#F8FAFC"),
|
||
BorderBrush = BrushFromHex("#E2E8F0"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(8, 6, 8, 6),
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
Child = activitySummary
|
||
});
|
||
}
|
||
|
||
if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var capturedRun = run;
|
||
var followUpButton = CreateTaskSummaryActionButton(
|
||
"후속 작업 큐에 넣기",
|
||
"#ECFDF5",
|
||
"#BBF7D0",
|
||
"#166534",
|
||
(_, _) => EnqueueFollowUpFromRun(capturedRun),
|
||
trailingMargin: false);
|
||
followUpButton.Margin = new Thickness(0, 8, 0, 0);
|
||
runCardStack.Children.Add(followUpButton);
|
||
}
|
||
|
||
if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
|
||
{
|
||
var retryButton = CreateTaskSummaryActionButton(
|
||
"이 실행 다시 시도",
|
||
"#FEF2F2",
|
||
"#FCA5A5",
|
||
"#991B1B",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
RetryLastUserMessageFromConversation();
|
||
},
|
||
trailingMargin: false);
|
||
retryButton.Margin = new Thickness(0, 8, 0, 0);
|
||
runCardStack.Children.Add(retryButton);
|
||
}
|
||
|
||
panel.Children.Add(new Border
|
||
{
|
||
Background = Brushes.White,
|
||
BorderBrush = BrushFromHex("#E5E7EB"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(9),
|
||
Padding = new Thickness(9, 6, 9, 6),
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
Child = runCardStack
|
||
});
|
||
}
|
||
|
||
if (_appState.GetLatestFailedRun() != null
|
||
&& CanRetryCurrentConversation())
|
||
{
|
||
var actionsPanel = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(8, 2, 8, 8),
|
||
};
|
||
|
||
var retryButton = CreateTaskSummaryActionButton(
|
||
"마지막 요청 다시 시도",
|
||
"#EEF2FF",
|
||
"#C7D2FE",
|
||
"#3730A3",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
RetryLastUserMessageFromConversation();
|
||
});
|
||
actionsPanel.Children.Add(retryButton);
|
||
|
||
if (_appState.ActiveTasks.Count > 0)
|
||
{
|
||
var runningFilterButton = CreateTaskSummaryActionButton(
|
||
"진행 중 대화만 보기",
|
||
"#DBEAFE",
|
||
"#93C5FD",
|
||
"#1D4ED8",
|
||
(_, _) =>
|
||
{
|
||
_runningOnlyFilter = true;
|
||
UpdateConversationRunningFilterUi();
|
||
PersistConversationListPreferences();
|
||
RefreshConversationList();
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
},
|
||
trailingMargin: false);
|
||
runningFilterButton.Margin = new Thickness(2, 0, 0, 0);
|
||
actionsPanel.Children.Add(runningFilterButton);
|
||
}
|
||
panel.Children.Add(actionsPanel);
|
||
}
|
||
}
|
||
|
||
foreach (var task in FilterTaskSummaryItems(_appState.ActiveTasks).Take(6))
|
||
panel.Children.Add(BuildTaskSummaryCard(task, active: true));
|
||
|
||
foreach (var task in FilterTaskSummaryItems(_appState.RecentTasks).Take(6))
|
||
panel.Children.Add(BuildTaskSummaryCard(task, active: false));
|
||
|
||
if (!FilterTaskSummaryItems(_appState.ActiveTasks).Any() && !FilterTaskSummaryItems(_appState.RecentTasks).Any())
|
||
{
|
||
panel.Children.Add(new TextBlock
|
||
{
|
||
Text = "표시할 작업 이력이 없습니다.",
|
||
Margin = new Thickness(10, 2, 10, 8),
|
||
Foreground = secondaryText,
|
||
});
|
||
}
|
||
|
||
_taskSummaryPopup = new Popup
|
||
{
|
||
PlacementTarget = _taskSummaryTarget,
|
||
Placement = PlacementMode.Top,
|
||
AllowsTransparency = true,
|
||
StaysOpen = false,
|
||
PopupAnimation = PopupAnimation.Fade,
|
||
Child = new Border
|
||
{
|
||
Background = popupBackground,
|
||
BorderBrush = BrushFromHex("#E5E7EB"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(6),
|
||
Child = new ScrollViewer
|
||
{
|
||
Content = panel,
|
||
MaxHeight = 340,
|
||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||
}
|
||
}
|
||
};
|
||
|
||
_taskSummaryPopup.IsOpen = true;
|
||
}
|
||
|
||
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);
|
||
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.EnqueueDraft(_activeTab, prompt, "next", _storage, "followup") != null
|
||
? session.CurrentConversation
|
||
: _currentConversation;
|
||
}
|
||
|
||
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);
|
||
lock (_convLock)
|
||
{
|
||
var session = _appState.ChatSession;
|
||
if (session != null)
|
||
_currentConversation = session.EnqueueDraft(_activeTab, prompt, "next", _storage, "followup") != null
|
||
? session.CurrentConversation
|
||
: _currentConversation;
|
||
}
|
||
|
||
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) || InputBox == null)
|
||
return;
|
||
|
||
InputBox.Text = lastUserMessage;
|
||
InputBox.CaretIndex = InputBox.Text.Length;
|
||
InputBox.Focus();
|
||
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
|
||
}
|
||
|
||
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 Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active)
|
||
{
|
||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||
var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind);
|
||
var taskStack = new StackPanel();
|
||
var headerRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 0, 0, 2),
|
||
};
|
||
headerRow.Children.Add(new TextBlock
|
||
{
|
||
Text = kindIcon,
|
||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||
FontSize = 11,
|
||
Foreground = kindColor,
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
});
|
||
headerRow.Children.Add(new TextBlock
|
||
{
|
||
Text = active
|
||
? $"진행 중 · {task.Title}"
|
||
: $"{GetTaskStatusLabel(task.Status)} · {task.Title}",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = active ? primaryText : secondaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
taskStack.Children.Add(headerRow);
|
||
|
||
if (!string.IsNullOrWhiteSpace(task.Summary))
|
||
{
|
||
taskStack.Children.Add(new TextBlock
|
||
{
|
||
Text = task.Summary,
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
var reviewChipRow = BuildReviewSignalChipRow(
|
||
kind: task.Kind,
|
||
toolName: task.Title,
|
||
title: task.Title,
|
||
summary: task.Summary);
|
||
if (reviewChipRow != null)
|
||
taskStack.Children.Add(reviewChipRow);
|
||
|
||
var actionRow = BuildTaskSummaryActionRow(task, active);
|
||
if (actionRow != null)
|
||
taskStack.Children.Add(actionRow);
|
||
|
||
return new Border
|
||
{
|
||
Background = active ? BrushFromHex("#F8FAFC") : Brushes.White,
|
||
BorderBrush = BrushFromHex("#E5E7EB"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
Child = taskStack
|
||
};
|
||
}
|
||
|
||
private Button CreateTaskSummaryActionButton(
|
||
string label,
|
||
string bg,
|
||
string border,
|
||
string fg,
|
||
RoutedEventHandler onClick,
|
||
bool trailingMargin = true)
|
||
{
|
||
var button = new Button
|
||
{
|
||
Content = label,
|
||
FontSize = 10.5,
|
||
MinHeight = 28,
|
||
Padding = new Thickness(9, 4, 9, 4),
|
||
Margin = trailingMargin ? new Thickness(0, 0, 6, 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, 8, 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, 8, 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("계획 모드", "#EEF2FF", "#C7D2FE", "#3730A3", PermissionModeCatalog.Plan));
|
||
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 = 11,
|
||
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = "훅 이벤트",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
|
||
}
|
||
}
|
||
});
|
||
hookCardStack.Children.Add(new TextBlock
|
||
{
|
||
Text = _appState.FormatHookEventLine(hook),
|
||
FontSize = 10.5,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"),
|
||
});
|
||
|
||
var hookActionRow = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 8, 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(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
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 = 11,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"실행 중인 백그라운드 작업 {activeBackgroundCount}개",
|
||
FontSize = 11,
|
||
Foreground = BrushFromHex("#1D4ED8"),
|
||
FontWeight = FontWeights.SemiBold,
|
||
}
|
||
}
|
||
});
|
||
|
||
foreach (var job in activeBackgroundJobs)
|
||
{
|
||
activeBackgroundStack.Children.Add(new TextBlock
|
||
{
|
||
Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 52)}",
|
||
Margin = new Thickness(0, 4, 0, 0),
|
||
FontSize = 10.5,
|
||
Foreground = secondaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
}
|
||
|
||
var activeActionRow = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
|
||
var runningFilterButton = CreateTaskSummaryActionButton(
|
||
"진행 중 대화만 보기",
|
||
"#DBEAFE",
|
||
"#93C5FD",
|
||
"#1D4ED8",
|
||
(_, _) => ShowRunningConversationsOnly());
|
||
activeActionRow.Children.Add(runningFilterButton);
|
||
|
||
var subTaskButton = CreateTaskSummaryActionButton(
|
||
"서브 작업만 보기",
|
||
"#F8FAFC",
|
||
"#CBD5E1",
|
||
"#334155",
|
||
(_, _) => ShowSubAgentTasksOnly(),
|
||
trailingMargin: false);
|
||
activeActionRow.Children.Add(subTaskButton);
|
||
activeBackgroundStack.Children.Add(activeActionRow);
|
||
|
||
return new Border
|
||
{
|
||
Background = BrushFromHex("#EFF6FF"),
|
||
BorderBrush = BrushFromHex("#BFDBFE"),
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(8, 0, 8, 8),
|
||
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 = 11,
|
||
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
|
||
Margin = new Thickness(0, 0, 6, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}",
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
|
||
}
|
||
}
|
||
});
|
||
jobCardStack.Children.Add(new TextBlock
|
||
{
|
||
Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 72)}",
|
||
FontSize = 10.5,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Foreground = isFailed
|
||
? BrushFromHex("#991B1B")
|
||
: secondaryText,
|
||
});
|
||
|
||
var jobActionRow = new WrapPanel
|
||
{
|
||
Margin = new Thickness(0, 8, 0, 0),
|
||
};
|
||
|
||
var focusButton = CreateTaskSummaryActionButton(
|
||
"이 대화로 이동",
|
||
"#F8FAFC",
|
||
"#CBD5E1",
|
||
"#334155",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
FocusCurrentConversation();
|
||
});
|
||
jobActionRow.Children.Add(focusButton);
|
||
|
||
var subagentOnlyButton = CreateTaskSummaryActionButton(
|
||
"서브 작업만 보기",
|
||
"#F8FAFC",
|
||
"#CBD5E1",
|
||
"#334155",
|
||
(_, _) => ShowSubAgentTasksOnly());
|
||
jobActionRow.Children.Add(subagentOnlyButton);
|
||
|
||
if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
|
||
{
|
||
var retryBackgroundButton = CreateTaskSummaryActionButton(
|
||
"이 작업 다시 시도",
|
||
"#FEF2F2",
|
||
"#FCA5A5",
|
||
"#991B1B",
|
||
(_, _) =>
|
||
{
|
||
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||
RetryLastUserMessageFromConversation();
|
||
},
|
||
trailingMargin: false);
|
||
jobActionRow.Children.Add(retryBackgroundButton);
|
||
}
|
||
|
||
jobCardStack.Children.Add(jobActionRow);
|
||
|
||
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(8),
|
||
Padding = new Thickness(10, 7, 10, 7),
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
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(10, 8, 10, 8),
|
||
Margin = new Thickness(8, 0, 8, 8),
|
||
Child = content,
|
||
});
|
||
}
|
||
|
||
private void AddTaskSummaryPermissionHistorySection(StackPanel panel)
|
||
{
|
||
var recentPermissions = _appState.GetRecentPermissionEvents(4);
|
||
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(10, 7, 10, 7),
|
||
Margin = new Thickness(8, 0, 8, 6),
|
||
Child = card,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void AddTaskSummaryHookSection(StackPanel panel)
|
||
{
|
||
var recentHooks = _appState.GetRecentHookEvents(5);
|
||
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(3);
|
||
var recentBackgroundJobs = _appState.GetRecentBackgroundJobs(4);
|
||
var activeBackgroundCount = _appState.GetBackgroundJobSummary().ActiveCount;
|
||
|
||
if (activeBackgroundCount > 0)
|
||
panel.Children.Add(BuildActiveBackgroundSummaryCard(activeBackgroundJobs, activeBackgroundCount));
|
||
|
||
if (recentBackgroundJobs.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 job in recentBackgroundJobs)
|
||
panel.Children.Add(BuildRecentBackgroundJobCard(job));
|
||
}
|
||
|
||
private void AddTaskSummaryObservabilitySections(StackPanel panel, ChatConversation? currentConversation)
|
||
{
|
||
AddTaskSummaryPermissionSection(panel, currentConversation);
|
||
AddTaskSummaryPermissionHistorySection(panel);
|
||
AddTaskSummaryHookSection(panel);
|
||
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);
|
||
}
|
||
}
|
||
|
||
|