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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user