Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View 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);

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

View 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}");
}
}
}

View 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;
}
}

View 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
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; }
";
}

View 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
}

View 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)");
}
}

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

View 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 = "";
}
}

View 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;
}
}
}

View 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;
}
}

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

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

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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("&nbsp;", " ")
.Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
}

View 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
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();
}
}

View 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;
}
}

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

View 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;
}
}

View 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;
}
}

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

View 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}";
}

View 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;
}
}

View 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",
};
}

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

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

View 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";
}
}

View 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}");
}
}
}

View 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",
};
}

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

View 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; }
}
}

View 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;
}
}

View 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";
}
}

View 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}"));
}
}
}

View 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}

View 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;
}
}

View 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, // 에이전트 재개
}

View 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";
}
}

View 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;
}
}

View 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 "(코드를 읽을 수 없습니다)"; }
}
}

View 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}");
}
}
}

View 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}"));
}
}
}

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

View 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}'를 찾지 못했습니다.");
}
}

View 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}");
}
}
}

View 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}");
}
}

View 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}"));
}
}

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

View 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}"));
}
}
}

View 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
}

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

View 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}");
}
}
}

View 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;
}
}

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

View 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] + "…";
}

View 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}개 로드됨.");
}
}

View 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..]))} 필요)" : "";
}
}
}

View 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;
}
}
}

View 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}");
}
}

View 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;
}

View 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}"));
}
}
}

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

View 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; }
}
}

View 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 = "",
}
};
}
}

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

View 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 본문에서 워크스페이스 하위 파일/폴더 경로를 파란색으로 강조합니다.
/// 코드 블록(&lt;code&gt;, &lt;pre&gt;) 내부의 경로 텍스트를 감지하여 파란색 스타일을 적용합니다.
/// </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);

View 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, "");
}
}

View 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;
}
}

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

View 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;
}
}

View 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("사용자 입력 콜백이 등록되지 않았습니다.");
}
}

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

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

View File

@@ -0,0 +1,309 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// 에이전트 메모리 서비스.
/// 작업 폴더별 + 전역 메모리를 관리하며, 대화 간 지속적 컨텍스트를 유지합니다.
/// 저장소: %APPDATA%\AxCopilot\memory\{hash}.dat (암호화)
/// </summary>
public class AgentMemoryService
{
private static readonly string MemoryDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "memory");
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly List<MemoryEntry> _entries = new();
private readonly object _lock = new();
private string? _currentWorkFolder;
/// <summary>현재 로드된 메모리 항목 수.</summary>
public int Count { get { lock (_lock) return _entries.Count; } }
/// <summary>모든 메모리 항목 (읽기 전용).</summary>
public IReadOnlyList<MemoryEntry> All { get { lock (_lock) return _entries.ToList(); } }
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
public void Load(string? workFolder)
{
lock (_lock)
{
_entries.Clear();
_currentWorkFolder = workFolder;
// 전역 메모리
var globalPath = GetFilePath(null);
LoadFromFile(globalPath);
// 폴더별 메모리
if (!string.IsNullOrEmpty(workFolder))
{
var folderPath = GetFilePath(workFolder);
LoadFromFile(folderPath);
}
}
}
/// <summary>새 메모리를 추가합니다. 유사한 내용이 있으면 병합합니다.</summary>
public MemoryEntry Add(string type, string content, string? source = null, string? workFolder = null)
{
lock (_lock)
{
// 중복 검사: 동일 타입 + 유사 내용 (80% 이상 키워드 겹침)
var existing = _entries.FirstOrDefault(e =>
e.Type == type && CalculateSimilarity(e.Content, content) > 0.8);
if (existing != null)
{
existing.LastUsedAt = DateTime.Now;
existing.UseCount++;
Save();
return existing;
}
var entry = new MemoryEntry
{
Id = Guid.NewGuid().ToString("N")[..12],
Type = type,
Content = content,
Source = source ?? "",
CreatedAt = DateTime.Now,
LastUsedAt = DateTime.Now,
UseCount = 1,
Relevance = 1.0,
WorkFolder = workFolder,
};
_entries.Add(entry);
Prune();
Save();
return entry;
}
}
/// <summary>쿼리와 관련된 메모리를 검색합니다.</summary>
public List<MemoryEntry> GetRelevant(string query, int maxCount = 10)
{
if (string.IsNullOrWhiteSpace(query)) return new();
lock (_lock)
{
var queryTokens = Tokenize(query);
var scored = _entries
.Select(e =>
{
var entryTokens = Tokenize(e.Content);
var overlap = queryTokens.Intersect(entryTokens, StringComparer.OrdinalIgnoreCase).Count();
var score = queryTokens.Count > 0 ? (double)overlap / queryTokens.Count : 0;
// 사용 빈도와 최근 사용 가중치 적용
score += Math.Min(e.UseCount * 0.05, 0.3);
if ((DateTime.Now - e.LastUsedAt).TotalDays < 7) score += 0.1;
return (Entry: e, Score: score);
})
.Where(x => x.Score > 0.1)
.OrderByDescending(x => x.Score)
.Take(maxCount)
.Select(x =>
{
x.Entry.Relevance = x.Score;
return x.Entry;
})
.ToList();
return scored;
}
}
/// <summary>메모리 사용 기록을 갱신합니다.</summary>
public void Touch(string id)
{
lock (_lock)
{
var entry = _entries.FirstOrDefault(e => e.Id == id);
if (entry != null)
{
entry.UseCount++;
entry.LastUsedAt = DateTime.Now;
Save();
}
}
}
/// <summary>메모리를 삭제합니다.</summary>
public bool Remove(string id)
{
lock (_lock)
{
var removed = _entries.RemoveAll(e => e.Id == id) > 0;
if (removed) Save();
return removed;
}
}
/// <summary>모든 메모리를 삭제합니다.</summary>
public void Clear()
{
lock (_lock)
{
_entries.Clear();
Save();
}
}
/// <summary>오래되고 사용되지 않는 메모리를 정리합니다.</summary>
private void Prune()
{
var maxEntries = 100;
try
{
var app = System.Windows.Application.Current as App;
maxEntries = app?.SettingsService?.Settings.Llm.MaxMemoryEntries ?? 100;
}
catch { }
if (_entries.Count <= maxEntries) return;
// 점수 기반 정리: 오래되고 사용 안 되는 것부터
var toRemove = _entries
.OrderBy(e => e.UseCount)
.ThenBy(e => e.LastUsedAt)
.Take(_entries.Count - maxEntries)
.ToList();
foreach (var entry in toRemove)
_entries.Remove(entry);
}
/// <summary>메모리를 암호화하여 파일에 저장합니다.</summary>
private void Save()
{
Directory.CreateDirectory(MemoryDir);
// 전역 메모리
var globalEntries = _entries.Where(e => e.WorkFolder == null).ToList();
SaveToFile(GetFilePath(null), globalEntries);
// 폴더별 메모리
if (!string.IsNullOrEmpty(_currentWorkFolder))
{
var folderEntries = _entries.Where(e => e.WorkFolder != null).ToList();
SaveToFile(GetFilePath(_currentWorkFolder), folderEntries);
}
}
private void SaveToFile(string path, List<MemoryEntry> entries)
{
try
{
var json = JsonSerializer.Serialize(entries, JsonOptions);
var encrypted = CryptoService.PortableEncrypt(json);
var tempPath = path + ".tmp";
File.WriteAllText(tempPath, encrypted);
File.Move(tempPath, path, overwrite: true);
}
catch (Exception ex)
{
LogService.Warn($"메모리 저장 실패: {ex.Message}");
}
}
private void LoadFromFile(string path)
{
if (!File.Exists(path)) return;
try
{
var encrypted = File.ReadAllText(path);
var json = CryptoService.PortableDecrypt(encrypted);
if (string.IsNullOrEmpty(json)) return;
var entries = JsonSerializer.Deserialize<List<MemoryEntry>>(json, JsonOptions);
if (entries != null)
_entries.AddRange(entries);
}
catch (Exception ex)
{
LogService.Warn($"메모리 로드 실패 ({path}): {ex.Message}");
}
}
private static string GetFilePath(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder))
return Path.Combine(MemoryDir, "_global.dat");
// 폴더 경로를 해시하여 파일명 생성
var hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant())))[..16];
return Path.Combine(MemoryDir, $"{hash}.dat");
}
/// <summary>두 문자열의 키워드 유사도를 계산합니다 (0~1).</summary>
private static double CalculateSimilarity(string a, string b)
{
var tokensA = Tokenize(a);
var tokensB = Tokenize(b);
if (tokensA.Count == 0 || tokensB.Count == 0) return 0;
var intersection = tokensA.Intersect(tokensB, StringComparer.OrdinalIgnoreCase).Count();
var union = tokensA.Union(tokensB, StringComparer.OrdinalIgnoreCase).Count();
return union > 0 ? (double)intersection / union : 0;
}
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
private static HashSet<string> Tokenize(string text)
{
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sb = new StringBuilder();
foreach (var c in text)
{
if (char.IsLetterOrDigit(c) || c == '_')
sb.Append(c);
else if (sb.Length > 1) // 1글자 토큰 제외
{
tokens.Add(sb.ToString());
sb.Clear();
}
else
sb.Clear();
}
if (sb.Length > 1) tokens.Add(sb.ToString());
return tokens;
}
}
/// <summary>에이전트 메모리 항목.</summary>
public class MemoryEntry
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "fact"; // rule | preference | fact | correction
[JsonPropertyName("content")]
public string Content { get; set; } = "";
[JsonPropertyName("source")]
public string Source { get; set; } = "";
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("lastUsedAt")]
public DateTime LastUsedAt { get; set; }
[JsonPropertyName("useCount")]
public int UseCount { get; set; }
[JsonPropertyName("relevance")]
public double Relevance { get; set; }
[JsonPropertyName("workFolder")]
public string? WorkFolder { get; set; }
}

View File

@@ -0,0 +1,213 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// 에이전트 실행 통계를 로컬 JSON 파일에 기록/집계합니다.
/// %APPDATA%\AxCopilot\stats\agent_stats.json
/// </summary>
public static class AgentStatsService
{
private static readonly string StatsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "stats");
private static readonly string StatsFile = Path.Combine(StatsDir, "agent_stats.json");
private static readonly JsonSerializerOptions _jsonOpts = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
// ─── 데이터 모델 ──────────────────────────────────────────────────────
public record AgentSessionRecord
{
[JsonPropertyName("ts")] public DateTime Timestamp { get; init; } = DateTime.Now;
[JsonPropertyName("tab")] public string Tab { get; init; } = "";
[JsonPropertyName("task")] public string TaskType { get; init; } = "";
[JsonPropertyName("model")] public string Model { get; init; } = "";
[JsonPropertyName("calls")] public int ToolCalls { get; init; }
[JsonPropertyName("ok")] public int SuccessCount{ get; init; }
[JsonPropertyName("fail")] public int FailCount { get; init; }
[JsonPropertyName("itok")] public int InputTokens { get; init; }
[JsonPropertyName("otok")] public int OutputTokens{ get; init; }
[JsonPropertyName("ms")] public long DurationMs { get; init; }
[JsonPropertyName("retryBlocked")] public int RepeatedFailureBlockedCount { get; init; }
[JsonPropertyName("retryRecovered")] public int RecoveredAfterFailureCount { get; init; }
[JsonPropertyName("tools")] public List<string> UsedTools { get; init; } = new();
}
/// <summary>집계 결과.</summary>
public record AgentStatsSummary
{
public int TotalSessions { get; init; }
public int TotalToolCalls { get; init; }
public int TotalTokens { get; init; }
public int TotalInputTokens { get; init; }
public int TotalOutputTokens { get; init; }
public long TotalDurationMs { get; init; }
public int TotalRepeatedFailureBlocked { get; init; }
public int TotalRecoveredAfterFailure { get; init; }
public double RetryQualityRate { get; init; }
public Dictionary<string, int> ToolFrequency { get; init; } = new();
public Dictionary<string, int> ModelBreakdown { get; init; } = new();
public Dictionary<string, int> TabBreakdown { get; init; } = new();
public Dictionary<string, int> TaskTypeBreakdown { get; init; } = new();
public Dictionary<string, double> RetryQualityByTaskType { get; init; } = new();
/// <summary>일별 세션 수 (날짜 키 "yyyy-MM-dd").</summary>
public Dictionary<string, int> DailySessions { get; init; } = new();
/// <summary>일별 토큰 수 (날짜 키 "yyyy-MM-dd").</summary>
public Dictionary<string, int> DailyTokens { get; init; } = new();
}
// ─── 기록 ─────────────────────────────────────────────────────────────
/// <summary>에이전트 세션 결과를 기록합니다. 비동기 fire-and-forget.</summary>
public static void RecordSession(AgentSessionRecord record)
{
Task.Run(() =>
{
try
{
if (!Directory.Exists(StatsDir))
Directory.CreateDirectory(StatsDir);
// 한 줄씩 append (JSONL 형식)
var line = JsonSerializer.Serialize(record, _jsonOpts);
File.AppendAllText(StatsFile, line + "\n");
}
catch (Exception ex)
{
LogService.Warn($"통계 기록 실패: {ex.Message}");
}
});
}
// ─── 집계 ─────────────────────────────────────────────────────────────
/// <summary>지정 일수 이내의 통계를 집계합니다. days=0이면 전체.</summary>
public static AgentStatsSummary Aggregate(int days = 0)
{
var records = LoadRecords(days);
return BuildSummary(records);
}
/// <summary>통계 파일에서 레코드를 읽어옵니다.</summary>
public static List<AgentSessionRecord> LoadRecords(int days = 0)
{
if (!File.Exists(StatsFile)) return new();
var cutoff = days > 0 ? DateTime.Now.AddDays(-days) : DateTime.MinValue;
var result = new List<AgentSessionRecord>();
foreach (var line in File.ReadLines(StatsFile))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var rec = JsonSerializer.Deserialize<AgentSessionRecord>(line, _jsonOpts);
if (rec != null && rec.Timestamp >= cutoff)
result.Add(rec);
}
catch { /* 손상된 줄 건너뜀 */ }
}
return result;
}
private static AgentStatsSummary BuildSummary(List<AgentSessionRecord> records)
{
var toolFreq = new Dictionary<string, int>();
var modelBreak = new Dictionary<string, int>();
var tabBreak = new Dictionary<string, int>();
var taskBreak = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var retryRecoveredByTask = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var retryBlockedByTask = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var dailySess = new Dictionary<string, int>();
var dailyTok = new Dictionary<string, int>();
int totalCalls = 0, totalIn = 0, totalOut = 0;
int totalRetryBlocked = 0, totalRecovered = 0;
long totalMs = 0;
foreach (var r in records)
{
totalCalls += r.ToolCalls;
totalIn += r.InputTokens;
totalOut += r.OutputTokens;
totalMs += r.DurationMs;
totalRetryBlocked += r.RepeatedFailureBlockedCount;
totalRecovered += r.RecoveredAfterFailureCount;
foreach (var t in r.UsedTools)
toolFreq[t] = toolFreq.GetValueOrDefault(t) + 1;
if (!string.IsNullOrEmpty(r.Model))
modelBreak[r.Model] = modelBreak.GetValueOrDefault(r.Model) + 1;
if (!string.IsNullOrEmpty(r.Tab))
tabBreak[r.Tab] = tabBreak.GetValueOrDefault(r.Tab) + 1;
var taskType = string.IsNullOrWhiteSpace(r.TaskType) ? "unknown" : r.TaskType.Trim().ToLowerInvariant();
taskBreak[taskType] = taskBreak.GetValueOrDefault(taskType) + 1;
retryRecoveredByTask[taskType] = retryRecoveredByTask.GetValueOrDefault(taskType) + r.RecoveredAfterFailureCount;
retryBlockedByTask[taskType] = retryBlockedByTask.GetValueOrDefault(taskType) + r.RepeatedFailureBlockedCount;
var dateKey = r.Timestamp.ToString("yyyy-MM-dd");
dailySess[dateKey] = dailySess.GetValueOrDefault(dateKey) + 1;
dailyTok[dateKey] = dailyTok.GetValueOrDefault(dateKey) + r.InputTokens + r.OutputTokens;
}
var retryQualityByTask = taskBreak.Keys
.ToDictionary(
k => k,
k => CalculateRetryQualityRate(
retryRecoveredByTask.GetValueOrDefault(k),
retryBlockedByTask.GetValueOrDefault(k)));
return new AgentStatsSummary
{
TotalSessions = records.Count,
TotalToolCalls = totalCalls,
TotalTokens = totalIn + totalOut,
TotalInputTokens = totalIn,
TotalOutputTokens= totalOut,
TotalDurationMs = totalMs,
TotalRepeatedFailureBlocked = totalRetryBlocked,
TotalRecoveredAfterFailure = totalRecovered,
RetryQualityRate = CalculateRetryQualityRate(totalRecovered, totalRetryBlocked),
ToolFrequency = toolFreq.OrderByDescending(kv => kv.Value).Take(10)
.ToDictionary(kv => kv.Key, kv => kv.Value),
ModelBreakdown = modelBreak,
TabBreakdown = tabBreak,
TaskTypeBreakdown = taskBreak.ToDictionary(kv => kv.Key, kv => kv.Value),
RetryQualityByTaskType = retryQualityByTask,
DailySessions = dailySess,
DailyTokens = dailyTok,
};
}
private static double CalculateRetryQualityRate(int recoveredAfterFailure, int repeatedFailureBlocked)
{
var total = recoveredAfterFailure + repeatedFailureBlocked;
if (total <= 0)
return 1.0;
return Math.Clamp((double)recoveredAfterFailure / total, 0.0, 1.0);
}
/// <summary>통계 파일 크기 (bytes). 파일 없으면 0.</summary>
public static long GetFileSize() =>
File.Exists(StatsFile) ? new FileInfo(StatsFile).Length : 0;
/// <summary>모든 통계 데이터를 삭제합니다.</summary>
public static void Clear()
{
try { if (File.Exists(StatsFile)) File.Delete(StatsFile); }
catch (Exception ex) { LogService.Warn($"통계 삭제 실패: {ex.Message}"); }
}
}

View File

