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)
This commit is contained in:
@@ -87,7 +87,26 @@ public partial class ChatWindow
|
||||
{
|
||||
var result = _chatEngine.AppendExecutionEvent(
|
||||
session, _storage, _currentConversation, activeTab, eventTab, evt);
|
||||
_currentConversation = result.CurrentConversation;
|
||||
// 방어: 결과 대화가 빈 대화로 교체되는 것을 방지
|
||||
// EnsureCurrentConversation이 기존 대화 대신 새 빈 대화를 생성하는 경우 발생
|
||||
var resultConv = result.CurrentConversation;
|
||||
var currentMsgCount = _currentConversation?.Messages?.Count ?? 0;
|
||||
var resultMsgCount = resultConv?.Messages?.Count ?? 0;
|
||||
if (resultConv != null && resultMsgCount == 0 && currentMsgCount > 0
|
||||
&& _currentConversation != null
|
||||
&& string.Equals(
|
||||
_currentConversation.Tab?.Trim(),
|
||||
resultConv.Tab?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 기존 대화 유지 — session에도 복원
|
||||
session.CurrentConversation = _currentConversation;
|
||||
LogService.Info($"[EventProc] 대화 교체 차단: 기존 msgCount={currentMsgCount}, 결과 msgCount={resultMsgCount}, convId={_currentConversation.Id?[..Math.Min(8, _currentConversation.Id?.Length ?? 0)]}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentConversation = resultConv;
|
||||
}
|
||||
pendingPersist = result.UpdatedConversation;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +128,17 @@ public partial class ChatWindow
|
||||
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary;
|
||||
var result = _chatEngine.AppendAgentRun(
|
||||
session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary);
|
||||
_currentConversation = result.CurrentConversation;
|
||||
// 방어: Complete 이벤트에서도 대화 교체 보호
|
||||
var completeMsgCount = result.CurrentConversation?.Messages?.Count ?? 0;
|
||||
var existingMsgCount = _currentConversation?.Messages?.Count ?? 0;
|
||||
if (completeMsgCount == 0 && existingMsgCount > 0 && _currentConversation != null)
|
||||
{
|
||||
session.CurrentConversation = _currentConversation;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentConversation = result.CurrentConversation;
|
||||
}
|
||||
pendingPersist = result.UpdatedConversation;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1064,6 +1064,15 @@ public partial class ChatWindow
|
||||
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
|
||||
{
|
||||
outerStack.Children.Clear();
|
||||
|
||||
// 부모 컨테이너(Border)의 테두리·배경 제거 — 승인 후 깔끔하게
|
||||
if (outerStack.Parent is Border containerBorder)
|
||||
{
|
||||
containerBorder.BorderThickness = new Thickness(0);
|
||||
containerBorder.Background = Brushes.Transparent;
|
||||
containerBorder.Padding = new Thickness(0, 2, 0, 2);
|
||||
}
|
||||
|
||||
var resultLabel = new TextBlock
|
||||
{
|
||||
Text = resultText,
|
||||
@@ -1071,7 +1080,7 @@ public partial class ChatWindow
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = fg,
|
||||
Opacity = 0.8,
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
Margin = new Thickness(40, 2, 0, 2),
|
||||
};
|
||||
outerStack.Children.Add(resultLabel);
|
||||
}
|
||||
|
||||
622
src/AxCopilot/Views/ChatWindow.CommandInteractionPresentation.cs
Normal file
622
src/AxCopilot/Views/ChatWindow.CommandInteractionPresentation.cs
Normal file
@@ -0,0 +1,622 @@
|
||||
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.Threading;
|
||||
using Microsoft.Win32;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── 대화 분기 (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();
|
||||
}
|
||||
ViewModel.ChatTitle = 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)
|
||||
{
|
||||
var activeLoop = GetAgentLoop(_activeTab);
|
||||
if (activeLoop.IsPaused)
|
||||
{
|
||||
activeLoop.Resume();
|
||||
PauseIcon.Text = "\uE769"; // 일시정지 아이콘
|
||||
BtnPause.ToolTip = "일시정지";
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = activeLoop.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 < GetTranscriptElementCount())
|
||||
{
|
||||
var element = GetTranscriptElementAt(msgIndex) as FrameworkElement;
|
||||
element?.BringIntoView();
|
||||
}
|
||||
else if (GetTranscriptElementCount() > 0)
|
||||
{
|
||||
// 범위 밖이면 마지막 자식으로 이동
|
||||
(GetTranscriptElementAt(GetTranscriptElementCount() - 1) as FrameworkElement)?.BringIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearSearchHighlights()
|
||||
{
|
||||
// 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요
|
||||
}
|
||||
|
||||
// ─── 메시지 우클릭 컨텍스트 메뉴 ───────────────────────────────────────
|
||||
|
||||
private void ShowMessageContextMenu(string content, string role)
|
||||
{
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||||
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
|
||||
var (popup, panel) = CreateThemedPopupMenu();
|
||||
|
||||
// 복사
|
||||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () =>
|
||||
{
|
||||
try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { }
|
||||
}));
|
||||
|
||||
// 마크다운 복사
|
||||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () =>
|
||||
{
|
||||
try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { }
|
||||
}));
|
||||
|
||||
// 인용하여 답장
|
||||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () =>
|
||||
{
|
||||
var quote = content.Length > 200 ? content[..200] + "..." : content;
|
||||
var lines = quote.Split('\n');
|
||||
var quoted = string.Join("\n", lines.Select(l => $"> {l}"));
|
||||
InputBox.Text = quoted + "\n\n";
|
||||
InputBox.Focus();
|
||||
InputBox.CaretIndex = InputBox.Text.Length;
|
||||
}));
|
||||
|
||||
AddPopupMenuSeparator(panel, borderBrush);
|
||||
|
||||
// 재생성 (AI 응답만)
|
||||
if (role == "assistant")
|
||||
{
|
||||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync()));
|
||||
}
|
||||
|
||||
// 대화 분기 (Fork)
|
||||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () =>
|
||||
{
|
||||
ChatConversation? conv;
|
||||
lock (_convLock) conv = _currentConversation;
|
||||
if (conv == null) return;
|
||||
|
||||
var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content);
|
||||
if (idx < 0) return;
|
||||
|
||||
ForkConversation(conv, idx);
|
||||
}));
|
||||
|
||||
AddPopupMenuSeparator(panel, borderBrush);
|
||||
|
||||
// 이후 메시지 모두 삭제
|
||||
var msgContent = content;
|
||||
var msgRole = role;
|
||||
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () =>
|
||||
{
|
||||
ChatConversation? conv;
|
||||
lock (_convLock) conv = _currentConversation;
|
||||
if (conv == null) return;
|
||||
|
||||
var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent);
|
||||
if (idx < 0) return;
|
||||
|
||||
var removeCount = conv.Messages.Count - idx;
|
||||
if (CustomMessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?",
|
||||
"메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||||
return;
|
||||
|
||||
conv.Messages.RemoveRange(idx, removeCount);
|
||||
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||||
RenderMessages();
|
||||
ShowToast($"{removeCount}개 메시지 삭제됨");
|
||||
}));
|
||||
|
||||
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
// ─── 팁 알림 ──────────────────────────────────────────────────────
|
||||
|
||||
private static readonly string[] Tips =
|
||||
[
|
||||
"💡 작업 폴더에 AGENTS.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.",
|
||||
"💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.",
|
||||
"💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.",
|
||||
"💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.",
|
||||
"💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.",
|
||||
"💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.",
|
||||
"💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.",
|
||||
"💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.",
|
||||
"💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.",
|
||||
"💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.",
|
||||
"💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.",
|
||||
"💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)",
|
||||
"💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.",
|
||||
"💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.",
|
||||
"💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.",
|
||||
"💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.",
|
||||
"💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.",
|
||||
"💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.",
|
||||
"💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.",
|
||||
"💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.",
|
||||
"💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.",
|
||||
"💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.",
|
||||
"💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!",
|
||||
"💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.",
|
||||
];
|
||||
private int _tipIndex;
|
||||
private DispatcherTimer? _tipDismissTimer;
|
||||
|
||||
private void ShowRandomTip()
|
||||
{
|
||||
if (!_settings.Settings.Llm.ShowTips) return;
|
||||
if (_activeTab != "Cowork" && _activeTab != "Code") return;
|
||||
|
||||
var tip = Tips[_tipIndex % Tips.Length];
|
||||
_tipIndex++;
|
||||
|
||||
// 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상)
|
||||
ShowTip(tip);
|
||||
}
|
||||
|
||||
private void ShowTip(string message)
|
||||
{
|
||||
// 두 타이머 모두 중지
|
||||
_tipDismissTimer?.Stop();
|
||||
_toastHideTimer?.Stop();
|
||||
_toastHideTimer = null;
|
||||
|
||||
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)
|
||||
{
|
||||
// duration=0: 자동 숨기기 없음 — 기본 3초로 폴백하여 영구 잔류 방지
|
||||
duration = 3;
|
||||
}
|
||||
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) };
|
||||
_tipDismissTimer = timer;
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
if (_tipDismissTimer != timer) return;
|
||||
timer.Stop();
|
||||
_tipDismissTimer = 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();
|
||||
}
|
||||
}
|
||||
@@ -91,14 +91,39 @@ public partial class ChatWindow
|
||||
: "에이전트 활동량과 실패를 우선으로 보는 중";
|
||||
}
|
||||
|
||||
private void BtnArchiveFilter_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// null(일반만) → true(아카이브만) → null(일반만) 순환
|
||||
_archiveFilter = _archiveFilter == null ? true : null;
|
||||
UpdateArchiveFilterUi();
|
||||
RefreshConversationList();
|
||||
}
|
||||
|
||||
private void UpdateArchiveFilterUi()
|
||||
{
|
||||
if (BtnArchiveFilter == null || ArchiveFilterLabel == null) return;
|
||||
|
||||
var isActive = _archiveFilter == true;
|
||||
BtnArchiveFilter.Background = isActive ? BrushFromHex("#FEF3C7") : Brushes.Transparent;
|
||||
BtnArchiveFilter.BorderBrush = isActive ? BrushFromHex("#FCD34D") : Brushes.Transparent;
|
||||
BtnArchiveFilter.BorderThickness = isActive ? new Thickness(1) : new Thickness(0);
|
||||
ArchiveFilterLabel.Text = isActive ? "아카이브" : "보관";
|
||||
ArchiveFilterLabel.Foreground = isActive
|
||||
? BrushFromHex("#92400E")
|
||||
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||||
BtnArchiveFilter.ToolTip = isActive ? "아카이브된 대화 보는 중 (클릭: 일반 대화로 전환)" : "아카이브된 대화 보기";
|
||||
}
|
||||
|
||||
private void ApplyConversationListPreferences(ChatConversation? conv)
|
||||
{
|
||||
_failedOnlyFilter = false;
|
||||
_runningOnlyFilter = false;
|
||||
_archiveFilter = null;
|
||||
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
|
||||
UpdateConversationFailureFilterUi();
|
||||
UpdateConversationRunningFilterUi();
|
||||
UpdateConversationSortUi();
|
||||
UpdateArchiveFilterUi();
|
||||
}
|
||||
|
||||
private void PersistConversationListPreferences()
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.ViewModels;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
@@ -43,6 +44,115 @@ public partial class ChatWindow
|
||||
ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave;
|
||||
ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown;
|
||||
ConversationPanel.PreviewMouseRightButtonUp += ConversationPanel_DelegatedRightButtonUp;
|
||||
|
||||
// 새 ItemsControl 이벤트 등록
|
||||
if (ConversationItemsControl != null)
|
||||
{
|
||||
ConversationItemsControl.PreviewMouseLeftButtonDown += ConversationItemsControl_LeftButtonDown;
|
||||
ConversationItemsControl.PreviewMouseRightButtonUp += ConversationItemsControl_RightButtonUp;
|
||||
ConversationItemsControl.AddHandler(System.Windows.Controls.Primitives.ButtonBase.ClickEvent,
|
||||
new RoutedEventHandler(ConversationItemsControl_ButtonClick));
|
||||
}
|
||||
}
|
||||
|
||||
private void ConversationItemsControl_ButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is not Button btn) return;
|
||||
var vm = FindDataContext<ConversationItemViewModel>(btn);
|
||||
if (vm != null)
|
||||
ShowConversationMenu(vm.Id);
|
||||
}
|
||||
|
||||
private void ConversationItemsControl_LeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (FindAncestor<Button>(e.OriginalSource as DependencyObject) is not null)
|
||||
return;
|
||||
|
||||
var vm = FindDataContext<ConversationItemViewModel>(e.OriginalSource as DependencyObject);
|
||||
if (vm == null) return;
|
||||
|
||||
e.Handled = true;
|
||||
HandleConversationItemClickById(vm.Id, vm.IsSelected);
|
||||
}
|
||||
|
||||
private void ConversationItemsControl_RightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var vm = FindDataContext<ConversationItemViewModel>(e.OriginalSource as DependencyObject);
|
||||
if (vm == null) return;
|
||||
|
||||
e.Handled = true;
|
||||
if (!vm.IsSelected)
|
||||
{
|
||||
var conv = _storage.Load(vm.Id);
|
||||
if (conv != null)
|
||||
{
|
||||
lock (_convLock)
|
||||
{
|
||||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||
SyncTabConversationIdsFromSession();
|
||||
}
|
||||
SaveLastConversations();
|
||||
UpdateChatTitle();
|
||||
RenderMessages();
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
}
|
||||
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(vm.Id)), DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
private void HandleConversationItemClickById(string id, bool isSelected)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (isSelected)
|
||||
{
|
||||
// 선택된 항목 클릭 → 이름 변경 모드
|
||||
var titleBlock = FindConversationTitleBlock(id);
|
||||
if (titleBlock != null)
|
||||
{
|
||||
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
EnterTitleEditMode(titleBlock, id, titleColor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
|
||||
StopStreamingIfActive();
|
||||
|
||||
var conv = _storage.Load(id);
|
||||
if (conv == null) return;
|
||||
|
||||
lock (_convLock)
|
||||
{
|
||||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||
SyncTabConversationIdsFromSession();
|
||||
}
|
||||
|
||||
SaveLastConversations();
|
||||
UpdateChatTitle();
|
||||
ClearTranscriptElements(); // 이전 대화의 UI 요소 완전 제거
|
||||
InvalidateTimelineCache(); // 타임라인 캐시 무효화 — 새 대화 데이터 반영 보장
|
||||
RenderMessages();
|
||||
EnsureEmptyStateConsistency(); // EmptyState 일관성 강제 검사
|
||||
RefreshConversationList();
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"대화 전환 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>VisualTree를 올라가며 지정 타입의 DataContext를 찾습니다.</summary>
|
||||
private static T? FindDataContext<T>(DependencyObject? element) where T : class
|
||||
{
|
||||
while (element != null)
|
||||
{
|
||||
if (element is FrameworkElement fe && fe.DataContext is T ctx)
|
||||
return ctx;
|
||||
element = VisualTreeHelper.GetParent(element);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
|
||||
@@ -119,17 +229,8 @@ public partial class ChatWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (_streamingTabs.Contains(_activeTab))
|
||||
{
|
||||
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
|
||||
_cursorTimer.Stop();
|
||||
_typingTimer.Stop();
|
||||
_elapsedTimer.Stop();
|
||||
_activeStreamText = null;
|
||||
_elapsedLabel = null;
|
||||
_streamingTabs.Remove(_activeTab);
|
||||
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
|
||||
}
|
||||
// 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
|
||||
StopStreamingIfActive();
|
||||
|
||||
var conv = _storage.Load(tag.Id);
|
||||
if (conv == null)
|
||||
@@ -218,6 +319,7 @@ public partial class ChatWindow
|
||||
LastFailedAt = runSummary.LastFailedAt,
|
||||
LastCompletedAt = runSummary.LastCompletedAt,
|
||||
WorkFolder = c.WorkFolder ?? "",
|
||||
Archived = c.Archived,
|
||||
IsRunning = _currentConversation?.Id == c.Id
|
||||
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
|
||||
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -227,15 +329,22 @@ public partial class ChatWindow
|
||||
|
||||
// LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
|
||||
items = items.Where(i =>
|
||||
string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)
|
||||
&& (i.Pinned
|
||||
{
|
||||
if (!string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
// 아카이브 필터: null=일반만(비아카이브), true=아카이브만, false(미사용)=전체
|
||||
if (_archiveFilter == null && i.Archived) return false;
|
||||
if (_archiveFilter == true && !i.Archived) return false;
|
||||
|
||||
return 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();
|
||||
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase);
|
||||
}).ToList();
|
||||
|
||||
// Count를 한 번의 루프로 계산 (3번 순회 → 1번)
|
||||
int failedCount = 0, runningCount = 0, spotlightCount = 0;
|
||||
@@ -300,7 +409,133 @@ public partial class ChatWindow
|
||||
.ThenByDescending(i => i.UpdatedAt))
|
||||
.ToList();
|
||||
|
||||
// 스크롤 위치 보존: 대화 전환 시 목록이 맨 위로 점프하는 현상 방지
|
||||
double savedScrollOffset = 0;
|
||||
bool hasScrollViewer = ConversationListScrollViewer != null;
|
||||
if (hasScrollViewer)
|
||||
savedScrollOffset = ConversationListScrollViewer!.VerticalOffset;
|
||||
|
||||
SyncConversationsToViewModel(items);
|
||||
RenderConversationList(items);
|
||||
|
||||
// 저장된 스크롤 위치 복원
|
||||
if (hasScrollViewer && savedScrollOffset > 0)
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
ConversationListScrollViewer?.ScrollToVerticalOffset(savedScrollOffset);
|
||||
}), System.Windows.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ConversationMeta>? _allFilteredItems;
|
||||
|
||||
private void SyncConversationsToViewModel(List<ConversationMeta> items)
|
||||
{
|
||||
var currentId = "";
|
||||
lock (_convLock)
|
||||
currentId = _currentConversation?.Id ?? "";
|
||||
|
||||
var spotlightIds = new HashSet<string>(
|
||||
BuildConversationSpotlightItems(items).Select(s => s.Id));
|
||||
|
||||
ViewModel.Conversations.Clear();
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
ViewModel.EmptyConversationText = _activeTab switch
|
||||
{
|
||||
"Cowork" => "Cowork 탭 대화가 없습니다",
|
||||
"Code" => "Code 탭 대화가 없습니다",
|
||||
_ => "Chat 탭 대화가 없습니다",
|
||||
};
|
||||
ViewModel.RemainingConversationCount = 0;
|
||||
_allFilteredItems = null;
|
||||
return;
|
||||
}
|
||||
|
||||
ViewModel.EmptyConversationText = "";
|
||||
|
||||
// 스포트라이트 항목 먼저 (GroupOrder=0)
|
||||
foreach (var item in items.Where(i => spotlightIds.Contains(i.Id)))
|
||||
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, "집중 필요", 0));
|
||||
|
||||
// 날짜 그룹 항목 — 페이지네이션 (GroupOrder=1+)
|
||||
var dateGrouped = items.Select(item =>
|
||||
{
|
||||
var group = GetConversationDateGroup(item.UpdatedAt);
|
||||
var groupOrder = group switch { "오늘" => 1, "어제" => 2, _ => 3 };
|
||||
return (item, group, groupOrder);
|
||||
}).ToList();
|
||||
|
||||
var pageItems = dateGrouped
|
||||
.Where(x => !spotlightIds.Contains(x.item.Id))
|
||||
.Take(ConversationPageSize).ToList();
|
||||
foreach (var (item, group, groupOrder) in pageItems)
|
||||
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, group, groupOrder));
|
||||
|
||||
var totalNonSpotlight = dateGrouped.Count(x => !spotlightIds.Contains(x.item.Id));
|
||||
var remaining = totalNonSpotlight - pageItems.Count;
|
||||
ViewModel.RemainingConversationCount = remaining;
|
||||
_allFilteredItems = remaining > 0 ? items : null;
|
||||
}
|
||||
|
||||
/// <summary>ViewModel의 "더 보기" 요청을 처리하여 나머지 항목을 로드합니다.</summary>
|
||||
internal void LoadAllConversations()
|
||||
{
|
||||
if (_allFilteredItems == null) return;
|
||||
|
||||
var items = _allFilteredItems;
|
||||
_allFilteredItems = null;
|
||||
|
||||
var currentId = "";
|
||||
lock (_convLock)
|
||||
currentId = _currentConversation?.Id ?? "";
|
||||
|
||||
var spotlightIds = new HashSet<string>(
|
||||
BuildConversationSpotlightItems(items).Select(s => s.Id));
|
||||
|
||||
ViewModel.Conversations.Clear();
|
||||
|
||||
// 스포트라이트
|
||||
foreach (var item in items.Where(i => spotlightIds.Contains(i.Id)))
|
||||
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, "집중 필요", 0));
|
||||
|
||||
// 전체 날짜 그룹 (스포트라이트 제외 — 이미 위에서 추가됨)
|
||||
foreach (var item in items.Where(i => !spotlightIds.Contains(i.Id)))
|
||||
{
|
||||
var group = GetConversationDateGroup(item.UpdatedAt);
|
||||
var groupOrder = group switch { "오늘" => 1, "어제" => 2, _ => 3 };
|
||||
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, group, groupOrder));
|
||||
}
|
||||
|
||||
ViewModel.RemainingConversationCount = 0;
|
||||
}
|
||||
|
||||
private static ConversationItemViewModel BuildConversationItemVm(
|
||||
ConversationMeta item, string currentId, string group, int groupOrder)
|
||||
{
|
||||
return new ConversationItemViewModel
|
||||
{
|
||||
Id = item.Id,
|
||||
Title = item.Title,
|
||||
UpdatedAtText = item.UpdatedAtText,
|
||||
Pinned = item.Pinned,
|
||||
IsSelected = item.Id == currentId,
|
||||
IsRunning = item.IsRunning,
|
||||
Category = item.Category,
|
||||
Symbol = item.Symbol,
|
||||
ColorHex = item.ColorHex,
|
||||
Tab = item.Tab,
|
||||
Preview = item.Preview,
|
||||
ParentId = item.ParentId,
|
||||
AgentRunCount = item.AgentRunCount,
|
||||
FailedAgentRunCount = item.FailedAgentRunCount,
|
||||
LastAgentRunSummary = item.LastAgentRunSummary,
|
||||
WorkFolder = item.WorkFolder,
|
||||
Group = group,
|
||||
GroupOrder = groupOrder,
|
||||
};
|
||||
}
|
||||
|
||||
private void RenderConversationList(List<ConversationMeta> items)
|
||||
@@ -514,7 +749,7 @@ public partial class ChatWindow
|
||||
var icon = new TextBlock
|
||||
{
|
||||
Text = iconText,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 10.5,
|
||||
Foreground = iconBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -587,7 +822,7 @@ public partial class ChatWindow
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = "\uE70F",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 9,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||
},
|
||||
@@ -628,4 +863,85 @@ public partial class ChatWindow
|
||||
|
||||
ConversationPanel.Children.Add(border);
|
||||
}
|
||||
|
||||
private void LoadMoreBorder_Click(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
LoadAllConversations();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대화 ID에 해당하는 제목 TextBlock을 찾습니다.
|
||||
/// ItemsControl(새) → ConversationPanel(레거시) 순서로 탐색합니다.
|
||||
/// </summary>
|
||||
private TextBlock? FindConversationTitleBlock(string conversationId)
|
||||
{
|
||||
// 1) 새 ItemsControl에서 검색
|
||||
if (ConversationItemsControl is { Visibility: Visibility.Visible })
|
||||
{
|
||||
var titleBlock = FindTitleBlockInItemsControl(conversationId);
|
||||
if (titleBlock != null) return titleBlock;
|
||||
}
|
||||
|
||||
// 2) 레거시 ConversationPanel에서 검색
|
||||
foreach (UIElement child in ConversationPanel.Children)
|
||||
{
|
||||
if (child is not Border b || b.Tag is not ConversationItemTag tag) continue;
|
||||
if (tag.Id == conversationId && tag.TitleBlock != null)
|
||||
return tag.TitleBlock;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ItemsControl 비주얼 트리에서 특정 대화 ID의 ConvTitleBlock을 찾습니다.
|
||||
/// GroupStyle 사용 시 ContainerFromIndex가 올바르게 동작하지 않으므로
|
||||
/// 비주얼 트리를 직접 탐색하여 DataContext가 일치하는 ContentPresenter를 찾습니다.
|
||||
/// </summary>
|
||||
private TextBlock? FindTitleBlockInItemsControl(string conversationId)
|
||||
{
|
||||
if (ConversationItemsControl == null) return null;
|
||||
|
||||
// GroupStyle이 적용된 ItemsControl은 비주얼 트리를 직접 탐색해야 함
|
||||
var presenters = new List<ContentPresenter>();
|
||||
CollectContentPresenters(ConversationItemsControl, presenters);
|
||||
|
||||
foreach (var presenter in presenters)
|
||||
{
|
||||
if (presenter.DataContext is ConversationItemViewModel vm && vm.Id == conversationId)
|
||||
{
|
||||
var titleBlock = FindNamedDescendant<TextBlock>(presenter, "ConvTitleBlock");
|
||||
if (titleBlock != null) return titleBlock;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>비주얼 트리에서 모든 ContentPresenter를 수집합니다.</summary>
|
||||
private static void CollectContentPresenters(DependencyObject parent, List<ContentPresenter> result)
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is ContentPresenter cp && cp.DataContext is ConversationItemViewModel)
|
||||
result.Add(cp);
|
||||
else
|
||||
CollectContentPresenters(child, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>비주얼 트리를 DFS로 순회하며 지정 이름의 요소를 찾습니다.</summary>
|
||||
private static T? FindNamedDescendant<T>(DependencyObject parent, string name) where T : FrameworkElement
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T fe && fe.Name == name) return fe;
|
||||
var result = FindNamedDescendant<T>(child, name);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ public partial class ChatWindow
|
||||
var iconTb = new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = iconColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -218,21 +218,11 @@ public partial class ChatWindow
|
||||
|
||||
stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () =>
|
||||
{
|
||||
foreach (UIElement child in ConversationPanel.Children)
|
||||
var titleBlock = FindConversationTitleBlock(conversationId);
|
||||
if (titleBlock != null)
|
||||
{
|
||||
if (child is not Border b || b.Child is not Grid g) continue;
|
||||
foreach (UIElement gc in g.Children)
|
||||
{
|
||||
if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
|
||||
{
|
||||
if (conv != null && tb.Text == conv.Title)
|
||||
{
|
||||
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
EnterTitleEditMode(tb, conversationId, titleColor);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
EnterTitleEditMode(titleBlock, conversationId, titleColor);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -267,7 +257,7 @@ public partial class ChatWindow
|
||||
infoSp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = catSymbol,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = catBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -324,7 +314,7 @@ public partial class ChatWindow
|
||||
var catIcon = new TextBlock
|
||||
{
|
||||
Text = symbol,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = BrushFromHex(color),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -406,6 +396,26 @@ public partial class ChatWindow
|
||||
}
|
||||
|
||||
stack.Children.Add(CreateSeparator());
|
||||
|
||||
// 아카이브 토글
|
||||
var isArchived = _storage.Load(conversationId)?.Archived ?? false;
|
||||
stack.Children.Add(CreateMenuItem(
|
||||
isArchived ? "\uE7B8" : "\uE7B7",
|
||||
isArchived ? "아카이브 해제" : "아카이브 보관",
|
||||
TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, () =>
|
||||
{
|
||||
var convToArchive = _storage.Load(conversationId);
|
||||
if (convToArchive == null) return;
|
||||
convToArchive.Archived = !convToArchive.Archived;
|
||||
_storage.Save(convToArchive);
|
||||
lock (_convLock)
|
||||
{
|
||||
if (_currentConversation?.Id == conversationId)
|
||||
_currentConversation.Archived = convToArchive.Archived;
|
||||
}
|
||||
RefreshConversationList();
|
||||
}));
|
||||
|
||||
stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () =>
|
||||
{
|
||||
var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제",
|
||||
|
||||
383
src/AxCopilot/Views/ChatWindow.FileMentionSuggestions.cs
Normal file
383
src/AxCopilot/Views/ChatWindow.FileMentionSuggestions.cs
Normal file
@@ -0,0 +1,383 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private sealed record FileMentionQuery(string Token, int Start, int Length);
|
||||
|
||||
private sealed record FileMentionCandidate(string RelativePath, string FileName, int Score);
|
||||
|
||||
private static readonly Regex FileMentionTokenRegex = new(
|
||||
"(?<token>[^\\s\\\"'`()\\[\\]{}<>]{2,})$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly HashSet<string> FileMentionIgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
|
||||
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
|
||||
"packages", ".nuget", "TestResults", "coverage", ".next",
|
||||
"target", ".gradle", ".cargo",
|
||||
};
|
||||
|
||||
private readonly object _fileMentionIndexLock = new();
|
||||
private List<string> _fileMentionIndexedPaths = new();
|
||||
private string? _fileMentionIndexedFolder;
|
||||
private DateTime _fileMentionIndexBuiltAtUtc = DateTime.MinValue;
|
||||
private CancellationTokenSource? _fileMentionIndexCts;
|
||||
private bool _fileMentionIndexBuildPending;
|
||||
private const int FileMentionIndexLimit = 4000;
|
||||
|
||||
private void RefreshFileMentionSuggestions(string text)
|
||||
{
|
||||
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.IsNullOrWhiteSpace(GetCurrentWorkFolder()))
|
||||
{
|
||||
HideFileMentionSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (SlashPopup?.IsOpen == true && text.StartsWith("/") && !text.Contains(' '))
|
||||
{
|
||||
HideFileMentionSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
var query = TryExtractFileMentionQuery(text);
|
||||
if (query == null)
|
||||
{
|
||||
HideFileMentionSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
var workFolder = GetCurrentWorkFolder();
|
||||
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
||||
{
|
||||
HideFileMentionSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> indexSnapshot;
|
||||
bool canUseCurrentIndex;
|
||||
lock (_fileMentionIndexLock)
|
||||
{
|
||||
canUseCurrentIndex =
|
||||
string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase)
|
||||
&& (DateTime.UtcNow - _fileMentionIndexBuiltAtUtc) < TimeSpan.FromMinutes(3)
|
||||
&& _fileMentionIndexedPaths.Count > 0;
|
||||
indexSnapshot = canUseCurrentIndex ? new List<string>(_fileMentionIndexedPaths) : [];
|
||||
}
|
||||
|
||||
if (!canUseCurrentIndex)
|
||||
{
|
||||
RenderFileMentionSuggestions(query.Token, [], isLoading: true);
|
||||
EnsureFileMentionIndexAsync(workFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
var candidates = FindFileMentionCandidates(indexSnapshot, query.Token);
|
||||
RenderFileMentionSuggestions(query.Token, candidates, isLoading: false);
|
||||
}
|
||||
|
||||
private void EnsureFileMentionIndexAsync(string workFolder)
|
||||
{
|
||||
if (_fileMentionIndexBuildPending
|
||||
&& string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
_fileMentionIndexCts?.Cancel();
|
||||
_fileMentionIndexCts = new CancellationTokenSource();
|
||||
var localCts = _fileMentionIndexCts;
|
||||
|
||||
_fileMentionIndexBuildPending = true;
|
||||
_fileMentionIndexedFolder = workFolder;
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
var indexedPaths = BuildFileMentionIndex(workFolder, localCts.Token);
|
||||
if (localCts.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
lock (_fileMentionIndexLock)
|
||||
{
|
||||
_fileMentionIndexedPaths = indexedPaths;
|
||||
_fileMentionIndexedFolder = workFolder;
|
||||
_fileMentionIndexBuiltAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
Dispatcher?.Invoke(() =>
|
||||
{
|
||||
_fileMentionIndexBuildPending = false;
|
||||
RefreshFileMentionSuggestions(InputBox?.Text ?? "");
|
||||
});
|
||||
}, localCts.Token).ContinueWith(_ =>
|
||||
{
|
||||
Dispatcher?.Invoke(() => { _fileMentionIndexBuildPending = false; });
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private static List<string> BuildFileMentionIndex(string workFolder, CancellationToken ct)
|
||||
{
|
||||
var results = new List<string>(capacity: 512);
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(workFolder);
|
||||
|
||||
while (pending.Count > 0 && results.Count < FileMentionIndexLimit)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var current = pending.Pop();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(current))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(dir);
|
||||
if (string.IsNullOrWhiteSpace(name)
|
||||
|| name.StartsWith('.')
|
||||
|| FileMentionIgnoredDirs.Contains(name))
|
||||
continue;
|
||||
|
||||
pending.Push(dir);
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(current))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName.StartsWith('.'))
|
||||
continue;
|
||||
|
||||
results.Add(Path.GetRelativePath(workFolder, file));
|
||||
if (results.Count >= FileMentionIndexLimit)
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static FileMentionQuery? TryExtractFileMentionQuery(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
var trimmedEnd = text.TrimEnd();
|
||||
if (trimmedEnd.Length < 2)
|
||||
return null;
|
||||
|
||||
var match = FileMentionTokenRegex.Match(trimmedEnd);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var token = match.Groups["token"].Value.Trim();
|
||||
if (token.Length < 2)
|
||||
return null;
|
||||
|
||||
var looksFileLike = token.Contains('.')
|
||||
|| token.Contains('/')
|
||||
|| token.Contains('\\')
|
||||
|| token.Contains('_')
|
||||
|| token.Contains('-');
|
||||
if (!looksFileLike)
|
||||
{
|
||||
var lower = trimmedEnd.ToLowerInvariant();
|
||||
var fileContext = lower.Contains("파일")
|
||||
|| lower.Contains("문서")
|
||||
|| lower.Contains("폴더")
|
||||
|| lower.Contains("file")
|
||||
|| lower.Contains("document")
|
||||
|| lower.Contains("folder")
|
||||
|| lower.Contains("read ")
|
||||
|| lower.Contains("open ");
|
||||
if (!fileContext)
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FileMentionQuery(
|
||||
token,
|
||||
match.Groups["token"].Index,
|
||||
match.Groups["token"].Length);
|
||||
}
|
||||
|
||||
private static List<FileMentionCandidate> FindFileMentionCandidates(
|
||||
IReadOnlyCollection<string> indexedPaths,
|
||||
string query)
|
||||
{
|
||||
if (indexedPaths.Count == 0 || string.IsNullOrWhiteSpace(query))
|
||||
return [];
|
||||
|
||||
var normalizedQuery = query.Replace('\\', '/').Trim().ToLowerInvariant();
|
||||
var queryFileName = Path.GetFileName(normalizedQuery);
|
||||
var candidates = new List<FileMentionCandidate>();
|
||||
|
||||
foreach (var relativePath in indexedPaths)
|
||||
{
|
||||
var normalizedPath = relativePath.Replace('\\', '/');
|
||||
var lowerPath = normalizedPath.ToLowerInvariant();
|
||||
var fileName = Path.GetFileName(normalizedPath);
|
||||
var lowerFileName = fileName.ToLowerInvariant();
|
||||
|
||||
if (!lowerPath.Contains(normalizedQuery) && !lowerFileName.Contains(queryFileName))
|
||||
continue;
|
||||
|
||||
var score = 0;
|
||||
if (string.Equals(lowerFileName, queryFileName, StringComparison.Ordinal))
|
||||
score += 600;
|
||||
else if (lowerFileName.StartsWith(queryFileName, StringComparison.Ordinal))
|
||||
score += 420;
|
||||
else if (lowerFileName.Contains(queryFileName, StringComparison.Ordinal))
|
||||
score += 260;
|
||||
|
||||
if (string.Equals(lowerPath, normalizedQuery, StringComparison.Ordinal))
|
||||
score += 520;
|
||||
else if (lowerPath.EndsWith(normalizedQuery, StringComparison.Ordinal))
|
||||
score += 300;
|
||||
else if (lowerPath.Contains(normalizedQuery, StringComparison.Ordinal))
|
||||
score += 140;
|
||||
|
||||
score += Math.Max(0, 80 - normalizedPath.Length);
|
||||
|
||||
candidates.Add(new FileMentionCandidate(normalizedPath, fileName, score));
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderByDescending(c => c.Score)
|
||||
.ThenBy(c => c.RelativePath.Length)
|
||||
.ThenBy(c => c.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(6)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void RenderFileMentionSuggestions(
|
||||
string query,
|
||||
IReadOnlyList<FileMentionCandidate> candidates,
|
||||
bool isLoading)
|
||||
{
|
||||
if (FileMentionSuggestionCard == null
|
||||
|| FileMentionSuggestionTitle == null
|
||||
|| FileMentionSuggestionChipPanel == null)
|
||||
return;
|
||||
|
||||
FileMentionSuggestionChipPanel.Children.Clear();
|
||||
|
||||
if (isLoading)
|
||||
{
|
||||
FileMentionSuggestionTitle.Text = $"파일 후보 찾는 중 · {query}";
|
||||
FileMentionSuggestionCard.Visibility = Visibility.Visible;
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
HideFileMentionSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
FileMentionSuggestionTitle.Text = $"파일 후보 · {query}";
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
|
||||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? itemBg;
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
|
||||
var accentBrush = TryFindResource("AccentColor") as Brush ?? primaryText;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var chip = new Border
|
||||
{
|
||||
Background = itemBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(10, 6, 10, 6),
|
||||
Margin = new Thickness(0, 0, 6, 6),
|
||||
Cursor = Cursors.Hand,
|
||||
Tag = candidate.RelativePath,
|
||||
};
|
||||
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = candidate.FileName,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
});
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = candidate.RelativePath,
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxWidth = 240,
|
||||
});
|
||||
chip.Child = stack;
|
||||
|
||||
chip.MouseEnter += (_, _) => chip.Background = hoverBg;
|
||||
chip.MouseLeave += (_, _) => chip.Background = itemBg;
|
||||
chip.MouseLeftButtonUp += (_, _) => ApplyFileMentionSuggestion(candidate.RelativePath);
|
||||
|
||||
FileMentionSuggestionChipPanel.Children.Add(chip);
|
||||
}
|
||||
|
||||
FileMentionSuggestionCard.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void HideFileMentionSuggestions()
|
||||
{
|
||||
if (FileMentionSuggestionCard == null || FileMentionSuggestionChipPanel == null)
|
||||
return;
|
||||
|
||||
FileMentionSuggestionChipPanel.Children.Clear();
|
||||
FileMentionSuggestionCard.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void ApplyFileMentionSuggestion(string relativePath)
|
||||
{
|
||||
if (InputBox == null || string.IsNullOrWhiteSpace(relativePath))
|
||||
return;
|
||||
|
||||
var text = InputBox.Text ?? "";
|
||||
var query = TryExtractFileMentionQuery(text);
|
||||
var replacement = relativePath.Replace('\\', '/');
|
||||
if (query != null)
|
||||
{
|
||||
text = text[..query.Start] + replacement + text[(query.Start + query.Length)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
text = string.IsNullOrWhiteSpace(text) ? replacement : $"{text.TrimEnd()} {replacement}";
|
||||
}
|
||||
|
||||
InputBox.Text = text;
|
||||
InputBox.CaretIndex = text.Length;
|
||||
InputBox.Focus();
|
||||
HideFileMentionSuggestions();
|
||||
}
|
||||
|
||||
private bool TryAcceptTopFileMentionSuggestion()
|
||||
{
|
||||
if (FileMentionSuggestionCard?.Visibility != Visibility.Visible
|
||||
|| FileMentionSuggestionChipPanel == null
|
||||
|| FileMentionSuggestionChipPanel.Children.Count == 0)
|
||||
return false;
|
||||
|
||||
if (FileMentionSuggestionChipPanel.Children[0] is not Border firstChip
|
||||
|| firstChip.Tag is not string relativePath)
|
||||
return false;
|
||||
|
||||
ApplyFileMentionSuggestion(relativePath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -66,17 +66,8 @@ public partial class ChatWindow
|
||||
}
|
||||
|
||||
FolderBar.Visibility = Visibility.Visible;
|
||||
var folder = GetCurrentWorkFolder();
|
||||
if (!string.IsNullOrEmpty(folder))
|
||||
{
|
||||
FolderPathLabel.Text = folder;
|
||||
FolderPathLabel.ToolTip = folder;
|
||||
}
|
||||
else
|
||||
{
|
||||
FolderPathLabel.Text = "폴더를 선택하세요.";
|
||||
FolderPathLabel.ToolTip = null;
|
||||
}
|
||||
ViewModel.WorkFolder = GetCurrentWorkFolder();
|
||||
UpdateFolderSelectButtonStyle();
|
||||
|
||||
LoadConversationSettings();
|
||||
LoadCompactionMetricsFromConversation();
|
||||
@@ -129,7 +120,7 @@ public partial class ChatWindow
|
||||
memory.Load(workFolder);
|
||||
var docs = memory.InstructionDocuments;
|
||||
var learned = memory.All.Count;
|
||||
var includePolicy = _settings.Settings.Llm.AllowExternalMemoryIncludes ? "?몃? include ?덉슜" : "?몃? include 李⑤떒";
|
||||
var includePolicy = _settings.Settings.Llm.AllowExternalMemoryIncludes ? "외부 include 허용" : "외부 include 차단";
|
||||
var auditEnabled = _settings.Settings.Llm.EnableAuditLog;
|
||||
var recentIncludeEntries = AuditLogService.LoadRecent("MemoryInclude", maxCount: 5, daysBack: 3);
|
||||
|
||||
@@ -143,7 +134,7 @@ public partial class ChatWindow
|
||||
var panel = new StackPanel { Margin = new Thickness(2) };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "硫붾え由??곹깭",
|
||||
Text = "메모리 상태",
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
@@ -151,7 +142,7 @@ public partial class ChatWindow
|
||||
});
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"怨꾩링??洹쒖튃 {docs.Count}媛?쨌 ?숈뒿 硫붾え由?{learned}媛?쨌 {includePolicy}",
|
||||
Text = $"규칙 문서 {docs.Count}개 · 학습 메모리 {learned}개 · {includePolicy}",
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
@@ -163,7 +154,7 @@ public partial class ChatWindow
|
||||
panel.Children.Add(CreateSurfacePopupSeparator());
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "?곸슜 以?洹쒖튃",
|
||||
Text = "적용 중 규칙",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
@@ -177,7 +168,7 @@ public partial class ChatWindow
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"??{docs.Count - 6}媛?洹쒖튃",
|
||||
Text = $"외 {docs.Count - 6}개 규칙",
|
||||
FontSize = 11,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(8, 2, 8, 4),
|
||||
@@ -188,7 +179,7 @@ public partial class ChatWindow
|
||||
panel.Children.Add(CreateSurfacePopupSeparator());
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "理쒓렐 include 媛먯궗",
|
||||
Text = "최근 include 감사",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
@@ -199,7 +190,7 @@ public partial class ChatWindow
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "媛먯궗 濡쒓렇媛 爰쇱졇 ?덉뼱 include ?대젰? 湲곕줉?섏? ?딆뒿?덈떎.",
|
||||
Text = "감사 로그가 꺼져 있어 include 이력이 기록되지 않습니다.",
|
||||
FontSize = 11,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
@@ -210,7 +201,7 @@ public partial class ChatWindow
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "理쒓렐 3?쇨컙 include 媛먯궗 湲곕줉???놁뒿?덈떎.",
|
||||
Text = "최근 3일간 include 감사 기록이 없습니다.",
|
||||
FontSize = 11,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(8, 0, 8, 6),
|
||||
@@ -266,7 +257,7 @@ public partial class ChatWindow
|
||||
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"[{doc.Label}] ?곗꽑?쒖쐞 {doc.Priority}",
|
||||
Text = $"[{doc.Label}] 우선순위 {doc.Priority}",
|
||||
FontSize = 11.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
@@ -297,7 +288,7 @@ public partial class ChatWindow
|
||||
private Border BuildMemoryPopupAuditRow(AuditEntry entry, Brush primaryText, Brush secondaryText, Brush okBrush, Brush warnBrush, Brush dangerBrush)
|
||||
{
|
||||
var statusBrush = entry.Success ? okBrush : dangerBrush;
|
||||
var statusText = entry.Success ? "?덉슜" : "李⑤떒";
|
||||
var statusText = entry.Success ? "허용" : "차단";
|
||||
var resultBrush = entry.Success ? secondaryText : warnBrush;
|
||||
|
||||
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };
|
||||
|
||||
@@ -16,15 +16,13 @@ public partial class ChatWindow
|
||||
{
|
||||
_currentGitBranchName = branchName;
|
||||
_currentGitTooltip = tooltip;
|
||||
ViewModel.GitBranchName = string.IsNullOrWhiteSpace(branchName) ? "브랜치 없음" : branchName;
|
||||
|
||||
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)
|
||||
@@ -493,7 +491,7 @@ public partial class ChatWindow
|
||||
grid.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = BrushFromHex(colorHex),
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
@@ -527,7 +525,7 @@ public partial class ChatWindow
|
||||
var chevron = new TextBlock
|
||||
{
|
||||
Text = "\uE76C",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
@@ -14,6 +15,13 @@ public partial class ChatWindow
|
||||
if (MessageList == null) return;
|
||||
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
// V2 분기
|
||||
if (_settings.Settings.Llm.EnableNewChatRendering)
|
||||
{
|
||||
ShowAgentLiveCardV2(runTab);
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveAgentLiveCard(animated: false);
|
||||
|
||||
_agentLiveStartTime = DateTime.UtcNow;
|
||||
@@ -52,23 +60,25 @@ public partial class ChatWindow
|
||||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var liveIcon = CreateMiniLauncherIcon(pixelSize: 4.0);
|
||||
if (!IsLightweightLiveProgressMode(runTab))
|
||||
var (agentName, _, _) = GetAgentIdentity();
|
||||
var (liveIconHost, livePixels, liveGlows, liveRotate, liveScale) = CreateMiniLauncherIconEx(4.0, "none");
|
||||
{
|
||||
liveIcon.BeginAnimation(
|
||||
UIElement.OpacityProperty,
|
||||
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(750))
|
||||
// 모든 모드에서 동일한 순차 점멸 애니메이션 적용
|
||||
var canvas = liveIconHost.Children.OfType<Canvas>().FirstOrDefault();
|
||||
if (canvas != null)
|
||||
{
|
||||
var animState = new ChatIconAnimState
|
||||
{
|
||||
AutoReverse = true,
|
||||
RepeatBehavior = RepeatBehavior.Forever,
|
||||
EasingFunction = new SineEase()
|
||||
});
|
||||
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
|
||||
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
|
||||
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
|
||||
};
|
||||
StartChatIconAnimation(animState);
|
||||
}
|
||||
}
|
||||
|
||||
Grid.SetColumn(liveIcon, 0);
|
||||
headerGrid.Children.Add(liveIcon);
|
||||
|
||||
var (agentName, _, _) = GetAgentIdentity();
|
||||
Grid.SetColumn(liveIconHost, 0);
|
||||
headerGrid.Children.Add(liveIconHost);
|
||||
var nameTb = new TextBlock
|
||||
{
|
||||
Text = agentName,
|
||||
@@ -108,7 +118,7 @@ public partial class ChatWindow
|
||||
{
|
||||
Text = "준비 중...",
|
||||
FontSize = 12,
|
||||
FontFamily = new FontFamily("Segoe UI, Malgun Gothic"),
|
||||
FontFamily = s_segoeUiFont,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
@@ -168,7 +178,7 @@ public partial class ChatWindow
|
||||
{
|
||||
Text = $"› {subItem}",
|
||||
FontSize = 10.5,
|
||||
FontFamily = new FontFamily("Segoe UI, Malgun Gothic"),
|
||||
FontFamily = s_segoeUiFont,
|
||||
Foreground = secondary,
|
||||
Opacity = 0.62,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
@@ -181,12 +191,19 @@ public partial class ChatWindow
|
||||
|
||||
private void RemoveAgentLiveCard(bool animated = true)
|
||||
{
|
||||
// V2 분기 — V2 라이브 컨테이너도 함께 정리
|
||||
if (_v2LiveContainer != null)
|
||||
RemoveAgentLiveCardV2(animated);
|
||||
|
||||
_agentLiveElapsedTimer?.Stop();
|
||||
_agentLiveElapsedTimer = null;
|
||||
|
||||
if (_agentLiveContainer == null)
|
||||
return;
|
||||
|
||||
// 라이브 카드에 연결된 아이콘 애니메이션 상태 제거
|
||||
_chatIconAnimStates.RemoveAll(s => s.Host != null && !s.Host.IsVisible);
|
||||
|
||||
var toRemove = _agentLiveContainer;
|
||||
_agentLiveContainer = null;
|
||||
_agentLiveStatusText = null;
|
||||
|
||||
784
src/AxCopilot/Views/ChatWindow.MascotPresentation.cs
Normal file
784
src/AxCopilot/Views/ChatWindow.MascotPresentation.cs
Normal file
@@ -0,0 +1,784 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 마스코트 GIF 애니메이션 시스템:
|
||||
/// Assets/gif/*.gif 에서 애니메이션 GIF를 로드하고, 배경색을 자동 투명화한 후
|
||||
/// 프레임 단위로 재생합니다. GIF가 없으면 기존 정적 이미지 폴백.
|
||||
/// 로딩은 백그라운드 스레드에서 수행하여 UI 렉을 방지합니다.
|
||||
/// </summary>
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── 상수 ──────────────────────────────────────────────
|
||||
private const int MascotMaxH = 220; // 캐릭터 최대 높이(px)
|
||||
private const int MascotMaxW = 200; // 캐릭터 최대 폭(px)
|
||||
private const int BgTolerance = 45; // 배경 투명화 색상 허용 오차 (어두운 배경 대응)
|
||||
|
||||
// ─── 상태 ──────────────────────────────────────────────
|
||||
private DispatcherTimer? _mascotCycleTimer; // GIF 간 교체 타이머
|
||||
private DispatcherTimer? _gifFrameTimer; // 프레임 재생 타이머
|
||||
private bool _mascotImageLoaded;
|
||||
private bool _mascotLoading; // 백그라운드 로딩 중 플래그
|
||||
private double _mascotAreaW;
|
||||
|
||||
// GIF 데이터
|
||||
private readonly List<GifAnimation> _gifAnimations = new();
|
||||
private int _currentGifIndex;
|
||||
private int _currentFrameIndex;
|
||||
private GifAnimation? _activeGif;
|
||||
|
||||
/// <summary>단일 GIF 파일의 디코딩된 프레임 + 딜레이 정보.</summary>
|
||||
private sealed class GifAnimation
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public BitmapSource[] Frames { get; init; } = [];
|
||||
public int[] DelaysMs { get; init; } = [];
|
||||
public double DisplayW { get; init; }
|
||||
public double DisplayH { get; init; }
|
||||
public bool UseNearestNeighbor { get; init; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 초기화: GIF 로드 · 배경 투명화 · 크기 보정 (백그라운드)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private void InitializeMascot()
|
||||
{
|
||||
if (_mascotImageLoaded || _mascotLoading) return;
|
||||
_mascotLoading = true;
|
||||
|
||||
var maxCount = MascotMaxGifCount;
|
||||
if (maxCount <= 0)
|
||||
{
|
||||
_mascotImageLoaded = true;
|
||||
_mascotLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 백그라운드 스레드에서 GIF 로드 (UI 렉 방지)
|
||||
// 첫 GIF를 먼저 전달하여 즉시 표시, 나머지는 이후 추가
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var gifDir = FindGifDirectory();
|
||||
if (gifDir == null || !Directory.Exists(gifDir))
|
||||
{
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
_mascotImageLoaded = true;
|
||||
_mascotLoading = false;
|
||||
Services.LogService.Info("마스코트 GIF 없음 — 정적 이미지 폴백");
|
||||
}, DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
|
||||
var gifFiles = Directory.GetFiles(gifDir, "*.gif");
|
||||
|
||||
// 랜덤 셔플 후 maxCount만큼만 선택
|
||||
var rng = Random.Shared;
|
||||
for (int i = gifFiles.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = rng.Next(i + 1);
|
||||
(gifFiles[i], gifFiles[j]) = (gifFiles[j], gifFiles[i]);
|
||||
}
|
||||
var selectedFiles = gifFiles.Length > maxCount
|
||||
? gifFiles[..maxCount]
|
||||
: gifFiles;
|
||||
|
||||
bool firstDelivered = false;
|
||||
|
||||
foreach (var path in selectedFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var gif = DecodeGif(path);
|
||||
if (gif == null || gif.Frames.Length == 0) continue;
|
||||
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
_gifAnimations.Add(gif);
|
||||
|
||||
if (!firstDelivered)
|
||||
{
|
||||
firstDelivered = true;
|
||||
MascotImage.Stretch = Stretch.Uniform;
|
||||
MascotImage.RenderTransformOrigin = new Point(0.5, 1.0);
|
||||
Services.LogService.Info($"마스코트 첫 GIF 로드 완료: {gif.Name} (최대 {maxCount}개)");
|
||||
|
||||
// 첫 GIF 즉시 재생 시작
|
||||
if (EmptyState.Visibility == Visibility.Visible
|
||||
&& string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
StartMascotAnimation();
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Warn($"GIF 로드 실패 ({Path.GetFileName(path)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 로딩 완료
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
_mascotImageLoaded = true;
|
||||
_mascotLoading = false;
|
||||
Services.LogService.Info($"마스코트 GIF {_gifAnimations.Count}개 로드 완료 (설정: {maxCount}개)");
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
_mascotLoading = false;
|
||||
Services.LogService.Warn($"마스코트 초기화 실패: {ex.Message}");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>백그라운드 스레드에서 GIF 파일들을 로드합니다.</summary>
|
||||
private static List<GifAnimation> LoadGifAnimationsOffThread()
|
||||
{
|
||||
var result = new List<GifAnimation>();
|
||||
|
||||
var gifDir = FindGifDirectory();
|
||||
if (gifDir == null || !Directory.Exists(gifDir))
|
||||
return result;
|
||||
|
||||
var gifFiles = Directory.GetFiles(gifDir, "*.gif");
|
||||
Array.Sort(gifFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var path in gifFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var gif = DecodeGif(path);
|
||||
if (gif != null && gif.Frames.Length > 0)
|
||||
result.Add(gif);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Warn($"GIF 로드 실패 ({Path.GetFileName(path)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>GIF 폴더 경로를 탐색합니다.</summary>
|
||||
private static string? FindGifDirectory()
|
||||
{
|
||||
var exeDir = AppContext.BaseDirectory;
|
||||
|
||||
// 1. 실행 파일 옆 Assets/gif/
|
||||
var candidate = Path.Combine(exeDir, "Assets", "gif");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
|
||||
// 2. %APPDATA%/AXCopilot/gif/
|
||||
var appData = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AXCopilot", "gif");
|
||||
if (Directory.Exists(appData)) return appData;
|
||||
|
||||
// 3. 개발 환경
|
||||
var devDir = Path.Combine(exeDir, "..", "..", "..", "Assets", "gif");
|
||||
if (Directory.Exists(devDir)) return Path.GetFullPath(devDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>GIF 파일을 디코딩하고 배경 투명화 + 크기 보정합니다.</summary>
|
||||
private static GifAnimation? DecodeGif(string path)
|
||||
{
|
||||
GifBitmapDecoder decoder;
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||
}
|
||||
|
||||
if (decoder.Frames.Count == 0)
|
||||
return null;
|
||||
|
||||
int frameCount = decoder.Frames.Count;
|
||||
var frames = new BitmapSource[frameCount];
|
||||
var delays = new int[frameCount];
|
||||
|
||||
// 원본 크기로 캔버스 생성 (GIF disposal 처리용)
|
||||
int canvasW = decoder.Frames[0].PixelWidth;
|
||||
int canvasH = decoder.Frames[0].PixelHeight;
|
||||
|
||||
// GIF 전역 크기 (메타데이터에서)
|
||||
try
|
||||
{
|
||||
var gifMeta = decoder.Metadata as BitmapMetadata;
|
||||
if (gifMeta != null)
|
||||
{
|
||||
var gw = gifMeta.GetQuery("/logscrdesc/Width");
|
||||
var gh = gifMeta.GetQuery("/logscrdesc/Height");
|
||||
if (gw is ushort w && gh is ushort h && w > 0 && h > 0)
|
||||
{ canvasW = w; canvasH = h; }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 첫 프레임에서 배경색 샘플링
|
||||
var firstRendered = RenderFullFrame(decoder.Frames[0], canvasW, canvasH);
|
||||
var bgColor = SampleBackgroundColor(firstRendered);
|
||||
|
||||
// 캔버스 기반 프레임 합성 (disposal method 처리)
|
||||
int stride = canvasW * 4;
|
||||
var canvas = new byte[stride * canvasH];
|
||||
var prevCanvas = new byte[stride * canvasH]; // disposal=3 복원용
|
||||
|
||||
for (int i = 0; i < frameCount; i++)
|
||||
{
|
||||
var bmpFrame = decoder.Frames[i];
|
||||
int disposal = GetDisposalMethod(bmpFrame);
|
||||
|
||||
// disposal=3 이전 상태 보존
|
||||
if (disposal == 3)
|
||||
Array.Copy(canvas, prevCanvas, canvas.Length);
|
||||
|
||||
// 프레임을 캔버스에 합성
|
||||
ComposeFrame(canvas, canvasW, canvasH, bmpFrame);
|
||||
|
||||
// 배경 투명화 적용
|
||||
var framePixels = new byte[canvas.Length];
|
||||
Array.Copy(canvas, framePixels, canvas.Length);
|
||||
if (bgColor.HasValue)
|
||||
RemoveBackgroundInPlace(framePixels, canvasW, canvasH, bgColor.Value);
|
||||
|
||||
var bmp = BitmapSource.Create(canvasW, canvasH, 96, 96,
|
||||
PixelFormats.Bgra32, null, framePixels, stride);
|
||||
bmp.Freeze();
|
||||
frames[i] = bmp;
|
||||
|
||||
// 프레임 딜레이
|
||||
delays[i] = GetFrameDelay(bmpFrame) * 10; // 1/100초 → ms
|
||||
if (delays[i] < 20) delays[i] = 100;
|
||||
|
||||
// disposal 처리
|
||||
switch (disposal)
|
||||
{
|
||||
case 2: // 배경으로 복원 (해당 영역 클리어)
|
||||
ClearFrameRegion(canvas, canvasW, canvasH, bmpFrame);
|
||||
break;
|
||||
case 3: // 이전 상태로 복원
|
||||
Array.Copy(prevCanvas, canvas, canvas.Length);
|
||||
break;
|
||||
// 0,1: 그대로 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 크기 보정
|
||||
var (dispW, dispH, useNN) = CalculateDisplaySize(canvasW, canvasH);
|
||||
|
||||
return new GifAnimation
|
||||
{
|
||||
Name = Path.GetFileNameWithoutExtension(path),
|
||||
Frames = frames,
|
||||
DelaysMs = delays,
|
||||
DisplayW = dispW,
|
||||
DisplayH = dispH,
|
||||
UseNearestNeighbor = useNN,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>단일 BitmapFrame을 전체 캔버스 크기의 BGRA32로 렌더합니다.</summary>
|
||||
private static BitmapSource RenderFullFrame(BitmapFrame frame, int canvasW, int canvasH)
|
||||
{
|
||||
int stride = canvasW * 4;
|
||||
var pixels = new byte[stride * canvasH];
|
||||
ComposeFrame(pixels, canvasW, canvasH, frame);
|
||||
var bmp = BitmapSource.Create(canvasW, canvasH, 96, 96, PixelFormats.Bgra32, null, pixels, stride);
|
||||
bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
|
||||
/// <summary>프레임을 캔버스 버퍼에 합성합니다 (오프셋 + 알파).</summary>
|
||||
private static void ComposeFrame(byte[] canvas, int canvasW, int canvasH, BitmapFrame frame)
|
||||
{
|
||||
// 프레임 오프셋 읽기
|
||||
int offX = 0, offY = 0;
|
||||
try
|
||||
{
|
||||
var meta = frame.Metadata as BitmapMetadata;
|
||||
if (meta != null)
|
||||
{
|
||||
var left = meta.GetQuery("/imgdesc/Left");
|
||||
var top = meta.GetQuery("/imgdesc/Top");
|
||||
if (left is ushort l) offX = l;
|
||||
if (top is ushort t) offY = t;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// BGRA32 변환
|
||||
var converted = frame.Format == PixelFormats.Bgra32
|
||||
? (BitmapSource)frame
|
||||
: new FormatConvertedBitmap(frame, PixelFormats.Bgra32, null, 0);
|
||||
|
||||
int fw = converted.PixelWidth, fh = converted.PixelHeight;
|
||||
int fStride = fw * 4;
|
||||
var fPixels = new byte[fStride * fh];
|
||||
converted.CopyPixels(fPixels, fStride, 0);
|
||||
|
||||
int cStride = canvasW * 4;
|
||||
for (int y = 0; y < fh; y++)
|
||||
{
|
||||
int cy = y + offY;
|
||||
if (cy < 0 || cy >= canvasH) continue;
|
||||
for (int x = 0; x < fw; x++)
|
||||
{
|
||||
int cx = x + offX;
|
||||
if (cx < 0 || cx >= canvasW) continue;
|
||||
|
||||
int fi = y * fStride + x * 4;
|
||||
int ci = cy * cStride + cx * 4;
|
||||
byte fa = fPixels[fi + 3];
|
||||
|
||||
if (fa == 255)
|
||||
{
|
||||
canvas[ci] = fPixels[fi];
|
||||
canvas[ci + 1] = fPixels[fi + 1];
|
||||
canvas[ci + 2] = fPixels[fi + 2];
|
||||
canvas[ci + 3] = 255;
|
||||
}
|
||||
else if (fa > 0)
|
||||
{
|
||||
// 알파 블렌딩
|
||||
int srcA = fa, dstA = canvas[ci + 3];
|
||||
int outA = srcA + dstA * (255 - srcA) / 255;
|
||||
if (outA > 0)
|
||||
{
|
||||
canvas[ci] = (byte)((fPixels[fi] * srcA + canvas[ci] * dstA * (255 - srcA) / 255) / outA);
|
||||
canvas[ci + 1] = (byte)((fPixels[fi + 1] * srcA + canvas[ci + 1] * dstA * (255 - srcA) / 255) / outA);
|
||||
canvas[ci + 2] = (byte)((fPixels[fi + 2] * srcA + canvas[ci + 2] * dstA * (255 - srcA) / 255) / outA);
|
||||
canvas[ci + 3] = (byte)outA;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>프레임 영역을 투명으로 초기화합니다 (disposal=2).</summary>
|
||||
private static void ClearFrameRegion(byte[] canvas, int canvasW, int canvasH, BitmapFrame frame)
|
||||
{
|
||||
int offX = 0, offY = 0;
|
||||
try
|
||||
{
|
||||
var meta = frame.Metadata as BitmapMetadata;
|
||||
if (meta != null)
|
||||
{
|
||||
var left = meta.GetQuery("/imgdesc/Left");
|
||||
var top = meta.GetQuery("/imgdesc/Top");
|
||||
if (left is ushort l) offX = l;
|
||||
if (top is ushort t) offY = t;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
int fw = frame.PixelWidth, fh = frame.PixelHeight;
|
||||
int cStride = canvasW * 4;
|
||||
for (int y = 0; y < fh; y++)
|
||||
{
|
||||
int cy = y + offY;
|
||||
if (cy < 0 || cy >= canvasH) continue;
|
||||
for (int x = 0; x < fw; x++)
|
||||
{
|
||||
int cx = x + offX;
|
||||
if (cx < 0 || cx >= canvasW) continue;
|
||||
int ci = cy * cStride + cx * 4;
|
||||
canvas[ci] = canvas[ci + 1] = canvas[ci + 2] = canvas[ci + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>4개 코너에서 배경색을 샘플링합니다.</summary>
|
||||
private static (byte R, byte G, byte B)? SampleBackgroundColor(BitmapSource frame)
|
||||
{
|
||||
int w = frame.PixelWidth, h = frame.PixelHeight;
|
||||
if (w < 2 || h < 2) return null;
|
||||
|
||||
int stride = w * 4;
|
||||
var pixels = new byte[stride * h];
|
||||
frame.CopyPixels(pixels, stride, 0);
|
||||
|
||||
// 4코너 + 각 변 중앙 (8포인트 샘플링)
|
||||
var points = new[] {
|
||||
(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
|
||||
(w / 2, 0), (w / 2, h - 1), (0, h / 2), (w - 1, h / 2)
|
||||
};
|
||||
int rSum = 0, gSum = 0, bSum = 0, count = 0;
|
||||
|
||||
foreach (var (cx, cy) in points)
|
||||
{
|
||||
int idx = cy * stride + cx * 4;
|
||||
byte a = pixels[idx + 3];
|
||||
if (a < 128) continue;
|
||||
rSum += pixels[idx + 2];
|
||||
gSum += pixels[idx + 1];
|
||||
bSum += pixels[idx];
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count == 0) return null;
|
||||
return ((byte)(rSum / count), (byte)(gSum / count), (byte)(bSum / count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다중 패스 BFS로 외곽 배경색을 투명화합니다 (in-place).
|
||||
/// GIF가 겹층 배경(예: 검정 테두리 → 회색 배경)을 가진 경우,
|
||||
/// 1차 패스에서 테두리를 제거하고, 투명화 경계에서 새로 노출된
|
||||
/// 색상을 감지하여 2차 패스로 내부 배경까지 제거합니다.
|
||||
/// </summary>
|
||||
private static void RemoveBackgroundInPlace(byte[] pixels, int w, int h, (byte R, byte G, byte B) bg)
|
||||
{
|
||||
int stride = w * 4;
|
||||
|
||||
// 최대 3회 패스 (테두리 → 1차 배경 → 2차 배경)
|
||||
for (int pass = 0; pass < 3; pass++)
|
||||
{
|
||||
var visited = new bool[w * h];
|
||||
var queue = new Queue<int>(w * 2 + h * 2);
|
||||
|
||||
// 시드: 모든 외곽 픽셀 + 투명 픽셀에 인접한 불투명 픽셀
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
TryEnqueue(x, 0);
|
||||
TryEnqueue(x, h - 1);
|
||||
}
|
||||
for (int y = 1; y < h - 1; y++)
|
||||
{
|
||||
TryEnqueue(0, y);
|
||||
TryEnqueue(w - 1, y);
|
||||
}
|
||||
|
||||
// 이전 패스에서 투명화된 픽셀 옆의 불투명 픽셀도 시드로 추가
|
||||
if (pass > 0)
|
||||
{
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int idx = y * stride + x * 4;
|
||||
if (pixels[idx + 3] >= 128) continue; // 불투명 → 스킵
|
||||
// 이 픽셀은 투명 — 4방향 이웃 중 불투명한 것을 시드로
|
||||
if (x > 0) TryEnqueue(x - 1, y);
|
||||
if (x < w - 1) TryEnqueue(x + 1, y);
|
||||
if (y > 0) TryEnqueue(x, y - 1);
|
||||
if (y < h - 1) TryEnqueue(x, y + 1);
|
||||
}
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
int pos = queue.Dequeue();
|
||||
int px = pos % w, py = pos / w;
|
||||
int idx = py * stride + px * 4;
|
||||
pixels[idx + 3] = 0; // alpha → 0
|
||||
removed++;
|
||||
|
||||
if (px > 0) TryEnqueue(px - 1, py);
|
||||
if (px < w - 1) TryEnqueue(px + 1, py);
|
||||
if (py > 0) TryEnqueue(px, py - 1);
|
||||
if (py < h - 1) TryEnqueue(px, py + 1);
|
||||
}
|
||||
|
||||
if (removed == 0) break; // 더 이상 제거할 것이 없음
|
||||
|
||||
// 다음 패스를 위해 새로 노출된 경계의 색상을 샘플링
|
||||
bg = SampleExposedBorderColor(pixels, w, h, stride) ?? bg;
|
||||
|
||||
void TryEnqueue(int x, int y)
|
||||
{
|
||||
int pos = y * w + x;
|
||||
if (visited[pos]) return;
|
||||
visited[pos] = true;
|
||||
|
||||
int idx = y * stride + x * 4;
|
||||
byte a = pixels[idx + 3];
|
||||
if (a < 128) { queue.Enqueue(pos); return; } // 이미 투명
|
||||
|
||||
byte b = pixels[idx], g = pixels[idx + 1], r = pixels[idx + 2];
|
||||
if (Math.Abs(r - bg.R) <= BgTolerance
|
||||
&& Math.Abs(g - bg.G) <= BgTolerance
|
||||
&& Math.Abs(b - bg.B) <= BgTolerance)
|
||||
{
|
||||
queue.Enqueue(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>투명 픽셀 경계에 접한 불투명 픽셀의 대표 색상을 구합니다.</summary>
|
||||
private static (byte R, byte G, byte B)? SampleExposedBorderColor(byte[] pixels, int w, int h, int stride)
|
||||
{
|
||||
long rSum = 0, gSum = 0, bSum = 0;
|
||||
int count = 0;
|
||||
|
||||
// 성능 최적화: 전체 스캔 대신 일정 간격으로 샘플링
|
||||
int step = Math.Max(1, Math.Min(w, h) / 100);
|
||||
|
||||
for (int y = 0; y < h; y += step)
|
||||
for (int x = 0; x < w; x += step)
|
||||
{
|
||||
int idx = y * stride + x * 4;
|
||||
if (pixels[idx + 3] >= 128) continue; // 불투명 → 스킵 (투명 픽셀만 검사)
|
||||
|
||||
// 투명 픽셀의 4방향 이웃 중 불투명 것의 색상 수집
|
||||
int[] dx = { -1, 1, 0, 0 }, dy = { 0, 0, -1, 1 };
|
||||
for (int d = 0; d < 4; d++)
|
||||
{
|
||||
int nx = x + dx[d], ny = y + dy[d];
|
||||
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
|
||||
int ni = ny * stride + nx * 4;
|
||||
if (pixels[ni + 3] < 128) continue;
|
||||
rSum += pixels[ni + 2];
|
||||
gSum += pixels[ni + 1];
|
||||
bSum += pixels[ni];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 4) return null;
|
||||
return ((byte)(rSum / count), (byte)(gSum / count), (byte)(bSum / count));
|
||||
}
|
||||
|
||||
/// <summary>GIF 프레임의 딜레이 메타데이터를 읽습니다.</summary>
|
||||
private static int GetFrameDelay(BitmapFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadata = frame.Metadata as BitmapMetadata;
|
||||
if (metadata != null)
|
||||
{
|
||||
var delay = metadata.GetQuery("/grctlext/Delay");
|
||||
if (delay is ushort d) return d;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return 10; // 기본 100ms
|
||||
}
|
||||
|
||||
/// <summary>GIF 프레임의 disposal method를 읽습니다.</summary>
|
||||
private static int GetDisposalMethod(BitmapFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadata = frame.Metadata as BitmapMetadata;
|
||||
if (metadata != null)
|
||||
{
|
||||
var disposal = metadata.GetQuery("/grctlext/Disposal");
|
||||
if (disposal is byte d) return d;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>표시 크기를 계산합니다 (종횡비 유지, 최대/최소 제한).</summary>
|
||||
private static (double w, double h, bool nearestNeighbor) CalculateDisplaySize(int origW, int origH)
|
||||
{
|
||||
if (origW <= 0 || origH <= 0)
|
||||
return (MascotMaxW, MascotMaxH, false);
|
||||
|
||||
double scale = Math.Min((double)MascotMaxW / origW, (double)MascotMaxH / origH);
|
||||
double dispW = origW * scale;
|
||||
double dispH = origH * scale;
|
||||
|
||||
// 너무 작은 원본(< 50px) → NearestNeighbor 스케일링
|
||||
bool useNN = origW < 50 || origH < 50;
|
||||
|
||||
return (dispW, dispH, useNN);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 시작 · 정지 · 프레임 재생
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>설정에서 마스코트 출동 수준을 반환합니다.</summary>
|
||||
private string MascotLevel => (_settings?.Settings?.Llm?.Code?.MascotLevel ?? "none").Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>마스코트 출동 수준에 따른 최대 GIF 로드 수.</summary>
|
||||
private int MascotMaxGifCount => MascotLevel switch
|
||||
{
|
||||
"one" => 1,
|
||||
"few" => 3,
|
||||
"mid" => 6,
|
||||
"all" => int.MaxValue,
|
||||
_ => 0, // "none"
|
||||
};
|
||||
|
||||
/// <summary>설정에서 마스코트 캐릭터 표시 여부를 확인합니다.</summary>
|
||||
private bool IsMascotEnabled => MascotLevel != "none";
|
||||
|
||||
private void StartMascotAnimation()
|
||||
{
|
||||
if (MascotCanvas == null) return;
|
||||
|
||||
// 마스코트는 코드 탭에서만 + 설정이 ON일 때만 표시
|
||||
if (!string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)
|
||||
|| !IsMascotEnabled)
|
||||
{
|
||||
MascotCanvas.Visibility = Visibility.Collapsed;
|
||||
if (EmptyIcon != null) EmptyIcon.Visibility = Visibility.Visible;
|
||||
return;
|
||||
}
|
||||
|
||||
// 백그라운드 로딩 시작 (아직 안 했으면)
|
||||
if (!_mascotImageLoaded && !_mascotLoading)
|
||||
{
|
||||
InitializeMascot();
|
||||
// 로딩 완료 시 자동으로 StartMascotAnimation 재호출됨
|
||||
MascotCanvas.Visibility = Visibility.Visible;
|
||||
EmptyIcon.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_gifAnimations.Count == 0)
|
||||
{
|
||||
// GIF 없음 또는 아직 로딩 중 — 기존 정적 이미지 폴백
|
||||
MascotCanvas.Visibility = Visibility.Visible;
|
||||
EmptyIcon.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
MascotCanvas.Visibility = Visibility.Visible;
|
||||
EmptyIcon.Visibility = Visibility.Collapsed;
|
||||
|
||||
_mascotAreaW = EmptyState.ActualWidth > 0 ? EmptyState.ActualWidth : 800;
|
||||
var areaH = EmptyState.ActualHeight > 0 ? EmptyState.ActualHeight : 400;
|
||||
|
||||
// 첫 GIF 시작
|
||||
_currentGifIndex = 0;
|
||||
StartGifPlayback(_gifAnimations[0]);
|
||||
PositionMascot(areaH);
|
||||
|
||||
// 레이아웃 완료 후 위치 재보정 (EmptyState가 Visible 직후 ActualHeight가 0일 수 있음)
|
||||
void onLayoutUpdated(object? s, EventArgs e)
|
||||
{
|
||||
EmptyState.LayoutUpdated -= onLayoutUpdated;
|
||||
if (MascotCanvas.Visibility == Visibility.Visible && EmptyState.ActualHeight > 0)
|
||||
PositionMascot(EmptyState.ActualHeight);
|
||||
}
|
||||
EmptyState.LayoutUpdated += onLayoutUpdated;
|
||||
|
||||
// 복수 GIF 교체 타이머
|
||||
if (_gifAnimations.Count > 1)
|
||||
{
|
||||
if (_mascotCycleTimer == null)
|
||||
{
|
||||
_mascotCycleTimer = new DispatcherTimer(DispatcherPriority.Background);
|
||||
_mascotCycleTimer.Tick += MascotCycleTimer_Tick;
|
||||
}
|
||||
_mascotCycleTimer.Interval = GetTotalGifDuration(_gifAnimations[0]);
|
||||
_mascotCycleTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void StopMascotAnimation()
|
||||
{
|
||||
_mascotCycleTimer?.Stop();
|
||||
_gifFrameTimer?.Stop();
|
||||
_activeGif = null;
|
||||
if (MascotCanvas != null) MascotCanvas.Visibility = Visibility.Collapsed;
|
||||
if (EmptyIcon != null) EmptyIcon.Visibility = Visibility.Visible;
|
||||
if (MascotScale != null) { MascotScale.ScaleX = 1; MascotScale.ScaleY = 1; }
|
||||
if (MascotRotate != null) MascotRotate.Angle = 0;
|
||||
if (MascotTranslate != null) { MascotTranslate.X = 0; MascotTranslate.Y = 0; }
|
||||
}
|
||||
|
||||
private void StartGifPlayback(GifAnimation gif)
|
||||
{
|
||||
_activeGif = gif;
|
||||
_currentFrameIndex = 0;
|
||||
|
||||
MascotImage.Width = gif.DisplayW;
|
||||
MascotImage.Height = gif.DisplayH;
|
||||
MascotImage.Visibility = Visibility.Visible;
|
||||
|
||||
RenderOptions.SetBitmapScalingMode(MascotImage,
|
||||
gif.UseNearestNeighbor ? BitmapScalingMode.NearestNeighbor : BitmapScalingMode.HighQuality);
|
||||
|
||||
if (gif.Frames.Length > 0)
|
||||
MascotImage.Source = gif.Frames[0];
|
||||
|
||||
if (gif.Frames.Length > 1)
|
||||
{
|
||||
if (_gifFrameTimer == null)
|
||||
{
|
||||
_gifFrameTimer = new DispatcherTimer(DispatcherPriority.Render);
|
||||
_gifFrameTimer.Tick += GifFrameTimer_Tick;
|
||||
}
|
||||
_gifFrameTimer.Interval = TimeSpan.FromMilliseconds(gif.DelaysMs[0]);
|
||||
_gifFrameTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void GifFrameTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_activeGif == null || _activeGif.Frames.Length == 0) return;
|
||||
|
||||
_currentFrameIndex++;
|
||||
|
||||
// 마지막 프레임에 도달하면 해당 프레임을 표시하고 타이머 정지
|
||||
// (마지막 모습을 유지한 채 cycle 타이머가 다음 GIF로 전환)
|
||||
if (_currentFrameIndex >= _activeGif.Frames.Length)
|
||||
{
|
||||
_currentFrameIndex = _activeGif.Frames.Length - 1;
|
||||
MascotImage.Source = _activeGif.Frames[_currentFrameIndex];
|
||||
_gifFrameTimer?.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
MascotImage.Source = _activeGif.Frames[_currentFrameIndex];
|
||||
|
||||
if (_gifFrameTimer != null)
|
||||
_gifFrameTimer.Interval = TimeSpan.FromMilliseconds(
|
||||
_activeGif.DelaysMs[_currentFrameIndex]);
|
||||
}
|
||||
|
||||
private void MascotCycleTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_gifAnimations.Count <= 1) return;
|
||||
|
||||
_currentGifIndex = (_currentGifIndex + 1) % _gifAnimations.Count;
|
||||
_gifFrameTimer?.Stop();
|
||||
|
||||
var nextGif = _gifAnimations[_currentGifIndex];
|
||||
StartGifPlayback(nextGif);
|
||||
|
||||
if (_mascotCycleTimer != null)
|
||||
_mascotCycleTimer.Interval = GetTotalGifDuration(nextGif);
|
||||
|
||||
if (EmptyState.ActualWidth > 0) _mascotAreaW = EmptyState.ActualWidth;
|
||||
var areaH = EmptyState.ActualHeight > 0 ? EmptyState.ActualHeight : 400;
|
||||
PositionMascot(areaH);
|
||||
}
|
||||
|
||||
private void PositionMascot(double areaH)
|
||||
{
|
||||
if (_activeGif == null) return;
|
||||
Canvas.SetLeft(MascotImage, 20);
|
||||
// 채팅 입력창 바로 위에 위치 (하단 여백 최소화)
|
||||
Canvas.SetTop(MascotImage, areaH - _activeGif.DisplayH);
|
||||
}
|
||||
|
||||
private static TimeSpan GetTotalGifDuration(GifAnimation gif)
|
||||
{
|
||||
long totalMs = 0;
|
||||
foreach (var d in gif.DelaysMs) totalMs += d;
|
||||
// GIF 1회 재생 + 마지막 모습 유지 시간 (7~12초 랜덤)
|
||||
var holdMs = Random.Shared.Next(7000, 12001);
|
||||
var total = totalMs + holdMs;
|
||||
return TimeSpan.FromMilliseconds(total);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
@@ -49,9 +50,9 @@ public partial class ChatWindow
|
||||
{
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(11, 7, 11, 7),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
};
|
||||
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
|
||||
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
|
||||
@@ -158,18 +159,30 @@ public partial class ChatWindow
|
||||
ApplyMessageEntryAnimation(container);
|
||||
|
||||
var (agentName, _, _) = GetAgentIdentity();
|
||||
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 1.5) };
|
||||
header.Children.Add(CreateMiniLauncherIcon(pixelSize: 4.0));
|
||||
var (iconHost, iconPixels, iconGlows, iconRotate, iconScale) = CreateMiniLauncherIconEx(4.0, "none");
|
||||
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 4, 0, 0) };
|
||||
header.Children.Add(iconHost);
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = agentName,
|
||||
FontSize = 11.5,
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(4, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
container.Children.Add(header);
|
||||
// 아이콘 애니메이션 적용
|
||||
var canvas = iconHost.Children.OfType<Canvas>().FirstOrDefault();
|
||||
if (canvas != null)
|
||||
{
|
||||
var animState = new ChatIconAnimState
|
||||
{
|
||||
Host = iconHost, Canvas = canvas, Pixels = iconPixels,
|
||||
Glows = iconGlows, Rotate = iconRotate, Scale = iconScale,
|
||||
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
|
||||
};
|
||||
StartChatIconAnimation(animState);
|
||||
}
|
||||
|
||||
var contentCard = new Border
|
||||
{
|
||||
@@ -297,6 +310,18 @@ public partial class ChatWindow
|
||||
contentCard.Child = contentStack;
|
||||
container.Children.Add(contentCard);
|
||||
|
||||
// 에이전트 이름 푸터 (메시지 본문 아래)
|
||||
container.Children.Add(header);
|
||||
|
||||
// 어시스턴트 메시지에 파일 경로가 포함되어 있으면 프리뷰/열기 퀵 액션 추가
|
||||
var outputFilePath = ExtractOutputFilePathFromContent(content);
|
||||
if (!string.IsNullOrEmpty(outputFilePath) && System.IO.File.Exists(outputFilePath))
|
||||
{
|
||||
var quickActions = BuildFileQuickActions(outputFilePath);
|
||||
quickActions.Margin = new Thickness(2, 4, 0, 2);
|
||||
container.Children.Add(quickActions);
|
||||
}
|
||||
|
||||
var actionBar = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
@@ -345,4 +370,29 @@ public partial class ChatWindow
|
||||
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container;
|
||||
AddTranscriptElement(container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 어시스턴트 메시지 텍스트에서 출력 파일 경로(절대 경로)를 추출합니다.
|
||||
/// "완료: C:\...\file.ext" 패턴을 우선 찾고, 없으면 일반 절대 경로를 검색합니다.
|
||||
/// </summary>
|
||||
private static string? ExtractOutputFilePathFromContent(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content)) return null;
|
||||
|
||||
// 패턴 1: "완료: C:\path\file.ext" 또는 "완료: E:\path\file.ext"
|
||||
var completionMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
content,
|
||||
@"완료:\s*([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
|
||||
if (completionMatch.Success)
|
||||
return completionMatch.Groups[1].Value.TrimEnd('.');
|
||||
|
||||
// 패턴 2: 임의의 절대 경로 (알려진 문서 확장자)
|
||||
var absMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
content,
|
||||
@"([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
|
||||
if (absMatch.Success)
|
||||
return absMatch.Groups[1].Value.TrimEnd('.');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,51 +11,579 @@ namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
/// <summary>채팅 본문용 런처 아이콘 애니메이션 상태 추적.</summary>
|
||||
private readonly List<ChatIconAnimState> _chatIconAnimStates = new();
|
||||
private System.Windows.Threading.DispatcherTimer? _chatIconAnimTimer;
|
||||
private static readonly Random s_chatIconRng = new();
|
||||
private static int s_currentSharedEffect = -1; // 모든 아이콘이 공유하는 현재 효과 번호
|
||||
|
||||
/// <summary>런처 아이콘과 동일한 2×2 컬러 픽셀 다이아몬드를 생성합니다.</summary>
|
||||
internal static FrameworkElement CreateMiniLauncherIcon(double pixelSize = 4.0)
|
||||
{
|
||||
var (host, _, _, _, _) = CreateMiniLauncherIconEx(pixelSize);
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>글로우 포함 런처 아이콘을 생성하고 애니메이션 가능 요소를 반환합니다.</summary>
|
||||
internal static (Grid Host, System.Windows.Shapes.Rectangle[] Pixels,
|
||||
System.Windows.Shapes.Rectangle[] Glows, RotateTransform Rotate, ScaleTransform Scale)
|
||||
CreateMiniLauncherIconEx(double pixelSize = 4.0, string glowIntensity = "none")
|
||||
{
|
||||
const double gap = 0.75;
|
||||
var total = pixelSize * 2 + gap;
|
||||
|
||||
var canvas = new System.Windows.Controls.Canvas
|
||||
var rotate = new RotateTransform(45);
|
||||
var scale = new ScaleTransform(1, 1);
|
||||
var tg = new TransformGroup();
|
||||
tg.Children.Add(rotate);
|
||||
tg.Children.Add(scale);
|
||||
|
||||
var canvas = new Canvas
|
||||
{
|
||||
Width = total,
|
||||
Height = total,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||||
RenderTransform = new RotateTransform(45),
|
||||
RenderTransform = tg,
|
||||
};
|
||||
|
||||
void AddPixel(double left, double top, string colorHex)
|
||||
var colors = new[] { "#4488FF", "#44DD66", "#44DD66", "#FF4466" };
|
||||
var positions = new (double L, double T)[] { (0, 0), (pixelSize + gap, 0), (0, pixelSize + gap), (pixelSize + gap, pixelSize + gap) };
|
||||
var pixels = new System.Windows.Shapes.Rectangle[4];
|
||||
var glows = new System.Windows.Shapes.Rectangle[4];
|
||||
|
||||
// 글로우 강도 설정
|
||||
double glowRadius = glowIntensity switch { "strong" => 6, "medium" => 3.5, "weak" => 1.8, _ => 0 };
|
||||
double glowOpacity = glowIntensity switch { "strong" => 0.7, "medium" => 0.45, "weak" => 0.25, _ => 0 };
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var color = (Color)ColorConverter.ConvertFromString(colors[i]);
|
||||
|
||||
// 글로우 (아래 레이어)
|
||||
if (glowRadius > 0)
|
||||
{
|
||||
var glow = new System.Windows.Shapes.Rectangle
|
||||
{
|
||||
Width = pixelSize + glowRadius,
|
||||
Height = pixelSize + glowRadius,
|
||||
RadiusX = (pixelSize + glowRadius) / 2,
|
||||
RadiusY = (pixelSize + glowRadius) / 2,
|
||||
Fill = new SolidColorBrush(color),
|
||||
Opacity = glowOpacity,
|
||||
Effect = new System.Windows.Media.Effects.BlurEffect { Radius = glowRadius },
|
||||
};
|
||||
Canvas.SetLeft(glow, positions[i].L - glowRadius / 2);
|
||||
Canvas.SetTop(glow, positions[i].T - glowRadius / 2);
|
||||
canvas.Children.Add(glow);
|
||||
glows[i] = glow;
|
||||
}
|
||||
|
||||
// 메인 픽셀
|
||||
var rect = new System.Windows.Shapes.Rectangle
|
||||
{
|
||||
Width = pixelSize,
|
||||
Height = pixelSize,
|
||||
RadiusX = 1.0,
|
||||
RadiusY = 1.0,
|
||||
Fill = new SolidColorBrush((Color)System.Windows.Media.ColorConverter.ConvertFromString(colorHex)),
|
||||
Fill = new SolidColorBrush(color),
|
||||
};
|
||||
System.Windows.Controls.Canvas.SetLeft(rect, left);
|
||||
System.Windows.Controls.Canvas.SetTop(rect, top);
|
||||
Canvas.SetLeft(rect, positions[i].L);
|
||||
Canvas.SetTop(rect, positions[i].T);
|
||||
canvas.Children.Add(rect);
|
||||
pixels[i] = rect;
|
||||
}
|
||||
|
||||
AddPixel(0, 0, "#4488FF");
|
||||
AddPixel(pixelSize + gap, 0, "#44DD66");
|
||||
AddPixel(0, pixelSize + gap, "#44DD66");
|
||||
AddPixel(pixelSize + gap, pixelSize + gap, "#FF4466");
|
||||
|
||||
// Wrap in a container so the rotated canvas doesn't disturb layout
|
||||
var host = new Grid
|
||||
{
|
||||
Width = total,
|
||||
Height = total,
|
||||
Width = total + (glowRadius > 0 ? glowRadius : 0),
|
||||
Height = total + (glowRadius > 0 ? glowRadius : 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
host.Children.Add(canvas);
|
||||
return host;
|
||||
return (host, pixels, glows, rotate, scale);
|
||||
}
|
||||
|
||||
/// <summary>기본 애니메이션 효과 번호 (랜덤 꺼져있을 때 사용). 0 = 순차 점멸.</summary>
|
||||
private const int DefaultAnimEffect = 0;
|
||||
|
||||
/// <summary>채팅 본문 런처 아이콘에 애니메이션을 적용합니다.</summary>
|
||||
private void StartChatIconAnimation(ChatIconAnimState state)
|
||||
{
|
||||
_chatIconAnimStates.Add(state);
|
||||
|
||||
// 현재 활성 효과를 결정 (모든 아이콘 동일)
|
||||
var effectId = state.IsRandomMode ? GetOrInitSharedEffect() : DefaultAnimEffect;
|
||||
|
||||
// 위상 동기화: 새 아이콘 추가 시 모든 아이콘의 애니메이션을 동시에 재시작
|
||||
foreach (var s in _chatIconAnimStates)
|
||||
ApplyChatIconEffect(s, effectId);
|
||||
|
||||
// 글로벌 타이머: 랜덤 모드일 때만 주기적으로 새 효과 전환
|
||||
if (_chatIconAnimTimer == null)
|
||||
{
|
||||
_chatIconAnimTimer = new System.Windows.Threading.DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
_chatIconAnimTimer.Tick += (_, _) =>
|
||||
{
|
||||
if (_chatIconAnimStates.Count == 0) return;
|
||||
|
||||
// 랜덤 모드 아이콘이 있을 때만 효과 전환
|
||||
var hasRandom = false;
|
||||
foreach (var s in _chatIconAnimStates)
|
||||
if (s.IsRandomMode) { hasRandom = true; break; }
|
||||
if (!hasRandom) return;
|
||||
|
||||
// 새 효과 선택 → 모든 아이콘에 동시 적용 (랜덤/고정 구분 없이)
|
||||
s_currentSharedEffect = s_chatIconRng.Next(20);
|
||||
foreach (var s in _chatIconAnimStates)
|
||||
ApplyChatIconEffect(s, s_currentSharedEffect);
|
||||
};
|
||||
_chatIconAnimTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetOrInitSharedEffect()
|
||||
{
|
||||
if (s_currentSharedEffect < 0)
|
||||
s_currentSharedEffect = s_chatIconRng.Next(20);
|
||||
return s_currentSharedEffect;
|
||||
}
|
||||
|
||||
/// <summary>아이콘에 지정된 효과를 적용합니다. 모든 아이콘이 동일한 effectId를 받습니다.</summary>
|
||||
private static void ApplyChatIconEffect(ChatIconAnimState state, int effectId)
|
||||
{
|
||||
// 기존 애니메이션 정리
|
||||
state.Storyboard?.Stop();
|
||||
state.Host.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
foreach (var p in state.Pixels) { p.BeginAnimation(UIElement.OpacityProperty, null); p.Opacity = 1; }
|
||||
state.Rotate.BeginAnimation(RotateTransform.AngleProperty, null);
|
||||
state.Scale.BeginAnimation(ScaleTransform.ScaleXProperty, null);
|
||||
state.Scale.BeginAnimation(ScaleTransform.ScaleYProperty, null);
|
||||
state.Rotate.Angle = 45;
|
||||
state.Scale.ScaleX = 1; state.Scale.ScaleY = 1;
|
||||
|
||||
var sb = new Storyboard { RepeatBehavior = RepeatBehavior.Forever };
|
||||
int effect = effectId;
|
||||
var pixels = state.Pixels;
|
||||
var glows = state.Glows;
|
||||
var canvas = state.Canvas;
|
||||
|
||||
switch (effect)
|
||||
{
|
||||
case 0: // 순차 점멸
|
||||
for (int i = 0; i < 4; i++)
|
||||
ChatIconAddPulse(sb, pixels[i], i, 4.0);
|
||||
break;
|
||||
|
||||
case 1: // 전체 호흡
|
||||
foreach (var p in pixels)
|
||||
{
|
||||
var a = new DoubleAnimation(1, 0.35, TimeSpan.FromSeconds(1.5)) { AutoReverse = true };
|
||||
Storyboard.SetTarget(a, p);
|
||||
Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case 2: // 대각선 교차
|
||||
ChatIconGroupFlash(sb, new[] { pixels[0], pixels[3] }, 0, 3.2);
|
||||
ChatIconGroupFlash(sb, new[] { pixels[1], pixels[2] }, 1.6, 3.2);
|
||||
break;
|
||||
|
||||
case 3: // 시계방향 순회 점등
|
||||
{
|
||||
var order = new[] { pixels[0], pixels[1], pixels[3], pixels[2] };
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var a = ChatIconKeyFrameAnim(new[] {
|
||||
(0.15, 0.0), (0.15, i*0.5), (1.0, i*0.5+0.25), (1.0, i*0.5+0.7), (0.15, i*0.5+0.95), (0.15, 2.5) });
|
||||
Storyboard.SetTarget(a, order[i]);
|
||||
Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 4: // 360도 회전
|
||||
{
|
||||
var rot = new DoubleAnimation(45, 405, TimeSpan.FromSeconds(4))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut } };
|
||||
Storyboard.SetTarget(rot, canvas);
|
||||
Storyboard.SetTargetProperty(rot, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
sb.Children.Add(rot);
|
||||
break;
|
||||
}
|
||||
|
||||
case 5: // 펄스 확대/축소
|
||||
{
|
||||
var sx = new DoubleAnimation(1, 1.25, TimeSpan.FromSeconds(0.6))
|
||||
{ AutoReverse = true, EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut } };
|
||||
var sy = new DoubleAnimation(1, 1.25, TimeSpan.FromSeconds(0.6))
|
||||
{ AutoReverse = true, EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut } };
|
||||
Storyboard.SetTarget(sx, canvas);
|
||||
Storyboard.SetTarget(sy, canvas);
|
||||
Storyboard.SetTargetProperty(sx, new PropertyPath("RenderTransform.Children[1].ScaleX"));
|
||||
Storyboard.SetTargetProperty(sy, new PropertyPath("RenderTransform.Children[1].ScaleY"));
|
||||
sb.Children.Add(sx);
|
||||
sb.Children.Add(sy);
|
||||
break;
|
||||
}
|
||||
|
||||
case 6: // 바운스 등장
|
||||
{
|
||||
var bx = new DoubleAnimationUsingKeyFrames();
|
||||
bx.KeyFrames.Add(new LinearDoubleKeyFrame(0.7, ChatKT(0)));
|
||||
bx.KeyFrames.Add(new EasingDoubleKeyFrame(1.15, ChatKT(0.35), new BounceEase { Bounces = 2, Bounciness = 3 }));
|
||||
bx.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, ChatKT(0.6)));
|
||||
bx.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, ChatKT(3.0)));
|
||||
var by = bx.Clone();
|
||||
Storyboard.SetTarget(bx, canvas);
|
||||
Storyboard.SetTarget(by, canvas);
|
||||
Storyboard.SetTargetProperty(bx, new PropertyPath("RenderTransform.Children[1].ScaleX"));
|
||||
Storyboard.SetTargetProperty(by, new PropertyPath("RenderTransform.Children[1].ScaleY"));
|
||||
sb.Children.Add(bx);
|
||||
sb.Children.Add(by);
|
||||
break;
|
||||
}
|
||||
|
||||
case 7: // 진동 흔들림
|
||||
{
|
||||
var shake = new DoubleAnimationUsingKeyFrames();
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(45, ChatKT(0)));
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(50, ChatKT(0.08)));
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(40, ChatKT(0.16)));
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(48, ChatKT(0.24)));
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(42, ChatKT(0.32)));
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(45, ChatKT(0.4)));
|
||||
shake.KeyFrames.Add(new LinearDoubleKeyFrame(45, ChatKT(3.5)));
|
||||
Storyboard.SetTarget(shake, canvas);
|
||||
Storyboard.SetTargetProperty(shake, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
sb.Children.Add(shake);
|
||||
break;
|
||||
}
|
||||
|
||||
case 8: // 파도 opacity
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var wave = new DoubleAnimationUsingKeyFrames();
|
||||
double d = i * 0.3;
|
||||
wave.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
wave.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(d)));
|
||||
wave.KeyFrames.Add(new EasingDoubleKeyFrame(0.1, ChatKT(d + 0.25),
|
||||
new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
wave.KeyFrames.Add(new EasingDoubleKeyFrame(1, ChatKT(d + 0.55),
|
||||
new QuadraticEase { EasingMode = EasingMode.EaseIn }));
|
||||
wave.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3)));
|
||||
Storyboard.SetTarget(wave, pixels[i]);
|
||||
Storyboard.SetTargetProperty(wave, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(wave);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 9: // 정적 (쉬는 턴)
|
||||
foreach (var p in pixels) p.Opacity = 1;
|
||||
return;
|
||||
|
||||
case 10: // 역방향 회전
|
||||
{
|
||||
var rot = new DoubleAnimation(45, -315, TimeSpan.FromSeconds(4.5))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut } };
|
||||
Storyboard.SetTarget(rot, canvas);
|
||||
Storyboard.SetTargetProperty(rot, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
sb.Children.Add(rot);
|
||||
break;
|
||||
}
|
||||
|
||||
case 11: // 하트비트
|
||||
{
|
||||
var hb = new DoubleAnimationUsingKeyFrames();
|
||||
hb.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.3, ChatKT(0.1), new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, ChatKT(0.22), new QuadraticEase { EasingMode = EasingMode.EaseIn }));
|
||||
hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.2, ChatKT(0.32), new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, ChatKT(0.44), new QuadraticEase { EasingMode = EasingMode.EaseIn }));
|
||||
hb.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, ChatKT(2.8)));
|
||||
var hby = hb.Clone();
|
||||
Storyboard.SetTarget(hb, canvas);
|
||||
Storyboard.SetTarget(hby, canvas);
|
||||
Storyboard.SetTargetProperty(hb, new PropertyPath("RenderTransform.Children[1].ScaleX"));
|
||||
Storyboard.SetTargetProperty(hby, new PropertyPath("RenderTransform.Children[1].ScaleY"));
|
||||
sb.Children.Add(hb);
|
||||
sb.Children.Add(hby);
|
||||
break;
|
||||
}
|
||||
|
||||
case 12: // 별빛 반짝임
|
||||
{
|
||||
var offsets = new[] { 0.0, 0.5, 1.1, 1.6 };
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
double d = offsets[i];
|
||||
var twinkle = new DoubleAnimationUsingKeyFrames();
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(d)));
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(0.05, ChatKT(d + 0.15)));
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(d + 0.35)));
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(0.5, ChatKT(d + 0.5)));
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(d + 0.65)));
|
||||
twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3.5)));
|
||||
Storyboard.SetTarget(twinkle, pixels[i]);
|
||||
Storyboard.SetTargetProperty(twinkle, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(twinkle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 13: // 나선 등장
|
||||
{
|
||||
var spiralRot = new DoubleAnimation(45 - 180, 45, TimeSpan.FromSeconds(0.9))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
|
||||
var spiralSx = new DoubleAnimation(0.4, 1, TimeSpan.FromSeconds(0.9))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
|
||||
var spiralSy = new DoubleAnimation(0.4, 1, TimeSpan.FromSeconds(0.9))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
|
||||
Storyboard.SetTarget(spiralRot, canvas);
|
||||
Storyboard.SetTarget(spiralSx, canvas);
|
||||
Storyboard.SetTarget(spiralSy, canvas);
|
||||
Storyboard.SetTargetProperty(spiralRot, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
Storyboard.SetTargetProperty(spiralSx, new PropertyPath("RenderTransform.Children[1].ScaleX"));
|
||||
Storyboard.SetTargetProperty(spiralSy, new PropertyPath("RenderTransform.Children[1].ScaleY"));
|
||||
sb.Children.Add(spiralRot);
|
||||
sb.Children.Add(spiralSx);
|
||||
sb.Children.Add(spiralSy);
|
||||
break;
|
||||
}
|
||||
|
||||
case 14: // 색상별 소멸·복원
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
double d = i * 0.6;
|
||||
var vanish = new DoubleAnimationUsingKeyFrames();
|
||||
vanish.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
vanish.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(d)));
|
||||
vanish.KeyFrames.Add(new EasingDoubleKeyFrame(0, ChatKT(d + 0.3),
|
||||
new QuadraticEase { EasingMode = EasingMode.EaseIn }));
|
||||
vanish.KeyFrames.Add(new EasingDoubleKeyFrame(1, ChatKT(d + 0.6),
|
||||
new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
vanish.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3.5)));
|
||||
Storyboard.SetTarget(vanish, pixels[i]);
|
||||
Storyboard.SetTargetProperty(vanish, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(vanish);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 15: // 스핀+확대 콤보
|
||||
{
|
||||
var rot = new DoubleAnimation(45, 405, TimeSpan.FromSeconds(2))
|
||||
{ EasingFunction = new BackEase { EasingMode = EasingMode.EaseInOut, Amplitude = 0.3 } };
|
||||
var sx = new DoubleAnimationUsingKeyFrames();
|
||||
sx.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
sx.KeyFrames.Add(new EasingDoubleKeyFrame(1.4, ChatKT(1), new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
sx.KeyFrames.Add(new EasingDoubleKeyFrame(1, ChatKT(2), new QuadraticEase { EasingMode = EasingMode.EaseIn }));
|
||||
sx.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3.5)));
|
||||
var sy = sx.Clone();
|
||||
Storyboard.SetTarget(rot, canvas);
|
||||
Storyboard.SetTarget(sx, canvas);
|
||||
Storyboard.SetTarget(sy, canvas);
|
||||
Storyboard.SetTargetProperty(rot, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
Storyboard.SetTargetProperty(sx, new PropertyPath("RenderTransform.Children[1].ScaleX"));
|
||||
Storyboard.SetTargetProperty(sy, new PropertyPath("RenderTransform.Children[1].ScaleY"));
|
||||
sb.Children.Add(rot);
|
||||
sb.Children.Add(sx);
|
||||
sb.Children.Add(sy);
|
||||
break;
|
||||
}
|
||||
|
||||
case 16: // 탄성 점프
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
double d = i * 0.15;
|
||||
var bounce = new DoubleAnimationUsingKeyFrames();
|
||||
bounce.KeyFrames.Add(new LinearDoubleKeyFrame(0, ChatKT(0)));
|
||||
bounce.KeyFrames.Add(new LinearDoubleKeyFrame(0, ChatKT(d)));
|
||||
bounce.KeyFrames.Add(new EasingDoubleKeyFrame(1, ChatKT(d + 0.5),
|
||||
new BounceEase { Bounces = 3, Bounciness = 2.5 }));
|
||||
bounce.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3)));
|
||||
Storyboard.SetTarget(bounce, pixels[i]);
|
||||
Storyboard.SetTargetProperty(bounce, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(bounce);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 17: // 진자 운동
|
||||
{
|
||||
var pendulum = new DoubleAnimationUsingKeyFrames();
|
||||
pendulum.KeyFrames.Add(new LinearDoubleKeyFrame(45, ChatKT(0)));
|
||||
pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(60, ChatKT(0.3), new SineEase { EasingMode = EasingMode.EaseOut }));
|
||||
pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(30, ChatKT(0.9), new SineEase { EasingMode = EasingMode.EaseInOut }));
|
||||
pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(55, ChatKT(1.5), new SineEase { EasingMode = EasingMode.EaseInOut }));
|
||||
pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(38, ChatKT(2.1), new SineEase { EasingMode = EasingMode.EaseInOut }));
|
||||
pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(45, ChatKT(2.5), new SineEase { EasingMode = EasingMode.EaseIn }));
|
||||
pendulum.KeyFrames.Add(new LinearDoubleKeyFrame(45, ChatKT(4)));
|
||||
Storyboard.SetTarget(pendulum, canvas);
|
||||
Storyboard.SetTargetProperty(pendulum, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
sb.Children.Add(pendulum);
|
||||
break;
|
||||
}
|
||||
|
||||
case 18: // 폭죽
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var rapid = new DoubleAnimationUsingKeyFrames();
|
||||
double offset = i * 0.08;
|
||||
rapid.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
for (int j = 0; j < 6; j++)
|
||||
{
|
||||
double t = offset + j * 0.12;
|
||||
rapid.KeyFrames.Add(new LinearDoubleKeyFrame(j % 2 == 0 ? 0.1 : 1, ChatKT(t)));
|
||||
}
|
||||
rapid.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(1.2)));
|
||||
rapid.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3.5)));
|
||||
Storyboard.SetTarget(rapid, pixels[i]);
|
||||
Storyboard.SetTargetProperty(rapid, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(rapid);
|
||||
}
|
||||
var pop = new DoubleAnimationUsingKeyFrames();
|
||||
pop.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
pop.KeyFrames.Add(new EasingDoubleKeyFrame(1.5, ChatKT(0.15), new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
pop.KeyFrames.Add(new EasingDoubleKeyFrame(0.8, ChatKT(0.4), new ElasticEase { Oscillations = 2, Springiness = 5 }));
|
||||
pop.KeyFrames.Add(new EasingDoubleKeyFrame(1, ChatKT(0.7), new QuadraticEase { EasingMode = EasingMode.EaseOut }));
|
||||
pop.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(3.5)));
|
||||
var popY = pop.Clone();
|
||||
Storyboard.SetTarget(pop, canvas);
|
||||
Storyboard.SetTarget(popY, canvas);
|
||||
Storyboard.SetTargetProperty(pop, new PropertyPath("RenderTransform.Children[1].ScaleX"));
|
||||
Storyboard.SetTargetProperty(popY, new PropertyPath("RenderTransform.Children[1].ScaleY"));
|
||||
sb.Children.Add(pop);
|
||||
sb.Children.Add(popY);
|
||||
break;
|
||||
}
|
||||
|
||||
case 19: // DNA 이중나선
|
||||
{
|
||||
ChatIconGroupFlash(sb, new[] { pixels[0], pixels[3] }, 0, 4);
|
||||
ChatIconGroupFlash(sb, new[] { pixels[1], pixels[2] }, 0.8, 4);
|
||||
var dnaRot = new DoubleAnimation(45, 225, TimeSpan.FromSeconds(4))
|
||||
{ EasingFunction = new SineEase { EasingMode = EasingMode.EaseInOut } };
|
||||
Storyboard.SetTarget(dnaRot, canvas);
|
||||
Storyboard.SetTargetProperty(dnaRot, new PropertyPath("RenderTransform.Children[0].Angle"));
|
||||
sb.Children.Add(dnaRot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Timeline.SetDesiredFrameRate(sb, 30);
|
||||
state.Storyboard = sb;
|
||||
sb.Begin(state.Host, true);
|
||||
}
|
||||
|
||||
/// <summary>Storyboard에서 pixel을 대상으로 하는 Opacity 애니메이션을 찾아 대응하는 glow에도 복제합니다.</summary>
|
||||
private static void SyncGlowAnimations(
|
||||
Storyboard sb,
|
||||
System.Windows.Shapes.Rectangle[] pixels,
|
||||
System.Windows.Shapes.Rectangle[] glows)
|
||||
{
|
||||
var opacityPath = new PropertyPath(UIElement.OpacityProperty);
|
||||
var toAdd = new List<Timeline>();
|
||||
foreach (var child in sb.Children)
|
||||
{
|
||||
var target = Storyboard.GetTarget(child);
|
||||
var prop = Storyboard.GetTargetProperty(child);
|
||||
if (target == null || prop == null) continue;
|
||||
if (prop.Path != "Opacity") continue;
|
||||
|
||||
for (int i = 0; i < pixels.Length; i++)
|
||||
{
|
||||
if (!ReferenceEquals(target, pixels[i])) continue;
|
||||
if (glows[i] == null) break;
|
||||
|
||||
// 이미 glow에 대한 애니메이션이 등록되었는지 확인
|
||||
bool alreadyAdded = false;
|
||||
foreach (var existing in sb.Children)
|
||||
{
|
||||
if (ReferenceEquals(Storyboard.GetTarget(existing), glows[i])
|
||||
&& Storyboard.GetTargetProperty(existing)?.Path == "Opacity")
|
||||
{
|
||||
alreadyAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alreadyAdded) break;
|
||||
|
||||
var clone = child.Clone();
|
||||
Storyboard.SetTarget(clone, glows[i]);
|
||||
Storyboard.SetTargetProperty(clone, opacityPath);
|
||||
toAdd.Add(clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (var t in toAdd)
|
||||
sb.Children.Add(t);
|
||||
}
|
||||
|
||||
// ─── 채팅 아이콘 애니메이션 헬퍼 ─────────────────────────────────────
|
||||
|
||||
private static KeyTime ChatKT(double sec) => KeyTime.FromTimeSpan(TimeSpan.FromSeconds(sec));
|
||||
|
||||
private static void ChatIconAddPulse(Storyboard sb, UIElement target, int index, double totalSec)
|
||||
{
|
||||
var a = new DoubleAnimationUsingKeyFrames();
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(index)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(0.25, ChatKT(index + 0.5)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(index + 1)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(totalSec)));
|
||||
Storyboard.SetTarget(a, target);
|
||||
Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(a);
|
||||
}
|
||||
|
||||
private static void ChatIconGroupFlash(Storyboard sb, UIElement[] group, double startSec, double totalSec)
|
||||
{
|
||||
foreach (var p in group)
|
||||
{
|
||||
var a = new DoubleAnimationUsingKeyFrames();
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(0)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(startSec)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(0.2, ChatKT(startSec + 0.6)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(startSec + 1.2)));
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(1, ChatKT(totalSec)));
|
||||
Storyboard.SetTarget(a, p);
|
||||
Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty));
|
||||
sb.Children.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
private static DoubleAnimationUsingKeyFrames ChatIconKeyFrameAnim((double val, double sec)[] frames)
|
||||
{
|
||||
var a = new DoubleAnimationUsingKeyFrames();
|
||||
foreach (var (val, sec) in frames)
|
||||
a.KeyFrames.Add(new LinearDoubleKeyFrame(val, ChatKT(sec)));
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>채팅 아이콘 애니메이션 상태.</summary>
|
||||
internal sealed class ChatIconAnimState
|
||||
{
|
||||
public required Grid Host { get; init; }
|
||||
public required Canvas Canvas { get; init; }
|
||||
public required System.Windows.Shapes.Rectangle[] Pixels { get; init; }
|
||||
public required System.Windows.Shapes.Rectangle[] Glows { get; init; }
|
||||
public required RotateTransform Rotate { get; init; }
|
||||
public required ScaleTransform Scale { get; init; }
|
||||
public bool IsRandomMode { get; set; }
|
||||
public int RepeatCount { get; set; }
|
||||
public Storyboard? Storyboard { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>좋아요/싫어요 피드백 버튼을 생성합니다.</summary>
|
||||
@@ -75,7 +603,7 @@ public partial class ChatWindow
|
||||
var icon = new TextBlock
|
||||
{
|
||||
Text = iconGlyph,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = normalColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
|
||||
3745
src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs
Normal file
3745
src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,7 @@ public partial class ChatWindow
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = item.Icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 15,
|
||||
Foreground = BrushFromHex(item.ColorHex),
|
||||
Margin = new Thickness(0, 0, 10, 0),
|
||||
@@ -167,7 +167,7 @@ public partial class ChatWindow
|
||||
var check = new TextBlock
|
||||
{
|
||||
Text = isActive ? "\uE73E" : "",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = BrushFromHex("#2563EB"),
|
||||
|
||||
@@ -14,8 +14,21 @@ public partial class ChatWindow
|
||||
{
|
||||
return async (planSummary, options) =>
|
||||
{
|
||||
// 도구 실행 승인(확인/건너뛰기/취소)은 간결한 별도 다이얼로그로 처리
|
||||
var isToolApproval = options.Contains("확인") && !options.Contains("승인");
|
||||
if (isToolApproval)
|
||||
{
|
||||
string? toolResult = null;
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
toolResult = ToolApprovalWindow.Show(this, planSummary, options);
|
||||
});
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
// 계획 승인은 PlanViewerV2로 처리
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
var steps = TaskDecomposer.ExtractSteps(planSummary);
|
||||
var steps = ExtractPlanSteps(planSummary);
|
||||
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
@@ -26,10 +39,10 @@ public partial class ChatWindow
|
||||
ShowPlanButton(true);
|
||||
AddDecisionButtons(tcs, options);
|
||||
// 플랜 창 자동 표시 (사용자가 직접 BtnPlanViewer 클릭 안 해도 됨)
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
if (_planViewerWindow != null && IsPlanWindowAlive())
|
||||
{
|
||||
_planViewerWindow.Show();
|
||||
_planViewerWindow.Activate();
|
||||
PlanWindow?.Show();
|
||||
PlanWindow?.Activate();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,7 +51,7 @@ public partial class ChatWindow
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_planViewerWindow?.Hide();
|
||||
PlanWindow?.Hide();
|
||||
ResetPendingPlanPresentation();
|
||||
});
|
||||
return "취소";
|
||||
@@ -59,21 +72,12 @@ public partial class ChatWindow
|
||||
agentDecision = $"수정 요청: {result.Trim()}";
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
// 승인/취소/수정 모두 계획 창 닫고 상태 초기화
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_planViewerWindow?.SwitchToExecutionMode();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
_planViewerWindow?.Hide();
|
||||
ResetPendingPlanPresentation();
|
||||
});
|
||||
}
|
||||
PlanWindow?.Hide();
|
||||
ResetPendingPlanPresentation();
|
||||
});
|
||||
|
||||
return agentDecision;
|
||||
};
|
||||
@@ -81,17 +85,27 @@ public partial class ChatWindow
|
||||
|
||||
private void EnsurePlanViewerWindow()
|
||||
{
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
if (_planViewerWindow != null && IsPlanWindowAlive())
|
||||
return;
|
||||
|
||||
_planViewerWindow = new PlanViewerWindow(this);
|
||||
_planViewerWindow.Closing += (_, e) =>
|
||||
if (_settings.Settings.Llm.EnableNewPlanViewer)
|
||||
{
|
||||
e.Cancel = true;
|
||||
_planViewerWindow.Hide();
|
||||
};
|
||||
var v2 = new PlanViewerWindowV2(this);
|
||||
v2.Closing += (_, e) => { e.Cancel = true; v2.Hide(); };
|
||||
_planViewerWindow = v2;
|
||||
}
|
||||
else
|
||||
{
|
||||
var v1 = new PlanViewerWindow(this);
|
||||
v1.Closing += (_, e) => { e.Cancel = true; v1.Hide(); };
|
||||
_planViewerWindow = v1;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPlanWindowAlive() => IsWindowAlive(_planViewerWindow as Window);
|
||||
|
||||
private Window? PlanWindow => _planViewerWindow as Window;
|
||||
|
||||
private void ShowPlanButton(bool show)
|
||||
{
|
||||
// 레거시: 이전 세션에서 동적 주입된 MoodIconPanel 칩 정리
|
||||
@@ -127,7 +141,7 @@ public partial class ChatWindow
|
||||
return;
|
||||
|
||||
EnsurePlanViewerWindow();
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
if (_planViewerWindow != null && IsPlanWindowAlive())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText)
|
||||
|| _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty)
|
||||
@@ -135,8 +149,8 @@ public partial class ChatWindow
|
||||
{
|
||||
_planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps);
|
||||
}
|
||||
_planViewerWindow.Show();
|
||||
_planViewerWindow.Activate();
|
||||
PlanWindow?.Show();
|
||||
PlanWindow?.Activate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +167,7 @@ public partial class ChatWindow
|
||||
|
||||
private void UpdatePlanViewerStep(AgentEvent evt)
|
||||
{
|
||||
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow))
|
||||
if (_planViewerWindow == null || !IsPlanWindowAlive())
|
||||
return;
|
||||
|
||||
if (evt.StepCurrent > 0)
|
||||
@@ -162,7 +176,7 @@ public partial class ChatWindow
|
||||
|
||||
private void CompletePlanViewer()
|
||||
{
|
||||
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||
if (_planViewerWindow != null && IsPlanWindowAlive())
|
||||
_planViewerWindow.MarkComplete();
|
||||
ResetPendingPlanPresentation();
|
||||
}
|
||||
@@ -174,6 +188,49 @@ public partial class ChatWindow
|
||||
ShowPlanButton(false);
|
||||
}
|
||||
|
||||
/// <summary>document_plan 결과 텍스트에서 계획 단계(섹션 목록)를 추출합니다.</summary>
|
||||
private static List<string> ExtractPlanSteps(string planText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(planText))
|
||||
return new List<string> { "문서 계획 검토" };
|
||||
|
||||
// 1) 번호 매긴 단계 (기존 TaskDecomposer)
|
||||
var numbered = TaskDecomposer.ExtractSteps(planText);
|
||||
if (numbered.Count >= 2) return numbered;
|
||||
|
||||
var sections = new List<string>();
|
||||
|
||||
// 2) JSON "heading" 필드 추출 (ExecuteSinglePassWithData 경로)
|
||||
var headingMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
planText, @"""heading""\s*:\s*""([^""]+)""");
|
||||
foreach (System.Text.RegularExpressions.Match m in headingMatches)
|
||||
sections.Add(m.Groups[1].Value);
|
||||
if (sections.Count >= 2) return sections;
|
||||
|
||||
// 3) HTML <h2> 태그 (ExecuteWithHtmlScaffold 경로)
|
||||
sections.Clear();
|
||||
var h2Matches = System.Text.RegularExpressions.Regex.Matches(
|
||||
planText, @"<h2>([^<]+)</h2>");
|
||||
foreach (System.Text.RegularExpressions.Match m in h2Matches)
|
||||
sections.Add(m.Groups[1].Value);
|
||||
if (sections.Count >= 2) return sections;
|
||||
|
||||
// 4) Markdown ## 헤딩
|
||||
sections.Clear();
|
||||
var mdMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
planText, @"(?:^|\n)##\s+(.+?)(?:\n|$)");
|
||||
foreach (System.Text.RegularExpressions.Match m in mdMatches)
|
||||
{
|
||||
var heading = m.Groups[1].Value.Trim();
|
||||
if (!heading.StartsWith("[") && !heading.Contains("즉시 실행"))
|
||||
sections.Add(heading);
|
||||
}
|
||||
if (sections.Count >= 2) return sections;
|
||||
|
||||
// 5) 폴백
|
||||
return new List<string> { "문서 계획 검토" };
|
||||
}
|
||||
|
||||
private static bool IsWindowAlive(Window? w)
|
||||
{
|
||||
if (w == null)
|
||||
|
||||
@@ -238,7 +238,7 @@ public partial class ChatWindow
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "\uE711",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 10,
|
||||
Foreground = closeFg,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -323,6 +323,25 @@ public partial class ChatWindow
|
||||
}
|
||||
|
||||
private async void LoadPreviewContent(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadPreviewContentCoreAsync(filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Warn($"미리보기 로드 실패: {ex.Message}");
|
||||
try
|
||||
{
|
||||
SetPreviewHeaderState("미리보기 오류");
|
||||
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
|
||||
PreviewTextScroll.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPreviewContentCoreAsync(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
SetPreviewHeader(filePath);
|
||||
|
||||
@@ -134,7 +134,7 @@ public partial class ChatWindow
|
||||
var iconBlock = new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 14,
|
||||
Foreground = selected ? accentBrush : secondaryText,
|
||||
Margin = new Thickness(0, 1, 10, 0),
|
||||
|
||||
667
src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs
Normal file
667
src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs
Normal file
@@ -0,0 +1,667 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
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 = s_segoeIconFont,
|
||||
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 = s_segoeIconFont,
|
||||
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 = s_segoeIconFont,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,37 @@ public partial class ChatWindow
|
||||
StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}";
|
||||
StatusTokens.Visibility = Visibility.Visible;
|
||||
RefreshContextUsageVisual();
|
||||
|
||||
// 입력창 위 스트리밍 메트릭 레이블 갱신
|
||||
UpdateStreamMetricsLabel(inputTokens, outputTokens);
|
||||
}
|
||||
|
||||
/// <summary>입력창 바로 위 오른쪽에 경과 시간 + 토큰 표시 (스트리밍 중에만 보임).</summary>
|
||||
private void UpdateStreamMetricsLabel(int inputTokens = 0, int outputTokens = 0)
|
||||
{
|
||||
if (StreamMetricsLabel == null) return;
|
||||
|
||||
if (!_isStreaming)
|
||||
{
|
||||
StreamMetricsLabel.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
StreamMetricsLabel.Visibility = Visibility.Visible;
|
||||
|
||||
var elapsedText = "0s";
|
||||
if (TryGetStreamingElapsed(out var elapsed))
|
||||
{
|
||||
if (elapsed.TotalMinutes >= 1)
|
||||
elapsedText = $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds:0}s";
|
||||
else
|
||||
elapsedText = $"{elapsed.Seconds}s";
|
||||
}
|
||||
|
||||
var inText = Services.TokenEstimator.Format(inputTokens);
|
||||
var outText = Services.TokenEstimator.Format(outputTokens);
|
||||
|
||||
StreamMetricsLabel.Text = $"{elapsedText} · ↑{inText} ↓{outText}";
|
||||
}
|
||||
|
||||
private void UpdateStatusBar(AgentEvent evt)
|
||||
|
||||
@@ -51,7 +51,7 @@ public partial class ChatWindow
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 13,
|
||||
Foreground = iconBrush,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
@@ -92,7 +92,7 @@ public partial class ChatWindow
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
|
||||
@@ -61,17 +61,24 @@ public partial class ChatWindow
|
||||
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("\nOnly present a step-by-step plan when the user explicitly asks for a plan or a short execution outline is required to unblock the task safely.");
|
||||
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the requested result.");
|
||||
sb.AppendLine("\nWhen the user explicitly asks for a plan (계획, 계획 수립, 계획 먼저, plan), present a step-by-step plan FIRST and wait for approval before executing.");
|
||||
sb.AppendLine("For ordinary Cowork requests where no plan is requested, proceed directly with the work and focus on producing the requested result.");
|
||||
sb.AppendLine("If the user asks for a brand-new report, proposal, analysis, manual, or other document and does not explicitly ask to reference workspace files, do NOT start with glob, grep, document_read, or folder_map.");
|
||||
sb.AppendLine("In that case, go straight to the creation tool. Use document_plan only when it materially improves a multi-section document.");
|
||||
sb.AppendLine("In that case, go straight to the creation tool. Use document_plan when creating multi-section documents (3+ pages) or when the user explicitly requests a plan.");
|
||||
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
|
||||
sb.AppendLine("IMPORTANT: In your FINAL response after ALL work is done, provide a structured completion summary in this format:");
|
||||
sb.AppendLine(" - 작업 유형: (e.g., report/analysis/proposal)");
|
||||
sb.AppendLine(" - 산출물 파일: full absolute path (e.g., E:\\test\\report.html)");
|
||||
sb.AppendLine(" - 주요 섹션: list key sections/chapters with brief description");
|
||||
sb.AppendLine(" - 분량: page count, word count estimate");
|
||||
sb.AppendLine(" - 사용 도구: tools used during creation");
|
||||
sb.AppendLine("The user wants to see what the document contains at a glance without opening the file.");
|
||||
sb.AppendLine("Do not stop after a single step. Continue autonomously until the request is completed or a concrete blocker (permission denial, missing dependency, hard error) is encountered.");
|
||||
sb.AppendLine("When adapting external references, rewrite names/structure/comments to AX Copilot style. Avoid clone-like outputs.");
|
||||
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates.");
|
||||
sb.AppendLine("IMPORTANT: For reports, proposals, analyses, and manuals with multiple sections:");
|
||||
sb.AppendLine(" 1. Decide the document structure internally first.");
|
||||
sb.AppendLine(" 2. Use document_plan only when it improves the actual output, not as a mandatory user-facing approval step.");
|
||||
sb.AppendLine(" 2. Use document_plan for multi-section documents or when the user explicitly asks for a plan. When the user requests a plan, present it for approval before proceeding.");
|
||||
sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, or file_write.");
|
||||
sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only.");
|
||||
sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated.");
|
||||
@@ -93,9 +100,13 @@ public partial class ChatWindow
|
||||
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");
|
||||
|
||||
// 사용자 지정 출력 포맷
|
||||
// 사용자 지정 출력 포맷 — "auto"일 때 HTML 우선
|
||||
var fmt = llm.DefaultOutputFormat;
|
||||
if (!string.IsNullOrEmpty(fmt) && fmt != "auto")
|
||||
if (string.IsNullOrEmpty(fmt) || string.Equals(fmt, "auto", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine("DEFAULT OUTPUT FORMAT: HTML (.html) using html_create. Unless the user explicitly requests a different format (Word, Excel, Markdown, etc.), always create documents as HTML.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var fmtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
||||
@@ -285,7 +285,7 @@ public partial class ChatWindow
|
||||
headerRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = kindIcon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 9.5,
|
||||
Foreground = kindColor,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
|
||||
@@ -141,8 +141,13 @@ public partial class ChatWindow
|
||||
TopicButtonPanel.Visibility = Visibility.Collapsed;
|
||||
if (TopicPresetScrollViewer != null)
|
||||
TopicPresetScrollViewer.Visibility = Visibility.Collapsed;
|
||||
StartMascotAnimation();
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
StopMascotAnimation();
|
||||
}
|
||||
|
||||
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
|
||||
var cardBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
|
||||
@@ -188,7 +193,7 @@ public partial class ChatWindow
|
||||
var iconBlock = new TextBlock
|
||||
{
|
||||
Text = preset.Symbol,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 15,
|
||||
Foreground = buttonColor,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
@@ -226,7 +231,7 @@ public partial class ChatWindow
|
||||
badge.Child = new TextBlock
|
||||
{
|
||||
Text = "\uE710",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 8,
|
||||
Foreground = buttonColor,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
@@ -284,7 +289,7 @@ public partial class ChatWindow
|
||||
etcIconCircle.Child = new TextBlock
|
||||
{
|
||||
Text = "\uE70F",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 15,
|
||||
Foreground = etcColor,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
@@ -335,7 +340,7 @@ public partial class ChatWindow
|
||||
addStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE710",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 18,
|
||||
Foreground = secondaryText,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
@@ -510,7 +515,7 @@ public partial class ChatWindow
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 13,
|
||||
Foreground = foreground,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
|
||||
@@ -123,8 +123,93 @@ public partial class ChatWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// React Virtual DOM reconciliation 방식의 diff 렌더.
|
||||
/// Incremental(prefix-match)이 실패해도, 키 기반 diff로 삭제/추가만 처리하여
|
||||
/// 전체 재빌드(Full Render)를 회피합니다.
|
||||
/// StreamingAppend → Incremental → DiffRender → FullRender 순으로 호출됩니다.
|
||||
/// </summary>
|
||||
private bool TryApplyDiffRender(TranscriptRenderPlan renderPlan)
|
||||
{
|
||||
// 이전 렌더 기록이 없으면 diff 불가
|
||||
if (_lastRenderedTimelineKeys.Count == 0 || renderPlan.NewKeys.Count == 0)
|
||||
return false;
|
||||
|
||||
// hiddenCount가 다르면 visible 범위 자체가 달라진 것 — diff 신뢰 불가
|
||||
if (renderPlan.HiddenCount != _lastRenderedHiddenCount)
|
||||
return false;
|
||||
|
||||
var oldKeys = _lastRenderedTimelineKeys;
|
||||
var newKeys = renderPlan.NewKeys;
|
||||
|
||||
// 변화가 없으면 빠른 경로
|
||||
if (oldKeys.Count == newKeys.Count && oldKeys.SequenceEqual(newKeys, StringComparer.Ordinal))
|
||||
{
|
||||
_lastRenderedTimelineKeys = renderPlan.NewKeys;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 기존 키 → 인덱스 매핑
|
||||
var oldKeyIndex = new Dictionary<string, int>(oldKeys.Count, StringComparer.Ordinal);
|
||||
for (var i = 0; i < oldKeys.Count; i++)
|
||||
oldKeyIndex[oldKeys[i]] = i;
|
||||
|
||||
var newKeySet = new HashSet<string>(newKeys, StringComparer.Ordinal);
|
||||
|
||||
// 2. 라이브 컨테이너 임시 분리
|
||||
var hadLiveContainer = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
|
||||
if (hadLiveContainer)
|
||||
RemoveTranscriptElement(_agentLiveContainer!);
|
||||
|
||||
// "더보기" 카드가 있으면 오프셋 1
|
||||
var loadMoreOffset = renderPlan.HiddenCount > 0 ? 1 : 0;
|
||||
|
||||
// 3. 삭제할 항목 제거 (뒤에서부터 — 인덱스 안정성 유지)
|
||||
for (var i = oldKeys.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!newKeySet.Contains(oldKeys[i]))
|
||||
{
|
||||
var elementIndex = i + loadMoreOffset;
|
||||
if (elementIndex < GetTranscriptElementCount())
|
||||
RemoveTranscriptElementAt(elementIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 새 항목만 생성·삽입 — 이미 존재하는 키는 건너뜀
|
||||
foreach (var item in renderPlan.VisibleTimeline)
|
||||
{
|
||||
if (!oldKeyIndex.ContainsKey(item.Key))
|
||||
item.Render();
|
||||
}
|
||||
|
||||
// 5. 라이브 컨테이너 재삽입
|
||||
if (hadLiveContainer && _agentLiveContainer != null)
|
||||
AddTranscriptElement(_agentLiveContainer);
|
||||
|
||||
_lastRenderedTimelineKeys = renderPlan.NewKeys;
|
||||
_lastRenderedHiddenCount = renderPlan.HiddenCount;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Warn($"Diff 렌더 실패, 전체 렌더로 전환: {ex.Message}");
|
||||
_lastRenderedTimelineKeys.Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFullTranscriptRender(TranscriptRenderPlan renderPlan)
|
||||
{
|
||||
// 스트리밍 중에는 ItemsSource 분리/재연결을 하지 않음
|
||||
// — 전체 시각적 트리 파괴 + VirtualizingStackPanel 컨테이너 재생성이 UI 렉의 핵심 원인
|
||||
// 비스트리밍 시에만 분리/재연결 (대량 초기 로드 시 레이아웃 패스 1회 축소 효과)
|
||||
var disconnectItemsSource = !_isStreaming;
|
||||
|
||||
if (disconnectItemsSource)
|
||||
MessageList.ItemsSource = null;
|
||||
|
||||
ClearTranscriptElements();
|
||||
_runBannerAnchors.Clear();
|
||||
|
||||
@@ -137,6 +222,14 @@ public partial class ChatWindow
|
||||
if (_agentLiveContainer != null && !ContainsTranscriptElement(_agentLiveContainer))
|
||||
AddTranscriptElement(_agentLiveContainer);
|
||||
|
||||
if (disconnectItemsSource)
|
||||
{
|
||||
// ItemsSource 재연결 — 단일 레이아웃 패스
|
||||
MessageList.ItemsSource = _transcriptElements;
|
||||
// ItemsSource 변경 시 ScrollViewer가 재생성될 수 있으므로 훅 재연결
|
||||
AttachTranscriptScrollChanged();
|
||||
}
|
||||
|
||||
_lastRenderedTimelineKeys = renderPlan.NewKeys;
|
||||
_lastRenderedHiddenCount = renderPlan.HiddenCount;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── 렌더링 쓰로틀: 스트리밍 중 최소 간격 보장 ───────────────────────
|
||||
private long _lastRenderTicks;
|
||||
private const long MinStreamingRenderIntervalMs = 1500; // 스트리밍 중 최소 1.5초 간격
|
||||
private const long MinIdleRenderIntervalMs = 300; // 비스트리밍(유휴) 시 최소 300ms 간격
|
||||
|
||||
private int GetActiveTimelineRenderLimit()
|
||||
{
|
||||
if (!_isStreaming)
|
||||
@@ -19,12 +24,49 @@ public partial class ChatWindow
|
||||
return Math.Min(_timelineRenderLimit, streamingLimit);
|
||||
}
|
||||
|
||||
private void RenderMessages(bool preserveViewport = false)
|
||||
private void RenderMessages(bool preserveViewport = false, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
|
||||
{
|
||||
// B-4: 비가시 상태일 때 렌더링 차단 — 최소화/숨김 시 불필요한 UI 재구축 방지
|
||||
if (this.WindowState == System.Windows.WindowState.Minimized || !IsVisible)
|
||||
return;
|
||||
|
||||
var now = Environment.TickCount64;
|
||||
|
||||
// B-5: 스트리밍 중 쓰로틀 — preserveViewport=true (타이머 기반) 호출만 제한
|
||||
// preserveViewport=false는 사용자 메시지 전송 등 중요 렌더이므로 항상 허용
|
||||
if (_isStreaming && preserveViewport)
|
||||
{
|
||||
if (now - _lastRenderTicks < MinStreamingRenderIntervalMs)
|
||||
return;
|
||||
}
|
||||
|
||||
// B-7: 유휴 상태 렌더 쓰로틀 — 빈 대화에서 반복 호출 방지 (UI 프리징 원인)
|
||||
// 대화 내용이 바뀌지 않았는데 짧은 간격으로 반복 호출되면 무시
|
||||
if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs)
|
||||
{
|
||||
ChatConversation? quickConv;
|
||||
lock (_convLock) quickConv = _currentConversation;
|
||||
var quickMsgCount = quickConv?.Messages?.Count ?? 0;
|
||||
var quickEvtCount = quickConv?.ExecutionEvents?.Count ?? 0;
|
||||
// 대화 내용이 마지막 렌더와 같으면 스킵 (빈 대화 반복 렌더 차단)
|
||||
if (quickMsgCount == _lastRenderedMessageCount
|
||||
&& quickEvtCount == _lastRenderedEventCount
|
||||
&& string.Equals(_lastRenderedConversationId, quickConv?.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// B-7b: 빈 대화 간 convId 플래핑 방지 — 둘 다 메시지 0개면
|
||||
// convId가 달라도 빈 화면 렌더를 반복할 이유 없음 (SwitchToTabConversation 스팸 차단)
|
||||
// 단, preserveViewport=false(탭 전환 등 명시적 렌더)는 차단하지 않음
|
||||
// — 탭 전환 시 EmptyState/마스코트 표시에 필요
|
||||
if (preserveViewport
|
||||
&& quickMsgCount == 0 && quickEvtCount == 0
|
||||
&& _lastRenderedMessageCount == 0 && _lastRenderedEventCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var renderStopwatch = Stopwatch.StartNew();
|
||||
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
||||
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
||||
@@ -36,6 +78,13 @@ public partial class ChatWindow
|
||||
var visibleMessages = GetVisibleTimelineMessages(conv);
|
||||
var visibleEvents = GetVisibleTimelineEvents(conv);
|
||||
|
||||
// 진단 로그: 렌더링 호출 시점의 상태 추적
|
||||
Services.LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " +
|
||||
$"convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}, " +
|
||||
$"rawMsgCount={conv?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " +
|
||||
$"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " +
|
||||
$"transcriptElements={GetTranscriptElementCount()}");
|
||||
|
||||
if (_isStreaming && preserveViewport
|
||||
&& visibleMessages.Count == _lastRenderedMessageCount
|
||||
&& visibleEvents.Count == _lastRenderedEventCount
|
||||
@@ -45,12 +94,25 @@ public partial class ChatWindow
|
||||
|
||||
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
||||
{
|
||||
ClearTranscriptElements();
|
||||
_runBannerAnchors.Clear();
|
||||
_lastRenderedTimelineKeys.Clear();
|
||||
_lastRenderedMessageCount = 0;
|
||||
_lastRenderedEventCount = 0;
|
||||
EmptyState.Visibility = System.Windows.Visibility.Visible;
|
||||
// 스트리밍 중이거나 대화에 원본 메시지가 있으면 EmptyState를 표시하지 않음
|
||||
// (GetVisibleTimelineMessages의 필터링으로 visibleMessages가 0이 되어도 원본은 존재)
|
||||
bool hasRawMessages = (conv?.Messages?.Count ?? 0) > 0;
|
||||
if (!_isStreaming && !hasRawMessages)
|
||||
{
|
||||
ClearTranscriptElements();
|
||||
_runBannerAnchors.Clear();
|
||||
_lastRenderedTimelineKeys.Clear();
|
||||
_lastRenderedMessageCount = 0;
|
||||
_lastRenderedEventCount = 0;
|
||||
EmptyState.Visibility = System.Windows.Visibility.Visible;
|
||||
StartMascotAnimation();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 메시지가 있거나 스트리밍 중 → EmptyState 강제 숨김
|
||||
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
||||
StopMascotAnimation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,19 +128,43 @@ public partial class ChatWindow
|
||||
}
|
||||
|
||||
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
||||
var renderPlan = BuildTranscriptRenderPlan(conv, visibleMessages, visibleEvents);
|
||||
StopMascotAnimation();
|
||||
|
||||
// B-3: 스트리밍 전용 빠른 경로 → 일반 인크리멘탈 → 전체 재빌드
|
||||
if (!TryApplyStreamingAppendRender(renderPlan)
|
||||
&& !TryApplyIncrementalTranscriptRender(renderPlan))
|
||||
ApplyFullTranscriptRender(renderPlan);
|
||||
PruneTranscriptElementCache(renderPlan.NewKeys);
|
||||
// V2 렌더링 분기 — 설정 토글로 Claude Code 스타일 상세 이력 UI 활성화
|
||||
if (_settings.Settings.Llm.EnableNewChatRendering)
|
||||
{
|
||||
RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport,
|
||||
previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller);
|
||||
return;
|
||||
}
|
||||
|
||||
_lastRenderedMessageCount = visibleMessages.Count;
|
||||
_lastRenderedEventCount = visibleEvents.Count;
|
||||
_lastRenderedShowHistory = renderPlan.ShowHistory;
|
||||
try
|
||||
{
|
||||
var renderPlan = BuildTranscriptRenderPlan(conv, visibleMessages, visibleEvents);
|
||||
|
||||
Services.LogService.Info($"[Render] plan: items={renderPlan.VisibleTimeline.Count}, hidden={renderPlan.HiddenCount}, " +
|
||||
$"canIncremental={renderPlan.CanIncremental}, keys={renderPlan.NewKeys.Count}");
|
||||
|
||||
// B-3: 스트리밍 전용 빠른 경로 → 일반 인크리멘탈 → Diff(Virtual DOM) → 전체 재빌드
|
||||
if (!TryApplyStreamingAppendRender(renderPlan)
|
||||
&& !TryApplyIncrementalTranscriptRender(renderPlan)
|
||||
&& !TryApplyDiffRender(renderPlan))
|
||||
ApplyFullTranscriptRender(renderPlan);
|
||||
PruneTranscriptElementCache(renderPlan.NewKeys);
|
||||
|
||||
_lastRenderedMessageCount = visibleMessages.Count;
|
||||
_lastRenderedEventCount = visibleEvents.Count;
|
||||
_lastRenderedShowHistory = renderPlan.ShowHistory;
|
||||
}
|
||||
catch (Exception renderEx)
|
||||
{
|
||||
Services.LogService.Error($"[Render] 렌더링 파이프라인 예외: {renderEx.GetType().Name}: {renderEx.Message}\n{renderEx.StackTrace}");
|
||||
}
|
||||
|
||||
_lastRenderTicks = Environment.TickCount64; // 쓰로틀 타임스탬프 갱신
|
||||
renderStopwatch.Stop();
|
||||
if (renderStopwatch.ElapsedMilliseconds >= 24 || _isStreaming)
|
||||
// B-6: 스트리밍 중 로깅 빈도 축소 — 100ms 미만 렌더는 기록하지 않음 (UI 부하 감소)
|
||||
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
|
||||
{
|
||||
AgentPerformanceLogService.LogMetric(
|
||||
"transcript",
|
||||
@@ -93,14 +179,7 @@ public partial class ChatWindow
|
||||
lightweight = IsLightweightLiveProgressMode(),
|
||||
visibleMessages = visibleMessages.Count,
|
||||
visibleEvents = visibleEvents.Count,
|
||||
renderedItems = renderPlan.NewKeys.Count,
|
||||
hiddenCount = renderPlan.HiddenCount,
|
||||
transcriptElements = GetTranscriptElementCount(),
|
||||
processFeedAppends = _processFeedAppendCount,
|
||||
processFeedMerges = _processFeedMergeCount,
|
||||
rowKindCounts = _transcriptRowKindCounts.ToDictionary(
|
||||
pair => pair.Key.ToString(),
|
||||
pair => pair.Value),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,5 +25,30 @@ public partial class ChatWindow
|
||||
|
||||
foreach (var key in removable)
|
||||
_elementCache.Remove(key);
|
||||
|
||||
// _transcriptElementMap도 동기 정리 — 더 이상 transcript에 없는 element 참조 제거
|
||||
PruneTranscriptElementMap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// _transcriptElementMap에서 현재 _transcriptElements에 없는 항목을 제거합니다.
|
||||
/// ObservableCollection 변경 시 자동으로 정리되지 않는 stale 참조를 회수합니다.
|
||||
/// </summary>
|
||||
private void PruneTranscriptElementMap()
|
||||
{
|
||||
if (_transcriptElementMap.Count <= _transcriptElements.Count * 2)
|
||||
return; // 맵이 실제 요소의 2배 미만이면 정리 불필요
|
||||
|
||||
var activeElements = new HashSet<System.Windows.UIElement>(
|
||||
_transcriptElements
|
||||
.Where(item => item.IsMaterialized && item.Element != null)
|
||||
.Select(item => item.Element!));
|
||||
|
||||
var staleKeys = _transcriptElementMap.Keys
|
||||
.Where(key => !activeElements.Contains(key))
|
||||
.ToList();
|
||||
|
||||
foreach (var key in staleKeys)
|
||||
_transcriptElementMap.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
1038
src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs
Normal file
1038
src/AxCopilot/Views/ChatWindow.UtilityPresentation.cs
Normal file
File diff suppressed because it is too large
Load Diff
601
src/AxCopilot/Views/ChatWindow.V2AgentEventPresentation.cs
Normal file
601
src/AxCopilot/Views/ChatWindow.V2AgentEventPresentation.cs
Normal file
@@ -0,0 +1,601 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
/// <summary>V2: 단일 에이전트 이벤트를 렌더링 (ToolCall/ToolResult 병합되지 않은 경우)</summary>
|
||||
private UIElement CreateV2AgentEventElement(AgentEvent agentEvent)
|
||||
{
|
||||
return agentEvent.Type switch
|
||||
{
|
||||
AgentEventType.Thinking => CreateV2ThinkingBlock(agentEvent),
|
||||
AgentEventType.ToolCall => CreateV2ToolCallOnlyCard(agentEvent),
|
||||
AgentEventType.ToolResult => CreateV2ToolResultOnlyCard(agentEvent),
|
||||
AgentEventType.Complete => CreateV2CompleteBanner(agentEvent),
|
||||
AgentEventType.Error => CreateV2ErrorBanner(agentEvent),
|
||||
_ => CreateV2GenericEventPill(agentEvent),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>V2: ToolCall + ToolResult 쌍을 병합한 실행 카드</summary>
|
||||
private UIElement CreateV2ToolExecutionCard(AgentEvent toolCall, AgentEvent toolResult)
|
||||
{
|
||||
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 hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var (icon, iconColor) = GetV2ToolIcon(toolCall.ToolName);
|
||||
var elapsed = NormalizeProgressElapsedMs(toolResult.ElapsedMs);
|
||||
var elapsedText = elapsed > 0 ? $"{elapsed / 1000.0:F1}s" : "";
|
||||
var isSuccess = toolResult.Success;
|
||||
|
||||
// 외부 컨테이너 — 왼쪽 세로선 + 카드
|
||||
var outerGrid = new Grid
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Width = msgMaxWidth,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
};
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// 왼쪽 세로선
|
||||
var lineColor = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x60, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50));
|
||||
var verticalLine = new Border
|
||||
{
|
||||
Width = 2,
|
||||
Background = lineColor,
|
||||
CornerRadius = new CornerRadius(1),
|
||||
Margin = new Thickness(12, 0, 8, 0),
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
};
|
||||
Grid.SetColumn(verticalLine, 0);
|
||||
outerGrid.Children.Add(verticalLine);
|
||||
|
||||
// 메인 카드
|
||||
var card = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(12, 8, 12, 8),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
Grid.SetColumn(card, 1);
|
||||
|
||||
var cardStack = new StackPanel();
|
||||
|
||||
// 헤더: 아이콘 + 도구명 + 파일경로 + 소요시간
|
||||
var headerGrid = new Grid();
|
||||
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) }); // 파일경로
|
||||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 상태+시간
|
||||
|
||||
var statusIcon = isSuccess ? "\uE73E" : "\uE711"; // 체크 or X
|
||||
var statusColor = isSuccess
|
||||
? new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50));
|
||||
|
||||
var iconTb = new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontSize = 13,
|
||||
Foreground = iconColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
};
|
||||
Grid.SetColumn(iconTb, 0);
|
||||
headerGrid.Children.Add(iconTb);
|
||||
|
||||
var toolNameTb = new TextBlock
|
||||
{
|
||||
Text = GetV2ToolDisplayName(toolCall.ToolName),
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
};
|
||||
Grid.SetColumn(toolNameTb, 1);
|
||||
headerGrid.Children.Add(toolNameTb);
|
||||
|
||||
// 파일 경로 (있으면)
|
||||
var filePath = toolCall.FilePath ?? toolResult.FilePath;
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
var pathTb = new TextBlock
|
||||
{
|
||||
Text = TruncateFilePath(filePath, 60),
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.8,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
ToolTip = filePath,
|
||||
};
|
||||
Grid.SetColumn(pathTb, 2);
|
||||
headerGrid.Children.Add(pathTb);
|
||||
}
|
||||
|
||||
// 상태 아이콘 + 소요시간
|
||||
var statusPanel = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
statusPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = statusIcon,
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = statusColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
});
|
||||
if (!string.IsNullOrEmpty(elapsedText))
|
||||
{
|
||||
statusPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = elapsedText,
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.7,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
}
|
||||
Grid.SetColumn(statusPanel, 3);
|
||||
headerGrid.Children.Add(statusPanel);
|
||||
cardStack.Children.Add(headerGrid);
|
||||
|
||||
// Summary 텍스트 (있으면)
|
||||
var summary = toolCall.Summary ?? toolResult.Summary;
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
var summaryTb = new TextBlock
|
||||
{
|
||||
Text = summary,
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 4, 0, 0),
|
||||
MaxHeight = 40,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
cardStack.Children.Add(summaryTb);
|
||||
}
|
||||
|
||||
// 접힘/펼침 상세 영역
|
||||
var detailBorder = new Border
|
||||
{
|
||||
Visibility = Visibility.Collapsed,
|
||||
Margin = new Thickness(0, 6, 0, 0),
|
||||
};
|
||||
var detailStack = new StackPanel();
|
||||
|
||||
// 도구 결과 상세 내용
|
||||
var resultSummary = toolResult.Summary;
|
||||
if (!string.IsNullOrWhiteSpace(resultSummary) && resultSummary.Length > 80)
|
||||
{
|
||||
var codeBg = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
||||
detailStack.Children.Add(MarkdownRenderer.Render(
|
||||
$"```\n{resultSummary}\n```", primaryText, secondaryText, accentBrush, codeBg));
|
||||
}
|
||||
|
||||
// 토큰 메타 정보
|
||||
var inputTokens = toolResult.InputTokens;
|
||||
var outputTokens = toolResult.OutputTokens;
|
||||
if (inputTokens > 0 || outputTokens > 0)
|
||||
{
|
||||
var metaParts = new System.Collections.Generic.List<string>();
|
||||
if (inputTokens > 0) metaParts.Add($"입력: {inputTokens:N0} 토큰");
|
||||
if (outputTokens > 0) metaParts.Add($"출력: {outputTokens:N0} 토큰");
|
||||
|
||||
detailStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = string.Join(" · ", metaParts),
|
||||
FontSize = 9.5,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.6,
|
||||
Margin = new Thickness(0, 4, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
detailBorder.Child = detailStack;
|
||||
cardStack.Children.Add(detailBorder);
|
||||
|
||||
// 펼치기 토글 화살표
|
||||
var arrowTb = new TextBlock
|
||||
{
|
||||
Text = "\uE76C", // 아래 화살표
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 9,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.5,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Margin = new Thickness(0, 3, 0, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
cardStack.Children.Add(arrowTb);
|
||||
|
||||
card.Child = cardStack;
|
||||
outerGrid.Children.Add(card);
|
||||
|
||||
// 클릭 → 접힘/펼침 토글
|
||||
card.MouseLeftButtonUp += (_, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
var isExpanded = detailBorder.Visibility == Visibility.Visible;
|
||||
detailBorder.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible;
|
||||
arrowTb.Text = isExpanded ? "\uE76C" : "\uE76B"; // 아래↔위
|
||||
};
|
||||
|
||||
// 호버 효과
|
||||
var normalBg = hintBg;
|
||||
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? hintBg;
|
||||
card.MouseEnter += (_, _) => card.Background = hoverBg;
|
||||
card.MouseLeave += (_, _) => card.Background = normalBg;
|
||||
|
||||
return outerGrid;
|
||||
}
|
||||
|
||||
/// <summary>V2: Thinking 블록 — 점선 테두리, 이탤릭 텍스트</summary>
|
||||
private UIElement CreateV2ThinkingBlock(AgentEvent agentEvent)
|
||||
{
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
|
||||
var summary = agentEvent.Summary;
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
return new Border { Width = 0, Height = 0 }; // 빈 thinking은 숨김
|
||||
|
||||
// agent_wait / context_compaction 같은 운영 이벤트
|
||||
if (string.Equals(agentEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(agentEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateV2GenericEventPill(agentEvent);
|
||||
}
|
||||
|
||||
var outerGrid = new Grid
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Width = msgMaxWidth,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
};
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// 왼쪽 세로선 (사고 과정 = 파란색 점선 느낌)
|
||||
var thinkLine = new Border
|
||||
{
|
||||
Width = 2,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x40, 0x59, 0xA5, 0xF5)),
|
||||
CornerRadius = new CornerRadius(1),
|
||||
Margin = new Thickness(12, 0, 8, 0),
|
||||
};
|
||||
Grid.SetColumn(thinkLine, 0);
|
||||
outerGrid.Children.Add(thinkLine);
|
||||
|
||||
var thinkCard = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x0A, 0x59, 0xA5, 0xF5)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0x59, 0xA5, 0xF5)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(10, 6, 10, 6),
|
||||
};
|
||||
Grid.SetColumn(thinkCard, 1);
|
||||
|
||||
var thinkStack = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
thinkStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE915", // 전구 아이콘
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x59, 0xA5, 0xF5)),
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, 1, 6, 0),
|
||||
});
|
||||
thinkStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = summary.Length > 200 ? summary[..200] + "..." : summary,
|
||||
FontSize = 11,
|
||||
FontStyle = FontStyles.Italic,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.75,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxWidth = msgMaxWidth - 60,
|
||||
});
|
||||
thinkCard.Child = thinkStack;
|
||||
outerGrid.Children.Add(thinkCard);
|
||||
|
||||
return outerGrid;
|
||||
}
|
||||
|
||||
/// <summary>V2: ToolCall만 있고 ToolResult가 없는 경우</summary>
|
||||
private UIElement CreateV2ToolCallOnlyCard(AgentEvent agentEvent)
|
||||
{
|
||||
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 hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var (icon, iconColor) = GetV2ToolIcon(agentEvent.ToolName);
|
||||
|
||||
var outerGrid = new Grid
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Width = msgMaxWidth,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
};
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var line = new Border
|
||||
{
|
||||
Width = 2,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x40, 0x59, 0xA5, 0xF5)),
|
||||
CornerRadius = new CornerRadius(1),
|
||||
Margin = new Thickness(12, 0, 8, 0),
|
||||
};
|
||||
Grid.SetColumn(line, 0);
|
||||
outerGrid.Children.Add(line);
|
||||
|
||||
var pill = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(10, 6, 10, 6),
|
||||
};
|
||||
Grid.SetColumn(pill, 1);
|
||||
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontSize = 12,
|
||||
Foreground = iconColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = GetV2ToolDisplayName(agentEvent.ToolName),
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(agentEvent.FilePath))
|
||||
{
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = TruncateFilePath(agentEvent.FilePath, 50),
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.7,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
pill.Child = sp;
|
||||
outerGrid.Children.Add(pill);
|
||||
|
||||
return outerGrid;
|
||||
}
|
||||
|
||||
/// <summary>V2: ToolResult만 있고 ToolCall이 없는 경우</summary>
|
||||
private UIElement CreateV2ToolResultOnlyCard(AgentEvent agentEvent)
|
||||
{
|
||||
// ToolCall과 유사하지만 결과 표시
|
||||
return CreateV2ToolCallOnlyCard(agentEvent);
|
||||
}
|
||||
|
||||
/// <summary>V2: 작업 완료 배너</summary>
|
||||
private UIElement CreateV2CompleteBanner(AgentEvent agentEvent)
|
||||
{
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var elapsed = NormalizeProgressElapsedMs(agentEvent.ElapsedMs);
|
||||
var elapsedText = elapsed > 0 ? $" · {elapsed / 1000.0:F1}s" : "";
|
||||
|
||||
var tokenInfo = "";
|
||||
if (agentEvent.InputTokens > 0 || agentEvent.OutputTokens > 0)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
if (agentEvent.InputTokens > 0) parts.Add($"입력 {agentEvent.InputTokens:N0}");
|
||||
if (agentEvent.OutputTokens > 0) parts.Add($"출력 {agentEvent.OutputTokens:N0}");
|
||||
tokenInfo = $" · {string.Join("/", parts)} 토큰";
|
||||
}
|
||||
|
||||
var banner = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x66, 0xBB, 0x6A)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x66, 0xBB, 0x6A)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(12, 6, 12, 6),
|
||||
Margin = new Thickness(0, 4, 0, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
MaxWidth = msgMaxWidth,
|
||||
};
|
||||
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE73E", // 체크마크
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"작업 완료{elapsedText}{tokenInfo}",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
banner.Child = sp;
|
||||
return banner;
|
||||
}
|
||||
|
||||
/// <summary>V2: 에러 배너</summary>
|
||||
private UIElement CreateV2ErrorBanner(AgentEvent agentEvent)
|
||||
{
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var banner = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x53, 0x50)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x53, 0x50)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(12, 6, 12, 6),
|
||||
Margin = new Thickness(0, 4, 0, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
MaxWidth = msgMaxWidth,
|
||||
};
|
||||
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE711", // X
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"오류 발생: {agentEvent.Summary ?? "알 수 없는 오류"}",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
banner.Child = sp;
|
||||
return banner;
|
||||
}
|
||||
|
||||
/// <summary>V2: 일반 이벤트 pill (분류 안 되는 이벤트)</summary>
|
||||
private UIElement CreateV2GenericEventPill(AgentEvent agentEvent)
|
||||
{
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
|
||||
var displayText = agentEvent.Summary ?? agentEvent.ToolName ?? agentEvent.Type.ToString();
|
||||
|
||||
var pill = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(10, 4, 10, 4),
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
MaxWidth = msgMaxWidth,
|
||||
};
|
||||
|
||||
pill.Child = new TextBlock
|
||||
{
|
||||
Text = displayText,
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
|
||||
return pill;
|
||||
}
|
||||
|
||||
// ─── V2 헬퍼 ────────────────────────────────────────────────────────
|
||||
|
||||
private static (string Icon, Brush Color) GetV2ToolIcon(string? toolName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolName))
|
||||
return ("\uE90F", Brushes.Gray); // 기어
|
||||
|
||||
var lower = toolName.ToLowerInvariant();
|
||||
|
||||
if (lower.Contains("read") || lower.Contains("document_read"))
|
||||
return ("\uE8E5", new SolidColorBrush(Color.FromRgb(0x42, 0xA5, 0xF5))); // 파일 읽기 파랑
|
||||
|
||||
if (lower.Contains("write") || lower.Contains("edit") || lower.Contains("create"))
|
||||
return ("\uE70F", new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A))); // 편집 초록
|
||||
|
||||
if (lower.Contains("search") || lower.Contains("web"))
|
||||
return ("\uE721", new SolidColorBrush(Color.FromRgb(0xFF, 0xB7, 0x4D))); // 검색 주황
|
||||
|
||||
if (lower.Contains("bash") || lower.Contains("script") || lower.Contains("run"))
|
||||
return ("\uE756", new SolidColorBrush(Color.FromRgb(0xAB, 0x47, 0xBC))); // 실행 보라
|
||||
|
||||
if (lower.Contains("delete") || lower.Contains("remove"))
|
||||
return ("\uE74D", new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50))); // 삭제 빨강
|
||||
|
||||
return ("\uE90F", new SolidColorBrush(Color.FromRgb(0x78, 0x90, 0x9C))); // 기타 회색
|
||||
}
|
||||
|
||||
private static string GetV2ToolDisplayName(string? toolName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolName)) return "Tool";
|
||||
|
||||
return toolName switch
|
||||
{
|
||||
"file_read" => "Read file",
|
||||
"document_read" => "Read document",
|
||||
"file_write" => "Write file",
|
||||
"file_edit" => "Edit file",
|
||||
"web_search" => "Web search",
|
||||
"bash" => "Run command",
|
||||
"script_run" => "Run script",
|
||||
"html_create" => "Create HTML",
|
||||
"docx_create" => "Create DOCX",
|
||||
"xlsx_create" or "excel_create" => "Create Excel",
|
||||
"pptx_create" => "Create PPTX",
|
||||
"csv_create" => "Create CSV",
|
||||
"markdown_create" or "md_create" => "Create Markdown",
|
||||
"context_compaction" => "Context compaction",
|
||||
"agent_wait" => "Waiting",
|
||||
_ => toolName,
|
||||
};
|
||||
}
|
||||
|
||||
private static string TruncateFilePath(string path, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || path.Length <= maxLength)
|
||||
return path;
|
||||
|
||||
var fileName = System.IO.Path.GetFileName(path);
|
||||
if (fileName.Length >= maxLength - 3)
|
||||
return "..." + fileName[^(maxLength - 3)..];
|
||||
|
||||
var remaining = maxLength - fileName.Length - 4; // ".../" 포함
|
||||
if (remaining <= 0)
|
||||
return ".../" + fileName;
|
||||
|
||||
return path[..remaining] + ".../" + fileName;
|
||||
}
|
||||
}
|
||||
342
src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs
Normal file
342
src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Threading;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private DispatcherTimer? _v2LiveElapsedTimer;
|
||||
private DateTime _v2LiveStartTime;
|
||||
private TextBlock? _v2LiveElapsedText;
|
||||
|
||||
/// <summary>V2: 스트리밍 시작 시 라이브 진행 컨테이너 생성</summary>
|
||||
private void ShowAgentLiveCardV2(string runTab)
|
||||
{
|
||||
if (MessageList == null) return;
|
||||
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
RemoveAgentLiveCardV2(animated: false);
|
||||
|
||||
_v2LiveStartTime = DateTime.UtcNow;
|
||||
_v2LiveToolCards.Clear();
|
||||
_v2LastLiveToolCallId = null;
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
|
||||
_v2LiveContainer = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Width = msgMaxWidth,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0, 4, 0, 6),
|
||||
};
|
||||
|
||||
// 에이전트 헤더 (아이콘 + 이름 + 경과시간)
|
||||
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 4) };
|
||||
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 (agentName, _, _) = GetAgentIdentity();
|
||||
var (liveIconHost, livePixels, liveGlows, liveRotate, liveScale) = CreateMiniLauncherIconEx(4.0, "none");
|
||||
var canvas = liveIconHost.Children.OfType<Canvas>().FirstOrDefault();
|
||||
if (canvas != null)
|
||||
{
|
||||
var animState = new ChatIconAnimState
|
||||
{
|
||||
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
|
||||
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
|
||||
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
|
||||
};
|
||||
StartChatIconAnimation(animState);
|
||||
}
|
||||
|
||||
Grid.SetColumn(liveIconHost, 0);
|
||||
headerGrid.Children.Add(liveIconHost);
|
||||
|
||||
var nameTb = new TextBlock
|
||||
{
|
||||
Text = agentName,
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
Grid.SetColumn(nameTb, 1);
|
||||
headerGrid.Children.Add(nameTb);
|
||||
|
||||
_v2LiveElapsedText = new TextBlock
|
||||
{
|
||||
Text = "",
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.50,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
Grid.SetColumn(_v2LiveElapsedText, 2);
|
||||
headerGrid.Children.Add(_v2LiveElapsedText);
|
||||
|
||||
_v2LiveContainer.Children.Add(headerGrid);
|
||||
|
||||
AddTranscriptElement(_v2LiveContainer);
|
||||
ForceScrollToEnd();
|
||||
|
||||
// 경과 시간 타이머
|
||||
_v2LiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
_v2LiveElapsedTimer.Tick += (_, _) =>
|
||||
{
|
||||
if (_v2LiveElapsedText == null) return;
|
||||
var sec = (int)(DateTime.UtcNow - _v2LiveStartTime).TotalSeconds;
|
||||
_v2LiveElapsedText.Text = sec > 0 ? $"{sec}초 경과" : "";
|
||||
};
|
||||
_v2LiveElapsedTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>V2: 에이전트 이벤트 수신 시 라이브 카드 업데이트</summary>
|
||||
private void UpdateAgentLiveCardV2(AgentEvent agentEvent)
|
||||
{
|
||||
if (_v2LiveContainer == null) return;
|
||||
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
|
||||
switch (agentEvent.Type)
|
||||
{
|
||||
case AgentEventType.ToolCall:
|
||||
{
|
||||
var (icon, iconColor) = GetV2ToolIcon(agentEvent.ToolName);
|
||||
var toolId = $"{agentEvent.ToolName}_{agentEvent.Timestamp.Ticks}";
|
||||
_v2LastLiveToolCallId = toolId;
|
||||
|
||||
var outerGrid = new Grid { Margin = new Thickness(0, 2, 0, 2) };
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// 왼쪽 세로선 (진행중 = 맥박)
|
||||
var accentColor = ResolveLiveProgressAccentColor(
|
||||
TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue);
|
||||
var pulseLine = new Border
|
||||
{
|
||||
Width = 2,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x80, accentColor.R, accentColor.G, accentColor.B)),
|
||||
CornerRadius = new CornerRadius(1),
|
||||
Margin = new Thickness(12, 0, 8, 0),
|
||||
};
|
||||
// 맥박 애니메이션
|
||||
var pulseAnim = new DoubleAnimation(0.4, 1.0, TimeSpan.FromMilliseconds(800))
|
||||
{
|
||||
AutoReverse = true,
|
||||
RepeatBehavior = RepeatBehavior.Forever,
|
||||
EasingFunction = new SineEase(),
|
||||
};
|
||||
pulseLine.BeginAnimation(UIElement.OpacityProperty, pulseAnim);
|
||||
Grid.SetColumn(pulseLine, 0);
|
||||
outerGrid.Children.Add(pulseLine);
|
||||
|
||||
var card = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(10, 6, 10, 6),
|
||||
Tag = "pending",
|
||||
};
|
||||
Grid.SetColumn(card, 1);
|
||||
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontSize = 12,
|
||||
Foreground = iconColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = GetV2ToolDisplayName(agentEvent.ToolName),
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(agentEvent.FilePath))
|
||||
{
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = TruncateFilePath(agentEvent.FilePath, 50),
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.7,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
// 스피너 대신 "..." 텍스트
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "...",
|
||||
FontSize = 11,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.5,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
});
|
||||
card.Child = sp;
|
||||
outerGrid.Children.Add(card);
|
||||
|
||||
_v2LiveToolCards[toolId] = card;
|
||||
_v2LiveContainer.Children.Add(outerGrid);
|
||||
ForceScrollToEnd();
|
||||
break;
|
||||
}
|
||||
|
||||
case AgentEventType.ToolResult:
|
||||
{
|
||||
// 마지막 pending 카드를 완료 상태로 변환
|
||||
if (_v2LastLiveToolCallId != null && _v2LiveToolCards.TryGetValue(_v2LastLiveToolCallId, out var pendingCard))
|
||||
{
|
||||
var isSuccess = agentEvent.Success;
|
||||
var statusIcon = isSuccess ? "\uE73E" : "\uE711";
|
||||
var statusColor = isSuccess
|
||||
? new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50));
|
||||
|
||||
// 카드 배경/테두리를 성공/실패 색으로 변경
|
||||
pendingCard.Background = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x0A, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x0A, 0xEF, 0x53, 0x50));
|
||||
pendingCard.BorderBrush = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x30, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50));
|
||||
pendingCard.Tag = "complete";
|
||||
|
||||
// "..." 텍스트를 상태 아이콘 + 소요시간으로 교체
|
||||
if (pendingCard.Child is StackPanel sp)
|
||||
{
|
||||
// 마지막 "..." 제거
|
||||
if (sp.Children.Count > 0 && sp.Children[^1] is TextBlock lastTb && lastTb.Text == "...")
|
||||
sp.Children.RemoveAt(sp.Children.Count - 1);
|
||||
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = statusIcon,
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = statusColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
});
|
||||
|
||||
var elapsed = NormalizeProgressElapsedMs(agentEvent.ElapsedMs);
|
||||
if (elapsed > 0)
|
||||
{
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{elapsed / 1000.0:F1}s",
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.6,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(4, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 세로선 맥박 애니메이션 중지
|
||||
var parent = pendingCard.Parent as Grid;
|
||||
if (parent?.Children[0] is Border pulseLine)
|
||||
{
|
||||
pulseLine.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
pulseLine.Opacity = 1;
|
||||
pulseLine.Background = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x60, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50));
|
||||
}
|
||||
|
||||
_v2LastLiveToolCallId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case AgentEventType.Thinking:
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentEvent.Summary)) break;
|
||||
|
||||
// 사고 과정을 간략히 표시
|
||||
var thinkRow = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(22, 2, 0, 2),
|
||||
};
|
||||
thinkRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE915",
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 10,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x59, 0xA5, 0xF5)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
});
|
||||
var thinkText = agentEvent.Summary;
|
||||
if (thinkText.Length > 100) thinkText = thinkText[..100] + "...";
|
||||
thinkRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = thinkText,
|
||||
FontSize = 10.5,
|
||||
FontStyle = FontStyles.Italic,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.65,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxWidth = msgMaxWidth - 60,
|
||||
});
|
||||
_v2LiveContainer.Children.Add(thinkRow);
|
||||
ForceScrollToEnd();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>V2: 스트리밍 종료 시 라이브 카드 제거</summary>
|
||||
private void RemoveAgentLiveCardV2(bool animated = true)
|
||||
{
|
||||
_v2LiveElapsedTimer?.Stop();
|
||||
_v2LiveElapsedTimer = null;
|
||||
_v2LiveElapsedText = null;
|
||||
|
||||
if (_v2LiveContainer == null) return;
|
||||
|
||||
// 아이콘 애니메이션 상태 정리
|
||||
_chatIconAnimStates.RemoveAll(s => s.Host != null && !s.Host.IsVisible);
|
||||
|
||||
var toRemove = _v2LiveContainer;
|
||||
_v2LiveContainer = null;
|
||||
_v2LiveToolCards.Clear();
|
||||
_v2LastLiveToolCallId = null;
|
||||
|
||||
if (animated && ContainsTranscriptElement(toRemove))
|
||||
{
|
||||
var anim = new DoubleAnimation(toRemove.Opacity, 0, TimeSpan.FromMilliseconds(160));
|
||||
anim.Completed += (_, _) => RemoveTranscriptElement(toRemove);
|
||||
toRemove.BeginAnimation(UIElement.OpacityProperty, anim);
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveTranscriptElement(toRemove);
|
||||
}
|
||||
}
|
||||
260
src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs
Normal file
260
src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
private UIElement CreateV2MessageElement(ChatMessage message)
|
||||
{
|
||||
var isUser = message.Role == "user";
|
||||
return isUser ? CreateV2UserBubble(message) : CreateV2AssistantBlock(message);
|
||||
}
|
||||
|
||||
private UIElement CreateV2UserBubble(ChatMessage message)
|
||||
{
|
||||
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 accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||
var hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var wrapper = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Width = msgMaxWidth,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0, 12, 0, 4),
|
||||
};
|
||||
|
||||
// 사용자 아이콘 + 이름 헤더
|
||||
var header = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(2, 0, 0, 4),
|
||||
};
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE77B", // 사용자 아이콘
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 12,
|
||||
Foreground = secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "You",
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
var timestamp = message.Timestamp;
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = timestamp.ToString("HH:mm"),
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.52,
|
||||
Margin = new Thickness(8, 0, 0, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
wrapper.Children.Add(header);
|
||||
|
||||
// 메시지 본문
|
||||
var bubble = new Border
|
||||
{
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
};
|
||||
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
|
||||
|
||||
var content = message.Content ?? "";
|
||||
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MarkdownRenderer.EnableFilePathHighlight =
|
||||
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||||
MarkdownRenderer.EnableCodeSymbolHighlight = true;
|
||||
bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, hintBg);
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkdownRenderer.EnableCodeSymbolHighlight = false;
|
||||
bubble.Child = new TextBlock
|
||||
{
|
||||
Text = content,
|
||||
TextAlignment = TextAlignment.Left,
|
||||
FontSize = 12,
|
||||
Foreground = primaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = 18,
|
||||
};
|
||||
}
|
||||
|
||||
wrapper.Children.Add(bubble);
|
||||
|
||||
// 우클릭 메뉴
|
||||
var capturedContent = content;
|
||||
wrapper.MouseRightButtonUp += (_, re) =>
|
||||
{
|
||||
re.Handled = true;
|
||||
ShowMessageContextMenu(capturedContent, "user");
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private UIElement CreateV2AssistantBlock(ChatMessage message)
|
||||
{
|
||||
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 hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
|
||||
// Compaction 메타 메시지 처리
|
||||
if (IsCompactionMetaMessage(message))
|
||||
{
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
return CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush);
|
||||
}
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var container = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Width = msgMaxWidth,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0, 8, 0, 8),
|
||||
};
|
||||
|
||||
// 에이전트 아이콘 + 이름 헤더
|
||||
var (agentName, _, _) = GetAgentIdentity();
|
||||
var (iconHost, iconPixels, iconGlows, iconRotate, iconScale) = CreateMiniLauncherIconEx(4.0, "none");
|
||||
var header = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(2, 0, 0, 4),
|
||||
};
|
||||
header.Children.Add(iconHost);
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = agentName,
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = secondaryText,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
|
||||
// 아이콘 애니메이션
|
||||
var canvas = iconHost.Children.OfType<Canvas>().FirstOrDefault();
|
||||
if (canvas != null)
|
||||
{
|
||||
var animState = new ChatIconAnimState
|
||||
{
|
||||
Host = iconHost, Canvas = canvas, Pixels = iconPixels,
|
||||
Glows = iconGlows, Rotate = iconRotate, Scale = iconScale,
|
||||
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
|
||||
};
|
||||
StartChatIconAnimation(animState);
|
||||
}
|
||||
|
||||
container.Children.Add(header);
|
||||
|
||||
// 마크다운 렌더 본문
|
||||
var content = message.Content ?? "";
|
||||
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
||||
MarkdownRenderer.EnableFilePathHighlight =
|
||||
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||||
MarkdownRenderer.EnableCodeSymbolHighlight =
|
||||
string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var contentPanel = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
Padding = new Thickness(2, 4, 2, 4),
|
||||
};
|
||||
|
||||
if (IsBranchContextMessage(content))
|
||||
{
|
||||
contentPanel.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
}
|
||||
else
|
||||
{
|
||||
contentPanel.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
}
|
||||
|
||||
container.Children.Add(contentPanel);
|
||||
|
||||
// 파일 퀵 액션
|
||||
var outputFilePath = ExtractOutputFilePathFromContent(content);
|
||||
if (!string.IsNullOrEmpty(outputFilePath) && System.IO.File.Exists(outputFilePath))
|
||||
{
|
||||
var quickActions = BuildFileQuickActions(outputFilePath);
|
||||
quickActions.Margin = new Thickness(2, 4, 0, 2);
|
||||
container.Children.Add(quickActions);
|
||||
}
|
||||
|
||||
// 액션 바 (복사, 재생성 등)
|
||||
var actionBar = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Margin = new Thickness(2, 2, 0, 0),
|
||||
Opacity = 0.7,
|
||||
};
|
||||
var btnColor = secondaryText;
|
||||
var capturedContent = content;
|
||||
|
||||
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
|
||||
{
|
||||
try { Clipboard.SetText(capturedContent); } catch { }
|
||||
}));
|
||||
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
|
||||
|
||||
var aiTimestamp = message.Timestamp;
|
||||
actionBar.Children.Add(new TextBlock
|
||||
{
|
||||
Text = aiTimestamp.ToString("HH:mm"),
|
||||
FontSize = 10,
|
||||
Opacity = 0.52,
|
||||
Foreground = btnColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(4, 0, 0, 1),
|
||||
});
|
||||
|
||||
container.Children.Add(actionBar);
|
||||
|
||||
// 메타 정보 (토큰 등)
|
||||
var assistantMeta = CreateAssistantMessageMetaText(message);
|
||||
if (assistantMeta != null)
|
||||
container.Children.Add(assistantMeta);
|
||||
|
||||
container.MouseEnter += (_, _) => actionBar.Opacity = 1;
|
||||
container.MouseLeave += (_, _) => actionBar.Opacity = 0.7;
|
||||
|
||||
var aiContent = content;
|
||||
container.MouseRightButtonUp += (_, re) =>
|
||||
{
|
||||
re.Handled = true;
|
||||
ShowMessageContextMenu(aiContent, "assistant");
|
||||
};
|
||||
|
||||
return container;
|
||||
}
|
||||
}
|
||||
225
src/AxCopilot/Views/ChatWindow.V2Rendering.cs
Normal file
225
src/AxCopilot/Views/ChatWindow.V2Rendering.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── V2 렌더 상태 ───────────────────────────────────────────────────
|
||||
private string? _v2LastRenderedConversationId;
|
||||
private int _v2LastRenderedMessageCount;
|
||||
private int _v2LastRenderedEventCount;
|
||||
private readonly List<string> _v2LastRenderedKeys = new();
|
||||
|
||||
// V2 라이브 프로그레스 상태
|
||||
private StackPanel? _v2LiveContainer;
|
||||
private readonly Dictionary<string, Border> _v2LiveToolCards = new();
|
||||
private string? _v2LastLiveToolCallId;
|
||||
|
||||
private void RenderMessagesV2(
|
||||
ChatConversation conv,
|
||||
IReadOnlyList<ChatMessage> visibleMessages,
|
||||
IReadOnlyList<ChatExecutionEvent> visibleEvents,
|
||||
bool preserveViewport,
|
||||
double previousScrollableHeight,
|
||||
double previousVerticalOffset,
|
||||
Stopwatch renderStopwatch,
|
||||
string? caller)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 대화 전환 감지 → 캐시 초기화
|
||||
if (!string.Equals(_v2LastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_v2LastRenderedConversationId = conv.Id;
|
||||
_v2LastRenderedKeys.Clear();
|
||||
_v2LastRenderedMessageCount = 0;
|
||||
_v2LastRenderedEventCount = 0;
|
||||
_elementCache.Clear();
|
||||
}
|
||||
|
||||
// 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합)
|
||||
var timeline = BuildV2Timeline(visibleMessages, visibleEvents);
|
||||
|
||||
// 새 키 목록 생성
|
||||
var newKeys = new List<string>(timeline.Count);
|
||||
foreach (var item in timeline)
|
||||
newKeys.Add(item.Key);
|
||||
|
||||
// 인크리멘탈 렌더 시도: 기존 키가 새 키의 접두사인 경우 추가분만 렌더
|
||||
var canIncremental = _v2LastRenderedKeys.Count > 0
|
||||
&& newKeys.Count >= _v2LastRenderedKeys.Count
|
||||
&& KeysArePrefixMatch(_v2LastRenderedKeys, newKeys);
|
||||
|
||||
if (canIncremental)
|
||||
{
|
||||
// 라이브 컨테이너가 있으면 임시 제거 (맨 끝에 다시 추가)
|
||||
if (_v2LiveContainer != null && ContainsTranscriptElement(_v2LiveContainer))
|
||||
RemoveTranscriptElement(_v2LiveContainer);
|
||||
|
||||
for (int i = _v2LastRenderedKeys.Count; i < timeline.Count; i++)
|
||||
{
|
||||
var item = timeline[i];
|
||||
AddDeferredTranscriptElement(item.Key, () =>
|
||||
{
|
||||
if (_elementCache.TryGetValue(item.Key, out var cached))
|
||||
return cached;
|
||||
var element = item.CreateElement();
|
||||
_elementCache[item.Key] = element;
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
// 라이브 컨테이너 재삽입
|
||||
if (_v2LiveContainer != null && _isStreaming)
|
||||
AddTranscriptElement(_v2LiveContainer);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 전체 재빌드
|
||||
ClearTranscriptElements();
|
||||
|
||||
foreach (var item in timeline)
|
||||
{
|
||||
var capturedItem = item;
|
||||
AddDeferredTranscriptElement(capturedItem.Key, () =>
|
||||
{
|
||||
if (_elementCache.TryGetValue(capturedItem.Key, out var cached))
|
||||
return cached;
|
||||
var element = capturedItem.CreateElement();
|
||||
_elementCache[capturedItem.Key] = element;
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
// 라이브 컨테이너 재삽입
|
||||
if (_v2LiveContainer != null && _isStreaming)
|
||||
AddTranscriptElement(_v2LiveContainer);
|
||||
}
|
||||
|
||||
_v2LastRenderedKeys.Clear();
|
||||
_v2LastRenderedKeys.AddRange(newKeys);
|
||||
_v2LastRenderedMessageCount = visibleMessages.Count;
|
||||
_v2LastRenderedEventCount = visibleEvents.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"[V2Render] 렌더링 예외: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
|
||||
_lastRenderTicks = Environment.TickCount64;
|
||||
_lastRenderedMessageCount = visibleMessages.Count;
|
||||
_lastRenderedEventCount = visibleEvents.Count;
|
||||
renderStopwatch.Stop();
|
||||
|
||||
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
|
||||
{
|
||||
AgentPerformanceLogService.LogMetric(
|
||||
"transcript", "render_messages_v2", conv.Id, _activeTab ?? "",
|
||||
renderStopwatch.ElapsedMilliseconds,
|
||||
new { preserveViewport, streaming = _isStreaming, visibleMessages = visibleMessages.Count, visibleEvents = visibleEvents.Count });
|
||||
}
|
||||
|
||||
if (!preserveViewport)
|
||||
{
|
||||
_ = Dispatcher.InvokeAsync(ScrollTranscriptToEnd, DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
if (_transcriptScrollViewer == null) return;
|
||||
var newScrollableHeight = GetTranscriptScrollableHeight();
|
||||
var delta = newScrollableHeight - previousScrollableHeight;
|
||||
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
|
||||
ScrollTranscriptToVerticalOffset(targetOffset);
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static bool KeysArePrefixMatch(List<string> oldKeys, List<string> newKeys)
|
||||
{
|
||||
for (int i = 0; i < oldKeys.Count; i++)
|
||||
{
|
||||
if (!string.Equals(oldKeys[i], newKeys[i], StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record V2TimelineItem(string Key, DateTime Timestamp, Func<UIElement> CreateElement);
|
||||
|
||||
private List<V2TimelineItem> BuildV2Timeline(
|
||||
IReadOnlyList<ChatMessage> visibleMessages,
|
||||
IReadOnlyList<ChatExecutionEvent> visibleEvents)
|
||||
{
|
||||
var timeline = new List<V2TimelineItem>(visibleMessages.Count + visibleEvents.Count);
|
||||
|
||||
// 1. 메시지 추가
|
||||
foreach (var msg in visibleMessages)
|
||||
{
|
||||
var capturedMsg = msg;
|
||||
var key = $"v2m_{msg.MsgId}";
|
||||
timeline.Add(new V2TimelineItem(key, msg.Timestamp, () => CreateV2MessageElement(capturedMsg)));
|
||||
}
|
||||
|
||||
// 2. 실행 이벤트 추가 — ToolCall+ToolResult 쌍을 병합
|
||||
var eventIndex = 0;
|
||||
var events = visibleEvents.ToList();
|
||||
|
||||
for (int i = 0; i < events.Count; i++)
|
||||
{
|
||||
var executionEvent = events[i];
|
||||
var agentEvent = ToAgentEvent(executionEvent);
|
||||
|
||||
// SessionStart / UserPromptSubmit 숨김
|
||||
if (agentEvent.Type == AgentEventType.SessionStart || agentEvent.Type == AgentEventType.UserPromptSubmit)
|
||||
continue;
|
||||
|
||||
var key = $"v2e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
|
||||
|
||||
// ToolCall → 바로 다음 같은 도구의 ToolResult를 찾아 병합
|
||||
if (agentEvent.Type == AgentEventType.ToolCall && i + 1 < events.Count)
|
||||
{
|
||||
var nextEvent = ToAgentEvent(events[i + 1]);
|
||||
if (nextEvent.Type == AgentEventType.ToolResult
|
||||
&& string.Equals(nextEvent.ToolName, agentEvent.ToolName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var capturedCall = agentEvent;
|
||||
var capturedResult = nextEvent;
|
||||
timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp,
|
||||
() => CreateV2ToolExecutionCard(capturedCall, capturedResult)));
|
||||
i++; // ToolResult 스킵
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var capturedEvent = agentEvent;
|
||||
timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp,
|
||||
() => CreateV2AgentEventElement(capturedEvent)));
|
||||
}
|
||||
|
||||
// 시간순 정렬
|
||||
var needsSort = false;
|
||||
for (int i = 1; i < timeline.Count; i++)
|
||||
{
|
||||
if (timeline[i].Timestamp < timeline[i - 1].Timestamp)
|
||||
{
|
||||
needsSort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (needsSort)
|
||||
timeline.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
|
||||
|
||||
return timeline;
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ public partial class ChatWindow
|
||||
var icon = new TextBlock
|
||||
{
|
||||
Text = symbol,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 10,
|
||||
Foreground = foreground,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
@@ -227,8 +227,8 @@ public partial class ChatWindow
|
||||
|
||||
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.BorderBrush = selected ? accent : Brushes.Transparent;
|
||||
border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(0);
|
||||
border.Effect = selected
|
||||
? new System.Windows.Media.Effects.DropShadowEffect
|
||||
{
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
|
||||
xmlns:local="clr-namespace:AxCopilot.Views"
|
||||
xmlns:conv="clr-namespace:AxCopilot.Themes"
|
||||
xmlns:vm="clr-namespace:AxCopilot.ViewModels"
|
||||
Title="AX Copilot — AX Agent"
|
||||
Width="1180" Height="880"
|
||||
MinWidth="780" MinHeight="560"
|
||||
@@ -23,6 +25,262 @@
|
||||
</WindowChrome.WindowChrome>
|
||||
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
<conv:HexToBrushConverter x:Key="HexToBrush"/>
|
||||
<conv:NullToCollapsedConverter x:Key="NullToVis"/>
|
||||
|
||||
<!-- ── 글로벌 슬림 커스텀 스크롤바 ── -->
|
||||
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#888888"/>
|
||||
|
||||
<!-- Thumb (드래그 핸들) -->
|
||||
<Style x:Key="SlimScrollThumb" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||
<Setter Property="IsTabStop" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border CornerRadius="3"
|
||||
Background="{DynamicResource ScrollThumbBrush}"
|
||||
Opacity="0.45"/>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 수직 스크롤바 -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Width" Value="6"/>
|
||||
<Setter Property="MinWidth" Value="6"/>
|
||||
<Setter Property="Opacity" Value="0"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid>
|
||||
<Border Background="Transparent" CornerRadius="3"/>
|
||||
<Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource SlimScrollThumb}"/>
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<!-- 수평 스크롤바도 슬림하게 -->
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Width" Value="Auto"/>
|
||||
<Setter Property="Height" Value="6"/>
|
||||
<Setter Property="MinHeight" Value="6"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid>
|
||||
<Border Background="Transparent" CornerRadius="3"/>
|
||||
<Track x:Name="PART_Track" IsDirectionReversed="False">
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource SlimScrollThumb}"/>
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ScrollViewer: 호버 시 스크롤바 페이드 인/아웃 -->
|
||||
<Style TargetType="ScrollViewer">
|
||||
<Setter Property="OverridesDefaultStyle" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollViewer">
|
||||
<Grid>
|
||||
<ScrollContentPresenter Margin="{TemplateBinding Padding}"
|
||||
CanContentScroll="{TemplateBinding CanContentScroll}"
|
||||
CanHorizontallyScroll="False"
|
||||
CanVerticallyScroll="False"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
Content="{TemplateBinding Content}"/>
|
||||
<ScrollBar x:Name="PART_VerticalScrollBar"
|
||||
HorizontalAlignment="Right"
|
||||
Maximum="{TemplateBinding ScrollableHeight}"
|
||||
ViewportSize="{TemplateBinding ViewportHeight}"
|
||||
Value="{TemplateBinding VerticalOffset}"
|
||||
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"
|
||||
Margin="0,2,0,2"/>
|
||||
<ScrollBar x:Name="PART_HorizontalScrollBar"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Bottom"
|
||||
Maximum="{TemplateBinding ScrollableWidth}"
|
||||
ViewportSize="{TemplateBinding ViewportWidth}"
|
||||
Value="{TemplateBinding HorizontalOffset}"
|
||||
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"
|
||||
Margin="2,0,2,1"/>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Trigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetName="PART_VerticalScrollBar"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To="1" Duration="0:0:0.15"/>
|
||||
<DoubleAnimation Storyboard.TargetName="PART_HorizontalScrollBar"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To="1" Duration="0:0:0.15"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.EnterActions>
|
||||
<Trigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetName="PART_VerticalScrollBar"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To="0" Duration="0:0:0.4"/>
|
||||
<DoubleAnimation Storyboard.TargetName="PART_HorizontalScrollBar"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To="0" Duration="0:0:0.4"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.ExitActions>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<CollectionViewSource x:Key="GroupedConversations" Source="{Binding Conversations}">
|
||||
<CollectionViewSource.GroupDescriptions>
|
||||
<PropertyGroupDescription PropertyName="Group"/>
|
||||
</CollectionViewSource.GroupDescriptions>
|
||||
</CollectionViewSource>
|
||||
|
||||
<!-- ── 대화 목록 항목 DataTemplate ── -->
|
||||
<DataTemplate x:Key="ConversationItemTemplate" DataType="{x:Type vm:ConversationItemViewModel}">
|
||||
<Border x:Name="ConvItemBorder"
|
||||
CornerRadius="5"
|
||||
Padding="7,4.5,7,4.5"
|
||||
Cursor="Hand"
|
||||
Background="Transparent">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Margin" Value="0,1,0,1"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsBranch}" Value="True">
|
||||
<Setter Property="Margin" Value="10,1,0,1"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||
<Setter Property="Background" Value="#104B5EFC"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1.25,0,0,0"/>
|
||||
</DataTrigger>
|
||||
<!-- 호버 효과 (선택 안 된 항목만) -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsSelected}" Value="False"/>
|
||||
<Condition Binding="{Binding RelativeSource={RelativeSource Self}, Path=IsMouseOver}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Background" Value="#08FFFFFF"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 아이콘 -->
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding IconText}"
|
||||
FontFamily="Segoe MDL2 Assets"
|
||||
FontSize="10.5"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding ColorHex, Converter={StaticResource HexToBrush}}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Pinned}" Value="True">
|
||||
<Setter Property="Foreground" Value="Orange"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsBranch}" Value="True">
|
||||
<Setter Property="Foreground" Value="#8B5CF6"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<!-- 제목 + 날짜 + 상태 -->
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ConvTitleBlock"
|
||||
Text="{Binding Title}"
|
||||
FontSize="11.75"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding UpdatedAtText}"
|
||||
FontSize="9"
|
||||
Foreground="{DynamicResource HintText}"
|
||||
Margin="0,1.5,0,0"/>
|
||||
<TextBlock Text="진행 중"
|
||||
FontSize="8.8" FontWeight="Medium"
|
||||
Foreground="#4F46E5"
|
||||
Margin="0,1.5,0,0"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock FontSize="8.9"
|
||||
Margin="0,1.5,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Visibility="{Binding HasRunStatus, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Text" Value="{Binding RunStatusText}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryText}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasFailed}" Value="True">
|
||||
<Setter Property="Foreground" Value="#B91C1C"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 카테고리 변경 버튼 (호버 시 표시) -->
|
||||
<Button x:Name="ConvCatBtn" Grid.Column="2"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Cursor="Hand" Width="20" Height="20" Padding="0"
|
||||
Opacity="0.72" Visibility="Collapsed"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="9"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<!-- 호버 시 카테고리 버튼 표시 -->
|
||||
<DataTrigger Binding="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType=Border}, Path=IsMouseOver}" Value="True">
|
||||
<Setter TargetName="ConvCatBtn" Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 사이드바 버튼 -->
|
||||
<Style x:Key="GhostBtn" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
@@ -289,13 +547,16 @@
|
||||
<Style x:Key="FooterChipBtn" TargetType="Button" BasedOn="{StaticResource OutlineHoverBtn}">
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="MinHeight" Value="30"/>
|
||||
<Setter Property="Background" Value="{DynamicResource LauncherBackground}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{DynamicResource LauncherBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="1"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="10"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
@@ -714,6 +975,7 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="13.5"
|
||||
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"/>
|
||||
@@ -760,7 +1022,24 @@
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="4"
|
||||
<Button x:Name="BtnArchiveFilter" Grid.Column="4"
|
||||
Style="{StaticResource GhostBtn}"
|
||||
Padding="7,3.5" MinWidth="50"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Click="BtnArchiveFilter_Click"
|
||||
ToolTip="아카이브 필터 전환">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" Margin="0,0,3,0"/>
|
||||
<TextBlock x:Name="ArchiveFilterLabel" Text="보관"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="5"
|
||||
Style="{StaticResource GhostBtn}"
|
||||
Padding="7,4"
|
||||
Margin="5,0,0,0"
|
||||
@@ -904,9 +1183,55 @@
|
||||
</Border>
|
||||
|
||||
<!-- 대화 목록 -->
|
||||
<ScrollViewer Grid.Row="4" VerticalScrollBarVisibility="Auto"
|
||||
<ScrollViewer x:Name="ConversationListScrollViewer"
|
||||
Grid.Row="4" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="ConversationPanel" Margin="6,0,6,0"/>
|
||||
<StackPanel>
|
||||
<!-- 빈 상태 메시지 -->
|
||||
<TextBlock Text="{Binding EmptyConversationText}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,20,0,0"
|
||||
Visibility="{Binding EmptyConversationText, Converter={StaticResource NullToVis}}"/>
|
||||
|
||||
<!-- ViewModel-bound ItemsControl (그룹 헤더 + DataTemplate) -->
|
||||
<ItemsControl x:Name="ConversationItemsControl"
|
||||
ItemsSource="{Binding Source={StaticResource GroupedConversations}}"
|
||||
ItemTemplate="{StaticResource ConversationItemTemplate}"
|
||||
Margin="6,0,6,0">
|
||||
<ItemsControl.GroupStyle>
|
||||
<GroupStyle>
|
||||
<GroupStyle.HeaderTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
Margin="8,10,0,4"/>
|
||||
</DataTemplate>
|
||||
</GroupStyle.HeaderTemplate>
|
||||
</GroupStyle>
|
||||
</ItemsControl.GroupStyle>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 더 보기 버튼 -->
|
||||
<Border x:Name="LoadMoreBorder"
|
||||
CornerRadius="8" Cursor="Hand"
|
||||
Padding="8,10" Margin="6,4,6,4"
|
||||
Background="Transparent"
|
||||
Visibility="{Binding HasMoreConversations, Converter={StaticResource BoolToVis}}"
|
||||
MouseLeftButtonUp="LoadMoreBorder_Click">
|
||||
<TextBlock HorizontalAlignment="Center" FontSize="12"
|
||||
Foreground="{DynamicResource AccentColor}">
|
||||
<Run Text="더 보기 ("/>
|
||||
<Run Text="{Binding RemainingConversationCount, Mode=OneWay}"/>
|
||||
<Run Text="개 남음)"/>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
|
||||
<!-- 레거시 ConversationPanel (Collapsed, 이름변경 TextBlock 검색용으로 유지) -->
|
||||
<StackPanel x:Name="ConversationPanel" Margin="6,0,6,0" Visibility="Collapsed"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 하단: 삭제 -->
|
||||
@@ -1008,7 +1333,7 @@
|
||||
<StackPanel Grid.Column="0" Orientation="Vertical" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Grid VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ChatTitle" Text="" FontSize="12.25" FontWeight="SemiBold"
|
||||
<TextBlock x:Name="ChatTitle" Text="{Binding ChatTitle, FallbackValue=''}" FontSize="12.25" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="360" Cursor="Hand"
|
||||
@@ -1380,7 +1705,7 @@
|
||||
<ListBox x:Name="MessageList" Grid.Row="3"
|
||||
Background="{DynamicResource LauncherBackground}"
|
||||
BorderThickness="0"
|
||||
Padding="24,12,24,8"
|
||||
Padding="24,12,0,8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
@@ -1389,7 +1714,7 @@
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||
VirtualizingPanel.ScrollUnit="Pixel"
|
||||
VirtualizingPanel.CacheLength="3"
|
||||
VirtualizingPanel.CacheLength="1,2"
|
||||
VirtualizingPanel.CacheLengthUnit="Page"
|
||||
UseLayoutRounding="True">
|
||||
<ListBox.ItemsPanel>
|
||||
@@ -1465,6 +1790,27 @@
|
||||
VerticalAlignment="Stretch"
|
||||
MaxWidth="960"
|
||||
Margin="24,16,24,16">
|
||||
<!-- 마스코트 캐릭터: EmptyState Grid 위를 자유롭게 돌아다니는 오버레이.
|
||||
배경 없음(Transparent), 텍스트 위에 겹쳐서 표시. -->
|
||||
<Canvas x:Name="MascotCanvas"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
Visibility="Collapsed" ClipToBounds="False"
|
||||
IsHitTestVisible="False">
|
||||
<Image x:Name="MascotImage" Width="300" Height="300"
|
||||
Canvas.Left="0" Canvas.Top="20"
|
||||
RenderTransformOrigin="0.5,1.0"
|
||||
RenderOptions.BitmapScalingMode="NearestNeighbor"
|
||||
Stretch="Uniform" Opacity="0.92">
|
||||
<Image.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform x:Name="MascotScale" ScaleX="1" ScaleY="1"/>
|
||||
<RotateTransform x:Name="MascotRotate" Angle="0"/>
|
||||
<TranslateTransform x:Name="MascotTranslate" X="0" Y="0"/>
|
||||
</TransformGroup>
|
||||
</Image.RenderTransform>
|
||||
</Image>
|
||||
</Canvas>
|
||||
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,8,0,8">
|
||||
@@ -1496,7 +1842,8 @@
|
||||
Padding="0,4,0,8">
|
||||
<!-- 대화 주제 버튼 (프리셋에서 동적 생성) -->
|
||||
<WrapPanel x:Name="TopicButtonPanel" HorizontalAlignment="Center"
|
||||
Margin="0,0,0,8"/>
|
||||
Margin="0,0,0,8"
|
||||
Background="Transparent"/>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -1786,11 +2133,24 @@
|
||||
VerticalAlignment="Bottom">
|
||||
<StackPanel HorizontalAlignment="Stretch">
|
||||
|
||||
<!-- ── 펄스 닷 애니메이션 (AI 처리 중) ── -->
|
||||
<Border x:Name="PulseDotBar"
|
||||
Visibility="Collapsed"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="6,0,0,10">
|
||||
<!-- ── 스트리밍 상태 행: 좌측 펄스 닷 + 우측 경과 시간/토큰 ── -->
|
||||
<Grid Margin="0,0,0,4">
|
||||
<TextBlock x:Name="StreamMetricsLabel"
|
||||
Visibility="Collapsed"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,4,0"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
Opacity="0.7"
|
||||
Text="0:00 · ↓ 0 tokens"/>
|
||||
|
||||
<!-- ── 펄스 닷 애니메이션 (AI 처리 중) ── -->
|
||||
<Border x:Name="PulseDotBar"
|
||||
Visibility="Collapsed"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Margin="6,0,0,0">
|
||||
<StackPanel>
|
||||
<!-- 레거시 펄스 점 (코드비하인드 참조용, 화면에 표시 안 함) -->
|
||||
<Ellipse x:Name="PulseDot1" Width="7" Height="7" Fill="{DynamicResource AccentColor}" Opacity="0.3" Visibility="Collapsed"/>
|
||||
@@ -1864,6 +2224,7 @@
|
||||
Text=""/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="CodeRepoSummaryBar"
|
||||
Visibility="Collapsed"
|
||||
@@ -2080,6 +2441,24 @@
|
||||
<StackPanel x:Name="DraftQueuePanel"
|
||||
Visibility="Collapsed"
|
||||
Margin="0,0,0,6"/>
|
||||
<Border x:Name="FileMentionSuggestionCard"
|
||||
Visibility="Collapsed"
|
||||
Background="{DynamicResource HintBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="10,8"
|
||||
Margin="0,0,0,6">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="FileMentionSuggestionTitle"
|
||||
FontSize="11.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
Text="파일 후보"/>
|
||||
<WrapPanel x:Name="FileMentionSuggestionChipPanel"
|
||||
Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- 무지개 글로우 + 입력 영역 (겹침 레이아웃) -->
|
||||
<Grid>
|
||||
<!-- 무지개 글로우 외부 테두리 (메시지 전송 시 애니메이션) -->
|
||||
@@ -2422,7 +2801,7 @@
|
||||
PreviewKeyDown="InputBox_PreviewKeyDown"
|
||||
TextChanged="InputBox_TextChanged"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="40" MaxHeight="148"
|
||||
MinHeight="40" MaxHeight="160"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
<!-- 워터마크 (프롬프트 카드 안내문구) -->
|
||||
<TextBlock x:Name="InputWatermark"
|
||||
@@ -2529,17 +2908,15 @@
|
||||
|
||||
<!-- ── 폴더 바 + 권한 메뉴 (입력 하단, Cowork/Code 탭 전용) ── -->
|
||||
<Border x:Name="FolderBar" Visibility="Collapsed"
|
||||
Margin="4,4,4,0"
|
||||
MinHeight="32"
|
||||
Margin="0,2,0,0"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/> <!-- 0: 폴더 아이콘 -->
|
||||
<ColumnDefinition Width="*"/> <!-- 1: 폴더 경로 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 0: 폴더 -->
|
||||
<ColumnDefinition Width="*"/> <!-- 1: 여백 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 2: 구분선 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 3: 포맷/디자인 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 4: 구분선 -->
|
||||
@@ -2548,22 +2925,39 @@
|
||||
<ColumnDefinition Width="Auto"/> <!-- 7: 메모리 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 8: 구분선 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 9: 권한 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 10: 구분선 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 11: git -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 10: 계획 버튼 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 11: 구분선 -->
|
||||
<ColumnDefinition Width="Auto"/> <!-- 12: git -->
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 폴더 아이콘 -->
|
||||
<TextBlock Grid.Column="0" Text="" FontFamily="Segoe MDL2 Assets" FontSize="14"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
VerticalAlignment="Center" Margin="6,0,4,0"/>
|
||||
<!-- (A) 폴더 미선택 시: 작은 칩 버튼 -->
|
||||
<Border x:Name="BtnFolderSelect" Grid.Column="0"
|
||||
CornerRadius="4" Margin="4,1,0,1"
|
||||
Padding="6,2,8,2"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource SeparatorColor}"
|
||||
Background="Transparent"
|
||||
Cursor="Hand" Visibility="Visible"
|
||||
MouseLeftButtonUp="BtnFolderSelect_Click">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="폴더 선택" FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 폴더 경로 (클릭 시 최근 폴더 메뉴) -->
|
||||
<TextBlock x:Name="FolderPathLabel" Grid.Column="1"
|
||||
Text="폴더를 선택하세요" FontSize="13"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
<!-- (B) 폴더 선택 후: 경로 텍스트 -->
|
||||
<TextBlock x:Name="FolderPathLabel" Grid.Column="0"
|
||||
Text="{Binding WorkFolderDisplay, FallbackValue=''}"
|
||||
FontSize="11" Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
Cursor="Hand" MouseLeftButtonUp="FolderPathLabel_Click"
|
||||
ToolTip="클릭하여 폴더 변경"/>
|
||||
MaxWidth="300" Cursor="Hand" Margin="6,0,0,0"
|
||||
Visibility="Collapsed"
|
||||
MouseLeftButtonUp="FolderPathLabel_CopyClick"
|
||||
ToolTip="클릭하면 경로가 복사됩니다"/>
|
||||
|
||||
<!-- 구분선 -->
|
||||
<Border Grid.Column="2" Width="1" Height="18" Margin="8,0,4,0"
|
||||
@@ -2602,24 +2996,42 @@
|
||||
|
||||
<!-- 권한 메뉴 -->
|
||||
<Button x:Name="BtnPermission" Grid.Column="9" Style="{StaticResource FooterChipBtn}"
|
||||
Padding="10,5" Click="BtnPermission_Click" ToolTip="파일 접근 권한"
|
||||
BorderThickness="0">
|
||||
Padding="6,4" Click="BtnPermission_Click" ToolTip="파일 접근 권한"
|
||||
BorderThickness="0" Background="Transparent">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="PermissionIcon" Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"
|
||||
<TextBlock x:Name="PermissionIcon" Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock x:Name="PermissionLabel" Text="활용하지 않음" FontSize="12"
|
||||
<TextBlock x:Name="PermissionLabel" Text="활용하지 않음" FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="8"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" Margin="4,1,0,0" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border x:Name="GitBranchSeparator" Grid.Column="10" Width="1" Height="18" Margin="4,0"
|
||||
<!-- 계획 버튼 (권한 옆, 계획 존재 시만 표시) -->
|
||||
<Border x:Name="BtnPlanViewer" Grid.Column="10" Visibility="Collapsed"
|
||||
CornerRadius="4" Padding="6,3,8,3" Margin="4,0,0,0"
|
||||
Background="{DynamicResource HintBackground}"
|
||||
BorderBrush="#10B981" BorderThickness="1"
|
||||
Cursor="Hand" ToolTip="실행 계획 보기"
|
||||
MouseLeftButtonUp="BtnPlanViewer_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
||||
Foreground="#10B981" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock x:Name="PlanViewerLabel" Text="계획" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="#10B981" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="GitBranchSeparator" Grid.Column="11" Width="1" Height="18" Margin="4,0"
|
||||
Visibility="Collapsed"
|
||||
Background="{DynamicResource SeparatorColor}"/>
|
||||
|
||||
<Button x:Name="BtnGitBranch"
|
||||
Grid.Column="11"
|
||||
Grid.Column="12"
|
||||
Style="{StaticResource FooterChipBtn}"
|
||||
Padding="10,5"
|
||||
Margin="2,0,0,0"
|
||||
@@ -2634,7 +3046,7 @@
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,4,0"/>
|
||||
<TextBlock x:Name="GitBranchLabel"
|
||||
Text="main"
|
||||
Text="{Binding GitBranchName, FallbackValue='main'}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -2796,11 +3208,11 @@
|
||||
Click="BtnToggleExecutionLog_Click"
|
||||
ToolTip="실행 로그 표시/숨기기">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="ExecutionLogIcon" Text=""
|
||||
<TextBlock x:Name="ExecutionLogIcon" Text=""
|
||||
FontFamily="Segoe MDL2 Assets" FontSize="8"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center" Margin="0,0,2,0"/>
|
||||
<TextBlock x:Name="ExecutionLogLabel" Text="실행 로그 0"
|
||||
<TextBlock x:Name="ExecutionLogLabel" Text="실행이력 · 상세"
|
||||
FontSize="8.25"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -2829,20 +3241,7 @@
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- 계획 버튼 (실행 계획 존재 시 표시) -->
|
||||
<Border x:Name="BtnPlanViewer" Visibility="Collapsed"
|
||||
CornerRadius="4" Padding="3.5,1" Margin="0,0,5,0"
|
||||
Background="{DynamicResource HintBackground}"
|
||||
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
|
||||
Cursor="Hand" ToolTip="실행 계획 보기"
|
||||
MouseLeftButtonUp="BtnPlanViewer_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="9"
|
||||
Foreground="#10B981" VerticalAlignment="Center" Margin="0,0,3,0"/>
|
||||
<TextBlock x:Name="PlanViewerLabel" Text="계획" FontSize="9" FontWeight="SemiBold"
|
||||
Foreground="#10B981" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- 계획 버튼은 하단 툴바(BtnPermission 옆)로 이동됨 -->
|
||||
<TextBlock x:Name="StatusElapsed" Text="" FontSize="8.25"
|
||||
Visibility="Collapsed"
|
||||
Foreground="{DynamicResource SecondaryText}"
|
||||
@@ -3276,6 +3675,7 @@
|
||||
Margin="0,0,0,8"/>
|
||||
<TextBlock x:Name="OverlaySelectedServiceText"
|
||||
Grid.Column="1"
|
||||
Text="{Binding ServiceLabel, FallbackValue=''}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"
|
||||
Margin="0,0,0,8"/>
|
||||
@@ -3286,6 +3686,7 @@
|
||||
<TextBlock x:Name="OverlaySelectedModelText"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Text="{Binding ModelLabel, FallbackValue=''}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
</Grid>
|
||||
@@ -3791,6 +4192,148 @@
|
||||
Unchecked="ChkOverlayFeatureToggle_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,6,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="아이콘 글로우 강도"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="채팅 본문 런처 아이콘의 글로우(발광) 효과 강도를 조절합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="CmbOverlayChatIconGlow"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Width="100"
|
||||
Style="{StaticResource OverlayComboBox}"
|
||||
SelectionChanged="CmbOverlayChatIconGlow_SelectionChanged">
|
||||
<ComboBoxItem Content="강하게" Tag="strong"/>
|
||||
<ComboBoxItem Content="적당히" Tag="medium"/>
|
||||
<ComboBoxItem Content="약하게" Tag="weak"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- ── 아이콘 애니메이션 설정 ── -->
|
||||
<Border x:Name="OverlaySectionIconEffects"
|
||||
Background="Transparent"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="0,1,0,0"
|
||||
CornerRadius="0"
|
||||
Padding="0,12,0,0"
|
||||
Margin="0,0,0,12">
|
||||
<StackPanel>
|
||||
<TextBlock Text="아이콘 애니메이션"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="채팅 본문의 런처 아이콘 애니메이션 효과를 조정합니다."
|
||||
Margin="0,4,0,10"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="랜덤 애니메이션"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="다양한 애니메이션 효과를 랜덤으로 적용합니다. 끄면 숨쉬기 효과만 사용합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="ChkOverlayEnableChatIconRandomAnim"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayFeatureToggle_Changed"
|
||||
Unchecked="ChkOverlayFeatureToggle_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- ── 계획 뷰어 스타일 ── -->
|
||||
<Border x:Name="OverlaySectionPlanViewer"
|
||||
Background="Transparent"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="0,1,0,0"
|
||||
CornerRadius="0"
|
||||
Padding="0,12,0,0"
|
||||
Margin="0,0,0,12">
|
||||
<StackPanel>
|
||||
<TextBlock Text="계획 뷰어"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="문서 계획 승인 시 표시되는 계획 뷰어 창의 스타일을 선택합니다."
|
||||
Margin="0,4,0,10"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="새 계획 뷰어 (V2)"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="좌측 사이드바 네비게이션 + 우측 마크다운 콘텐츠 레이아웃. 끄면 기존 리스트형 뷰어를 사용합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="ChkOverlayEnableNewPlanViewer"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayFeatureToggle_Changed"
|
||||
Unchecked="ChkOverlayFeatureToggle_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="새 채팅 렌더링 (V2)"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="Claude Code 스타일 상세 실행 이력. 도구 호출·결과가 접힘/펼침 카드로 표시됩니다. 끄면 기존 버블형 렌더링을 사용합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="ChkOverlayEnableNewChatRendering"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayFeatureToggle_Changed"
|
||||
Unchecked="ChkOverlayFeatureToggle_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border x:Name="OverlayToggleImageInput" Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
@@ -4363,6 +4906,7 @@
|
||||
MinWidth="160"
|
||||
Style="{StaticResource OverlayComboBox}"
|
||||
SelectionChanged="CmbOverlayAgentLogLevel_SelectionChanged">
|
||||
<ComboBoxItem Content="숨김" Tag="hidden"/>
|
||||
<ComboBoxItem Content="간략" Tag="simple"/>
|
||||
<ComboBoxItem Content="상세" Tag="detailed"/>
|
||||
<ComboBoxItem Content="디버그" Tag="debug"/>
|
||||
@@ -4887,6 +5431,36 @@
|
||||
Unchecked="ChkOverlayFeatureToggle_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- 코워크 완료 후 문서 자동 처리 -->
|
||||
<Border x:Name="OverlayToggleCoworkOnComplete" Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="Cowork 완료 후 문서 처리"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="작업 완료 시 생성된 문서를 자동으로 열거나 미리보기합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="CmbOverlayCoworkOnComplete"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Width="140"
|
||||
Style="{StaticResource OverlayComboBox}"
|
||||
SelectionChanged="CmbOverlayCoworkOnComplete_SelectionChanged">
|
||||
<ComboBoxItem Content="아무것도 안하기" Tag="none" IsSelected="True"/>
|
||||
<ComboBoxItem Content="문서 실행" Tag="open"/>
|
||||
<ComboBoxItem Content="미리보기 뷰어" Tag="preview"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border x:Name="OverlayToggleCodeVerification" Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -5239,6 +5813,57 @@
|
||||
Unchecked="ChkOverlayFeatureToggle_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border x:Name="OverlayToggleMascotCharacter" Style="{StaticResource OverlayAdvancedToggleRowStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="캐릭터 출동"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryText}"/>
|
||||
<Border Style="{StaticResource OverlayHelpBadge}">
|
||||
<TextBlock Text="?"
|
||||
FontSize="10"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
<Border.ToolTip>
|
||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Foreground="White"
|
||||
FontSize="12"
|
||||
LineHeight="18"
|
||||
MaxWidth="280">코드 탭의 빈 화면에 마스코트 캐릭터 GIF 애니메이션을 표시합니다. 출동 수가 많을수록 메모리를 많이 사용합니다.
|
||||
한명: ~150MB, 적게(3): ~450MB, 중간(6): ~900MB, 전부: ~1.5GB</TextBlock>
|
||||
</ToolTip>
|
||||
</Border.ToolTip>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Text="코드 탭 빈 화면에 마스코트 캐릭터를 표시합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<ComboBox x:Name="CmbOverlayMascotLevel"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
MinWidth="120"
|
||||
Style="{StaticResource OverlayComboBox}"
|
||||
SelectionChanged="CmbOverlayMascotLevel_SelectionChanged">
|
||||
<ComboBoxItem Content="출동 안하기" Tag="none"/>
|
||||
<ComboBoxItem Content="한명만 출동" Tag="one"/>
|
||||
<ComboBoxItem Content="적게 출동" Tag="few"/>
|
||||
<ComboBoxItem Content="중간 출동" Tag="mid"/>
|
||||
<ComboBoxItem Content="전부 출동" Tag="all"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Border x:Name="OverlayToolsInfoPanel"
|
||||
Visibility="Collapsed"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
18
src/AxCopilot/Views/IPlanViewerWindow.cs
Normal file
18
src/AxCopilot/Views/IPlanViewerWindow.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 계획 뷰어 창 공통 인터페이스.
|
||||
/// PlanViewerWindow(기존)과 PlanViewerWindowV2(새 사이드바 레이아웃) 모두 이 인터페이스를 구현합니다.
|
||||
/// </summary>
|
||||
internal interface IPlanViewerWindow
|
||||
{
|
||||
void LoadPlan(string planText, List<string> steps, TaskCompletionSource<string?> tcs);
|
||||
void LoadPlanPreview(string planText, List<string> steps);
|
||||
Task<string?> ShowPlanAsync(string planText, List<string> steps, TaskCompletionSource<string?> tcs);
|
||||
void SwitchToExecutionMode();
|
||||
void UpdateCurrentStep(int stepIndex);
|
||||
void MarkComplete();
|
||||
string PlanText { get; }
|
||||
List<string> Steps { get; }
|
||||
string? BuildApprovedDecisionPayload(string prefix);
|
||||
}
|
||||
@@ -332,7 +332,7 @@ public partial class LauncherWindow : Window
|
||||
|
||||
case 6: // 💫 바운스 등장 — 작아졌다가 탄력적으로 커짐 (PPT 바운스)
|
||||
{
|
||||
sb.RepeatBehavior = new RepeatBehavior(3); // 3회 반복 후 Completed → 다음 애니메이션
|
||||
sb.RepeatBehavior = new RepeatBehavior(1); // 1회 반복 후 Completed → 다음 애니메이션
|
||||
var bx = new DoubleAnimationUsingKeyFrames();
|
||||
bx.KeyFrames.Add(new LinearDoubleKeyFrame(0.7, KT(0)));
|
||||
bx.KeyFrames.Add(new EasingDoubleKeyFrame(1.15, KT(0.35), new BounceEase { Bounces = 2, Bounciness = 3 }));
|
||||
|
||||
@@ -41,7 +41,8 @@ internal sealed class PermissionRequestWindow : Window
|
||||
private PermissionRequestWindow(
|
||||
string toolName,
|
||||
string target,
|
||||
AgentLoopService.PermissionPromptPreview? preview)
|
||||
AgentLoopService.PermissionPromptPreview? preview,
|
||||
string? notice = null)
|
||||
{
|
||||
Width = 580;
|
||||
MinWidth = 520;
|
||||
@@ -172,6 +173,45 @@ internal sealed class PermissionRequestWindow : Window
|
||||
}
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(notice))
|
||||
{
|
||||
var noticeColor = Color.FromRgb(0xF5, 0x9E, 0x0B); // amber
|
||||
stack.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, noticeColor.R, noticeColor.G, noticeColor.B)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x60, noticeColor.R, noticeColor.G, noticeColor.B)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(10, 7, 10, 7),
|
||||
Margin = new Thickness(0, 0, 0, 10),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "\uE7BA",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 13,
|
||||
Foreground = new SolidColorBrush(noticeColor),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = notice,
|
||||
FontSize = 11.5,
|
||||
Foreground = new SolidColorBrush(noticeColor),
|
||||
FontWeight = FontWeights.Medium,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stack.Children.Add(BuildInfoRow("\uE943", "Tool", toolName, primary, secondary, itemBg));
|
||||
stack.Children.Add(BuildInfoRow("\uE8B7", "Target", target, primary, secondary, itemBg));
|
||||
|
||||
@@ -397,7 +437,7 @@ internal sealed class PermissionRequestWindow : Window
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
ToolTip = new TextBlock
|
||||
{
|
||||
Text = "???寃쎈줈 鍮좊Ⅸ ?묒뾽",
|
||||
Text = "높은 경로 비용 작업",
|
||||
Foreground = secondary,
|
||||
}
|
||||
};
|
||||
@@ -481,7 +521,7 @@ internal sealed class PermissionRequestWindow : Window
|
||||
{
|
||||
var summary = ClassifyCommandRisk(command);
|
||||
return BuildPreviewCard(
|
||||
"紐낅졊 誘몃━蹂닿린",
|
||||
"명령 미리보기",
|
||||
summary,
|
||||
command,
|
||||
primary,
|
||||
@@ -499,13 +539,13 @@ internal sealed class PermissionRequestWindow : Window
|
||||
Brush border,
|
||||
UiExpressionProfile uiProfile)
|
||||
{
|
||||
var summary = "??곸쓣 ?뺤씤?????놁뒿?덈떎";
|
||||
var summary = "내용을 확인할 수 없습니다";
|
||||
if (Uri.TryCreate(urlText, UriKind.Absolute, out var uri))
|
||||
summary = $"{uri.Scheme}://{uri.Host}";
|
||||
|
||||
return BuildPreviewCard(
|
||||
"?ㅽ듃?뚰겕 誘몃━蹂닿린",
|
||||
$"?묒냽 ??? {summary}",
|
||||
"네트워크 미리보기",
|
||||
$"접속 대상: {summary}",
|
||||
urlText,
|
||||
primary,
|
||||
secondary,
|
||||
@@ -532,7 +572,7 @@ internal sealed class PermissionRequestWindow : Window
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = "?뚯씪??李얠쓣 ???놁뒿?덈떎";
|
||||
var summary = "파일을 찾을 수 없습니다";
|
||||
var previewBody = fullPath;
|
||||
|
||||
if (File.Exists(fullPath))
|
||||
@@ -540,7 +580,7 @@ internal sealed class PermissionRequestWindow : Window
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(fullPath);
|
||||
summary = $"議댁옱??쨌 {FormatBytes(info.Length)} 쨌 ?섏젙 {info.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
summary = $"존재함 · {FormatBytes(info.Length)} · 수정 {info.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(fullPath);
|
||||
@@ -561,7 +601,7 @@ internal sealed class PermissionRequestWindow : Window
|
||||
}
|
||||
|
||||
return BuildPreviewCard(
|
||||
"?뚯씪 誘몃━蹂닿린",
|
||||
"파일 미리보기",
|
||||
summary,
|
||||
previewBody,
|
||||
primary,
|
||||
@@ -861,16 +901,16 @@ internal sealed class PermissionRequestWindow : Window
|
||||
{
|
||||
var text = command.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return "鍮?紐낅졊";
|
||||
return "비 명령";
|
||||
|
||||
var lower = text.ToLowerInvariant();
|
||||
if (lower.Contains("del ") || lower.Contains("remove-item") || lower.Contains("format "))
|
||||
return "??젣/?щ㎎ 媛?μ꽦???덈뒗 紐낅졊";
|
||||
return "삭제/리네임 가능성이 있는 명령";
|
||||
if (lower.Contains("git reset --hard") || lower.Contains("rm -rf"))
|
||||
return "怨좎쐞????젣 紐낅졊";
|
||||
return "고위험 삭제 명령";
|
||||
if (lower.Contains("curl ") || lower.Contains("invoke-webrequest") || lower.Contains("wget "))
|
||||
return "?ㅼ슫濡쒕뱶 ?먮뒗 ?먭꺽 ?묎렐???ы븿??紐낅졊";
|
||||
return "紐낅졊 ?ㅽ뻾 ?붿껌";
|
||||
return "다운로드 또는 외부 접근 가능성을 포함한 명령";
|
||||
return "명령 실행 요청";
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
@@ -1081,9 +1121,10 @@ internal sealed class PermissionRequestWindow : Window
|
||||
Window owner,
|
||||
string toolName,
|
||||
string target,
|
||||
AgentLoopService.PermissionPromptPreview? preview = null)
|
||||
AgentLoopService.PermissionPromptPreview? preview = null,
|
||||
string? notice = null)
|
||||
{
|
||||
var dialog = new PermissionRequestWindow(toolName, target, preview)
|
||||
var dialog = new PermissionRequestWindow(toolName, target, preview, notice)
|
||||
{
|
||||
Owner = owner,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace AxCopilot.Views;
|
||||
/// - 사방 가장자리 드래그 리사이즈
|
||||
/// - 항목 드래그로 순서 변경
|
||||
/// </summary>
|
||||
internal sealed class PlanViewerWindow : Window
|
||||
internal sealed class PlanViewerWindow : Window, IPlanViewerWindow
|
||||
{
|
||||
// ── Win32 Resize ──
|
||||
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
@@ -30,7 +30,7 @@ internal sealed class PlanViewerWindow : Window
|
||||
|
||||
private readonly StackPanel _stepsPanel;
|
||||
private readonly ScrollViewer _scrollViewer;
|
||||
private readonly StackPanel _btnPanel;
|
||||
private readonly WrapPanel _btnPanel;
|
||||
private readonly Border _statusBar;
|
||||
private readonly TextBlock _statusText;
|
||||
private readonly TextBlock _progressText;
|
||||
@@ -60,19 +60,21 @@ internal sealed class PlanViewerWindow : Window
|
||||
|
||||
_uiExpressionLevel = ResolveUiExpressionLevel();
|
||||
|
||||
Width = 640;
|
||||
Height = 520;
|
||||
MinWidth = 480;
|
||||
MinHeight = 360;
|
||||
WindowStartupLocation = owner == null
|
||||
? WindowStartupLocation.CenterScreen
|
||||
: WindowStartupLocation.CenterOwner;
|
||||
Width = 720;
|
||||
Height = 640;
|
||||
MinWidth = 520;
|
||||
MinHeight = 420;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual; // 채팅 영역 중앙에 수동 배치
|
||||
WindowStyle = WindowStyle.None;
|
||||
AllowsTransparency = true;
|
||||
Background = Brushes.Transparent;
|
||||
ResizeMode = ResizeMode.CanResize; // WndProc로 직접 처리
|
||||
ShowInTaskbar = false;
|
||||
|
||||
// 채팅 영역 기준 중앙 배치
|
||||
if (owner != null)
|
||||
PositionToChatArea(owner);
|
||||
|
||||
var bgBrush = TryFindResource("LauncherBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
@@ -199,7 +201,7 @@ internal sealed class PlanViewerWindow : Window
|
||||
mainGrid.Children.Add(_scrollViewer);
|
||||
|
||||
// ── 하단 버튼 패널 ──
|
||||
_btnPanel = new StackPanel
|
||||
_btnPanel = new WrapPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
@@ -252,6 +254,52 @@ internal sealed class PlanViewerWindow : Window
|
||||
// 창 이동 / 리사이즈
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>채팅 영역(사이드바 제외) 기준으로 창을 중앙 배치합니다.</summary>
|
||||
private void PositionToChatArea(Window owner)
|
||||
{
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// owner 좌표 기준으로 채팅 영역 산출
|
||||
// 사이드바는 보통 좌측 ~250px, 나머지가 채팅 영역
|
||||
double sidebarWidth = 250;
|
||||
|
||||
// XAML에서 SidebarColumn + IconBarColumn 너비를 동적으로 가져오기
|
||||
if (owner is AxCopilot.Views.ChatWindow chatWin)
|
||||
{
|
||||
try
|
||||
{
|
||||
sidebarWidth = chatWin.SidebarColumn.ActualWidth
|
||||
+ chatWin.IconBarColumn.ActualWidth;
|
||||
}
|
||||
catch { /* 폴백: 기본 250px */ }
|
||||
}
|
||||
|
||||
double ownerLeft = owner.Left;
|
||||
double ownerTop = owner.Top;
|
||||
double ownerWidth = owner.ActualWidth;
|
||||
double ownerHeight = owner.ActualHeight;
|
||||
|
||||
// 채팅 영역: 사이드바 이후 ~ 오른쪽 끝
|
||||
double chatAreaLeft = ownerLeft + sidebarWidth;
|
||||
double chatAreaWidth = ownerWidth - sidebarWidth;
|
||||
double chatAreaCenterX = chatAreaLeft + (chatAreaWidth - ActualWidth) / 2;
|
||||
double chatAreaCenterY = ownerTop + (ownerHeight - ActualHeight) / 2;
|
||||
|
||||
// 화면 밖으로 나가지 않도록 클램핑
|
||||
Left = Math.Max(0, chatAreaCenterX);
|
||||
Top = Math.Max(0, chatAreaCenterY);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 실패 시 owner 중앙 폴백
|
||||
Left = owner.Left + (owner.ActualWidth - ActualWidth) / 2;
|
||||
Top = owner.Top + (owner.ActualHeight - ActualHeight) / 2;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ClickCount == 2) return;
|
||||
@@ -403,54 +451,66 @@ internal sealed class PlanViewerWindow : Window
|
||||
|
||||
var canEdit = !_isExecuting && _currentStep < 0; // 승인 대기 중에만 편집/순서변경 가능
|
||||
|
||||
var summaryText = _uiExpressionLevel switch
|
||||
// ── 계획 메타 정보 추출 ──
|
||||
var planMeta = ExtractPlanMeta(_planText);
|
||||
var summaryText = _isExecuting
|
||||
? $"현재 단계를 기준으로 진행률을 표시합니다."
|
||||
: $"{_steps.Count}개 섹션의 문서 구조를 검토한 후 승인 또는 수정 요청을 선택하세요.";
|
||||
|
||||
var accentColor = ((SolidColorBrush)accentBrush).Color;
|
||||
var statusPanel = new StackPanel();
|
||||
statusPanel.Children.Add(new TextBlock
|
||||
{
|
||||
"simple" => _isExecuting
|
||||
? "진행률을 간단히 표시합니다."
|
||||
: $"{_steps.Count}단계 계획입니다. 핵심 단계만 확인하고 승인하세요.",
|
||||
"rich" => _isExecuting
|
||||
? "현재 단계를 기준으로 진행률을 표시합니다. 필요 시 단계를 펼쳐 세부 내용을 확인할 수 있습니다."
|
||||
: $"총 {_steps.Count}단계입니다. 단계별 내용을 열어 우선순위/의존성을 검토한 뒤 승인 또는 수정 요청을 선택하세요.",
|
||||
_ => _isExecuting
|
||||
? "현재 단계를 기준으로 진행률을 표시합니다."
|
||||
: $"총 {_steps.Count}단계입니다. 단계를 펼쳐 검토한 후 승인 또는 수정 요청을 선택하세요.",
|
||||
};
|
||||
Text = _isExecuting ? "계획 실행 중" : "계획 승인 대기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = accentBrush,
|
||||
});
|
||||
|
||||
// 메타 정보 표시 (토픽, 포맷, 분량)
|
||||
if (!string.IsNullOrWhiteSpace(planMeta.Topic))
|
||||
{
|
||||
statusPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = planMeta.Topic,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = primaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 6, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(planMeta.Details))
|
||||
{
|
||||
statusPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = planMeta.Details,
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 3, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
statusPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = summaryText,
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 3, 0, 0),
|
||||
});
|
||||
|
||||
_stepsPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x12,
|
||||
((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)),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x12, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(10, 8, 10, 8),
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = _isExecuting ? "계획 실행 중" : "계획 승인 대기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = accentBrush,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = summaryText,
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 3, 0, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
Child = statusPanel,
|
||||
});
|
||||
|
||||
for (int i = 0; i < _steps.Count; i++)
|
||||
@@ -866,39 +926,22 @@ internal sealed class PlanViewerWindow : Window
|
||||
var editLabel = _uiExpressionLevel == "simple" ? "수정" : "수정 피드백";
|
||||
var rejectLabel = _uiExpressionLevel == "simple" ? "취소" : "거부";
|
||||
|
||||
_btnPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x16, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(12, 8, 12, 8),
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "검토가 끝나면 바로 실행하거나 방향만 짧게 남길 수 있습니다.",
|
||||
FontSize = 11.5,
|
||||
Foreground = secondaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
}
|
||||
});
|
||||
var cancelBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
|
||||
var cancelBtn = CreateActionButton("\uE711", rejectLabel, cancelBrush, cancelBrush, false);
|
||||
cancelBtn.MouseLeftButtonUp += (_, _) => { _tcs?.TrySetResult("취소"); Hide(); };
|
||||
_btnPanel.Children.Add(cancelBtn);
|
||||
|
||||
var approveBtn = CreateActionButton("\uE73E", approveLabel, accentBrush, Brushes.White, true);
|
||||
approveBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
_tcs?.TrySetResult(null);
|
||||
SwitchToExecutionMode();
|
||||
Hide();
|
||||
};
|
||||
_btnPanel.Children.Add(approveBtn);
|
||||
|
||||
var editBtn = CreateActionButton("\uE70F", editLabel, accentBrush, accentBrush, false);
|
||||
editBtn.MouseLeftButtonUp += (_, _) => ShowEditInput();
|
||||
_btnPanel.Children.Add(editBtn);
|
||||
|
||||
var cancelBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
|
||||
var cancelBtn = CreateActionButton("\uE711", rejectLabel, cancelBrush, cancelBrush, false);
|
||||
cancelBtn.MouseLeftButtonUp += (_, _) => { _tcs?.TrySetResult("취소"); Hide(); };
|
||||
_btnPanel.Children.Add(cancelBtn);
|
||||
}
|
||||
|
||||
private void BuildExecutionButtons()
|
||||
@@ -1114,6 +1157,71 @@ internal sealed class PlanViewerWindow : Window
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 계획 메타 정보 추출
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private readonly record struct PlanMeta(string Topic, string Details);
|
||||
|
||||
private static PlanMeta ExtractPlanMeta(string planText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(planText))
|
||||
return new PlanMeta("", "");
|
||||
|
||||
string topic = "";
|
||||
var details = new List<string>();
|
||||
|
||||
// 1) JSON "title" 필드
|
||||
var titleMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"""title""\s*:\s*""([^""]+)""");
|
||||
if (titleMatch.Success)
|
||||
topic = titleMatch.Groups[1].Value.Trim();
|
||||
|
||||
// 2) Markdown # 최상위 헤딩 (## 아닌 단일 #)
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
{
|
||||
var h1Match = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"(?:^|\n)#\s+(.+?)(?:\n|$)");
|
||||
if (h1Match.Success && !h1Match.Groups[1].Value.TrimStart().StartsWith("#"))
|
||||
topic = h1Match.Groups[1].Value.Trim();
|
||||
}
|
||||
|
||||
// 3) HTML <h1> 태그
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
{
|
||||
var h1Html = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"<h1>([^<]+)</h1>");
|
||||
if (h1Html.Success)
|
||||
topic = h1Html.Groups[1].Value.Trim();
|
||||
}
|
||||
|
||||
// JSON "format" 필드
|
||||
var formatMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"""format""\s*:\s*""([^""]+)""");
|
||||
if (formatMatch.Success)
|
||||
details.Add(formatMatch.Groups[1].Value.Trim());
|
||||
|
||||
// JSON "pages" / "page_count" 필드
|
||||
var pagesMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"""(?:pages?|page_count)""\s*:\s*""?(\d+)""?");
|
||||
if (pagesMatch.Success)
|
||||
details.Add($"{pagesMatch.Groups[1].Value}페이지");
|
||||
|
||||
// JSON "word_count" / "words" 필드
|
||||
var wordsMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"""(?:word_count|words)""\s*:\s*""?(\d+)""?");
|
||||
if (wordsMatch.Success)
|
||||
details.Add($"약 {wordsMatch.Groups[1].Value}자");
|
||||
|
||||
// JSON "tone" 필드
|
||||
var toneMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
planText, @"""tone""\s*:\s*""([^""]+)""");
|
||||
if (toneMatch.Success)
|
||||
details.Add(toneMatch.Groups[1].Value.Trim());
|
||||
|
||||
return new PlanMeta(topic, string.Join(" · ", details));
|
||||
}
|
||||
|
||||
private static string ResolveUiExpressionLevel()
|
||||
{
|
||||
if (Application.Current is App app)
|
||||
|
||||
968
src/AxCopilot/Views/PlanViewerWindowV2.cs
Normal file
968
src/AxCopilot/Views/PlanViewerWindowV2.cs
Normal file
@@ -0,0 +1,968 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Effects;
|
||||
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 새로운 계획 뷰어 — 좌측 사이드바 네비게이션 + 우측 마크다운 콘텐츠 패널.
|
||||
/// MarkdownRenderer를 사용하여 풀 마크다운 렌더링 지원.
|
||||
/// </summary>
|
||||
internal sealed class PlanViewerWindowV2 : Window, IPlanViewerWindow
|
||||
{
|
||||
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
private const int WM_NCHITTEST = 0x0084;
|
||||
private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13,
|
||||
HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
|
||||
private const int ResizeGrip = 12;
|
||||
|
||||
private TaskCompletionSource<string?>? _tcs;
|
||||
private string _planText = "";
|
||||
private List<string> _steps = new();
|
||||
private int _currentStep = -1;
|
||||
private bool _isExecuting;
|
||||
|
||||
// UI 요소
|
||||
private readonly StackPanel _navPanel;
|
||||
private readonly ScrollViewer _navScrollViewer;
|
||||
private readonly ScrollViewer _contentScrollViewer;
|
||||
private readonly StackPanel _contentPanel;
|
||||
private readonly StackPanel _btnPanel;
|
||||
private readonly TextBlock _titleText;
|
||||
private readonly Border _progressBarOuter;
|
||||
private readonly Border _progressBarInner;
|
||||
private readonly Border _completionBanner;
|
||||
private readonly TextBlock _completionText;
|
||||
private readonly TextBlock _progressText;
|
||||
private readonly Border _feedbackPanel;
|
||||
private readonly TextBox _feedbackTextBox;
|
||||
|
||||
// 파싱된 섹션
|
||||
private readonly List<PlanSection> _sections = new();
|
||||
private int _selectedSectionIndex;
|
||||
|
||||
// 섹션별 콘텐츠 요소 (스크롤 대상)
|
||||
private readonly List<FrameworkElement> _sectionElements = new();
|
||||
|
||||
// 스텝 타이밍
|
||||
private readonly Dictionary<int, DateTime> _stepStartTimes = new();
|
||||
private readonly Dictionary<int, TimeSpan> _stepDurations = new();
|
||||
|
||||
// 테마 색상 (캐시)
|
||||
private Brush _primaryText = Brushes.White;
|
||||
private Brush _secondaryText = Brushes.Gray;
|
||||
private Brush _accentBrush = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
|
||||
private Brush _codeBg = new SolidColorBrush(Color.FromArgb(0x40, 0x00, 0x00, 0x00));
|
||||
|
||||
private sealed class PlanSection
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Body { get; set; } = "";
|
||||
public int Level { get; set; } // 1=h1, 2=h2, 3=h3
|
||||
}
|
||||
|
||||
public PlanViewerWindowV2(Window? owner = null)
|
||||
{
|
||||
if (owner != null)
|
||||
{
|
||||
Owner = owner;
|
||||
Resources.MergedDictionaries.Add(owner.Resources);
|
||||
}
|
||||
|
||||
Width = 960;
|
||||
Height = 700;
|
||||
MinWidth = 700;
|
||||
MinHeight = 480;
|
||||
WindowStartupLocation = owner != null
|
||||
? WindowStartupLocation.CenterOwner
|
||||
: WindowStartupLocation.CenterScreen;
|
||||
WindowStyle = WindowStyle.None;
|
||||
AllowsTransparency = true;
|
||||
Background = Brushes.Transparent;
|
||||
ResizeMode = ResizeMode.CanResize;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
if (owner != null)
|
||||
PositionToChatArea(owner);
|
||||
|
||||
var bgBrush = TryFindResource("LauncherBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1F, 0x2E));
|
||||
var sidebarBg = new SolidColorBrush(Color.FromArgb(0x30, 0x00, 0x00, 0x00));
|
||||
_primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
_secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
_accentBrush = TryFindResource("AccentColor") as Brush
|
||||
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
_codeBg = new SolidColorBrush(Color.FromArgb(0x40, 0x00, 0x00, 0x00));
|
||||
|
||||
// ── Root Border ──
|
||||
var root = new Border
|
||||
{
|
||||
Background = bgBrush,
|
||||
CornerRadius = new CornerRadius(14),
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
Effect = new DropShadowEffect { BlurRadius = 20, ShadowDepth = 4, Opacity = 0.35, Color = Colors.Black },
|
||||
};
|
||||
|
||||
var outerGrid = new Grid();
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 0: 타이틀 바
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 1: 프로그레스 바
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 2: 완료 배너
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); // 3: 메인 (사이드바+콘텐츠)
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 4: 피드백 입력
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 5: 하단 버튼
|
||||
|
||||
// ── 타이틀 바 ──
|
||||
var titleBar = new Grid { Background = Brushes.Transparent, Margin = new Thickness(20, 14, 12, 0) };
|
||||
titleBar.MouseLeftButtonDown += (_, _) => { try { DragMove(); } catch { } };
|
||||
|
||||
_titleText = new TextBlock
|
||||
{
|
||||
Text = "실행 계획",
|
||||
FontSize = 15,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = _primaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Margin = new Thickness(0, 0, 40, 0),
|
||||
};
|
||||
titleBar.Children.Add(_titleText);
|
||||
|
||||
var closeBtn = new Border
|
||||
{
|
||||
Width = 28, Height = 28, CornerRadius = new CornerRadius(6),
|
||||
Background = Brushes.Transparent, Cursor = Cursors.Hand,
|
||||
HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "\u00D7", FontSize = 18, Foreground = _secondaryText,
|
||||
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
};
|
||||
closeBtn.MouseEnter += (s, _) => ((Border)s).Background = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0x44, 0x44));
|
||||
closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
|
||||
closeBtn.MouseLeftButtonUp += (_, _) => Hide();
|
||||
titleBar.Children.Add(closeBtn);
|
||||
Grid.SetRow(titleBar, 0);
|
||||
outerGrid.Children.Add(titleBar);
|
||||
|
||||
// ── 프로그레스 바 ──
|
||||
_progressBarOuter = new Border
|
||||
{
|
||||
Height = 3,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)),
|
||||
Margin = new Thickness(20, 10, 20, 0),
|
||||
CornerRadius = new CornerRadius(2),
|
||||
Visibility = Visibility.Collapsed,
|
||||
};
|
||||
_progressBarInner = new Border
|
||||
{
|
||||
Height = 3,
|
||||
Background = new SolidColorBrush(Color.FromRgb(0x4F, 0xC3, 0xF7)),
|
||||
CornerRadius = new CornerRadius(2),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Width = 0,
|
||||
};
|
||||
_progressBarOuter.Child = _progressBarInner;
|
||||
_progressText = new TextBlock
|
||||
{
|
||||
FontSize = 11, Foreground = _secondaryText,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Margin = new Thickness(0, 0, 22, 0),
|
||||
Visibility = Visibility.Collapsed,
|
||||
};
|
||||
// 프로그레스를 패널에 넣기
|
||||
var progressPanel = new StackPanel();
|
||||
progressPanel.Children.Add(_progressBarOuter);
|
||||
progressPanel.Children.Add(_progressText);
|
||||
Grid.SetRow(progressPanel, 1);
|
||||
outerGrid.Children.Add(progressPanel);
|
||||
|
||||
// ── 완료 배너 ──
|
||||
_completionBanner = new Border
|
||||
{
|
||||
Visibility = Visibility.Collapsed,
|
||||
Margin = new Thickness(20, 8, 20, 0),
|
||||
Padding = new Thickness(12, 8, 12, 8),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0x10, 0xB9, 0x81)),
|
||||
};
|
||||
_completionText = new TextBlock
|
||||
{
|
||||
Text = "✓ 계획 실행 완료",
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)),
|
||||
};
|
||||
_completionBanner.Child = _completionText;
|
||||
Grid.SetRow(_completionBanner, 2);
|
||||
outerGrid.Children.Add(_completionBanner);
|
||||
|
||||
// ── 메인 영역: 좌측 사이드바 + 우측 콘텐츠 ──
|
||||
var mainGrid = new Grid { Margin = new Thickness(0, 8, 0, 0) };
|
||||
mainGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(220), MinWidth = 150, MaxWidth = 400 });
|
||||
mainGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // GridSplitter
|
||||
mainGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// 좌측 사이드바
|
||||
var sidebarBorder = new Border
|
||||
{
|
||||
Background = sidebarBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(0, 0, 1, 0),
|
||||
Padding = new Thickness(8, 4, 8, 8),
|
||||
};
|
||||
_navScrollViewer = new ScrollViewer
|
||||
{
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
};
|
||||
_navPanel = new StackPanel();
|
||||
_navScrollViewer.Content = _navPanel;
|
||||
sidebarBorder.Child = _navScrollViewer;
|
||||
Grid.SetColumn(sidebarBorder, 0);
|
||||
mainGrid.Children.Add(sidebarBorder);
|
||||
|
||||
// GridSplitter (사이드바 폭 조절)
|
||||
var splitter = new GridSplitter
|
||||
{
|
||||
Width = 4,
|
||||
Background = Brushes.Transparent,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
Cursor = Cursors.SizeWE,
|
||||
};
|
||||
Grid.SetColumn(splitter, 1);
|
||||
mainGrid.Children.Add(splitter);
|
||||
|
||||
// 우측 콘텐츠
|
||||
_contentScrollViewer = new ScrollViewer
|
||||
{
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
Padding = new Thickness(28, 12, 28, 20),
|
||||
};
|
||||
_contentPanel = new StackPanel();
|
||||
_contentScrollViewer.Content = _contentPanel;
|
||||
Grid.SetColumn(_contentScrollViewer, 2);
|
||||
mainGrid.Children.Add(_contentScrollViewer);
|
||||
|
||||
Grid.SetRow(mainGrid, 3);
|
||||
outerGrid.Children.Add(mainGrid);
|
||||
|
||||
// ── 피드백 입력 패널 (수정 요청 시 표시) ──
|
||||
_feedbackPanel = new Border
|
||||
{
|
||||
Visibility = Visibility.Collapsed,
|
||||
Margin = new Thickness(20, 8, 20, 0),
|
||||
Padding = new Thickness(12, 8, 12, 8),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0xB7, 0x4D)),
|
||||
BorderThickness = new Thickness(1),
|
||||
};
|
||||
var feedbackSp = new DockPanel();
|
||||
feedbackSp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "수정 사항을 입력하세요:",
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xB7, 0x4D)),
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
});
|
||||
DockPanel.SetDock(feedbackSp.Children[0], Dock.Top);
|
||||
var feedbackRow = new DockPanel();
|
||||
var sendBtn = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(6),
|
||||
Background = new SolidColorBrush(Color.FromRgb(0xFF, 0xB7, 0x4D)),
|
||||
Padding = new Thickness(14, 5, 14, 5),
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
Child = new TextBlock { Text = "전송", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = new SolidColorBrush(Color.FromRgb(0x1E, 0x1F, 0x2E)) },
|
||||
};
|
||||
DockPanel.SetDock(sendBtn, Dock.Right);
|
||||
feedbackRow.Children.Add(sendBtn);
|
||||
_feedbackTextBox = new TextBox
|
||||
{
|
||||
FontSize = 13,
|
||||
Foreground = _primaryText,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
|
||||
BorderThickness = new Thickness(1),
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
AcceptsReturn = false,
|
||||
CaretBrush = _primaryText,
|
||||
};
|
||||
_feedbackTextBox.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter && !string.IsNullOrWhiteSpace(_feedbackTextBox.Text))
|
||||
SubmitFeedback();
|
||||
else if (e.Key == Key.Escape)
|
||||
HideFeedbackPanel();
|
||||
};
|
||||
sendBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_feedbackTextBox.Text))
|
||||
SubmitFeedback();
|
||||
};
|
||||
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||||
sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1;
|
||||
feedbackRow.Children.Add(_feedbackTextBox);
|
||||
feedbackSp.Children.Add(feedbackRow);
|
||||
_feedbackPanel.Child = feedbackSp;
|
||||
Grid.SetRow(_feedbackPanel, 4);
|
||||
outerGrid.Children.Add(_feedbackPanel);
|
||||
|
||||
// ── 하단 버튼 ──
|
||||
_btnPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Margin = new Thickness(20, 10, 20, 14),
|
||||
};
|
||||
Grid.SetRow(_btnPanel, 5);
|
||||
outerGrid.Children.Add(_btnPanel);
|
||||
|
||||
root.Child = outerGrid;
|
||||
Content = root;
|
||||
|
||||
// 키보드 단축키
|
||||
KeyDown += OnWindowKeyDown;
|
||||
|
||||
SourceInitialized += (_, _) =>
|
||||
{
|
||||
var hwnd = new WindowInteropHelper(this).Handle;
|
||||
HwndSource.FromHwnd(hwnd)?.AddHook(WndProc);
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 공개 API — IPlanViewerWindow
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
public void LoadPlan(string planText, List<string> steps, TaskCompletionSource<string?> tcs)
|
||||
{
|
||||
_planText = planText;
|
||||
_steps = steps;
|
||||
_tcs = tcs;
|
||||
_currentStep = -1;
|
||||
_isExecuting = false;
|
||||
_stepStartTimes.Clear();
|
||||
_stepDurations.Clear();
|
||||
ParsePlanText(planText, steps);
|
||||
_selectedSectionIndex = 0;
|
||||
RenderAllContent();
|
||||
RenderNavigation();
|
||||
BuildApprovalButtons();
|
||||
_progressBarOuter.Visibility = Visibility.Collapsed;
|
||||
_progressText.Visibility = Visibility.Collapsed;
|
||||
_completionBanner.Visibility = Visibility.Collapsed;
|
||||
HideFeedbackPanel();
|
||||
}
|
||||
|
||||
public void LoadPlanPreview(string planText, List<string> steps)
|
||||
{
|
||||
_planText = planText;
|
||||
_steps = steps;
|
||||
_tcs = null;
|
||||
_currentStep = -1;
|
||||
_isExecuting = false;
|
||||
_stepStartTimes.Clear();
|
||||
_stepDurations.Clear();
|
||||
ParsePlanText(planText, steps);
|
||||
_selectedSectionIndex = 0;
|
||||
RenderAllContent();
|
||||
RenderNavigation();
|
||||
BuildCloseButton();
|
||||
_progressBarOuter.Visibility = Visibility.Collapsed;
|
||||
_progressText.Visibility = Visibility.Collapsed;
|
||||
_completionBanner.Visibility = Visibility.Collapsed;
|
||||
HideFeedbackPanel();
|
||||
}
|
||||
|
||||
public Task<string?> ShowPlanAsync(string planText, List<string> steps, TaskCompletionSource<string?> tcs)
|
||||
{
|
||||
LoadPlan(planText, steps, tcs);
|
||||
Show();
|
||||
Activate();
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public void SwitchToExecutionMode()
|
||||
{
|
||||
_isExecuting = true;
|
||||
_progressBarOuter.Visibility = Visibility.Visible;
|
||||
_progressText.Visibility = Visibility.Visible;
|
||||
_progressText.Text = $"0 / {_steps.Count}";
|
||||
_completionBanner.Visibility = Visibility.Collapsed;
|
||||
UpdateProgressBar(0);
|
||||
BuildExecutionButtons();
|
||||
if (_steps.Count > 0)
|
||||
_stepStartTimes[0] = DateTime.Now;
|
||||
}
|
||||
|
||||
public void UpdateCurrentStep(int stepIndex)
|
||||
{
|
||||
if (stepIndex < 0 || stepIndex >= _steps.Count) return;
|
||||
|
||||
// 이전 스텝 타이밍 기록
|
||||
if (_currentStep >= 0 && _stepStartTimes.ContainsKey(_currentStep))
|
||||
{
|
||||
_stepDurations[_currentStep] = DateTime.Now - _stepStartTimes[_currentStep];
|
||||
}
|
||||
|
||||
_currentStep = stepIndex;
|
||||
_progressText.Text = $"{stepIndex + 1} / {_steps.Count}";
|
||||
UpdateProgressBar(stepIndex + 1);
|
||||
|
||||
// 새 스텝 시작 시간
|
||||
if (!_stepStartTimes.ContainsKey(stepIndex))
|
||||
_stepStartTimes[stepIndex] = DateTime.Now;
|
||||
|
||||
// 해당 단계에 대응하는 섹션으로 네비게이션
|
||||
if (stepIndex < _sections.Count)
|
||||
{
|
||||
_selectedSectionIndex = Math.Min(stepIndex + 1, _sections.Count - 1);
|
||||
RenderNavigation();
|
||||
ScrollToSection(_selectedSectionIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkComplete()
|
||||
{
|
||||
// 마지막 스텝 타이밍
|
||||
if (_currentStep >= 0 && _stepStartTimes.ContainsKey(_currentStep))
|
||||
_stepDurations[_currentStep] = DateTime.Now - _stepStartTimes[_currentStep];
|
||||
|
||||
_isExecuting = false;
|
||||
_currentStep = _steps.Count;
|
||||
_progressText.Text = $"{_steps.Count} / {_steps.Count}";
|
||||
UpdateProgressBar(_steps.Count);
|
||||
|
||||
// 프로그레스 바 → 초록색
|
||||
_progressBarInner.Background = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A));
|
||||
|
||||
// 완료 배너 표시
|
||||
_completionBanner.Visibility = Visibility.Visible;
|
||||
_titleText.Text = $"✓ 실행 계획 ({_steps.Count}/{_steps.Count})";
|
||||
|
||||
RenderNavigation();
|
||||
BuildCloseButton();
|
||||
}
|
||||
|
||||
public string PlanText => _planText;
|
||||
public List<string> Steps => _steps;
|
||||
|
||||
public string? BuildApprovedDecisionPayload(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix)) return null;
|
||||
var normalized = _steps.Select(s => s?.Trim() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
|
||||
if (normalized.Count == 0) return null;
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(prefix.Trim());
|
||||
foreach (var step in normalized) sb.AppendLine(step);
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 계획 텍스트 파싱 → 섹션 분리
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void ParsePlanText(string planText, List<string> steps)
|
||||
{
|
||||
_sections.Clear();
|
||||
|
||||
var lines = planText.Split('\n');
|
||||
var currentTitle = "";
|
||||
var currentBody = new StringBuilder();
|
||||
var currentLevel = 1;
|
||||
var hasSections = false;
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
|
||||
var headerMatch = Regex.Match(line, @"^(#{1,3})\s+(.+)$");
|
||||
if (headerMatch.Success)
|
||||
{
|
||||
hasSections = true;
|
||||
if (!string.IsNullOrWhiteSpace(currentTitle) || currentBody.Length > 0)
|
||||
{
|
||||
_sections.Add(new PlanSection
|
||||
{
|
||||
Title = string.IsNullOrWhiteSpace(currentTitle) ? "Context" : currentTitle,
|
||||
Body = currentBody.ToString().Trim(),
|
||||
Level = currentLevel,
|
||||
});
|
||||
}
|
||||
currentTitle = headerMatch.Groups[2].Value.Trim();
|
||||
currentLevel = headerMatch.Groups[1].Value.Length;
|
||||
currentBody.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
currentBody.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSections && (!string.IsNullOrWhiteSpace(currentTitle) || currentBody.Length > 0))
|
||||
{
|
||||
_sections.Add(new PlanSection
|
||||
{
|
||||
Title = string.IsNullOrWhiteSpace(currentTitle) ? "Context" : currentTitle,
|
||||
Body = currentBody.ToString().Trim(),
|
||||
Level = currentLevel,
|
||||
});
|
||||
}
|
||||
|
||||
if (_sections.Count == 0)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(planText))
|
||||
_sections.Add(new PlanSection { Title = "Context", Body = planText.Trim(), Level = 1 });
|
||||
|
||||
for (int i = 0; i < steps.Count; i++)
|
||||
{
|
||||
_sections.Add(new PlanSection
|
||||
{
|
||||
Title = $"{i + 1}. {steps[i]}",
|
||||
Body = steps[i],
|
||||
Level = 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (_sections.Count > 0)
|
||||
{
|
||||
var firstH1 = _sections.FirstOrDefault(s => s.Level == 1);
|
||||
if (firstH1 != null && !string.IsNullOrWhiteSpace(firstH1.Title))
|
||||
_titleText.Text = firstH1.Title;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 좌측 사이드바 네비게이션 렌더링
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void RenderNavigation()
|
||||
{
|
||||
_navPanel.Children.Clear();
|
||||
|
||||
var hoverBg = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
var selectedBg = new SolidColorBrush(Color.FromArgb(0x25, 0xFF, 0xFF, 0xFF));
|
||||
|
||||
for (int i = 0; i < _sections.Count; i++)
|
||||
{
|
||||
var section = _sections[i];
|
||||
var idx = i;
|
||||
var isSelected = i == _selectedSectionIndex;
|
||||
|
||||
var stepIdx = i - 1; // Context가 0이므로 단계는 1부터
|
||||
var isComplete = _isExecuting && stepIdx >= 0 && stepIdx < _currentStep;
|
||||
var isCurrent = _isExecuting && stepIdx == _currentStep;
|
||||
var isCompleteDone = !_isExecuting && _currentStep >= _steps.Count && stepIdx >= 0;
|
||||
|
||||
var leftMargin = section.Level >= 3 ? 16.0 : (section.Level >= 2 ? 4.0 : 0.0);
|
||||
|
||||
var navItem = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(6),
|
||||
Padding = new Thickness(8, 5, 8, 5),
|
||||
Margin = new Thickness(leftMargin, 1, 0, 1),
|
||||
Background = isSelected ? selectedBg : Brushes.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
BorderBrush = isSelected ? _accentBrush : Brushes.Transparent,
|
||||
BorderThickness = new Thickness(isSelected ? 2 : 0, 0, 0, 0),
|
||||
};
|
||||
|
||||
var navGrid = new Grid();
|
||||
navGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 아이콘
|
||||
navGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 제목
|
||||
navGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 타이밍
|
||||
|
||||
// 실행 상태 아이콘
|
||||
if ((_isExecuting || isCompleteDone) && stepIdx >= 0)
|
||||
{
|
||||
string icon;
|
||||
Brush iconFg;
|
||||
if (isComplete || isCompleteDone) { icon = "✓"; iconFg = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); }
|
||||
else if (isCurrent) { icon = "▶"; iconFg = _accentBrush; }
|
||||
else { icon = "○"; iconFg = _secondaryText; }
|
||||
|
||||
var iconTb = new TextBlock
|
||||
{
|
||||
Text = icon, FontSize = 10, Foreground = iconFg,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
Width = 14, TextAlignment = TextAlignment.Center,
|
||||
};
|
||||
|
||||
// 현재 스텝에 펄스 애니메이션
|
||||
if (isCurrent)
|
||||
{
|
||||
var pulse = new DoubleAnimation(1.0, 0.4, TimeSpan.FromMilliseconds(800))
|
||||
{
|
||||
AutoReverse = true,
|
||||
RepeatBehavior = RepeatBehavior.Forever,
|
||||
EasingFunction = new SineEase(),
|
||||
};
|
||||
iconTb.BeginAnimation(OpacityProperty, pulse);
|
||||
}
|
||||
|
||||
Grid.SetColumn(iconTb, 0);
|
||||
navGrid.Children.Add(iconTb);
|
||||
}
|
||||
|
||||
// 제목
|
||||
var displayTitle = section.Title;
|
||||
if (displayTitle.Length > 28)
|
||||
displayTitle = displayTitle[..26] + "…";
|
||||
|
||||
var titleTb = new TextBlock
|
||||
{
|
||||
Text = displayTitle,
|
||||
FontSize = section.Level <= 1 ? 12.5 : 12,
|
||||
FontWeight = isSelected ? FontWeights.SemiBold : (section.Level <= 1 ? FontWeights.SemiBold : FontWeights.Normal),
|
||||
Foreground = isSelected ? _primaryText : _secondaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
Grid.SetColumn(titleTb, 1);
|
||||
navGrid.Children.Add(titleTb);
|
||||
|
||||
// 소요 시간 표시
|
||||
if (stepIdx >= 0 && _stepDurations.TryGetValue(stepIdx, out var duration))
|
||||
{
|
||||
var durationStr = duration.TotalSeconds < 60
|
||||
? $"{duration.TotalSeconds:F1}s"
|
||||
: $"{duration.TotalMinutes:F1}m";
|
||||
var durationTb = new TextBlock
|
||||
{
|
||||
Text = durationStr,
|
||||
FontSize = 10,
|
||||
Foreground = _secondaryText,
|
||||
Opacity = 0.6,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(4, 0, 0, 0),
|
||||
};
|
||||
Grid.SetColumn(durationTb, 2);
|
||||
navGrid.Children.Add(durationTb);
|
||||
}
|
||||
|
||||
navItem.Child = navGrid;
|
||||
|
||||
navItem.MouseEnter += (s, _) => { if (idx != _selectedSectionIndex) ((Border)s).Background = hoverBg; };
|
||||
navItem.MouseLeave += (s, _) => { if (idx != _selectedSectionIndex) ((Border)s).Background = Brushes.Transparent; };
|
||||
navItem.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
_selectedSectionIndex = idx;
|
||||
RenderNavigation();
|
||||
ScrollToSection(idx);
|
||||
};
|
||||
|
||||
_navPanel.Children.Add(navItem);
|
||||
}
|
||||
|
||||
// 현재 스텝의 사이드바 항목이 보이도록 자동 스크롤
|
||||
if (_selectedSectionIndex >= 0 && _selectedSectionIndex < _navPanel.Children.Count)
|
||||
{
|
||||
var target = _navPanel.Children[_selectedSectionIndex] as FrameworkElement;
|
||||
target?.BringIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 우측 콘텐츠 영역 — 모든 섹션을 연속으로 렌더링
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void RenderAllContent()
|
||||
{
|
||||
_contentPanel.Children.Clear();
|
||||
_sectionElements.Clear();
|
||||
_contentScrollViewer.ScrollToTop();
|
||||
|
||||
for (int i = 0; i < _sections.Count; i++)
|
||||
{
|
||||
var section = _sections[i];
|
||||
|
||||
// 섹션 컨테이너
|
||||
var sectionContainer = new StackPanel { Margin = new Thickness(0, 0, 0, 8) };
|
||||
|
||||
// 섹션 제목
|
||||
sectionContainer.Children.Add(new TextBlock
|
||||
{
|
||||
Text = section.Title,
|
||||
FontSize = section.Level <= 1 ? 22 : (section.Level == 2 ? 18 : 15),
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = _primaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, i > 0 ? 16 : 0, 0, 8),
|
||||
});
|
||||
|
||||
// MarkdownRenderer로 본문 렌더링
|
||||
if (!string.IsNullOrWhiteSpace(section.Body))
|
||||
{
|
||||
var rendered = MarkdownRenderer.Render(section.Body, _primaryText, _secondaryText, _accentBrush, _codeBg);
|
||||
sectionContainer.Children.Add(rendered);
|
||||
}
|
||||
|
||||
// 섹션 사이 구분선 (마지막 제외)
|
||||
if (i < _sections.Count - 1)
|
||||
{
|
||||
sectionContainer.Children.Add(new Border
|
||||
{
|
||||
Height = 1,
|
||||
Background = _secondaryText,
|
||||
Opacity = 0.15,
|
||||
Margin = new Thickness(0, 16, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
_contentPanel.Children.Add(sectionContainer);
|
||||
_sectionElements.Add(sectionContainer);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScrollToSection(int sectionIndex)
|
||||
{
|
||||
if (sectionIndex >= 0 && sectionIndex < _sectionElements.Count)
|
||||
{
|
||||
_sectionElements[sectionIndex].BringIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 프로그레스 바
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void UpdateProgressBar(int completedSteps)
|
||||
{
|
||||
if (_steps.Count == 0) return;
|
||||
|
||||
var ratio = (double)completedSteps / _steps.Count;
|
||||
var targetWidth = _progressBarOuter.ActualWidth > 0
|
||||
? _progressBarOuter.ActualWidth * ratio
|
||||
: 0;
|
||||
|
||||
// 프로그레스 바 너비가 아직 레이아웃되지 않은 경우
|
||||
if (_progressBarOuter.ActualWidth <= 0)
|
||||
{
|
||||
_progressBarOuter.Loaded += (_, _) =>
|
||||
{
|
||||
var w = _progressBarOuter.ActualWidth * ratio;
|
||||
AnimateProgressBar(w);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
AnimateProgressBar(targetWidth);
|
||||
|
||||
// 타이틀에 진행 상태 포함
|
||||
if (_isExecuting)
|
||||
{
|
||||
var baseName = _sections.FirstOrDefault(s => s.Level == 1)?.Title ?? "실행 계획";
|
||||
_titleText.Text = $"{baseName} ({completedSteps}/{_steps.Count})";
|
||||
}
|
||||
}
|
||||
|
||||
private void AnimateProgressBar(double targetWidth)
|
||||
{
|
||||
var anim = new DoubleAnimation(targetWidth, TimeSpan.FromMilliseconds(350))
|
||||
{
|
||||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
|
||||
};
|
||||
_progressBarInner.BeginAnimation(WidthProperty, anim);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 하단 버튼 빌드 — 4가지 승인 옵션
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void BuildApprovalButtons()
|
||||
{
|
||||
_btnPanel.Children.Clear();
|
||||
|
||||
// 승인 (초록)
|
||||
var approveBtn = MakeActionButton("승인", new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)), Brushes.White);
|
||||
approveBtn.MouseLeftButtonUp += (_, _) => _tcs?.TrySetResult(null);
|
||||
_btnPanel.Children.Add(approveBtn);
|
||||
|
||||
// 수정 요청 (노랑)
|
||||
var editBtn = MakeActionButton("수정 요청",
|
||||
new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xB7, 0x4D)),
|
||||
new SolidColorBrush(Color.FromRgb(0xFF, 0xB7, 0x4D)),
|
||||
new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xB7, 0x4D)));
|
||||
editBtn.MouseLeftButtonUp += (_, _) => ShowFeedbackPanel();
|
||||
_btnPanel.Children.Add(editBtn);
|
||||
|
||||
// 건너뛰기 (회색)
|
||||
var skipBtn = MakeActionButton("건너뛰기", Brushes.Transparent, _secondaryText,
|
||||
new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)));
|
||||
skipBtn.MouseLeftButtonUp += (_, _) => _tcs?.TrySetResult("건너뛰기");
|
||||
_btnPanel.Children.Add(skipBtn);
|
||||
|
||||
// 중단 (빨강)
|
||||
var cancelBtn = MakeActionButton("중단",
|
||||
new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0x44, 0x44)),
|
||||
new SolidColorBrush(Color.FromRgb(0xFF, 0x66, 0x66)),
|
||||
new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x44, 0x44)));
|
||||
cancelBtn.MouseLeftButtonUp += (_, _) => _tcs?.TrySetResult("중단");
|
||||
_btnPanel.Children.Add(cancelBtn);
|
||||
}
|
||||
|
||||
private void BuildExecutionButtons()
|
||||
{
|
||||
_btnPanel.Children.Clear();
|
||||
var cancelBtn = MakeActionButton("중단",
|
||||
new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0x44, 0x44)),
|
||||
new SolidColorBrush(Color.FromRgb(0xFF, 0x66, 0x66)),
|
||||
new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x44, 0x44)));
|
||||
cancelBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
_tcs?.TrySetResult("중단");
|
||||
Hide();
|
||||
};
|
||||
_btnPanel.Children.Add(cancelBtn);
|
||||
}
|
||||
|
||||
private void BuildCloseButton()
|
||||
{
|
||||
_btnPanel.Children.Clear();
|
||||
var closeBtn = MakeActionButton("닫기", Brushes.Transparent, _secondaryText,
|
||||
new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)));
|
||||
closeBtn.MouseLeftButtonUp += (_, _) => Hide();
|
||||
_btnPanel.Children.Add(closeBtn);
|
||||
}
|
||||
|
||||
private static Border MakeActionButton(string label, Brush bg, Brush fg, Brush? borderBrush = null)
|
||||
{
|
||||
var btn = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = bg,
|
||||
BorderBrush = borderBrush ?? Brushes.Transparent,
|
||||
BorderThickness = new Thickness(borderBrush != null ? 1 : 0),
|
||||
Padding = new Thickness(18, 7, 18, 7),
|
||||
Margin = new Thickness(4, 0, 4, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = fg,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
},
|
||||
};
|
||||
btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||||
btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1;
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 피드백 입력 (수정 요청)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void ShowFeedbackPanel()
|
||||
{
|
||||
_feedbackPanel.Visibility = Visibility.Visible;
|
||||
_feedbackTextBox.Text = "";
|
||||
_feedbackTextBox.Focus();
|
||||
}
|
||||
|
||||
private void HideFeedbackPanel()
|
||||
{
|
||||
_feedbackPanel.Visibility = Visibility.Collapsed;
|
||||
_feedbackTextBox.Text = "";
|
||||
}
|
||||
|
||||
private void SubmitFeedback()
|
||||
{
|
||||
var text = _feedbackTextBox.Text.Trim();
|
||||
HideFeedbackPanel();
|
||||
_tcs?.TrySetResult(text);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 키보드 단축키
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void OnWindowKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
if (_feedbackPanel.Visibility == Visibility.Visible)
|
||||
HideFeedbackPanel();
|
||||
else
|
||||
Hide();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 창 위치 / 리사이즈
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private void PositionToChatArea(Window owner)
|
||||
{
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
double sidebarWidth = 250;
|
||||
if (owner is ChatWindow chatWin)
|
||||
{
|
||||
try { sidebarWidth = chatWin.SidebarColumn.ActualWidth + chatWin.IconBarColumn.ActualWidth; } catch { }
|
||||
}
|
||||
|
||||
double ownerLeft = owner.Left;
|
||||
double ownerTop = owner.Top;
|
||||
double ownerW = owner.ActualWidth;
|
||||
double ownerH = owner.ActualHeight;
|
||||
|
||||
double chatLeft = ownerLeft + sidebarWidth;
|
||||
double chatWidth = ownerW - sidebarWidth;
|
||||
|
||||
Left = chatLeft + (chatWidth - ActualWidth) / 2;
|
||||
Top = ownerTop + (ownerH - ActualHeight) / 2;
|
||||
|
||||
Left = Math.Max(ownerLeft, Left);
|
||||
Top = Math.Max(ownerTop, Top);
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == WM_NCHITTEST)
|
||||
{
|
||||
var pt = PointFromScreen(new Point(
|
||||
(short)(lParam.ToInt32() & 0xFFFF),
|
||||
(short)((lParam.ToInt32() >> 16) & 0xFFFF)));
|
||||
double w = ActualWidth, h = ActualHeight;
|
||||
bool left = pt.X < ResizeGrip, right = pt.X > w - ResizeGrip;
|
||||
bool top = pt.Y < ResizeGrip, bottom = pt.Y > h - ResizeGrip;
|
||||
|
||||
if (top && left) { handled = true; return (IntPtr)HTTOPLEFT; }
|
||||
if (top && right) { handled = true; return (IntPtr)HTTOPRIGHT; }
|
||||
if (bottom && left) { handled = true; return (IntPtr)HTBOTTOMLEFT; }
|
||||
if (bottom && right) { handled = true; return (IntPtr)HTBOTTOMRIGHT; }
|
||||
if (left) { handled = true; return (IntPtr)HTLEFT; }
|
||||
if (right) { handled = true; return (IntPtr)HTRIGHT; }
|
||||
if (top) { handled = true; return (IntPtr)HTTOP; }
|
||||
if (bottom) { handled = true; return (IntPtr)HTBOTTOM; }
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,24 @@ public partial class PreviewWindow : Window
|
||||
// ─── 콘텐츠 로드 ────────────────────────────────────────────
|
||||
|
||||
private async void LoadContent(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadContentCoreAsync(filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Warn($"PreviewWindow 콘텐츠 로드 실패: {ex.Message}");
|
||||
try
|
||||
{
|
||||
TextContent.Text = $"미리보기 오류: {ex.Message}";
|
||||
TextScroll.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadContentCoreAsync(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}"
|
||||
SnapsToDevicePixels="True">
|
||||
<Border x:Name="DropDownBorder"
|
||||
Background="White" CornerRadius="8"
|
||||
Background="{DynamicResource CardBackground}" CornerRadius="8"
|
||||
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
|
||||
Margin="0,2,0,0" Padding="4">
|
||||
<Border.Effect>
|
||||
@@ -3257,6 +3257,8 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 채팅 아이콘 설정은 공통 탭에 있음 -->
|
||||
|
||||
<!-- 안내 문구 랜덤 -->
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
@@ -3550,6 +3552,45 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── 아이콘 효과 ── -->
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="아이콘 효과"/>
|
||||
|
||||
<Border Style="{StaticResource AgentSettingsRow}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="채팅 아이콘 랜덤 효과"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="채팅 본문의 런처 아이콘에 다양한 애니메이션 효과를 랜덤으로 적용합니다. 끄면 숨쉬기 효과만 사용합니다."/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1" Style="{StaticResource ToggleSwitch}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EnableChatIconRandomAnimation, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource AgentSettingsRow}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="채팅 아이콘 글로우 강도"/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="채팅 본문 런처 아이콘의 글로우(발광) 효과 강도를 조절합니다."/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Width="100" x:Name="CmbChatIconGlow"
|
||||
SelectionChanged="CmbChatIconGlow_SelectionChanged">
|
||||
<ComboBoxItem Content="강하게" Tag="strong"/>
|
||||
<ComboBoxItem Content="적당히" Tag="medium"/>
|
||||
<ComboBoxItem Content="약하게" Tag="weak"/>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── 활성 서비스 선택 ── -->
|
||||
<StackPanel x:Name="AgentServiceSection">
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="활성 서비스"/>
|
||||
@@ -5443,6 +5484,12 @@
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="에이전트 실행 이력(도구 호출/결과)의 채팅창 표시 수준입니다."/>
|
||||
</StackPanel>
|
||||
<WrapPanel HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<RadioButton x:Name="AgentLogLevelHidden"
|
||||
Content="숨김"
|
||||
GroupName="AgentLogLevel"
|
||||
Style="{StaticResource AgentSubTabStyle}"
|
||||
Margin="0,0,6,0"
|
||||
Checked="AgentLogLevelCard_Checked"/>
|
||||
<RadioButton x:Name="AgentLogLevelSimple"
|
||||
Content="간략"
|
||||
GroupName="AgentLogLevel"
|
||||
|
||||
@@ -76,6 +76,7 @@ public partial class SettingsWindow : Window
|
||||
EnsureHotkeyInCombo();
|
||||
BuildQuoteCategoryCheckboxes();
|
||||
BuildDockBarSettings();
|
||||
InitChatIconGlowCombo();
|
||||
BuildTextActionCommandsPanel();
|
||||
if (HasLegacyAgentTab())
|
||||
{
|
||||
@@ -182,7 +183,8 @@ public partial class SettingsWindow : Window
|
||||
if (AgentRetentionDays90 != null) AgentRetentionDays90.IsChecked = retentionDays == 90;
|
||||
if (AgentRetentionDaysUnlimited != null) AgentRetentionDaysUnlimited.IsChecked = retentionDays == 0;
|
||||
|
||||
var logLevel = (_vm.AgentLogLevel ?? "simple").Trim().ToLowerInvariant();
|
||||
var logLevel = (_vm.AgentLogLevel ?? "hidden").Trim().ToLowerInvariant();
|
||||
if (AgentLogLevelHidden != null) AgentLogLevelHidden.IsChecked = logLevel == "hidden";
|
||||
if (AgentLogLevelSimple != null) AgentLogLevelSimple.IsChecked = logLevel == "simple";
|
||||
if (AgentLogLevelDetailed != null) AgentLogLevelDetailed.IsChecked = logLevel == "detailed";
|
||||
if (AgentLogLevelDebug != null) AgentLogLevelDebug.IsChecked = logLevel == "debug";
|
||||
@@ -2330,6 +2332,7 @@ public partial class SettingsWindow : Window
|
||||
|
||||
_vm.AgentLogLevel = rb.Name switch
|
||||
{
|
||||
"AgentLogLevelHidden" => "hidden",
|
||||
"AgentLogLevelDetailed" => "detailed",
|
||||
"AgentLogLevelDebug" => "debug",
|
||||
_ => "simple",
|
||||
@@ -2875,6 +2878,28 @@ public partial class SettingsWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void InitChatIconGlowCombo()
|
||||
{
|
||||
if (CmbChatIconGlow == null || _vm == null) return;
|
||||
var current = _vm.ChatIconGlowIntensity ?? "medium";
|
||||
for (int i = 0; i < CmbChatIconGlow.Items.Count; i++)
|
||||
{
|
||||
if (CmbChatIconGlow.Items[i] is ComboBoxItem item && (item.Tag as string) == current)
|
||||
{
|
||||
CmbChatIconGlow.SelectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CmbChatIconGlow_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
if (sender is not ComboBox combo || combo.SelectedItem is not ComboBoxItem selected) return;
|
||||
var intensity = selected.Tag as string ?? "medium";
|
||||
if (_vm != null) _vm.ChatIconGlowIntensity = intensity;
|
||||
}
|
||||
|
||||
private void OperationModeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
257
src/AxCopilot/Views/ToolApprovalWindow.cs
Normal file
257
src/AxCopilot/Views/ToolApprovalWindow.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Effects;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 도구 실행 승인을 위한 간결한 별도 다이얼로그 창.
|
||||
/// PlanViewerV2(전체 계획 뷰어)와 분리하여 도구 단위 승인에 사용합니다.
|
||||
/// </summary>
|
||||
internal sealed class ToolApprovalWindow : Window
|
||||
{
|
||||
private string? _result;
|
||||
|
||||
private ToolApprovalWindow(string message, List<string> options)
|
||||
{
|
||||
Width = 480;
|
||||
MinWidth = 400;
|
||||
MaxWidth = 600;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
WindowStyle = WindowStyle.None;
|
||||
AllowsTransparency = true;
|
||||
Background = Brushes.Transparent;
|
||||
ShowInTaskbar = false;
|
||||
Topmost = true;
|
||||
|
||||
var bg = Application.Current.TryFindResource("LauncherBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
|
||||
var primary = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var secondary = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var accent = Application.Current.TryFindResource("AccentColor") as Brush
|
||||
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
|
||||
var border = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
||||
|
||||
var root = new Border
|
||||
{
|
||||
Background = bg,
|
||||
BorderBrush = border,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(20, 16, 20, 16),
|
||||
Effect = new DropShadowEffect
|
||||
{
|
||||
BlurRadius = 20,
|
||||
ShadowDepth = 4,
|
||||
Opacity = 0.35,
|
||||
Color = Colors.Black,
|
||||
},
|
||||
};
|
||||
|
||||
var stack = new StackPanel();
|
||||
|
||||
// Header
|
||||
var header = new Grid { Margin = new Thickness(0, 0, 0, 12) };
|
||||
header.MouseLeftButtonDown += (_, _) => { try { DragMove(); } catch { } };
|
||||
header.Children.Add(new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "\uE946", // Shield icon
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 15,
|
||||
Foreground = accent,
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = "실행 확인",
|
||||
FontSize = 13.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primary,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close button
|
||||
var close = new Border
|
||||
{
|
||||
Width = 26,
|
||||
Height = 26,
|
||||
CornerRadius = new CornerRadius(7),
|
||||
Background = Brushes.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "\uE8BB",
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 10,
|
||||
Foreground = secondary,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
};
|
||||
close.MouseLeftButtonUp += (_, _) => { _result = "취소"; Close(); };
|
||||
close.MouseEnter += (_, _) => close.Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF));
|
||||
close.MouseLeave += (_, _) => close.Background = Brushes.Transparent;
|
||||
header.Children.Add(close);
|
||||
stack.Children.Add(header);
|
||||
|
||||
// Message content
|
||||
var msgBorder = new Border
|
||||
{
|
||||
Background = itemBg,
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(14, 11, 14, 11),
|
||||
Margin = new Thickness(0, 0, 0, 14),
|
||||
};
|
||||
|
||||
var msgText = new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
FontSize = 12.5,
|
||||
FontFamily = new FontFamily("Segoe UI"),
|
||||
Foreground = primary,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = 19,
|
||||
};
|
||||
msgBorder.Child = msgText;
|
||||
stack.Children.Add(msgBorder);
|
||||
|
||||
// Buttons
|
||||
var btnPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
};
|
||||
|
||||
foreach (var option in options)
|
||||
{
|
||||
var btn = CreateOptionButton(option, primary, secondary, accent, bg);
|
||||
btn.MouseLeftButtonUp += (_, _) => { _result = option; Close(); };
|
||||
btnPanel.Children.Add(btn);
|
||||
}
|
||||
stack.Children.Add(btnPanel);
|
||||
|
||||
root.Child = stack;
|
||||
Content = root;
|
||||
|
||||
// Entrance animation
|
||||
root.Opacity = 0;
|
||||
root.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||
root.RenderTransform = new ScaleTransform(0.96, 0.96);
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
root.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(140)));
|
||||
var sx = new DoubleAnimation(0.96, 1, TimeSpan.FromMilliseconds(180))
|
||||
{
|
||||
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut },
|
||||
};
|
||||
var sy = new DoubleAnimation(0.96, 1, TimeSpan.FromMilliseconds(180))
|
||||
{
|
||||
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut },
|
||||
};
|
||||
((ScaleTransform)root.RenderTransform).BeginAnimation(ScaleTransform.ScaleXProperty, sx);
|
||||
((ScaleTransform)root.RenderTransform).BeginAnimation(ScaleTransform.ScaleYProperty, sy);
|
||||
};
|
||||
|
||||
// ESC to cancel
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
_result = "취소";
|
||||
Close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Border CreateOptionButton(string label, Brush primary, Brush secondary, Brush accent, Brush bg)
|
||||
{
|
||||
Brush foreground, background, borderBrush;
|
||||
switch (label)
|
||||
{
|
||||
case "확인":
|
||||
case "승인":
|
||||
foreground = Brushes.White;
|
||||
background = accent;
|
||||
borderBrush = accent;
|
||||
break;
|
||||
case "취소":
|
||||
case "중단":
|
||||
foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
|
||||
background = Brushes.Transparent;
|
||||
borderBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
|
||||
break;
|
||||
default:
|
||||
foreground = primary;
|
||||
background = Brushes.Transparent;
|
||||
borderBrush = secondary;
|
||||
break;
|
||||
}
|
||||
|
||||
var btn = new Border
|
||||
{
|
||||
MinWidth = 70,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = background,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
Padding = new Thickness(14, 0, 14, 0),
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = foreground,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
};
|
||||
|
||||
btn.MouseEnter += (_, _) => btn.Opacity = 0.85;
|
||||
btn.MouseLeave += (_, _) => btn.Opacity = 1.0;
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/// <summary>도구 승인 다이얼로그를 표시하고 결과를 반환합니다.</summary>
|
||||
internal static string? Show(Window? owner, string message, List<string> options)
|
||||
{
|
||||
var dialog = new ToolApprovalWindow(message, options);
|
||||
if (owner != null && IsWindowAlive(owner))
|
||||
{
|
||||
dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
dialog.Owner = owner;
|
||||
}
|
||||
|
||||
dialog.ShowDialog();
|
||||
return dialog._result;
|
||||
}
|
||||
|
||||
private static bool IsWindowAlive(Window? w)
|
||||
{
|
||||
if (w == null) return false;
|
||||
try { var _ = w.IsVisible; return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,41 @@ using System.Windows.Controls;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
/// <summary>
|
||||
/// TranscriptVisualItem의 지연 생성 호스트.
|
||||
/// VirtualizingStackPanel Recycling 모드에서 효율적으로 동작합니다:
|
||||
/// - Loaded: 콘텐츠 구체화 (GetOrCreateElement)
|
||||
/// - Unloaded: 콘텐츠 참조 해제 (재활용 시 메모리 절약)
|
||||
/// - DataContextChanged: 재활용된 컨테이너에 새 아이템 바인딩
|
||||
/// </summary>
|
||||
public sealed class TranscriptVisualHost : ContentControl
|
||||
{
|
||||
public TranscriptVisualHost()
|
||||
{
|
||||
DataContextChanged += (_, _) => SyncContent();
|
||||
Loaded += (_, _) => SyncContent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
Loaded += OnLoaded;
|
||||
Unloaded += OnUnloaded;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
// 재활용 시: 이전 콘텐츠 즉시 해제 후 새 아이템 구체화
|
||||
if (e.OldValue != null && e.NewValue != e.OldValue)
|
||||
Content = null;
|
||||
|
||||
if (IsLoaded)
|
||||
SyncContent();
|
||||
}
|
||||
|
||||
private void OnLoaded(object sender, RoutedEventArgs e) => SyncContent();
|
||||
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// 화면 밖으로 스크롤된 항목의 콘텐츠 참조 해제
|
||||
// 단, DataContext가 이미 새 아이템으로 바뀐 경우(재활용 중)는 건드리지 않음
|
||||
// → Unloaded가 DataContextChanged 이후 호출되는 레이스 컨디션 방지
|
||||
if (DataContext is not TranscriptVisualItem)
|
||||
Content = null;
|
||||
}
|
||||
|
||||
private void SyncContent()
|
||||
|
||||
@@ -85,6 +85,10 @@ public partial class WorkflowAnalyzerWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
// 내부 운영 이벤트는 타임라인에 표시하지 않음
|
||||
if (evt.Type is AgentEventType.SessionStart or AgentEventType.UserPromptSubmit)
|
||||
return;
|
||||
|
||||
// 요약 카드 업데이트
|
||||
if (evt.Iteration > _maxIteration) _maxIteration = evt.Iteration;
|
||||
if (evt.InputTokens > 0) _totalInputTokens += evt.InputTokens;
|
||||
|
||||
Reference in New Issue
Block a user