변경 목적: - 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
408 lines
14 KiB
C#
408 lines
14 KiB
C#
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 System.Windows.Threading.DispatcherTimer? _fileMentionDebounceTimer;
|
|
private string _fileMentionPendingText = "";
|
|
|
|
private void ScheduleFileMentionRefresh(string text)
|
|
{
|
|
_fileMentionPendingText = text;
|
|
if (_fileMentionDebounceTimer == null)
|
|
{
|
|
_fileMentionDebounceTimer = new System.Windows.Threading.DispatcherTimer
|
|
{
|
|
Interval = TimeSpan.FromMilliseconds(120),
|
|
};
|
|
_fileMentionDebounceTimer.Tick += (_, _) =>
|
|
{
|
|
_fileMentionDebounceTimer!.Stop();
|
|
try { RefreshFileMentionSuggestions(_fileMentionPendingText); }
|
|
catch { }
|
|
};
|
|
}
|
|
_fileMentionDebounceTimer.Stop();
|
|
_fileMentionDebounceTimer.Start();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|