@@ -0,0 +1,178 @@
using System.IO;
using System.Text.Json;
using System.Windows.Threading;
namespace AxCopilot.Services;
/// <summary>
/// 이벤트 기반 에이전트 트리거 서비스.
/// 파일 변경, 스케줄, Git 이벤트를 감지하여 에이전트를 자동 실행합니다.
/// </summary>
public class AgentTriggerService : IDisposable
{
private readonly SettingsService _settings;
private FileSystemWatcher? _fileWatcher;
private DispatcherTimer? _scheduleTimer;
private readonly List<TriggerRule> _rules = new();
private bool _disposed;
/// <summary>트리거 발동 시 에이전트 실행 콜백. (triggerName, prompt) → void</summary>
public Action<string, string>? OnTriggerFired { get; set; }
public AgentTriggerService(SettingsService settings)
{
_settings = settings;
}
/// <summary>트리거 규칙을 로드하고 모니터링을 시작합니다.</summary>
public void Start()
{
LoadRules();
StartFileWatcher();
StartScheduleTimer();
LogService.Info($"에이전트 트리거 서비스 시작: {_rules.Count}개 규칙");
}
/// <summary>트리거 규칙을 다시 로드합니다.</summary>
public void Reload()
{
Stop();
Start();
}
public void Stop()
{
_fileWatcher?.Dispose();
_fileWatcher = null;
_scheduleTimer?.Stop();
}
private void LoadRules()
{
_rules.Clear();
var triggerFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "triggers.json");
if (!File.Exists(triggerFile)) return;
try
{
var json = File.ReadAllText(triggerFile);
var loaded = JsonSerializer.Deserialize<List<TriggerRule>>(json);
if (loaded != null) _rules.AddRange(loaded.Where(r => r.Enabled));
}
catch (Exception ex)
{
LogService.Warn($"트리거 규칙 로드 실패: {ex.Message}");
}
}
private void StartFileWatcher()
{
var fileRules = _rules.Where(r => r.Type == "file_change").ToList();
if (fileRules.Count == 0) return;
var workFolder = _settings.Settings.Llm.WorkFolder;
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
_fileWatcher = new FileSystemWatcher(workFolder)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
EnableRaisingEvents = true,
};
// 디바운스용 — 짧은 시간 내 여러 변경을 하나로 묶음
DateTime lastTrigger = DateTime.MinValue;
_fileWatcher.Changed += (_, e) =>
{
if ((DateTime.Now - lastTrigger).TotalSeconds < 5) return;
foreach (var rule in fileRules)
{
if (MatchesPattern(e.FullPath, rule.Pattern))
{
lastTrigger = DateTime.Now;
var prompt = rule.Prompt.Replace("{file}", e.FullPath).Replace("{name}", e.Name ?? "");
System.Windows.Application.Current?.Dispatcher.BeginInvoke(() =>
OnTriggerFired?.Invoke(rule.Name, prompt));
break;
}
}
};
}
private void StartScheduleTimer()
{
var scheduleRules = _rules.Where(r => r.Type == "schedule").ToList();
if (scheduleRules.Count == 0) return;
_scheduleTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
_scheduleTimer.Tick += (_, _) =>
{
var now = DateTime.Now;
foreach (var rule in scheduleRules)
{
if (ShouldRunSchedule(rule, now))
{
rule.LastRun = now;
OnTriggerFired?.Invoke(rule.Name, rule.Prompt);
}
}
};
_scheduleTimer.Start();
}
private static bool MatchesPattern(string filePath, string? pattern)
{
if (string.IsNullOrEmpty(pattern)) return true;
// 간단한 확장자/폴더 패턴 매칭
if (pattern.StartsWith("*."))
return filePath.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
return filePath.Contains(pattern, StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldRunSchedule(TriggerRule rule, DateTime now)
{
if (rule.LastRun.HasValue && (now - rule.LastRun.Value).TotalMinutes < (rule.IntervalMinutes ?? 60))
return false;
// 시간 범위 체크 (근무 시간만)
if (rule.ActiveHourStart.HasValue && now.Hour < rule.ActiveHourStart.Value) return false;
if (rule.ActiveHourEnd.HasValue && now.Hour >= rule.ActiveHourEnd.Value) return false;
return true;
}
/// <summary>수동으로 트리거를 실행합니다.</summary>
public void FireManual(string ruleName)
{
var rule = _rules.FirstOrDefault(r => r.Name == ruleName);
if (rule != null)
OnTriggerFired?.Invoke(rule.Name, rule.Prompt);
}
/// <summary>현재 로드된 트리거 규칙 목록.</summary>
public IReadOnlyList<TriggerRule> Rules => _rules;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
}
/// <summary>트리거 규칙 정의.</summary>
public class TriggerRule
{
public string Name { get; set; } = "";
public string Type { get; set; } = "file_change"; // file_change | schedule | git
public string Prompt { get; set; } = "";
public string? Pattern { get; set; } // file_change: 확장자/경로 패턴
public int? IntervalMinutes { get; set; } // schedule: 실행 간격 (분)
public int? ActiveHourStart { get; set; } // schedule: 활성 시작 시각 (0~23)
public int? ActiveHourEnd { get; set; } // schedule: 활성 종료 시각
public bool Enabled { get; set; } = true;
public DateTime? LastRun { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services;
/// <summary>
/// 에이전트 도구 호출 이력을 로컬 JSON 파일로 영속화하는 보안 감사 로그 서비스.
/// 파일 위치: %APPDATA%\AxCopilot\audit\{yyyy-MM-dd}.json
/// </summary>
public static class AuditLogService
{
private static readonly string AuditDir;
private static readonly object _lock = new();
private static readonly JsonSerializerOptions _jsonOpts = new()
{
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
static AuditLogService()
{
AuditDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "audit");
try { Directory.CreateDirectory(AuditDir); } catch { }
}
/// <summary>감사 로그 항목을 기록합니다.</summary>
public static void Log(AuditEntry entry)
{
try
{
var fileName = $"{DateTime.Now:yyyy-MM-dd}.json";
var filePath = Path.Combine(AuditDir, fileName);
var json = JsonSerializer.Serialize(entry, _jsonOpts);
lock (_lock)
{
File.AppendAllText(filePath, json + "\n", Encoding.UTF8);
}
}
catch { /* 감사 로그 실패는 무시 — 앱 동작에 영향 없음 */ }
}
/// <summary>에이전트 도구 호출을 감사 로그에 기록합니다.</summary>
public static void LogToolCall(string conversationId, string tab, string toolName,
string parameters, string result, string? filePath, bool success)
{
Log(new AuditEntry
{
ConversationId = conversationId,
Tab = tab,
Action = "ToolCall",
ToolName = toolName,
Parameters = Truncate(parameters, 500),
Result = Truncate(result, 500),
FilePath = filePath,
Success = success,
});
}
/// <summary>파일 접근을 감사 로그에 기록합니다.</summary>
public static void LogFileAccess(string conversationId, string tab, string action, string filePath, bool success)
{
Log(new AuditEntry
{
ConversationId = conversationId,
Tab = tab,
Action = action, // Read, Write, Delete, Execute
FilePath = filePath,
Success = success,
});
}
/// <summary>오늘 감사 로그를 읽습니다.</summary>
public static List<AuditEntry> LoadToday()
{
var fileName = $"{DateTime.Now:yyyy-MM-dd}.json";
return LoadFile(Path.Combine(AuditDir, fileName));
}
/// <summary>특정 날짜의 감사 로그를 읽습니다.</summary>
public static List<AuditEntry> LoadDate(DateTime date)
{
var fileName = $"{date:yyyy-MM-dd}.json";
return LoadFile(Path.Combine(AuditDir, fileName));
}
private static List<AuditEntry> LoadFile(string filePath)
{
var entries = new List<AuditEntry>();
if (!File.Exists(filePath)) return entries;
try
{
foreach (var line in File.ReadAllLines(filePath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var entry = JsonSerializer.Deserialize<AuditEntry>(line, _jsonOpts);
if (entry != null) entries.Add(entry);
}
}
catch { }
return entries;
}
/// <summary>30일 이전 감사 로그를 삭제합니다.</summary>
public static void PurgeOldLogs(int retentionDays = 30)
{
try
{
var cutoff = DateTime.Now.AddDays(-retentionDays);
foreach (var f in Directory.GetFiles(AuditDir, "*.json"))
{
if (File.GetCreationTime(f) < cutoff)
File.Delete(f);
}
}
catch { }
}
/// <summary>감사 로그 폴더 경로를 반환합니다.</summary>
public static string GetAuditFolder() => AuditDir;
private static string Truncate(string? s, int maxLen) =>
string.IsNullOrEmpty(s) ? "" : s.Length <= maxLen ? s : s[..maxLen] + "…";
}
/// <summary>감사 로그 항목.</summary>
public class AuditEntry
{
public DateTime Timestamp { get; init; } = DateTime.Now;
public string ConversationId { get; init; } = "";
public string Tab { get; init; } = ""; // Chat, Cowork, Code
public string Action { get; init; } = ""; // ToolCall, Read, Write, Delete, Execute
public string ToolName { get; init; } = "";
public string Parameters { get; init; } = "";
public string Result { get; init; } = "";
public string? FilePath { get; init; }
public bool Success { get; init; } = true;
}

View File

@@ -0,0 +1,72 @@
namespace AxCopilot.Services;
/// <summary>
/// 장시간 실행되거나 메인 응답 흐름과 분리된 작업을 앱 전역에서 추적합니다.
/// 현재는 서브에이전트/분리 작업을 우선 배경 작업으로 취급합니다.
/// </summary>
public sealed class BackgroundJobService
{
public sealed class BackgroundJobState
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public string Title { get; set; } = "";
public string Summary { get; set; } = "";
public string Status { get; set; } = "running";
public DateTime StartedAt { get; set; } = DateTime.Now;
public DateTime UpdatedAt { get; set; } = DateTime.Now;
}
private readonly List<BackgroundJobState> _active = [];
private readonly List<BackgroundJobState> _recent = [];
public IReadOnlyList<BackgroundJobState> ActiveJobs => _active;
public IReadOnlyList<BackgroundJobState> RecentJobs => _recent;
public event Action? Changed;
public void Upsert(string id, string kind, string title, string summary, string status = "running")
{
var existing = _active.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
_active.Insert(0, new BackgroundJobState
{
Id = id,
Kind = kind,
Title = title,
Summary = summary,
Status = status,
StartedAt = DateTime.Now,
UpdatedAt = DateTime.Now,
});
}
else
{
existing.Kind = kind;
existing.Title = title;
existing.Summary = summary;
existing.Status = status;
existing.UpdatedAt = DateTime.Now;
}
Changed?.Invoke();
}
public void Complete(string id, string? summary = null, string status = "completed")
{
var existing = _active.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
if (existing == null)
return;
_active.Remove(existing);
existing.Status = status;
if (!string.IsNullOrWhiteSpace(summary))
existing.Summary = summary;
existing.UpdatedAt = DateTime.Now;
_recent.Insert(0, existing);
if (_recent.Count > 20)
_recent.RemoveRange(20, _recent.Count - 20);
Changed?.Invoke();
}
}

View File

@@ -0,0 +1,677 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 채팅 창 외부에 유지되는 세션 상태.
/// 활성 탭과 탭별 마지막 대화 ID를 관리해 창 재생성/재로딩에도 상태를 복원합니다.
/// </summary>
public sealed class ChatSessionStateService
{
private const int MaxExecutionEventHistory = 400;
private const int MaxAgentRunHistory = 12;
private readonly DraftQueueService _draftQueue = new();
public sealed class RuntimeActivity
{
public string Key { get; init; } = "";
public string Kind { get; init; } = "";
public string Title { get; set; } = "";
public string Summary { get; set; } = "";
public DateTime StartedAt { get; set; } = DateTime.Now;
}
public string ActiveTab { get; set; } = "Chat";
public ChatConversation? CurrentConversation { get; set; }
public bool IsStreaming { get; set; }
public string StatusText { get; set; } = "대기 중";
public bool IsStatusSpinning { get; set; }
public string LastCompletedSummary { get; set; } = "";
public List<RuntimeActivity> ActiveRuntimeActivities { get; } = new();
public Dictionary<string, string?> TabConversationIds { get; } = new(StringComparer.OrdinalIgnoreCase)
{
["Chat"] = null,
["Cowork"] = null,
["Code"] = null,
};
public ChatConversation EnsureCurrentConversation(string tab)
{
var normalizedTab = NormalizeTab(tab);
if (CurrentConversation == null
|| !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
{
CurrentConversation = new ChatConversation { Tab = normalizedTab };
}
if (string.IsNullOrWhiteSpace(CurrentConversation.Tab)
|| !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
CurrentConversation.Tab = normalizedTab;
return CurrentConversation;
}
public void Load(SettingsService settings)
{
var llm = settings.Settings.Llm;
ActiveTab = NormalizeTab(llm.LastActiveTab);
foreach (var key in TabConversationIds.Keys.ToList())
TabConversationIds[key] = null;
foreach (var kv in llm.LastConversationIds)
{
var normalized = NormalizeTab(kv.Key);
if (TabConversationIds.ContainsKey(normalized) && !string.IsNullOrWhiteSpace(kv.Value))
TabConversationIds[normalized] = kv.Value;
}
}
public void Save(SettingsService settings)
{
var llm = settings.Settings.Llm;
llm.LastActiveTab = NormalizeTab(ActiveTab);
var snapshot = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in TabConversationIds)
{
if (!string.IsNullOrWhiteSpace(kv.Value))
snapshot[kv.Key] = kv.Value!;
}
llm.LastConversationIds = snapshot;
settings.Save();
}
public void RememberConversation(string tab, string? conversationId)
{
TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId;
}
public ChatConversation LoadOrCreateConversation(string tab, ChatStorageService storage, SettingsService settings)
{
var normalizedTab = NormalizeTab(tab);
var rememberedId = GetConversationId(normalizedTab);
if (!string.IsNullOrWhiteSpace(rememberedId))
{
var loaded = storage.Load(rememberedId);
if (loaded != null)
{
if (string.IsNullOrWhiteSpace(loaded.Tab))
loaded.Tab = normalizedTab;
if (string.Equals(NormalizeTab(loaded.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
{
var normalized = NormalizeLoadedConversation(loaded);
CurrentConversation = loaded;
if (normalized)
try { storage.Save(loaded); } catch { }
return loaded;
}
// 잘못 매핑된 탭 ID 방어: 교차 탭 대화 누적 방지
RememberConversation(normalizedTab, null);
}
else
RememberConversation(normalizedTab, null);
}
return CreateFreshConversation(normalizedTab, settings);
}
public ChatConversation CreateFreshConversation(string tab, SettingsService settings)
{
var normalizedTab = NormalizeTab(tab);
var created = new ChatConversation { Tab = normalizedTab };
var workFolder = settings.Settings.Llm.WorkFolder;
if (!string.IsNullOrWhiteSpace(workFolder) && normalizedTab != "Chat")
created.WorkFolder = workFolder;
CurrentConversation = created;
return created;
}
public ChatConversation CreateBranchConversation(
ChatConversation source,
int atIndex,
int branchCount,
string? branchHint = null,
string? branchContextMessage = null,
string? branchContextRunId = null)
{
var branchLabel = string.IsNullOrWhiteSpace(branchHint)
? $"분기 {branchCount}"
: $"분기 {branchCount} · {Truncate(branchHint, 18)}";
var fork = new ChatConversation
{
Title = $"{source.Title} ({branchLabel})",
Tab = source.Tab,
Category = source.Category,
WorkFolder = source.WorkFolder,
SystemCommand = source.SystemCommand,
ParentId = source.Id,
BranchLabel = branchLabel,
BranchAtIndex = atIndex,
ConversationFailedOnlyFilter = source.ConversationFailedOnlyFilter,
ConversationRunningOnlyFilter = source.ConversationRunningOnlyFilter,
ConversationSortMode = source.ConversationSortMode,
AgentRunHistory = source.AgentRunHistory
.Select(x => new ChatAgentRunRecord
{
RunId = x.RunId,
Status = x.Status,
Summary = x.Summary,
LastIteration = x.LastIteration,
StartedAt = x.StartedAt,
UpdatedAt = x.UpdatedAt,
})
.ToList(),
};
for (var i = 0; i <= atIndex && i < source.Messages.Count; i++)
{
var m = source.Messages[i];
fork.Messages.Add(new ChatMessage
{
Role = m.Role,
Content = m.Content,
Timestamp = m.Timestamp,
MetaKind = m.MetaKind,
MetaRunId = m.MetaRunId,
Feedback = m.Feedback,
AttachedFiles = m.AttachedFiles?.ToList(),
Images = m.Images?.Select(img => new ImageAttachment
{
FileName = img.FileName,
MimeType = img.MimeType,
Base64 = img.Base64,
}).ToList(),
});
}
if (!string.IsNullOrWhiteSpace(branchContextMessage))
{
fork.Messages.Add(new ChatMessage
{
Role = "assistant",
Content = branchContextMessage.Trim(),
Timestamp = DateTime.Now,
MetaKind = "branch_context",
MetaRunId = branchContextRunId,
});
}
return fork;
}
public void SaveCurrentConversation(ChatStorageService storage, string tab)
{
var conv = CurrentConversation;
if (conv == null) return;
try { storage.Save(conv); } catch { }
var conversationTab = NormalizeTab(conv.Tab);
if (conv.Messages.Count > 0
|| (conv.ExecutionEvents?.Count ?? 0) > 0
|| (conv.AgentRunHistory?.Count ?? 0) > 0
|| (conv.DraftQueueItems?.Count ?? 0) > 0)
RememberConversation(conversationTab, conv.Id);
}
public void ClearCurrentConversation(string tab)
{
CurrentConversation = null;
RememberConversation(tab, null);
}
public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, ChatStorageService? storage = null, bool remember = true)
{
var normalizedTab = NormalizeTab(tab);
conversation.Tab = normalizedTab;
NormalizeLoadedConversation(conversation);
CurrentConversation = conversation;
if (remember && !string.IsNullOrWhiteSpace(conversation.Id))
RememberConversation(normalizedTab, conversation.Id);
try { storage?.Save(conversation); } catch { }
return conversation;
}
public ChatMessage AppendMessage(string tab, ChatMessage message, ChatStorageService? storage = null, bool useForTitle = false)
{
var conv = EnsureCurrentConversation(tab);
conv.Messages.Add(message);
if (useForTitle && string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
{
var userMessageCount = conv.Messages.Count(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
if (userMessageCount == 1 && !string.IsNullOrWhiteSpace(message.Content))
conv.Title = message.Content.Length > 30 ? message.Content[..30] + "…" : message.Content;
}
TouchConversation(storage, tab);
return message;
}
public ChatConversation UpdateConversationMetadata(
string tab,
Action<ChatConversation> apply,
ChatStorageService? storage = null,
bool ensureConversation = true)
{
var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) });
apply(conv);
if (CurrentConversation == null || ensureConversation)
CurrentConversation = conv;
TouchConversation(storage, tab);
return conv;
}
public ChatConversation SaveConversationSettings(
string tab,
string? permission,
string? dataUsage,
string? outputFormat,
string? mood,
ChatStorageService? storage = null)
{
return UpdateConversationMetadata(tab, conv =>
{
conv.Permission = permission;
conv.DataUsage = dataUsage;
conv.OutputFormat = outputFormat;
conv.Mood = mood;
}, storage);
}
public bool RemoveLastAssistantMessage(string tab, ChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null || conv.Messages.Count == 0)
return false;
if (!string.Equals(conv.Messages[^1].Role, "assistant", StringComparison.OrdinalIgnoreCase))
return false;
conv.Messages.RemoveAt(conv.Messages.Count - 1);
TouchConversation(storage, tab);
return true;
}
public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, ChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null)
return false;
if (userMessageIndex < 0 || userMessageIndex >= conv.Messages.Count)
return false;
conv.Messages[userMessageIndex].Content = newText;
while (conv.Messages.Count > userMessageIndex + 1)
conv.Messages.RemoveAt(conv.Messages.Count - 1);
TouchConversation(storage, tab);
return true;
}
public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, ChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null)
return false;
var index = conv.Messages.IndexOf(message);
if (index < 0)
return false;
conv.Messages[index].Feedback = string.IsNullOrWhiteSpace(feedback) ? null : feedback;
TouchConversation(storage, tab);
return true;
}
public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ExecutionEvents ??= new List<ChatExecutionEvent>();
var nextEvent = new ChatExecutionEvent
{
Timestamp = evt.Timestamp,
RunId = evt.RunId,
Type = evt.Type.ToString(),
ToolName = evt.ToolName,
Summary = evt.Summary,
FilePath = evt.FilePath,
Success = evt.Success,
StepCurrent = evt.StepCurrent,
StepTotal = evt.StepTotal,
Steps = evt.Steps?.ToList(),
ElapsedMs = evt.ElapsedMs,
InputTokens = evt.InputTokens,
OutputTokens = evt.OutputTokens,
};
if (conv.ExecutionEvents.Count > 0
&& IsNearDuplicateExecutionEvent(conv.ExecutionEvents[^1], nextEvent))
{
var existing = conv.ExecutionEvents[^1];
existing.Timestamp = nextEvent.Timestamp;
existing.ElapsedMs = Math.Max(existing.ElapsedMs, nextEvent.ElapsedMs);
existing.InputTokens = Math.Max(existing.InputTokens, nextEvent.InputTokens);
existing.OutputTokens = Math.Max(existing.OutputTokens, nextEvent.OutputTokens);
existing.StepCurrent = Math.Max(existing.StepCurrent, nextEvent.StepCurrent);
existing.StepTotal = Math.Max(existing.StepTotal, nextEvent.StepTotal);
existing.Steps = nextEvent.Steps ?? existing.Steps;
existing.Success = existing.Success && nextEvent.Success;
if (string.IsNullOrWhiteSpace(existing.FilePath))
existing.FilePath = nextEvent.FilePath;
if (!string.IsNullOrWhiteSpace(nextEvent.Summary) && nextEvent.Summary.Length >= existing.Summary.Length)
existing.Summary = nextEvent.Summary;
}
else
{
conv.ExecutionEvents.Add(nextEvent);
}
if (conv.ExecutionEvents.Count > MaxExecutionEventHistory)
conv.ExecutionEvents.RemoveRange(0, conv.ExecutionEvents.Count - MaxExecutionEventHistory);
TouchConversation(storage, tab);
return conv;
}
public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.AgentRunHistory ??= new List<ChatAgentRunRecord>();
var newRecord = new ChatAgentRunRecord
{
RunId = evt.RunId,
Status = status,
Summary = summary,
LastIteration = evt.Iteration,
StartedAt = evt.Timestamp,
UpdatedAt = DateTime.Now,
};
var existingIndex = conv.AgentRunHistory.FindIndex(x =>
!string.IsNullOrWhiteSpace(x.RunId) &&
string.Equals(x.RunId, newRecord.RunId, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
conv.AgentRunHistory.RemoveAt(existingIndex);
conv.AgentRunHistory.Insert(0, newRecord);
}
else
conv.AgentRunHistory.Insert(0, newRecord);
if (conv.AgentRunHistory.Count > MaxAgentRunHistory)
conv.AgentRunHistory.RemoveRange(MaxAgentRunHistory, conv.AgentRunHistory.Count - MaxAgentRunHistory);
TouchConversation(storage, tab);
return conv;
}
public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", ChatStorageService? storage = null)
{
var trimmed = text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(trimmed))
return null;
var conv = EnsureCurrentConversation(tab);
conv.DraftQueueItems ??= new List<DraftQueueItem>();
var item = _draftQueue.CreateItem(trimmed, priority);
conv.DraftQueueItems.Add(item);
TouchConversation(storage, tab);
return item;
}
public DraftQueueItem? GetNextQueuedDraft(string tab)
{
var conv = EnsureCurrentConversation(tab);
return _draftQueue.GetNextQueuedItem(conv.DraftQueueItems);
}
public DraftQueueService.DraftQueueSummary GetDraftQueueSummary(string tab)
{
var conv = EnsureCurrentConversation(tab);
return _draftQueue.GetSummary(conv.DraftQueueItems);
}
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
{
var conv = EnsureCurrentConversation(tab);
return (conv.DraftQueueItems ?? []).ToList();
}
public bool MarkDraftRunning(string tab, string draftId, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage);
public bool MarkDraftCompleted(string tab, string draftId, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage);
public bool MarkDraftFailed(string tab, string draftId, string? error, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage);
public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, ChatStorageService? storage = null)
{
return UpdateDraftItem(tab, draftId, item =>
{
if (item == null)
return false;
if (item.AttemptCount >= maxAutoRetries)
return _draftQueue.MarkFailed(item, error);
return _draftQueue.ScheduleRetry(item, error);
}, storage);
}
public bool ResetDraftToQueued(string tab, string draftId, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage);
public bool RemoveDraft(string tab, string draftId, ChatStorageService? storage = null)
{
if (string.IsNullOrWhiteSpace(draftId))
return false;
var conv = EnsureCurrentConversation(tab);
var removed = conv.DraftQueueItems?.RemoveAll(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase)) ?? 0;
if (removed <= 0)
return false;
TouchConversation(storage, tab);
return true;
}
public bool ToggleExecutionHistory(string tab, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ShowExecutionHistory = !conv.ShowExecutionHistory;
TouchConversation(storage, tab);
return conv.ShowExecutionHistory;
}
public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ConversationFailedOnlyFilter = failedOnly;
conv.ConversationRunningOnlyFilter = runningOnly;
conv.ConversationSortMode = sortByRecent ? "recent" : "activity";
TouchConversation(storage, tab);
}
public void SetRuntimeState(bool isStreaming, string statusText, bool isStatusSpinning)
{
IsStreaming = isStreaming;
StatusText = string.IsNullOrWhiteSpace(statusText) ? "대기 중" : statusText;
IsStatusSpinning = isStatusSpinning;
}
public void UpsertRuntimeActivity(string key, string kind, string title, string summary)
{
var existing = ActiveRuntimeActivities.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
ActiveRuntimeActivities.Add(new RuntimeActivity
{
Key = key,
Kind = kind,
Title = title,
Summary = summary,
StartedAt = DateTime.Now,
});
return;
}
existing.Title = title;
existing.Summary = summary;
}
public void RemoveRuntimeActivity(string key, string? completionSummary = null)
{
ActiveRuntimeActivities.RemoveAll(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(completionSummary))
LastCompletedSummary = completionSummary;
}
public void ClearRuntimeActivities(string? completionSummary = null)
{
ActiveRuntimeActivities.Clear();
if (!string.IsNullOrWhiteSpace(completionSummary))
LastCompletedSummary = completionSummary;
}
public string? GetConversationId(string tab)
{
TabConversationIds.TryGetValue(NormalizeTab(tab), out var value);
return value;
}
private static string NormalizeTab(string? tab)
{
var normalized = (tab ?? "").Trim();
if (string.IsNullOrEmpty(normalized))
return "Chat";
if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase))
return "Cowork";
var canonical = new string(normalized
.Where(char.IsLetterOrDigit)
.ToArray())
.ToLowerInvariant();
if (canonical is "cowork" or "coworkcode" or "coworkcodetab")
return "Cowork";
if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase)
|| canonical is "code" or "codetab")
return "Code";
return "Chat";
}
private static bool NormalizeLoadedConversation(ChatConversation conversation)
{
var changed = false;
conversation.Messages ??= new List<ChatMessage>();
conversation.ExecutionEvents ??= new List<ChatExecutionEvent>();
conversation.AgentRunHistory ??= new List<ChatAgentRunRecord>();
conversation.DraftQueueItems ??= new List<DraftQueueItem>();
var orderedEvents = conversation.ExecutionEvents
.OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp)
.ToList();
if (!conversation.ExecutionEvents.SequenceEqual(orderedEvents))
{
conversation.ExecutionEvents = orderedEvents;
changed = true;
}
if (conversation.ExecutionEvents.Count > MaxExecutionEventHistory)
{
conversation.ExecutionEvents = conversation.ExecutionEvents
.Skip(conversation.ExecutionEvents.Count - MaxExecutionEventHistory)
.ToList();
changed = true;
}
var orderedRuns = conversation.AgentRunHistory
.GroupBy(run => string.IsNullOrWhiteSpace(run.RunId) ? Guid.NewGuid().ToString("N") : run.RunId, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt)
.First())
.OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt)
.ToList();
if (!conversation.AgentRunHistory.SequenceEqual(orderedRuns))
{
conversation.AgentRunHistory = orderedRuns;
changed = true;
}
if (conversation.AgentRunHistory.Count > MaxAgentRunHistory)
{
conversation.AgentRunHistory = conversation.AgentRunHistory
.Take(MaxAgentRunHistory)
.ToList();
changed = true;
}
return changed;
}
private static bool IsNearDuplicateExecutionEvent(ChatExecutionEvent left, ChatExecutionEvent right)
{
if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal))
return false;
if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase))
return false;
if (left.Success != right.Success)
return false;
var delta = (right.Timestamp - left.Timestamp).Duration();
return delta <= TimeSpan.FromSeconds(2);
}
private void TouchConversation(ChatStorageService? storage, string tab)
{
var conv = EnsureCurrentConversation(tab);
conv.UpdatedAt = DateTime.Now;
var conversationTab = NormalizeTab(conv.Tab);
if (!string.IsNullOrWhiteSpace(conv.Id))
RememberConversation(conversationTab, conv.Id);
try { storage?.Save(conv); } catch { }
}
private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, ChatStorageService? storage)
{
if (string.IsNullOrWhiteSpace(draftId))
return false;
var conv = EnsureCurrentConversation(tab);
var item = conv.DraftQueueItems?.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
if (item == null)
return false;
if (!update(item))
return false;
TouchConversation(storage, tab);
return true;
}
private static string Truncate(string? text, int max)
{
var value = text?.Trim() ?? "";
if (string.IsNullOrEmpty(value) || value.Length <= max)
return value;
return value[..max] + "…";
}
}

