Files
AX-Copilot-Codex/src/AxCopilot/Handlers/LogHandler.cs
lacvet 0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: 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개를 확인했습니다.
2026-04-05 00:59:45 +09:00

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();
}