179 lines
6.5 KiB
C#
179 lines
6.5 KiB
C#
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;
|
|
}
|
|
}
|