View File

@@ -0,0 +1,329 @@
using System.IO;
using System.Linq;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 대화 내역을 로컬에 AES-256-GCM 암호화하여 저장/로드합니다.
/// 파일 형식: conversations/{id}.axchat (암호화 바이너리)
/// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호.
/// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지.
/// </summary>
public class ChatStorageService
{
private static readonly string ConversationsDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "conversations");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
PropertyNameCaseInsensitive = true
};
private static readonly ReaderWriterLockSlim Lock = new();
public ChatStorageService()
{
Directory.CreateDirectory(ConversationsDir);
}
/// <summary>대화를 암호화하여 저장합니다 (원자적 쓰기).</summary>
public void Save(ChatConversation conversation)
{
conversation.UpdatedAt = DateTime.Now;
// 검색용 미리보기 자동 갱신 (첫 사용자 메시지 100자)
if (string.IsNullOrEmpty(conversation.Preview) && conversation.Messages.Count > 0)
{
var firstUser = conversation.Messages.FirstOrDefault(m => m.Role == "user");
if (firstUser != null)
conversation.Preview = firstUser.Content.Length > 100
? firstUser.Content[..100] : firstUser.Content;
}
var json = JsonSerializer.Serialize(conversation, JsonOpts);
var path = GetFilePath(conversation.Id);
var tempPath = path + ".tmp";
Lock.EnterWriteLock();
try
{
CryptoService.EncryptToFile(tempPath, json);
// 원자적 교체: 기존 파일이 있으면 덮어쓰기
if (File.Exists(path)) File.Delete(path);
File.Move(tempPath, path);
UpdateMetaCache(conversation);
}
catch (Exception ex)
{
// 임시 파일 정리
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
LogService.Warn($"대화 저장 실패 ({conversation.Id}): {ex.Message}");
throw;
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>대화를 복호화하여 로드합니다.</summary>
public ChatConversation? Load(string id)
{
var path = GetFilePath(id);
Lock.EnterReadLock();
try
{
if (!File.Exists(path)) return null;
var json = CryptoService.DecryptFromFile(path);
return JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
}
catch (Exception ex)
{
LogService.Warn($"대화 로드 실패 ({id}): {ex.Message}");
return null;
}
finally
{
Lock.ExitReadLock();
}
}
// ── 메타 캐시 ─────────────────────────────────────────────────────────
private List<ChatConversation>? _metaCache;
private bool _metaDirty = true;
/// <summary>메타 캐시를 무효화합니다. 다음 LoadAllMeta 호출 시 디스크에서 다시 읽습니다.</summary>
public void InvalidateMetaCache() => _metaDirty = true;
/// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary>
public void UpdateMetaCache(ChatConversation conv)
{
if (_metaCache == null) return;
var existing = _metaCache.FindIndex(c => c.Id == conv.Id);
var meta = new ChatConversation
{
Id = conv.Id, Title = conv.Title,
CreatedAt = conv.CreatedAt, UpdatedAt = conv.UpdatedAt,
Pinned = conv.Pinned, Category = conv.Category,
Tab = conv.Tab, SystemCommand = conv.SystemCommand,
WorkFolder = conv.WorkFolder, Preview = conv.Preview,
Messages = new(),
ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new()
};
if (existing >= 0)
_metaCache[existing] = meta;
else
_metaCache.Add(meta);
}
/// <summary>메타 캐시에서 항목을 제거합니다.</summary>
public void RemoveFromMetaCache(string id)
{
_metaCache?.RemoveAll(c => c.Id == id);
}
/// <summary>모든 대화의 메타 정보(메시지 미포함)를 로드합니다. 캐시를 사용합니다.</summary>
public List<ChatConversation> LoadAllMeta()
{
if (!_metaDirty && _metaCache != null)
{
return _metaCache.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
}
var result = new List<ChatConversation>();
if (!Directory.Exists(ConversationsDir))
{
_metaCache = result;
_metaDirty = false;
return result;
}
Lock.EnterReadLock();
try
{
foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat"))
{
try
{
var json = CryptoService.DecryptFromFile(file);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null)
{
var meta = new ChatConversation
{
Id = conv.Id,
Title = conv.Title,
CreatedAt = conv.CreatedAt,
UpdatedAt = conv.UpdatedAt,
Pinned = conv.Pinned,
Category = conv.Category,
Tab = conv.Tab,
SystemCommand = conv.SystemCommand,
WorkFolder = conv.WorkFolder,
Preview = conv.Preview,
Messages = new(),
ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new()
};
result.Add(meta);
}
}
catch (Exception ex)
{
LogService.Warn($"대화 메타 로드 실패 ({Path.GetFileName(file)}): {ex.Message}");
}
}
}
finally
{
Lock.ExitReadLock();
}
_metaCache = result;
_metaDirty = false;
return result.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
}
/// <summary>특정 대화를 삭제합니다.</summary>
public void Delete(string id)
{
var path = GetFilePath(id);
Lock.EnterWriteLock();
try
{
if (File.Exists(path)) File.Delete(path);
RemoveFromMetaCache(id);
}
catch (Exception ex)
{
LogService.Warn($"대화 삭제 실패 ({id}): {ex.Message}");
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>모든 대화를 삭제합니다.</summary>
public int DeleteAll()
{
if (!Directory.Exists(ConversationsDir)) return 0;
Lock.EnterWriteLock();
try
{
var files = Directory.GetFiles(ConversationsDir, "*.axchat");
int count = 0;
foreach (var f in files)
{
try { File.Delete(f); count++; }
catch (Exception ex) { LogService.Warn($"파일 삭제 실패 ({Path.GetFileName(f)}): {ex.Message}"); }
}
InvalidateMetaCache();
return count;
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외).</summary>
public int PurgeExpired(int retentionDays)
{
if (retentionDays <= 0) return 0;
var cutoff = DateTime.Now.AddDays(-retentionDays);
int count = 0;
Lock.EnterWriteLock();
try
{
foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat"))
{
try
{
var json = CryptoService.DecryptFromFile(file);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff)
{
File.Delete(file);
RemoveFromMetaCache(conv.Id);
count++;
}
}
catch (Exception ex)
{
LogService.Warn($"만료 대화 정리 실패 ({Path.GetFileName(file)}): {ex.Message}");
}
}
}
finally
{
Lock.ExitWriteLock();
}
return count;
}
/// <summary>드라이브 사용률이 98% 이상이면 오래된 대화부터 삭제합니다 (핀 고정 제외).</summary>
public int PurgeForDiskSpace(double threshold = 0.98)
{
try
{
var drive = new DriveInfo(Path.GetPathRoot(ConversationsDir) ?? "C");
if (drive.TotalSize <= 0) return 0;
var usageRatio = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize;
if (usageRatio < threshold) return 0;
LogService.Info($"드라이브 사용률 {usageRatio:P1} — 대화 정리 시작 (임계값: {threshold:P0})");
// 오래된 순으로 정렬하여 삭제 (핀 고정 제외)
var files = Directory.GetFiles(ConversationsDir, "*.axchat")
.Select(f => new { Path = f, LastWrite = File.GetLastWriteTime(f) })
.OrderBy(f => f.LastWrite)
.ToList();
int count = 0;
Lock.EnterWriteLock();
try
{
foreach (var file in files)
{
// 사용률이 임계값 미만으로 내려가면 중단
var currentUsage = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize;
if (currentUsage < threshold - 0.02) break; // 2% 여유 확보 후 중단
try
{
var json = CryptoService.DecryptFromFile(file.Path);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null && conv.Pinned) continue; // 핀 고정 건너뜀
File.Delete(file.Path);
count++;
}
catch { }
}
}
finally
{
Lock.ExitWriteLock();
}
if (count > 0)
LogService.Info($"드라이브 용량 부족으로 대화 {count}개 삭제 완료");
return count;
}
catch (Exception ex)
{
LogService.Warn($"디스크 용량 확인 실패: {ex.Message}");
return 0;
}
}
private static string GetFilePath(string id) => Path.Combine(ConversationsDir, $"{id}.axchat");
}

View File

@@ -0,0 +1,575 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 클립보드 변경을 감지하여 히스토리를 관리합니다.
/// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다.
/// </summary>
public class ClipboardHistoryService : IDisposable
{
private const int WM_CLIPBOARDUPDATE = 0x031D;
private static readonly string HistoryPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "clipboard_history.dat");
// 구버전 평문 파일 경로 (마이그레이션용)
private static readonly string LegacyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "clipboard_history.json");
/// <summary>원본 이미지 캐시 폴더 (%APPDATA%\AxCopilot\clipboard_images\)</summary>
private static readonly string ImageCachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "clipboard_images");
private const long MaxCacheSizeBytes = 500 * 1024 * 1024; // 500MB
private const int MaxCacheAgeDays = 30;
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
PropertyNameCaseInsensitive = true
};
private readonly SettingsService _settings;
private HwndSource? _msgSource;
private readonly object _lock = new();
private volatile bool _ignoreNext; // 자체 클립보드 조작 시 히스토리 추가 방지
private bool _disposed;
private readonly List<ClipboardEntry> _history = new();
public IReadOnlyList<ClipboardEntry> History
{
get { lock (_lock) return _history.ToList(); }
}
public event EventHandler? HistoryChanged;
public ClipboardHistoryService(SettingsService settings)
{
_settings = settings;
}
/// <summary>메시지 창을 생성하고 클립보드 알림을 등록합니다.</summary>
public void Initialize()
{
if (_disposed) return;
if (_msgSource != null) return; // 이미 초기화됨
if (!_settings.Settings.ClipboardHistory.Enabled) return;
// 저장된 히스토리 복원 (텍스트 항목만)
LoadHistory();
Application.Current.Dispatcher.Invoke(() =>
{
var sourceParams = new HwndSourceParameters("axClipboardMonitor")
{
Width = 0,
Height = 0,
ParentWindow = new IntPtr(-3), // HWND_MESSAGE — 화면에 표시 안 됨
WindowStyle = 0,
ExtendedWindowStyle = 0
};
_msgSource = new HwndSource(sourceParams);
_msgSource.AddHook(WndProc);
AddClipboardFormatListener(_msgSource.Handle);
LogService.Info("클립보드 히스토리 서비스 시작");
});
}
/// <summary>자체 클립보드 조작(히스토리 붙여넣기) 시 히스토리가 중복 추가되지 않도록 플래그 설정.</summary>
public void SuppressNextCapture() => _ignoreNext = true;
/// <summary>항목을 사용했을 때 CopiedAt을 현재 시각으로 갱신하고 목록 맨 위로 이동합니다.</summary>
public void PromoteEntry(ClipboardEntry entry)
{
lock (_lock)
{
_history.Remove(entry);
var updated = new ClipboardEntry(entry.Text, DateTime.Now)
{
Image = entry.Image,
OriginalImagePath = entry.OriginalImagePath,
IsPinned = entry.IsPinned,
Category = entry.Category,
};
_history.Insert(0, updated);
}
HistoryChanged?.Invoke(this, EventArgs.Empty);
_ = SaveHistoryAsync();
}
public void ClearHistory()
{
lock (_lock) _history.Clear();
HistoryChanged?.Invoke(this, EventArgs.Empty);
_ = SaveHistoryAsync();
}
/// <summary>항목의 핀 고정을 토글합니다.</summary>
public void TogglePin(ClipboardEntry entry)
{
lock (_lock)
{
if (!entry.IsPinned)
{
// 최대 핀 개수 체크
var maxPins = _settings.Settings.Launcher.MaxPinnedClipboardItems;
var currentPins = _history.Count(e => e.IsPinned);
if (currentPins >= maxPins) return; // 최대 도달 시 무시
}
entry.IsPinned = !entry.IsPinned;
}
HistoryChanged?.Invoke(this, EventArgs.Empty);
_ = SaveHistoryAsync();
}
/// <summary>텍스트 내용에서 카테고리를 자동 감지합니다.</summary>
private static string DetectCategory(string text)
{
var trimmed = text.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) &&
(uri.Scheme == "http" || uri.Scheme == "https"))
return "URL";
if (trimmed.StartsWith("\\\\") || (trimmed.Length >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/')))
return "경로";
if (trimmed.Contains('{') && trimmed.Contains('}') || trimmed.Contains("function ") ||
trimmed.Contains("class ") || trimmed.Contains("public ") || trimmed.Contains("private ") ||
trimmed.Contains("def ") || trimmed.Contains("import ") || trimmed.Contains("using "))
return "코드";
return "일반";
}
/// <summary>
/// 클립보드 감지가 동작하지 않을 때 강제 재시작합니다.
/// Dispose 후 호출하면 내부 상태를 초기화하고 클립보드 모니터링을 재개합니다.
/// </summary>
public void Reinitialize()
{
_disposed = false;
_msgSource = null;
Initialize();
LogService.Info("클립보드 히스토리 서비스 재초기화 완료");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_msgSource != null)
{
RemoveClipboardFormatListener(_msgSource.Handle);
_msgSource.Dispose();
_msgSource = null;
}
// 종료 시 히스토리 저장 (동기)
SaveHistorySync();
}
// ─── 히스토리 영속성 (DPAPI 암호화) ─────────────────────────────────────
// Windows DPAPI(DataProtectionScope.CurrentUser)를 사용하여
// 현재 Windows 사용자 계정에서만 복호화 가능하도록 합니다.
// 이 앱 외부에서는 파일 내용을 읽을 수 없습니다.
private void LoadHistory()
{
try
{
// 구버전 평문 파일 → 암호화 파일 마이그레이션
if (!File.Exists(HistoryPath) && File.Exists(LegacyPath))
{
MigrateLegacyFile();
}
if (!File.Exists(HistoryPath)) return;
var encrypted = File.ReadAllBytes(HistoryPath);
var plain = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser);
var json = Encoding.UTF8.GetString(plain);
var saved = JsonSerializer.Deserialize<List<SavedClipEntry>>(json, JsonOpts);
if (saved == null) return;
int max = _settings.Settings.ClipboardHistory.MaxItems;
lock (_lock)
{
foreach (var s in saved.Take(max))
{
if (!string.IsNullOrEmpty(s.ImageBase64))
{
var img = Base64ToImage(s.ImageBase64);
if (img != null)
{
_history.Add(new ClipboardEntry("", s.CopiedAt)
{
Image = img,
OriginalImagePath = s.OriginalImagePath,
IsPinned = s.IsPinned,
Category = s.Category,
});
continue;
}
}
_history.Add(new ClipboardEntry(s.Text, s.CopiedAt) { IsPinned = s.IsPinned, Category = s.Category });
}
}
// 시작 시 이미지 캐시 정리
Task.Run(CleanupImageCache);
LogService.Info($"클립보드 히스토리 {_history.Count}개 복원 (암호화)");
}
catch (Exception ex)
{
LogService.Warn($"클립보드 히스토리 로드 실패: {ex.Message}");
}
}
private void MigrateLegacyFile()
{
try
{
var json = File.ReadAllText(LegacyPath);
var plain = Encoding.UTF8.GetBytes(json);
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
File.WriteAllBytes(HistoryPath, encrypted);
File.Delete(LegacyPath); // 평문 파일 삭제
LogService.Info("클립보드 히스토리 평문→암호화 마이그레이션 완료");
}
catch (Exception ex)
{
LogService.Warn($"클립보드 히스토리 마이그레이션 실패: {ex.Message}");
}
}
private async Task SaveHistoryAsync()
{
try
{
var snapshot = BuildSnapshot();
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
var plain = Encoding.UTF8.GetBytes(json);
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
await File.WriteAllBytesAsync(HistoryPath, encrypted).ConfigureAwait(false);
}
catch (Exception ex)
{
LogService.Warn($"클립보드 히스토리 저장 실패: {ex.Message}");
}
}
private void SaveHistorySync()
{
try
{
var snapshot = BuildSnapshot();
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
var plain = Encoding.UTF8.GetBytes(json);
var encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
File.WriteAllBytes(HistoryPath, encrypted);
}
catch { /* 종료 시 실패 무시 */ }
}
private List<SavedClipEntry> BuildSnapshot()
{
lock (_lock)
{
return _history.Select(e =>
{
if (e.IsText)
return new SavedClipEntry { Text = e.Text, CopiedAt = e.CopiedAt, IsPinned = e.IsPinned, Category = e.Category };
var b64 = ImageToBase64(e.Image);
return new SavedClipEntry
{
CopiedAt = e.CopiedAt,
ImageBase64 = b64,
OriginalImagePath = e.OriginalImagePath,
IsPinned = e.IsPinned,
Category = e.Category,
};
}).ToList();
}
}
// System.Text.Json 역직렬화를 위해 기본 생성자 + 프로퍼티 형태로 선언
private class SavedClipEntry
{
public string Text { get; set; } = "";
public DateTime CopiedAt { get; set; }
/// <summary>이미지 썸네일 PNG 바이트 (Base64 인코딩). null이면 텍스트 항목.</summary>
public string? ImageBase64 { get; set; }
/// <summary>원본 이미지 파일 경로 (clipboard_images 폴더).</summary>
public string? OriginalImagePath { get; set; }
public bool IsPinned { get; set; }
public string Category { get; set; } = "일반";
}
// ─── 내부 ──────────────────────────────────────────────────────────────
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_CLIPBOARDUPDATE)
{
OnClipboardUpdate();
handled = false;
}
return IntPtr.Zero;
}
private void OnClipboardUpdate()
{
if (_ignoreNext) { _ignoreNext = false; return; }
if (!_settings.Settings.ClipboardHistory.Enabled) return;
Application.Current.Dispatcher.Invoke(() =>
{
try
{
ClipboardEntry? entry = null;
// ─── 텍스트 ────────────────────────────────────────────────────
if (Clipboard.ContainsText())
{
var text = Clipboard.GetText();
if (string.IsNullOrWhiteSpace(text)) return;
if (text.Length > 10_000) return;
// 제외 패턴 검사
foreach (var pattern in _settings.Settings.ClipboardHistory.ExcludePatterns)
{
try
{
if (Regex.IsMatch(text.Trim(), pattern,
RegexOptions.None, TimeSpan.FromMilliseconds(200)))
return;
}
catch { /* 잘못된 패턴 무시 */ }
}
var category = _settings.Settings.Launcher.EnableClipboardAutoCategory
? DetectCategory(text) : "일반";
entry = new ClipboardEntry(text, DateTime.Now) { Category = category };
}
// ─── 이미지 ────────────────────────────────────────────────────
else if (Clipboard.ContainsImage())
{
var src = Clipboard.GetImage();
if (src == null) return;
// 원본 이미지를 캐시 폴더에 PNG로 저장
var originalPath = SaveOriginalImage(src);
// 표시용 썸네일 (최대 80px 폭)
var thumb = CreateThumbnail(src, 80);
entry = new ClipboardEntry("", DateTime.Now)
{
Image = thumb,
OriginalImagePath = originalPath,
};
}
if (entry == null) return;
lock (_lock)
{
// 텍스트 중복 제거
if (entry.IsText)
{
if (_history.Count > 0 && _history[0].Text == entry.Text) return;
_history.RemoveAll(e => e.IsText && e.Text == entry.Text);
}
_history.Insert(0, entry);
int max = _settings.Settings.ClipboardHistory.MaxItems;
while (_history.Count > max)
{
// 핀 고정 항목은 삭제 보호 — 뒤에서부터 핀 아닌 항목 제거
var removeIdx = _history.FindLastIndex(e => !e.IsPinned);
if (removeIdx >= 0) _history.RemoveAt(removeIdx);
else break; // 전부 핀이면 중단
}
}
HistoryChanged?.Invoke(this, EventArgs.Empty);
_ = SaveHistoryAsync();
}
catch (Exception ex)
{
LogService.Warn($"클립보드 캡처 실패: {ex.Message}");
}
});
}
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
private static string? SaveOriginalImage(BitmapSource src)
{
try
{
Directory.CreateDirectory(ImageCachePath);
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
var filePath = Path.Combine(ImageCachePath, fileName);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(src));
using var fs = new FileStream(filePath, FileMode.Create);
encoder.Save(fs);
return filePath;
}
catch (Exception ex)
{
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
return null;
}
}
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
public static BitmapSource? LoadOriginalImage(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
try
{
var bi = new BitmapImage();
bi.BeginInit();
bi.CacheOption = BitmapCacheOption.OnLoad;
bi.UriSource = new Uri(path, UriKind.Absolute);
bi.EndInit();
bi.Freeze();
return bi;
}
catch { return null; }
}
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
public static void CleanupImageCache()
{
try
{
if (!Directory.Exists(ImageCachePath)) return;
var files = new DirectoryInfo(ImageCachePath)
.GetFiles("clip_*.png")
.OrderBy(f => f.LastWriteTime)
.ToList();
// 30일 초과 파일 삭제
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
{
try { f.Delete(); files.Remove(f); } catch { }
}
// 500MB 초과 시 오래된 파일부터 삭제
var totalSize = files.Sum(f => f.Length);
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
{
var oldest = files[0];
totalSize -= oldest.Length;
try { oldest.Delete(); } catch { }
files.RemoveAt(0);
}
}
catch (Exception ex)
{
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
}
}
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
{
if (src.PixelWidth <= maxWidth) return src;
var scale = (double)maxWidth / src.PixelWidth;
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
}
private static string? ImageToBase64(BitmapSource? img)
{
if (img == null) return null;
try
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(img));
using var ms = new MemoryStream();
encoder.Save(ms);
return Convert.ToBase64String(ms.ToArray());
}
catch { return null; }
}
private static BitmapSource? Base64ToImage(string? base64)
{
if (string.IsNullOrEmpty(base64)) return null;
try
{
var bytes = Convert.FromBase64String(base64);
using var ms = new MemoryStream(bytes);
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
return decoder.Frames[0];
}
catch { return null; }
}
// ─── P/Invoke ──────────────────────────────────────────────────────────
[DllImport("user32.dll", SetLastError = true)]
private static extern bool AddClipboardFormatListener(IntPtr hwnd);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
}
/// <summary>클립보드 히스토리 단일 항목. 텍스트 또는 이미지 중 하나를 담습니다.</summary>
public record ClipboardEntry(string Text, DateTime CopiedAt)
{
/// <summary>이미지 항목의 표시용 썸네일 (텍스트 항목은 null)</summary>
public BitmapSource? Image { get; init; }
/// <summary>원본 해상도 이미지 파일 경로 (clipboard_images 폴더). null이면 썸네일만 존재.</summary>
public string? OriginalImagePath { get; init; }
/// <summary>핀 고정 여부 (핀 항목은 삭제되지 않고 상단에 표시)</summary>
public bool IsPinned { get; set; }
/// <summary>카테고리 (URL, 코드, 경로, 일반)</summary>
public string Category { get; set; } = "일반";
/// <summary>텍스트 항목 여부</summary>
public bool IsText => Image == null;
/// <summary>UI 표시용 첫 줄 미리보기 (최대 80자)</summary>
public string Preview
{
get
{
if (!IsText) return "[이미지]";
var line = Text.Replace("\r\n", "↵ ").Replace("\n", "↵ ").Replace("\r", "↵ ");
return line.Length > 80 ? line[..77] + "…" : line;
}
}
/// <summary>복사 시각 상대 표시</summary>
public string RelativeTime
{
get
{
var diff = DateTime.Now - CopiedAt;
if (diff.TotalSeconds < 60) return "방금 전";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 전";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 전";
return CopiedAt.ToString("MM/dd HH:mm");
}
}
}

