Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs
lacvet fb0bea41f7 AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리
- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
2026-04-12 22:02:14 +09:00

1039 lines
42 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
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 Microsoft.Win32;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── 프로젝트 문맥 파일 (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? _smoothScrollTimer;
private double _smoothScrollStartOffset;
private double _smoothScrollDiff;
private DateTime _smoothScrollStartTime;
private void SmoothScrollTimer_Tick(object? sender, EventArgs e)
{
var elapsed = (DateTime.UtcNow - _smoothScrollStartTime).TotalMilliseconds;
var progress = Math.Min(elapsed / 200.0, 1.0);
var eased = 1.0 - Math.Pow(1.0 - progress, 3);
ScrollTranscriptToVerticalOffset(_smoothScrollStartOffset + _smoothScrollDiff * eased);
if (progress >= 1.0)
_smoothScrollTimer?.Stop();
}
// ─── 무지개 글로우 애니메이션 ─────────────────────────────────────────
private DispatcherTimer? _rainbowTimer;
private DateTime _rainbowStartTime;
private bool TryGetStreamingElapsed(out TimeSpan elapsed)
{
elapsed = TimeSpan.Zero;
if (_streamStartTime.Year < 2000)
return false;
var now = DateTime.UtcNow;
if (_streamStartTime > now.AddSeconds(1))
return false;
elapsed = now - _streamStartTime;
if (elapsed < TimeSpan.Zero || elapsed > TimeSpan.FromHours(6))
{
elapsed = TimeSpan.Zero;
return false;
}
return true;
}
private long GetStreamingElapsedMsOrZero()
=> TryGetStreamingElapsed(out var elapsed)
? Math.Max(0L, (long)elapsed.TotalMilliseconds)
: 0L;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
private void PlayRainbowGlow()
{
if (!_settings.Settings.Llm.EnableChatRainbowGlow) return;
if (_rainbowTimer != null) return; // 이미 실행 중이면 opacity 리셋 없이 그냥 유지
_rainbowStartTime = DateTime.UtcNow;
InputGlowBorder.Visibility = Visibility.Visible;
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 };
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180)));
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_rainbowTimer.Tick += (_, _) =>
{
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
var shift = (elapsed / 2000.0) % 1.0;
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 || InputGlowBorder.Visibility == Visibility.Visible)
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
fadeOut.Completed += (_, _) =>
{
InputGlowBorder.Opacity = 0;
InputGlowBorder.Visibility = Visibility.Collapsed;
};
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
else
{
InputGlowBorder.Visibility = Visibility.Collapsed;
}
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────
private DispatcherTimer? _toastHideTimer;
/// <summary>ToastBorder를 즉시 페이드아웃하고 숨깁니다.</summary>
private void HideToast()
{
_toastHideTimer?.Stop();
_toastHideTimer = null;
_tipDismissTimer?.Stop();
_tipDismissTimer = null;
if (ToastBorder?.Visibility != Visibility.Visible) return;
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(200));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000)
{
// 두 타이머 모두 중지 (ShowToast/ShowTip이 같은 ToastBorder를 공유)
_toastHideTimer?.Stop();
_tipDismissTimer?.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)));
// 자동 숨기기 — 타이머 인스턴스를 로컬 변수로 캡처해 필드 재할당 간섭 방지
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) };
_toastHideTimer = timer;
timer.Tick += (_, _) =>
{
if (_toastHideTimer != timer) return; // 다른 ShowToast가 교체한 경우 무시
timer.Stop();
_toastHideTimer = null;
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
timer.Start();
}
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
/// <summary>하단 바를 구성합니다 (Cowork 작업 제어 중심).</summary>
private void BuildBottomBar()
{
MoodIconPanel.Children.Clear();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
}
/// <summary>Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심.</summary>
private void BuildCodeBottomBar()
{
MoodIconPanel.Children.Clear();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
}
private Border CreateWorkspaceFolderBarButton()
{
var currentFolder = GetCurrentWorkFolder();
var label = string.IsNullOrWhiteSpace(currentFolder)
? "워크스페이스"
: TruncateForStatus(Path.GetFileName(currentFolder.TrimEnd('\\', '/')), 18);
var tooltip = string.IsNullOrWhiteSpace(currentFolder)
? "워크스페이스 선택"
: $"워크스페이스 선택\n현재: {currentFolder}";
return CreateFolderBarButton("\uE8B7", label, tooltip, "#4B5EFC");
}
private string GetWorktreeModeLabel()
{
var folder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
return "로컬";
var root = WorktreeStateStore.ResolveRoot(folder);
var active = WorktreeStateStore.Load(root).Active;
return string.Equals(Path.GetFullPath(active), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)
? "로컬"
: "워크트리";
}
private List<string> GetAvailableWorkspaceVariants(string root, string? active)
{
var variants = new List<string>();
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
return variants;
try
{
var parent = Directory.GetParent(root)?.FullName ?? root;
var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-wt-*"));
variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-copy-*"));
}
catch
{
// ignore discovery failures
}
if (!string.IsNullOrWhiteSpace(active) && Directory.Exists(active))
variants.Add(active);
return variants
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Where(path => !string.Equals(Path.GetFullPath(path), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(path => string.Equals(Path.GetFullPath(path), Path.GetFullPath(active ?? ""), StringComparison.OrdinalIgnoreCase))
.ThenByDescending(path => Directory.GetLastWriteTime(path))
.Take(8)
.ToList();
}
private void SwitchToWorkspace(string targetPath, string rootPath)
{
if (string.IsNullOrWhiteSpace(targetPath) || !Directory.Exists(targetPath))
return;
if (!string.IsNullOrWhiteSpace(rootPath))
{
var state = WorktreeStateStore.Load(rootPath);
state.Active = targetPath;
WorktreeStateStore.Save(rootPath, state);
}
SetWorkFolder(targetPath);
ShowToast(string.Equals(targetPath, rootPath, StringComparison.OrdinalIgnoreCase) ? "로컬 워크스페이스로 전환했습니다." : "워크트리로 전환했습니다.");
}
private async Task CreateCurrentBranchWorktreeAsync()
{
var currentFolder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder))
return;
var root = WorktreeStateStore.ResolveRoot(currentFolder);
var gitRoot = ResolveGitRoot(root);
if (!string.IsNullOrWhiteSpace(gitRoot))
{
await CreateGitWorktreeAsync(gitRoot);
return;
}
var copied = CreateWorkspaceCopy(root);
SwitchToWorkspace(copied, root);
}
private async Task CreateGitWorktreeAsync(string gitRoot)
{
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return;
var branchResult = await RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, CancellationToken.None);
var branchName = branchResult.ExitCode == 0 ? branchResult.StdOut.Trim() : "worktree";
if (string.IsNullOrWhiteSpace(branchName))
branchName = "worktree";
var safeBranch = string.Concat(branchName.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-');
if (string.IsNullOrWhiteSpace(safeBranch))
safeBranch = "worktree";
var parent = Directory.GetParent(gitRoot)?.FullName ?? gitRoot;
var repoName = Path.GetFileName(gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var suffix = DateTime.Now.ToString("MMddHHmm");
var worktreePath = Path.Combine(parent, $"{repoName}-wt-{safeBranch}-{suffix}");
var worktreeBranch = $"ax/{safeBranch}-{suffix}";
var addResult = await RunGitAsync(gitPath, gitRoot, new[] { "worktree", "add", "-b", worktreeBranch, worktreePath, branchName }, CancellationToken.None);
if (addResult.ExitCode != 0)
{
CustomMessageBox.Show($"워크트리 생성에 실패했습니다.\n{addResult.StdErr.Trim()}", "워크트리", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
SwitchToWorkspace(worktreePath, gitRoot);
await RefreshGitBranchStatusAsync();
}
private string CreateWorkspaceCopy(string root)
{
var parent = Directory.GetParent(root)?.FullName ?? root;
var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var copyPath = Path.Combine(parent, $"{repoName}-copy-{DateTime.Now:MMddHHmm}");
CopyDirectoryRecursive(root, copyPath, skipGitMetadata: true);
return copyPath;
}
private static void CopyDirectoryRecursive(string source, string destination, bool skipGitMetadata)
{
Directory.CreateDirectory(destination);
foreach (var file in Directory.GetFiles(source))
{
var name = Path.GetFileName(file);
if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase))
continue;
File.Copy(file, Path.Combine(destination, name), overwrite: true);
}
foreach (var directory in Directory.GetDirectories(source))
{
var name = Path.GetFileName(directory);
if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase))
continue;
CopyDirectoryRecursive(directory, Path.Combine(destination, name), skipGitMetadata);
}
}
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
private void AppendLogLevelButton()
{
// 구분선
MoodIconPanel.Children.Add(new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
});
var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "detailed";
var levelLabel = currentLevel switch
{
"debug" => "디버그",
"detailed" => "상세",
"hidden" => "숨김",
_ => "간략",
};
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)[]
{
("hidden", "Hidden (숨김)", "실행 로그를 표시하지 않음"),
("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"),
("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"),
("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"),
};
var current = _settings.Settings.Llm.AgentLogLevel ?? "detailed";
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 = s_segoeIconFont,
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 = s_segoeIconFont,
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 = s_segoeIconFont,
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()
{
RefreshInputWatermarkText();
InputWatermark.Visibility = Visibility.Visible;
InputBox.Text = "";
InputBox.Focus();
}
private void UpdateWatermarkVisibility()
{
// 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지)
if (_slashPalette.ActiveCommand != null)
{
InputWatermark.Visibility = Visibility.Collapsed;
return;
}
RefreshInputWatermarkText();
if (string.IsNullOrEmpty(InputBox.Text))
InputWatermark.Visibility = Visibility.Visible;
else
InputWatermark.Visibility = Visibility.Collapsed;
}
private void ClearPromptCardPlaceholder()
{
_promptCardPlaceholder = null;
RefreshInputWatermarkText();
UpdateWatermarkVisibility();
}
private void BtnSettings_Click(object sender, RoutedEventArgs e)
{
OpenAgentSettingsWindow();
}
}