변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
623 lines
27 KiB
C#
623 lines
27 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Controls.Primitives;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.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;
|
|
ScheduleSettingsSave();
|
|
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();
|
|
}
|
|
}
|