View File

@@ -0,0 +1,587 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
namespace AxCopilot.Services;
/// <summary>
/// 프로젝트 코드베이스 인덱싱 및 시맨틱 검색 서비스.
/// TF-IDF 기반 유사도 검색을 SQLite에 영속 저장하여
/// 증분 업데이트와 빠른 재시작을 지원합니다.
/// (로컬 전용, 외부 서버 불필요)
/// </summary>
public class CodeIndexService : IDisposable
{
private SqliteConnection? _db;
private string _workFolder = "";
private bool _indexed;
private int _totalDocs;
public bool IsIndexed => _indexed;
public int ChunkCount => _totalDocs;
// ── 스톱워드 (TF-IDF 정확도 향상) ──────────────────────────────────
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
{
// 영어 공통
"the", "is", "at", "of", "on", "and", "or", "not", "in", "to", "for",
"it", "be", "as", "do", "by", "this", "that", "with", "from", "but",
"an", "are", "was", "were", "been", "being", "have", "has", "had",
"if", "else", "then", "than", "so", "no", "yes",
// 프로그래밍 공통 (너무 빈번해서 변별력 없음)
"var", "int", "string", "void", "null", "new", "return", "get", "set",
"public", "private", "class", "static", "using", "namespace", "true", "false",
"import", "export", "function", "const", "let", "def", "self",
};
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".hpp",
".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala",
".html", ".css", ".scss", ".json", ".xml", ".yaml", ".yml",
".md", ".txt", ".sql", ".sh", ".bat", ".ps1",
".csproj", ".sln", ".gradle", ".pom",
};
// ── DB 초기화 ───────────────────────────────────────────────────────
private string GetDbPath(string workFolder)
{
// %APPDATA%\AxCopilot\index\{folderHash}.db
var hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant())))[..16];
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "index");
Directory.CreateDirectory(dir);
return Path.Combine(dir, $"{hash}.db");
}
private void EnsureDb(string workFolder)
{
if (_db != null && _workFolder == workFolder) return;
_db?.Dispose();
_workFolder = workFolder;
var dbPath = GetDbPath(workFolder);
_db = new SqliteConnection($"Data Source={dbPath}");
_db.Open();
// WAL 모드 (동시 읽기/쓰기 성능)
using (var cmd = _db.CreateCommand())
{
cmd.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;";
cmd.ExecuteNonQuery();
}
// 테이블 생성
using var create = _db.CreateCommand();
create.CommandText = """
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
last_modified TEXT NOT NULL,
file_size INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tokens (
chunk_id INTEGER NOT NULL,
token TEXT NOT NULL,
tf INTEGER NOT NULL,
FOREIGN KEY (chunk_id) REFERENCES chunks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS doc_freq (
token TEXT PRIMARY KEY,
df INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tokens_chunk ON tokens(chunk_id);
CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token);
CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id);
""";
create.ExecuteNonQuery();
}
// ── 인덱싱 ──────────────────────────────────────────────────────────
/// <summary>작업 폴더의 코드 파일을 인덱싱합니다. 증분 업데이트 지원.</summary>
public async Task IndexAsync(string workFolder, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder))
return;
EnsureDb(workFolder);
await Task.Run(() =>
{
var existingFiles = LoadExistingFiles();
// 설정에서 최대 파일 크기 조회
var maxFileKb = 500;
try
{
var app = System.Windows.Application.Current as App;
var cfgMax = app?.SettingsService?.Settings.Llm.Code.CodeIndexMaxFileKb ?? 500;
if (cfgMax > 0) maxFileKb = cfgMax;
}
catch { }
var currentFiles = ScanFiles(workFolder, maxFileKb);
int added = 0, updated = 0, removed = 0;
// 삭제된 파일 제거
foreach (var (path, fileId) in existingFiles)
{
if (!currentFiles.ContainsKey(path))
{
RemoveFileFromIndex(fileId);
removed++;
}
}
// 신규/변경 파일 인덱싱
foreach (var (relPath, info) in currentFiles)
{
if (ct.IsCancellationRequested) break;
var lastMod = info.LastWriteTimeUtc.ToString("O");
var size = info.Length;
if (existingFiles.TryGetValue(relPath, out var fileId))
{
// 기존 파일 — 변경 여부 확인
if (!IsFileChanged(fileId, lastMod, size))
continue;
RemoveFileFromIndex(fileId);
updated++;
}
else
{
added++;
}
IndexFile(workFolder, relPath, lastMod, size);
}
// DF 테이블 재계산
RebuildDocFreq();
// 총 청크 수 캐시
_totalDocs = GetTotalChunkCount();
_indexed = _totalDocs > 0;
// 메타 저장
SaveMeta("lastIndexed", DateTime.UtcNow.ToString("O"));
SaveMeta("workFolder", workFolder);
LogService.Info($"코드 인덱싱 완료: {_totalDocs}개 청크 (추가:{added} 갱신:{updated} 삭제:{removed}) [{workFolder}]");
}, ct);
}
private Dictionary<string, int> LoadExistingFiles()
{
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
using var cmd = _db!.CreateCommand();
cmd.CommandText = "SELECT id, path FROM files";
using var reader = cmd.ExecuteReader();
while (reader.Read())
dict[reader.GetString(1)] = reader.GetInt32(0);
return dict;
}
private Dictionary<string, FileInfo> ScanFiles(string workFolder, int maxFileKb = 500)
{
var dict = new Dictionary<string, FileInfo>(StringComparer.OrdinalIgnoreCase);
var maxBytes = (long)maxFileKb * 1024;
try
{
var files = Directory.EnumerateFiles(workFolder, "*.*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 8,
});
foreach (var file in files)
{
var ext = Path.GetExtension(file);
if (!CodeExtensions.Contains(ext)) continue;
try
{
var info = new FileInfo(file);
if (info.Length > maxBytes) continue;
var relPath = Path.GetRelativePath(workFolder, file);
dict[relPath] = info;
}
catch { }
}
}
catch { }
return dict;
}
private bool IsFileChanged(int fileId, string lastMod, long size)
{
using var cmd = _db!.CreateCommand();
cmd.CommandText = "SELECT last_modified, file_size FROM files WHERE id = @id";
cmd.Parameters.AddWithValue("@id", fileId);
using var reader = cmd.ExecuteReader();
if (!reader.Read()) return true;
return reader.GetString(0) != lastMod || reader.GetInt64(1) != size;
}
private void RemoveFileFromIndex(int fileId)
{
// 청크 ID 목록
var chunkIds = new List<int>();
using (var cmd = _db!.CreateCommand())
{
cmd.CommandText = "SELECT id FROM chunks WHERE file_id = @fid";
cmd.Parameters.AddWithValue("@fid", fileId);
using var reader = cmd.ExecuteReader();
while (reader.Read()) chunkIds.Add(reader.GetInt32(0));
}
// 토큰 삭제
foreach (var cid in chunkIds)
{
using var cmd = _db!.CreateCommand();
cmd.CommandText = "DELETE FROM tokens WHERE chunk_id = @cid";
cmd.Parameters.AddWithValue("@cid", cid);
cmd.ExecuteNonQuery();
}
// 청크 삭제
using (var cmd = _db!.CreateCommand())
{
cmd.CommandText = "DELETE FROM chunks WHERE file_id = @fid";
cmd.Parameters.AddWithValue("@fid", fileId);
cmd.ExecuteNonQuery();
}
// 파일 삭제
using (var cmd = _db!.CreateCommand())
{
cmd.CommandText = "DELETE FROM files WHERE id = @fid";
cmd.Parameters.AddWithValue("@fid", fileId);
cmd.ExecuteNonQuery();
}
}
private void IndexFile(string workFolder, string relPath, string lastMod, long size)
{
try
{
var fullPath = Path.Combine(workFolder, relPath);
var content = File.ReadAllText(fullPath, Encoding.UTF8);
// 파일 등록
int fileId;
using (var cmd = _db!.CreateCommand())
{
cmd.CommandText = "INSERT INTO files (path, last_modified, file_size) VALUES (@p, @m, @s) RETURNING id";
cmd.Parameters.AddWithValue("@p", relPath);
cmd.Parameters.AddWithValue("@m", lastMod);
cmd.Parameters.AddWithValue("@s", size);
fileId = Convert.ToInt32(cmd.ExecuteScalar());
}
// 청크 분할 (50라인씩)
var lines = content.Split('\n');
using var tx = _db!.BeginTransaction();
for (int i = 0; i < lines.Length; i += 50)
{
var chunkLines = lines.AsSpan(i, Math.Min(50, lines.Length - i));
var chunkText = string.Join("\n", chunkLines.ToArray());
if (string.IsNullOrWhiteSpace(chunkText)) continue;
var endLine = Math.Min(i + 50, lines.Length);
// 청크 저장
int chunkId;
using (var cmd = _db!.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = "INSERT INTO chunks (file_id, start_line, end_line, content) VALUES (@f, @s, @e, @c) RETURNING id";
cmd.Parameters.AddWithValue("@f", fileId);
cmd.Parameters.AddWithValue("@s", i + 1);
cmd.Parameters.AddWithValue("@e", endLine);
cmd.Parameters.AddWithValue("@c", chunkText);
chunkId = Convert.ToInt32(cmd.ExecuteScalar());
}
// 토큰 저장
var tokens = Tokenize(chunkText);
foreach (var (token, tf) in tokens)
{
using var cmd = _db!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "INSERT INTO tokens (chunk_id, token, tf) VALUES (@cid, @t, @tf)";
cmd.Parameters.AddWithValue("@cid", chunkId);
cmd.Parameters.AddWithValue("@t", token);
cmd.Parameters.AddWithValue("@tf", tf);
cmd.ExecuteNonQuery();
}
}
tx.Commit();
}
catch { /* 읽기 실패 파일 건너뛰기 */ }
}
private void RebuildDocFreq()
{
using var cmd = _db!.CreateCommand();
cmd.CommandText = """
DELETE FROM doc_freq;
INSERT INTO doc_freq (token, df)
SELECT token, COUNT(DISTINCT chunk_id) FROM tokens GROUP BY token;
""";
cmd.ExecuteNonQuery();
}
private int GetTotalChunkCount()
{
using var cmd = _db!.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM chunks";
return Convert.ToInt32(cmd.ExecuteScalar());
}
private void SaveMeta(string key, string value)
{
using var cmd = _db!.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO meta (key, value) VALUES (@k, @v)";
cmd.Parameters.AddWithValue("@k", key);
cmd.Parameters.AddWithValue("@v", value);
cmd.ExecuteNonQuery();
}
// ── 검색 ────────────────────────────────────────────────────────────
/// <summary>시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다.</summary>
public List<SearchResult> Search(string query, int maxResults = 5)
{
if (!_indexed || _db == null || _totalDocs == 0)
return new();
var queryTokens = Tokenize(query);
if (queryTokens.Count == 0) return new();
// 쿼리 토큰의 DF 조회
var dfMap = new Dictionary<string, int>();
foreach (var token in queryTokens.Keys)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT df FROM doc_freq WHERE token = @t";
cmd.Parameters.AddWithValue("@t", token);
var result = cmd.ExecuteScalar();
if (result != null) dfMap[token] = Convert.ToInt32(result);
}
// 후보 청크 검색: 쿼리 토큰이 하나라도 포함된 청크만
var candidateChunks = new HashSet<int>();
foreach (var token in queryTokens.Keys)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT DISTINCT chunk_id FROM tokens WHERE token = @t";
cmd.Parameters.AddWithValue("@t", token);
using var reader = cmd.ExecuteReader();
while (reader.Read()) candidateChunks.Add(reader.GetInt32(0));
}
if (candidateChunks.Count == 0) return new();
// 각 후보 청크의 TF-IDF 유사도 계산
var scored = new List<(int ChunkId, double Score)>();
foreach (var chunkId in candidateChunks)
{
// 청크의 토큰 TF 로드
var docTf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _db.CreateCommand())
{
cmd.CommandText = "SELECT token, tf FROM tokens WHERE chunk_id = @cid";
cmd.Parameters.AddWithValue("@cid", chunkId);
using var reader = cmd.ExecuteReader();
while (reader.Read())
docTf[reader.GetString(0)] = reader.GetInt32(1);
}
var score = ComputeTfIdfSimilarity(queryTokens, docTf, dfMap);
if (score > 0.01)
scored.Add((chunkId, score));
}
// 상위 결과 추출
var topChunks = scored
.OrderByDescending(s => s.Score)
.Take(maxResults)
.ToList();
var results = new List<SearchResult>();
foreach (var (chunkId, score) in topChunks)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = """
SELECT f.path, c.start_line, c.end_line, c.content
FROM chunks c JOIN files f ON c.file_id = f.id
WHERE c.id = @cid
""";
cmd.Parameters.AddWithValue("@cid", chunkId);
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
results.Add(new SearchResult
{
FilePath = reader.GetString(0),
StartLine = reader.GetInt32(1),
EndLine = reader.GetInt32(2),
Score = score,
Preview = reader.GetString(3) is { Length: > 200 } s ? s[..200] + "..." : reader.GetString(3),
});
}
}
return results;
}
/// <summary>기존 인덱스가 있으면 로드합니다 (앱 재시작 시).</summary>
public void TryLoadExisting(string workFolder)
{
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
var dbPath = GetDbPath(workFolder);
if (!File.Exists(dbPath)) return;
EnsureDb(workFolder);
_totalDocs = GetTotalChunkCount();
_indexed = _totalDocs > 0;
if (_indexed)
LogService.Info($"기존 코드 인덱스 로드: {_totalDocs}개 청크 [{workFolder}]");
}
// ── TF-IDF 계산 ─────────────────────────────────────────────────────
private double ComputeTfIdfSimilarity(
Dictionary<string, int> queryTf,
Dictionary<string, int> docTf,
Dictionary<string, int> dfMap)
{
double dotProduct = 0, queryNorm = 0, docNorm = 0;
foreach (var (token, qtf) in queryTf)
{
var df = dfMap.GetValueOrDefault(token, 0);
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
var qWeight = qtf * idf;
queryNorm += qWeight * qWeight;
if (docTf.TryGetValue(token, out var dtf))
{
var dWeight = dtf * idf;
dotProduct += qWeight * dWeight;
}
}
foreach (var (token, dtf) in docTf)
{
var df = dfMap.GetValueOrDefault(token, 0);
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
var dWeight = dtf * idf;
docNorm += dWeight * dWeight;
}
if (queryNorm == 0 || docNorm == 0) return 0;
return dotProduct / (Math.Sqrt(queryNorm) * Math.Sqrt(docNorm));
}
// ── 토큰화 ──────────────────────────────────────────────────────────
/// <summary>텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함.</summary>
private static Dictionary<string, int> Tokenize(string text)
{
var tf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var words = Regex.Split(text, @"[^a-zA-Z0-9가-힣_]+")
.SelectMany(SplitCamelCase)
.Where(w => w.Length >= 2 && !StopWords.Contains(w));
foreach (var word in words)
{
var lower = word.ToLowerInvariant();
tf.TryGetValue(lower, out var count);
tf[lower] = count + 1;
}
// 바이그램 추가 (구문 검색 품질 향상)
var wordList = words.Select(w => w.ToLowerInvariant()).ToList();
for (int i = 0; i < wordList.Count - 1; i++)
{
var bigram = $"{wordList[i]}_{wordList[i + 1]}";
tf.TryGetValue(bigram, out var bc);
tf[bigram] = bc + 1;
}
return tf;
}
private static IEnumerable<string> SplitCamelCase(string word)
{
if (string.IsNullOrEmpty(word)) yield break;
var sb = new StringBuilder();
foreach (var ch in word)
{
if (char.IsUpper(ch) && sb.Length > 0)
{
yield return sb.ToString();
sb.Clear();
}
sb.Append(ch);
}
if (sb.Length > 0) yield return sb.ToString();
}
// ── Dispose ─────────────────────────────────────────────────────────
public void Dispose()
{
_db?.Dispose();
_db = null;
}
}
/// <summary>인덱싱된 코드 청크.</summary>
public class CodeChunk
{
public string FilePath { get; init; } = "";
public int StartLine { get; init; }
public int EndLine { get; init; }
public string Content { get; init; } = "";
public Dictionary<string, int> Tokens { get; init; } = new();
}
/// <summary>검색 결과.</summary>
public class SearchResult
{
public string FilePath { get; init; } = "";
public int StartLine { get; init; }
public int EndLine { get; init; }
public double Score { get; init; }
public string Preview { get; init; } = "";
public override string ToString() => $"{FilePath}:{StartLine}-{EndLine} (score: {Score:F3})";
}

