using System.IO; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; /// /// Markdown (.md) 문서를 생성하는 내장 스킬. /// sections 배열로 구조화된 콘텐츠 블록(heading/paragraph/table/list/callout/code/quote/divider/toc)을 지원합니다. /// frontmatter, toc 옵션으로 메타데이터와 목차를 자동 생성할 수 있습니다. /// content 파라미터로 기존 방식의 원시 마크다운도 계속 지원합니다. /// public class MarkdownSkill : IAgentTool { public string Name => "markdown_create"; public string Description => "Create a Markdown (.md) document. " + "REQUIRED: 'content' (raw markdown string). " + "Alternative: set content=\"\" and provide 'sections' array for structured blocks. " + "NEVER call this tool with only title — you MUST include content (or sections). " + "Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " + "Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents."; public ToolParameterSchema Parameters => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." }, ["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." }, ["content"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] 원시 마크다운 내용. 문서의 주요 내용을 여기에 작성하세요. " + "sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요." }, ["sections"] = new() { Type = "array", Description = "구조화된 콘텐츠 블록 배열. 각 객체는 'type' 필드를 가집니다. " + "타입: " + "'heading' {level:1-6, text} — 제목, " + "'paragraph' {text} — 단락, " + "'table' {headers:[], rows:[[]]} — 표, " + "'list' {items:[], ordered:false} — 목록, " + "'callout' {style:'info|warning|tip|danger', text} — 콜아웃 블록, " + "'code' {language:'python', code:'...'} — 코드 블록, " + "'quote' {text, author} — 인용구, " + "'divider' — 구분선, " + "'toc' — 이 위치에 목차 삽입." }, ["frontmatter"] = new() { Type = "object", Description = "YAML 프론트매터 메타데이터 키-값 쌍. 예: {\"author\": \"홍길동\", \"date\": \"2026-04-07\"}." }, ["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." }, ["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." }, }, Required = ["content"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { // ── 필수 파라미터 ────────────────────────────────────────────────── var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array && sectionsEl.GetArrayLength() > 0; var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(contentEl.SafeGetString()); var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object; if (!hasSections && !hasContent) { return ToolResult.Fail( "필수 파라미터 누락: 'content' (마크다운 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" + "다시 호출할 때는 title 외에 반드시 content를 포함하세요. 예:\n" + "{\"name\":\"markdown_create\",\"arguments\":{\"title\":\"...\",\"content\":\"## 개요\\n...\\n\\n## 상세\\n...\"}}\n" + "sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요."); } // path 미제공 시 title에서 자동 생성 string path; if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.SafeGetString())) { path = pathEl.SafeGetString()!; } else { var baseTitle = (args.SafeTryGetProperty("title", out var autoT) ? autoT.SafeGetString() : null) ?? "document"; var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.'); if (safe.Length > 60) safe = safe[..60].TrimEnd(); path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md"; } var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null; var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True; var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8"; // ── 경로 처리 ────────────────────────────────────────────────────── 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 { Encoding fileEncoding; try { fileEncoding = Encoding.GetEncoding(encodingName); } catch { fileEncoding = new UTF8Encoding(false); } var sb = new StringBuilder(); // ── YAML 프론트매터 ──────────────────────────────────────────── if (hasFrontmatter) { sb.AppendLine("---"); foreach (var prop in frontEl.EnumerateObject()) { var val = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.SafeGetString() ?? "" : prop.Value.ToString(); // 값에 특수 문자가 있으면 인용 if (val.Contains(':') || val.Contains('#') || val.StartsWith('"')) val = $"\"{val.Replace("\"", "\\\"")}\""; sb.AppendLine($"{prop.Name}: {val}"); } sb.AppendLine("---"); sb.AppendLine(); } // ── 제목 ─────────────────────────────────────────────────────── if (!string.IsNullOrEmpty(title)) { sb.AppendLine($"# {title}"); sb.AppendLine(); } if (hasSections) { // ── sections 모드 ────────────────────────────────────────── // 먼저 heading 목록 수집 (TOC 생성용) var headings = CollectHeadings(sectionsEl); // 문서 상단 TOC (toc:true 옵션) if (useToc && headings.Count > 0) { RenderToc(sb, headings); sb.AppendLine(); } // 섹션 렌더링 foreach (var section in sectionsEl.EnumerateArray()) { if (!section.SafeTryGetProperty("type", out var typeEl)) continue; var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? ""; switch (type) { case "heading": RenderHeading(sb, section); break; case "paragraph": RenderParagraph(sb, section); break; case "table": RenderTable(sb, section); break; case "list": RenderList(sb, section); break; case "callout": RenderCallout(sb, section); break; case "code": RenderCode(sb, section); break; case "quote": RenderQuote(sb, section); break; case "divider": sb.AppendLine("---"); sb.AppendLine(); break; case "toc": // 인라인 TOC 삽입 if (headings.Count > 0) RenderToc(sb, headings); sb.AppendLine(); break; } } } else { // ── 하위 호환: 원시 content 모드 ────────────────────────── if (useToc) { // content에서 헤딩 파싱하여 TOC 생성 var raw = contentEl.SafeGetString() ?? ""; var headings = ParseHeadingsFromContent(raw); if (headings.Count > 0) { RenderToc(sb, headings); sb.AppendLine(); } } sb.Append(contentEl.SafeGetString() ?? ""); } // ── 파일 쓰기 ────────────────────────────────────────────────── var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct); var lines = sb.ToString().Split('\n').Length; return ToolResult.Ok( $"Markdown 문서 저장 완료: {fullPath} ({lines}줄, 인코딩: {encodingName})", fullPath); } catch (Exception ex) { return ToolResult.Fail($"Markdown 문서 저장 중 오류가 발생했습니다: {ex.Message}"); } } // ── 헤딩 수집 (sections 배열에서) ──────────────────────────────────────── private static List<(int Level, string Text)> CollectHeadings(JsonElement sections) { var list = new List<(int, string)>(); foreach (var s in sections.EnumerateArray()) { if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue; var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2; var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : ""; if (!string.IsNullOrEmpty(text)) list.Add((Math.Clamp(level, 1, 6), text)); } return list; } // ── 헤딩 파싱 (원시 마크다운에서) ──────────────────────────────────────── private static List<(int Level, string Text)> ParseHeadingsFromContent(string content) { var list = new List<(int, string)>(); foreach (var line in content.Split('\n')) { var m = Regex.Match(line, @"^(#{1,6})\s+(.+)"); if (m.Success) list.Add((m.Groups[1].Length, m.Groups[2].Value.Trim())); } return list; } // ── TOC 렌더링 ──────────────────────────────────────────────────────────── private static void RenderToc(StringBuilder sb, List<(int Level, string Text)> headings) { sb.AppendLine("## 목차"); sb.AppendLine(); foreach (var (level, text) in headings) { // level 1은 들여쓰기 없음, level 2 → 2스페이스, 이후 2씩 추가 var indent = level <= 1 ? "" : new string(' ', (level - 1) * 2); var anchor = HeadingToAnchor(text); sb.AppendLine($"{indent}- [{text}](#{anchor})"); } sb.AppendLine(); } // ── GitHub-style 앵커 생성 ──────────────────────────────────────────────── private static string HeadingToAnchor(string text) { // 소문자 변환, 영문/숫자/한글 이외 공백 → '-', 연속 '-' 정리 var anchor = text.ToLowerInvariant(); anchor = Regex.Replace(anchor, @"[^\w\uAC00-\uD7A3\s-]", ""); anchor = Regex.Replace(anchor, @"\s+", "-"); anchor = Regex.Replace(anchor, @"-+", "-").Trim('-'); return anchor; } // ── 개별 섹션 렌더러 ────────────────────────────────────────────────────── private static void RenderHeading(StringBuilder sb, JsonElement s) { var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2; var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : ""; sb.AppendLine($"{new string('#', level)} {text}"); sb.AppendLine(); } private static void RenderParagraph(StringBuilder sb, JsonElement s) { var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : ""; if (!string.IsNullOrWhiteSpace(text)) { sb.AppendLine(text); sb.AppendLine(); } } private static void RenderTable(StringBuilder sb, JsonElement s) { if (!s.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array) return; if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array) return; var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.SafeGetString() ?? "")).ToList(); if (headers.Count == 0) return; // 헤더 행 sb.AppendLine("| " + string.Join(" | ", headers) + " |"); // 구분 행 sb.AppendLine("| " + string.Join(" | ", headers.Select(_ => "---")) + " |"); // 데이터 행 foreach (var row in rowsEl.EnumerateArray()) { if (row.ValueKind != JsonValueKind.Array) continue; var cells = row.EnumerateArray() .Select(c => EscapeTableCell(c.ValueKind == JsonValueKind.Null ? "" : c.ToString())) .ToList(); // 열 수 맞추기 while (cells.Count < headers.Count) cells.Add(""); sb.AppendLine("| " + string.Join(" | ", cells.Take(headers.Count)) + " |"); } sb.AppendLine(); } private static string EscapeTableCell(string value) => value.Replace("|", "\\|").Replace("\n", " ").Replace("\r", ""); private static void RenderList(StringBuilder sb, JsonElement s) { if (!s.SafeTryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array) return; var ordered = s.SafeTryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True; int idx = 1; foreach (var item in itemsEl.EnumerateArray()) { var text = item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? "" : item.ToString(); // 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로) var prefix = ordered ? $"{idx++}. " : "- "; if (text.StartsWith(" ") || text.StartsWith("\t")) sb.AppendLine(text); // 하위 항목: 이미 들여쓰기 포함 else sb.AppendLine($"{prefix}{text}"); } sb.AppendLine(); } private static void RenderCallout(StringBuilder sb, JsonElement s) { var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO"; var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : ""; // 표준 GitHub/Obsidian 콜아웃 형식 sb.AppendLine($"> [!{style}]"); foreach (var line in text.Split('\n')) sb.AppendLine($"> {line}"); sb.AppendLine(); } private static void RenderCode(StringBuilder sb, JsonElement s) { var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : ""; var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : ""; sb.AppendLine($"```{lang}"); sb.AppendLine(code); sb.AppendLine("```"); sb.AppendLine(); } private static void RenderQuote(StringBuilder sb, JsonElement s) { var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : ""; var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null; foreach (var line in text.Split('\n')) sb.AppendLine($"> {line}"); if (!string.IsNullOrEmpty(author)) sb.AppendLine($">"); sb.AppendLine($"> — {author}"); sb.AppendLine(); } }