Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/FormatConvertTool.cs

192 lines
8.1 KiB
C#

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