View File

@@ -0,0 +1,116 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
namespace AxCopilot.Services;
/// <summary>
/// IBM Cloud Pak for Data (CP4D) 토큰 발급 및 캐싱 서비스.
/// CP4D의 /icp4d-api/v1/authorize 엔드포인트에서 Bearer 토큰을 발급받고,
/// 만료 전까지 캐싱하여 재사용합니다.
/// </summary>
internal sealed class Cp4dTokenService
{
private static readonly HttpClient _http = new()
{
Timeout = TimeSpan.FromSeconds(15)
};
// 캐시: (cp4dUrl + username) → (token, expiry)
private static readonly Dictionary<string, (string Token, DateTime Expiry)> _cache = new();
private static readonly object _lock = new();
/// <summary>
/// CP4D 토큰을 발급받거나 캐시에서 반환합니다.
/// </summary>
/// <param name="cp4dUrl">CP4D 서버 URL (예: https://cpd-host.example.com)</param>
/// <param name="username">CP4D 사용자 이름</param>
/// <param name="password">CP4D 비밀번호 또는 API 키</param>
/// <param name="ct">취소 토큰</param>
/// <returns>Bearer 토큰 문자열. 실패 시 null.</returns>
public static async Task<string?> GetTokenAsync(string cp4dUrl, string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(cp4dUrl) || string.IsNullOrWhiteSpace(username))
return null;
var cacheKey = $"{cp4dUrl}|{username}";
// 캐시 확인 — 만료 1분 전까지 유효
lock (_lock)
{
if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1))
return cached.Token;
}
// 토큰 발급
try
{
var tokenUrl = cp4dUrl.TrimEnd('/') + "/icp4d-api/v1/authorize";
var body = new { username, password };
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
{
Content = JsonContent.Create(body)
};
// CP4D는 자체 서명 인증서를 사용할 수 있으므로 TLS 오류 무시 옵션 (사내 환경)
using var resp = await _http.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
LogService.Warn($"CP4D 토큰 발급 실패: {resp.StatusCode} - {errBody}");
return null;
}
var json = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
// CP4D 응답 형식: {"token": "...", "_messageCode_": "...", "message": "..."}
if (!doc.RootElement.TryGetProperty("token", out var tokenProp))
{
LogService.Warn("CP4D 응답에 token 필드가 없습니다.");
return null;
}
var token = tokenProp.GetString();
if (string.IsNullOrEmpty(token)) return null;
// 토큰 만료 시간 — CP4D 기본 12시간, 안전하게 11시간으로 설정
var expiry = DateTime.UtcNow.AddHours(11);
// _messageCode_에서 만료 정보가 있으면 파싱 시도
if (doc.RootElement.TryGetProperty("accessTokenExpiry", out var expiryProp) &&
expiryProp.TryGetInt64(out var expiryMs))
{
expiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime;
}
lock (_lock)
{
_cache[cacheKey] = (token, expiry);
}
LogService.Info($"CP4D 토큰 발급 완료: {cp4dUrl} (만료: {expiry:yyyy-MM-dd HH:mm} UTC)");
return token;
}
catch (Exception ex)
{
LogService.Error($"CP4D 토큰 발급 오류: {ex.Message}");
return null;
}
}
/// <summary>특정 CP4D 서버의 캐시된 토큰을 무효화합니다.</summary>
public static void InvalidateToken(string cp4dUrl, string username)
{
var cacheKey = $"{cp4dUrl}|{username}";
lock (_lock) { _cache.Remove(cacheKey); }
}
/// <summary>모든 캐시된 토큰을 초기화합니다.</summary>
public static void ClearAllTokens()
{
lock (_lock) { _cache.Clear(); }
}
}

View File

@@ -0,0 +1,212 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace AxCopilot.Services;
/// <summary>
/// AX Copilot 암호화 서비스.
///
/// 두 가지 암호화 계층을 제공합니다:
///
/// 1. **앱 공용 키 (Portable)** — 설정값(API 키 등) 암호화
/// - 앱 내장 고정 키 + AES-256-CBC
/// - 관리자 PC에서 암호화한 값이 모든 PC에서 동일하게 복호화됨
/// - AxKeyEncryptor 도구에서도 동일 키 사용
///
/// 2. **PC별 개인 키 (Local)** — 대화 내역 파일 암호화
/// - DPAPI로 보호되는 PC/사용자별 랜덤 마스터 키 + AES-256-GCM
/// - 해당 PC/사용자 계정에서만 복호화 가능
/// - 대화 파일(.axchat)은 다른 PC로 복사해도 열리지 않음
/// </summary>
public static class CryptoService
{
// ═══════════════════════════════════════════════════════════════════════
// 1. 앱 공용 키 — 설정값 암호화 (Portable, 모든 PC에서 동일)
// ═══════════════════════════════════════════════════════════════════════
// 앱 고유 시드. 이 값에서 PBKDF2로 256-bit 키를 파생합니다.
// ※ 바이너리에 포함되므로 완벽한 보안은 아니지만, 평문 노출을 방지합니다.
private static readonly byte[] AppSeed =
{
// "AX-Commander-Key-0104-sj.baeck" + 2-byte pad
0x41, 0x58, 0x2D, 0x43, 0x6F, 0x6D, 0x6D, 0x61,
0x6E, 0x64, 0x65, 0x72, 0x2D, 0x4B, 0x65, 0x79,
0x2D, 0x30, 0x31, 0x30, 0x34, 0x2D, 0x73, 0x6A,
0x2E, 0x62, 0x61, 0x65, 0x63, 0x6B, 0xA7, 0x5C
};
private static readonly byte[] AppSalt =
{
0x58, 0x43, 0x4D, 0x44, 0x53, 0x61, 0x6C, 0x74, // "XCMDSalt"
0x9E, 0x27, 0xC1, 0x4A, 0xB3, 0x06, 0x7F, 0xD8 // random
};
private static byte[]? _appKey;
/// <summary>앱 공용 AES-256 키를 PBKDF2로 파생합니다.</summary>
private static byte[] GetAppKey()
{
if (_appKey != null) return _appKey;
using var pbkdf2 = new Rfc2898DeriveBytes(AppSeed, AppSalt, 100_000, HashAlgorithmName.SHA256);
_appKey = pbkdf2.GetBytes(32); // 256-bit
return _appKey;
}
/// <summary>
/// 평문 문자열을 앱 공용 키로 AES-256-CBC 암호화하여 Base64 반환.
/// 관리자 PC에서 암호화한 값이 모든 사용자 PC에서 동일하게 복호화됩니다.
/// </summary>
public static string PortableEncrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText)) return "";
var key = GetAppKey();
using var aes = Aes.Create();
aes.Key = key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateIV();
using var enc = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipher = enc.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// [IV(16)] [ciphertext]
var result = new byte[16 + cipher.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, 16);
Buffer.BlockCopy(cipher, 0, result, 16, cipher.Length);
return Convert.ToBase64String(result);
}
/// <summary>
/// 앱 공용 키로 AES-256-CBC 복호화. PortableEncrypt의 역.
/// </summary>
public static string PortableDecrypt(string base64)
{
if (string.IsNullOrEmpty(base64)) return "";
try
{
var raw = Convert.FromBase64String(base64);
if (raw.Length < 17) return ""; // IV(16) + 최소 1블록
var key = GetAppKey();
var iv = new byte[16];
Buffer.BlockCopy(raw, 0, iv, 0, 16);
var cipher = new byte[raw.Length - 16];
Buffer.BlockCopy(raw, 16, cipher, 0, cipher.Length);
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var dec = aes.CreateDecryptor();
var plain = dec.TransformFinalBlock(cipher, 0, cipher.Length);
return Encoding.UTF8.GetString(plain);
}
catch { return ""; }
}
// ═══════════════════════════════════════════════════════════════════════
// 1-B. 암호화 모드 분기 — encryptionEnabled에 따라 암호화 또는 패스스루
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// encryptionEnabled=true이면 PortableEncrypt, false이면 평문 그대로 반환.
/// 운영 배포 시 true로 전환하면 자동으로 암호화가 적용됩니다.
/// </summary>
public static string EncryptIfEnabled(string plainText, bool encryptionEnabled)
=> encryptionEnabled ? PortableEncrypt(plainText) : plainText;
/// <summary>
/// encryptionEnabled=true이면 PortableDecrypt, false이면 평문 그대로 반환.
/// 하위 호환: 암호화된 값이 들어와도 복호화 시도 후 실패하면 평문으로 간주.
/// </summary>
public static string DecryptIfEnabled(string value, bool encryptionEnabled)
{
if (string.IsNullOrEmpty(value)) return "";
if (!encryptionEnabled) return value;
// 암호화 모드: 복호화 시도, 실패하면 평문으로 간주 (마이그레이션 호환)
var result = PortableDecrypt(value);
return string.IsNullOrEmpty(result) ? value : result;
}
// ═══════════════════════════════════════════════════════════════════════
// 2. PC별 개인 키 — 대화 내역 파일 암호화 (Local, PC 종속)
// ═══════════════════════════════════════════════════════════════════════
private static byte[] GetOrCreateMasterKey()
{
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot");
var path = Path.Combine(dir, ".master_key");
if (File.Exists(path))
{
var dpapi = File.ReadAllBytes(path);
return ProtectedData.Unprotect(dpapi, null, DataProtectionScope.CurrentUser);
}
// 최초 생성: 256-bit 랜덤 키 → DPAPI 보호 후 저장
var key = RandomNumberGenerator.GetBytes(32);
Directory.CreateDirectory(dir);
File.WriteAllBytes(path, ProtectedData.Protect(key, null, DataProtectionScope.CurrentUser));
return key;
}
/// <summary>바이트 배열을 PC별 마스터 키로 AES-256-GCM 암호화</summary>
public static byte[] EncryptBytes(byte[] plainBytes)
{
var key = GetOrCreateMasterKey();
var nonce = RandomNumberGenerator.GetBytes(12); // 96-bit nonce
var tag = new byte[16]; // 128-bit tag
var cipher = new byte[plainBytes.Length];
using var aes = new AesGcm(key, 16);
aes.Encrypt(nonce, plainBytes, cipher, tag);
// [nonce(12)] [tag(16)] [ciphertext]
var result = new byte[12 + 16 + cipher.Length];
Buffer.BlockCopy(nonce, 0, result, 0, 12);
Buffer.BlockCopy(tag, 0, result, 12, 16);
Buffer.BlockCopy(cipher, 0, result, 28, cipher.Length);
return result;
}
/// <summary>PC별 마스터 키로 AES-256-GCM 복호화</summary>
public static byte[] DecryptBytes(byte[] encrypted)
{
if (encrypted.Length < 28) throw new CryptographicException("Invalid encrypted data");
var key = GetOrCreateMasterKey();
var nonce = new byte[12];
var tag = new byte[16];
var cipher = new byte[encrypted.Length - 28];
Buffer.BlockCopy(encrypted, 0, nonce, 0, 12);
Buffer.BlockCopy(encrypted, 12, tag, 0, 16);
Buffer.BlockCopy(encrypted, 28, cipher, 0, cipher.Length);
var plain = new byte[cipher.Length];
using var aes = new AesGcm(key, 16);
aes.Decrypt(nonce, cipher, tag, plain);
return plain;
}
/// <summary>문자열을 PC별 키로 AES-256-GCM 암호화하여 파일에 저장</summary>
public static void EncryptToFile(string filePath, string content)
{
var plain = Encoding.UTF8.GetBytes(content);
var enc = EncryptBytes(plain);
File.WriteAllBytes(filePath, enc);
}
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화</summary>
public static string DecryptFromFile(string filePath)
{
if (!File.Exists(filePath)) return "";
var enc = File.ReadAllBytes(filePath);
var plain = DecryptBytes(enc);
return Encoding.UTF8.GetString(plain);
}
}

View File

@@ -0,0 +1,99 @@
namespace AxCopilot.Services;
/// <summary>
/// 라인 기반 텍스트 diff 서비스. 두 텍스트의 변경 사항을 비교합니다.
/// </summary>
public static class DiffService
{
public enum DiffType { Equal, Added, Removed }
public record DiffLine(DiffType Type, int? OldLineNo, int? NewLineNo, string Content);
/// <summary>두 텍스트를 라인 단위로 비교하여 diff 결과를 반환합니다.</summary>
public static List<DiffLine> ComputeDiff(string oldText, string newText)
{
var oldLines = (oldText ?? "").Split('\n');
var newLines = (newText ?? "").Split('\n');
var result = new List<DiffLine>();
// LCS (Longest Common Subsequence) 기반 간단 diff
var lcs = ComputeLcs(oldLines, newLines);
int oi = 0, ni = 0, li = 0;
while (oi < oldLines.Length || ni < newLines.Length)
{
if (li < lcs.Count && oi < oldLines.Length && ni < newLines.Length &&
oldLines[oi].TrimEnd('\r') == lcs[li] && newLines[ni].TrimEnd('\r') == lcs[li])
{
result.Add(new DiffLine(DiffType.Equal, oi + 1, ni + 1, oldLines[oi].TrimEnd('\r')));
oi++; ni++; li++;
}
else if (li < lcs.Count && oi < oldLines.Length &&
oldLines[oi].TrimEnd('\r') != lcs[li])
{
result.Add(new DiffLine(DiffType.Removed, oi + 1, null, oldLines[oi].TrimEnd('\r')));
oi++;
}
else if (ni < newLines.Length &&
(li >= lcs.Count || newLines[ni].TrimEnd('\r') != lcs[li]))
{
result.Add(new DiffLine(DiffType.Added, null, ni + 1, newLines[ni].TrimEnd('\r')));
ni++;
}
else
{
// 나머지 처리
if (oi < oldLines.Length)
{
result.Add(new DiffLine(DiffType.Removed, oi + 1, null, oldLines[oi].TrimEnd('\r')));
oi++;
}
if (ni < newLines.Length)
{
result.Add(new DiffLine(DiffType.Added, null, ni + 1, newLines[ni].TrimEnd('\r')));
ni++;
}
}
}
return result;
}
private static List<string> ComputeLcs(string[] a, string[] b)
{
int m = a.Length, n = b.Length;
var dp = new int[m + 1, n + 1];
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
dp[i, j] = a[i - 1].TrimEnd('\r') == b[j - 1].TrimEnd('\r')
? dp[i - 1, j - 1] + 1
: Math.Max(dp[i - 1, j], dp[i, j - 1]);
// 역추적
var lcs = new List<string>();
int x = m, y = n;
while (x > 0 && y > 0)
{
if (a[x - 1].TrimEnd('\r') == b[y - 1].TrimEnd('\r'))
{
lcs.Add(a[x - 1].TrimEnd('\r'));
x--; y--;
}
else if (dp[x - 1, y] > dp[x, y - 1])
x--;
else
y--;
}
lcs.Reverse();
return lcs;
}
/// <summary>diff 결과를 통계 요약 문자열로 반환합니다.</summary>
public static string GetSummary(List<DiffLine> diff)
{
int added = diff.Count(d => d.Type == DiffType.Added);
int removed = diff.Count(d => d.Type == DiffType.Removed);
return $"+{added} -{removed} 라인 변경";
}
}

View File

