using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace AxCopilot.Services.Agent; 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 { get { ToolParameterSchema obj = new ToolParameterSchema { Properties = new Dictionary { ["template_path"] = new ToolProperty { Type = "string", Description = "Path to template file (.html, .md, .txt). Relative to work folder." }, ["template_text"] = new ToolProperty { Type = "string", Description = "Inline template text (used if template_path is not provided)." }, ["variables"] = new ToolProperty { 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 ToolProperty { Type = "string", Description = "Output file path. If not provided, returns rendered text." } } }; int num = 1; List list = new List(num); CollectionsMarshal.SetCount(list, num); CollectionsMarshal.AsSpan(list)[0] = "variables"; obj.Required = list; return obj; } } public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { string template; if (args.TryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.GetString())) { string 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 File.ReadAllTextAsync(templatePath, ct); } else { if (!args.TryGetProperty("template_text", out var ttEl) || string.IsNullOrEmpty(ttEl.GetString())) { return ToolResult.Fail("template_path 또는 template_text가 필요합니다."); } template = ttEl.GetString(); } if (!args.TryGetProperty("variables", out var varsEl)) { return ToolResult.Fail("variables가 필요합니다."); } try { string rendered = Render(template, varsEl); if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString())) { string 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); } string dir = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } await File.WriteAllTextAsync(outputPath, rendered, Encoding.UTF8, ct); return ToolResult.Ok($"✅ 템플릿 렌더링 완료: {Path.GetFileName(outputPath)} ({rendered.Length:N0}자)", outputPath); } if (rendered.Length > 4000) { return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered.Substring(0, 3900)}...\n[이하 생략]"); } return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered}"); } catch (Exception ex) { return ToolResult.Fail("템플릿 렌더링 실패: " + ex.Message); } } internal static string Render(string template, JsonElement variables) { string input = template; input = Regex.Replace(input, "\\{\\{#(\\w+)\\}\\}(.*?)\\{\\{/\\1\\}\\}", delegate(Match match) { string value = match.Groups[1].Value; string value2 = match.Groups[2].Value; if (!variables.TryGetProperty(value, out var value3)) { return ""; } if (value3.ValueKind == JsonValueKind.Array) { StringBuilder stringBuilder = new StringBuilder(); int num = 0; foreach (JsonElement item in value3.EnumerateArray()) { string text = value2; if (item.ValueKind == JsonValueKind.Object) { foreach (JsonProperty item2 in item.EnumerateObject()) { text = text.Replace("{{" + item2.Name + "}}", item2.Value.ToString()).Replace("{{." + item2.Name + "}}", item2.Value.ToString()); } } else { text = text.Replace("{{.}}", item.ToString()); } text = text.Replace("{{@index}}", (num + 1).ToString()); stringBuilder.Append(text); num++; } return stringBuilder.ToString(); } if (value3.ValueKind == JsonValueKind.True) { return RenderSimpleVars(value2, variables); } return (value3.ValueKind != JsonValueKind.False && value3.ValueKind != JsonValueKind.Null && value3.ValueKind != JsonValueKind.Undefined) ? RenderSimpleVars(value2, variables) : ""; }, RegexOptions.Singleline); input = Regex.Replace(input, "\\{\\{\\^(\\w+)\\}\\}(.*?)\\{\\{/\\1\\}\\}", delegate(Match match) { string value = match.Groups[1].Value; string value2 = match.Groups[2].Value; if (!variables.TryGetProperty(value, out var value3)) { return value2; } return (value3.ValueKind == JsonValueKind.False || value3.ValueKind == JsonValueKind.Null || (value3.ValueKind == JsonValueKind.Array && value3.GetArrayLength() == 0) || (value3.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(value3.GetString()))) ? value2 : ""; }, RegexOptions.Singleline); return RenderSimpleVars(input, variables); } private static string RenderSimpleVars(string text, JsonElement variables) { return Regex.Replace(text, "\\{\\{(\\w+)\\}\\}", delegate(Match match) { string value = match.Groups[1].Value; if (!variables.TryGetProperty(value, out var value2)) { return match.Value; } JsonValueKind valueKind = value2.ValueKind; if (1 == 0) { } string result = valueKind switch { JsonValueKind.String => value2.GetString() ?? "", JsonValueKind.Number => value2.ToString(), JsonValueKind.True => "true", JsonValueKind.False => "false", _ => value2.ToString(), }; if (1 == 0) { } return result; }); } }