모델 프로파일 기반 Cowork/Code 루프와 진행 UX 고도화 반영
- 등록 모델 실행 프로파일을 검증 게이트, 문서 fallback, post-tool verification까지 확장 적용 - Cowork/Code 진행 카드에 계획/도구/검증/압축/폴백/재시도 단계 메타를 추가해 대기 상태 가시성 강화 - OpenAI/vLLM tool 요청에 병렬 도구 호출 힌트를 추가하고 회귀 프롬프트 문서를 프로파일 기준으로 전면 정리 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
@@ -8,15 +8,16 @@ 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), " +
|
||||
"Supports: header styling (bold white text on colored background), " +
|
||||
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
|
||||
"cell merge, freeze panes (freeze header row), and number formatting.";
|
||||
"cell merge, freeze panes (freeze header row), number formatting, " +
|
||||
"themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
@@ -26,22 +27,77 @@ public class ExcelSkill : IAgentTool
|
||||
["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'" },
|
||||
["style"] = new() { Type = "string", Description = "Table style: 'styled' (colored header, striped rows, borders) or 'plain'. Default: 'styled'" },
|
||||
["theme"] = new() { Type = "string", Description = "Color theme: 'professional' (blue), 'modern' (green), 'dark' (slate), 'minimal' (gray). Default: 'professional'." },
|
||||
["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." },
|
||||
["number_formats"] = new() { Type = "array", Description = "Number format per column index. Supported: 'currency' (#,##0\"원\"), 'percent' (0.00%), 'decimal' (#,##0.00), 'integer' (#,##0), 'date' (yyyy-mm-dd), or any custom Excel format string. e.g. [\"text\",\"integer\",\"currency\",\"percent\"]", Items = new() { Type = "string" } },
|
||||
["col_alignments"] = new() { Type = "array", Description = "Horizontal alignment per column: 'left', 'center', 'right'. Headers always center-aligned.", Items = new() { Type = "string" } },
|
||||
["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } },
|
||||
},
|
||||
Required = ["path", "headers", "rows"]
|
||||
Required = []
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Theme definitions
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private record ThemeColors(string HeaderBg, string HeaderFg, string StripeBg);
|
||||
|
||||
private static ThemeColors GetTheme(string? theme) => theme?.ToLower() switch
|
||||
{
|
||||
"modern" => new("FF065F46", "FFFFFFFF", "FFF0FDF4"),
|
||||
"dark" => new("FF1E293B", "FFFFFFFF", "FFF1F5F9"),
|
||||
"minimal"=> new("FF374151", "FFFFFFFF", "FFF9FAFB"),
|
||||
_ => new("FF1F4E79", "FFFFFFFF", "FFF2F7FC"), // professional (default)
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Number format resolution
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
// Returns (isBuiltIn, numFmtId, formatString)
|
||||
// Built-in IDs we use: 0=General, 164+ = custom
|
||||
private static (bool isBuiltIn, uint numFmtId, string? formatCode) ResolveNumberFormat(string? fmt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fmt) || fmt == "text")
|
||||
return (true, 0, null); // General / no special format
|
||||
|
||||
return fmt.ToLower() switch
|
||||
{
|
||||
"percent" => (true, 10, null), // 0.00% (built-in id 10)
|
||||
"decimal" => (true, 4, null), // #,##0.00 (built-in id 4)
|
||||
"integer" => (true, 3, null), // #,##0 (built-in id 3)
|
||||
"date" => (true, 14, null), // m/d/yyyy (built-in id 14)
|
||||
"currency" => (false, 0, "#,##0\"원\""), // custom
|
||||
_ => (false, 0, fmt), // user-supplied custom string
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Execute
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
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;
|
||||
// path 미제공 시 title 또는 sheet_name에서 자동 생성
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
var hint = (args.TryGetProperty("title", out var tEl) ? tEl.GetString() : null)
|
||||
?? (args.TryGetProperty("sheet_name", out var snEl) ? snEl.GetString() : null)
|
||||
?? "workbook";
|
||||
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "workbook" : safe) + ".xlsx";
|
||||
}
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
@@ -56,157 +112,18 @@ public class ExcelSkill : IAgentTool
|
||||
|
||||
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);
|
||||
// Determine if we are in multi-sheet mode
|
||||
var multiSheetMode = args.TryGetProperty("sheets", out var sheetsArr)
|
||||
&& sheetsArr.ValueKind == JsonValueKind.Array
|
||||
&& sheetsArr.GetArrayLength() > 0;
|
||||
|
||||
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);
|
||||
if (multiSheetMode)
|
||||
return GenerateMultiSheetWorkbook(args, sheetsArr, fullPath);
|
||||
else
|
||||
return GenerateSingleSheetWorkbook(args, fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -215,26 +132,383 @@ public class ExcelSkill : IAgentTool
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Stylesheet (셀 서식)
|
||||
// Single-sheet workbook (backward-compatible path)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Stylesheet CreateStylesheet(bool isStyled)
|
||||
private static ToolResult GenerateSingleSheetWorkbook(JsonElement args, string fullPath)
|
||||
{
|
||||
if (!args.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'headers'");
|
||||
if (!args.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'rows'");
|
||||
|
||||
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 themeName = args.TryGetProperty("theme", out var th) ? th.GetString() : null;
|
||||
var isStyled = tableStyle != "plain";
|
||||
var freezeHeader= args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
|
||||
|
||||
var theme = GetTheme(themeName);
|
||||
var numFmts = ParseNumberFormats(args, "number_formats");
|
||||
var alignments = ParseAlignments(args, "col_alignments");
|
||||
|
||||
// Collect custom number formats for stylesheet
|
||||
var customFmts = CollectCustomFormats(numFmts);
|
||||
|
||||
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
|
||||
var workbookPart = spreadsheet.AddWorkbookPart();
|
||||
workbookPart.Workbook = new Workbook();
|
||||
|
||||
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
|
||||
stylesPart.Stylesheet = CreateStylesheet(isStyled, theme, customFmts, numFmts, alignments);
|
||||
stylesPart.Stylesheet.Save();
|
||||
|
||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
|
||||
var colCount = headers.GetArrayLength();
|
||||
|
||||
var summaryArg = args.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = args.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
|
||||
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
||||
summaryArg, mergesArg, colCount);
|
||||
|
||||
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
Id = workbookPart.GetIdOfPart(worksheetPart),
|
||||
SheetId = 1,
|
||||
Name = sheetName,
|
||||
});
|
||||
|
||||
workbookPart.Workbook.Save();
|
||||
|
||||
var features = BuildFeatureList(isStyled, freezeHeader,
|
||||
mergesArg.ValueKind == JsonValueKind.Array,
|
||||
summaryArg.ValueKind == JsonValueKind.Object,
|
||||
numFmts.Count > 0, alignments.Count > 0, themeName);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Excel 파일 생성 완료: {fullPath}\n시트: {sheetName}, 열: {colCount}, 행: {rowCount}{features}",
|
||||
fullPath);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Multi-sheet workbook
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static ToolResult GenerateMultiSheetWorkbook(JsonElement args, JsonElement sheetsArr, string fullPath)
|
||||
{
|
||||
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
|
||||
var workbookPart = spreadsheet.AddWorkbookPart();
|
||||
workbookPart.Workbook = new Workbook();
|
||||
|
||||
// Build a combined set of custom formats and alignments across all sheets for the stylesheet.
|
||||
// We create one shared stylesheet (using the first sheet's theme if not overridden).
|
||||
var allNumFmts = new List<string?>();
|
||||
var allAligns = new List<string?>();
|
||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||
{
|
||||
var nf = ParseNumberFormats(sheetDef, "number_formats");
|
||||
var al = ParseAlignments(sheetDef, "col_alignments");
|
||||
allNumFmts.AddRange(nf);
|
||||
allAligns.AddRange(al);
|
||||
}
|
||||
|
||||
// Use first sheet's theme for the shared stylesheet
|
||||
var firstThemeName = sheetsArr[0].TryGetProperty("theme", out var ft) ? ft.GetString() : null;
|
||||
var firstTheme = GetTheme(firstThemeName);
|
||||
|
||||
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
|
||||
|
||||
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
|
||||
stylesPart.Stylesheet = CreateStylesheet(true, firstTheme, combinedCustomFmts, allNumFmts, allAligns);
|
||||
stylesPart.Stylesheet.Save();
|
||||
|
||||
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
|
||||
|
||||
uint sheetId = 1;
|
||||
var totalSheets = 0;
|
||||
var totalRows = 0;
|
||||
|
||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||
{
|
||||
var sheetName = sheetDef.TryGetProperty("name", out var snEl) ? snEl.GetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
|
||||
var tableStyle = sheetDef.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "styled" : "styled";
|
||||
var themeName = sheetDef.TryGetProperty("theme", out var thEl) ? thEl.GetString() : firstThemeName;
|
||||
var isStyled = tableStyle != "plain";
|
||||
var freezeHeader = sheetDef.TryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
|
||||
var theme = GetTheme(themeName);
|
||||
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
|
||||
var alignments = ParseAlignments(sheetDef, "col_alignments");
|
||||
|
||||
if (!sheetDef.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
|
||||
if (!sheetDef.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
|
||||
|
||||
var colCount = headers.GetArrayLength();
|
||||
var summaryArg = sheetDef.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = sheetDef.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
|
||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
|
||||
var rowCount = WriteSheetContent(worksheetPart, sheetDef, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, combinedCustomFmts,
|
||||
summaryArg, mergesArg, colCount);
|
||||
|
||||
wbSheets.Append(new Sheet
|
||||
{
|
||||
Id = workbookPart.GetIdOfPart(worksheetPart),
|
||||
SheetId = sheetId,
|
||||
Name = sheetName,
|
||||
});
|
||||
|
||||
sheetId++;
|
||||
totalSheets++;
|
||||
totalRows += rowCount;
|
||||
}
|
||||
|
||||
workbookPart.Workbook.Save();
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}",
|
||||
fullPath);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Core sheet writer (shared by single + multi)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static int WriteSheetContent(
|
||||
WorksheetPart worksheetPart,
|
||||
JsonElement args,
|
||||
string sheetName,
|
||||
JsonElement headers,
|
||||
JsonElement rows,
|
||||
bool isStyled,
|
||||
bool freezeHeader,
|
||||
ThemeColors theme,
|
||||
List<string?> numFmts,
|
||||
List<string?> alignments,
|
||||
List<(string code, uint id)> customFmtRegistry,
|
||||
JsonElement summaryArg,
|
||||
JsonElement mergesArg,
|
||||
int colCount)
|
||||
{
|
||||
// Column widths
|
||||
var columns = CreateColumns(args, colCount);
|
||||
if (columns != null)
|
||||
worksheetPart.Worksheet.Append(columns);
|
||||
|
||||
var sheetData = new SheetData();
|
||||
worksheetPart.Worksheet.Append(sheetData);
|
||||
|
||||
// Header row
|
||||
var headerRow = new Row { RowIndex = 1 };
|
||||
for (int ci = 0; ci < colCount; ci++)
|
||||
{
|
||||
var h = headers.EnumerateArray().ElementAtOrDefault(ci);
|
||||
var cellRef = GetCellReference(ci, 0);
|
||||
var cell = new Cell
|
||||
{
|
||||
CellReference = cellRef,
|
||||
DataType = CellValues.String,
|
||||
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.GetString() ?? "" : ""),
|
||||
StyleIndex = isStyled ? (uint)1 : 0,
|
||||
};
|
||||
headerRow.Append(cell);
|
||||
}
|
||||
sheetData.Append(headerRow);
|
||||
|
||||
// Data rows
|
||||
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 };
|
||||
|
||||
// Determine style index
|
||||
cell.StyleIndex = ResolveDataStyleIndex(
|
||||
ci, rowCount, isStyled, numFmts, alignments, customFmtRegistry);
|
||||
|
||||
var strVal = cellVal.ToString();
|
||||
|
||||
if (strVal.StartsWith('='))
|
||||
{
|
||||
cell.CellFormula = new CellFormula(strVal);
|
||||
cell.DataType = null;
|
||||
}
|
||||
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 (summaryArg.ValueKind == JsonValueKind.Object)
|
||||
AddSummaryRow(sheetData, summaryArg, rowNum, colCount, rowCount, isStyled);
|
||||
|
||||
// Cell merges
|
||||
if (mergesArg.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var mergeCells = new MergeCells();
|
||||
foreach (var merge in mergesArg.EnumerateArray())
|
||||
{
|
||||
var range = merge.GetString();
|
||||
if (!string.IsNullOrEmpty(range))
|
||||
mergeCells.Append(new MergeCell { Reference = range });
|
||||
}
|
||||
if (mergeCells.HasChildren)
|
||||
worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData);
|
||||
}
|
||||
|
||||
// Freeze header
|
||||
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);
|
||||
}
|
||||
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Style index resolution for data cells
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
// StyleIndex layout (base indices built by CreateStylesheet):
|
||||
// 0 = default (no style)
|
||||
// 1 = header (bold, theme header bg)
|
||||
// 2 = stripe (theme stripe bg)
|
||||
// 3 = summary (bold, gray bg)
|
||||
// 4..3+N = number-format variants of default (plain, even-row): base=4
|
||||
//
|
||||
// For each unique (numFmt, isStripe, alignment) we build extra CellFormats.
|
||||
// To keep it simple, we encode them linearly after index 3.
|
||||
// BuildExtraFormats returns a mapping used both in stylesheet creation and here.
|
||||
//
|
||||
// ExtraFormat key: (colIndex, isStripe)
|
||||
// We pre-compute these in CreateStylesheet and expose via static registry pattern.
|
||||
//
|
||||
// To avoid complex cross-method state, we compute style index inline using the
|
||||
// same deterministic formula used when building CellFormats in CreateStylesheet.
|
||||
|
||||
private static uint ResolveDataStyleIndex(
|
||||
int colIndex,
|
||||
int dataRowIndex,
|
||||
bool isStyled,
|
||||
List<string?> numFmts,
|
||||
List<string?> alignments,
|
||||
List<(string code, uint id)> customFmtRegistry)
|
||||
{
|
||||
if (!isStyled && numFmts.Count == 0 && alignments.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Base: 4 reserved indices (0=default, 1=header, 2=stripe, 3=summary)
|
||||
// Extra formats start at index 4.
|
||||
// Layout: for each col (0..maxCol-1), two entries: [plain, stripe]
|
||||
// combined (colIndex, isStripe) into a single ordinal.
|
||||
|
||||
bool isStripe = isStyled && dataRowIndex % 2 == 0;
|
||||
|
||||
var maxCol = Math.Max(numFmts.Count, alignments.Count);
|
||||
if (maxCol == 0)
|
||||
{
|
||||
// No per-column formats, just use built-in stripes
|
||||
if (!isStyled) return 0;
|
||||
return isStripe ? (uint)2 : (uint)0;
|
||||
}
|
||||
|
||||
if (colIndex >= maxCol)
|
||||
{
|
||||
// Column beyond per-column format definitions — fall back to default/stripe
|
||||
if (!isStyled) return 0;
|
||||
return isStripe ? (uint)2 : (uint)0;
|
||||
}
|
||||
|
||||
// Extra style index = 4 + colIndex * 2 + (isStripe ? 1 : 0)
|
||||
return (uint)(4 + colIndex * 2 + (isStripe ? 1 : 0));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Stylesheet
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static Stylesheet CreateStylesheet(
|
||||
bool isStyled,
|
||||
ThemeColors theme,
|
||||
List<(string code, uint id)> customFmtRegistry,
|
||||
List<string?> allNumFmts,
|
||||
List<string?>? allAlignments = null)
|
||||
{
|
||||
var stylesheet = new Stylesheet();
|
||||
|
||||
// Fonts
|
||||
// ── NumberingFormats (must come first) ───────────────────
|
||||
if (customFmtRegistry.Count > 0)
|
||||
{
|
||||
var nfSection = new NumberingFormats();
|
||||
foreach (var (code, id) in customFmtRegistry)
|
||||
{
|
||||
nfSection.Append(new NumberingFormat
|
||||
{
|
||||
NumberFormatId = id,
|
||||
FormatCode = code,
|
||||
});
|
||||
}
|
||||
nfSection.Count = (uint)customFmtRegistry.Count;
|
||||
stylesheet.Append(nfSection);
|
||||
}
|
||||
|
||||
// ── Fonts ─────────────────────────────────────────────────
|
||||
var fonts = new Fonts(
|
||||
new Font( // 0: 기본
|
||||
new Font( // 0: default
|
||||
new FontSize { Val = 11 },
|
||||
new FontName { Val = "맑은 고딕" }
|
||||
),
|
||||
new Font( // 1: 볼드 흰색 (헤더용)
|
||||
new Font( // 1: bold white (header)
|
||||
new Bold(),
|
||||
new FontSize { Val = 11 },
|
||||
new Color { Rgb = "FFFFFFFF" },
|
||||
new Color { Rgb = theme.HeaderFg },
|
||||
new FontName { Val = "맑은 고딕" }
|
||||
),
|
||||
new Font( // 2: 볼드 (요약행용)
|
||||
new Font( // 2: bold (summary row)
|
||||
new Bold(),
|
||||
new FontSize { Val = 11 },
|
||||
new FontName { Val = "맑은 고딕" }
|
||||
@@ -242,23 +516,23 @@ public class ExcelSkill : IAgentTool
|
||||
);
|
||||
stylesheet.Append(fonts);
|
||||
|
||||
// Fills
|
||||
// ── 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: 파란 헤더 배경
|
||||
new Fill(new PatternFill { PatternType = PatternValues.None }), // 0: none (required)
|
||||
new Fill(new PatternFill { PatternType = PatternValues.Gray125 }), // 1: gray125 (required)
|
||||
new Fill(new PatternFill // 2: theme header bg
|
||||
{
|
||||
PatternType = PatternValues.Solid,
|
||||
ForegroundColor = new ForegroundColor { Rgb = "FF2E74B5" },
|
||||
ForegroundColor = new ForegroundColor { Rgb = theme.HeaderBg },
|
||||
BackgroundColor = new BackgroundColor { Indexed = 64 }
|
||||
}),
|
||||
new Fill(new PatternFill // 3: 연한 파란 (striped)
|
||||
new Fill(new PatternFill // 3: theme stripe bg
|
||||
{
|
||||
PatternType = PatternValues.Solid,
|
||||
ForegroundColor = new ForegroundColor { Rgb = "FFF2F7FB" },
|
||||
ForegroundColor = new ForegroundColor { Rgb = theme.StripeBg },
|
||||
BackgroundColor = new BackgroundColor { Indexed = 64 }
|
||||
}),
|
||||
new Fill(new PatternFill // 4: 연한 회색 (요약행)
|
||||
new Fill(new PatternFill // 4: light gray (summary)
|
||||
{
|
||||
PatternType = PatternValues.Solid,
|
||||
ForegroundColor = new ForegroundColor { Rgb = "FFE8E8E8" },
|
||||
@@ -267,13 +541,13 @@ public class ExcelSkill : IAgentTool
|
||||
);
|
||||
stylesheet.Append(fills);
|
||||
|
||||
// Borders
|
||||
// ── Borders ───────────────────────────────────────────────
|
||||
var borders = new Borders(
|
||||
new Border( // 0: 테두리 없음
|
||||
new Border( // 0: no border
|
||||
new LeftBorder(), new RightBorder(),
|
||||
new TopBorder(), new BottomBorder(), new DiagonalBorder()
|
||||
),
|
||||
new Border( // 1: 얇은 테두리
|
||||
new Border( // 1: thin border
|
||||
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 },
|
||||
@@ -283,47 +557,133 @@ public class ExcelSkill : IAgentTool
|
||||
);
|
||||
stylesheet.Append(borders);
|
||||
|
||||
// CellFormats
|
||||
var cellFormats = new CellFormats(
|
||||
new CellFormat // 0: 기본
|
||||
// ── CellFormats ───────────────────────────────────────────
|
||||
// Indices 0-3: reserved base formats (same as before)
|
||||
// Indices 4+: per-column extra formats (plain + stripe per column)
|
||||
|
||||
var cellFormats = new CellFormats();
|
||||
|
||||
// 0: default
|
||||
cellFormats.Append(new CellFormat
|
||||
{
|
||||
FontId = 0, FillId = 0, BorderId = isStyled ? (uint)1 : 0,
|
||||
ApplyBorder = isStyled
|
||||
});
|
||||
|
||||
// 1: header (bold, theme header bg, center)
|
||||
cellFormats.Append(new CellFormat
|
||||
{
|
||||
FontId = 1, FillId = 2, BorderId = 1,
|
||||
ApplyFont = true, ApplyFill = true, ApplyBorder = true,
|
||||
Alignment = new Alignment
|
||||
{
|
||||
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
|
||||
Horizontal = HorizontalAlignmentValues.Center,
|
||||
Vertical = VerticalAlignmentValues.Center
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// 2: stripe row (theme stripe bg)
|
||||
cellFormats.Append(new CellFormat
|
||||
{
|
||||
FontId = 0, FillId = 3, BorderId = 1,
|
||||
ApplyFill = true, ApplyBorder = true
|
||||
});
|
||||
|
||||
// 3: summary row (bold + gray bg)
|
||||
cellFormats.Append(new CellFormat
|
||||
{
|
||||
FontId = 2, FillId = 4, BorderId = 1,
|
||||
ApplyFont = true, ApplyFill = true, ApplyBorder = true
|
||||
});
|
||||
|
||||
// 4+: per-column formats (plain + stripe), two entries per column
|
||||
int maxCol = Math.Max(allNumFmts.Count, allAlignments?.Count ?? 0);
|
||||
for (int ci = 0; ci < maxCol; ci++)
|
||||
{
|
||||
var fmtStr = ci < allNumFmts.Count ? allNumFmts[ci] : null;
|
||||
var (isBuiltIn, builtInId, customCode) = ResolveNumberFormat(fmtStr);
|
||||
uint numFmtId = isBuiltIn ? builtInId : GetCustomFmtId(customCode!, customFmtRegistry);
|
||||
bool applyNumFmt = numFmtId != 0;
|
||||
|
||||
var alignStr = (allAlignments != null && ci < allAlignments.Count) ? allAlignments[ci] : null;
|
||||
var horizAlign = alignStr switch
|
||||
{
|
||||
"right" => HorizontalAlignmentValues.Right,
|
||||
"center" => HorizontalAlignmentValues.Center,
|
||||
_ => HorizontalAlignmentValues.Left,
|
||||
};
|
||||
bool applyAlign = !string.IsNullOrEmpty(alignStr);
|
||||
|
||||
// plain (odd rows)
|
||||
var plainFmt = new CellFormat
|
||||
{
|
||||
FontId = 0, FillId = 0,
|
||||
BorderId = isStyled ? (uint)1 : 0,
|
||||
NumberFormatId = numFmtId,
|
||||
ApplyBorder = isStyled,
|
||||
ApplyNumberFormat = applyNumFmt,
|
||||
ApplyAlignment = applyAlign,
|
||||
};
|
||||
if (applyAlign)
|
||||
plainFmt.Alignment = new Alignment { Horizontal = horizAlign };
|
||||
cellFormats.Append(plainFmt);
|
||||
|
||||
// stripe (even rows)
|
||||
var stripeFmt = new CellFormat
|
||||
{
|
||||
FontId = 0, FillId = isStyled ? (uint)3 : 0,
|
||||
BorderId = isStyled ? (uint)1 : 0,
|
||||
NumberFormatId = numFmtId,
|
||||
ApplyFill = isStyled,
|
||||
ApplyBorder = isStyled,
|
||||
ApplyNumberFormat = applyNumFmt,
|
||||
ApplyAlignment = applyAlign,
|
||||
};
|
||||
if (applyAlign)
|
||||
stripeFmt.Alignment = new Alignment { Horizontal = horizAlign };
|
||||
cellFormats.Append(stripeFmt);
|
||||
}
|
||||
|
||||
stylesheet.Append(cellFormats);
|
||||
|
||||
return stylesheet;
|
||||
}
|
||||
|
||||
private static uint GetCustomFmtId(string code, List<(string code, uint id)> registry)
|
||||
{
|
||||
foreach (var (c, id) in registry)
|
||||
if (c == code) return id;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Build registry of custom number formats (deduplicated), assigning IDs from 164+
|
||||
private static List<(string code, uint id)> CollectCustomFormats(IEnumerable<string?> numFmts)
|
||||
{
|
||||
var registry = new List<(string code, uint id)>();
|
||||
uint nextId = 164;
|
||||
foreach (var fmt in numFmts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fmt) || fmt == "text") continue;
|
||||
var (isBuiltIn, _, customCode) = ResolveNumberFormat(fmt);
|
||||
if (isBuiltIn || customCode == null) continue;
|
||||
if (registry.Any(r => r.code == customCode)) continue;
|
||||
registry.Add((customCode, nextId++));
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 열 너비
|
||||
// Column widths
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
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; // 기본 너비
|
||||
double width = 15;
|
||||
if (hasWidths && i < widthsArr.GetArrayLength())
|
||||
width = widthsArr[i].GetDouble();
|
||||
|
||||
@@ -340,7 +700,7 @@ public class ExcelSkill : IAgentTool
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 요약 행
|
||||
// Summary row
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
|
||||
@@ -351,7 +711,6 @@ public class ExcelSkill : IAgentTool
|
||||
|
||||
var summaryRow = new Row { RowIndex = rowNum };
|
||||
|
||||
// 첫 번째 열에 라벨
|
||||
var labelCell = new Cell
|
||||
{
|
||||
CellReference = GetCellReference(0, (int)rowNum - 1),
|
||||
@@ -361,7 +720,6 @@ public class ExcelSkill : IAgentTool
|
||||
};
|
||||
summaryRow.Append(labelCell);
|
||||
|
||||
// 나머지 열에 수식 또는 빈 셀
|
||||
for (int ci = 1; ci < colCount; ci++)
|
||||
{
|
||||
var colLetter = GetColumnLetter(ci);
|
||||
@@ -387,7 +745,45 @@ public class ExcelSkill : IAgentTool
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 유틸리티
|
||||
// Parameter parsing helpers
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static List<string?> ParseNumberFormats(JsonElement args, string key)
|
||||
{
|
||||
var result = new List<string?>();
|
||||
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string?> ParseAlignments(JsonElement args, string key)
|
||||
{
|
||||
var result = new List<string?>();
|
||||
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString()?.ToLower());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string BuildFeatureList(bool isStyled, bool freezeHeader,
|
||||
bool hasMerges, bool hasSummary, bool hasNumFmts, bool hasAlignments, string? theme)
|
||||
{
|
||||
var features = new List<string>();
|
||||
if (isStyled) features.Add("스타일 적용");
|
||||
if (theme != null) features.Add($"테마:{theme}");
|
||||
if (freezeHeader) features.Add("틀 고정");
|
||||
if (hasMerges) features.Add("셀 병합");
|
||||
if (hasSummary) features.Add("요약행");
|
||||
if (hasNumFmts) features.Add("숫자형식");
|
||||
if (hasAlignments) features.Add("정렬");
|
||||
return features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Utilities
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static string GetColumnLetter(int colIndex)
|
||||
|
||||
Reference in New Issue
Block a user