@@ -0,0 +1,127 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 드래프트 큐 항목의 실행 전이와 재시도 정책을 담당합니다.
/// ChatWindow가 직접 상태 전이를 조합하지 않도록 별도 서비스로 분리합니다.
/// </summary>
public sealed class DraftQueueProcessorService
{
public bool CanStartNext(ChatSessionStateService? session, string tab)
=> session?.GetNextQueuedDraft(tab) != null;
public DraftQueueItem? TryStartNext(ChatSessionStateService? session, string tab, ChatStorageService? storage = null, string? preferredDraftId = null, TaskRunService? taskRuns = null)
{
if (session == null)
return null;
DraftQueueItem? next = null;
if (!string.IsNullOrWhiteSpace(preferredDraftId))
{
next = session.GetDraftQueueItems(tab)
.FirstOrDefault(x => string.Equals(x.Id, preferredDraftId, StringComparison.OrdinalIgnoreCase));
if (next != null &&
!string.Equals(next.State, "queued", StringComparison.OrdinalIgnoreCase))
{
session.ResetDraftToQueued(tab, next.Id, storage);
next = session.GetDraftQueueItems(tab)
.FirstOrDefault(x => string.Equals(x.Id, preferredDraftId, StringComparison.OrdinalIgnoreCase));
}
}
next ??= session.GetNextQueuedDraft(tab);
if (next == null)
return null;
if (!session.MarkDraftRunning(tab, next.Id, storage))
return null;
taskRuns?.StartQueueRun(tab, next.Id, next.Text);
return session.GetDraftQueueItems(tab)
.FirstOrDefault(x => string.Equals(x.Id, next.Id, StringComparison.OrdinalIgnoreCase))
?? next;
}
public bool Complete(ChatSessionStateService? session, string tab, string draftId, ChatStorageService? storage = null, TaskRunService? taskRuns = null)
{
var completed = session?.MarkDraftCompleted(tab, draftId, storage) ?? false;
if (completed)
taskRuns?.CompleteQueueRun(tab, draftId, "대기열 작업 완료", "completed");
return completed;
}
public bool HandleFailure(ChatSessionStateService? session, string tab, string draftId, string? error, bool cancelled = false, int maxAutoRetries = 3, ChatStorageService? storage = null, TaskRunService? taskRuns = null)
{
if (session == null)
return false;
if (cancelled)
{
var reset = session.ResetDraftToQueued(tab, draftId, storage);
if (reset)
taskRuns?.CompleteQueueRun(tab, draftId, string.IsNullOrWhiteSpace(error) ? "대기열 작업 중단" : error, "cancelled");
return reset;
}
var handled = session.ScheduleDraftRetry(tab, draftId, error, maxAutoRetries, storage);
if (handled)
{
var item = session.GetDraftQueueItems(tab)
.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
var blocked = item?.NextRetryAt.HasValue == true;
taskRuns?.CompleteQueueRun(
tab,
draftId,
string.IsNullOrWhiteSpace(error) ? (blocked ? "재시도 대기" : "대기열 작업 실패") : error,
blocked ? "blocked" : "failed");
}
return handled;
}
public int PromoteReadyBlockedItems(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
{
if (session == null)
return 0;
var promoted = 0;
foreach (var item in session.GetDraftQueueItems(tab)
.Where(x => string.Equals(x.State, "queued", StringComparison.OrdinalIgnoreCase)
&& x.NextRetryAt.HasValue
&& x.NextRetryAt.Value <= DateTime.Now)
.ToList())
{
if (session.ResetDraftToQueued(tab, item.Id, storage))
promoted++;
}
return promoted;
}
public int ClearCompleted(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
=> ClearByState(session, tab, "completed", storage);
public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
=> ClearByState(session, tab, "failed", storage);
private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage)
{
if (session == null)
return 0;
var removed = 0;
foreach (var item in session.GetDraftQueueItems(tab)
.Where(x => string.Equals(x.State, state, StringComparison.OrdinalIgnoreCase))
.ToList())
{
if (session.RemoveDraft(tab, item.Id, storage))
removed++;
}
return removed;
}
}

View File

@@ -0,0 +1,157 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 드래프트 큐 항목의 정렬과 상태 전이를 담당한다.
/// 이후 command queue 실행기와 직접 연결될 수 있도록 queue policy를 분리한다.
/// </summary>
public sealed class DraftQueueService
{
public sealed class DraftQueueSummary
{
public int TotalCount { get; init; }
public int QueuedCount { get; init; }
public int RunningCount { get; init; }
public int BlockedCount { get; init; }
public int FailedCount { get; init; }
public int CompletedCount { get; init; }
public DraftQueueItem? NextItem { get; init; }
public DateTime? NextReadyAt { get; init; }
}
public DraftQueueItem CreateItem(string text, string priority = "next")
{
return new DraftQueueItem
{
Text = text.Trim(),
Priority = NormalizePriority(priority),
State = "queued",
CreatedAt = DateTime.Now,
};
}
public DraftQueueItem? GetNextQueuedItem(IEnumerable<DraftQueueItem>? items, DateTime? now = null)
{
var at = now ?? DateTime.Now;
return items?
.Where(x => CanRunNow(x, at))
.OrderBy(GetPriorityRank)
.ThenBy(x => x.CreatedAt)
.FirstOrDefault();
}
public DraftQueueSummary GetSummary(IEnumerable<DraftQueueItem>? items, DateTime? now = null)
{
var at = now ?? DateTime.Now;
var snapshot = items?.ToList() ?? [];
var nextReadyAt = snapshot
.Where(IsBlocked)
.Select(x => x.NextRetryAt)
.Where(x => x.HasValue)
.OrderBy(x => x)
.FirstOrDefault();
return new DraftQueueSummary
{
TotalCount = snapshot.Count,
QueuedCount = snapshot.Count(x => CanRunNow(x, at)),
RunningCount = snapshot.Count(x => string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase)),
BlockedCount = snapshot.Count(IsBlocked),
FailedCount = snapshot.Count(x => string.Equals(x.State, "failed", StringComparison.OrdinalIgnoreCase)),
CompletedCount = snapshot.Count(x => string.Equals(x.State, "completed", StringComparison.OrdinalIgnoreCase)),
NextItem = GetNextQueuedItem(snapshot, at),
NextReadyAt = nextReadyAt,
};
}
public bool MarkRunning(DraftQueueItem? item)
{
if (item == null)
return false;
item.State = "running";
item.LastError = null;
item.NextRetryAt = null;
item.AttemptCount++;
return true;
}
public bool MarkCompleted(DraftQueueItem? item)
{
if (item == null)
return false;
item.State = "completed";
item.LastError = null;
item.NextRetryAt = null;
return true;
}
public bool MarkFailed(DraftQueueItem? item, string? error)
{
if (item == null)
return false;
item.State = "failed";
item.LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim();
item.NextRetryAt = null;
return true;
}
public bool ScheduleRetry(DraftQueueItem? item, string? error, TimeSpan? delay = null)
{
if (item == null)
return false;
item.State = "queued";
item.LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim();
item.NextRetryAt = DateTime.Now.Add(delay ?? GetRetryDelay(item));
return true;
}
public bool ResetToQueued(DraftQueueItem? item)
{
if (item == null)
return false;
item.State = "queued";
item.LastError = null;
item.NextRetryAt = null;
return true;
}
public TimeSpan GetRetryDelay(DraftQueueItem item)
{
var seconds = Math.Min(300, 15 * Math.Max(1, (int)Math.Pow(2, Math.Max(0, item.AttemptCount - 1))));
return TimeSpan.FromSeconds(seconds);
}
public bool CanRunNow(DraftQueueItem item, DateTime? now = null)
{
var at = now ?? DateTime.Now;
return IsQueued(item) && (!item.NextRetryAt.HasValue || item.NextRetryAt.Value <= at);
}
private static bool IsQueued(DraftQueueItem item)
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase);
private static bool IsBlocked(DraftQueueItem item)
=> IsQueued(item) && item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now;
private static string NormalizePriority(string? priority)
=> priority switch
{
"now" => "now",
"later" => "later",
_ => "next",
};
private static int GetPriorityRank(DraftQueueItem item)
=> NormalizePriority(item.Priority) switch
{
"now" => 0,
"next" => 1,
_ => 2,
};
}

View File

@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.IO;
using System.Net.Http;
using System.Windows.Media.Imaging;
namespace AxCopilot.Services;
/// <summary>
/// 외부 URL의 favicon을 다운로드하여 캐시합니다.
/// Google Favicon API (https://www.google.com/s2/favicons?domain=xxx&sz=32)를 사용합니다.
/// 캐시는 메모리(ConcurrentDictionary) + 디스크(%APPDATA%\AxCopilot\favicons\)에 저장됩니다.
/// </summary>
public static class FaviconService
{
private static readonly string CacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "favicons");
private static readonly ConcurrentDictionary<string, BitmapImage?> _memCache = new();
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
/// <summary>
/// 도메인의 favicon BitmapImage를 반환합니다.
/// 캐시에 있으면 즉시 반환, 없으면 null 반환 후 백그라운드에서 다운로드합니다.
/// 다운로드 완료 시 콜백을 호출합니다.
/// </summary>
public static BitmapImage? GetFavicon(string url, Action? onLoaded = null)
{
var domain = ExtractDomain(url);
if (string.IsNullOrEmpty(domain)) return null;
// 메모리 캐시 확인
if (_memCache.TryGetValue(domain, out var cached))
return cached;
// 디스크 캐시 확인
var diskPath = Path.Combine(CacheDir, $"{domain}.png");
if (File.Exists(diskPath))
{
try
{
var bmp = LoadFromDisk(diskPath);
_memCache[domain] = bmp;
return bmp;
}
catch { }
}
// 백그라운드에서 다운로드
_memCache[domain] = null; // 중복 요청 방지
_ = DownloadAsync(domain, diskPath, onLoaded);
return null;
}
private static async Task DownloadAsync(string domain, string diskPath, Action? onLoaded)
{
try
{
var faviconUrl = $"https://www.google.com/s2/favicons?domain={Uri.EscapeDataString(domain)}&sz=32";
var bytes = await _http.GetByteArrayAsync(faviconUrl).ConfigureAwait(false);
if (bytes.Length < 100) return; // 너무 작으면 유효한 이미지 아님
// 디스크 저장
Directory.CreateDirectory(CacheDir);
await File.WriteAllBytesAsync(diskPath, bytes).ConfigureAwait(false);
// 메모리 캐시 갱신
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
try
{
var bmp = LoadFromDisk(diskPath);
_memCache[domain] = bmp;
onLoaded?.Invoke();
}
catch { }
});
}
catch (Exception ex)
{
LogService.Warn($"Favicon 다운로드 실패: {domain} — {ex.Message}");
}
}
private static BitmapImage LoadFromDisk(string path)
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 32;
bmp.EndInit();
bmp.Freeze();
return bmp;
}
private static string? ExtractDomain(string url)
{
try
{
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
url = "https://" + url;
return new Uri(url).Host.ToLowerInvariant();
}
catch { return null; }
}
}

View File

@@ -0,0 +1,108 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Threading;
namespace AxCopilot.Services;
/// <summary>
/// Windows 열기/저장 대화상자(#32770)를 감지하여 이벤트를 발생시킵니다.
/// SetWinEventHook으로 HWND 생성/소멸을 모니터링합니다.
/// </summary>
public class FileDialogWatcher : IDisposable
{
// ─── P/Invoke ────────────────────────────────────────────────────────────
private delegate void WinEventDelegate(IntPtr hWinEventHook, uint eventType,
IntPtr hwnd, int idObject, int idChild, uint idEventThread, uint dwmsEventTime);
[DllImport("user32.dll")]
private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax,
IntPtr hmodWinEventProc, WinEventDelegate lpfnWinEventProc,
uint idProcess, uint idThread, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hWnd);
private const uint EVENT_OBJECT_SHOW = 0x8002;
private const uint EVENT_OBJECT_CREATE = 0x8000;
private const uint WINEVENT_OUTOFCONTEXT = 0x0000;
private const uint WINEVENT_SKIPOWNPROCESS = 0x0002;
// ─── 상태 ────────────────────────────────────────────────────────────────
private IntPtr _hook;
private WinEventDelegate? _delegate; // prevent GC
private bool _disposed;
private readonly Dispatcher _dispatcher;
/// <summary>열기/저장 대화상자가 감지되면 발생합니다. IntPtr = 대화상자 HWND.</summary>
public event EventHandler<IntPtr>? FileDialogOpened;
public FileDialogWatcher()
{
_dispatcher = Dispatcher.CurrentDispatcher;
}
public void Start()
{
if (_hook != IntPtr.Zero) return;
_delegate = OnWinEvent;
_hook = SetWinEventHook(
EVENT_OBJECT_SHOW, EVENT_OBJECT_SHOW,
IntPtr.Zero, _delegate,
0, 0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
}
public void Stop()
{
if (_hook != IntPtr.Zero)
{
UnhookWinEvent(_hook);
_hook = IntPtr.Zero;
}
}
private void OnWinEvent(IntPtr hWinEventHook, uint eventType,
IntPtr hwnd, int idObject, int idChild, uint idEventThread, uint dwmsEventTime)
{
if (hwnd == IntPtr.Zero || idObject != 0) return;
if (!IsWindowVisible(hwnd)) return;
// 클래스명 #32770 = 공통 대화상자
var sb = new StringBuilder(256);
GetClassName(hwnd, sb, 256);
var className = sb.ToString();
if (className != "#32770") return;
// 창 제목으로 열기/저장 대화상자인지 확인
var titleSb = new StringBuilder(256);
GetWindowText(hwnd, titleSb, 256);
var title = titleSb.ToString();
if (title.Contains("열기") || title.Contains("Open") ||
title.Contains("저장") || title.Contains("Save") ||
title.Contains("다른 이름") || title.Contains("Browse") ||
title.Contains("폴더") || title.Contains("Folder"))
{
_dispatcher.BeginInvoke(() => FileDialogOpened?.Invoke(this, hwnd));
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
}

View File

@@ -0,0 +1,87 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace AxCopilot.Services;
/// <summary>
/// 가이드 HTML 파일 암호화/복호화 유틸리티.
/// 고정 AES-256-CBC 키를 사용하여 모든 PC에서 동일하게 작동합니다.
/// </summary>
public static class GuideEncryptor
{
// 고정 키 (32바이트 = AES-256) — 앱 내장 가이드 보호용
private static readonly byte[] Key =
{
0x41, 0x58, 0x43, 0x6F, 0x70, 0x69, 0x6C, 0x6F,
0x74, 0x47, 0x75, 0x69, 0x64, 0x65, 0x4B, 0x65,
0x79, 0x32, 0x30, 0x32, 0x36, 0x53, 0x65, 0x63,
0x75, 0x72, 0x65, 0x46, 0x69, 0x78, 0x65, 0x64
};
// 고정 IV (16바이트) — 동일 출력 보장
private static readonly byte[] Iv =
{
0x47, 0x75, 0x69, 0x64, 0x65, 0x49, 0x56, 0x46,
0x69, 0x78, 0x65, 0x64, 0x32, 0x30, 0x32, 0x36
};
/// <summary>평문 HTML 파일을 암호화하여 .enc 파일로 저장합니다.</summary>
public static void EncryptFile(string inputHtmlPath, string outputEncPath)
{
var plaintext = File.ReadAllBytes(inputHtmlPath);
using var aes = Aes.Create();
aes.Key = Key;
aes.IV = Iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var encryptor = aes.CreateEncryptor();
var encrypted = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);
File.WriteAllBytes(outputEncPath, encrypted);
}
/// <summary>암호화된 .enc 파일을 복호화하여 HTML 문자열로 반환합니다.</summary>
public static string DecryptToString(string encFilePath)
{
var encrypted = File.ReadAllBytes(encFilePath);
using var aes = Aes.Create();
aes.Key = Key;
aes.IV = Iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var decrypted = decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);
return Encoding.UTF8.GetString(decrypted);
}
/// <summary>암호화된 바이트 배열을 복호화하여 HTML 문자열로 반환합니다.</summary>
public static string DecryptToString(byte[] encrypted)
{
using var aes = Aes.Create();
aes.Key = Key;
aes.IV = Iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var decrypted = decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);
return Encoding.UTF8.GetString(decrypted);
}
/// <summary>Assets 폴더의 가이드 파일을 암호화합니다. 빌드 전 수동 실행용.</summary>
public static void EncryptGuides(string assetsFolder)
{
var userGuide = Path.Combine(assetsFolder, "AX Copilot 사용가이드.htm");
var devGuide = Path.Combine(assetsFolder, "AX Copilot 개발자가이드.htm");
if (File.Exists(userGuide))
EncryptFile(userGuide, Path.Combine(assetsFolder, "guide_user.enc"));
if (File.Exists(devGuide))
EncryptFile(devGuide, Path.Combine(assetsFolder, "guide_dev.enc"));
}
}

View File

