변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: 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개를 확인했습니다.
403 lines
16 KiB
C#
403 lines
16 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Windows;
|
|
using AxCopilot.SDK;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Themes;
|
|
|
|
namespace AxCopilot.Handlers;
|
|
|
|
/// <summary>
|
|
/// L21-2: 로그 파일 분석기 핸들러. "log" 프리픽스로 사용합니다.
|
|
///
|
|
/// 예: log → 클립보드 로그 통계
|
|
/// log <경로> → 파일 경로 직접 분석
|
|
/// log error → ERROR 줄만 표시
|
|
/// log warn → WARN 줄만 표시
|
|
/// log last 20 → 마지막 20줄 (tail)
|
|
/// log head 20 → 처음 20줄
|
|
/// log grep <키워드> → 키워드 필터
|
|
/// log stats → 레벨별 통계 + 시간대 분포
|
|
/// log exceptions → 예외·스택트레이스 블록 추출
|
|
/// log today → 오늘 날짜 포함 줄만 표시
|
|
/// Enter → 값 복사.
|
|
/// </summary>
|
|
public partial class LogHandler : IActionHandler
|
|
{
|
|
public string? Prefix => "log";
|
|
|
|
public PluginMetadata Metadata => new(
|
|
"Log",
|
|
"로그 파일 분석기 — ERROR/WARN 필터·tail·grep·통계·예외 추출",
|
|
"1.0",
|
|
"AX");
|
|
|
|
private record LogLine(int No, string Level, string Raw);
|
|
|
|
// 로그 레벨 감지 패턴
|
|
private static readonly (string Level, string[] Keywords)[] LevelPatterns =
|
|
[
|
|
("ERROR", ["[ERROR]", "ERROR:", "ERRO ", "error", "Error", "FATAL", "[FATAL]", "Exception", "EXCEPTION"]),
|
|
("WARN", ["[WARN]", "WARN:", "WARNING", "warning", "Warning", "[WARNING]"]),
|
|
("INFO", ["[INFO]", "INFO:", "information", "Information"]),
|
|
("DEBUG", ["[DEBUG]", "DEBUG:", "debug", "Debug", "TRACE", "[TRACE]"]),
|
|
];
|
|
|
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
|
{
|
|
var q = query.Trim();
|
|
var items = new List<LauncherItem>();
|
|
|
|
// 클립보드 or 파일
|
|
string? src = null;
|
|
string? srcLabel = null;
|
|
try
|
|
{
|
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
if (Clipboard.ContainsText()) src = Clipboard.GetText();
|
|
});
|
|
}
|
|
catch { }
|
|
|
|
if (string.IsNullOrWhiteSpace(q))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(src))
|
|
{
|
|
items.Add(new LauncherItem("로그 파일 분석기",
|
|
"클립보드에 로그를 복사하거나 log <파일경로> / log error / log grep <키워드>",
|
|
null, null, Symbol: "\uE9D9"));
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
srcLabel = "클립보드";
|
|
BuildSummary(items, src!, srcLabel);
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
var sub = parts[0].ToLowerInvariant();
|
|
|
|
// 파일 경로인지 판단
|
|
if ((sub.Contains('\\') || sub.Contains('/') || sub.Contains(':') || sub.EndsWith(".log") || sub.EndsWith(".txt"))
|
|
&& File.Exists(parts[0]))
|
|
{
|
|
string? fileErr = null;
|
|
string? content = null;
|
|
try { content = File.ReadAllText(parts[0]); }
|
|
catch (Exception ex) { fileErr = ex.Message; }
|
|
|
|
if (fileErr != null) { items.Add(ErrorItem($"파일 읽기 오류: {fileErr}")); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
|
|
src = content;
|
|
srcLabel = Path.GetFileName(parts[0]);
|
|
if (parts.Length == 1)
|
|
{
|
|
BuildSummary(items, src!, srcLabel);
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
// 파일 경로 + 서브커맨드
|
|
sub = parts[1].ToLowerInvariant();
|
|
parts = parts[1..];
|
|
}
|
|
else
|
|
{
|
|
srcLabel = "클립보드";
|
|
}
|
|
|
|
var logSrc = src ?? "";
|
|
if (string.IsNullOrWhiteSpace(logSrc))
|
|
{
|
|
items.Add(ErrorItem("분석할 로그가 없습니다. 클립보드에 로그를 복사하세요."));
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
var allLines = ParseLogLines(logSrc);
|
|
|
|
switch (sub)
|
|
{
|
|
case "error" or "err":
|
|
BuildFilteredLines(items, allLines, "ERROR", "ERROR / FATAL / Exception", srcLabel!);
|
|
break;
|
|
|
|
case "warn" or "warning":
|
|
BuildFilteredLines(items, allLines, "WARN", "WARN / WARNING", srcLabel!);
|
|
break;
|
|
|
|
case "info":
|
|
BuildFilteredLines(items, allLines, "INFO", "INFO / information", srcLabel!);
|
|
break;
|
|
|
|
case "debug" or "trace":
|
|
BuildFilteredLines(items, allLines, "DEBUG", "DEBUG / TRACE", srcLabel!);
|
|
break;
|
|
|
|
case "last" or "tail":
|
|
{
|
|
int n = 20;
|
|
if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500);
|
|
var tail = allLines.TakeLast(n).ToList();
|
|
var joined = string.Join("\n", tail.Select(l => l.Raw));
|
|
items.Add(new LauncherItem($"마지막 {tail.Count}줄 ({srcLabel})",
|
|
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
|
foreach (var l in tail)
|
|
items.Add(BuildLineItem(l));
|
|
break;
|
|
}
|
|
|
|
case "head" or "first":
|
|
{
|
|
int n = 20;
|
|
if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500);
|
|
var head = allLines.Take(n).ToList();
|
|
var joined = string.Join("\n", head.Select(l => l.Raw));
|
|
items.Add(new LauncherItem($"처음 {head.Count}줄 ({srcLabel})",
|
|
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
|
foreach (var l in head)
|
|
items.Add(BuildLineItem(l));
|
|
break;
|
|
}
|
|
|
|
case "grep" or "find" or "search":
|
|
{
|
|
if (parts.Length < 2) { items.Add(ErrorItem("예: log grep Exception")); break; }
|
|
var kw = string.Join(" ", parts[1..]);
|
|
var matched = allLines.Where(l => l.Raw.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
var joined = string.Join("\n", matched.Select(l => l.Raw));
|
|
items.Add(new LauncherItem($"'{kw}' 검색 결과: {matched.Count}줄",
|
|
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
|
foreach (var l in matched.Take(15))
|
|
items.Add(BuildLineItem(l));
|
|
if (matched.Count > 15)
|
|
items.Add(new LauncherItem($"... ({matched.Count - 15}줄 더)", "Enter 전체 복사",
|
|
null, ("copy", joined), Symbol: "\uE9D9"));
|
|
break;
|
|
}
|
|
|
|
case "stats":
|
|
BuildStats(items, allLines, logSrc, srcLabel!);
|
|
break;
|
|
|
|
case "exceptions" or "exception" or "ex":
|
|
{
|
|
var blocks = ExtractExceptions(logSrc);
|
|
items.Add(new LauncherItem($"예외 블록 {blocks.Count}개 발견",
|
|
srcLabel!, null, null, Symbol: "\uE9D9"));
|
|
for (int i = 0; i < blocks.Count; i++)
|
|
{
|
|
var b = blocks[i];
|
|
var preview = b.Split('\n')[0];
|
|
items.Add(new LauncherItem($"#{i + 1} {TruncateStr(preview, 60)}",
|
|
$"{b.Split('\n').Length}줄", null, ("copy", b), Symbol: "\uE9D9"));
|
|
}
|
|
if (blocks.Count == 0)
|
|
items.Add(new LauncherItem("예외·스택트레이스 패턴 없음", "", null, null, Symbol: "\uE9D9"));
|
|
break;
|
|
}
|
|
|
|
case "today":
|
|
{
|
|
var today = DateTime.Today.ToString("yyyy-MM-dd");
|
|
var today2 = DateTime.Today.ToString("yyyy/MM/dd");
|
|
var matched = allLines.Where(l =>
|
|
l.Raw.Contains(today) || l.Raw.Contains(today2)).ToList();
|
|
var joined = string.Join("\n", matched.Select(l => l.Raw));
|
|
items.Add(new LauncherItem($"오늘({today}) {matched.Count}줄",
|
|
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
|
foreach (var l in matched.Take(15))
|
|
items.Add(BuildLineItem(l));
|
|
break;
|
|
}
|
|
|
|
default:
|
|
// 기본 요약으로 폴백
|
|
BuildSummary(items, logSrc, srcLabel!);
|
|
break;
|
|
}
|
|
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
|
{
|
|
if (item.Data is ("copy", string text))
|
|
{
|
|
try
|
|
{
|
|
System.Windows.Application.Current.Dispatcher.Invoke(
|
|
() => Clipboard.SetText(text));
|
|
NotificationService.Notify("Log", "클립보드에 복사했습니다.");
|
|
}
|
|
catch { }
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ── 빌더 ────────────────────────────────────────────────────────────────
|
|
|
|
private static void BuildSummary(List<LauncherItem> items, string src, string label)
|
|
{
|
|
var lines = ParseLogLines(src);
|
|
var errors = lines.Count(l => l.Level == "ERROR");
|
|
var warns = lines.Count(l => l.Level == "WARN");
|
|
var infos = lines.Count(l => l.Level == "INFO");
|
|
var debugs = lines.Count(l => l.Level == "DEBUG");
|
|
var unknowns = lines.Count(l => l.Level == "OTHER");
|
|
|
|
items.Add(new LauncherItem($"{label} — {lines.Count}줄",
|
|
$"ERROR:{errors} WARN:{warns} INFO:{infos} DEBUG:{debugs}",
|
|
null, null, Symbol: "\uE9D9"));
|
|
if (errors > 0)
|
|
{
|
|
items.Add(new LauncherItem($"🔴 ERROR {errors}줄",
|
|
"log error 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9"));
|
|
}
|
|
if (warns > 0)
|
|
{
|
|
items.Add(new LauncherItem($"🟡 WARN {warns}줄",
|
|
"log warn 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9"));
|
|
}
|
|
items.Add(CopyItem("전체 줄 수", lines.Count.ToString()));
|
|
items.Add(CopyItem("ERROR 줄", errors.ToString()));
|
|
items.Add(CopyItem("WARN 줄", warns.ToString()));
|
|
|
|
// 마지막 5줄 미리보기
|
|
items.Add(new LauncherItem("── 마지막 5줄 ──", "", null, null, Symbol: "\uE9D9"));
|
|
foreach (var l in lines.TakeLast(5))
|
|
items.Add(BuildLineItem(l));
|
|
}
|
|
|
|
private static void BuildFilteredLines(List<LauncherItem> items, List<LogLine> lines,
|
|
string level, string label, string srcLabel)
|
|
{
|
|
var filtered = lines.Where(l => l.Level == level).ToList();
|
|
var joined = string.Join("\n", filtered.Select(l => l.Raw));
|
|
items.Add(new LauncherItem($"{label} {filtered.Count}줄 ({srcLabel})",
|
|
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
|
if (filtered.Count == 0)
|
|
{
|
|
items.Add(new LauncherItem($"{label} 줄이 없습니다", "", null, null, Symbol: "\uE9D9"));
|
|
return;
|
|
}
|
|
foreach (var l in filtered.Take(15))
|
|
items.Add(BuildLineItem(l));
|
|
if (filtered.Count > 15)
|
|
items.Add(new LauncherItem($"... ({filtered.Count - 15}줄 더)", "Enter 전체 복사",
|
|
null, ("copy", joined), Symbol: "\uE9D9"));
|
|
}
|
|
|
|
private static void BuildStats(List<LauncherItem> items, List<LogLine> lines, string src, string label)
|
|
{
|
|
var counts = new Dictionary<string, int>
|
|
{
|
|
["ERROR"] = lines.Count(l => l.Level == "ERROR"),
|
|
["WARN"] = lines.Count(l => l.Level == "WARN"),
|
|
["INFO"] = lines.Count(l => l.Level == "INFO"),
|
|
["DEBUG"] = lines.Count(l => l.Level == "DEBUG"),
|
|
["OTHER"] = lines.Count(l => l.Level == "OTHER"),
|
|
};
|
|
|
|
items.Add(new LauncherItem($"로그 통계 ({label})", $"총 {lines.Count}줄",
|
|
null, null, Symbol: "\uE9D9"));
|
|
foreach (var (lvl, cnt) in counts.Where(kv => kv.Value > 0))
|
|
items.Add(CopyItem(lvl, $"{cnt}줄 ({cnt * 100.0 / Math.Max(1, lines.Count):F1}%)"));
|
|
|
|
// 날짜별 분포 (yyyy-MM-dd 패턴 추출)
|
|
var dateCounts = new Dictionary<string, int>();
|
|
foreach (var l in lines)
|
|
{
|
|
var m = DatePattern().Match(l.Raw);
|
|
if (m.Success)
|
|
{
|
|
var date = m.Value[..10];
|
|
dateCounts[date] = dateCounts.GetValueOrDefault(date) + 1;
|
|
}
|
|
}
|
|
if (dateCounts.Count > 0)
|
|
{
|
|
items.Add(new LauncherItem("── 날짜별 분포 ──", "", null, null, Symbol: "\uE9D9"));
|
|
foreach (var (date, cnt) in dateCounts.OrderByDescending(kv => kv.Key).Take(5))
|
|
items.Add(CopyItem(date, $"{cnt}줄"));
|
|
}
|
|
}
|
|
|
|
private static List<string> ExtractExceptions(string src)
|
|
{
|
|
var blocks = new List<string>();
|
|
var lines = src.Split('\n');
|
|
var inBlock = false;
|
|
var current = new StringBuilder();
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
var isEx = line.Contains("Exception") || line.Contains("Error:") ||
|
|
line.Contains("at ") || line.TrimStart().StartsWith("Caused by");
|
|
if (!inBlock && isEx)
|
|
{
|
|
inBlock = true;
|
|
current.Clear();
|
|
current.AppendLine(line);
|
|
}
|
|
else if (inBlock)
|
|
{
|
|
if (isEx || line.TrimStart().StartsWith("at ") || line.TrimStart().StartsWith("..."))
|
|
current.AppendLine(line);
|
|
else
|
|
{
|
|
if (current.Length > 0) blocks.Add(current.ToString().Trim());
|
|
inBlock = false;
|
|
current.Clear();
|
|
}
|
|
}
|
|
}
|
|
if (inBlock && current.Length > 0) blocks.Add(current.ToString().Trim());
|
|
return blocks;
|
|
}
|
|
|
|
// ── 파서·헬퍼 ───────────────────────────────────────────────────────────
|
|
|
|
private static List<LogLine> ParseLogLines(string src)
|
|
{
|
|
var lines = src.Split('\n');
|
|
return lines.Select((raw, i) =>
|
|
{
|
|
var level = DetectLevel(raw);
|
|
return new LogLine(i + 1, level, raw.TrimEnd('\r'));
|
|
}).ToList();
|
|
}
|
|
|
|
private static string DetectLevel(string line)
|
|
{
|
|
foreach (var (level, keywords) in LevelPatterns)
|
|
if (keywords.Any(kw => line.Contains(kw, StringComparison.Ordinal)))
|
|
return level;
|
|
return "OTHER";
|
|
}
|
|
|
|
private static LauncherItem BuildLineItem(LogLine l)
|
|
{
|
|
var icon = l.Level switch
|
|
{
|
|
"ERROR" => "🔴",
|
|
"WARN" => "🟡",
|
|
"INFO" => "🔵",
|
|
"DEBUG" => "⚪",
|
|
_ => " "
|
|
};
|
|
var display = TruncateStr(l.Raw, 80);
|
|
return new LauncherItem($"{icon} {display}",
|
|
$"줄 {l.No} ({l.Level})", null, ("copy", l.Raw), Symbol: "\uE9D9");
|
|
}
|
|
|
|
private static string TruncateStr(string s, int max) =>
|
|
s.Length <= max ? s : s[..max] + "…";
|
|
|
|
private static LauncherItem CopyItem(string label, string value) =>
|
|
new(label, value, null, ("copy", value), Symbol: "\uE9D9");
|
|
|
|
private static LauncherItem ErrorItem(string msg) =>
|
|
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
|
|
|
[GeneratedRegex(@"\d{4}[-/]\d{2}[-/]\d{2}")]
|
|
private static partial Regex DatePattern();
|
|
}
|