Initial commit to new repository
This commit is contained in:
402
src/AxCopilot/Services/Agent/AgentHookRunner.cs
Normal file
402
src/AxCopilot/Services/Agent/AgentHookRunner.cs
Normal file
@@ -0,0 +1,402 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 도구 실행 전/후에 사용자 정의 스크립트(bat/ps1/cmd)를 실행하는 훅 러너.
|
||||
/// <para>
|
||||
/// 환경 변수로 도구 정보를 전달:
|
||||
/// AX_TOOL_NAME — 도구 이름
|
||||
/// AX_TOOL_TIMING — pre | post
|
||||
/// AX_TOOL_INPUT — 도구 입력 JSON (최대 4KB)
|
||||
/// AX_TOOL_OUTPUT — 도구 출력 (post만, 최대 4KB)
|
||||
/// AX_TOOL_SUCCESS — true | false (post만)
|
||||
/// AX_WORK_FOLDER — 작업 폴더 경로
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentHookRunner
|
||||
{
|
||||
private const int MaxEnvValueLength = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// 지정된 타이밍(pre/post)에 해당하는 훅을 모두 실행합니다.
|
||||
/// </summary>
|
||||
/// <param name="hooks">설정에 등록된 전체 훅 목록</param>
|
||||
/// <param name="toolName">실행된 도구 이름</param>
|
||||
/// <param name="timing">"pre" 또는 "post"</param>
|
||||
/// <param name="toolInput">도구 입력 JSON</param>
|
||||
/// <param name="toolOutput">도구 출력 (post만)</param>
|
||||
/// <param name="success">도구 실행 성공 여부 (post만)</param>
|
||||
/// <param name="workFolder">작업 폴더 경로</param>
|
||||
/// <param name="timeoutMs">스크립트 타임아웃 (밀리초)</param>
|
||||
/// <param name="ct">취소 토큰</param>
|
||||
/// <returns>각 훅의 실행 결과 (이름, 성공여부, 출력/에러)</returns>
|
||||
public static async Task<List<HookExecutionResult>> RunAsync(
|
||||
IReadOnlyList<AgentHookEntry> hooks,
|
||||
string toolName,
|
||||
string timing,
|
||||
string? toolInput = null,
|
||||
string? toolOutput = null,
|
||||
bool success = true,
|
||||
string? workFolder = null,
|
||||
int timeoutMs = 10000,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<HookExecutionResult>();
|
||||
if (hooks == null || hooks.Count == 0) return results;
|
||||
|
||||
foreach (var hook in hooks)
|
||||
{
|
||||
if (!hook.Enabled) continue;
|
||||
if (!string.Equals(hook.Timing, timing, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
// 도구 이름 매칭: "*" = 전체, 그 외 정확 매칭 (대소문자 무시)
|
||||
if (hook.ToolName != "*" &&
|
||||
!string.Equals(hook.ToolName, toolName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var result = await ExecuteHookAsync(hook, toolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task<HookExecutionResult> ExecuteHookAsync(
|
||||
AgentHookEntry hook,
|
||||
string toolName,
|
||||
string timing,
|
||||
string? toolInput,
|
||||
string? toolOutput,
|
||||
bool success,
|
||||
string? workFolder,
|
||||
int timeoutMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hook.ScriptPath))
|
||||
return new HookExecutionResult(hook.Name, false, "스크립트 경로가 비어 있습니다.");
|
||||
|
||||
var scriptPath = Environment.ExpandEnvironmentVariables(hook.ScriptPath);
|
||||
if (!File.Exists(scriptPath))
|
||||
return new HookExecutionResult(hook.Name, false, $"스크립트를 찾을 수 없습니다: {scriptPath}");
|
||||
|
||||
var ext = Path.GetExtension(scriptPath).ToLowerInvariant();
|
||||
string fileName;
|
||||
string arguments;
|
||||
|
||||
switch (ext)
|
||||
{
|
||||
case ".ps1":
|
||||
fileName = "powershell.exe";
|
||||
arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{scriptPath}\"";
|
||||
break;
|
||||
case ".bat":
|
||||
case ".cmd":
|
||||
fileName = "cmd.exe";
|
||||
arguments = $"/c \"{scriptPath}\"";
|
||||
break;
|
||||
default:
|
||||
return new HookExecutionResult(hook.Name, false, $"지원하지 않는 스크립트 확장자: {ext} (.bat/.cmd/.ps1만 허용)");
|
||||
}
|
||||
|
||||
// 사용자 정의 추가 인수
|
||||
if (!string.IsNullOrWhiteSpace(hook.Arguments))
|
||||
arguments += $" {hook.Arguments}";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workFolder ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
// 환경 변수로 도구 정보 전달
|
||||
psi.EnvironmentVariables["AX_TOOL_NAME"] = toolName;
|
||||
psi.EnvironmentVariables["AX_TOOL_TIMING"] = timing;
|
||||
psi.EnvironmentVariables["AX_TOOL_INPUT"] = Truncate(toolInput, MaxEnvValueLength);
|
||||
psi.EnvironmentVariables["AX_WORK_FOLDER"] = workFolder ?? "";
|
||||
|
||||
if (string.Equals(timing, "post", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
psi.EnvironmentVariables["AX_TOOL_OUTPUT"] = Truncate(toolOutput, MaxEnvValueLength);
|
||||
psi.EnvironmentVariables["AX_TOOL_SUCCESS"] = success ? "true" : "false";
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
var stdOut = new StringBuilder();
|
||||
var stdErr = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdOut.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stdErr.AppendLine(e.Data); };
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { process.Kill(true); } catch { }
|
||||
return new HookExecutionResult(hook.Name, false, $"타임아웃 ({timeoutMs}ms 초과)");
|
||||
}
|
||||
|
||||
var exitCode = process.ExitCode;
|
||||
var output = stdOut.ToString().TrimEnd();
|
||||
var error = stdErr.ToString().TrimEnd();
|
||||
|
||||
if (exitCode != 0)
|
||||
return new HookExecutionResult(hook.Name, false, $"종료 코드 {exitCode}: {(string.IsNullOrEmpty(error) ? output : error)}");
|
||||
|
||||
var displayOutput = string.IsNullOrEmpty(output) ? "(정상 완료)" : output;
|
||||
if (TryParseStructuredPayload(
|
||||
output,
|
||||
out var updatedInput,
|
||||
out var updatedPermissions,
|
||||
out var additionalContext,
|
||||
out var hookMessage))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(hookMessage))
|
||||
displayOutput = hookMessage;
|
||||
else if (string.IsNullOrWhiteSpace(displayOutput))
|
||||
displayOutput = "(정상 완료)";
|
||||
|
||||
return new HookExecutionResult(
|
||||
hook.Name,
|
||||
true,
|
||||
displayOutput,
|
||||
updatedInput,
|
||||
updatedPermissions,
|
||||
additionalContext);
|
||||
}
|
||||
|
||||
return new HookExecutionResult(hook.Name, true, displayOutput);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HookExecutionResult(hook.Name, false, $"훅 실행 예외: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLen)
|
||||
=> string.IsNullOrEmpty(value) ? "" : (value.Length <= maxLen ? value : value[..maxLen]);
|
||||
|
||||
private static bool TryParseStructuredPayload(
|
||||
string rawOutput,
|
||||
out JsonElement? updatedInput,
|
||||
out Dictionary<string, string>? updatedPermissions,
|
||||
out string? additionalContext,
|
||||
out string? message)
|
||||
{
|
||||
updatedInput = null;
|
||||
updatedPermissions = null;
|
||||
additionalContext = null;
|
||||
message = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawOutput))
|
||||
return false;
|
||||
|
||||
var candidate = rawOutput.Trim();
|
||||
if (!TryParseJsonObject(candidate, out var root))
|
||||
{
|
||||
var lines = candidate
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (lines.Length == 0)
|
||||
return false;
|
||||
|
||||
var lastLine = lines[^1];
|
||||
if (!TryParseJsonObject(lastLine, out root))
|
||||
return false;
|
||||
}
|
||||
|
||||
var structured = false;
|
||||
|
||||
if (root.TryGetProperty("updatedInput", out var inputProp) &&
|
||||
inputProp.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
|
||||
{
|
||||
updatedInput = inputProp.Clone();
|
||||
structured = true;
|
||||
}
|
||||
|
||||
if (TryExtractUpdatedPermissions(root, out var map))
|
||||
{
|
||||
updatedPermissions = map;
|
||||
structured = true;
|
||||
}
|
||||
|
||||
if (TryExtractAdditionalContext(root, out var parsedContext))
|
||||
{
|
||||
additionalContext = parsedContext;
|
||||
structured = true;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("message", out var msgProp) &&
|
||||
msgProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var msg = msgProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(msg))
|
||||
message = msg.Trim();
|
||||
}
|
||||
|
||||
return structured;
|
||||
}
|
||||
|
||||
private static bool TryParseJsonObject(string text, out JsonElement root)
|
||||
{
|
||||
root = default;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
root = doc.RootElement.Clone();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractUpdatedPermissions(
|
||||
JsonElement root,
|
||||
out Dictionary<string, string>? updatedPermissions)
|
||||
{
|
||||
updatedPermissions = null;
|
||||
|
||||
JsonElement permProp;
|
||||
if (!(root.TryGetProperty("updatedPermissions", out permProp)
|
||||
|| root.TryGetProperty("permissionUpdates", out permProp)))
|
||||
return false;
|
||||
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (permProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var item in permProp.EnumerateObject())
|
||||
{
|
||||
if (TryExtractPermissionValue(item.Value, out var normalized))
|
||||
map[item.Name] = normalized;
|
||||
}
|
||||
}
|
||||
else if (permProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var entry in permProp.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Object)
|
||||
continue;
|
||||
|
||||
if (!entry.TryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var tool = toolProp.GetString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(tool))
|
||||
continue;
|
||||
|
||||
if (entry.TryGetProperty("permission", out var permValue) &&
|
||||
TryExtractPermissionValue(permValue, out var normalized))
|
||||
{
|
||||
map[tool] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (map.Count == 0)
|
||||
return false;
|
||||
|
||||
updatedPermissions = map;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryExtractPermissionValue(JsonElement permissionElement, out string normalized)
|
||||
{
|
||||
normalized = "";
|
||||
|
||||
if (permissionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = permissionElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
normalized = text.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (permissionElement.ValueKind == JsonValueKind.Object &&
|
||||
permissionElement.TryGetProperty("permission", out var nestedPermission) &&
|
||||
nestedPermission.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = nestedPermission.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
normalized = text.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
|
||||
{
|
||||
additionalContext = null;
|
||||
if (!root.TryGetProperty("additionalContext", out var ctxProp))
|
||||
return false;
|
||||
|
||||
if (ctxProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = ctxProp.GetString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
additionalContext = text;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ctxProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var chunks = new List<string>();
|
||||
foreach (var part in ctxProp.EnumerateArray())
|
||||
{
|
||||
if (part.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var text = part.GetString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
chunks.Add(text);
|
||||
}
|
||||
|
||||
if (chunks.Count > 0)
|
||||
{
|
||||
additionalContext = string.Join("\n", chunks);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>훅 실행 결과.</summary>
|
||||
public record HookExecutionResult(
|
||||
string HookName,
|
||||
bool Success,
|
||||
string Output,
|
||||
JsonElement? UpdatedInput = null,
|
||||
Dictionary<string, string>? UpdatedPermissions = null,
|
||||
string? AdditionalContext = null);
|
||||
246
src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs
Normal file
246
src/AxCopilot/Services/Agent/AgentLoopParallelExecution.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
private static int GetMaxParallelToolConcurrency()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("AXCOPILOT_MAX_PARALLEL_TOOLS");
|
||||
if (int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
return Math.Min(parsed, 12);
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
|
||||
private static readonly HashSet<string> ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"file_read", "glob", "grep_tool", "folder_map", "document_read",
|
||||
"search_codebase", "code_search", "env_tool", "datetime_tool",
|
||||
"dev_env_detect", "memory", "skill_manager", "json_tool",
|
||||
"regex_tool", "base64_tool", "hash_tool", "image_analyze",
|
||||
};
|
||||
|
||||
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
|
||||
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential)
|
||||
ClassifyToolCalls(List<LlmService.ContentBlock> calls)
|
||||
{
|
||||
var parallel = new List<LlmService.ContentBlock>();
|
||||
var sequential = new List<LlmService.ContentBlock>();
|
||||
var collectParallelPrefix = true;
|
||||
|
||||
foreach (var call in calls)
|
||||
{
|
||||
var requestedToolName = call.ToolName ?? "";
|
||||
var normalizedToolName = NormalizeAliasToken(requestedToolName);
|
||||
var classificationToolName = ToolAliasMap.TryGetValue(normalizedToolName, out var mappedToolName)
|
||||
? mappedToolName
|
||||
: requestedToolName;
|
||||
|
||||
if (collectParallelPrefix && ReadOnlyTools.Contains(classificationToolName))
|
||||
parallel.Add(call);
|
||||
else
|
||||
{
|
||||
collectParallelPrefix = false;
|
||||
sequential.Add(call);
|
||||
}
|
||||
}
|
||||
|
||||
// 읽기 전용 도구가 1개뿐이면 병렬화 의미 없음
|
||||
if (parallel.Count <= 1)
|
||||
{
|
||||
sequential.InsertRange(0, parallel);
|
||||
parallel.Clear();
|
||||
}
|
||||
|
||||
return (parallel, sequential);
|
||||
}
|
||||
|
||||
/// <summary>병렬 실행용 가변 상태.</summary>
|
||||
private class ParallelState
|
||||
{
|
||||
public int CurrentStep;
|
||||
public int TotalToolCalls;
|
||||
public int MaxIterations;
|
||||
public int ConsecutiveReadOnlySuccessTools;
|
||||
public int ConsecutiveNonMutatingSuccessTools;
|
||||
public int ConsecutiveErrors;
|
||||
public int StatsSuccessCount;
|
||||
public int StatsFailCount;
|
||||
public int StatsInputTokens;
|
||||
public int StatsOutputTokens;
|
||||
public int StatsRepeatedFailureBlocks;
|
||||
public int StatsRecoveredAfterFailure;
|
||||
public bool RecoveryPendingAfterFailure;
|
||||
public string? LastFailedToolSignature;
|
||||
public int RepeatedFailedToolSignatureCount;
|
||||
}
|
||||
|
||||
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
|
||||
private async Task ExecuteToolsInParallelAsync(
|
||||
List<LlmService.ContentBlock> calls,
|
||||
List<ChatMessage> messages,
|
||||
AgentContext context,
|
||||
List<string> planSteps,
|
||||
ParallelState state,
|
||||
int baseMax, int maxRetry,
|
||||
Models.LlmSettings llm,
|
||||
int iteration,
|
||||
CancellationToken ct,
|
||||
List<string> statsUsedTools)
|
||||
{
|
||||
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
|
||||
|
||||
var activeToolNames = _tools.GetActiveTools(llm.DisabledTools)
|
||||
.Select(t => t.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var executableCalls = new List<LlmService.ContentBlock>();
|
||||
foreach (var call in calls)
|
||||
{
|
||||
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
|
||||
var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
|
||||
? call
|
||||
: new LlmService.ContentBlock
|
||||
{
|
||||
Type = call.Type,
|
||||
Text = call.Text,
|
||||
ToolName = resolvedToolName,
|
||||
ToolId = call.ToolId,
|
||||
ToolInput = call.ToolInput,
|
||||
};
|
||||
var signature = BuildToolCallSignature(effectiveCall);
|
||||
if (ShouldBlockRepeatedFailedCall(
|
||||
signature,
|
||||
state.LastFailedToolSignature,
|
||||
state.RepeatedFailedToolSignatureCount,
|
||||
maxRetry))
|
||||
{
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId,
|
||||
call.ToolName,
|
||||
BuildRepeatedFailureGuardMessage(call.ToolName, state.RepeatedFailedToolSignatureCount, maxRetry)));
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
call.ToolName,
|
||||
$"병렬 배치에서도 동일 호출 반복 실패를 감지해 실행을 건너뜁니다 ({state.RepeatedFailedToolSignatureCount}/{maxRetry})");
|
||||
state.StatsRepeatedFailureBlocks++;
|
||||
continue;
|
||||
}
|
||||
|
||||
executableCalls.Add(effectiveCall);
|
||||
}
|
||||
|
||||
if (executableCalls.Count == 0)
|
||||
return;
|
||||
|
||||
var maxConcurrency = GetMaxParallelToolConcurrency();
|
||||
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
|
||||
var tasks = executableCalls.Select(async call =>
|
||||
{
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
var tool = _tools.Get(call.ToolName);
|
||||
try
|
||||
{
|
||||
if (tool == null)
|
||||
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
||||
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, ct);
|
||||
sw.Stop();
|
||||
return (call, result, sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// 결과를 순서대로 메시지에 추가
|
||||
foreach (var (call, result, elapsed) in results)
|
||||
{
|
||||
EmitEvent(
|
||||
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
|
||||
call.ToolName,
|
||||
TruncateOutput(result.Output, 200),
|
||||
result.FilePath,
|
||||
elapsedMs: elapsed,
|
||||
iteration: iteration);
|
||||
|
||||
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
|
||||
state.ConsecutiveReadOnlySuccessTools = UpdateConsecutiveReadOnlySuccessTools(
|
||||
state.ConsecutiveReadOnlySuccessTools,
|
||||
call.ToolName,
|
||||
result.Success);
|
||||
state.ConsecutiveNonMutatingSuccessTools = UpdateConsecutiveNonMutatingSuccessTools(
|
||||
state.ConsecutiveNonMutatingSuccessTools,
|
||||
call.ToolName,
|
||||
result.Success);
|
||||
if (!statsUsedTools.Contains(call.ToolName))
|
||||
statsUsedTools.Add(call.ToolName);
|
||||
|
||||
state.TotalToolCalls++;
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000)));
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var signature = BuildToolCallSignature(call);
|
||||
if (string.Equals(state.LastFailedToolSignature, signature, StringComparison.Ordinal))
|
||||
state.RepeatedFailedToolSignatureCount++;
|
||||
else
|
||||
{
|
||||
state.LastFailedToolSignature = signature;
|
||||
state.RepeatedFailedToolSignatureCount = 1;
|
||||
}
|
||||
|
||||
state.ConsecutiveErrors++;
|
||||
state.RecoveryPendingAfterFailure = true;
|
||||
if (state.ConsecutiveErrors > maxRetry)
|
||||
{
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId, call.ToolName,
|
||||
$"[FAILED after retries] {TruncateOutput(result.Output, 500)}"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
state.ConsecutiveErrors = 0;
|
||||
if (state.RecoveryPendingAfterFailure)
|
||||
{
|
||||
state.StatsRecoveredAfterFailure++;
|
||||
state.RecoveryPendingAfterFailure = false;
|
||||
}
|
||||
state.LastFailedToolSignature = null;
|
||||
state.RepeatedFailedToolSignatureCount = 0;
|
||||
}
|
||||
|
||||
// 감사 로그
|
||||
if (llm.EnableAuditLog)
|
||||
{
|
||||
AuditLogService.LogToolCall(
|
||||
_conversationId, ActiveTab ?? "",
|
||||
call.ToolName,
|
||||
call.ToolInput?.ToString() ?? "",
|
||||
TruncateOutput(result.Output, 500),
|
||||
result.FilePath, result.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4282
src/AxCopilot/Services/Agent/AgentLoopService.cs
Normal file
4282
src/AxCopilot/Services/Agent/AgentLoopService.cs
Normal file
File diff suppressed because it is too large
Load Diff
1461
src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs
Normal file
1461
src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs
Normal file
File diff suppressed because it is too large
Load Diff
75
src/AxCopilot/Services/Agent/AgentLoopTransitions.cs
Normal file
75
src/AxCopilot/Services/Agent/AgentLoopTransitions.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
private static (bool ShouldRun, List<LlmService.ContentBlock> ParallelBatch, List<LlmService.ContentBlock> SequentialBatch)
|
||||
CreateParallelExecutionPlan(bool parallelEnabled, List<LlmService.ContentBlock> toolCalls)
|
||||
{
|
||||
if (!parallelEnabled || toolCalls.Count <= 1)
|
||||
return (false, new List<LlmService.ContentBlock>(), toolCalls);
|
||||
|
||||
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
|
||||
return (true, parallelBatch, sequentialBatch);
|
||||
}
|
||||
|
||||
private static (
|
||||
string? LastFailedToolSignature,
|
||||
int RepeatedFailedToolSignatureCount,
|
||||
int ConsecutiveErrors,
|
||||
bool CanRetry)
|
||||
ComputeFailureTransitionState(
|
||||
string currentToolCallSignature,
|
||||
string? lastFailedToolSignature,
|
||||
int repeatedFailedToolSignatureCount,
|
||||
int consecutiveErrors,
|
||||
int maxRetry)
|
||||
{
|
||||
if (string.Equals(lastFailedToolSignature, currentToolCallSignature, StringComparison.Ordinal))
|
||||
repeatedFailedToolSignatureCount++;
|
||||
else
|
||||
{
|
||||
lastFailedToolSignature = currentToolCallSignature;
|
||||
repeatedFailedToolSignatureCount = 1;
|
||||
}
|
||||
|
||||
consecutiveErrors++;
|
||||
var canRetry = consecutiveErrors <= maxRetry;
|
||||
return (lastFailedToolSignature, repeatedFailedToolSignatureCount, consecutiveErrors, canRetry);
|
||||
}
|
||||
|
||||
private static bool ShouldRunPostToolVerification(
|
||||
string? activeTab,
|
||||
string? toolName,
|
||||
bool toolSucceeded,
|
||||
bool codeVerificationEnabled,
|
||||
bool coworkVerificationEnabled)
|
||||
{
|
||||
if (!toolSucceeded || string.IsNullOrWhiteSpace(toolName))
|
||||
return false;
|
||||
|
||||
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
return codeVerificationEnabled && IsCodeVerificationTarget(toolName);
|
||||
|
||||
return coworkVerificationEnabled && IsDocumentCreationTool(toolName);
|
||||
}
|
||||
|
||||
private static (bool ShouldContinue, string? TerminalResponse, string? ToolResultMessage) EvaluateDevStepDecision(string? decision)
|
||||
{
|
||||
if (string.Equals(decision, "중단", StringComparison.Ordinal))
|
||||
return (false, "사용자가 개발자 모드에서 실행을 중단했습니다.", null);
|
||||
if (string.Equals(decision, "건너뛰기", StringComparison.Ordinal))
|
||||
return (true, null, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다.");
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
private static (bool ShouldContinue, string? TerminalResponse, string? ToolResultMessage) EvaluateScopeDecision(string? decision)
|
||||
{
|
||||
if (string.Equals(decision, "취소", StringComparison.Ordinal))
|
||||
return (false, "사용자가 작업을 취소했습니다.", null);
|
||||
if (string.Equals(decision, "건너뛰기", StringComparison.Ordinal))
|
||||
return (true, null, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다.");
|
||||
return (false, null, null);
|
||||
}
|
||||
}
|
||||
88
src/AxCopilot/Services/Agent/Base64Tool.cs
Normal file
88
src/AxCopilot/Services/Agent/Base64Tool.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>Base64/URL 인코딩·디코딩 도구.</summary>
|
||||
public class Base64Tool : IAgentTool
|
||||
{
|
||||
public string Name => "base64_tool";
|
||||
public string Description =>
|
||||
"Encode or decode Base64 and URL strings. Actions: " +
|
||||
"'b64encode' — encode text to Base64; " +
|
||||
"'b64decode' — decode Base64 to text; " +
|
||||
"'urlencode' — URL-encode text; " +
|
||||
"'urldecode' — URL-decode text; " +
|
||||
"'b64file' — encode a file to Base64 (max 5MB).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["b64encode", "b64decode", "urlencode", "urldecode", "b64file"],
|
||||
},
|
||||
["text"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Text to encode/decode",
|
||||
},
|
||||
["file_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File path for b64file action",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var text = args.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"b64encode" => ToolResult.Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(text))),
|
||||
"b64decode" => ToolResult.Ok(Encoding.UTF8.GetString(Convert.FromBase64String(text))),
|
||||
"urlencode" => ToolResult.Ok(Uri.EscapeDataString(text)),
|
||||
"urldecode" => ToolResult.Ok(Uri.UnescapeDataString(text)),
|
||||
"b64file" => EncodeFile(args, context),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail("Invalid Base64 string"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
|
||||
{
|
||||
if (!args.TryGetProperty("file_path", out var fp))
|
||||
return ToolResult.Fail("'file_path' is required for b64file action");
|
||||
var path = fp.GetString() ?? "";
|
||||
if (!Path.IsPathRooted(path)) path = Path.Combine(context.WorkFolder, path);
|
||||
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");
|
||||
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length > 5 * 1024 * 1024)
|
||||
return ToolResult.Fail($"File too large ({info.Length / 1024}KB). Max 5MB.");
|
||||
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var b64 = Convert.ToBase64String(bytes);
|
||||
if (b64.Length > 10000)
|
||||
return ToolResult.Ok($"Base64 ({b64.Length} chars, first 500):\n{b64[..500]}...");
|
||||
return ToolResult.Ok(b64);
|
||||
}
|
||||
}
|
||||
99
src/AxCopilot/Services/Agent/BatchSkill.cs
Normal file
99
src/AxCopilot/Services/Agent/BatchSkill.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 배치파일(.bat) / PowerShell 스크립트(.ps1)를 생성하는 내장 스킬.
|
||||
/// 파일 생성만 수행하며 자동 실행하지 않습니다.
|
||||
/// 시스템 수준 명령(레지스트리, 서비스, 드라이버 등)은 차단합니다.
|
||||
/// </summary>
|
||||
public class BatchSkill : IAgentTool
|
||||
{
|
||||
public string Name => "script_create";
|
||||
public string Description => "Create a batch (.bat) or PowerShell (.ps1) script file. The script is ONLY created, NOT executed. System-level commands are blocked.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.bat or .ps1). Relative to work folder." },
|
||||
["content"] = new() { Type = "string", Description = "Script content. Each line should have Korean comments explaining the command." },
|
||||
["description"] = new() { Type = "string", Description = "Brief description of what this script does." },
|
||||
},
|
||||
Required = ["path", "content"]
|
||||
};
|
||||
|
||||
// 시스템 수준 명령 차단 목록
|
||||
private static readonly string[] BlockedCommands =
|
||||
[
|
||||
"reg ", "reg.exe", "regedit",
|
||||
"sc ", "sc.exe",
|
||||
"net stop", "net start", "net user",
|
||||
"bcdedit", "diskpart", "format ",
|
||||
"shutdown", "schtasks",
|
||||
"wmic", "powercfg",
|
||||
"Set-Service", "Stop-Service", "Start-Service",
|
||||
"New-Service", "Remove-Service",
|
||||
"Set-ItemProperty.*HKLM", "Set-ItemProperty.*HKCU",
|
||||
"Remove-Item.*-Recurse.*-Force",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var content = args.GetProperty("content").GetString() ?? "";
|
||||
var desc = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
|
||||
// 확장자 검증
|
||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
if (ext != ".bat" && ext != ".ps1" && ext != ".cmd")
|
||||
return ToolResult.Fail("지원하는 스크립트 형식: .bat, .cmd, .ps1");
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
// 시스템 명령 차단 검사
|
||||
var contentLower = content.ToLowerInvariant();
|
||||
foreach (var blocked in BlockedCommands)
|
||||
{
|
||||
if (contentLower.Contains(blocked.ToLowerInvariant()))
|
||||
return ToolResult.Fail($"시스템 수준 명령이 포함되어 차단됨: {blocked.Trim()}");
|
||||
}
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// 파일 상단에 설명 주석 추가
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(desc))
|
||||
{
|
||||
var commentPrefix = ext == ".ps1" ? "#" : "REM";
|
||||
sb.AppendLine($"{commentPrefix} === {desc} ===");
|
||||
sb.AppendLine($"{commentPrefix} 이 스크립트는 AX Copilot에 의해 생성되었습니다.");
|
||||
sb.AppendLine($"{commentPrefix} 실행 전 내용을 반드시 확인하세요.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.Append(content);
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, sb.ToString(), new UTF8Encoding(false), ct);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"스크립트 파일 생성 완료: {fullPath}\n형식: {ext}, 설명: {(string.IsNullOrEmpty(desc) ? "(없음)" : desc)}\n⚠ 자동 실행되지 않습니다. 내용을 확인한 후 직접 실행하세요.",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"스크립트 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
207
src/AxCopilot/Services/Agent/BuildRunTool.cs
Normal file
207
src/AxCopilot/Services/Agent/BuildRunTool.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 프로젝트 빌드/테스트 실행 도구.
|
||||
/// 작업 폴더의 프로젝트 타입을 자동 감지하고 적절한 빌드/테스트 명령을 실행합니다.
|
||||
/// 사내 환경에서 설치된 도구만 사용하며, 빌더를 직접 설치하지 않습니다.
|
||||
/// </summary>
|
||||
public class BuildRunTool : IAgentTool
|
||||
{
|
||||
public string Name => "build_run";
|
||||
public string Description =>
|
||||
"Detect project type and run build/test commands. " +
|
||||
"Supports: .NET (dotnet), Maven (mvn), Gradle, Node.js (npm), Python (pytest), CMake, Make. " +
|
||||
"Actions: detect (show project type), build, test, run, custom (run arbitrary command with longer timeout).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform: detect, build, test, run, lint, format, custom",
|
||||
Enum = ["detect", "build", "test", "run", "lint", "format", "custom"],
|
||||
},
|
||||
["command"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Custom command to execute (required for action='custom')",
|
||||
},
|
||||
["project_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Subdirectory within work folder (optional, defaults to work folder root)",
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
// ProcessTool과 동일한 위험 명령 패턴
|
||||
private static readonly string[] DangerousPatterns =
|
||||
[
|
||||
"format ", "del /s", "rd /s", "rmdir /s", "rm -rf",
|
||||
"Remove-Item -Recurse -Force",
|
||||
"Stop-Computer", "Restart-Computer",
|
||||
"shutdown", "taskkill /f",
|
||||
"reg delete", "reg add",
|
||||
"net user", "net localgroup",
|
||||
"schtasks /create", "schtasks /delete",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "detect";
|
||||
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
|
||||
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
|
||||
|
||||
var workDir = context.WorkFolder;
|
||||
if (!string.IsNullOrEmpty(subPath))
|
||||
workDir = Path.Combine(workDir, subPath);
|
||||
|
||||
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
||||
return ToolResult.Fail($"작업 폴더가 유효하지 않습니다: {workDir}");
|
||||
|
||||
// 프로젝트 타입 감지
|
||||
var project = DetectProjectType(workDir);
|
||||
|
||||
if (action == "detect")
|
||||
{
|
||||
if (project == null)
|
||||
return ToolResult.Ok($"프로젝트 감지 실패: {workDir}\n알려진 프로젝트 마커 파일이 없습니다.");
|
||||
return ToolResult.Ok(
|
||||
$"프로젝트 감지 완료:\n" +
|
||||
$" 타입: {project.Type}\n" +
|
||||
$" 마커: {project.Marker}\n" +
|
||||
$" 빌드: {project.BuildCommand}\n" +
|
||||
$" 테스트: {project.TestCommand}\n" +
|
||||
$" 실행: {project.RunCommand}\n" +
|
||||
$" 린트: {(string.IsNullOrEmpty(project.LintCommand) ? "(미지원)" : project.LintCommand)}\n" +
|
||||
$" 포맷: {(string.IsNullOrEmpty(project.FormatCommand) ? "(미지원)" : project.FormatCommand)}\n" +
|
||||
$" 경로: {workDir}");
|
||||
}
|
||||
|
||||
// 실행할 명령 결정
|
||||
string? command;
|
||||
if (action == "custom")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(customCmd))
|
||||
return ToolResult.Fail("custom 액션에는 command 파라미터가 필요합니다.");
|
||||
command = customCmd;
|
||||
}
|
||||
else if (project == null)
|
||||
{
|
||||
return ToolResult.Fail("프로젝트 타입을 감지할 수 없습니다. action='custom'으로 직접 명령을 지정하세요.");
|
||||
}
|
||||
else
|
||||
{
|
||||
command = action switch
|
||||
{
|
||||
"build" => project.BuildCommand,
|
||||
"test" => project.TestCommand,
|
||||
"run" => project.RunCommand,
|
||||
"lint" => string.IsNullOrEmpty(project.LintCommand) ? null : project.LintCommand,
|
||||
"format" => string.IsNullOrEmpty(project.FormatCommand) ? null : project.FormatCommand,
|
||||
_ => project.BuildCommand,
|
||||
};
|
||||
if (command == null)
|
||||
return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다.");
|
||||
}
|
||||
|
||||
// 위험 명령 검사
|
||||
foreach (var pattern in DangerousPatterns)
|
||||
{
|
||||
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail($"차단된 명령 패턴: {pattern}");
|
||||
}
|
||||
|
||||
// 쓰기 권한 확인
|
||||
if (!await context.CheckWritePermissionAsync(Name, workDir))
|
||||
return ToolResult.Fail("빌드 실행 권한이 거부되었습니다.");
|
||||
|
||||
// 명령 실행 (ProcessTool 패턴)
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var timeout = app?.SettingsService?.Settings.Llm.Code.BuildTimeout ?? 120;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("cmd.exe", $"/C {command}")
|
||||
{
|
||||
WorkingDirectory = workDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return ToolResult.Fail("프로세스 시작 실패");
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
// 출력 제한 (8000자)
|
||||
if (stdout.Length > 8000) stdout = stdout[..8000] + "\n... (출력 잘림)";
|
||||
if (stderr.Length > 4000) stderr = stderr[..4000] + "\n... (출력 잘림)";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[{action}] {command}");
|
||||
sb.AppendLine($"[Exit code: {proc.ExitCode}]");
|
||||
if (!string.IsNullOrWhiteSpace(stdout)) sb.AppendLine(stdout);
|
||||
if (!string.IsNullOrWhiteSpace(stderr)) sb.AppendLine($"[stderr]\n{stderr}");
|
||||
|
||||
return proc.ExitCode == 0
|
||||
? ToolResult.Ok(sb.ToString())
|
||||
: ToolResult.Fail(sb.ToString());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ToolResult.Fail($"빌드 타임아웃 ({timeout}초 초과): {command}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private record ProjectInfo(string Type, string Marker, string BuildCommand, string TestCommand, string RunCommand, string LintCommand, string FormatCommand);
|
||||
|
||||
private static ProjectInfo? DetectProjectType(string dir)
|
||||
{
|
||||
if (Directory.GetFiles(dir, "*.sln").Length > 0)
|
||||
return new(".NET Solution", "*.sln", "dotnet build", "dotnet test", "dotnet run", "dotnet format --verify-no-changes", "dotnet format");
|
||||
if (Directory.GetFiles(dir, "*.csproj").Length > 0)
|
||||
return new(".NET Project", "*.csproj", "dotnet build", "dotnet test", "dotnet run", "dotnet format --verify-no-changes", "dotnet format");
|
||||
if (File.Exists(Path.Combine(dir, "pom.xml")))
|
||||
return new("Maven", "pom.xml", "mvn compile", "mvn test", "mvn exec:java", "mvn checkstyle:check", "");
|
||||
if (Directory.GetFiles(dir, "build.gradle*").Length > 0)
|
||||
return new("Gradle", "build.gradle", "gradle build", "gradle test", "gradle run", "gradle check", "");
|
||||
if (File.Exists(Path.Combine(dir, "package.json")))
|
||||
return new("Node.js", "package.json", "npm run build", "npm test", "npm start", "npx eslint .", "npx prettier --write .");
|
||||
if (File.Exists(Path.Combine(dir, "CMakeLists.txt")))
|
||||
return new("CMake", "CMakeLists.txt", "cmake --build build", "ctest --test-dir build", "", "", "");
|
||||
if (File.Exists(Path.Combine(dir, "pyproject.toml")))
|
||||
return new("Python (pyproject)", "pyproject.toml", "python -m build", "python -m pytest", "python -m", "python -m ruff check .", "python -m black .");
|
||||
if (File.Exists(Path.Combine(dir, "setup.py")))
|
||||
return new("Python (setup.py)", "setup.py", "python setup.py build", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
|
||||
if (File.Exists(Path.Combine(dir, "Makefile")))
|
||||
return new("Make", "Makefile", "make", "make test", "make run", "make lint", "make format");
|
||||
if (Directory.GetFiles(dir, "*.py").Length > 0)
|
||||
return new("Python Scripts", "*.py", "", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
537
src/AxCopilot/Services/Agent/ChartSkill.cs
Normal file
537
src/AxCopilot/Services/Agent/ChartSkill.cs
Normal file
@@ -0,0 +1,537 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CSS/SVG 기반 차트를 HTML 파일로 생성하는 스킬.
|
||||
/// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
|
||||
/// </summary>
|
||||
public class ChartSkill : IAgentTool
|
||||
{
|
||||
public string Name => "chart_create";
|
||||
public string Description =>
|
||||
"Create a styled HTML chart document with CSS/SVG-based charts. " +
|
||||
"Supports chart types: bar, horizontal_bar, stacked_bar, line, area, pie, donut, radar, progress, comparison. " +
|
||||
"Multiple charts can be placed in one document using the 'charts' array. " +
|
||||
"Applies design mood from TemplateService (modern, professional, creative, etc.).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
|
||||
["title"] = new() { Type = "string", Description = "Document title" },
|
||||
["charts"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Array of chart objects. Each chart: " +
|
||||
"{\"type\": \"bar|horizontal_bar|stacked_bar|line|area|pie|donut|radar|progress|comparison\", " +
|
||||
"\"title\": \"Chart Title\", " +
|
||||
"\"labels\": [\"A\",\"B\",\"C\"], " +
|
||||
"\"datasets\": [{\"name\": \"Series1\", \"values\": [10,20,30], \"color\": \"#4B5EFC\"}], " +
|
||||
"\"unit\": \"%\"}",
|
||||
Items = new ToolProperty { Type = "object" },
|
||||
},
|
||||
["mood"] = new() { Type = "string", Description = "Design mood: modern, professional, creative, dark, dashboard, etc. Default: dashboard" },
|
||||
["layout"] = new() { Type = "string", Description = "Chart layout: 'single' (one per row) or 'grid' (2-column grid). Default: single" },
|
||||
},
|
||||
Required = ["path", "title", "charts"]
|
||||
};
|
||||
|
||||
// 기본 차트 팔레트
|
||||
private static readonly string[] Palette =
|
||||
[
|
||||
"#4B5EFC", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
|
||||
"#06B6D4", "#EC4899", "#84CC16", "#F97316", "#6366F1",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "chart.html";
|
||||
var title = args.GetProperty("title").GetString() ?? "Chart";
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
|
||||
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".html";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
if (!args.TryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("charts 파라미터가 필요합니다 (배열 형식).");
|
||||
|
||||
var chartCount = chartsEl.GetArrayLength();
|
||||
var body = new StringBuilder();
|
||||
|
||||
body.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
|
||||
if (layout == "grid" && chartCount > 1)
|
||||
body.AppendLine("<div class=\"grid-2\">");
|
||||
|
||||
int chartIdx = 0;
|
||||
foreach (var chartEl in chartsEl.EnumerateArray())
|
||||
{
|
||||
var chartHtml = RenderChart(chartEl, chartIdx);
|
||||
body.AppendLine("<div class=\"card\" style=\"margin-bottom:20px;\">");
|
||||
body.AppendLine(chartHtml);
|
||||
body.AppendLine("</div>");
|
||||
chartIdx++;
|
||||
}
|
||||
|
||||
if (layout == "grid" && chartCount > 1)
|
||||
body.AppendLine("</div>");
|
||||
|
||||
var css = TemplateService.GetCss(mood) + "\n" + ChartCss;
|
||||
|
||||
var html = $@"<!DOCTYPE html>
|
||||
<html lang=""ko"">
|
||||
<head>
|
||||
<meta charset=""UTF-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>{Escape(title)}</title>
|
||||
<style>
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""container"">
|
||||
{body}
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, html, Encoding.UTF8, ct);
|
||||
return ToolResult.Ok($"차트 문서 생성 완료: {fullPath} ({chartCount}개 차트)", fullPath);
|
||||
}
|
||||
|
||||
private string RenderChart(JsonElement chart, int idx)
|
||||
{
|
||||
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
|
||||
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
|
||||
var unit = chart.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
|
||||
var labels = ParseStringArray(chart, "labels");
|
||||
var datasets = ParseDatasets(chart);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(chartTitle))
|
||||
sb.AppendLine($"<h3 style=\"margin-bottom:12px;\">{Escape(chartTitle)}</h3>");
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "bar":
|
||||
sb.Append(RenderBarChart(labels, datasets, unit, false));
|
||||
break;
|
||||
case "horizontal_bar":
|
||||
sb.Append(RenderBarChart(labels, datasets, unit, true));
|
||||
break;
|
||||
case "stacked_bar":
|
||||
sb.Append(RenderStackedBar(labels, datasets, unit));
|
||||
break;
|
||||
case "line":
|
||||
case "area":
|
||||
sb.Append(RenderLineChart(labels, datasets, unit, type == "area"));
|
||||
break;
|
||||
case "pie":
|
||||
case "donut":
|
||||
sb.Append(RenderPieChart(labels, datasets, type == "donut"));
|
||||
break;
|
||||
case "progress":
|
||||
sb.Append(RenderProgressChart(labels, datasets, unit));
|
||||
break;
|
||||
case "comparison":
|
||||
sb.Append(RenderComparisonChart(labels, datasets, unit));
|
||||
break;
|
||||
case "radar":
|
||||
sb.Append(RenderRadarChart(labels, datasets));
|
||||
break;
|
||||
default:
|
||||
sb.Append(RenderBarChart(labels, datasets, unit, false));
|
||||
break;
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (datasets.Count > 1)
|
||||
{
|
||||
sb.AppendLine("<div class=\"chart-legend\">");
|
||||
foreach (var ds in datasets)
|
||||
sb.AppendLine($"<span class=\"legend-item\"><span class=\"legend-dot\" style=\"background:{ds.Color}\"></span>{Escape(ds.Name)}</span>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Bar Chart ───────────────────────────────────────────────────────
|
||||
|
||||
private static string RenderBarChart(List<string> labels, List<Dataset> datasets, string unit, bool horizontal)
|
||||
{
|
||||
var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max();
|
||||
if (maxVal <= 0) maxVal = 1;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (horizontal)
|
||||
{
|
||||
sb.AppendLine("<div class=\"hbar-chart\">");
|
||||
for (int i = 0; i < labels.Count; i++)
|
||||
{
|
||||
var val = datasets.Count > 0 && i < datasets[0].Values.Count ? datasets[0].Values[i] : 0;
|
||||
var pct = (int)(val / maxVal * 100);
|
||||
var color = datasets.Count > 0 ? datasets[0].Color : Palette[0];
|
||||
sb.AppendLine($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
|
||||
sb.AppendLine($"<div class=\"hbar-track\"><div class=\"hbar-fill\" style=\"width:{pct}%;background:{color};\"></div></div>");
|
||||
sb.AppendLine($"<span class=\"hbar-value\">{val:G}{unit}</span></div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("<div class=\"vbar-chart\">");
|
||||
sb.AppendLine("<div class=\"vbar-bars\">");
|
||||
for (int i = 0; i < labels.Count; i++)
|
||||
{
|
||||
sb.AppendLine("<div class=\"vbar-group\">");
|
||||
foreach (var ds in datasets)
|
||||
{
|
||||
var val = i < ds.Values.Count ? ds.Values[i] : 0;
|
||||
var pct = (int)(val / maxVal * 100);
|
||||
sb.AppendLine($"<div class=\"vbar-bar\" style=\"height:{pct}%;background:{ds.Color};\" title=\"{val:G}{unit}\"></div>");
|
||||
}
|
||||
sb.AppendLine($"<div class=\"vbar-label\">{Escape(labels[i])}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
sb.AppendLine("</div></div>");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Stacked Bar ─────────────────────────────────────────────────────
|
||||
|
||||
private static string RenderStackedBar(List<string> labels, List<Dataset> datasets, string unit)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<div class=\"hbar-chart\">");
|
||||
for (int i = 0; i < labels.Count; i++)
|
||||
{
|
||||
var total = datasets.Sum(ds => i < ds.Values.Count ? ds.Values[i] : 0);
|
||||
sb.AppendLine($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
|
||||
sb.AppendLine("<div class=\"hbar-track\" style=\"display:flex;\">");
|
||||
foreach (var ds in datasets)
|
||||
{
|
||||
var val = i < ds.Values.Count ? ds.Values[i] : 0;
|
||||
var pct = total > 0 ? (int)(val / total * 100) : 0;
|
||||
sb.AppendLine($"<div style=\"width:{pct}%;background:{ds.Color};height:100%;\" title=\"{ds.Name}: {val:G}{unit}\"></div>");
|
||||
}
|
||||
sb.AppendLine($"</div><span class=\"hbar-value\">{total:G}{unit}</span></div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Line / Area Chart (SVG) ─────────────────────────────────────────
|
||||
|
||||
private static string RenderLineChart(List<string> labels, List<Dataset> datasets, string unit, bool isArea)
|
||||
{
|
||||
var allVals = datasets.SelectMany(d => d.Values).ToList();
|
||||
var maxVal = allVals.DefaultIfEmpty(1).Max();
|
||||
var minVal = allVals.DefaultIfEmpty(0).Min();
|
||||
if (maxVal <= minVal) maxVal = minVal + 1;
|
||||
|
||||
int w = 600, h = 300, padL = 50, padR = 20, padT = 20, padB = 40;
|
||||
var chartW = w - padL - padR;
|
||||
var chartH = h - padT - padB;
|
||||
var n = labels.Count;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\">");
|
||||
|
||||
// Y축 그리드
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
var y = padT + chartH - (chartH * i / 4.0);
|
||||
var val = minVal + (maxVal - minVal) * i / 4.0;
|
||||
sb.AppendLine($"<line x1=\"{padL}\" y1=\"{y:F0}\" x2=\"{w - padR}\" y2=\"{y:F0}\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
|
||||
sb.AppendLine($"<text x=\"{padL - 8}\" y=\"{y + 4:F0}\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">{val:G3}{unit}</text>");
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
|
||||
sb.AppendLine($"<text x=\"{x:F0}\" y=\"{h - 8}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">{Escape(labels[i])}</text>");
|
||||
}
|
||||
|
||||
// 데이터셋
|
||||
foreach (var ds in datasets)
|
||||
{
|
||||
var points = new List<(double x, double y)>();
|
||||
for (int i = 0; i < Math.Min(n, ds.Values.Count); i++)
|
||||
{
|
||||
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
|
||||
var y = padT + chartH - ((ds.Values[i] - minVal) / (maxVal - minVal) * chartH);
|
||||
points.Add((x, y));
|
||||
}
|
||||
|
||||
var pathData = string.Join(" ", points.Select((p, i) => $"{(i == 0 ? "M" : "L")}{p.x:F1},{p.y:F1}"));
|
||||
|
||||
if (isArea && points.Count > 1)
|
||||
{
|
||||
var areaPath = pathData + $" L{points.Last().x:F1},{padT + chartH} L{points.First().x:F1},{padT + chartH} Z";
|
||||
sb.AppendLine($"<path d=\"{areaPath}\" fill=\"{ds.Color}\" opacity=\"0.15\"/>");
|
||||
}
|
||||
|
||||
sb.AppendLine($"<path d=\"{pathData}\" fill=\"none\" stroke=\"{ds.Color}\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
|
||||
|
||||
// 데이터 포인트
|
||||
foreach (var (px, py) in points)
|
||||
sb.AppendLine($"<circle cx=\"{px:F1}\" cy=\"{py:F1}\" r=\"4\" fill=\"{ds.Color}\" stroke=\"white\" stroke-width=\"2\"/>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Pie / Donut Chart (SVG) ─────────────────────────────────────────
|
||||
|
||||
private static string RenderPieChart(List<string> labels, List<Dataset> datasets, bool isDonut)
|
||||
{
|
||||
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
|
||||
var total = values.Sum();
|
||||
if (total <= 0) total = 1;
|
||||
|
||||
int cx = 150, cy = 150, r = 120;
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
|
||||
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\">");
|
||||
|
||||
double startAngle = -90;
|
||||
for (int i = 0; i < Math.Min(values.Count, labels.Count); i++)
|
||||
{
|
||||
var pct = values[i] / total;
|
||||
var angle = pct * 360;
|
||||
var endAngle = startAngle + angle;
|
||||
|
||||
var x1 = cx + r * Math.Cos(startAngle * Math.PI / 180);
|
||||
var y1 = cy + r * Math.Sin(startAngle * Math.PI / 180);
|
||||
var x2 = cx + r * Math.Cos(endAngle * Math.PI / 180);
|
||||
var y2 = cy + r * Math.Sin(endAngle * Math.PI / 180);
|
||||
var largeArc = angle > 180 ? 1 : 0;
|
||||
|
||||
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
|
||||
sb.AppendLine($"<path d=\"M{cx},{cy} L{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1} Z\" fill=\"{color}\"/>");
|
||||
startAngle = endAngle;
|
||||
}
|
||||
|
||||
if (isDonut)
|
||||
sb.AppendLine($"<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r * 0.55}\" fill=\"white\"/>");
|
||||
|
||||
sb.AppendLine("</svg>");
|
||||
|
||||
// 범례
|
||||
sb.AppendLine("<div class=\"pie-legend\">");
|
||||
for (int i = 0; i < Math.Min(values.Count, labels.Count); i++)
|
||||
{
|
||||
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
|
||||
var pct = values[i] / total * 100;
|
||||
sb.AppendLine($"<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:{color}\"></span>{Escape(labels[i])} <span style=\"color:#6B7280;font-size:12px;\">({pct:F1}%)</span></div>");
|
||||
}
|
||||
sb.AppendLine("</div></div>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Progress Chart ──────────────────────────────────────────────────
|
||||
|
||||
private static string RenderProgressChart(List<string> labels, List<Dataset> datasets, string unit)
|
||||
{
|
||||
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < Math.Min(labels.Count, values.Count); i++)
|
||||
{
|
||||
var pct = Math.Clamp(values[i], 0, 100);
|
||||
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
|
||||
if (datasets.Count > 0 && !string.IsNullOrEmpty(datasets[0].Color))
|
||||
color = datasets[0].Color;
|
||||
sb.AppendLine($"<div style=\"margin-bottom:12px;\"><div style=\"display:flex;justify-content:space-between;margin-bottom:4px;\"><span style=\"font-size:13px;font-weight:600;\">{Escape(labels[i])}</span><span style=\"font-size:13px;color:#6B7280;\">{values[i]:G}{unit}</span></div>");
|
||||
sb.AppendLine($"<div class=\"progress\"><div class=\"progress-fill\" style=\"width:{pct}%;background:{color};\"></div></div></div>");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Comparison Chart ────────────────────────────────────────────────
|
||||
|
||||
private static string RenderComparisonChart(List<string> labels, List<Dataset> datasets, string unit)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<table style=\"width:100%;border-collapse:collapse;\">");
|
||||
sb.AppendLine("<tr><th style=\"text-align:left;padding:8px 12px;\">항목</th>");
|
||||
foreach (var ds in datasets)
|
||||
sb.AppendLine($"<th style=\"text-align:center;padding:8px 12px;color:{ds.Color};\">{Escape(ds.Name)}</th>");
|
||||
sb.AppendLine("</tr>");
|
||||
for (int i = 0; i < labels.Count; i++)
|
||||
{
|
||||
sb.Append($"<tr style=\"border-top:1px solid #E5E7EB;\"><td style=\"padding:8px 12px;font-weight:500;\">{Escape(labels[i])}</td>");
|
||||
foreach (var ds in datasets)
|
||||
{
|
||||
var val = i < ds.Values.Count ? ds.Values[i] : 0;
|
||||
sb.Append($"<td style=\"text-align:center;padding:8px 12px;\">{val:G}{unit}</td>");
|
||||
}
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
sb.AppendLine("</table>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Radar Chart (SVG) ───────────────────────────────────────────────
|
||||
|
||||
private static string RenderRadarChart(List<string> labels, List<Dataset> datasets)
|
||||
{
|
||||
int cx = 150, cy = 150, r = 110;
|
||||
var n = labels.Count;
|
||||
if (n < 3) return "<p>레이더 차트는 최소 3개 항목이 필요합니다.</p>";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\">");
|
||||
|
||||
// 그리드
|
||||
for (int level = 1; level <= 4; level++)
|
||||
{
|
||||
var lr = r * level / 4.0;
|
||||
var points = string.Join(" ", Enumerable.Range(0, n).Select(i =>
|
||||
{
|
||||
var angle = (360.0 / n * i - 90) * Math.PI / 180;
|
||||
return $"{cx + lr * Math.Cos(angle):F1},{cy + lr * Math.Sin(angle):F1}";
|
||||
}));
|
||||
sb.AppendLine($"<polygon points=\"{points}\" fill=\"none\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
|
||||
}
|
||||
|
||||
// 축선 + 라벨
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var angle = (360.0 / n * i - 90) * Math.PI / 180;
|
||||
var x = cx + r * Math.Cos(angle);
|
||||
var y = cy + r * Math.Sin(angle);
|
||||
sb.AppendLine($"<line x1=\"{cx}\" y1=\"{cy}\" x2=\"{x:F1}\" y2=\"{y:F1}\" stroke=\"#D1D5DB\" stroke-width=\"1\"/>");
|
||||
var lx = cx + (r + 16) * Math.Cos(angle);
|
||||
var ly = cy + (r + 16) * Math.Sin(angle);
|
||||
sb.AppendLine($"<text x=\"{lx:F0}\" y=\"{ly + 4:F0}\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\">{Escape(labels[i])}</text>");
|
||||
}
|
||||
|
||||
// 데이터
|
||||
var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max();
|
||||
if (maxVal <= 0) maxVal = 1;
|
||||
foreach (var ds in datasets)
|
||||
{
|
||||
var points = string.Join(" ", Enumerable.Range(0, n).Select(i =>
|
||||
{
|
||||
var val = i < ds.Values.Count ? ds.Values[i] : 0;
|
||||
var dr = r * val / maxVal;
|
||||
var angle = (360.0 / n * i - 90) * Math.PI / 180;
|
||||
return $"{cx + dr * Math.Cos(angle):F1},{cy + dr * Math.Sin(angle):F1}";
|
||||
}));
|
||||
sb.AppendLine($"<polygon points=\"{points}\" fill=\"{ds.Color}\" fill-opacity=\"0.2\" stroke=\"{ds.Color}\" stroke-width=\"2\"/>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static List<string> ParseStringArray(JsonElement parent, string prop)
|
||||
{
|
||||
if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return new();
|
||||
return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
|
||||
}
|
||||
|
||||
private List<Dataset> ParseDatasets(JsonElement chart)
|
||||
{
|
||||
if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
// datasets 없으면 values 배열에서 단일 데이터셋 생성
|
||||
if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
new Dataset
|
||||
{
|
||||
Name = "Data",
|
||||
Values = vals.EnumerateArray().Select(v => v.TryGetDouble(out var d) ? d : 0).ToList(),
|
||||
Color = Palette[0],
|
||||
}
|
||||
};
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
var list = new List<Dataset>();
|
||||
int colorIdx = 0;
|
||||
foreach (var ds in dsArr.EnumerateArray())
|
||||
{
|
||||
var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
|
||||
var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
|
||||
var values = new List<double>();
|
||||
if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList();
|
||||
list.Add(new Dataset { Name = name, Values = values, Color = color });
|
||||
colorIdx++;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string Escape(string s) =>
|
||||
s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
|
||||
private static string FormatSize(long bytes) =>
|
||||
bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" };
|
||||
|
||||
private sealed class Dataset
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public List<double> Values { get; init; } = new();
|
||||
public string Color { get; init; } = "#4B5EFC";
|
||||
}
|
||||
|
||||
// ─── Chart CSS ───────────────────────────────────────────────────────
|
||||
|
||||
private const string ChartCss = @"
|
||||
/* Vertical Bar Chart */
|
||||
.vbar-chart { margin: 16px 0; }
|
||||
.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; }
|
||||
.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; }
|
||||
.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; }
|
||||
.vbar-bar:hover { opacity: 0.8; }
|
||||
.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; }
|
||||
|
||||
/* Horizontal Bar Chart */
|
||||
.hbar-chart { margin: 12px 0; }
|
||||
.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; }
|
||||
.hbar-track { flex: 1; height: 22px; background: #F3F4F6; border-radius: 6px; overflow: hidden; }
|
||||
.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; }
|
||||
.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; }
|
||||
|
||||
/* Line/Area Chart */
|
||||
.line-chart-svg { width: 100%; max-width: 600px; height: auto; }
|
||||
|
||||
/* Legend */
|
||||
.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
|
||||
/* Pie Legend */
|
||||
.pie-legend { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; }
|
||||
";
|
||||
}
|
||||
388
src/AxCopilot/Services/Agent/CheckpointTool.cs
Normal file
388
src/AxCopilot/Services/Agent/CheckpointTool.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 파일 시스템 체크포인트(스냅샷)를 생성/복원/삭제하는 도구.
|
||||
/// 작업 폴더의 텍스트 파일을 .ax/checkpoints/ 에 백업하여 undo/rollback을 지원합니다.
|
||||
/// </summary>
|
||||
public class CheckpointTool : IAgentTool
|
||||
{
|
||||
public string Name => "checkpoint";
|
||||
|
||||
public string Description =>
|
||||
"Create, list, or restore file system checkpoints for undo/rollback. " +
|
||||
"Checkpoints capture text files in the working folder as snapshots. " +
|
||||
"- action=\"create\": Create a new checkpoint (name optional)\n" +
|
||||
"- action=\"list\": List all checkpoints\n" +
|
||||
"- action=\"restore\": Restore files from a checkpoint (id or name required, requires user approval)\n" +
|
||||
"- action=\"delete\": Delete a checkpoint (id required)";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "create | list | restore | delete",
|
||||
Enum = ["create", "list", "restore", "delete"],
|
||||
},
|
||||
["name"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Checkpoint name (for create/restore)",
|
||||
},
|
||||
["id"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Checkpoint ID (for restore/delete)",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
private const int MaxCheckpoints = 10;
|
||||
private const long MaxFileSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"bin", "obj", "node_modules", ".git", ".vs", ".ax", "packages",
|
||||
"Debug", "Release", "TestResults", ".idea", "__pycache__",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".cs", ".py", ".js", ".ts", ".java", ".cpp", ".c", ".h", ".hpp",
|
||||
".xml", ".json", ".yaml", ".yml", ".md", ".txt", ".csv", ".html", ".htm",
|
||||
".css", ".sql", ".sh", ".bat", ".ps1", ".config", ".ini", ".xaml",
|
||||
".csproj", ".sln", ".props", ".targets", ".editorconfig", ".gitignore",
|
||||
".tsx", ".jsx", ".vue", ".svelte", ".scss", ".less", ".toml", ".env",
|
||||
".razor", ".proto", ".graphql", ".rs", ".go", ".rb", ".php", ".swift",
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
return ToolResult.Fail("action이 필요합니다.");
|
||||
var action = actionEl.GetString() ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||
|
||||
var checkpointDir = Path.Combine(context.WorkFolder, ".ax", "checkpoints");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"create" => await CreateCheckpoint(args, context, checkpointDir, ct),
|
||||
"list" => ListCheckpoints(checkpointDir),
|
||||
"restore" => await RestoreCheckpoint(args, context, checkpointDir, ct),
|
||||
"delete" => DeleteCheckpoint(args, checkpointDir),
|
||||
_ => ToolResult.Fail($"알 수 없는 액션: {action}. create | list | restore | delete 중 선택하세요."),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed";
|
||||
// 이름에서 파일 시스템 비안전 문자 제거
|
||||
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
var folderName = $"{timestamp}_{name}";
|
||||
var cpDir = Path.Combine(checkpointDir, folderName);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(cpDir);
|
||||
|
||||
// 추적 대상 텍스트 파일 수집
|
||||
var files = CollectTextFiles(context.WorkFolder);
|
||||
if (files.Count == 0)
|
||||
return ToolResult.Fail("체크포인트할 텍스트 파일이 없습니다.");
|
||||
|
||||
var manifest = new CheckpointManifest
|
||||
{
|
||||
Name = name,
|
||||
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
WorkFolder = context.WorkFolder,
|
||||
Files = [],
|
||||
};
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = Path.GetRelativePath(context.WorkFolder, file);
|
||||
var destPath = Path.Combine(cpDir, relativePath);
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
await CopyFileAsync(file, destPath, ct);
|
||||
|
||||
// SHA256 해시 계산
|
||||
var hash = await ComputeHashAsync(file, ct);
|
||||
manifest.Files.Add(new FileEntry { Path = relativePath, Hash = hash });
|
||||
}
|
||||
|
||||
// manifest.json 저장
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
await TextFileCodec.WriteAllTextAsync(
|
||||
Path.Combine(cpDir, "manifest.json"),
|
||||
manifestJson,
|
||||
TextFileCodec.Utf8NoBom,
|
||||
ct);
|
||||
|
||||
// 최대 개수 초과 시 가장 오래된 체크포인트 삭제
|
||||
CleanupOldCheckpoints(checkpointDir);
|
||||
|
||||
return ToolResult.Ok($"체크포인트 생성 완료: {folderName}\n파일 수: {manifest.Files.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"체크포인트 생성 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ListCheckpoints(string checkpointDir)
|
||||
{
|
||||
if (!Directory.Exists(checkpointDir))
|
||||
return ToolResult.Ok("저장된 체크포인트가 없습니다.");
|
||||
|
||||
var dirs = Directory.GetDirectories(checkpointDir)
|
||||
.OrderByDescending(d => d)
|
||||
.ToList();
|
||||
|
||||
if (dirs.Count == 0)
|
||||
return ToolResult.Ok("저장된 체크포인트가 없습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"체크포인트 {dirs.Count}개:");
|
||||
for (var i = 0; i < dirs.Count; i++)
|
||||
{
|
||||
var dirName = Path.GetFileName(dirs[i]);
|
||||
var manifestPath = Path.Combine(dirs[i], "manifest.json");
|
||||
var fileCount = 0;
|
||||
var createdAt = "";
|
||||
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(manifestPath).Text;
|
||||
var manifest = JsonSerializer.Deserialize<CheckpointManifest>(json);
|
||||
fileCount = manifest?.Files.Count ?? 0;
|
||||
createdAt = manifest?.CreatedAt ?? "";
|
||||
}
|
||||
catch { /* manifest 읽기 실패 무시 */ }
|
||||
}
|
||||
|
||||
sb.AppendLine($" [{i}] {dirName} — 파일 {fileCount}개{(string.IsNullOrEmpty(createdAt) ? "" : $", {createdAt}")}");
|
||||
}
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private async Task<ToolResult> RestoreCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(checkpointDir))
|
||||
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
|
||||
|
||||
var dirs = Directory.GetDirectories(checkpointDir)
|
||||
.OrderByDescending(d => d)
|
||||
.ToList();
|
||||
|
||||
if (dirs.Count == 0)
|
||||
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
|
||||
|
||||
// ID 또는 이름으로 체크포인트 찾기
|
||||
string? targetDir = null;
|
||||
|
||||
if (args.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
if (id >= 0 && id < dirs.Count)
|
||||
targetDir = dirs[id];
|
||||
}
|
||||
|
||||
if (targetDir == null && args.TryGetProperty("name", out var nameEl))
|
||||
{
|
||||
var name = nameEl.GetString() ?? "";
|
||||
targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (targetDir == null)
|
||||
return ToolResult.Fail("체크포인트를 찾을 수 없습니다. id 또는 name을 확인하세요.");
|
||||
|
||||
// 사용자 승인 요청
|
||||
if (context.AskPermission != null)
|
||||
{
|
||||
var approved = await context.AskPermission("checkpoint_restore", $"체크포인트 복원: {Path.GetFileName(targetDir)}");
|
||||
if (!approved)
|
||||
return ToolResult.Ok("사용자가 복원을 거부했습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var manifestPath = Path.Combine(targetDir, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
return ToolResult.Fail("체크포인트 매니페스트를 찾을 수 없습니다.");
|
||||
|
||||
var manifestJson = (await TextFileCodec.ReadAllTextAsync(manifestPath, ct)).Text;
|
||||
var manifest = JsonSerializer.Deserialize<CheckpointManifest>(manifestJson);
|
||||
if (manifest == null)
|
||||
return ToolResult.Fail("매니페스트 파싱 오류");
|
||||
|
||||
var restoredCount = 0;
|
||||
foreach (var entry in manifest.Files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var srcPath = Path.Combine(targetDir, entry.Path);
|
||||
var destPath = Path.Combine(context.WorkFolder, entry.Path);
|
||||
|
||||
if (!File.Exists(srcPath)) continue;
|
||||
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
await CopyFileAsync(srcPath, destPath, ct);
|
||||
restoredCount++;
|
||||
}
|
||||
|
||||
return ToolResult.Ok($"체크포인트 복원 완료: {Path.GetFileName(targetDir)}\n복원 파일 수: {restoredCount}/{manifest.Files.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"체크포인트 복원 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult DeleteCheckpoint(JsonElement args, string checkpointDir)
|
||||
{
|
||||
if (!Directory.Exists(checkpointDir))
|
||||
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
|
||||
|
||||
var dirs = Directory.GetDirectories(checkpointDir)
|
||||
.OrderByDescending(d => d)
|
||||
.ToList();
|
||||
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
|
||||
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
if (id < 0 || id >= dirs.Count)
|
||||
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");
|
||||
|
||||
try
|
||||
{
|
||||
var target = dirs[id];
|
||||
var name = Path.GetFileName(target);
|
||||
Directory.Delete(target, recursive: true);
|
||||
return ToolResult.Ok($"체크포인트 삭제됨: {name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"체크포인트 삭제 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private List<string> CollectTextFiles(string workFolder)
|
||||
{
|
||||
var files = new List<string>();
|
||||
CollectFilesRecursive(workFolder, workFolder, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
private void CollectFilesRecursive(string dir, string rootDir, List<string> files)
|
||||
{
|
||||
var dirName = Path.GetFileName(dir);
|
||||
if (dir != rootDir && SkipDirs.Contains(dirName))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(dir))
|
||||
{
|
||||
var ext = Path.GetExtension(file);
|
||||
if (!TextExtensions.Contains(ext)) continue;
|
||||
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > MaxFileSize) continue;
|
||||
|
||||
files.Add(file);
|
||||
}
|
||||
|
||||
foreach (var subDir in Directory.GetDirectories(dir))
|
||||
CollectFilesRecursive(subDir, rootDir, files);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { /* 접근 불가 디렉토리 무시 */ }
|
||||
}
|
||||
|
||||
private static async Task CopyFileAsync(string src, string dest, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
await using var srcStream = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, useAsync: true);
|
||||
await using var destStream = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, buffer.Length, useAsync: true);
|
||||
int bytesRead;
|
||||
while ((bytesRead = await srcStream.ReadAsync(buffer, ct)) > 0)
|
||||
await destStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
|
||||
var hash = await sha256.ComputeHashAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void CleanupOldCheckpoints(string checkpointDir)
|
||||
{
|
||||
if (!Directory.Exists(checkpointDir)) return;
|
||||
var dirs = Directory.GetDirectories(checkpointDir)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
while (dirs.Count > MaxCheckpoints)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dirs[0], recursive: true);
|
||||
dirs.RemoveAt(0);
|
||||
}
|
||||
catch { break; }
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Models
|
||||
|
||||
private class CheckpointManifest
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string CreatedAt { get; set; } = "";
|
||||
public string WorkFolder { get; set; } = "";
|
||||
public List<FileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
private class FileEntry
|
||||
{
|
||||
public string Path { get; set; } = "";
|
||||
public string Hash { get; set; } = "";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
90
src/AxCopilot/Services/Agent/ClipboardTool.cs
Normal file
90
src/AxCopilot/Services/Agent/ClipboardTool.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 클립보드 읽기·쓰기 도구.
|
||||
/// 에이전트가 클립보드를 통해 데이터를 주고받을 수 있게 합니다.
|
||||
/// </summary>
|
||||
public class ClipboardTool : IAgentTool
|
||||
{
|
||||
public string Name => "clipboard_tool";
|
||||
public string Description =>
|
||||
"Read or write the Windows clipboard. Actions: " +
|
||||
"'read' — get current clipboard text content; " +
|
||||
"'write' — set clipboard text content; " +
|
||||
"'has_text' — check if clipboard contains text; " +
|
||||
"'has_image' — check if clipboard contains an image.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["read", "write", "has_text", "has_image"],
|
||||
},
|
||||
["text"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Text to write to clipboard (required for 'write' action)",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
// 클립보드는 STA 스레드에서만 접근 가능
|
||||
ToolResult? result = null;
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
result = action switch
|
||||
{
|
||||
"read" => ReadClipboard(),
|
||||
"write" => WriteClipboard(args),
|
||||
"has_text" => ToolResult.Ok(Clipboard.ContainsText() ? "true" : "false"),
|
||||
"has_image" => ToolResult.Ok(Clipboard.ContainsImage() ? "true" : "false"),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
};
|
||||
});
|
||||
return Task.FromResult(result ?? ToolResult.Fail("클립보드 접근 실패"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"클립보드 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ReadClipboard()
|
||||
{
|
||||
if (!Clipboard.ContainsText())
|
||||
return ToolResult.Ok("(clipboard is empty or contains non-text data)");
|
||||
|
||||
var text = Clipboard.GetText();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return ToolResult.Ok("(empty)");
|
||||
|
||||
if (text.Length > 10000)
|
||||
return ToolResult.Ok(text[..10000] + $"\n\n... (truncated, total {text.Length} chars)");
|
||||
|
||||
return ToolResult.Ok(text);
|
||||
}
|
||||
|
||||
private static ToolResult WriteClipboard(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("text", out var textProp))
|
||||
return ToolResult.Fail("'text' parameter is required for write action");
|
||||
|
||||
var text = textProp.GetString() ?? "";
|
||||
Clipboard.SetText(text);
|
||||
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
|
||||
}
|
||||
}
|
||||
429
src/AxCopilot/Services/Agent/CodeReviewTool.cs
Normal file
429
src/AxCopilot/Services/Agent/CodeReviewTool.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// AI 코드 리뷰 도구.
|
||||
/// Git diff 분석, 파일 정적 검사, PR 요약 생성을 지원합니다.
|
||||
/// LLM이 구조화된 분석 결과를 바탕으로 상세한 리뷰를 작성합니다.
|
||||
/// </summary>
|
||||
public class CodeReviewTool : IAgentTool
|
||||
{
|
||||
public string Name => "code_review";
|
||||
|
||||
public string Description =>
|
||||
"코드 리뷰를 수행합니다. Git diff 분석, 파일 정적 검사, PR 요약을 생성합니다.\n" +
|
||||
"action별 기능:\n" +
|
||||
"- diff_review: git diff 출력을 분석하여 이슈/개선점을 구조화\n" +
|
||||
"- file_review: 특정 파일의 코드 품질을 정적 검사\n" +
|
||||
"- pr_summary: 변경사항을 PR 설명 형식으로 요약";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "리뷰 유형: diff_review (diff 분석), file_review (파일 검사), pr_summary (PR 요약)",
|
||||
Enum = ["diff_review", "file_review", "pr_summary"]
|
||||
},
|
||||
["target"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "대상 지정. diff_review: '--staged' 또는 빈값(working tree). file_review: 파일 경로. pr_summary: 브랜치명(선택)."
|
||||
},
|
||||
["focus"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "리뷰 초점: all (전체), bugs (버그), performance (성능), security (보안), style (스타일). 기본 all.",
|
||||
Enum = ["all", "bugs", "performance", "security", "style"]
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
|
||||
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var target = args.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
|
||||
var focus = args.TryGetProperty("focus", out var f) ? f.GetString() ?? "all" : "all";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"diff_review" => await DiffReviewAsync(context, target, focus, ct),
|
||||
"file_review" => await FileReviewAsync(context, target, focus, ct),
|
||||
"pr_summary" => await PrSummaryAsync(context, target, ct),
|
||||
_ => ToolResult.Fail($"지원하지 않는 action: {action}. diff_review, file_review, pr_summary 중 선택하세요.")
|
||||
};
|
||||
}
|
||||
|
||||
// ─── diff_review ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ToolResult> DiffReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct)
|
||||
{
|
||||
var diffArgs = string.IsNullOrEmpty(target) ? "diff" : $"diff {target}";
|
||||
var diffResult = await RunGitAsync(ctx.WorkFolder, diffArgs, ct);
|
||||
if (diffResult == null) return ToolResult.Fail("Git을 찾을 수 없습니다.");
|
||||
if (string.IsNullOrWhiteSpace(diffResult))
|
||||
return ToolResult.Ok("변경사항이 없습니다. (clean working tree)");
|
||||
|
||||
// diff 파싱
|
||||
var files = ParseDiffFiles(diffResult);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("═══ Code Review Report (diff_review) ═══\n");
|
||||
|
||||
// 통계
|
||||
int totalAdded = 0, totalRemoved = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
totalAdded += file.Added;
|
||||
totalRemoved += file.Removed;
|
||||
}
|
||||
sb.AppendLine($"📊 파일 {files.Count}개 변경 | +{totalAdded} 추가 -{totalRemoved} 삭제\n");
|
||||
sb.AppendLine($"🔍 리뷰 초점: {focus}\n");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
sb.AppendLine($"─── {file.Path} ({file.Status}) ───");
|
||||
sb.AppendLine($" 변경: +{file.Added} -{file.Removed}");
|
||||
|
||||
// 정적 패턴 검사
|
||||
var issues = AnalyzeDiffHunks(file.Hunks, focus);
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
foreach (var issue in issues)
|
||||
sb.AppendLine($" [{issue.Severity}] Line {issue.Line}: {issue.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" [OK] 정적 검사에서 특이사항 없음");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("═══ 위 분석 결과를 바탕으로 상세한 코드 리뷰를 작성하세요. ═══");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
// ─── file_review ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ToolResult> FileReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(target))
|
||||
return ToolResult.Fail("file_review에는 target(파일 경로)이 필요합니다.");
|
||||
|
||||
var fullPath = Path.IsPathRooted(target) ? target : Path.Combine(ctx.WorkFolder, target);
|
||||
if (!File.Exists(fullPath))
|
||||
return ToolResult.Fail($"파일을 찾을 수 없습니다: {target}");
|
||||
if (!ctx.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"접근이 차단된 경로입니다: {target}");
|
||||
|
||||
var content = await File.ReadAllTextAsync(fullPath, ct);
|
||||
var lines = content.Split('\n');
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"═══ Code Review Report (file_review) ═══\n");
|
||||
sb.AppendLine($"📁 파일: {target}");
|
||||
sb.AppendLine($"📏 {lines.Length}줄 | 🔍 초점: {focus}\n");
|
||||
|
||||
var issues = AnalyzeFile(lines, focus);
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"⚠️ 발견된 이슈 {issues.Count}개:\n");
|
||||
foreach (var issue in issues)
|
||||
sb.AppendLine($" [{issue.Severity}] Line {issue.Line}: {issue.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("✅ 정적 검사에서 특이사항 없음");
|
||||
}
|
||||
|
||||
// 파일 앞부분 제공 (LLM이 상세 리뷰할 수 있도록)
|
||||
sb.AppendLine($"\n─── 파일 내용 (처음 200줄) ───");
|
||||
var preview = string.Join('\n', lines.Take(200));
|
||||
sb.AppendLine(preview);
|
||||
if (lines.Length > 200)
|
||||
sb.AppendLine($"\n... ({lines.Length - 200}줄 생략)");
|
||||
|
||||
sb.AppendLine("\n═══ 위 분석 결과와 코드를 바탕으로 상세한 리뷰를 작성하세요. ═══");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
// ─── pr_summary ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ToolResult> PrSummaryAsync(AgentContext ctx, string target, CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("═══ PR Summary Data ═══\n");
|
||||
|
||||
// git log
|
||||
var logArgs = string.IsNullOrEmpty(target)
|
||||
? "log --oneline -20"
|
||||
: $"log --oneline {target}..HEAD";
|
||||
var log = await RunGitAsync(ctx.WorkFolder, logArgs, ct);
|
||||
if (!string.IsNullOrWhiteSpace(log))
|
||||
{
|
||||
sb.AppendLine("📋 커밋 이력:");
|
||||
sb.AppendLine(log);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// git diff --stat
|
||||
var statArgs = string.IsNullOrEmpty(target) ? "diff --stat" : $"diff --stat {target}..HEAD";
|
||||
var stat = await RunGitAsync(ctx.WorkFolder, statArgs, ct);
|
||||
if (!string.IsNullOrWhiteSpace(stat))
|
||||
{
|
||||
sb.AppendLine("📊 변경 통계:");
|
||||
sb.AppendLine(stat);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// git status
|
||||
var status = await RunGitAsync(ctx.WorkFolder, "status --short", ct);
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
sb.AppendLine("📁 현재 상태:");
|
||||
sb.AppendLine(status);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("═══ 위 데이터를 바탕으로 PR 제목과 설명(Summary, Changes, Test Plan)을 작성하세요. ═══");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
// ─── 정적 분석 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static List<ReviewIssue> AnalyzeDiffHunks(List<string> hunks, string focus)
|
||||
{
|
||||
var issues = new List<ReviewIssue>();
|
||||
int lineNum = 0;
|
||||
|
||||
foreach (var line in hunks)
|
||||
{
|
||||
// @@ -a,b +c,d @@ 에서 라인 번호 추출
|
||||
var hunkMatch = Regex.Match(line, @"@@ -\d+(?:,\d+)? \+(\d+)");
|
||||
if (hunkMatch.Success)
|
||||
{
|
||||
lineNum = int.Parse(hunkMatch.Groups[1].Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith('+') || line.StartsWith("+++")) continue;
|
||||
lineNum++;
|
||||
|
||||
var content = line[1..];
|
||||
|
||||
if (focus is "all" or "bugs")
|
||||
{
|
||||
if (Regex.IsMatch(content, @"catch\s*\{?\s*\}"))
|
||||
issues.Add(new(lineNum, "WARNING", "빈 catch 블록 — 예외가 무시됩니다"));
|
||||
if (Regex.IsMatch(content, @"\.Result\b|\.Wait\(\)"))
|
||||
issues.Add(new(lineNum, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험"));
|
||||
if (Regex.IsMatch(content, @"==\s*null") && content.Contains('.'))
|
||||
issues.Add(new(lineNum, "INFO", "null 비교 — null 조건 연산자(?.) 사용 검토"));
|
||||
}
|
||||
|
||||
if (focus is "all" or "security")
|
||||
{
|
||||
if (Regex.IsMatch(content, @"(password|secret|token|api_?key)\s*=\s*""[^""]+""", RegexOptions.IgnoreCase))
|
||||
issues.Add(new(lineNum, "CRITICAL", "하드코딩된 비밀번호/키 감지"));
|
||||
if (Regex.IsMatch(content, @"(TODO|FIXME|HACK|XXX)\b", RegexOptions.IgnoreCase))
|
||||
issues.Add(new(lineNum, "INFO", $"TODO/FIXME 마커 발견: {content.Trim()}"));
|
||||
}
|
||||
|
||||
if (focus is "all" or "performance")
|
||||
{
|
||||
if (Regex.IsMatch(content, @"new\s+List<.*>\(\).*\.Add\(") || Regex.IsMatch(content, @"\.ToList\(\).*\.Where\("))
|
||||
issues.Add(new(lineNum, "INFO", "불필요한 컬렉션 할당 가능성"));
|
||||
if (Regex.IsMatch(content, @"string\s*\+\s*=|"".*""\s*\+\s*"""))
|
||||
issues.Add(new(lineNum, "INFO", "문자열 연결 — StringBuilder 사용 검토"));
|
||||
}
|
||||
|
||||
if (focus is "all" or "style")
|
||||
{
|
||||
if (content.Length > 150)
|
||||
issues.Add(new(lineNum, "STYLE", $"긴 라인 ({content.Length}자) — 가독성 저하"));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static List<ReviewIssue> AnalyzeFile(string[] lines, string focus)
|
||||
{
|
||||
var issues = new List<ReviewIssue>();
|
||||
|
||||
// 메서드 길이 추정 (중괄호 카운팅)
|
||||
int braceDepth = 0, methodStart = 0;
|
||||
bool inMethod = false;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var lineNum = i + 1;
|
||||
var trimmed = line.TrimStart();
|
||||
|
||||
// 메서드 시작 감지 (간단 휴리스틱)
|
||||
if (Regex.IsMatch(trimmed, @"(public|private|protected|internal|static|async|override)\s+.*\(.*\)\s*\{?\s*$") && !trimmed.Contains(';'))
|
||||
{
|
||||
inMethod = true;
|
||||
methodStart = lineNum;
|
||||
}
|
||||
|
||||
if (trimmed.Contains('{')) braceDepth++;
|
||||
if (trimmed.Contains('}'))
|
||||
{
|
||||
braceDepth--;
|
||||
if (inMethod && braceDepth <= 1)
|
||||
{
|
||||
var methodLen = lineNum - methodStart;
|
||||
if (methodLen > 60 && (focus is "all" or "style"))
|
||||
issues.Add(new(methodStart, "STYLE", $"긴 메서드 ({methodLen}줄) — 분할 검토"));
|
||||
inMethod = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (focus is "all" or "bugs")
|
||||
{
|
||||
if (Regex.IsMatch(trimmed, @"catch\s*(\(\s*Exception)?\s*\)?\s*\{\s*\}"))
|
||||
issues.Add(new(lineNum, "WARNING", "빈 catch 블록"));
|
||||
if (Regex.IsMatch(trimmed, @"\.Result\b|\.Wait\(\)"))
|
||||
issues.Add(new(lineNum, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험"));
|
||||
}
|
||||
|
||||
if (focus is "all" or "security")
|
||||
{
|
||||
if (Regex.IsMatch(trimmed, @"(password|secret|token|api_?key)\s*=\s*""[^""]+""", RegexOptions.IgnoreCase))
|
||||
issues.Add(new(lineNum, "CRITICAL", "하드코딩된 비밀번호/키"));
|
||||
if (Regex.IsMatch(trimmed, @"(TODO|FIXME|HACK|XXX)\b", RegexOptions.IgnoreCase))
|
||||
issues.Add(new(lineNum, "INFO", $"마커: {trimmed.Trim()}"));
|
||||
}
|
||||
|
||||
if (focus is "all" or "performance")
|
||||
{
|
||||
if (Regex.IsMatch(trimmed, @"string\s*\+\s*="))
|
||||
issues.Add(new(lineNum, "INFO", "루프 내 문자열 연결 — StringBuilder 검토"));
|
||||
}
|
||||
|
||||
if (focus is "all" or "style")
|
||||
{
|
||||
if (line.Length > 150)
|
||||
issues.Add(new(lineNum, "STYLE", $"긴 라인 ({line.Length}자)"));
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 전체 크기
|
||||
if (lines.Length > 500 && (focus is "all" or "style"))
|
||||
issues.Add(new(1, "STYLE", $"큰 파일 ({lines.Length}줄) — 클래스 분할 검토"));
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ─── Git 실행 헬퍼 ──────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string?> RunGitAsync(string workDir, string args, CancellationToken ct)
|
||||
{
|
||||
var gitPath = FindGit();
|
||||
if (gitPath == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(gitPath, args)
|
||||
{
|
||||
WorkingDirectory = workDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return null;
|
||||
|
||||
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
|
||||
return stdout.Length > 12000 ? stdout[..12000] + "\n... (출력 잘림)" : stdout;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? FindGit()
|
||||
{
|
||||
var paths = new[] { "git", @"C:\Program Files\Git\bin\git.exe", @"C:\Program Files (x86)\Git\bin\git.exe" };
|
||||
foreach (var p in paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(p, "--version")
|
||||
{ RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true };
|
||||
using var proc = Process.Start(psi);
|
||||
proc?.WaitForExit(3000);
|
||||
if (proc?.ExitCode == 0) return p;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Diff 파서 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static List<DiffFile> ParseDiffFiles(string diff)
|
||||
{
|
||||
var files = new List<DiffFile>();
|
||||
DiffFile? current = null;
|
||||
|
||||
foreach (var line in diff.Split('\n'))
|
||||
{
|
||||
if (line.StartsWith("diff --git"))
|
||||
{
|
||||
current = new DiffFile();
|
||||
files.Add(current);
|
||||
// "diff --git a/path b/path" → path 추출
|
||||
var parts = line.Split(" b/");
|
||||
current.Path = parts.Length > 1 ? parts[1].Trim() : line;
|
||||
}
|
||||
else if (current != null)
|
||||
{
|
||||
if (line.StartsWith("new file")) current.Status = "added";
|
||||
else if (line.StartsWith("deleted file")) current.Status = "deleted";
|
||||
else if (line.StartsWith("@@") || line.StartsWith("+") || line.StartsWith("-"))
|
||||
{
|
||||
current.Hunks.Add(line);
|
||||
if (line.StartsWith("+") && !line.StartsWith("+++")) current.Added++;
|
||||
if (line.StartsWith("-") && !line.StartsWith("---")) current.Removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private class DiffFile
|
||||
{
|
||||
public string Path { get; set; } = "";
|
||||
public string Status { get; set; } = "modified";
|
||||
public int Added { get; set; }
|
||||
public int Removed { get; set; }
|
||||
public List<string> Hunks { get; } = new();
|
||||
}
|
||||
|
||||
private record ReviewIssue(int Line, string Severity, string Message);
|
||||
}
|
||||
108
src/AxCopilot/Services/Agent/CodeSearchTool.cs
Normal file
108
src/AxCopilot/Services/Agent/CodeSearchTool.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 코드베이스 시맨틱 검색 도구.
|
||||
/// 프로젝트 파일을 TF-IDF로 인덱싱하고, 자연어 질문으로 관련 코드를 찾습니다.
|
||||
/// SQLite에 인덱스를 영속 저장하여 증분 업데이트와 빠른 재시작을 지원합니다.
|
||||
/// </summary>
|
||||
public class CodeSearchTool : IAgentTool
|
||||
{
|
||||
public string Name => "search_codebase";
|
||||
|
||||
public string Description =>
|
||||
"코드베이스를 시맨틱 검색합니다. 자연어 질문으로 관련 있는 코드를 찾습니다.\n" +
|
||||
"예: '사용자 인증 로직', '데이터베이스 연결 설정', '에러 핸들링 패턴'\n" +
|
||||
"인덱스는 영속 저장되어 변경된 파일만 증분 업데이트합니다.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["query"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "검색할 내용 (자연어 또는 키워드)"
|
||||
},
|
||||
["max_results"] = new ToolProperty
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "최대 결과 수 (기본 5)"
|
||||
},
|
||||
["reindex"] = new ToolProperty
|
||||
{
|
||||
Type = "boolean",
|
||||
Description = "true면 기존 인덱스를 버리고 전체 재인덱싱 (기본 false)"
|
||||
},
|
||||
},
|
||||
Required = new() { "query" }
|
||||
};
|
||||
|
||||
private static CodeIndexService? _indexService;
|
||||
private static string _lastWorkFolder = "";
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
// 설정 체크
|
||||
var app = System.Windows.Application.Current as App;
|
||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
|
||||
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||
|
||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
||||
var maxResults = args.TryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
|
||||
var reindex = args.TryGetProperty("reindex", out var ri) && ri.GetBoolean();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return ToolResult.Fail("query가 필요합니다.");
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
// 작업 폴더가 바뀌면 인덱스 서비스 재생성
|
||||
if (_indexService == null || _lastWorkFolder != context.WorkFolder || reindex)
|
||||
{
|
||||
_indexService?.Dispose();
|
||||
_indexService = new CodeIndexService();
|
||||
_lastWorkFolder = context.WorkFolder;
|
||||
|
||||
// 기존 sqlite 인덱스 로드 시도 (앱 재시작 시 즉시 사용 가능)
|
||||
if (!reindex)
|
||||
_indexService.TryLoadExisting(context.WorkFolder);
|
||||
}
|
||||
|
||||
// 증분 인덱싱 (신규/변경 파일만 처리)
|
||||
if (!_indexService.IsIndexed || reindex)
|
||||
{
|
||||
await _indexService.IndexAsync(context.WorkFolder, ct);
|
||||
if (!_indexService.IsIndexed)
|
||||
return ToolResult.Fail("코드 인덱싱에 실패했습니다.");
|
||||
}
|
||||
|
||||
var results = _indexService.Search(query, maxResults);
|
||||
if (results.Count == 0)
|
||||
return ToolResult.Ok($"'{query}'에 대한 관련 코드를 찾지 못했습니다. 다른 키워드로 검색해보세요.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"'{query}' 검색 결과 ({results.Count}개, 인덱스 {_indexService.ChunkCount}개 청크):\n");
|
||||
foreach (var r in results)
|
||||
{
|
||||
sb.AppendLine($"📁 {r.FilePath} (line {r.StartLine}-{r.EndLine}, score {r.Score:F3})");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine(r.Preview);
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>인덱스를 강제 재빌드합니다.</summary>
|
||||
public static void InvalidateIndex()
|
||||
{
|
||||
_indexService?.Dispose();
|
||||
_indexService = null;
|
||||
_lastWorkFolder = "";
|
||||
}
|
||||
}
|
||||
250
src/AxCopilot/Services/Agent/ContextCondenser.cs
Normal file
250
src/AxCopilot/Services/Agent/ContextCondenser.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
|
||||
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
|
||||
///
|
||||
/// 2단계 압축 전략:
|
||||
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
|
||||
/// 2단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
||||
/// </summary>
|
||||
public static class ContextCondenser
|
||||
{
|
||||
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
|
||||
private const int MaxToolResultChars = 1500;
|
||||
|
||||
/// <summary>요약 시 유지할 최근 메시지 수</summary>
|
||||
private const int RecentKeepCount = 6;
|
||||
|
||||
/// <summary>모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용.</summary>
|
||||
private static int GetModelInputLimit(string service, string model)
|
||||
{
|
||||
var key = $"{service}:{model}".ToLowerInvariant();
|
||||
return key switch
|
||||
{
|
||||
_ when key.Contains(string.Concat("cl", "aude")) => 180_000, // Claude 계열 200K
|
||||
_ when key.Contains("gemini-2.5") => 900_000, // Gemini 1M
|
||||
_ when key.Contains("gemini-2.0") => 900_000,
|
||||
_ when key.Contains("gemini") => 900_000,
|
||||
_ when key.Contains("gpt-4") => 120_000, // GPT-4 128K
|
||||
_ => 16_000, // Ollama/vLLM 로컬 모델 기본값
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
|
||||
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
|
||||
/// 2단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
|
||||
/// </summary>
|
||||
public static async Task<bool> CondenseIfNeededAsync(
|
||||
List<ChatMessage> messages, LlmService llm, int maxOutputTokens, CancellationToken ct = default)
|
||||
{
|
||||
if (messages.Count < 6) return false;
|
||||
|
||||
// 현재 모델의 입력 토큰 한도
|
||||
var settings = llm.GetCurrentModelInfo();
|
||||
var inputLimit = GetModelInputLimit(settings.service, settings.model);
|
||||
var threshold = (int)(inputLimit * 0.65); // 65%에서 압축 시작
|
||||
|
||||
var currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (currentTokens < threshold) return false;
|
||||
|
||||
bool didCompress = false;
|
||||
|
||||
// ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ──
|
||||
didCompress |= TruncateToolResults(messages);
|
||||
|
||||
// 1단계 후 다시 추정
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (currentTokens < threshold) return didCompress;
|
||||
|
||||
// ── 2단계: 이전 대화 LLM 요약 ──
|
||||
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
|
||||
|
||||
if (didCompress)
|
||||
{
|
||||
var afterTokens = TokenEstimator.EstimateMessages(messages);
|
||||
LogService.Info($"Context Condenser: {currentTokens} → {afterTokens} 토큰 (절감 {currentTokens - afterTokens})");
|
||||
}
|
||||
|
||||
return didCompress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1단계: 대용량 도구 결과를 축약합니다.
|
||||
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
|
||||
/// </summary>
|
||||
private static bool TruncateToolResults(List<ChatMessage> messages)
|
||||
{
|
||||
bool truncated = false;
|
||||
|
||||
// 최근 RecentKeepCount개는 건드리지 않음 (방금 실행한 도구 결과는 유지)
|
||||
var cutoff = Math.Max(0, messages.Count - RecentKeepCount);
|
||||
|
||||
for (int i = 0; i < cutoff; i++)
|
||||
{
|
||||
var msg = messages[i];
|
||||
if (msg.Content == null) continue;
|
||||
|
||||
// tool_result 메시지의 대용량 출력 축약
|
||||
if (msg.Content.StartsWith("{\"type\":\"tool_result\"") && msg.Content.Length > MaxToolResultChars)
|
||||
{
|
||||
// JSON 구조를 유지하되 output 부분만 축약
|
||||
messages[i] = CloneWithContent(msg, TruncateToolResultJson(msg.Content));
|
||||
truncated = true;
|
||||
}
|
||||
// assistant의 도구 호출 블록 내 긴 텍스트도 축약
|
||||
else if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 &&
|
||||
msg.Content.StartsWith("{\"_tool_use_blocks\""))
|
||||
{
|
||||
// 도구 호출 구조는 유지, 텍스트 블록만 축약
|
||||
if (msg.Content.Length > MaxToolResultChars * 3)
|
||||
{
|
||||
messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}");
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
// 일반 assistant/user 메시지 중 비정상적으로 긴 것 (예: 파일 내용 전체 붙여넣기)
|
||||
else if (msg.Content.Length > MaxToolResultChars * 3 && msg.Role != "system")
|
||||
{
|
||||
messages[i] = CloneWithContent(
|
||||
msg,
|
||||
msg.Content[..MaxToolResultChars] + "\n\n...[이전 내용 축약됨 — 원본 " +
|
||||
$"{msg.Content.Length:N0}자 중 {MaxToolResultChars:N0}자 유지]");
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return truncated;
|
||||
}
|
||||
|
||||
private static ChatMessage CloneWithContent(ChatMessage source, string content)
|
||||
{
|
||||
return new ChatMessage
|
||||
{
|
||||
Role = source.Role,
|
||||
Content = content,
|
||||
Timestamp = source.Timestamp,
|
||||
MetaKind = source.MetaKind,
|
||||
MetaRunId = source.MetaRunId,
|
||||
Feedback = source.Feedback,
|
||||
AttachedFiles = source.AttachedFiles?.ToList(),
|
||||
Images = source.Images?.Select(x => new ImageAttachment
|
||||
{
|
||||
Base64 = x.Base64,
|
||||
MimeType = x.MimeType,
|
||||
FileName = x.FileName,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>tool_result JSON 내의 output 값을 축약합니다.</summary>
|
||||
private static string TruncateToolResultJson(string json)
|
||||
{
|
||||
// 간단한 문자열 처리로 output 부분만 축약 (JSON 파서 없이)
|
||||
const string marker = "\"output\":\"";
|
||||
var idx = json.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (idx < 0) return json[..Math.Min(json.Length, MaxToolResultChars)] + "...[축약됨]}";
|
||||
|
||||
var outputStart = idx + marker.Length;
|
||||
// output 끝 찾기 (이스케이프된 따옴표 고려)
|
||||
var outputEnd = outputStart;
|
||||
while (outputEnd < json.Length)
|
||||
{
|
||||
if (json[outputEnd] == '\\') { outputEnd += 2; continue; }
|
||||
if (json[outputEnd] == '"') break;
|
||||
outputEnd++;
|
||||
}
|
||||
|
||||
var outputLen = outputEnd - outputStart;
|
||||
if (outputLen <= MaxToolResultChars) return json; // 이미 짧음
|
||||
|
||||
// 앞부분 + "...[축약됨]" + 뒷부분
|
||||
var keepLen = MaxToolResultChars / 2;
|
||||
var prefix = json[..outputStart];
|
||||
var outputText = json[outputStart..outputEnd];
|
||||
var suffix = json[outputEnd..];
|
||||
|
||||
return prefix +
|
||||
outputText[..keepLen] +
|
||||
"\\n...[축약됨: " + $"{outputLen:N0}" + "자 중 " + $"{MaxToolResultChars:N0}" + "자 유지]\\n" +
|
||||
outputText[^keepLen..] +
|
||||
suffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2단계: 오래된 메시지를 LLM으로 요약합니다.
|
||||
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.
|
||||
/// </summary>
|
||||
private static async Task<bool> SummarizeOldMessagesAsync(
|
||||
List<ChatMessage> messages, LlmService llm, CancellationToken ct)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var systemCount = systemMsg != null ? 1 : 0;
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
|
||||
if (oldMessages.Count < 3) return false;
|
||||
|
||||
// 요약 대상 텍스트 구성
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("다음 대화 기록을 간결하게 요약하세요.");
|
||||
sb.AppendLine("반드시 유지할 정보: 사용자 요청, 핵심 결정 사항, 생성/수정된 파일 경로, 작업 진행 상황, 중요한 결과.");
|
||||
sb.AppendLine("제거할 정보: 도구 실행의 상세 출력, 반복되는 내용, 중간 사고 과정.");
|
||||
sb.AppendLine("요약은 대화와 동일한 언어로 작성하세요. 글머리 기호(-)로 핵심 사항만 나열하세요.\n---");
|
||||
|
||||
foreach (var m in oldMessages)
|
||||
{
|
||||
var content = m.Content ?? "";
|
||||
if (content.StartsWith("{\"_tool_use_blocks\""))
|
||||
content = "[도구 호출]";
|
||||
else if (content.StartsWith("{\"type\":\"tool_result\""))
|
||||
content = "[도구 결과]";
|
||||
else if (content.Length > 300)
|
||||
content = content[..300] + "...";
|
||||
|
||||
sb.AppendLine($"[{m.Role}]: {content}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var summaryMessages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "user", Content = sb.ToString() }
|
||||
};
|
||||
|
||||
var summary = await llm.SendAsync(summaryMessages, ct);
|
||||
if (string.IsNullOrEmpty(summary)) return false;
|
||||
|
||||
// 메시지 재구성: system + 요약 + 최근 메시지
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"[이전 대화 요약 — {oldMessages.Count}개 메시지 압축]\n{summary}",
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = "이전 대화 내용을 확인했습니다. 이어서 작업하겠습니다.",
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
|
||||
messages.AddRange(recentMessages);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"Context Condenser 요약 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/AxCopilot/Services/Agent/CsvSkill.cs
Normal file
93
src/AxCopilot/Services/Agent/CsvSkill.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CSV (.csv) 파일을 생성하는 내장 스킬.
|
||||
/// LLM이 헤더와 데이터 행을 전달하면 CSV 파일을 생성합니다.
|
||||
/// </summary>
|
||||
public class CsvSkill : IAgentTool
|
||||
{
|
||||
public string Name => "csv_create";
|
||||
public string Description => "Create a CSV (.csv) file with structured data. Provide headers and rows as JSON arrays.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.csv). Relative to work folder." },
|
||||
["headers"] = new() { Type = "array", Description = "Column headers as JSON array of strings.", Items = new() { Type = "string" } },
|
||||
["rows"] = new() { Type = "array", Description = "Data rows as JSON array of arrays.", Items = new() { Type = "array", Items = new() { Type = "string" } } },
|
||||
["encoding"] = new() { Type = "string", Description = "File encoding: 'utf-8' (default) or 'euc-kr'." },
|
||||
},
|
||||
Required = ["path", "headers", "rows"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var encodingName = args.TryGetProperty("encoding", out var enc) ? enc.GetString() ?? "utf-8" : "utf-8";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".csv";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var headers = args.GetProperty("headers");
|
||||
var rows = args.GetProperty("rows");
|
||||
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
Encoding fileEncoding;
|
||||
try { fileEncoding = Encoding.GetEncoding(encodingName); }
|
||||
catch { fileEncoding = new UTF8Encoding(true); }
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// 헤더
|
||||
var headerValues = new List<string>();
|
||||
foreach (var h in headers.EnumerateArray())
|
||||
headerValues.Add(EscapeCsvField(h.GetString() ?? ""));
|
||||
sb.AppendLine(string.Join(",", headerValues));
|
||||
|
||||
// 데이터
|
||||
int rowCount = 0;
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var fields = new List<string>();
|
||||
foreach (var cell in row.EnumerateArray())
|
||||
fields.Add(EscapeCsvField(cell.ToString()));
|
||||
sb.AppendLine(string.Join(",", fields));
|
||||
rowCount++;
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"CSV 파일 생성 완료: {fullPath}\n열: {headerValues.Count}, 행: {rowCount}, 인코딩: {encodingName}",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"CSV 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeCsvField(string field)
|
||||
{
|
||||
if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r'))
|
||||
return $"\"{field.Replace("\"", "\"\"")}\"";
|
||||
return field;
|
||||
}
|
||||
}
|
||||
359
src/AxCopilot/Services/Agent/DataPivotTool.cs
Normal file
359
src/AxCopilot/Services/Agent/DataPivotTool.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// CSV/JSON 데이터를 그룹화, 피벗, 집계하는 도구.
|
||||
/// LINQ 기반 순수 C# 구현으로 외부 의존성 없음.
|
||||
/// </summary>
|
||||
public class DataPivotTool : IAgentTool
|
||||
{
|
||||
public string Name => "data_pivot";
|
||||
public string Description =>
|
||||
"Group, pivot, and aggregate CSV/JSON data without external dependencies. " +
|
||||
"Supports: group_by columns, aggregate functions (sum/avg/count/min/max), " +
|
||||
"filter conditions, sorting, and output as table/csv/json.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["source_path"] = new() { Type = "string", Description = "Path to CSV or JSON data file." },
|
||||
["group_by"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Column names to group by.",
|
||||
Items = new() { Type = "string" }
|
||||
},
|
||||
["aggregates"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Aggregation specs: [{\"column\": \"sales\", \"function\": \"sum\"}, ...]. " +
|
||||
"Functions: sum, avg, count, min, max.",
|
||||
Items = new() { Type = "object" }
|
||||
},
|
||||
["filter"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Optional filter expression: 'column == value' or 'column > 100'. " +
|
||||
"Supports: ==, !=, >, <, >=, <=, contains. " +
|
||||
"Multiple conditions: 'region == Seoul AND year >= 2025'."
|
||||
},
|
||||
["sort_by"] = new() { Type = "string", Description = "Column name to sort results by. Prefix with '-' for descending." },
|
||||
["top_n"] = new() { Type = "integer", Description = "Limit results to top N rows. Default: all rows." },
|
||||
["output_format"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Output format: table (markdown), csv, json. Default: table",
|
||||
Enum = ["table", "csv", "json"]
|
||||
},
|
||||
},
|
||||
Required = ["source_path"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var sourcePath = args.GetProperty("source_path").GetString() ?? "";
|
||||
var fullPath = FileReadTool.ResolvePath(sourcePath, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
|
||||
if (!File.Exists(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"파일 없음: {fullPath}"));
|
||||
|
||||
try
|
||||
{
|
||||
// 데이터 로드
|
||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
List<Dictionary<string, string>> data;
|
||||
|
||||
if (ext == ".json")
|
||||
data = LoadJson(fullPath);
|
||||
else
|
||||
data = LoadCsv(fullPath);
|
||||
|
||||
if (data.Count == 0)
|
||||
return Task.FromResult(ToolResult.Fail("데이터가 비어있습니다."));
|
||||
|
||||
var originalCount = data.Count;
|
||||
|
||||
// 필터 적용
|
||||
if (args.TryGetProperty("filter", out var filterEl))
|
||||
{
|
||||
var filterStr = filterEl.GetString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(filterStr))
|
||||
data = ApplyFilter(data, filterStr);
|
||||
}
|
||||
|
||||
// 그룹화 & 집계
|
||||
List<Dictionary<string, string>> result;
|
||||
if (args.TryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var groupCols = new List<string>();
|
||||
foreach (var g in groupEl.EnumerateArray())
|
||||
groupCols.Add(g.GetString() ?? "");
|
||||
|
||||
var aggregates = new List<(string Column, string Function)>();
|
||||
if (args.TryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var agg in aggEl.EnumerateArray())
|
||||
{
|
||||
var col = agg.TryGetProperty("column", out var c) ? c.GetString() ?? "" : "";
|
||||
var func = agg.TryGetProperty("function", out var f) ? f.GetString() ?? "count" : "count";
|
||||
if (!string.IsNullOrEmpty(col))
|
||||
aggregates.Add((col, func));
|
||||
}
|
||||
}
|
||||
|
||||
result = GroupAndAggregate(data, groupCols, aggregates);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = data;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (args.TryGetProperty("sort_by", out var sortEl))
|
||||
{
|
||||
var sortBy = sortEl.GetString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(sortBy))
|
||||
result = ApplySort(result, sortBy);
|
||||
}
|
||||
|
||||
// Top N
|
||||
if (args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
|
||||
result = result.Take(topN).ToList();
|
||||
|
||||
// 출력 포맷
|
||||
var outputFormat = args.TryGetProperty("output_format", out var ofmt) ? ofmt.GetString() ?? "table" : "table";
|
||||
var output = FormatOutput(result, outputFormat);
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(
|
||||
$"📊 데이터 피벗 완료: {originalCount}행 → 필터 후 {data.Count}행 → 결과 {result.Count}행\n\n{output}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"데이터 피벗 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, string>> LoadCsv(string path)
|
||||
{
|
||||
var read = TextFileCodec.ReadAllText(path);
|
||||
var lines = TextFileCodec.SplitLines(read.Text);
|
||||
if (lines.Length < 2) return new();
|
||||
|
||||
var headers = ParseCsvLine(lines[0]);
|
||||
var data = new List<Dictionary<string, string>>();
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lines[i])) continue;
|
||||
var values = ParseCsvLine(lines[i]);
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int j = 0; j < headers.Count && j < values.Count; j++)
|
||||
row[headers[j]] = values[j];
|
||||
data.Add(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
bool inQuote = false;
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (c == '"') { inQuote = !inQuote; continue; }
|
||||
if (c == ',' && !inQuote)
|
||||
{
|
||||
result.Add(sb.ToString().Trim());
|
||||
sb.Clear();
|
||||
continue;
|
||||
}
|
||||
sb.Append(c);
|
||||
}
|
||||
result.Add(sb.ToString().Trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, string>> LoadJson(string path)
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(path).Text;
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var data = new List<Dictionary<string, string>>();
|
||||
|
||||
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||
? doc.RootElement
|
||||
: doc.RootElement.TryGetProperty("data", out var d) ? d : doc.RootElement;
|
||||
|
||||
if (arr.ValueKind != JsonValueKind.Array) return data;
|
||||
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
row[prop.Name] = prop.Value.ToString();
|
||||
data.Add(row);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, string>> ApplyFilter(List<Dictionary<string, string>> data, string filter)
|
||||
{
|
||||
var conditions = filter.Split(new[] { " AND ", " and " }, StringSplitOptions.TrimEntries);
|
||||
var result = data;
|
||||
|
||||
foreach (var cond in conditions)
|
||||
{
|
||||
var match = Regex.Match(cond, @"(\w+)\s*(==|!=|>=|<=|>|<|contains)\s*(.+)");
|
||||
if (!match.Success) continue;
|
||||
|
||||
var col = match.Groups[1].Value;
|
||||
var op = match.Groups[2].Value;
|
||||
var val = match.Groups[3].Value.Trim().Trim('\'', '"');
|
||||
|
||||
result = result.Where(row =>
|
||||
{
|
||||
if (!row.TryGetValue(col, out var cellVal)) return false;
|
||||
return op switch
|
||||
{
|
||||
"==" => cellVal.Equals(val, StringComparison.OrdinalIgnoreCase),
|
||||
"!=" => !cellVal.Equals(val, StringComparison.OrdinalIgnoreCase),
|
||||
"contains" => cellVal.Contains(val, StringComparison.OrdinalIgnoreCase),
|
||||
">" => double.TryParse(cellVal, out var a) && double.TryParse(val, out var b) && a > b,
|
||||
"<" => double.TryParse(cellVal, out var a2) && double.TryParse(val, out var b2) && a2 < b2,
|
||||
">=" => double.TryParse(cellVal, out var a3) && double.TryParse(val, out var b3) && a3 >= b3,
|
||||
"<=" => double.TryParse(cellVal, out var a4) && double.TryParse(val, out var b4) && a4 <= b4,
|
||||
_ => true
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, string>> GroupAndAggregate(
|
||||
List<Dictionary<string, string>> data,
|
||||
List<string> groupCols,
|
||||
List<(string Column, string Function)> aggregates)
|
||||
{
|
||||
var groups = data.GroupBy(row =>
|
||||
{
|
||||
var key = new StringBuilder();
|
||||
foreach (var col in groupCols)
|
||||
{
|
||||
row.TryGetValue(col, out var val);
|
||||
key.Append(val ?? "").Append('|');
|
||||
}
|
||||
return key.ToString();
|
||||
});
|
||||
|
||||
var result = new List<Dictionary<string, string>>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 그룹 키 컬럼
|
||||
var first = group.First();
|
||||
foreach (var col in groupCols)
|
||||
row[col] = first.TryGetValue(col, out var v) ? v : "";
|
||||
|
||||
// 집계 컬럼
|
||||
foreach (var (aggCol, func) in aggregates)
|
||||
{
|
||||
var values = group
|
||||
.Select(r => r.TryGetValue(aggCol, out var v) ? v : "")
|
||||
.Where(v => double.TryParse(v, out _))
|
||||
.Select(v => double.Parse(v))
|
||||
.ToList();
|
||||
|
||||
var aggResult = func.ToLowerInvariant() switch
|
||||
{
|
||||
"sum" => values.Sum(),
|
||||
"avg" or "average" => values.Count > 0 ? values.Average() : 0,
|
||||
"min" => values.Count > 0 ? values.Min() : 0,
|
||||
"max" => values.Count > 0 ? values.Max() : 0,
|
||||
"count" => group.Count(),
|
||||
_ => (double)group.Count()
|
||||
};
|
||||
|
||||
var label = $"{aggCol}_{func}";
|
||||
row[label] = func == "count" ? ((int)aggResult).ToString() : aggResult.ToString("F2");
|
||||
}
|
||||
|
||||
// count 집계가 없으면 기본 count 추가
|
||||
if (aggregates.Count == 0)
|
||||
row["count"] = group.Count().ToString();
|
||||
|
||||
result.Add(row);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, string>> ApplySort(List<Dictionary<string, string>> data, string sortBy)
|
||||
{
|
||||
bool desc = sortBy.StartsWith('-');
|
||||
var col = sortBy.TrimStart('-');
|
||||
|
||||
return (desc
|
||||
? data.OrderByDescending(r => GetSortKey(r, col))
|
||||
: data.OrderBy(r => GetSortKey(r, col))
|
||||
).ToList();
|
||||
}
|
||||
|
||||
private static object GetSortKey(Dictionary<string, string> row, string col)
|
||||
{
|
||||
if (!row.TryGetValue(col, out var val)) return "";
|
||||
if (double.TryParse(val, out var num)) return num;
|
||||
return val;
|
||||
}
|
||||
|
||||
private static string FormatOutput(List<Dictionary<string, string>> data, string format)
|
||||
{
|
||||
if (data.Count == 0) return "(결과 없음)";
|
||||
|
||||
var columns = data.SelectMany(r => r.Keys).Distinct().ToList();
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case "json":
|
||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
|
||||
case "csv":
|
||||
var csvSb = new StringBuilder();
|
||||
csvSb.AppendLine(string.Join(",", columns));
|
||||
foreach (var row in data)
|
||||
{
|
||||
var vals = columns.Select(c => row.TryGetValue(c, out var v) ? $"\"{v}\"" : "\"\"");
|
||||
csvSb.AppendLine(string.Join(",", vals));
|
||||
}
|
||||
return csvSb.ToString();
|
||||
|
||||
default: // table (markdown)
|
||||
var sb = new StringBuilder();
|
||||
// 헤더
|
||||
sb.AppendLine("| " + string.Join(" | ", columns) + " |");
|
||||
sb.AppendLine("| " + string.Join(" | ", columns.Select(_ => "---")) + " |");
|
||||
// 행
|
||||
foreach (var row in data)
|
||||
{
|
||||
var vals = columns.Select(c => row.TryGetValue(c, out var v) ? v : "");
|
||||
sb.AppendLine("| " + string.Join(" | ", vals) + " |");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
197
src/AxCopilot/Services/Agent/DateTimeTool.cs
Normal file
197
src/AxCopilot/Services/Agent/DateTimeTool.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>날짜·시간 변환, 타임존, 기간 계산 유틸리티 도구.</summary>
|
||||
public class DateTimeTool : IAgentTool
|
||||
{
|
||||
public string Name => "datetime_tool";
|
||||
public string Description =>
|
||||
"Date/time utility tool. Actions: " +
|
||||
"'now' — get current date/time in various formats; " +
|
||||
"'parse' — parse a date string into standard format; " +
|
||||
"'diff' — calculate difference between two dates; " +
|
||||
"'add' — add/subtract days/hours/minutes to a date; " +
|
||||
"'epoch' — convert between Unix epoch and datetime; " +
|
||||
"'format' — format a date into specified pattern.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["now", "parse", "diff", "add", "epoch", "format"],
|
||||
},
|
||||
["date"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Date string (for parse/diff/add/format/epoch). For epoch: Unix timestamp in seconds.",
|
||||
},
|
||||
["date2"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Second date string (for diff action)",
|
||||
},
|
||||
["amount"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Amount to add (for add action). E.g. '7' for 7 days",
|
||||
},
|
||||
["unit"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Unit for add action",
|
||||
Enum = ["days", "hours", "minutes", "seconds", "months", "years"],
|
||||
},
|
||||
["pattern"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Format pattern (for format action). E.g. 'yyyy-MM-dd HH:mm:ss', 'ddd MMM d yyyy'",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"now" => Now(),
|
||||
"parse" => Parse(args),
|
||||
"diff" => Diff(args),
|
||||
"add" => Add(args),
|
||||
"epoch" => Epoch(args),
|
||||
"format" => FormatDate(args),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"DateTime 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult Now()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var utc = DateTime.UtcNow;
|
||||
var epoch = new DateTimeOffset(utc).ToUnixTimeSeconds();
|
||||
return ToolResult.Ok(
|
||||
$"Local: {now:yyyy-MM-dd HH:mm:ss (ddd)} ({TimeZoneInfo.Local.DisplayName})\n" +
|
||||
$"UTC: {utc:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$"ISO: {now:O}\n" +
|
||||
$"Epoch: {epoch}\n" +
|
||||
$"Week: {CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(now, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday)}");
|
||||
}
|
||||
|
||||
private static ToolResult Parse(JsonElement args)
|
||||
{
|
||||
var dateStr = args.TryGetProperty("date", out var d) ? d.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||
|
||||
if (!DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) &&
|
||||
!DateTime.TryParse(dateStr, CultureInfo.CurrentCulture, DateTimeStyles.None, out dt))
|
||||
return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Parsed: {dt:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$"Day: {dt:dddd}\n" +
|
||||
$"ISO: {dt:O}\n" +
|
||||
$"Epoch: {new DateTimeOffset(dt).ToUnixTimeSeconds()}");
|
||||
}
|
||||
|
||||
private static ToolResult Diff(JsonElement args)
|
||||
{
|
||||
var d1 = args.TryGetProperty("date", out var v1) ? v1.GetString() ?? "" : "";
|
||||
var d2 = args.TryGetProperty("date2", out var v2) ? v2.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
|
||||
return ToolResult.Fail("'date' and 'date2' parameters are required");
|
||||
|
||||
if (!DateTime.TryParse(d1, out var dt1)) return ToolResult.Fail($"Cannot parse date: '{d1}'");
|
||||
if (!DateTime.TryParse(d2, out var dt2)) return ToolResult.Fail($"Cannot parse date: '{d2}'");
|
||||
|
||||
var diff = dt2 - dt1;
|
||||
return ToolResult.Ok(
|
||||
$"From: {dt1:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$"To: {dt2:yyyy-MM-dd HH:mm:ss}\n\n" +
|
||||
$"Difference:\n" +
|
||||
$" {Math.Abs(diff.TotalDays):F1} days\n" +
|
||||
$" {Math.Abs(diff.TotalHours):F1} hours\n" +
|
||||
$" {Math.Abs(diff.TotalMinutes):F0} minutes\n" +
|
||||
$" {Math.Abs(diff.TotalSeconds):F0} seconds\n" +
|
||||
$" ({(diff.TotalDays >= 0 ? "forward" : "backward")})");
|
||||
}
|
||||
|
||||
private static ToolResult Add(JsonElement args)
|
||||
{
|
||||
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
||||
var amountStr = args.TryGetProperty("amount", out var av) ? av.GetString() ?? "0" : "0";
|
||||
var unit = args.TryGetProperty("unit", out var uv) ? uv.GetString() ?? "days" : "days";
|
||||
|
||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
|
||||
if (!double.TryParse(amountStr, out var amount)) return ToolResult.Fail($"Invalid amount: '{amountStr}'");
|
||||
|
||||
var result = unit switch
|
||||
{
|
||||
"days" => dt.AddDays(amount),
|
||||
"hours" => dt.AddHours(amount),
|
||||
"minutes" => dt.AddMinutes(amount),
|
||||
"seconds" => dt.AddSeconds(amount),
|
||||
"months" => dt.AddMonths((int)amount),
|
||||
"years" => dt.AddYears((int)amount),
|
||||
_ => dt.AddDays(amount),
|
||||
};
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Original: {dt:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$"Added: {amount} {unit}\n" +
|
||||
$"Result: {result:yyyy-MM-dd HH:mm:ss} ({result:dddd})");
|
||||
}
|
||||
|
||||
private static ToolResult Epoch(JsonElement args)
|
||||
{
|
||||
var input = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(input)) return ToolResult.Fail("'date' parameter is required");
|
||||
|
||||
// 숫자면 epoch → datetime
|
||||
if (long.TryParse(input, out var epoch))
|
||||
{
|
||||
var dt = DateTimeOffset.FromUnixTimeSeconds(epoch);
|
||||
return ToolResult.Ok(
|
||||
$"Epoch: {epoch}\n" +
|
||||
$"UTC: {dt.UtcDateTime:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$"Local: {dt.LocalDateTime:yyyy-MM-dd HH:mm:ss}");
|
||||
}
|
||||
|
||||
// 문자열이면 datetime → epoch
|
||||
if (DateTime.TryParse(input, out var parsed))
|
||||
{
|
||||
var e = new DateTimeOffset(parsed).ToUnixTimeSeconds();
|
||||
return ToolResult.Ok(
|
||||
$"Date: {parsed:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$"Epoch: {e}");
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"Cannot parse: '{input}'");
|
||||
}
|
||||
|
||||
private static ToolResult FormatDate(JsonElement args)
|
||||
{
|
||||
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
||||
var pattern = args.TryGetProperty("pattern", out var pv) ? pv.GetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
|
||||
|
||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
|
||||
|
||||
return ToolResult.Ok(dt.ToString(pattern, CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
217
src/AxCopilot/Services/Agent/DevEnvDetectTool.cs
Normal file
217
src/AxCopilot/Services/Agent/DevEnvDetectTool.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 개발 환경 감지 도구.
|
||||
/// IDE, 언어 런타임, 빌드 도구의 설치 여부와 경로를 자동 감지합니다.
|
||||
/// 사내 환경에서 설치된 도구만 사용하도록 LLM에 정보를 제공합니다.
|
||||
/// </summary>
|
||||
public class DevEnvDetectTool : IAgentTool
|
||||
{
|
||||
public string Name => "dev_env_detect";
|
||||
public string Description =>
|
||||
"Detect installed development tools on this machine. " +
|
||||
"Returns: IDEs (VS Code, Visual Studio, IntelliJ, PyCharm), " +
|
||||
"language runtimes (dotnet, python/conda, java, node, gcc/g++), " +
|
||||
"and build tools (MSBuild, Maven, Gradle, CMake, npm/yarn). " +
|
||||
"Use this before running build/test commands to know what's available.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["category"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Detection category: all (default), ides, runtimes, build_tools",
|
||||
Enum = ["all", "ides", "runtimes", "build_tools"],
|
||||
},
|
||||
},
|
||||
Required = []
|
||||
};
|
||||
|
||||
// 60초 캐시
|
||||
private static (DateTime Time, string Result)? _cache;
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var category = args.TryGetProperty("category", out var cat) ? cat.GetString() ?? "all" : "all";
|
||||
|
||||
// 캐시 확인
|
||||
if (_cache.HasValue && (DateTime.UtcNow - _cache.Value.Time).TotalSeconds < 60)
|
||||
return Task.FromResult(ToolResult.Ok(_cache.Value.Result));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== 개발 환경 감지 보고서 ===\n");
|
||||
|
||||
if (category is "all" or "ides")
|
||||
{
|
||||
sb.AppendLine("## IDE");
|
||||
DetectIde(sb, "VS Code", DetectVsCode);
|
||||
DetectIde(sb, "Visual Studio", DetectVisualStudio);
|
||||
DetectIde(sb, "IntelliJ IDEA", () => DetectJetBrains("IntelliJ"));
|
||||
DetectIde(sb, "PyCharm", () => DetectJetBrains("PyCharm"));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (category is "all" or "runtimes")
|
||||
{
|
||||
sb.AppendLine("## Language Runtimes");
|
||||
DetectCommand(sb, "dotnet", "dotnet --version", "DOTNET_ROOT");
|
||||
DetectCommand(sb, "python", "python --version", "PYTHON_HOME");
|
||||
DetectCommand(sb, "conda", "conda --version", "CONDA_PREFIX");
|
||||
DetectCommand(sb, "java", "java -version", "JAVA_HOME");
|
||||
DetectCommand(sb, "node", "node --version", "NODE_HOME");
|
||||
DetectCommand(sb, "npm", "npm --version", null);
|
||||
DetectCommand(sb, "gcc", "gcc --version", null);
|
||||
DetectCommand(sb, "g++", "g++ --version", null);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (category is "all" or "build_tools")
|
||||
{
|
||||
sb.AppendLine("## Build Tools");
|
||||
DetectCommand(sb, "MSBuild", "msbuild -version", null);
|
||||
DetectCommand(sb, "Maven", "mvn --version", "MAVEN_HOME");
|
||||
DetectCommand(sb, "Gradle", "gradle --version", "GRADLE_HOME");
|
||||
DetectCommand(sb, "CMake", "cmake --version", null);
|
||||
DetectCommand(sb, "yarn", "yarn --version", null);
|
||||
DetectCommand(sb, "pip", "pip --version", null);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var result = sb.ToString();
|
||||
_cache = (DateTime.UtcNow, result);
|
||||
return Task.FromResult(ToolResult.Ok(result));
|
||||
}
|
||||
|
||||
private static void DetectIde(StringBuilder sb, string name, Func<string?> detector)
|
||||
{
|
||||
var path = detector();
|
||||
sb.AppendLine(path != null ? $" ✅ {name}: {path}" : $" ❌ {name}: 미설치");
|
||||
}
|
||||
|
||||
private static void DetectCommand(StringBuilder sb, string name, string versionCmd, string? envVar)
|
||||
{
|
||||
// 환경변수 확인
|
||||
string? envPath = null;
|
||||
if (envVar != null)
|
||||
envPath = Environment.GetEnvironmentVariable(envVar);
|
||||
|
||||
// where.exe로 PATH 확인
|
||||
var wherePath = RunQuick("where.exe", name);
|
||||
var version = "";
|
||||
|
||||
if (wherePath != null)
|
||||
{
|
||||
// 버전 확인
|
||||
var parts = versionCmd.Split(' ', 2);
|
||||
var verOutput = RunQuick(parts[0], parts.Length > 1 ? parts[1] : "");
|
||||
if (verOutput != null)
|
||||
version = verOutput.Split('\n')[0].Trim();
|
||||
}
|
||||
|
||||
if (wherePath != null)
|
||||
sb.AppendLine($" ✅ {name}: {version} ({wherePath.Split('\n')[0].Trim()})");
|
||||
else if (envPath != null)
|
||||
sb.AppendLine($" ⚠ {name}: 환경변수만 ({envVar}={envPath})");
|
||||
else
|
||||
sb.AppendLine($" ❌ {name}: 미설치");
|
||||
}
|
||||
|
||||
private static string? RunQuick(string exe, string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return null;
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
var error = proc.StandardError.ReadToEnd();
|
||||
proc.WaitForExit(5000);
|
||||
var result = string.IsNullOrWhiteSpace(output) ? error : output;
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result.Trim();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? DetectVsCode()
|
||||
{
|
||||
// PATH 확인
|
||||
var where = RunQuick("where.exe", "code");
|
||||
if (where != null) return where.Split('\n')[0].Trim();
|
||||
|
||||
// 기본 설치 경로
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var defaultPath = Path.Combine(localAppData, "Programs", "Microsoft VS Code", "Code.exe");
|
||||
return File.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
private static string? DetectVisualStudio()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 레지스트리에서 Visual Studio 설치 경로 검색
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\VisualStudio\SxS\VS7");
|
||||
if (key != null)
|
||||
{
|
||||
foreach (var name in key.GetValueNames().OrderByDescending(n => n))
|
||||
{
|
||||
var path = key.GetValue(name)?.ToString();
|
||||
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
|
||||
return $"Visual Studio {name} ({path})";
|
||||
}
|
||||
}
|
||||
|
||||
// WOW6432Node 확인
|
||||
using var key32 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\SxS\VS7");
|
||||
if (key32 != null)
|
||||
{
|
||||
foreach (var name in key32.GetValueNames().OrderByDescending(n => n))
|
||||
{
|
||||
var path = key32.GetValue(name)?.ToString();
|
||||
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
|
||||
return $"Visual Studio {name} ({path})";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// devenv.exe PATH 확인
|
||||
var where = RunQuick("where.exe", "devenv");
|
||||
return where != null ? where.Split('\n')[0].Trim() : null;
|
||||
}
|
||||
|
||||
private static string? DetectJetBrains(string product)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\JetBrains\{product}");
|
||||
if (key != null)
|
||||
{
|
||||
var subKeys = key.GetSubKeyNames().OrderByDescending(n => n).ToArray();
|
||||
if (subKeys.Length > 0)
|
||||
{
|
||||
using var verKey = key.OpenSubKey(subKeys[0]);
|
||||
var installDir = verKey?.GetValue("InstallDir")?.ToString()
|
||||
?? verKey?.GetValue("")?.ToString();
|
||||
if (!string.IsNullOrEmpty(installDir))
|
||||
return $"{product} {subKeys[0]} ({installDir})";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
178
src/AxCopilot/Services/Agent/DiffPreviewTool.cs
Normal file
178
src/AxCopilot/Services/Agent/DiffPreviewTool.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 파일 변경 사항을 미리 보여주고 사용자 승인을 받는 도구.
|
||||
/// 통합 diff를 생성하여 "[PREVIEW_PENDING]" 접두사와 함께 반환합니다.
|
||||
/// </summary>
|
||||
public class DiffPreviewTool : IAgentTool
|
||||
{
|
||||
public string Name => "diff_preview";
|
||||
|
||||
public string Description =>
|
||||
"Preview file changes before applying them. Shows a unified diff and waits for user approval. " +
|
||||
"If approved, writes the new content to the file. If rejected, no changes are made. " +
|
||||
"The diff output is prefixed with [PREVIEW_PENDING] so the UI can show an approval panel.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File path to modify",
|
||||
},
|
||||
["new_content"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Proposed new content for the file",
|
||||
},
|
||||
["description"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Description of the changes (optional)",
|
||||
},
|
||||
},
|
||||
Required = ["path", "new_content"],
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var newContent = args.GetProperty("new_content").GetString() ?? "";
|
||||
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
||||
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
if (!context.IsPathAllowed(path))
|
||||
return ToolResult.Fail($"경로 접근 차단: {path}");
|
||||
|
||||
try
|
||||
{
|
||||
// 원본 파일 읽기 (없으면 새 파일 생성으로 처리)
|
||||
var originalContent = "";
|
||||
var isNewFile = !File.Exists(path);
|
||||
var sourceEncoding = TextFileCodec.Utf8NoBom;
|
||||
var sourceHasBom = false;
|
||||
if (!isNewFile)
|
||||
{
|
||||
var read = await TextFileCodec.ReadAllTextAsync(path, ct);
|
||||
originalContent = read.Text;
|
||||
sourceEncoding = read.Encoding;
|
||||
sourceHasBom = read.HasBom;
|
||||
}
|
||||
|
||||
// 통합 diff 생성
|
||||
var originalLines = originalContent.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
var newLines = newContent.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
var diff = GenerateUnifiedDiff(originalLines, newLines, path);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[PREVIEW_PENDING]");
|
||||
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
sb.AppendLine($"변경 설명: {description}");
|
||||
|
||||
sb.AppendLine($"파일: {path}");
|
||||
sb.AppendLine(isNewFile ? "상태: 새 파일 생성" : "상태: 기존 파일 수정");
|
||||
sb.AppendLine();
|
||||
|
||||
if (string.IsNullOrEmpty(diff))
|
||||
sb.AppendLine("변경 사항 없음 — 내용이 동일합니다.");
|
||||
else
|
||||
sb.Append(diff);
|
||||
|
||||
// 쓰기 권한 확인 (AskPermission 콜백 사용 — CustomMessageBox)
|
||||
if (!await context.CheckWritePermissionAsync("diff_preview", path))
|
||||
return ToolResult.Ok($"사용자가 파일 변경을 거부했습니다.\n\n{sb}");
|
||||
|
||||
// 디렉토리 생성
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var writeEncoding = isNewFile
|
||||
? TextFileCodec.Utf8NoBom
|
||||
: TextFileCodec.ResolveWriteEncoding(sourceEncoding, sourceHasBom);
|
||||
await TextFileCodec.WriteAllTextAsync(path, newContent, writeEncoding, ct);
|
||||
return ToolResult.Ok($"변경 사항이 적용되었습니다: {path}\n\n{sb}", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"미리보기 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateUnifiedDiff(string[] original, string[] modified, string filePath)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"--- {filePath} (원본)");
|
||||
sb.AppendLine($"+++ {filePath} (수정)");
|
||||
|
||||
// 간단한 LCS 기반 diff
|
||||
var lcs = ComputeLcs(original, modified);
|
||||
int oi = 0, mi = 0, ci = 0;
|
||||
var hunks = new List<(int os, int oe, int ms, int me)>();
|
||||
|
||||
while (oi < original.Length || mi < modified.Length)
|
||||
{
|
||||
if (ci < lcs.Count && oi < original.Length && mi < modified.Length
|
||||
&& original[oi] == lcs[ci] && modified[mi] == lcs[ci])
|
||||
{
|
||||
oi++; mi++; ci++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var hos = oi;
|
||||
var hms = mi;
|
||||
while (oi < original.Length && (ci >= lcs.Count || original[oi] != lcs[ci]))
|
||||
oi++;
|
||||
while (mi < modified.Length && (ci >= lcs.Count || modified[mi] != lcs[ci]))
|
||||
mi++;
|
||||
hunks.Add((hos, oi, hms, mi));
|
||||
}
|
||||
}
|
||||
|
||||
if (hunks.Count == 0) return "";
|
||||
|
||||
foreach (var (os, oe, ms, me) in hunks)
|
||||
{
|
||||
sb.AppendLine($"@@ -{os + 1},{oe - os} +{ms + 1},{me - ms} @@");
|
||||
for (var i = os; i < oe; i++)
|
||||
sb.AppendLine($"-{original[i]}");
|
||||
for (var i = ms; i < me; i++)
|
||||
sb.AppendLine($"+{modified[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<string> ComputeLcs(string[] a, string[] b)
|
||||
{
|
||||
var m = a.Length;
|
||||
var n = b.Length;
|
||||
|
||||
// 메모리 절약: 큰 파일은 전체를 diff로 표시
|
||||
if ((long)m * n > 10_000_000)
|
||||
return [];
|
||||
|
||||
var dp = new int[m + 1, n + 1];
|
||||
for (var i = m - 1; i >= 0; i--)
|
||||
for (var j = n - 1; j >= 0; j--)
|
||||
dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);
|
||||
|
||||
var result = new List<string>();
|
||||
int x = 0, y = 0;
|
||||
while (x < m && y < n)
|
||||
{
|
||||
if (a[x] == b[y]) { result.Add(a[x]); x++; y++; }
|
||||
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
|
||||
else y++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
158
src/AxCopilot/Services/Agent/DiffTool.cs
Normal file
158
src/AxCopilot/Services/Agent/DiffTool.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 두 텍스트 또는 파일을 비교하여 통합 diff 결과를 출력하는 도구.
|
||||
/// </summary>
|
||||
public class DiffTool : IAgentTool
|
||||
{
|
||||
public string Name => "diff_tool";
|
||||
public string Description =>
|
||||
"Compare two texts or files and output a unified diff. " +
|
||||
"Use 'text' mode to compare two text strings directly, " +
|
||||
"or 'file' mode to compare two files by path.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["mode"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Comparison mode",
|
||||
Enum = ["text", "file"],
|
||||
},
|
||||
["left"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Left text content or file path",
|
||||
},
|
||||
["right"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Right text content or file path",
|
||||
},
|
||||
["left_label"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Label for left side (optional, default: 'left')",
|
||||
},
|
||||
["right_label"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Label for right side (optional, default: 'right')",
|
||||
},
|
||||
},
|
||||
Required = ["mode", "left", "right"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var mode = args.GetProperty("mode").GetString() ?? "text";
|
||||
var left = args.GetProperty("left").GetString() ?? "";
|
||||
var right = args.GetProperty("right").GetString() ?? "";
|
||||
var leftLabel = args.TryGetProperty("left_label", out var ll) ? ll.GetString() ?? "left" : "left";
|
||||
var rightLabel = args.TryGetProperty("right_label", out var rl) ? rl.GetString() ?? "right" : "right";
|
||||
|
||||
try
|
||||
{
|
||||
if (mode == "file")
|
||||
{
|
||||
var leftPath = Path.IsPathRooted(left) ? left : Path.Combine(context.WorkFolder, left);
|
||||
var rightPath = Path.IsPathRooted(right) ? right : Path.Combine(context.WorkFolder, right);
|
||||
|
||||
if (!File.Exists(leftPath)) return Task.FromResult(ToolResult.Fail($"Left file not found: {leftPath}"));
|
||||
if (!File.Exists(rightPath)) return Task.FromResult(ToolResult.Fail($"Right file not found: {rightPath}"));
|
||||
|
||||
left = TextFileCodec.ReadAllText(leftPath).Text;
|
||||
right = TextFileCodec.ReadAllText(rightPath).Text;
|
||||
leftLabel = Path.GetFileName(leftPath);
|
||||
rightLabel = Path.GetFileName(rightPath);
|
||||
}
|
||||
|
||||
var leftLines = left.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
var rightLines = right.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
|
||||
var diff = GenerateUnifiedDiff(leftLines, rightLines, leftLabel, rightLabel);
|
||||
if (string.IsNullOrEmpty(diff))
|
||||
return Task.FromResult(ToolResult.Ok("No differences found — files/texts are identical."));
|
||||
|
||||
if (diff.Length > 10000) diff = diff[..10000] + "\n... (truncated)";
|
||||
return Task.FromResult(ToolResult.Ok(diff));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"Diff 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateUnifiedDiff(string[] left, string[] right, string leftLabel, string rightLabel)
|
||||
{
|
||||
// 간단한 LCS 기반 diff
|
||||
var lcs = ComputeLcs(left, right);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"--- {leftLabel}");
|
||||
sb.AppendLine($"+++ {rightLabel}");
|
||||
|
||||
int li = 0, ri = 0, ci = 0;
|
||||
var hunks = new List<(int ls, int le, int rs, int re)>();
|
||||
|
||||
// hunk 수집
|
||||
while (li < left.Length || ri < right.Length)
|
||||
{
|
||||
if (ci < lcs.Count && li < left.Length && ri < right.Length && left[li] == lcs[ci] && right[ri] == lcs[ci])
|
||||
{
|
||||
li++; ri++; ci++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var hls = li; var hrs = ri;
|
||||
while (li < left.Length && (ci >= lcs.Count || left[li] != lcs[ci]))
|
||||
li++;
|
||||
while (ri < right.Length && (ci >= lcs.Count || right[ri] != lcs[ci]))
|
||||
ri++;
|
||||
hunks.Add((hls, li, hrs, ri));
|
||||
}
|
||||
}
|
||||
|
||||
if (hunks.Count == 0) return "";
|
||||
|
||||
// 출력
|
||||
foreach (var (ls, le, rs, re) in hunks)
|
||||
{
|
||||
var contextStart = Math.Max(0, ls - 3);
|
||||
sb.AppendLine($"@@ -{ls + 1},{le - ls} +{rs + 1},{re - rs} @@");
|
||||
for (var i = ls; i < le; i++)
|
||||
sb.AppendLine($"-{left[i]}");
|
||||
for (var i = rs; i < re; i++)
|
||||
sb.AppendLine($"+{right[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<string> ComputeLcs(string[] a, string[] b)
|
||||
{
|
||||
var m = a.Length; var n = b.Length;
|
||||
// 메모리 절약을 위해 큰 파일은 제한
|
||||
if ((long)m * n > 10_000_000)
|
||||
return new List<string>(); // 너무 큰 경우 전체를 diff로 표시
|
||||
|
||||
var dp = new int[m + 1, n + 1];
|
||||
for (var i = m - 1; i >= 0; i--)
|
||||
for (var j = n - 1; j >= 0; j--)
|
||||
dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);
|
||||
|
||||
var result = new List<string>();
|
||||
int x = 0, y = 0;
|
||||
while (x < m && y < n)
|
||||
{
|
||||
if (a[x] == b[y]) { result.Add(a[x]); x++; y++; }
|
||||
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
|
||||
else y++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
370
src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs
Normal file
370
src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs
Normal file
@@ -0,0 +1,370 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 여러 섹션의 내용을 하나의 완성된 문서로 조립하는 도구.
|
||||
/// 멀티패스 문서 생성의 3단계: 개별 생성된 섹션들을 최종 문서로 결합합니다.
|
||||
/// </summary>
|
||||
public class DocumentAssemblerTool : IAgentTool
|
||||
{
|
||||
|
||||
public string Name => "document_assemble";
|
||||
public string Description =>
|
||||
"Assemble multiple individually-written sections into a single complete document. " +
|
||||
"Use this after writing each section separately with document_plan. " +
|
||||
"Supports HTML, DOCX, and Markdown output. " +
|
||||
"Automatically adds table of contents, cover page, and section numbering for HTML. " +
|
||||
"After assembly, the document is auto-validated for quality issues.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path. Relative to work folder." },
|
||||
["title"] = new() { Type = "string", Description = "Document title" },
|
||||
["sections"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Array of section objects: [{\"heading\": \"1. 개요\", \"content\": \"HTML or markdown body...\", \"level\": 1}]. " +
|
||||
"content should be the detailed text for each section.",
|
||||
Items = new() { Type = "object" }
|
||||
},
|
||||
["format"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Output format: html, docx, markdown. Default: html",
|
||||
Enum = ["html", "docx", "markdown"]
|
||||
},
|
||||
["mood"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional"
|
||||
},
|
||||
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" },
|
||||
["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." },
|
||||
["header"] = new() { Type = "string", Description = "Header text for DOCX output." },
|
||||
["footer"] = new() { Type = "string", Description = "Footer text for DOCX output. Use {page} for page number." },
|
||||
},
|
||||
Required = ["path", "title", "sections"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var title = args.GetProperty("title").GetString() ?? "Document";
|
||||
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "professional" : "professional";
|
||||
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
||||
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
|
||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
||||
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
|
||||
|
||||
if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("sections 배열이 필요합니다.");
|
||||
|
||||
var sections = new List<(string Heading, string Content, int Level)>();
|
||||
foreach (var sec in sectionsEl.EnumerateArray())
|
||||
{
|
||||
var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
|
||||
var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
|
||||
sections.Add((heading, content, level));
|
||||
}
|
||||
|
||||
if (sections.Count == 0)
|
||||
return ToolResult.Fail("조립할 섹션이 없습니다.");
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
|
||||
// 확장자 자동 추가
|
||||
var ext = format switch
|
||||
{
|
||||
"docx" => ".docx",
|
||||
"markdown" => ".md",
|
||||
_ => ".html"
|
||||
};
|
||||
if (!fullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ext;
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
try
|
||||
{
|
||||
string resultMsg;
|
||||
switch (format)
|
||||
{
|
||||
case "docx":
|
||||
resultMsg = AssembleDocx(fullPath, title, sections, headerText, footerText);
|
||||
break;
|
||||
case "markdown":
|
||||
resultMsg = AssembleMarkdown(fullPath, title, sections);
|
||||
break;
|
||||
default:
|
||||
resultMsg = AssembleHtml(fullPath, title, sections, mood, useToc, coverSubtitle);
|
||||
break;
|
||||
}
|
||||
|
||||
// 품질 요약 통계
|
||||
var totalChars = sections.Sum(s => s.Content.Length);
|
||||
var totalWords = sections.Sum(s => EstimateWordCount(s.Content));
|
||||
var pageEstimate = Math.Max(1, totalWords / 500);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"✅ 문서 조립 완료: {Path.GetFileName(fullPath)}\n" +
|
||||
$" 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n" +
|
||||
$"{resultMsg}", fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"문서 조립 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string AssembleHtml(string path, string title, List<(string Heading, string Content, int Level)> sections,
|
||||
string mood, bool toc, string? coverSubtitle)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var style = TemplateService.GetCss(mood);
|
||||
var moodInfo = TemplateService.GetMood(mood);
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"ko\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine($"<title>{Escape(title)}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(style);
|
||||
// 추가 조립용 스타일
|
||||
sb.AppendLine(@"
|
||||
.assembled-doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
|
||||
.assembled-doc h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
.assembled-doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
|
||||
.assembled-doc h3 { font-size: 18px; margin-top: 24px; margin-bottom: 8px; }
|
||||
.assembled-doc .section-content { line-height: 1.8; margin-bottom: 20px; }
|
||||
.assembled-doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
|
||||
.assembled-doc .toc h3 { margin-top: 0; }
|
||||
.assembled-doc .toc a { color: inherit; text-decoration: none; }
|
||||
.assembled-doc .toc a:hover { text-decoration: underline; }
|
||||
.assembled-doc .toc ul { list-style: none; padding-left: 0; }
|
||||
.assembled-doc .toc li { padding: 4px 0; }
|
||||
.cover-page { text-align: center; padding: 120px 40px 80px; page-break-after: always; }
|
||||
.cover-page h1 { font-size: 36px; margin-bottom: 16px; }
|
||||
.cover-page .subtitle { font-size: 18px; color: #666; margin-bottom: 40px; }
|
||||
.cover-page .date { font-size: 14px; color: #999; }
|
||||
@media print { .cover-page { page-break-after: always; } .assembled-doc h2 { page-break-before: auto; } }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
|
||||
// 커버 페이지
|
||||
if (!string.IsNullOrWhiteSpace(coverSubtitle))
|
||||
{
|
||||
sb.AppendLine("<div class=\"cover-page\">");
|
||||
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
sb.AppendLine($"<div class=\"subtitle\">{Escape(coverSubtitle)}</div>");
|
||||
sb.AppendLine($"<div class=\"date\">{DateTime.Now:yyyy년 MM월 dd일}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("<div class=\"assembled-doc\">");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(coverSubtitle))
|
||||
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
|
||||
// TOC 생성
|
||||
if (toc && sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("<div class=\"toc\">");
|
||||
sb.AppendLine("<h3>📋 목차</h3>");
|
||||
sb.AppendLine("<ul>");
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
{
|
||||
var indent = sections[i].Level > 1 ? " style=\"padding-left:20px\"" : "";
|
||||
sb.AppendLine($"<li{indent}><a href=\"#section-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
|
||||
}
|
||||
sb.AppendLine("</ul>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// 섹션 본문
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
{
|
||||
var (heading, content, level) = sections[i];
|
||||
var tag = level <= 1 ? "h2" : "h3";
|
||||
sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}</{tag}>");
|
||||
sb.AppendLine($"<div class=\"section-content\">{content}</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
|
||||
var issues = ValidateBasic(sb.ToString());
|
||||
return issues.Count > 0
|
||||
? $" ⚠ 품질 검증 이슈 {issues.Count}건: {string.Join("; ", issues)}"
|
||||
: " ✓ 품질 검증 통과";
|
||||
}
|
||||
|
||||
private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections,
|
||||
string? headerText, string? footerText)
|
||||
{
|
||||
// DOCX 조립: DocxSkill의 sections 형식으로 변환하여 OpenXML 사용
|
||||
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
|
||||
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
|
||||
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
||||
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
|
||||
|
||||
// 제목
|
||||
var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "48" }
|
||||
});
|
||||
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(title));
|
||||
titlePara.AppendChild(titleRun);
|
||||
body.AppendChild(titlePara);
|
||||
|
||||
// 빈 줄
|
||||
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
|
||||
|
||||
// 각 섹션
|
||||
foreach (var (heading, content, level) in sections)
|
||||
{
|
||||
// 섹션 제목
|
||||
var headPara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" },
|
||||
Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" },
|
||||
});
|
||||
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(heading));
|
||||
headPara.AppendChild(headRun);
|
||||
body.AppendChild(headPara);
|
||||
|
||||
// 섹션 본문 (줄 단위 분할)
|
||||
var lines = StripHtmlTags(content).Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim())
|
||||
{
|
||||
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
|
||||
});
|
||||
para.AppendChild(run);
|
||||
body.AppendChild(para);
|
||||
}
|
||||
|
||||
// 섹션 간 빈 줄
|
||||
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
|
||||
}
|
||||
|
||||
return " ✓ DOCX 조립 완료";
|
||||
}
|
||||
|
||||
private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# {title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
|
||||
sb.AppendLine();
|
||||
|
||||
// TOC
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("## 목차");
|
||||
sb.AppendLine();
|
||||
foreach (var (heading, _, _) in sections)
|
||||
{
|
||||
var anchor = heading.Replace(" ", "-").ToLowerInvariant();
|
||||
sb.AppendLine($"- [{heading}](#{anchor})");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var (heading, content, level) in sections)
|
||||
{
|
||||
var prefix = level <= 1 ? "##" : "###";
|
||||
sb.AppendLine($"{prefix} {heading}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(StripHtmlTags(content));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
return " ✓ Markdown 조립 완료";
|
||||
}
|
||||
|
||||
private static int EstimateWordCount(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||
var plain = StripHtmlTags(text);
|
||||
// 한국어: 글자 수 / 3 ≈ 단어 수, 영어: 공백 분리
|
||||
var spaces = plain.Count(c => c == ' ');
|
||||
var koreanChars = plain.Count(c => c >= 0xAC00 && c <= 0xD7A3);
|
||||
return spaces + 1 + koreanChars / 3;
|
||||
}
|
||||
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html)) return "";
|
||||
return System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ")
|
||||
.Replace(" ", " ")
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace(" ", " ")
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static List<string> ValidateBasic(string html)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
if (html.Length < 500)
|
||||
issues.Add("문서 내용이 매우 짧습니다 (500자 미만)");
|
||||
|
||||
// 빈 섹션 검사
|
||||
var emptySectionPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"<h[23][^>]*>[^<]+</h[23]>\s*<div class=""section-content"">\s*</div>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var emptyMatches = emptySectionPattern.Matches(html);
|
||||
if (emptyMatches.Count > 0)
|
||||
issues.Add($"빈 섹션 {emptyMatches.Count}개 발견");
|
||||
|
||||
// 플레이스홀더 검사
|
||||
if (html.Contains("[TODO]", StringComparison.OrdinalIgnoreCase) ||
|
||||
html.Contains("[PLACEHOLDER]", StringComparison.OrdinalIgnoreCase) ||
|
||||
html.Contains("Lorem ipsum", StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add("플레이스홀더 텍스트가 남아있습니다");
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static string Escape(string text)
|
||||
{
|
||||
return text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
}
|
||||
600
src/AxCopilot/Services/Agent/DocumentPlannerTool.cs
Normal file
600
src/AxCopilot/Services/Agent/DocumentPlannerTool.cs
Normal file
@@ -0,0 +1,600 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 개요(Outline)를 구조화된 JSON으로 생성하는 도구.
|
||||
/// - 멀티패스(고품질) ON : 개요만 반환 → LLM이 섹션별로 상세 작성 → document_assemble로 조립
|
||||
/// - 멀티패스(고품질) OFF: 개요 + 기본 문서를 즉시 로컬 파일로 저장 (LLM 호출 최소)
|
||||
/// </summary>
|
||||
public class DocumentPlannerTool : IAgentTool
|
||||
{
|
||||
private static bool IsMultiPassEnabled()
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
return app?.SettingsService?.Settings.Llm.EnableMultiPassDocument ?? false;
|
||||
}
|
||||
|
||||
private static string GetFolderDataUsage()
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
return app?.SettingsService?.Settings.Llm.FolderDataUsage ?? "none";
|
||||
}
|
||||
|
||||
public string Name => "document_plan";
|
||||
public string Description =>
|
||||
"Create a structured document outline/plan and optionally generate the document file immediately. " +
|
||||
"Use this BEFORE generating long documents (3+ pages). " +
|
||||
"When multi-pass mode is OFF, this tool directly creates and saves a document file with section headings and key points. " +
|
||||
"When multi-pass mode is ON, returns a plan for section-by-section writing via document_assemble.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["topic"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Document topic or full user request describing what the document should cover."
|
||||
},
|
||||
["document_type"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Type of document: report, proposal, analysis, manual, minutes, presentation, guide. Default: report",
|
||||
Enum = ["report", "proposal", "analysis", "manual", "minutes", "presentation", "guide"]
|
||||
},
|
||||
["target_pages"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Target number of pages (1 page ≈ 500 words). Default: 5"
|
||||
},
|
||||
["format"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Output document format: html, docx, markdown. Default: html",
|
||||
Enum = ["html", "docx", "markdown"]
|
||||
},
|
||||
["sections_hint"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Optional hint for desired sections/structure (e.g. '서론, 현황분석, 문제점, 개선방안, 결론')"
|
||||
},
|
||||
["reference_summary"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Optional summary of reference data already read via file_read (to incorporate into plan)"
|
||||
},
|
||||
},
|
||||
Required = ["topic"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var topic = args.GetProperty("topic").GetString() ?? "";
|
||||
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
|
||||
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
||||
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
|
||||
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
|
||||
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
return Task.FromResult(ToolResult.Fail("topic이 비어있습니다."));
|
||||
|
||||
if (targetPages < 1) targetPages = 1;
|
||||
if (targetPages > 50) targetPages = 50;
|
||||
|
||||
var highQuality = IsMultiPassEnabled();
|
||||
var folderDataUsage = GetFolderDataUsage();
|
||||
|
||||
// 고품질 모드: 목표 페이지와 단어 수를 1.5배 확장
|
||||
var effectivePages = highQuality ? (int)Math.Ceiling(targetPages * 1.5) : targetPages;
|
||||
var totalWords = effectivePages * 500;
|
||||
|
||||
var sections = BuildSections(docType, effectivePages, sectionsHint, refSummary);
|
||||
DistributeWordCount(sections, totalWords);
|
||||
|
||||
// 폴더 데이터 활용 모드: 먼저 파일을 읽어야 하므로 별도 처리
|
||||
if (folderDataUsage is "active" or "passive")
|
||||
return ExecuteSinglePassWithData(topic, docType, format, effectivePages, totalWords, sections, folderDataUsage, refSummary);
|
||||
|
||||
// 일반/고품질 모드 모두 동일 구조:
|
||||
// 개요 반환 + document_assemble 즉시 호출 지시
|
||||
return ExecuteWithAssembleInstructions(topic, docType, format, effectivePages, totalWords, sections, highQuality);
|
||||
}
|
||||
|
||||
// ─── 통합: 포맷별 body 골격 생성 + 즉시 호출 가능한 도구 파라미터 제시 ──────
|
||||
|
||||
private Task<ToolResult> ExecuteWithAssembleInstructions(string topic, string docType, string format,
|
||||
int targetPages, int totalWords, List<SectionPlan> sections, bool highQuality)
|
||||
{
|
||||
var safeTitle = SanitizeFileName(topic);
|
||||
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
|
||||
var suggestedFileName = $"{safeTitle}{ext}";
|
||||
var label = highQuality ? "[고품질]" : "[표준]";
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case "markdown":
|
||||
return ExecuteWithMarkdownScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
|
||||
case "docx":
|
||||
return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
|
||||
default: // html
|
||||
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>html_create 즉시 호출 가능한 body 골격 반환. 섹션 구조만 고정하고 내부 시각화는 LLM이 자유롭게 선택.</summary>
|
||||
private Task<ToolResult> ExecuteWithHtmlScaffold(string topic, string fileName,
|
||||
List<SectionPlan> sections, int targetPages, int totalWords, string label)
|
||||
{
|
||||
var bodySb = new StringBuilder();
|
||||
foreach (var s in sections)
|
||||
{
|
||||
// 키포인트 내용을 분석해 적합한 HTML 요소를 제안 (강제 아님)
|
||||
var elementHints = SuggestHtmlElements(s.KeyPoints);
|
||||
|
||||
bodySb.AppendLine($"<h2>{Escape(s.Heading)}</h2>");
|
||||
bodySb.AppendLine($"<!-- 목표 {s.TargetWords}단어 | 핵심: {string.Join(", ", s.KeyPoints)}");
|
||||
bodySb.AppendLine($" 활용 가능 요소(내용에 맞게 자유 선택): {elementHints} -->");
|
||||
// 섹션 내부는 완전히 비워둠 — LLM이 내용과 구조를 모두 결정
|
||||
bodySb.AppendLine();
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.AppendLine($"📋 문서 개요 생성 완료 {label} ({sections.Count}개 섹션, {targetPages}페이지/{totalWords}단어)");
|
||||
output.AppendLine();
|
||||
output.AppendLine("## 즉시 실행: html_create 호출");
|
||||
output.AppendLine($"path: \"{fileName}\"");
|
||||
output.AppendLine($"title: \"{topic}\"");
|
||||
output.AppendLine("toc: true, numbered: true, mood: \"professional\"");
|
||||
output.AppendLine($"cover: {{\"title\": \"{topic}\", \"author\": \"AX Copilot Agent\"}}");
|
||||
output.AppendLine();
|
||||
output.AppendLine("body에 아래 섹션 구조를 기반으로 각 섹션의 내용과 시각화를 자유롭게 작성하세요:");
|
||||
output.AppendLine("(주석의 '활용 가능 요소'는 참고용이며, 내용에 맞게 다른 요소를 써도 됩니다)");
|
||||
output.AppendLine();
|
||||
output.AppendLine("--- body 시작 ---");
|
||||
output.Append(bodySb);
|
||||
output.AppendLine("--- body 끝 ---");
|
||||
output.AppendLine();
|
||||
output.AppendLine("⚠ html_create를 지금 즉시 호출하세요. 모든 섹션에 충분한 실제 내용을 작성하세요.");
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(output.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>키포인트 키워드를 분석해 적합한 HTML 시각화 요소를 제안합니다.</summary>
|
||||
private static string SuggestHtmlElements(List<string> keyPoints)
|
||||
{
|
||||
var joined = string.Join(" ", keyPoints).ToLowerInvariant();
|
||||
var hints = new List<string>();
|
||||
|
||||
// 숫자/통계/비교 데이터 → 테이블 또는 차트
|
||||
if (ContainsAny(joined, "매출", "실적", "통계", "수치", "비교", "현황", "지표", "점유율", "비율", "순위", "성과"))
|
||||
{
|
||||
hints.Add("table(데이터 정리)");
|
||||
hints.Add("div.chart-bar(수치 시각화)");
|
||||
}
|
||||
// SWOT/강약점/리스크 → 그리드 + 콜아웃
|
||||
if (ContainsAny(joined, "강점", "약점", "기회", "위협", "swot", "리스크", "문제점", "장점", "단점"))
|
||||
{
|
||||
hints.Add("div.grid-2(항목 대비)");
|
||||
hints.Add("callout-warning/callout-tip");
|
||||
}
|
||||
// 전략/제안/방향 → 콜아웃 + 배지
|
||||
if (ContainsAny(joined, "전략", "제안", "방안", "계획", "방향", "로드맵", "목표"))
|
||||
{
|
||||
hints.Add("callout-info(핵심 전략)");
|
||||
hints.Add("span.badge-blue/green(구분 배지)");
|
||||
}
|
||||
// 일정/단계/프로세스 → 타임라인
|
||||
if (ContainsAny(joined, "일정", "단계", "절차", "프로세스", "과정", "순서", "연혁", "역사"))
|
||||
hints.Add("div.timeline(단계/일정)");
|
||||
// 진행률/달성도 → 진행 바
|
||||
if (ContainsAny(joined, "달성", "진행", "완료", "목표 대비", "진척"))
|
||||
hints.Add("div.progress(달성률)");
|
||||
// 카드형 항목 나열
|
||||
if (ContainsAny(joined, "종류", "유형", "분류", "구성", "항목", "요소", "구분"))
|
||||
hints.Add("div.grid-3.card(항목 카드)");
|
||||
// 인용/핵심 메시지
|
||||
if (ContainsAny(joined, "핵심", "요약", "결론", "시사점", "포인트"))
|
||||
hints.Add("blockquote(핵심 메시지)");
|
||||
|
||||
// 기본 (항상 포함)
|
||||
hints.Add("p/ul/ol(일반 서술)");
|
||||
|
||||
// 중복 제거, 최대 4개
|
||||
return string.Join(" | ", hints.Distinct().Take(4));
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string text, params string[] keywords)
|
||||
=> keywords.Any(k => text.Contains(k));
|
||||
|
||||
/// <summary>document_assemble 즉시 호출 가능한 sections 골격 반환 (docx용).</summary>
|
||||
private Task<ToolResult> ExecuteWithDocxScaffold(string topic, string fileName,
|
||||
List<SectionPlan> sections, int targetPages, int totalWords, string label)
|
||||
{
|
||||
var sectionsTemplate = sections.Select(s => new
|
||||
{
|
||||
heading = s.Heading,
|
||||
content = $"[{s.Heading} 내용을 {s.TargetWords}단어 이상으로 작성. 핵심 항목: {string.Join(", ", s.KeyPoints)}]",
|
||||
level = s.Level,
|
||||
});
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
path = fileName,
|
||||
title = topic,
|
||||
format = "docx",
|
||||
cover_subtitle = "AX Copilot Agent",
|
||||
sections = sectionsTemplate,
|
||||
}, _jsonOptions);
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.AppendLine($"📋 문서 개요 생성 완료 {label} ({sections.Count}개 섹션, {targetPages}페이지/{totalWords}단어)");
|
||||
output.AppendLine("아래 document_assemble 파라미터에서 각 sections[].content의 [내용...] 부분을 실제 내용으로 채워서 즉시 호출하세요.");
|
||||
output.AppendLine();
|
||||
output.AppendLine("## 즉시 실행: document_assemble 호출 파라미터");
|
||||
output.AppendLine(json);
|
||||
output.AppendLine("⚠ 주의: 설명하지 말고 document_assemble 도구를 지금 즉시 호출하세요.");
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(output.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>html_create(markdown body) 즉시 호출 가능한 골격 반환.</summary>
|
||||
private Task<ToolResult> ExecuteWithMarkdownScaffold(string topic, string fileName,
|
||||
List<SectionPlan> sections, int targetPages, int totalWords, string label)
|
||||
{
|
||||
var mdSb = new StringBuilder();
|
||||
mdSb.AppendLine($"# {topic}");
|
||||
mdSb.AppendLine();
|
||||
mdSb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
|
||||
mdSb.AppendLine();
|
||||
foreach (var s in sections)
|
||||
{
|
||||
mdSb.AppendLine($"## {s.Heading}");
|
||||
mdSb.AppendLine();
|
||||
mdSb.AppendLine($"<!-- 목표 {s.TargetWords}단어 | 핵심: {string.Join(" / ", s.KeyPoints)} -->");
|
||||
mdSb.AppendLine($"[{s.Heading} 내용을 {s.TargetWords}단어 이상으로 상세히 작성하세요]");
|
||||
mdSb.AppendLine();
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.AppendLine($"📋 문서 개요 생성 완료 {label} ({sections.Count}개 섹션, {targetPages}페이지/{totalWords}단어)");
|
||||
output.AppendLine("아래 file_write 파라미터에서 각 섹션의 [내용...] 부분을 실제 내용으로 채워서 즉시 호출하세요.");
|
||||
output.AppendLine();
|
||||
output.AppendLine("## 즉시 실행: file_write 호출");
|
||||
output.AppendLine($"- path: \"{fileName}\"");
|
||||
output.AppendLine("- content (각 [내용...] 부분을 실제 내용으로 교체):");
|
||||
output.AppendLine(mdSb.ToString());
|
||||
output.AppendLine("⚠ 주의: 설명하지 말고 file_write 도구를 지금 즉시 호출하세요. 모든 섹션에 실제 내용을 작성하세요.");
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(output.ToString()));
|
||||
}
|
||||
|
||||
// ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ──
|
||||
|
||||
private Task<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format,
|
||||
int targetPages, int totalWords, List<SectionPlan> sections, string folderDataUsage, string refSummary)
|
||||
{
|
||||
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
|
||||
var createTool = format switch { "docx" => "docx_create", "markdown" => "file_write", _ => "html_create" };
|
||||
var safeTitle = SanitizeFileName(topic);
|
||||
var suggestedPath = $"{safeTitle}{ext}";
|
||||
|
||||
// reference_summary가 이미 있으면 데이터 읽기 단계를 건너뛸 수 있음
|
||||
var hasRefData = !string.IsNullOrWhiteSpace(refSummary);
|
||||
|
||||
var plan = new
|
||||
{
|
||||
topic,
|
||||
document_type = docType,
|
||||
format,
|
||||
target_pages = targetPages,
|
||||
estimated_total_words = totalWords,
|
||||
suggested_file_name = suggestedPath,
|
||||
sections,
|
||||
reference_data = hasRefData ? refSummary : null as string,
|
||||
instructions = new
|
||||
{
|
||||
mode = "single-pass-with-data",
|
||||
folder_data_usage = folderDataUsage,
|
||||
step1 = hasRefData
|
||||
? "Reference data is already provided above. Skip to step 3."
|
||||
: "Use folder_map to scan the work folder, then use document_read to read RELEVANT files only.",
|
||||
step2 = hasRefData
|
||||
? "(skipped)"
|
||||
: "Summarize the key findings from the folder documents relevant to the topic.",
|
||||
step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.",
|
||||
step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
|
||||
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
|
||||
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(plan, _jsonOptions);
|
||||
|
||||
var dataNote = hasRefData
|
||||
? "참조 데이터가 이미 제공되었습니다. 바로 문서를 작성하세요."
|
||||
: $"폴더 데이터 활용 모드({folderDataUsage})가 활성화되어 있습니다. 먼저 관련 데이터를 읽은 후 문서를 작성하세요.";
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(
|
||||
$"📋 문서 개요가 생성되었습니다 [싱글패스+데이터활용] ({sections.Count}개 섹션, 목표 {targetPages}페이지/{totalWords}단어).\n" +
|
||||
$"{dataNote}\n" +
|
||||
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
|
||||
}
|
||||
|
||||
// ─── HTML 생성 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateHtml(string path, string title, string docType, List<SectionPlan> sections)
|
||||
{
|
||||
var css = TemplateService.GetCss("professional");
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"ko\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine($"<title>{Escape(title)}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(css);
|
||||
sb.AppendLine(@"
|
||||
.doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
|
||||
.doc h1 { font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }
|
||||
.doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
|
||||
.doc .meta { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
.doc .section { line-height: 1.8; margin-bottom: 20px; }
|
||||
.doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
|
||||
.doc .toc h3 { margin-top: 0; }
|
||||
.doc .toc a { color: inherit; text-decoration: none; }
|
||||
.doc .toc a:hover { text-decoration: underline; }
|
||||
.doc .toc ul { list-style: none; padding-left: 0; }
|
||||
.doc .toc li { padding: 4px 0; }
|
||||
.doc .key-point { background: #f0f4ff; border-left: 4px solid var(--accent, #4B5EFC); padding: 12px 16px; margin: 12px 0; border-radius: 0 8px 8px 0; }
|
||||
@media print { .doc h2 { page-break-before: auto; } }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<div class=\"doc\">");
|
||||
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
sb.AppendLine($"<div class=\"meta\">문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개</div>");
|
||||
|
||||
// 목차
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("<div class=\"toc\">");
|
||||
sb.AppendLine("<h3>📋 목차</h3>");
|
||||
sb.AppendLine("<ul>");
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
sb.AppendLine($"<li><a href=\"#sec-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
|
||||
sb.AppendLine("</ul>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// 섹션 본문
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
{
|
||||
var sec = sections[i];
|
||||
sb.AppendLine($"<h2 id=\"sec-{i + 1}\">{Escape(sec.Heading)}</h2>");
|
||||
sb.AppendLine("<div class=\"section\">");
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
sb.AppendLine($"<div class=\"key-point\">");
|
||||
sb.AppendLine($"<strong>▸ {Escape(kp)}</strong>");
|
||||
sb.AppendLine($"<p>{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)</p>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
// ─── DOCX 생성 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateDocx(string path, string title, List<SectionPlan> sections)
|
||||
{
|
||||
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
|
||||
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
|
||||
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
||||
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
|
||||
|
||||
// 제목
|
||||
AddDocxParagraph(body, title, bold: true, fontSize: "48");
|
||||
AddDocxParagraph(body, $"작성일: {DateTime.Now:yyyy-MM-dd}", fontSize: "20", color: "888888");
|
||||
AddDocxParagraph(body, ""); // 빈 줄
|
||||
|
||||
foreach (var sec in sections)
|
||||
{
|
||||
AddDocxParagraph(body, sec.Heading, bold: true, fontSize: "32", color: "2B579A");
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
AddDocxParagraph(body, $"▸ {kp}", bold: true, fontSize: "22");
|
||||
AddDocxParagraph(body, $"{kp}에 대한 상세 내용을 여기에 작성합니다.");
|
||||
}
|
||||
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
|
||||
string text, bool bold = false, string fontSize = "22", string? color = null)
|
||||
{
|
||||
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
|
||||
};
|
||||
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
|
||||
if (color != null) props.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
|
||||
run.AppendChild(props);
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
|
||||
{
|
||||
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
|
||||
});
|
||||
para.AppendChild(run);
|
||||
body.AppendChild(para);
|
||||
}
|
||||
|
||||
// ─── Markdown 생성 ──────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# {title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
|
||||
sb.AppendLine();
|
||||
|
||||
// 목차
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("## 목차");
|
||||
sb.AppendLine();
|
||||
foreach (var sec in sections)
|
||||
sb.AppendLine($"- [{sec.Heading}](#{sec.Heading.Replace(" ", "-").ToLowerInvariant()})");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var sec in sections)
|
||||
{
|
||||
sb.AppendLine($"## {sec.Heading}");
|
||||
sb.AppendLine();
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
sb.AppendLine($"### ▸ {kp}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{kp}에 대한 상세 내용을 여기에 작성합니다.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<SectionPlan> BuildSections(string docType, int pages, string hint, string refSummary)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
var hintSections = hint.Split(new[] { ',', '/', '→', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var result = new List<SectionPlan>();
|
||||
for (int i = 0; i < hintSections.Length; i++)
|
||||
{
|
||||
result.Add(new SectionPlan
|
||||
{
|
||||
Id = $"sec-{i + 1}",
|
||||
Heading = $"{i + 1}. {hintSections[i].TrimStart("0123456789. ".ToCharArray())}",
|
||||
Level = 1,
|
||||
KeyPoints = [$"{hintSections[i]} 관련 핵심 내용을 상세히 작성"],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return docType switch
|
||||
{
|
||||
"proposal" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
|
||||
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
|
||||
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
|
||||
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
|
||||
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
|
||||
},
|
||||
"analysis" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
|
||||
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
|
||||
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
|
||||
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
|
||||
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
|
||||
},
|
||||
"manual" or "guide" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 소개", Level = 1, KeyPoints = ["목적", "대상 독자", "사전 요구사항"] },
|
||||
new() { Id = "sec-2", Heading = "2. 시작하기", Level = 1, KeyPoints = ["설치", "초기 설정", "기본 사용법"] },
|
||||
new() { Id = "sec-3", Heading = "3. 주요 기능", Level = 1, KeyPoints = ["기능별 상세 설명", "사용 예시"] },
|
||||
new() { Id = "sec-4", Heading = "4. 고급 기능", Level = 1, KeyPoints = ["고급 설정", "커스터마이징", "자동화"] },
|
||||
new() { Id = "sec-5", Heading = "5. 문제 해결", Level = 1, KeyPoints = ["자주 묻는 질문", "에러 대응", "지원 정보"] },
|
||||
},
|
||||
"minutes" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
|
||||
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
|
||||
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
|
||||
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
|
||||
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
|
||||
},
|
||||
_ => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
|
||||
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
|
||||
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
|
||||
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
|
||||
{
|
||||
if (sections.Count == 0) return;
|
||||
var weights = new double[sections.Count];
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
weights[i] = (i == 0 || i == sections.Count - 1) ? 0.7 : 1.2;
|
||||
|
||||
var totalWeight = weights.Sum();
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
sections[i].TargetWords = Math.Max(100, (int)(totalWords * weights[i] / totalWeight));
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var safe = name.Length > 60 ? name[..60] : name;
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(c, '_');
|
||||
return safe.Trim().TrimEnd('.');
|
||||
}
|
||||
|
||||
private static string GetDocTypeLabel(string docType) => docType switch
|
||||
{
|
||||
"proposal" => "제안서",
|
||||
"analysis" => "분석 보고서",
|
||||
"manual" or "guide" => "매뉴얼/가이드",
|
||||
"minutes" => "회의록",
|
||||
"presentation" => "프레젠테이션",
|
||||
_ => "보고서",
|
||||
};
|
||||
|
||||
private static string Escape(string text)
|
||||
=> text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private class SectionPlan
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Heading { get; set; } = "";
|
||||
public int Level { get; set; } = 1;
|
||||
public int TargetWords { get; set; } = 300;
|
||||
public List<string> KeyPoints { get; set; } = new();
|
||||
}
|
||||
}
|
||||
571
src/AxCopilot/Services/Agent/DocumentReaderTool.cs
Normal file
571
src/AxCopilot/Services/Agent/DocumentReaderTool.cs
Normal file
@@ -0,0 +1,571 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 파일을 읽어 텍스트로 반환하는 도구.
|
||||
/// PDF, DOCX, XLSX, CSV, TXT, BibTeX, RIS 등 다양한 형식을 지원합니다.
|
||||
/// </summary>
|
||||
public class DocumentReaderTool : IAgentTool
|
||||
{
|
||||
public string Name => "document_read";
|
||||
public string Description =>
|
||||
"Read a document file and extract its text content. " +
|
||||
"Supports: PDF (.pdf), Word (.docx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " +
|
||||
"BibTeX (.bib), RIS (.ris). " +
|
||||
"For large files, use 'offset' to read from a specific character position (chunked reading). " +
|
||||
"For large PDFs, use 'pages' parameter to read specific page ranges (e.g., '1-5', '10-20'). " +
|
||||
"Use 'section' parameter with value 'references' to extract only the references/bibliography section from a PDF.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Document file path (absolute or relative to work folder)" },
|
||||
["max_chars"] = new() { Type = "integer", Description = "Maximum characters to extract per chunk. Default: 8000. Use smaller values for summaries." },
|
||||
["offset"] = new() { Type = "integer", Description = "Character offset to start reading from. Default: 0. Use this to read the next chunk of a large file (value from 'next_offset' in previous response)." },
|
||||
["sheet"] = new() { Type = "string", Description = "For Excel files: sheet name or 1-based index. Default: first sheet." },
|
||||
["pages"] = new() { Type = "string", Description = "For PDF files: page range to read (e.g., '1-5', '3', '10-20'). Default: all pages." },
|
||||
["section"] = new() { Type = "string", Description = "Extract specific section. 'references' = extract references/bibliography from PDF. 'abstract' = extract abstract." },
|
||||
},
|
||||
Required = ["path"]
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".txt", ".log", ".json", ".xml", ".md", ".csv", ".tsv",
|
||||
".yaml", ".yml", ".ini", ".cfg", ".conf", ".properties",
|
||||
".html", ".htm", ".css", ".js", ".ts", ".py", ".cs", ".java",
|
||||
".sql", ".sh", ".bat", ".ps1", ".r", ".m",
|
||||
};
|
||||
|
||||
private const int DefaultMaxChars = 8000;
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("path", out var pathEl))
|
||||
return ToolResult.Fail("path가 필요합니다.");
|
||||
var path = pathEl.GetString() ?? "";
|
||||
var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
|
||||
var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
|
||||
var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : "";
|
||||
var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : "";
|
||||
var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : "";
|
||||
|
||||
if (maxChars < 100) maxChars = DefaultMaxChars;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}");
|
||||
|
||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
// 전체 텍스트 추출 (offset > 0이면 전체를 추출해서 잘라야 함)
|
||||
var extractMax = offset > 0 ? offset + maxChars + 100 : maxChars;
|
||||
var text = ext switch
|
||||
{
|
||||
".pdf" => await Task.Run(() => ReadPdf(fullPath, extractMax, pagesParam, sectionParam), ct),
|
||||
".docx" => await Task.Run(() => ReadDocx(fullPath, extractMax), ct),
|
||||
".xlsx" => await Task.Run(() => ReadXlsx(fullPath, sheetParam, extractMax), ct),
|
||||
".bib" => await Task.Run(() => ReadBibTeX(fullPath, extractMax), ct),
|
||||
".ris" => await Task.Run(() => ReadRis(fullPath, extractMax), ct),
|
||||
_ when TextExtensions.Contains(ext) => await ReadTextFile(fullPath, extractMax, ct),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (text == null)
|
||||
return ToolResult.Fail($"지원하지 않는 파일 형식: {ext}");
|
||||
|
||||
var totalExtracted = text.Length;
|
||||
|
||||
// offset 적용 — 청크 분할 읽기
|
||||
if (offset > 0)
|
||||
{
|
||||
if (offset >= text.Length)
|
||||
return ToolResult.Ok($"[{Path.GetFileName(fullPath)}] offset {offset}은 문서 끝을 초과합니다 (전체 {text.Length}자).", fullPath);
|
||||
text = text[offset..];
|
||||
}
|
||||
|
||||
// maxChars 자르기
|
||||
var hasMore = text.Length > maxChars;
|
||||
if (hasMore)
|
||||
text = text[..maxChars];
|
||||
|
||||
var fileInfo = new FileInfo(fullPath);
|
||||
var header = $"[{Path.GetFileName(fullPath)}] ({ext.TrimStart('.')}, {FormatSize(fileInfo.Length)})";
|
||||
|
||||
if (offset > 0)
|
||||
header += $" — offset {offset}부터 {maxChars}자 읽음";
|
||||
|
||||
if (hasMore)
|
||||
{
|
||||
var nextOffset = offset + maxChars;
|
||||
header += $"\n⚡ 추가 내용이 있습니다. 다음 청크를 읽으려면 offset={nextOffset}을 사용하세요.";
|
||||
}
|
||||
else if (offset > 0)
|
||||
{
|
||||
header += " — 문서 끝까지 읽음 ✓";
|
||||
}
|
||||
else if (totalExtracted >= maxChars)
|
||||
{
|
||||
header += $" — 처음 {maxChars}자만 추출됨. 계속 읽으려면 offset={maxChars}을 사용하거나, pages 파라미터로 특정 페이지를 지정하세요.";
|
||||
}
|
||||
|
||||
return ToolResult.Ok($"{header}\n\n{text}", fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"문서 읽기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PDF ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadPdf(string path, int maxChars, string pagesParam, string sectionParam)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = PdfDocument.Open(path);
|
||||
var totalPages = doc.NumberOfPages;
|
||||
sb.AppendLine($"PDF: {totalPages}페이지");
|
||||
sb.AppendLine();
|
||||
|
||||
// 페이지 범위 파싱
|
||||
var (startPage, endPage) = ParsePageRange(pagesParam, totalPages);
|
||||
|
||||
// 섹션 추출 모드
|
||||
if (string.Equals(sectionParam, "references", StringComparison.OrdinalIgnoreCase))
|
||||
return ExtractReferences(doc, totalPages, maxChars);
|
||||
if (string.Equals(sectionParam, "abstract", StringComparison.OrdinalIgnoreCase))
|
||||
return ExtractAbstract(doc, totalPages, maxChars);
|
||||
|
||||
sb.AppendLine($"읽는 범위: {startPage}-{endPage} / {totalPages} 페이지");
|
||||
sb.AppendLine();
|
||||
|
||||
for (int i = startPage; i <= endPage && sb.Length < maxChars; i++)
|
||||
{
|
||||
var page = doc.GetPage(i);
|
||||
var pageText = page.Text;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pageText))
|
||||
{
|
||||
sb.AppendLine($"--- Page {i} ---");
|
||||
sb.AppendLine(pageText.Trim());
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
private static (int start, int end) ParsePageRange(string pagesParam, int totalPages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pagesParam))
|
||||
return (1, totalPages);
|
||||
|
||||
// "5" → page 5 only
|
||||
if (int.TryParse(pagesParam.Trim(), out var single))
|
||||
return (Math.Max(1, single), Math.Min(single, totalPages));
|
||||
|
||||
// "3-10" → pages 3 to 10
|
||||
var parts = pagesParam.Split('-', StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 2 &&
|
||||
int.TryParse(parts[0], out var s) &&
|
||||
int.TryParse(parts[1], out var e))
|
||||
{
|
||||
return (Math.Max(1, s), Math.Min(e, totalPages));
|
||||
}
|
||||
|
||||
return (1, totalPages);
|
||||
}
|
||||
|
||||
/// <summary>PDF에서 References/Bibliography 섹션을 추출합니다.</summary>
|
||||
private static string ExtractReferences(PdfDocument doc, int totalPages, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== References / Bibliography ===");
|
||||
sb.AppendLine();
|
||||
|
||||
// 뒤에서부터 References 섹션 시작점을 찾습니다
|
||||
var refPatterns = new[]
|
||||
{
|
||||
@"(?i)^\s*(References|Bibliography|Works\s+Cited|Literature\s+Cited|참고\s*문헌|참조|인용\s*문헌)\s*$",
|
||||
@"(?i)^(References|Bibliography|참고문헌)\s*\n",
|
||||
};
|
||||
|
||||
bool found = false;
|
||||
for (int i = totalPages; i >= Math.Max(1, totalPages - 10) && !found; i--)
|
||||
{
|
||||
var pageText = doc.GetPage(i).Text;
|
||||
if (string.IsNullOrWhiteSpace(pageText)) continue;
|
||||
|
||||
foreach (var pattern in refPatterns)
|
||||
{
|
||||
var match = Regex.Match(pageText, pattern, RegexOptions.Multiline);
|
||||
if (match.Success)
|
||||
{
|
||||
// References 시작 지점부터 끝까지 추출
|
||||
var refStart = match.Index;
|
||||
sb.AppendLine($"(Page {i}부터 시작)");
|
||||
sb.AppendLine(pageText[refStart..].Trim());
|
||||
|
||||
// 이후 페이지도 포함
|
||||
for (int j = i + 1; j <= totalPages && sb.Length < maxChars; j++)
|
||||
{
|
||||
var nextText = doc.GetPage(j).Text;
|
||||
if (!string.IsNullOrWhiteSpace(nextText))
|
||||
sb.AppendLine(nextText.Trim());
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
// References 헤더를 못 찾으면 마지막 3페이지를 반환
|
||||
sb.AppendLine("(References 섹션 헤더를 찾지 못했습니다. 마지막 3페이지를 반환합니다.)");
|
||||
sb.AppendLine();
|
||||
for (int i = Math.Max(1, totalPages - 2); i <= totalPages && sb.Length < maxChars; i++)
|
||||
{
|
||||
var pageText = doc.GetPage(i).Text;
|
||||
if (!string.IsNullOrWhiteSpace(pageText))
|
||||
{
|
||||
sb.AppendLine($"--- Page {i} ---");
|
||||
sb.AppendLine(pageText.Trim());
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 개별 참고문헌 항목 파싱 시도
|
||||
var rawRefs = sb.ToString();
|
||||
var parsed = ParseReferenceEntries(rawRefs);
|
||||
if (parsed.Count > 0)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
result.AppendLine($"=== References ({parsed.Count}개 항목) ===\n");
|
||||
for (int i = 0; i < parsed.Count; i++)
|
||||
{
|
||||
result.AppendLine($"[{i + 1}] {parsed[i]}");
|
||||
}
|
||||
return Truncate(result.ToString(), maxChars);
|
||||
}
|
||||
|
||||
return Truncate(rawRefs, maxChars);
|
||||
}
|
||||
|
||||
/// <summary>PDF에서 Abstract 섹션을 추출합니다.</summary>
|
||||
private static string ExtractAbstract(PdfDocument doc, int totalPages, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== Abstract ===");
|
||||
sb.AppendLine();
|
||||
|
||||
// 첫 3페이지에서 Abstract 찾기
|
||||
for (int i = 1; i <= Math.Min(3, totalPages); i++)
|
||||
{
|
||||
var pageText = doc.GetPage(i).Text;
|
||||
if (string.IsNullOrWhiteSpace(pageText)) continue;
|
||||
|
||||
var match = Regex.Match(pageText,
|
||||
@"(?i)(Abstract|초록|요약)\s*\n(.*?)(?=\n\s*(Keywords|Introduction|1\.|서론|키워드|핵심어)\s*[\n:])",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
sb.AppendLine(match.Groups[2].Value.Trim());
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
}
|
||||
|
||||
// 찾지 못하면 첫 페이지 반환
|
||||
sb.AppendLine("(Abstract 섹션을 찾지 못했습니다. 첫 페이지를 반환합니다.)");
|
||||
var firstPage = doc.GetPage(1).Text;
|
||||
if (!string.IsNullOrWhiteSpace(firstPage))
|
||||
sb.AppendLine(firstPage.Trim());
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
/// <summary>참고문헌 텍스트에서 개별 항목을 파싱합니다.</summary>
|
||||
private static List<string> ParseReferenceEntries(string text)
|
||||
{
|
||||
var entries = new List<string>();
|
||||
|
||||
// [1], [2] 형태의 번호 매기기
|
||||
var numbered = Regex.Split(text, @"\n\s*\[(\d+)\]\s*");
|
||||
if (numbered.Length > 3)
|
||||
{
|
||||
for (int i = 2; i < numbered.Length; i += 2)
|
||||
{
|
||||
var entry = numbered[i].Trim().Replace("\n", " ").Replace(" ", " ");
|
||||
if (entry.Length > 10)
|
||||
entries.Add(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// 1. 2. 3. 형태
|
||||
var dotNumbered = Regex.Split(text, @"\n\s*(\d+)\.\s+");
|
||||
if (dotNumbered.Length > 5)
|
||||
{
|
||||
for (int i = 2; i < dotNumbered.Length; i += 2)
|
||||
{
|
||||
var entry = dotNumbered[i].Trim().Replace("\n", " ").Replace(" ", " ");
|
||||
if (entry.Length > 10)
|
||||
entries.Add(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── BibTeX ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadBibTeX(string path, int maxChars)
|
||||
{
|
||||
var content = TextFileCodec.ReadAllText(path).Text;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var entryPattern = new Regex(
|
||||
@"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
var fieldPattern = new Regex(
|
||||
@"(\w+)\s*=\s*[\{""](.*?)[\}""]",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
var matches = entryPattern.Matches(content);
|
||||
sb.AppendLine($"BibTeX: {matches.Count}개 항목");
|
||||
sb.AppendLine();
|
||||
|
||||
int idx = 0;
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
if (sb.Length >= maxChars) break;
|
||||
idx++;
|
||||
|
||||
var entryType = m.Groups[1].Value;
|
||||
var citeKey = m.Groups[2].Value;
|
||||
var body = m.Groups[3].Value;
|
||||
|
||||
sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}");
|
||||
|
||||
var fields = fieldPattern.Matches(body);
|
||||
foreach (Match f in fields)
|
||||
{
|
||||
var fieldName = f.Groups[1].Value.ToLower();
|
||||
var fieldValue = f.Groups[2].Value.Trim();
|
||||
|
||||
// 핵심 필드만 표시
|
||||
if (fieldName is "author" or "title" or "journal" or "booktitle"
|
||||
or "year" or "volume" or "number" or "pages" or "doi"
|
||||
or "publisher" or "url")
|
||||
{
|
||||
sb.AppendLine($" {fieldName}: {fieldValue}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
|
||||
sb.AppendLine(Truncate(content, maxChars - sb.Length));
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── RIS ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadRis(string path, int maxChars)
|
||||
{
|
||||
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(path).Text);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var entries = new List<Dictionary<string, List<string>>>();
|
||||
Dictionary<string, List<string>>? current = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.StartsWith("TY -"))
|
||||
{
|
||||
current = new Dictionary<string, List<string>>();
|
||||
entries.Add(current);
|
||||
}
|
||||
else if (line.StartsWith("ER -"))
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ')
|
||||
{
|
||||
var tag = line[..2].Trim();
|
||||
var value = line[6..].Trim();
|
||||
if (!current.ContainsKey(tag))
|
||||
current[tag] = new List<string>();
|
||||
current[tag].Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"RIS: {entries.Count}개 항목");
|
||||
sb.AppendLine();
|
||||
|
||||
// RIS 태그 → 사람이 읽을 수 있는 이름
|
||||
var tagNames = new Dictionary<string, string>
|
||||
{
|
||||
["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title",
|
||||
["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year",
|
||||
["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page",
|
||||
["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract",
|
||||
["KW"] = "Keyword", ["SN"] = "ISSN/ISBN",
|
||||
};
|
||||
|
||||
for (int i = 0; i < entries.Count && sb.Length < maxChars; i++)
|
||||
{
|
||||
sb.AppendLine($"[{i + 1}]");
|
||||
var entry = entries[i];
|
||||
foreach (var (tag, values) in entry)
|
||||
{
|
||||
var label = tagNames.GetValueOrDefault(tag, tag);
|
||||
if (tag is "AU" or "KW")
|
||||
sb.AppendLine($" {label}: {string.Join("; ", values)}");
|
||||
else
|
||||
sb.AppendLine($" {label}: {string.Join(" ", values)}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── DOCX ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadDocx(string path, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = WordprocessingDocument.Open(path, false);
|
||||
var body = doc.MainDocumentPart?.Document.Body;
|
||||
if (body == null) return "(빈 문서)";
|
||||
|
||||
foreach (var para in body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
|
||||
{
|
||||
var text = para.InnerText;
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
sb.AppendLine(text);
|
||||
if (sb.Length >= maxChars) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── XLSX ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadXlsx(string path, string sheetParam, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = SpreadsheetDocument.Open(path, false);
|
||||
var workbook = doc.WorkbookPart;
|
||||
if (workbook == null) return "(빈 스프레드시트)";
|
||||
|
||||
var sheets = workbook.Workbook.Sheets?.Elements<Sheet>().ToList() ?? [];
|
||||
if (sheets.Count == 0) return "(시트 없음)";
|
||||
|
||||
sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})");
|
||||
sb.AppendLine();
|
||||
|
||||
Sheet? targetSheet = null;
|
||||
if (!string.IsNullOrEmpty(sheetParam))
|
||||
{
|
||||
if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count)
|
||||
targetSheet = sheets[idx - 1];
|
||||
else
|
||||
targetSheet = sheets.FirstOrDefault(s =>
|
||||
string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
targetSheet ??= sheets[0];
|
||||
|
||||
var sheetId = targetSheet.Id?.Value;
|
||||
if (sheetId == null) return "(시트 ID 없음)";
|
||||
|
||||
var wsPart = (WorksheetPart)workbook.GetPartById(sheetId);
|
||||
var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable
|
||||
.Elements<SharedStringItem>().ToList() ?? [];
|
||||
|
||||
var rows = wsPart.Worksheet.Descendants<Row>().ToList();
|
||||
sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var cells = row.Elements<Cell>().ToList();
|
||||
var values = new List<string>();
|
||||
foreach (var cell in cells)
|
||||
values.Add(GetCellValue(cell, sharedStrings));
|
||||
sb.AppendLine(string.Join("\t", values));
|
||||
if (sb.Length >= maxChars) break;
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
|
||||
{
|
||||
var value = cell.CellValue?.Text ?? "";
|
||||
if (cell.DataType?.Value == CellValues.SharedString)
|
||||
{
|
||||
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count)
|
||||
return sharedStrings[idx].InnerText;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Text ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
|
||||
{
|
||||
var text = (await TextFileCodec.ReadAllTextAsync(path, ct)).Text;
|
||||
return Truncate(text, maxChars);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Truncate(string text, int maxChars)
|
||||
{
|
||||
if (text.Length <= maxChars) return text;
|
||||
return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||||
};
|
||||
|
||||
/// <summary>JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환.</summary>
|
||||
private static int GetIntValue(JsonElement el, int defaultValue)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
|
||||
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
161
src/AxCopilot/Services/Agent/DocumentReviewTool.cs
Normal file
161
src/AxCopilot/Services/Agent/DocumentReviewTool.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 생성된 문서를 자동 검증하는 도구.
|
||||
/// HTML/Markdown/텍스트 파일의 구조적 완성도, 날짜 정합성, 빈 섹션 등을 점검합니다.
|
||||
/// </summary>
|
||||
public class DocumentReviewTool : IAgentTool
|
||||
{
|
||||
public string Name => "document_review";
|
||||
public string Description =>
|
||||
"Review a generated document for quality issues. " +
|
||||
"Checks: empty sections, placeholder text, date consistency, missing headings, broken HTML tags, " +
|
||||
"content completeness. Returns a structured review report with issues found and suggestions.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Path to the document to review" },
|
||||
["expected_sections"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Optional list of expected section titles to verify presence",
|
||||
Items = new() { Type = "string" },
|
||||
},
|
||||
},
|
||||
Required = ["path"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("path", out var pathEl))
|
||||
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
||||
var path = pathEl.GetString() ?? "";
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
|
||||
if (!File.Exists(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"파일 없음: {fullPath}"));
|
||||
|
||||
var content = TextFileCodec.ReadAllText(fullPath).Text;
|
||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
var issues = new List<string>();
|
||||
var stats = new List<string>();
|
||||
|
||||
// 기본 통계
|
||||
var lineCount = content.Split('\n').Length;
|
||||
var charCount = content.Length;
|
||||
stats.Add($"파일: {Path.GetFileName(fullPath)} ({charCount:N0}자, {lineCount}줄)");
|
||||
|
||||
// 1. 빈 콘텐츠 검사
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
issues.Add("[CRITICAL] 파일 내용이 비어있습니다");
|
||||
return Task.FromResult(ToolResult.Ok(FormatReport(stats, issues, []), fullPath));
|
||||
}
|
||||
|
||||
// 2. 플레이스홀더 텍스트 검사
|
||||
var placeholders = new[] { "TODO", "TBD", "FIXME", "Lorem ipsum", "[여기에", "[INSERT", "placeholder", "예시 텍스트" };
|
||||
foreach (var ph in placeholders)
|
||||
{
|
||||
if (content.Contains(ph, StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add($"[WARNING] 플레이스홀더 텍스트 발견: \"{ph}\"");
|
||||
}
|
||||
|
||||
// 3. 날짜 정합성 (미래 날짜, 너무 오래된 날짜)
|
||||
var datePattern = new Regex(@"\d{4}[-년.]\s*\d{1,2}[-월.]\s*\d{1,2}[일]?");
|
||||
foreach (Match m in datePattern.Matches(content))
|
||||
{
|
||||
var cleaned = Regex.Replace(m.Value, @"[년월일\s]", "-").TrimEnd('-');
|
||||
if (DateTime.TryParse(cleaned, out var dt))
|
||||
{
|
||||
if (dt > DateTime.Now.AddDays(365))
|
||||
issues.Add($"[WARNING] 미래 날짜 감지: {m.Value}");
|
||||
else if (dt < DateTime.Now.AddYears(-50))
|
||||
issues.Add($"[INFO] 매우 오래된 날짜: {m.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. HTML 전용 검사
|
||||
if (ext is ".html" or ".htm")
|
||||
{
|
||||
// 빈 섹션 (h2/h3 뒤에 내용 없이 바로 다음 h2/h3)
|
||||
var emptySection = Regex.Matches(content, @"<h[23][^>]*>.*?</h[23]>\s*<h[23]");
|
||||
if (emptySection.Count > 0)
|
||||
issues.Add($"[WARNING] 빈 섹션 {emptySection.Count}개 감지 (헤딩 뒤 내용 없음)");
|
||||
|
||||
// 닫히지 않은 태그
|
||||
var openTags = Regex.Matches(content, @"<(table|div|section|article)\b[^/]*>").Count;
|
||||
var closeTags = Regex.Matches(content, @"</(table|div|section|article)>").Count;
|
||||
if (openTags != closeTags)
|
||||
issues.Add($"[WARNING] HTML 태그 불균형: 열림 {openTags}개, 닫힘 {closeTags}개");
|
||||
|
||||
// 이미지 alt 텍스트 누락
|
||||
var imgNoAlt = Regex.Matches(content, @"<img\b(?![^>]*\balt\s*=)[^>]*>");
|
||||
if (imgNoAlt.Count > 0)
|
||||
issues.Add($"[INFO] alt 속성 없는 이미지 {imgNoAlt.Count}개");
|
||||
|
||||
// 제목 태그 수
|
||||
var h1Count = Regex.Matches(content, @"<h1\b").Count;
|
||||
var h2Count = Regex.Matches(content, @"<h2\b").Count;
|
||||
stats.Add($"구조: h1={h1Count}, h2={h2Count}개 섹션");
|
||||
}
|
||||
|
||||
// 5. 기대 섹션 검사
|
||||
if (args.TryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var sec in sections.EnumerateArray())
|
||||
{
|
||||
var title = sec.GetString() ?? "";
|
||||
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 반복 텍스트 검사 (같은 문장 3회 이상 반복)
|
||||
var sentences = Regex.Split(content, @"[.!?。]\s+")
|
||||
.Where(s => s.Length > 20)
|
||||
.GroupBy(s => s.Trim())
|
||||
.Where(g => g.Count() >= 3);
|
||||
foreach (var dup in sentences.Take(3))
|
||||
issues.Add($"[WARNING] 반복 텍스트 ({dup.Count()}회): \"{dup.Key[..Math.Min(50, dup.Key.Length)]}...\"");
|
||||
|
||||
var suggestions = new List<string>();
|
||||
if (issues.Count == 0)
|
||||
suggestions.Add("문서 검증 통과 — 구조적 이슈가 발견되지 않았습니다.");
|
||||
else
|
||||
{
|
||||
suggestions.Add($"총 {issues.Count}개 이슈 발견. 수정 후 다시 검증하세요.");
|
||||
if (issues.Any(i => i.Contains("플레이스홀더")))
|
||||
suggestions.Add("플레이스홀더를 실제 내용으로 교체하세요.");
|
||||
if (issues.Any(i => i.Contains("빈 섹션")))
|
||||
suggestions.Add("빈 섹션에 내용을 추가하거나 불필요한 헤딩을 제거하세요.");
|
||||
}
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(FormatReport(stats, issues, suggestions), fullPath));
|
||||
}
|
||||
|
||||
private static string FormatReport(List<string> stats, List<string> issues, List<string> suggestions)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("=== 문서 검증 보고서 ===\n");
|
||||
foreach (var s in stats) sb.AppendLine($"📊 {s}");
|
||||
sb.AppendLine();
|
||||
if (issues.Count == 0)
|
||||
sb.AppendLine("✅ 이슈 없음 — 문서가 정상입니다.");
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"⚠ 발견된 이슈 ({issues.Count}건):");
|
||||
foreach (var i in issues) sb.AppendLine($" {i}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
foreach (var s in suggestions) sb.AppendLine($"💡 {s}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
543
src/AxCopilot/Services/Agent/DocxSkill.cs
Normal file
543
src/AxCopilot/Services/Agent/DocxSkill.cs
Normal file
@@ -0,0 +1,543 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Word (.docx) 문서를 생성하는 내장 스킬.
|
||||
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
|
||||
/// </summary>
|
||||
public class DocxSkill : IAgentTool
|
||||
{
|
||||
public string Name => "docx_create";
|
||||
public string Description => "Create a rich Word (.docx) document. " +
|
||||
"Supports: sections with heading+body, tables with optional header styling, " +
|
||||
"text formatting (bold, italic, color, highlight, shading), " +
|
||||
"headers/footers with page numbers, page breaks between sections, " +
|
||||
"and numbered/bulleted lists.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.docx). Relative to work folder." },
|
||||
["title"] = new() { Type = "string", Description = "Document title (optional)." },
|
||||
["sections"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Array of content blocks. Each block is one of:\n" +
|
||||
"• Section: {\"heading\": \"...\", \"body\": \"...\", \"level\": 1|2}\n" +
|
||||
"• Table: {\"type\": \"table\", \"headers\": [\"A\",\"B\"], \"rows\": [[\"1\",\"2\"]], \"style\": \"striped|plain\"}\n" +
|
||||
"• PageBreak: {\"type\": \"pagebreak\"}\n" +
|
||||
"• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \"item2\"]}\n" +
|
||||
"Body text supports inline formatting: **bold**, *italic*, `code`.",
|
||||
Items = new() { Type = "object" }
|
||||
},
|
||||
["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." },
|
||||
["footer"] = new() { Type = "string", Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set." },
|
||||
["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in footer. Default: true if header or footer is set." },
|
||||
},
|
||||
Required = ["path", "sections"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
|
||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
||||
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
|
||||
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
|
||||
(headerText != null || footerText != null);
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".docx";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var sections = args.GetProperty("sections");
|
||||
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document);
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new Document();
|
||||
var body = mainPart.Document.AppendChild(new Body());
|
||||
|
||||
// 머리글/바닥글 설정
|
||||
if (headerText != null || footerText != null || showPageNumbers)
|
||||
AddHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers);
|
||||
|
||||
// 제목
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
body.Append(CreateTitleParagraph(title));
|
||||
// 제목 아래 구분선
|
||||
body.Append(new Paragraph(
|
||||
new ParagraphProperties
|
||||
{
|
||||
ParagraphBorders = new ParagraphBorders(
|
||||
new BottomBorder { Val = BorderValues.Single, Size = 6, Color = "4472C4", Space = 1 }),
|
||||
SpacingBetweenLines = new SpacingBetweenLines { After = "300" },
|
||||
}));
|
||||
}
|
||||
|
||||
int sectionCount = 0;
|
||||
int tableCount = 0;
|
||||
foreach (var section in sections.EnumerateArray())
|
||||
{
|
||||
var blockType = section.TryGetProperty("type", out var bt) ? bt.GetString()?.ToLower() : null;
|
||||
|
||||
if (blockType == "pagebreak")
|
||||
{
|
||||
body.Append(CreatePageBreak());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockType == "table")
|
||||
{
|
||||
body.Append(CreateTable(section));
|
||||
tableCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockType == "list")
|
||||
{
|
||||
AppendList(body, section);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 일반 섹션 (heading + body)
|
||||
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
|
||||
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
|
||||
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||
|
||||
if (!string.IsNullOrEmpty(heading))
|
||||
body.Append(CreateHeadingParagraph(heading, level));
|
||||
|
||||
if (!string.IsNullOrEmpty(bodyText))
|
||||
{
|
||||
foreach (var line in bodyText.Split('\n'))
|
||||
{
|
||||
body.Append(CreateBodyParagraph(line));
|
||||
}
|
||||
}
|
||||
sectionCount++;
|
||||
}
|
||||
|
||||
mainPart.Document.Save();
|
||||
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(title)) parts.Add($"제목: {title}");
|
||||
if (sectionCount > 0) parts.Add($"섹션: {sectionCount}개");
|
||||
if (tableCount > 0) parts.Add($"테이블: {tableCount}개");
|
||||
if (headerText != null) parts.Add("머리글");
|
||||
if (showPageNumbers) parts.Add("페이지번호");
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Word 문서 생성 완료: {fullPath}\n{string.Join(", ", parts)}",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"Word 문서 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 제목/소제목/본문 단락 생성
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Paragraph CreateTitleParagraph(string text)
|
||||
{
|
||||
var para = new Paragraph();
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
Justification = new Justification { Val = JustificationValues.Center },
|
||||
SpacingBetweenLines = new SpacingBetweenLines { After = "100" },
|
||||
};
|
||||
var run = new Run(new Text(text));
|
||||
run.RunProperties = new RunProperties
|
||||
{
|
||||
Bold = new Bold(),
|
||||
FontSize = new FontSize { Val = "44" }, // 22pt
|
||||
Color = new Color { Val = "1F3864" },
|
||||
};
|
||||
para.Append(run);
|
||||
return para;
|
||||
}
|
||||
|
||||
private static Paragraph CreateHeadingParagraph(string text, int level)
|
||||
{
|
||||
var para = new Paragraph();
|
||||
var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt
|
||||
var color = level <= 1 ? "2E74B5" : "404040";
|
||||
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Before = level <= 1 ? "360" : "240", After = "120" },
|
||||
};
|
||||
|
||||
// 레벨1 소제목에 하단 테두리 추가
|
||||
if (level <= 1)
|
||||
{
|
||||
para.ParagraphProperties.ParagraphBorders = new ParagraphBorders(
|
||||
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "B4C6E7", Space = 1 });
|
||||
}
|
||||
|
||||
var run = new Run(new Text(text));
|
||||
run.RunProperties = new RunProperties
|
||||
{
|
||||
Bold = new Bold(),
|
||||
FontSize = new FontSize { Val = fontSize },
|
||||
Color = new Color { Val = color },
|
||||
};
|
||||
para.Append(run);
|
||||
return para;
|
||||
}
|
||||
|
||||
private static Paragraph CreateBodyParagraph(string text)
|
||||
{
|
||||
var para = new Paragraph();
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Line = "360" }, // 1.5배 줄간격
|
||||
};
|
||||
|
||||
// 인라인 서식 파싱: **bold**, *italic*, `code`
|
||||
AppendFormattedRuns(para, text);
|
||||
return para;
|
||||
}
|
||||
|
||||
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary>
|
||||
private static void AppendFormattedRuns(Paragraph para, string text)
|
||||
{
|
||||
// 패턴: **bold** | *italic* | `code` | 일반텍스트
|
||||
var regex = new System.Text.RegularExpressions.Regex(
|
||||
@"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`");
|
||||
int lastIndex = 0;
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in regex.Matches(text))
|
||||
{
|
||||
// 매치 전 일반 텍스트
|
||||
if (match.Index > lastIndex)
|
||||
para.Append(CreateRun(text[lastIndex..match.Index]));
|
||||
|
||||
if (match.Groups[1].Success) // **bold**
|
||||
{
|
||||
var run = CreateRun(match.Groups[1].Value);
|
||||
run.RunProperties ??= new RunProperties();
|
||||
run.RunProperties.Bold = new Bold();
|
||||
para.Append(run);
|
||||
}
|
||||
else if (match.Groups[2].Success) // *italic*
|
||||
{
|
||||
var run = CreateRun(match.Groups[2].Value);
|
||||
run.RunProperties ??= new RunProperties();
|
||||
run.RunProperties.Italic = new Italic();
|
||||
para.Append(run);
|
||||
}
|
||||
else if (match.Groups[3].Success) // `code`
|
||||
{
|
||||
var run = CreateRun(match.Groups[3].Value);
|
||||
run.RunProperties ??= new RunProperties();
|
||||
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas" };
|
||||
run.RunProperties.FontSize = new FontSize { Val = "20" };
|
||||
run.RunProperties.Shading = new Shading
|
||||
{
|
||||
Val = ShadingPatternValues.Clear,
|
||||
Fill = "F2F2F2",
|
||||
Color = "auto"
|
||||
};
|
||||
para.Append(run);
|
||||
}
|
||||
|
||||
lastIndex = match.Index + match.Length;
|
||||
}
|
||||
|
||||
// 나머지 텍스트
|
||||
if (lastIndex < text.Length)
|
||||
para.Append(CreateRun(text[lastIndex..]));
|
||||
|
||||
// 빈 텍스트인 경우 빈 Run 추가
|
||||
if (lastIndex == 0 && text.Length == 0)
|
||||
para.Append(CreateRun(""));
|
||||
}
|
||||
|
||||
private static Run CreateRun(string text)
|
||||
{
|
||||
var run = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
|
||||
run.RunProperties = new RunProperties
|
||||
{
|
||||
FontSize = new FontSize { Val = "22" }, // 11pt
|
||||
};
|
||||
return run;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 테이블 생성
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Table CreateTable(JsonElement section)
|
||||
{
|
||||
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
|
||||
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
|
||||
var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped";
|
||||
|
||||
var table = new Table();
|
||||
|
||||
// 테이블 속성 — 테두리 + 전체 너비
|
||||
var tblProps = new TableProperties(
|
||||
new TableBorders(
|
||||
new TopBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
|
||||
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
|
||||
new LeftBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
|
||||
new RightBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
|
||||
new InsideHorizontalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
|
||||
new InsideVerticalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }
|
||||
),
|
||||
new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct }
|
||||
);
|
||||
table.AppendChild(tblProps);
|
||||
|
||||
// 헤더 행
|
||||
if (headers.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var headerRow = new TableRow();
|
||||
foreach (var h in headers.EnumerateArray())
|
||||
{
|
||||
var cell = new TableCell();
|
||||
cell.TableCellProperties = new TableCellProperties
|
||||
{
|
||||
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "2E74B5", Color = "auto" },
|
||||
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
|
||||
};
|
||||
var para = new Paragraph(new Run(new Text(h.GetString() ?? ""))
|
||||
{
|
||||
RunProperties = new RunProperties
|
||||
{
|
||||
Bold = new Bold(),
|
||||
FontSize = new FontSize { Val = "20" },
|
||||
Color = new Color { Val = "FFFFFF" },
|
||||
}
|
||||
});
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Before = "40", After = "40" },
|
||||
};
|
||||
cell.Append(para);
|
||||
headerRow.Append(cell);
|
||||
}
|
||||
table.Append(headerRow);
|
||||
}
|
||||
|
||||
// 데이터 행
|
||||
if (rows.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int rowIdx = 0;
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var dataRow = new TableRow();
|
||||
foreach (var cellVal in row.EnumerateArray())
|
||||
{
|
||||
var cell = new TableCell();
|
||||
|
||||
// striped 스타일: 짝수행에 배경색
|
||||
if (tableStyle == "striped" && rowIdx % 2 == 0)
|
||||
{
|
||||
cell.TableCellProperties = new TableCellProperties
|
||||
{
|
||||
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "F2F7FB", Color = "auto" },
|
||||
};
|
||||
}
|
||||
|
||||
var para = new Paragraph(new Run(new Text(cellVal.ToString()) { Space = SpaceProcessingModeValues.Preserve })
|
||||
{
|
||||
RunProperties = new RunProperties { FontSize = new FontSize { Val = "20" } }
|
||||
});
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Before = "20", After = "20" },
|
||||
};
|
||||
cell.Append(para);
|
||||
dataRow.Append(cell);
|
||||
}
|
||||
table.Append(dataRow);
|
||||
rowIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 리스트 (번호/불릿)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static void AppendList(Body body, JsonElement section)
|
||||
{
|
||||
var items = section.TryGetProperty("items", out var arr) ? arr : default;
|
||||
var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet";
|
||||
|
||||
if (items.ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
int idx = 1;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var text = item.GetString() ?? item.ToString();
|
||||
var prefix = listStyle == "number" ? $"{idx}. " : "• ";
|
||||
|
||||
var para = new Paragraph();
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
Indentation = new Indentation { Left = "720" }, // 0.5 inch
|
||||
SpacingBetweenLines = new SpacingBetweenLines { Line = "320" },
|
||||
};
|
||||
|
||||
var prefixRun = new Run(new Text(prefix) { Space = SpaceProcessingModeValues.Preserve });
|
||||
prefixRun.RunProperties = new RunProperties
|
||||
{
|
||||
FontSize = new FontSize { Val = "22" },
|
||||
Bold = listStyle == "number" ? new Bold() : null,
|
||||
};
|
||||
para.Append(prefixRun);
|
||||
|
||||
var textRun = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
|
||||
textRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" } };
|
||||
para.Append(textRun);
|
||||
|
||||
body.Append(para);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 페이지 나누기
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Paragraph CreatePageBreak()
|
||||
{
|
||||
var para = new Paragraph();
|
||||
var run = new Run(new Break { Type = BreakValues.Page });
|
||||
para.Append(run);
|
||||
return para;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 머리글/바닥글
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static void AddHeaderFooter(MainDocumentPart mainPart, Body body,
|
||||
string? headerText, string? footerText, bool showPageNumbers)
|
||||
{
|
||||
// 머리글
|
||||
if (!string.IsNullOrEmpty(headerText))
|
||||
{
|
||||
var headerPart = mainPart.AddNewPart<HeaderPart>();
|
||||
var header = new Header();
|
||||
var para = new Paragraph(new Run(new Text(headerText))
|
||||
{
|
||||
RunProperties = new RunProperties
|
||||
{
|
||||
FontSize = new FontSize { Val = "18" }, // 9pt
|
||||
Color = new Color { Val = "808080" },
|
||||
}
|
||||
});
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
Justification = new Justification { Val = JustificationValues.Right },
|
||||
};
|
||||
header.Append(para);
|
||||
headerPart.Header = header;
|
||||
|
||||
// SectionProperties에 머리글 연결
|
||||
var secProps = body.GetFirstChild<SectionProperties>() ?? body.AppendChild(new SectionProperties());
|
||||
secProps.Append(new HeaderReference
|
||||
{
|
||||
Type = HeaderFooterValues.Default,
|
||||
Id = mainPart.GetIdOfPart(headerPart)
|
||||
});
|
||||
}
|
||||
|
||||
// 바닥글
|
||||
if (!string.IsNullOrEmpty(footerText) || showPageNumbers)
|
||||
{
|
||||
var footerPart = mainPart.AddNewPart<FooterPart>();
|
||||
var footer = new Footer();
|
||||
var para = new Paragraph();
|
||||
para.ParagraphProperties = new ParagraphProperties
|
||||
{
|
||||
Justification = new Justification { Val = JustificationValues.Center },
|
||||
};
|
||||
|
||||
var displayText = footerText ?? "AX Copilot";
|
||||
|
||||
if (showPageNumbers)
|
||||
{
|
||||
// 바닥글 텍스트 + 페이지 번호
|
||||
if (displayText.Contains("{page}"))
|
||||
{
|
||||
var parts = displayText.Split("{page}");
|
||||
para.Append(CreateFooterRun(parts[0]));
|
||||
para.Append(CreatePageNumberRun());
|
||||
if (parts.Length > 1)
|
||||
para.Append(CreateFooterRun(parts[1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
para.Append(CreateFooterRun(displayText + " · "));
|
||||
para.Append(CreatePageNumberRun());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
para.Append(CreateFooterRun(displayText));
|
||||
}
|
||||
|
||||
footer.Append(para);
|
||||
footerPart.Footer = footer;
|
||||
|
||||
var secProps = body.GetFirstChild<SectionProperties>() ?? body.AppendChild(new SectionProperties());
|
||||
secProps.Append(new FooterReference
|
||||
{
|
||||
Type = HeaderFooterValues.Default,
|
||||
Id = mainPart.GetIdOfPart(footerPart)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static Run CreateFooterRun(string text) =>
|
||||
new(new Text(text) { Space = SpaceProcessingModeValues.Preserve })
|
||||
{
|
||||
RunProperties = new RunProperties
|
||||
{
|
||||
FontSize = new FontSize { Val = "16" },
|
||||
Color = new Color { Val = "999999" },
|
||||
}
|
||||
};
|
||||
|
||||
private static Run CreatePageNumberRun()
|
||||
{
|
||||
var run = new Run();
|
||||
run.RunProperties = new RunProperties
|
||||
{
|
||||
FontSize = new FontSize { Val = "16" },
|
||||
Color = new Color { Val = "999999" },
|
||||
};
|
||||
run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin });
|
||||
run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve });
|
||||
run.Append(new FieldChar { FieldCharType = FieldCharValues.End });
|
||||
return run;
|
||||
}
|
||||
}
|
||||
189
src/AxCopilot/Services/Agent/EncodingTool.cs
Normal file
189
src/AxCopilot/Services/Agent/EncodingTool.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일 인코딩 감지 및 변환 도구.</summary>
|
||||
public class EncodingTool : IAgentTool
|
||||
{
|
||||
public string Name => "encoding_tool";
|
||||
public string Description =>
|
||||
"Detect and convert file text encoding. Actions: " +
|
||||
"'detect' — detect file encoding (UTF-8, EUC-KR, etc.); " +
|
||||
"'convert' — convert file from one encoding to another; " +
|
||||
"'list' — list common encoding names.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action: detect, convert, list",
|
||||
Enum = ["detect", "convert", "list"],
|
||||
},
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File path",
|
||||
},
|
||||
["from_encoding"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Source encoding name (e.g. 'euc-kr', 'shift-jis'). Auto-detected if omitted.",
|
||||
},
|
||||
["to_encoding"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Target encoding name (default: 'utf-8')",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
|
||||
if (action == "list")
|
||||
return ListEncodings();
|
||||
|
||||
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
return ToolResult.Fail("'path'가 필요합니다.");
|
||||
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
if (!context.IsPathAllowed(path))
|
||||
return ToolResult.Fail($"경로 접근 차단: {path}");
|
||||
if (!File.Exists(path))
|
||||
return ToolResult.Fail($"파일 없음: {path}");
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"detect" => DetectEncoding(path),
|
||||
"convert" => await ConvertEncoding(path, args, context),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"인코딩 처리 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult DetectEncoding(string path)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var detected = DetectEncodingFromBytes(bytes);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"File: {Path.GetFileName(path)}");
|
||||
sb.AppendLine($"Size: {bytes.Length:N0} bytes");
|
||||
sb.AppendLine($"Detected Encoding: {detected.EncodingName}");
|
||||
sb.AppendLine($"Code Page: {detected.CodePage}");
|
||||
sb.AppendLine($"BOM Present: {HasBom(bytes)}");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
|
||||
{
|
||||
var toName = args.TryGetProperty("to_encoding", out var te) ? te.GetString() ?? "utf-8" : "utf-8";
|
||||
|
||||
// 쓰기 권한 확인
|
||||
var allowed = await context.CheckWritePermissionAsync("encoding_tool", path);
|
||||
if (!allowed) return ToolResult.Fail("파일 쓰기 권한이 거부되었습니다.");
|
||||
|
||||
// 소스 인코딩 결정
|
||||
Encoding fromEnc;
|
||||
if (args.TryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.GetString()))
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
fromEnc = Encoding.GetEncoding(fe.GetString()!);
|
||||
}
|
||||
else
|
||||
{
|
||||
var rawBytes = File.ReadAllBytes(path);
|
||||
fromEnc = DetectEncodingFromBytes(rawBytes);
|
||||
}
|
||||
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var toEnc = Encoding.GetEncoding(toName);
|
||||
|
||||
var content = File.ReadAllText(path, fromEnc);
|
||||
File.WriteAllText(path, content, toEnc);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"변환 완료: {fromEnc.EncodingName} → {toEnc.EncodingName}\nFile: {path}",
|
||||
filePath: path);
|
||||
}
|
||||
|
||||
private static ToolResult ListEncodings()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("주요 인코딩 목록:");
|
||||
sb.AppendLine(" utf-8 — UTF-8 (유니코드, 기본)");
|
||||
sb.AppendLine(" utf-16 — UTF-16 LE");
|
||||
sb.AppendLine(" utf-16BE — UTF-16 BE");
|
||||
sb.AppendLine(" euc-kr — EUC-KR (한국어)");
|
||||
sb.AppendLine(" ks_c_5601-1987 — 한글 완성형");
|
||||
sb.AppendLine(" shift_jis — Shift-JIS (일본어)");
|
||||
sb.AppendLine(" gb2312 — GB2312 (중국어 간체)");
|
||||
sb.AppendLine(" iso-8859-1 — Latin-1 (서유럽)");
|
||||
sb.AppendLine(" ascii — US-ASCII");
|
||||
sb.AppendLine(" utf-32 — UTF-32");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static Encoding DetectEncodingFromBytes(byte[] bytes)
|
||||
{
|
||||
// BOM 감지
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
|
||||
return Encoding.UTF8;
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
|
||||
return Encoding.Unicode; // UTF-16 LE
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
|
||||
return Encoding.BigEndianUnicode;
|
||||
|
||||
// 간단한 UTF-8 유효성 검사
|
||||
if (IsValidUtf8(bytes))
|
||||
return Encoding.UTF8;
|
||||
|
||||
// 한국어 환경 기본값
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
try { return Encoding.GetEncoding("euc-kr"); }
|
||||
catch { return Encoding.Default; }
|
||||
}
|
||||
|
||||
private static bool IsValidUtf8(byte[] bytes)
|
||||
{
|
||||
var i = 0;
|
||||
var hasMultibyte = false;
|
||||
while (i < bytes.Length)
|
||||
{
|
||||
if (bytes[i] <= 0x7F) { i++; continue; }
|
||||
int extra;
|
||||
if ((bytes[i] & 0xE0) == 0xC0) extra = 1;
|
||||
else if ((bytes[i] & 0xF0) == 0xE0) extra = 2;
|
||||
else if ((bytes[i] & 0xF8) == 0xF0) extra = 3;
|
||||
else return false;
|
||||
|
||||
if (i + extra >= bytes.Length) return false;
|
||||
for (var j = 1; j <= extra; j++)
|
||||
if ((bytes[i + j] & 0xC0) != 0x80) return false;
|
||||
hasMultibyte = true;
|
||||
i += extra + 1;
|
||||
}
|
||||
return hasMultibyte || bytes.Length < 100; // 순수 ASCII도 UTF-8으로 간주
|
||||
}
|
||||
|
||||
private static bool HasBom(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return true;
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return true;
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
127
src/AxCopilot/Services/Agent/EnvTool.cs
Normal file
127
src/AxCopilot/Services/Agent/EnvTool.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 환경변수 읽기·쓰기 도구.
|
||||
/// 현재 프로세스 범위에서만 동작하며 시스템 환경변수는 변경하지 않습니다.
|
||||
/// </summary>
|
||||
public class EnvTool : IAgentTool
|
||||
{
|
||||
public string Name => "env_tool";
|
||||
public string Description =>
|
||||
"Read or set environment variables (process scope only). Actions: " +
|
||||
"'get' — read an environment variable value; " +
|
||||
"'set' — set an environment variable (process scope, not permanent); " +
|
||||
"'list' — list all environment variables; " +
|
||||
"'expand' — expand %VAR% references in a string.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["get", "set", "list", "expand"],
|
||||
},
|
||||
["name"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Variable name (for get/set)",
|
||||
},
|
||||
["value"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Variable value (for set) or string to expand (for expand)",
|
||||
},
|
||||
["filter"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Filter pattern for list action (case-insensitive substring match)",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"get" => Get(args),
|
||||
"set" => Set(args),
|
||||
"list" => ListVars(args),
|
||||
"expand" => Expand(args),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"환경변수 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult Get(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("name", out var n))
|
||||
return ToolResult.Fail("'name' parameter is required for get action");
|
||||
var name = n.GetString() ?? "";
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return value != null
|
||||
? ToolResult.Ok($"{name}={value}")
|
||||
: ToolResult.Ok($"{name} is not set");
|
||||
}
|
||||
|
||||
private static ToolResult Set(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("name", out var n))
|
||||
return ToolResult.Fail("'name' parameter is required for set action");
|
||||
if (!args.TryGetProperty("value", out var v))
|
||||
return ToolResult.Fail("'value' parameter is required for set action");
|
||||
|
||||
var name = n.GetString() ?? "";
|
||||
var value = v.GetString() ?? "";
|
||||
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
|
||||
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
|
||||
}
|
||||
|
||||
private static ToolResult ListVars(JsonElement args)
|
||||
{
|
||||
var filter = args.TryGetProperty("filter", out var f) ? f.GetString() ?? "" : "";
|
||||
var vars = Environment.GetEnvironmentVariables();
|
||||
var entries = new List<string>();
|
||||
|
||||
foreach (System.Collections.DictionaryEntry entry in vars)
|
||||
{
|
||||
var key = entry.Key?.ToString() ?? "";
|
||||
var val = entry.Value?.ToString() ?? "";
|
||||
if (!string.IsNullOrEmpty(filter) &&
|
||||
!key.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
|
||||
!val.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// 긴 값은 자르기
|
||||
if (val.Length > 120) val = val[..120] + "...";
|
||||
entries.Add($"{key}={val}");
|
||||
}
|
||||
|
||||
entries.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
var result = $"Environment variables ({entries.Count}):\n" + string.Join("\n", entries);
|
||||
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
|
||||
private static ToolResult Expand(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("value", out var v))
|
||||
return ToolResult.Fail("'value' parameter is required for expand action");
|
||||
var input = v.GetString() ?? "";
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input);
|
||||
return ToolResult.Ok(expanded);
|
||||
}
|
||||
}
|
||||
406
src/AxCopilot/Services/Agent/ExcelSkill.cs
Normal file
406
src/AxCopilot/Services/Agent/ExcelSkill.cs
Normal file
@@ -0,0 +1,406 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Excel (.xlsx) 문서를 생성하는 내장 스킬.
|
||||
/// 셀 서식(색상/테두리/볼드), 수식, 열 너비, 셀 병합, 틀 고정 등을 지원합니다.
|
||||
/// </summary>
|
||||
public class ExcelSkill : IAgentTool
|
||||
{
|
||||
public string Name => "excel_create";
|
||||
public string Description => "Create a styled Excel (.xlsx) file. " +
|
||||
"Supports: header styling (bold white text on blue background), " +
|
||||
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
|
||||
"cell merge, freeze panes (freeze header row), and number formatting.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.xlsx). Relative to work folder." },
|
||||
["sheet_name"] = new() { Type = "string", Description = "Sheet name. Default: 'Sheet1'." },
|
||||
["headers"] = new() { Type = "array", Description = "Column headers as JSON array of strings.", Items = new() { Type = "string" } },
|
||||
["rows"] = new() { Type = "array", Description = "Data rows as JSON array of arrays. Use string starting with '=' for formulas (e.g. '=SUM(A2:A10)').", Items = new() { Type = "array", Items = new() { Type = "string" } } },
|
||||
["style"] = new() { Type = "string", Description = "Table style: 'styled' (blue header, striped rows, borders) or 'plain'. Default: 'styled'" },
|
||||
["col_widths"] = new() { Type = "array", Description = "Column widths as JSON array of numbers (in characters). e.g. [15, 10, 20]. Auto-fit if omitted.", Items = new() { Type = "number" } },
|
||||
["freeze_header"] = new() { Type = "boolean", Description = "Freeze the header row. Default: true for styled." },
|
||||
["merges"] = new() { Type = "array", Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]", Items = new() { Type = "string" } },
|
||||
["summary_row"] = new() { Type = "object", Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom." },
|
||||
},
|
||||
Required = ["path", "headers", "rows"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1";
|
||||
var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled";
|
||||
var isStyled = tableStyle != "plain";
|
||||
var freezeHeader = args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".xlsx";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var headers = args.GetProperty("headers");
|
||||
var rows = args.GetProperty("rows");
|
||||
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
|
||||
|
||||
var workbookPart = spreadsheet.AddWorkbookPart();
|
||||
workbookPart.Workbook = new Workbook();
|
||||
|
||||
// Stylesheet 추가 (서식용)
|
||||
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
|
||||
stylesPart.Stylesheet = CreateStylesheet(isStyled);
|
||||
stylesPart.Stylesheet.Save();
|
||||
|
||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
|
||||
// 열 너비 설정
|
||||
var colCount = headers.GetArrayLength();
|
||||
var columns = CreateColumns(args, colCount);
|
||||
if (columns != null)
|
||||
worksheetPart.Worksheet.Append(columns);
|
||||
|
||||
var sheetData = new SheetData();
|
||||
worksheetPart.Worksheet.Append(sheetData);
|
||||
|
||||
var sheets = workbookPart.Workbook.AppendChild(new Sheets());
|
||||
sheets.Append(new Sheet
|
||||
{
|
||||
Id = workbookPart.GetIdOfPart(worksheetPart),
|
||||
SheetId = 1,
|
||||
Name = sheetName,
|
||||
});
|
||||
|
||||
// 헤더 행 (styleIndex 1 = 볼드 흰색 + 파란배경)
|
||||
var headerRow = new Row { RowIndex = 1 };
|
||||
int colIdx = 0;
|
||||
foreach (var h in headers.EnumerateArray())
|
||||
{
|
||||
var cellRef = GetCellReference(colIdx, 0);
|
||||
var cell = new Cell
|
||||
{
|
||||
CellReference = cellRef,
|
||||
DataType = CellValues.String,
|
||||
CellValue = new CellValue(h.GetString() ?? ""),
|
||||
StyleIndex = isStyled ? (uint)1 : 0,
|
||||
};
|
||||
headerRow.Append(cell);
|
||||
colIdx++;
|
||||
}
|
||||
sheetData.Append(headerRow);
|
||||
|
||||
// 데이터 행
|
||||
int rowCount = 0;
|
||||
uint rowNum = 2;
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var dataRow = new Row { RowIndex = rowNum };
|
||||
int ci = 0;
|
||||
foreach (var cellVal in row.EnumerateArray())
|
||||
{
|
||||
var cellRef = GetCellReference(ci, (int)rowNum - 1);
|
||||
var cell = new Cell { CellReference = cellRef };
|
||||
|
||||
// striped 스타일: 짝수행
|
||||
if (isStyled && rowCount % 2 == 0)
|
||||
cell.StyleIndex = 2; // 연한 파란 배경
|
||||
|
||||
var strVal = cellVal.ToString();
|
||||
|
||||
// 수식 (=으로 시작)
|
||||
if (strVal.StartsWith('='))
|
||||
{
|
||||
cell.CellFormula = new CellFormula(strVal);
|
||||
cell.DataType = null; // 수식은 DataType 없음
|
||||
}
|
||||
else if (cellVal.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
cell.DataType = CellValues.Number;
|
||||
cell.CellValue = new CellValue(cellVal.GetDouble().ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.DataType = CellValues.String;
|
||||
cell.CellValue = new CellValue(strVal);
|
||||
}
|
||||
|
||||
dataRow.Append(cell);
|
||||
ci++;
|
||||
}
|
||||
sheetData.Append(dataRow);
|
||||
rowCount++;
|
||||
rowNum++;
|
||||
}
|
||||
|
||||
// 요약 행 (summary_row)
|
||||
if (args.TryGetProperty("summary_row", out var summary))
|
||||
AddSummaryRow(sheetData, summary, rowNum, colCount, rowCount, isStyled);
|
||||
|
||||
// 셀 병합
|
||||
if (args.TryGetProperty("merges", out var merges) && merges.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var mergeCells = new MergeCells();
|
||||
foreach (var merge in merges.EnumerateArray())
|
||||
{
|
||||
var range = merge.GetString();
|
||||
if (!string.IsNullOrEmpty(range))
|
||||
mergeCells.Append(new MergeCell { Reference = range });
|
||||
}
|
||||
if (mergeCells.HasChildren)
|
||||
worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData);
|
||||
}
|
||||
|
||||
// 틀 고정 (헤더 행)
|
||||
if (freezeHeader)
|
||||
{
|
||||
var sheetViews = new SheetViews(new SheetView(
|
||||
new Pane
|
||||
{
|
||||
VerticalSplit = 1,
|
||||
TopLeftCell = "A2",
|
||||
ActivePane = PaneValues.BottomLeft,
|
||||
State = PaneStateValues.Frozen
|
||||
},
|
||||
new Selection
|
||||
{
|
||||
Pane = PaneValues.BottomLeft,
|
||||
ActiveCell = "A2",
|
||||
SequenceOfReferences = new ListValue<StringValue> { InnerText = "A2" }
|
||||
})
|
||||
{ TabSelected = true, WorkbookViewId = 0 });
|
||||
|
||||
var insertBefore = (OpenXmlElement?)worksheetPart.Worksheet.GetFirstChild<Columns>()
|
||||
?? worksheetPart.Worksheet.GetFirstChild<SheetData>();
|
||||
worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore);
|
||||
}
|
||||
|
||||
workbookPart.Workbook.Save();
|
||||
|
||||
var features = new List<string>();
|
||||
if (isStyled) features.Add("스타일 적용");
|
||||
if (freezeHeader) features.Add("틀 고정");
|
||||
if (args.TryGetProperty("merges", out _)) features.Add("셀 병합");
|
||||
if (args.TryGetProperty("summary_row", out _)) features.Add("요약행");
|
||||
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Excel 파일 생성 완료: {fullPath}\n시트: {sheetName}, 열: {colCount}, 행: {rowCount}{featureStr}",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"Excel 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Stylesheet (셀 서식)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Stylesheet CreateStylesheet(bool isStyled)
|
||||
{
|
||||
var stylesheet = new Stylesheet();
|
||||
|
||||
// Fonts
|
||||
var fonts = new Fonts(
|
||||
new Font( // 0: 기본
|
||||
new FontSize { Val = 11 },
|
||||
new FontName { Val = "맑은 고딕" }
|
||||
),
|
||||
new Font( // 1: 볼드 흰색 (헤더용)
|
||||
new Bold(),
|
||||
new FontSize { Val = 11 },
|
||||
new Color { Rgb = "FFFFFFFF" },
|
||||
new FontName { Val = "맑은 고딕" }
|
||||
),
|
||||
new Font( // 2: 볼드 (요약행용)
|
||||
new Bold(),
|
||||
new FontSize { Val = 11 },
|
||||
new FontName { Val = "맑은 고딕" }
|
||||
)
|
||||
);
|
||||
stylesheet.Append(fonts);
|
||||
|
||||
// Fills
|
||||
var fills = new Fills(
|
||||
new Fill(new PatternFill { PatternType = PatternValues.None }), // 0: none (필수)
|
||||
new Fill(new PatternFill { PatternType = PatternValues.Gray125 }), // 1: gray125 (필수)
|
||||
new Fill(new PatternFill // 2: 파란 헤더 배경
|
||||
{
|
||||
PatternType = PatternValues.Solid,
|
||||
ForegroundColor = new ForegroundColor { Rgb = "FF2E74B5" },
|
||||
BackgroundColor = new BackgroundColor { Indexed = 64 }
|
||||
}),
|
||||
new Fill(new PatternFill // 3: 연한 파란 (striped)
|
||||
{
|
||||
PatternType = PatternValues.Solid,
|
||||
ForegroundColor = new ForegroundColor { Rgb = "FFF2F7FB" },
|
||||
BackgroundColor = new BackgroundColor { Indexed = 64 }
|
||||
}),
|
||||
new Fill(new PatternFill // 4: 연한 회색 (요약행)
|
||||
{
|
||||
PatternType = PatternValues.Solid,
|
||||
ForegroundColor = new ForegroundColor { Rgb = "FFE8E8E8" },
|
||||
BackgroundColor = new BackgroundColor { Indexed = 64 }
|
||||
})
|
||||
);
|
||||
stylesheet.Append(fills);
|
||||
|
||||
// Borders
|
||||
var borders = new Borders(
|
||||
new Border( // 0: 테두리 없음
|
||||
new LeftBorder(), new RightBorder(),
|
||||
new TopBorder(), new BottomBorder(), new DiagonalBorder()
|
||||
),
|
||||
new Border( // 1: 얇은 테두리
|
||||
new LeftBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
|
||||
new RightBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
|
||||
new TopBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
|
||||
new BottomBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
|
||||
new DiagonalBorder()
|
||||
)
|
||||
);
|
||||
stylesheet.Append(borders);
|
||||
|
||||
// CellFormats
|
||||
var cellFormats = new CellFormats(
|
||||
new CellFormat // 0: 기본
|
||||
{
|
||||
FontId = 0, FillId = 0, BorderId = isStyled ? (uint)1 : 0, ApplyBorder = isStyled
|
||||
},
|
||||
new CellFormat // 1: 헤더 (볼드 흰색 + 파란배경 + 테두리)
|
||||
{
|
||||
FontId = 1, FillId = 2, BorderId = 1,
|
||||
ApplyFont = true, ApplyFill = true, ApplyBorder = true,
|
||||
Alignment = new Alignment { Horizontal = HorizontalAlignmentValues.Center, Vertical = VerticalAlignmentValues.Center }
|
||||
},
|
||||
new CellFormat // 2: striped 행 (연한 파란 배경)
|
||||
{
|
||||
FontId = 0, FillId = 3, BorderId = 1,
|
||||
ApplyFill = true, ApplyBorder = true
|
||||
},
|
||||
new CellFormat // 3: 요약행 (볼드 + 회색 배경)
|
||||
{
|
||||
FontId = 2, FillId = 4, BorderId = 1,
|
||||
ApplyFont = true, ApplyFill = true, ApplyBorder = true
|
||||
}
|
||||
);
|
||||
stylesheet.Append(cellFormats);
|
||||
|
||||
return stylesheet;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 열 너비
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Columns? CreateColumns(JsonElement args, int colCount)
|
||||
{
|
||||
var hasWidths = args.TryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
|
||||
|
||||
// col_widths가 없으면 기본 너비 15 적용
|
||||
var columns = new Columns();
|
||||
for (int i = 0; i < colCount; i++)
|
||||
{
|
||||
double width = 15; // 기본 너비
|
||||
if (hasWidths && i < widthsArr.GetArrayLength())
|
||||
width = widthsArr[i].GetDouble();
|
||||
|
||||
columns.Append(new Column
|
||||
{
|
||||
Min = (uint)(i + 1),
|
||||
Max = (uint)(i + 1),
|
||||
Width = width,
|
||||
CustomWidth = true,
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 요약 행
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
|
||||
uint rowNum, int colCount, int dataRowCount, bool isStyled)
|
||||
{
|
||||
var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계";
|
||||
var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default;
|
||||
|
||||
var summaryRow = new Row { RowIndex = rowNum };
|
||||
|
||||
// 첫 번째 열에 라벨
|
||||
var labelCell = new Cell
|
||||
{
|
||||
CellReference = GetCellReference(0, (int)rowNum - 1),
|
||||
DataType = CellValues.String,
|
||||
CellValue = new CellValue(label),
|
||||
StyleIndex = isStyled ? (uint)3 : 0,
|
||||
};
|
||||
summaryRow.Append(labelCell);
|
||||
|
||||
// 나머지 열에 수식 또는 빈 셀
|
||||
for (int ci = 1; ci < colCount; ci++)
|
||||
{
|
||||
var colLetter = GetColumnLetter(ci);
|
||||
var cell = new Cell
|
||||
{
|
||||
CellReference = GetCellReference(ci, (int)rowNum - 1),
|
||||
StyleIndex = isStyled ? (uint)3 : 0,
|
||||
};
|
||||
|
||||
if (colFormulas.ValueKind == JsonValueKind.Object &&
|
||||
colFormulas.TryGetProperty(colLetter, out var funcName))
|
||||
{
|
||||
var func = funcName.GetString()?.ToUpper() ?? "SUM";
|
||||
var startRow = 2;
|
||||
var endRow = startRow + dataRowCount - 1;
|
||||
cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})");
|
||||
}
|
||||
|
||||
summaryRow.Append(cell);
|
||||
}
|
||||
|
||||
sheetData.Append(summaryRow);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 유틸리티
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static string GetColumnLetter(int colIndex)
|
||||
{
|
||||
var result = "";
|
||||
while (colIndex >= 0)
|
||||
{
|
||||
result = (char)('A' + colIndex % 26) + result;
|
||||
colIndex = colIndex / 26 - 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetCellReference(int colIndex, int rowIndex)
|
||||
=> $"{GetColumnLetter(colIndex)}{rowIndex + 1}";
|
||||
}
|
||||
127
src/AxCopilot/Services/Agent/FileEditTool.cs
Normal file
127
src/AxCopilot/Services/Agent/FileEditTool.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴).</summary>
|
||||
public class FileEditTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_edit";
|
||||
public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "File path to edit" },
|
||||
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" },
|
||||
["new_string"] = new() { Type = "string", Description = "Replacement string" },
|
||||
["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." },
|
||||
},
|
||||
Required = ["path", "old_string", "new_string"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var oldStr = args.GetProperty("old_string").GetString() ?? "";
|
||||
var newStr = args.GetProperty("new_string").GetString() ?? "";
|
||||
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
|
||||
var content = read.Text;
|
||||
|
||||
var count = CountOccurrences(content, oldStr);
|
||||
if (count == 0)
|
||||
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
|
||||
if (!replaceAll && count > 1)
|
||||
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
|
||||
|
||||
// Diff Preview: 변경 내용을 컨텍스트와 함께 표시
|
||||
var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
|
||||
|
||||
var updated = content.Replace(oldStr, newStr);
|
||||
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
|
||||
await TextFileCodec.WriteAllTextAsync(fullPath, updated, writeEncoding, ct);
|
||||
|
||||
var msg = replaceAll && count > 1
|
||||
? $"파일 수정 완료: {fullPath} ({count}곳 전체 교체)"
|
||||
: $"파일 수정 완료: {fullPath}";
|
||||
return ToolResult.Ok($"{msg}\n\n{diffPreview}", fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"파일 수정 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>변경 전/후 diff를 생성합니다 (unified diff 스타일).</summary>
|
||||
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal);
|
||||
if (matchIdx < 0) return "";
|
||||
|
||||
// 변경 시작 줄 번호 계산
|
||||
var startLine = content[..matchIdx].Count(c => c == '\n');
|
||||
var oldLines = oldStr.Split('\n');
|
||||
var newLines = newStr.Split('\n');
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
sb.AppendLine($"--- {fileName} (before)");
|
||||
sb.AppendLine($"+++ {fileName} (after)");
|
||||
|
||||
// 컨텍스트 라인 수
|
||||
const int ctx = 2;
|
||||
var ctxStart = Math.Max(0, startLine - ctx);
|
||||
var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx);
|
||||
|
||||
sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@");
|
||||
|
||||
// 앞쪽 컨텍스트
|
||||
for (int i = ctxStart; i < startLine; i++)
|
||||
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
|
||||
|
||||
// 삭제 라인
|
||||
foreach (var line in oldLines)
|
||||
sb.AppendLine($"-{line.TrimEnd('\r')}");
|
||||
|
||||
// 추가 라인
|
||||
foreach (var line in newLines)
|
||||
sb.AppendLine($"+{line.TrimEnd('\r')}");
|
||||
|
||||
// 뒤쪽 컨텍스트
|
||||
var afterEnd = startLine + oldLines.Length;
|
||||
for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++)
|
||||
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string search)
|
||||
{
|
||||
if (string.IsNullOrEmpty(search)) return 0;
|
||||
int count = 0, idx = 0;
|
||||
while ((idx = text.IndexOf(search, idx, StringComparison.Ordinal)) != -1)
|
||||
{
|
||||
count++;
|
||||
idx += search.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
98
src/AxCopilot/Services/Agent/FileInfoTool.cs
Normal file
98
src/AxCopilot/Services/Agent/FileInfoTool.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일/폴더 메타 정보(크기, 수정일, 줄 수 등) 조회 도구.</summary>
|
||||
public class FileInfoTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_info";
|
||||
public string Description =>
|
||||
"Get file or directory metadata without reading contents. " +
|
||||
"Returns: size, created/modified dates, line count (files), item count (directories), encoding hint.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File or directory path",
|
||||
},
|
||||
},
|
||||
Required = ["path"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
if (!context.IsPathAllowed(path))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {path}"));
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Type: File");
|
||||
sb.AppendLine($"Path: {fi.FullName}");
|
||||
sb.AppendLine($"Size: {FormatSize(fi.Length)} ({fi.Length:N0} bytes)");
|
||||
sb.AppendLine($"Extension: {fi.Extension}");
|
||||
sb.AppendLine($"Created: {fi.CreationTime:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"Modified: {fi.LastWriteTime:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"ReadOnly: {fi.IsReadOnly}");
|
||||
|
||||
// 텍스트 파일이면 줄 수 카운트 (최대 100만 줄까지)
|
||||
var textExts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".cs", ".py", ".js", ".ts", ".java", ".cpp", ".c", ".h", ".xml", ".json",
|
||||
".yaml", ".yml", ".md", ".txt", ".csv", ".html", ".htm", ".css", ".sql",
|
||||
".sh", ".bat", ".ps1", ".config", ".ini", ".log", ".xaml" };
|
||||
if (textExts.Contains(fi.Extension) && fi.Length < 50 * 1024 * 1024)
|
||||
{
|
||||
var lineCount = File.ReadLines(path).Take(1_000_000).Count();
|
||||
sb.AppendLine($"Lines: {lineCount:N0}{(lineCount >= 1_000_000 ? "+" : "")}");
|
||||
}
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var di = new DirectoryInfo(path);
|
||||
var files = di.GetFiles("*", SearchOption.TopDirectoryOnly);
|
||||
var dirs = di.GetDirectories();
|
||||
var totalSize = files.Sum(f => f.Length);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Type: Directory");
|
||||
sb.AppendLine($"Path: {di.FullName}");
|
||||
sb.AppendLine($"Files: {files.Length}");
|
||||
sb.AppendLine($"Subdirectories: {dirs.Length}");
|
||||
sb.AppendLine($"Total Size (top-level files): {FormatSize(totalSize)}");
|
||||
sb.AppendLine($"Created: {di.CreationTime:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"Modified: {di.LastWriteTime:yyyy-MM-dd HH:mm:ss}");
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
|
||||
return Task.FromResult(ToolResult.Fail($"경로를 찾을 수 없습니다: {path}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"정보 조회 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes}B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1}KB",
|
||||
< 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1}MB",
|
||||
_ => $"{bytes / (1024.0 * 1024 * 1024):F2}GB",
|
||||
};
|
||||
}
|
||||
137
src/AxCopilot/Services/Agent/FileManageTool.cs
Normal file
137
src/AxCopilot/Services/Agent/FileManageTool.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일/폴더 이동·복사·이름변경·삭제·디렉토리 생성 도구.</summary>
|
||||
public class FileManageTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_manage";
|
||||
public string Description =>
|
||||
"Manage files and directories. Actions: " +
|
||||
"'move' — move file/folder to destination; " +
|
||||
"'copy' — copy file/folder to destination; " +
|
||||
"'rename' — rename file/folder; " +
|
||||
"'delete' — delete file (requires Ask permission); " +
|
||||
"'mkdir' — create directory recursively.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["move", "copy", "rename", "delete", "mkdir"],
|
||||
},
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Source file/folder path",
|
||||
},
|
||||
["destination"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Destination path (for move/copy/rename)",
|
||||
},
|
||||
},
|
||||
Required = ["action", "path"],
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var dest = args.TryGetProperty("destination", out var d) ? d.GetString() ?? "" : "";
|
||||
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
if (!context.IsPathAllowed(path))
|
||||
return ToolResult.Fail($"경로 접근 차단: {path}");
|
||||
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "mkdir":
|
||||
Directory.CreateDirectory(path);
|
||||
return ToolResult.Ok($"디렉토리 생성: {path}");
|
||||
|
||||
case "delete":
|
||||
if (context.AskPermission != null)
|
||||
{
|
||||
var allowed = await context.AskPermission("file_manage(delete)", path);
|
||||
if (!allowed) return ToolResult.Fail("사용자가 삭제를 거부했습니다.");
|
||||
}
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
return ToolResult.Ok($"파일 삭제: {path}", filePath: path);
|
||||
}
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
return ToolResult.Ok($"폴더 삭제: {path}", filePath: path);
|
||||
}
|
||||
return ToolResult.Fail($"경로를 찾을 수 없습니다: {path}");
|
||||
|
||||
case "move":
|
||||
case "copy":
|
||||
case "rename":
|
||||
if (string.IsNullOrEmpty(dest))
|
||||
return ToolResult.Fail($"'{action}' 작업에는 'destination'이 필요합니다.");
|
||||
|
||||
var destPath = Path.IsPathRooted(dest) ? dest : Path.Combine(context.WorkFolder, dest);
|
||||
if (!context.IsPathAllowed(destPath))
|
||||
return ToolResult.Fail($"대상 경로 접근 차단: {destPath}");
|
||||
|
||||
// 대상 디렉토리 자동 생성
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
if (action == "rename")
|
||||
{
|
||||
// rename: 같은 폴더 내 이름 변경
|
||||
var dir = Path.GetDirectoryName(path) ?? context.WorkFolder;
|
||||
destPath = Path.Combine(dir, dest);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (action == "copy")
|
||||
File.Copy(path, destPath, overwrite: true);
|
||||
else
|
||||
File.Move(path, destPath, overwrite: true);
|
||||
return ToolResult.Ok($"{action}: {path} → {destPath}", filePath: destPath);
|
||||
}
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
if (action == "copy")
|
||||
CopyDirectory(path, destPath);
|
||||
else
|
||||
Directory.Move(path, destPath);
|
||||
return ToolResult.Ok($"{action}: {path} → {destPath}", filePath: destPath);
|
||||
}
|
||||
return ToolResult.Fail($"소스 경로를 찾을 수 없습니다: {path}");
|
||||
|
||||
default:
|
||||
return ToolResult.Fail($"Unknown action: {action}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"파일 관리 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string src, string dst)
|
||||
{
|
||||
Directory.CreateDirectory(dst);
|
||||
foreach (var file in Directory.GetFiles(src))
|
||||
File.Copy(file, Path.Combine(dst, Path.GetFileName(file)), true);
|
||||
foreach (var dir in Directory.GetDirectories(src))
|
||||
CopyDirectory(dir, Path.Combine(dst, Path.GetFileName(dir)));
|
||||
}
|
||||
}
|
||||
67
src/AxCopilot/Services/Agent/FileReadTool.cs
Normal file
67
src/AxCopilot/Services/Agent/FileReadTool.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일 내용을 읽어 반환하는 도구.</summary>
|
||||
public class FileReadTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_read";
|
||||
public string Description => "Read the contents of a file. Returns the text content with line numbers.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "File path to read (absolute or relative to work folder)" },
|
||||
["offset"] = new() { Type = "integer", Description = "Starting line number (1-based). Optional, default 1." },
|
||||
["limit"] = new() { Type = "integer", Description = "Maximum number of lines to read. Optional, default 500." },
|
||||
},
|
||||
Required = ["path"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("path", out var pathEl))
|
||||
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
||||
var path = pathEl.GetString() ?? "";
|
||||
var offset = args.TryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
|
||||
var limit = args.TryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
|
||||
|
||||
var fullPath = ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}"));
|
||||
|
||||
try
|
||||
{
|
||||
var read = TextFileCodec.ReadAllText(fullPath);
|
||||
var lines = TextFileCodec.SplitLines(read.Text);
|
||||
var total = lines.Length;
|
||||
var startIdx = Math.Max(0, offset - 1);
|
||||
var endIdx = Math.Min(total, startIdx + limit);
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})");
|
||||
for (int i = startIdx; i < endIdx; i++)
|
||||
sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}");
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString(), fullPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"파일 읽기 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ResolvePath(string path, string workFolder)
|
||||
{
|
||||
if (Path.IsPathRooted(path)) return Path.GetFullPath(path);
|
||||
if (!string.IsNullOrEmpty(workFolder))
|
||||
return Path.GetFullPath(Path.Combine(workFolder, path));
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
182
src/AxCopilot/Services/Agent/FileWatchTool.cs
Normal file
182
src/AxCopilot/Services/Agent/FileWatchTool.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 지정 경로의 파일 변경을 감지하고 변경 내역을 반환하는 도구.
|
||||
/// FileSystemInfo의 타임스탬프 기반으로 최근 변경 파일을 조회합니다.
|
||||
/// </summary>
|
||||
public class FileWatchTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_watch";
|
||||
public string Description =>
|
||||
"Detect recent file changes in a folder. Returns a list of created, modified, and deleted files " +
|
||||
"since a given time. Useful for monitoring data folders, detecting log updates, " +
|
||||
"or tracking file system changes.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Folder path to watch. Relative to work folder."
|
||||
},
|
||||
["pattern"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File pattern filter (e.g. '*.csv', '*.log', '*.xlsx'). Default: '*' (all files)"
|
||||
},
|
||||
["since"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Time threshold: ISO 8601 datetime (e.g. '2026-03-30T09:00:00') " +
|
||||
"or relative duration ('1h', '6h', '24h', '7d', '30d'). Default: '24h'"
|
||||
},
|
||||
["recursive"] = new()
|
||||
{
|
||||
Type = "boolean",
|
||||
Description = "Search subdirectories recursively. Default: true"
|
||||
},
|
||||
["include_size"] = new()
|
||||
{
|
||||
Type = "boolean",
|
||||
Description = "Include file sizes in output. Default: true"
|
||||
},
|
||||
["top_n"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Limit results to most recent N files. Default: 50"
|
||||
},
|
||||
},
|
||||
Required = ["path"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var pattern = args.TryGetProperty("pattern", out var patEl) ? patEl.GetString() ?? "*" : "*";
|
||||
var sinceStr = args.TryGetProperty("since", out var sinceEl) ? sinceEl.GetString() ?? "24h" : "24h";
|
||||
var recursive = !args.TryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
|
||||
var includeSize = !args.TryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
|
||||
var topN = args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
|
||||
if (!Directory.Exists(fullPath))
|
||||
return Task.FromResult(ToolResult.Fail($"폴더 없음: {fullPath}"));
|
||||
|
||||
try
|
||||
{
|
||||
var since = ParseSince(sinceStr);
|
||||
var searchOpt = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
|
||||
var files = Directory.GetFiles(fullPath, pattern, searchOpt)
|
||||
.Select(f => new FileInfo(f))
|
||||
.Where(fi => fi.LastWriteTime >= since || fi.CreationTime >= since)
|
||||
.OrderByDescending(fi => fi.LastWriteTime)
|
||||
.Take(topN)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
return Task.FromResult(ToolResult.Ok(
|
||||
$"📂 {sinceStr} 이내 변경된 파일이 없습니다. (경로: {path}, 패턴: {pattern})"));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"📂 파일 변경 감지: {files.Count}개 파일 ({sinceStr} 이내)");
|
||||
sb.AppendLine($" 경로: {path} | 패턴: {pattern}");
|
||||
sb.AppendLine();
|
||||
|
||||
// 생성/수정 분류
|
||||
var created = files.Where(f => f.CreationTime >= since && f.CreationTime == f.LastWriteTime).ToList();
|
||||
var modified = files.Where(f => f.LastWriteTime >= since && f.CreationTime < since).ToList();
|
||||
var recentlyCreated = files.Where(f => f.CreationTime >= since && f.CreationTime != f.LastWriteTime).ToList();
|
||||
|
||||
if (created.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"🆕 신규 생성 ({created.Count}개):");
|
||||
foreach (var f in created)
|
||||
AppendFileInfo(sb, f, fullPath, includeSize);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (modified.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"✏️ 수정됨 ({modified.Count}개):");
|
||||
foreach (var f in modified)
|
||||
AppendFileInfo(sb, f, fullPath, includeSize);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (recentlyCreated.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"📝 생성 후 수정됨 ({recentlyCreated.Count}개):");
|
||||
foreach (var f in recentlyCreated)
|
||||
AppendFileInfo(sb, f, fullPath, includeSize);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// 요약 통계
|
||||
var totalSize = files.Sum(f => f.Length);
|
||||
sb.AppendLine($"── 요약: 총 {files.Count}개 파일, {FormatSize(totalSize)}");
|
||||
|
||||
// 파일 유형별 분포
|
||||
var byExt = files.GroupBy(f => f.Extension.ToLowerInvariant())
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10);
|
||||
sb.Append(" 유형: ");
|
||||
sb.AppendLine(string.Join(", ", byExt.Select(g => $"{g.Key}({g.Count()})")));
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"파일 감시 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime ParseSince(string since)
|
||||
{
|
||||
if (DateTime.TryParse(since, out var dt))
|
||||
return dt;
|
||||
|
||||
// 상대 시간: "1h", "24h", "7d", "30d"
|
||||
var match = System.Text.RegularExpressions.Regex.Match(since, @"^(\d+)(h|d|m)$");
|
||||
if (match.Success)
|
||||
{
|
||||
var amount = int.Parse(match.Groups[1].Value);
|
||||
var unit = match.Groups[2].Value;
|
||||
return unit switch
|
||||
{
|
||||
"h" => DateTime.Now.AddHours(-amount),
|
||||
"d" => DateTime.Now.AddDays(-amount),
|
||||
"m" => DateTime.Now.AddMinutes(-amount),
|
||||
_ => DateTime.Now.AddHours(-24)
|
||||
};
|
||||
}
|
||||
|
||||
return DateTime.Now.AddHours(-24); // default: 24시간
|
||||
}
|
||||
|
||||
private static void AppendFileInfo(StringBuilder sb, FileInfo f, string basePath, bool includeSize)
|
||||
{
|
||||
var relPath = Path.GetRelativePath(basePath, f.FullName);
|
||||
var timeStr = f.LastWriteTime.ToString("MM-dd HH:mm");
|
||||
if (includeSize)
|
||||
sb.AppendLine($" {relPath} ({FormatSize(f.Length)}, {timeStr})");
|
||||
else
|
||||
sb.AppendLine($" {relPath} ({timeStr})");
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return $"{bytes}B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1}KB";
|
||||
if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1}MB";
|
||||
return $"{bytes / (1024.0 * 1024 * 1024):F2}GB";
|
||||
}
|
||||
}
|
||||
46
src/AxCopilot/Services/Agent/FileWriteTool.cs
Normal file
46
src/AxCopilot/Services/Agent/FileWriteTool.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일 전체를 새로 쓰는 도구. 새 파일 생성 또는 기존 파일 덮어쓰기.</summary>
|
||||
public class FileWriteTool : IAgentTool
|
||||
{
|
||||
public string Name => "file_write";
|
||||
public string Description => "Write content to a file. Creates new file or overwrites existing. Parent directories are created automatically.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "File path to write (absolute or relative to work folder)" },
|
||||
["content"] = new() { Type = "string", Description = "Content to write to the file" },
|
||||
},
|
||||
Required = ["path", "content"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var content = args.GetProperty("content").GetString() ?? "";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
await TextFileCodec.WriteAllTextAsync(fullPath, content, TextFileCodec.Utf8NoBom, ct);
|
||||
var lines = content.Split('\n').Length;
|
||||
return ToolResult.Ok($"파일 저장 완료: {fullPath} ({lines} lines, {content.Length} chars)", fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"파일 쓰기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/AxCopilot/Services/Agent/FolderMapTool.cs
Normal file
171
src/AxCopilot/Services/Agent/FolderMapTool.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 작업 폴더의 디렉토리 트리 구조를 생성하는 도구.
|
||||
/// LLM이 프로젝트 전체 구조를 파악하고 적절한 파일을 찾을 수 있도록 돕습니다.
|
||||
/// </summary>
|
||||
public class FolderMapTool : IAgentTool
|
||||
{
|
||||
public string Name => "folder_map";
|
||||
public string Description =>
|
||||
"Generate a directory tree map of the work folder or a specified subfolder. " +
|
||||
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
|
||||
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 3." },
|
||||
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
|
||||
["pattern"] = new() { Type = "string", Description = "File extension filter (e.g. '.cs', '.py'). Optional, shows all files if omitted." },
|
||||
},
|
||||
Required = []
|
||||
};
|
||||
|
||||
// 무시할 디렉토리 (빌드 산출물, 패키지 캐시 등)
|
||||
private static readonly HashSet<string> IgnoredDirs = 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 const int MaxEntries = 500;
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var depth = 3;
|
||||
if (args.TryGetProperty("depth", out var d))
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
|
||||
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
|
||||
}
|
||||
var depthStr = depth.ToString();
|
||||
var includeFiles = true;
|
||||
if (args.TryGetProperty("include_files", out var inc))
|
||||
{
|
||||
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
|
||||
includeFiles = inc.GetBoolean();
|
||||
else
|
||||
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
var extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
|
||||
|
||||
if (!int.TryParse(depthStr, out var maxDepth) || maxDepth < 1)
|
||||
maxDepth = 3;
|
||||
maxDepth = Math.Min(maxDepth, 10);
|
||||
|
||||
var baseDir = string.IsNullOrEmpty(subPath)
|
||||
? context.WorkFolder
|
||||
: FileReadTool.ResolvePath(subPath, context.WorkFolder);
|
||||
|
||||
if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir))
|
||||
return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}"));
|
||||
|
||||
if (!context.IsPathAllowed(baseDir))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}"));
|
||||
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var dirName = Path.GetFileName(baseDir);
|
||||
if (string.IsNullOrEmpty(dirName)) dirName = baseDir;
|
||||
sb.AppendLine($"{dirName}/");
|
||||
|
||||
int entryCount = 0;
|
||||
BuildTree(sb, baseDir, "", 0, maxDepth, includeFiles, extFilter, context, ref entryCount);
|
||||
|
||||
if (entryCount >= MaxEntries)
|
||||
sb.AppendLine($"\n... ({MaxEntries}개 항목 제한 도달, depth 또는 pattern을 조정하세요)");
|
||||
|
||||
var summary = $"폴더 맵 생성 완료 ({entryCount}개 항목, 깊이 {maxDepth})";
|
||||
return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"폴더 맵 생성 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildTree(
|
||||
StringBuilder sb, string dir, string prefix, int currentDepth, int maxDepth,
|
||||
bool includeFiles, string extFilter, AgentContext context, ref int entryCount)
|
||||
{
|
||||
if (currentDepth >= maxDepth || entryCount >= MaxEntries) return;
|
||||
|
||||
// 하위 디렉토리
|
||||
List<DirectoryInfo> subDirs;
|
||||
try
|
||||
{
|
||||
subDirs = new DirectoryInfo(dir).GetDirectories()
|
||||
.Where(d => !d.Attributes.HasFlag(FileAttributes.Hidden)
|
||||
&& !IgnoredDirs.Contains(d.Name))
|
||||
.OrderBy(d => d.Name)
|
||||
.ToList();
|
||||
}
|
||||
catch { return; } // 접근 불가 디렉토리 무시
|
||||
|
||||
// 하위 파일
|
||||
List<FileInfo> files = [];
|
||||
if (includeFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
files = new DirectoryInfo(dir).GetFiles()
|
||||
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden)
|
||||
&& (string.IsNullOrEmpty(extFilter)
|
||||
|| f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(f => f.Name)
|
||||
.ToList();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var totalItems = subDirs.Count + files.Count;
|
||||
var index = 0;
|
||||
|
||||
// 디렉토리 출력
|
||||
foreach (var sub in subDirs)
|
||||
{
|
||||
if (entryCount >= MaxEntries) break;
|
||||
index++;
|
||||
var isLast = index == totalItems;
|
||||
var connector = isLast ? "└── " : "├── ";
|
||||
var childPrefix = isLast ? " " : "│ ";
|
||||
|
||||
sb.AppendLine($"{prefix}{connector}{sub.Name}/");
|
||||
entryCount++;
|
||||
|
||||
if (context.IsPathAllowed(sub.FullName))
|
||||
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, maxDepth,
|
||||
includeFiles, extFilter, context, ref entryCount);
|
||||
}
|
||||
|
||||
// 파일 출력
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (entryCount >= MaxEntries) break;
|
||||
index++;
|
||||
var isLast = index == totalItems;
|
||||
var connector = isLast ? "└── " : "├── ";
|
||||
|
||||
var sizeStr = FormatSize(file.Length);
|
||||
sb.AppendLine($"{prefix}{connector}{file.Name} ({sizeStr})");
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||||
};
|
||||
}
|
||||
191
src/AxCopilot/Services/Agent/FormatConvertTool.cs
Normal file
191
src/AxCopilot/Services/Agent/FormatConvertTool.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 포맷 변환 도구.
|
||||
/// Markdown → HTML, HTML → 텍스트, CSV → HTML 테이블 등 경량 변환을 수행합니다.
|
||||
/// 복잡한 변환(DOCX↔HTML)은 원본 파일을 읽고 적절한 생성 스킬(docx_create, html_create)로
|
||||
/// 재생성하도록 LLM에 안내합니다.
|
||||
/// </summary>
|
||||
public class FormatConvertTool : IAgentTool
|
||||
{
|
||||
public string Name => "format_convert";
|
||||
public string Description =>
|
||||
"Convert a document between formats. Supports: " +
|
||||
"md→html (Markdown to styled HTML with mood CSS), " +
|
||||
"html→text (strip HTML tags to plain text), " +
|
||||
"csv→html (CSV to HTML table). " +
|
||||
"For complex conversions (docx↔html, xlsx↔csv), read the source with document_read/file_read, " +
|
||||
"then use the appropriate creation skill (html_create, docx_create, etc.).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["source"] = new() { Type = "string", Description = "Source file path to convert" },
|
||||
["target"] = new() { Type = "string", Description = "Target output file path (extension determines format)" },
|
||||
["mood"] = new() { Type = "string", Description = "Design mood for HTML output (default: modern). Only used for md→html conversion." },
|
||||
},
|
||||
Required = ["source", "target"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var source = args.GetProperty("source").GetString() ?? "";
|
||||
var target = args.GetProperty("target").GetString() ?? "";
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
|
||||
|
||||
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
|
||||
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") tgtPath = AgentContext.EnsureTimestampedPath(tgtPath);
|
||||
|
||||
if (!context.IsPathAllowed(srcPath))
|
||||
return ToolResult.Fail($"소스 경로 접근 차단: {srcPath}");
|
||||
if (!context.IsPathAllowed(tgtPath))
|
||||
return ToolResult.Fail($"대상 경로 접근 차단: {tgtPath}");
|
||||
if (!File.Exists(srcPath))
|
||||
return ToolResult.Fail($"소스 파일 없음: {srcPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync("format_convert", tgtPath))
|
||||
return ToolResult.Fail("쓰기 권한이 거부되었습니다.");
|
||||
|
||||
var srcExt = Path.GetExtension(srcPath).ToLowerInvariant();
|
||||
var tgtExt = Path.GetExtension(tgtPath).ToLowerInvariant();
|
||||
var convKey = $"{srcExt}→{tgtExt}";
|
||||
|
||||
try
|
||||
{
|
||||
var srcContent = (await TextFileCodec.ReadAllTextAsync(srcPath, ct)).Text;
|
||||
|
||||
string result;
|
||||
switch (convKey)
|
||||
{
|
||||
case ".md→.html":
|
||||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||
var bodyHtml = Markdown.ToHtml(srcContent, pipeline);
|
||||
var css = TemplateService.GetCss(mood);
|
||||
result = $"<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"UTF-8\"/>\n" +
|
||||
$"<style>{css}</style>\n</head>\n<body>\n<div class=\"container\">\n{bodyHtml}\n</div>\n</body>\n</html>";
|
||||
break;
|
||||
|
||||
case ".html→.txt" or ".htm→.txt":
|
||||
result = StripHtmlTags(srcContent);
|
||||
break;
|
||||
|
||||
case ".csv→.html":
|
||||
result = CsvToHtmlTable(srcContent, mood);
|
||||
break;
|
||||
|
||||
case ".md→.txt":
|
||||
result = StripMarkdown(srcContent);
|
||||
break;
|
||||
|
||||
default:
|
||||
return ToolResult.Fail(
|
||||
$"직접 변환 미지원: {convKey}\n" +
|
||||
"대안: source를 file_read/document_read로 읽은 뒤, " +
|
||||
"적절한 생성 스킬(html_create, docx_create, excel_create 등)을 사용하세요.");
|
||||
}
|
||||
|
||||
await TextFileCodec.WriteAllTextAsync(tgtPath, result, TextFileCodec.Utf8NoBom, ct);
|
||||
|
||||
var srcName = Path.GetFileName(srcPath);
|
||||
var tgtName = Path.GetFileName(tgtPath);
|
||||
return ToolResult.Ok(
|
||||
$"변환 완료: {srcName} → {tgtName}\n" +
|
||||
$"변환 유형: {convKey}\n" +
|
||||
$"출력 크기: {result.Length:N0}자",
|
||||
tgtPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"변환 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
// script/style 제거
|
||||
var cleaned = Regex.Replace(html, @"<(script|style)[^>]*>.*?</\1>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
// 줄바꿈 태그 처리
|
||||
cleaned = Regex.Replace(cleaned, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
cleaned = Regex.Replace(cleaned, @"</(p|div|h[1-6]|li|tr)>", "\n", RegexOptions.IgnoreCase);
|
||||
// 태그 제거
|
||||
cleaned = Regex.Replace(cleaned, @"<[^>]+>", "");
|
||||
// HTML 엔티티 디코딩
|
||||
cleaned = System.Net.WebUtility.HtmlDecode(cleaned);
|
||||
// 연속 빈줄 정리
|
||||
cleaned = Regex.Replace(cleaned, @"\n{3,}", "\n\n");
|
||||
return cleaned.Trim();
|
||||
}
|
||||
|
||||
private static string StripMarkdown(string md)
|
||||
{
|
||||
var result = md;
|
||||
result = Regex.Replace(result, @"^#{1,6}\s+", "", RegexOptions.Multiline); // 헤딩
|
||||
result = Regex.Replace(result, @"\*\*(.+?)\*\*", "$1"); // 볼드
|
||||
result = Regex.Replace(result, @"\*(.+?)\*", "$1"); // 이탤릭
|
||||
result = Regex.Replace(result, @"`(.+?)`", "$1"); // 인라인 코드
|
||||
result = Regex.Replace(result, @"^\s*[-*+]\s+", "", RegexOptions.Multiline); // 리스트
|
||||
result = Regex.Replace(result, @"^\s*\d+\.\s+", "", RegexOptions.Multiline); // 번호 리스트
|
||||
result = Regex.Replace(result, @"\[(.+?)\]\(.+?\)", "$1"); // 링크
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
private static string CsvToHtmlTable(string csv, string mood)
|
||||
{
|
||||
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length == 0) return "<p>빈 CSV 파일</p>";
|
||||
|
||||
var cssStr = TemplateService.GetCss(mood);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"UTF-8\"/>");
|
||||
sb.AppendLine($"<style>{cssStr}</style>\n</head>\n<body>\n<div class=\"container\">");
|
||||
sb.AppendLine("<table><thead><tr>");
|
||||
|
||||
var headers = ParseCsvLine(lines[0]);
|
||||
foreach (var h in headers) sb.Append($"<th>{System.Net.WebUtility.HtmlEncode(h)}</th>");
|
||||
sb.AppendLine("</tr></thead><tbody>");
|
||||
|
||||
for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
|
||||
{
|
||||
var vals = ParseCsvLine(lines[i]);
|
||||
sb.Append("<tr>");
|
||||
foreach (var v in vals) sb.Append($"<td>{System.Net.WebUtility.HtmlEncode(v)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</tbody></table>\n</div>\n</body>\n</html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string[] ParseCsvLine(string line)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
bool inQuotes = false;
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; }
|
||||
else if (c == '"') inQuotes = false;
|
||||
else current.Append(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '"') inQuotes = true;
|
||||
else if (c == ',') { fields.Add(current.ToString()); current.Clear(); }
|
||||
else current.Append(c);
|
||||
}
|
||||
}
|
||||
fields.Add(current.ToString());
|
||||
return fields.ToArray();
|
||||
}
|
||||
}
|
||||
203
src/AxCopilot/Services/Agent/GitTool.cs
Normal file
203
src/AxCopilot/Services/Agent/GitTool.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Git 버전 관리 도구.
|
||||
/// 사내 GitHub Enterprise 환경을 고려하여 안전한 Git 작업을 지원합니다.
|
||||
/// push/force push는 차단되며, 사용자가 직접 수행해야 합니다.
|
||||
/// </summary>
|
||||
public class GitTool : IAgentTool
|
||||
{
|
||||
public string Name => "git_tool";
|
||||
public string Description =>
|
||||
"Execute safe Git operations. Supports: status, diff, log, add, commit, branch, checkout. " +
|
||||
"Push operations are blocked for safety — user must push manually. " +
|
||||
"Works with enterprise GitHub (on-premise) repositories.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Git action: status, diff, log, add, commit, branch, checkout, stash, remote",
|
||||
Enum = ["status", "diff", "log", "add", "commit", "branch", "checkout", "stash", "remote"],
|
||||
},
|
||||
["args"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Additional arguments. For commit: commit message. For add: file path(s). For log: '--oneline -10'. For diff: file path or '--staged'.",
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
// 차단 명령 패턴 — 원격 수정 및 위험 작업
|
||||
private static readonly string[] BlockedPatterns =
|
||||
[
|
||||
"push", "push --force", "push -f",
|
||||
"pull", "fetch",
|
||||
"reset --hard", "clean -f",
|
||||
"rebase", "merge",
|
||||
"remote add", "remote remove", "remote set-url",
|
||||
"branch -D", "branch -d",
|
||||
"tag -d", "tag -D",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
return ToolResult.Fail("action이 필요합니다.");
|
||||
var action = actionEl.GetString() ?? "status";
|
||||
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
|
||||
|
||||
var workDir = context.WorkFolder;
|
||||
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||
|
||||
// Git 설치 확인
|
||||
var gitPath = FindGit();
|
||||
if (gitPath == null)
|
||||
return ToolResult.Fail("Git이 설치되어 있지 않습니다. PATH에 git이 있는지 확인하세요.");
|
||||
|
||||
// Git 저장소 확인
|
||||
if (!Directory.Exists(Path.Combine(workDir, ".git")))
|
||||
{
|
||||
// 상위 디렉토리에서 .git 확인 (서브디렉토리 작업 지원)
|
||||
var checkDir = workDir;
|
||||
bool found = false;
|
||||
while (!string.IsNullOrEmpty(checkDir))
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(checkDir, ".git"))) { found = true; break; }
|
||||
var parent = Directory.GetParent(checkDir)?.FullName;
|
||||
if (parent == checkDir) break;
|
||||
checkDir = parent;
|
||||
}
|
||||
if (!found)
|
||||
return ToolResult.Fail("현재 작업 폴더는 Git 저장소가 아닙니다.");
|
||||
}
|
||||
|
||||
// 명령 구성
|
||||
var gitCommand = action switch
|
||||
{
|
||||
"status" => "status --short --branch",
|
||||
"diff" => string.IsNullOrEmpty(extraArgs) ? "diff" : $"diff {extraArgs}",
|
||||
"log" => string.IsNullOrEmpty(extraArgs) ? "log --oneline -15" : $"log {extraArgs}",
|
||||
"add" => string.IsNullOrEmpty(extraArgs) ? "add -A" : $"add {extraArgs}",
|
||||
"commit" => string.IsNullOrEmpty(extraArgs)
|
||||
? null // 커밋 메시지 필수
|
||||
: $"commit -m \"{extraArgs.Replace("\"", "\\\"")}\"",
|
||||
"branch" => string.IsNullOrEmpty(extraArgs) ? "branch -a" : $"branch {extraArgs}",
|
||||
"checkout" => string.IsNullOrEmpty(extraArgs) ? null : $"checkout {extraArgs}",
|
||||
"stash" => string.IsNullOrEmpty(extraArgs) ? "stash list" : $"stash {extraArgs}",
|
||||
"remote" => "remote -v",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (gitCommand == null)
|
||||
{
|
||||
if (action == "commit")
|
||||
return ToolResult.Fail("커밋 메시지가 필요합니다. args에 커밋 메시지를 지정하세요.");
|
||||
if (action == "checkout")
|
||||
return ToolResult.Fail("체크아웃할 브랜치/파일을 args에 지정하세요.");
|
||||
return ToolResult.Fail($"알 수 없는 액션: {action}");
|
||||
}
|
||||
|
||||
// 위험 명령 차단
|
||||
var fullCmd = $"git {gitCommand}";
|
||||
foreach (var pattern in BlockedPatterns)
|
||||
{
|
||||
if (fullCmd.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail(
|
||||
$"안전을 위해 '{pattern}' 작업은 차단됩니다.\n" +
|
||||
"원격 저장소 작업(push/pull/fetch)과 이력 변경 작업은 사용자가 직접 수행하세요.");
|
||||
}
|
||||
|
||||
// 쓰기 작업은 권한 확인
|
||||
var writeActions = new HashSet<string> { "add", "commit", "checkout", "stash" };
|
||||
if (writeActions.Contains(action))
|
||||
{
|
||||
if (!await context.CheckWritePermissionAsync(Name, workDir))
|
||||
return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다.");
|
||||
}
|
||||
|
||||
// Git 커밋 — 현재 비활성 (향후 활성화 예정)
|
||||
// 의사결정 수준에서 무조건 확인을 받더라도, 커밋 자체를 차단합니다.
|
||||
if (action == "commit")
|
||||
{
|
||||
return ToolResult.Fail(
|
||||
"Git 커밋 기능은 현재 비활성 상태입니다.\n" +
|
||||
"안전을 위해 커밋은 사용자가 직접 수행하세요.\n" +
|
||||
"향후 버전에서 활성화될 예정입니다.");
|
||||
}
|
||||
|
||||
// 명령 실행
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(gitPath, gitCommand)
|
||||
{
|
||||
WorkingDirectory = workDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return ToolResult.Fail("Git 프로세스 시작 실패");
|
||||
|
||||
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
|
||||
// 출력 제한
|
||||
if (stdout.Length > 8000) stdout = stdout[..8000] + "\n... (출력 잘림)";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[git {action}] Exit code: {proc.ExitCode}");
|
||||
if (!string.IsNullOrWhiteSpace(stdout)) sb.Append(stdout);
|
||||
if (!string.IsNullOrWhiteSpace(stderr) && proc.ExitCode != 0) sb.AppendLine($"\n[stderr] {stderr.Trim()}");
|
||||
|
||||
return proc.ExitCode == 0
|
||||
? ToolResult.Ok(sb.ToString())
|
||||
: ToolResult.Fail(sb.ToString());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ToolResult.Fail("Git 명령 타임아웃 (30초)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"Git 실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindGit()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("where.exe", "git")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return null;
|
||||
var output = proc.StandardOutput.ReadToEnd().Trim();
|
||||
proc.WaitForExit(5000);
|
||||
return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
69
src/AxCopilot/Services/Agent/GlobTool.cs
Normal file
69
src/AxCopilot/Services/Agent/GlobTool.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일 패턴 검색 도구. glob 패턴으로 파일 목록을 반환합니다.</summary>
|
||||
public class GlobTool : IAgentTool
|
||||
{
|
||||
public string Name => "glob";
|
||||
public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json'). Returns matching file paths.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["pattern"] = new() { Type = "string", Description = "Glob pattern to match files (e.g. '**/*.cs', '*.txt')" },
|
||||
["path"] = new() { Type = "string", Description = "Directory to search in. Optional, defaults to work folder." },
|
||||
},
|
||||
Required = ["pattern"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
|
||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||
? context.WorkFolder
|
||||
: FileReadTool.ResolvePath(searchPath, context.WorkFolder);
|
||||
|
||||
if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir))
|
||||
return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}"));
|
||||
|
||||
if (!context.IsPathAllowed(baseDir))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}"));
|
||||
|
||||
try
|
||||
{
|
||||
// glob 패턴을 Directory.EnumerateFiles용으로 변환
|
||||
var searchPattern = ExtractSearchPattern(pattern);
|
||||
var recursive = pattern.Contains("**") || pattern.Contains('/') || pattern.Contains('\\');
|
||||
var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
|
||||
var files = Directory.EnumerateFiles(baseDir, searchPattern, option)
|
||||
.Where(f => context.IsPathAllowed(f))
|
||||
.OrderBy(f => f)
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다."));
|
||||
|
||||
var result = string.Join("\n", files.Select(f => Path.GetRelativePath(baseDir, f)));
|
||||
return Task.FromResult(ToolResult.Ok($"{files.Count}개 파일 발견:\n{result}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractSearchPattern(string globPattern)
|
||||
{
|
||||
// **/*.cs → *.cs, src/**/*.json → *.json
|
||||
var parts = globPattern.Replace('/', '\\').Split('\\');
|
||||
var last = parts[^1];
|
||||
return string.IsNullOrEmpty(last) || last == "**" ? "*" : last;
|
||||
}
|
||||
}
|
||||
134
src/AxCopilot/Services/Agent/GrepTool.cs
Normal file
134
src/AxCopilot/Services/Agent/GrepTool.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일 내용 텍스트 검색 도구. 정규식을 지원합니다.</summary>
|
||||
public class GrepTool : IAgentTool
|
||||
{
|
||||
public string Name => "grep";
|
||||
public string Description => "Search file contents for a pattern (regex supported). Returns matching lines with file paths and line numbers.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["pattern"] = new() { Type = "string", Description = "Search pattern (regex supported)" },
|
||||
["path"] = new() { Type = "string", Description = "File or directory to search in. Optional, defaults to work folder." },
|
||||
["glob"] = new() { Type = "string", Description = "File pattern filter (e.g. '*.cs', '*.json'). Optional." },
|
||||
["context_lines"] = new() { Type = "integer", Description = "Number of context lines before/after each match (0-5). Default 0." },
|
||||
["case_sensitive"] = new() { Type = "boolean", Description = "Case-sensitive search. Default false (case-insensitive)." },
|
||||
},
|
||||
Required = ["pattern"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
|
||||
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 5) : 0;
|
||||
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
|
||||
|
||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||
? context.WorkFolder
|
||||
: FileReadTool.ResolvePath(searchPath, context.WorkFolder);
|
||||
|
||||
if (string.IsNullOrEmpty(baseDir))
|
||||
return Task.FromResult(ToolResult.Fail("작업 폴더가 설정되지 않았습니다."));
|
||||
|
||||
try
|
||||
{
|
||||
var regexOpts = RegexOptions.Compiled | (caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase);
|
||||
var regex = new Regex(pattern, regexOpts, TimeSpan.FromSeconds(5));
|
||||
|
||||
var filePattern = string.IsNullOrEmpty(globFilter) ? "*" : globFilter;
|
||||
|
||||
IEnumerable<string> files;
|
||||
if (File.Exists(baseDir))
|
||||
files = [baseDir];
|
||||
else if (Directory.Exists(baseDir))
|
||||
files = Directory.EnumerateFiles(baseDir, filePattern, SearchOption.AllDirectories);
|
||||
else
|
||||
return Task.FromResult(ToolResult.Fail($"경로가 존재하지 않습니다: {baseDir}"));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
int matchCount = 0;
|
||||
int fileCount = 0;
|
||||
const int maxMatches = 100;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
if (!context.IsPathAllowed(file)) continue;
|
||||
if (IsBinaryFile(file)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var read = TextFileCodec.ReadAllText(file);
|
||||
var lines = TextFileCodec.SplitLines(read.Text);
|
||||
bool fileHit = false;
|
||||
for (int i = 0; i < lines.Length && matchCount < maxMatches; i++)
|
||||
{
|
||||
if (regex.IsMatch(lines[i]))
|
||||
{
|
||||
if (!fileHit)
|
||||
{
|
||||
var rel = Directory.Exists(context.WorkFolder)
|
||||
? Path.GetRelativePath(context.WorkFolder, file)
|
||||
: file;
|
||||
sb.AppendLine($"\n{rel}:");
|
||||
fileHit = true;
|
||||
fileCount++;
|
||||
}
|
||||
// 컨텍스트 라인 (before)
|
||||
if (contextLines > 0)
|
||||
{
|
||||
for (int c = Math.Max(0, i - contextLines); c < i; c++)
|
||||
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
|
||||
}
|
||||
sb.AppendLine($" {i + 1}: {lines[i].TrimEnd()}");
|
||||
// 컨텍스트 라인 (after)
|
||||
if (contextLines > 0)
|
||||
{
|
||||
for (int c = i + 1; c <= Math.Min(lines.Length - 1, i + contextLines); c++)
|
||||
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
|
||||
sb.AppendLine(" ---");
|
||||
}
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* 읽기 실패 파일 무시 */ }
|
||||
|
||||
if (matchCount >= maxMatches) break;
|
||||
}
|
||||
|
||||
if (matchCount == 0)
|
||||
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 결과가 없습니다."));
|
||||
|
||||
var header = $"{fileCount}개 파일에서 {matchCount}개 일치{(matchCount >= maxMatches ? " (제한 도달)" : "")}:";
|
||||
return Task.FromResult(ToolResult.Ok(header + sb));
|
||||
}
|
||||
catch (RegexParseException)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"잘못된 정규식 패턴: {pattern}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBinaryFile(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz"
|
||||
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp"
|
||||
or ".pdf" or ".docx" or ".xlsx" or ".pptx"
|
||||
or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv"
|
||||
or ".psd" or ".msi" or ".iso" or ".bin" or ".dat" or ".db";
|
||||
}
|
||||
}
|
||||
85
src/AxCopilot/Services/Agent/HashTool.cs
Normal file
85
src/AxCopilot/Services/Agent/HashTool.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일·텍스트 MD5/SHA256 해시 계산 도구.</summary>
|
||||
public class HashTool : IAgentTool
|
||||
{
|
||||
public string Name => "hash_tool";
|
||||
public string Description =>
|
||||
"Compute hash digests for text or files. " +
|
||||
"Supports MD5, SHA1, SHA256, SHA512. " +
|
||||
"Use 'text' mode for inline text or 'file' mode for file path.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["mode"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Input mode",
|
||||
Enum = ["text", "file"],
|
||||
},
|
||||
["input"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Text to hash or file path",
|
||||
},
|
||||
["algorithm"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Hash algorithm (default: sha256)",
|
||||
Enum = ["md5", "sha1", "sha256", "sha512"],
|
||||
},
|
||||
},
|
||||
Required = ["mode", "input"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var mode = args.GetProperty("mode").GetString() ?? "text";
|
||||
var input = args.GetProperty("input").GetString() ?? "";
|
||||
var algo = args.TryGetProperty("algorithm", out var a) ? a.GetString() ?? "sha256" : "sha256";
|
||||
|
||||
try
|
||||
{
|
||||
byte[] data;
|
||||
string label;
|
||||
|
||||
if (mode == "file")
|
||||
{
|
||||
var path = Path.IsPathRooted(input) ? input : Path.Combine(context.WorkFolder, input);
|
||||
if (!File.Exists(path))
|
||||
return Task.FromResult(ToolResult.Fail($"File not found: {path}"));
|
||||
data = File.ReadAllBytes(path);
|
||||
label = Path.GetFileName(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
data = Encoding.UTF8.GetBytes(input);
|
||||
label = $"text ({data.Length} bytes)";
|
||||
}
|
||||
|
||||
using var hasher = algo switch
|
||||
{
|
||||
"md5" => (HashAlgorithm)MD5.Create(),
|
||||
"sha1" => SHA1.Create(),
|
||||
"sha256" => SHA256.Create(),
|
||||
"sha512" => SHA512.Create(),
|
||||
_ => SHA256.Create(),
|
||||
};
|
||||
|
||||
var hash = hasher.ComputeHash(data);
|
||||
var hex = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
return Task.FromResult(ToolResult.Ok($"{algo.ToUpperInvariant()}({label}):\n{hex}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"해시 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
249
src/AxCopilot/Services/Agent/HtmlSkill.cs
Normal file
249
src/AxCopilot/Services/Agent/HtmlSkill.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// HTML (.html) 보고서를 생성하는 내장 스킬.
|
||||
/// 테마 무드(mood)를 선택하면 TemplateService에서 해당 CSS를 가져와 적용합니다.
|
||||
/// TOC, 커버 페이지, 섹션 번호 등 고급 문서 기능을 지원합니다.
|
||||
/// </summary>
|
||||
public class HtmlSkill : IAgentTool
|
||||
{
|
||||
public string Name => "html_create";
|
||||
public string Description => "Create a styled HTML (.html) document with rich formatting. " +
|
||||
"Supports: table of contents (toc), cover page, callouts (.callout-info/warning/tip/danger), " +
|
||||
"badges (.badge-blue/green/red/yellow/purple), CSS bar charts (.chart-bar), " +
|
||||
"progress bars (.progress), timelines (.timeline), grid layouts (.grid-2/3/4), " +
|
||||
"and auto section numbering. " +
|
||||
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
|
||||
["title"] = new() { Type = "string", Description = "Document title (shown in browser tab and header)" },
|
||||
["body"] = new() { Type = "string", Description = "HTML body content. Use semantic tags: h2/h3 for sections, " +
|
||||
"div.callout-info/warning/tip/danger for callouts, span.badge-blue/green/red for badges, " +
|
||||
"div.chart-bar>div.bar-item for charts, div.grid-2/3/4 for grid layouts, " +
|
||||
"div.timeline>div.timeline-item for timelines, div.progress for progress bars." },
|
||||
["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard. Default: modern" },
|
||||
["style"] = new() { Type = "string", Description = "Optional additional CSS. Appended after mood+shared CSS." },
|
||||
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" },
|
||||
["numbered"] = new() { Type = "boolean", Description = "Auto-number h2/h3 sections (1., 1-1., etc). Default: false" },
|
||||
["cover"] = new()
|
||||
{
|
||||
Type = "object",
|
||||
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
|
||||
},
|
||||
},
|
||||
Required = ["path", "title", "body"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var title = args.GetProperty("title").GetString() ?? "Report";
|
||||
var body = args.GetProperty("body").GetString() ?? "";
|
||||
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
|
||||
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.GetBoolean();
|
||||
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.GetBoolean();
|
||||
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) &&
|
||||
!fullPath.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".html";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// 스타일 결정: mood CSS + shared CSS + custom
|
||||
var style = TemplateService.GetCss(mood);
|
||||
if (!string.IsNullOrEmpty(customStyle))
|
||||
style += "\n" + customStyle;
|
||||
|
||||
var moodInfo = TemplateService.GetMood(mood);
|
||||
var moodLabel = moodInfo != null ? $" · {moodInfo.Icon} {moodInfo.Label}" : "";
|
||||
|
||||
// 섹션 번호 자동 부여 — h2, h3에 class="numbered" 추가
|
||||
if (useNumbered)
|
||||
body = AddNumberedClass(body);
|
||||
|
||||
// h2/h3에서 id 속성 자동 부여 (TOC 앵커용)
|
||||
body = EnsureHeadingIds(body);
|
||||
|
||||
// TOC 생성
|
||||
var tocHtml = useToc ? GenerateToc(body) : "";
|
||||
|
||||
// 커버 페이지 생성
|
||||
var coverHtml = hasCover ? GenerateCover(coverVal, title) : "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"ko\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||
sb.AppendLine($"<title>{Escape(title)}</title>");
|
||||
sb.AppendLine($"<style>{style}</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<div class=\"container\">");
|
||||
|
||||
// 커버 페이지
|
||||
if (!string.IsNullOrEmpty(coverHtml))
|
||||
{
|
||||
sb.AppendLine(coverHtml);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 커버가 없으면 기존 방식의 제목+메타
|
||||
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
sb.AppendLine($"<div class=\"meta\">생성: {DateTime.Now:yyyy-MM-dd HH:mm} | AX Copilot{moodLabel}</div>");
|
||||
}
|
||||
|
||||
// TOC
|
||||
if (!string.IsNullOrEmpty(tocHtml))
|
||||
sb.AppendLine(tocHtml);
|
||||
|
||||
// 본문
|
||||
sb.AppendLine(body);
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
|
||||
|
||||
var features = new List<string>();
|
||||
if (useToc) features.Add("목차");
|
||||
if (useNumbered) features.Add("섹션번호");
|
||||
if (hasCover) features.Add("커버페이지");
|
||||
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"HTML 문서 생성 완료: {fullPath} (디자인: {mood}{featureStr})",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"HTML 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>h2, h3 태그에 id 속성이 없으면 자동 부여</summary>
|
||||
private static string EnsureHeadingIds(string html)
|
||||
{
|
||||
int counter = 0;
|
||||
return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
|
||||
{
|
||||
var tag = match.Groups[1].Value;
|
||||
var attrs = match.Groups[2].Value;
|
||||
counter++;
|
||||
|
||||
// 이미 id가 있으면 그대로
|
||||
if (attrs.Contains("id=", StringComparison.OrdinalIgnoreCase))
|
||||
return match.Value;
|
||||
|
||||
return $"<{tag}{attrs} id=\"section-{counter}\">";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>h2, h3에 class="numbered" 추가</summary>
|
||||
private static string AddNumberedClass(string html)
|
||||
{
|
||||
return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
|
||||
{
|
||||
var tag = match.Groups[1].Value;
|
||||
var attrs = match.Groups[2].Value;
|
||||
|
||||
// 이미 numbered 클래스가 있으면 그대로
|
||||
if (attrs.Contains("numbered", StringComparison.OrdinalIgnoreCase))
|
||||
return match.Value;
|
||||
|
||||
// 기존 class 속성에 추가
|
||||
if (Regex.IsMatch(attrs, @"class\s*=\s*""", RegexOptions.IgnoreCase))
|
||||
return Regex.Replace(match.Value, @"class\s*=\s*""", "class=\"numbered ");
|
||||
|
||||
return $"<{tag}{attrs} class=\"numbered\">";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>body HTML에서 h2/h3을 파싱해 목차 HTML 생성</summary>
|
||||
private static string GenerateToc(string html)
|
||||
{
|
||||
var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)</\1>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
if (headings.Count == 0) return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<nav class=\"toc\">");
|
||||
sb.AppendLine("<h2>📋 목차</h2>");
|
||||
sb.AppendLine("<ul>");
|
||||
|
||||
foreach (Match h in headings)
|
||||
{
|
||||
var level = h.Groups[1].Value.ToLower();
|
||||
var id = h.Groups[2].Value;
|
||||
// 태그 내부 텍스트에서 HTML 태그 제거
|
||||
var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim();
|
||||
var cssClass = level == "h3" ? " class=\"toc-h3\"" : "";
|
||||
sb.AppendLine($"<li{cssClass}><a href=\"#{id}\">{text}</a></li>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</ul>");
|
||||
sb.AppendLine("</nav>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
|
||||
private static string GenerateCover(JsonElement cover, string fallbackTitle)
|
||||
{
|
||||
var coverTitle = cover.TryGetProperty("title", out var ct) ? ct.GetString() ?? fallbackTitle : fallbackTitle;
|
||||
var subtitle = cover.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
|
||||
var author = cover.TryGetProperty("author", out var auth) ? auth.GetString() ?? "" : "";
|
||||
var date = cover.TryGetProperty("date", out var dt) ? dt.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
|
||||
var gradient = cover.TryGetProperty("gradient", out var grad) ? grad.GetString() : null;
|
||||
|
||||
var styleAttr = "";
|
||||
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
|
||||
{
|
||||
var colors = gradient.Split(',');
|
||||
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<div class=\"cover-page\"{styleAttr}>");
|
||||
sb.AppendLine($"<h1>{Escape(coverTitle)}</h1>");
|
||||
if (!string.IsNullOrEmpty(subtitle))
|
||||
sb.AppendLine($"<div class=\"cover-subtitle\">{Escape(subtitle)}</div>");
|
||||
sb.AppendLine("<div class=\"cover-divider\"></div>");
|
||||
|
||||
var metaParts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(author)) metaParts.Add(author);
|
||||
metaParts.Add(date);
|
||||
metaParts.Add("AX Copilot");
|
||||
sb.AppendLine($"<div class=\"cover-meta\">{Escape(string.Join(" · ", metaParts))}</div>");
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string s) =>
|
||||
s.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
147
src/AxCopilot/Services/Agent/HttpTool.cs
Normal file
147
src/AxCopilot/Services/Agent/HttpTool.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 로컬/사내 HTTP API 호출 도구.
|
||||
/// GET/POST/PUT/DELETE 요청, JSON 파싱, 헤더 설정을 지원합니다.
|
||||
/// </summary>
|
||||
public class HttpTool : IAgentTool
|
||||
{
|
||||
public string Name => "http_tool";
|
||||
public string Description =>
|
||||
"Make HTTP requests to local or internal APIs. " +
|
||||
"Supports GET, POST, PUT, DELETE methods with JSON body and custom headers. " +
|
||||
"Only allows localhost and internal network addresses (security restriction). " +
|
||||
"Use this for testing APIs, fetching data from internal services, or webhooks.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["method"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "HTTP method",
|
||||
Enum = ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
},
|
||||
["url"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Request URL (localhost or internal network only)",
|
||||
},
|
||||
["body"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Request body (JSON string, for POST/PUT/PATCH)",
|
||||
},
|
||||
["headers"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Custom headers as JSON object, e.g. {\"Authorization\": \"Bearer token\"}",
|
||||
},
|
||||
["timeout"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Request timeout in seconds (default: 30, max: 120)",
|
||||
},
|
||||
},
|
||||
Required = ["method", "url"],
|
||||
};
|
||||
|
||||
private static readonly HttpClient _client = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
|
||||
var url = args.GetProperty("url").GetString() ?? "";
|
||||
var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
|
||||
var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : "";
|
||||
var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30;
|
||||
|
||||
// 보안: 허용된 호스트만
|
||||
if (!IsAllowedHost(url))
|
||||
return ToolResult.Fail("보안 제한: localhost, 127.0.0.1, 사내 네트워크(10.x, 172.16-31.x, 192.168.x)만 허용됩니다.");
|
||||
|
||||
try
|
||||
{
|
||||
var httpMethod = new HttpMethod(method);
|
||||
using var request = new HttpRequestMessage(httpMethod, url);
|
||||
|
||||
// 헤더 설정
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
using var headerDoc = JsonDocument.Parse(headers);
|
||||
foreach (var prop in headerDoc.RootElement.EnumerateObject())
|
||||
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
|
||||
}
|
||||
|
||||
// 본문 설정
|
||||
if (!string.IsNullOrEmpty(body) && method is "POST" or "PUT" or "PATCH")
|
||||
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
using var response = await _client.SendAsync(request, cts.Token);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cts.Token);
|
||||
|
||||
// 응답 포맷팅
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"HTTP {statusCode} {response.ReasonPhrase}");
|
||||
sb.AppendLine($"Content-Type: {response.Content.Headers.ContentType}");
|
||||
sb.AppendLine();
|
||||
|
||||
// JSON이면 포맷
|
||||
if (response.Content.Headers.ContentType?.MediaType?.Contains("json") == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
responseBody = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch { /* not valid JSON, keep raw */ }
|
||||
}
|
||||
|
||||
if (responseBody.Length > 8000)
|
||||
responseBody = responseBody[..8000] + $"\n... (truncated, total {responseBody.Length} chars)";
|
||||
|
||||
sb.Append(responseBody);
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return ToolResult.Fail($"요청 시간 초과 ({timeout}초)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"HTTP 요청 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedHost(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
var host = uri.Host;
|
||||
|
||||
if (host is "localhost" or "127.0.0.1" or "::1") return true;
|
||||
|
||||
// 사내 네트워크 대역
|
||||
if (System.Net.IPAddress.TryParse(host, out var ip))
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
if (bytes[0] == 10) return true; // 10.0.0.0/8
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
286
src/AxCopilot/Services/Agent/IAgentTool.cs
Normal file
286
src/AxCopilot/Services/Agent/IAgentTool.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 도구의 공통 인터페이스.
|
||||
/// LLM function calling을 통해 호출되며, JSON 파라미터를 받아 결과를 반환합니다.
|
||||
/// </summary>
|
||||
public interface IAgentTool
|
||||
{
|
||||
/// <summary>LLM에 노출되는 도구 이름 (snake_case). 예: "file_read"</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>LLM에 전달되는 도구 설명.</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>LLM function calling용 파라미터 JSON Schema.</summary>
|
||||
ToolParameterSchema Parameters { get; }
|
||||
|
||||
/// <summary>도구를 실행하고 결과를 반환합니다.</summary>
|
||||
/// <param name="args">LLM이 생성한 JSON 파라미터</param>
|
||||
/// <param name="context">실행 컨텍스트 (작업 폴더, 권한 등)</param>
|
||||
/// <param name="ct">취소 토큰</param>
|
||||
Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>도구 실행 결과.</summary>
|
||||
public class ToolResult
|
||||
{
|
||||
/// <summary>성공 여부.</summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>결과 텍스트 (LLM에 피드백).</summary>
|
||||
public string Output { get; init; } = "";
|
||||
|
||||
/// <summary>생성/수정된 파일 경로 (UI 표시용).</summary>
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
/// <summary>오류 메시지 (실패 시).</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static ToolResult Ok(string output, string? filePath = null) =>
|
||||
new() { Success = true, Output = output, FilePath = filePath };
|
||||
|
||||
public static ToolResult Fail(string error) =>
|
||||
new() { Success = false, Output = error, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>도구 파라미터 JSON Schema (LLM function calling용).</summary>
|
||||
public class ToolParameterSchema
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "object";
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public Dictionary<string, ToolProperty> Properties { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public List<string> Required { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>파라미터 속성 정의.</summary>
|
||||
public class ToolProperty
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("enum")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<string>? Enum { get; init; }
|
||||
|
||||
/// <summary>array 타입일 때 항목 스키마. Gemini API 필수.</summary>
|
||||
[JsonPropertyName("items")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ToolProperty? Items { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>에이전트 실행 컨텍스트.</summary>
|
||||
public class AgentContext
|
||||
{
|
||||
private static readonly HashSet<string> SensitiveTools = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"file_write", "file_edit", "file_manage",
|
||||
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
|
||||
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
|
||||
"process", "build_run", "git_tool", "http_tool", "open_external", "snippet_runner",
|
||||
"spawn_agent", "test_loop",
|
||||
};
|
||||
|
||||
private readonly object _permissionLock = new();
|
||||
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>작업 폴더 경로.</summary>
|
||||
public string WorkFolder { get; init; } = "";
|
||||
|
||||
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
|
||||
public string Permission { get; init; } = "Ask";
|
||||
|
||||
/// <summary>도구별 권한 오버라이드. 키: 도구명, 값: "ask" | "auto" | "deny".</summary>
|
||||
public Dictionary<string, string> ToolPermissions { get; init; } = new();
|
||||
|
||||
/// <summary>차단 경로 패턴 목록.</summary>
|
||||
public List<string> BlockedPaths { get; init; } = new();
|
||||
|
||||
/// <summary>차단 확장자 목록.</summary>
|
||||
public List<string> BlockedExtensions { get; init; } = new();
|
||||
|
||||
/// <summary>현재 활성 탭. "Chat" | "Cowork" | "Code".</summary>
|
||||
public string ActiveTab { get; init; } = "Chat";
|
||||
|
||||
/// <summary>운영 모드. internal(사내) | external(사외).</summary>
|
||||
public string OperationMode { get; init; } = AxCopilot.Services.OperationModePolicy.InternalMode;
|
||||
|
||||
/// <summary>개발자 모드: 상세 이력 표시.</summary>
|
||||
public bool DevMode { get; init; }
|
||||
|
||||
/// <summary>개발자 모드: 도구 실행 전 매번 사용자 승인 대기.</summary>
|
||||
public bool DevModeStepApproval { get; init; }
|
||||
|
||||
/// <summary>권한 확인 콜백 (Ask 모드). 반환값: true=승인, false=거부.</summary>
|
||||
public Func<string, string, Task<bool>>? AskPermission { get; init; }
|
||||
|
||||
/// <summary>사용자 의사결정 콜백. (질문, 선택지) → 사용자 응답 문자열.</summary>
|
||||
public Func<string, List<string>, Task<string?>>? UserDecision { get; init; }
|
||||
|
||||
/// <summary>에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 사용자 응답.</summary>
|
||||
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; init; }
|
||||
|
||||
/// <summary>경로가 허용되는지 확인합니다.</summary>
|
||||
public bool IsPathAllowed(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
|
||||
// 차단 확장자 검사
|
||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
if (BlockedExtensions.Any(e => string.Equals(e, ext, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
// 차단 경로 패턴 검사
|
||||
foreach (var pattern in BlockedPaths)
|
||||
{
|
||||
// 간단한 와일드카드 매칭: *\Windows\* → fullPath에 \Windows\ 포함 시 차단
|
||||
var clean = pattern.Replace("*", "");
|
||||
if (!string.IsNullOrEmpty(clean) && fullPath.Contains(clean, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
// 작업 폴더 제한: 작업 폴더가 설정되어 있으면 하위 경로만 허용
|
||||
if (!string.IsNullOrEmpty(WorkFolder))
|
||||
{
|
||||
var workFull = Path.GetFullPath(WorkFolder);
|
||||
if (!fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 파일 경로에 타임스탬프를 추가합니다.
|
||||
/// 예: report.html → report_20260328_1430.html
|
||||
/// 동일 이름 파일이 이미 존재하면 자동으로 타임스탬프를 붙입니다.
|
||||
/// </summary>
|
||||
public static string EnsureTimestampedPath(string fullPath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(fullPath) ?? "";
|
||||
var name = Path.GetFileNameWithoutExtension(fullPath);
|
||||
var ext = Path.GetExtension(fullPath);
|
||||
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmm");
|
||||
|
||||
// 이미 타임스탬프가 포함된 파일명이면 그대로 사용
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(name, @"_\d{8}_\d{4}$"))
|
||||
return fullPath;
|
||||
|
||||
var timestamped = Path.Combine(dir, $"{name}_{stamp}{ext}");
|
||||
return timestamped;
|
||||
}
|
||||
|
||||
/// <summary>파일 쓰기/수정 권한을 확인합니다. 도구별 권한 오버라이드를 우선 적용합니다.</summary>
|
||||
public async Task<bool> CheckWritePermissionAsync(string toolName, string filePath)
|
||||
{
|
||||
return await CheckToolPermissionAsync(toolName, filePath);
|
||||
}
|
||||
|
||||
public string GetEffectiveToolPermission(string toolName)
|
||||
{
|
||||
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
|
||||
!string.IsNullOrWhiteSpace(toolPerm))
|
||||
return toolPerm;
|
||||
if (ToolPermissions.TryGetValue("*", out var wildcardPerm) &&
|
||||
!string.IsNullOrWhiteSpace(wildcardPerm))
|
||||
return wildcardPerm;
|
||||
if (ToolPermissions.TryGetValue("default", out var defaultPerm) &&
|
||||
!string.IsNullOrWhiteSpace(defaultPerm))
|
||||
return defaultPerm;
|
||||
|
||||
return SensitiveTools.Contains(toolName) ? Permission : "Auto";
|
||||
}
|
||||
|
||||
public async Task<bool> CheckToolPermissionAsync(string toolName, string target)
|
||||
{
|
||||
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode)
|
||||
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
|
||||
return false;
|
||||
|
||||
var effectivePerm = GetEffectiveToolPermission(toolName);
|
||||
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (AskPermission == null) return false;
|
||||
|
||||
var normalizedTarget = string.IsNullOrWhiteSpace(target) ? toolName : target.Trim();
|
||||
var cacheKey = $"{toolName}|{normalizedTarget}";
|
||||
lock (_permissionLock)
|
||||
{
|
||||
if (_approvedPermissionCache.Contains(cacheKey))
|
||||
return true;
|
||||
}
|
||||
|
||||
var allowed = await AskPermission(toolName, normalizedTarget);
|
||||
if (allowed)
|
||||
{
|
||||
lock (_permissionLock)
|
||||
_approvedPermissionCache.Add(cacheKey);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>에이전트 이벤트 (UI 표시용).</summary>
|
||||
public class AgentEvent
|
||||
{
|
||||
public DateTime Timestamp { get; init; } = DateTime.Now;
|
||||
public string RunId { get; init; } = "";
|
||||
public AgentEventType Type { get; init; }
|
||||
public string ToolName { get; init; } = "";
|
||||
public string Summary { get; init; } = "";
|
||||
public string? FilePath { get; init; }
|
||||
public bool Success { get; init; } = true;
|
||||
|
||||
/// <summary>Task Decomposition: 현재 단계 / 전체 단계 (진행률 표시용).</summary>
|
||||
public int StepCurrent { get; init; }
|
||||
public int StepTotal { get; init; }
|
||||
/// <summary>Task Decomposition: 단계 목록.</summary>
|
||||
public List<string>? Steps { get; init; }
|
||||
|
||||
// ── 워크플로우 분석기용 확장 필드 ──
|
||||
|
||||
/// <summary>도구 실행 소요 시간 (ms). 0이면 미측정.</summary>
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
/// <summary>이번 LLM 호출의 입력 토큰 수.</summary>
|
||||
public int InputTokens { get; init; }
|
||||
|
||||
/// <summary>이번 LLM 호출의 출력 토큰 수.</summary>
|
||||
public int OutputTokens { get; init; }
|
||||
|
||||
/// <summary>도구 파라미터 JSON (debug 모드에서만 기록).</summary>
|
||||
public string? ToolInput { get; init; }
|
||||
|
||||
/// <summary>현재 에이전트 루프 반복 번호.</summary>
|
||||
public int Iteration { get; init; }
|
||||
}
|
||||
|
||||
public enum AgentEventType
|
||||
{
|
||||
Thinking, // LLM 사고 중
|
||||
Planning, // 작업 계획 수립
|
||||
StepStart, // 단계 시작
|
||||
StepDone, // 단계 완료
|
||||
HookResult, // 훅 실행 결과
|
||||
PermissionRequest, // 권한 승인 대기
|
||||
PermissionGranted, // 권한 승인됨
|
||||
PermissionDenied, // 권한 거부/차단
|
||||
ToolCall, // 도구 호출
|
||||
ToolResult, // 도구 결과
|
||||
SkillCall, // 스킬 호출
|
||||
Error, // 오류
|
||||
Complete, // 완료
|
||||
Decision, // 사용자 의사결정 대기
|
||||
Paused, // 에이전트 일시정지
|
||||
Resumed, // 에이전트 재개
|
||||
}
|
||||
150
src/AxCopilot/Services/Agent/ImageAnalyzeTool.cs
Normal file
150
src/AxCopilot/Services/Agent/ImageAnalyzeTool.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 이미지를 분석하여 내용 설명, 텍스트 추출, 차트 데이터 해석을 수행하는 도구.
|
||||
/// LLM 멀티모달 API를 활용합니다.
|
||||
/// </summary>
|
||||
public class ImageAnalyzeTool : IAgentTool
|
||||
{
|
||||
public string Name => "image_analyze";
|
||||
public string Description =>
|
||||
"Analyze an image using LLM multimodal vision. " +
|
||||
"Tasks: describe (general description), extract_text (OCR-like text extraction), " +
|
||||
"extract_data (extract structured data like tables/charts from image), " +
|
||||
"compare (compare two images and describe differences).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["image_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Path to the image file (.png, .jpg, .jpeg, .bmp, .gif, .webp)."
|
||||
},
|
||||
["task"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Analysis task: describe, extract_text, extract_data, compare. Default: describe",
|
||||
Enum = ["describe", "extract_text", "extract_data", "compare"]
|
||||
},
|
||||
["compare_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Path to second image for comparison (only used with task=compare)."
|
||||
},
|
||||
["question"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Optional specific question about the image."
|
||||
},
|
||||
["language"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Response language: ko (Korean), en (English). Default: ko"
|
||||
},
|
||||
},
|
||||
Required = ["image_path"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var imagePath = args.GetProperty("image_path").GetString() ?? "";
|
||||
var task = args.TryGetProperty("task", out var taskEl) ? taskEl.GetString() ?? "describe" : "describe";
|
||||
var question = args.TryGetProperty("question", out var qEl) ? qEl.GetString() ?? "" : "";
|
||||
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
if (!File.Exists(fullPath))
|
||||
return ToolResult.Fail($"파일 없음: {fullPath}");
|
||||
|
||||
// 지원 이미지 형식 확인
|
||||
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
if (!IsImageExtension(ext))
|
||||
return ToolResult.Fail($"지원하지 않는 이미지 형식: {ext}");
|
||||
|
||||
// 이미지를 base64로 인코딩
|
||||
var imageBytes = await File.ReadAllBytesAsync(fullPath, ct);
|
||||
var base64 = Convert.ToBase64String(imageBytes);
|
||||
var mimeType = ext switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".bmp" => "image/bmp",
|
||||
_ => "image/png"
|
||||
};
|
||||
|
||||
// 파일 크기 제한 (10MB)
|
||||
if (imageBytes.Length > 10 * 1024 * 1024)
|
||||
return ToolResult.Fail("이미지 크기가 10MB를 초과합니다.");
|
||||
|
||||
// 비교 모드: 두 번째 이미지
|
||||
string? compareBase64 = null;
|
||||
string? compareMime = null;
|
||||
if (task == "compare" && args.TryGetProperty("compare_path", out var cpEl))
|
||||
{
|
||||
var comparePath = FileReadTool.ResolvePath(cpEl.GetString() ?? "", context.WorkFolder);
|
||||
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
|
||||
{
|
||||
var compareBytes = await File.ReadAllBytesAsync(comparePath, ct);
|
||||
compareBase64 = Convert.ToBase64String(compareBytes);
|
||||
var compareExt = Path.GetExtension(comparePath).ToLowerInvariant();
|
||||
compareMime = compareExt switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/png"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 프롬프트 구성
|
||||
var langPrompt = language == "en" ? "Respond in English." : "한국어로 응답하세요.";
|
||||
var prompt = task switch
|
||||
{
|
||||
"extract_text" =>
|
||||
$"이 이미지에서 모든 텍스트를 추출하세요. 원본 레이아웃을 최대한 유지하세요. {langPrompt}",
|
||||
"extract_data" =>
|
||||
$"이 이미지에서 구조화된 데이터를 추출하세요. 테이블, 차트, 그래프 등의 데이터를 " +
|
||||
$"CSV 또는 JSON 형식으로 변환하세요. 차트의 경우 각 항목의 값을 추정하세요. {langPrompt}",
|
||||
"compare" =>
|
||||
$"두 이미지를 비교하고 차이점을 설명하세요. {langPrompt}",
|
||||
_ => string.IsNullOrEmpty(question)
|
||||
? $"이 이미지의 내용을 상세하게 설명하세요. 주요 요소, 텍스트, 레이아웃, 색상 등을 포함하세요. {langPrompt}"
|
||||
: $"{question} {langPrompt}"
|
||||
};
|
||||
|
||||
// LLM에 이미지 분석 요청을 위한 결과 생성
|
||||
// 실제 LLM 호출은 에이전트 루프에서 수행하므로, 여기서는 이미지 정보와 프롬프트를 반환
|
||||
var info = new System.Text.StringBuilder();
|
||||
info.AppendLine($"🖼 이미지 분석 준비 완료");
|
||||
info.AppendLine($" 파일: {Path.GetFileName(fullPath)}");
|
||||
info.AppendLine($" 크기: {imageBytes.Length / 1024}KB");
|
||||
info.AppendLine($" 형식: {mimeType}");
|
||||
info.AppendLine($" 작업: {task}");
|
||||
info.AppendLine();
|
||||
info.AppendLine($"[IMAGE_BASE64:{mimeType}]{base64}[/IMAGE_BASE64]");
|
||||
|
||||
if (compareBase64 != null)
|
||||
info.AppendLine($"[IMAGE_BASE64:{compareMime}]{compareBase64}[/IMAGE_BASE64]");
|
||||
|
||||
info.AppendLine();
|
||||
info.AppendLine($"분석 프롬프트: {prompt}");
|
||||
|
||||
return ToolResult.Ok(info.ToString());
|
||||
}
|
||||
|
||||
private static bool IsImageExtension(string ext)
|
||||
{
|
||||
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp";
|
||||
}
|
||||
}
|
||||
231
src/AxCopilot/Services/Agent/JsonTool.cs
Normal file
231
src/AxCopilot/Services/Agent/JsonTool.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 파싱·변환·검증·포맷팅 도구.
|
||||
/// jq 스타일 경로 쿼리, 유효성 검사, 포맷 변환을 지원합니다.
|
||||
/// </summary>
|
||||
public class JsonTool : IAgentTool
|
||||
{
|
||||
public string Name => "json_tool";
|
||||
public string Description =>
|
||||
"JSON processing tool. Actions: " +
|
||||
"'validate' — check if text is valid JSON and report errors; " +
|
||||
"'format' — pretty-print or minify JSON; " +
|
||||
"'query' — extract value by dot-path (e.g. 'data.users[0].name'); " +
|
||||
"'keys' — list top-level keys; " +
|
||||
"'convert' — convert between JSON/CSV (flat arrays only).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["validate", "format", "query", "keys", "convert"],
|
||||
},
|
||||
["json"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "JSON text to process",
|
||||
},
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Dot-path for query action (e.g. 'data.items[0].name')",
|
||||
},
|
||||
["minify"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "For format action: 'true' to minify, 'false' to pretty-print (default)",
|
||||
},
|
||||
["target_format"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "For convert action: target format",
|
||||
Enum = ["csv"],
|
||||
},
|
||||
},
|
||||
Required = ["action", "json"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var json = args.GetProperty("json").GetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"validate" => Validate(json),
|
||||
"format" => Format(json, args.TryGetProperty("minify", out var m) && m.GetString() == "true"),
|
||||
"query" => Query(json, args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""),
|
||||
"keys" => Keys(json),
|
||||
"convert" => Convert(json, args.TryGetProperty("target_format", out var tf) ? tf.GetString() ?? "csv" : "csv"),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"JSON 처리 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult Validate(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var kind = root.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => $"Object ({root.EnumerateObject().Count()} keys)",
|
||||
JsonValueKind.Array => $"Array ({root.GetArrayLength()} items)",
|
||||
_ => root.ValueKind.ToString(),
|
||||
};
|
||||
return ToolResult.Ok($"✓ Valid JSON — {kind}");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ToolResult.Ok($"✗ Invalid JSON — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult Format(string json, bool minify)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var opts = new JsonSerializerOptions { WriteIndented = !minify };
|
||||
var result = JsonSerializer.Serialize(doc.RootElement, opts);
|
||||
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
|
||||
private static ToolResult Query(string json, string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return ToolResult.Fail("path parameter is required for query action");
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var current = doc.RootElement;
|
||||
|
||||
foreach (var segment in ParsePath(path))
|
||||
{
|
||||
if (segment.IsIndex)
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Array || segment.Index >= current.GetArrayLength())
|
||||
return ToolResult.Fail($"Array index [{segment.Index}] out of range");
|
||||
current = current[segment.Index];
|
||||
}
|
||||
else
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment.Key, out var prop))
|
||||
return ToolResult.Fail($"Key '{segment.Key}' not found");
|
||||
current = prop;
|
||||
}
|
||||
}
|
||||
|
||||
var value = current.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => current.GetString() ?? "",
|
||||
JsonValueKind.Number => current.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => JsonSerializer.Serialize(current, new JsonSerializerOptions { WriteIndented = true }),
|
||||
};
|
||||
if (value.Length > 5000) value = value[..5000] + "\n... (truncated)";
|
||||
return ToolResult.Ok(value);
|
||||
}
|
||||
|
||||
private static ToolResult Keys(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return ToolResult.Fail("Root element is not an object");
|
||||
|
||||
var keys = doc.RootElement.EnumerateObject().Select(p =>
|
||||
{
|
||||
var type = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => "object",
|
||||
JsonValueKind.Array => $"array[{p.Value.GetArrayLength()}]",
|
||||
JsonValueKind.String => "string",
|
||||
JsonValueKind.Number => "number",
|
||||
JsonValueKind.True or JsonValueKind.False => "boolean",
|
||||
_ => "null",
|
||||
};
|
||||
return $" {p.Name}: {type}";
|
||||
});
|
||||
return ToolResult.Ok($"Keys ({doc.RootElement.EnumerateObject().Count()}):\n{string.Join("\n", keys)}");
|
||||
}
|
||||
|
||||
private static ToolResult Convert(string json, string targetFormat)
|
||||
{
|
||||
if (targetFormat != "csv")
|
||||
return ToolResult.Fail($"Unsupported target format: {targetFormat}");
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("JSON must be an array for CSV conversion");
|
||||
|
||||
var arr = doc.RootElement;
|
||||
if (arr.GetArrayLength() == 0)
|
||||
return ToolResult.Ok("(empty array)");
|
||||
|
||||
// 모든 키 수집
|
||||
var allKeys = new List<string>();
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
return ToolResult.Fail("All array items must be objects for CSV conversion");
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
if (!allKeys.Contains(prop.Name)) allKeys.Add(prop.Name);
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(string.Join(",", allKeys.Select(k => $"\"{k}\"")));
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
var values = allKeys.Select(k =>
|
||||
{
|
||||
if (!item.TryGetProperty(k, out var v)) return "\"\"";
|
||||
return v.ValueKind == JsonValueKind.String
|
||||
? $"\"{v.GetString()?.Replace("\"", "\"\"") ?? ""}\""
|
||||
: v.GetRawText();
|
||||
});
|
||||
sb.AppendLine(string.Join(",", values));
|
||||
}
|
||||
var result = sb.ToString();
|
||||
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
|
||||
private record PathSegment(string Key, int Index, bool IsIndex);
|
||||
|
||||
private static List<PathSegment> ParsePath(string path)
|
||||
{
|
||||
var segments = new List<PathSegment>();
|
||||
foreach (var part in path.Split('.'))
|
||||
{
|
||||
var bracketIdx = part.IndexOf('[');
|
||||
if (bracketIdx >= 0)
|
||||
{
|
||||
var key = part[..bracketIdx];
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
segments.Add(new PathSegment(key, 0, false));
|
||||
var idxStr = part[(bracketIdx + 1)..].TrimEnd(']');
|
||||
if (int.TryParse(idxStr, out var idx))
|
||||
segments.Add(new PathSegment("", idx, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
segments.Add(new PathSegment(part, 0, false));
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
202
src/AxCopilot/Services/Agent/LspTool.cs
Normal file
202
src/AxCopilot/Services/Agent/LspTool.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// LSP 기반 코드 인텔리전스 도구.
|
||||
/// 정의 이동(goto_definition), 참조 검색(find_references), 심볼 목록(symbols) 3가지 액션을 제공합니다.
|
||||
/// </summary>
|
||||
public class LspTool : IAgentTool, IDisposable
|
||||
{
|
||||
public string Name => "lsp_code_intel";
|
||||
|
||||
public string Description =>
|
||||
"코드 인텔리전스 도구. 정의 이동, 참조 검색, 심볼 목록을 제공합니다.\n" +
|
||||
"- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다 (파일, 라인, 컬럼)\n" +
|
||||
"- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n" +
|
||||
"- action=\"symbols\": 파일 내 모든 심볼(클래스, 메서드, 필드 등)을 나열합니다\n" +
|
||||
"file_path, line, character 파라미터가 필요합니다 (line과 character는 0-based).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "수행할 작업: goto_definition | find_references | symbols",
|
||||
Enum = new() { "goto_definition", "find_references", "symbols" }
|
||||
},
|
||||
["file_path"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로)"
|
||||
},
|
||||
["line"] = new ToolProperty
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "대상 라인 번호 (0-based). symbols 액션에서는 불필요."
|
||||
},
|
||||
["character"] = new ToolProperty
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "라인 내 문자 위치 (0-based). symbols 액션에서는 불필요."
|
||||
},
|
||||
},
|
||||
Required = new() { "action", "file_path" }
|
||||
};
|
||||
|
||||
// 언어별 LSP 클라이언트 캐시
|
||||
private readonly Dictionary<string, LspClientService> _clients = new();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
// 설정 체크
|
||||
var app = System.Windows.Application.Current as App;
|
||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
|
||||
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
|
||||
var line = args.TryGetProperty("line", out var l) ? l.GetInt32() : 0;
|
||||
var character = args.TryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return ToolResult.Fail("file_path가 필요합니다.");
|
||||
|
||||
// 절대 경로 변환
|
||||
if (!Path.IsPathRooted(filePath) && !string.IsNullOrEmpty(context.WorkFolder))
|
||||
filePath = Path.Combine(context.WorkFolder, filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return ToolResult.Fail($"파일을 찾을 수 없습니다: {filePath}");
|
||||
|
||||
// 언어 감지
|
||||
var language = DetectLanguage(filePath);
|
||||
if (language == null)
|
||||
return ToolResult.Fail($"지원하지 않는 파일 형식: {Path.GetExtension(filePath)}");
|
||||
|
||||
// LSP 클라이언트 시작 (캐시)
|
||||
var client = await GetOrCreateClientAsync(language, context.WorkFolder, ct);
|
||||
if (client == null || !client.IsConnected)
|
||||
return ToolResult.Fail($"{language} 언어 서버를 시작할 수 없습니다. 해당 언어 서버가 설치되어 있는지 확인하세요.");
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"goto_definition" => await GotoDefinitionAsync(client, filePath, line, character, ct),
|
||||
"find_references" => await FindReferencesAsync(client, filePath, line, character, ct),
|
||||
"symbols" => await GetSymbolsAsync(client, filePath, ct),
|
||||
_ => ToolResult.Fail($"알 수 없는 액션: {action}. goto_definition | find_references | symbols 중 선택하세요.")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"LSP 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> GotoDefinitionAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
|
||||
{
|
||||
var loc = await client.GotoDefinitionAsync(filePath, line, character, ct);
|
||||
if (loc == null)
|
||||
return ToolResult.Ok("정의를 찾을 수 없습니다 (해당 위치에 심볼이 없거나 외부 라이브러리일 수 있습니다).");
|
||||
|
||||
// 정의 위치의 코드를 읽어서 컨텍스트 제공
|
||||
var contextCode = ReadCodeContext(loc.FilePath, loc.Line, 3);
|
||||
return ToolResult.Ok(
|
||||
$"정의 위치: {loc}\n\n```\n{contextCode}\n```",
|
||||
loc.FilePath);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FindReferencesAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
|
||||
{
|
||||
var locations = await client.FindReferencesAsync(filePath, line, character, ct);
|
||||
if (locations.Count == 0)
|
||||
return ToolResult.Ok("참조를 찾을 수 없습니다.");
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"총 {locations.Count}개 참조:");
|
||||
foreach (var loc in locations.Take(30))
|
||||
sb.AppendLine($" {loc}");
|
||||
if (locations.Count > 30)
|
||||
sb.AppendLine($" ... 외 {locations.Count - 30}개");
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private async Task<ToolResult> GetSymbolsAsync(LspClientService client, string filePath, CancellationToken ct)
|
||||
{
|
||||
var symbols = await client.GetDocumentSymbolsAsync(filePath, ct);
|
||||
if (symbols.Count == 0)
|
||||
return ToolResult.Ok("심볼을 찾을 수 없습니다.");
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"총 {symbols.Count}개 심볼:");
|
||||
foreach (var sym in symbols)
|
||||
sb.AppendLine($" {sym}");
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private async Task<LspClientService?> GetOrCreateClientAsync(string language, string workFolder, CancellationToken ct)
|
||||
{
|
||||
if (_clients.TryGetValue(language, out var existing) && existing.IsConnected)
|
||||
return existing;
|
||||
|
||||
var client = new LspClientService(language);
|
||||
var started = await client.StartAsync(workFolder, ct);
|
||||
if (!started)
|
||||
{
|
||||
client.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
_clients[language] = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string? DetectLanguage(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".cs" => "csharp",
|
||||
".ts" or ".tsx" => "typescript",
|
||||
".js" or ".jsx" => "javascript",
|
||||
".py" => "python",
|
||||
".cpp" or ".cc" or ".cxx" or ".c" or ".h" or ".hpp" => "cpp",
|
||||
".java" => "java",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
client.Dispose();
|
||||
_clients.Clear();
|
||||
}
|
||||
|
||||
private static string ReadCodeContext(string filePath, int targetLine, int contextLines)
|
||||
{
|
||||
try
|
||||
{
|
||||
var read = TextFileCodec.ReadAllText(filePath);
|
||||
var lines = TextFileCodec.SplitLines(read.Text);
|
||||
var start = Math.Max(0, targetLine - contextLines);
|
||||
var end = Math.Min(lines.Length - 1, targetLine + contextLines);
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
var marker = i == targetLine ? ">>>" : " ";
|
||||
sb.AppendLine($"{marker} {i + 1,4}: {lines[i].TrimEnd('\r')}");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
catch { return "(코드를 읽을 수 없습니다)"; }
|
||||
}
|
||||
}
|
||||
69
src/AxCopilot/Services/Agent/MarkdownSkill.cs
Normal file
69
src/AxCopilot/Services/Agent/MarkdownSkill.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Markdown (.md) 문서를 생성하는 내장 스킬.
|
||||
/// LLM이 마크다운 내용을 전달하면 파일로 저장합니다.
|
||||
/// </summary>
|
||||
public class MarkdownSkill : IAgentTool
|
||||
{
|
||||
public string Name => "markdown_create";
|
||||
public string Description => "Create a Markdown (.md) document file. Provide the content in Markdown format.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.md). Relative to work folder." },
|
||||
["content"] = new() { Type = "string", Description = "Markdown content to write" },
|
||||
["title"] = new() { Type = "string", Description = "Optional document title. If provided, prepends '# title' at the top." },
|
||||
},
|
||||
Required = ["path", "content"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var content = args.GetProperty("content").GetString() ?? "";
|
||||
var title = args.TryGetProperty("title", out var t) ? t.GetString() : null;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".md";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
sb.AppendLine($"# {title}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.Append(content);
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
|
||||
|
||||
var lines = sb.ToString().Split('\n').Length;
|
||||
return ToolResult.Ok(
|
||||
$"Markdown 문서 저장 완료: {fullPath} ({lines} lines)",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"Markdown 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/AxCopilot/Services/Agent/MathTool.cs
Normal file
66
src/AxCopilot/Services/Agent/MathTool.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>수학 수식을 계산하는 도구.</summary>
|
||||
public class MathTool : IAgentTool
|
||||
{
|
||||
public string Name => "math_eval";
|
||||
public string Description =>
|
||||
"Evaluate a mathematical expression and return the result. " +
|
||||
"Supports: +, -, *, /, %, parentheses, and common math operations. " +
|
||||
"Use for calculations, unit conversions, and numeric analysis.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["expression"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Mathematical expression to evaluate (e.g. '(100 * 1.08) / 3')",
|
||||
},
|
||||
["precision"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Decimal places for rounding (default: 6)",
|
||||
},
|
||||
},
|
||||
Required = ["expression"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var expression = args.GetProperty("expression").GetString() ?? "";
|
||||
var precision = args.TryGetProperty("precision", out var p) ? p.GetInt32() : 6;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));
|
||||
|
||||
try
|
||||
{
|
||||
// DataTable.Compute를 사용한 안전한 수식 평가
|
||||
var sanitized = expression
|
||||
.Replace("^", " ") // XOR 방지 — ** 패턴은 아래에서 처리
|
||||
.Replace("Math.", "")
|
||||
.Replace("System.", "");
|
||||
|
||||
// 기본 보안 검사: 알파벳 함수 호출 차단
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(sanitized, @"[a-zA-Z]{3,}"))
|
||||
return Task.FromResult(ToolResult.Fail("함수 호출은 지원하지 않습니다. 기본 사칙연산만 가능합니다."));
|
||||
|
||||
var dt = new DataTable();
|
||||
var result = dt.Compute(sanitized, null);
|
||||
var value = Convert.ToDouble(result);
|
||||
var rounded = Math.Round(value, precision);
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(
|
||||
$"Expression: {expression}\nResult: {rounded}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"수식 평가 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/AxCopilot/Services/Agent/McpListResourcesTool.cs
Normal file
61
src/AxCopilot/Services/Agent/McpListResourcesTool.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>MCP 서버의 리소스 목록을 조회합니다.</summary>
|
||||
public class McpListResourcesTool : IAgentTool
|
||||
{
|
||||
private readonly Func<IReadOnlyCollection<McpClientService>> _getClients;
|
||||
|
||||
public McpListResourcesTool(Func<IReadOnlyCollection<McpClientService>> getClients)
|
||||
{
|
||||
_getClients = getClients;
|
||||
}
|
||||
|
||||
public string Name => "mcp_list_resources";
|
||||
public string Description => "연결된 MCP 서버들의 리소스 목록을 조회합니다. server_name을 지정하면 특정 서버만 조회합니다.";
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["server_name"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "조회할 MCP 서버 이름. 생략하면 모든 연결된 서버를 조회합니다."
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var serverName = args.ValueKind == JsonValueKind.Object &&
|
||||
args.TryGetProperty("server_name", out var serverProp)
|
||||
? serverProp.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var clients = _getClients()
|
||||
.Where(c => c.IsConnected &&
|
||||
(string.IsNullOrWhiteSpace(serverName) || string.Equals(c.ServerName, serverName, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (clients.Count == 0)
|
||||
return ToolResult.Fail(string.IsNullOrWhiteSpace(serverName)
|
||||
? "연결된 MCP 서버가 없습니다."
|
||||
: $"MCP 서버 '{serverName}'를 찾지 못했습니다.");
|
||||
|
||||
var lines = new List<string>();
|
||||
foreach (var client in clients)
|
||||
{
|
||||
var resources = await client.ListResourcesAsync(ct);
|
||||
lines.Add($"[{client.ServerName}] {resources.Count}개 리소스");
|
||||
foreach (var resource in resources.Take(20))
|
||||
{
|
||||
var desc = string.IsNullOrWhiteSpace(resource.Description) ? "" : $" - {resource.Description}";
|
||||
var mime = string.IsNullOrWhiteSpace(resource.MimeType) ? "" : $" ({resource.MimeType})";
|
||||
lines.Add($"- {resource.Name} :: {resource.Uri}{mime}{desc}");
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult.Ok(string.Join(Environment.NewLine, lines));
|
||||
}
|
||||
}
|
||||
69
src/AxCopilot/Services/Agent/McpReadResourceTool.cs
Normal file
69
src/AxCopilot/Services/Agent/McpReadResourceTool.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>MCP 리소스 내용을 읽습니다.</summary>
|
||||
public class McpReadResourceTool : IAgentTool
|
||||
{
|
||||
private readonly Func<IReadOnlyCollection<McpClientService>> _getClients;
|
||||
|
||||
public McpReadResourceTool(Func<IReadOnlyCollection<McpClientService>> getClients)
|
||||
{
|
||||
_getClients = getClients;
|
||||
}
|
||||
|
||||
public string Name => "mcp_read_resource";
|
||||
public string Description => "MCP 리소스 URI를 읽어 내용을 가져옵니다. server_name을 지정하면 해당 서버에서 우선 조회합니다.";
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["uri"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "읽을 MCP 리소스 URI"
|
||||
},
|
||||
["server_name"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "리소스가 있는 MCP 서버 이름. 생략하면 연결된 서버들에서 검색합니다."
|
||||
}
|
||||
},
|
||||
Required = new() { "uri" }
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (args.ValueKind != JsonValueKind.Object ||
|
||||
!args.TryGetProperty("uri", out var uriProp) ||
|
||||
string.IsNullOrWhiteSpace(uriProp.GetString()))
|
||||
return ToolResult.Fail("uri가 필요합니다.");
|
||||
|
||||
var uri = uriProp.GetString()!;
|
||||
var serverName = args.TryGetProperty("server_name", out var serverProp)
|
||||
? serverProp.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var clients = _getClients()
|
||||
.Where(c => c.IsConnected &&
|
||||
(string.IsNullOrWhiteSpace(serverName) || string.Equals(c.ServerName, serverName, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (clients.Count == 0)
|
||||
return ToolResult.Fail(string.IsNullOrWhiteSpace(serverName)
|
||||
? "연결된 MCP 서버가 없습니다."
|
||||
: $"MCP 서버 '{serverName}'를 찾지 못했습니다.");
|
||||
|
||||
foreach (var client in clients)
|
||||
{
|
||||
var resources = await client.ListResourcesAsync(ct);
|
||||
if (resources.Any(r => string.Equals(r.Uri, uri, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var content = await client.ReadResourceAsync(uri, ct);
|
||||
return ToolResult.Ok($"[{client.ServerName}] {uri}{Environment.NewLine}{content}");
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"리소스 '{uri}'를 찾지 못했습니다.");
|
||||
}
|
||||
}
|
||||
77
src/AxCopilot/Services/Agent/McpTool.cs
Normal file
77
src/AxCopilot/Services/Agent/McpTool.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// MCP 서버의 도구를 에이전트 도구로 래핑합니다.
|
||||
/// 하나의 McpTool 인스턴스가 하나의 MCP 도구를 나타냅니다.
|
||||
/// </summary>
|
||||
public class McpTool : IAgentTool
|
||||
{
|
||||
private readonly McpClientService _client;
|
||||
private readonly Models.McpToolDefinition _def;
|
||||
|
||||
public McpTool(McpClientService client, Models.McpToolDefinition def)
|
||||
{
|
||||
_client = client;
|
||||
_def = def;
|
||||
}
|
||||
|
||||
public string Name => $"mcp_{_def.ServerName}_{_def.Name}";
|
||||
public string Description => $"[MCP:{_def.ServerName}] {_def.Description}";
|
||||
|
||||
public ToolParameterSchema Parameters
|
||||
{
|
||||
get
|
||||
{
|
||||
var schema = new ToolParameterSchema
|
||||
{
|
||||
Properties = new(),
|
||||
Required = new(),
|
||||
};
|
||||
foreach (var (name, param) in _def.Parameters)
|
||||
{
|
||||
schema.Properties[name] = new ToolProperty
|
||||
{
|
||||
Type = param.Type,
|
||||
Description = param.Description,
|
||||
};
|
||||
if (param.Required) schema.Required.Add(name);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_client.IsConnected)
|
||||
return ToolResult.Fail($"MCP 서버 '{_def.ServerName}'에 연결되어 있지 않습니다.");
|
||||
|
||||
var arguments = new Dictionary<string, object>();
|
||||
if (args.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in args.EnumerateObject())
|
||||
{
|
||||
arguments[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString()!,
|
||||
JsonValueKind.Number => prop.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => prop.Value.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _client.CallToolAsync(_def.Name, arguments, ct);
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"MCP 도구 실행 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/AxCopilot/Services/Agent/MemoryTool.cs
Normal file
122
src/AxCopilot/Services/Agent/MemoryTool.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 메모리 관리 도구.
|
||||
/// 프로젝트 규칙, 사용자 선호도, 학습 내용을 저장/검색/삭제합니다.
|
||||
/// </summary>
|
||||
public class MemoryTool : IAgentTool
|
||||
{
|
||||
public string Name => "memory";
|
||||
|
||||
public string Description =>
|
||||
"프로젝트 규칙, 사용자 선호도, 학습 내용을 저장하고 검색합니다.\n" +
|
||||
"대화 간 지속되는 메모리로, 새 대화에서도 이전에 학습한 내용을 활용할 수 있습니다.\n" +
|
||||
"- action=\"save\": 새 메모리 저장 (type, content 필수)\n" +
|
||||
"- action=\"search\": 관련 메모리 검색 (query 필수)\n" +
|
||||
"- action=\"list\": 현재 메모리 전체 목록\n" +
|
||||
"- action=\"delete\": 메모리 삭제 (id 필수)\n" +
|
||||
"type 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new() { Type = "string", Description = "save | search | list | delete" },
|
||||
["type"] = new() { Type = "string", Description = "메모리 유형: rule | preference | fact | correction. save 시 필수." },
|
||||
["content"] = new() { Type = "string", Description = "저장할 내용. save 시 필수." },
|
||||
["query"] = new() { Type = "string", Description = "검색 쿼리. search 시 필수." },
|
||||
["id"] = new() { Type = "string", Description = "메모리 ID. delete 시 필수." },
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
// 설정 체크
|
||||
var app = System.Windows.Application.Current as App;
|
||||
if (!(app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? true))
|
||||
return Task.FromResult(ToolResult.Ok("에이전트 메모리가 비활성 상태입니다. 설정에서 활성화하세요."));
|
||||
|
||||
var memoryService = app?.MemoryService;
|
||||
if (memoryService == null)
|
||||
return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다."));
|
||||
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
|
||||
var action = actionEl.GetString() ?? "";
|
||||
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"save" => ExecuteSave(args, memoryService, context),
|
||||
"search" => ExecuteSearch(args, memoryService),
|
||||
"list" => ExecuteList(memoryService),
|
||||
"delete" => ExecuteDelete(args, memoryService),
|
||||
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | search | list | delete 중 선택하세요."),
|
||||
});
|
||||
}
|
||||
|
||||
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() ?? "fact" : "fact";
|
||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("content가 필요합니다.");
|
||||
|
||||
var validTypes = new[] { "rule", "preference", "fact", "correction" };
|
||||
if (!validTypes.Contains(type))
|
||||
return ToolResult.Fail($"잘못된 type: {type}. rule | preference | fact | correction 중 선택하세요.");
|
||||
|
||||
var workFolder = string.IsNullOrEmpty(context.WorkFolder) ? null : context.WorkFolder;
|
||||
var entry = svc.Add(type, content, $"agent:{context.ActiveTab}", workFolder);
|
||||
return ToolResult.Ok($"메모리 저장됨 [{entry.Type}] (ID: {entry.Id}): {entry.Content}");
|
||||
}
|
||||
|
||||
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
|
||||
{
|
||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return ToolResult.Fail("query가 필요합니다.");
|
||||
|
||||
var results = svc.GetRelevant(query, 10);
|
||||
if (results.Count == 0)
|
||||
return ToolResult.Ok("관련 메모리가 없습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"관련 메모리 {results.Count}개:");
|
||||
foreach (var e in results)
|
||||
sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult ExecuteList(AgentMemoryService svc)
|
||||
{
|
||||
var all = svc.All;
|
||||
if (all.Count == 0)
|
||||
return ToolResult.Ok("저장된 메모리가 없습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"전체 메모리 {all.Count}개:");
|
||||
foreach (var group in all.GroupBy(e => e.Type))
|
||||
{
|
||||
sb.AppendLine($"\n[{group.Key}]");
|
||||
foreach (var e in group.OrderByDescending(e => e.UseCount))
|
||||
sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
|
||||
{
|
||||
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return ToolResult.Fail("id가 필요합니다.");
|
||||
|
||||
return svc.Remove(id)
|
||||
? ToolResult.Ok($"메모리 삭제됨 (ID: {id})")
|
||||
: ToolResult.Fail($"해당 ID의 메모리를 찾을 수 없습니다: {id}");
|
||||
}
|
||||
}
|
||||
94
src/AxCopilot/Services/Agent/MultiReadTool.cs
Normal file
94
src/AxCopilot/Services/Agent/MultiReadTool.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구.</summary>
|
||||
public class MultiReadTool : IAgentTool
|
||||
{
|
||||
public string Name => "multi_read";
|
||||
public string Description =>
|
||||
"Read multiple files in a single call (max 10). " +
|
||||
"Returns concatenated contents with file headers. " +
|
||||
"More efficient than calling file_read multiple times.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["paths"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "List of file paths to read (max 10)",
|
||||
Items = new() { Type = "string", Description = "File path" },
|
||||
},
|
||||
["max_lines"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Max lines per file (default 200)",
|
||||
},
|
||||
},
|
||||
Required = ["paths"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var maxLines = args.TryGetProperty("max_lines", out var ml) ? ml.GetInt32() : 200;
|
||||
if (maxLines <= 0) maxLines = 200;
|
||||
|
||||
if (!args.TryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
|
||||
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
|
||||
|
||||
var paths = new List<string>();
|
||||
foreach (var p in pathsEl.EnumerateArray())
|
||||
{
|
||||
var s = p.GetString();
|
||||
if (!string.IsNullOrEmpty(s)) paths.Add(s);
|
||||
}
|
||||
|
||||
if (paths.Count == 0)
|
||||
return Task.FromResult(ToolResult.Fail("읽을 파일이 없습니다."));
|
||||
if (paths.Count > 10)
|
||||
return Task.FromResult(ToolResult.Fail("최대 10개 파일만 지원합니다."));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var readCount = 0;
|
||||
|
||||
foreach (var rawPath in paths)
|
||||
{
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
sb.AppendLine($"═══ {Path.GetFileName(path)} ═══");
|
||||
sb.AppendLine($"Path: {path}");
|
||||
|
||||
if (!context.IsPathAllowed(path))
|
||||
{
|
||||
sb.AppendLine("[접근 차단됨]");
|
||||
}
|
||||
else if (!File.Exists(path))
|
||||
{
|
||||
sb.AppendLine("[파일 없음]");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var lines = File.ReadLines(path).Take(maxLines).ToList();
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
sb.AppendLine($"{i + 1}\t{lines[i]}");
|
||||
if (lines.Count >= maxLines)
|
||||
sb.AppendLine($"... (이후 생략, max_lines={maxLines})");
|
||||
readCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sb.AppendLine($"[읽기 오류: {ex.Message}]");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return Task.FromResult(ToolResult.Ok($"{readCount}/{paths.Count}개 파일 읽기 완료.\n\n{sb}"));
|
||||
}
|
||||
}
|
||||
152
src/AxCopilot/Services/Agent/NotifyTool.cs
Normal file
152
src/AxCopilot/Services/Agent/NotifyTool.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 알림 전송 도구.
|
||||
/// 장시간 작업 완료 알림, 사용자 확인 요청 등을 트레이 또는 앱 내 토스트로 표시합니다.
|
||||
/// </summary>
|
||||
public class NotifyTool : IAgentTool
|
||||
{
|
||||
public string Name => "notify_tool";
|
||||
public string Description =>
|
||||
"Send a notification to the user. Use this when: " +
|
||||
"a long-running task completes, an important result needs attention, " +
|
||||
"or you want to inform the user of something. " +
|
||||
"The notification appears as an in-app toast message.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["title"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Notification title (short, 1-2 words)",
|
||||
},
|
||||
["message"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Notification message (detail text)",
|
||||
},
|
||||
["level"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Notification level: info (default), success, warning, error",
|
||||
Enum = ["info", "success", "warning", "error"],
|
||||
},
|
||||
},
|
||||
Required = ["title", "message"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var title = args.GetProperty("title").GetString() ?? "알림";
|
||||
var message = args.GetProperty("message").GetString() ?? "";
|
||||
var level = args.TryGetProperty("level", out var lv) ? lv.GetString() ?? "info" : "info";
|
||||
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
ShowToast(title, message, level);
|
||||
});
|
||||
return Task.FromResult(ToolResult.Ok($"✓ Notification sent: [{level}] {title}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"알림 전송 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowToast(string title, string message, string level)
|
||||
{
|
||||
var mainWindow = Application.Current.MainWindow;
|
||||
if (mainWindow == null) return;
|
||||
|
||||
var (iconChar, iconColor) = level switch
|
||||
{
|
||||
"success" => ("\uE73E", "#34D399"),
|
||||
"warning" => ("\uE7BA", "#F59E0B"),
|
||||
"error" => ("\uEA39", "#F87171"),
|
||||
_ => ("\uE946", "#4B5EFC"), // info
|
||||
};
|
||||
|
||||
var toast = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(16, 12, 16, 12),
|
||||
Margin = new Thickness(0, 0, 20, 20),
|
||||
MinWidth = 280,
|
||||
MaxWidth = 400,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Bottom,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||
{
|
||||
BlurRadius = 16,
|
||||
ShadowDepth = 4,
|
||||
Opacity = 0.4,
|
||||
Color = Colors.Black,
|
||||
},
|
||||
};
|
||||
|
||||
var content = new StackPanel();
|
||||
|
||||
// 타이틀 행
|
||||
var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
|
||||
titleRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = iconChar,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 14,
|
||||
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor)),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
titleRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = Brushes.White,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
content.Children.Add(titleRow);
|
||||
|
||||
// 메시지
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = message.Length > 200 ? message[..200] + "..." : message,
|
||||
FontSize = 12,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xAA, 0xAA, 0xCC)),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
}
|
||||
|
||||
toast.Child = content;
|
||||
|
||||
// 기존 Grid/Panel에 추가
|
||||
if (mainWindow.Content is Grid grid)
|
||||
{
|
||||
Grid.SetRowSpan(toast, grid.RowDefinitions.Count > 0 ? grid.RowDefinitions.Count : 1);
|
||||
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
|
||||
grid.Children.Add(toast);
|
||||
|
||||
// 5초 후 자동 제거 (페이드 아웃)
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
grid.Children.Remove(toast);
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/AxCopilot/Services/Agent/OpenExternalTool.cs
Normal file
70
src/AxCopilot/Services/Agent/OpenExternalTool.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>파일/URL을 시스템 기본 앱으로 여는 도구.</summary>
|
||||
public class OpenExternalTool : IAgentTool
|
||||
{
|
||||
public string Name => "open_external";
|
||||
public string Description =>
|
||||
"Open a file with its default application or open a URL in the default browser. " +
|
||||
"Also supports opening a folder in File Explorer. " +
|
||||
"Use after creating documents, reports, or charts for the user to view.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File path, directory path, or URL to open",
|
||||
},
|
||||
},
|
||||
Required = ["path"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
|
||||
|
||||
try
|
||||
{
|
||||
// URL인 경우
|
||||
if (rawPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
rawPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(rawPath) { UseShellExecute = true });
|
||||
return Task.FromResult(ToolResult.Ok($"URL 열기: {rawPath}"));
|
||||
}
|
||||
|
||||
// 파일/폴더 경로
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
if (!context.IsPathAllowed(path))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {path}"));
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
return Task.FromResult(ToolResult.Ok($"파일 열기: {path}", filePath: path));
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", path));
|
||||
return Task.FromResult(ToolResult.Ok($"폴더 열기: {path}", filePath: path));
|
||||
}
|
||||
|
||||
return Task.FromResult(ToolResult.Fail($"경로를 찾을 수 없습니다: {path}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"열기 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
324
src/AxCopilot/Services/Agent/PlaybookTool.cs
Normal file
324
src/AxCopilot/Services/Agent/PlaybookTool.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 성공적인 실행 흐름을 재사용 가능한 플레이북으로 저장/관리하는 도구.
|
||||
/// .ax/playbooks/ 에 JSON 파일로 저장됩니다.
|
||||
/// </summary>
|
||||
public class PlaybookTool : IAgentTool
|
||||
{
|
||||
public string Name => "playbook";
|
||||
|
||||
public string Description =>
|
||||
"Save, list, describe, or delete execution playbooks. " +
|
||||
"A playbook captures a successful task workflow for reuse.\n" +
|
||||
"- action=\"save\": Save a new playbook (name, description, steps required)\n" +
|
||||
"- action=\"list\": List all saved playbooks\n" +
|
||||
"- action=\"describe\": Show full playbook details (id required)\n" +
|
||||
"- action=\"delete\": Delete a playbook (id required)";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "save | list | describe | delete",
|
||||
Enum = ["save", "list", "describe", "delete"],
|
||||
},
|
||||
["name"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Playbook name (for save)",
|
||||
},
|
||||
["description"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "What this playbook does (for save)",
|
||||
},
|
||||
["steps"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "List of step descriptions (for save)",
|
||||
Items = new() { Type = "string", Description = "Step description" },
|
||||
},
|
||||
["tools_used"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "List of tool names used in this workflow (for save)",
|
||||
Items = new() { Type = "string", Description = "Tool name" },
|
||||
},
|
||||
["id"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Playbook ID (for describe/delete)",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
return ToolResult.Fail("action이 필요합니다.");
|
||||
var action = actionEl.GetString() ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||
|
||||
var playbookDir = Path.Combine(context.WorkFolder, ".ax", "playbooks");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"save" => await SavePlaybook(args, playbookDir, ct),
|
||||
"list" => ListPlaybooks(playbookDir),
|
||||
"describe" => await DescribePlaybook(args, playbookDir, ct),
|
||||
"delete" => DeletePlaybook(args, playbookDir),
|
||||
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | list | describe | delete 중 선택하세요."),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ToolResult.Fail("플레이북 name이 필요합니다.");
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
return ToolResult.Fail("플레이북 description이 필요합니다.");
|
||||
|
||||
// steps 파싱
|
||||
var steps = new List<string>();
|
||||
if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var step in stepsEl.EnumerateArray())
|
||||
{
|
||||
var s = step.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
steps.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (steps.Count == 0)
|
||||
return ToolResult.Fail("최소 1개 이상의 step이 필요합니다.");
|
||||
|
||||
// tools_used 파싱
|
||||
var toolsUsed = new List<string>();
|
||||
if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var tool in toolsEl.EnumerateArray())
|
||||
{
|
||||
var t = tool.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(t))
|
||||
toolsUsed.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(playbookDir);
|
||||
|
||||
// 다음 ID 결정
|
||||
var nextId = GetNextId(playbookDir);
|
||||
|
||||
// 파일명에서 비안전 문자 제거
|
||||
var safeName = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
var fileName = $"{nextId}_{safeName}.json";
|
||||
|
||||
var playbook = new PlaybookData
|
||||
{
|
||||
Id = nextId,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Steps = steps,
|
||||
ToolsUsed = toolsUsed,
|
||||
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(playbook, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
|
||||
var filePath = Path.Combine(playbookDir, fileName);
|
||||
await TextFileCodec.WriteAllTextAsync(filePath, json, TextFileCodec.Utf8NoBom, ct);
|
||||
|
||||
return ToolResult.Ok($"플레이북 저장 완료: [{nextId}] {name}\n단계 수: {steps.Count}, 사용 도구: {toolsUsed.Count}개");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"플레이북 저장 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ListPlaybooks(string playbookDir)
|
||||
{
|
||||
if (!Directory.Exists(playbookDir))
|
||||
return ToolResult.Ok("저장된 플레이북이 없습니다.");
|
||||
|
||||
var files = Directory.GetFiles(playbookDir, "*.json")
|
||||
.OrderBy(f => f)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
return ToolResult.Ok("저장된 플레이북이 없습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"플레이북 {files.Count}개:");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(file).Text;
|
||||
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
|
||||
if (pb != null)
|
||||
sb.AppendLine($" [{pb.Id}] {pb.Name} — {pb.Description} ({pb.Steps.Count}단계, {pb.CreatedAt})");
|
||||
}
|
||||
catch
|
||||
{
|
||||
sb.AppendLine($" [?] {Path.GetFileName(file)} — 파싱 오류");
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("플레이북 id가 필요합니다.");
|
||||
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
var playbook = await FindPlaybookById(playbookDir, id, ct);
|
||||
|
||||
if (playbook == null)
|
||||
return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"플레이북: {playbook.Name} (ID: {playbook.Id})");
|
||||
sb.AppendLine($"설명: {playbook.Description}");
|
||||
sb.AppendLine($"생성일: {playbook.CreatedAt}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("단계:");
|
||||
for (var i = 0; i < playbook.Steps.Count; i++)
|
||||
sb.AppendLine($" {i + 1}. {playbook.Steps[i]}");
|
||||
|
||||
if (playbook.ToolsUsed.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"사용 도구: {string.Join(", ", playbook.ToolsUsed)}");
|
||||
}
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("삭제할 플레이북 id가 필요합니다.");
|
||||
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
|
||||
if (!Directory.Exists(playbookDir))
|
||||
return ToolResult.Fail("저장된 플레이북이 없습니다.");
|
||||
|
||||
// ID에 해당하는 파일 찾기
|
||||
var files = Directory.GetFiles(playbookDir, $"{id}_*.json");
|
||||
if (files.Length == 0)
|
||||
{
|
||||
// 파일 내부 ID로 검색
|
||||
foreach (var file in Directory.GetFiles(playbookDir, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(file).Text;
|
||||
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
|
||||
if (pb?.Id == id)
|
||||
{
|
||||
var name = pb.Name;
|
||||
File.Delete(file);
|
||||
return ToolResult.Ok($"플레이북 삭제됨: [{id}] {name}");
|
||||
}
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
}
|
||||
return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(files[0]);
|
||||
File.Delete(files[0]);
|
||||
return ToolResult.Ok($"플레이북 삭제됨: {fileName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"플레이북 삭제 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static int GetNextId(string playbookDir)
|
||||
{
|
||||
if (!Directory.Exists(playbookDir))
|
||||
return 1;
|
||||
|
||||
var maxId = 0;
|
||||
foreach (var file in Directory.GetFiles(playbookDir, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(file).Text;
|
||||
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
|
||||
if (pb != null && pb.Id > maxId)
|
||||
maxId = pb.Id;
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
}
|
||||
return maxId + 1;
|
||||
}
|
||||
|
||||
private static async Task<PlaybookData?> FindPlaybookById(string playbookDir, int id, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(playbookDir))
|
||||
return null;
|
||||
|
||||
foreach (var file in Directory.GetFiles(playbookDir, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = (await TextFileCodec.ReadAllTextAsync(file, ct)).Text;
|
||||
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
|
||||
if (pb?.Id == id)
|
||||
return pb;
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Models
|
||||
|
||||
private class PlaybookData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<string> Steps { get; set; } = [];
|
||||
public List<string> ToolsUsed { get; set; } = [];
|
||||
public string CreatedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
270
src/AxCopilot/Services/Agent/PptxSkill.cs
Normal file
270
src/AxCopilot/Services/Agent/PptxSkill.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Presentation;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// PowerPoint (.pptx) 프레젠테이션을 네이티브 생성하는 스킬.
|
||||
/// OpenXML SDK를 사용하여 Python/Node 의존 없이 PPTX를 생성합니다.
|
||||
/// </summary>
|
||||
public class PptxSkill : IAgentTool
|
||||
{
|
||||
public string Name => "pptx_create";
|
||||
public string Description =>
|
||||
"Create a PowerPoint (.pptx) presentation. " +
|
||||
"Supports slide layouts: title (title+subtitle), content (title+body text), " +
|
||||
"two_column (title+left+right), table (title+headers+rows), blank. " +
|
||||
"No external runtime required (native OpenXML).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Output file path (.pptx). Relative to work folder." },
|
||||
["title"] = new() { Type = "string", Description = "Presentation title (used on first slide if no explicit title slide)." },
|
||||
["slides"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Array of slide objects. Each slide: " +
|
||||
"{\"layout\": \"title|content|two_column|table|blank\", " +
|
||||
"\"title\": \"Slide Title\", " +
|
||||
"\"subtitle\": \"...\", " + // title layout
|
||||
"\"body\": \"...\", " + // content layout
|
||||
"\"left\": \"...\", \"right\": \"...\", " + // two_column
|
||||
"\"headers\": [...], \"rows\": [[...]], " + // table
|
||||
"\"notes\": \"Speaker notes\"}",
|
||||
Items = new() { Type = "object" }
|
||||
},
|
||||
["theme"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Color theme: professional (blue), modern (teal), dark (dark gray), minimal (light). Default: professional",
|
||||
Enum = ["professional", "modern", "dark", "minimal"]
|
||||
},
|
||||
},
|
||||
Required = ["path", "slides"]
|
||||
};
|
||||
|
||||
// 테마별 색상 정의
|
||||
private static readonly Dictionary<string, (string Primary, string Accent, string TextDark, string TextLight, string Bg)> Themes = new()
|
||||
{
|
||||
["professional"] = ("2B579A", "4B5EFC", "1A1A2E", "FFFFFF", "FFFFFF"),
|
||||
["modern"] = ("0D9488", "06B6D4", "1A1A2E", "FFFFFF", "FFFFFF"),
|
||||
["dark"] = ("374151", "6366F1", "F9FAFB", "FFFFFF", "1F2937"),
|
||||
["minimal"] = ("6B7280", "3B82F6", "111827", "FFFFFF", "FAFAFA"),
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var presTitle = args.TryGetProperty("title", out var tt) ? tt.GetString() ?? "Presentation" : "Presentation";
|
||||
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
|
||||
|
||||
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("slides 배열이 필요합니다.");
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase))
|
||||
fullPath += ".pptx";
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
||||
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
if (!Themes.TryGetValue(theme, out var colors))
|
||||
colors = Themes["professional"];
|
||||
|
||||
try
|
||||
{
|
||||
using var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation);
|
||||
var presPart = pres.AddPresentationPart();
|
||||
presPart.Presentation = new P.Presentation();
|
||||
presPart.Presentation.SlideIdList = new SlideIdList();
|
||||
presPart.Presentation.SlideSize = new SlideSize
|
||||
{
|
||||
Cx = 12192000, // 10 inches
|
||||
Cy = 6858000, // 7.5 inches
|
||||
};
|
||||
presPart.Presentation.NotesSize = new NotesSize { Cx = 6858000, Cy = 9144000 };
|
||||
|
||||
uint slideId = 256;
|
||||
int slideCount = 0;
|
||||
|
||||
foreach (var slideEl in slidesEl.EnumerateArray())
|
||||
{
|
||||
var layout = slideEl.TryGetProperty("layout", out var lay) ? lay.GetString() ?? "content" : "content";
|
||||
var slideTitle = slideEl.TryGetProperty("title", out var st) ? st.GetString() ?? "" : "";
|
||||
var subtitle = slideEl.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
|
||||
var body = slideEl.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
|
||||
var left = slideEl.TryGetProperty("left", out var lf) ? lf.GetString() ?? "" : "";
|
||||
var right = slideEl.TryGetProperty("right", out var rt) ? rt.GetString() ?? "" : "";
|
||||
var notes = slideEl.TryGetProperty("notes", out var nt) ? nt.GetString() ?? "" : "";
|
||||
|
||||
var slidePart = presPart.AddNewPart<SlidePart>();
|
||||
slidePart.Slide = new Slide(new CommonSlideData(new ShapeTree(
|
||||
new P.NonVisualGroupShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
|
||||
new P.NonVisualGroupShapeDrawingProperties(),
|
||||
new ApplicationNonVisualDrawingProperties()),
|
||||
new GroupShapeProperties(new A.TransformGroup())
|
||||
)));
|
||||
|
||||
var shapeTree = slidePart.Slide.CommonSlideData!.ShapeTree!;
|
||||
uint shapeId = 2;
|
||||
|
||||
switch (layout)
|
||||
{
|
||||
case "title":
|
||||
// 배경 색상 박스
|
||||
AddRectangle(shapeTree, ref shapeId, 0, 0, 12192000, 6858000, colors.Primary);
|
||||
// 타이틀
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 2000000, 10992000, 1400000,
|
||||
slideTitle, 3600, colors.TextLight, true);
|
||||
// 서브타이틀
|
||||
if (!string.IsNullOrEmpty(subtitle))
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 3600000, 10992000, 800000,
|
||||
subtitle, 2000, colors.TextLight, false);
|
||||
break;
|
||||
|
||||
case "two_column":
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
|
||||
slideTitle, 2800, colors.Primary, true);
|
||||
// 왼쪽 컬럼
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 5200000, 5000000,
|
||||
left, 1600, colors.TextDark, false);
|
||||
// 오른쪽 컬럼
|
||||
AddTextBox(shapeTree, ref shapeId, 6400000, 1300000, 5200000, 5000000,
|
||||
right, 1600, colors.TextDark, false);
|
||||
break;
|
||||
|
||||
case "table":
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
|
||||
slideTitle, 2800, colors.Primary, true);
|
||||
// 테이블은 텍스트로 시뮬레이션 (OpenXML 테이블은 매우 복잡)
|
||||
var tableText = FormatTableAsText(slideEl, colors);
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 10992000, 5000000,
|
||||
tableText, 1400, colors.TextDark, false);
|
||||
break;
|
||||
|
||||
case "blank":
|
||||
// 빈 슬라이드
|
||||
break;
|
||||
|
||||
default: // content
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
|
||||
slideTitle, 2800, colors.Primary, true);
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 10992000, 5000000,
|
||||
body, 1600, colors.TextDark, false);
|
||||
break;
|
||||
}
|
||||
|
||||
// 슬라이드 등록
|
||||
presPart.Presentation.SlideIdList.AppendChild(new SlideId
|
||||
{
|
||||
Id = slideId++,
|
||||
RelationshipId = presPart.GetIdOfPart(slidePart)
|
||||
});
|
||||
slideCount++;
|
||||
}
|
||||
|
||||
presPart.Presentation.Save();
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"✅ PPTX 생성 완료: {Path.GetFileName(fullPath)} ({slideCount}슬라이드, 테마: {theme})",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"PPTX 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddTextBox(ShapeTree tree, ref uint id, long x, long y, long cx, long cy,
|
||||
string text, int fontSize, string color, bool bold)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.NonVisualShapeProperties = new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id++, Name = $"TextBox{id}" },
|
||||
new P.NonVisualShapeDrawingProperties(new A.ShapeLocks { NoGrouping = true }),
|
||||
new ApplicationNonVisualDrawingProperties());
|
||||
shape.ShapeProperties = new ShapeProperties(
|
||||
new A.Transform2D(
|
||||
new A.Offset { X = x, Y = y },
|
||||
new A.Extents { Cx = cx, Cy = cy }),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle });
|
||||
|
||||
var txBody = new TextBody(
|
||||
new A.BodyProperties { Wrap = A.TextWrappingValues.Square },
|
||||
new A.ListStyle());
|
||||
|
||||
// 텍스트를 줄 단위로 분리
|
||||
var lines = text.Split('\n');
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var para = new A.Paragraph();
|
||||
var run = new A.Run();
|
||||
var runProps = new A.RunProperties { Language = "ko-KR", FontSize = fontSize, Dirty = false };
|
||||
runProps.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = color }));
|
||||
if (bold) runProps.Bold = true;
|
||||
run.AppendChild(runProps);
|
||||
run.AppendChild(new A.Text(line));
|
||||
para.AppendChild(run);
|
||||
txBody.AppendChild(para);
|
||||
}
|
||||
|
||||
shape.TextBody = txBody;
|
||||
tree.AppendChild(shape);
|
||||
}
|
||||
|
||||
private static void AddRectangle(ShapeTree tree, ref uint id, long x, long y, long cx, long cy, string fillColor)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.NonVisualShapeProperties = new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id++, Name = $"Rect{id}" },
|
||||
new P.NonVisualShapeDrawingProperties(),
|
||||
new ApplicationNonVisualDrawingProperties());
|
||||
shape.ShapeProperties = new ShapeProperties(
|
||||
new A.Transform2D(
|
||||
new A.Offset { X = x, Y = y },
|
||||
new A.Extents { Cx = cx, Cy = cy }),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
|
||||
new A.SolidFill(new A.RgbColorModelHex { Val = fillColor }));
|
||||
tree.AppendChild(shape);
|
||||
}
|
||||
|
||||
private static string FormatTableAsText(JsonElement slideEl, (string Primary, string Accent, string TextDark, string TextLight, string Bg) colors)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (slideEl.TryGetProperty("headers", out var headers))
|
||||
{
|
||||
var headerTexts = new List<string>();
|
||||
foreach (var h in headers.EnumerateArray())
|
||||
headerTexts.Add(h.GetString() ?? "");
|
||||
sb.AppendLine(string.Join(" | ", headerTexts));
|
||||
sb.AppendLine(new string('─', headerTexts.Sum(h => h.Length + 5)));
|
||||
}
|
||||
|
||||
if (slideEl.TryGetProperty("rows", out var rows))
|
||||
{
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var cells = new List<string>();
|
||||
foreach (var cell in row.EnumerateArray())
|
||||
cells.Add(cell.GetString() ?? cell.ToString());
|
||||
sb.AppendLine(string.Join(" | ", cells));
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
129
src/AxCopilot/Services/Agent/ProcessTool.cs
Normal file
129
src/AxCopilot/Services/Agent/ProcessTool.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>cmd/powershell 명령을 실행하는 도구. 타임아웃 및 위험 명령 차단 포함.</summary>
|
||||
public class ProcessTool : IAgentTool
|
||||
{
|
||||
public string Name => "process";
|
||||
public string Description => "Execute a shell command (cmd or powershell). Returns stdout and stderr. Has a timeout limit.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["command"] = new() { Type = "string", Description = "Command to execute" },
|
||||
["shell"] = new() { Type = "string", Description = "Shell to use: 'cmd' or 'powershell'. Default: 'cmd'.",
|
||||
Enum = ["cmd", "powershell"] },
|
||||
["timeout"] = new() { Type = "integer", Description = "Timeout in seconds. Default: 30, max: 120." },
|
||||
},
|
||||
Required = ["command"]
|
||||
};
|
||||
|
||||
// 위험 명령 패턴 (대소문자 무시)
|
||||
private static readonly string[] DangerousPatterns =
|
||||
[
|
||||
"format ", "del /s", "rd /s", "rmdir /s",
|
||||
"rm -rf", "Remove-Item -Recurse -Force",
|
||||
"Stop-Computer", "Restart-Computer",
|
||||
"shutdown", "taskkill /f",
|
||||
"reg delete", "reg add",
|
||||
"net user", "net localgroup",
|
||||
"schtasks /create", "schtasks /delete",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("command", out var cmdEl))
|
||||
return ToolResult.Fail("command가 필요합니다.");
|
||||
var command = cmdEl.GetString() ?? "";
|
||||
var shell = args.TryGetProperty("shell", out var sh) ? sh.GetString() ?? "cmd" : "cmd";
|
||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return ToolResult.Fail("명령이 비어 있습니다.");
|
||||
|
||||
// 위험 명령 차단
|
||||
foreach (var pattern in DangerousPatterns)
|
||||
{
|
||||
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail($"위험 명령 차단: '{pattern}' 패턴이 감지되었습니다.");
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
if (!await context.CheckWritePermissionAsync(Name, command))
|
||||
return ToolResult.Fail("명령 실행 권한 거부");
|
||||
|
||||
try
|
||||
{
|
||||
var (fileName, arguments) = shell == "powershell"
|
||||
? ("powershell.exe", $"-NoProfile -NonInteractive -Command \"{command.Replace("\"", "\\\"")}\"")
|
||||
: ("cmd.exe", $"/C {command}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
// 작업 폴더 설정
|
||||
if (!string.IsNullOrEmpty(context.WorkFolder) && Directory.Exists(context.WorkFolder))
|
||||
psi.WorkingDirectory = context.WorkFolder;
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
var stdout = new StringBuilder();
|
||||
var stderr = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
return ToolResult.Fail($"명령 실행 타임아웃 ({timeout}초 초과)");
|
||||
}
|
||||
|
||||
var output = stdout.ToString().TrimEnd();
|
||||
var error = stderr.ToString().TrimEnd();
|
||||
|
||||
// 출력 크기 제한 (8000자)
|
||||
if (output.Length > 8000)
|
||||
output = output[..8000] + "\n... (출력 잘림)";
|
||||
|
||||
var result = new StringBuilder();
|
||||
result.AppendLine($"[Exit code: {process.ExitCode}]");
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
result.AppendLine(output);
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
result.AppendLine($"[stderr]\n{error}");
|
||||
|
||||
return process.ExitCode == 0
|
||||
? ToolResult.Ok(result.ToString())
|
||||
: ToolResult.Ok(result.ToString()); // 비정상 종료도 결과로 반환 (LLM이 판단)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"명령 실행 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/AxCopilot/Services/Agent/ProjectRuleTool.cs
Normal file
222
src/AxCopilot/Services/Agent/ProjectRuleTool.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 프로젝트 개발 지침(AGENTS.md) 관리 도구.
|
||||
/// 작업 폴더의 AGENTS.md 파일에 개발 규칙, 코딩 컨벤션, 설계 원칙을 읽고 쓸 수 있습니다.
|
||||
/// 쓰기 시 사용자 승인을 받습니다.
|
||||
/// </summary>
|
||||
public class ProjectRuleTool : IAgentTool
|
||||
{
|
||||
public string Name => "project_rules";
|
||||
|
||||
public string Description =>
|
||||
"프로젝트 개발 지침(AGENTS.md) 및 규칙(.ax/rules/)을 관리합니다.\n" +
|
||||
"- read: 현재 AGENTS.md 내용을 읽습니다\n" +
|
||||
"- append: 새 규칙/지침을 AGENTS.md에 추가합니다 (사용자 승인 필요)\n" +
|
||||
"- write: AGENTS.md를 새 내용으로 덮어씁니다 (사용자 승인 필요)\n" +
|
||||
"- list_rules: .ax/rules/ 디렉토리의 프로젝트 규칙 파일 목록을 조회합니다\n" +
|
||||
"- read_rule: .ax/rules/ 디렉토리의 특정 규칙 파일을 읽습니다\n" +
|
||||
"사용자가 '개발 지침에 추가해', '규칙을 저장해', 'AGENTS.md에 기록해' 등을 요청하면 이 도구를 사용하세요.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "read (AGENTS.md 읽기), append (추가), write (전체 덮어쓰기), list_rules (.ax/rules/ 목록), read_rule (규칙 파일 읽기)",
|
||||
Enum = ["read", "append", "write", "list_rules", "read_rule"]
|
||||
},
|
||||
["rule_name"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "read_rule 시 읽을 규칙 파일 이름 (확장자 제외). 예: 'coding-conventions'"
|
||||
},
|
||||
["content"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "append/write 시 저장할 내용. 마크다운 형식을 권장합니다."
|
||||
},
|
||||
["section"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "append 시 섹션 제목. 예: '코딩 컨벤션', '빌드 규칙'. 비어있으면 파일 끝에 추가."
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
|
||||
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
var axMdPath = FindAxMd(context.WorkFolder) ?? Path.Combine(context.WorkFolder, "AGENTS.md");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"read" => ReadAxMd(axMdPath),
|
||||
"append" => await AppendAxMdAsync(axMdPath, content, section, context),
|
||||
"write" => await WriteAxMdAsync(axMdPath, content, context),
|
||||
"list_rules" => ListRules(context.WorkFolder),
|
||||
"read_rule" => ReadRule(context.WorkFolder, ruleName),
|
||||
_ => ToolResult.Fail($"지원하지 않는 action: {action}. read, append, write, list_rules, read_rule 중 선택하세요.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolResult ReadAxMd(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return ToolResult.Ok($"AGENTS.md 파일이 없습니다.\n경로: {path}\n\n새로 생성하려면 append 또는 write 액션을 사용하세요.");
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(path, Encoding.UTF8);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Ok("AGENTS.md 파일이 비어 있습니다.");
|
||||
|
||||
return ToolResult.Ok($"[AGENTS.md 내용 ({content.Length}자)]\n\n{content}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"AGENTS.md 읽기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> AppendAxMdAsync(string path, string content, string section, AgentContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("추가할 content가 필요합니다.");
|
||||
|
||||
// 사용자 승인
|
||||
var desc = $"AGENTS.md에 개발 지침을 추가합니다:\n{(content.Length > 200 ? content[..200] + "..." : content)}";
|
||||
if (!await context.CheckWritePermissionAsync("project_rules", desc))
|
||||
return ToolResult.Ok("사용자가 AGENTS.md 수정을 거부했습니다.");
|
||||
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// 기존 내용 보존
|
||||
if (File.Exists(path))
|
||||
sb.Append(File.ReadAllText(path, Encoding.UTF8));
|
||||
|
||||
// 구분선 + 섹션 제목
|
||||
if (sb.Length > 0 && !sb.ToString().EndsWith('\n'))
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
|
||||
if (!string.IsNullOrEmpty(section))
|
||||
sb.AppendLine($"## {section}");
|
||||
|
||||
sb.AppendLine(content.Trim());
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
return ToolResult.Ok($"AGENTS.md에 개발 지침이 추가되었습니다.\n경로: {path}\n추가된 내용 ({content.Length}자):\n{content}", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"AGENTS.md 쓰기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> WriteAxMdAsync(string path, string content, AgentContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("저장할 content가 필요합니다.");
|
||||
|
||||
var desc = $"AGENTS.md를 전체 덮어씁니다 ({content.Length}자):\n{(content.Length > 200 ? content[..200] + "..." : content)}";
|
||||
if (!await context.CheckWritePermissionAsync("project_rules", desc))
|
||||
return ToolResult.Ok("사용자가 AGENTS.md 수정을 거부했습니다.");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, content, Encoding.UTF8);
|
||||
return ToolResult.Ok($"AGENTS.md가 저장되었습니다.\n경로: {path}\n내용 ({content.Length}자)", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"AGENTS.md 쓰기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ListRules(string workFolder)
|
||||
{
|
||||
var rules = ProjectRulesService.LoadRules(workFolder);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
var rulesDir = ProjectRulesService.FindRulesDirectory(workFolder);
|
||||
var suggestedPath = rulesDir ?? Path.Combine(workFolder, ".ax", "rules");
|
||||
return ToolResult.Ok(
|
||||
$"프로젝트 규칙이 없습니다.\n" +
|
||||
$"규칙 파일을 추가하려면 {suggestedPath} 디렉토리에 .md 파일을 생성하세요.\n\n" +
|
||||
"예시 규칙 파일 형식:\n" +
|
||||
"---\nname: 코딩 컨벤션\ndescription: C# 코딩 규칙\napplies-to: \"*.cs\"\nwhen: always\n---\n\n규칙 내용...");
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[프로젝트 규칙 {rules.Count}개]\n");
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
sb.AppendLine($" • {rule.Name}");
|
||||
if (!string.IsNullOrEmpty(rule.Description))
|
||||
sb.AppendLine($" 설명: {rule.Description}");
|
||||
if (!string.IsNullOrEmpty(rule.AppliesTo))
|
||||
sb.AppendLine($" 적용 대상: {rule.AppliesTo}");
|
||||
sb.AppendLine($" 적용 시점: {rule.When}");
|
||||
sb.AppendLine($" 파일: {rule.FilePath}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult ReadRule(string workFolder, string ruleName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ruleName))
|
||||
return ToolResult.Fail("rule_name 파라미터가 필요합니다.");
|
||||
|
||||
var rules = ProjectRulesService.LoadRules(workFolder);
|
||||
var rule = rules.FirstOrDefault(r =>
|
||||
r.Name.Equals(ruleName, StringComparison.OrdinalIgnoreCase) ||
|
||||
Path.GetFileNameWithoutExtension(r.FilePath).Equals(ruleName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (rule == null)
|
||||
return ToolResult.Fail($"규칙 '{ruleName}'을(를) 찾을 수 없습니다. list_rules로 목록을 확인하세요.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[규칙: {rule.Name}]");
|
||||
if (!string.IsNullOrEmpty(rule.Description)) sb.AppendLine($"설명: {rule.Description}");
|
||||
if (!string.IsNullOrEmpty(rule.AppliesTo)) sb.AppendLine($"적용 대상: {rule.AppliesTo}");
|
||||
sb.AppendLine($"적용 시점: {rule.When}");
|
||||
sb.AppendLine($"파일: {rule.FilePath}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(rule.Body);
|
||||
return ToolResult.Ok(sb.ToString(), rule.FilePath);
|
||||
}
|
||||
|
||||
/// <summary>AGENTS.md 파일을 작업 폴더에서 최대 3단계 상위까지 탐색합니다 (없으면 AX.md 폴백).</summary>
|
||||
private static string? FindAxMd(string workFolder)
|
||||
{
|
||||
var dir = workFolder;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) break;
|
||||
var agentsPath = Path.Combine(dir, "AGENTS.md");
|
||||
if (File.Exists(agentsPath)) return agentsPath;
|
||||
var legacyPath = Path.Combine(dir, "AX.md");
|
||||
if (File.Exists(legacyPath)) return legacyPath;
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
206
src/AxCopilot/Services/Agent/ProjectRulesService.cs
Normal file
206
src/AxCopilot/Services/Agent/ProjectRulesService.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 프로젝트별 규칙 파일(.ax/rules/*.md)을 로드하고 컨텍스트에 맞는 규칙을 선별합니다.
|
||||
/// 각 규칙 파일은 YAML 프론트매터로 적용 조건을 정의합니다.
|
||||
/// </summary>
|
||||
public static class ProjectRulesService
|
||||
{
|
||||
/// <summary>파싱된 프로젝트 규칙.</summary>
|
||||
public class ProjectRule
|
||||
{
|
||||
/// <summary>규칙 파일 경로.</summary>
|
||||
public string FilePath { get; set; } = "";
|
||||
|
||||
/// <summary>규칙 이름 (프론트매터 name 또는 파일명).</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>규칙 설명.</summary>
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
/// <summary>적용 대상 글로브 패턴. 예: "*.cs", "src/**/*.ts"</summary>
|
||||
public string AppliesTo { get; set; } = "";
|
||||
|
||||
/// <summary>적용 시점. 예: "always", "code-review", "document", "refactor"</summary>
|
||||
public string When { get; set; } = "always";
|
||||
|
||||
/// <summary>규칙 본문 (프론트매터 제외).</summary>
|
||||
public string Body { get; set; } = "";
|
||||
}
|
||||
|
||||
// YAML 프론트매터 경계
|
||||
private static readonly Regex FrontMatterRegex = new(
|
||||
@"^---\s*\n(.*?)\n---\s*\n",
|
||||
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
|
||||
// YAML 키-값 파싱 (단순 1줄 값만)
|
||||
private static readonly Regex YamlKeyValue = new(
|
||||
@"^\s*(\w[\w-]*)\s*:\s*(.+?)\s*$",
|
||||
RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// 작업 폴더에서 .ax/rules/ 디렉토리의 모든 규칙을 로드합니다.
|
||||
/// 최대 3단계 상위 폴더까지 .ax/rules/ 를 탐색합니다.
|
||||
/// </summary>
|
||||
public static List<ProjectRule> LoadRules(string workFolder)
|
||||
{
|
||||
var rules = new List<ProjectRule>();
|
||||
if (string.IsNullOrEmpty(workFolder)) return rules;
|
||||
|
||||
var rulesDir = FindRulesDirectory(workFolder);
|
||||
if (rulesDir == null) return rules;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(rulesDir, "*.md"))
|
||||
{
|
||||
var rule = ParseRuleFile(file);
|
||||
if (rule != null)
|
||||
rules.Add(rule);
|
||||
}
|
||||
}
|
||||
catch { /* 디렉토리 읽기 실패 시 빈 목록 반환 */ }
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 컨텍스트에 맞는 규칙만 필터링합니다.
|
||||
/// </summary>
|
||||
/// <param name="rules">전체 규칙 목록</param>
|
||||
/// <param name="when">현재 컨텍스트 (예: "code-review", "document", "always")</param>
|
||||
/// <param name="filePaths">현재 작업 대상 파일 경로들 (applies-to 매칭용)</param>
|
||||
public static List<ProjectRule> FilterRules(
|
||||
List<ProjectRule> rules, string when = "always", IEnumerable<string>? filePaths = null)
|
||||
{
|
||||
var result = new List<ProjectRule>();
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
// when 조건 체크
|
||||
var ruleWhen = rule.When.ToLowerInvariant().Trim();
|
||||
if (ruleWhen != "always" && ruleWhen != when.ToLowerInvariant())
|
||||
continue;
|
||||
|
||||
// applies-to 조건 체크
|
||||
if (!string.IsNullOrEmpty(rule.AppliesTo) && filePaths != null)
|
||||
{
|
||||
var pattern = rule.AppliesTo.Trim();
|
||||
if (!filePaths.Any(fp => MatchesGlob(fp, pattern)))
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 규칙 목록을 시스템 프롬프트용 텍스트로 포맷합니다.
|
||||
/// </summary>
|
||||
public static string FormatForSystemPrompt(List<ProjectRule> rules)
|
||||
{
|
||||
if (rules.Count == 0) return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("\n## 프로젝트 규칙 (.ax/rules/)");
|
||||
sb.AppendLine("아래 규칙을 반드시 준수하세요:\n");
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(rule.Name))
|
||||
sb.AppendLine($"### {rule.Name}");
|
||||
if (!string.IsNullOrEmpty(rule.Description))
|
||||
sb.AppendLine($"*{rule.Description}*\n");
|
||||
sb.AppendLine(rule.Body.Trim());
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>.ax/rules/ 디렉토리를 작업 폴더에서 최대 3단계 상위까지 탐색합니다.</summary>
|
||||
internal static string? FindRulesDirectory(string workFolder)
|
||||
{
|
||||
var dir = workFolder;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) break;
|
||||
var rulesPath = Path.Combine(dir, ".ax", "rules");
|
||||
if (Directory.Exists(rulesPath)) return rulesPath;
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>규칙 파일을 파싱합니다 (YAML 프론트매터 + 본문).</summary>
|
||||
internal static ProjectRule? ParseRuleFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(filePath, Encoding.UTF8);
|
||||
if (string.IsNullOrWhiteSpace(content)) return null;
|
||||
|
||||
var rule = new ProjectRule
|
||||
{
|
||||
FilePath = filePath,
|
||||
Name = Path.GetFileNameWithoutExtension(filePath),
|
||||
};
|
||||
|
||||
// 프론트매터 파싱
|
||||
var fmMatch = FrontMatterRegex.Match(content);
|
||||
if (fmMatch.Success)
|
||||
{
|
||||
var yaml = fmMatch.Groups[1].Value;
|
||||
foreach (Match kv in YamlKeyValue.Matches(yaml))
|
||||
{
|
||||
var key = kv.Groups[1].Value.ToLowerInvariant();
|
||||
var val = kv.Groups[2].Value.Trim().Trim('"', '\'');
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "name": rule.Name = val; break;
|
||||
case "description": rule.Description = val; break;
|
||||
case "applies-to" or "appliesto": rule.AppliesTo = val; break;
|
||||
case "when": rule.When = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
rule.Body = content[(fmMatch.Index + fmMatch.Length)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
rule.Body = content;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(rule.Body) ? null : rule;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>간단한 글로브 패턴 매칭 (*, ** 지원).</summary>
|
||||
private static bool MatchesGlob(string path, string pattern)
|
||||
{
|
||||
// "*.cs" → 확장자 매칭
|
||||
if (pattern.StartsWith("*."))
|
||||
return path.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// "**/*.cs" → 경로 내 확장자 매칭
|
||||
if (pattern.StartsWith("**/"))
|
||||
{
|
||||
var subPattern = pattern[3..];
|
||||
return MatchesGlob(Path.GetFileName(path), subPattern);
|
||||
}
|
||||
|
||||
// 정확한 파일명 매칭
|
||||
return Path.GetFileName(path).Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
188
src/AxCopilot/Services/Agent/RegexTool.cs
Normal file
188
src/AxCopilot/Services/Agent/RegexTool.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 정규식 테스트·추출·치환 도구.
|
||||
/// 패턴 매칭, 그룹 추출, 치환, 패턴 설명 기능을 제공합니다.
|
||||
/// </summary>
|
||||
public class RegexTool : IAgentTool
|
||||
{
|
||||
public string Name => "regex_tool";
|
||||
public string Description =>
|
||||
"Regular expression tool. Actions: " +
|
||||
"'test' — check if text matches a pattern; " +
|
||||
"'match' — find all matches with groups; " +
|
||||
"'replace' — replace matches with replacement string; " +
|
||||
"'split' — split text by pattern; " +
|
||||
"'extract' — extract named/numbered groups from first match.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["test", "match", "replace", "split", "extract"],
|
||||
},
|
||||
["pattern"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Regular expression pattern",
|
||||
},
|
||||
["text"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Text to process",
|
||||
},
|
||||
["replacement"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Replacement string for replace action (supports $1, $2, ${name})",
|
||||
},
|
||||
["flags"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Regex flags: 'i' (ignore case), 'm' (multiline), 's' (singleline). Combine: 'im'",
|
||||
},
|
||||
},
|
||||
Required = ["action", "pattern", "text"],
|
||||
};
|
||||
|
||||
private const int MaxTimeout = 5000; // ReDoS 방지
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var text = args.GetProperty("text").GetString() ?? "";
|
||||
var replacement = args.TryGetProperty("replacement", out var r) ? r.GetString() ?? "" : "";
|
||||
var flags = args.TryGetProperty("flags", out var f) ? f.GetString() ?? "" : "";
|
||||
|
||||
try
|
||||
{
|
||||
var options = ParseFlags(flags);
|
||||
var regex = new Regex(pattern, options, TimeSpan.FromMilliseconds(MaxTimeout));
|
||||
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"test" => Test(regex, text),
|
||||
"match" => Match(regex, text),
|
||||
"replace" => Replace(regex, text, replacement),
|
||||
"split" => Split(regex, text),
|
||||
"extract" => Extract(regex, text),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail("정규식 실행 시간 초과 (ReDoS 방지). 패턴을 간소화하세요."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"정규식 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static RegexOptions ParseFlags(string flags)
|
||||
{
|
||||
var options = RegexOptions.None;
|
||||
foreach (var c in flags)
|
||||
{
|
||||
options |= c switch
|
||||
{
|
||||
'i' => RegexOptions.IgnoreCase,
|
||||
'm' => RegexOptions.Multiline,
|
||||
's' => RegexOptions.Singleline,
|
||||
_ => RegexOptions.None,
|
||||
};
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private static ToolResult Test(Regex regex, string text)
|
||||
{
|
||||
var isMatch = regex.IsMatch(text);
|
||||
return ToolResult.Ok(isMatch ? "✓ Pattern matches" : "✗ No match");
|
||||
}
|
||||
|
||||
private static ToolResult Match(Regex regex, string text)
|
||||
{
|
||||
var matches = regex.Matches(text);
|
||||
if (matches.Count == 0)
|
||||
return ToolResult.Ok("No matches found.");
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"Found {matches.Count} match(es):");
|
||||
var limit = Math.Min(matches.Count, 50); // 최대 50개
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
var m = matches[i];
|
||||
sb.AppendLine($"\n[{i}] \"{Truncate(m.Value, 200)}\" (index {m.Index}, length {m.Length})");
|
||||
if (m.Groups.Count > 1)
|
||||
{
|
||||
for (var g = 1; g < m.Groups.Count; g++)
|
||||
{
|
||||
var group = m.Groups[g];
|
||||
var name = regex.GroupNameFromNumber(g);
|
||||
var label = name != g.ToString() ? $"'{name}'" : $"${g}";
|
||||
sb.AppendLine($" Group {label}: \"{Truncate(group.Value, 100)}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matches.Count > limit)
|
||||
sb.AppendLine($"\n... and {matches.Count - limit} more matches");
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult Replace(Regex regex, string text, string replacement)
|
||||
{
|
||||
var result = regex.Replace(text, replacement);
|
||||
var count = regex.Matches(text).Count;
|
||||
if (result.Length > 8000)
|
||||
result = result[..8000] + "\n... (truncated)";
|
||||
return ToolResult.Ok($"Replaced {count} occurrence(s):\n\n{result}");
|
||||
}
|
||||
|
||||
private static ToolResult Split(Regex regex, string text)
|
||||
{
|
||||
var parts = regex.Split(text);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"Split into {parts.Length} parts:");
|
||||
var limit = Math.Min(parts.Length, 100);
|
||||
for (var i = 0; i < limit; i++)
|
||||
sb.AppendLine($" [{i}] \"{Truncate(parts[i], 200)}\"");
|
||||
if (parts.Length > limit)
|
||||
sb.AppendLine($"\n... and {parts.Length - limit} more parts");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult Extract(Regex regex, string text)
|
||||
{
|
||||
var m = regex.Match(text);
|
||||
if (!m.Success)
|
||||
return ToolResult.Ok("No match found.");
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"Match: \"{Truncate(m.Value, 300)}\"");
|
||||
if (m.Groups.Count > 1)
|
||||
{
|
||||
sb.AppendLine("\nGroups:");
|
||||
for (var g = 1; g < m.Groups.Count; g++)
|
||||
{
|
||||
var group = m.Groups[g];
|
||||
var name = regex.GroupNameFromNumber(g);
|
||||
var label = name != g.ToString() ? $"'{name}'" : $"${g}";
|
||||
sb.AppendLine($" {label}: \"{Truncate(group.Value, 200)}\"");
|
||||
}
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int maxLen) =>
|
||||
s.Length <= maxLen ? s : s[..maxLen] + "…";
|
||||
}
|
||||
132
src/AxCopilot/Services/Agent/SkillManagerTool.cs
Normal file
132
src/AxCopilot/Services/Agent/SkillManagerTool.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 관리 에이전트 도구.
|
||||
/// 로드된 스킬 목록 조회, 스킬 정보 확인, 스킬 실행을 지원합니다.
|
||||
/// </summary>
|
||||
public class SkillManagerTool : IAgentTool
|
||||
{
|
||||
public string Name => "skill_manager";
|
||||
|
||||
public string Description =>
|
||||
"마크다운 기반 스킬(워크플로우)을 관리합니다.\n" +
|
||||
"- list: 사용 가능한 스킬 목록 조회\n" +
|
||||
"- info: 특정 스킬의 상세 정보 확인\n" +
|
||||
"- reload: 스킬 폴더를 다시 스캔하여 새 스킬 로드";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "list (목록), info (상세정보), reload (재로드)",
|
||||
Enum = ["list", "info", "reload"]
|
||||
},
|
||||
["skill_name"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "스킬 이름 (info 액션에서 사용)"
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
|
||||
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
|
||||
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : "";
|
||||
|
||||
return action switch
|
||||
{
|
||||
"list" => ListSkills(),
|
||||
"info" => InfoSkill(skillName),
|
||||
"reload" => ReloadSkills(app),
|
||||
_ => ToolResult.Fail($"지원하지 않는 action: {action}. list, info, reload 중 선택하세요.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolResult ListSkills()
|
||||
{
|
||||
var skills = SkillService.Skills;
|
||||
if (skills.Count == 0)
|
||||
return ToolResult.Ok("로드된 스킬이 없습니다. %APPDATA%\\AxCopilot\\skills\\에 *.skill.md 파일을 추가하세요.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"사용 가능한 스킬 ({skills.Count}개):\n");
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
var execBadge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase)
|
||||
? "[FORK]"
|
||||
: "[DIRECT]";
|
||||
sb.AppendLine($" /{skill.Name} {execBadge} — {skill.Label}");
|
||||
sb.AppendLine($" {skill.Description}");
|
||||
if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase))
|
||||
sb.AppendLine(" 실행 방식: 위임 우선 (spawn_agent → wait_agents)");
|
||||
if (!string.IsNullOrWhiteSpace(skill.AllowedTools))
|
||||
sb.AppendLine($" allowed-tools: {skill.AllowedTools}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.Hooks))
|
||||
sb.AppendLine($" hooks: {skill.Hooks}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.HookFilters))
|
||||
sb.AppendLine($" hook-filters: {skill.HookFilters}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine("슬래시 명령어(/{name})로 호출하거나, 대화에서 해당 워크플로우를 요청할 수 있습니다.");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult InfoSkill(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return ToolResult.Fail("skill_name이 필요합니다.");
|
||||
|
||||
var skill = SkillService.Find(name);
|
||||
if (skill == null)
|
||||
return ToolResult.Fail($"'{name}' 스킬을 찾을 수 없습니다. skill_manager(action: list)로 목록을 확인하세요.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"스킬 상세: {skill.Label} (/{skill.Name})");
|
||||
sb.AppendLine($"설명: {skill.Description}");
|
||||
sb.AppendLine($"파일: {skill.FilePath}");
|
||||
if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase))
|
||||
sb.AppendLine("실행 배지: [FORK] · 위임 우선 실행");
|
||||
else
|
||||
sb.AppendLine("실행 배지: [DIRECT] · 일반 실행");
|
||||
if (!string.IsNullOrWhiteSpace(skill.ExecutionContext))
|
||||
sb.AppendLine($"context: {skill.ExecutionContext}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.Agent))
|
||||
sb.AppendLine($"agent: {skill.Agent}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.Effort))
|
||||
sb.AppendLine($"effort: {skill.Effort}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.Model))
|
||||
sb.AppendLine($"model: {skill.Model}");
|
||||
if (skill.DisableModelInvocation)
|
||||
sb.AppendLine("disable-model-invocation: true");
|
||||
if (!string.IsNullOrWhiteSpace(skill.AllowedTools))
|
||||
sb.AppendLine($"allowed-tools: {skill.AllowedTools}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.Hooks))
|
||||
sb.AppendLine($"hooks: {skill.Hooks}");
|
||||
if (!string.IsNullOrWhiteSpace(skill.HookFilters))
|
||||
sb.AppendLine($"hook-filters: {skill.HookFilters}");
|
||||
var runtimeDirective = SkillService.BuildRuntimeDirective(skill);
|
||||
if (!string.IsNullOrWhiteSpace(runtimeDirective))
|
||||
sb.AppendLine($"\n--- 런타임 정책 ---\n{runtimeDirective}");
|
||||
sb.AppendLine($"\n--- 시스템 프롬프트 ---\n{skill.SystemPrompt}");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult ReloadSkills(App? app)
|
||||
{
|
||||
var customFolder = app?.SettingsService?.Settings.Llm.SkillsFolderPath ?? "";
|
||||
SkillService.LoadSkills(customFolder);
|
||||
return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨.");
|
||||
}
|
||||
}
|
||||
964
src/AxCopilot/Services/Agent/SkillService.cs
Normal file
964
src/AxCopilot/Services/Agent/SkillService.cs
Normal file
@@ -0,0 +1,964 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 마크다운 기반 스킬 정의를 로드/관리하는 서비스.
|
||||
/// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다.
|
||||
/// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다.
|
||||
/// </summary>
|
||||
public static class SkillService
|
||||
{
|
||||
private static List<SkillDefinition> _skills = new();
|
||||
private static string _lastFolder = "";
|
||||
private static readonly HashSet<string> _activeConditionalSkillNames = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>로드된 스킬 목록.</summary>
|
||||
public static IReadOnlyList<SkillDefinition> Skills => _skills;
|
||||
|
||||
/// <summary>스킬 폴더에서 *.skill.md 파일을 로드합니다.</summary>
|
||||
public static void LoadSkills(string? customFolder = null)
|
||||
{
|
||||
var folders = new List<string>();
|
||||
|
||||
// 1) 앱 기본 스킬 폴더
|
||||
var defaultFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills");
|
||||
if (Directory.Exists(defaultFolder)) folders.Add(defaultFolder);
|
||||
|
||||
// 2) 사용자 스킬 폴더 (%APPDATA%\AxCopilot\skills\)
|
||||
var appDataFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "skills");
|
||||
if (Directory.Exists(appDataFolder)) folders.Add(appDataFolder);
|
||||
|
||||
// 3) 사용자 지정 폴더
|
||||
if (!string.IsNullOrEmpty(customFolder) && Directory.Exists(customFolder))
|
||||
folders.Add(customFolder);
|
||||
|
||||
var allSkills = new List<SkillDefinition>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
// 1) 기존 형식: *.skill.md 파일
|
||||
foreach (var file in Directory.GetFiles(folder, "*.skill.md"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var skill = ParseSkillFile(file);
|
||||
if (skill != null && seen.Add(skill.Name))
|
||||
allSkills.Add(skill);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"스킬 로드 실패 [{file}]: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) SKILL.md 표준: 하위폴더/SKILL.md 구조
|
||||
try
|
||||
{
|
||||
foreach (var subDir in Directory.GetDirectories(folder))
|
||||
{
|
||||
var skillMd = Path.Combine(subDir, "SKILL.md");
|
||||
if (!File.Exists(skillMd)) continue;
|
||||
try
|
||||
{
|
||||
var skill = ParseSkillFile(skillMd);
|
||||
if (skill != null && seen.Add(skill.Name))
|
||||
allSkills.Add(skill);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"스킬 로드 실패 [{skillMd}]: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* 폴더 접근 오류 무시 */ }
|
||||
}
|
||||
|
||||
// 런타임 의존성 검증
|
||||
foreach (var skill in allSkills)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(skill.Requires))
|
||||
{
|
||||
var runtimes = skill.Requires.Split(',').Select(r => r.Trim());
|
||||
skill.IsAvailable = runtimes.All(r => RuntimeDetector.IsAvailable(r));
|
||||
}
|
||||
}
|
||||
|
||||
_skills = allSkills;
|
||||
_lastFolder = customFolder ?? "";
|
||||
_activeConditionalSkillNames.Clear();
|
||||
var unavailCount = allSkills.Count(s => !s.IsAvailable);
|
||||
LogService.Info($"스킬 {allSkills.Count}개 로드 완료" +
|
||||
(unavailCount > 0 ? $" (런타임 미충족 {unavailCount}개)" : ""));
|
||||
}
|
||||
|
||||
/// <summary>스킬 이름으로 검색합니다.</summary>
|
||||
public static SkillDefinition? Find(string name) =>
|
||||
_skills.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>슬래시 명령어 매칭용: /로 시작하는 텍스트에 매칭되는 스킬 목록.</summary>
|
||||
public static List<SkillDefinition> MatchSlashCommand(string input)
|
||||
{
|
||||
if (!input.StartsWith('/')) return new();
|
||||
return _skills
|
||||
.Where(IsSkillInvocableNow)
|
||||
.Where(s => ("/" + s.Name).StartsWith(input, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>슬래시 입력 전체 문자열에서 실제 실행 가능한 스킬을 찾습니다.</summary>
|
||||
public static SkillDefinition? MatchSlashInvocation(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input) || !input.StartsWith('/')) return null;
|
||||
|
||||
// 접두어 충돌 방지: 긴 이름 우선
|
||||
foreach (var skill in _skills
|
||||
.Where(IsSkillInvocableNow)
|
||||
.OrderByDescending(s => s.Name.Length))
|
||||
{
|
||||
var slashCmd = "/" + skill.Name;
|
||||
if (input.StartsWith(slashCmd, StringComparison.OrdinalIgnoreCase))
|
||||
return skill;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 메타데이터(context/agent/effort/model 등)를
|
||||
/// 런타임 시스템 지시문으로 변환합니다.
|
||||
/// </summary>
|
||||
public static string BuildRuntimeDirective(SkillDefinition skill)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(skill.Model))
|
||||
lines.Add($"- preferred_model: {skill.Model.Trim()}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(skill.Effort))
|
||||
lines.Add($"- reasoning_effort: {NormalizeEffort(skill.Effort)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(skill.ExecutionContext) &&
|
||||
skill.ExecutionContext.Trim().Equals("fork", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lines.Add("- execution_context: fork");
|
||||
lines.Add("- guidance: 작업을 분리 가능한 경우 spawn_agent로 위임하고, 필요할 때만 wait_agents를 사용하세요.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(skill.Agent))
|
||||
lines.Add($"- preferred_agent: {skill.Agent.Trim()}");
|
||||
|
||||
var allowedTools = ParseAllowedToolsForRuntimePolicy(skill.AllowedTools);
|
||||
if (allowedTools.Count > 0)
|
||||
lines.Add($"- allowed_tools: {string.Join(", ", allowedTools)}");
|
||||
|
||||
var hookNames = ParseHookNamesForRuntimePolicy(skill.Hooks);
|
||||
if (hookNames.Count > 0)
|
||||
lines.Add($"- hook_names: {string.Join(", ", hookNames)}");
|
||||
var hookFilters = ParseHookNamesForRuntimePolicy(skill.HookFilters);
|
||||
if (hookFilters.Count > 0)
|
||||
lines.Add($"- hook_filters: {string.Join(", ", hookFilters)}");
|
||||
|
||||
if (skill.DisableModelInvocation)
|
||||
lines.Add("- disable_model_invocation: 가능하면 도구 실행과 기존 컨텍스트를 우선 사용하고, 불필요한 추가 추론을 줄이세요.");
|
||||
|
||||
if (lines.Count == 0)
|
||||
return "";
|
||||
|
||||
return "[Skill Runtime Policy]\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// paths 전면조건 스킬 활성화.
|
||||
/// claw-code의 conditional skill 활성화 패턴과 동일하게
|
||||
/// file path 입력이 매칭될 때만 동적으로 활성화합니다.
|
||||
/// </summary>
|
||||
public static string[] ActivateConditionalSkillsForPaths(IEnumerable<string> filePaths, string cwd)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cwd) || !Directory.Exists(cwd))
|
||||
return [];
|
||||
|
||||
var activated = new List<string>();
|
||||
foreach (var skill in _skills)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skill.Paths) || _activeConditionalSkillNames.Contains(skill.Name))
|
||||
continue;
|
||||
|
||||
var patterns = ParsePathPatterns(skill.Paths);
|
||||
if (patterns.Count == 0)
|
||||
continue;
|
||||
|
||||
foreach (var filePath in filePaths)
|
||||
{
|
||||
var relativePath = ToRelativePathIfUnderCwd(filePath, cwd);
|
||||
if (string.IsNullOrEmpty(relativePath))
|
||||
continue;
|
||||
|
||||
if (PathMatchesPatterns(relativePath, patterns))
|
||||
{
|
||||
_activeConditionalSkillNames.Add(skill.Name);
|
||||
activated.Add(skill.Name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activated.Count > 0)
|
||||
LogService.Info($"조건부 스킬 활성화: {string.Join(", ", activated)}");
|
||||
|
||||
return activated.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>조건부 스킬 활성화 상태를 초기화합니다.</summary>
|
||||
public static void ResetConditionalSkillActivation() => _activeConditionalSkillNames.Clear();
|
||||
|
||||
/// <summary>스킬 폴더가 없으면 생성하고 예제 스킬을 배치합니다.</summary>
|
||||
public static void EnsureSkillFolder()
|
||||
{
|
||||
var folder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "skills");
|
||||
if (!Directory.Exists(folder))
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
// 예제 스킬이 없으면 생성
|
||||
CreateExampleSkill(folder, "daily-standup.skill.md",
|
||||
"daily-standup", "데일리 스탠드업",
|
||||
"작업 폴더의 최근 변경사항을 요약하여 데일리 스탠드업 보고서를 생성합니다.",
|
||||
"""
|
||||
작업 폴더의 Git 상태와 최근 커밋을 분석하여 데일리 스탠드업 보고서를 작성하세요.
|
||||
|
||||
다음 도구를 사용하세요:
|
||||
1. git_tool (action: log, args: "--oneline -10") — 최근 커밋 확인
|
||||
2. git_tool (action: status) — 현재 변경사항 확인
|
||||
3. git_tool (action: diff, args: "--stat") — 변경 파일 통계
|
||||
|
||||
보고서 형식:
|
||||
## 📋 데일리 스탠드업 보고서
|
||||
|
||||
### ✅ 완료한 작업
|
||||
- 최근 커밋 기반으로 정리
|
||||
|
||||
### 🔄 진행 중인 작업
|
||||
- 현재 수정 중인 파일 기반
|
||||
|
||||
### ⚠️ 블로커/이슈
|
||||
- TODO/FIXME가 있으면 표시
|
||||
|
||||
한국어로 작성하세요.
|
||||
""");
|
||||
|
||||
CreateExampleSkill(folder, "bug-hunt.skill.md",
|
||||
"bug-hunt", "버그 탐색",
|
||||
"작업 폴더에서 잠재적 버그 패턴을 검색합니다.",
|
||||
"""
|
||||
작업 폴더의 코드에서 잠재적 버그 패턴을 찾아 보고하세요.
|
||||
|
||||
다음 도구를 사용하세요:
|
||||
1. grep_tool — 위험 패턴 검색:
|
||||
- 빈 catch 블록: catch\s*\{\s*\}
|
||||
- TODO/FIXME: (TODO|FIXME|HACK|XXX)
|
||||
- .Result/.Wait(): \.(Result|Wait\(\))
|
||||
- 하드코딩된 자격증명: (password|secret|apikey)\s*=\s*"
|
||||
2. code_review (action: diff_review, focus: bugs) — 최근 변경사항 버그 검사
|
||||
|
||||
결과를 심각도별로 분류하여 보고하세요:
|
||||
- 🔴 CRITICAL: 즉시 수정 필요
|
||||
- 🟡 WARNING: 검토 필요
|
||||
- 🔵 INFO: 개선 권장
|
||||
|
||||
한국어로 작성하세요.
|
||||
""");
|
||||
|
||||
CreateExampleSkill(folder, "code-explain.skill.md",
|
||||
"code-explain", "코드 설명",
|
||||
"지정한 파일의 코드를 상세히 설명합니다.",
|
||||
"""
|
||||
사용자가 지정한 파일 또는 작업 폴더의 주요 파일을 읽고 상세히 설명하세요.
|
||||
|
||||
다음 도구를 사용하세요:
|
||||
1. file_read — 파일 내용 읽기
|
||||
2. folder_map — 프로젝트 구조 파악 (필요시)
|
||||
|
||||
설명 포함 사항:
|
||||
- 파일의 역할과 책임
|
||||
- 주요 클래스/함수의 목적
|
||||
- 데이터 흐름
|
||||
- 외부 의존성
|
||||
- 개선 포인트 (있다면)
|
||||
|
||||
한국어로 쉽게 설명하세요. 코드 블록을 활용하여 핵심 부분을 인용하세요.
|
||||
""");
|
||||
}
|
||||
|
||||
// ─── 가져오기/내보내기 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 스킬을 zip 파일로 내보냅니다.
|
||||
/// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더).
|
||||
/// </summary>
|
||||
/// <returns>생성된 zip 파일 경로. 실패 시 null.</returns>
|
||||
public static string? ExportSkill(SkillDefinition skill, string outputDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(skill.FilePath))
|
||||
{
|
||||
LogService.Warn($"스킬 내보내기 실패: 파일 없음 — {skill.FilePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var zipName = $"{skill.Name}.skill.zip";
|
||||
var zipPath = Path.Combine(outputDir, zipName);
|
||||
|
||||
// 기존 파일이 있으면 삭제
|
||||
if (File.Exists(zipPath)) File.Delete(zipPath);
|
||||
|
||||
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
|
||||
|
||||
if (skill.IsStandardFormat)
|
||||
{
|
||||
// SKILL.md 표준: 전체 폴더를 zip에 추가
|
||||
var skillDir = Path.GetDirectoryName(skill.FilePath);
|
||||
if (skillDir != null && Directory.Exists(skillDir))
|
||||
{
|
||||
var baseName = Path.GetFileName(skillDir);
|
||||
foreach (var file in Directory.EnumerateFiles(skillDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
// 실행 가능 파일 제외
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (ext is ".exe" or ".dll" or ".bat" or ".cmd" or ".ps1" or ".sh") continue;
|
||||
|
||||
var entryName = baseName + "/" + Path.GetRelativePath(skillDir, file).Replace('\\', '/');
|
||||
zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// *.skill.md 파일 단독
|
||||
var entryName = $"{skill.Name}/{Path.GetFileName(skill.FilePath)}";
|
||||
zip.CreateEntryFromFile(skill.FilePath, entryName, CompressionLevel.Optimal);
|
||||
}
|
||||
|
||||
LogService.Info($"스킬 내보내기 완료: {zipPath}");
|
||||
return zipPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"스킬 내보내기 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// zip 파일에서 스킬을 가져옵니다.
|
||||
/// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다.
|
||||
/// </summary>
|
||||
/// <returns>가져온 스킬 수. 0이면 실패.</returns>
|
||||
public static int ImportSkills(string zipPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(zipPath))
|
||||
{
|
||||
LogService.Warn($"스킬 가져오기 실패: 파일 없음 — {zipPath}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var userFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "skills");
|
||||
if (!Directory.Exists(userFolder))
|
||||
Directory.CreateDirectory(userFolder);
|
||||
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
|
||||
// 보안 검증: 실행 가능 파일 차단
|
||||
var dangerousExts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" };
|
||||
foreach (var entry in zip.Entries)
|
||||
{
|
||||
if (dangerousExts.Contains(Path.GetExtension(entry.Name)))
|
||||
{
|
||||
LogService.Warn($"스킬 가져오기 차단: 실행 가능 파일 포함 — {entry.FullName}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 스킬 파일 존재 여부 확인
|
||||
var skillEntries = zip.Entries
|
||||
.Where(e => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)
|
||||
|| e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (skillEntries.Count == 0)
|
||||
{
|
||||
LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// zip 압축 해제
|
||||
int importedCount = 0;
|
||||
foreach (var entry in zip.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Name)) continue; // 디렉토리 항목 건너뛰기
|
||||
|
||||
// 상위 경로 이탈 방지
|
||||
var relativePath = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
|
||||
if (relativePath.Contains("..")) continue;
|
||||
|
||||
var destPath = Path.Combine(userFolder, relativePath);
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (destDir != null && !Directory.Exists(destDir))
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
entry.ExtractToFile(destPath, overwrite: true);
|
||||
|
||||
if (entry.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)
|
||||
|| entry.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
if (importedCount > 0)
|
||||
{
|
||||
LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})");
|
||||
// 스킬 목록 리로드
|
||||
LoadSkills();
|
||||
}
|
||||
|
||||
return importedCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"스킬 가져오기 실패: {ex.Message}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 도구 이름 매핑 (외부 스킬 호환) ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다.
|
||||
/// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> ToolNameMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// 범용 에이전트 표준 별칭
|
||||
["Bash"] = "process",
|
||||
["bash"] = "process",
|
||||
["Read"] = "file_read",
|
||||
["Write"] = "file_write",
|
||||
["Edit"] = "file_edit",
|
||||
["Glob"] = "glob",
|
||||
["Grep"] = "grep",
|
||||
["WebSearch"] = "http_tool",
|
||||
["WebFetch"] = "http_tool",
|
||||
["AskUserQuestion"] = "user_ask",
|
||||
["LSP"] = "lsp_code_intel",
|
||||
["ListMcpResourcesTool"] = "mcp_list_resources",
|
||||
["ReadMcpResourceTool"] = "mcp_read_resource",
|
||||
["Agent"] = "spawn_agent",
|
||||
["Task"] = "spawn_agent",
|
||||
["SendMessage"] = "notify_tool",
|
||||
["PowerShell"] = "process",
|
||||
// agentskills.io 표준
|
||||
["execute_command"] = "process",
|
||||
["read_file"] = "file_read",
|
||||
["write_file"] = "file_write",
|
||||
["edit_file"] = "file_edit",
|
||||
["search_files"] = "glob",
|
||||
["search_content"] = "grep",
|
||||
["list_files"] = "folder_map",
|
||||
// 기타 일반적 도구명
|
||||
["shell"] = "process",
|
||||
["terminal"] = "process",
|
||||
["cat"] = "file_read",
|
||||
["find"] = "glob",
|
||||
["rg"] = "grep",
|
||||
["grep_tool"] = "grep",
|
||||
["git"] = "git_tool",
|
||||
};
|
||||
|
||||
/// <summary>스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다.</summary>
|
||||
public static string MapToolNames(string skillBody)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skillBody)) return skillBody;
|
||||
|
||||
foreach (var kv in ToolNameMap)
|
||||
{
|
||||
// 코드 블록 내 도구 참조: `Bash`, `Read` 등을 `process`, `file_read`로 변환
|
||||
skillBody = skillBody.Replace($"`{kv.Key}`", $"`{kv.Value}`");
|
||||
// 괄호 내 참조: (Bash), (Read) 패턴
|
||||
skillBody = skillBody.Replace($"({kv.Key})", $"({kv.Value})");
|
||||
}
|
||||
|
||||
return skillBody;
|
||||
}
|
||||
|
||||
// ─── 내부 메서드 ─────────────────────────────────────────────────────────
|
||||
|
||||
private static void CreateExampleSkill(string folder, string fileName, string name, string label, string description, string body)
|
||||
{
|
||||
var path = Path.Combine(folder, fileName);
|
||||
if (File.Exists(path)) return;
|
||||
|
||||
var content = $"""
|
||||
---
|
||||
name: {name}
|
||||
label: {label}
|
||||
description: {description}
|
||||
icon: \uE768
|
||||
---
|
||||
|
||||
{body.Trim()}
|
||||
""";
|
||||
// 들여쓰기 정리 (raw string literal의 인덴트 제거)
|
||||
var lines = content.Split('\n').Select(l => l.TrimStart()).ToArray();
|
||||
File.WriteAllText(path, string.Join('\n', lines), Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>*.skill.md 파일을 파싱합니다.</summary>
|
||||
private static SkillDefinition? ParseSkillFile(string filePath)
|
||||
{
|
||||
var content = File.ReadAllText(filePath, Encoding.UTF8);
|
||||
if (!content.TrimStart().StartsWith("---"))
|
||||
return null;
|
||||
|
||||
// 프론트매터 추출
|
||||
var firstSep = content.IndexOf("---", StringComparison.Ordinal);
|
||||
var secondSep = content.IndexOf("---", firstSep + 3, StringComparison.Ordinal);
|
||||
if (secondSep < 0) return null;
|
||||
|
||||
var frontmatter = content[(firstSep + 3)..secondSep].Trim();
|
||||
var body = content[(secondSep + 3)..].Trim();
|
||||
|
||||
// 키-값 파싱 (YAML 1단계 + metadata 맵 지원)
|
||||
var meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? currentMap = null;
|
||||
foreach (var line in frontmatter.Split('\n'))
|
||||
{
|
||||
// 들여쓰기된 줄 → 현재 맵의 하위 키
|
||||
if (currentMap != null && (line.StartsWith(" ") || line.StartsWith("\t")))
|
||||
{
|
||||
var trimmed = line.TrimStart();
|
||||
if (trimmed.StartsWith("-"))
|
||||
{
|
||||
var item = trimmed[1..].Trim().Trim('"', '\'');
|
||||
if (!string.IsNullOrEmpty(item))
|
||||
{
|
||||
var old = meta.GetValueOrDefault(currentMap, "");
|
||||
meta[currentMap] = string.IsNullOrWhiteSpace(old) ? item : $"{old}, {item}";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var colonIdx = trimmed.IndexOf(':');
|
||||
if (colonIdx > 0)
|
||||
{
|
||||
var subKey = trimmed[..colonIdx].Trim();
|
||||
var subVal = trimmed[(colonIdx + 1)..].Trim().Trim('"', '\'');
|
||||
if (string.IsNullOrEmpty(subVal))
|
||||
{
|
||||
// 중첩 맵 지원: hooks.file_edit.pre 형태로 확장
|
||||
currentMap = $"{currentMap}.{subKey}";
|
||||
}
|
||||
else
|
||||
{
|
||||
meta[$"{currentMap}.{subKey}"] = subVal;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
currentMap = null;
|
||||
|
||||
var ci = line.IndexOf(':');
|
||||
if (ci > 0)
|
||||
{
|
||||
var key = line[..ci].Trim();
|
||||
var value = line[(ci + 1)..].Trim().Trim('"', '\'');
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
// 빈 값 = 맵 시작 (metadata:)
|
||||
currentMap = key;
|
||||
}
|
||||
else
|
||||
{
|
||||
meta[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 폴더명을 기본 이름으로 사용 (SKILL.md 표준: 폴더명 = name)
|
||||
var dirName = Path.GetFileName(Path.GetDirectoryName(filePath) ?? "");
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath).Replace(".skill", "");
|
||||
var fallbackName = filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase) ? dirName : fileName;
|
||||
|
||||
var name = meta.GetValueOrDefault("name", fallbackName);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
// SKILL.md 표준: label/icon은 metadata 맵에 있을 수 있음
|
||||
var label = meta.GetValueOrDefault("label", "") ?? "";
|
||||
var icon = meta.GetValueOrDefault("icon", "") ?? "";
|
||||
|
||||
// metadata.label / metadata.icon 지원 (SKILL.md 표준)
|
||||
if (string.IsNullOrEmpty(label) && meta.TryGetValue("metadata.label", out var ml))
|
||||
label = ml ?? "";
|
||||
if (string.IsNullOrEmpty(icon) && meta.TryGetValue("metadata.icon", out var mi))
|
||||
icon = mi ?? "";
|
||||
|
||||
var (hooks, hookFilters) = BuildHookPolicy(meta);
|
||||
|
||||
return new SkillDefinition
|
||||
{
|
||||
Id = name,
|
||||
Name = name,
|
||||
Label = string.IsNullOrEmpty(label) ? name : label,
|
||||
Description = meta.GetValueOrDefault("description", "") ?? "",
|
||||
Icon = string.IsNullOrEmpty(icon) ? "\uE768" : ConvertUnicodeEscape(icon),
|
||||
SystemPrompt = MapToolNames(body),
|
||||
FilePath = filePath,
|
||||
License = meta.GetValueOrDefault("license", "") ?? "",
|
||||
Compatibility = meta.GetValueOrDefault("compatibility", "") ?? "",
|
||||
AllowedTools = meta.GetValueOrDefault("allowed-tools", "") ?? "",
|
||||
Requires = meta.GetValueOrDefault("requires", "") ?? "",
|
||||
Tabs = meta.GetValueOrDefault("tabs", "all") ?? "all",
|
||||
WhenToUse = meta.GetValueOrDefault("when_to_use", "") ?? "",
|
||||
ArgumentHint = meta.GetValueOrDefault("argument-hint", "") ?? "",
|
||||
Model = meta.GetValueOrDefault("model", "") ?? "",
|
||||
DisableModelInvocation = ParseBooleanMeta(meta.GetValueOrDefault("disable-model-invocation", "")),
|
||||
UserInvocable = !meta.ContainsKey("user-invocable") || ParseBooleanMeta(meta.GetValueOrDefault("user-invocable", "true")),
|
||||
ExecutionContext = meta.GetValueOrDefault("context", "") ?? "",
|
||||
Agent = meta.GetValueOrDefault("agent", "") ?? "",
|
||||
Effort = meta.GetValueOrDefault("effort", "") ?? "",
|
||||
Paths = meta.GetValueOrDefault("paths", "") ?? "",
|
||||
Shell = meta.GetValueOrDefault("shell", "") ?? "",
|
||||
Hooks = hooks,
|
||||
HookFilters = hookFilters,
|
||||
IsSample = ParseBooleanMeta(meta.GetValueOrDefault("sample", ""))
|
||||
|| ParseBooleanMeta(meta.GetValueOrDefault("metadata.sample", "")),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ParseBooleanMeta(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "yes" or "y" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSkillInvocableNow(SkillDefinition skill)
|
||||
{
|
||||
if (!skill.UserInvocable) return false;
|
||||
if (!skill.IsAvailable) return false;
|
||||
if (string.IsNullOrWhiteSpace(skill.Paths)) return true;
|
||||
return _activeConditionalSkillNames.Contains(skill.Name);
|
||||
}
|
||||
|
||||
private static string NormalizeEffort(string raw)
|
||||
{
|
||||
var effort = raw.Trim().ToLowerInvariant();
|
||||
return effort switch
|
||||
{
|
||||
"low" or "medium" or "high" or "xhigh" => effort,
|
||||
_ => raw.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParseAllowedToolsForRuntimePolicy(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return new List<string>();
|
||||
|
||||
var tools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var normalized = token.Trim().Trim('`', '"', '\'');
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
continue;
|
||||
|
||||
if (ToolNameMap.TryGetValue(normalized, out var mapped))
|
||||
normalized = mapped;
|
||||
|
||||
tools.Add(normalized);
|
||||
}
|
||||
|
||||
return tools.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static List<string> ParseHookNamesForRuntimePolicy(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return new List<string>();
|
||||
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var normalized = token.Trim().Trim('`', '"', '\'');
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
names.Add(normalized);
|
||||
}
|
||||
|
||||
return names.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static List<string> ParsePathPatterns(string raw)
|
||||
{
|
||||
return raw
|
||||
.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(p => p.Trim().Trim('"', '\''))
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p) && p != "**")
|
||||
.Select(NormalizePathForMatch)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static (string Hooks, string HookFilters) BuildHookPolicy(Dictionary<string, string> meta)
|
||||
{
|
||||
var hooks = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var filters = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddHookTokens(hooks, meta.GetValueOrDefault("hooks", ""));
|
||||
foreach (var hook in hooks)
|
||||
filters.Add($"{hook}@*@*");
|
||||
foreach (var kv in meta.Where(kv => kv.Key.StartsWith("hooks.", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var suffix = kv.Key["hooks.".Length..].Trim();
|
||||
var keyFilter = ParseHookKeyFilter(suffix);
|
||||
if (!string.IsNullOrWhiteSpace(keyFilter))
|
||||
filters.Add(keyFilter);
|
||||
if (!string.IsNullOrWhiteSpace(suffix)
|
||||
&& ExtractTimingFromSuffix(suffix) == null)
|
||||
{
|
||||
hooks.Add(suffix);
|
||||
}
|
||||
foreach (var hookName in ParseHookTokens(kv.Value))
|
||||
{
|
||||
hooks.Add(hookName);
|
||||
var timing = ExtractTimingFromSuffix(suffix) ?? "*";
|
||||
var tool = ExtractToolFromSuffix(suffix) ?? "*";
|
||||
filters.Add($"{hookName}@{timing}@{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
string.Join(", ", hooks.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)),
|
||||
string.Join(", ", filters.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
);
|
||||
}
|
||||
|
||||
private static void AddHookTokens(HashSet<string> target, string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return;
|
||||
|
||||
foreach (var token in ParseHookTokens(raw))
|
||||
target.Add(token);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseHookTokens(string raw)
|
||||
{
|
||||
foreach (var token in raw.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var normalized = token.Trim().Trim('`', '"', '\'');
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
yield return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ParseHookKeyFilter(string suffix)
|
||||
{
|
||||
var timing = ExtractTimingFromSuffix(suffix);
|
||||
if (string.IsNullOrWhiteSpace(timing))
|
||||
return null;
|
||||
|
||||
var tool = ExtractToolFromSuffix(suffix) ?? "*";
|
||||
return $"*@{timing}@{tool}";
|
||||
}
|
||||
|
||||
private static string? ExtractTimingFromSuffix(string suffix)
|
||||
{
|
||||
var normalized = suffix.Trim().ToLowerInvariant();
|
||||
if (normalized is "pre" or "pretooluse")
|
||||
return "pre";
|
||||
if (normalized is "post" or "posttooluse")
|
||||
return "post";
|
||||
if (normalized.EndsWith(".pre") || normalized.EndsWith(".pretooluse"))
|
||||
return "pre";
|
||||
if (normalized.EndsWith(".post") || normalized.EndsWith(".posttooluse"))
|
||||
return "post";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractToolFromSuffix(string suffix)
|
||||
{
|
||||
var trimmed = suffix.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
return null;
|
||||
|
||||
var dotIndex = trimmed.LastIndexOf('.');
|
||||
if (dotIndex <= 0)
|
||||
return null;
|
||||
|
||||
return trimmed[..dotIndex].Trim();
|
||||
}
|
||||
|
||||
private static string? ToRelativePathIfUnderCwd(string filePath, string cwd)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullCwd = Path.GetFullPath(cwd);
|
||||
var fullFile = Path.GetFullPath(filePath);
|
||||
if (!fullFile.StartsWith(fullCwd, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var rel = Path.GetRelativePath(fullCwd, fullFile);
|
||||
if (string.IsNullOrWhiteSpace(rel) || rel.StartsWith(".."))
|
||||
return null;
|
||||
return NormalizePathForMatch(rel);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PathMatchesPatterns(string relativePath, List<string> patterns)
|
||||
{
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (GlobMatch(relativePath, pattern))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool GlobMatch(string path, string pattern)
|
||||
{
|
||||
var p = path.Replace('\\', '/');
|
||||
var g = pattern.Replace('\\', '/');
|
||||
|
||||
// 디렉터리 표기 관용 지원: "src/" -> "src/**"
|
||||
if (g.EndsWith('/')) g += "**";
|
||||
|
||||
var regex = "^" + System.Text.RegularExpressions.Regex
|
||||
.Escape(g)
|
||||
.Replace(@"\*\*", ".*")
|
||||
.Replace(@"\*", @"[^/]*")
|
||||
.Replace(@"\?", @"[^/]") + "$";
|
||||
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(
|
||||
p,
|
||||
regex,
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizePathForMatch(string path) =>
|
||||
path.Replace('\\', '/').Trim();
|
||||
|
||||
/// <summary>YAML의 \uXXXX 이스케이프를 실제 유니코드 문자로 변환합니다.</summary>
|
||||
private static string ConvertUnicodeEscape(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
return System.Text.RegularExpressions.Regex.Replace(
|
||||
value, @"\\u([0-9a-fA-F]{4})",
|
||||
m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>스킬 정의 (*.skill.md에서 로드).</summary>
|
||||
public class SkillDefinition
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Name { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public string Icon { get; init; } = "\uE768";
|
||||
public string SystemPrompt { get; init; } = "";
|
||||
public string FilePath { get; init; } = "";
|
||||
|
||||
// SKILL.md 표준 확장 필드
|
||||
public string License { get; init; } = "";
|
||||
public string Compatibility { get; init; } = "";
|
||||
public string AllowedTools { get; init; } = "";
|
||||
|
||||
/// <summary>런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음.</summary>
|
||||
public string Requires { get; init; } = "";
|
||||
|
||||
/// <summary>표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능.</summary>
|
||||
public string Tabs { get; init; } = "all";
|
||||
|
||||
/// <summary>스킬 사용 추천 조건 설명.</summary>
|
||||
public string WhenToUse { get; init; } = "";
|
||||
|
||||
/// <summary>슬래시 호출 시 인자 힌트 문자열.</summary>
|
||||
public string ArgumentHint { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 고정 모델 (선택).</summary>
|
||||
public string Model { get; init; } = "";
|
||||
|
||||
/// <summary>모델 호출 비활성화 여부.</summary>
|
||||
public bool DisableModelInvocation { get; init; }
|
||||
|
||||
/// <summary>사용자가 직접 호출 가능한지 여부.</summary>
|
||||
public bool UserInvocable { get; init; } = true;
|
||||
|
||||
/// <summary>실행 컨텍스트 힌트 (예: inline, fork).</summary>
|
||||
public string ExecutionContext { get; init; } = "";
|
||||
|
||||
/// <summary>지정 에이전트 힌트.</summary>
|
||||
public string Agent { get; init; } = "";
|
||||
|
||||
/// <summary>추론 강도 힌트.</summary>
|
||||
public string Effort { get; init; } = "";
|
||||
|
||||
/// <summary>조건부 활성화 경로 패턴 원문.</summary>
|
||||
public string Paths { get; init; } = "";
|
||||
|
||||
/// <summary>쉘 실행 옵션 힌트.</summary>
|
||||
public string Shell { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 전용 훅 이름 목록(쉼표 구분).</summary>
|
||||
public string Hooks { get; init; } = "";
|
||||
|
||||
/// <summary>스킬 훅 필터 규칙(형식: hook@timing@tool, 쉼표 구분).</summary>
|
||||
public string HookFilters { get; init; } = "";
|
||||
|
||||
/// <summary>갤러리에서 예제 스킬로 표시할지 여부.</summary>
|
||||
public bool IsSample { get; init; }
|
||||
|
||||
/// <summary>런타임 의존성 충족 여부. Requires가 비어있으면 항상 true.</summary>
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
/// <summary>지정 탭에서 이 스킬을 표시할지 판정합니다.</summary>
|
||||
public bool IsVisibleInTab(string activeTab)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
var tabs = Tabs.Split(',').Select(t => t.Trim().ToLowerInvariant());
|
||||
var tab = activeTab.ToLowerInvariant();
|
||||
return tabs.Any(t => t == "all" || t == tab);
|
||||
}
|
||||
|
||||
/// <summary>SKILL.md 표준 폴더 형식인지 여부.</summary>
|
||||
public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>비가용 시 사용자에게 표시할 힌트 메시지.</summary>
|
||||
public string UnavailableHint
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsAvailable || string.IsNullOrEmpty(Requires)) return "";
|
||||
var runtimes = Requires.Split(',').Select(r => r.Trim());
|
||||
var missing = runtimes.Where(r => !RuntimeDetector.IsAvailable(r)).ToArray();
|
||||
return missing.Length > 0 ? $"({string.Join(", ", missing.Select(r => char.ToUpper(r[0]) + r[1..]))} 필요)" : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
246
src/AxCopilot/Services/Agent/SnippetRunnerTool.cs
Normal file
246
src/AxCopilot/Services/Agent/SnippetRunnerTool.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// C#/Python/JavaScript 코드 스니펫을 즉시 실행하는 도구.
|
||||
/// 임시 파일에 코드를 저장하고 해당 런타임으로 실행합니다.
|
||||
/// </summary>
|
||||
public class SnippetRunnerTool : IAgentTool
|
||||
{
|
||||
public string Name => "snippet_runner";
|
||||
public string Description =>
|
||||
"Execute a code snippet in C#, Python, or JavaScript. " +
|
||||
"Writes the code to a temp file, runs it, and returns stdout/stderr. " +
|
||||
"Useful for quick calculations, data transformations, format conversions, and testing algorithms. " +
|
||||
"Max execution time: 30 seconds. Max output: 8000 chars.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["language"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Programming language: 'csharp', 'python', or 'javascript'",
|
||||
Enum = ["csharp", "python", "javascript"],
|
||||
},
|
||||
["code"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Source code to execute. For C#: top-level statements or full Program class. " +
|
||||
"For Python: standard script. For JavaScript: Node.js script.",
|
||||
},
|
||||
["timeout"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Timeout in seconds. Default: 30, max: 60.",
|
||||
},
|
||||
},
|
||||
Required = ["language", "code"],
|
||||
};
|
||||
|
||||
// 위험한 코드 패턴 차단
|
||||
private static readonly string[] DangerousPatterns =
|
||||
[
|
||||
"Process.Start", "ProcessStartInfo",
|
||||
"Registry.", "RegistryKey",
|
||||
"Environment.Exit",
|
||||
"File.Delete", "Directory.Delete",
|
||||
"Format-Volume", "Remove-Item",
|
||||
"os.remove", "os.rmdir", "shutil.rmtree",
|
||||
"subprocess.call", "subprocess.Popen",
|
||||
"child_process", "require('fs').unlink",
|
||||
"exec(", "eval(",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var language = args.GetProperty("language").GetString() ?? "";
|
||||
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
|
||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return ToolResult.Fail("code가 비어 있습니다.");
|
||||
|
||||
// 위험 패턴 검사
|
||||
foreach (var pattern in DangerousPatterns)
|
||||
{
|
||||
if (code.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail($"보안 차단: '{pattern}' 패턴은 snippet_runner에서 허용되지 않습니다. process 도구를 사용하세요.");
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
if (!await context.CheckWritePermissionAsync(Name, $"[{language}] {(code.Length > 100 ? code[..100] + "..." : code)}"))
|
||||
return ToolResult.Fail("코드 실행 권한 거부");
|
||||
|
||||
// 설정에서 비활성화 여부 확인
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var enabled = app?.SettingsService?.Settings?.Llm?.Code?.EnableSnippetRunner ?? true;
|
||||
if (!enabled)
|
||||
return ToolResult.Fail("snippet_runner가 설정에서 비활성화되어 있습니다. 설정 → AX Agent → 기능에서 활성화하세요.");
|
||||
|
||||
return language switch
|
||||
{
|
||||
"csharp" => await RunCSharpAsync(code, timeout, context, ct),
|
||||
"python" => await RunScriptAsync("python", ".py", code, timeout, context, ct),
|
||||
"javascript" => await RunScriptAsync("node", ".js", code, timeout, context, ct),
|
||||
_ => ToolResult.Fail($"지원하지 않는 언어: {language}. csharp, python, javascript 중 선택하세요."),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ToolResult> RunCSharpAsync(string code, int timeout, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// dotnet-script 사용 시도, 없으면 dotnet run 임시 프로젝트
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// dotnet-script 먼저 확인
|
||||
if (await IsToolAvailableAsync("dotnet-script"))
|
||||
{
|
||||
var scriptFile = Path.Combine(tempDir, "snippet.csx");
|
||||
await File.WriteAllTextAsync(scriptFile, code, ct);
|
||||
return await RunProcessAsync("dotnet-script", scriptFile, timeout, tempDir, ct);
|
||||
}
|
||||
|
||||
// dotnet run으로 폴백 — 임시 콘솔 프로젝트 생성
|
||||
var csprojContent = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(tempDir, "Snippet.csproj"), csprojContent, ct);
|
||||
|
||||
// top-level statements 코드를 Program.cs로
|
||||
var programCode = code.Contains("class ") && code.Contains("static void Main")
|
||||
? code // 이미 전체 클래스
|
||||
: code; // top-level statements (net8.0 지원)
|
||||
await File.WriteAllTextAsync(Path.Combine(tempDir, "Program.cs"), programCode, ct);
|
||||
|
||||
return await RunProcessAsync("dotnet", $"run --project \"{tempDir}\"", timeout, tempDir, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 정리
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> RunScriptAsync(string runtime, string extension, string code,
|
||||
int timeout, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// 런타임 사용 가능 확인
|
||||
if (!await IsToolAvailableAsync(runtime))
|
||||
return ToolResult.Fail($"{runtime}이 설치되지 않았습니다. 시스템에 {runtime}을 설치하세요.");
|
||||
|
||||
var tempFile = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}{extension}");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempFile, code, Encoding.UTF8, ct);
|
||||
return await RunProcessAsync(runtime, $"\"{tempFile}\"", timeout, context.WorkFolder, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tempFile); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> RunProcessAsync(string fileName, string arguments,
|
||||
int timeout, string workDir, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(workDir) && Directory.Exists(workDir))
|
||||
psi.WorkingDirectory = workDir;
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
var stdout = new StringBuilder();
|
||||
var stderr = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"실행 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
return ToolResult.Fail($"실행 타임아웃 ({timeout}초 초과)");
|
||||
}
|
||||
|
||||
var output = stdout.ToString().TrimEnd();
|
||||
var error = stderr.ToString().TrimEnd();
|
||||
|
||||
// 출력 크기 제한
|
||||
if (output.Length > 8000)
|
||||
output = output[..8000] + "\n... (출력 잘림)";
|
||||
|
||||
var result = new StringBuilder();
|
||||
result.AppendLine($"[Exit code: {process.ExitCode}]");
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
result.AppendLine(output);
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
result.AppendLine($"[stderr]\n{error}");
|
||||
|
||||
return ToolResult.Ok(result.ToString());
|
||||
}
|
||||
|
||||
private static async Task<bool> IsToolAvailableAsync(string tool)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = Environment.OSVersion.Platform == PlatformID.Win32NT ? "where" : "which",
|
||||
Arguments = tool,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return false;
|
||||
await process.WaitForExitAsync();
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
210
src/AxCopilot/Services/Agent/SqlTool.cs
Normal file
210
src/AxCopilot/Services/Agent/SqlTool.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 쿼리 실행 도구.
|
||||
/// 로컬 .db/.sqlite 파일에 대해 SELECT/INSERT/UPDATE/DELETE 쿼리를 실행합니다.
|
||||
/// </summary>
|
||||
public class SqlTool : IAgentTool
|
||||
{
|
||||
public string Name => "sql_tool";
|
||||
public string Description =>
|
||||
"Execute SQL queries on local SQLite database files. Actions: " +
|
||||
"'query' — run SELECT query and return results as table; " +
|
||||
"'execute' — run INSERT/UPDATE/DELETE and return affected rows; " +
|
||||
"'schema' — show database schema (tables, columns, types); " +
|
||||
"'tables' — list all tables in the database.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["query", "execute", "schema", "tables"],
|
||||
},
|
||||
["db_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Path to SQLite database file (.db, .sqlite, .sqlite3)",
|
||||
},
|
||||
["sql"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "SQL query to execute (for query/execute actions)",
|
||||
},
|
||||
["max_rows"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Maximum rows to return (default: 100, max: 1000)",
|
||||
},
|
||||
},
|
||||
Required = ["action", "db_path"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var dbPath = args.GetProperty("db_path").GetString() ?? "";
|
||||
|
||||
if (!Path.IsPathRooted(dbPath))
|
||||
dbPath = Path.Combine(context.WorkFolder, dbPath);
|
||||
|
||||
if (!File.Exists(dbPath))
|
||||
return Task.FromResult(ToolResult.Fail($"Database file not found: {dbPath}"));
|
||||
|
||||
try
|
||||
{
|
||||
var connStr = $"Data Source={dbPath};Mode=ReadOnly";
|
||||
// execute 액션은 ReadWrite 필요
|
||||
if (action == "execute")
|
||||
connStr = $"Data Source={dbPath}";
|
||||
|
||||
using var conn = new SqliteConnection(connStr);
|
||||
conn.Open();
|
||||
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"query" => QueryAction(conn, args),
|
||||
"execute" => ExecuteAction(conn, args),
|
||||
"schema" => SchemaAction(conn),
|
||||
"tables" => TablesAction(conn),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"SQL 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("sql", out var sqlProp))
|
||||
return ToolResult.Fail("'sql' parameter is required for query action");
|
||||
|
||||
var sql = sqlProp.GetString() ?? "";
|
||||
|
||||
// SELECT만 허용
|
||||
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
|
||||
!sql.TrimStart().StartsWith("WITH", StringComparison.OrdinalIgnoreCase) &&
|
||||
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
|
||||
|
||||
var maxRows = args.TryGetProperty("max_rows", out var mr) && int.TryParse(mr.GetString(), out var mrv)
|
||||
? Math.Min(mrv, 1000) : 100;
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var sb = new StringBuilder();
|
||||
var colCount = reader.FieldCount;
|
||||
|
||||
// 헤더
|
||||
var colNames = new string[colCount];
|
||||
for (var i = 0; i < colCount; i++)
|
||||
colNames[i] = reader.GetName(i);
|
||||
sb.AppendLine(string.Join(" | ", colNames));
|
||||
sb.AppendLine(new string('-', colNames.Sum(c => c.Length + 3)));
|
||||
|
||||
// 행
|
||||
var rowCount = 0;
|
||||
while (reader.Read() && rowCount < maxRows)
|
||||
{
|
||||
var values = new string[colCount];
|
||||
for (var i = 0; i < colCount; i++)
|
||||
values[i] = reader.IsDBNull(i) ? "NULL" : reader.GetValue(i)?.ToString() ?? "";
|
||||
sb.AppendLine(string.Join(" | ", values));
|
||||
rowCount++;
|
||||
}
|
||||
|
||||
if (rowCount == 0)
|
||||
return ToolResult.Ok("Query returned 0 rows.");
|
||||
|
||||
var result = sb.ToString();
|
||||
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
|
||||
return ToolResult.Ok($"Rows: {rowCount}" + (rowCount >= maxRows ? $" (limited to {maxRows})" : "") + $"\n\n{result}");
|
||||
}
|
||||
|
||||
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("sql", out var sqlProp))
|
||||
return ToolResult.Fail("'sql' parameter is required for execute action");
|
||||
|
||||
var sql = sqlProp.GetString() ?? "";
|
||||
|
||||
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
|
||||
var trimmed = sql.TrimStart().ToUpperInvariant();
|
||||
if (trimmed.StartsWith("DROP DATABASE") || trimmed.StartsWith("ATTACH") || trimmed.StartsWith("DETACH"))
|
||||
return ToolResult.Fail("Security: DROP DATABASE, ATTACH, DETACH are not allowed.");
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var affected = cmd.ExecuteNonQuery();
|
||||
return ToolResult.Ok($"✓ {affected} row(s) affected");
|
||||
}
|
||||
|
||||
private static ToolResult SchemaAction(SqliteConnection conn)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
var tables = new List<string>();
|
||||
while (reader.Read()) tables.Add(reader.GetString(0));
|
||||
reader.Close();
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
sb.AppendLine($"## {table}");
|
||||
using var pragmaCmd = conn.CreateCommand();
|
||||
pragmaCmd.CommandText = $"PRAGMA table_info(\"{table}\")";
|
||||
using var pragmaReader = pragmaCmd.ExecuteReader();
|
||||
sb.AppendLine($"{"#",-4} {"Name",-25} {"Type",-15} {"NotNull",-8} {"Default",-15} {"PK"}");
|
||||
while (pragmaReader.Read())
|
||||
{
|
||||
sb.AppendLine($"{pragmaReader.GetInt32(0),-4} " +
|
||||
$"{pragmaReader.GetString(1),-25} " +
|
||||
$"{pragmaReader.GetString(2),-15} " +
|
||||
$"{(pragmaReader.GetInt32(3) == 1 ? "YES" : ""),-8} " +
|
||||
$"{(pragmaReader.IsDBNull(4) ? "" : pragmaReader.GetString(4)),-15} " +
|
||||
$"{(pragmaReader.GetInt32(5) > 0 ? "PK" : "")}");
|
||||
}
|
||||
pragmaReader.Close();
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult TablesAction(SqliteConnection conn)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT m.name, m.type,
|
||||
(SELECT count(*) FROM pragma_table_info(m.name)) as col_count
|
||||
FROM sqlite_master m
|
||||
WHERE m.type IN ('table','view')
|
||||
ORDER BY m.type, m.name";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"{"Name",-30} {"Type",-8} {"Columns"}");
|
||||
sb.AppendLine(new string('-', 50));
|
||||
var count = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
sb.AppendLine($"{reader.GetString(0),-30} {reader.GetString(1),-8} {reader.GetInt32(2)}");
|
||||
count++;
|
||||
}
|
||||
return ToolResult.Ok($"Found {count} tables/views:\n\n{sb}");
|
||||
}
|
||||
}
|
||||
498
src/AxCopilot/Services/Agent/SubAgentTool.cs
Normal file
498
src/AxCopilot/Services/Agent/SubAgentTool.cs
Normal file
@@ -0,0 +1,498 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Spawns a background sub-agent that runs an isolated read-only agent loop.
|
||||
/// The parent agent can later collect all finished results with wait_agents.
|
||||
/// </summary>
|
||||
public class SubAgentTool : IAgentTool
|
||||
{
|
||||
public static event Action<SubAgentStatusEvent>? StatusChanged;
|
||||
|
||||
public string Name => "spawn_agent";
|
||||
|
||||
public string Description =>
|
||||
"Create a read-only sub-agent for bounded parallel research or codebase analysis.\n" +
|
||||
"Use this when a side task can run independently while the main agent continues.\n" +
|
||||
"The sub-agent can inspect files, search code, review diffs, and summarize findings.\n" +
|
||||
"Collect results later with wait_agents.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["task"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "The self-contained task for the sub-agent."
|
||||
},
|
||||
["id"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "A unique sub-agent identifier used by wait_agents."
|
||||
},
|
||||
},
|
||||
Required = new() { "task", "id" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, SubAgentTask> _activeTasks = new();
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
|
||||
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
|
||||
return Task.FromResult(ToolResult.Fail("task and id are required."));
|
||||
|
||||
CleanupStale();
|
||||
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var maxAgents = app?.SettingsService?.Settings.Llm.MaxSubAgents ?? 3;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_activeTasks.ContainsKey(id))
|
||||
return Task.FromResult(ToolResult.Fail($"Sub-agent id already exists: {id}"));
|
||||
|
||||
var running = _activeTasks.Values.Count(x => x.CompletedAt == null);
|
||||
if (running >= maxAgents)
|
||||
return Task.FromResult(ToolResult.Fail($"Maximum concurrent sub-agents reached ({maxAgents})."));
|
||||
}
|
||||
|
||||
var subTask = new SubAgentTask
|
||||
{
|
||||
Id = id,
|
||||
Task = task,
|
||||
StartedAt = DateTime.Now,
|
||||
};
|
||||
|
||||
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await RunSubAgentAsync(id, task, context).ConfigureAwait(false);
|
||||
subTask.Result = result;
|
||||
subTask.Success = true;
|
||||
NotifyStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = id,
|
||||
Task = task,
|
||||
Status = SubAgentRunStatus.Completed,
|
||||
Summary = $"Sub-agent '{id}' completed.",
|
||||
Result = result,
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
subTask.Result = $"Error: {ex.Message}";
|
||||
subTask.Success = false;
|
||||
NotifyStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = id,
|
||||
Task = task,
|
||||
Status = SubAgentRunStatus.Failed,
|
||||
Summary = $"Sub-agent '{id}' failed: {ex.Message}",
|
||||
Result = subTask.Result,
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
subTask.CompletedAt = DateTime.Now;
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
lock (_lock)
|
||||
_activeTasks[id] = subTask;
|
||||
|
||||
NotifyStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = id,
|
||||
Task = task,
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = $"Sub-agent '{id}' started.",
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(
|
||||
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
|
||||
}
|
||||
|
||||
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext)
|
||||
{
|
||||
var settings = CreateSubAgentSettings(parentContext);
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(false);
|
||||
|
||||
var loop = new AgentLoopService(llm, tools, settings)
|
||||
{
|
||||
ActiveTab = parentContext.ActiveTab,
|
||||
};
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Role = "system",
|
||||
Content = BuildSubAgentSystemPrompt(task, parentContext),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Role = "user",
|
||||
Content = task,
|
||||
}
|
||||
};
|
||||
|
||||
var finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var eventSummary = SummarizeEvents(loop.Events);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[Sub-agent {id}]");
|
||||
sb.AppendLine($"Task: {task}");
|
||||
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
|
||||
sb.AppendLine($"Work folder: {parentContext.WorkFolder}");
|
||||
if (!string.IsNullOrWhiteSpace(eventSummary))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Observed work:");
|
||||
sb.AppendLine(eventSummary);
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Result:");
|
||||
sb.AppendLine(string.IsNullOrWhiteSpace(finalText) ? "(empty)" : finalText.Trim());
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static SettingsService CreateSubAgentSettings(AgentContext parentContext)
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Load();
|
||||
|
||||
var llm = settings.Settings.Llm;
|
||||
llm.WorkFolder = parentContext.WorkFolder;
|
||||
llm.FilePermission = "Deny";
|
||||
llm.PlanMode = "off";
|
||||
llm.AgentHooks = new();
|
||||
llm.ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
llm.DisabledTools = new List<string>
|
||||
{
|
||||
"spawn_agent",
|
||||
"wait_agents",
|
||||
"file_write",
|
||||
"file_edit",
|
||||
"process",
|
||||
"build_run",
|
||||
"snippet_runner",
|
||||
"memory",
|
||||
"notify",
|
||||
"open_external",
|
||||
"user_ask",
|
||||
"checkpoint",
|
||||
"diff_preview",
|
||||
"playbook",
|
||||
"http_tool",
|
||||
"clipboard",
|
||||
"sql_tool",
|
||||
};
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings)
|
||||
{
|
||||
var registry = new ToolRegistry();
|
||||
|
||||
registry.Register(new FileReadTool());
|
||||
registry.Register(new GlobTool());
|
||||
registry.Register(new GrepTool());
|
||||
registry.Register(new FolderMapTool());
|
||||
registry.Register(new DocumentReaderTool());
|
||||
registry.Register(new DevEnvDetectTool());
|
||||
registry.Register(new GitTool());
|
||||
registry.Register(new LspTool());
|
||||
registry.Register(new CodeSearchTool());
|
||||
registry.Register(new CodeReviewTool());
|
||||
registry.Register(new ProjectRuleTool());
|
||||
registry.Register(new SkillManagerTool());
|
||||
registry.Register(new JsonTool());
|
||||
registry.Register(new RegexTool());
|
||||
registry.Register(new DiffTool());
|
||||
registry.Register(new Base64Tool());
|
||||
registry.Register(new HashTool());
|
||||
registry.Register(new DateTimeTool());
|
||||
registry.Register(new MathTool());
|
||||
registry.Register(new XmlTool());
|
||||
registry.Register(new MultiReadTool());
|
||||
registry.Register(new FileInfoTool());
|
||||
registry.Register(new DocumentReviewTool());
|
||||
|
||||
await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false);
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("You are a focused sub-agent for AX Copilot.");
|
||||
sb.AppendLine("You are running a bounded, read-only investigation.");
|
||||
sb.AppendLine("Use tools to inspect the project, gather evidence, and produce an actionable result.");
|
||||
sb.AppendLine("Do not ask the user questions.");
|
||||
sb.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects.");
|
||||
sb.AppendLine("Prefer direct evidence from files and tool results over speculation.");
|
||||
sb.AppendLine("If something is uncertain, say so briefly and identify what evidence is missing.");
|
||||
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
|
||||
sb.AppendLine($"Current work folder: {parentContext.WorkFolder}");
|
||||
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
|
||||
sb.AppendLine($"Current tab: {parentContext.ActiveTab}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Investigation rules:");
|
||||
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
|
||||
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
|
||||
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
|
||||
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
|
||||
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
|
||||
var workflowHints = BuildSubAgentWorkflowHints(task, parentContext.ActiveTab);
|
||||
if (!string.IsNullOrWhiteSpace(workflowHints))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Task-specific guidance:");
|
||||
sb.AppendLine(workflowHints);
|
||||
}
|
||||
sb.AppendLine("Final answer format:");
|
||||
sb.AppendLine("1. Short conclusion");
|
||||
sb.AppendLine("2. Files checked");
|
||||
sb.AppendLine("3. Key evidence");
|
||||
sb.AppendLine("4. Recommended next action for the main agent");
|
||||
sb.AppendLine("5. Risks or unknowns");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string BuildSubAgentWorkflowHints(string task, string? activeTab)
|
||||
{
|
||||
var normalizedTask = task ?? "";
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (normalizedTask.Contains("review", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("검토", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("리뷰", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine("- Review tasks should name the concrete issue first, then cite the supporting file evidence.");
|
||||
sb.AppendLine("- Mention missing or weak tests when behavior could regress.");
|
||||
}
|
||||
|
||||
if (normalizedTask.Contains("bug", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("error", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("실패", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("오류", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine("- Bug investigations should identify the most likely root cause and the exact files that support that conclusion.");
|
||||
sb.AppendLine("- Suggest the smallest safe fix path for the main agent.");
|
||||
}
|
||||
|
||||
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine("- For code tasks, always mention impacted callers/references if you found any.");
|
||||
sb.AppendLine("- Call out related tests or note explicitly when tests were not found.");
|
||||
}
|
||||
|
||||
if (normalizedTask.Contains("plan", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("설계", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedTask.Contains("계획", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine("- Planning tasks should identify the minimum file set, order of work, and the primary validation step.");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string SummarizeEvents(IEnumerable<AgentEvent> events)
|
||||
{
|
||||
var lines = events
|
||||
.Where(e => e.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.StepStart)
|
||||
.TakeLast(12)
|
||||
.Select(e =>
|
||||
{
|
||||
var label = e.Type switch
|
||||
{
|
||||
AgentEventType.ToolCall => $"tool:{e.ToolName}",
|
||||
AgentEventType.ToolResult => $"result:{e.ToolName}",
|
||||
AgentEventType.StepStart => "step",
|
||||
_ => e.Type.ToString().ToLowerInvariant()
|
||||
};
|
||||
var summary = string.IsNullOrWhiteSpace(e.Summary) ? "" : $" - {e.Summary.Trim()}";
|
||||
return $"- {label}{summary}";
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return lines.Count == 0 ? "" : string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, SubAgentTask> ActiveTasks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
return new Dictionary<string, SubAgentTask>(_activeTasks);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<string> WaitAsync(
|
||||
IEnumerable<string>? ids = null,
|
||||
bool completedOnly = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
List<SubAgentTask> tasks;
|
||||
lock (_lock)
|
||||
tasks = _activeTasks.Values.ToList();
|
||||
|
||||
var requestedIds = ids?
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (requestedIds is { Count: > 0 })
|
||||
tasks = tasks
|
||||
.Where(t => requestedIds.Contains(t.Id, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (tasks.Count == 0)
|
||||
{
|
||||
if (requestedIds is { Count: > 0 })
|
||||
return $"No matching sub-agents found for: {string.Join(", ", requestedIds)}";
|
||||
return "No active sub-agents.";
|
||||
}
|
||||
|
||||
if (!completedOnly)
|
||||
await Task.WhenAll(tasks.Where(t => t.RunTask != null).Select(t => t.RunTask!)).WaitAsync(ct);
|
||||
else
|
||||
tasks = tasks.Where(t => t.CompletedAt != null).ToList();
|
||||
|
||||
if (tasks.Count == 0)
|
||||
return "No requested sub-agents have completed yet.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Collected {tasks.Count} sub-agent result(s):");
|
||||
foreach (var task in tasks.OrderBy(t => t.StartedAt))
|
||||
{
|
||||
var status = task.Success ? "OK" : "FAIL";
|
||||
var duration = task.CompletedAt.HasValue
|
||||
? $"{(task.CompletedAt.Value - task.StartedAt).TotalSeconds:F1}s"
|
||||
: "running";
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"--- [{status}] {task.Id} ({duration}) ---");
|
||||
sb.AppendLine(task.Result ?? "(no result)");
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var task in tasks)
|
||||
_activeTasks.Remove(task.Id);
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public static void CleanupStale()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var stale = _activeTasks
|
||||
.Where(kv => kv.Value.CompletedAt.HasValue &&
|
||||
(DateTime.Now - kv.Value.CompletedAt.Value).TotalMinutes > 10)
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in stale)
|
||||
_activeTasks.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyStatus(SubAgentStatusEvent evt)
|
||||
{
|
||||
try { StatusChanged?.Invoke(evt); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public class WaitAgentsTool : IAgentTool
|
||||
{
|
||||
public string Name => "wait_agents";
|
||||
|
||||
public string Description =>
|
||||
"Wait for sub-agents and collect their results.\n" +
|
||||
"You may wait for all sub-agents or only specific ids.\n" +
|
||||
"Use completed_only=true to collect only already finished sub-agents without blocking.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["ids"] = new ToolProperty
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Optional list of sub-agent ids to collect. Omit to collect all.",
|
||||
Items = new ToolProperty { Type = "string", Description = "Sub-agent id" }
|
||||
},
|
||||
["completed_only"] = new ToolProperty
|
||||
{
|
||||
Type = "boolean",
|
||||
Description = "If true, collect only already completed sub-agents and do not wait."
|
||||
},
|
||||
},
|
||||
Required = new()
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
List<string>? ids = null;
|
||||
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ids = idsEl.EnumerateArray()
|
||||
.Where(x => x.ValueKind == JsonValueKind.String)
|
||||
.Select(x => x.GetString() ?? "")
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
|
||||
completedEl.ValueKind == JsonValueKind.True;
|
||||
|
||||
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class SubAgentTask
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Task { get; init; } = "";
|
||||
public DateTime StartedAt { get; init; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Result { get; set; }
|
||||
public Task? RunTask { get; set; }
|
||||
}
|
||||
|
||||
public enum SubAgentRunStatus
|
||||
{
|
||||
Started,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
|
||||
public class SubAgentStatusEvent
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Task { get; init; } = "";
|
||||
public SubAgentRunStatus Status { get; init; }
|
||||
public string Summary { get; init; } = "";
|
||||
public string? Result { get; init; }
|
||||
public DateTime Timestamp { get; init; } = DateTime.Now;
|
||||
}
|
||||
103
src/AxCopilot/Services/Agent/SuggestActionsTool.cs
Normal file
103
src/AxCopilot/Services/Agent/SuggestActionsTool.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 작업 완료 후 후속 액션을 구조화하여 제안하는 도구.
|
||||
/// UI가 클릭 가능한 칩으로 렌더링할 수 있도록 JSON 형태로 반환합니다.
|
||||
/// </summary>
|
||||
public class SuggestActionsTool : IAgentTool
|
||||
{
|
||||
public string Name => "suggest_actions";
|
||||
|
||||
public string Description =>
|
||||
"Suggest 2-5 follow-up actions after completing a task. " +
|
||||
"Returns structured JSON that the UI renders as clickable action chips. " +
|
||||
"Each action has a label (display text), command (slash command or natural language prompt), " +
|
||||
"optional icon (Segoe MDL2 Assets code), and priority (high/medium/low).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["actions"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "List of action objects. Each object: {\"label\": \"표시 텍스트\", \"command\": \"/slash 또는 자연어\", \"icon\": \"\\uE8A5\" (optional), \"priority\": \"high|medium|low\"}",
|
||||
Items = new() { Type = "object", Description = "Action object with label, command, icon, priority" },
|
||||
},
|
||||
["context"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Current task context summary (optional)",
|
||||
},
|
||||
},
|
||||
Required = ["actions"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!args.TryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
|
||||
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
|
||||
|
||||
var actions = new List<Dictionary<string, string>>();
|
||||
foreach (var item in actionsEl.EnumerateArray())
|
||||
{
|
||||
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
|
||||
var command = item.TryGetProperty("command", out var c) ? c.GetString() ?? "" : "";
|
||||
var icon = item.TryGetProperty("icon", out var i) ? i.GetString() ?? "" : "";
|
||||
var priority = item.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return Task.FromResult(ToolResult.Fail("각 action에는 command가 필요합니다."));
|
||||
|
||||
// priority 유효성 검사
|
||||
var validPriorities = new[] { "high", "medium", "low" };
|
||||
if (!validPriorities.Contains(priority))
|
||||
priority = "medium";
|
||||
|
||||
var action = new Dictionary<string, string>
|
||||
{
|
||||
["label"] = label,
|
||||
["command"] = command,
|
||||
["priority"] = priority,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(icon))
|
||||
action["icon"] = icon;
|
||||
|
||||
actions.Add(action);
|
||||
}
|
||||
|
||||
if (actions.Count < 1 || actions.Count > 5)
|
||||
return Task.FromResult(ToolResult.Fail("actions는 1~5개 사이여야 합니다."));
|
||||
|
||||
var contextSummary = args.TryGetProperty("context", out var ctx) ? ctx.GetString() ?? "" : "";
|
||||
|
||||
// 구조화된 JSON 응답 생성
|
||||
var result = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "suggest_actions",
|
||||
["actions"] = actions,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(contextSummary))
|
||||
result["context"] = contextSummary;
|
||||
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(json));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"액션 제안 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/AxCopilot/Services/Agent/TaskDecomposer.cs
Normal file
95
src/AxCopilot/Services/Agent/TaskDecomposer.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// LLM 응답 텍스트에서 작업 계획(단계 목록)을 추출합니다.
|
||||
/// Plan-and-Solve 논문의 경량 구현: 번호가 매겨진 단계를 파싱하여 진행률 추적에 사용합니다.
|
||||
/// </summary>
|
||||
public static class TaskDecomposer
|
||||
{
|
||||
// 번호 매긴 단계 패턴: "1. ...", "1) ...", "Step 1: ..."
|
||||
private static readonly Regex StepPattern = new(
|
||||
@"(?:^|\n)\s*(?:(?:Step\s*)?(\d+)[.):\-]\s*)(.+?)(?=\n|$)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// LLM 텍스트 응답에서 계획 단계를 추출합니다.
|
||||
/// </summary>
|
||||
/// <returns>단계 목록. 2개 미만이면 빈 리스트 (계획이 아닌 것으로 판단).</returns>
|
||||
public static List<string> ExtractSteps(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return [];
|
||||
|
||||
var matches = StepPattern.Matches(text);
|
||||
if (matches.Count < 2) return [];
|
||||
|
||||
var steps = new List<string>();
|
||||
int lastNum = 0;
|
||||
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
if (int.TryParse(m.Groups[1].Value, out var num))
|
||||
{
|
||||
// 연속 번호인지 확인 (1,2,3... 또는 첫 번째)
|
||||
if (num == lastNum + 1 || lastNum == 0)
|
||||
{
|
||||
var stepText = m.Groups[2].Value.Trim();
|
||||
// 마크다운 기호 제거 (볼드, 이탤릭, 코드, 링크 등)
|
||||
stepText = Regex.Replace(stepText, @"\*\*(.+?)\*\*", "$1"); // **볼드**
|
||||
stepText = Regex.Replace(stepText, @"\*(.+?)\*", "$1"); // *이탤릭*
|
||||
stepText = Regex.Replace(stepText, @"`(.+?)`", "$1"); // `코드`
|
||||
stepText = Regex.Replace(stepText, @"\[(.+?)\]\(.+?\)", "$1"); // [링크](url)
|
||||
stepText = stepText.TrimEnd(':', ' ');
|
||||
// 너무 짧거나 긴 것은 제외
|
||||
if (stepText.Length >= 3 && stepText.Length <= 300)
|
||||
{
|
||||
steps.Add(stepText);
|
||||
lastNum = num;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 최소 2단계, 최대 20단계
|
||||
return steps.Count >= 2 ? steps.Take(20).ToList() : [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 도구 호출과 매칭하여 현재 어느 단계를 실행 중인지 추정합니다.
|
||||
/// </summary>
|
||||
public static int EstimateCurrentStep(List<string> steps, string toolName, string toolSummary, int lastStep)
|
||||
{
|
||||
if (steps.Count == 0) return 0;
|
||||
|
||||
// 다음 단계부터 검색 (역행하지 않음)
|
||||
for (int i = lastStep; i < steps.Count; i++)
|
||||
{
|
||||
var step = steps[i].ToLowerInvariant();
|
||||
var tool = toolName.ToLowerInvariant();
|
||||
var summary = toolSummary.ToLowerInvariant();
|
||||
|
||||
// 단계 텍스트에 도구명이나 주요 키워드가 포함되면 매칭
|
||||
if (step.Contains(tool) ||
|
||||
ContainsKeywordOverlap(step, summary))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭 실패 → 마지막 단계 + 1로 전진 (최소 진행)
|
||||
return Math.Min(lastStep + 1, steps.Count - 1);
|
||||
}
|
||||
|
||||
private static bool ContainsKeywordOverlap(string stepText, string summary)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary)) return false;
|
||||
|
||||
// 의미 있는 키워드 추출 (3자 이상 단어)
|
||||
var summaryWords = summary.Split(' ', '/', '\\', '.', ',', ':', ';', '(', ')')
|
||||
.Where(w => w.Length >= 3)
|
||||
.Take(5);
|
||||
|
||||
return summaryWords.Any(w => stepText.Contains(w));
|
||||
}
|
||||
}
|
||||
209
src/AxCopilot/Services/Agent/TaskTrackerTool.cs
Normal file
209
src/AxCopilot/Services/Agent/TaskTrackerTool.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>작업 폴더 내 TODO/태스크 추적 도구.</summary>
|
||||
public class TaskTrackerTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_tracker";
|
||||
public string Description =>
|
||||
"Track tasks/TODOs in the working folder. Actions: " +
|
||||
"'scan' — scan source files for TODO/FIXME/HACK/BUG comments; " +
|
||||
"'add' — add a task to .ax/tasks.json; " +
|
||||
"'list' — list tasks from .ax/tasks.json; " +
|
||||
"'done' — mark a task as completed.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action: scan, add, list, done",
|
||||
Enum = ["scan", "add", "list", "done"],
|
||||
},
|
||||
["title"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Task title (for 'add')",
|
||||
},
|
||||
["priority"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Priority: high, medium, low (for 'add', default: medium)",
|
||||
},
|
||||
["id"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Task ID (for 'done')",
|
||||
},
|
||||
["extensions"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "File extensions to scan, comma-separated (default: .cs,.py,.js,.ts,.java,.cpp,.c)",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
private const string TaskFileName = ".ax/tasks.json";
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"scan" => Task.FromResult(ScanTodos(args, context)),
|
||||
"add" => Task.FromResult(AddTask(args, context)),
|
||||
"list" => Task.FromResult(ListTasks(context)),
|
||||
"done" => Task.FromResult(MarkDone(args, context)),
|
||||
_ => Task.FromResult(ToolResult.Fail($"Unknown action: {action}")),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"태스크 추적 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
|
||||
{
|
||||
var extStr = args.TryGetProperty("extensions", out var e)
|
||||
? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
|
||||
: ".cs,.py,.js,.ts,.java,.cpp,.c";
|
||||
var exts = new HashSet<string>(
|
||||
extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var patterns = new[] { "TODO", "FIXME", "HACK", "BUG", "XXX" };
|
||||
var results = new List<(string File, int Line, string Tag, string Text)>();
|
||||
var workDir = context.WorkFolder;
|
||||
|
||||
if (!Directory.Exists(workDir))
|
||||
return ToolResult.Fail($"작업 폴더 없음: {workDir}");
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(workDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!exts.Contains(Path.GetExtension(file))) continue;
|
||||
if (file.Contains("bin") || file.Contains("obj") || file.Contains("node_modules")) continue;
|
||||
|
||||
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(file).Text);
|
||||
var lineNum = 0;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
lineNum++;
|
||||
foreach (var pat in patterns)
|
||||
{
|
||||
var idx = line.IndexOf(pat, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var text = line[(idx + pat.Length)..].TrimStart(':', ' ');
|
||||
if (text.Length > 100) text = text[..100] + "...";
|
||||
var relPath = Path.GetRelativePath(workDir, file);
|
||||
results.Add((relPath, lineNum, pat, text));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (results.Count >= 200) break;
|
||||
}
|
||||
if (results.Count >= 200) break;
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
return ToolResult.Ok("TODO/FIXME 코멘트가 발견되지 않았습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"발견된 TODO 코멘트: {results.Count}개");
|
||||
foreach (var (file, line, tag, text) in results)
|
||||
sb.AppendLine($" [{tag}] {file}:{line} — {text}");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult AddTask(JsonElement args, AgentContext context)
|
||||
{
|
||||
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(title))
|
||||
return ToolResult.Fail("'title'이 필요합니다.");
|
||||
|
||||
var priority = args.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
|
||||
var tasks = LoadTasks(context);
|
||||
|
||||
var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0;
|
||||
tasks.Add(new TaskItem
|
||||
{
|
||||
Id = maxId + 1,
|
||||
Title = title,
|
||||
Priority = priority,
|
||||
Created = DateTime.Now.ToString("yyyy-MM-dd HH:mm"),
|
||||
Done = false,
|
||||
});
|
||||
|
||||
SaveTasks(context, tasks);
|
||||
return ToolResult.Ok($"태스크 추가: #{maxId + 1} — {title} (우선순위: {priority})");
|
||||
}
|
||||
|
||||
private static ToolResult ListTasks(AgentContext context)
|
||||
{
|
||||
var tasks = LoadTasks(context);
|
||||
if (tasks.Count == 0)
|
||||
return ToolResult.Ok("등록된 태스크가 없습니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"태스크 목록 ({tasks.Count}개):");
|
||||
foreach (var task in tasks.OrderBy(t => t.Done).ThenByDescending(t => t.Priority == "high" ? 0 : t.Priority == "medium" ? 1 : 2))
|
||||
{
|
||||
var status = task.Done ? "✓" : "○";
|
||||
sb.AppendLine($" {status} #{task.Id} [{task.Priority}] {task.Title} ({task.Created})");
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult MarkDone(JsonElement args, AgentContext context)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("'id'가 필요합니다.");
|
||||
var id = idEl.GetInt32();
|
||||
|
||||
var tasks = LoadTasks(context);
|
||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task == null) return ToolResult.Fail($"태스크 #{id}를 찾을 수 없습니다.");
|
||||
|
||||
task.Done = true;
|
||||
SaveTasks(context, tasks);
|
||||
return ToolResult.Ok($"태스크 #{id} 완료: {task.Title}");
|
||||
}
|
||||
|
||||
private static List<TaskItem> LoadTasks(AgentContext context)
|
||||
{
|
||||
var path = Path.Combine(context.WorkFolder, TaskFileName);
|
||||
if (!File.Exists(path)) return new List<TaskItem>();
|
||||
var json = TextFileCodec.ReadAllText(path).Text;
|
||||
return JsonSerializer.Deserialize<List<TaskItem>>(json) ?? new List<TaskItem>();
|
||||
}
|
||||
|
||||
private static void SaveTasks(AgentContext context, List<TaskItem> tasks)
|
||||
{
|
||||
var path = Path.Combine(context.WorkFolder, TaskFileName);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
var json = JsonSerializer.Serialize(tasks, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(path, json, TextFileCodec.Utf8NoBom);
|
||||
}
|
||||
|
||||
private class TaskItem
|
||||
{
|
||||
[JsonPropertyName("id")] public int Id { get; set; }
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||
[JsonPropertyName("priority")] public string Priority { get; set; } = "medium";
|
||||
[JsonPropertyName("created")] public string Created { get; set; } = "";
|
||||
[JsonPropertyName("done")] public bool Done { get; set; }
|
||||
}
|
||||
}
|
||||
88
src/AxCopilot/Services/Agent/TaskTypePolicy.cs
Normal file
88
src/AxCopilot/Services/Agent/TaskTypePolicy.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Task type specific runtime policy for agent loop guidance and quality gates.
|
||||
/// </summary>
|
||||
internal sealed class TaskTypePolicy
|
||||
{
|
||||
public required string TaskType { get; init; }
|
||||
public required string GuidanceMessage { get; init; }
|
||||
public required string FailurePatternFocus { get; init; }
|
||||
public required string FollowUpTaskLine { get; init; }
|
||||
public required string FailureInvestigationTaskLine { get; init; }
|
||||
public required string FinalReportTaskLine { get; init; }
|
||||
public bool IsReviewTask { get; init; }
|
||||
|
||||
public static TaskTypePolicy FromTaskType(string taskType)
|
||||
{
|
||||
return taskType switch
|
||||
{
|
||||
"bugfix" => new TaskTypePolicy
|
||||
{
|
||||
TaskType = "bugfix",
|
||||
GuidanceMessage =
|
||||
"[System:TaskType] This is a bug-fix task. Prioritize reproduction evidence, root cause linkage, smallest safe fix, and regression verification. " +
|
||||
"Preferred tool order: file_read -> grep/glob -> build_run/test_loop -> file_edit -> build_run/test_loop.",
|
||||
FailurePatternFocus = "재현 조건과 원인 연결을 먼저 확인하세요. Check reproduction conditions and root-cause linkage first.",
|
||||
FollowUpTaskLine = "작업 유형: bugfix. Task type: bugfix. Verify the fix is directly linked to the symptom and confirm non-regression.\n",
|
||||
FailureInvestigationTaskLine = "추가 점검: 재현 조건 기준으로 증상이 재현되지 않는지와 원인 연결이 타당한지 확인하세요. Extra check: confirm the symptom is no longer reproducible and root-cause linkage is valid.\n",
|
||||
FinalReportTaskLine = "버그 수정은 원인, 수정 내용, 재현/회귀 검증 근거를 포함하세요. For bug fixes, include root cause, change summary, and reproduction/regression evidence.\n",
|
||||
},
|
||||
"feature" => new TaskTypePolicy
|
||||
{
|
||||
TaskType = "feature",
|
||||
GuidanceMessage =
|
||||
"[System:TaskType] This is a feature task. Prioritize affected interfaces/callers, data flow, validation paths, and test/documentation needs. " +
|
||||
"Preferred tool order: folder_map -> file_read -> grep/glob -> file_edit -> build_run/test_loop.",
|
||||
FailurePatternFocus = "Check new behavior flow and caller linkage first.",
|
||||
FollowUpTaskLine = "작업 유형: feature. Task type: feature. Verify behavior flow, input/output path, caller impact, and test additions.\n",
|
||||
FailureInvestigationTaskLine = "추가 점검: 새 기능 경로와 호출부 연결이 의도대로 동작하는지 확인하세요. Extra check: confirm feature path and caller linkage behave as intended.\n",
|
||||
FinalReportTaskLine = "For features, include behavior flow, impacted files/callers, and verification evidence.\n",
|
||||
},
|
||||
"refactor" => new TaskTypePolicy
|
||||
{
|
||||
TaskType = "refactor",
|
||||
GuidanceMessage =
|
||||
"[System:TaskType] This is a refactor task. Prioritize behavior preservation, reference impact, diff review, and non-regression evidence. " +
|
||||
"Preferred tool order: file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop.",
|
||||
FailurePatternFocus = "Check behavior preservation and impact scope first.",
|
||||
FollowUpTaskLine = "작업 유형: refactor. Task type: refactor. Prioritize behavior-preservation evidence over cosmetic cleanup.\n",
|
||||
FailureInvestigationTaskLine = "추가 점검: 동작 보존 관점에서 기존 호출 흐름이 동일하게 유지되는지 확인하세요. Extra check: validate existing call flow remains behavior-compatible.\n",
|
||||
FinalReportTaskLine = "For refactors, include behavior-preservation evidence and impact scope.\n",
|
||||
},
|
||||
"review" => new TaskTypePolicy
|
||||
{
|
||||
TaskType = "review",
|
||||
GuidanceMessage =
|
||||
"[System:TaskType] This is a review task. Prioritize concrete defects, regressions, risky assumptions, and missing tests before summaries. " +
|
||||
"Report findings with P0-P3 severity and file evidence, then separate Fixed vs Unfixed status. " +
|
||||
"Preferred tool order: file_read -> grep/glob -> git_tool(diff) -> evidence-first findings.",
|
||||
FailurePatternFocus = "Review focus: severity accuracy (P0-P3), file-grounded evidence, and unresolved-risk clarity.",
|
||||
FollowUpTaskLine = "작업 유형: review-follow-up. Task type: review-follow-up. For each finding, state status as Fixed or Unfixed with verification evidence.\n",
|
||||
FailureInvestigationTaskLine = "추가 점검: 리뷰에서 지적된 위험은 반드시 수정 근거나 미해결 사유/영향을 남기세요. Extra check: every risk must have either a concrete fix or an explicit unresolved rationale and impact.\n",
|
||||
FinalReportTaskLine = "For review, list P0-P3 findings with file evidence and split into Fixed vs Unfixed with residual risk.\n",
|
||||
IsReviewTask = true
|
||||
},
|
||||
"docs" => new TaskTypePolicy
|
||||
{
|
||||
TaskType = "docs",
|
||||
GuidanceMessage =
|
||||
"[System:TaskType] This is a document/content task. Prioritize source evidence, completeness, consistency, and self-review. " +
|
||||
"Preferred tool order: folder_map -> document_read/file_read -> drafting tool -> self-review.",
|
||||
FailurePatternFocus = "Check source evidence and document completeness first.",
|
||||
FollowUpTaskLine = "",
|
||||
FailureInvestigationTaskLine = "",
|
||||
FinalReportTaskLine = "",
|
||||
},
|
||||
_ => new TaskTypePolicy
|
||||
{
|
||||
TaskType = "general",
|
||||
GuidanceMessage = "[System:TaskType] Use a cautious analyze -> implement -> verify workflow and do not finish without concrete evidence.",
|
||||
FailurePatternFocus = "Check recent failure patterns first.",
|
||||
FollowUpTaskLine = "",
|
||||
FailureInvestigationTaskLine = "",
|
||||
FinalReportTaskLine = "",
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
203
src/AxCopilot/Services/Agent/TemplateRenderTool.cs
Normal file
203
src/AxCopilot/Services/Agent/TemplateRenderTool.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Mustache 스타일 변수 치환 기반 템플릿 렌더링 도구.
|
||||
/// {{variable}}, {{#list}}...{{/list}} 반복, {{^cond}}...{{/cond}} 조건부 렌더링을 지원합니다.
|
||||
/// </summary>
|
||||
public class TemplateRenderTool : IAgentTool
|
||||
{
|
||||
public string Name => "template_render";
|
||||
public string Description =>
|
||||
"Render a template file with variable substitution and loops. " +
|
||||
"Supports Mustache-style syntax: {{variable}}, {{#list}}...{{/list}} loops, " +
|
||||
"{{^variable}}...{{/variable}} inverted sections (if empty/false). " +
|
||||
"Useful for generating repetitive documents like emails, reports, invoices from templates.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["template_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Path to template file (.html, .md, .txt). Relative to work folder."
|
||||
},
|
||||
["template_text"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Inline template text (used if template_path is not provided)."
|
||||
},
|
||||
["variables"] = new()
|
||||
{
|
||||
Type = "object",
|
||||
Description = "Key-value pairs for substitution. Values can be strings, numbers, " +
|
||||
"or arrays of objects for loops. Example: {\"name\": \"홍길동\", \"items\": [{\"product\": \"A\", \"qty\": 10}]}"
|
||||
},
|
||||
["output_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Output file path. If not provided, returns rendered text."
|
||||
},
|
||||
},
|
||||
Required = ["variables"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// 템플릿 텍스트 로드
|
||||
string template;
|
||||
if (args.TryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.GetString()))
|
||||
{
|
||||
var templatePath = FileReadTool.ResolvePath(tpEl.GetString()!, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(templatePath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
|
||||
if (!File.Exists(templatePath))
|
||||
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
|
||||
template = (await TextFileCodec.ReadAllTextAsync(templatePath, ct)).Text;
|
||||
}
|
||||
else if (args.TryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.GetString()))
|
||||
{
|
||||
template = ttEl.GetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
|
||||
}
|
||||
|
||||
if (!args.TryGetProperty("variables", out var varsEl))
|
||||
return ToolResult.Fail("variables가 필요합니다.");
|
||||
|
||||
try
|
||||
{
|
||||
// 렌더링
|
||||
var rendered = Render(template, varsEl);
|
||||
|
||||
// 출력
|
||||
if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString()))
|
||||
{
|
||||
var outputPath = FileReadTool.ResolvePath(opEl.GetString()!, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") outputPath = AgentContext.EnsureTimestampedPath(outputPath);
|
||||
if (!context.IsPathAllowed(outputPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
|
||||
if (!await context.CheckWritePermissionAsync(Name, outputPath))
|
||||
return ToolResult.Fail($"쓰기 권한 거부: {outputPath}");
|
||||
|
||||
await TextFileCodec.WriteAllTextAsync(outputPath, rendered, TextFileCodec.Utf8NoBom, ct);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"✅ 템플릿 렌더링 완료: {Path.GetFileName(outputPath)} ({rendered.Length:N0}자)",
|
||||
outputPath);
|
||||
}
|
||||
|
||||
// 파일로 저장하지 않으면 텍스트로 반환
|
||||
if (rendered.Length > 4000)
|
||||
return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered[..3900]}...\n[이하 생략]");
|
||||
|
||||
return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"템플릿 렌더링 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mustache 스타일 템플릿을 렌더링합니다.</summary>
|
||||
internal static string Render(string template, JsonElement variables)
|
||||
{
|
||||
var result = template;
|
||||
|
||||
// 1. 반복 섹션 {{#key}}...{{/key}}
|
||||
result = Regex.Replace(result, @"\{\{#(\w+)\}\}(.*?)\{\{/\1\}\}", match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
var body = match.Groups[2].Value;
|
||||
|
||||
if (!variables.TryGetProperty(key, out var val)) return "";
|
||||
|
||||
if (val.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int index = 0;
|
||||
foreach (var item in val.EnumerateArray())
|
||||
{
|
||||
var itemBody = body;
|
||||
// {{.key}} 또는 {{key}} 치환
|
||||
if (item.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
{
|
||||
itemBody = itemBody
|
||||
.Replace($"{{{{{prop.Name}}}}}", prop.Value.ToString())
|
||||
.Replace($"{{{{.{prop.Name}}}}}", prop.Value.ToString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
itemBody = itemBody.Replace("{{.}}", item.ToString());
|
||||
}
|
||||
// {{@index}} 치환
|
||||
itemBody = itemBody.Replace("{{@index}}", (index + 1).ToString());
|
||||
sb.Append(itemBody);
|
||||
index++;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// bool true → 섹션 표시
|
||||
if (val.ValueKind == JsonValueKind.True)
|
||||
return RenderSimpleVars(body, variables);
|
||||
|
||||
// 값이 있으면 표시
|
||||
if (val.ValueKind != JsonValueKind.False &&
|
||||
val.ValueKind != JsonValueKind.Null &&
|
||||
val.ValueKind != JsonValueKind.Undefined)
|
||||
return RenderSimpleVars(body, variables);
|
||||
|
||||
return "";
|
||||
}, RegexOptions.Singleline);
|
||||
|
||||
// 2. 반전 섹션 {{^key}}...{{/key}} (값이 없거나 false일 때 표시)
|
||||
result = Regex.Replace(result, @"\{\{\^(\w+)\}\}(.*?)\{\{/\1\}\}", match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
var body = match.Groups[2].Value;
|
||||
|
||||
if (!variables.TryGetProperty(key, out var val)) return body;
|
||||
if (val.ValueKind == JsonValueKind.False ||
|
||||
val.ValueKind == JsonValueKind.Null ||
|
||||
(val.ValueKind == JsonValueKind.Array && val.GetArrayLength() == 0) ||
|
||||
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.GetString())))
|
||||
return body;
|
||||
|
||||
return "";
|
||||
}, RegexOptions.Singleline);
|
||||
|
||||
// 3. 단순 변수 치환 {{key}}
|
||||
result = RenderSimpleVars(result, variables);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RenderSimpleVars(string text, JsonElement variables)
|
||||
{
|
||||
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
if (!variables.TryGetProperty(key, out var val)) return match.Value;
|
||||
|
||||
return val.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => val.GetString() ?? "",
|
||||
JsonValueKind.Number => val.ToString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => val.ToString()
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
734
src/AxCopilot/Services/Agent/TemplateService.cs
Normal file
734
src/AxCopilot/Services/Agent/TemplateService.cs
Normal file
@@ -0,0 +1,734 @@
|
||||
using Markdig;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 디자인 템플릿 서비스.
|
||||
/// 테마 무드(현대적, 전문가, 창의적 등)에 따라 CSS 스타일을 제공합니다.
|
||||
/// HtmlSkill, DocxSkill 등에서 호출하여 문서 생성 시 적용합니다.
|
||||
/// </summary>
|
||||
public static class TemplateService
|
||||
{
|
||||
/// <summary>사용 가능한 테마 무드 목록.</summary>
|
||||
public static readonly TemplateMood[] AvailableMoods =
|
||||
[
|
||||
new("modern", "현대적", "🔷", "깔끔한 라인, 넓은 여백, 미니멀한 색상 — 테크 기업 스타일"),
|
||||
new("professional", "전문가", "📊", "신뢰감 있는 네이비 톤, 데이터 중심 레이아웃 — 비즈니스 보고서"),
|
||||
new("creative", "아이디어", "🎨", "생동감 있는 그라데이션, 카드 레이아웃 — 브레인스토밍·기획서"),
|
||||
new("minimal", "미니멀", "◻️", "극도로 절제된 흑백, 타이포그래피 중심 — 학술·논문 스타일"),
|
||||
new("elegant", "우아한", "✨", "세리프 서체, 골드 포인트, 격식 있는 레이아웃 — 공식 문서"),
|
||||
new("dark", "다크 모드", "🌙", "어두운 배경, 고대비 텍스트 — 개발자·야간 리딩"),
|
||||
new("colorful", "컬러풀", "🌈", "밝고 활기찬 멀티 컬러, 둥근 모서리 — 프레젠테이션·요약"),
|
||||
new("corporate", "기업 공식", "🏢", "보수적인 레이아웃, 로고 영역, 페이지 번호 — 사내 공식 보고서"),
|
||||
new("magazine", "매거진", "📰", "멀티 컬럼, 큰 히어로 헤더, 인용 강조 — 뉴스레터·매거진"),
|
||||
new("dashboard", "대시보드", "📈", "KPI 카드, 차트 영역, 그리드 레이아웃 — 데이터 대시보드"),
|
||||
];
|
||||
|
||||
// ── 커스텀 무드 저장소 ──
|
||||
private static readonly Dictionary<string, Models.CustomMoodEntry> _customMoods = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>커스텀 무드를 로드합니다 (앱 시작 시 SettingsService에서 호출).</summary>
|
||||
public static void LoadCustomMoods(IEnumerable<Models.CustomMoodEntry> entries)
|
||||
{
|
||||
_customMoods.Clear();
|
||||
foreach (var e in entries)
|
||||
if (!string.IsNullOrWhiteSpace(e.Key))
|
||||
_customMoods[e.Key] = e;
|
||||
}
|
||||
|
||||
/// <summary>내장 + 커스텀 무드를 합친 전체 목록.</summary>
|
||||
public static IReadOnlyList<TemplateMood> AllMoods
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = new List<TemplateMood>(AvailableMoods);
|
||||
foreach (var cm in _customMoods.Values)
|
||||
list.Add(new TemplateMood(cm.Key, cm.Label, cm.Icon, cm.Description));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>무드 키로 CSS 스타일을 반환합니다. 없으면 modern 기본값. 공통 CSS가 자동 첨부됩니다.</summary>
|
||||
public static string GetCss(string moodKey)
|
||||
{
|
||||
// 커스텀 무드 우선 확인
|
||||
if (_customMoods.TryGetValue(moodKey, out var custom))
|
||||
return custom.Css + "\n" + CssShared;
|
||||
|
||||
var moodCss = moodKey switch
|
||||
{
|
||||
"modern" => CssModern,
|
||||
"professional" => CssProfessional,
|
||||
"creative" => CssCreative,
|
||||
"minimal" => CssMinimal,
|
||||
"elegant" => CssElegant,
|
||||
"dark" => CssDark,
|
||||
"colorful" => CssColorful,
|
||||
"corporate" => CssCorporate,
|
||||
"magazine" => CssMagazine,
|
||||
"dashboard" => CssDashboard,
|
||||
_ => CssModern,
|
||||
};
|
||||
return moodCss + "\n" + CssShared;
|
||||
}
|
||||
|
||||
/// <summary>무드 키로 TemplateMood를 반환합니다.</summary>
|
||||
public static TemplateMood? GetMood(string key)
|
||||
{
|
||||
var builtin = Array.Find(AvailableMoods, m => m.Key == key);
|
||||
if (builtin != null) return builtin;
|
||||
return _customMoods.TryGetValue(key, out var cm)
|
||||
? new TemplateMood(cm.Key, cm.Label, cm.Icon, cm.Description)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>LLM 시스템 프롬프트에 삽입할 무드 목록 설명.</summary>
|
||||
public static string GetMoodListForPrompt()
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Available document design moods (pass as 'mood' parameter to html_create):");
|
||||
foreach (var m in AllMoods)
|
||||
sb.AppendLine($" - \"{m.Key}\": {m.Label} — {m.Description}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Markdown 텍스트를 무드 CSS가 적용된 HTML 문서로 변환합니다.</summary>
|
||||
public static string RenderMarkdownToHtml(string markdown, string moodKey = "modern")
|
||||
{
|
||||
var pipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.Build();
|
||||
var bodyHtml = Markdown.ToHtml(markdown, pipeline);
|
||||
var css = GetCss(moodKey);
|
||||
return $"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{bodyHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML 본문에서 워크스페이스 하위 파일/폴더 경로를 파란색으로 강조합니다.
|
||||
/// 코드 블록(<code>, <pre>) 내부의 경로 텍스트를 감지하여 파란색 스타일을 적용합니다.
|
||||
/// </summary>
|
||||
public static string HighlightFilePaths(string html, string? workFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder) || string.IsNullOrEmpty(html))
|
||||
return html;
|
||||
|
||||
// <code> 태그 내부의 텍스트 중 파일/폴더 경로 패턴을 감지하여 파란색으로 감싸기
|
||||
// 패턴: 슬래시/백슬래시를 포함한 경로 또는 확장자가 있는 파일명
|
||||
var pathPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"(?<![""'=/>])(\b[A-Za-z]:\\[^\s<>""]+|" + // 절대 경로: C:\folder\file.ext
|
||||
@"\.{0,2}/[^\s<>""]+(?:\.[a-zA-Z]{1,10})?|" + // 상대 경로: ./src/file.cs, ../util.py
|
||||
@"\b[\w\-]+(?:/[\w\-\.]+)+(?:\.[a-zA-Z]{1,10})?|" + // 폴더/파일: src/utils/helper.cs
|
||||
@"\b[\w\-]+\.(?:cs|py|js|ts|tsx|jsx|json|xml|html|htm|css|md|txt|yml|yaml|toml|sh|bat|ps1|csproj|sln|docx|xlsx|pptx|pdf|csv)\b)", // 파일명.확장자
|
||||
System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
// <pre> 및 <code> 블록 외부의 텍스트에서만 치환
|
||||
// 간단 접근: 모든 텍스트 노드에서 경로를 감지
|
||||
return pathPattern.Replace(html, match =>
|
||||
{
|
||||
var path = match.Value;
|
||||
// 이미 HTML 태그 내부이거나 href/src 속성값이면 건너뛰기
|
||||
var prefix = html[..match.Index];
|
||||
if (prefix.Length > 0)
|
||||
{
|
||||
var lastLt = prefix.LastIndexOf('<');
|
||||
var lastGt = prefix.LastIndexOf('>');
|
||||
if (lastLt > lastGt) return path; // 태그 속성 내부
|
||||
}
|
||||
return $"<span style=\"color:#3B82F6;font-weight:500;\">{path}</span>";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>무드의 주요 색상을 반환합니다 (갤러리 미리보기용).</summary>
|
||||
public static MoodColors GetMoodColors(string moodKey)
|
||||
{
|
||||
return moodKey switch
|
||||
{
|
||||
"modern" => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"),
|
||||
"professional" => new("#f0f2f5", "#ffffff", "#1a365d", "#4a5568", "#2c5282", "#e2e8f0"),
|
||||
"creative" => new("#faf5ff", "#ffffff", "#2d3748", "#718096", "#7c3aed", "#e9d5ff"),
|
||||
"minimal" => new("#fafafa", "#ffffff", "#111111", "#555555", "#333333", "#e0e0e0"),
|
||||
"elegant" => new("#fefdf8", "#fffef9", "#2c1810", "#6b5c4f", "#b8860b", "#e8e0d4"),
|
||||
"dark" => new("#0d1117", "#161b22", "#e6edf3", "#8b949e", "#58a6ff", "#30363d"),
|
||||
"colorful" => new("#f0f9ff", "#ffffff", "#1e293b", "#64748b", "#3b82f6", "#e0f2fe"),
|
||||
"corporate" => new("#f3f4f6", "#ffffff", "#1f2937", "#6b7280", "#1e40af", "#e5e7eb"),
|
||||
"magazine" => new("#f9fafb", "#ffffff", "#111827", "#6b7280", "#dc2626", "#f3f4f6"),
|
||||
"dashboard" => new("#0f172a", "#1e293b", "#f1f5f9", "#94a3b8", "#3b82f6", "#334155"),
|
||||
_ => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>무드 갤러리용 색상 정보.</summary>
|
||||
public record MoodColors(string Background, string CardBg, string PrimaryText, string SecondaryText, string Accent, string Border);
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// CSS 템플릿 정의
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
#region Modern — 현대적
|
||||
private const string CssModern = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: #fff;
|
||||
border-radius: 16px; padding: 56px 52px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
|
||||
h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; }
|
||||
h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #1d1d1f;
|
||||
padding-bottom: 8px; border-bottom: 2px solid #e5e5ea; }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #0071e3; }
|
||||
.meta { font-size: 12px; color: #86868b; margin-bottom: 28px; letter-spacing: 0.3px; }
|
||||
p { margin: 10px 0; font-size: 14.5px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13.5px;
|
||||
border-radius: 10px; overflow: hidden; }
|
||||
th { background: #f5f5f7; text-align: left; padding: 12px 14px; font-weight: 600;
|
||||
color: #1d1d1f; border-bottom: 2px solid #d2d2d7; }
|
||||
td { padding: 10px 14px; border-bottom: 1px solid #f0f0f2; }
|
||||
tr:hover td { background: #f9f9fb; }
|
||||
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
|
||||
li { margin: 5px 0; }
|
||||
code { background: #f5f5f7; padding: 2px 8px; border-radius: 6px; font-size: 13px;
|
||||
font-family: 'SF Mono', Consolas, monospace; color: #e3116c; }
|
||||
pre { background: #1d1d1f; color: #f5f5f7; padding: 20px; border-radius: 12px;
|
||||
overflow-x: auto; font-size: 13px; margin: 16px 0; line-height: 1.6; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 3px solid #0071e3; padding: 12px 20px; margin: 16px 0;
|
||||
background: #f0f7ff; color: #1d1d1f; border-radius: 0 8px 8px 0; font-size: 14px; }
|
||||
.highlight { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%);
|
||||
padding: 16px 20px; border-radius: 10px; margin: 16px 0; }
|
||||
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px;
|
||||
font-weight: 600; background: #0071e3; color: #fff; margin: 2px 4px 2px 0; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Professional — 전문가
|
||||
private const string CssProfessional = """
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
||||
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: #fff;
|
||||
border-radius: 8px; padding: 48px;
|
||||
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
|
||||
border-top: 4px solid #1e3a5f; }
|
||||
h1 { font-size: 26px; font-weight: 700; color: #1e3a5f; margin-bottom: 4px; }
|
||||
h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; color: #1e3a5f;
|
||||
border-bottom: 2px solid #c8d6e5; padding-bottom: 6px; }
|
||||
h3 { font-size: 15px; font-weight: 600; margin: 22px 0 8px; color: #2c5282; }
|
||||
.meta { font-size: 12px; color: #94a3b8; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 12px; }
|
||||
p { margin: 8px 0; font-size: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px;
|
||||
border: 1px solid #e2e8f0; }
|
||||
th { background: #1e3a5f; color: #fff; text-align: left; padding: 10px 14px;
|
||||
font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #e2e8f0; }
|
||||
tr:nth-child(even) td { background: #f8fafc; }
|
||||
tr:hover td { background: #eef2ff; }
|
||||
ul, ol { margin: 8px 0 8px 24px; }
|
||||
li { margin: 4px 0; font-size: 14px; }
|
||||
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 12.5px;
|
||||
font-family: Consolas, monospace; color: #1e3a5f; }
|
||||
pre { background: #0f172a; color: #e2e8f0; padding: 18px; border-radius: 6px;
|
||||
overflow-x: auto; font-size: 12.5px; margin: 14px 0; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 4px solid #1e3a5f; padding: 10px 18px; margin: 14px 0;
|
||||
background: #f0f4f8; color: #334155; }
|
||||
.callout { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px;
|
||||
padding: 14px 18px; margin: 14px 0; font-size: 13.5px; }
|
||||
.callout strong { color: #1e40af; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Creative — 아이디어
|
||||
private const string CssCreative = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(20px); border-radius: 20px; padding: 52px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||
h1 { font-size: 30px; font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea, #e040fb);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
margin-bottom: 4px; }
|
||||
h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #553c9a;
|
||||
position: relative; padding-left: 16px; }
|
||||
h2::before { content: ''; position: absolute; left: 0; top: 4px; width: 4px; height: 22px;
|
||||
background: linear-gradient(180deg, #667eea, #e040fb); border-radius: 4px; }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 22px 0 10px; color: #7c3aed; }
|
||||
.meta { font-size: 12px; color: #a0aec0; margin-bottom: 28px; }
|
||||
p { margin: 10px 0; font-size: 14.5px; }
|
||||
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0;
|
||||
font-size: 13.5px; border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(102,126,234,0.1); }
|
||||
th { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff;
|
||||
text-align: left; padding: 12px 14px; font-weight: 600; }
|
||||
td { padding: 10px 14px; border-bottom: 1px solid #f0e7fe; }
|
||||
tr:hover td { background: #faf5ff; }
|
||||
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
|
||||
li { margin: 5px 0; }
|
||||
li::marker { color: #7c3aed; }
|
||||
code { background: #f5f3ff; padding: 2px 8px; border-radius: 6px; font-size: 13px;
|
||||
font-family: 'Fira Code', Consolas, monospace; color: #7c3aed; }
|
||||
pre { background: #1a1a2e; color: #e0d4f5; padding: 20px; border-radius: 14px;
|
||||
overflow-x: auto; font-size: 13px; margin: 16px 0;
|
||||
border: 1px solid rgba(124,58,237,0.2); }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 4px solid #7c3aed; padding: 14px 20px; margin: 16px 0;
|
||||
background: linear-gradient(135deg, #f5f3ff, #faf5ff);
|
||||
border-radius: 0 12px 12px 0; font-style: italic; }
|
||||
.card { background: #fff; border: 1px solid #e9d8fd; border-radius: 14px;
|
||||
padding: 20px; margin: 14px 0; box-shadow: 0 2px 8px rgba(124,58,237,0.08); }
|
||||
.tag { display: inline-block; padding: 3px 12px; border-radius: 20px; font-size: 11px;
|
||||
font-weight: 500; background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff; margin: 2px 4px 2px 0; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Minimal — 미니멀
|
||||
private const string CssMinimal = """
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Georgia', 'Batang', serif;
|
||||
background: #fff; color: #222; line-height: 1.85; padding: 60px 24px; }
|
||||
.container { max-width: 720px; margin: 0 auto; padding: 0; }
|
||||
h1 { font-size: 32px; font-weight: 400; color: #000; margin-bottom: 4px;
|
||||
letter-spacing: -0.5px; }
|
||||
h2 { font-size: 20px; font-weight: 400; margin: 40px 0 14px; color: #000;
|
||||
border-bottom: 1px solid #ddd; padding-bottom: 8px; }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 28px 0 10px; color: #333; }
|
||||
.meta { font-size: 12px; color: #999; margin-bottom: 36px; font-style: italic; }
|
||||
p { margin: 12px 0; font-size: 15px; text-align: justify; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 14px; }
|
||||
th { text-align: left; padding: 8px 0; font-weight: 600; border-bottom: 2px solid #000;
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #555; }
|
||||
td { padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
ul, ol { margin: 12px 0 12px 20px; font-size: 15px; }
|
||||
li { margin: 6px 0; }
|
||||
code { background: #f7f7f7; padding: 2px 6px; border-radius: 2px; font-size: 13px;
|
||||
font-family: 'Courier New', monospace; }
|
||||
pre { background: #f7f7f7; color: #333; padding: 18px; margin: 16px 0;
|
||||
overflow-x: auto; font-size: 13px; border: 1px solid #e5e5e5; }
|
||||
pre code { background: transparent; padding: 0; }
|
||||
blockquote { border-left: 3px solid #000; padding: 8px 20px; margin: 16px 0;
|
||||
color: #555; font-style: italic; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 32px 0; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Elegant — 우아한
|
||||
private const string CssElegant = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
|
||||
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 860px; margin: 0 auto; background: #fff;
|
||||
border-radius: 4px; padding: 56px 52px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
border: 1px solid #e8e4dd; }
|
||||
h1 { font-family: 'Playfair Display', Georgia, serif; font-size: 30px;
|
||||
font-weight: 700; color: #2c2416; margin-bottom: 6px; letter-spacing: -0.3px; }
|
||||
h2 { font-family: 'Playfair Display', Georgia, serif; font-size: 20px;
|
||||
font-weight: 600; margin: 36px 0 14px; color: #2c2416;
|
||||
border-bottom: 1px solid #d4c9b8; padding-bottom: 8px; }
|
||||
h3 { font-size: 15px; font-weight: 600; margin: 24px 0 10px; color: #8b7a5e; }
|
||||
.meta { font-size: 12px; color: #b0a48e; margin-bottom: 28px; letter-spacing: 0.5px;
|
||||
text-transform: uppercase; }
|
||||
p { margin: 10px 0; font-size: 14.5px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; }
|
||||
th { background: #f8f5f0; text-align: left; padding: 10px 14px; font-weight: 600;
|
||||
color: #5a4d38; border-bottom: 2px solid #d4c9b8; font-size: 12.5px;
|
||||
letter-spacing: 0.5px; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #f0ece5; }
|
||||
tr:hover td { background: #fdfcfa; }
|
||||
ul, ol { margin: 10px 0 10px 26px; font-size: 14.5px; }
|
||||
li { margin: 5px 0; }
|
||||
code { background: #f8f5f0; padding: 2px 7px; border-radius: 3px; font-size: 12.5px;
|
||||
font-family: 'Courier New', monospace; color: #8b6914; }
|
||||
pre { background: #2c2416; color: #e8e0d0; padding: 18px; border-radius: 4px;
|
||||
overflow-x: auto; font-size: 12.5px; margin: 16px 0; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 3px solid #c9a96e; padding: 12px 20px; margin: 16px 0;
|
||||
background: #fdf9f0; color: #5a4d38; font-style: italic;
|
||||
font-family: 'Playfair Display', Georgia, serif; }
|
||||
.ornament { text-align: center; color: #c9a96e; font-size: 18px; margin: 24px 0; letter-spacing: 8px; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Dark — 다크 모드
|
||||
private const string CssDark = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: #161b22;
|
||||
border-radius: 12px; padding: 52px;
|
||||
border: 1px solid #30363d;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
|
||||
h1 { font-size: 28px; font-weight: 700; color: #f0f6fc; margin-bottom: 4px; }
|
||||
h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #f0f6fc;
|
||||
border-bottom: 1px solid #30363d; padding-bottom: 8px; }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #58a6ff; }
|
||||
.meta { font-size: 12px; color: #8b949e; margin-bottom: 28px; }
|
||||
p { margin: 10px 0; font-size: 14.5px; color: #c9d1d9; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px;
|
||||
border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||
th { background: #21262d; text-align: left; padding: 10px 14px; font-weight: 600;
|
||||
color: #f0f6fc; border-bottom: 1px solid #30363d; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #21262d; color: #c9d1d9; }
|
||||
tr:hover td { background: #1c2128; }
|
||||
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; color: #c9d1d9; }
|
||||
li { margin: 5px 0; }
|
||||
code { background: #1c2128; padding: 2px 8px; border-radius: 6px; font-size: 13px;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace; color: #79c0ff; }
|
||||
pre { background: #0d1117; color: #c9d1d9; padding: 20px; border-radius: 8px;
|
||||
overflow-x: auto; font-size: 13px; margin: 16px 0;
|
||||
border: 1px solid #30363d; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 3px solid #58a6ff; padding: 12px 20px; margin: 16px 0;
|
||||
background: #161b22; color: #8b949e;
|
||||
border-radius: 0 8px 8px 0; }
|
||||
a { color: #58a6ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.label { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px;
|
||||
font-weight: 500; border: 1px solid #30363d; color: #8b949e; margin: 2px 4px 2px 0; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Colorful — 컬러풀
|
||||
private const string CssColorful = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
|
||||
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: #fff;
|
||||
border-radius: 20px; padding: 52px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
|
||||
h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; }
|
||||
h2 { font-size: 20px; font-weight: 700; margin: 34px 0 14px; color: #6c5ce7;
|
||||
padding: 6px 14px; background: #f8f0ff; border-radius: 8px; display: inline-block; }
|
||||
h3 { font-size: 16px; font-weight: 700; margin: 22px 0 10px; color: #00b894; }
|
||||
.meta { font-size: 12px; color: #b2bec3; margin-bottom: 28px; }
|
||||
p { margin: 10px 0; font-size: 14.5px; }
|
||||
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0;
|
||||
font-size: 13.5px; border-radius: 14px; overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(108,92,231,0.1); }
|
||||
th { background: linear-gradient(135deg, #a29bfe, #6c5ce7); color: #fff;
|
||||
text-align: left; padding: 12px 14px; font-weight: 700; }
|
||||
td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; }
|
||||
tr:hover td { background: #faf0ff; }
|
||||
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
|
||||
li { margin: 5px 0; }
|
||||
li::marker { color: #e17055; font-weight: 700; }
|
||||
code { background: #fff3e0; padding: 2px 8px; border-radius: 6px; font-size: 13px;
|
||||
font-family: Consolas, monospace; color: #e17055; }
|
||||
pre { background: #2d3436; color: #dfe6e9; padding: 20px; border-radius: 14px;
|
||||
overflow-x: auto; font-size: 13px; margin: 16px 0; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 4px solid #fdcb6e; padding: 14px 20px; margin: 16px 0;
|
||||
background: #fffbf0; border-radius: 0 12px 12px 0; color: #636e72; }
|
||||
.chip { display: inline-block; padding: 4px 14px; border-radius: 20px; font-size: 12px;
|
||||
font-weight: 700; color: #fff; margin: 3px 4px 3px 0; }
|
||||
.chip-red { background: #e17055; } .chip-blue { background: #74b9ff; }
|
||||
.chip-green { background: #00b894; } .chip-purple { background: #6c5ce7; }
|
||||
.chip-yellow { background: #fdcb6e; color: #2d3436; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Corporate — 기업 공식
|
||||
private const string CssCorporate = """
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
||||
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
|
||||
border-bottom: 3px solid #ff6600; }
|
||||
.header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; }
|
||||
.header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; }
|
||||
.body-content { padding: 36px 40px 40px; }
|
||||
h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; }
|
||||
h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366;
|
||||
border-left: 4px solid #ff6600; padding-left: 12px; }
|
||||
h3 { font-size: 14.5px; font-weight: 600; margin: 20px 0 8px; color: #004488; }
|
||||
.meta { font-size: 11.5px; color: #999; margin-bottom: 20px; }
|
||||
p { margin: 8px 0; font-size: 13.5px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px;
|
||||
border: 1px solid #ddd; }
|
||||
th { background: #003366; color: #fff; text-align: left; padding: 8px 12px;
|
||||
font-weight: 600; font-size: 11.5px; }
|
||||
td { padding: 7px 12px; border: 1px solid #e0e0e0; }
|
||||
tr:nth-child(even) td { background: #f9f9f9; }
|
||||
ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; }
|
||||
li { margin: 3px 0; }
|
||||
code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 12px;
|
||||
font-family: Consolas, monospace; }
|
||||
pre { background: #f4f4f4; color: #333; padding: 14px; border-radius: 4px;
|
||||
overflow-x: auto; font-size: 12px; margin: 12px 0; border: 1px solid #ddd; }
|
||||
pre code { background: transparent; padding: 0; }
|
||||
blockquote { border-left: 4px solid #ff6600; padding: 10px 16px; margin: 12px 0;
|
||||
background: #fff8f0; color: #555; }
|
||||
.footer { text-align: center; font-size: 10.5px; color: #aaa; margin-top: 32px;
|
||||
padding-top: 12px; border-top: 1px solid #eee; }
|
||||
.stamp { display: inline-block; border: 2px solid #003366; color: #003366; padding: 4px 16px;
|
||||
border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 1px; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Magazine — 매거진
|
||||
private const string CssMagazine = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
|
||||
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: #fff;
|
||||
border-radius: 2px; padding: 0; overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 48px 44px 36px; color: #fff; }
|
||||
.hero h1 { font-family: 'Merriweather', Georgia, serif; font-size: 32px; font-weight: 900;
|
||||
line-height: 1.3; margin-bottom: 8px; }
|
||||
.hero .meta { color: rgba(255,255,255,0.6); margin-bottom: 0; font-size: 13px; }
|
||||
.content { padding: 40px 44px 44px; }
|
||||
h1 { font-family: 'Merriweather', Georgia, serif; font-size: 28px; font-weight: 900;
|
||||
color: #1a1a2e; margin-bottom: 4px; }
|
||||
h2 { font-family: 'Merriweather', Georgia, serif; font-size: 20px; font-weight: 700;
|
||||
margin: 36px 0 14px; color: #1a1a2e; }
|
||||
h3 { font-size: 15px; font-weight: 700; margin: 24px 0 10px; color: #e94560;
|
||||
text-transform: uppercase; letter-spacing: 1px; font-size: 12px; }
|
||||
.meta { font-size: 12px; color: #999; margin-bottom: 24px; }
|
||||
p { margin: 10px 0; font-size: 15px; }
|
||||
p:first-of-type::first-letter { font-family: 'Merriweather', Georgia, serif;
|
||||
font-size: 48px; float: left; line-height: 1; padding-right: 8px; color: #e94560;
|
||||
font-weight: 900; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; }
|
||||
th { background: #1a1a2e; color: #fff; text-align: left; padding: 10px 14px;
|
||||
font-weight: 600; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #eee; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
|
||||
li { margin: 5px 0; }
|
||||
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 12.5px;
|
||||
font-family: 'Courier New', monospace; }
|
||||
pre { background: #1a1a2e; color: #e0e0e0; padding: 18px; border-radius: 4px;
|
||||
overflow-x: auto; font-size: 12.5px; margin: 16px 0; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { font-family: 'Merriweather', Georgia, serif; font-size: 18px;
|
||||
font-style: italic; color: #555; border: none; padding: 20px 0; margin: 24px 0;
|
||||
text-align: center; position: relative; }
|
||||
blockquote::before { content: '\201C'; font-size: 60px; color: #e94560;
|
||||
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
|
||||
opacity: 0.3; }
|
||||
.pullquote { font-size: 20px; font-family: 'Merriweather', Georgia, serif;
|
||||
font-weight: 700; color: #e94560; border-top: 3px solid #e94560;
|
||||
border-bottom: 3px solid #e94560; padding: 16px 0; margin: 24px 0;
|
||||
text-align: center; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
#region Dashboard — 대시보드
|
||||
private const string CssDashboard = """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; }
|
||||
.container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; }
|
||||
h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; }
|
||||
h2 { font-size: 17px; font-weight: 600; margin: 28px 0 14px; color: #1a1a2e; }
|
||||
h3 { font-size: 14px; font-weight: 600; margin: 18px 0 8px; color: #6c7893; }
|
||||
.meta { font-size: 12px; color: #8c95a6; margin-bottom: 24px; }
|
||||
p { margin: 8px 0; font-size: 13.5px; color: #4a5568; }
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px; margin: 20px 0; }
|
||||
.kpi-card { background: #fff; border-radius: 12px; padding: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.kpi-card .kpi-label { font-size: 12px; color: #8c95a6; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.kpi-card .kpi-value { font-size: 28px; font-weight: 700; color: #1a1a2e; margin: 4px 0; }
|
||||
.kpi-card .kpi-change { font-size: 12px; font-weight: 600; }
|
||||
.kpi-up { color: #10b981; } .kpi-down { color: #ef4444; }
|
||||
.chart-area { background: #fff; border-radius: 12px; padding: 24px; margin: 16px 0;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06); min-height: 200px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px;
|
||||
background: #fff; border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
th { background: #f7f8fa; text-align: left; padding: 10px 14px; font-weight: 600;
|
||||
color: #6c7893; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #edf0f4; }
|
||||
td { padding: 10px 14px; border-bottom: 1px solid #f3f4f6; }
|
||||
tr:hover td { background: #f9fafb; }
|
||||
ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; }
|
||||
li { margin: 4px 0; }
|
||||
code { background: #f1f3f5; padding: 2px 7px; border-radius: 5px; font-size: 12px;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace; }
|
||||
pre { background: #1a1a2e; color: #c9d1d9; padding: 18px; border-radius: 10px;
|
||||
overflow-x: auto; font-size: 12px; margin: 14px 0; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; }
|
||||
blockquote { border-left: 3px solid #4b5efc; padding: 10px 16px; margin: 14px 0;
|
||||
background: #f0f0ff; border-radius: 0 8px 8px 0; font-size: 13px; }
|
||||
.status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 600; }
|
||||
.status-ok { background: #d1fae5; color: #065f46; }
|
||||
.status-warn { background: #fef3c7; color: #92400e; }
|
||||
.status-err { background: #fee2e2; color: #991b1b; }
|
||||
""";
|
||||
#endregion
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// 공통 CSS 컴포넌트 (모든 무드에 자동 첨부)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
#region Shared — 공통 컴포넌트
|
||||
private const string CssShared = """
|
||||
|
||||
/* ── 목차 (TOC) ── */
|
||||
nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px;
|
||||
padding: 20px 28px; margin: 24px 0 32px; }
|
||||
nav.toc h2 { font-size: 15px; font-weight: 700; margin: 0 0 12px; padding: 0; border: none;
|
||||
color: inherit; display: block; background: none; }
|
||||
nav.toc ul { list-style: none; margin: 0; padding: 0; }
|
||||
nav.toc li { margin: 4px 0; }
|
||||
nav.toc li.toc-h3 { padding-left: 18px; }
|
||||
nav.toc a { text-decoration: none; color: #4b5efc; font-size: 13.5px; }
|
||||
nav.toc a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── 커버 페이지 ── */
|
||||
.cover-page { text-align: center; padding: 80px 40px 60px; margin: -56px -52px 40px;
|
||||
border-radius: 16px 16px 0 0; position: relative; overflow: hidden;
|
||||
background: linear-gradient(135deg, #4b5efc 0%, #7c3aed 100%); color: #fff; }
|
||||
.cover-page h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; color: #fff;
|
||||
-webkit-text-fill-color: #fff; }
|
||||
.cover-page .cover-subtitle { font-size: 18px; opacity: 0.9; margin-bottom: 24px; }
|
||||
.cover-page .cover-meta { font-size: 13px; opacity: 0.7; }
|
||||
.cover-page .cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5);
|
||||
margin: 20px auto; border-radius: 2px; }
|
||||
|
||||
/* ── 콜아웃 (callout) ── */
|
||||
.callout { border-radius: 8px; padding: 16px 20px; margin: 16px 0; font-size: 14px;
|
||||
border-left: 4px solid; display: flex; gap: 10px; align-items: flex-start; }
|
||||
.callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
.callout-info { background: #eff6ff; border-color: #3b82f6; color: #1e40af; }
|
||||
.callout-info::before { content: 'ℹ️'; }
|
||||
.callout-warning { background: #fffbeb; border-color: #f59e0b; color: #92400e; }
|
||||
.callout-warning::before { content: '⚠️'; }
|
||||
.callout-tip { background: #f0fdf4; border-color: #22c55e; color: #166534; }
|
||||
.callout-tip::before { content: '💡'; }
|
||||
.callout-danger { background: #fef2f2; border-color: #ef4444; color: #991b1b; }
|
||||
.callout-danger::before { content: '🚨'; }
|
||||
.callout-note { background: #f5f3ff; border-color: #8b5cf6; color: #5b21b6; }
|
||||
.callout-note::before { content: '📝'; }
|
||||
|
||||
/* ── 배지 (badge) — 공통 ── */
|
||||
.badge, .tag, .chip { display: inline-block; padding: 3px 10px; border-radius: 20px;
|
||||
font-size: 11px; font-weight: 600; margin: 2px 4px 2px 0; }
|
||||
.badge-blue { background: #dbeafe; color: #1e40af; }
|
||||
.badge-green { background: #d1fae5; color: #065f46; }
|
||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||
.badge-yellow { background: #fef3c7; color: #92400e; }
|
||||
.badge-purple { background: #ede9fe; color: #5b21b6; }
|
||||
.badge-gray { background: #f3f4f6; color: #374151; }
|
||||
.badge-orange { background: #ffedd5; color: #9a3412; }
|
||||
|
||||
/* ── 하이라이트 박스 ── */
|
||||
.highlight-box { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%);
|
||||
padding: 16px 20px; border-radius: 10px; margin: 16px 0; }
|
||||
|
||||
/* ── CSS 차트 (bar/horizontal) ── */
|
||||
.chart-bar { margin: 20px 0; }
|
||||
.chart-bar .bar-item { display: flex; align-items: center; margin: 6px 0; gap: 10px; }
|
||||
.chart-bar .bar-label { min-width: 100px; font-size: 13px; text-align: right; flex-shrink: 0; }
|
||||
.chart-bar .bar-track { flex: 1; background: #e5e7eb; border-radius: 6px; height: 22px;
|
||||
overflow: hidden; }
|
||||
.chart-bar .bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center;
|
||||
padding: 0 8px; font-size: 11px; font-weight: 600; color: #fff;
|
||||
transition: width 0.3s ease; min-width: fit-content; }
|
||||
.bar-fill.blue { background: #3b82f6; } .bar-fill.green { background: #22c55e; }
|
||||
.bar-fill.red { background: #ef4444; } .bar-fill.yellow { background: #f59e0b; }
|
||||
.bar-fill.purple { background: #8b5cf6; } .bar-fill.orange { background: #f97316; }
|
||||
|
||||
/* ── CSS 도넛 차트 ── */
|
||||
.chart-donut { width: 160px; height: 160px; border-radius: 50%; margin: 20px auto;
|
||||
background: conic-gradient(var(--seg1-color, #3b82f6) 0% var(--seg1, 0%),
|
||||
var(--seg2-color, #22c55e) var(--seg1, 0%) var(--seg2, 0%),
|
||||
var(--seg3-color, #f59e0b) var(--seg2, 0%) var(--seg3, 0%),
|
||||
var(--seg4-color, #ef4444) var(--seg3, 0%) var(--seg4, 0%),
|
||||
#e5e7eb var(--seg4, 0%) 100%);
|
||||
display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.chart-donut::after { content: ''; width: 100px; height: 100px; background: #fff;
|
||||
border-radius: 50%; position: absolute; }
|
||||
.chart-donut .donut-label { position: absolute; z-index: 1; font-size: 18px; font-weight: 700; }
|
||||
|
||||
/* ── 진행률 바 ── */
|
||||
.progress { background: #e5e7eb; border-radius: 8px; height: 10px; margin: 8px 0;
|
||||
overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 8px; background: #3b82f6; }
|
||||
|
||||
/* ── 타임라인 ── */
|
||||
.timeline { position: relative; padding-left: 28px; margin: 20px 0; }
|
||||
.timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0;
|
||||
width: 2px; background: #e5e7eb; }
|
||||
.timeline-item { position: relative; margin: 16px 0; }
|
||||
.timeline-item::before { content: ''; position: absolute; left: -24px; top: 5px;
|
||||
width: 12px; height: 12px; border-radius: 50%; background: #4b5efc;
|
||||
border: 2px solid #fff; box-shadow: 0 0 0 2px #4b5efc; }
|
||||
.timeline-item .timeline-date { font-size: 12px; color: #6b7280; font-weight: 600; }
|
||||
.timeline-item .timeline-content { font-size: 14px; margin-top: 4px; }
|
||||
|
||||
/* ── 섹션 자동 번호 ── */
|
||||
body { counter-reset: section; }
|
||||
h2.numbered { counter-increment: section; counter-reset: subsection; }
|
||||
h2.numbered::before { content: counter(section) '. '; }
|
||||
h3.numbered { counter-increment: subsection; }
|
||||
h3.numbered::before { content: counter(section) '-' counter(subsection) '. '; }
|
||||
|
||||
/* ── 그리드 레이아웃 ── */
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 16px 0; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 16px 0; }
|
||||
|
||||
/* ── 카드 공통 ── */
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
||||
padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-header { font-size: 15px; font-weight: 700; margin-bottom: 8px; }
|
||||
|
||||
/* ── 구분선 ── */
|
||||
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 32px 0; }
|
||||
.divider-thick { border: none; border-top: 3px solid #e5e7eb; margin: 40px 0; }
|
||||
|
||||
/* ── 인쇄/PDF 최적화 ── */
|
||||
@media print {
|
||||
body { background: #fff !important; padding: 0 !important; }
|
||||
.container { box-shadow: none !important; border: none !important;
|
||||
max-width: none !important; padding: 20px !important; }
|
||||
.cover-page { break-after: page; }
|
||||
h2, h3 { break-after: avoid; }
|
||||
table, figure, .chart-bar, .callout { break-inside: avoid; }
|
||||
nav.toc { break-after: page; }
|
||||
a { color: inherit !important; text-decoration: none !important; }
|
||||
a[href]::after { content: ' (' attr(href) ')'; font-size: 10px; color: #999; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
""";
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>테마 무드 정의.</summary>
|
||||
public record TemplateMood(string Key, string Label, string Icon, string Description);
|
||||
316
src/AxCopilot/Services/Agent/TestLoopTool.cs
Normal file
316
src/AxCopilot/Services/Agent/TestLoopTool.cs
Normal file
@@ -0,0 +1,316 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 자동 테스트 생성 + 실행 + 결과 분석 도구.
|
||||
/// 코드 변경 → 관련 테스트 자동 생성 → 실행 → 결과 기반 피드백 루프.
|
||||
/// </summary>
|
||||
public class TestLoopTool : IAgentTool
|
||||
{
|
||||
public string Name => "test_loop";
|
||||
|
||||
public string Description =>
|
||||
"코드 변경에 대한 테스트를 자동으로 생성하고 실행합니다.\n" +
|
||||
"- action=\"generate\": 변경된 파일에 대한 테스트 코드 생성 제안\n" +
|
||||
"- action=\"run\": 프로젝트의 테스트를 실행하고 결과 반환\n" +
|
||||
"- action=\"analyze\": 테스트 결과를 분석하여 수정 방향 제시\n" +
|
||||
"- action=\"auto_fix\": 테스트 실행 → 실패 파싱 → 구조화된 수정 지침 반환 (반복 수정용)\n" +
|
||||
"테스트 프레임워크를 자동 감지합니다 (xUnit, NUnit, MSTest, pytest, Jest 등).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "수행할 작업: generate | run | analyze | auto_fix",
|
||||
Enum = new() { "generate", "run", "analyze", "auto_fix" }
|
||||
},
|
||||
["file_path"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "대상 소스 파일 경로 (generate 시 필요)"
|
||||
},
|
||||
["test_output"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "분석할 테스트 출력 (analyze 시 필요)"
|
||||
},
|
||||
},
|
||||
Required = new() { "action" }
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
|
||||
return action switch
|
||||
{
|
||||
"generate" => GenerateTestSuggestion(args, context),
|
||||
"run" => await RunTestsAsync(context, ct),
|
||||
"analyze" => AnalyzeTestOutput(args),
|
||||
"auto_fix" => await AutoFixAsync(context, ct),
|
||||
_ => ToolResult.Fail("action은 generate, run, analyze, auto_fix 중 하나여야 합니다.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
|
||||
{
|
||||
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return ToolResult.Fail("file_path가 필요합니다.");
|
||||
|
||||
if (!Path.IsPathRooted(filePath) && !string.IsNullOrEmpty(context.WorkFolder))
|
||||
filePath = Path.Combine(context.WorkFolder, filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return ToolResult.Fail($"파일 없음: {filePath}");
|
||||
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var (framework, testExt, convention) = ext switch
|
||||
{
|
||||
".cs" => ("xUnit/NUnit/MSTest", ".cs", "ClassNameTests.cs"),
|
||||
".py" => ("pytest", ".py", "test_module.py"),
|
||||
".ts" or ".tsx" => ("Jest/Vitest", ".test.ts", "Component.test.ts"),
|
||||
".js" or ".jsx" => ("Jest", ".test.js", "module.test.js"),
|
||||
".java" => ("JUnit", ".java", "ClassTest.java"),
|
||||
".go" => ("go test", "_test.go", "module_test.go"),
|
||||
_ => ("unknown", ext, "test" + ext),
|
||||
};
|
||||
|
||||
var content = TextFileCodec.ReadAllText(filePath).Text;
|
||||
var lineCount = content.Split('\n').Length;
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"테스트 생성 제안:\n" +
|
||||
$" 대상 파일: {Path.GetFileName(filePath)} ({lineCount}줄)\n" +
|
||||
$" 감지된 프레임워크: {framework}\n" +
|
||||
$" 테스트 파일 명명: {convention}\n" +
|
||||
$" 테스트 파일 확장자: {testExt}\n\n" +
|
||||
$"file_write 도구로 테스트 파일을 생성한 후, test_loop action=\"run\"으로 실행하세요.");
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> RunTestsAsync(AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
// 프로젝트 타입 자동 감지 → 테스트 명령 결정
|
||||
var (cmd, cmdArgs) = DetectTestCommand(context.WorkFolder);
|
||||
if (cmd == null)
|
||||
return ToolResult.Fail("테스트 프레임워크를 감지할 수 없습니다. 지원: .NET (dotnet test), Python (pytest), Node.js (npm test)");
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = cmd,
|
||||
Arguments = cmdArgs,
|
||||
WorkingDirectory = context.WorkFolder,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
if (proc == null) return ToolResult.Fail("테스트 프로세스 시작 실패");
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(120));
|
||||
|
||||
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"테스트 실행 결과 (exit code: {proc.ExitCode}):");
|
||||
sb.AppendLine($"명령: {cmd} {cmdArgs}");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(stdout)) sb.AppendLine(stdout);
|
||||
if (!string.IsNullOrWhiteSpace(stderr)) sb.AppendLine($"[STDERR]\n{stderr}");
|
||||
|
||||
return proc.ExitCode == 0
|
||||
? ToolResult.Ok(sb.ToString())
|
||||
: ToolResult.Ok($"테스트 실패 (exit code {proc.ExitCode}):\n{sb}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"테스트 실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult AnalyzeTestOutput(JsonElement args)
|
||||
{
|
||||
var output = args.TryGetProperty("test_output", out var o) ? o.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return ToolResult.Fail("test_output이 필요합니다.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("테스트 결과 분석:");
|
||||
|
||||
var lines = output.Split('\n');
|
||||
var failedCount = lines.Count(l => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED"));
|
||||
var passedCount = lines.Count(l => l.Contains("PASS", StringComparison.OrdinalIgnoreCase) || l.Contains("PASSED"));
|
||||
var errorLines = lines.Where(l => l.Contains("Error", StringComparison.OrdinalIgnoreCase) || l.Contains("Exception")).Take(10).ToList();
|
||||
|
||||
sb.AppendLine($" 통과: {passedCount}개, 실패: {failedCount}개");
|
||||
|
||||
if (errorLines.Count > 0)
|
||||
{
|
||||
sb.AppendLine("\n주요 오류:");
|
||||
foreach (var line in errorLines)
|
||||
sb.AppendLine($" {line.Trim()}");
|
||||
}
|
||||
|
||||
if (failedCount > 0)
|
||||
sb.AppendLine("\n다음 단계: 실패한 테스트를 확인하고 관련 코드를 수정한 후 test_loop action=\"run\"으로 다시 실행하세요.");
|
||||
else
|
||||
sb.AppendLine("\n모든 테스트가 통과했습니다.");
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// auto_fix: 테스트 실행 → 실패 파싱 → 구조화된 수정 지침 반환.
|
||||
/// LLM이 이 결과를 받아 코드를 수정하고 다시 auto_fix를 호출하는 반복 루프를 형성합니다.
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> AutoFixAsync(AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// 1. 테스트 실행
|
||||
var runResult = await RunTestsAsync(context, ct);
|
||||
var output = runResult.Output;
|
||||
|
||||
// 2. 테스트 전체 통과 → 성공 종료
|
||||
var lines = output.Split('\n');
|
||||
var failedCount = lines.Count(l =>
|
||||
l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) ||
|
||||
l.Contains("FAILED", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (failedCount == 0 && runResult.Success && output.Contains("exit code: 0"))
|
||||
return ToolResult.Ok("[AUTO_FIX: ALL_PASSED]\n모든 테스트가 통과했습니다. 수정 루프를 종료하세요.");
|
||||
|
||||
// 3. 실패 파싱 → 구조화된 실패 정보
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[AUTO_FIX: FAILURES_DETECTED]");
|
||||
sb.AppendLine($"실패 테스트 수: {failedCount}");
|
||||
sb.AppendLine();
|
||||
|
||||
// 오류 메시지 추출
|
||||
var errors = ExtractFailureDetails(lines);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## 실패 상세:");
|
||||
foreach (var err in errors.Take(10))
|
||||
{
|
||||
sb.AppendLine($"- 테스트: {err.TestName}");
|
||||
if (!string.IsNullOrEmpty(err.FilePath))
|
||||
sb.AppendLine($" 파일: {err.FilePath}:{err.Line}");
|
||||
sb.AppendLine($" 오류: {err.Message}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("## 수정 지침:");
|
||||
sb.AppendLine("1. 위 오류 메시지에서 원인을 파악하세요");
|
||||
sb.AppendLine("2. file_read로 관련 파일을 읽고 오류 원인을 확인하세요");
|
||||
sb.AppendLine("3. file_edit로 코드를 수정하세요");
|
||||
sb.AppendLine("4. test_loop action=\"auto_fix\"를 다시 호출하여 결과를 확인하세요");
|
||||
sb.AppendLine("5. 모든 테스트가 통과할 때까지 반복하세요");
|
||||
|
||||
// 설정에서 최대 반복 횟수 안내
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var maxIter = app?.SettingsService?.Settings.Llm.MaxTestFixIterations ?? 5;
|
||||
sb.AppendLine($"\n※ 최대 수정 반복 횟수: {maxIter}회. 초과 시 사용자에게 보고하세요.");
|
||||
|
||||
// 전체 테스트 출력 (잘라서)
|
||||
sb.AppendLine("\n## 전체 테스트 출력:");
|
||||
var truncated = output.Length > 3000 ? output[..3000] + "\n... (출력 일부 생략)" : output;
|
||||
sb.AppendLine(truncated);
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>테스트 출력에서 실패 상세를 추출합니다.</summary>
|
||||
private static List<FailureDetail> ExtractFailureDetails(string[] lines)
|
||||
{
|
||||
var failures = new List<FailureDetail>();
|
||||
FailureDetail? current = null;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
|
||||
// .NET: "X 실패 테스트명 [시간]" 또는 "Failed 테스트명"
|
||||
if (line.StartsWith("Failed ", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("FAIL!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
current = new FailureDetail { TestName = line };
|
||||
failures.Add(current);
|
||||
}
|
||||
// pytest: "FAILED test_file.py::test_name"
|
||||
else if (line.StartsWith("FAILED ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
current = new FailureDetail { TestName = line[7..].Trim() };
|
||||
failures.Add(current);
|
||||
}
|
||||
// 파일 경로 + 줄 번호 패턴: "file.cs(123," or "file.py:123:"
|
||||
else if (current != null && string.IsNullOrEmpty(current.FilePath))
|
||||
{
|
||||
var pathMatch = System.Text.RegularExpressions.Regex.Match(line,
|
||||
@"([^\s]+\.\w+)[:\(](\d+)");
|
||||
if (pathMatch.Success)
|
||||
{
|
||||
current.FilePath = pathMatch.Groups[1].Value;
|
||||
current.Line = int.Parse(pathMatch.Groups[2].Value);
|
||||
}
|
||||
}
|
||||
// 오류 메시지: "Assert.", "Error:", "Exception:"
|
||||
else if (current != null && string.IsNullOrEmpty(current.Message) &&
|
||||
(line.Contains("Assert", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("Error", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("Exception", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
current.Message = line;
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
private class FailureDetail
|
||||
{
|
||||
public string TestName { get; set; } = "";
|
||||
public string FilePath { get; set; } = "";
|
||||
public int Line { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
private static (string? Cmd, string Args) DetectTestCommand(string workFolder)
|
||||
{
|
||||
// .NET
|
||||
if (Directory.EnumerateFiles(workFolder, "*.csproj", SearchOption.AllDirectories).Any() ||
|
||||
Directory.EnumerateFiles(workFolder, "*.sln", SearchOption.TopDirectoryOnly).Any())
|
||||
return ("dotnet", "test --no-build --verbosity normal");
|
||||
|
||||
// Python
|
||||
if (File.Exists(Path.Combine(workFolder, "pytest.ini")) ||
|
||||
File.Exists(Path.Combine(workFolder, "setup.py")) ||
|
||||
Directory.EnumerateFiles(workFolder, "test_*.py", SearchOption.AllDirectories).Any())
|
||||
return ("pytest", "--tb=short -q");
|
||||
|
||||
// Node.js
|
||||
if (File.Exists(Path.Combine(workFolder, "package.json")))
|
||||
return ("npm", "test -- --passWithNoTests");
|
||||
|
||||
// Go
|
||||
if (Directory.EnumerateFiles(workFolder, "*_test.go", SearchOption.AllDirectories).Any())
|
||||
return ("go", "test ./...");
|
||||
|
||||
return (null, "");
|
||||
}
|
||||
}
|
||||
131
src/AxCopilot/Services/Agent/TextFileCodec.cs
Normal file
131
src/AxCopilot/Services/Agent/TextFileCodec.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 텍스트 파일 인코딩 감지/읽기/쓰기 유틸.
|
||||
/// - 읽기: BOM + UTF-8 유효성 검사 기반 자동 감지
|
||||
/// - 쓰기: 기존 파일 인코딩/UTF-8 BOM 여부를 최대한 보존
|
||||
/// </summary>
|
||||
public static class TextFileCodec
|
||||
{
|
||||
public readonly record struct TextReadResult(string Text, Encoding Encoding, bool HasBom);
|
||||
|
||||
static TextFileCodec()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
public static TextReadResult ReadAllText(string path)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
return Decode(bytes);
|
||||
}
|
||||
|
||||
public static async Task<TextReadResult> ReadAllTextAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(path, ct);
|
||||
return Decode(bytes);
|
||||
}
|
||||
|
||||
public static string[] SplitLines(string text)
|
||||
=> text.Split('\n');
|
||||
|
||||
public static Encoding ResolveWriteEncoding(Encoding sourceEncoding, bool sourceHasBom)
|
||||
{
|
||||
if (sourceEncoding.CodePage == Encoding.UTF8.CodePage)
|
||||
return new UTF8Encoding(sourceHasBom);
|
||||
return sourceEncoding;
|
||||
}
|
||||
|
||||
public static async Task WriteAllTextAsync(string path, string content, Encoding encoding, CancellationToken ct = default)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 4096, useAsync: true);
|
||||
await using var writer = new StreamWriter(stream, encoding);
|
||||
await writer.WriteAsync(content.AsMemory(), ct);
|
||||
await writer.FlushAsync();
|
||||
}
|
||||
|
||||
public static Encoding Utf8NoBom => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
private static TextReadResult Decode(byte[] bytes)
|
||||
{
|
||||
var detected = DetectEncoding(bytes, out var bomLength, out var hasBom);
|
||||
var payload = bomLength > 0 ? bytes[bomLength..] : bytes;
|
||||
var text = detected.GetString(payload);
|
||||
return new TextReadResult(text, detected, hasBom);
|
||||
}
|
||||
|
||||
private static Encoding DetectEncoding(byte[] bytes, out int bomLength, out bool hasBom)
|
||||
{
|
||||
// UTF-8 BOM
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
|
||||
{
|
||||
bomLength = 3;
|
||||
hasBom = true;
|
||||
return Encoding.UTF8;
|
||||
}
|
||||
|
||||
// UTF-16 LE BOM
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
|
||||
{
|
||||
bomLength = 2;
|
||||
hasBom = true;
|
||||
return Encoding.Unicode;
|
||||
}
|
||||
|
||||
// UTF-16 BE BOM
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
|
||||
{
|
||||
bomLength = 2;
|
||||
hasBom = true;
|
||||
return Encoding.BigEndianUnicode;
|
||||
}
|
||||
|
||||
bomLength = 0;
|
||||
hasBom = false;
|
||||
|
||||
if (IsValidUtf8(bytes))
|
||||
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
// 한국어 Windows 환경 호환 fallback
|
||||
try { return Encoding.GetEncoding("euc-kr"); }
|
||||
catch { return Encoding.Default; }
|
||||
}
|
||||
|
||||
private static bool IsValidUtf8(byte[] bytes)
|
||||
{
|
||||
var i = 0;
|
||||
while (i < bytes.Length)
|
||||
{
|
||||
if (bytes[i] <= 0x7F)
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int extra;
|
||||
if ((bytes[i] & 0xE0) == 0xC0) extra = 1;
|
||||
else if ((bytes[i] & 0xF0) == 0xE0) extra = 2;
|
||||
else if ((bytes[i] & 0xF8) == 0xF0) extra = 3;
|
||||
else return false;
|
||||
|
||||
if (i + extra >= bytes.Length) return false;
|
||||
for (var j = 1; j <= extra; j++)
|
||||
{
|
||||
if ((bytes[i + j] & 0xC0) != 0x80)
|
||||
return false;
|
||||
}
|
||||
|
||||
i += extra + 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
266
src/AxCopilot/Services/Agent/TextSummarizeTool.cs
Normal file
266
src/AxCopilot/Services/Agent/TextSummarizeTool.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 긴 텍스트나 문서를 지정된 길이와 형식으로 요약하는 도구.
|
||||
/// 텍스트를 청크 분할하여 단계적으로 요약합니다.
|
||||
/// </summary>
|
||||
public class TextSummarizeTool : IAgentTool
|
||||
{
|
||||
public string Name => "text_summarize";
|
||||
public string Description =>
|
||||
"Summarize long text or documents into a specified length and format. " +
|
||||
"Supports: bullet points, paragraph, executive summary, technical summary. " +
|
||||
"For very long texts, automatically chunks and summarizes progressively. " +
|
||||
"Can summarize file contents or inline text.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["source"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Text to summarize, OR file path (if starts with '/' or contains '\\' or '.'). " +
|
||||
"For files: supports .txt, .md, .html, .csv, .json, .log"
|
||||
},
|
||||
["max_length"] = new()
|
||||
{
|
||||
Type = "integer",
|
||||
Description = "Maximum summary length in characters. Default: 500"
|
||||
},
|
||||
["style"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Summary style: bullet (bullet points), paragraph (flowing text), " +
|
||||
"executive (key conclusions + action items), technical (detailed with terminology). Default: bullet",
|
||||
Enum = ["bullet", "paragraph", "executive", "technical"]
|
||||
},
|
||||
["language"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Output language: ko (Korean), en (English). Default: ko"
|
||||
},
|
||||
["focus"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Optional focus area or keywords to emphasize in the summary."
|
||||
},
|
||||
["sections"] = new()
|
||||
{
|
||||
Type = "boolean",
|
||||
Description = "If true, provide section-by-section summary instead of one overall summary. Default: false"
|
||||
},
|
||||
},
|
||||
Required = ["source"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var source = args.GetProperty("source").GetString() ?? "";
|
||||
var maxLength = args.TryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
|
||||
var style = args.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "bullet" : "bullet";
|
||||
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
|
||||
var focus = args.TryGetProperty("focus", out var focEl) ? focEl.GetString() ?? "" : "";
|
||||
var bySections = args.TryGetProperty("sections", out var secEl) && secEl.GetBoolean();
|
||||
|
||||
string text;
|
||||
|
||||
// 파일 경로인지 확인
|
||||
if (LooksLikeFilePath(source))
|
||||
{
|
||||
var fullPath = FileReadTool.ResolvePath(source, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
||||
if (!File.Exists(fullPath))
|
||||
return ToolResult.Fail($"파일 없음: {fullPath}");
|
||||
|
||||
text = (await TextFileCodec.ReadAllTextAsync(fullPath, ct)).Text;
|
||||
|
||||
// HTML 태그 제거
|
||||
if (fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) ||
|
||||
fullPath.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = StripHtmlTags(text);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
text = source;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return ToolResult.Fail("요약할 텍스트가 비어있습니다.");
|
||||
|
||||
// 텍스트 통계
|
||||
var charCount = text.Length;
|
||||
var lineCount = text.Split('\n').Length;
|
||||
var wordCount = EstimateWordCount(text);
|
||||
|
||||
// 텍스트가 이미 충분히 짧으면 그대로 반환
|
||||
if (charCount <= maxLength)
|
||||
return ToolResult.Ok($"📝 텍스트가 이미 요약 기준 이하입니다 ({charCount}자).\n\n{text}");
|
||||
|
||||
// 청크 분할 (매우 긴 텍스트용)
|
||||
var chunks = ChunkText(text, 3000);
|
||||
var chunkSummaries = new List<string>();
|
||||
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
var summary = ExtractKeyContent(chunk, maxLength / chunks.Count, style, focus);
|
||||
chunkSummaries.Add(summary);
|
||||
}
|
||||
|
||||
// 최종 요약 구성
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"📝 텍스트 요약 (원문: {charCount:N0}자, {lineCount}줄, ~{wordCount}단어)");
|
||||
sb.AppendLine();
|
||||
|
||||
if (bySections && chunks.Count > 1)
|
||||
{
|
||||
for (int i = 0; i < chunkSummaries.Count; i++)
|
||||
{
|
||||
sb.AppendLine($"### 섹션 {i + 1}/{chunkSummaries.Count}");
|
||||
sb.AppendLine(chunkSummaries[i]);
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var combined = string.Join("\n", chunkSummaries);
|
||||
sb.AppendLine(FormatSummary(combined, style, language, focus));
|
||||
}
|
||||
|
||||
var result = sb.ToString();
|
||||
if (result.Length > maxLength + 500)
|
||||
result = result[..(maxLength + 500)] + "\n...[요약 길이 초과로 생략]";
|
||||
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
|
||||
private static bool LooksLikeFilePath(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return false;
|
||||
if (s.Contains('\\') || s.Contains('/')) return true;
|
||||
if (s.Length < 260 && System.Text.RegularExpressions.Regex.IsMatch(s, @"\.\w{1,5}$"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
var text = System.Text.RegularExpressions.Regex.Replace(html, @"<script[^>]*>.*?</script>", "",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"<style[^>]*>.*?</style>", "",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"<[^>]+>", " ");
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
return System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
private static int EstimateWordCount(string text)
|
||||
{
|
||||
var spaces = text.Count(c => c == ' ');
|
||||
var koreanChars = text.Count(c => c >= 0xAC00 && c <= 0xD7A3);
|
||||
return spaces + 1 + koreanChars / 3;
|
||||
}
|
||||
|
||||
private static List<string> ChunkText(string text, int chunkSize)
|
||||
{
|
||||
var chunks = new List<string>();
|
||||
var lines = text.Split('\n');
|
||||
var currentChunk = new StringBuilder();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (currentChunk.Length + line.Length > chunkSize && currentChunk.Length > 0)
|
||||
{
|
||||
chunks.Add(currentChunk.ToString());
|
||||
currentChunk.Clear();
|
||||
}
|
||||
currentChunk.AppendLine(line);
|
||||
}
|
||||
|
||||
if (currentChunk.Length > 0)
|
||||
chunks.Add(currentChunk.ToString());
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static string ExtractKeyContent(string text, int targetLength, string style, string focus)
|
||||
{
|
||||
// 텍스트에서 핵심 문장 추출 (간단한 추출 기반 요약)
|
||||
var sentences = System.Text.RegularExpressions.Regex.Split(text, @"(?<=[.!?。\n])\s+")
|
||||
.Where(s => s.Trim().Length > 10)
|
||||
.ToList();
|
||||
|
||||
if (sentences.Count == 0) return text.Length > targetLength ? text[..targetLength] : text;
|
||||
|
||||
// 중요도 점수 계산
|
||||
var scored = sentences.Select(s =>
|
||||
{
|
||||
double score = 0;
|
||||
// 길이 적정성 (너무 짧지도 길지도 않은 문장 선호)
|
||||
if (s.Length > 20 && s.Length < 200) score += 1;
|
||||
// 숫자 포함 (데이터/통계)
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(s, @"\d+")) score += 0.5;
|
||||
// 키워드 포함
|
||||
if (!string.IsNullOrEmpty(focus) && s.Contains(focus, StringComparison.OrdinalIgnoreCase)) score += 2;
|
||||
// 위치 가중치 (첫 문장, 마지막 문장 중요)
|
||||
var idx = sentences.IndexOf(s);
|
||||
if (idx == 0 || idx == sentences.Count - 1) score += 1;
|
||||
if (idx < 3) score += 0.5; // 앞쪽 문장 선호
|
||||
// 핵심 키워드
|
||||
if (s.Contains("결론") || s.Contains("요약") || s.Contains("핵심") ||
|
||||
s.Contains("중요") || s.Contains("결과") || s.Contains("therefore") ||
|
||||
s.Contains("conclusion") || s.Contains("key"))
|
||||
score += 1.5;
|
||||
|
||||
return (Sentence: s.Trim(), Score: score);
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
// 목표 길이에 맞게 문장 선택
|
||||
var selected = new List<string>();
|
||||
int currentLength = 0;
|
||||
foreach (var (sentence, _) in scored)
|
||||
{
|
||||
if (currentLength + sentence.Length > targetLength && selected.Count > 0) break;
|
||||
selected.Add(sentence);
|
||||
currentLength += sentence.Length;
|
||||
}
|
||||
|
||||
// 원문 순서로 재정렬
|
||||
selected.Sort((a, b) => text.IndexOf(a).CompareTo(text.IndexOf(b)));
|
||||
|
||||
return string.Join("\n", selected);
|
||||
}
|
||||
|
||||
private static string FormatSummary(string content, string style, string language, string focus)
|
||||
{
|
||||
switch (style)
|
||||
{
|
||||
case "bullet":
|
||||
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return string.Join("\n", lines.Select(l => l.StartsWith("•") || l.StartsWith("-") ? l : $"• {l}"));
|
||||
|
||||
case "executive":
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("**핵심 요약**");
|
||||
sb.AppendLine(content);
|
||||
if (!string.IsNullOrEmpty(focus))
|
||||
sb.AppendLine($"\n**주요 관심 영역 ({focus})**");
|
||||
return sb.ToString();
|
||||
|
||||
case "technical":
|
||||
return $"**기술 요약**\n{content}";
|
||||
|
||||
default: // paragraph
|
||||
return content.Replace("\n\n", "\n").Replace("\n", " ").Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/AxCopilot/Services/Agent/ToolRegistry.cs
Normal file
191
src/AxCopilot/Services/Agent/ToolRegistry.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 사용 가능한 에이전트 도구/스킬을 관리하는 레지스트리.
|
||||
/// 도구 목록을 LLM function calling에 전달하고, 이름으로 도구를 찾습니다.
|
||||
/// </summary>
|
||||
public class ToolRegistry : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, IAgentTool> _tools = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<IDisposable> _ownedResources = new();
|
||||
private readonly Dictionary<string, McpClientService> _mcpClients = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>등록된 모든 도구 목록.</summary>
|
||||
public IReadOnlyCollection<IAgentTool> All => _tools.Values;
|
||||
|
||||
/// <summary>도구를 이름으로 찾습니다.</summary>
|
||||
public IAgentTool? Get(string name) =>
|
||||
_tools.TryGetValue(name, out var tool) ? tool : null;
|
||||
|
||||
/// <summary>도구를 등록합니다.</summary>
|
||||
public void Register(IAgentTool tool) => _tools[tool.Name] = tool;
|
||||
|
||||
public IReadOnlyCollection<McpClientService> GetMcpClients() => _mcpClients.Values.ToList().AsReadOnly();
|
||||
|
||||
public async Task<int> RegisterMcpToolsAsync(IEnumerable<Models.McpServerEntry>? servers, CancellationToken ct = default)
|
||||
{
|
||||
if (servers == null) return 0;
|
||||
|
||||
var registered = 0;
|
||||
foreach (var server in servers)
|
||||
{
|
||||
if (server == null || !server.Enabled) continue;
|
||||
|
||||
if (!string.Equals(server.Transport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogService.Warn($"MCP '{server.Name}': unsupported transport '{server.Transport}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = new McpClientService(server);
|
||||
if (!await client.ConnectAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
client.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
_ownedResources.Add(client);
|
||||
_mcpClients[server.Name] = client;
|
||||
foreach (var def in client.Tools)
|
||||
{
|
||||
Register(new McpTool(client, def));
|
||||
registered++;
|
||||
}
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
/// <summary>비활성 도구를 제외한 활성 도구 목록을 반환합니다.</summary>
|
||||
public IReadOnlyCollection<IAgentTool> GetActiveTools(IEnumerable<string>? disabledNames = null)
|
||||
{
|
||||
if (disabledNames == null) return All;
|
||||
var disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase);
|
||||
if (disabled.Count == 0) return All;
|
||||
return _tools.Values.Where(t => !disabled.Contains(t.Name)).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>IDisposable 도구를 모두 해제합니다.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var tool in _tools.Values)
|
||||
{
|
||||
if (tool is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
foreach (var resource in _ownedResources)
|
||||
resource.Dispose();
|
||||
_ownedResources.Clear();
|
||||
_mcpClients.Clear();
|
||||
_tools.Clear();
|
||||
}
|
||||
|
||||
/// <summary>기본 도구 + 내장 스킬을 모두 등록한 레지스트리를 생성합니다.</summary>
|
||||
public static ToolRegistry CreateDefault()
|
||||
{
|
||||
var registry = new ToolRegistry();
|
||||
|
||||
// 기본 도구 (파일/검색/프로세스)
|
||||
registry.Register(new FileReadTool());
|
||||
registry.Register(new FileWriteTool());
|
||||
registry.Register(new FileEditTool());
|
||||
registry.Register(new GlobTool());
|
||||
registry.Register(new GrepTool());
|
||||
registry.Register(new ProcessTool());
|
||||
registry.Register(new FolderMapTool());
|
||||
registry.Register(new DocumentReaderTool());
|
||||
|
||||
// 내장 스킬 (문서 생성)
|
||||
registry.Register(new ExcelSkill());
|
||||
registry.Register(new DocxSkill());
|
||||
registry.Register(new CsvSkill());
|
||||
registry.Register(new MarkdownSkill());
|
||||
registry.Register(new HtmlSkill());
|
||||
registry.Register(new ChartSkill());
|
||||
registry.Register(new BatchSkill());
|
||||
registry.Register(new PptxSkill());
|
||||
|
||||
// 멀티패스 문서 엔진
|
||||
registry.Register(new DocumentPlannerTool());
|
||||
registry.Register(new DocumentAssemblerTool());
|
||||
|
||||
// 문서 품질 검증 & 포맷 변환
|
||||
registry.Register(new DocumentReviewTool());
|
||||
registry.Register(new FormatConvertTool());
|
||||
|
||||
// Code 탭: 개발 환경 감지 & 빌드/테스트 & Git
|
||||
registry.Register(new DevEnvDetectTool());
|
||||
registry.Register(new BuildRunTool());
|
||||
registry.Register(new GitTool());
|
||||
registry.Register(new LspTool());
|
||||
registry.Register(new SubAgentTool());
|
||||
registry.Register(new WaitAgentsTool());
|
||||
registry.Register(new CodeSearchTool());
|
||||
registry.Register(new TestLoopTool());
|
||||
|
||||
// 코드 리뷰 + 프로젝트 규칙
|
||||
registry.Register(new CodeReviewTool());
|
||||
registry.Register(new ProjectRuleTool());
|
||||
|
||||
// 스킬 시스템
|
||||
registry.Register(new SkillManagerTool());
|
||||
|
||||
// 에이전트 메모리
|
||||
registry.Register(new MemoryTool());
|
||||
|
||||
// 데이터 처리 + 시스템 유틸리티
|
||||
registry.Register(new JsonTool());
|
||||
registry.Register(new RegexTool());
|
||||
registry.Register(new DiffTool());
|
||||
registry.Register(new ClipboardTool());
|
||||
registry.Register(new NotifyTool());
|
||||
registry.Register(new EnvTool());
|
||||
registry.Register(new ZipTool());
|
||||
registry.Register(new HttpTool());
|
||||
registry.Register(new SqlTool());
|
||||
registry.Register(new Base64Tool());
|
||||
registry.Register(new HashTool());
|
||||
registry.Register(new DateTimeTool());
|
||||
|
||||
// 코드 품질
|
||||
registry.Register(new SnippetRunnerTool());
|
||||
|
||||
// 데이터 분석 + 문서 자동화
|
||||
registry.Register(new DataPivotTool());
|
||||
registry.Register(new TemplateRenderTool());
|
||||
registry.Register(new TextSummarizeTool());
|
||||
|
||||
// 파일 모니터링 + 이미지 분석
|
||||
registry.Register(new FileWatchTool());
|
||||
registry.Register(new ImageAnalyzeTool());
|
||||
|
||||
// 파일 관리 + 메타데이터 + 멀티리드
|
||||
registry.Register(new FileManageTool());
|
||||
registry.Register(new FileInfoTool());
|
||||
registry.Register(new MultiReadTool());
|
||||
|
||||
// 사용자 질문
|
||||
registry.Register(new UserAskTool());
|
||||
|
||||
// MCP 리소스
|
||||
registry.Register(new McpListResourcesTool(() => registry.GetMcpClients()));
|
||||
registry.Register(new McpReadResourceTool(() => registry.GetMcpClients()));
|
||||
|
||||
// 외부 열기 + 수학 + XML + 인코딩
|
||||
registry.Register(new OpenExternalTool());
|
||||
registry.Register(new MathTool());
|
||||
registry.Register(new XmlTool());
|
||||
registry.Register(new EncodingTool());
|
||||
|
||||
// 태스크 추적
|
||||
registry.Register(new TaskTrackerTool());
|
||||
|
||||
// 워크플로우 도구
|
||||
registry.Register(new SuggestActionsTool());
|
||||
registry.Register(new DiffPreviewTool());
|
||||
registry.Register(new CheckpointTool());
|
||||
registry.Register(new PlaybookTool());
|
||||
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
104
src/AxCopilot/Services/Agent/UserAskTool.cs
Normal file
104
src/AxCopilot/Services/Agent/UserAskTool.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>에이전트가 사용자에게 질문하고 응답을 대기하는 도구.</summary>
|
||||
public class UserAskTool : IAgentTool
|
||||
{
|
||||
public string Name => "user_ask";
|
||||
public string Description =>
|
||||
"Ask the user a question and wait for their response. " +
|
||||
"Use when you need clarification, confirmation, or a choice from the user. " +
|
||||
"Optionally provide predefined options for the user to pick from. " +
|
||||
"The user can select from options OR type a custom response.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["question"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "The question to ask the user",
|
||||
},
|
||||
["options"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Optional list of choices for the user (e.g. ['Option A', 'Option B'])",
|
||||
Items = new() { Type = "string", Description = "Choice option" },
|
||||
},
|
||||
["default_value"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Default value if user doesn't specify",
|
||||
},
|
||||
},
|
||||
Required = ["question"],
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var question = args.GetProperty("question").GetString() ?? "";
|
||||
var defaultVal = args.TryGetProperty("default_value", out var dv) ? dv.GetString() ?? "" : "";
|
||||
|
||||
var options = new List<string>();
|
||||
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var o in opts.EnumerateArray())
|
||||
{
|
||||
var s = o.GetString();
|
||||
if (!string.IsNullOrEmpty(s)) options.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
// UserAskCallback 사용 (커스텀 대화 상자)
|
||||
if (context.UserAskCallback != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await context.UserAskCallback(question, options, defaultVal);
|
||||
if (response == null)
|
||||
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
|
||||
|
||||
return ToolResult.Ok($"사용자 응답: {response}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"사용자 입력 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: UserDecision 콜백
|
||||
if (context.UserDecision != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var prompt = question;
|
||||
if (!string.IsNullOrEmpty(defaultVal))
|
||||
prompt += $"\n(기본값: {defaultVal})";
|
||||
|
||||
var effectiveOptions = options.Count > 0 ? options : new List<string> { "확인" };
|
||||
var response = await context.UserDecision(prompt, effectiveOptions);
|
||||
|
||||
if (string.IsNullOrEmpty(response) && !string.IsNullOrEmpty(defaultVal))
|
||||
response = defaultVal;
|
||||
|
||||
return ToolResult.Ok($"사용자 응답: {response}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"사용자 입력 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult.Fail("사용자 입력 콜백이 등록되지 않았습니다.");
|
||||
}
|
||||
}
|
||||
189
src/AxCopilot/Services/Agent/XmlTool.cs
Normal file
189
src/AxCopilot/Services/Agent/XmlTool.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.XPath;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>XML 파싱, XPath 쿼리, 변환 도구.</summary>
|
||||
public class XmlTool : IAgentTool
|
||||
{
|
||||
public string Name => "xml_tool";
|
||||
public string Description =>
|
||||
"Parse and query XML documents. Actions: " +
|
||||
"'parse' — parse XML file/string and return structure summary; " +
|
||||
"'xpath' — evaluate XPath expression and return matching nodes; " +
|
||||
"'to_json' — convert XML to JSON; " +
|
||||
"'format' — pretty-print XML with indentation.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action: parse, xpath, to_json, format",
|
||||
Enum = ["parse", "xpath", "to_json", "format"],
|
||||
},
|
||||
["path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "XML file path (optional if 'xml' is provided)",
|
||||
},
|
||||
["xml"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "XML string (optional if 'path' is provided)",
|
||||
},
|
||||
["expression"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "XPath expression (for 'xpath' action)",
|
||||
},
|
||||
},
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var xmlStr = args.TryGetProperty("xml", out var x) ? x.GetString() ?? "" : "";
|
||||
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
|
||||
var expression = args.TryGetProperty("expression", out var ex) ? ex.GetString() ?? "" : "";
|
||||
|
||||
try
|
||||
{
|
||||
// XML 소스 결정
|
||||
if (string.IsNullOrEmpty(xmlStr) && !string.IsNullOrEmpty(rawPath))
|
||||
{
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
if (!context.IsPathAllowed(path))
|
||||
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {path}"));
|
||||
if (!File.Exists(path))
|
||||
return Task.FromResult(ToolResult.Fail($"파일 없음: {path}"));
|
||||
xmlStr = TextFileCodec.ReadAllText(path).Text;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(xmlStr))
|
||||
return Task.FromResult(ToolResult.Fail("'xml' 또는 'path' 중 하나를 지정해야 합니다."));
|
||||
|
||||
var doc = XDocument.Parse(xmlStr);
|
||||
|
||||
return action switch
|
||||
{
|
||||
"parse" => Task.FromResult(ParseSummary(doc)),
|
||||
"xpath" => Task.FromResult(EvalXPath(doc, expression)),
|
||||
"to_json" => Task.FromResult(XmlToJson(doc)),
|
||||
"format" => Task.FromResult(FormatXml(doc)),
|
||||
_ => Task.FromResult(ToolResult.Fail($"Unknown action: {action}")),
|
||||
};
|
||||
}
|
||||
catch (XmlException xe)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"XML 파싱 오류: {xe.Message}"));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"XML 처리 오류: {e.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ParseSummary(XDocument doc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Root: {doc.Root?.Name.LocalName ?? "(none)"}");
|
||||
if (doc.Root != null)
|
||||
{
|
||||
var ns = doc.Root.Name.Namespace;
|
||||
if (!string.IsNullOrEmpty(ns.NamespaceName))
|
||||
sb.AppendLine($"Namespace: {ns.NamespaceName}");
|
||||
|
||||
var elements = doc.Descendants().Count();
|
||||
var attrs = doc.Descendants().SelectMany(e => e.Attributes()).Count();
|
||||
sb.AppendLine($"Elements: {elements}");
|
||||
sb.AppendLine($"Attributes: {attrs}");
|
||||
|
||||
// 최상위 자식 요소 나열 (최대 20개)
|
||||
var children = doc.Root.Elements().Take(20).ToList();
|
||||
sb.AppendLine($"Top-level children ({doc.Root.Elements().Count()}):");
|
||||
foreach (var child in children)
|
||||
sb.AppendLine($" <{child.Name.LocalName}> ({child.Elements().Count()} children)");
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult EvalXPath(XDocument doc, string xpath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(xpath))
|
||||
return ToolResult.Fail("XPath 'expression'이 필요합니다.");
|
||||
|
||||
var results = doc.XPathSelectElements(xpath).Take(50).ToList();
|
||||
if (results.Count == 0)
|
||||
return ToolResult.Ok("매칭 노드 없음.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"매칭: {results.Count}개 노드");
|
||||
foreach (var el in results)
|
||||
{
|
||||
var text = el.ToString();
|
||||
if (text.Length > 500) text = text[..500] + "...";
|
||||
sb.AppendLine(text);
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult XmlToJson(XDocument doc)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(
|
||||
XmlToDict(doc.Root!),
|
||||
new JsonSerializerOptions { WriteIndented = true });
|
||||
if (json.Length > 50_000) json = json[..50_000] + "\n... (truncated)";
|
||||
return ToolResult.Ok(json);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> XmlToDict(XElement el)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var attr in el.Attributes())
|
||||
dict[$"@{attr.Name.LocalName}"] = attr.Value;
|
||||
|
||||
var groups = el.Elements().GroupBy(e => e.Name.LocalName).ToList();
|
||||
foreach (var g in groups)
|
||||
{
|
||||
var items = g.ToList();
|
||||
if (items.Count == 1)
|
||||
{
|
||||
var child = items[0];
|
||||
dict[g.Key] = child.HasElements ? XmlToDict(child) : (object?)child.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
dict[g.Key] = items.Select(c => c.HasElements ? (object)XmlToDict(c) : c.Value).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (!el.HasElements && groups.Count == 0 && !string.IsNullOrEmpty(el.Value))
|
||||
dict["#text"] = el.Value;
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static ToolResult FormatXml(XDocument doc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var writer = XmlWriter.Create(sb, new XmlWriterSettings
|
||||
{
|
||||
Indent = true,
|
||||
IndentChars = " ",
|
||||
OmitXmlDeclaration = false,
|
||||
});
|
||||
doc.WriteTo(writer);
|
||||
writer.Flush();
|
||||
var result = sb.ToString();
|
||||
if (result.Length > 50_000) result = result[..50_000] + "\n... (truncated)";
|
||||
return ToolResult.Ok(result);
|
||||
}
|
||||
}
|
||||
167
src/AxCopilot/Services/Agent/ZipTool.cs
Normal file
167
src/AxCopilot/Services/Agent/ZipTool.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 파일 압축(zip) / 해제 도구.
|
||||
/// </summary>
|
||||
public class ZipTool : IAgentTool
|
||||
{
|
||||
public string Name => "zip_tool";
|
||||
public string Description =>
|
||||
"Compress or extract zip archives. Actions: " +
|
||||
"'compress' — create a zip file from files/folders; " +
|
||||
"'extract' — extract a zip file to a directory; " +
|
||||
"'list' — list contents of a zip file without extracting.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform",
|
||||
Enum = ["compress", "extract", "list"],
|
||||
},
|
||||
["zip_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Path to the zip file (to create or extract)",
|
||||
},
|
||||
["source_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Source file or directory path (for compress action)",
|
||||
},
|
||||
["dest_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Destination directory (for extract action)",
|
||||
},
|
||||
},
|
||||
Required = ["action", "zip_path"],
|
||||
};
|
||||
|
||||
private const long MaxExtractSize = 500 * 1024 * 1024; // 500MB 제한
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var zipPath = args.GetProperty("zip_path").GetString() ?? "";
|
||||
|
||||
if (!Path.IsPathRooted(zipPath))
|
||||
zipPath = Path.Combine(context.WorkFolder, zipPath);
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"compress" => Compress(args, zipPath, context),
|
||||
"extract" => Extract(args, zipPath, context),
|
||||
"list" => ListContents(zipPath),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"Zip 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult Compress(JsonElement args, string zipPath, AgentContext context)
|
||||
{
|
||||
if (!args.TryGetProperty("source_path", out var sp))
|
||||
return ToolResult.Fail("'source_path' is required for compress action");
|
||||
|
||||
var sourcePath = sp.GetString() ?? "";
|
||||
if (!Path.IsPathRooted(sourcePath))
|
||||
sourcePath = Path.Combine(context.WorkFolder, sourcePath);
|
||||
|
||||
if (File.Exists(zipPath))
|
||||
return ToolResult.Fail($"Zip file already exists: {zipPath}");
|
||||
|
||||
if (Directory.Exists(sourcePath))
|
||||
{
|
||||
ZipFile.CreateFromDirectory(sourcePath, zipPath, CompressionLevel.Optimal, includeBaseDirectory: false);
|
||||
var info = new FileInfo(zipPath);
|
||||
return ToolResult.Ok($"✓ Created {zipPath} ({info.Length / 1024}KB)", zipPath);
|
||||
}
|
||||
else if (File.Exists(sourcePath))
|
||||
{
|
||||
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
|
||||
zip.CreateEntryFromFile(sourcePath, Path.GetFileName(sourcePath), CompressionLevel.Optimal);
|
||||
var info = new FileInfo(zipPath);
|
||||
return ToolResult.Ok($"✓ Created {zipPath} ({info.Length / 1024}KB)", zipPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ToolResult.Fail($"Source not found: {sourcePath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult Extract(JsonElement args, string zipPath, AgentContext context)
|
||||
{
|
||||
if (!File.Exists(zipPath))
|
||||
return ToolResult.Fail($"Zip file not found: {zipPath}");
|
||||
|
||||
var destPath = args.TryGetProperty("dest_path", out var dp)
|
||||
? dp.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(destPath))
|
||||
destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder,
|
||||
Path.GetFileNameWithoutExtension(zipPath));
|
||||
if (!Path.IsPathRooted(destPath))
|
||||
destPath = Path.Combine(context.WorkFolder, destPath);
|
||||
|
||||
// 사이즈 체크
|
||||
using (var check = ZipFile.OpenRead(zipPath))
|
||||
{
|
||||
var totalSize = check.Entries.Sum(e => e.Length);
|
||||
if (totalSize > MaxExtractSize)
|
||||
return ToolResult.Fail($"Uncompressed size ({totalSize / 1024 / 1024}MB) exceeds 500MB limit");
|
||||
|
||||
// 보안: 상위 경로 이탈 방지
|
||||
foreach (var entry in check.Entries)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(Path.Combine(destPath, entry.FullName));
|
||||
if (!fullPath.StartsWith(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail($"Security: entry '{entry.FullName}' escapes destination directory");
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destPath);
|
||||
ZipFile.ExtractToDirectory(zipPath, destPath, overwriteFiles: true);
|
||||
|
||||
var fileCount = Directory.GetFiles(destPath, "*", SearchOption.AllDirectories).Length;
|
||||
return ToolResult.Ok($"✓ Extracted {fileCount} files to {destPath}");
|
||||
}
|
||||
|
||||
private static ToolResult ListContents(string zipPath)
|
||||
{
|
||||
if (!File.Exists(zipPath))
|
||||
return ToolResult.Fail($"Zip file not found: {zipPath}");
|
||||
|
||||
using var zip = ZipFile.OpenRead(zipPath);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"Archive: {Path.GetFileName(zipPath)} ({new FileInfo(zipPath).Length / 1024}KB)");
|
||||
sb.AppendLine($"Entries: {zip.Entries.Count}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{"Size",10} {"Compressed",10} {"Name"}");
|
||||
sb.AppendLine(new string('-', 60));
|
||||
|
||||
var limit = Math.Min(zip.Entries.Count, 200);
|
||||
foreach (var entry in zip.Entries.Take(limit))
|
||||
{
|
||||
sb.AppendLine($"{entry.Length,10} {entry.CompressedLength,10} {entry.FullName}");
|
||||
}
|
||||
if (zip.Entries.Count > limit)
|
||||
sb.AppendLine($"\n... and {zip.Entries.Count - limit} more entries");
|
||||
|
||||
var totalUncompressed = zip.Entries.Sum(e => e.Length);
|
||||
sb.AppendLine($"\nTotal uncompressed: {totalUncompressed / 1024}KB");
|
||||
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
}
|
||||
309
src/AxCopilot/Services/AgentMemoryService.cs
Normal file
309
src/AxCopilot/Services/AgentMemoryService.cs
Normal file
@@ -0,0 +1,309 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 메모리 서비스.
|
||||
/// 작업 폴더별 + 전역 메모리를 관리하며, 대화 간 지속적 컨텍스트를 유지합니다.
|
||||
/// 저장소: %APPDATA%\AxCopilot\memory\{hash}.dat (암호화)
|
||||
/// </summary>
|
||||
public class AgentMemoryService
|
||||
{
|
||||
private static readonly string MemoryDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "memory");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly List<MemoryEntry> _entries = new();
|
||||
private readonly object _lock = new();
|
||||
private string? _currentWorkFolder;
|
||||
|
||||
/// <summary>현재 로드된 메모리 항목 수.</summary>
|
||||
public int Count { get { lock (_lock) return _entries.Count; } }
|
||||
|
||||
/// <summary>모든 메모리 항목 (읽기 전용).</summary>
|
||||
public IReadOnlyList<MemoryEntry> All { get { lock (_lock) return _entries.ToList(); } }
|
||||
|
||||
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
|
||||
public void Load(string? workFolder)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Clear();
|
||||
_currentWorkFolder = workFolder;
|
||||
|
||||
// 전역 메모리
|
||||
var globalPath = GetFilePath(null);
|
||||
LoadFromFile(globalPath);
|
||||
|
||||
// 폴더별 메모리
|
||||
if (!string.IsNullOrEmpty(workFolder))
|
||||
{
|
||||
var folderPath = GetFilePath(workFolder);
|
||||
LoadFromFile(folderPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>새 메모리를 추가합니다. 유사한 내용이 있으면 병합합니다.</summary>
|
||||
public MemoryEntry Add(string type, string content, string? source = null, string? workFolder = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// 중복 검사: 동일 타입 + 유사 내용 (80% 이상 키워드 겹침)
|
||||
var existing = _entries.FirstOrDefault(e =>
|
||||
e.Type == type && CalculateSimilarity(e.Content, content) > 0.8);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.LastUsedAt = DateTime.Now;
|
||||
existing.UseCount++;
|
||||
Save();
|
||||
return existing;
|
||||
}
|
||||
|
||||
var entry = new MemoryEntry
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N")[..12],
|
||||
Type = type,
|
||||
Content = content,
|
||||
Source = source ?? "",
|
||||
CreatedAt = DateTime.Now,
|
||||
LastUsedAt = DateTime.Now,
|
||||
UseCount = 1,
|
||||
Relevance = 1.0,
|
||||
WorkFolder = workFolder,
|
||||
};
|
||||
_entries.Add(entry);
|
||||
Prune();
|
||||
Save();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>쿼리와 관련된 메모리를 검색합니다.</summary>
|
||||
public List<MemoryEntry> GetRelevant(string query, int maxCount = 10)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return new();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var queryTokens = Tokenize(query);
|
||||
var scored = _entries
|
||||
.Select(e =>
|
||||
{
|
||||
var entryTokens = Tokenize(e.Content);
|
||||
var overlap = queryTokens.Intersect(entryTokens, StringComparer.OrdinalIgnoreCase).Count();
|
||||
var score = queryTokens.Count > 0 ? (double)overlap / queryTokens.Count : 0;
|
||||
// 사용 빈도와 최근 사용 가중치 적용
|
||||
score += Math.Min(e.UseCount * 0.05, 0.3);
|
||||
if ((DateTime.Now - e.LastUsedAt).TotalDays < 7) score += 0.1;
|
||||
return (Entry: e, Score: score);
|
||||
})
|
||||
.Where(x => x.Score > 0.1)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(maxCount)
|
||||
.Select(x =>
|
||||
{
|
||||
x.Entry.Relevance = x.Score;
|
||||
return x.Entry;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return scored;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>메모리 사용 기록을 갱신합니다.</summary>
|
||||
public void Touch(string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = _entries.FirstOrDefault(e => e.Id == id);
|
||||
if (entry != null)
|
||||
{
|
||||
entry.UseCount++;
|
||||
entry.LastUsedAt = DateTime.Now;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>메모리를 삭제합니다.</summary>
|
||||
public bool Remove(string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var removed = _entries.RemoveAll(e => e.Id == id) > 0;
|
||||
if (removed) Save();
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>모든 메모리를 삭제합니다.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Clear();
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>오래되고 사용되지 않는 메모리를 정리합니다.</summary>
|
||||
private void Prune()
|
||||
{
|
||||
var maxEntries = 100;
|
||||
try
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
maxEntries = app?.SettingsService?.Settings.Llm.MaxMemoryEntries ?? 100;
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (_entries.Count <= maxEntries) return;
|
||||
|
||||
// 점수 기반 정리: 오래되고 사용 안 되는 것부터
|
||||
var toRemove = _entries
|
||||
.OrderBy(e => e.UseCount)
|
||||
.ThenBy(e => e.LastUsedAt)
|
||||
.Take(_entries.Count - maxEntries)
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in toRemove)
|
||||
_entries.Remove(entry);
|
||||
}
|
||||
|
||||
/// <summary>메모리를 암호화하여 파일에 저장합니다.</summary>
|
||||
private void Save()
|
||||
{
|
||||
Directory.CreateDirectory(MemoryDir);
|
||||
|
||||
// 전역 메모리
|
||||
var globalEntries = _entries.Where(e => e.WorkFolder == null).ToList();
|
||||
SaveToFile(GetFilePath(null), globalEntries);
|
||||
|
||||
// 폴더별 메모리
|
||||
if (!string.IsNullOrEmpty(_currentWorkFolder))
|
||||
{
|
||||
var folderEntries = _entries.Where(e => e.WorkFolder != null).ToList();
|
||||
SaveToFile(GetFilePath(_currentWorkFolder), folderEntries);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveToFile(string path, List<MemoryEntry> entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(entries, JsonOptions);
|
||||
var encrypted = CryptoService.PortableEncrypt(json);
|
||||
var tempPath = path + ".tmp";
|
||||
File.WriteAllText(tempPath, encrypted);
|
||||
File.Move(tempPath, path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"메모리 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFromFile(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
var encrypted = File.ReadAllText(path);
|
||||
var json = CryptoService.PortableDecrypt(encrypted);
|
||||
if (string.IsNullOrEmpty(json)) return;
|
||||
var entries = JsonSerializer.Deserialize<List<MemoryEntry>>(json, JsonOptions);
|
||||
if (entries != null)
|
||||
_entries.AddRange(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"메모리 로드 실패 ({path}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFilePath(string? workFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder))
|
||||
return Path.Combine(MemoryDir, "_global.dat");
|
||||
|
||||
// 폴더 경로를 해시하여 파일명 생성
|
||||
var hash = Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant())))[..16];
|
||||
return Path.Combine(MemoryDir, $"{hash}.dat");
|
||||
}
|
||||
|
||||
/// <summary>두 문자열의 키워드 유사도를 계산합니다 (0~1).</summary>
|
||||
private static double CalculateSimilarity(string a, string b)
|
||||
{
|
||||
var tokensA = Tokenize(a);
|
||||
var tokensB = Tokenize(b);
|
||||
if (tokensA.Count == 0 || tokensB.Count == 0) return 0;
|
||||
var intersection = tokensA.Intersect(tokensB, StringComparer.OrdinalIgnoreCase).Count();
|
||||
var union = tokensA.Union(tokensB, StringComparer.OrdinalIgnoreCase).Count();
|
||||
return union > 0 ? (double)intersection / union : 0;
|
||||
}
|
||||
|
||||
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
|
||||
private static HashSet<string> Tokenize(string text)
|
||||
{
|
||||
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c == '_')
|
||||
sb.Append(c);
|
||||
else if (sb.Length > 1) // 1글자 토큰 제외
|
||||
{
|
||||
tokens.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else
|
||||
sb.Clear();
|
||||
}
|
||||
if (sb.Length > 1) tokens.Add(sb.ToString());
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>에이전트 메모리 항목.</summary>
|
||||
public class MemoryEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "fact"; // rule | preference | fact | correction
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("lastUsedAt")]
|
||||
public DateTime LastUsedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("useCount")]
|
||||
public int UseCount { get; set; }
|
||||
|
||||
[JsonPropertyName("relevance")]
|
||||
public double Relevance { get; set; }
|
||||
|
||||
[JsonPropertyName("workFolder")]
|
||||
public string? WorkFolder { get; set; }
|
||||
}
|
||||
213
src/AxCopilot/Services/AgentStatsService.cs
Normal file
213
src/AxCopilot/Services/AgentStatsService.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 실행 통계를 로컬 JSON 파일에 기록/집계합니다.
|
||||
/// %APPDATA%\AxCopilot\stats\agent_stats.json
|
||||
/// </summary>
|
||||
public static class AgentStatsService
|
||||
{
|
||||
private static readonly string StatsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "stats");
|
||||
|
||||
private static readonly string StatsFile = Path.Combine(StatsDir, "agent_stats.json");
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
// ─── 데이터 모델 ──────────────────────────────────────────────────────
|
||||
|
||||
public record AgentSessionRecord
|
||||
{
|
||||
[JsonPropertyName("ts")] public DateTime Timestamp { get; init; } = DateTime.Now;
|
||||
[JsonPropertyName("tab")] public string Tab { get; init; } = "";
|
||||
[JsonPropertyName("task")] public string TaskType { get; init; } = "";
|
||||
[JsonPropertyName("model")] public string Model { get; init; } = "";
|
||||
[JsonPropertyName("calls")] public int ToolCalls { get; init; }
|
||||
[JsonPropertyName("ok")] public int SuccessCount{ get; init; }
|
||||
[JsonPropertyName("fail")] public int FailCount { get; init; }
|
||||
[JsonPropertyName("itok")] public int InputTokens { get; init; }
|
||||
[JsonPropertyName("otok")] public int OutputTokens{ get; init; }
|
||||
[JsonPropertyName("ms")] public long DurationMs { get; init; }
|
||||
[JsonPropertyName("retryBlocked")] public int RepeatedFailureBlockedCount { get; init; }
|
||||
[JsonPropertyName("retryRecovered")] public int RecoveredAfterFailureCount { get; init; }
|
||||
[JsonPropertyName("tools")] public List<string> UsedTools { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>집계 결과.</summary>
|
||||
public record AgentStatsSummary
|
||||
{
|
||||
public int TotalSessions { get; init; }
|
||||
public int TotalToolCalls { get; init; }
|
||||
public int TotalTokens { get; init; }
|
||||
public int TotalInputTokens { get; init; }
|
||||
public int TotalOutputTokens { get; init; }
|
||||
public long TotalDurationMs { get; init; }
|
||||
public int TotalRepeatedFailureBlocked { get; init; }
|
||||
public int TotalRecoveredAfterFailure { get; init; }
|
||||
public double RetryQualityRate { get; init; }
|
||||
public Dictionary<string, int> ToolFrequency { get; init; } = new();
|
||||
public Dictionary<string, int> ModelBreakdown { get; init; } = new();
|
||||
public Dictionary<string, int> TabBreakdown { get; init; } = new();
|
||||
public Dictionary<string, int> TaskTypeBreakdown { get; init; } = new();
|
||||
public Dictionary<string, double> RetryQualityByTaskType { get; init; } = new();
|
||||
/// <summary>일별 세션 수 (날짜 키 "yyyy-MM-dd").</summary>
|
||||
public Dictionary<string, int> DailySessions { get; init; } = new();
|
||||
/// <summary>일별 토큰 수 (날짜 키 "yyyy-MM-dd").</summary>
|
||||
public Dictionary<string, int> DailyTokens { get; init; } = new();
|
||||
}
|
||||
|
||||
// ─── 기록 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>에이전트 세션 결과를 기록합니다. 비동기 fire-and-forget.</summary>
|
||||
public static void RecordSession(AgentSessionRecord record)
|
||||
{
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(StatsDir))
|
||||
Directory.CreateDirectory(StatsDir);
|
||||
|
||||
// 한 줄씩 append (JSONL 형식)
|
||||
var line = JsonSerializer.Serialize(record, _jsonOpts);
|
||||
File.AppendAllText(StatsFile, line + "\n");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"통계 기록 실패: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 집계 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>지정 일수 이내의 통계를 집계합니다. days=0이면 전체.</summary>
|
||||
public static AgentStatsSummary Aggregate(int days = 0)
|
||||
{
|
||||
var records = LoadRecords(days);
|
||||
return BuildSummary(records);
|
||||
}
|
||||
|
||||
/// <summary>통계 파일에서 레코드를 읽어옵니다.</summary>
|
||||
public static List<AgentSessionRecord> LoadRecords(int days = 0)
|
||||
{
|
||||
if (!File.Exists(StatsFile)) return new();
|
||||
|
||||
var cutoff = days > 0 ? DateTime.Now.AddDays(-days) : DateTime.MinValue;
|
||||
var result = new List<AgentSessionRecord>();
|
||||
|
||||
foreach (var line in File.ReadLines(StatsFile))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
var rec = JsonSerializer.Deserialize<AgentSessionRecord>(line, _jsonOpts);
|
||||
if (rec != null && rec.Timestamp >= cutoff)
|
||||
result.Add(rec);
|
||||
}
|
||||
catch { /* 손상된 줄 건너뜀 */ }
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AgentStatsSummary BuildSummary(List<AgentSessionRecord> records)
|
||||
{
|
||||
var toolFreq = new Dictionary<string, int>();
|
||||
var modelBreak = new Dictionary<string, int>();
|
||||
var tabBreak = new Dictionary<string, int>();
|
||||
var taskBreak = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var retryRecoveredByTask = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var retryBlockedByTask = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var dailySess = new Dictionary<string, int>();
|
||||
var dailyTok = new Dictionary<string, int>();
|
||||
|
||||
int totalCalls = 0, totalIn = 0, totalOut = 0;
|
||||
int totalRetryBlocked = 0, totalRecovered = 0;
|
||||
long totalMs = 0;
|
||||
|
||||
foreach (var r in records)
|
||||
{
|
||||
totalCalls += r.ToolCalls;
|
||||
totalIn += r.InputTokens;
|
||||
totalOut += r.OutputTokens;
|
||||
totalMs += r.DurationMs;
|
||||
totalRetryBlocked += r.RepeatedFailureBlockedCount;
|
||||
totalRecovered += r.RecoveredAfterFailureCount;
|
||||
|
||||
foreach (var t in r.UsedTools)
|
||||
toolFreq[t] = toolFreq.GetValueOrDefault(t) + 1;
|
||||
|
||||
if (!string.IsNullOrEmpty(r.Model))
|
||||
modelBreak[r.Model] = modelBreak.GetValueOrDefault(r.Model) + 1;
|
||||
|
||||
if (!string.IsNullOrEmpty(r.Tab))
|
||||
tabBreak[r.Tab] = tabBreak.GetValueOrDefault(r.Tab) + 1;
|
||||
|
||||
var taskType = string.IsNullOrWhiteSpace(r.TaskType) ? "unknown" : r.TaskType.Trim().ToLowerInvariant();
|
||||
taskBreak[taskType] = taskBreak.GetValueOrDefault(taskType) + 1;
|
||||
retryRecoveredByTask[taskType] = retryRecoveredByTask.GetValueOrDefault(taskType) + r.RecoveredAfterFailureCount;
|
||||
retryBlockedByTask[taskType] = retryBlockedByTask.GetValueOrDefault(taskType) + r.RepeatedFailureBlockedCount;
|
||||
|
||||
var dateKey = r.Timestamp.ToString("yyyy-MM-dd");
|
||||
dailySess[dateKey] = dailySess.GetValueOrDefault(dateKey) + 1;
|
||||
dailyTok[dateKey] = dailyTok.GetValueOrDefault(dateKey) + r.InputTokens + r.OutputTokens;
|
||||
}
|
||||
|
||||
var retryQualityByTask = taskBreak.Keys
|
||||
.ToDictionary(
|
||||
k => k,
|
||||
k => CalculateRetryQualityRate(
|
||||
retryRecoveredByTask.GetValueOrDefault(k),
|
||||
retryBlockedByTask.GetValueOrDefault(k)));
|
||||
|
||||
return new AgentStatsSummary
|
||||
{
|
||||
TotalSessions = records.Count,
|
||||
TotalToolCalls = totalCalls,
|
||||
TotalTokens = totalIn + totalOut,
|
||||
TotalInputTokens = totalIn,
|
||||
TotalOutputTokens= totalOut,
|
||||
TotalDurationMs = totalMs,
|
||||
TotalRepeatedFailureBlocked = totalRetryBlocked,
|
||||
TotalRecoveredAfterFailure = totalRecovered,
|
||||
RetryQualityRate = CalculateRetryQualityRate(totalRecovered, totalRetryBlocked),
|
||||
ToolFrequency = toolFreq.OrderByDescending(kv => kv.Value).Take(10)
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
ModelBreakdown = modelBreak,
|
||||
TabBreakdown = tabBreak,
|
||||
TaskTypeBreakdown = taskBreak.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
RetryQualityByTaskType = retryQualityByTask,
|
||||
DailySessions = dailySess,
|
||||
DailyTokens = dailyTok,
|
||||
};
|
||||
}
|
||||
|
||||
private static double CalculateRetryQualityRate(int recoveredAfterFailure, int repeatedFailureBlocked)
|
||||
{
|
||||
var total = recoveredAfterFailure + repeatedFailureBlocked;
|
||||
if (total <= 0)
|
||||
return 1.0;
|
||||
|
||||
return Math.Clamp((double)recoveredAfterFailure / total, 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>통계 파일 크기 (bytes). 파일 없으면 0.</summary>
|
||||
public static long GetFileSize() =>
|
||||
File.Exists(StatsFile) ? new FileInfo(StatsFile).Length : 0;
|
||||
|
||||
/// <summary>모든 통계 데이터를 삭제합니다.</summary>
|
||||
public static void Clear()
|
||||
{
|
||||
try { if (File.Exists(StatsFile)) File.Delete(StatsFile); }
|
||||
catch (Exception ex) { LogService.Warn($"통계 삭제 실패: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
178
src/AxCopilot/Services/AgentTriggerService.cs
Normal file
178
src/AxCopilot/Services/AgentTriggerService.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 이벤트 기반 에이전트 트리거 서비스.
|
||||
/// 파일 변경, 스케줄, Git 이벤트를 감지하여 에이전트를 자동 실행합니다.
|
||||
/// </summary>
|
||||
public class AgentTriggerService : IDisposable
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private FileSystemWatcher? _fileWatcher;
|
||||
private DispatcherTimer? _scheduleTimer;
|
||||
private readonly List<TriggerRule> _rules = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>트리거 발동 시 에이전트 실행 콜백. (triggerName, prompt) → void</summary>
|
||||
public Action<string, string>? OnTriggerFired { get; set; }
|
||||
|
||||
public AgentTriggerService(SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>트리거 규칙을 로드하고 모니터링을 시작합니다.</summary>
|
||||
public void Start()
|
||||
{
|
||||
LoadRules();
|
||||
StartFileWatcher();
|
||||
StartScheduleTimer();
|
||||
LogService.Info($"에이전트 트리거 서비스 시작: {_rules.Count}개 규칙");
|
||||
}
|
||||
|
||||
/// <summary>트리거 규칙을 다시 로드합니다.</summary>
|
||||
public void Reload()
|
||||
{
|
||||
Stop();
|
||||
Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_fileWatcher?.Dispose();
|
||||
_fileWatcher = null;
|
||||
_scheduleTimer?.Stop();
|
||||
}
|
||||
|
||||
private void LoadRules()
|
||||
{
|
||||
_rules.Clear();
|
||||
var triggerFile = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "triggers.json");
|
||||
|
||||
if (!File.Exists(triggerFile)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(triggerFile);
|
||||
var loaded = JsonSerializer.Deserialize<List<TriggerRule>>(json);
|
||||
if (loaded != null) _rules.AddRange(loaded.Where(r => r.Enabled));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"트리거 규칙 로드 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void StartFileWatcher()
|
||||
{
|
||||
var fileRules = _rules.Where(r => r.Type == "file_change").ToList();
|
||||
if (fileRules.Count == 0) return;
|
||||
|
||||
var workFolder = _settings.Settings.Llm.WorkFolder;
|
||||
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
|
||||
|
||||
_fileWatcher = new FileSystemWatcher(workFolder)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
|
||||
// 디바운스용 — 짧은 시간 내 여러 변경을 하나로 묶음
|
||||
DateTime lastTrigger = DateTime.MinValue;
|
||||
|
||||
_fileWatcher.Changed += (_, e) =>
|
||||
{
|
||||
if ((DateTime.Now - lastTrigger).TotalSeconds < 5) return;
|
||||
|
||||
foreach (var rule in fileRules)
|
||||
{
|
||||
if (MatchesPattern(e.FullPath, rule.Pattern))
|
||||
{
|
||||
lastTrigger = DateTime.Now;
|
||||
var prompt = rule.Prompt.Replace("{file}", e.FullPath).Replace("{name}", e.Name ?? "");
|
||||
System.Windows.Application.Current?.Dispatcher.BeginInvoke(() =>
|
||||
OnTriggerFired?.Invoke(rule.Name, prompt));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void StartScheduleTimer()
|
||||
{
|
||||
var scheduleRules = _rules.Where(r => r.Type == "schedule").ToList();
|
||||
if (scheduleRules.Count == 0) return;
|
||||
|
||||
_scheduleTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
|
||||
_scheduleTimer.Tick += (_, _) =>
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
foreach (var rule in scheduleRules)
|
||||
{
|
||||
if (ShouldRunSchedule(rule, now))
|
||||
{
|
||||
rule.LastRun = now;
|
||||
OnTriggerFired?.Invoke(rule.Name, rule.Prompt);
|
||||
}
|
||||
}
|
||||
};
|
||||
_scheduleTimer.Start();
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string filePath, string? pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern)) return true;
|
||||
// 간단한 확장자/폴더 패턴 매칭
|
||||
if (pattern.StartsWith("*."))
|
||||
return filePath.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||
return filePath.Contains(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldRunSchedule(TriggerRule rule, DateTime now)
|
||||
{
|
||||
if (rule.LastRun.HasValue && (now - rule.LastRun.Value).TotalMinutes < (rule.IntervalMinutes ?? 60))
|
||||
return false;
|
||||
// 시간 범위 체크 (근무 시간만)
|
||||
if (rule.ActiveHourStart.HasValue && now.Hour < rule.ActiveHourStart.Value) return false;
|
||||
if (rule.ActiveHourEnd.HasValue && now.Hour >= rule.ActiveHourEnd.Value) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>수동으로 트리거를 실행합니다.</summary>
|
||||
public void FireManual(string ruleName)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Name == ruleName);
|
||||
if (rule != null)
|
||||
OnTriggerFired?.Invoke(rule.Name, rule.Prompt);
|
||||
}
|
||||
|
||||
/// <summary>현재 로드된 트리거 규칙 목록.</summary>
|
||||
public IReadOnlyList<TriggerRule> Rules => _rules;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>트리거 규칙 정의.</summary>
|
||||
public class TriggerRule
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "file_change"; // file_change | schedule | git
|
||||
public string Prompt { get; set; } = "";
|
||||
public string? Pattern { get; set; } // file_change: 확장자/경로 패턴
|
||||
public int? IntervalMinutes { get; set; } // schedule: 실행 간격 (분)
|
||||
public int? ActiveHourStart { get; set; } // schedule: 활성 시작 시각 (0~23)
|
||||
public int? ActiveHourEnd { get; set; } // schedule: 활성 종료 시각
|
||||
public bool Enabled { get; set; } = true;
|
||||
public DateTime? LastRun { get; set; }
|
||||
}
|
||||
1014
src/AxCopilot/Services/AppStateService.cs
Normal file
1014
src/AxCopilot/Services/AppStateService.cs
Normal file
File diff suppressed because it is too large
Load Diff
141
src/AxCopilot/Services/AuditLogService.cs
Normal file
141
src/AxCopilot/Services/AuditLogService.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 도구 호출 이력을 로컬 JSON 파일로 영속화하는 보안 감사 로그 서비스.
|
||||
/// 파일 위치: %APPDATA%\AxCopilot\audit\{yyyy-MM-dd}.json
|
||||
/// </summary>
|
||||
public static class AuditLogService
|
||||
{
|
||||
private static readonly string AuditDir;
|
||||
private static readonly object _lock = new();
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
static AuditLogService()
|
||||
{
|
||||
AuditDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "audit");
|
||||
try { Directory.CreateDirectory(AuditDir); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>감사 로그 항목을 기록합니다.</summary>
|
||||
public static void Log(AuditEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = $"{DateTime.Now:yyyy-MM-dd}.json";
|
||||
var filePath = Path.Combine(AuditDir, fileName);
|
||||
var json = JsonSerializer.Serialize(entry, _jsonOpts);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(filePath, json + "\n", Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch { /* 감사 로그 실패는 무시 — 앱 동작에 영향 없음 */ }
|
||||
}
|
||||
|
||||
/// <summary>에이전트 도구 호출을 감사 로그에 기록합니다.</summary>
|
||||
public static void LogToolCall(string conversationId, string tab, string toolName,
|
||||
string parameters, string result, string? filePath, bool success)
|
||||
{
|
||||
Log(new AuditEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
Tab = tab,
|
||||
Action = "ToolCall",
|
||||
ToolName = toolName,
|
||||
Parameters = Truncate(parameters, 500),
|
||||
Result = Truncate(result, 500),
|
||||
FilePath = filePath,
|
||||
Success = success,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>파일 접근을 감사 로그에 기록합니다.</summary>
|
||||
public static void LogFileAccess(string conversationId, string tab, string action, string filePath, bool success)
|
||||
{
|
||||
Log(new AuditEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
Tab = tab,
|
||||
Action = action, // Read, Write, Delete, Execute
|
||||
FilePath = filePath,
|
||||
Success = success,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>오늘 감사 로그를 읽습니다.</summary>
|
||||
public static List<AuditEntry> LoadToday()
|
||||
{
|
||||
var fileName = $"{DateTime.Now:yyyy-MM-dd}.json";
|
||||
return LoadFile(Path.Combine(AuditDir, fileName));
|
||||
}
|
||||
|
||||
/// <summary>특정 날짜의 감사 로그를 읽습니다.</summary>
|
||||
public static List<AuditEntry> LoadDate(DateTime date)
|
||||
{
|
||||
var fileName = $"{date:yyyy-MM-dd}.json";
|
||||
return LoadFile(Path.Combine(AuditDir, fileName));
|
||||
}
|
||||
|
||||
private static List<AuditEntry> LoadFile(string filePath)
|
||||
{
|
||||
var entries = new List<AuditEntry>();
|
||||
if (!File.Exists(filePath)) return entries;
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadAllLines(filePath, Encoding.UTF8))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var entry = JsonSerializer.Deserialize<AuditEntry>(line, _jsonOpts);
|
||||
if (entry != null) entries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>30일 이전 감사 로그를 삭제합니다.</summary>
|
||||
public static void PurgeOldLogs(int retentionDays = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
foreach (var f in Directory.GetFiles(AuditDir, "*.json"))
|
||||
{
|
||||
if (File.GetCreationTime(f) < cutoff)
|
||||
File.Delete(f);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>감사 로그 폴더 경로를 반환합니다.</summary>
|
||||
public static string GetAuditFolder() => AuditDir;
|
||||
|
||||
private static string Truncate(string? s, int maxLen) =>
|
||||
string.IsNullOrEmpty(s) ? "" : s.Length <= maxLen ? s : s[..maxLen] + "…";
|
||||
}
|
||||
|
||||
/// <summary>감사 로그 항목.</summary>
|
||||
public class AuditEntry
|
||||
{
|
||||
public DateTime Timestamp { get; init; } = DateTime.Now;
|
||||
public string ConversationId { get; init; } = "";
|
||||
public string Tab { get; init; } = ""; // Chat, Cowork, Code
|
||||
public string Action { get; init; } = ""; // ToolCall, Read, Write, Delete, Execute
|
||||
public string ToolName { get; init; } = "";
|
||||
public string Parameters { get; init; } = "";
|
||||
public string Result { get; init; } = "";
|
||||
public string? FilePath { get; init; }
|
||||
public bool Success { get; init; } = true;
|
||||
}
|
||||
72
src/AxCopilot/Services/BackgroundJobService.cs
Normal file
72
src/AxCopilot/Services/BackgroundJobService.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 장시간 실행되거나 메인 응답 흐름과 분리된 작업을 앱 전역에서 추적합니다.
|
||||
/// 현재는 서브에이전트/분리 작업을 우선 배경 작업으로 취급합니다.
|
||||
/// </summary>
|
||||
public sealed class BackgroundJobService
|
||||
{
|
||||
public sealed class BackgroundJobState
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Kind { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Summary { get; set; } = "";
|
||||
public string Status { get; set; } = "running";
|
||||
public DateTime StartedAt { get; set; } = DateTime.Now;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
private readonly List<BackgroundJobState> _active = [];
|
||||
private readonly List<BackgroundJobState> _recent = [];
|
||||
|
||||
public IReadOnlyList<BackgroundJobState> ActiveJobs => _active;
|
||||
public IReadOnlyList<BackgroundJobState> RecentJobs => _recent;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public void Upsert(string id, string kind, string title, string summary, string status = "running")
|
||||
{
|
||||
var existing = _active.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing == null)
|
||||
{
|
||||
_active.Insert(0, new BackgroundJobState
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Status = status,
|
||||
StartedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Kind = kind;
|
||||
existing.Title = title;
|
||||
existing.Summary = summary;
|
||||
existing.Status = status;
|
||||
existing.UpdatedAt = DateTime.Now;
|
||||
}
|
||||
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Complete(string id, string? summary = null, string status = "completed")
|
||||
{
|
||||
var existing = _active.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing == null)
|
||||
return;
|
||||
|
||||
_active.Remove(existing);
|
||||
existing.Status = status;
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
existing.Summary = summary;
|
||||
existing.UpdatedAt = DateTime.Now;
|
||||
_recent.Insert(0, existing);
|
||||
if (_recent.Count > 20)
|
||||
_recent.RemoveRange(20, _recent.Count - 20);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
677
src/AxCopilot/Services/ChatSessionStateService.cs
Normal file
677
src/AxCopilot/Services/ChatSessionStateService.cs
Normal file
@@ -0,0 +1,677 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 채팅 창 외부에 유지되는 세션 상태.
|
||||
/// 활성 탭과 탭별 마지막 대화 ID를 관리해 창 재생성/재로딩에도 상태를 복원합니다.
|
||||
/// </summary>
|
||||
public sealed class ChatSessionStateService
|
||||
{
|
||||
private const int MaxExecutionEventHistory = 400;
|
||||
private const int MaxAgentRunHistory = 12;
|
||||
private readonly DraftQueueService _draftQueue = new();
|
||||
|
||||
public sealed class RuntimeActivity
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Summary { get; set; } = "";
|
||||
public DateTime StartedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
public string ActiveTab { get; set; } = "Chat";
|
||||
public ChatConversation? CurrentConversation { get; set; }
|
||||
public bool IsStreaming { get; set; }
|
||||
public string StatusText { get; set; } = "대기 중";
|
||||
public bool IsStatusSpinning { get; set; }
|
||||
public string LastCompletedSummary { get; set; } = "";
|
||||
public List<RuntimeActivity> ActiveRuntimeActivities { get; } = new();
|
||||
|
||||
public Dictionary<string, string?> TabConversationIds { get; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Chat"] = null,
|
||||
["Cowork"] = null,
|
||||
["Code"] = null,
|
||||
};
|
||||
|
||||
public ChatConversation EnsureCurrentConversation(string tab)
|
||||
{
|
||||
var normalizedTab = NormalizeTab(tab);
|
||||
if (CurrentConversation == null
|
||||
|| !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
CurrentConversation = new ChatConversation { Tab = normalizedTab };
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(CurrentConversation.Tab)
|
||||
|| !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
|
||||
CurrentConversation.Tab = normalizedTab;
|
||||
return CurrentConversation;
|
||||
}
|
||||
|
||||
public void Load(SettingsService settings)
|
||||
{
|
||||
var llm = settings.Settings.Llm;
|
||||
ActiveTab = NormalizeTab(llm.LastActiveTab);
|
||||
|
||||
foreach (var key in TabConversationIds.Keys.ToList())
|
||||
TabConversationIds[key] = null;
|
||||
|
||||
foreach (var kv in llm.LastConversationIds)
|
||||
{
|
||||
var normalized = NormalizeTab(kv.Key);
|
||||
if (TabConversationIds.ContainsKey(normalized) && !string.IsNullOrWhiteSpace(kv.Value))
|
||||
TabConversationIds[normalized] = kv.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(SettingsService settings)
|
||||
{
|
||||
var llm = settings.Settings.Llm;
|
||||
llm.LastActiveTab = NormalizeTab(ActiveTab);
|
||||
|
||||
var snapshot = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in TabConversationIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(kv.Value))
|
||||
snapshot[kv.Key] = kv.Value!;
|
||||
}
|
||||
llm.LastConversationIds = snapshot;
|
||||
settings.Save();
|
||||
}
|
||||
|
||||
public void RememberConversation(string tab, string? conversationId)
|
||||
{
|
||||
TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId;
|
||||
}
|
||||
|
||||
public ChatConversation LoadOrCreateConversation(string tab, ChatStorageService storage, SettingsService settings)
|
||||
{
|
||||
var normalizedTab = NormalizeTab(tab);
|
||||
var rememberedId = GetConversationId(normalizedTab);
|
||||
if (!string.IsNullOrWhiteSpace(rememberedId))
|
||||
{
|
||||
var loaded = storage.Load(rememberedId);
|
||||
if (loaded != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(loaded.Tab))
|
||||
loaded.Tab = normalizedTab;
|
||||
|
||||
if (string.Equals(NormalizeTab(loaded.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var normalized = NormalizeLoadedConversation(loaded);
|
||||
CurrentConversation = loaded;
|
||||
if (normalized)
|
||||
try { storage.Save(loaded); } catch { }
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// 잘못 매핑된 탭 ID 방어: 교차 탭 대화 누적 방지
|
||||
RememberConversation(normalizedTab, null);
|
||||
}
|
||||
else
|
||||
RememberConversation(normalizedTab, null);
|
||||
}
|
||||
|
||||
return CreateFreshConversation(normalizedTab, settings);
|
||||
}
|
||||
|
||||
public ChatConversation CreateFreshConversation(string tab, SettingsService settings)
|
||||
{
|
||||
var normalizedTab = NormalizeTab(tab);
|
||||
var created = new ChatConversation { Tab = normalizedTab };
|
||||
var workFolder = settings.Settings.Llm.WorkFolder;
|
||||
if (!string.IsNullOrWhiteSpace(workFolder) && normalizedTab != "Chat")
|
||||
created.WorkFolder = workFolder;
|
||||
|
||||
CurrentConversation = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
public ChatConversation CreateBranchConversation(
|
||||
ChatConversation source,
|
||||
int atIndex,
|
||||
int branchCount,
|
||||
string? branchHint = null,
|
||||
string? branchContextMessage = null,
|
||||
string? branchContextRunId = null)
|
||||
{
|
||||
var branchLabel = string.IsNullOrWhiteSpace(branchHint)
|
||||
? $"분기 {branchCount}"
|
||||
: $"분기 {branchCount} · {Truncate(branchHint, 18)}";
|
||||
|
||||
var fork = new ChatConversation
|
||||
{
|
||||
Title = $"{source.Title} ({branchLabel})",
|
||||
Tab = source.Tab,
|
||||
Category = source.Category,
|
||||
WorkFolder = source.WorkFolder,
|
||||
SystemCommand = source.SystemCommand,
|
||||
ParentId = source.Id,
|
||||
BranchLabel = branchLabel,
|
||||
BranchAtIndex = atIndex,
|
||||
ConversationFailedOnlyFilter = source.ConversationFailedOnlyFilter,
|
||||
ConversationRunningOnlyFilter = source.ConversationRunningOnlyFilter,
|
||||
ConversationSortMode = source.ConversationSortMode,
|
||||
AgentRunHistory = source.AgentRunHistory
|
||||
.Select(x => new ChatAgentRunRecord
|
||||
{
|
||||
RunId = x.RunId,
|
||||
Status = x.Status,
|
||||
Summary = x.Summary,
|
||||
LastIteration = x.LastIteration,
|
||||
StartedAt = x.StartedAt,
|
||||
UpdatedAt = x.UpdatedAt,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
for (var i = 0; i <= atIndex && i < source.Messages.Count; i++)
|
||||
{
|
||||
var m = source.Messages[i];
|
||||
fork.Messages.Add(new ChatMessage
|
||||
{
|
||||
Role = m.Role,
|
||||
Content = m.Content,
|
||||
Timestamp = m.Timestamp,
|
||||
MetaKind = m.MetaKind,
|
||||
MetaRunId = m.MetaRunId,
|
||||
Feedback = m.Feedback,
|
||||
AttachedFiles = m.AttachedFiles?.ToList(),
|
||||
Images = m.Images?.Select(img => new ImageAttachment
|
||||
{
|
||||
FileName = img.FileName,
|
||||
MimeType = img.MimeType,
|
||||
Base64 = img.Base64,
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(branchContextMessage))
|
||||
{
|
||||
fork.Messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = branchContextMessage.Trim(),
|
||||
Timestamp = DateTime.Now,
|
||||
MetaKind = "branch_context",
|
||||
MetaRunId = branchContextRunId,
|
||||
});
|
||||
}
|
||||
|
||||
return fork;
|
||||
}
|
||||
|
||||
public void SaveCurrentConversation(ChatStorageService storage, string tab)
|
||||
{
|
||||
var conv = CurrentConversation;
|
||||
if (conv == null) return;
|
||||
|
||||
try { storage.Save(conv); } catch { }
|
||||
var conversationTab = NormalizeTab(conv.Tab);
|
||||
if (conv.Messages.Count > 0
|
||||
|| (conv.ExecutionEvents?.Count ?? 0) > 0
|
||||
|| (conv.AgentRunHistory?.Count ?? 0) > 0
|
||||
|| (conv.DraftQueueItems?.Count ?? 0) > 0)
|
||||
RememberConversation(conversationTab, conv.Id);
|
||||
}
|
||||
|
||||
public void ClearCurrentConversation(string tab)
|
||||
{
|
||||
CurrentConversation = null;
|
||||
RememberConversation(tab, null);
|
||||
}
|
||||
|
||||
public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, ChatStorageService? storage = null, bool remember = true)
|
||||
{
|
||||
var normalizedTab = NormalizeTab(tab);
|
||||
conversation.Tab = normalizedTab;
|
||||
NormalizeLoadedConversation(conversation);
|
||||
|
||||
CurrentConversation = conversation;
|
||||
if (remember && !string.IsNullOrWhiteSpace(conversation.Id))
|
||||
RememberConversation(normalizedTab, conversation.Id);
|
||||
|
||||
try { storage?.Save(conversation); } catch { }
|
||||
return conversation;
|
||||
}
|
||||
|
||||
public ChatMessage AppendMessage(string tab, ChatMessage message, ChatStorageService? storage = null, bool useForTitle = false)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.Messages.Add(message);
|
||||
|
||||
if (useForTitle && string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var userMessageCount = conv.Messages.Count(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
|
||||
if (userMessageCount == 1 && !string.IsNullOrWhiteSpace(message.Content))
|
||||
conv.Title = message.Content.Length > 30 ? message.Content[..30] + "…" : message.Content;
|
||||
}
|
||||
|
||||
TouchConversation(storage, tab);
|
||||
return message;
|
||||
}
|
||||
|
||||
public ChatConversation UpdateConversationMetadata(
|
||||
string tab,
|
||||
Action<ChatConversation> apply,
|
||||
ChatStorageService? storage = null,
|
||||
bool ensureConversation = true)
|
||||
{
|
||||
var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) });
|
||||
apply(conv);
|
||||
if (CurrentConversation == null || ensureConversation)
|
||||
CurrentConversation = conv;
|
||||
TouchConversation(storage, tab);
|
||||
return conv;
|
||||
}
|
||||
|
||||
public ChatConversation SaveConversationSettings(
|
||||
string tab,
|
||||
string? permission,
|
||||
string? dataUsage,
|
||||
string? outputFormat,
|
||||
string? mood,
|
||||
ChatStorageService? storage = null)
|
||||
{
|
||||
return UpdateConversationMetadata(tab, conv =>
|
||||
{
|
||||
conv.Permission = permission;
|
||||
conv.DataUsage = dataUsage;
|
||||
conv.OutputFormat = outputFormat;
|
||||
conv.Mood = mood;
|
||||
}, storage);
|
||||
}
|
||||
|
||||
public bool RemoveLastAssistantMessage(string tab, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = CurrentConversation;
|
||||
if (conv == null || conv.Messages.Count == 0)
|
||||
return false;
|
||||
|
||||
if (!string.Equals(conv.Messages[^1].Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||||
TouchConversation(storage, tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = CurrentConversation;
|
||||
if (conv == null)
|
||||
return false;
|
||||
|
||||
if (userMessageIndex < 0 || userMessageIndex >= conv.Messages.Count)
|
||||
return false;
|
||||
|
||||
conv.Messages[userMessageIndex].Content = newText;
|
||||
while (conv.Messages.Count > userMessageIndex + 1)
|
||||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||||
|
||||
TouchConversation(storage, tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = CurrentConversation;
|
||||
if (conv == null)
|
||||
return false;
|
||||
|
||||
var index = conv.Messages.IndexOf(message);
|
||||
if (index < 0)
|
||||
return false;
|
||||
|
||||
conv.Messages[index].Feedback = string.IsNullOrWhiteSpace(feedback) ? null : feedback;
|
||||
TouchConversation(storage, tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.ExecutionEvents ??= new List<ChatExecutionEvent>();
|
||||
var nextEvent = new ChatExecutionEvent
|
||||
{
|
||||
Timestamp = evt.Timestamp,
|
||||
RunId = evt.RunId,
|
||||
Type = evt.Type.ToString(),
|
||||
ToolName = evt.ToolName,
|
||||
Summary = evt.Summary,
|
||||
FilePath = evt.FilePath,
|
||||
Success = evt.Success,
|
||||
StepCurrent = evt.StepCurrent,
|
||||
StepTotal = evt.StepTotal,
|
||||
Steps = evt.Steps?.ToList(),
|
||||
ElapsedMs = evt.ElapsedMs,
|
||||
InputTokens = evt.InputTokens,
|
||||
OutputTokens = evt.OutputTokens,
|
||||
};
|
||||
|
||||
if (conv.ExecutionEvents.Count > 0
|
||||
&& IsNearDuplicateExecutionEvent(conv.ExecutionEvents[^1], nextEvent))
|
||||
{
|
||||
var existing = conv.ExecutionEvents[^1];
|
||||
existing.Timestamp = nextEvent.Timestamp;
|
||||
existing.ElapsedMs = Math.Max(existing.ElapsedMs, nextEvent.ElapsedMs);
|
||||
existing.InputTokens = Math.Max(existing.InputTokens, nextEvent.InputTokens);
|
||||
existing.OutputTokens = Math.Max(existing.OutputTokens, nextEvent.OutputTokens);
|
||||
existing.StepCurrent = Math.Max(existing.StepCurrent, nextEvent.StepCurrent);
|
||||
existing.StepTotal = Math.Max(existing.StepTotal, nextEvent.StepTotal);
|
||||
existing.Steps = nextEvent.Steps ?? existing.Steps;
|
||||
existing.Success = existing.Success && nextEvent.Success;
|
||||
if (string.IsNullOrWhiteSpace(existing.FilePath))
|
||||
existing.FilePath = nextEvent.FilePath;
|
||||
if (!string.IsNullOrWhiteSpace(nextEvent.Summary) && nextEvent.Summary.Length >= existing.Summary.Length)
|
||||
existing.Summary = nextEvent.Summary;
|
||||
}
|
||||
else
|
||||
{
|
||||
conv.ExecutionEvents.Add(nextEvent);
|
||||
}
|
||||
|
||||
if (conv.ExecutionEvents.Count > MaxExecutionEventHistory)
|
||||
conv.ExecutionEvents.RemoveRange(0, conv.ExecutionEvents.Count - MaxExecutionEventHistory);
|
||||
TouchConversation(storage, tab);
|
||||
return conv;
|
||||
}
|
||||
|
||||
public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.AgentRunHistory ??= new List<ChatAgentRunRecord>();
|
||||
var newRecord = new ChatAgentRunRecord
|
||||
{
|
||||
RunId = evt.RunId,
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
LastIteration = evt.Iteration,
|
||||
StartedAt = evt.Timestamp,
|
||||
UpdatedAt = DateTime.Now,
|
||||
};
|
||||
|
||||
var existingIndex = conv.AgentRunHistory.FindIndex(x =>
|
||||
!string.IsNullOrWhiteSpace(x.RunId) &&
|
||||
string.Equals(x.RunId, newRecord.RunId, StringComparison.OrdinalIgnoreCase));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
conv.AgentRunHistory.RemoveAt(existingIndex);
|
||||
conv.AgentRunHistory.Insert(0, newRecord);
|
||||
}
|
||||
else
|
||||
conv.AgentRunHistory.Insert(0, newRecord);
|
||||
|
||||
if (conv.AgentRunHistory.Count > MaxAgentRunHistory)
|
||||
conv.AgentRunHistory.RemoveRange(MaxAgentRunHistory, conv.AgentRunHistory.Count - MaxAgentRunHistory);
|
||||
TouchConversation(storage, tab);
|
||||
return conv;
|
||||
}
|
||||
|
||||
public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", ChatStorageService? storage = null)
|
||||
{
|
||||
var trimmed = text?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
return null;
|
||||
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.DraftQueueItems ??= new List<DraftQueueItem>();
|
||||
var item = _draftQueue.CreateItem(trimmed, priority);
|
||||
conv.DraftQueueItems.Add(item);
|
||||
TouchConversation(storage, tab);
|
||||
return item;
|
||||
}
|
||||
|
||||
public DraftQueueItem? GetNextQueuedDraft(string tab)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
return _draftQueue.GetNextQueuedItem(conv.DraftQueueItems);
|
||||
}
|
||||
|
||||
public DraftQueueService.DraftQueueSummary GetDraftQueueSummary(string tab)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
return _draftQueue.GetSummary(conv.DraftQueueItems);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
return (conv.DraftQueueItems ?? []).ToList();
|
||||
}
|
||||
|
||||
public bool MarkDraftRunning(string tab, string draftId, ChatStorageService? storage = null)
|
||||
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage);
|
||||
|
||||
public bool MarkDraftCompleted(string tab, string draftId, ChatStorageService? storage = null)
|
||||
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage);
|
||||
|
||||
public bool MarkDraftFailed(string tab, string draftId, string? error, ChatStorageService? storage = null)
|
||||
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage);
|
||||
|
||||
public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, ChatStorageService? storage = null)
|
||||
{
|
||||
return UpdateDraftItem(tab, draftId, item =>
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
if (item.AttemptCount >= maxAutoRetries)
|
||||
return _draftQueue.MarkFailed(item, error);
|
||||
|
||||
return _draftQueue.ScheduleRetry(item, error);
|
||||
}, storage);
|
||||
}
|
||||
|
||||
public bool ResetDraftToQueued(string tab, string draftId, ChatStorageService? storage = null)
|
||||
=> UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage);
|
||||
|
||||
public bool RemoveDraft(string tab, string draftId, ChatStorageService? storage = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draftId))
|
||||
return false;
|
||||
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
var removed = conv.DraftQueueItems?.RemoveAll(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase)) ?? 0;
|
||||
if (removed <= 0)
|
||||
return false;
|
||||
|
||||
TouchConversation(storage, tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ToggleExecutionHistory(string tab, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.ShowExecutionHistory = !conv.ShowExecutionHistory;
|
||||
TouchConversation(storage, tab);
|
||||
return conv.ShowExecutionHistory;
|
||||
}
|
||||
|
||||
public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, ChatStorageService? storage = null)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.ConversationFailedOnlyFilter = failedOnly;
|
||||
conv.ConversationRunningOnlyFilter = runningOnly;
|
||||
conv.ConversationSortMode = sortByRecent ? "recent" : "activity";
|
||||
TouchConversation(storage, tab);
|
||||
}
|
||||
|
||||
public void SetRuntimeState(bool isStreaming, string statusText, bool isStatusSpinning)
|
||||
{
|
||||
IsStreaming = isStreaming;
|
||||
StatusText = string.IsNullOrWhiteSpace(statusText) ? "대기 중" : statusText;
|
||||
IsStatusSpinning = isStatusSpinning;
|
||||
}
|
||||
|
||||
public void UpsertRuntimeActivity(string key, string kind, string title, string summary)
|
||||
{
|
||||
var existing = ActiveRuntimeActivities.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing == null)
|
||||
{
|
||||
ActiveRuntimeActivities.Add(new RuntimeActivity
|
||||
{
|
||||
Key = key,
|
||||
Kind = kind,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
StartedAt = DateTime.Now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.Title = title;
|
||||
existing.Summary = summary;
|
||||
}
|
||||
|
||||
public void RemoveRuntimeActivity(string key, string? completionSummary = null)
|
||||
{
|
||||
ActiveRuntimeActivities.RemoveAll(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(completionSummary))
|
||||
LastCompletedSummary = completionSummary;
|
||||
}
|
||||
|
||||
public void ClearRuntimeActivities(string? completionSummary = null)
|
||||
{
|
||||
ActiveRuntimeActivities.Clear();
|
||||
if (!string.IsNullOrWhiteSpace(completionSummary))
|
||||
LastCompletedSummary = completionSummary;
|
||||
}
|
||||
|
||||
public string? GetConversationId(string tab)
|
||||
{
|
||||
TabConversationIds.TryGetValue(NormalizeTab(tab), out var value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string NormalizeTab(string? tab)
|
||||
{
|
||||
var normalized = (tab ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
return "Chat";
|
||||
|
||||
if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase))
|
||||
return "Cowork";
|
||||
|
||||
var canonical = new string(normalized
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray())
|
||||
.ToLowerInvariant();
|
||||
|
||||
if (canonical is "cowork" or "coworkcode" or "coworkcodetab")
|
||||
return "Cowork";
|
||||
|
||||
if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase)
|
||||
|| canonical is "code" or "codetab")
|
||||
return "Code";
|
||||
|
||||
return "Chat";
|
||||
}
|
||||
|
||||
private static bool NormalizeLoadedConversation(ChatConversation conversation)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
conversation.Messages ??= new List<ChatMessage>();
|
||||
conversation.ExecutionEvents ??= new List<ChatExecutionEvent>();
|
||||
conversation.AgentRunHistory ??= new List<ChatAgentRunRecord>();
|
||||
conversation.DraftQueueItems ??= new List<DraftQueueItem>();
|
||||
|
||||
var orderedEvents = conversation.ExecutionEvents
|
||||
.OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp)
|
||||
.ToList();
|
||||
if (!conversation.ExecutionEvents.SequenceEqual(orderedEvents))
|
||||
{
|
||||
conversation.ExecutionEvents = orderedEvents;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (conversation.ExecutionEvents.Count > MaxExecutionEventHistory)
|
||||
{
|
||||
conversation.ExecutionEvents = conversation.ExecutionEvents
|
||||
.Skip(conversation.ExecutionEvents.Count - MaxExecutionEventHistory)
|
||||
.ToList();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var orderedRuns = conversation.AgentRunHistory
|
||||
.GroupBy(run => string.IsNullOrWhiteSpace(run.RunId) ? Guid.NewGuid().ToString("N") : run.RunId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group
|
||||
.OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt)
|
||||
.First())
|
||||
.OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt)
|
||||
.ToList();
|
||||
if (!conversation.AgentRunHistory.SequenceEqual(orderedRuns))
|
||||
{
|
||||
conversation.AgentRunHistory = orderedRuns;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (conversation.AgentRunHistory.Count > MaxAgentRunHistory)
|
||||
{
|
||||
conversation.AgentRunHistory = conversation.AgentRunHistory
|
||||
.Take(MaxAgentRunHistory)
|
||||
.ToList();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool IsNearDuplicateExecutionEvent(ChatExecutionEvent left, ChatExecutionEvent right)
|
||||
{
|
||||
if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal))
|
||||
return false;
|
||||
if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (left.Success != right.Success)
|
||||
return false;
|
||||
|
||||
var delta = (right.Timestamp - left.Timestamp).Duration();
|
||||
return delta <= TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
private void TouchConversation(ChatStorageService? storage, string tab)
|
||||
{
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
conv.UpdatedAt = DateTime.Now;
|
||||
var conversationTab = NormalizeTab(conv.Tab);
|
||||
if (!string.IsNullOrWhiteSpace(conv.Id))
|
||||
RememberConversation(conversationTab, conv.Id);
|
||||
try { storage?.Save(conv); } catch { }
|
||||
}
|
||||
|
||||
private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, ChatStorageService? storage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draftId))
|
||||
return false;
|
||||
|
||||
var conv = EnsureCurrentConversation(tab);
|
||||
var item = conv.DraftQueueItems?.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
if (!update(item))
|
||||
return false;
|
||||
|
||||
TouchConversation(storage, tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Truncate(string? text, int max)
|
||||
{
|
||||
var value = text?.Trim() ?? "";
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= max)
|
||||
return value;
|
||||
return value[..max] + "…";
|
||||
}
|
||||
}
|
||||
329
src/AxCopilot/Services/ChatStorageService.cs
Normal file
329
src/AxCopilot/Services/ChatStorageService.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 대화 내역을 로컬에 AES-256-GCM 암호화하여 저장/로드합니다.
|
||||
/// 파일 형식: conversations/{id}.axchat (암호화 바이너리)
|
||||
/// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호.
|
||||
/// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지.
|
||||
/// </summary>
|
||||
public class ChatStorageService
|
||||
{
|
||||
private static readonly string ConversationsDir =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "conversations");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly ReaderWriterLockSlim Lock = new();
|
||||
|
||||
public ChatStorageService()
|
||||
{
|
||||
Directory.CreateDirectory(ConversationsDir);
|
||||
}
|
||||
|
||||
/// <summary>대화를 암호화하여 저장합니다 (원자적 쓰기).</summary>
|
||||
public void Save(ChatConversation conversation)
|
||||
{
|
||||
conversation.UpdatedAt = DateTime.Now;
|
||||
// 검색용 미리보기 자동 갱신 (첫 사용자 메시지 100자)
|
||||
if (string.IsNullOrEmpty(conversation.Preview) && conversation.Messages.Count > 0)
|
||||
{
|
||||
var firstUser = conversation.Messages.FirstOrDefault(m => m.Role == "user");
|
||||
if (firstUser != null)
|
||||
conversation.Preview = firstUser.Content.Length > 100
|
||||
? firstUser.Content[..100] : firstUser.Content;
|
||||
}
|
||||
var json = JsonSerializer.Serialize(conversation, JsonOpts);
|
||||
var path = GetFilePath(conversation.Id);
|
||||
var tempPath = path + ".tmp";
|
||||
|
||||
Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
CryptoService.EncryptToFile(tempPath, json);
|
||||
// 원자적 교체: 기존 파일이 있으면 덮어쓰기
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
File.Move(tempPath, path);
|
||||
UpdateMetaCache(conversation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 임시 파일 정리
|
||||
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
|
||||
LogService.Warn($"대화 저장 실패 ({conversation.Id}): {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>대화를 복호화하여 로드합니다.</summary>
|
||||
public ChatConversation? Load(string id)
|
||||
{
|
||||
var path = GetFilePath(id);
|
||||
Lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
var json = CryptoService.DecryptFromFile(path);
|
||||
return JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"대화 로드 실패 ({id}): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 메타 캐시 ─────────────────────────────────────────────────────────
|
||||
private List<ChatConversation>? _metaCache;
|
||||
private bool _metaDirty = true;
|
||||
|
||||
/// <summary>메타 캐시를 무효화합니다. 다음 LoadAllMeta 호출 시 디스크에서 다시 읽습니다.</summary>
|
||||
public void InvalidateMetaCache() => _metaDirty = true;
|
||||
|
||||
/// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary>
|
||||
public void UpdateMetaCache(ChatConversation conv)
|
||||
{
|
||||
if (_metaCache == null) return;
|
||||
var existing = _metaCache.FindIndex(c => c.Id == conv.Id);
|
||||
var meta = new ChatConversation
|
||||
{
|
||||
Id = conv.Id, Title = conv.Title,
|
||||
CreatedAt = conv.CreatedAt, UpdatedAt = conv.UpdatedAt,
|
||||
Pinned = conv.Pinned, Category = conv.Category,
|
||||
Tab = conv.Tab, SystemCommand = conv.SystemCommand,
|
||||
WorkFolder = conv.WorkFolder, Preview = conv.Preview,
|
||||
Messages = new(),
|
||||
ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new()
|
||||
};
|
||||
if (existing >= 0)
|
||||
_metaCache[existing] = meta;
|
||||
else
|
||||
_metaCache.Add(meta);
|
||||
}
|
||||
|
||||
/// <summary>메타 캐시에서 항목을 제거합니다.</summary>
|
||||
public void RemoveFromMetaCache(string id)
|
||||
{
|
||||
_metaCache?.RemoveAll(c => c.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>모든 대화의 메타 정보(메시지 미포함)를 로드합니다. 캐시를 사용합니다.</summary>
|
||||
public List<ChatConversation> LoadAllMeta()
|
||||
{
|
||||
if (!_metaDirty && _metaCache != null)
|
||||
{
|
||||
return _metaCache.OrderByDescending(c => c.Pinned)
|
||||
.ThenByDescending(c => c.UpdatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = new List<ChatConversation>();
|
||||
if (!Directory.Exists(ConversationsDir))
|
||||
{
|
||||
_metaCache = result;
|
||||
_metaDirty = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
Lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = CryptoService.DecryptFromFile(file);
|
||||
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
|
||||
if (conv != null)
|
||||
{
|
||||
var meta = new ChatConversation
|
||||
{
|
||||
Id = conv.Id,
|
||||
Title = conv.Title,
|
||||
CreatedAt = conv.CreatedAt,
|
||||
UpdatedAt = conv.UpdatedAt,
|
||||
Pinned = conv.Pinned,
|
||||
Category = conv.Category,
|
||||
Tab = conv.Tab,
|
||||
SystemCommand = conv.SystemCommand,
|
||||
WorkFolder = conv.WorkFolder,
|
||||
Preview = conv.Preview,
|
||||
Messages = new(),
|
||||
ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new()
|
||||
};
|
||||
result.Add(meta);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"대화 메타 로드 실패 ({Path.GetFileName(file)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitReadLock();
|
||||
}
|
||||
|
||||
_metaCache = result;
|
||||
_metaDirty = false;
|
||||
|
||||
return result.OrderByDescending(c => c.Pinned)
|
||||
.ThenByDescending(c => c.UpdatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>특정 대화를 삭제합니다.</summary>
|
||||
public void Delete(string id)
|
||||
{
|
||||
var path = GetFilePath(id);
|
||||
Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
RemoveFromMetaCache(id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"대화 삭제 실패 ({id}): {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>모든 대화를 삭제합니다.</summary>
|
||||
public int DeleteAll()
|
||||
{
|
||||
if (!Directory.Exists(ConversationsDir)) return 0;
|
||||
Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(ConversationsDir, "*.axchat");
|
||||
int count = 0;
|
||||
foreach (var f in files)
|
||||
{
|
||||
try { File.Delete(f); count++; }
|
||||
catch (Exception ex) { LogService.Warn($"파일 삭제 실패 ({Path.GetFileName(f)}): {ex.Message}"); }
|
||||
}
|
||||
InvalidateMetaCache();
|
||||
return count;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외).</summary>
|
||||
public int PurgeExpired(int retentionDays)
|
||||
{
|
||||
if (retentionDays <= 0) return 0;
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
int count = 0;
|
||||
|
||||
Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = CryptoService.DecryptFromFile(file);
|
||||
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
|
||||
if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff)
|
||||
{
|
||||
File.Delete(file);
|
||||
RemoveFromMetaCache(conv.Id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"만료 대화 정리 실패 ({Path.GetFileName(file)}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitWriteLock();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>드라이브 사용률이 98% 이상이면 오래된 대화부터 삭제합니다 (핀 고정 제외).</summary>
|
||||
public int PurgeForDiskSpace(double threshold = 0.98)
|
||||
{
|
||||
try
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(ConversationsDir) ?? "C");
|
||||
if (drive.TotalSize <= 0) return 0;
|
||||
var usageRatio = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize;
|
||||
if (usageRatio < threshold) return 0;
|
||||
|
||||
LogService.Info($"드라이브 사용률 {usageRatio:P1} — 대화 정리 시작 (임계값: {threshold:P0})");
|
||||
|
||||
// 오래된 순으로 정렬하여 삭제 (핀 고정 제외)
|
||||
var files = Directory.GetFiles(ConversationsDir, "*.axchat")
|
||||
.Select(f => new { Path = f, LastWrite = File.GetLastWriteTime(f) })
|
||||
.OrderBy(f => f.LastWrite)
|
||||
.ToList();
|
||||
|
||||
int count = 0;
|
||||
Lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
// 사용률이 임계값 미만으로 내려가면 중단
|
||||
var currentUsage = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize;
|
||||
if (currentUsage < threshold - 0.02) break; // 2% 여유 확보 후 중단
|
||||
|
||||
try
|
||||
{
|
||||
var json = CryptoService.DecryptFromFile(file.Path);
|
||||
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
|
||||
if (conv != null && conv.Pinned) continue; // 핀 고정 건너뜀
|
||||
|
||||
File.Delete(file.Path);
|
||||
count++;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
LogService.Info($"드라이브 용량 부족으로 대화 {count}개 삭제 완료");
|
||||
return count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"디스크 용량 확인 실패: {ex.Message}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFilePath(string id) => Path.Combine(ConversationsDir, $"{id}.axchat");
|
||||
}
|
||||
575
src/AxCopilot/Services/ClipboardHistoryService.cs
Normal file
575
src/AxCopilot/Services/ClipboardHistoryService.cs
Normal file
@@ -0,0 +1,575 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media.Imaging;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 클립보드 변경을 감지하여 히스토리를 관리합니다.
|
||||
/// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다.
|
||||
/// </summary>
|
||||
public class ClipboardHistoryService : IDisposable
|
||||
{
|
||||
private const int WM_CLIPBOARDUPDATE = 0x031D;
|
||||
|
||||
private static readonly string HistoryPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "clipboard_history.dat");
|
||||
|
||||
// 구버전 평문 파일 경로 (마이그레이션용)
|
||||
private static readonly string LegacyPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "clipboard_history.json");
|
||||
|
||||
/// <summary>원본 이미지 캐시 폴더 (%APPDATA%\AxCopilot\clipboard_images\)</summary>
|
||||
private static readonly string ImageCachePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "clipboard_images");
|
||||
|
||||
private const long MaxCacheSizeBytes = 500 * 1024 * 1024; // 500MB
|
||||
private const int MaxCacheAgeDays = 30;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly SettingsService _settings;
|
||||
private HwndSource? _msgSource;
|
||||
private readonly object _lock = new();
|
||||
private volatile bool _ignoreNext; // 자체 클립보드 조작 시 히스토리 추가 방지
|
||||
private bool _disposed;
|
||||
|
||||
private readonly List<ClipboardEntry> _history = new();
|
||||
|
||||
public IReadOnlyList<ClipboardEntry> History
|
||||
{
|
||||
get { lock (_lock) return _history.ToList(); }
|
||||
}
|
||||
|
||||
public event EventHandler? HistoryChanged;
|
||||
|
||||
public ClipboardHistoryService(SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>메시지 창을 생성하고 클립보드 알림을 등록합니다.</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (_msgSource != null) return; // 이미 초기화됨
|
||||
if (!_settings.Settings.ClipboardHistory.Enabled) return;
|
||||
|
||||
// 저장된 히스토리 복원 (텍스트 항목만)
|
||||
LoadHistory();
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var sourceParams = new HwndSourceParameters("axClipboardMonitor")
|
||||
{
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
ParentWindow = new IntPtr(-3), // HWND_MESSAGE — 화면에 표시 안 됨
|
||||
WindowStyle = 0,
|
||||
ExtendedWindowStyle = 0
|
||||
};
|
||||
_msgSource = new HwndSource(sourceParams);
|
||||
_msgSource.AddHook(WndProc);
|
||||
AddClipboardFormatListener(_msgSource.Handle);
|
||||
LogService.Info("클립보드 히스토리 서비스 시작");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>자체 클립보드 조작(히스토리 붙여넣기) 시 히스토리가 중복 추가되지 않도록 플래그 설정.</summary>
|
||||
public void SuppressNextCapture() => _ignoreNext = true;
|
||||
|
||||
/// <summary>항목을 사용했을 때 CopiedAt을 현재 시각으로 갱신하고 목록 맨 위로 이동합니다.</summary>
|
||||
public void PromoteEntry(ClipboardEntry entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_history.Remove(entry);
|
||||
var updated = new ClipboardEntry(entry.Text, DateTime.Now)
|
||||
{
|
||||
Image = entry.Image,
|
||||
OriginalImagePath = entry.OriginalImagePath,
|
||||
IsPinned = entry.IsPinned,
|
||||
Category = entry.Category,
|
||||
};
|
||||
_history.Insert(0, updated);
|
||||
}
|
||||
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
||||
_ = SaveHistoryAsync();
|
||||
}
|
||||
|
||||
public void ClearHistory()
|
||||
{
|
||||
lock (_lock) _history.Clear();
|
||||
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
||||
_ = SaveHistoryAsync();
|
||||
}
|
||||
|
||||
/// <summary>항목의 핀 고정을 토글합니다.</summary>
|
||||
public void TogglePin(ClipboardEntry entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!entry.IsPinned)
|
||||
{
|
||||
// 최대 핀 개수 체크
|
||||
var maxPins = _settings.Settings.Launcher.MaxPinnedClipboardItems;
|
||||
var currentPins = _history.Count(e => e.IsPinned);
|
||||
if (currentPins >= maxPins) return; // 최대 도달 시 무시
|
||||
}
|
||||
entry.IsPinned = !entry.IsPinned;
|
||||
}
|
||||
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
||||
_ = SaveHistoryAsync();
|
||||
}
|
||||
|
||||
/// <summary>텍스트 내용에서 카테고리를 자동 감지합니다.</summary>
|
||||
private static string DetectCategory(string text)
|
||||
{
|
||||
var trimmed = text.Trim();
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == "http" || uri.Scheme == "https"))
|
||||
return "URL";
|
||||
if (trimmed.StartsWith("\\\\") || (trimmed.Length >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/')))
|
||||
return "경로";
|
||||
if (trimmed.Contains('{') && trimmed.Contains('}') || trimmed.Contains("function ") ||
|
||||
trimmed.Contains("class ") || trimmed.Contains("public ") || trimmed.Contains("private ") ||
|
||||
trimmed.Contains("def ") || trimmed.Contains("import ") || trimmed.Contains("using "))
|
||||
return "코드";
|
||||
return "일반";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 클립보드 감지가 동작하지 않을 때 강제 재시작합니다.
|
||||
/// Dispose 후 호출하면 내부 상태를 초기화하고 클립보드 모니터링을 재개합니다.
|
||||
/// </summary>
|
||||
public void Reinitialize()
|
||||
{
|
||||
_disposed = false;
|
||||
_msgSource = null;
|
||||
Initialize();
|
||||
LogService.Info("클립보드 히스토리 서비스 재초기화 완료");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_msgSource != null)
|
||||
{
|
||||
RemoveClipboardFormatListener(_msgSource.Handle);
|
||||
_msgSource.Dispose();
|
||||
_msgSource = null;
|
||||
}
|
||||
// 종료 시 히스토리 저장 (동기)
|
||||
SaveHistorySync();
|
||||
}
|
||||
|
||||
// ─── 히스토리 영속성 (DPAPI 암호화) ─────────────────────────────────────
|
||||
// Windows DPAPI(DataProtectionScope.CurrentUser)를 사용하여
|
||||
// 현재 Windows 사용자 계정에서만 복호화 가능하도록 합니다.
|
||||
// 이 앱 외부에서는 파일 내용을 읽을 수 없습니다.
|
||||
|
||||
private void LoadHistory()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 구버전 평문 파일 → 암호화 파일 마이그레이션
|
||||
if (!File.Exists(HistoryPath) && File.Exists(LegacyPath))
|
||||
{
|
||||
MigrateLegacyFile();
|
||||
}
|
||||
|
||||
if (!File.Exists(HistoryPath)) return;
|
||||
|
||||
var encrypted = File.ReadAllBytes(HistoryPath);
|
||||
var plain = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser);
|
||||
var json = Encoding.UTF8.GetString(plain);
|
||||
var saved = JsonSerializer.Deserialize<List<SavedClipEntry>>(json, JsonOpts);
|
||||
if (saved == null) return;
|
||||
|
||||
int max = _settings.Settings.ClipboardHistory.MaxItems;
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var s in saved.Take(max))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(s.ImageBase64))
|
||||
{
|
||||
var img = Base64ToImage(s.ImageBase64);
|
||||
if (img != null)
|
||||
{
|
||||
_history.Add(new ClipboardEntry("", s.CopiedAt)
|
||||
{
|
||||
Image = img,
|
||||
OriginalImagePath = s.OriginalImagePath,
|
||||
IsPinned = s.IsPinned,
|
||||
Category = s.Category,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_history.Add(new ClipboardEntry(s.Text, s.CopiedAt) { IsPinned = s.IsPinned, Category = s.Category });
|
||||
}
|
||||
}
|
||||
// 시작 시 이미지 캐시 정리
|
||||
Task.Run(CleanupImageCache);
|
||||
LogService.Info($"클립보드 히스토리 {_history.Count}개 복원 (암호화)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"클립보드 히스토리 로드 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateLegacyFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(LegacyPath);
|
||||
var plain = Encoding.UTF8.GetBytes(json);
|
||||
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
||||
File.WriteAllBytes(HistoryPath, encrypted);
|
||||
File.Delete(LegacyPath); // 평문 파일 삭제
|
||||
LogService.Info("클립보드 히스토리 평문→암호화 마이그레이션 완료");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"클립보드 히스토리 마이그레이션 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveHistoryAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = BuildSnapshot();
|
||||
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
|
||||
var plain = Encoding.UTF8.GetBytes(json);
|
||||
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
||||
await File.WriteAllBytesAsync(HistoryPath, encrypted).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"클립보드 히스토리 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveHistorySync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = BuildSnapshot();
|
||||
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
|
||||
var plain = Encoding.UTF8.GetBytes(json);
|
||||
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
||||
File.WriteAllBytes(HistoryPath, encrypted);
|
||||
}
|
||||
catch { /* 종료 시 실패 무시 */ }
|
||||
}
|
||||
|
||||
private List<SavedClipEntry> BuildSnapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _history.Select(e =>
|
||||
{
|
||||
if (e.IsText)
|
||||
return new SavedClipEntry { Text = e.Text, CopiedAt = e.CopiedAt, IsPinned = e.IsPinned, Category = e.Category };
|
||||
var b64 = ImageToBase64(e.Image);
|
||||
return new SavedClipEntry
|
||||
{
|
||||
CopiedAt = e.CopiedAt,
|
||||
ImageBase64 = b64,
|
||||
OriginalImagePath = e.OriginalImagePath,
|
||||
IsPinned = e.IsPinned,
|
||||
Category = e.Category,
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// System.Text.Json 역직렬화를 위해 기본 생성자 + 프로퍼티 형태로 선언
|
||||
private class SavedClipEntry
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public DateTime CopiedAt { get; set; }
|
||||
/// <summary>이미지 썸네일 PNG 바이트 (Base64 인코딩). null이면 텍스트 항목.</summary>
|
||||
public string? ImageBase64 { get; set; }
|
||||
/// <summary>원본 이미지 파일 경로 (clipboard_images 폴더).</summary>
|
||||
public string? OriginalImagePath { get; set; }
|
||||
public bool IsPinned { get; set; }
|
||||
public string Category { get; set; } = "일반";
|
||||
}
|
||||
|
||||
// ─── 내부 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == WM_CLIPBOARDUPDATE)
|
||||
{
|
||||
OnClipboardUpdate();
|
||||
handled = false;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private void OnClipboardUpdate()
|
||||
{
|
||||
if (_ignoreNext) { _ignoreNext = false; return; }
|
||||
if (!_settings.Settings.ClipboardHistory.Enabled) return;
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
ClipboardEntry? entry = null;
|
||||
|
||||
// ─── 텍스트 ────────────────────────────────────────────────────
|
||||
if (Clipboard.ContainsText())
|
||||
{
|
||||
var text = Clipboard.GetText();
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
if (text.Length > 10_000) return;
|
||||
|
||||
// 제외 패턴 검사
|
||||
foreach (var pattern in _settings.Settings.ClipboardHistory.ExcludePatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Regex.IsMatch(text.Trim(), pattern,
|
||||
RegexOptions.None, TimeSpan.FromMilliseconds(200)))
|
||||
return;
|
||||
}
|
||||
catch { /* 잘못된 패턴 무시 */ }
|
||||
}
|
||||
|
||||
var category = _settings.Settings.Launcher.EnableClipboardAutoCategory
|
||||
? DetectCategory(text) : "일반";
|
||||
entry = new ClipboardEntry(text, DateTime.Now) { Category = category };
|
||||
}
|
||||
// ─── 이미지 ────────────────────────────────────────────────────
|
||||
else if (Clipboard.ContainsImage())
|
||||
{
|
||||
var src = Clipboard.GetImage();
|
||||
if (src == null) return;
|
||||
|
||||
// 원본 이미지를 캐시 폴더에 PNG로 저장
|
||||
var originalPath = SaveOriginalImage(src);
|
||||
|
||||
// 표시용 썸네일 (최대 80px 폭)
|
||||
var thumb = CreateThumbnail(src, 80);
|
||||
entry = new ClipboardEntry("", DateTime.Now)
|
||||
{
|
||||
Image = thumb,
|
||||
OriginalImagePath = originalPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (entry == null) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// 텍스트 중복 제거
|
||||
if (entry.IsText)
|
||||
{
|
||||
if (_history.Count > 0 && _history[0].Text == entry.Text) return;
|
||||
_history.RemoveAll(e => e.IsText && e.Text == entry.Text);
|
||||
}
|
||||
|
||||
_history.Insert(0, entry);
|
||||
|
||||
int max = _settings.Settings.ClipboardHistory.MaxItems;
|
||||
while (_history.Count > max)
|
||||
{
|
||||
// 핀 고정 항목은 삭제 보호 — 뒤에서부터 핀 아닌 항목 제거
|
||||
var removeIdx = _history.FindLastIndex(e => !e.IsPinned);
|
||||
if (removeIdx >= 0) _history.RemoveAt(removeIdx);
|
||||
else break; // 전부 핀이면 중단
|
||||
}
|
||||
}
|
||||
|
||||
HistoryChanged?.Invoke(this, EventArgs.Empty);
|
||||
_ = SaveHistoryAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"클립보드 캡처 실패: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
|
||||
private static string? SaveOriginalImage(BitmapSource src)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ImageCachePath);
|
||||
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
|
||||
var filePath = Path.Combine(ImageCachePath, fileName);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(src));
|
||||
using var fs = new FileStream(filePath, FileMode.Create);
|
||||
encoder.Save(fs);
|
||||
return filePath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
|
||||
public static BitmapSource? LoadOriginalImage(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
var bi = new BitmapImage();
|
||||
bi.BeginInit();
|
||||
bi.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bi.UriSource = new Uri(path, UriKind.Absolute);
|
||||
bi.EndInit();
|
||||
bi.Freeze();
|
||||
return bi;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
|
||||
public static void CleanupImageCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(ImageCachePath)) return;
|
||||
var files = new DirectoryInfo(ImageCachePath)
|
||||
.GetFiles("clip_*.png")
|
||||
.OrderBy(f => f.LastWriteTime)
|
||||
.ToList();
|
||||
|
||||
// 30일 초과 파일 삭제
|
||||
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
|
||||
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
|
||||
{
|
||||
try { f.Delete(); files.Remove(f); } catch { }
|
||||
}
|
||||
|
||||
// 500MB 초과 시 오래된 파일부터 삭제
|
||||
var totalSize = files.Sum(f => f.Length);
|
||||
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
|
||||
{
|
||||
var oldest = files[0];
|
||||
totalSize -= oldest.Length;
|
||||
try { oldest.Delete(); } catch { }
|
||||
files.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
|
||||
{
|
||||
if (src.PixelWidth <= maxWidth) return src;
|
||||
var scale = (double)maxWidth / src.PixelWidth;
|
||||
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
|
||||
}
|
||||
|
||||
private static string? ImageToBase64(BitmapSource? img)
|
||||
{
|
||||
if (img == null) return null;
|
||||
try
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(img));
|
||||
using var ms = new MemoryStream();
|
||||
encoder.Save(ms);
|
||||
return Convert.ToBase64String(ms.ToArray());
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static BitmapSource? Base64ToImage(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
||||
return decoder.Frames[0];
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ─── P/Invoke ──────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool AddClipboardFormatListener(IntPtr hwnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
|
||||
}
|
||||
|
||||
/// <summary>클립보드 히스토리 단일 항목. 텍스트 또는 이미지 중 하나를 담습니다.</summary>
|
||||
public record ClipboardEntry(string Text, DateTime CopiedAt)
|
||||
{
|
||||
/// <summary>이미지 항목의 표시용 썸네일 (텍스트 항목은 null)</summary>
|
||||
public BitmapSource? Image { get; init; }
|
||||
|
||||
/// <summary>원본 해상도 이미지 파일 경로 (clipboard_images 폴더). null이면 썸네일만 존재.</summary>
|
||||
public string? OriginalImagePath { get; init; }
|
||||
|
||||
/// <summary>핀 고정 여부 (핀 항목은 삭제되지 않고 상단에 표시)</summary>
|
||||
public bool IsPinned { get; set; }
|
||||
|
||||
/// <summary>카테고리 (URL, 코드, 경로, 일반)</summary>
|
||||
public string Category { get; set; } = "일반";
|
||||
|
||||
/// <summary>텍스트 항목 여부</summary>
|
||||
public bool IsText => Image == null;
|
||||
|
||||
/// <summary>UI 표시용 첫 줄 미리보기 (최대 80자)</summary>
|
||||
public string Preview
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsText) return "[이미지]";
|
||||
var line = Text.Replace("\r\n", "↵ ").Replace("\n", "↵ ").Replace("\r", "↵ ");
|
||||
return line.Length > 80 ? line[..77] + "…" : line;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>복사 시각 상대 표시</summary>
|
||||
public string RelativeTime
|
||||
{
|
||||
get
|
||||
{
|
||||
var diff = DateTime.Now - CopiedAt;
|
||||
if (diff.TotalSeconds < 60) return "방금 전";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 전";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 전";
|
||||
return CopiedAt.ToString("MM/dd HH:mm");
|
||||
}
|
||||
}
|
||||
}
|
||||
587
src/AxCopilot/Services/CodeIndexService.cs
Normal file
587
src/AxCopilot/Services/CodeIndexService.cs
Normal file
@@ -0,0 +1,587 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 프로젝트 코드베이스 인덱싱 및 시맨틱 검색 서비스.
|
||||
/// TF-IDF 기반 유사도 검색을 SQLite에 영속 저장하여
|
||||
/// 증분 업데이트와 빠른 재시작을 지원합니다.
|
||||
/// (로컬 전용, 외부 서버 불필요)
|
||||
/// </summary>
|
||||
public class CodeIndexService : IDisposable
|
||||
{
|
||||
private SqliteConnection? _db;
|
||||
private string _workFolder = "";
|
||||
private bool _indexed;
|
||||
private int _totalDocs;
|
||||
|
||||
public bool IsIndexed => _indexed;
|
||||
public int ChunkCount => _totalDocs;
|
||||
|
||||
// ── 스톱워드 (TF-IDF 정확도 향상) ──────────────────────────────────
|
||||
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// 영어 공통
|
||||
"the", "is", "at", "of", "on", "and", "or", "not", "in", "to", "for",
|
||||
"it", "be", "as", "do", "by", "this", "that", "with", "from", "but",
|
||||
"an", "are", "was", "were", "been", "being", "have", "has", "had",
|
||||
"if", "else", "then", "than", "so", "no", "yes",
|
||||
// 프로그래밍 공통 (너무 빈번해서 변별력 없음)
|
||||
"var", "int", "string", "void", "null", "new", "return", "get", "set",
|
||||
"public", "private", "class", "static", "using", "namespace", "true", "false",
|
||||
"import", "export", "function", "const", "let", "def", "self",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".hpp",
|
||||
".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala",
|
||||
".html", ".css", ".scss", ".json", ".xml", ".yaml", ".yml",
|
||||
".md", ".txt", ".sql", ".sh", ".bat", ".ps1",
|
||||
".csproj", ".sln", ".gradle", ".pom",
|
||||
};
|
||||
|
||||
// ── DB 초기화 ───────────────────────────────────────────────────────
|
||||
|
||||
private string GetDbPath(string workFolder)
|
||||
{
|
||||
// %APPDATA%\AxCopilot\index\{folderHash}.db
|
||||
var hash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant())))[..16];
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "index");
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, $"{hash}.db");
|
||||
}
|
||||
|
||||
private void EnsureDb(string workFolder)
|
||||
{
|
||||
if (_db != null && _workFolder == workFolder) return;
|
||||
|
||||
_db?.Dispose();
|
||||
_workFolder = workFolder;
|
||||
var dbPath = GetDbPath(workFolder);
|
||||
_db = new SqliteConnection($"Data Source={dbPath}");
|
||||
_db.Open();
|
||||
|
||||
// WAL 모드 (동시 읽기/쓰기 성능)
|
||||
using (var cmd = _db.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 테이블 생성
|
||||
using var create = _db.CreateCommand();
|
||||
create.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
last_modified TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL,
|
||||
start_line INTEGER NOT NULL,
|
||||
end_line INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
chunk_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
tf INTEGER NOT NULL,
|
||||
FOREIGN KEY (chunk_id) REFERENCES chunks(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS doc_freq (
|
||||
token TEXT PRIMARY KEY,
|
||||
df INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_chunk ON tokens(chunk_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id);
|
||||
""";
|
||||
create.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── 인덱싱 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>작업 폴더의 코드 파일을 인덱싱합니다. 증분 업데이트 지원.</summary>
|
||||
public async Task IndexAsync(string workFolder, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder))
|
||||
return;
|
||||
|
||||
EnsureDb(workFolder);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var existingFiles = LoadExistingFiles();
|
||||
// 설정에서 최대 파일 크기 조회
|
||||
var maxFileKb = 500;
|
||||
try
|
||||
{
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var cfgMax = app?.SettingsService?.Settings.Llm.Code.CodeIndexMaxFileKb ?? 500;
|
||||
if (cfgMax > 0) maxFileKb = cfgMax;
|
||||
}
|
||||
catch { }
|
||||
|
||||
var currentFiles = ScanFiles(workFolder, maxFileKb);
|
||||
|
||||
int added = 0, updated = 0, removed = 0;
|
||||
|
||||
// 삭제된 파일 제거
|
||||
foreach (var (path, fileId) in existingFiles)
|
||||
{
|
||||
if (!currentFiles.ContainsKey(path))
|
||||
{
|
||||
RemoveFileFromIndex(fileId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// 신규/변경 파일 인덱싱
|
||||
foreach (var (relPath, info) in currentFiles)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var lastMod = info.LastWriteTimeUtc.ToString("O");
|
||||
var size = info.Length;
|
||||
|
||||
if (existingFiles.TryGetValue(relPath, out var fileId))
|
||||
{
|
||||
// 기존 파일 — 변경 여부 확인
|
||||
if (!IsFileChanged(fileId, lastMod, size))
|
||||
continue;
|
||||
|
||||
RemoveFileFromIndex(fileId);
|
||||
updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
added++;
|
||||
}
|
||||
|
||||
IndexFile(workFolder, relPath, lastMod, size);
|
||||
}
|
||||
|
||||
// DF 테이블 재계산
|
||||
RebuildDocFreq();
|
||||
|
||||
// 총 청크 수 캐시
|
||||
_totalDocs = GetTotalChunkCount();
|
||||
_indexed = _totalDocs > 0;
|
||||
|
||||
// 메타 저장
|
||||
SaveMeta("lastIndexed", DateTime.UtcNow.ToString("O"));
|
||||
SaveMeta("workFolder", workFolder);
|
||||
|
||||
LogService.Info($"코드 인덱싱 완료: {_totalDocs}개 청크 (추가:{added} 갱신:{updated} 삭제:{removed}) [{workFolder}]");
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private Dictionary<string, int> LoadExistingFiles()
|
||||
{
|
||||
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, path FROM files";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
dict[reader.GetString(1)] = reader.GetInt32(0);
|
||||
return dict;
|
||||
}
|
||||
|
||||
private Dictionary<string, FileInfo> ScanFiles(string workFolder, int maxFileKb = 500)
|
||||
{
|
||||
var dict = new Dictionary<string, FileInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
var maxBytes = (long)maxFileKb * 1024;
|
||||
try
|
||||
{
|
||||
var files = Directory.EnumerateFiles(workFolder, "*.*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
MaxRecursionDepth = 8,
|
||||
});
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var ext = Path.GetExtension(file);
|
||||
if (!CodeExtensions.Contains(ext)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length > maxBytes) continue;
|
||||
var relPath = Path.GetRelativePath(workFolder, file);
|
||||
dict[relPath] = info;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return dict;
|
||||
}
|
||||
|
||||
private bool IsFileChanged(int fileId, string lastMod, long size)
|
||||
{
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.CommandText = "SELECT last_modified, file_size FROM files WHERE id = @id";
|
||||
cmd.Parameters.AddWithValue("@id", fileId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (!reader.Read()) return true;
|
||||
return reader.GetString(0) != lastMod || reader.GetInt64(1) != size;
|
||||
}
|
||||
|
||||
private void RemoveFileFromIndex(int fileId)
|
||||
{
|
||||
// 청크 ID 목록
|
||||
var chunkIds = new List<int>();
|
||||
using (var cmd = _db!.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT id FROM chunks WHERE file_id = @fid";
|
||||
cmd.Parameters.AddWithValue("@fid", fileId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read()) chunkIds.Add(reader.GetInt32(0));
|
||||
}
|
||||
|
||||
// 토큰 삭제
|
||||
foreach (var cid in chunkIds)
|
||||
{
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM tokens WHERE chunk_id = @cid";
|
||||
cmd.Parameters.AddWithValue("@cid", cid);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 청크 삭제
|
||||
using (var cmd = _db!.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM chunks WHERE file_id = @fid";
|
||||
cmd.Parameters.AddWithValue("@fid", fileId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 파일 삭제
|
||||
using (var cmd = _db!.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM files WHERE id = @fid";
|
||||
cmd.Parameters.AddWithValue("@fid", fileId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private void IndexFile(string workFolder, string relPath, string lastMod, long size)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.Combine(workFolder, relPath);
|
||||
var content = File.ReadAllText(fullPath, Encoding.UTF8);
|
||||
|
||||
// 파일 등록
|
||||
int fileId;
|
||||
using (var cmd = _db!.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO files (path, last_modified, file_size) VALUES (@p, @m, @s) RETURNING id";
|
||||
cmd.Parameters.AddWithValue("@p", relPath);
|
||||
cmd.Parameters.AddWithValue("@m", lastMod);
|
||||
cmd.Parameters.AddWithValue("@s", size);
|
||||
fileId = Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
// 청크 분할 (50라인씩)
|
||||
var lines = content.Split('\n');
|
||||
using var tx = _db!.BeginTransaction();
|
||||
|
||||
for (int i = 0; i < lines.Length; i += 50)
|
||||
{
|
||||
var chunkLines = lines.AsSpan(i, Math.Min(50, lines.Length - i));
|
||||
var chunkText = string.Join("\n", chunkLines.ToArray());
|
||||
if (string.IsNullOrWhiteSpace(chunkText)) continue;
|
||||
|
||||
var endLine = Math.Min(i + 50, lines.Length);
|
||||
|
||||
// 청크 저장
|
||||
int chunkId;
|
||||
using (var cmd = _db!.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "INSERT INTO chunks (file_id, start_line, end_line, content) VALUES (@f, @s, @e, @c) RETURNING id";
|
||||
cmd.Parameters.AddWithValue("@f", fileId);
|
||||
cmd.Parameters.AddWithValue("@s", i + 1);
|
||||
cmd.Parameters.AddWithValue("@e", endLine);
|
||||
cmd.Parameters.AddWithValue("@c", chunkText);
|
||||
chunkId = Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
// 토큰 저장
|
||||
var tokens = Tokenize(chunkText);
|
||||
foreach (var (token, tf) in tokens)
|
||||
{
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "INSERT INTO tokens (chunk_id, token, tf) VALUES (@cid, @t, @tf)";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
cmd.Parameters.AddWithValue("@tf", tf);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
catch { /* 읽기 실패 파일 건너뛰기 */ }
|
||||
}
|
||||
|
||||
private void RebuildDocFreq()
|
||||
{
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
DELETE FROM doc_freq;
|
||||
INSERT INTO doc_freq (token, df)
|
||||
SELECT token, COUNT(DISTINCT chunk_id) FROM tokens GROUP BY token;
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private int GetTotalChunkCount()
|
||||
{
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM chunks";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
private void SaveMeta(string key, string value)
|
||||
{
|
||||
using var cmd = _db!.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR REPLACE INTO meta (key, value) VALUES (@k, @v)";
|
||||
cmd.Parameters.AddWithValue("@k", key);
|
||||
cmd.Parameters.AddWithValue("@v", value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── 검색 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다.</summary>
|
||||
public List<SearchResult> Search(string query, int maxResults = 5)
|
||||
{
|
||||
if (!_indexed || _db == null || _totalDocs == 0)
|
||||
return new();
|
||||
|
||||
var queryTokens = Tokenize(query);
|
||||
if (queryTokens.Count == 0) return new();
|
||||
|
||||
// 쿼리 토큰의 DF 조회
|
||||
var dfMap = new Dictionary<string, int>();
|
||||
foreach (var token in queryTokens.Keys)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = "SELECT df FROM doc_freq WHERE token = @t";
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
var result = cmd.ExecuteScalar();
|
||||
if (result != null) dfMap[token] = Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
// 후보 청크 검색: 쿼리 토큰이 하나라도 포함된 청크만
|
||||
var candidateChunks = new HashSet<int>();
|
||||
foreach (var token in queryTokens.Keys)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = "SELECT DISTINCT chunk_id FROM tokens WHERE token = @t";
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read()) candidateChunks.Add(reader.GetInt32(0));
|
||||
}
|
||||
|
||||
if (candidateChunks.Count == 0) return new();
|
||||
|
||||
// 각 후보 청크의 TF-IDF 유사도 계산
|
||||
var scored = new List<(int ChunkId, double Score)>();
|
||||
|
||||
foreach (var chunkId in candidateChunks)
|
||||
{
|
||||
// 청크의 토큰 TF 로드
|
||||
var docTf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _db.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT token, tf FROM tokens WHERE chunk_id = @cid";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
docTf[reader.GetString(0)] = reader.GetInt32(1);
|
||||
}
|
||||
|
||||
var score = ComputeTfIdfSimilarity(queryTokens, docTf, dfMap);
|
||||
if (score > 0.01)
|
||||
scored.Add((chunkId, score));
|
||||
}
|
||||
|
||||
// 상위 결과 추출
|
||||
var topChunks = scored
|
||||
.OrderByDescending(s => s.Score)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
var results = new List<SearchResult>();
|
||||
foreach (var (chunkId, score) in topChunks)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT f.path, c.start_line, c.end_line, c.content
|
||||
FROM chunks c JOIN files f ON c.file_id = f.id
|
||||
WHERE c.id = @cid
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
results.Add(new SearchResult
|
||||
{
|
||||
FilePath = reader.GetString(0),
|
||||
StartLine = reader.GetInt32(1),
|
||||
EndLine = reader.GetInt32(2),
|
||||
Score = score,
|
||||
Preview = reader.GetString(3) is { Length: > 200 } s ? s[..200] + "..." : reader.GetString(3),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>기존 인덱스가 있으면 로드합니다 (앱 재시작 시).</summary>
|
||||
public void TryLoadExisting(string workFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
|
||||
|
||||
var dbPath = GetDbPath(workFolder);
|
||||
if (!File.Exists(dbPath)) return;
|
||||
|
||||
EnsureDb(workFolder);
|
||||
_totalDocs = GetTotalChunkCount();
|
||||
_indexed = _totalDocs > 0;
|
||||
|
||||
if (_indexed)
|
||||
LogService.Info($"기존 코드 인덱스 로드: {_totalDocs}개 청크 [{workFolder}]");
|
||||
}
|
||||
|
||||
// ── TF-IDF 계산 ─────────────────────────────────────────────────────
|
||||
|
||||
private double ComputeTfIdfSimilarity(
|
||||
Dictionary<string, int> queryTf,
|
||||
Dictionary<string, int> docTf,
|
||||
Dictionary<string, int> dfMap)
|
||||
{
|
||||
double dotProduct = 0, queryNorm = 0, docNorm = 0;
|
||||
|
||||
foreach (var (token, qtf) in queryTf)
|
||||
{
|
||||
var df = dfMap.GetValueOrDefault(token, 0);
|
||||
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
|
||||
var qWeight = qtf * idf;
|
||||
queryNorm += qWeight * qWeight;
|
||||
|
||||
if (docTf.TryGetValue(token, out var dtf))
|
||||
{
|
||||
var dWeight = dtf * idf;
|
||||
dotProduct += qWeight * dWeight;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (token, dtf) in docTf)
|
||||
{
|
||||
var df = dfMap.GetValueOrDefault(token, 0);
|
||||
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
|
||||
var dWeight = dtf * idf;
|
||||
docNorm += dWeight * dWeight;
|
||||
}
|
||||
|
||||
if (queryNorm == 0 || docNorm == 0) return 0;
|
||||
return dotProduct / (Math.Sqrt(queryNorm) * Math.Sqrt(docNorm));
|
||||
}
|
||||
|
||||
// ── 토큰화 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함.</summary>
|
||||
private static Dictionary<string, int> Tokenize(string text)
|
||||
{
|
||||
var tf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var words = Regex.Split(text, @"[^a-zA-Z0-9가-힣_]+")
|
||||
.SelectMany(SplitCamelCase)
|
||||
.Where(w => w.Length >= 2 && !StopWords.Contains(w));
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var lower = word.ToLowerInvariant();
|
||||
tf.TryGetValue(lower, out var count);
|
||||
tf[lower] = count + 1;
|
||||
}
|
||||
|
||||
// 바이그램 추가 (구문 검색 품질 향상)
|
||||
var wordList = words.Select(w => w.ToLowerInvariant()).ToList();
|
||||
for (int i = 0; i < wordList.Count - 1; i++)
|
||||
{
|
||||
var bigram = $"{wordList[i]}_{wordList[i + 1]}";
|
||||
tf.TryGetValue(bigram, out var bc);
|
||||
tf[bigram] = bc + 1;
|
||||
}
|
||||
|
||||
return tf;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitCamelCase(string word)
|
||||
{
|
||||
if (string.IsNullOrEmpty(word)) yield break;
|
||||
var sb = new StringBuilder();
|
||||
foreach (var ch in word)
|
||||
{
|
||||
if (char.IsUpper(ch) && sb.Length > 0)
|
||||
{
|
||||
yield return sb.ToString();
|
||||
sb.Clear();
|
||||
}
|
||||
sb.Append(ch);
|
||||
}
|
||||
if (sb.Length > 0) yield return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Dispose ─────────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db?.Dispose();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>인덱싱된 코드 청크.</summary>
|
||||
public class CodeChunk
|
||||
{
|
||||
public string FilePath { get; init; } = "";
|
||||
public int StartLine { get; init; }
|
||||
public int EndLine { get; init; }
|
||||
public string Content { get; init; } = "";
|
||||
public Dictionary<string, int> Tokens { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>검색 결과.</summary>
|
||||
public class SearchResult
|
||||
{
|
||||
public string FilePath { get; init; } = "";
|
||||
public int StartLine { get; init; }
|
||||
public int EndLine { get; init; }
|
||||
public double Score { get; init; }
|
||||
public string Preview { get; init; } = "";
|
||||
public override string ToString() => $"{FilePath}:{StartLine}-{EndLine} (score: {Score:F3})";
|
||||
}
|
||||
116
src/AxCopilot/Services/Cp4dTokenService.cs
Normal file
116
src/AxCopilot/Services/Cp4dTokenService.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// IBM Cloud Pak for Data (CP4D) 토큰 발급 및 캐싱 서비스.
|
||||
/// CP4D의 /icp4d-api/v1/authorize 엔드포인트에서 Bearer 토큰을 발급받고,
|
||||
/// 만료 전까지 캐싱하여 재사용합니다.
|
||||
/// </summary>
|
||||
internal sealed class Cp4dTokenService
|
||||
{
|
||||
private static readonly HttpClient _http = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
|
||||
// 캐시: (cp4dUrl + username) → (token, expiry)
|
||||
private static readonly Dictionary<string, (string Token, DateTime Expiry)> _cache = new();
|
||||
private static readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// CP4D 토큰을 발급받거나 캐시에서 반환합니다.
|
||||
/// </summary>
|
||||
/// <param name="cp4dUrl">CP4D 서버 URL (예: https://cpd-host.example.com)</param>
|
||||
/// <param name="username">CP4D 사용자 이름</param>
|
||||
/// <param name="password">CP4D 비밀번호 또는 API 키</param>
|
||||
/// <param name="ct">취소 토큰</param>
|
||||
/// <returns>Bearer 토큰 문자열. 실패 시 null.</returns>
|
||||
public static async Task<string?> GetTokenAsync(string cp4dUrl, string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cp4dUrl) || string.IsNullOrWhiteSpace(username))
|
||||
return null;
|
||||
|
||||
var cacheKey = $"{cp4dUrl}|{username}";
|
||||
|
||||
// 캐시 확인 — 만료 1분 전까지 유효
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1))
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
// 토큰 발급
|
||||
try
|
||||
{
|
||||
var tokenUrl = cp4dUrl.TrimEnd('/') + "/icp4d-api/v1/authorize";
|
||||
var body = new { username, password };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
|
||||
{
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
|
||||
// CP4D는 자체 서명 인증서를 사용할 수 있으므로 TLS 오류 무시 옵션 (사내 환경)
|
||||
using var resp = await _http.SendAsync(req, ct);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
LogService.Warn($"CP4D 토큰 발급 실패: {resp.StatusCode} - {errBody}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
// CP4D 응답 형식: {"token": "...", "_messageCode_": "...", "message": "..."}
|
||||
if (!doc.RootElement.TryGetProperty("token", out var tokenProp))
|
||||
{
|
||||
LogService.Warn("CP4D 응답에 token 필드가 없습니다.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tokenProp.GetString();
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
// 토큰 만료 시간 — CP4D 기본 12시간, 안전하게 11시간으로 설정
|
||||
var expiry = DateTime.UtcNow.AddHours(11);
|
||||
|
||||
// _messageCode_에서 만료 정보가 있으면 파싱 시도
|
||||
if (doc.RootElement.TryGetProperty("accessTokenExpiry", out var expiryProp) &&
|
||||
expiryProp.TryGetInt64(out var expiryMs))
|
||||
{
|
||||
expiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_cache[cacheKey] = (token, expiry);
|
||||
}
|
||||
|
||||
LogService.Info($"CP4D 토큰 발급 완료: {cp4dUrl} (만료: {expiry:yyyy-MM-dd HH:mm} UTC)");
|
||||
return token;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"CP4D 토큰 발급 오류: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>특정 CP4D 서버의 캐시된 토큰을 무효화합니다.</summary>
|
||||
public static void InvalidateToken(string cp4dUrl, string username)
|
||||
{
|
||||
var cacheKey = $"{cp4dUrl}|{username}";
|
||||
lock (_lock) { _cache.Remove(cacheKey); }
|
||||
}
|
||||
|
||||
/// <summary>모든 캐시된 토큰을 초기화합니다.</summary>
|
||||
public static void ClearAllTokens()
|
||||
{
|
||||
lock (_lock) { _cache.Clear(); }
|
||||
}
|
||||
}
|
||||
212
src/AxCopilot/Services/CryptoService.cs
Normal file
212
src/AxCopilot/Services/CryptoService.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AX Copilot 암호화 서비스.
|
||||
///
|
||||
/// 두 가지 암호화 계층을 제공합니다:
|
||||
///
|
||||
/// 1. **앱 공용 키 (Portable)** — 설정값(API 키 등) 암호화
|
||||
/// - 앱 내장 고정 키 + AES-256-CBC
|
||||
/// - 관리자 PC에서 암호화한 값이 모든 PC에서 동일하게 복호화됨
|
||||
/// - AxKeyEncryptor 도구에서도 동일 키 사용
|
||||
///
|
||||
/// 2. **PC별 개인 키 (Local)** — 대화 내역 파일 암호화
|
||||
/// - DPAPI로 보호되는 PC/사용자별 랜덤 마스터 키 + AES-256-GCM
|
||||
/// - 해당 PC/사용자 계정에서만 복호화 가능
|
||||
/// - 대화 파일(.axchat)은 다른 PC로 복사해도 열리지 않음
|
||||
/// </summary>
|
||||
public static class CryptoService
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 1. 앱 공용 키 — 설정값 암호화 (Portable, 모든 PC에서 동일)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 앱 고유 시드. 이 값에서 PBKDF2로 256-bit 키를 파생합니다.
|
||||
// ※ 바이너리에 포함되므로 완벽한 보안은 아니지만, 평문 노출을 방지합니다.
|
||||
private static readonly byte[] AppSeed =
|
||||
{
|
||||
// "AX-Commander-Key-0104-sj.baeck" + 2-byte pad
|
||||
0x41, 0x58, 0x2D, 0x43, 0x6F, 0x6D, 0x6D, 0x61,
|
||||
0x6E, 0x64, 0x65, 0x72, 0x2D, 0x4B, 0x65, 0x79,
|
||||
0x2D, 0x30, 0x31, 0x30, 0x34, 0x2D, 0x73, 0x6A,
|
||||
0x2E, 0x62, 0x61, 0x65, 0x63, 0x6B, 0xA7, 0x5C
|
||||
};
|
||||
|
||||
private static readonly byte[] AppSalt =
|
||||
{
|
||||
0x58, 0x43, 0x4D, 0x44, 0x53, 0x61, 0x6C, 0x74, // "XCMDSalt"
|
||||
0x9E, 0x27, 0xC1, 0x4A, 0xB3, 0x06, 0x7F, 0xD8 // random
|
||||
};
|
||||
|
||||
private static byte[]? _appKey;
|
||||
|
||||
/// <summary>앱 공용 AES-256 키를 PBKDF2로 파생합니다.</summary>
|
||||
private static byte[] GetAppKey()
|
||||
{
|
||||
if (_appKey != null) return _appKey;
|
||||
using var pbkdf2 = new Rfc2898DeriveBytes(AppSeed, AppSalt, 100_000, HashAlgorithmName.SHA256);
|
||||
_appKey = pbkdf2.GetBytes(32); // 256-bit
|
||||
return _appKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 평문 문자열을 앱 공용 키로 AES-256-CBC 암호화하여 Base64 반환.
|
||||
/// 관리자 PC에서 암호화한 값이 모든 사용자 PC에서 동일하게 복호화됩니다.
|
||||
/// </summary>
|
||||
public static string PortableEncrypt(string plainText)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plainText)) return "";
|
||||
var key = GetAppKey();
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.GenerateIV();
|
||||
|
||||
using var enc = aes.CreateEncryptor();
|
||||
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
var cipher = enc.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||
|
||||
// [IV(16)] [ciphertext]
|
||||
var result = new byte[16 + cipher.Length];
|
||||
Buffer.BlockCopy(aes.IV, 0, result, 0, 16);
|
||||
Buffer.BlockCopy(cipher, 0, result, 16, cipher.Length);
|
||||
return Convert.ToBase64String(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 앱 공용 키로 AES-256-CBC 복호화. PortableEncrypt의 역.
|
||||
/// </summary>
|
||||
public static string PortableDecrypt(string base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return "";
|
||||
try
|
||||
{
|
||||
var raw = Convert.FromBase64String(base64);
|
||||
if (raw.Length < 17) return ""; // IV(16) + 최소 1블록
|
||||
|
||||
var key = GetAppKey();
|
||||
var iv = new byte[16];
|
||||
Buffer.BlockCopy(raw, 0, iv, 0, 16);
|
||||
var cipher = new byte[raw.Length - 16];
|
||||
Buffer.BlockCopy(raw, 16, cipher, 0, cipher.Length);
|
||||
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
using var dec = aes.CreateDecryptor();
|
||||
var plain = dec.TransformFinalBlock(cipher, 0, cipher.Length);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 1-B. 암호화 모드 분기 — encryptionEnabled에 따라 암호화 또는 패스스루
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// encryptionEnabled=true이면 PortableEncrypt, false이면 평문 그대로 반환.
|
||||
/// 운영 배포 시 true로 전환하면 자동으로 암호화가 적용됩니다.
|
||||
/// </summary>
|
||||
public static string EncryptIfEnabled(string plainText, bool encryptionEnabled)
|
||||
=> encryptionEnabled ? PortableEncrypt(plainText) : plainText;
|
||||
|
||||
/// <summary>
|
||||
/// encryptionEnabled=true이면 PortableDecrypt, false이면 평문 그대로 반환.
|
||||
/// 하위 호환: 암호화된 값이 들어와도 복호화 시도 후 실패하면 평문으로 간주.
|
||||
/// </summary>
|
||||
public static string DecryptIfEnabled(string value, bool encryptionEnabled)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
if (!encryptionEnabled) return value;
|
||||
// 암호화 모드: 복호화 시도, 실패하면 평문으로 간주 (마이그레이션 호환)
|
||||
var result = PortableDecrypt(value);
|
||||
return string.IsNullOrEmpty(result) ? value : result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 2. PC별 개인 키 — 대화 내역 파일 암호화 (Local, PC 종속)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private static byte[] GetOrCreateMasterKey()
|
||||
{
|
||||
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot");
|
||||
var path = Path.Combine(dir, ".master_key");
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var dpapi = File.ReadAllBytes(path);
|
||||
return ProtectedData.Unprotect(dpapi, null, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
|
||||
// 최초 생성: 256-bit 랜덤 키 → DPAPI 보호 후 저장
|
||||
var key = RandomNumberGenerator.GetBytes(32);
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(path, ProtectedData.Protect(key, null, DataProtectionScope.CurrentUser));
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>바이트 배열을 PC별 마스터 키로 AES-256-GCM 암호화</summary>
|
||||
public static byte[] EncryptBytes(byte[] plainBytes)
|
||||
{
|
||||
var key = GetOrCreateMasterKey();
|
||||
var nonce = RandomNumberGenerator.GetBytes(12); // 96-bit nonce
|
||||
var tag = new byte[16]; // 128-bit tag
|
||||
var cipher = new byte[plainBytes.Length];
|
||||
|
||||
using var aes = new AesGcm(key, 16);
|
||||
aes.Encrypt(nonce, plainBytes, cipher, tag);
|
||||
|
||||
// [nonce(12)] [tag(16)] [ciphertext]
|
||||
var result = new byte[12 + 16 + cipher.Length];
|
||||
Buffer.BlockCopy(nonce, 0, result, 0, 12);
|
||||
Buffer.BlockCopy(tag, 0, result, 12, 16);
|
||||
Buffer.BlockCopy(cipher, 0, result, 28, cipher.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>PC별 마스터 키로 AES-256-GCM 복호화</summary>
|
||||
public static byte[] DecryptBytes(byte[] encrypted)
|
||||
{
|
||||
if (encrypted.Length < 28) throw new CryptographicException("Invalid encrypted data");
|
||||
|
||||
var key = GetOrCreateMasterKey();
|
||||
var nonce = new byte[12];
|
||||
var tag = new byte[16];
|
||||
var cipher = new byte[encrypted.Length - 28];
|
||||
|
||||
Buffer.BlockCopy(encrypted, 0, nonce, 0, 12);
|
||||
Buffer.BlockCopy(encrypted, 12, tag, 0, 16);
|
||||
Buffer.BlockCopy(encrypted, 28, cipher, 0, cipher.Length);
|
||||
|
||||
var plain = new byte[cipher.Length];
|
||||
using var aes = new AesGcm(key, 16);
|
||||
aes.Decrypt(nonce, cipher, tag, plain);
|
||||
return plain;
|
||||
}
|
||||
|
||||
/// <summary>문자열을 PC별 키로 AES-256-GCM 암호화하여 파일에 저장</summary>
|
||||
public static void EncryptToFile(string filePath, string content)
|
||||
{
|
||||
var plain = Encoding.UTF8.GetBytes(content);
|
||||
var enc = EncryptBytes(plain);
|
||||
File.WriteAllBytes(filePath, enc);
|
||||
}
|
||||
|
||||
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화</summary>
|
||||
public static string DecryptFromFile(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath)) return "";
|
||||
var enc = File.ReadAllBytes(filePath);
|
||||
var plain = DecryptBytes(enc);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
}
|
||||
99
src/AxCopilot/Services/DiffService.cs
Normal file
99
src/AxCopilot/Services/DiffService.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 라인 기반 텍스트 diff 서비스. 두 텍스트의 변경 사항을 비교합니다.
|
||||
/// </summary>
|
||||
public static class DiffService
|
||||
{
|
||||
public enum DiffType { Equal, Added, Removed }
|
||||
|
||||
public record DiffLine(DiffType Type, int? OldLineNo, int? NewLineNo, string Content);
|
||||
|
||||
/// <summary>두 텍스트를 라인 단위로 비교하여 diff 결과를 반환합니다.</summary>
|
||||
public static List<DiffLine> ComputeDiff(string oldText, string newText)
|
||||
{
|
||||
var oldLines = (oldText ?? "").Split('\n');
|
||||
var newLines = (newText ?? "").Split('\n');
|
||||
var result = new List<DiffLine>();
|
||||
|
||||
// LCS (Longest Common Subsequence) 기반 간단 diff
|
||||
var lcs = ComputeLcs(oldLines, newLines);
|
||||
int oi = 0, ni = 0, li = 0;
|
||||
|
||||
while (oi < oldLines.Length || ni < newLines.Length)
|
||||
{
|
||||
if (li < lcs.Count && oi < oldLines.Length && ni < newLines.Length &&
|
||||
oldLines[oi].TrimEnd('\r') == lcs[li] && newLines[ni].TrimEnd('\r') == lcs[li])
|
||||
{
|
||||
result.Add(new DiffLine(DiffType.Equal, oi + 1, ni + 1, oldLines[oi].TrimEnd('\r')));
|
||||
oi++; ni++; li++;
|
||||
}
|
||||
else if (li < lcs.Count && oi < oldLines.Length &&
|
||||
oldLines[oi].TrimEnd('\r') != lcs[li])
|
||||
{
|
||||
result.Add(new DiffLine(DiffType.Removed, oi + 1, null, oldLines[oi].TrimEnd('\r')));
|
||||
oi++;
|
||||
}
|
||||
else if (ni < newLines.Length &&
|
||||
(li >= lcs.Count || newLines[ni].TrimEnd('\r') != lcs[li]))
|
||||
{
|
||||
result.Add(new DiffLine(DiffType.Added, null, ni + 1, newLines[ni].TrimEnd('\r')));
|
||||
ni++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 나머지 처리
|
||||
if (oi < oldLines.Length)
|
||||
{
|
||||
result.Add(new DiffLine(DiffType.Removed, oi + 1, null, oldLines[oi].TrimEnd('\r')));
|
||||
oi++;
|
||||
}
|
||||
if (ni < newLines.Length)
|
||||
{
|
||||
result.Add(new DiffLine(DiffType.Added, null, ni + 1, newLines[ni].TrimEnd('\r')));
|
||||
ni++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ComputeLcs(string[] a, string[] b)
|
||||
{
|
||||
int m = a.Length, n = b.Length;
|
||||
var dp = new int[m + 1, n + 1];
|
||||
|
||||
for (int i = 1; i <= m; i++)
|
||||
for (int j = 1; j <= n; j++)
|
||||
dp[i, j] = a[i - 1].TrimEnd('\r') == b[j - 1].TrimEnd('\r')
|
||||
? dp[i - 1, j - 1] + 1
|
||||
: Math.Max(dp[i - 1, j], dp[i, j - 1]);
|
||||
|
||||
// 역추적
|
||||
var lcs = new List<string>();
|
||||
int x = m, y = n;
|
||||
while (x > 0 && y > 0)
|
||||
{
|
||||
if (a[x - 1].TrimEnd('\r') == b[y - 1].TrimEnd('\r'))
|
||||
{
|
||||
lcs.Add(a[x - 1].TrimEnd('\r'));
|
||||
x--; y--;
|
||||
}
|
||||
else if (dp[x - 1, y] > dp[x, y - 1])
|
||||
x--;
|
||||
else
|
||||
y--;
|
||||
}
|
||||
lcs.Reverse();
|
||||
return lcs;
|
||||
}
|
||||
|
||||
/// <summary>diff 결과를 통계 요약 문자열로 반환합니다.</summary>
|
||||
public static string GetSummary(List<DiffLine> diff)
|
||||
{
|
||||
int added = diff.Count(d => d.Type == DiffType.Added);
|
||||
int removed = diff.Count(d => d.Type == DiffType.Removed);
|
||||
return $"+{added} -{removed} 라인 변경";
|
||||
}
|
||||
}
|
||||
127
src/AxCopilot/Services/DraftQueueProcessorService.cs
Normal file
127
src/AxCopilot/Services/DraftQueueProcessorService.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 드래프트 큐 항목의 실행 전이와 재시도 정책을 담당합니다.
|
||||
/// ChatWindow가 직접 상태 전이를 조합하지 않도록 별도 서비스로 분리합니다.
|
||||
/// </summary>
|
||||
public sealed class DraftQueueProcessorService
|
||||
{
|
||||
public bool CanStartNext(ChatSessionStateService? session, string tab)
|
||||
=> session?.GetNextQueuedDraft(tab) != null;
|
||||
|
||||
public DraftQueueItem? TryStartNext(ChatSessionStateService? session, string tab, ChatStorageService? storage = null, string? preferredDraftId = null, TaskRunService? taskRuns = null)
|
||||
{
|
||||
if (session == null)
|
||||
return null;
|
||||
|
||||
DraftQueueItem? next = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(preferredDraftId))
|
||||
{
|
||||
next = session.GetDraftQueueItems(tab)
|
||||
.FirstOrDefault(x => string.Equals(x.Id, preferredDraftId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (next != null &&
|
||||
!string.Equals(next.State, "queued", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
session.ResetDraftToQueued(tab, next.Id, storage);
|
||||
next = session.GetDraftQueueItems(tab)
|
||||
.FirstOrDefault(x => string.Equals(x.Id, preferredDraftId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
next ??= session.GetNextQueuedDraft(tab);
|
||||
if (next == null)
|
||||
return null;
|
||||
|
||||
if (!session.MarkDraftRunning(tab, next.Id, storage))
|
||||
return null;
|
||||
|
||||
taskRuns?.StartQueueRun(tab, next.Id, next.Text);
|
||||
|
||||
return session.GetDraftQueueItems(tab)
|
||||
.FirstOrDefault(x => string.Equals(x.Id, next.Id, StringComparison.OrdinalIgnoreCase))
|
||||
?? next;
|
||||
}
|
||||
|
||||
public bool Complete(ChatSessionStateService? session, string tab, string draftId, ChatStorageService? storage = null, TaskRunService? taskRuns = null)
|
||||
{
|
||||
var completed = session?.MarkDraftCompleted(tab, draftId, storage) ?? false;
|
||||
if (completed)
|
||||
taskRuns?.CompleteQueueRun(tab, draftId, "대기열 작업 완료", "completed");
|
||||
return completed;
|
||||
}
|
||||
|
||||
public bool HandleFailure(ChatSessionStateService? session, string tab, string draftId, string? error, bool cancelled = false, int maxAutoRetries = 3, ChatStorageService? storage = null, TaskRunService? taskRuns = null)
|
||||
{
|
||||
if (session == null)
|
||||
return false;
|
||||
|
||||
if (cancelled)
|
||||
{
|
||||
var reset = session.ResetDraftToQueued(tab, draftId, storage);
|
||||
if (reset)
|
||||
taskRuns?.CompleteQueueRun(tab, draftId, string.IsNullOrWhiteSpace(error) ? "대기열 작업 중단" : error, "cancelled");
|
||||
return reset;
|
||||
}
|
||||
|
||||
var handled = session.ScheduleDraftRetry(tab, draftId, error, maxAutoRetries, storage);
|
||||
if (handled)
|
||||
{
|
||||
var item = session.GetDraftQueueItems(tab)
|
||||
.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
|
||||
var blocked = item?.NextRetryAt.HasValue == true;
|
||||
taskRuns?.CompleteQueueRun(
|
||||
tab,
|
||||
draftId,
|
||||
string.IsNullOrWhiteSpace(error) ? (blocked ? "재시도 대기" : "대기열 작업 실패") : error,
|
||||
blocked ? "blocked" : "failed");
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
public int PromoteReadyBlockedItems(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
|
||||
{
|
||||
if (session == null)
|
||||
return 0;
|
||||
|
||||
var promoted = 0;
|
||||
foreach (var item in session.GetDraftQueueItems(tab)
|
||||
.Where(x => string.Equals(x.State, "queued", StringComparison.OrdinalIgnoreCase)
|
||||
&& x.NextRetryAt.HasValue
|
||||
&& x.NextRetryAt.Value <= DateTime.Now)
|
||||
.ToList())
|
||||
{
|
||||
if (session.ResetDraftToQueued(tab, item.Id, storage))
|
||||
promoted++;
|
||||
}
|
||||
|
||||
return promoted;
|
||||
}
|
||||
|
||||
public int ClearCompleted(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
|
||||
=> ClearByState(session, tab, "completed", storage);
|
||||
|
||||
public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
|
||||
=> ClearByState(session, tab, "failed", storage);
|
||||
|
||||
private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage)
|
||||
{
|
||||
if (session == null)
|
||||
return 0;
|
||||
|
||||
var removed = 0;
|
||||
foreach (var item in session.GetDraftQueueItems(tab)
|
||||
.Where(x => string.Equals(x.State, state, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList())
|
||||
{
|
||||
if (session.RemoveDraft(tab, item.Id, storage))
|
||||
removed++;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
157
src/AxCopilot/Services/DraftQueueService.cs
Normal file
157
src/AxCopilot/Services/DraftQueueService.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 드래프트 큐 항목의 정렬과 상태 전이를 담당한다.
|
||||
/// 이후 command queue 실행기와 직접 연결될 수 있도록 queue policy를 분리한다.
|
||||
/// </summary>
|
||||
public sealed class DraftQueueService
|
||||
{
|
||||
public sealed class DraftQueueSummary
|
||||
{
|
||||
public int TotalCount { get; init; }
|
||||
public int QueuedCount { get; init; }
|
||||
public int RunningCount { get; init; }
|
||||
public int BlockedCount { get; init; }
|
||||
public int FailedCount { get; init; }
|
||||
public int CompletedCount { get; init; }
|
||||
public DraftQueueItem? NextItem { get; init; }
|
||||
public DateTime? NextReadyAt { get; init; }
|
||||
}
|
||||
|
||||
public DraftQueueItem CreateItem(string text, string priority = "next")
|
||||
{
|
||||
return new DraftQueueItem
|
||||
{
|
||||
Text = text.Trim(),
|
||||
Priority = NormalizePriority(priority),
|
||||
State = "queued",
|
||||
CreatedAt = DateTime.Now,
|
||||
};
|
||||
}
|
||||
|
||||
public DraftQueueItem? GetNextQueuedItem(IEnumerable<DraftQueueItem>? items, DateTime? now = null)
|
||||
{
|
||||
var at = now ?? DateTime.Now;
|
||||
return items?
|
||||
.Where(x => CanRunNow(x, at))
|
||||
.OrderBy(GetPriorityRank)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public DraftQueueSummary GetSummary(IEnumerable<DraftQueueItem>? items, DateTime? now = null)
|
||||
{
|
||||
var at = now ?? DateTime.Now;
|
||||
var snapshot = items?.ToList() ?? [];
|
||||
var nextReadyAt = snapshot
|
||||
.Where(IsBlocked)
|
||||
.Select(x => x.NextRetryAt)
|
||||
.Where(x => x.HasValue)
|
||||
.OrderBy(x => x)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new DraftQueueSummary
|
||||
{
|
||||
TotalCount = snapshot.Count,
|
||||
QueuedCount = snapshot.Count(x => CanRunNow(x, at)),
|
||||
RunningCount = snapshot.Count(x => string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase)),
|
||||
BlockedCount = snapshot.Count(IsBlocked),
|
||||
FailedCount = snapshot.Count(x => string.Equals(x.State, "failed", StringComparison.OrdinalIgnoreCase)),
|
||||
CompletedCount = snapshot.Count(x => string.Equals(x.State, "completed", StringComparison.OrdinalIgnoreCase)),
|
||||
NextItem = GetNextQueuedItem(snapshot, at),
|
||||
NextReadyAt = nextReadyAt,
|
||||
};
|
||||
}
|
||||
|
||||
public bool MarkRunning(DraftQueueItem? item)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
item.State = "running";
|
||||
item.LastError = null;
|
||||
item.NextRetryAt = null;
|
||||
item.AttemptCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool MarkCompleted(DraftQueueItem? item)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
item.State = "completed";
|
||||
item.LastError = null;
|
||||
item.NextRetryAt = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool MarkFailed(DraftQueueItem? item, string? error)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
item.State = "failed";
|
||||
item.LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim();
|
||||
item.NextRetryAt = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ScheduleRetry(DraftQueueItem? item, string? error, TimeSpan? delay = null)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
item.State = "queued";
|
||||
item.LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim();
|
||||
item.NextRetryAt = DateTime.Now.Add(delay ?? GetRetryDelay(item));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResetToQueued(DraftQueueItem? item)
|
||||
{
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
item.State = "queued";
|
||||
item.LastError = null;
|
||||
item.NextRetryAt = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public TimeSpan GetRetryDelay(DraftQueueItem item)
|
||||
{
|
||||
var seconds = Math.Min(300, 15 * Math.Max(1, (int)Math.Pow(2, Math.Max(0, item.AttemptCount - 1))));
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
public bool CanRunNow(DraftQueueItem item, DateTime? now = null)
|
||||
{
|
||||
var at = now ?? DateTime.Now;
|
||||
return IsQueued(item) && (!item.NextRetryAt.HasValue || item.NextRetryAt.Value <= at);
|
||||
}
|
||||
|
||||
private static bool IsQueued(DraftQueueItem item)
|
||||
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsBlocked(DraftQueueItem item)
|
||||
=> IsQueued(item) && item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now;
|
||||
|
||||
private static string NormalizePriority(string? priority)
|
||||
=> priority switch
|
||||
{
|
||||
"now" => "now",
|
||||
"later" => "later",
|
||||
_ => "next",
|
||||
};
|
||||
|
||||
private static int GetPriorityRank(DraftQueueItem item)
|
||||
=> NormalizePriority(item.Priority) switch
|
||||
{
|
||||
"now" => 0,
|
||||
"next" => 1,
|
||||
_ => 2,
|
||||
};
|
||||
}
|
||||
108
src/AxCopilot/Services/FaviconService.cs
Normal file
108
src/AxCopilot/Services/FaviconService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 외부 URL의 favicon을 다운로드하여 캐시합니다.
|
||||
/// Google Favicon API (https://www.google.com/s2/favicons?domain=xxx&sz=32)를 사용합니다.
|
||||
/// 캐시는 메모리(ConcurrentDictionary) + 디스크(%APPDATA%\AxCopilot\favicons\)에 저장됩니다.
|
||||
/// </summary>
|
||||
public static class FaviconService
|
||||
{
|
||||
private static readonly string CacheDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "favicons");
|
||||
|
||||
private static readonly ConcurrentDictionary<string, BitmapImage?> _memCache = new();
|
||||
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||
|
||||
/// <summary>
|
||||
/// 도메인의 favicon BitmapImage를 반환합니다.
|
||||
/// 캐시에 있으면 즉시 반환, 없으면 null 반환 후 백그라운드에서 다운로드합니다.
|
||||
/// 다운로드 완료 시 콜백을 호출합니다.
|
||||
/// </summary>
|
||||
public static BitmapImage? GetFavicon(string url, Action? onLoaded = null)
|
||||
{
|
||||
var domain = ExtractDomain(url);
|
||||
if (string.IsNullOrEmpty(domain)) return null;
|
||||
|
||||
// 메모리 캐시 확인
|
||||
if (_memCache.TryGetValue(domain, out var cached))
|
||||
return cached;
|
||||
|
||||
// 디스크 캐시 확인
|
||||
var diskPath = Path.Combine(CacheDir, $"{domain}.png");
|
||||
if (File.Exists(diskPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = LoadFromDisk(diskPath);
|
||||
_memCache[domain] = bmp;
|
||||
return bmp;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// 백그라운드에서 다운로드
|
||||
_memCache[domain] = null; // 중복 요청 방지
|
||||
_ = DownloadAsync(domain, diskPath, onLoaded);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task DownloadAsync(string domain, string diskPath, Action? onLoaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
var faviconUrl = $"https://www.google.com/s2/favicons?domain={Uri.EscapeDataString(domain)}&sz=32";
|
||||
var bytes = await _http.GetByteArrayAsync(faviconUrl).ConfigureAwait(false);
|
||||
|
||||
if (bytes.Length < 100) return; // 너무 작으면 유효한 이미지 아님
|
||||
|
||||
// 디스크 저장
|
||||
Directory.CreateDirectory(CacheDir);
|
||||
await File.WriteAllBytesAsync(diskPath, bytes).ConfigureAwait(false);
|
||||
|
||||
// 메모리 캐시 갱신
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = LoadFromDisk(diskPath);
|
||||
_memCache[domain] = bmp;
|
||||
onLoaded?.Invoke();
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"Favicon 다운로드 실패: {domain} — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapImage LoadFromDisk(string path)
|
||||
{
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bmp.DecodePixelWidth = 32;
|
||||
bmp.EndInit();
|
||||
bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private static string? ExtractDomain(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
url = "https://" + url;
|
||||
return new Uri(url).Host.ToLowerInvariant();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
108
src/AxCopilot/Services/FileDialogWatcher.cs
Normal file
108
src/AxCopilot/Services/FileDialogWatcher.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 열기/저장 대화상자(#32770)를 감지하여 이벤트를 발생시킵니다.
|
||||
/// SetWinEventHook으로 HWND 생성/소멸을 모니터링합니다.
|
||||
/// </summary>
|
||||
public class FileDialogWatcher : IDisposable
|
||||
{
|
||||
// ─── P/Invoke ────────────────────────────────────────────────────────────
|
||||
|
||||
private delegate void WinEventDelegate(IntPtr hWinEventHook, uint eventType,
|
||||
IntPtr hwnd, int idObject, int idChild, uint idEventThread, uint dwmsEventTime);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax,
|
||||
IntPtr hmodWinEventProc, WinEventDelegate lpfnWinEventProc,
|
||||
uint idProcess, uint idThread, uint dwFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
|
||||
private const uint EVENT_OBJECT_SHOW = 0x8002;
|
||||
private const uint EVENT_OBJECT_CREATE = 0x8000;
|
||||
private const uint WINEVENT_OUTOFCONTEXT = 0x0000;
|
||||
private const uint WINEVENT_SKIPOWNPROCESS = 0x0002;
|
||||
|
||||
// ─── 상태 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private IntPtr _hook;
|
||||
private WinEventDelegate? _delegate; // prevent GC
|
||||
private bool _disposed;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
/// <summary>열기/저장 대화상자가 감지되면 발생합니다. IntPtr = 대화상자 HWND.</summary>
|
||||
public event EventHandler<IntPtr>? FileDialogOpened;
|
||||
|
||||
public FileDialogWatcher()
|
||||
{
|
||||
_dispatcher = Dispatcher.CurrentDispatcher;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_hook != IntPtr.Zero) return;
|
||||
_delegate = OnWinEvent;
|
||||
_hook = SetWinEventHook(
|
||||
EVENT_OBJECT_SHOW, EVENT_OBJECT_SHOW,
|
||||
IntPtr.Zero, _delegate,
|
||||
0, 0,
|
||||
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_hook != IntPtr.Zero)
|
||||
{
|
||||
UnhookWinEvent(_hook);
|
||||
_hook = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWinEvent(IntPtr hWinEventHook, uint eventType,
|
||||
IntPtr hwnd, int idObject, int idChild, uint idEventThread, uint dwmsEventTime)
|
||||
{
|
||||
if (hwnd == IntPtr.Zero || idObject != 0) return;
|
||||
if (!IsWindowVisible(hwnd)) return;
|
||||
|
||||
// 클래스명 #32770 = 공통 대화상자
|
||||
var sb = new StringBuilder(256);
|
||||
GetClassName(hwnd, sb, 256);
|
||||
var className = sb.ToString();
|
||||
|
||||
if (className != "#32770") return;
|
||||
|
||||
// 창 제목으로 열기/저장 대화상자인지 확인
|
||||
var titleSb = new StringBuilder(256);
|
||||
GetWindowText(hwnd, titleSb, 256);
|
||||
var title = titleSb.ToString();
|
||||
|
||||
if (title.Contains("열기") || title.Contains("Open") ||
|
||||
title.Contains("저장") || title.Contains("Save") ||
|
||||
title.Contains("다른 이름") || title.Contains("Browse") ||
|
||||
title.Contains("폴더") || title.Contains("Folder"))
|
||||
{
|
||||
_dispatcher.BeginInvoke(() => FileDialogOpened?.Invoke(this, hwnd));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
87
src/AxCopilot/Services/GuideEncryptor.cs
Normal file
87
src/AxCopilot/Services/GuideEncryptor.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 가이드 HTML 파일 암호화/복호화 유틸리티.
|
||||
/// 고정 AES-256-CBC 키를 사용하여 모든 PC에서 동일하게 작동합니다.
|
||||
/// </summary>
|
||||
public static class GuideEncryptor
|
||||
{
|
||||
// 고정 키 (32바이트 = AES-256) — 앱 내장 가이드 보호용
|
||||
private static readonly byte[] Key =
|
||||
{
|
||||
0x41, 0x58, 0x43, 0x6F, 0x70, 0x69, 0x6C, 0x6F,
|
||||
0x74, 0x47, 0x75, 0x69, 0x64, 0x65, 0x4B, 0x65,
|
||||
0x79, 0x32, 0x30, 0x32, 0x36, 0x53, 0x65, 0x63,
|
||||
0x75, 0x72, 0x65, 0x46, 0x69, 0x78, 0x65, 0x64
|
||||
};
|
||||
|
||||
// 고정 IV (16바이트) — 동일 출력 보장
|
||||
private static readonly byte[] Iv =
|
||||
{
|
||||
0x47, 0x75, 0x69, 0x64, 0x65, 0x49, 0x56, 0x46,
|
||||
0x69, 0x78, 0x65, 0x64, 0x32, 0x30, 0x32, 0x36
|
||||
};
|
||||
|
||||
/// <summary>평문 HTML 파일을 암호화하여 .enc 파일로 저장합니다.</summary>
|
||||
public static void EncryptFile(string inputHtmlPath, string outputEncPath)
|
||||
{
|
||||
var plaintext = File.ReadAllBytes(inputHtmlPath);
|
||||
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = Key;
|
||||
aes.IV = Iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var encrypted = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);
|
||||
File.WriteAllBytes(outputEncPath, encrypted);
|
||||
}
|
||||
|
||||
/// <summary>암호화된 .enc 파일을 복호화하여 HTML 문자열로 반환합니다.</summary>
|
||||
public static string DecryptToString(string encFilePath)
|
||||
{
|
||||
var encrypted = File.ReadAllBytes(encFilePath);
|
||||
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = Key;
|
||||
aes.IV = Iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decrypted = decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
|
||||
/// <summary>암호화된 바이트 배열을 복호화하여 HTML 문자열로 반환합니다.</summary>
|
||||
public static string DecryptToString(byte[] encrypted)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = Key;
|
||||
aes.IV = Iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decrypted = decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
|
||||
/// <summary>Assets 폴더의 가이드 파일을 암호화합니다. 빌드 전 수동 실행용.</summary>
|
||||
public static void EncryptGuides(string assetsFolder)
|
||||
{
|
||||
var userGuide = Path.Combine(assetsFolder, "AX Copilot 사용가이드.htm");
|
||||
var devGuide = Path.Combine(assetsFolder, "AX Copilot 개발자가이드.htm");
|
||||
|
||||
if (File.Exists(userGuide))
|
||||
EncryptFile(userGuide, Path.Combine(assetsFolder, "guide_user.enc"));
|
||||
|
||||
if (File.Exists(devGuide))
|
||||
EncryptFile(devGuide, Path.Combine(assetsFolder, "guide_dev.enc"));
|
||||
}
|
||||
}
|
||||
568
src/AxCopilot/Services/IndexService.cs
Normal file
568
src/AxCopilot/Services/IndexService.cs
Normal file
@@ -0,0 +1,568 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다.
|
||||
/// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다.
|
||||
/// </summary>
|
||||
public class IndexService : IDisposable
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private List<IndexEntry> _index = new();
|
||||
private readonly List<FileSystemWatcher> _watchers = new();
|
||||
private System.Threading.Timer? _debounceTimer;
|
||||
private readonly object _timerLock = new();
|
||||
private const int DebounceMs = 3000;
|
||||
private readonly SemaphoreSlim _rebuildLock = new(1, 1);
|
||||
|
||||
public IReadOnlyList<IndexEntry> Entries => _index;
|
||||
|
||||
public event EventHandler? IndexRebuilt;
|
||||
public TimeSpan LastIndexDuration { get; private set; }
|
||||
public int LastIndexCount { get; private set; }
|
||||
|
||||
public IndexService(SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 앱 시작 시 전체 인덱스 빌드 후 FileSystemWatcher 시작.
|
||||
/// </summary>
|
||||
public async Task BuildAsync(CancellationToken ct = default)
|
||||
{
|
||||
await _rebuildLock.WaitAsync(ct);
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var entries = new List<IndexEntry>();
|
||||
var paths = _settings.Settings.IndexPaths
|
||||
.Select(p => Environment.ExpandEnvironmentVariables(p));
|
||||
var allowedExts = new HashSet<string>(
|
||||
_settings.Settings.IndexExtensions
|
||||
.Select(e => e.ToLowerInvariant().StartsWith(".") ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var indexSpeed = _settings.Settings.IndexSpeed ?? "normal";
|
||||
|
||||
foreach (var dir in paths)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
await ScanDirectoryAsync(dir, entries, allowedExts, indexSpeed, ct);
|
||||
}
|
||||
|
||||
// Alias도 인덱스에 포함 (type에 따라 IndexEntryType 구분)
|
||||
foreach (var alias in _settings.Settings.Aliases)
|
||||
{
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = alias.Key,
|
||||
DisplayName = alias.Description ?? alias.Key,
|
||||
Path = alias.Target,
|
||||
AliasType = alias.Type,
|
||||
Type = alias.Type switch
|
||||
{
|
||||
"app" => IndexEntryType.App,
|
||||
"folder" => IndexEntryType.Folder,
|
||||
_ => IndexEntryType.Alias // url, batch, api, clipboard
|
||||
},
|
||||
Score = 100 // Alias는 최우선
|
||||
});
|
||||
}
|
||||
|
||||
// Built-in 앱 별칭 (한글+영문 이름으로 즉시 실행)
|
||||
RegisterBuiltInApps(entries);
|
||||
|
||||
// 검색 가속 캐시 일괄 계산 (ToLower·자모·초성을 빌드 시 1회만 수행)
|
||||
ComputeAllSearchCaches(entries);
|
||||
|
||||
_index = entries;
|
||||
sw.Stop();
|
||||
LastIndexDuration = sw.Elapsed;
|
||||
LastIndexCount = entries.Count;
|
||||
LogService.Info($"인덱싱 완료: {entries.Count}개 항목 ({sw.Elapsed.TotalSeconds:F1}초)");
|
||||
IndexRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rebuildLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다.
|
||||
/// BuildAsync() 완료 후 호출하세요.
|
||||
/// </summary>
|
||||
public void StartWatchers()
|
||||
{
|
||||
StopWatchers();
|
||||
|
||||
foreach (var rawPath in _settings.Settings.IndexPaths)
|
||||
{
|
||||
var dir = Environment.ExpandEnvironmentVariables(rawPath);
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var w = new FileSystemWatcher(dir)
|
||||
{
|
||||
Filter = "*.*",
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
w.Created += OnWatcherEvent;
|
||||
w.Deleted += OnWatcherEvent;
|
||||
w.Renamed += OnWatcherRenamed;
|
||||
_watchers.Add(w);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"FileSystemWatcher 생성 실패: {dir} - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
LogService.Info($"파일 감시 시작: {_watchers.Count}개 경로");
|
||||
}
|
||||
|
||||
private void StopWatchers()
|
||||
{
|
||||
foreach (var w in _watchers)
|
||||
{
|
||||
w.EnableRaisingEvents = false;
|
||||
w.Dispose();
|
||||
}
|
||||
_watchers.Clear();
|
||||
}
|
||||
|
||||
private void OnWatcherEvent(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
ScheduleRebuild(e.FullPath);
|
||||
}
|
||||
|
||||
private void OnWatcherRenamed(object sender, RenamedEventArgs e)
|
||||
{
|
||||
ScheduleRebuild(e.FullPath);
|
||||
}
|
||||
|
||||
private void ScheduleRebuild(string triggerPath)
|
||||
{
|
||||
LogService.Info($"파일 변경 감지: {triggerPath} — {DebounceMs}ms 후 재빌드 예약");
|
||||
lock (_timerLock)
|
||||
{
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = new System.Threading.Timer(__ =>
|
||||
{
|
||||
_ = BuildAsync();
|
||||
}, null, DebounceMs, System.Threading.Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_debounceTimer?.Dispose();
|
||||
StopWatchers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 자주 사용하는 Windows 기본 앱을 한글+영문 이름으로 등록합니다.
|
||||
/// 실제로 존재하는 경우에만 인덱스에 추가됩니다.
|
||||
/// </summary>
|
||||
private static void RegisterBuiltInApps(List<IndexEntry> entries)
|
||||
{
|
||||
// (displayName, keywords[], exePath)
|
||||
var builtIns = new (string Display, string[] Names, string? Exe)[]
|
||||
{
|
||||
// 메모장
|
||||
("메모장 (Notepad)", new[] { "메모장", "notepad", "note", "txt" },
|
||||
@"C:\Windows\notepad.exe"),
|
||||
// 계산기
|
||||
("계산기 (Calculator)", new[] { "계산기", "calc", "calculator" },
|
||||
"calculator:"), // UWP protocol
|
||||
// 캡처 도구 (Snipping Tool)
|
||||
("캡처 도구 (Snipping Tool)", new[] { "캡처", "캡처도구", "snippingtool", "snip", "스크린샷" },
|
||||
@"C:\Windows\System32\SnippingTool.exe"),
|
||||
// 그림판
|
||||
("그림판 (Paint)", new[] { "그림판", "mspaint", "paint" },
|
||||
@"C:\Windows\System32\mspaint.exe"),
|
||||
// 탐색기
|
||||
("파일 탐색기 (Explorer)", new[] { "탐색기", "explorer", "파일탐색기" },
|
||||
@"C:\Windows\explorer.exe"),
|
||||
// 명령 프롬프트
|
||||
("명령 프롬프트 (CMD)", new[] { "cmd", "명령프롬프트", "커맨드", "터미널" },
|
||||
@"C:\Windows\System32\cmd.exe"),
|
||||
// PowerShell
|
||||
("PowerShell", new[] { "powershell", "파워쉘" },
|
||||
@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
|
||||
// 제어판
|
||||
("제어판 (Control Panel)", new[] { "제어판", "control", "controlpanel" },
|
||||
@"C:\Windows\System32\control.exe"),
|
||||
// 작업 관리자
|
||||
("작업 관리자 (Task Manager)", new[] { "작업관리자", "taskmgr", "taskmanager" },
|
||||
@"C:\Windows\System32\Taskmgr.exe"),
|
||||
// 원격 데스크톱
|
||||
("원격 데스크톱 (Remote Desktop)", new[] { "원격", "mstsc", "rdp", "원격데스크톱" },
|
||||
@"C:\Windows\System32\mstsc.exe"),
|
||||
// 레지스트리 편집기
|
||||
("레지스트리 편집기", new[] { "regedit", "레지스트리" },
|
||||
@"C:\Windows\regedit.exe"),
|
||||
// 장치 관리자
|
||||
("장치 관리자", new[] { "장치관리자", "devmgmt", "devicemanager" },
|
||||
@"C:\Windows\System32\devmgmt.msc"),
|
||||
// Microsoft Edge
|
||||
("Microsoft Edge", new[] { "edge", "엣지", "브라우저" },
|
||||
@"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||||
// Excel — 확장자 + MS prefix + 한글
|
||||
("Microsoft Excel", new[] { "엑셀", "excel", "msexcel", "xlsx", "xls", "csv" },
|
||||
FindOfficeApp("EXCEL.EXE")),
|
||||
// PowerPoint
|
||||
("Microsoft PowerPoint", new[] { "파워포인트", "powerpoint", "mspowerpoint", "pptx", "ppt" },
|
||||
FindOfficeApp("POWERPNT.EXE")),
|
||||
// Word
|
||||
("Microsoft Word", new[] { "워드", "word", "msword", "docx", "doc" },
|
||||
FindOfficeApp("WINWORD.EXE")),
|
||||
// Outlook
|
||||
("Microsoft Outlook", new[] { "아웃룩", "outlook", "메일" },
|
||||
FindOfficeApp("OUTLOOK.EXE")),
|
||||
// Teams
|
||||
("Microsoft Teams", new[] { "팀즈", "teams" },
|
||||
FindTeams()),
|
||||
// OneNote
|
||||
("Microsoft OneNote", new[] { "원노트", "onenote" },
|
||||
FindOfficeApp("ONENOTE.EXE")),
|
||||
// Access
|
||||
("Microsoft Access", new[] { "액세스", "access", "msaccess" },
|
||||
FindOfficeApp("MSACCESS.EXE")),
|
||||
// VS Code
|
||||
("Visual Studio Code", new[] { "vscode", "비주얼스튜디오코드", "코드에디터", "code" },
|
||||
FindInPath("code.cmd") ?? FindInLocalAppData(@"Programs\Microsoft VS Code\Code.exe")),
|
||||
// Visual Studio
|
||||
("Visual Studio", new[] { "비주얼스튜디오", "devenv", "vs2022", "vs2019", "visualstudio" },
|
||||
FindInProgramFiles(@"Microsoft Visual Studio\2022\Community\Common7\IDE\devenv.exe")
|
||||
?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Professional\Common7\IDE\devenv.exe")
|
||||
?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Enterprise\Common7\IDE\devenv.exe")),
|
||||
// Windows Terminal
|
||||
("Windows Terminal", new[] { "윈도우터미널", "wt", "windowsterminal", "터미널" },
|
||||
FindInLocalAppData(@"Microsoft\WindowsApps\wt.exe")),
|
||||
// OneDrive
|
||||
("OneDrive", new[] { "원드라이브", "onedrive" },
|
||||
FindInLocalAppData(@"Microsoft\OneDrive\OneDrive.exe")),
|
||||
// Google Chrome
|
||||
("Google Chrome", new[] { "크롬", "구글크롬", "chrome", "google" },
|
||||
FindInProgramFiles(@"Google\Chrome\Application\chrome.exe")
|
||||
?? FindInLocalAppData(@"Google\Chrome\Application\chrome.exe")),
|
||||
// Firefox
|
||||
("Mozilla Firefox", new[] { "파이어폭스", "불여우", "firefox" },
|
||||
FindInProgramFiles(@"Mozilla Firefox\firefox.exe")),
|
||||
// Naver Whale
|
||||
("Naver Whale", new[] { "웨일", "네이버웨일", "whale" },
|
||||
FindInLocalAppData(@"Naver\Naver Whale\Application\whale.exe")),
|
||||
// KakaoTalk
|
||||
("KakaoTalk", new[] { "카카오톡", "카톡", "kakaotalk", "kakao" },
|
||||
FindInLocalAppData(@"Kakao\KakaoTalk\KakaoTalk.exe")),
|
||||
// KakaoWork
|
||||
("KakaoWork", new[] { "카카오워크", "카워크", "kakaowork" },
|
||||
FindInLocalAppData(@"Kakao\KakaoWork\KakaoWork.exe")),
|
||||
// Zoom
|
||||
("Zoom", new[] { "줌", "zoom" },
|
||||
FindInRoaming(@"Zoom\bin\Zoom.exe")
|
||||
?? FindInLocalAppData(@"Zoom\bin\Zoom.exe")),
|
||||
// Slack
|
||||
("Slack", new[] { "슬랙", "slack" },
|
||||
FindInLocalAppData(@"slack\slack.exe")),
|
||||
// Figma
|
||||
("Figma", new[] { "피그마", "figma" },
|
||||
FindInLocalAppData(@"Figma\Figma.exe")),
|
||||
// Notepad++
|
||||
("Notepad++", new[] { "노트패드++", "npp", "notepad++" },
|
||||
FindInProgramFiles(@"Notepad++\notepad++.exe")),
|
||||
// 7-Zip
|
||||
("7-Zip", new[] { "7zip", "7집", "세븐집", "7z" },
|
||||
FindInProgramFiles(@"7-Zip\7zFM.exe")),
|
||||
// Bandizip
|
||||
("Bandizip", new[] { "반디집", "bandizip" },
|
||||
FindInProgramFiles(@"Bandizip\Bandizip.exe")
|
||||
?? FindInLocalAppData(@"Bandizip\Bandizip.exe")),
|
||||
// ALZip
|
||||
("ALZip", new[] { "알집", "alzip", "이스트소프트" },
|
||||
FindInProgramFiles(@"ESTsoft\ALZip\ALZip.exe")),
|
||||
// PotPlayer
|
||||
("PotPlayer", new[] { "팟플레이어", "팟플", "potplayer" },
|
||||
FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini64.exe")
|
||||
?? FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini.exe")),
|
||||
// GOM Player
|
||||
("GOM Player", new[] { "곰플레이어", "곰플", "gomplayer", "gom" },
|
||||
FindInProgramFiles(@"GRETECH\GomPlayer\GOM.EXE")),
|
||||
// Adobe Photoshop
|
||||
("Adobe Photoshop", new[] { "포토샵", "포샵", "photoshop", "ps" },
|
||||
FindInProgramFiles(@"Adobe\Adobe Photoshop 2025\Photoshop.exe")
|
||||
?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2024\Photoshop.exe")
|
||||
?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2023\Photoshop.exe")),
|
||||
// Adobe Acrobat
|
||||
("Adobe Acrobat", new[] { "아크로뱃", "acrobat", "아도비pdf" },
|
||||
FindInProgramFiles(@"Adobe\Acrobat DC\Acrobat\Acrobat.exe")),
|
||||
// 한컴 한글
|
||||
("한컴 한글", new[] { "한글", "한컴한글", "hwp", "hangul", "아래아한글" },
|
||||
FindInProgramFiles(@"HNC\Hwp\Hwp.exe")
|
||||
?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hwp.exe")
|
||||
?? FindInProgramFiles(@"Hnc\Hwp80\Hwp.exe")),
|
||||
// 한컴 한셀
|
||||
("한컴 한셀", new[] { "한셀", "hcell", "한컴스프레드" },
|
||||
FindInProgramFiles(@"HNC\Hwp\Hcell.exe")
|
||||
?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hcell.exe")),
|
||||
// 한컴 한쇼
|
||||
("한컴 한쇼", new[] { "한쇼", "hshow", "한컴프레젠테이션" },
|
||||
FindInProgramFiles(@"HNC\Hwp\Show.exe")
|
||||
?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Show.exe")),
|
||||
};
|
||||
|
||||
// 기존 항목의 이름 → 인덱스 매핑 + Path 기반 중복 방지
|
||||
var nameToIdx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var pathToIdx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
nameToIdx.TryAdd(entries[i].Name, i);
|
||||
pathToIdx.TryAdd(entries[i].Path, i);
|
||||
}
|
||||
|
||||
foreach (var (display, names, exe) in builtIns)
|
||||
{
|
||||
if (string.IsNullOrEmpty(exe)) continue;
|
||||
// UWP protocol은 파일 존재 체크 불필요
|
||||
if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc")) continue;
|
||||
|
||||
// 같은 경로가 이미 인덱스에 있으면 DisplayName과 Score만 업데이트
|
||||
if (pathToIdx.TryGetValue(exe, out var existingIdx))
|
||||
{
|
||||
entries[existingIdx].DisplayName = display;
|
||||
if (entries[existingIdx].Score < 95) entries[existingIdx].Score = 95;
|
||||
}
|
||||
|
||||
// ── 키워드별 IndexEntry 등록 ─────────────────────────────────────────
|
||||
// - 한글 이름: 항상 IndexEntry로 추가 (앱이 이미 스캔됐어도)
|
||||
// → "엑" 입력 시 "엑셀" IndexEntry의 StartsWith("엑")으로 매칭
|
||||
// - 영문/약어: 앱이 미스캔 상태일 때 첫 키워드만 대표 항목으로 등록
|
||||
// - 경로 중복은 CommandResolver 레이어에서 seenPaths로 최고점만 표시
|
||||
bool mainAdded = pathToIdx.ContainsKey(exe);
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (nameToIdx.ContainsKey(name))
|
||||
{
|
||||
// 이미 같은 이름 존재 → Score만 부스트
|
||||
if (nameToIdx.TryGetValue(name, out var idx) && entries[idx].Score < 95)
|
||||
entries[idx].Score = 95;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 한글 음절이 포함된 이름은 항상 IndexEntry로 추가
|
||||
bool isKoreanName = name.Any(c => c >= '\uAC00' && c <= '\uD7A3');
|
||||
|
||||
if (!mainAdded || isKoreanName)
|
||||
{
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = display,
|
||||
Path = exe,
|
||||
Type = IndexEntryType.App,
|
||||
Score = 95
|
||||
});
|
||||
var newIdx = entries.Count - 1;
|
||||
nameToIdx[name] = newIdx;
|
||||
if (!mainAdded)
|
||||
{
|
||||
pathToIdx[exe] = newIdx;
|
||||
mainAdded = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 영문/약어는 pathToIdx 참조만 (FuzzyEngine은 영문 앱명 직접 매칭)
|
||||
nameToIdx[name] = pathToIdx[exe];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindOfficeApp(string exeName)
|
||||
{
|
||||
var roots = new[]
|
||||
{
|
||||
@"C:\Program Files\Microsoft Office\root\Office16",
|
||||
@"C:\Program Files (x86)\Microsoft Office\root\Office16",
|
||||
@"C:\Program Files\Microsoft Office\Office16",
|
||||
@"C:\Program Files (x86)\Microsoft Office\Office16",
|
||||
};
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var path = Path.Combine(root, exeName);
|
||||
if (File.Exists(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindTeams()
|
||||
{
|
||||
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe");
|
||||
if (File.Exists(path)) return path;
|
||||
// New Teams (MSIX)
|
||||
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
|
||||
return File.Exists(msTeams) ? msTeams : null;
|
||||
}
|
||||
|
||||
private static string? FindInPath(string fileName)
|
||||
{
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathEnv.Split(';'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) continue;
|
||||
var full = Path.Combine(dir.Trim(), fileName);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindInLocalAppData(string relativePath)
|
||||
{
|
||||
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var path = Path.Combine(localApp, relativePath);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
|
||||
private static string? FindInProgramFiles(string relativePath)
|
||||
{
|
||||
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||
var p1 = Path.Combine(pf, relativePath);
|
||||
if (File.Exists(p1)) return p1;
|
||||
var p2 = Path.Combine(pf86, relativePath);
|
||||
return File.Exists(p2) ? p2 : null;
|
||||
}
|
||||
|
||||
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
|
||||
private static string? FindInRoaming(string relativePath)
|
||||
{
|
||||
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var path = Path.Combine(roaming, relativePath);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
|
||||
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
ComputeSearchCache(e);
|
||||
}
|
||||
|
||||
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
|
||||
private static void ComputeSearchCache(IndexEntry entry)
|
||||
{
|
||||
entry.NameLower = entry.Name.ToLowerInvariant();
|
||||
// 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근)
|
||||
entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower);
|
||||
// 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ")
|
||||
var sb = new System.Text.StringBuilder(entry.NameLower.Length);
|
||||
foreach (var c in entry.NameLower)
|
||||
{
|
||||
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
|
||||
if (cho != '\0') sb.Append(cho);
|
||||
}
|
||||
entry.NameChosung = sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
|
||||
private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch
|
||||
{
|
||||
"fast" => (500, 0), // 최대 속도, CPU 양보 없음
|
||||
"slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소
|
||||
_ => (150, 5), // normal: 150개마다 5ms → 적정 균형
|
||||
};
|
||||
|
||||
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
|
||||
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
|
||||
{
|
||||
var (batchSize, delayMs) = GetThrottle(indexSpeed);
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
int count = 0;
|
||||
|
||||
// 파일 인덱싱 (확장자 필터 적용)
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue;
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
var type = ext switch
|
||||
{
|
||||
".exe" => IndexEntryType.App,
|
||||
".lnk" or ".url" => IndexEntryType.File,
|
||||
_ => IndexEntryType.File
|
||||
};
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext,
|
||||
Path = file,
|
||||
Type = type
|
||||
});
|
||||
|
||||
// 속도 조절: batchSize개마다 CPU 양보
|
||||
if (delayMs > 0 && ++count % batchSize == 0)
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
// 폴더 인덱싱 (1단계 하위 폴더)
|
||||
foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(subDir);
|
||||
if (name.StartsWith(".")) continue;
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
Path = subDir,
|
||||
Type = IndexEntryType.Folder
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class IndexEntry
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public IndexEntryType Type { get; set; }
|
||||
public int Score { get; set; } = 0;
|
||||
/// <summary>Alias 항목의 원본 type 문자열 (url | folder | app | batch | api | clipboard)</summary>
|
||||
public string? AliasType { get; set; }
|
||||
|
||||
// ─ 검색 가속 캐시 (BuildAsync 시 1회 계산, 검색마다 재계산 방지) ──────
|
||||
/// <summary>ToLowerInvariant() 결과 캐시</summary>
|
||||
public string NameLower { get; set; } = "";
|
||||
/// <summary>자모 분리 문자열 캐시 (DecomposeToJamo 결과)</summary>
|
||||
public string NameJamo { get; set; } = "";
|
||||
/// <summary>초성만 연결한 문자열 캐시 (예: "엑셀" → "ㅇㅅ")</summary>
|
||||
public string NameChosung { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum IndexEntryType { App, File, Alias, Folder }
|
||||
177
src/AxCopilot/Services/IntentDetector.cs
Normal file
177
src/AxCopilot/Services/IntentDetector.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 메시지에서 질문 유형(인텐트)을 감지하는 로컬 키워드 기반 분류기.
|
||||
/// 외부 API 없이 순수 키워드 매칭으로 동작합니다.
|
||||
/// </summary>
|
||||
public static class IntentDetector
|
||||
{
|
||||
/// <summary>인텐트 카테고리 상수.</summary>
|
||||
public static class Categories
|
||||
{
|
||||
public const string Coding = "coding";
|
||||
public const string Translation = "translation";
|
||||
public const string Analysis = "analysis";
|
||||
public const string Creative = "creative";
|
||||
public const string Document = "document";
|
||||
public const string Math = "math";
|
||||
public const string General = "general";
|
||||
|
||||
public static readonly string[] All =
|
||||
{
|
||||
Coding, Translation, Analysis, Creative, Document, Math, General
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>카테고리별 키워드 사전. (키워드, 가중치) 쌍.</summary>
|
||||
private static readonly Dictionary<string, (string Keyword, double Weight)[]> _keywords = new()
|
||||
{
|
||||
[Categories.Coding] = new (string, double)[]
|
||||
{
|
||||
// 한국어
|
||||
("코드", 1.0), ("함수", 1.0), ("클래스", 1.0), ("버그", 1.2), ("디버그", 1.2),
|
||||
("리팩토링", 1.5), ("컴파일", 1.2), ("에러", 0.8), ("구현", 0.9), ("개발", 0.7),
|
||||
("테스트", 0.8), ("빌드", 1.0), ("배포", 0.8), ("커밋", 1.2), ("브랜치", 1.2),
|
||||
("머지", 1.0), ("풀리퀘", 1.2), ("변수", 1.0), ("메서드", 1.2), ("인터<EC9DB8><ED84B0>이스", 1.0),
|
||||
("타입", 0.6), ("파라미터", 1.0), ("반환", 0.8), ("예외", 1.0), ("스택", 0.9),
|
||||
("알고리즘", 1.2), ("자료구조", 1.2), ("정렬", 0.8), ("재귀", 1.0), ("루프", 0.9),
|
||||
("API", 1.0), ("SDK", 1.0), ("라이브러리", 0.8), ("패키지", 0.8), ("모듈", 0.8),
|
||||
("깃", 1.0), ("레포", 1.0), ("소스", 0.7), ("프로그래밍", 1.0), ("코딩", 1.2),
|
||||
// 영어
|
||||
("code", 1.0), ("function", 1.0), ("class", 0.8), ("bug", 1.2), ("debug", 1.2),
|
||||
("refactor", 1.5), ("compile", 1.2), ("error", 0.6), ("implement", 1.0), ("develop", 0.7),
|
||||
("test", 0.7), ("build", 0.8), ("deploy", 0.8), ("commit", 1.2), ("branch", 1.2),
|
||||
("merge", 1.0), ("variable", 1.0), ("method", 1.0), ("interface", 0.8),
|
||||
("parameter", 1.0), ("return", 0.6), ("exception", 1.0), ("stack", 0.7),
|
||||
("algorithm", 1.2), ("syntax", 1.2), ("runtime", 1.0), ("compile", 1.0),
|
||||
("git", 1.2), ("npm", 1.0), ("pip", 1.0), ("nuget", 1.0), ("docker", 1.0),
|
||||
},
|
||||
[Categories.Translation] = new (string, double)[]
|
||||
{
|
||||
("번역", 2.0), ("영어로", 2.0), ("한국어로", 2.0), ("일본어로", 2.0), ("중국어로", 2.0),
|
||||
("영문", 1.5), ("국문", 1.5), ("통역", 1.5), ("원문", 1.2), ("의역", 1.5), ("직역", 1.5),
|
||||
("translate", 2.0), ("English", 1.0), ("Korean", 1.0), ("Japanese", 1.0), ("Chinese", 1.0),
|
||||
("translation", 2.0), ("localize", 1.5), ("localization", 1.5),
|
||||
},
|
||||
[Categories.Analysis] = new (string, double)[]
|
||||
{
|
||||
("분석", 1.5), ("요약", 1.5), ("비교", 1.2), ("장<><EC9EA5>점", 1.5), ("평가", 1.2),
|
||||
("검토", 1.0), ("리뷰", 0.8), ("통계", 1.2), ("데이터", 0.8), ("트렌드", 1.0),
|
||||
("인사이트", 1.2), ("근거", 1.0), ("원인", 0.8), ("결론", 0.8), ("핵심", 0.7),
|
||||
("analyze", 1.5), ("summarize", 1.5), ("compare", 1.2), ("evaluate", 1.2),
|
||||
("review", 0.8), ("statistics", 1.2), ("data", 0.6), ("trend", 1.0),
|
||||
("insight", 1.2), ("pros", 1.0), ("cons", 1.0), ("conclusion", 0.8),
|
||||
},
|
||||
[Categories.Creative] = new (string, double)[]
|
||||
{
|
||||
("작성", 0.8), ("글쓰기", 1.5), ("스토리", 1.5), ("시", 1.2), ("소설", 1.5),
|
||||
("에<><EC9790>이", 1.5), ("블로그", 1.2), ("카피", 1.2), ("슬로건", 1.5), ("제목", 0.8),
|
||||
("아이디어", 1.0), ("창작", 1.5), ("묘사", 1.2), ("대본", 1.5), ("가사", 1.5),
|
||||
("story", 1.5), ("poem", 1.5), ("essay", 1.5), ("blog", 1.2), ("creative", 1.5),
|
||||
("slogan", 1.5), ("copy", 0.8), ("fiction", 1.5), ("narrative", 1.2), ("lyrics", 1.5),
|
||||
},
|
||||
[Categories.Document] = new (string, double)[]
|
||||
{
|
||||
("보고서", 2.0), ("문서", 1.2), ("제안서", 2.0), ("기획서", 2.0), ("계획서", 1.8),
|
||||
("발표자료", 2.0), ("프레젠테이션", 2.0), ("양식", 1.5), ("서식", 1.5), ("템플릿", 1.2),
|
||||
("<22><>셀", 1.5), ("워드", 1.2), ("파워포인트", 1.5), ("PDF", 0.8), ("CSV", 1.0),
|
||||
("회의록", 2.0), ("업무일지", 2.0), ("주간보고", 2.0), ("월간보고", 2.0),
|
||||
("report", 1.8), ("document", 1.0), ("proposal", 2.0), ("presentation", 2.0),
|
||||
("template", 1.2), ("spreadsheet", 1.5), ("excel", 1.5), ("memo", 1.2),
|
||||
},
|
||||
[Categories.Math] = new (string, double)[]
|
||||
{
|
||||
("수학", 1.5), ("계산", 1.2), ("방정식", 2.0), ("증명", 2.0), ("미적분", 2.0),
|
||||
("통계", 1.0), ("확률", 1.5), ("행렬", 2.0), ("벡터", 1.5), ("미분", 2.0),
|
||||
("적분", 2.0), ("함수", 0.5), ("그래프", 0.8), ("좌표", 1.5), ("기하", 1.5),
|
||||
("삼각함수", 2.0), ("로그", 1.0), ("지수", 1.0), ("급수", 2.0), ("극한", 2.0),
|
||||
("math", 1.5), ("calculate", 1.2), ("equation", 2.0), ("proof", 2.0), ("calculus", 2.0),
|
||||
("probability", 1.5), ("matrix", 2.0), ("vector", 1.5), ("derivative", 2.0),
|
||||
("integral", 2.0), ("theorem", 2.0), ("formula", 1.5), ("algebra", 1.5),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 메시지에서 인텐트를 감지합니다.
|
||||
/// </summary>
|
||||
/// <returns>(카테고리명, 확신도 0.0~1.0). 매칭 없으면 ("general", 0.0).</returns>
|
||||
public static (string Category, double Confidence) Detect(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return (Categories.General, 0.0);
|
||||
|
||||
var lowerMessage = message.ToLowerInvariant();
|
||||
var words = lowerMessage.Split(new[] { ' ', '\t', '\n', '\r', ',', '.', '!', '?', ';', ':', '(', ')', '[', ']', '{', '}' },
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
var wordSet = new HashSet<string>(words);
|
||||
|
||||
var scores = new Dictionary<string, double>();
|
||||
double maxScore = 0;
|
||||
|
||||
foreach (var (category, keywords) in _keywords)
|
||||
{
|
||||
double score = 0;
|
||||
int hits = 0;
|
||||
|
||||
foreach (var (keyword, weight) in keywords)
|
||||
{
|
||||
var lowerKeyword = keyword.ToLowerInvariant();
|
||||
|
||||
// 한국어: substring 매칭 (조사 붙어도 감지)
|
||||
// 영어: 단어 경계 매칭 (대소문자 무시)
|
||||
bool matched = IsKorean(keyword)
|
||||
? lowerMessage.Contains(lowerKeyword)
|
||||
: wordSet.Contains(lowerKeyword);
|
||||
|
||||
if (matched)
|
||||
{
|
||||
score += weight;
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
|
||||
scores[category] = score;
|
||||
if (score > maxScore) maxScore = score;
|
||||
}
|
||||
|
||||
if (maxScore < 1.0)
|
||||
return (Categories.General, 0.0);
|
||||
|
||||
// 최고 점수 카테고리 선택
|
||||
var bestCategory = Categories.General;
|
||||
double bestScore = 0;
|
||||
foreach (var (cat, score) in scores)
|
||||
{
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestCategory = cat;
|
||||
}
|
||||
}
|
||||
|
||||
// 확신도: 최고 점수를 정규화 (점수 범위를 0~1로 변환)
|
||||
// 점수 3.0 이상이면 확신도 0.9+, 2.0이면 0.7~0.8 수준
|
||||
double confidence = Math.Min(1.0, bestScore / 4.0 + 0.3);
|
||||
|
||||
// 2위와의 차이가 작으면 확신도 낮춤 (모호한 경우)
|
||||
var sortedScores = scores.Values.OrderByDescending(s => s).ToArray();
|
||||
if (sortedScores.Length >= 2 && sortedScores[1] > 0)
|
||||
{
|
||||
double ratio = sortedScores[1] / sortedScores[0];
|
||||
if (ratio > 0.7) confidence *= 0.8; // 2위가 70% 이상이면 20% 감<><EAB090>
|
||||
}
|
||||
|
||||
return (bestCategory, Math.Round(confidence, 2));
|
||||
}
|
||||
|
||||
/// <summary>문자열에 한국어 문자가 포함되어 있는지 확인.</summary>
|
||||
private static bool IsKorean(string text)
|
||||
{
|
||||
foreach (var ch in text)
|
||||
{
|
||||
if (ch >= 0xAC00 && ch <= 0xD7A3) return true; // 완성형 한글
|
||||
if (ch >= 0x3131 && ch <= 0x318E) return true; // 자모
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
314
src/AxCopilot/Services/L10n.cs
Normal file
314
src/AxCopilot/Services/L10n.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 간단한 다국어 문자열 서비스.
|
||||
/// 설정의 Language 값(ko/en/ja/zh/vi)에 따라 UI 문자열을 반환합니다.
|
||||
/// </summary>
|
||||
public static class L10n
|
||||
{
|
||||
private static string _lang = "ko";
|
||||
|
||||
public static void SetLanguage(string lang) =>
|
||||
_lang = lang?.ToLowerInvariant() ?? "ko";
|
||||
|
||||
public static string Get(string key) =>
|
||||
(_lang switch
|
||||
{
|
||||
"en" => _en,
|
||||
"ja" => _ja,
|
||||
"zh" => _zh,
|
||||
"vi" => _vi,
|
||||
_ => _ko
|
||||
}).TryGetValue(key, out var v) ? v : _ko.GetValueOrDefault(key, key);
|
||||
|
||||
/// <summary>런처 입력창에 표시할 랜덤 안내 문구를 반환합니다.</summary>
|
||||
public static string GetRandomPlaceholder() =>
|
||||
(_lang switch
|
||||
{
|
||||
"en" => _enPlaceholders,
|
||||
"ja" => _jaPlaceholders,
|
||||
"zh" => _zhPlaceholders,
|
||||
"vi" => _viPlaceholders,
|
||||
_ => _koPlaceholders
|
||||
}) is { } list ? list[Random.Shared.Next(list.Length)] : Get("placeholder");
|
||||
|
||||
// ─── 한국어 랜덤 플레이스홀더 (100종) ───────────────────────────────────────
|
||||
private static readonly string[] _koPlaceholders =
|
||||
[
|
||||
// ── 기본 인사 (15종) ──
|
||||
"무엇을 도와드릴까요?",
|
||||
"오늘도 좋은 하루 되세요. 무엇을 찾으시나요?",
|
||||
"안녕하세요! 어떤 작업을 도와드릴까요?",
|
||||
"필요하신 것이 있으시면 입력해 주세요.",
|
||||
"편하게 검색해 보세요.",
|
||||
"명령어를 입력하시면 바로 실행해 드리겠습니다.",
|
||||
"무엇이든 빠르게 찾아드리겠습니다.",
|
||||
"오늘도 효율적인 하루를 응원합니다.",
|
||||
"언제든 도움이 필요하시면 말씀해 주세요.",
|
||||
"어떤 파일이나 앱이든 바로 열어드릴 수 있습니다.",
|
||||
"찾으시는 것을 입력해 보세요.",
|
||||
"앱, 파일, 명령어 모두 여기서 시작됩니다.",
|
||||
"빠른 실행을 도와드리겠습니다.",
|
||||
"키보드 하나로 모든 업무를 시작해 보세요.",
|
||||
"입력 한 번이면 원하는 앱이 바로 열립니다.",
|
||||
// ── 계산기 (5종) ──
|
||||
"= 1920*1080 으로 수식 계산을 해보세요.",
|
||||
"= 100/3 처럼 입력하면 바로 계산됩니다.",
|
||||
"= sqrt(144) 같은 함수도 지원합니다.",
|
||||
"= 2^10 으로 거듭제곱도 계산할 수 있습니다.",
|
||||
"= (100+200)*1.1 괄호·소수점 모두 지원됩니다.",
|
||||
// ── 클립보드 (6종) ──
|
||||
"# 을 입력하면 클립보드 히스토리를 열 수 있습니다.",
|
||||
"Ctrl+H로 클립보드 히스토리를 바로 열어보세요.",
|
||||
"Shift+↑↓로 여러 클립보드 항목을 한 번에 합칠 수 있습니다.",
|
||||
"# 회의 로 클립보드에서 '회의' 관련 항목만 필터링됩니다.",
|
||||
"클립보드 히스토리는 DPAPI로 안전하게 암호화됩니다.",
|
||||
"$ upper 로 클립보드 텍스트를 대문자로 변환할 수 있습니다.",
|
||||
// ── 웹 검색 (5종) ──
|
||||
"? 키워드 로 웹 검색을 즉시 실행할 수 있습니다.",
|
||||
"?n 키워드 로 네이버 검색도 가능합니다.",
|
||||
"? WPF DataBinding 처럼 바로 검색할 수 있습니다.",
|
||||
"검색 엔진은 설정에서 변경할 수 있습니다.",
|
||||
"? 환율 달러 같은 검색도 한 번에 가능합니다.",
|
||||
// ── 시스템 정보 (8종) ──
|
||||
"info cpu 로 CPU 사용률을 확인해 보세요.",
|
||||
"* ip 로 내 IP 주소를 빠르게 확인할 수 있습니다.",
|
||||
"info disk 에서 드라이브를 Enter하면 탐색기가 열립니다.",
|
||||
"info ram 을 Enter하면 실시간 리소스 모니터를 열 수 있습니다.",
|
||||
"info battery 로 배터리 상태를 확인할 수 있습니다.",
|
||||
"info volume 으로 현재 볼륨 레벨을 볼 수 있습니다.",
|
||||
"info uptime 으로 PC 가동 시간을 확인할 수 있습니다.",
|
||||
"info 를 입력하면 시스템 전체 상태를 한눈에 볼 수 있습니다.",
|
||||
// ── 즐겨찾기 (5종) ──
|
||||
"Ctrl+P로 자주 쓰는 파일을 즐겨찾기에 추가해 보세요.",
|
||||
"fav 을 입력하면 즐겨찾기 목록을 볼 수 있습니다.",
|
||||
"Ctrl+B를 누르면 즐겨찾기 목록을 바로 토글할 수 있습니다.",
|
||||
"자주 여는 폴더를 즐겨찾기에 등록하면 한 번에 열립니다.",
|
||||
"즐겨찾기에 등록된 항목은 Ctrl+P로 간편하게 해제됩니다.",
|
||||
// ── 스니펫 (5종) ──
|
||||
"; 키워드 로 미리 등록한 텍스트를 즉시 확장할 수 있습니다.",
|
||||
"스니펫은 런처 없이 어디서든 자동 확장됩니다.",
|
||||
"자주 쓰는 이메일 서명을 스니펫으로 등록해 보세요.",
|
||||
"스니펫에 {{date}} 변수를 넣으면 오늘 날짜가 자동 삽입됩니다.",
|
||||
"설정 › 스니펫에서 키워드·내용을 자유롭게 관리하세요.",
|
||||
// ── 워크스페이스 (4종) ──
|
||||
"~save 이름 으로 현재 창 배치를 저장해 보세요.",
|
||||
"~이름 으로 저장된 창 배치를 한 번에 복원할 수 있습니다.",
|
||||
"여러 모니터 창 배치를 워크스페이스로 관리해 보세요.",
|
||||
"~list 로 저장된 워크스페이스 목록을 확인할 수 있습니다.",
|
||||
// ── 색상 (4종) ──
|
||||
"color #FF5733 으로 색상 코드를 변환할 수 있습니다.",
|
||||
"pick 을 입력하면 화면에서 색상을 추출할 수 있습니다.",
|
||||
"color rgb(75,94,252) 처럼 RGB 형식도 지원됩니다.",
|
||||
"추출한 색상은 HEX·RGB·HSL로 자동 변환됩니다.",
|
||||
// ── 날짜 계산 (4종) ──
|
||||
"date +30 으로 30일 후 날짜를 계산할 수 있습니다.",
|
||||
"date -7 로 일주일 전 날짜도 바로 확인됩니다.",
|
||||
"date 로 오늘 날짜와 요일을 바로 확인할 수 있습니다.",
|
||||
"date +365 로 1년 후 날짜도 계산할 수 있습니다.",
|
||||
// ── 파일 액션 (6종) ──
|
||||
"파일을 선택하고 → 키를 누르면 다양한 액션을 사용할 수 있습니다.",
|
||||
"Ctrl+Shift+E로 선택한 파일을 탐색기에서 바로 열 수 있습니다.",
|
||||
"Ctrl+T로 해당 경로에서 터미널을 열 수 있습니다.",
|
||||
"Ctrl+C로 파일 이름만 복사, Ctrl+Shift+C로 전체 경로를 복사합니다.",
|
||||
"Ctrl+Enter로 관리자 권한으로 실행할 수 있습니다.",
|
||||
"Alt+Enter로 파일 속성 대화상자를 열 수 있습니다.",
|
||||
// ── 단축키 (5종) ──
|
||||
"F1 을 누르면 전체 도움말을 확인할 수 있습니다.",
|
||||
"Ctrl+K로 단축키 참조 창을 열어보세요.",
|
||||
"Ctrl+1~9로 원하는 번호의 항목을 즉시 실행할 수 있습니다.",
|
||||
"Ctrl+L로 입력창을 즉시 비울 수 있습니다.",
|
||||
"Ctrl+, 로 설정 창을 바로 열 수 있습니다.",
|
||||
// ── 터미널 (4종) ──
|
||||
"> ipconfig 로 터미널 명령을 바로 실행할 수 있습니다.",
|
||||
"> ping google.com 같은 네트워크 진단도 가능합니다.",
|
||||
"> dir 로 현재 디렉터리 목록을 확인할 수 있습니다.",
|
||||
"^ notepad 로 메모장을 직접 실행할 수 있습니다.",
|
||||
// ── 캡처 (4종) ──
|
||||
"cap screen 으로 화면을 캡처할 수 있습니다.",
|
||||
"cap region 으로 원하는 영역만 캡처할 수 있습니다.",
|
||||
"cap window 로 현재 활성 창만 캡처할 수 있습니다.",
|
||||
"설정에서 캡처 글로벌 단축키를 지정할 수 있습니다.",
|
||||
// ── 시스템 명령 (5종) ──
|
||||
"/ lock 으로 화면을 즉시 잠글 수 있습니다.",
|
||||
"/ shutdown 으로 PC를 종료할 수 있습니다.",
|
||||
"/ restart 로 PC를 재시작할 수 있습니다.",
|
||||
"/ sleep 으로 절전 모드로 전환할 수 있습니다.",
|
||||
"/ recycle 로 휴지통을 비울 수 있습니다.",
|
||||
// ── 기타 유틸 (10종) ──
|
||||
"encode base64 hello 로 인코딩/디코딩이 됩니다.",
|
||||
"json 을 입력하면 클립보드 JSON을 정리해 줍니다.",
|
||||
"emoji 웃음 으로 이모지를 검색할 수 있습니다.",
|
||||
"kill 프로세스명 으로 프로세스를 종료할 수 있습니다.",
|
||||
"recent 를 입력하면 최근 실행 목록을 확인할 수 있습니다.",
|
||||
"note 내용 으로 빠른 메모를 저장할 수 있습니다.",
|
||||
"journal 오늘 할 일 로 업무 일지를 기록할 수 있습니다.",
|
||||
"pipe upper | trim 으로 텍스트를 파이프라인 처리할 수 있습니다.",
|
||||
"diff 로 클립보드의 두 텍스트를 비교할 수 있습니다.",
|
||||
"stats 로 클립보드 텍스트의 글자수·단어수를 확인합니다.",
|
||||
// ── 폴더/URL 별칭 (4종) ──
|
||||
"cd desktop 으로 바탕화면 폴더를 바로 열 수 있습니다.",
|
||||
"@blog 으로 등록된 URL을 즉시 열 수 있습니다.",
|
||||
"cd 경로 로 원하는 폴더를 바로 열 수 있습니다.",
|
||||
"Ctrl+D로 다운로드 폴더를 바로 열 수 있습니다.",
|
||||
// ── 창 관리 (4종) ──
|
||||
"win chrome 으로 크롬 창을 바로 전환할 수 있습니다.",
|
||||
"snap left 로 현재 창을 왼쪽에 정렬할 수 있습니다.",
|
||||
"snap grid 로 여러 창을 격자 배치할 수 있습니다.",
|
||||
"Tab 키로 선택한 항목의 제목을 자동완성할 수 있습니다.",
|
||||
// ── 테마 (2종) ──
|
||||
"설정에서 8종 테마(Dark·Light·OLED 등)를 선택할 수 있습니다.",
|
||||
"커스텀 테마로 나만의 색상 조합을 만들어 보세요.",
|
||||
// ── AX Agent (8종) ──
|
||||
"! 질문 으로 AX Agent에게 바로 물어볼 수 있습니다.",
|
||||
"! 오늘 회의 내용 정리해줘 처럼 AX Agent과 대화해 보세요.",
|
||||
"AX Agent은 ! 를 입력하는 것만으로 시작됩니다.",
|
||||
"! 를 입력하면 AX Agent이 업무를 도와드립니다.",
|
||||
"AX Agent이 직관적으로 답변합니다. ! 로 바로 시작해 보세요.",
|
||||
"! 이메일 초안 작성해줘 같은 요청도 가능합니다.",
|
||||
"AX Agent 대화 내역은 암호화되어 안전하게 보관됩니다.",
|
||||
"! 보고서 요약해줘 처럼 업무 보조에 활용해 보세요.",
|
||||
// ── AX Agent 기능 안내 (8종) ──
|
||||
"AX Agent에서 코드 구문 강조와 마크다운 렌더링을 지원합니다.",
|
||||
"Ollama/vLLM/Gemini/Claude 4종 LLM을 지원합니다.",
|
||||
"AX Agent 대화에서 프롬프트 카드로 AI 역할을 바로 지정할 수 있습니다.",
|
||||
"AX Agent에서 대화를 분류하고 검색할 수 있습니다. ! 로 시작하세요!",
|
||||
"AX Agent은 토큰 사용량과 응답 시간을 실시간으로 표시합니다.",
|
||||
"AX Agent에서 타이핑 효과로 자연스러운 대화를 경험해 보세요.",
|
||||
"대화 제목을 클릭하면 바로 이름을 변경할 수 있습니다.",
|
||||
"프롬프트 템플릿으로 자주 쓰는 지시를 저장해 보세요.",
|
||||
// ── 캡처 업데이트 (3종) ──
|
||||
"cap 모드에서 Shift+Enter로 지연 캡처(3/5/10초)를 할 수 있습니다.",
|
||||
"cap region 으로 원하는 영역만 정확하게 캡처할 수 있습니다.",
|
||||
"지연 캡처로 메뉴가 열린 상태도 캡처할 수 있습니다.",
|
||||
];
|
||||
|
||||
private static readonly string[] _enPlaceholders =
|
||||
[
|
||||
"What can I help you with?",
|
||||
"Type anything to search or run commands.",
|
||||
"Try = 1920*1080 for quick calculations.",
|
||||
"Press # to browse clipboard history.",
|
||||
"Type ? keyword to search the web instantly.",
|
||||
"Press F1 for the full help guide.",
|
||||
"Use Ctrl+P to bookmark your favorite files.",
|
||||
"Type info cpu to check system resources.",
|
||||
];
|
||||
|
||||
private static readonly string[] _jaPlaceholders =
|
||||
[
|
||||
"何をお手伝いしますか?",
|
||||
"検索やコマンドを入力してください。",
|
||||
"= 1920*1080 で計算ができます。",
|
||||
"# でクリップボード履歴を表示できます。",
|
||||
"F1 でヘルプを表示できます。",
|
||||
];
|
||||
|
||||
private static readonly string[] _zhPlaceholders =
|
||||
[
|
||||
"我能帮你做什么?",
|
||||
"输入内容进行搜索或执行命令。",
|
||||
"试试 = 1920*1080 进行快速计算。",
|
||||
"按 # 浏览剪贴板历史。",
|
||||
"按 F1 查看完整帮助。",
|
||||
];
|
||||
|
||||
private static readonly string[] _viPlaceholders =
|
||||
[
|
||||
"Tôi có thể giúp gì cho bạn?",
|
||||
"Nhập nội dung để tìm kiếm hoặc chạy lệnh.",
|
||||
"Thử = 1920*1080 để tính toán nhanh.",
|
||||
"Nhấn # để xem lịch sử clipboard.",
|
||||
"Nhấn F1 để xem hướng dẫn đầy đủ.",
|
||||
];
|
||||
|
||||
// ─── 한국어 (기본) ────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<string, string> _ko = new()
|
||||
{
|
||||
["placeholder"] = "무엇을 도와드릴까요?",
|
||||
["loading"] = "검색 중...",
|
||||
["no_results"] = "결과 없음",
|
||||
["help_copy_hint"] = "Enter로 예시를 클립보드에 복사",
|
||||
["help_total"] = "총 {0}개 명령어 · {1}개 단축키 · Enter → 전체 기능 창 열기",
|
||||
["clipboard_empty"] = "클립보드 히스토리가 없습니다",
|
||||
["clipboard_empty_hint"] = "텍스트를 복사하면 이 곳에 기록됩니다",
|
||||
["merge_hint"] = "✓ {0}개 선택됨 · Shift+Enter로 합치기 · Esc로 취소",
|
||||
["action_breadcrumb"] = "파일 액션",
|
||||
["large_type_hint"] = "Shift+Enter: Large Type",
|
||||
["settings_hotkey"] = "단축키",
|
||||
["settings_theme"] = "테마",
|
||||
["settings_language"] = "언어",
|
||||
};
|
||||
|
||||
// ─── English ──────────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<string, string> _en = new()
|
||||
{
|
||||
["placeholder"] = "What can I help you with?",
|
||||
["loading"] = "Searching...",
|
||||
["no_results"] = "No results",
|
||||
["help_copy_hint"] = "Press Enter to copy example to clipboard",
|
||||
["help_total"] = "{0} commands · {1} shortcuts · Enter → Show all features",
|
||||
["clipboard_empty"] = "No clipboard history",
|
||||
["clipboard_empty_hint"] = "Copy text and it will appear here",
|
||||
["merge_hint"] = "✓ {0} selected · Shift+Enter to merge · Esc to cancel",
|
||||
["action_breadcrumb"] = "File Action",
|
||||
["large_type_hint"] = "Shift+Enter: Large Type",
|
||||
["settings_hotkey"] = "Hotkey",
|
||||
["settings_theme"] = "Theme",
|
||||
["settings_language"] = "Language",
|
||||
};
|
||||
|
||||
// ─── 日本語 ───────────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<string, string> _ja = new()
|
||||
{
|
||||
["placeholder"] = "何をお手伝いしますか?",
|
||||
["loading"] = "検索中...",
|
||||
["no_results"] = "結果なし",
|
||||
["help_copy_hint"] = "Enterで例をクリップボードにコピー",
|
||||
["help_total"] = "コマンド {0}個 · ショートカット {1}個 · Enter → 全機能表示",
|
||||
["clipboard_empty"] = "クリップボード履歴がありません",
|
||||
["clipboard_empty_hint"] = "テキストをコピーするとここに表示されます",
|
||||
["merge_hint"] = "✓ {0}件選択 · Shift+Enterで結合 · Escでキャンセル",
|
||||
["action_breadcrumb"] = "ファイルアクション",
|
||||
["large_type_hint"] = "Shift+Enter: 拡大表示",
|
||||
["settings_hotkey"] = "ショートカット",
|
||||
["settings_theme"] = "テーマ",
|
||||
["settings_language"] = "言語",
|
||||
};
|
||||
|
||||
// ─── 中文 ─────────────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<string, string> _zh = new()
|
||||
{
|
||||
["placeholder"] = "我能帮你做什么?",
|
||||
["loading"] = "搜索中...",
|
||||
["no_results"] = "无结果",
|
||||
["help_copy_hint"] = "按 Enter 复制示例到剪贴板",
|
||||
["help_total"] = "{0} 个命令 · {1} 个快捷键 · Enter → 显示全部功能",
|
||||
["clipboard_empty"] = "没有剪贴板历史",
|
||||
["clipboard_empty_hint"] = "复制文本后将显示在此处",
|
||||
["merge_hint"] = "✓ 已选 {0} 项 · Shift+Enter 合并 · Esc 取消",
|
||||
["action_breadcrumb"] = "文件操作",
|
||||
["large_type_hint"] = "Shift+Enter: 大字显示",
|
||||
["settings_hotkey"] = "热键",
|
||||
["settings_theme"] = "主题",
|
||||
["settings_language"] = "语言",
|
||||
};
|
||||
|
||||
// ─── Tiếng Việt ───────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<string, string> _vi = new()
|
||||
{
|
||||
["placeholder"] = "Tôi có thể giúp gì cho bạn?",
|
||||
["loading"] = "Đang tìm kiếm...",
|
||||
["no_results"] = "Không có kết quả",
|
||||
["help_copy_hint"] = "Nhấn Enter để sao chép ví dụ vào clipboard",
|
||||
["help_total"] = "{0} lệnh · {1} phím tắt · Enter → Xem tất cả tính năng",
|
||||
["clipboard_empty"] = "Không có lịch sử clipboard",
|
||||
["clipboard_empty_hint"] = "Sao chép văn bản và nó sẽ xuất hiện ở đây",
|
||||
["merge_hint"] = "✓ Đã chọn {0} mục · Shift+Enter để gộp · Esc hủy",
|
||||
["action_breadcrumb"] = "Hành động tệp",
|
||||
["large_type_hint"] = "Shift+Enter: Hiển thị lớn",
|
||||
["settings_hotkey"] = "Phím tắt",
|
||||
["settings_theme"] = "Giao diện",
|
||||
["settings_language"] = "Ngôn ngữ",
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user