@@ -0,0 +1,568 @@
using System.Diagnostics;
using System.IO;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다.
/// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다.
/// </summary>
public class IndexService : IDisposable
{
private readonly SettingsService _settings;
private List<IndexEntry> _index = new();
private readonly List<FileSystemWatcher> _watchers = new();
private System.Threading.Timer? _debounceTimer;
private readonly object _timerLock = new();
private const int DebounceMs = 3000;
private readonly SemaphoreSlim _rebuildLock = new(1, 1);
public IReadOnlyList<IndexEntry> Entries => _index;
public event EventHandler? IndexRebuilt;
public TimeSpan LastIndexDuration { get; private set; }
public int LastIndexCount { get; private set; }
public IndexService(SettingsService settings)
{
_settings = settings;
}
/// <summary>
/// 앱 시작 시 전체 인덱스 빌드 후 FileSystemWatcher 시작.
/// </summary>
public async Task BuildAsync(CancellationToken ct = default)
{
await _rebuildLock.WaitAsync(ct);
var sw = Stopwatch.StartNew();
try
{
var entries = new List<IndexEntry>();
var paths = _settings.Settings.IndexPaths
.Select(p => Environment.ExpandEnvironmentVariables(p));
var allowedExts = new HashSet<string>(
_settings.Settings.IndexExtensions
.Select(e => e.ToLowerInvariant().StartsWith(".") ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()),
StringComparer.OrdinalIgnoreCase);
var indexSpeed = _settings.Settings.IndexSpeed ?? "normal";
foreach (var dir in paths)
{
if (!Directory.Exists(dir)) continue;
await ScanDirectoryAsync(dir, entries, allowedExts, indexSpeed, ct);
}
// Alias도 인덱스에 포함 (type에 따라 IndexEntryType 구분)
foreach (var alias in _settings.Settings.Aliases)
{
entries.Add(new IndexEntry
{
Name = alias.Key,
DisplayName = alias.Description ?? alias.Key,
Path = alias.Target,
AliasType = alias.Type,
Type = alias.Type switch
{
"app" => IndexEntryType.App,
"folder" => IndexEntryType.Folder,
_ => IndexEntryType.Alias // url, batch, api, clipboard
},
Score = 100 // Alias는 최우선
});
}
// Built-in 앱 별칭 (한글+영문 이름으로 즉시 실행)
RegisterBuiltInApps(entries);
// 검색 가속 캐시 일괄 계산 (ToLower·자모·초성을 빌드 시 1회만 수행)
ComputeAllSearchCaches(entries);
_index = entries;
sw.Stop();
LastIndexDuration = sw.Elapsed;
LastIndexCount = entries.Count;
LogService.Info($"인덱싱 완료: {entries.Count}개 항목 ({sw.Elapsed.TotalSeconds:F1}초)");
IndexRebuilt?.Invoke(this, EventArgs.Empty);
}
finally
{
_rebuildLock.Release();
}
}
/// <summary>
/// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다.
/// BuildAsync() 완료 후 호출하세요.
/// </summary>
public void StartWatchers()
{
StopWatchers();
foreach (var rawPath in _settings.Settings.IndexPaths)
{
var dir = Environment.ExpandEnvironmentVariables(rawPath);
if (!Directory.Exists(dir)) continue;
try
{
var w = new FileSystemWatcher(dir)
{
Filter = "*.*",
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName,
EnableRaisingEvents = true
};
w.Created += OnWatcherEvent;
w.Deleted += OnWatcherEvent;
w.Renamed += OnWatcherRenamed;
_watchers.Add(w);
}
catch (Exception ex)
{
LogService.Warn($"FileSystemWatcher 생성 실패: {dir} - {ex.Message}");
}
}
LogService.Info($"파일 감시 시작: {_watchers.Count}개 경로");
}
private void StopWatchers()
{
foreach (var w in _watchers)
{
w.EnableRaisingEvents = false;
w.Dispose();
}
_watchers.Clear();
}
private void OnWatcherEvent(object sender, FileSystemEventArgs e)
{
ScheduleRebuild(e.FullPath);
}
private void OnWatcherRenamed(object sender, RenamedEventArgs e)
{
ScheduleRebuild(e.FullPath);
}
private void ScheduleRebuild(string triggerPath)
{
LogService.Info($"파일 변경 감지: {triggerPath} — {DebounceMs}ms 후 재빌드 예약");
lock (_timerLock)
{
_debounceTimer?.Dispose();
_debounceTimer = new System.Threading.Timer(__ =>
{
_ = BuildAsync();
}, null, DebounceMs, System.Threading.Timeout.Infinite);
}
}
public void Dispose()
{
_debounceTimer?.Dispose();
StopWatchers();
}
/// <summary>
/// 자주 사용하는 Windows 기본 앱을 한글+영문 이름으로 등록합니다.
/// 실제로 존재하는 경우에만 인덱스에 추가됩니다.
/// </summary>
private static void RegisterBuiltInApps(List<IndexEntry> entries)
{
// (displayName, keywords[], exePath)
var builtIns = new (string Display, string[] Names, string? Exe)[]
{
// 메모장
("메모장 (Notepad)", new[] { "메모장", "notepad", "note", "txt" },
@"C:\Windows\notepad.exe"),
// 계산기
("계산기 (Calculator)", new[] { "계산기", "calc", "calculator" },
"calculator:"), // UWP protocol
// 캡처 도구 (Snipping Tool)
("캡처 도구 (Snipping Tool)", new[] { "캡처", "캡처도구", "snippingtool", "snip", "스크린샷" },
@"C:\Windows\System32\SnippingTool.exe"),
// 그림판
("그림판 (Paint)", new[] { "그림판", "mspaint", "paint" },
@"C:\Windows\System32\mspaint.exe"),
// 탐색기
("파일 탐색기 (Explorer)", new[] { "탐색기", "explorer", "파일탐색기" },
@"C:\Windows\explorer.exe"),
// 명령 프롬프트
("명령 프롬프트 (CMD)", new[] { "cmd", "명령프롬프트", "커맨드", "터미널" },
@"C:\Windows\System32\cmd.exe"),
// PowerShell
("PowerShell", new[] { "powershell", "파워쉘" },
@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
// 제어판
("제어판 (Control Panel)", new[] { "제어판", "control", "controlpanel" },
@"C:\Windows\System32\control.exe"),
// 작업 관리자
("작업 관리자 (Task Manager)", new[] { "작업관리자", "taskmgr", "taskmanager" },
@"C:\Windows\System32\Taskmgr.exe"),
// 원격 데스크톱
("원격 데스크톱 (Remote Desktop)", new[] { "원격", "mstsc", "rdp", "원격데스크톱" },
@"C:\Windows\System32\mstsc.exe"),
// 레지스트리 편집기
("레지스트리 편집기", new[] { "regedit", "레지스트리" },
@"C:\Windows\regedit.exe"),
// 장치 관리자
("장치 관리자", new[] { "장치관리자", "devmgmt", "devicemanager" },
@"C:\Windows\System32\devmgmt.msc"),
// Microsoft Edge
("Microsoft Edge", new[] { "edge", "엣지", "브라우저" },
@"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
// Excel — 확장자 + MS prefix + 한글
("Microsoft Excel", new[] { "엑셀", "excel", "msexcel", "xlsx", "xls", "csv" },
FindOfficeApp("EXCEL.EXE")),
// PowerPoint
("Microsoft PowerPoint", new[] { "파워포인트", "powerpoint", "mspowerpoint", "pptx", "ppt" },
FindOfficeApp("POWERPNT.EXE")),
// Word
("Microsoft Word", new[] { "워드", "word", "msword", "docx", "doc" },
FindOfficeApp("WINWORD.EXE")),
// Outlook
("Microsoft Outlook", new[] { "아웃룩", "outlook", "메일" },
FindOfficeApp("OUTLOOK.EXE")),
// Teams
("Microsoft Teams", new[] { "팀즈", "teams" },
FindTeams()),
// OneNote
("Microsoft OneNote", new[] { "원노트", "onenote" },
FindOfficeApp("ONENOTE.EXE")),
// Access
("Microsoft Access", new[] { "액세스", "access", "msaccess" },
FindOfficeApp("MSACCESS.EXE")),
// VS Code
("Visual Studio Code", new[] { "vscode", "비주얼스튜디오코드", "코드에디터", "code" },
FindInPath("code.cmd") ?? FindInLocalAppData(@"Programs\Microsoft VS Code\Code.exe")),
// Visual Studio
("Visual Studio", new[] { "비주얼스튜디오", "devenv", "vs2022", "vs2019", "visualstudio" },
FindInProgramFiles(@"Microsoft Visual Studio\2022\Community\Common7\IDE\devenv.exe")
?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Professional\Common7\IDE\devenv.exe")
?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Enterprise\Common7\IDE\devenv.exe")),
// Windows Terminal
("Windows Terminal", new[] { "윈도우터미널", "wt", "windowsterminal", "터미널" },
FindInLocalAppData(@"Microsoft\WindowsApps\wt.exe")),
// OneDrive
("OneDrive", new[] { "원드라이브", "onedrive" },
FindInLocalAppData(@"Microsoft\OneDrive\OneDrive.exe")),
// Google Chrome
("Google Chrome", new[] { "크롬", "구글크롬", "chrome", "google" },
FindInProgramFiles(@"Google\Chrome\Application\chrome.exe")
?? FindInLocalAppData(@"Google\Chrome\Application\chrome.exe")),
// Firefox
("Mozilla Firefox", new[] { "파이어폭스", "불여우", "firefox" },
FindInProgramFiles(@"Mozilla Firefox\firefox.exe")),
// Naver Whale
("Naver Whale", new[] { "웨일", "네이버웨일", "whale" },
FindInLocalAppData(@"Naver\Naver Whale\Application\whale.exe")),
// KakaoTalk
("KakaoTalk", new[] { "카카오톡", "카톡", "kakaotalk", "kakao" },
FindInLocalAppData(@"Kakao\KakaoTalk\KakaoTalk.exe")),
// KakaoWork
("KakaoWork", new[] { "카카오워크", "카워크", "kakaowork" },
FindInLocalAppData(@"Kakao\KakaoWork\KakaoWork.exe")),
// Zoom
("Zoom", new[] { "줌", "zoom" },
FindInRoaming(@"Zoom\bin\Zoom.exe")
?? FindInLocalAppData(@"Zoom\bin\Zoom.exe")),
// Slack
("Slack", new[] { "슬랙", "slack" },
FindInLocalAppData(@"slack\slack.exe")),
// Figma
("Figma", new[] { "피그마", "figma" },
FindInLocalAppData(@"Figma\Figma.exe")),
// Notepad++
("Notepad++", new[] { "노트패드++", "npp", "notepad++" },
FindInProgramFiles(@"Notepad++\notepad++.exe")),
// 7-Zip
("7-Zip", new[] { "7zip", "7집", "세븐집", "7z" },
FindInProgramFiles(@"7-Zip\7zFM.exe")),
// Bandizip
("Bandizip", new[] { "반디집", "bandizip" },
FindInProgramFiles(@"Bandizip\Bandizip.exe")
?? FindInLocalAppData(@"Bandizip\Bandizip.exe")),
// ALZip
("ALZip", new[] { "알집", "alzip", "이스트소프트" },
FindInProgramFiles(@"ESTsoft\ALZip\ALZip.exe")),
// PotPlayer
("PotPlayer", new[] { "팟플레이어", "팟플", "potplayer" },
FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini64.exe")
?? FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini.exe")),
// GOM Player
("GOM Player", new[] { "곰플레이어", "곰플", "gomplayer", "gom" },
FindInProgramFiles(@"GRETECH\GomPlayer\GOM.EXE")),
// Adobe Photoshop
("Adobe Photoshop", new[] { "포토샵", "포샵", "photoshop", "ps" },
FindInProgramFiles(@"Adobe\Adobe Photoshop 2025\Photoshop.exe")
?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2024\Photoshop.exe")
?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2023\Photoshop.exe")),
// Adobe Acrobat
("Adobe Acrobat", new[] { "아크로뱃", "acrobat", "아도비pdf" },
FindInProgramFiles(@"Adobe\Acrobat DC\Acrobat\Acrobat.exe")),
// 한컴 한글
("한컴 한글", new[] { "한글", "한컴한글", "hwp", "hangul", "아래아한글" },
FindInProgramFiles(@"HNC\Hwp\Hwp.exe")
?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hwp.exe")
?? FindInProgramFiles(@"Hnc\Hwp80\Hwp.exe")),
// 한컴 한셀
("한컴 한셀", new[] { "한셀", "hcell", "한컴스프레드" },
FindInProgramFiles(@"HNC\Hwp\Hcell.exe")
?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hcell.exe")),
// 한컴 한쇼
("한컴 한쇼", new[] { "한쇼", "hshow", "한컴프레젠테이션" },
FindInProgramFiles(@"HNC\Hwp\Show.exe")
?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Show.exe")),
};
// 기존 항목의 이름 → 인덱스 매핑 + Path 기반 중복 방지
var nameToIdx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var pathToIdx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < entries.Count; i++)
{
nameToIdx.TryAdd(entries[i].Name, i);
pathToIdx.TryAdd(entries[i].Path, i);
}
foreach (var (display, names, exe) in builtIns)
{
if (string.IsNullOrEmpty(exe)) continue;
// UWP protocol은 파일 존재 체크 불필요
if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc")) continue;
// 같은 경로가 이미 인덱스에 있으면 DisplayName과 Score만 업데이트
if (pathToIdx.TryGetValue(exe, out var existingIdx))
{
entries[existingIdx].DisplayName = display;
if (entries[existingIdx].Score < 95) entries[existingIdx].Score = 95;
}
// ── 키워드별 IndexEntry 등록 ─────────────────────────────────────────
// - 한글 이름: 항상 IndexEntry로 추가 (앱이 이미 스캔됐어도)
// → "엑" 입력 시 "엑셀" IndexEntry의 StartsWith("엑")으로 매칭
// - 영문/약어: 앱이 미스캔 상태일 때 첫 키워드만 대표 항목으로 등록
// - 경로 중복은 CommandResolver 레이어에서 seenPaths로 최고점만 표시
bool mainAdded = pathToIdx.ContainsKey(exe);
foreach (var name in names)
{
if (nameToIdx.ContainsKey(name))
{
// 이미 같은 이름 존재 → Score만 부스트
if (nameToIdx.TryGetValue(name, out var idx) && entries[idx].Score < 95)
entries[idx].Score = 95;
continue;
}
// 한글 음절이 포함된 이름은 항상 IndexEntry로 추가
bool isKoreanName = name.Any(c => c >= '\uAC00' && c <= '\uD7A3');
if (!mainAdded || isKoreanName)
{
entries.Add(new IndexEntry
{
Name = name,
DisplayName = display,
Path = exe,
Type = IndexEntryType.App,
Score = 95
});
var newIdx = entries.Count - 1;
nameToIdx[name] = newIdx;
if (!mainAdded)
{
pathToIdx[exe] = newIdx;
mainAdded = true;
}
}
else
{
// 영문/약어는 pathToIdx 참조만 (FuzzyEngine은 영문 앱명 직접 매칭)
nameToIdx[name] = pathToIdx[exe];
}
}
}
}
private static string? FindOfficeApp(string exeName)
{
var roots = new[]
{
@"C:\Program Files\Microsoft Office\root\Office16",
@"C:\Program Files (x86)\Microsoft Office\root\Office16",
@"C:\Program Files\Microsoft Office\Office16",
@"C:\Program Files (x86)\Microsoft Office\Office16",
};
foreach (var root in roots)
{
var path = Path.Combine(root, exeName);
if (File.Exists(path)) return path;
}
return null;
}
private static string? FindTeams()
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe");
if (File.Exists(path)) return path;
// New Teams (MSIX)
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
return File.Exists(msTeams) ? msTeams : null;
}
private static string? FindInPath(string fileName)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathEnv.Split(';'))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
var full = Path.Combine(dir.Trim(), fileName);
if (File.Exists(full)) return full;
}
return null;
}
private static string? FindInLocalAppData(string relativePath)
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, relativePath);
return File.Exists(path) ? path : null;
}
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
private static string? FindInProgramFiles(string relativePath)
{
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var p1 = Path.Combine(pf, relativePath);
if (File.Exists(p1)) return p1;
var p2 = Path.Combine(pf86, relativePath);
return File.Exists(p2) ? p2 : null;
}
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
private static string? FindInRoaming(string relativePath)
{
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(roaming, relativePath);
return File.Exists(path) ? path : null;
}
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
{
foreach (var e in entries)
ComputeSearchCache(e);
}
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
private static void ComputeSearchCache(IndexEntry entry)
{
entry.NameLower = entry.Name.ToLowerInvariant();
// 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근)
entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower);
// 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ")
var sb = new System.Text.StringBuilder(entry.NameLower.Length);
foreach (var c in entry.NameLower)
{
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
if (cho != '\0') sb.Append(cho);
}
entry.NameChosung = sb.ToString();
}
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch
{
"fast" => (500, 0), // 최대 속도, CPU 양보 없음
"slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소
_ => (150, 5), // normal: 150개마다 5ms → 적정 균형
};
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
{
var (batchSize, delayMs) = GetThrottle(indexSpeed);
await Task.Run(async () =>
{
try
{
int count = 0;
// 파일 인덱싱 (확장자 필터 적용)
foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
var ext = Path.GetExtension(file).ToLowerInvariant();
if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue;
var name = Path.GetFileNameWithoutExtension(file);
if (string.IsNullOrEmpty(name)) continue;
var type = ext switch
{
".exe" => IndexEntryType.App,
".lnk" or ".url" => IndexEntryType.File,
_ => IndexEntryType.File
};
entries.Add(new IndexEntry
{
Name = name,
DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext,
Path = file,
Type = type
});
// 속도 조절: batchSize개마다 CPU 양보
if (delayMs > 0 && ++count % batchSize == 0)
await Task.Delay(delayMs, ct);
}
// 폴더 인덱싱 (1단계 하위 폴더)
foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(subDir);
if (name.StartsWith(".")) continue;
entries.Add(new IndexEntry
{
Name = name,
DisplayName = name,
Path = subDir,
Type = IndexEntryType.Folder
});
}
}
catch (UnauthorizedAccessException ex)
{
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
}
}, ct);
}
}
public class IndexEntry
{
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Path { get; set; } = "";
public IndexEntryType Type { get; set; }
public int Score { get; set; } = 0;
/// <summary>Alias 항목의 원본 type 문자열 (url | folder | app | batch | api | clipboard)</summary>
public string? AliasType { get; set; }
// ─ 검색 가속 캐시 (BuildAsync 시 1회 계산, 검색마다 재계산 방지) ──────
/// <summary>ToLowerInvariant() 결과 캐시</summary>
public string NameLower { get; set; } = "";
/// <summary>자모 분리 문자열 캐시 (DecomposeToJamo 결과)</summary>
public string NameJamo { get; set; } = "";
/// <summary>초성만 연결한 문자열 캐시 (예: "엑셀" → "ㅇㅅ")</summary>
public string NameChosung { get; set; } = "";
}
public enum IndexEntryType { App, File, Alias, Folder }

View File

@@ -0,0 +1,177 @@
namespace AxCopilot.Services;
/// <summary>
/// 사용자 메시지에서 질문 유형(인텐트)을 감지하는 로컬 키워드 기반 분류기.
/// 외부 API 없이 순수 키워드 매칭으로 동작합니다.
/// </summary>
public static class IntentDetector
{
/// <summary>인텐트 카테고리 상수.</summary>
public static class Categories
{
public const string Coding = "coding";
public const string Translation = "translation";
public const string Analysis = "analysis";
public const string Creative = "creative";
public const string Document = "document";
public const string Math = "math";
public const string General = "general";
public static readonly string[] All =
{
Coding, Translation, Analysis, Creative, Document, Math, General
};
}
/// <summary>카테고리별 키워드 사전. (키워드, 가중치) 쌍.</summary>
private static readonly Dictionary<string, (string Keyword, double Weight)[]> _keywords = new()
{
[Categories.Coding] = new (string, double)[]
{
// 한국어
("코드", 1.0), ("함수", 1.0), ("클래스", 1.0), ("버그", 1.2), ("디버그", 1.2),
("리팩토링", 1.5), ("컴파일", 1.2), ("에러", 0.8), ("구현", 0.9), ("개발", 0.7),
("테스트", 0.8), ("빌드", 1.0), ("배포", 0.8), ("커밋", 1.2), ("브랜치", 1.2),
("머지", 1.0), ("풀리퀘", 1.2), ("변수", 1.0), ("메서드", 1.2), ("인터<EC9DB8><ED84B0>이스", 1.0),
("타입", 0.6), ("파라미터", 1.0), ("반환", 0.8), ("예외", 1.0), ("스택", 0.9),
("알고리즘", 1.2), ("자료구조", 1.2), ("정렬", 0.8), ("재귀", 1.0), ("루프", 0.9),
("API", 1.0), ("SDK", 1.0), ("라이브러리", 0.8), ("패키지", 0.8), ("모듈", 0.8),
("깃", 1.0), ("레포", 1.0), ("소스", 0.7), ("프로그래밍", 1.0), ("코딩", 1.2),
// 영어
("code", 1.0), ("function", 1.0), ("class", 0.8), ("bug", 1.2), ("debug", 1.2),
("refactor", 1.5), ("compile", 1.2), ("error", 0.6), ("implement", 1.0), ("develop", 0.7),
("test", 0.7), ("build", 0.8), ("deploy", 0.8), ("commit", 1.2), ("branch", 1.2),
("merge", 1.0), ("variable", 1.0), ("method", 1.0), ("interface", 0.8),
("parameter", 1.0), ("return", 0.6), ("exception", 1.0), ("stack", 0.7),
("algorithm", 1.2), ("syntax", 1.2), ("runtime", 1.0), ("compile", 1.0),
("git", 1.2), ("npm", 1.0), ("pip", 1.0), ("nuget", 1.0), ("docker", 1.0),
},
[Categories.Translation] = new (string, double)[]
{
("번역", 2.0), ("영어로", 2.0), ("한국어로", 2.0), ("일본어로", 2.0), ("중국어로", 2.0),
("영문", 1.5), ("국문", 1.5), ("통역", 1.5), ("원문", 1.2), ("의역", 1.5), ("직역", 1.5),
("translate", 2.0), ("English", 1.0), ("Korean", 1.0), ("Japanese", 1.0), ("Chinese", 1.0),
("translation", 2.0), ("localize", 1.5), ("localization", 1.5),
},
[Categories.Analysis] = new (string, double)[]
{
("분석", 1.5), ("요약", 1.5), ("비교", 1.2), ("장<><EC9EA5>점", 1.5), ("평가", 1.2),
("검토", 1.0), ("리뷰", 0.8), ("통계", 1.2), ("데이터", 0.8), ("트렌드", 1.0),
("인사이트", 1.2), ("근거", 1.0), ("원인", 0.8), ("결론", 0.8), ("핵심", 0.7),
("analyze", 1.5), ("summarize", 1.5), ("compare", 1.2), ("evaluate", 1.2),
("review", 0.8), ("statistics", 1.2), ("data", 0.6), ("trend", 1.0),
("insight", 1.2), ("pros", 1.0), ("cons", 1.0), ("conclusion", 0.8),
},
[Categories.Creative] = new (string, double)[]
{
("작성", 0.8), ("글쓰기", 1.5), ("스토리", 1.5), ("시", 1.2), ("소설", 1.5),
("에<><EC9790>이", 1.5), ("블로그", 1.2), ("카피", 1.2), ("슬로건", 1.5), ("제목", 0.8),
("아이디어", 1.0), ("창작", 1.5), ("묘사", 1.2), ("대본", 1.5), ("가사", 1.5),
("story", 1.5), ("poem", 1.5), ("essay", 1.5), ("blog", 1.2), ("creative", 1.5),
("slogan", 1.5), ("copy", 0.8), ("fiction", 1.5), ("narrative", 1.2), ("lyrics", 1.5),
},
[Categories.Document] = new (string, double)[]
{
("보고서", 2.0), ("문서", 1.2), ("제안서", 2.0), ("기획서", 2.0), ("계획서", 1.8),
("발표자료", 2.0), ("프레젠테이션", 2.0), ("양식", 1.5), ("서식", 1.5), ("템플릿", 1.2),
("<22><>셀", 1.5), ("워드", 1.2), ("파워포인트", 1.5), ("PDF", 0.8), ("CSV", 1.0),
("회의록", 2.0), ("업무일지", 2.0), ("주간보고", 2.0), ("월간보고", 2.0),
("report", 1.8), ("document", 1.0), ("proposal", 2.0), ("presentation", 2.0),
("template", 1.2), ("spreadsheet", 1.5), ("excel", 1.5), ("memo", 1.2),
},
[Categories.Math] = new (string, double)[]
{
("수학", 1.5), ("계산", 1.2), ("방정식", 2.0), ("증명", 2.0), ("미적분", 2.0),
("통계", 1.0), ("확률", 1.5), ("행렬", 2.0), ("벡터", 1.5), ("미분", 2.0),
("적분", 2.0), ("함수", 0.5), ("그래프", 0.8), ("좌표", 1.5), ("기하", 1.5),
("삼각함수", 2.0), ("로그", 1.0), ("지수", 1.0), ("급수", 2.0), ("극한", 2.0),
("math", 1.5), ("calculate", 1.2), ("equation", 2.0), ("proof", 2.0), ("calculus", 2.0),
("probability", 1.5), ("matrix", 2.0), ("vector", 1.5), ("derivative", 2.0),
("integral", 2.0), ("theorem", 2.0), ("formula", 1.5), ("algebra", 1.5),
},
};
/// <summary>
/// 사용자 메시지에서 인텐트를 감지합니다.
/// </summary>
/// <returns>(카테고리명, 확신도 0.0~1.0). 매칭 없으면 ("general", 0.0).</returns>
public static (string Category, double Confidence) Detect(string message)
{
if (string.IsNullOrWhiteSpace(message))
return (Categories.General, 0.0);
var lowerMessage = message.ToLowerInvariant();
var words = lowerMessage.Split(new[] { ' ', '\t', '\n', '\r', ',', '.', '!', '?', ';', ':', '(', ')', '[', ']', '{', '}' },
StringSplitOptions.RemoveEmptyEntries);
var wordSet = new HashSet<string>(words);
var scores = new Dictionary<string, double>();
double maxScore = 0;
foreach (var (category, keywords) in _keywords)
{
double score = 0;
int hits = 0;
foreach (var (keyword, weight) in keywords)
{
var lowerKeyword = keyword.ToLowerInvariant();
// 한국어: substring 매칭 (조사 붙어도 감지)
// 영어: 단어 경계 매칭 (대소문자 무시)
bool matched = IsKorean(keyword)
? lowerMessage.Contains(lowerKeyword)
: wordSet.Contains(lowerKeyword);
if (matched)
{
score += weight;
hits++;
}
}
scores[category] = score;
if (score > maxScore) maxScore = score;
}
if (maxScore < 1.0)
return (Categories.General, 0.0);
// 최고 점수 카테고리 선택
var bestCategory = Categories.General;
double bestScore = 0;
foreach (var (cat, score) in scores)
{
if (score > bestScore)
{
bestScore = score;
bestCategory = cat;
}
}
// 확신도: 최고 점수를 정규화 (점수 범위를 0~1로 변환)
// 점수 3.0 이상이면 확신도 0.9+, 2.0이면 0.7~0.8 수준
double confidence = Math.Min(1.0, bestScore / 4.0 + 0.3);
// 2위와의 차이가 작으면 확신도 낮춤 (모호한 경우)
var sortedScores = scores.Values.OrderByDescending(s => s).ToArray();
if (sortedScores.Length >= 2 && sortedScores[1] > 0)
{
double ratio = sortedScores[1] / sortedScores[0];
if (ratio > 0.7) confidence *= 0.8; // 2위가 70% 이상이면 20% 감<><EAB090>
}
return (bestCategory, Math.Round(confidence, 2));
}
/// <summary>문자열에 한국어 문자가 포함되어 있는지 확인.</summary>
private static bool IsKorean(string text)
{
foreach (var ch in text)
{
if (ch >= 0xAC00 && ch <= 0xD7A3) return true; // 완성형 한글
if (ch >= 0x3131 && ch <= 0x318E) return true; // 자모
}
return false;
}
}

View File

@@ -0,0 +1,314 @@
namespace AxCopilot.Services;
/// <summary>
/// 간단한 다국어 문자열 서비스.
/// 설정의 Language 값(ko/en/ja/zh/vi)에 따라 UI 문자열을 반환합니다.
/// </summary>
public static class L10n
{
private static string _lang = "ko";
public static void SetLanguage(string lang) =>
_lang = lang?.ToLowerInvariant() ?? "ko";
public static string Get(string key) =>
(_lang switch
{
"en" => _en,
"ja" => _ja,
"zh" => _zh,
"vi" => _vi,
_ => _ko
}).TryGetValue(key, out var v) ? v : _ko.GetValueOrDefault(key, key);
/// <summary>런처 입력창에 표시할 랜덤 안내 문구를 반환합니다.</summary>
public static string GetRandomPlaceholder() =>
(_lang switch
{
"en" => _enPlaceholders,
"ja" => _jaPlaceholders,
"zh" => _zhPlaceholders,
"vi" => _viPlaceholders,
_ => _koPlaceholders
}) is { } list ? list[Random.Shared.Next(list.Length)] : Get("placeholder");
// ─── 한국어 랜덤 플레이스홀더 (100종) ───────────────────────────────────────
private static readonly string[] _koPlaceholders =
[
// ── 기본 인사 (15종) ──
"무엇을 도와드릴까요?",
"오늘도 좋은 하루 되세요. 무엇을 찾으시나요?",
"안녕하세요! 어떤 작업을 도와드릴까요?",
"필요하신 것이 있으시면 입력해 주세요.",
"편하게 검색해 보세요.",
"명령어를 입력하시면 바로 실행해 드리겠습니다.",
"무엇이든 빠르게 찾아드리겠습니다.",
"오늘도 효율적인 하루를 응원합니다.",
"언제든 도움이 필요하시면 말씀해 주세요.",
"어떤 파일이나 앱이든 바로 열어드릴 수 있습니다.",
"찾으시는 것을 입력해 보세요.",
"앱, 파일, 명령어 모두 여기서 시작됩니다.",
"빠른 실행을 도와드리겠습니다.",
"키보드 하나로 모든 업무를 시작해 보세요.",
"입력 한 번이면 원하는 앱이 바로 열립니다.",
// ── 계산기 (5종) ──
"= 1920*1080 으로 수식 계산을 해보세요.",
"= 100/3 처럼 입력하면 바로 계산됩니다.",
"= sqrt(144) 같은 함수도 지원합니다.",
"= 2^10 으로 거듭제곱도 계산할 수 있습니다.",
"= (100+200)*1.1 괄호·소수점 모두 지원됩니다.",
// ── 클립보드 (6종) ──
"# 을 입력하면 클립보드 히스토리를 열 수 있습니다.",
"Ctrl+H로 클립보드 히스토리를 바로 열어보세요.",
"Shift+↑↓로 여러 클립보드 항목을 한 번에 합칠 수 있습니다.",
"# 회의 로 클립보드에서 '회의' 관련 항목만 필터링됩니다.",
"클립보드 히스토리는 DPAPI로 안전하게 암호화됩니다.",
"$ upper 로 클립보드 텍스트를 대문자로 변환할 수 있습니다.",
// ── 웹 검색 (5종) ──
"? 키워드 로 웹 검색을 즉시 실행할 수 있습니다.",
"?n 키워드 로 네이버 검색도 가능합니다.",
"? WPF DataBinding 처럼 바로 검색할 수 있습니다.",
"검색 엔진은 설정에서 변경할 수 있습니다.",
"? 환율 달러 같은 검색도 한 번에 가능합니다.",
// ── 시스템 정보 (8종) ──
"info cpu 로 CPU 사용률을 확인해 보세요.",
"* ip 로 내 IP 주소를 빠르게 확인할 수 있습니다.",
"info disk 에서 드라이브를 Enter하면 탐색기가 열립니다.",
"info ram 을 Enter하면 실시간 리소스 모니터를 열 수 있습니다.",
"info battery 로 배터리 상태를 확인할 수 있습니다.",
"info volume 으로 현재 볼륨 레벨을 볼 수 있습니다.",
"info uptime 으로 PC 가동 시간을 확인할 수 있습니다.",
"info 를 입력하면 시스템 전체 상태를 한눈에 볼 수 있습니다.",
// ── 즐겨찾기 (5종) ──
"Ctrl+P로 자주 쓰는 파일을 즐겨찾기에 추가해 보세요.",
"fav 을 입력하면 즐겨찾기 목록을 볼 수 있습니다.",
"Ctrl+B를 누르면 즐겨찾기 목록을 바로 토글할 수 있습니다.",
"자주 여는 폴더를 즐겨찾기에 등록하면 한 번에 열립니다.",
"즐겨찾기에 등록된 항목은 Ctrl+P로 간편하게 해제됩니다.",
// ── 스니펫 (5종) ──
"; 키워드 로 미리 등록한 텍스트를 즉시 확장할 수 있습니다.",
"스니펫은 런처 없이 어디서든 자동 확장됩니다.",
"자주 쓰는 이메일 서명을 스니펫으로 등록해 보세요.",
"스니펫에 {{date}} 변수를 넣으면 오늘 날짜가 자동 삽입됩니다.",
"설정 스니펫에서 키워드·내용을 자유롭게 관리하세요.",
// ── 워크스페이스 (4종) ──
"~save 이름 으로 현재 창 배치를 저장해 보세요.",
"~이름 으로 저장된 창 배치를 한 번에 복원할 수 있습니다.",
"여러 모니터 창 배치를 워크스페이스로 관리해 보세요.",
"~list 로 저장된 워크스페이스 목록을 확인할 수 있습니다.",
// ── 색상 (4종) ──
"color #FF5733 으로 색상 코드를 변환할 수 있습니다.",
"pick 을 입력하면 화면에서 색상을 추출할 수 있습니다.",
"color rgb(75,94,252) 처럼 RGB 형식도 지원됩니다.",
"추출한 색상은 HEX·RGB·HSL로 자동 변환됩니다.",
// ── 날짜 계산 (4종) ──
"date +30 으로 30일 후 날짜를 계산할 수 있습니다.",
"date -7 로 일주일 전 날짜도 바로 확인됩니다.",
"date 로 오늘 날짜와 요일을 바로 확인할 수 있습니다.",
"date +365 로 1년 후 날짜도 계산할 수 있습니다.",
// ── 파일 액션 (6종) ──
"파일을 선택하고 → 키를 누르면 다양한 액션을 사용할 수 있습니다.",
"Ctrl+Shift+E로 선택한 파일을 탐색기에서 바로 열 수 있습니다.",
"Ctrl+T로 해당 경로에서 터미널을 열 수 있습니다.",
"Ctrl+C로 파일 이름만 복사, Ctrl+Shift+C로 전체 경로를 복사합니다.",
"Ctrl+Enter로 관리자 권한으로 실행할 수 있습니다.",
"Alt+Enter로 파일 속성 대화상자를 열 수 있습니다.",
// ── 단축키 (5종) ──
"F1 을 누르면 전체 도움말을 확인할 수 있습니다.",
"Ctrl+K로 단축키 참조 창을 열어보세요.",
"Ctrl+1~9로 원하는 번호의 항목을 즉시 실행할 수 있습니다.",
"Ctrl+L로 입력창을 즉시 비울 수 있습니다.",
"Ctrl+, 로 설정 창을 바로 열 수 있습니다.",
// ── 터미널 (4종) ──
"> ipconfig 로 터미널 명령을 바로 실행할 수 있습니다.",
"> ping google.com 같은 네트워크 진단도 가능합니다.",
"> dir 로 현재 디렉터리 목록을 확인할 수 있습니다.",
"^ notepad 로 메모장을 직접 실행할 수 있습니다.",
// ── 캡처 (4종) ──
"cap screen 으로 화면을 캡처할 수 있습니다.",
"cap region 으로 원하는 영역만 캡처할 수 있습니다.",
"cap window 로 현재 활성 창만 캡처할 수 있습니다.",
"설정에서 캡처 글로벌 단축키를 지정할 수 있습니다.",
// ── 시스템 명령 (5종) ──
"/ lock 으로 화면을 즉시 잠글 수 있습니다.",
"/ shutdown 으로 PC를 종료할 수 있습니다.",
"/ restart 로 PC를 재시작할 수 있습니다.",
"/ sleep 으로 절전 모드로 전환할 수 있습니다.",
"/ recycle 로 휴지통을 비울 수 있습니다.",
// ── 기타 유틸 (10종) ──
"encode base64 hello 로 인코딩/디코딩이 됩니다.",
"json 을 입력하면 클립보드 JSON을 정리해 줍니다.",
"emoji 웃음 으로 이모지를 검색할 수 있습니다.",
"kill 프로세스명 으로 프로세스를 종료할 수 있습니다.",
"recent 를 입력하면 최근 실행 목록을 확인할 수 있습니다.",
"note 내용 으로 빠른 메모를 저장할 수 있습니다.",
"journal 오늘 할 일 로 업무 일지를 기록할 수 있습니다.",
"pipe upper | trim 으로 텍스트를 파이프라인 처리할 수 있습니다.",
"diff 로 클립보드의 두 텍스트를 비교할 수 있습니다.",
"stats 로 클립보드 텍스트의 글자수·단어수를 확인합니다.",
// ── 폴더/URL 별칭 (4종) ──
"cd desktop 으로 바탕화면 폴더를 바로 열 수 있습니다.",
"@blog 으로 등록된 URL을 즉시 열 수 있습니다.",
"cd 경로 로 원하는 폴더를 바로 열 수 있습니다.",
"Ctrl+D로 다운로드 폴더를 바로 열 수 있습니다.",
// ── 창 관리 (4종) ──
"win chrome 으로 크롬 창을 바로 전환할 수 있습니다.",
"snap left 로 현재 창을 왼쪽에 정렬할 수 있습니다.",
"snap grid 로 여러 창을 격자 배치할 수 있습니다.",
"Tab 키로 선택한 항목의 제목을 자동완성할 수 있습니다.",
// ── 테마 (2종) ──
"설정에서 8종 테마(Dark·Light·OLED 등)를 선택할 수 있습니다.",
"커스텀 테마로 나만의 색상 조합을 만들어 보세요.",
// ── AX Agent (8종) ──
"! 질문 으로 AX Agent에게 바로 물어볼 수 있습니다.",
"! 오늘 회의 내용 정리해줘 처럼 AX Agent과 대화해 보세요.",
"AX Agent은 ! 를 입력하는 것만으로 시작됩니다.",
"! 를 입력하면 AX Agent이 업무를 도와드립니다.",
"AX Agent이 직관적으로 답변합니다. ! 로 바로 시작해 보세요.",
"! 이메일 초안 작성해줘 같은 요청도 가능합니다.",
"AX Agent 대화 내역은 암호화되어 안전하게 보관됩니다.",
"! 보고서 요약해줘 처럼 업무 보조에 활용해 보세요.",
// ── AX Agent 기능 안내 (8종) ──
"AX Agent에서 코드 구문 강조와 마크다운 렌더링을 지원합니다.",
"Ollama/vLLM/Gemini/Claude 4종 LLM을 지원합니다.",
"AX Agent 대화에서 프롬프트 카드로 AI 역할을 바로 지정할 수 있습니다.",
"AX Agent에서 대화를 분류하고 검색할 수 있습니다. ! 로 시작하세요!",
"AX Agent은 토큰 사용량과 응답 시간을 실시간으로 표시합니다.",
"AX Agent에서 타이핑 효과로 자연스러운 대화를 경험해 보세요.",
"대화 제목을 클릭하면 바로 이름을 변경할 수 있습니다.",
"프롬프트 템플릿으로 자주 쓰는 지시를 저장해 보세요.",
// ── 캡처 업데이트 (3종) ──
"cap 모드에서 Shift+Enter로 지연 캡처(3/5/10초)를 할 수 있습니다.",
"cap region 으로 원하는 영역만 정확하게 캡처할 수 있습니다.",
"지연 캡처로 메뉴가 열린 상태도 캡처할 수 있습니다.",
];
private static readonly string[] _enPlaceholders =
[
"What can I help you with?",
"Type anything to search or run commands.",
"Try = 1920*1080 for quick calculations.",
"Press # to browse clipboard history.",
"Type ? keyword to search the web instantly.",
"Press F1 for the full help guide.",
"Use Ctrl+P to bookmark your favorite files.",
"Type info cpu to check system resources.",
];
private static readonly string[] _jaPlaceholders =
[
"何をお手伝いしますか?",
"検索やコマンドを入力してください。",
"= 1920*1080 で計算ができます。",
"# でクリップボード履歴を表示できます。",
"F1 でヘルプを表示できます。",
];
private static readonly string[] _zhPlaceholders =
[
"我能帮你做什么?",
"输入内容进行搜索或执行命令。",
"试试 = 1920*1080 进行快速计算。",
"按 # 浏览剪贴板历史。",
"按 F1 查看完整帮助。",
];
private static readonly string[] _viPlaceholders =
[
"Tôi có thể giúp gì cho bạn?",
"Nhập nội dung để tìm kiếm hoặc chạy lệnh.",
"Thử = 1920*1080 để tính toán nhanh.",
"Nhấn # để xem lịch sử clipboard.",
"Nhấn F1 để xem hướng dẫn đầy đủ.",
];
// ─── 한국어 (기본) ────────────────────────────────────────────────────────
private static readonly Dictionary<string, string> _ko = new()
{
["placeholder"] = "무엇을 도와드릴까요?",
["loading"] = "검색 중...",
["no_results"] = "결과 없음",
["help_copy_hint"] = "Enter로 예시를 클립보드에 복사",
["help_total"] = "총 {0}개 명령어 · {1}개 단축키 · Enter → 전체 기능 창 열기",
["clipboard_empty"] = "클립보드 히스토리가 없습니다",
["clipboard_empty_hint"] = "텍스트를 복사하면 이 곳에 기록됩니다",
["merge_hint"] = "✓ {0}개 선택됨 · Shift+Enter로 합치기 · Esc로 취소",
["action_breadcrumb"] = "파일 액션",
["large_type_hint"] = "Shift+Enter: Large Type",
["settings_hotkey"] = "단축키",
["settings_theme"] = "테마",
["settings_language"] = "언어",
};
// ─── English ──────────────────────────────────────────────────────────────
private static readonly Dictionary<string, string> _en = new()
{
["placeholder"] = "What can I help you with?",
["loading"] = "Searching...",
["no_results"] = "No results",
["help_copy_hint"] = "Press Enter to copy example to clipboard",
["help_total"] = "{0} commands · {1} shortcuts · Enter → Show all features",
["clipboard_empty"] = "No clipboard history",
["clipboard_empty_hint"] = "Copy text and it will appear here",
["merge_hint"] = "✓ {0} selected · Shift+Enter to merge · Esc to cancel",
["action_breadcrumb"] = "File Action",
["large_type_hint"] = "Shift+Enter: Large Type",
["settings_hotkey"] = "Hotkey",
["settings_theme"] = "Theme",
["settings_language"] = "Language",
};
// ─── 日本語 ───────────────────────────────────────────────────────────────
private static readonly Dictionary<string, string> _ja = new()
{
["placeholder"] = "何をお手伝いしますか?",
["loading"] = "検索中...",
["no_results"] = "結果なし",
["help_copy_hint"] = "Enterで例をクリップボードにコピー",
["help_total"] = "コマンド {0}個 · ショートカット {1}個 · Enter → 全機能表示",
["clipboard_empty"] = "クリップボード履歴がありません",
["clipboard_empty_hint"] = "テキストをコピーするとここに表示されます",
["merge_hint"] = "✓ {0}件選択 · Shift+Enterで結合 · Escでキャンセル",
["action_breadcrumb"] = "ファイルアクション",
["large_type_hint"] = "Shift+Enter: 拡大表示",
["settings_hotkey"] = "ショートカット",
["settings_theme"] = "テーマ",
["settings_language"] = "言語",
};
// ─── 中文 ─────────────────────────────────────────────────────────────────
private static readonly Dictionary<string, string> _zh = new()
{
["placeholder"] = "我能帮你做什么?",
["loading"] = "搜索中...",
["no_results"] = "无结果",
["help_copy_hint"] = "按 Enter 复制示例到剪贴板",
["help_total"] = "{0} 个命令 · {1} 个快捷键 · Enter → 显示全部功能",
["clipboard_empty"] = "没有剪贴板历史",
["clipboard_empty_hint"] = "复制文本后将显示在此处",
["merge_hint"] = "✓ 已选 {0} 项 · Shift+Enter 合并 · Esc 取消",
["action_breadcrumb"] = "文件操作",
["large_type_hint"] = "Shift+Enter: 大字显示",
["settings_hotkey"] = "热键",
["settings_theme"] = "主题",
["settings_language"] = "语言",
};
// ─── Tiếng Việt ───────────────────────────────────────────────────────────
private static readonly Dictionary<string, string> _vi = new()
{
["placeholder"] = "Tôi có thể giúp gì cho bạn?",
["loading"] = "Đang tìm kiếm...",
["no_results"] = "Không có kết quả",
["help_copy_hint"] = "Nhấn Enter để sao chép ví dụ vào clipboard",
["help_total"] = "{0} lệnh · {1} phím tắt · Enter → Xem tất cả tính năng",
["clipboard_empty"] = "Không có lịch sử clipboard",
["clipboard_empty_hint"] = "Sao chép văn bản và nó sẽ xuất hiện ở đây",
["merge_hint"] = "✓ Đã chọn {0} mục · Shift+Enter để gộp · Esc hủy",
["action_breadcrumb"] = "Hành động tệp",
["large_type_hint"] = "Shift+Enter: Hiển thị lớn",
["settings_hotkey"] = "Phím tắt",
["settings_theme"] = "Giao diện",
["settings_language"] = "Ngôn ngữ",
};
}

Some files were not shown because too many files have changed in this diff Show More