모델 프로파일 기반 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:
2026-04-08 13:41:57 +09:00
parent b391dfdfb3
commit a2c952879d
552 changed files with 8094 additions and 13595 deletions

View File

@@ -185,16 +185,19 @@ public partial class AgentLoopService
string? documentPlanTitle = null; // document_plan이 제안한 문서 제목
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
var taskType = ClassifyTaskType(userQuery, ActiveTab);
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
var executionPolicy = _llm.GetActiveExecutionPolicy();
var consecutiveNoToolResponses = 0;
var noToolResponseThreshold = GetNoToolCallResponseThreshold();
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries();
var planExecutionRetryMax = GetPlanExecutionRetryMax();
var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
var terminalEvidenceGateRetryMax = GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
var failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var runState = new RunState();
var requireHighImpactCodeVerification = false;
string? lastModifiedCodeFilePath = null;
var taskType = ClassifyTaskType(userQuery, ActiveTab);
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
maxRetry = ComputeAdaptiveMaxRetry(maxRetry, taskPolicy.TaskType);
var recentTaskRetryQuality = TryGetRecentTaskRetryQuality(taskPolicy.TaskType);
maxRetry = ComputeQualityAwareMaxRetry(maxRetry, recentTaskRetryQuality, taskPolicy.TaskType);
@@ -204,7 +207,8 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
if (!executionPolicy.ReduceEarlyMemoryPressure)
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
var runtimeHooks = GetRuntimeHooks(llm.AgentHooks, runtimeOverrides);
var runtimeOverrideApplied = false;
@@ -443,12 +447,17 @@ public partial class AgentLoopService
// Context Condenser: 토큰 초과 시 이전 대화 자동 압축
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
{
var proactiveCompact = llm.EnableProactiveContextCompact
&& !(executionPolicy.ReduceEarlyMemoryPressure && iteration <= 1);
var compactTriggerPercent = executionPolicy.ReduceEarlyMemoryPressure
? Math.Min(95, llm.ContextCompactTriggerPercent + 10)
: llm.ContextCompactTriggerPercent;
var compactionResult = await ContextCondenser.CondenseWithStatsAsync(
messages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
proactiveCompact,
compactTriggerPercent,
false,
ct);
if (compactionResult.Changed)
@@ -509,12 +518,16 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
return "⚠ 현재 스킬 정책에서 허용된 도구가 없어 작업을 진행할 수 없습니다. allowed-tools 설정을 확인하세요.";
}
// totalToolCalls == 0: 아직 한 번도 도구를 안 불렀으면 tool_choice:"required" 강제
// → chatty 모델(Qwen 등)이 텍스트 설명만 하고 도구를 안 부르는 현상 방지
var forceFirst = totalToolCalls == 0 && executionPolicy.ForceInitialToolCall;
blocks = await SendWithToolsWithRecoveryAsync(
messages,
activeTools,
ct,
$"메인 루프 {iteration}",
runState);
runState,
forceToolCall: forceFirst);
runState.ContextRecoveryAttempts = 0;
runState.TransientLlmErrorRetries = 0;
NotifyPostCompactionTurnIfNeeded(runState);
@@ -601,6 +614,11 @@ public partial class AgentLoopService
return $"⚠ LLM 오류 (도구 호출 실패 후 폴백도 실패): {fallbackEx.Message}";
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 사용자가 직접 취소한 경우 — 오류로 처리하지 않고 상위로 전파
throw;
}
catch (Exception ex)
{
if (TryHandleContextOverflowTransition(ex, messages, runState))
@@ -706,13 +724,27 @@ public partial class AgentLoopService
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
messages.Add(new ChatMessage
var retryNum = runState.NoToolCallLoopRetry;
var recoveryContent = retryNum switch
{
Role = "user",
Content = "[System:ToolCallRequired] 현재 응답이 연속으로 도구 호출 없이 종료되고 있습니다. " +
"설명만 하지 말고 즉시 도구를 최소 1회 이상 호출해 실행을 진행하세요. " +
$"사용 가능 도구 예시: {activeToolPreview}"
});
1 =>
"[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
"지금 즉시 도구를 1개 이상 호출하세요. " +
"할 말이 있다면 도구 호출 이후에 하세요 — 도구 호출 전 설명 금지. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있고, 그렇게 해야 합니다. " +
$"지금 사용 가능한 도구: {activeToolPreview}",
_ =>
"[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
"지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " +
"작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " +
"도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " +
"여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
};
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
EmitEvent(
AgentEventType.Thinking,
"",
@@ -725,20 +757,50 @@ public partial class AgentLoopService
// 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것
// "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회)
if (planSteps.Count > 0 && totalToolCalls == 0 && planExecutionRetry < planExecutionRetryMax)
if (executionPolicy.ForceToolCallAfterPlan
&& planSteps.Count > 0
&& totalToolCalls == 0
&& planExecutionRetry < planExecutionRetryMax)
{
planExecutionRetry++;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
messages.Add(new ChatMessage { Role = "user",
Content = "도구를 호출하지 않았습니다. 계획 1단계를 지금 즉시 도구(tool call)로 실행하세요. " +
"설명 없이 도구 호출만 하세요." });
var planToolList = string.Join(", ",
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
var planRecoveryContent = planExecutionRetry switch
{
1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
"계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " +
"텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " +
$"사용 가능한 도구: {planToolList}",
_ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
"이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " +
"지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " +
"독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " +
$"사용 가능한 도구: {planToolList}"
};
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
EmitEvent(AgentEventType.Thinking, "", $"도구 미호출 감지 — 실행 재시도 {planExecutionRetry}/{planExecutionRetryMax}...");
continue; // 루프 재시작
}
// document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 재시도 (최대 2회)
if (documentPlanCalled && postDocumentPlanRetry < 2)
// 문서 생성 우선 프로파일은 재시도보다 빠른 fallback을 우선합니다.
if (documentPlanCalled
&& executionPolicy.PreferAggressiveDocumentFallback
&& !string.IsNullOrEmpty(documentPlanScaffold)
&& !_docFallbackAttempted)
{
postDocumentPlanRetry = documentPlanRetryMax;
}
// document_plan은 호출됐지만 terminal 문서 도구(html_create 등)가 미호출인 경우 → 프로파일 기준 재시도
if (documentPlanCalled && postDocumentPlanRetry < documentPlanRetryMax)
{
postDocumentPlanRetry++;
if (!string.IsNullOrEmpty(textResponse))
@@ -747,7 +809,7 @@ public partial class AgentLoopService
Content = "html_create 도구를 호출하지 않았습니다. " +
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
"html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요." });
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/2...");
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
continue; // 루프 재시작
}
@@ -845,36 +907,41 @@ public partial class AgentLoopService
textResponse,
taskPolicy,
lastArtifactFilePath,
runState))
runState,
executionPolicy))
continue;
if (TryApplyCodeDiffEvidenceGateTransition(
messages,
textResponse,
runState))
runState,
executionPolicy))
continue;
if (TryApplyRecentExecutionEvidenceGateTransition(
messages,
textResponse,
taskPolicy,
runState))
runState,
executionPolicy))
continue;
if (TryApplyExecutionSuccessGateTransition(
messages,
textResponse,
taskPolicy,
runState))
runState,
executionPolicy))
continue;
if (TryApplyCodeCompletionGateTransition(
if (executionPolicy.EnableCodeQualityGates && TryApplyCodeCompletionGateTransition(
messages,
textResponse,
taskPolicy,
requireHighImpactCodeVerification,
totalToolCalls,
runState))
runState,
executionPolicy))
continue;
if (TryApplyTerminalEvidenceGateTransition(
@@ -884,7 +951,8 @@ public partial class AgentLoopService
userQuery,
totalToolCalls,
lastArtifactFilePath,
runState))
runState,
terminalEvidenceGateRetryMax))
continue;
if (!string.IsNullOrEmpty(textResponse))
@@ -904,7 +972,10 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
var parallelPlan = CreateParallelExecutionPlan(llm.EnableParallelTools, toolCalls);
var parallelPlan = CreateParallelExecutionPlan(
llm.EnableParallelTools && executionPolicy.EnableParallelReadBatch,
toolCalls,
executionPolicy.MaxParallelReadBatch);
if (parallelPlan.ShouldRun)
{
if (parallelPlan.ParallelBatch.Count > 1)
@@ -1398,6 +1469,7 @@ public partial class AgentLoopService
toolCalls,
messages,
llm,
executionPolicy,
context,
ct);
if (terminalCompleted)
@@ -1412,6 +1484,7 @@ public partial class AgentLoopService
result,
messages,
llm,
executionPolicy,
context,
ct);
if (consumedVerificationIteration)
@@ -3474,29 +3547,53 @@ public partial class AgentLoopService
}
private static int GetNoToolCallResponseThreshold()
=> ResolveNoToolCallResponseThreshold(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"));
=> GetNoToolCallResponseThreshold(defaultValue: 2);
private static int GetNoToolCallResponseThreshold(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
defaultValue,
min: 1,
max: 6);
private static int GetNoToolCallRecoveryMaxRetries()
=> ResolveNoToolCallRecoveryMaxRetries(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"));
=> GetNoToolCallRecoveryMaxRetries(defaultValue: 3);
private static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
defaultValue,
min: 0,
max: 6);
private static int GetPlanExecutionRetryMax()
=> ResolvePlanExecutionRetryMax(
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"));
=> GetPlanExecutionRetryMax(defaultValue: 2);
private static int GetPlanExecutionRetryMax(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
defaultValue,
min: 0,
max: 6);
private static int ResolveNoToolCallResponseThreshold(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
private static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
private static int ResolvePlanExecutionRetryMax(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
private static int GetTerminalEvidenceGateMaxRetries()
=> ResolveTerminalEvidenceGateMaxRetries(
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"));
=> GetTerminalEvidenceGateMaxRetries(defaultValue: 1);
private static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
=> ResolveThresholdValue(
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
defaultValue,
min: 0,
max: 3);
private static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
@@ -3957,6 +4054,10 @@ public partial class AgentLoopService
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
EmitEvent(AgentEventType.Error, "", $"검증 LLM 호출 실패: {ex.Message}");
@@ -4592,9 +4693,15 @@ public partial class AgentLoopService
}
// 문서 생성 (Excel, Word, HTML 등)
if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create")
// Cowork 탭에서 문서 생성은 핵심 목적이므로 승인 불필요
if (toolName is "excel_create" or "docx_create" or "html_create" or "csv_create" or "script_create"
or "markdown_create" or "pptx_create")
{
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return null;
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
}
@@ -4833,4 +4940,3 @@ public partial class AgentLoopService
}
}

View File

@@ -61,7 +61,8 @@ public partial class AgentLoopService
TaskTypePolicy taskPolicy,
bool requireHighImpactCodeVerification,
int totalToolCalls,
RunState runState)
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) || totalToolCalls <= 0)
return false;
@@ -69,7 +70,9 @@ public partial class AgentLoopService
var hasCodeVerificationEvidence = HasCodeVerificationEvidenceAfterLastModification(
messages,
requireHighImpactCodeVerification);
if (!hasCodeVerificationEvidence && runState.CodeVerificationGateRetry < 2)
if (executionPolicy.CodeVerificationGateMaxRetries > 0
&& !hasCodeVerificationEvidence
&& runState.CodeVerificationGateRetry < executionPolicy.CodeVerificationGateMaxRetries)
{
runState.CodeVerificationGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
@@ -78,18 +81,19 @@ public partial class AgentLoopService
{
Role = "user",
Content = requireHighImpactCodeVerification
? "[System:CodeQualityGate] 怨듭슜/?듭떖 肄붾뱶 ?섏젙 ?댄썑 寃€利?洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 file_read, grep/glob, git diff, build/test源뚯? ?뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
: "[System:CodeQualityGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test/file_read/diff 洹쇨굅媛€ 遺€議깊빀?덈떎. 醫낅즺?섏? 留먭퀬 寃€利?洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
? "[System:CodeQualityGate] 공용/핵심 코드 변경 이후 검증 근거가 부족합니다. 종료하지 말고 file_read, grep/glob, git diff, build/test까지 확인한 뒤에만 마무리하세요."
: "[System:CodeQualityGate] 마지막 코드 수정 이후 build/test/file_read/diff 근거가 부족합니다. 종료하지 말고 검증 근거를 보강한 뒤에만 마무리하세요."
});
EmitEvent(AgentEventType.Thinking, "", requireHighImpactCodeVerification
? "怨좎쁺??肄붾뱶 蹂€寃쎌쓽 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??.."
: "肄붾뱶 寃곌낵 寃€利?洹쇨굅媛€ 遺€議깊빐 異붽? 寃€利앹쓣 吏꾪뻾?⑸땲??..");
? "핵심 코드 변경의 검증 근거가 부족해 추가 검증을 진행합니다..."
: "코드 결과 검증 근거가 부족해 추가 검증을 진행합니다...");
return true;
}
if (requireHighImpactCodeVerification
&& executionPolicy.HighImpactBuildTestGateMaxRetries > 0
&& !HasSuccessfulBuildAndTestAfterLastModification(messages)
&& runState.HighImpactBuildTestGateRetry < 1)
&& runState.HighImpactBuildTestGateRetry < executionPolicy.HighImpactBuildTestGateMaxRetries)
{
runState.HighImpactBuildTestGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
@@ -97,15 +101,15 @@ public partial class AgentLoopService
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:HighImpactBuildTestGate] 怨좎쁺??肄붾뱶 蹂€寃쎌엯?덈떎. " +
"醫낅즺?섏? 留먭퀬 build_run怨?test_loop瑜?紐⑤몢 ?ㅽ뻾???깃났 洹쇨굅瑜??뺣낫???ㅼ뿉留?留덈Т由ы븯?몄슂."
Content = "[System:HighImpactBuildTestGate] 핵심 코드 변경입니다. 종료하지 말고 build_run과 test_loop를 모두 실행해 성공 근거를 확보한 뒤에만 마무리하세요."
});
EmitEvent(AgentEventType.Thinking, "", "怨좎쁺??蹂€寃쎌씠??build+test ?깃났 洹쇨굅瑜?紐⑤몢 ?뺣낫???뚭퉴吏€ 吏꾪뻾?⑸땲??..");
EmitEvent(AgentEventType.Thinking, "", "핵심 변경이라 build+test 성공 근거를 모두 확보할 때까지 진행합니다...");
return true;
}
if (!HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
&& runState.FinalReportGateRetry < 1)
if (executionPolicy.FinalReportGateMaxRetries > 0
&& !HasSufficientFinalReportEvidence(textResponse, taskPolicy, requireHighImpactCodeVerification, messages)
&& runState.FinalReportGateRetry < executionPolicy.FinalReportGateMaxRetries)
{
runState.FinalReportGateRetry++;
if (!string.IsNullOrEmpty(textResponse))
@@ -115,7 +119,7 @@ public partial class AgentLoopService
Role = "user",
Content = BuildFinalReportQualityPrompt(taskPolicy, requireHighImpactCodeVerification)
});
EmitEvent(AgentEventType.Thinking, "", "理쒖쥌 蹂닿퀬??蹂€寃?寃€利?由ъ뒪???붿빟??遺€議깊빐 ??踰????뺣━?⑸땲??..");
EmitEvent(AgentEventType.Thinking, "", "최종 보고에 변경·검증·리스크 요약이 부족해 한 번 더 정리합니다...");
return true;
}
@@ -125,12 +129,13 @@ public partial class AgentLoopService
private bool TryApplyCodeDiffEvidenceGateTransition(
List<ChatMessage> messages,
string? textResponse,
RunState runState)
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (runState.CodeDiffGateRetry >= 1)
if (executionPolicy.CodeDiffGateMaxRetries <= 0 || runState.CodeDiffGateRetry >= executionPolicy.CodeDiffGateMaxRetries)
return false;
if (HasDiffEvidenceAfterLastModification(messages))
@@ -154,12 +159,13 @@ public partial class AgentLoopService
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState)
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (runState.RecentExecutionGateRetry >= 1)
if (executionPolicy.RecentExecutionGateMaxRetries <= 0 || runState.RecentExecutionGateRetry >= executionPolicy.RecentExecutionGateMaxRetries)
return false;
if (!HasAnyBuildOrTestEvidence(messages))
@@ -184,12 +190,13 @@ public partial class AgentLoopService
List<ChatMessage> messages,
string? textResponse,
TaskTypePolicy taskPolicy,
RunState runState)
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (runState.ExecutionSuccessGateRetry >= 1)
if (executionPolicy.ExecutionSuccessGateMaxRetries <= 0 || runState.ExecutionSuccessGateRetry >= executionPolicy.ExecutionSuccessGateMaxRetries)
return false;
if (!HasAnyBuildOrTestAttempt(messages))
@@ -217,7 +224,8 @@ public partial class AgentLoopService
string userQuery,
int totalToolCalls,
string? lastArtifactFilePath,
RunState runState)
RunState runState,
int retryMax)
{
if (totalToolCalls <= 0)
return false;
@@ -225,7 +233,6 @@ public partial class AgentLoopService
if (ShouldSkipTerminalEvidenceGateForAnalysisQuery(userQuery, taskPolicy))
return false;
var retryMax = GetTerminalEvidenceGateMaxRetries();
if (runState.TerminalEvidenceGateRetry >= retryMax)
return false;
@@ -426,9 +433,18 @@ public partial class AgentLoopService
string? textResponse,
TaskTypePolicy taskPolicy,
string? lastArtifactFilePath,
RunState runState)
RunState runState,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy)
{
if (!ShouldRequestDocumentVerification(taskPolicy.TaskType, lastArtifactFilePath, messages, runState.DocumentVerificationGateRetry))
if (!executionPolicy.EnableDocumentVerificationGate)
return false;
if (!ShouldRequestDocumentVerification(
taskPolicy.TaskType,
lastArtifactFilePath,
messages,
runState.DocumentVerificationGateRetry,
executionPolicy.DocumentVerificationGateMaxRetries))
return false;
runState.DocumentVerificationGateRetry++;
@@ -437,13 +453,13 @@ public partial class AgentLoopService
messages.Add(new ChatMessage
{
Role = "user",
Content = "[System:DocumentVerificationGate] 臾몄꽌 ?뚯씪?€ ?앹꽦?섏뿀吏€留?寃€利?洹쇨굅媛€ 遺€議깊빀?덈떎. " +
"file_read ?먮뒗 document_read濡?諛⑷툑 ?앹꽦???뚯씪???ㅼ떆 ?쎄퀬, 臾몄젣 ?놁쓬/?섏젙 ?꾩슂瑜?紐낆떆??蹂닿퀬?섏꽭??"
Content = "[System:DocumentVerificationGate] 문서 파일은 생성됐지만 검증 근거가 부족합니다. " +
"file_read 또는 document_read로 방금 생성한 파일을 다시 확인하고, 문제 없음/수정 필요 여부를 명시해 보고하세요."
});
EmitEvent(
AgentEventType.Thinking,
"",
$"臾몄꽌 寃€利?洹쇨굅媛€ 遺€議깊빐 ?ш?利앹쓣 ?붿껌?⑸땲??({runState.DocumentVerificationGateRetry}/1)");
$"문서 검증 근거가 부족해 결과 검증을 요청합니다 ({runState.DocumentVerificationGateRetry}/{Math.Max(1, executionPolicy.DocumentVerificationGateMaxRetries)})");
return true;
}
@@ -461,12 +477,14 @@ public partial class AgentLoopService
string taskType,
string? lastArtifactFilePath,
List<ChatMessage> messages,
int verificationGateRetry)
int verificationGateRetry,
int maxRetries)
{
return string.Equals(taskType, "docs", StringComparison.OrdinalIgnoreCase)
&& HasMaterializedArtifact(lastArtifactFilePath)
&& !HasDocumentVerificationEvidenceAfterLastArtifact(messages)
&& verificationGateRetry < 1;
&& maxRetries > 0
&& verificationGateRetry < maxRetries;
}
private static bool HasDocumentVerificationEvidenceAfterLastArtifact(List<ChatMessage> messages)
@@ -758,9 +776,9 @@ public partial class AgentLoopService
{
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
{
var fileHint = string.IsNullOrWhiteSpace(documentPlanPath) ? "寃곌낵 臾몄꽌 ?뚯씪" : $"'{documentPlanPath}'";
return "[System:NoProgressExecutionRecovery] ?쎄린/遺꾩꽍留?諛섎났?섍퀬 ?덉뒿?덈떎. " +
$"?댁젣 ?ㅻ챸??硫덉텛怨?{fileHint}???ㅼ젣濡??앹꽦?섎뒗 ?꾧뎄(html_create/markdown_create/document_assemble/file_write)瑜?利됱떆 ?몄텧?섏꽭??";
var fileHint = string.IsNullOrWhiteSpace(documentPlanPath) ? "결과 문서 파일" : $"'{documentPlanPath}'";
return "[System:NoProgressExecutionRecovery] 계획/분석만 반복되고 있습니다. " +
$"지금 즉시 설명을 멈추고 {fileHint}을(를) 실제로 생성하는 도구(html_create/markdown_create/document_assemble/file_write)를 호출하세요.";
}
return BuildFailureNextToolPriorityPrompt(
@@ -961,9 +979,9 @@ public partial class AgentLoopService
{
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
{
return "[System:NoProgressRecovery] ?숈씪???쎄린 ?몄텧??諛섎났?섏뿀?듬땲?? " +
"?댁젣 臾몄꽌 ?앹꽦/?섏젙 ?꾧뎄(html_create/markdown_create/document_assemble/file_write) 以??섎굹瑜??ㅼ젣濡??몄텧??吏꾪뻾?섏꽭?? " +
"?ㅻ챸留??섏? 留먭퀬 利됱떆 ?꾧뎄瑜??몄텧?섏꽭??";
return "[System:NoProgressRecovery] 동일한 읽기 도구 호출이 반복되고 있습니다. " +
"지금 즉시 문서 생성/수정 도구(html_create/markdown_create/document_assemble/file_write) 중 하나를 호출하세요. " +
"설명 없이 바로 도구를 호출하세요. 여러 도구를 한 번에 호출할 수 있습니다.";
}
return BuildFailureNextToolPriorityPrompt(
@@ -978,6 +996,8 @@ public partial class AgentLoopService
RunState runState,
CancellationToken ct)
{
// 사용자 취소는 재시도하지 않음
if (ct.IsCancellationRequested) return false;
if (!IsTransientLlmError(ex) || runState.TransientLlmErrorRetries >= 3)
return false;
@@ -986,7 +1006,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"?쇱떆??LLM ?ㅻ쪟濡??ъ떆?꾪빀?덈떎 ({runState.TransientLlmErrorRetries}/3, {delayMs}ms ?€湲?");
$"일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({runState.TransientLlmErrorRetries}/3)");
await Task.Delay(delayMs, ct);
return true;
}
@@ -1092,7 +1112,8 @@ public partial class AgentLoopService
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct,
string phaseLabel,
RunState? runState = null)
RunState? runState = null,
bool forceToolCall = false)
{
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
@@ -1100,7 +1121,7 @@ public partial class AgentLoopService
{
try
{
return await _llm.SendWithToolsAsync(messages, tools, ct);
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall);
}
catch (Exception ex)
{
@@ -1114,10 +1135,13 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"{phaseLabel}: 而⑦뀓?ㅽ듃 珥덇낵瑜?媛먯???蹂듦뎄 ???ъ떆?꾪빀?덈떎 ({contextRecoveryRetries}/2)");
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
continue;
}
// 사용자 취소(ct)인 경우 재시도하지 않고 즉시 전파
if (ct.IsCancellationRequested) throw;
if (IsTransientLlmError(ex) && transientRetries < 3)
{
transientRetries++;
@@ -1127,7 +1151,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"{phaseLabel}: ?쇱떆??LLM ?ㅻ쪟濡??ъ떆?꾪빀?덈떎 ({transientRetries}/3, {delayMs}ms ?€湲?");
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
await Task.Delay(delayMs, ct);
continue;
}
@@ -1444,13 +1468,15 @@ public partial class AgentLoopService
List<LlmService.ContentBlock> toolCalls,
List<ChatMessage> messages,
Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct)
{
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
return (false, false);
var verificationEnabled = AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
ActiveTab,
call.ToolName,
@@ -1464,7 +1490,7 @@ public partial class AgentLoopService
consumedExtraIteration = true;
}
EmitEvent(AgentEventType.Complete, "", "?먯씠?꾪듃 ?묒뾽 ?꾨즺");
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (true, consumedExtraIteration);
}
@@ -1473,13 +1499,15 @@ public partial class AgentLoopService
ToolResult result,
List<ChatMessage> messages,
Models.LlmSettings llm,
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct)
{
if (!result.Success)
return false;
var verificationEnabled = AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
ActiveTab,
call.ToolName,
@@ -1501,19 +1529,19 @@ public partial class AgentLoopService
if (context.DevModeStepApproval && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
$"[DEV] ?꾧뎄 '{call.ToolName}' ?ㅽ뻾???뱀씤?섏떆寃좎뒿?덇퉴?\n{FormatToolCallSummary(call)}",
new List<string> { "?뱀씤", "嫄대꼫?곌린", "以묐떒" });
$"[DEV] 도구 '{call.ToolName}' 실행을 확인하시겠습니까?\n{FormatToolCallSummary(call)}",
new List<string> { "확인", "건너뛰기", "중단" });
var (devShouldContinue, devTerminalResponse, devToolResultMessage) =
EvaluateDevStepDecision(decision);
if (!string.IsNullOrEmpty(devTerminalResponse))
{
EmitEvent(AgentEventType.Complete, "", "[DEV] ?ъ슜?먭? ?ㅽ뻾??以묐떒?덉뒿?덈떎");
EmitEvent(AgentEventType.Complete, "", "[DEV] 사용자가 실행을 중단했습니다");
return (false, devTerminalResponse);
}
if (devShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] ?ъ슜?먭? ???꾧뎄 ?ㅽ뻾??嫄대꼫?곗뿀?듬땲??"));
call.ToolId, call.ToolName, devToolResultMessage ?? "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
return (true, null);
}
}
@@ -1523,18 +1551,18 @@ public partial class AgentLoopService
{
var decision = await UserDecisionCallback(
decisionRequired,
new List<string> { "?뱀씤", "嫄대꼫?곌린", "痍⑥냼" });
new List<string> { "확인", "건너뛰기", "취소" });
var (scopeShouldContinue, scopeTerminalResponse, scopeToolResultMessage) =
EvaluateScopeDecision(decision);
if (!string.IsNullOrEmpty(scopeTerminalResponse))
{
EmitEvent(AgentEventType.Complete, "", "?ъ슜?먭? ?묒뾽??痍⑥냼?덉뒿?덈떎");
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
return (false, scopeTerminalResponse);
}
if (scopeShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] ?ъ슜?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??"));
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
return (true, null);
}
}

View File

@@ -5,12 +5,19 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static (bool ShouldRun, List<LlmService.ContentBlock> ParallelBatch, List<LlmService.ContentBlock> SequentialBatch)
CreateParallelExecutionPlan(bool parallelEnabled, List<LlmService.ContentBlock> toolCalls)
CreateParallelExecutionPlan(bool parallelEnabled, List<LlmService.ContentBlock> toolCalls, int maxParallelBatch)
{
if (!parallelEnabled || toolCalls.Count <= 1)
return (false, new List<LlmService.ContentBlock>(), toolCalls);
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
if (maxParallelBatch > 0 && parallelBatch.Count > maxParallelBatch)
{
var overflow = parallelBatch.Skip(maxParallelBatch).ToList();
parallelBatch = parallelBatch.Take(maxParallelBatch).ToList();
sequentialBatch = overflow.Concat(sequentialBatch).ToList();
}
return (true, parallelBatch, sequentialBatch);
}

View File

@@ -147,7 +147,19 @@ public sealed class AxAgentExecutionEngine
if (session != null)
{
session.AppendMessage(tab, assistant, storage);
// session.CurrentConversation이 전달된 conversation과 다른 경우 (새 대화 시작 등),
// session을 통하지 않고 conversation에 직접 추가하여 새 대화가 오염되지 않도록 함.
if (session.CurrentConversation == null ||
string.Equals(session.CurrentConversation.Id, conversation.Id, StringComparison.Ordinal))
{
session.AppendMessage(tab, assistant, storage);
}
else
{
conversation.Messages.Add(assistant);
conversation.UpdatedAt = DateTime.Now;
try { storage?.Save(conversation); } catch { }
}
return assistant;
}
@@ -197,23 +209,7 @@ public sealed class AxAgentExecutionEngine
if (!string.IsNullOrWhiteSpace(content))
return content;
var latestEventSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(latestEventSummary))
{
return runTab switch
{
"Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}",
"Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}",
_ => latestEventSummary,
};
}
return "(빈 응답)";
return BuildFallbackCompletionMessage(conversation, runTab);
}
public FinalizedContent FinalizeExecutionContent(string? currentContent, Exception? error = null, bool cancelled = false)
@@ -276,23 +272,59 @@ public sealed class AxAgentExecutionEngine
if (!string.IsNullOrWhiteSpace(content))
return content;
var latestEventSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary))
return BuildFallbackCompletionMessage(conversation, runTab);
}
/// <summary>
/// LLM 응답이 비어있을 때 실행 이벤트에서 의미 있는 완료 메시지를 구성합니다.
/// UserPromptSubmit/Paused/Resumed 같은 내부 운영 이벤트는 제외합니다.
/// </summary>
private static string BuildFallbackCompletionMessage(ChatConversation conversation, string runTab)
{
static bool IsSignificantEventType(string t)
=> !string.Equals(t, "UserPromptSubmit", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "Resumed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t, "SessionStart", StringComparison.OrdinalIgnoreCase);
var completionLine = runTab switch
{
"Cowork" => "코워크 작업이 완료되었습니다.",
"Code" => "코드 작업이 완료되었습니다.",
_ => null,
};
// 파일 경로가 있는 이벤트를 최우선으로 — 산출물 파일을 명시적으로 표시
var artifactEvent = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.FilePath) && IsSignificantEventType(evt.Type))
.OrderByDescending(evt => evt.Timestamp)
.FirstOrDefault();
if (artifactEvent != null)
{
var fileLine = string.IsNullOrWhiteSpace(artifactEvent.Summary)
? $"생성된 파일: {artifactEvent.FilePath}"
: $"{artifactEvent.Summary}\n경로: {artifactEvent.FilePath}";
return completionLine != null
? $"{completionLine}\n\n{fileLine}"
: fileLine;
}
// 파일 없으면 가장 최근 의미 있는 이벤트 요약 사용
var latestSummary = conversation.ExecutionEvents?
.Where(evt => !string.IsNullOrWhiteSpace(evt.Summary) && IsSignificantEventType(evt.Type))
.OrderByDescending(evt => evt.Timestamp)
.Select(evt => evt.Summary.Trim())
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(latestEventSummary))
if (!string.IsNullOrWhiteSpace(latestSummary))
{
return runTab switch
{
"Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}",
"Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}",
_ => latestEventSummary,
};
return completionLine != null
? $"{completionLine}\n\n{latestSummary}"
: latestSummary;
}
return "(빈 응답)";
return completionLine ?? "(빈 응답)";
}
private static ChatMessage CloneMessage(ChatMessage source)

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
@@ -6,14 +6,14 @@ namespace AxCopilot.Services.Agent;
/// <summary>
/// CSS/SVG 기반 차트를 HTML 파일로 생성하는 스킬.
/// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
/// bar, line, pie(donut), radar, area, scatter, heatmap, gauge 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
/// </summary>
public class ChartSkill : IAgentTool
{
public string Name => "chart_create";
public string Description =>
"Create a styled HTML chart document with CSS/SVG-based charts. " +
"Supports chart types: bar, horizontal_bar, stacked_bar, line, area, pie, donut, radar, progress, comparison. " +
"Supports chart types: bar, horizontal_bar, stacked_bar, line, area, pie, donut, radar, progress, comparison, scatter, heatmap, gauge. " +
"Multiple charts can be placed in one document using the 'charts' array. " +
"Applies design mood from TemplateService (modern, professional, creative, etc.).";
@@ -22,22 +22,29 @@ public class ChartSkill : IAgentTool
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title" },
["title"] = new() { Type = "string", Description = "Document title." },
["charts"] = new()
{
Type = "array",
Description = "Array of chart objects. Each chart: " +
"{\"type\": \"bar|horizontal_bar|stacked_bar|line|area|pie|donut|radar|progress|comparison\", " +
"{\"type\": \"bar|horizontal_bar|stacked_bar|line|area|pie|donut|radar|progress|comparison|scatter|heatmap|gauge\", " +
"\"title\": \"Chart Title\", " +
"\"labels\": [\"A\",\"B\",\"C\"], " +
"\"datasets\": [{\"name\": \"Series1\", \"values\": [10,20,30], \"color\": \"#4B5EFC\"}], " +
"\"unit\": \"%\"}",
"\"unit\": \"%\", " +
"\"accent_color\": \"#4B5EFC\", " +
"\"show_values\": true}. " +
"For scatter: datasets[].points=[{x,y}]. " +
"For heatmap: y_labels, values (2D array), color_from, color_to. " +
"For gauge: value, min, max, thresholds=[{at, color}].",
Items = new ToolProperty { Type = "object" },
},
["mood"] = new() { Type = "string", Description = "Design mood: modern, professional, creative, dark, dashboard, etc. Default: dashboard" },
["layout"] = new() { Type = "string", Description = "Chart layout: 'single' (one per row) or 'grid' (2-column grid). Default: single" },
["mood"] = new() { Type = "string", Description = "Design mood: modern, professional, creative, dark, dashboard, etc. Default: dashboard." },
["layout"] = new() { Type = "string", Description = "Chart layout: 'single' (one per row) or 'grid' (2-column grid). Default: single." },
["accent_color"] = new() { Type = "string", Description = "Global accent color override for single-color charts." },
["show_values"] = new() { Type = "boolean", Description = "Show value labels on bars/points globally. Default: true." },
},
Required = ["path", "title", "charts"]
Required = ["title", "charts"]
};
// 기본 차트 팔레트
@@ -49,10 +56,25 @@ public class ChartSkill : IAgentTool
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "chart.html";
var title = args.GetProperty("title").GetString() ?? "Chart";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
{
path = pathEl.GetString()!;
}
else
{
var safe = System.Text.RegularExpressions.Regex.Replace(title, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "chart" : safe) + ".html";
}
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
var globalAccent = args.TryGetProperty("accent_color", out var ga) ? ga.GetString() : null;
var globalShowValues = true;
if (args.TryGetProperty("show_values", out var sv))
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -81,8 +103,8 @@ public class ChartSkill : IAgentTool
int chartIdx = 0;
foreach (var chartEl in chartsEl.EnumerateArray())
{
var chartHtml = RenderChart(chartEl, chartIdx);
body.AppendLine("<div class=\"card\" style=\"margin-bottom:20px;\">");
var chartHtml = RenderChart(chartEl, chartIdx, globalAccent, globalShowValues);
body.AppendLine("<div class=\"card\" style=\"margin-bottom:24px;\">");
body.AppendLine(chartHtml);
body.AppendLine("</div>");
chartIdx++;
@@ -114,7 +136,7 @@ public class ChartSkill : IAgentTool
return ToolResult.Ok($"차트 문서 생성 완료: {fullPath} ({chartCount}개 차트)", fullPath);
}
private string RenderChart(JsonElement chart, int idx)
private string RenderChart(JsonElement chart, int idx, string? globalAccent, bool globalShowValues)
{
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
@@ -122,17 +144,27 @@ public class ChartSkill : IAgentTool
var labels = ParseStringArray(chart, "labels");
var datasets = ParseDatasets(chart);
// Per-chart overrides
var accentColor = chart.TryGetProperty("accent_color", out var ac) ? ac.GetString() : globalAccent;
var showValues = globalShowValues;
if (chart.TryGetProperty("show_values", out var sv))
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
// Apply accent color to first dataset if single-color context
if (!string.IsNullOrEmpty(accentColor) && datasets.Count > 0 && datasets[0].Color == Palette[0])
datasets[0] = datasets[0] with { Color = accentColor };
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(chartTitle))
sb.AppendLine($"<h3 style=\"margin-bottom:12px;\">{Escape(chartTitle)}</h3>");
sb.AppendLine($"<h3 class=\"chart-title\">{Escape(chartTitle)}</h3>");
switch (type)
{
case "bar":
sb.Append(RenderBarChart(labels, datasets, unit, false));
sb.Append(RenderBarChart(labels, datasets, unit, false, showValues));
break;
case "horizontal_bar":
sb.Append(RenderBarChart(labels, datasets, unit, true));
sb.Append(RenderBarChart(labels, datasets, unit, true, showValues));
break;
case "stacked_bar":
sb.Append(RenderStackedBar(labels, datasets, unit));
@@ -154,8 +186,17 @@ public class ChartSkill : IAgentTool
case "radar":
sb.Append(RenderRadarChart(labels, datasets));
break;
case "scatter":
sb.Append(RenderScatterChart(chart, datasets, unit));
break;
case "heatmap":
sb.Append(RenderHeatmap(chart, labels));
break;
case "gauge":
sb.Append(RenderGauge(chart, unit));
break;
default:
sb.Append(RenderBarChart(labels, datasets, unit, false));
sb.Append(RenderBarChart(labels, datasets, unit, false, showValues));
break;
}
@@ -173,7 +214,7 @@ public class ChartSkill : IAgentTool
// ─── Bar Chart ───────────────────────────────────────────────────────
private static string RenderBarChart(List<string> labels, List<Dataset> datasets, string unit, bool horizontal)
private static string RenderBarChart(List<string> labels, List<Dataset> datasets, string unit, bool horizontal, bool showValues)
{
var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max();
if (maxVal <= 0) maxVal = 1;
@@ -204,7 +245,8 @@ public class ChartSkill : IAgentTool
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
var pct = (int)(val / maxVal * 100);
sb.AppendLine($"<div class=\"vbar-bar\" style=\"height:{pct}%;background:{ds.Color};\" title=\"{val:G}{unit}\"></div>");
var valLabel = showValues ? $"<span class=\"vbar-value-label\">{val:G}{unit}</span>" : "";
sb.AppendLine($"<div class=\"vbar-bar-wrap\" style=\"height:{pct}%;\">{valLabel}<div class=\"vbar-bar\" style=\"background:{ds.Color};\" title=\"{val:G}{unit}\"></div></div>");
}
sb.AppendLine($"<div class=\"vbar-label\">{Escape(labels[i])}</div>");
sb.AppendLine("</div>");
@@ -238,7 +280,7 @@ public class ChartSkill : IAgentTool
return sb.ToString();
}
// ─── Line / Area Chart (SVG) ─────────────────────────────────────────
// ─── Line / Area Chart (SVG, smooth bezier) ──────────────────────────
private static string RenderLineChart(List<string> labels, List<Dataset> datasets, string unit, bool isArea)
{
@@ -253,9 +295,10 @@ public class ChartSkill : IAgentTool
var n = labels.Count;
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\">");
sb.AppendLine($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\" font-family=\"system-ui, -apple-system, sans-serif\" role=\"img\">");
sb.AppendLine($"<title>{Escape(isArea ? "Area Chart" : "Line Chart")}</title>");
// Y축 그리드
// Y-axis grid
for (int i = 0; i <= 4; i++)
{
var y = padT + chartH - (chartH * i / 4.0);
@@ -264,14 +307,14 @@ public class ChartSkill : IAgentTool
sb.AppendLine($"<text x=\"{padL - 8}\" y=\"{y + 4:F0}\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">{val:G3}{unit}</text>");
}
// X축 라벨
// X-axis labels
for (int i = 0; i < n; i++)
{
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
sb.AppendLine($"<text x=\"{x:F0}\" y=\"{h - 8}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터셋
// Datasets with smooth cubic bezier
foreach (var ds in datasets)
{
var points = new List<(double x, double y)>();
@@ -282,17 +325,18 @@ public class ChartSkill : IAgentTool
points.Add((x, y));
}
var pathData = string.Join(" ", points.Select((p, i) => $"{(i == 0 ? "M" : "L")}{p.x:F1},{p.y:F1}"));
if (points.Count == 0) continue;
var pathData = BuildSmoothPath(points);
if (isArea && points.Count > 1)
{
var areaPath = pathData + $" L{points.Last().x:F1},{padT + chartH} L{points.First().x:F1},{padT + chartH} Z";
var areaPath = pathData + $" L{points.Last().x:F1},{padT + chartH:F0} L{points.First().x:F1},{padT + chartH:F0} Z";
sb.AppendLine($"<path d=\"{areaPath}\" fill=\"{ds.Color}\" opacity=\"0.15\"/>");
}
sb.AppendLine($"<path d=\"{pathData}\" fill=\"none\" stroke=\"{ds.Color}\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
// 데이터 포인트
foreach (var (px, py) in points)
sb.AppendLine($"<circle cx=\"{px:F1}\" cy=\"{py:F1}\" r=\"4\" fill=\"{ds.Color}\" stroke=\"white\" stroke-width=\"2\"/>");
}
@@ -301,6 +345,22 @@ public class ChartSkill : IAgentTool
return sb.ToString();
}
private static string BuildSmoothPath(List<(double x, double y)> pts)
{
if (pts.Count == 1) return $"M{pts[0].x:F1},{pts[0].y:F1}";
var sb = new StringBuilder();
sb.Append($"M{pts[0].x:F1},{pts[0].y:F1}");
for (int i = 1; i < pts.Count; i++)
{
var prev = pts[i - 1];
var curr = pts[i];
var cpTension = 0.35;
var dx = (curr.x - prev.x) * cpTension;
sb.Append($" C{prev.x + dx:F1},{prev.y:F1} {curr.x - dx:F1},{curr.y:F1} {curr.x:F1},{curr.y:F1}");
}
return sb.ToString();
}
// ─── Pie / Donut Chart (SVG) ─────────────────────────────────────────
private static string RenderPieChart(List<string> labels, List<Dataset> datasets, bool isDonut)
@@ -311,39 +371,54 @@ public class ChartSkill : IAgentTool
int cx = 150, cy = 150, r = 120;
var sb = new StringBuilder();
sb.AppendLine($"<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\">");
sb.AppendLine("<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\" font-family=\"system-ui, -apple-system, sans-serif\" role=\"img\">");
sb.AppendLine($"<title>{Escape(isDonut ? "Donut Chart" : "Pie Chart")}</title>");
double startAngle = -90;
var slices = new List<(double start, double end, string color, string label, double pct)>();
for (int i = 0; i < Math.Min(values.Count, labels.Count); i++)
{
var pct = values[i] / total;
var angle = pct * 360;
var endAngle = startAngle + angle;
var x1 = cx + r * Math.Cos(startAngle * Math.PI / 180);
var y1 = cy + r * Math.Sin(startAngle * Math.PI / 180);
var x2 = cx + r * Math.Cos(endAngle * Math.PI / 180);
var y2 = cy + r * Math.Sin(endAngle * Math.PI / 180);
var largeArc = angle > 180 ? 1 : 0;
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
sb.AppendLine($"<path d=\"M{cx},{cy} L{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1} Z\" fill=\"{color}\"/>");
slices.Add((startAngle, endAngle, color, labels[i], pct * 100));
startAngle = endAngle;
}
foreach (var (start, end, color, label, pct) in slices)
{
var x1 = cx + r * Math.Cos(start * Math.PI / 180);
var y1 = cy + r * Math.Sin(start * Math.PI / 180);
var x2 = cx + r * Math.Cos(end * Math.PI / 180);
var y2 = cy + r * Math.Sin(end * Math.PI / 180);
var largeArc = (end - start) > 180 ? 1 : 0;
sb.AppendLine($"<path d=\"M{cx},{cy} L{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1} Z\" fill=\"{color}\"><title>{Escape(label)}: {pct:F1}%</title></path>");
// Percentage label inside slice (skip tiny slices)
if (pct >= 8)
{
var midAngle = (start + end) / 2 * Math.PI / 180;
var lr = isDonut ? r * 0.78 : r * 0.65;
var lx = cx + lr * Math.Cos(midAngle);
var ly = cy + lr * Math.Sin(midAngle);
sb.AppendLine($"<text x=\"{lx:F1}\" y=\"{ly + 4:F1}\" text-anchor=\"middle\" fill=\"white\" font-size=\"11\" font-weight=\"600\">{pct:F0}%</text>");
}
}
if (isDonut)
sb.AppendLine($"<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r * 0.55}\" fill=\"white\"/>");
sb.AppendLine($"<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r * 0.55:F0}\" fill=\"white\"/>");
sb.AppendLine("</svg>");
// 범례
// Legend
sb.AppendLine("<div class=\"pie-legend\">");
for (int i = 0; i < Math.Min(values.Count, labels.Count); i++)
for (int i = 0; i < slices.Count; i++)
{
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
var pct = values[i] / total * 100;
sb.AppendLine($"<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:{color}\"></span>{Escape(labels[i])} <span style=\"color:#6B7280;font-size:12px;\">({pct:F1}%)</span></div>");
var (_, _, color, label, pct) = slices[i];
sb.AppendLine($"<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:{color}\"></span>{Escape(label)} <span style=\"color:#6B7280;font-size:12px;\">({pct:F1}%)</span></div>");
}
sb.AppendLine("</div></div>");
@@ -401,9 +476,10 @@ public class ChartSkill : IAgentTool
if (n < 3) return "<p>레이더 차트는 최소 3개 항목이 필요합니다.</p>";
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\">");
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\" font-family=\"system-ui, -apple-system, sans-serif\" role=\"img\">");
sb.AppendLine("<title>Radar Chart</title>");
// 그리드
// Grid
for (int level = 1; level <= 4; level++)
{
var lr = r * level / 4.0;
@@ -415,19 +491,19 @@ public class ChartSkill : IAgentTool
sb.AppendLine($"<polygon points=\"{points}\" fill=\"none\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
}
// 축선 + 라벨
// Axes + labels
for (int i = 0; i < n; i++)
{
var angle = (360.0 / n * i - 90) * Math.PI / 180;
var x = cx + r * Math.Cos(angle);
var y = cy + r * Math.Sin(angle);
sb.AppendLine($"<line x1=\"{cx}\" y1=\"{cy}\" x2=\"{x:F1}\" y2=\"{y:F1}\" stroke=\"#D1D5DB\" stroke-width=\"1\"/>");
var lx = cx + (r + 16) * Math.Cos(angle);
var ly = cy + (r + 16) * Math.Sin(angle);
var lx = cx + (r + 18) * Math.Cos(angle);
var ly = cy + (r + 18) * Math.Sin(angle);
sb.AppendLine($"<text x=\"{lx:F0}\" y=\"{ly + 4:F0}\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터
// Data
var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max();
if (maxVal <= 0) maxVal = 1;
foreach (var ds in datasets)
@@ -446,8 +522,236 @@ public class ChartSkill : IAgentTool
return sb.ToString();
}
// ─── Scatter Chart (SVG) ─────────────────────────────────────────────
private static string RenderScatterChart(JsonElement chart, List<Dataset> datasets, string unit)
{
// Gather all points across all datasets to determine axis bounds
var allX = new List<double>();
var allY = new List<double>();
var dsPoints = new List<(Dataset ds, List<(double x, double y)> pts)>();
foreach (var ds in datasets)
{
var pts = new List<(double x, double y)>();
if (chart.TryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
{
foreach (var dEl in dsArr.EnumerateArray())
{
var dName = dEl.TryGetProperty("name", out var nn) ? nn.GetString() : null;
if (dName != ds.Name) continue;
if (dEl.TryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
{
foreach (var pEl in ptsEl.EnumerateArray())
{
if (pEl.TryGetProperty("x", out var px) && pEl.TryGetProperty("y", out var py)
&& px.TryGetDouble(out var xd) && py.TryGetDouble(out var yd))
pts.Add((xd, yd));
}
}
}
}
dsPoints.Add((ds, pts));
allX.AddRange(pts.Select(p => p.x));
allY.AddRange(pts.Select(p => p.y));
}
if (allX.Count == 0) return "<p>Scatter chart requires datasets with points [{x, y}].</p>";
int w = 600, h = 320, padL = 55, padR = 20, padT = 20, padB = 40;
var chartW = w - padL - padR;
var chartH = h - padT - padB;
var minX = allX.Min(); var maxX = allX.Max();
var minY = allY.Min(); var maxY = allY.Max();
if (maxX <= minX) maxX = minX + 1;
if (maxY <= minY) maxY = minY + 1;
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\" font-family=\"system-ui, -apple-system, sans-serif\" role=\"img\">");
sb.AppendLine("<title>Scatter Plot</title>");
// Grid
for (int i = 0; i <= 4; i++)
{
var gy = padT + chartH - chartH * i / 4.0;
var gVal = minY + (maxY - minY) * i / 4.0;
sb.AppendLine($"<line x1=\"{padL}\" y1=\"{gy:F0}\" x2=\"{w - padR}\" y2=\"{gy:F0}\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
sb.AppendLine($"<text x=\"{padL - 8}\" y=\"{gy + 4:F0}\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">{gVal:G3}{unit}</text>");
var gx = padL + chartW * i / 4.0;
var gxVal = minX + (maxX - minX) * i / 4.0;
sb.AppendLine($"<line x1=\"{gx:F0}\" y1=\"{padT}\" x2=\"{gx:F0}\" y2=\"{padT + chartH}\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
sb.AppendLine($"<text x=\"{gx:F0}\" y=\"{h - 8}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">{gxVal:G3}</text>");
}
// Axis lines
sb.AppendLine($"<line x1=\"{padL}\" y1=\"{padT + chartH}\" x2=\"{w - padR}\" y2=\"{padT + chartH}\" stroke=\"#9CA3AF\" stroke-width=\"1.5\"/>");
sb.AppendLine($"<line x1=\"{padL}\" y1=\"{padT}\" x2=\"{padL}\" y2=\"{padT + chartH}\" stroke=\"#9CA3AF\" stroke-width=\"1.5\"/>");
// Points
foreach (var (ds, pts) in dsPoints)
{
foreach (var (px, py) in pts)
{
var cx = padL + (px - minX) / (maxX - minX) * chartW;
var cy = padT + chartH - (py - minY) / (maxY - minY) * chartH;
sb.AppendLine($"<circle cx=\"{cx:F1}\" cy=\"{cy:F1}\" r=\"5\" fill=\"{ds.Color}\" fill-opacity=\"0.75\" stroke=\"{ds.Color}\" stroke-width=\"1.5\"><title>{ds.Name}: ({px:G}, {py:G})</title></circle>");
}
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Heatmap (SVG) ───────────────────────────────────────────────────
private static string RenderHeatmap(JsonElement chart, List<string> xLabels)
{
var yLabels = ParseStringArray(chart, "y_labels");
var colorFrom = chart.TryGetProperty("color_from", out var cf) ? cf.GetString() ?? "#EFF6FF" : "#EFF6FF";
var colorTo = chart.TryGetProperty("color_to", out var ct2) ? ct2.GetString() ?? "#1D4ED8" : "#1D4ED8";
// Parse 2D values array
var grid = new List<List<double>>();
if (chart.TryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
{
foreach (var row in valsEl.EnumerateArray())
{
var r = new List<double>();
if (row.ValueKind == JsonValueKind.Array)
foreach (var cell in row.EnumerateArray())
r.Add(cell.TryGetDouble(out var dv) ? dv : 0);
grid.Add(r);
}
}
if (grid.Count == 0 || xLabels.Count == 0 || yLabels.Count == 0)
return "<p>Heatmap requires labels, y_labels, and a 2D values array.</p>";
var allCells = grid.SelectMany(r => r).ToList();
var minV = allCells.Min();
var maxV = allCells.Max();
if (maxV <= minV) maxV = minV + 1;
int cellW = 60, cellH = 36, labelW = 80, labelH = 28, padTop = labelH + 8;
int svgW = labelW + xLabels.Count * cellW + 20;
int svgH = padTop + yLabels.Count * cellH + 10;
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 {svgW} {svgH}\" style=\"max-width:100%;height:auto;\" font-family=\"system-ui, -apple-system, sans-serif\" role=\"img\">");
sb.AppendLine("<title>Heatmap</title>");
// X labels
for (int xi = 0; xi < xLabels.Count; xi++)
{
var lx = labelW + xi * cellW + cellW / 2;
sb.AppendLine($"<text x=\"{lx}\" y=\"{labelH - 4}\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\" font-weight=\"600\">{Escape(xLabels[xi])}</text>");
}
// Rows
for (int yi = 0; yi < Math.Min(yLabels.Count, grid.Count); yi++)
{
var row = grid[yi];
var ry = padTop + yi * cellH;
// Y label
sb.AppendLine($"<text x=\"{labelW - 6}\" y=\"{ry + cellH / 2 + 4}\" text-anchor=\"end\" fill=\"#374151\" font-size=\"11\">{Escape(yLabels[yi])}</text>");
for (int xi = 0; xi < Math.Min(xLabels.Count, row.Count); xi++)
{
var val = row[xi];
var t = (val - minV) / (maxV - minV);
var cellColor = InterpolateHex(colorFrom, colorTo, t);
var textColor = t > 0.55 ? "white" : "#1F2937";
var rx = labelW + xi * cellW;
sb.AppendLine($"<rect x=\"{rx}\" y=\"{ry}\" width=\"{cellW - 2}\" height=\"{cellH - 2}\" rx=\"3\" fill=\"{cellColor}\"><title>{Escape(xLabels[xi])} / {Escape(yLabels[yi])}: {val:G}</title></rect>");
sb.AppendLine($"<text x=\"{rx + cellW / 2 - 1}\" y=\"{ry + cellH / 2 + 4}\" text-anchor=\"middle\" fill=\"{textColor}\" font-size=\"11\">{val:G}</text>");
}
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Gauge (SVG semi-circle) ─────────────────────────────────────────
private static string RenderGauge(JsonElement chart, string unit)
{
var value = chart.TryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
var min = chart.TryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
var max = chart.TryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
if (max <= min) max = min + 1;
// Determine arc color from thresholds (highest threshold below value wins)
var arcColor = Palette[0];
if (chart.TryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
{
var thresholds = thEl.EnumerateArray()
.Select(t => (
At: t.TryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
Color: t.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[0] : Palette[0]))
.OrderBy(t => t.At)
.ToList();
foreach (var (at, color) in thresholds)
if (value >= at) arcColor = color;
}
int cx = 150, cy = 155, r = 110;
var ratio = Math.Clamp((value - min) / (max - min), 0, 1);
// Semi-circle: -180deg (left) to 0deg (right), spanning 180 degrees
var endDeg = 180.0 + ratio * 180.0;
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 300 180\" style=\"max-width:300px;height:auto;\" font-family=\"system-ui, -apple-system, sans-serif\" role=\"img\">");
sb.AppendLine("<title>Gauge Chart</title>");
// Background track arc
sb.AppendLine(ArcPath(cx, cy, r, 180, 360, "#E5E7EB", 18, false));
// Value arc
if (ratio > 0)
sb.AppendLine(ArcPath(cx, cy, r, 180, 180 + ratio * 180, arcColor, 18, false));
// Center value text
sb.AppendLine($"<text x=\"{cx}\" y=\"{cy - 10}\" text-anchor=\"middle\" fill=\"#111827\" font-size=\"28\" font-weight=\"700\">{value:G}{unit}</text>");
sb.AppendLine($"<text x=\"{cx}\" y=\"{cy + 12}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"12\">{min:G} {max:G}</text>");
sb.AppendLine("</svg>");
return sb.ToString();
}
private static string ArcPath(int cx, int cy, int r, double startDeg, double endDeg, string color, int strokeWidth, bool fill)
{
var startRad = startDeg * Math.PI / 180;
var endRad = endDeg * Math.PI / 180;
var x1 = cx + r * Math.Cos(startRad);
var y1 = cy + r * Math.Sin(startRad);
var x2 = cx + r * Math.Cos(endRad);
var y2 = cy + r * Math.Sin(endRad);
var largeArc = (endDeg - startDeg) > 180 ? 1 : 0;
var fillAttr = fill ? color : "none";
return $"<path d=\"M{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1}\" fill=\"{fillAttr}\" stroke=\"{color}\" stroke-width=\"{strokeWidth}\" stroke-linecap=\"round\"/>";
}
// ─── Helpers ─────────────────────────────────────────────────────────
private static string InterpolateHex(string from, string to, double t)
{
t = Math.Clamp(t, 0, 1);
static (int r, int g, int b) Parse(string hex)
{
hex = hex.TrimStart('#');
if (hex.Length == 3) hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}";
return (Convert.ToInt32(hex[..2], 16), Convert.ToInt32(hex[2..4], 16), Convert.ToInt32(hex[4..6], 16));
}
var (r1, g1, b1) = Parse(from);
var (r2, g2, b2) = Parse(to);
var ri = (int)(r1 + (r2 - r1) * t);
var gi = (int)(g1 + (g2 - g1) * t);
var bi = (int)(b1 + (b2 - b1) * t);
return $"#{ri:X2}{gi:X2}{bi:X2}";
}
private static List<string> ParseStringArray(JsonElement parent, string prop)
{
if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
@@ -459,7 +763,6 @@ public class ChartSkill : IAgentTool
{
if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
{
// datasets 없으면 values 배열에서 단일 데이터셋 생성
if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
{
return new()
@@ -493,10 +796,7 @@ public class ChartSkill : IAgentTool
private static string Escape(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private static string FormatSize(long bytes) =>
bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" };
private sealed class Dataset
private sealed record Dataset
{
public string Name { get; init; } = "";
public List<double> Values { get; init; } = new();
@@ -506,15 +806,34 @@ public class ChartSkill : IAgentTool
// ─── Chart CSS ───────────────────────────────────────────────────────
private const string ChartCss = @"
/* Vertical Bar Chart */
/* ── Card ── */
.card {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 1px 4px rgba(0,0,0,.06), 0 4px 16px rgba(0,0,0,.06);
}
/* ── Chart title ── */
.chart-title { margin: 0 0 14px; font-size: 15px; font-weight: 700; color: #111827; }
/* ── Animations ── */
@keyframes slideUp {
from { transform: scaleY(0); transform-origin: bottom; opacity: 0; }
to { transform: scaleY(1); transform-origin: bottom; opacity: 1; }
}
/* ── Vertical Bar Chart ── */
.vbar-chart { margin: 16px 0; }
.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; }
.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; }
.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; }
.vbar-bar-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; min-width: 18px; animation: slideUp 0.5s ease both; }
.vbar-bar { width: 100%; flex: 1; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; }
.vbar-bar:hover { opacity: 0.8; }
.vbar-value-label { font-size: 10px; font-weight: 600; color: #374151; margin-bottom: 3px; white-space: nowrap; }
.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; }
/* Horizontal Bar Chart */
/* ── Horizontal Bar Chart ── */
.hbar-chart { margin: 12px 0; }
.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; }
@@ -522,16 +841,20 @@ public class ChartSkill : IAgentTool
.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; }
.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; }
/* Line/Area Chart */
.line-chart-svg { width: 100%; max-width: 600px; height: auto; }
/* ── Line / Area / Scatter SVG ── */
.line-chart-svg { width: 100%; max-width: 100%; height: auto; }
/* Legend */
/* ── Legend ── */
.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
/* Pie Legend */
/* ── Pie Legend ── */
.pie-legend { display: flex; flex-direction: column; gap: 6px; }
.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; }
/* ── Progress ── */
.progress { height: 10px; background: #F3F4F6; border-radius: 99px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 99px; transition: width 0.6s ease; }
";
}

View File

@@ -52,7 +52,10 @@ public static class ContextCondenser
_ when key.Contains("gemini-2.0") => 900_000,
_ when key.Contains("gemini") => 900_000,
_ when key.Contains("gpt-4") => 120_000, // GPT-4 128K
_ => 16_000, // Ollama/vLLM 로컬 모델 기본값
_ when key.Contains("deepseek") => 128_000, // DeepSeek-V3/R1 128K
_ when key.Contains("qwen") => 32_000, // Qwen 계열 32K
_ when key.Contains("llama") => 32_000, // LLaMA 계열 32K
_ => 32_000, // vLLM/Ollama 알 수 없는 모델 기본값 (보수적으로 32K)
};
}
@@ -90,8 +93,9 @@ public static class ContextCondenser
// 현재 모델의 입력 토큰 한도
var settings = llm.GetCurrentModelInfo();
// 사용자가 설정한 컨텍스트 크기를 우선 사용. 미설정 시 모델별 기본값 적용.
var inputLimit = GetModelInputLimit(settings.service, settings.model);
var effectiveMax = maxOutputTokens > 0 ? Math.Min(inputLimit, maxOutputTokens) : inputLimit;
var effectiveMax = maxOutputTokens > 0 ? maxOutputTokens : inputLimit;
var percent = Math.Clamp(triggerPercent, 50, 95);
var threshold = (int)(effectiveMax * (percent / 100.0)); // 설정 임계치에서 압축 시작

View File

@@ -1,4 +1,5 @@
using System.IO;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
@@ -7,29 +8,81 @@ namespace AxCopilot.Services.Agent;
/// <summary>
/// CSV (.csv) 파일을 생성하는 내장 스킬.
/// LLM이 헤더와 데이터 행을 전달하면 CSV 파일을 생성합니다.
/// delimiter, bom, summary, col_types 옵션을 지원합니다.
/// </summary>
public class CsvSkill : IAgentTool
{
public string Name => "csv_create";
public string Description => "Create a CSV (.csv) file with structured data. Provide headers and rows as JSON arrays.";
public string Description =>
"Create a CSV (.csv) file with structured data. " +
"Supports custom delimiters (comma/tab/semicolon), UTF-8 BOM for Excel compatibility, " +
"optional summary row with counts/sums, and column type hints for proper formatting.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.csv). Relative to work folder." },
["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.", Items = new() { Type = "array", Items = new() { Type = "string" } } },
["encoding"] = new() { Type = "string", Description = "File encoding: 'utf-8' (default) or 'euc-kr'." },
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.csv). 작업 폴더 기준 상대 경로." },
["headers"] = new() { Type = "array", Description = "열 헤더 배열 (문자열 배열).", Items = new() { Type = "string" } },
["rows"] = new() { Type = "array", Description = "데이터 행 배열 (배열의 배열).", Items = new() { Type = "array", Items = new() { Type = "string" } } },
["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." },
["delimiter"] = new() { Type = "string", Description = "열 구분자: 'comma' (기본값, ','), 'tab' ('\\t'), 'semicolon' (';')." },
["bom"] = new() { Type = "boolean", Description = "UTF-8 BOM 추가 여부 (기본값: true). Excel 호환성을 위해 utf-8 인코딩 시 BOM을 추가합니다." },
["summary"] = new() { Type = "boolean", Description = "true이면 파일 하단에 수치 열의 합계/개수를 포함한 요약 행을 추가합니다." },
["col_types"] = new()
{
Type = "array",
Description = "각 열의 타입 힌트 배열: 'text', 'number', 'date', 'percent'. 생략 시 자동 감지.",
Items = new() { Type = "string" }
},
},
Required = ["path", "headers", "rows"]
Required = ["headers", "rows"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var encodingName = args.TryGetProperty("encoding", out var enc) ? enc.GetString() ?? "utf-8" : "utf-8";
// ── 필수 파라미터 검증 ──────────────────────────────────────────────
if (!args.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'headers' (문자열 배열)가 필요합니다.");
if (!args.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("필수 파라미터 누락: 'rows' (배열의 배열)가 필요합니다.");
// path 미제공 시 첫 번째 헤더로 파일명 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
{
path = pathEl.GetString()!;
}
else
{
var hint = headersEl.GetArrayLength() > 0
? headersEl[0].GetString() ?? "data"
: "data";
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 40) safe = safe[..40].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "data" : safe) + ".csv";
}
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var delimiterKey = args.TryGetProperty("delimiter", out var delimEl) ? delimEl.GetString() ?? "comma" : "comma";
var useBom = !args.TryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
var useSummary = args.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
// ── 구분자 해석 ────────────────────────────────────────────────────
var delimiter = delimiterKey.ToLowerInvariant() switch
{
"tab" => "\t",
"semicolon" => ";",
_ => ","
};
// ── 열 타입 힌트 ───────────────────────────────────────────────────
var colTypeHints = new List<string>();
if (args.TryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
foreach (var ct2 in colTypesEl.EnumerateArray())
colTypeHints.Add(ct2.GetString()?.ToLowerInvariant() ?? "text");
// ── 경로 처리 ──────────────────────────────────────────────────────
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
@@ -43,51 +96,230 @@ public class CsvSkill : IAgentTool
try
{
var headers = args.GetProperty("headers");
var rows = args.GetProperty("rows");
// ── 인코딩 결정 ────────────────────────────────────────────────
Encoding fileEncoding;
try
{
if (encodingName.Equals("utf-8", StringComparison.OrdinalIgnoreCase) ||
encodingName.Equals("utf8", StringComparison.OrdinalIgnoreCase))
{
fileEncoding = useBom ? new UTF8Encoding(true) : new UTF8Encoding(false);
}
else
{
fileEncoding = Encoding.GetEncoding(encodingName);
}
}
catch
{
fileEncoding = useBom ? new UTF8Encoding(true) : new UTF8Encoding(false);
}
// ── 헤더 수집 ──────────────────────────────────────────────────
var headerList = new List<string>();
foreach (var h in headersEl.EnumerateArray())
headerList.Add(h.GetString() ?? "");
int colCount = headerList.Count;
// ── 행 데이터 수집 (원본 문자열) ───────────────────────────────
var allRows = new List<List<string>>();
foreach (var row in rowsEl.EnumerateArray())
{
var fields = new List<string>();
if (row.ValueKind == JsonValueKind.Array)
foreach (var cell in row.EnumerateArray())
fields.Add(cell.ValueKind == JsonValueKind.Null ? "" : cell.ToString());
allRows.Add(fields);
}
// ── 열 타입 자동 감지 ──────────────────────────────────────────
var resolvedTypes = ResolveColumnTypes(colTypeHints, colCount, allRows);
// ── CSV 빌드 ───────────────────────────────────────────────────
var sb = new StringBuilder();
// 헤더 행
sb.AppendLine(string.Join(delimiter,
headerList.Select(h => EscapeField(h, delimiter))));
// 데이터 행
foreach (var row in allRows)
{
var escaped = new List<string>();
for (int i = 0; i < colCount; i++)
{
var raw = i < row.Count ? row[i] : "";
escaped.Add(FormatAndEscape(raw, i < resolvedTypes.Count ? resolvedTypes[i] : "text", delimiter));
}
sb.AppendLine(string.Join(delimiter, escaped));
}
// 요약 행
if (useSummary && allRows.Count > 0)
{
var summaryFields = BuildSummaryRow(headerList, allRows, resolvedTypes, delimiter);
sb.AppendLine(string.Join(delimiter, summaryFields));
}
// ── 파일 쓰기 ──────────────────────────────────────────────────
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
Encoding fileEncoding;
try { fileEncoding = Encoding.GetEncoding(encodingName); }
catch { fileEncoding = new UTF8Encoding(true); }
var sb = new StringBuilder();
// 헤더
var headerValues = new List<string>();
foreach (var h in headers.EnumerateArray())
headerValues.Add(EscapeCsvField(h.GetString() ?? ""));
sb.AppendLine(string.Join(",", headerValues));
// 데이터
int rowCount = 0;
foreach (var row in rows.EnumerateArray())
{
var fields = new List<string>();
foreach (var cell in row.EnumerateArray())
fields.Add(EscapeCsvField(cell.ToString()));
sb.AppendLine(string.Join(",", fields));
rowCount++;
}
await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct);
var delimLabel = delimiterKey.ToLowerInvariant() switch
{
"tab" => "탭",
"semicolon" => "세미콜론",
_ => "쉼표"
};
var bomLabel = (useBom && fileEncoding is UTF8Encoding) ? ", BOM 포함" : "";
return ToolResult.Ok(
$"CSV 파일 생성 완료: {fullPath}\n열: {headerValues.Count}, 행: {rowCount}, 인코딩: {encodingName}",
$"CSV 파일 생성 완료: {fullPath}\n열: {colCount}, 행: {allRows.Count}, 구분자: {delimLabel}, 인코딩: {encodingName}{bomLabel}" +
(useSummary ? ", 요약 행 포함" : ""),
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"CSV 생성 실패: {ex.Message}");
return ToolResult.Fail($"CSV 파일 생성 중 오류가 발생했습니다: {ex.Message}");
}
}
private static string EscapeCsvField(string field)
// ── 열 타입 해석 ──────────────────────────────────────────────────────────
private static List<string> ResolveColumnTypes(List<string> hints, int colCount, List<List<string>> rows)
{
if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r'))
var types = new List<string>(colCount);
for (int i = 0; i < colCount; i++)
{
if (i < hints.Count && hints[i] != "text")
{
types.Add(hints[i]);
continue;
}
// 자동 감지: 모든 비어있지 않은 값이 숫자인지 확인
bool allNumeric = true;
bool hasAny = false;
foreach (var row in rows)
{
var val = i < row.Count ? row[i].Trim() : "";
if (string.IsNullOrEmpty(val)) continue;
hasAny = true;
if (!IsNumericString(val)) { allNumeric = false; break; }
}
types.Add(hasAny && allNumeric ? "number" : "text");
}
return types;
}
// ── 숫자 여부 판별 ────────────────────────────────────────────────────────
private static bool IsNumericString(string s)
{
// 선행/후행 공백 제거, 선택적 음수 부호, 선택적 소수점, 선택적 퍼센트 부호 허용
var trimmed = s.Trim().TrimEnd('%');
return double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out _);
}
// ── 값 포매팅 및 이스케이프 ──────────────────────────────────────────────
private static string FormatAndEscape(string raw, string colType, string delimiter)
{
if (string.IsNullOrEmpty(raw))
return "";
switch (colType)
{
case "number":
{
var trimmed = raw.Trim();
if (double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
{
// 정수면 소수점 없이, 소수면 그대로
var formatted = num == Math.Floor(num) && !trimmed.Contains('.')
? num.ToString("0", CultureInfo.InvariantCulture)
: num.ToString("G", CultureInfo.InvariantCulture);
// 숫자는 구분자/큰따옴표가 없으면 인용 불필요
return NeedsQuoting(formatted, delimiter) ? $"\"{formatted.Replace("\"", "\"\"")}\"" : formatted;
}
return EscapeField(raw, delimiter);
}
case "percent":
{
var trimmed = raw.Trim().TrimEnd('%');
if (double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out var pct))
{
var formatted = pct.ToString("G", CultureInfo.InvariantCulture) + "%";
return NeedsQuoting(formatted, delimiter) ? $"\"{formatted.Replace("\"", "\"\"")}\"" : formatted;
}
return EscapeField(raw, delimiter);
}
default:
return EscapeField(raw, delimiter);
}
}
// ── 인용 필요 여부 판별 ───────────────────────────────────────────────────
private static bool NeedsQuoting(string value, string delimiter)
=> value.Contains(delimiter) || value.Contains('"') ||
value.Contains('\n') || value.Contains('\r');
// ── 텍스트 필드 이스케이프 ───────────────────────────────────────────────
private static string EscapeField(string field, string delimiter)
{
if (NeedsQuoting(field, delimiter))
return $"\"{field.Replace("\"", "\"\"")}\"";
return field;
}
// ── 요약 행 생성 ──────────────────────────────────────────────────────────
private static List<string> BuildSummaryRow(
List<string> headers, List<List<string>> rows,
List<string> types, string delimiter)
{
int colCount = headers.Count;
var summary = new List<string>(colCount);
for (int i = 0; i < colCount; i++)
{
var colType = i < types.Count ? types[i] : "text";
if (colType == "number" || colType == "percent")
{
// 합계 계산
double sum = 0;
int count = 0;
bool valid = true;
foreach (var row in rows)
{
var raw = i < row.Count ? row[i].Trim().TrimEnd('%') : "";
if (string.IsNullOrEmpty(raw)) continue;
if (double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{ sum += v; count++; }
else { valid = false; break; }
}
if (valid && count > 0)
{
var sumStr = sum == Math.Floor(sum)
? sum.ToString("0", CultureInfo.InvariantCulture)
: sum.ToString("G", CultureInfo.InvariantCulture);
summary.Add(colType == "percent" ? sumStr + "%" : sumStr);
}
else
{
summary.Add(EscapeField($"(개수: {rows.Count})", delimiter));
}
}
else if (i == 0)
{
// 첫 번째 텍스트 열에 "합계" 레이블
summary.Add(EscapeField("합계", delimiter));
}
else
{
summary.Add("");
}
}
return summary;
}
}

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
@@ -8,16 +8,18 @@ namespace AxCopilot.Services.Agent;
/// <summary>
/// Word (.docx) 문서를 생성하는 내장 스킬.
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
/// 테마, 테이블, 콜아웃, 하이라이트박스, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
/// </summary>
public class DocxSkill : IAgentTool
{
public string Name => "docx_create";
public string Description => "Create a rich Word (.docx) document. " +
"Supports: sections with heading+body, tables with optional header styling, " +
"Supports: themes (professional/modern/dark/minimal/creative), " +
"sections with heading+body, tables with optional header styling, " +
"callout blocks (info/warning/tip/danger), highlight boxes, " +
"text formatting (bold, italic, color, highlight, shading), " +
"headers/footers with page numbers, page breaks between sections, " +
"and numbered/bulleted lists.";
"and numbered/bulleted lists with sub-bullets.";
public ToolParameterSchema Parameters => new()
{
@@ -25,6 +27,7 @@ public class DocxSkill : IAgentTool
{
["path"] = new() { Type = "string", Description = "Output file path (.docx). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title (optional)." },
["theme"] = new() { Type = "string", Description = "Visual theme: professional (default), modern, dark, minimal, creative." },
["sections"] = new()
{
Type = "array",
@@ -32,7 +35,9 @@ public class DocxSkill : IAgentTool
"• Section: {\"heading\": \"...\", \"body\": \"...\", \"level\": 1|2}\n" +
"• Table: {\"type\": \"table\", \"headers\": [\"A\",\"B\"], \"rows\": [[\"1\",\"2\"]], \"style\": \"striped|plain\"}\n" +
"• PageBreak: {\"type\": \"pagebreak\"}\n" +
"• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \"item2\"]}\n" +
"• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \" - sub-item\"]}\n" +
"• Callout: {\"type\": \"callout\", \"style\": \"info|warning|tip|danger\", \"title\": \"...\", \"body\": \"...\"}\n" +
"• HighlightBox: {\"type\": \"highlight_box\", \"text\": \"...\", \"color\": \"blue|green|orange|red\"}\n" +
"Body text supports inline formatting: **bold**, *italic*, `code`.",
Items = new() { Type = "object" }
},
@@ -40,18 +45,77 @@ public class DocxSkill : IAgentTool
["footer"] = new() { Type = "string", Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set." },
["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in footer. Default: true if header or footer is set." },
},
Required = ["path", "sections"]
Required = ["sections"]
};
// ═══════════════════════════════════════════════════
// 테마 색상 정의
// ═══════════════════════════════════════════════════
private record ThemeColors(
string Title,
string H1,
string H2,
string TableHeader,
string BorderColor);
private static readonly Dictionary<string, ThemeColors> Themes = new(StringComparer.OrdinalIgnoreCase)
{
["professional"] = new("1F3864", "2E74B5", "404040", "2E74B5", "B4C6E7"),
["modern"] = new("065F46", "059669", "374151", "059669", "A7F3D0"),
["dark"] = new("1E293B", "6366F1", "94A3B8", "374151", "334155"),
["minimal"] = new("111827", "374151", "6B7280", "6B7280", "E5E7EB"),
["creative"] = new("7C3AED", "8B5CF6", "374151", "7C3AED", "EDE9FE"),
};
// Callout border/fill by style
private static readonly Dictionary<string, (string Border, string Fill)> CalloutColors =
new(StringComparer.OrdinalIgnoreCase)
{
["info"] = ("2563EB", "EFF6FF"),
["warning"] = ("D97706", "FFFBEB"),
["danger"] = ("DC2626", "FEF2F2"),
["tip"] = ("059669", "F0FDF4"),
};
// HighlightBox colors (fill, bottom-border)
private static readonly Dictionary<string, (string Fill, string Border)> HighlightBoxColors =
new(StringComparer.OrdinalIgnoreCase)
{
["blue"] = ("DBEAFE", "2563EB"),
["green"] = ("DCFCE7", "059669"),
["orange"] = ("FEF3C7", "D97706"),
["red"] = ("FEE2E2", "DC2626"),
};
// ═══════════════════════════════════════════════════
// ExecuteAsync
// ═══════════════════════════════════════════════════
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
{
path = pathEl.GetString()!;
}
else
{
var safe = System.Text.RegularExpressions.Regex.Replace(
string.IsNullOrWhiteSpace(title) ? "document" : title, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".docx";
}
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
(headerText != null || footerText != null);
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
var theme = Themes.TryGetValue(themeName, out var tc) ? tc : Themes["professional"];
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase))
@@ -82,13 +146,13 @@ public class DocxSkill : IAgentTool
// 제목
if (!string.IsNullOrEmpty(title))
{
body.Append(CreateTitleParagraph(title));
body.Append(CreateTitleParagraph(title, theme));
// 제목 아래 구분선
body.Append(new Paragraph(
new ParagraphProperties
{
ParagraphBorders = new ParagraphBorders(
new BottomBorder { Val = BorderValues.Single, Size = 6, Color = "4472C4", Space = 1 }),
new BottomBorder { Val = BorderValues.Single, Size = 6, Color = theme.BorderColor, Space = 1 }),
SpacingBetweenLines = new SpacingBetweenLines { After = "300" },
}));
}
@@ -107,7 +171,7 @@ public class DocxSkill : IAgentTool
if (blockType == "table")
{
body.Append(CreateTable(section));
body.Append(CreateTable(section, theme));
tableCount++;
continue;
}
@@ -118,13 +182,25 @@ public class DocxSkill : IAgentTool
continue;
}
if (blockType == "callout")
{
AppendCallout(body, section);
continue;
}
if (blockType == "highlight_box")
{
body.Append(CreateHighlightBox(section));
continue;
}
// 일반 섹션 (heading + body)
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrEmpty(heading))
body.Append(CreateHeadingParagraph(heading, level));
body.Append(CreateHeadingParagraph(heading, level, theme));
if (!string.IsNullOrEmpty(bodyText))
{
@@ -144,6 +220,7 @@ public class DocxSkill : IAgentTool
if (tableCount > 0) parts.Add($"테이블: {tableCount}개");
if (headerText != null) parts.Add("머리글");
if (showPageNumbers) parts.Add("페이지번호");
parts.Add($"테마: {themeName}");
return ToolResult.Ok(
$"Word 문서 생성 완료: {fullPath}\n{string.Join(", ", parts)}",
@@ -159,7 +236,7 @@ public class DocxSkill : IAgentTool
// 제목/소제목/본문 단락 생성
// ═══════════════════════════════════════════════════
private static Paragraph CreateTitleParagraph(string text)
private static Paragraph CreateTitleParagraph(string text, ThemeColors theme)
{
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
@@ -172,17 +249,18 @@ public class DocxSkill : IAgentTool
{
Bold = new Bold(),
FontSize = new FontSize { Val = "44" }, // 22pt
Color = new Color { Val = "1F3864" },
Color = new Color { Val = theme.Title },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
para.Append(run);
return para;
}
private static Paragraph CreateHeadingParagraph(string text, int level)
private static Paragraph CreateHeadingParagraph(string text, int level, ThemeColors theme)
{
var para = new Paragraph();
var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt
var color = level <= 1 ? "2E74B5" : "404040";
var color = level <= 1 ? theme.H1 : theme.H2;
para.ParagraphProperties = new ParagraphProperties
{
@@ -193,7 +271,7 @@ public class DocxSkill : IAgentTool
if (level <= 1)
{
para.ParagraphProperties.ParagraphBorders = new ParagraphBorders(
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "B4C6E7", Space = 1 });
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = theme.BorderColor, Space = 1 });
}
var run = new Run(new Text(text));
@@ -202,6 +280,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(),
FontSize = new FontSize { Val = fontSize },
Color = new Color { Val = color },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
para.Append(run);
return para;
@@ -252,7 +331,7 @@ public class DocxSkill : IAgentTool
{
var run = CreateRun(match.Groups[3].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas" };
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "맑은 고딕" };
run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading
{
@@ -281,6 +360,7 @@ public class DocxSkill : IAgentTool
run.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" }, // 11pt
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
return run;
}
@@ -289,7 +369,7 @@ public class DocxSkill : IAgentTool
// 테이블 생성
// ═══════════════════════════════════════════════════
private static Table CreateTable(JsonElement section)
private static Table CreateTable(JsonElement section, ThemeColors theme)
{
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
@@ -320,7 +400,7 @@ public class DocxSkill : IAgentTool
var cell = new TableCell();
cell.TableCellProperties = new TableCellProperties
{
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "2E74B5", Color = "auto" },
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = theme.TableHeader, Color = "auto" },
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
};
var para = new Paragraph(new Run(new Text(h.GetString() ?? ""))
@@ -330,6 +410,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(),
FontSize = new FontSize { Val = "20" },
Color = new Color { Val = "FFFFFF" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
}
});
para.ParagraphProperties = new ParagraphProperties
@@ -364,7 +445,11 @@ public class DocxSkill : IAgentTool
var para = new Paragraph(new Run(new Text(cellVal.ToString()) { Space = SpaceProcessingModeValues.Preserve })
{
RunProperties = new RunProperties { FontSize = new FontSize { Val = "20" } }
RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "20" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
}
});
para.ParagraphProperties = new ParagraphProperties
{
@@ -382,7 +467,7 @@ public class DocxSkill : IAgentTool
}
// ═══════════════════════════════════════════════════
// 리스트 (번호/불릿)
// 리스트 (번호/불릿) — 서브불릿 지원
// ═══════════════════════════════════════════════════
private static void AppendList(Body body, JsonElement section)
@@ -395,13 +480,21 @@ public class DocxSkill : IAgentTool
int idx = 1;
foreach (var item in items.EnumerateArray())
{
var text = item.GetString() ?? item.ToString();
var prefix = listStyle == "number" ? $"{idx}. " : "• ";
var rawText = item.GetString() ?? item.ToString();
// 서브불릿 감지: 앞에 공백/탭 또는 "- " 시작
bool isSub = rawText.StartsWith(" ") || rawText.StartsWith("\t") || rawText.TrimStart().StartsWith("- ");
var text = isSub ? rawText.TrimStart().TrimStart('-').TrimStart() : rawText;
var indentLeft = isSub ? "1440" : "720"; // 서브: 1 inch, 일반: 0.5 inch
var prefix = isSub
? "◦ " // 서브불릿 기호
: (listStyle == "number" ? $"{idx}. " : "• ");
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
Indentation = new Indentation { Left = "720" }, // 0.5 inch
Indentation = new Indentation { Left = indentLeft },
SpacingBetweenLines = new SpacingBetweenLines { Line = "320" },
};
@@ -409,19 +502,167 @@ public class DocxSkill : IAgentTool
prefixRun.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" },
Bold = listStyle == "number" ? new Bold() : null,
Bold = (listStyle == "number" && !isSub) ? new Bold() : null,
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
para.Append(prefixRun);
var textRun = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
textRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" } };
textRun.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
para.Append(textRun);
body.Append(para);
idx++;
if (!isSub) idx++;
}
}
// ═══════════════════════════════════════════════════
// 콜아웃 블록
// ═══════════════════════════════════════════════════
private static void AppendCallout(Body body, JsonElement section)
{
var style = section.TryGetProperty("style", out var sv) ? sv.GetString() ?? "info" : "info";
var calloutTitle = section.TryGetProperty("title", out var tv) ? tv.GetString() ?? "" : "";
var calloutBody = section.TryGetProperty("body", out var bv) ? bv.GetString() ?? "" : "";
var (borderColor, fillColor) = CalloutColors.TryGetValue(style, out var cc)
? cc
: CalloutColors["info"];
// 제목 단락 (있을 경우)
if (!string.IsNullOrEmpty(calloutTitle))
{
var titlePara = new Paragraph();
titlePara.ParagraphProperties = new ParagraphProperties
{
ParagraphBorders = new ParagraphBorders(
new LeftBorder { Val = BorderValues.Single, Size = 16, Color = borderColor, Space = 4 }),
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = fillColor, Color = "auto" },
Indentation = new Indentation { Left = "360", Right = "360" },
SpacingBetweenLines = new SpacingBetweenLines { Before = "60", After = "60" },
};
var titleRun = new Run(new Text(calloutTitle) { Space = SpaceProcessingModeValues.Preserve });
titleRun.RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize { Val = "22" },
Color = new Color { Val = borderColor },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
titlePara.Append(titleRun);
body.Append(titlePara);
}
// 본문 단락
if (!string.IsNullOrEmpty(calloutBody))
{
foreach (var line in calloutBody.Split('\n'))
{
var bodyPara = new Paragraph();
bodyPara.ParagraphProperties = new ParagraphProperties
{
ParagraphBorders = new ParagraphBorders(
new LeftBorder { Val = BorderValues.Single, Size = 16, Color = borderColor, Space = 4 }),
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = fillColor, Color = "auto" },
Indentation = new Indentation { Left = "360", Right = "360" },
SpacingBetweenLines = new SpacingBetweenLines { Before = "40", After = "40" },
};
AppendFormattedRunsWithFont(bodyPara, line);
body.Append(bodyPara);
}
}
// 콜아웃 뒤 여백 단락
body.Append(new Paragraph(new ParagraphProperties(
new SpacingBetweenLines { After = "120" })));
}
// ═══════════════════════════════════════════════════
// 하이라이트 박스
// ═══════════════════════════════════════════════════
private static Paragraph CreateHighlightBox(JsonElement section)
{
var text = section.TryGetProperty("text", out var tv) ? tv.GetString() ?? "" : "";
var color = section.TryGetProperty("color", out var cv) ? cv.GetString() ?? "blue" : "blue";
var (fillColor, borderColor) = HighlightBoxColors.TryGetValue(color, out var hc)
? hc
: HighlightBoxColors["blue"];
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
ParagraphBorders = new ParagraphBorders(
new BottomBorder { Val = BorderValues.Single, Size = 8, Color = borderColor, Space = 1 }),
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = fillColor, Color = "auto" },
Indentation = new Indentation { Left = "240", Right = "240" },
SpacingBetweenLines = new SpacingBetweenLines { Before = "80", After = "80" },
};
AppendFormattedRunsWithFont(para, text);
return para;
}
// ═══════════════════════════════════════════════════
// 서식 있는 런 추가 (폰트 포함) — 콜아웃/하이라이트 박스용
// ═══════════════════════════════════════════════════
/// <summary>AppendFormattedRuns와 동일하나 모든 일반 런에 한/영 폰트를 적용합니다.</summary>
private static void AppendFormattedRunsWithFont(Paragraph para, string text)
{
var regex = new System.Text.RegularExpressions.Regex(
@"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`");
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in regex.Matches(text))
{
if (match.Index > lastIndex)
para.Append(CreateRun(text[lastIndex..match.Index]));
if (match.Groups[1].Success)
{
var run = CreateRun(match.Groups[1].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.Bold = new Bold();
para.Append(run);
}
else if (match.Groups[2].Success)
{
var run = CreateRun(match.Groups[2].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.Italic = new Italic();
para.Append(run);
}
else if (match.Groups[3].Success)
{
var run = CreateRun(match.Groups[3].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "맑은 고딕" };
run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading
{
Val = ShadingPatternValues.Clear,
Fill = "F2F2F2",
Color = "auto"
};
para.Append(run);
}
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
para.Append(CreateRun(text[lastIndex..]));
if (lastIndex == 0 && text.Length == 0)
para.Append(CreateRun(""));
}
// ═══════════════════════════════════════════════════
// 페이지 나누기
// ═══════════════════════════════════════════════════
@@ -452,6 +693,7 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "18" }, // 9pt
Color = new Color { Val = "808080" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
}
});
para.ParagraphProperties = new ParagraphProperties
@@ -524,6 +766,7 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
}
};
@@ -534,6 +777,7 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
};
run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin });
run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve });

View File

@@ -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)

View File

@@ -1,30 +1,60 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 전체를 새로 쓰는 도구. 새 파일 생성 또는 기존 파일 덮어쓰기.</summary>
/// <summary>파일 전체를 새로 쓰거나 추가 쓰기하는 도구. 다양한 인코딩 지원.</summary>
public class FileWriteTool : IAgentTool
{
public string Name => "file_write";
public string Description => "Write content to a file. Creates new file or overwrites existing. Parent directories are created automatically.";
public string Description =>
"Write content to a file. Creates a new file or overwrites an existing one. " +
"Supports append mode, automatic parent directory creation, and multiple encodings " +
"(utf-8, utf-8-bom, utf-16le, utf-16be, euc-kr, cp949).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "File path to write (absolute or relative to work folder)" },
["content"] = new() { Type = "string", Description = "Content to write to the file" },
["path"] = new()
{
Type = "string",
Description = "File path to write (absolute or relative to work folder)"
},
["content"] = new()
{
Type = "string",
Description = "Content to write to the file"
},
["encoding"] = new()
{
Type = "string",
Description = "Text encoding to use. Options: \"utf-8\" (default, no BOM), \"utf-8-bom\", \"utf-16le\", \"utf-16be\", \"euc-kr\", \"cp949\""
},
["append"] = new()
{
Type = "boolean",
Description = "If true, appends content to the file instead of overwriting. Creates the file if it does not exist. Default: false"
},
["create_dirs"] = new()
{
Type = "boolean",
Description = "If true (default), automatically creates any missing parent directories before writing"
},
},
Required = ["path", "content"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var rawPath = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var encName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
var append = args.TryGetProperty("append", out var appEl) && appEl.GetBoolean();
var mkDirs = !args.TryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
var fullPath = FileReadTool.ResolvePath(rawPath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
@@ -32,15 +62,90 @@ public class FileWriteTool : IAgentTool
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
// Resolve encoding
Encoding encoding;
string encodingLabel;
try
{
await TextFileCodec.WriteAllTextAsync(fullPath, content, TextFileCodec.Utf8NoBom, ct);
var lines = content.Split('\n').Length;
return ToolResult.Ok($"파일 저장 완료: {fullPath} ({lines} lines, {content.Length} chars)", fullPath);
(encoding, encodingLabel) = ResolveEncoding(encName.Trim().ToLowerInvariant());
}
catch (Exception ex)
{
return ToolResult.Fail($"지원하지 않는 인코딩: '{encName}'. ({ex.Message})");
}
try
{
if (mkDirs)
{
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
if (append)
{
// Append path: use StreamWriter so we respect the requested encoding
await using var sw = new StreamWriter(fullPath, append: true, encoding: encoding);
await sw.WriteAsync(content.AsMemory(), ct);
}
else
{
// Overwrite path: prefer TextFileCodec for UTF-8 variants, raw StreamWriter otherwise
if (encoding is UTF8Encoding)
{
await TextFileCodec.WriteAllTextAsync(fullPath, content, encoding, ct);
}
else
{
await File.WriteAllTextAsync(fullPath, content, encoding, ct);
}
}
// Build success stats
var fileInfo = new FileInfo(fullPath);
var lines = TextFileCodec.SplitLines(content).Length;
var charCount = content.Length;
var sizeKb = fileInfo.Exists ? fileInfo.Length / 1024.0 : 0.0;
var mode = append ? "추가" : "덮어쓰기";
var summary =
$"파일 {mode} 완료\n" +
$" 경로 : {fullPath}\n" +
$" 줄 수 : {lines:N0} lines\n" +
$" 문자 수 : {charCount:N0} chars\n" +
$" 파일 크기: {sizeKb:F1} KB\n" +
$" 인코딩 : {encodingLabel}";
return ToolResult.Ok(summary, fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"파일 쓰기 실패: {ex.Message}");
}
}
/// <summary>
/// 인코딩 이름 문자열을 System.Text.Encoding 인스턴스로 변환합니다.
/// </summary>
private static (Encoding encoding, string label) ResolveEncoding(string name) => name switch
{
"utf-8" => (TextFileCodec.Utf8NoBom, "UTF-8 (no BOM)"),
"utf-8-bom" => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), "UTF-8 BOM"),
"utf-16le" => (new UnicodeEncoding(bigEndian: false, byteOrderMark: true), "UTF-16 LE"),
"utf-16be" => (new UnicodeEncoding(bigEndian: true, byteOrderMark: true), "UTF-16 BE"),
"euc-kr" => (CodePagesEncoding("euc-kr"), "EUC-KR"),
"cp949" => (CodePagesEncoding("ks_c_5601-1987"), "CP949 (ks_c_5601-1987)"),
_ => throw new NotSupportedException($"알 수 없는 인코딩: {name}")
};
/// <summary>
/// .NET 8에서 코드 페이지 인코딩을 가져옵니다. EncodingProvider 등록을 보장합니다.
/// </summary>
private static Encoding CodePagesEncoding(string name)
{
// CodePagesEncodingProvider를 최초 1회 등록
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(name);
}
}

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
@@ -13,7 +13,8 @@ public class FolderMapTool : IAgentTool
public string Name => "folder_map";
public string Description =>
"Generate a directory tree map of the work folder or a specified subfolder. " +
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files.";
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files. " +
"Supports sorting, size filtering, date filtering, and multi-extension filtering.";
public ToolParameterSchema Parameters => new()
{
@@ -22,7 +23,17 @@ public class FolderMapTool : IAgentTool
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 3." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["pattern"] = new() { Type = "string", Description = "File extension filter (e.g. '.cs', '.py'). Optional, shows all files if omitted." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new()
{
Type = "array",
Description = "Filter by multiple extensions, e.g. [\".cs\", \".json\"]. Takes precedence over 'pattern' if both are provided.",
Items = new ToolProperty { Type = "string" },
},
["sort_by"] = new() { Type = "string", Description = "Sort files/dirs within each level: 'name' (default), 'size' (descending), 'modified' (newest first)." },
["show_dir_sizes"] = new() { Type = "boolean", Description = "If true, show the total size of each directory in parentheses. Default: false." },
["modified_after"] = new() { Type = "string", Description = "ISO date string (e.g. '2024-01-01'). Only show files modified after this date." },
["max_file_size"] = new() { Type = "string", Description = "Only show files smaller than this size, e.g. '1MB', '500KB', '2048B'." },
},
Required = []
};
@@ -40,14 +51,20 @@ public class FolderMapTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── path ──────────────────────────────────────────────────────────
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
// ── depth ─────────────────────────────────────────────────────────
var depth = 3;
if (args.TryGetProperty("depth", out var d))
{
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
}
var depthStr = depth.ToString();
if (depth < 1) depth = 1;
var maxDepth = Math.Min(depth, 10);
// ── include_files ─────────────────────────────────────────────────
var includeFiles = true;
if (args.TryGetProperty("include_files", out var inc))
{
@@ -56,12 +73,51 @@ public class FolderMapTool : IAgentTool
else
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
}
var extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
if (!int.TryParse(depthStr, out var maxDepth) || maxDepth < 1)
maxDepth = 3;
maxDepth = Math.Min(maxDepth, 10);
// ── extensions / pattern ──────────────────────────────────────────
HashSet<string>? extSet = null;
if (args.TryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
{
var list = extsEl.EnumerateArray()
.Select(e => e.GetString() ?? "")
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.StartsWith('.') ? s : "." + s)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (list.Count > 0) extSet = list;
}
// Fall back to single pattern if extensions not provided
string extFilter = "";
if (extSet == null)
extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
// ── sort_by ───────────────────────────────────────────────────────
var sortBy = args.TryGetProperty("sort_by", out var sb2) ? sb2.GetString() ?? "name" : "name";
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
// ── show_dir_sizes ────────────────────────────────────────────────
var showDirSizes = false;
if (args.TryGetProperty("show_dir_sizes", out var sds))
{
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
showDirSizes = sds.GetBoolean();
else
showDirSizes = string.Equals(sds.GetString(), "true", StringComparison.OrdinalIgnoreCase);
}
// ── modified_after ────────────────────────────────────────────────
DateTime? modifiedAfter = null;
if (args.TryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(maEl.GetString(), out var mdt))
modifiedAfter = mdt;
}
// ── max_file_size ─────────────────────────────────────────────────
long? maxFileSizeBytes = null;
if (args.TryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
maxFileSizeBytes = ParseSizeString(mfsEl.GetString() ?? "");
// ── resolve base directory ────────────────────────────────────────
var baseDir = string.IsNullOrEmpty(subPath)
? context.WorkFolder
: FileReadTool.ResolvePath(subPath, context.WorkFolder);
@@ -74,18 +130,31 @@ public class FolderMapTool : IAgentTool
try
{
var options = new TreeOptions(maxDepth, includeFiles, extFilter, extSet,
sortBy, showDirSizes, modifiedAfter, maxFileSizeBytes);
var sb = new StringBuilder();
var dirName = Path.GetFileName(baseDir);
if (string.IsNullOrEmpty(dirName)) dirName = baseDir;
sb.AppendLine($"{dirName}/");
long rootTotalSize = 0;
sb.Append($"{dirName}/");
int entryCount = 0;
BuildTree(sb, baseDir, "", 0, maxDepth, includeFiles, extFilter, context, ref entryCount);
int totalFiles = 0;
int totalDirs = 0;
BuildTree(sb, baseDir, "", 0, options, context,
ref entryCount, ref totalFiles, ref totalDirs, ref rootTotalSize);
if (showDirSizes)
sb.Insert(sb.ToString().IndexOf('/') + 1, $" ({FormatSize(rootTotalSize)})");
sb.AppendLine(); // newline after root
if (entryCount >= MaxEntries)
sb.AppendLine($"\n... ({MaxEntries}개 항목 제한 도달, depth 또는 pattern을 조정하세요)");
sb.AppendLine($"\n... ({MaxEntries} entry limit reached; adjust depth or filters)");
var summary = $"폴더 맵 생성 완료 ({entryCount}개 항목, 깊이 {maxDepth})";
var summary = $"Folder map complete — {totalFiles} files, {totalDirs} dirs, {FormatSize(rootTotalSize)} total (depth {maxDepth})";
return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}"));
}
catch (Exception ex)
@@ -94,44 +163,58 @@ public class FolderMapTool : IAgentTool
}
}
private static void BuildTree(
StringBuilder sb, string dir, string prefix, int currentDepth, int maxDepth,
bool includeFiles, string extFilter, AgentContext context, ref int entryCount)
{
if (currentDepth >= maxDepth || entryCount >= MaxEntries) return;
// ─── Tree builder ────────────────────────────────────────────────────
// 하위 디렉토리
private static void BuildTree(
StringBuilder sb, string dir, string prefix, int currentDepth,
TreeOptions opts, AgentContext context,
ref int entryCount, ref int totalFiles, ref int totalDirs, ref long accumSize)
{
if (currentDepth >= opts.MaxDepth || entryCount >= MaxEntries) return;
// ── Collect subdirectories ────────────────────────────────────────
List<DirectoryInfo> subDirs;
try
{
subDirs = new DirectoryInfo(dir).GetDirectories()
.Where(d => !d.Attributes.HasFlag(FileAttributes.Hidden)
&& !IgnoredDirs.Contains(d.Name))
.OrderBy(d => d.Name)
.ToList();
}
catch { return; } // 접근 불가 디렉토리 무시
catch { return; }
// 하위 파일
// ── Collect files ─────────────────────────────────────────────────
List<FileInfo> files = [];
if (includeFiles)
if (opts.IncludeFiles)
{
try
{
files = new DirectoryInfo(dir).GetFiles()
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden)
&& (string.IsNullOrEmpty(extFilter)
|| f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase)))
.OrderBy(f => f.Name)
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden))
.Where(f => MatchesExtension(f, opts.ExtFilter, opts.ExtSet))
.Where(f => opts.ModifiedAfter == null || f.LastWriteTime > opts.ModifiedAfter.Value)
.Where(f => opts.MaxFileSizeBytes == null || f.Length <= opts.MaxFileSizeBytes.Value)
.ToList();
}
catch { /* ignore */ }
}
// ── Sort ──────────────────────────────────────────────────────────
subDirs = opts.SortBy == "modified"
? subDirs.OrderByDescending(d => d.LastWriteTime).ToList()
: subDirs.OrderBy(d => d.Name).ToList();
files = opts.SortBy switch
{
"size" => files.OrderByDescending(f => f.Length).ToList(),
"modified" => files.OrderByDescending(f => f.LastWriteTime).ToList(),
_ => files.OrderBy(f => f.Name).ToList(),
};
var totalItems = subDirs.Count + files.Count;
var index = 0;
// 디렉토리 출력
// ── Render subdirectories ─────────────────────────────────────────
foreach (var sub in subDirs)
{
if (entryCount >= MaxEntries) break;
@@ -140,15 +223,41 @@ public class FolderMapTool : IAgentTool
var connector = isLast ? "└── " : "├── ";
var childPrefix = isLast ? " " : "│ ";
sb.AppendLine($"{prefix}{connector}{sub.Name}/");
entryCount++;
long subSize = 0;
int subFiles = 0, subDirsCount = 0;
_ = subFiles; _ = subDirsCount; // suppress unused warnings (reserved for future use)
if (context.IsPathAllowed(sub.FullName))
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, maxDepth,
includeFiles, extFilter, context, ref entryCount);
{
// We always recurse to gather sizes; count only when rendered
if (opts.ShowDirSizes)
{
// Pre-compute directory size (best-effort, no error propagation)
try { subSize = ComputeDirSize(sub.FullName); } catch { }
}
totalDirs++;
entryCount++;
var dirLabel = opts.ShowDirSizes
? $"{sub.Name}/ ({FormatSize(subSize)})"
: $"{sub.Name}/";
sb.AppendLine($"{prefix}{connector}{dirLabel}");
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, opts, context,
ref entryCount, ref totalFiles, ref totalDirs, ref subSize);
accumSize += subSize;
}
else
{
totalDirs++;
entryCount++;
sb.AppendLine($"{prefix}{connector}{sub.Name}/ [access denied]");
}
}
// 파일 출력
// ── Render files ──────────────────────────────────────────────────
foreach (var file in files)
{
if (entryCount >= MaxEntries) break;
@@ -156,16 +265,73 @@ public class FolderMapTool : IAgentTool
var isLast = index == totalItems;
var connector = isLast ? "└── " : "├── ";
var sizeStr = FormatSize(file.Length);
sb.AppendLine($"{prefix}{connector}{file.Name} ({sizeStr})");
string annotation = opts.SortBy switch
{
"modified" => $"({file.LastWriteTime:yyyy-MM-dd}, {FormatSize(file.Length)})",
"size" => $"({FormatSize(file.Length)})",
_ => $"({FormatSize(file.Length)})",
};
sb.AppendLine($"{prefix}{connector}{file.Name} {annotation}");
accumSize += file.Length;
totalFiles++;
entryCount++;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
private static bool MatchesExtension(FileInfo f, string extFilter, HashSet<string>? extSet)
{
if (extSet != null) return extSet.Contains(f.Extension);
if (!string.IsNullOrEmpty(extFilter))
return f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase);
return true;
}
private static long ComputeDirSize(string dir)
{
long total = 0;
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(f).Length; } catch { }
}
return total;
}
private static long? ParseSizeString(string s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
s = s.Trim();
if (s.EndsWith("GB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var gb))
return (long)(gb * 1024 * 1024 * 1024);
if (s.EndsWith("MB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var mb))
return (long)(mb * 1024 * 1024);
if (s.EndsWith("KB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var kb))
return (long)(kb * 1024);
if (s.EndsWith("B", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^1], out var b))
return (long)b;
if (long.TryParse(s, out var raw)) return raw;
return null;
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
< 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB",
};
// ─── Options record ────────────────────────────────────────────────────
private sealed record TreeOptions(
int MaxDepth,
bool IncludeFiles,
string ExtFilter,
HashSet<string>? ExtSet,
string SortBy,
bool ShowDirSizes,
DateTime? ModifiedAfter,
long? MaxFileSizeBytes);
}

View File

@@ -1,5 +1,7 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
@@ -7,22 +9,30 @@ namespace AxCopilot.Services.Agent;
public class GlobTool : IAgentTool
{
public string Name => "glob";
public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json'). Returns matching file paths.";
public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json', '*.txt'). Returns matching file paths relative to the search directory.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["pattern"] = new() { Type = "string", Description = "Glob pattern to match files (e.g. '**/*.cs', '*.txt')" },
["path"] = new() { Type = "string", Description = "Directory to search in. Optional, defaults to work folder." },
["pattern"] = new() { Type = "string", Description = "Glob pattern to match files. Supports ** (recursive), * (any chars), ? (single char). E.g. '**/*.cs', 'src/models/*.json', '*.txt'." },
["path"] = new() { Type = "string", Description = "Directory to search in. Optional, defaults to work folder." },
["sort_by"] = new() { Type = "string", Description = "'name' (default), 'size', or 'modified'. When size/modified, shows that info next to each file." },
["max_results"] = new() { Type = "integer", Description = "Maximum number of results to return. Default 200, max 2000." },
["include_hidden"] = new() { Type = "boolean", Description = "Include hidden files/directories (starting with '.'). Default false." },
["exclude_pattern"] = new() { Type = "string", Description = "Glob pattern for files to exclude (e.g. '*.min.js', '*.g.cs'). Matched against file name only." },
},
Required = ["pattern"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var sortBy = args.TryGetProperty("sort_by", out var sb) ? sb.GetString() ?? "name" : "name";
var maxResults = args.TryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
var includeHidden = args.TryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
var excludePattern = args.TryGetProperty("exclude_pattern", out var ep) ? ep.GetString() ?? "" : "";
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
@@ -36,22 +46,89 @@ public class GlobTool : IAgentTool
try
{
// glob 패턴을 Directory.EnumerateFiles용으로 변환
var searchPattern = ExtractSearchPattern(pattern);
var recursive = pattern.Contains("**") || pattern.Contains('/') || pattern.Contains('\\');
var (searchDir, filePattern, recursive) = DecomposePattern(pattern, baseDir);
if (!Directory.Exists(searchDir))
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다. (디렉토리 없음: {Path.GetRelativePath(baseDir, searchDir)})"));
var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var files = Directory.EnumerateFiles(baseDir, searchPattern, option)
.Where(f => context.IsPathAllowed(f))
.OrderBy(f => f)
.Take(200)
// Build a regex from the file-name portion of the glob for exact matching
var fileNameRegex = GlobSegmentToRegex(filePattern);
// Build exclude regex if provided (matched against filename only)
Regex? excludeRegex = string.IsNullOrEmpty(excludePattern)
? null
: new Regex(GlobSegmentToRegex(excludePattern), RegexOptions.IgnoreCase | RegexOptions.Compiled);
var files = Directory.EnumerateFiles(searchDir, "*", option)
.Where(f =>
{
if (!context.IsPathAllowed(f)) return false;
var fileName = Path.GetFileName(f);
// Hidden file/dir check
if (!includeHidden)
{
// Check every path segment from searchDir downward
var rel = Path.GetRelativePath(searchDir, f);
if (rel.Split(Path.DirectorySeparatorChar)
.Any(seg => seg.StartsWith('.')))
return false;
}
// File-name pattern match
if (!Regex.IsMatch(fileName, fileNameRegex, RegexOptions.IgnoreCase))
return false;
// Exclude pattern
if (excludeRegex != null && excludeRegex.IsMatch(fileName))
return false;
return true;
})
.ToList();
if (files.Count == 0)
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다."));
var result = string.Join("\n", files.Select(f => Path.GetRelativePath(baseDir, f)));
return Task.FromResult(ToolResult.Ok($"{files.Count}개 파일 발견:\n{result}"));
// Sort
IEnumerable<string> sorted = sortBy switch
{
"size" => files.OrderBy(f => new FileInfo(f).Length),
"modified" => files.OrderByDescending(f => new FileInfo(f).LastWriteTime),
_ => files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
};
var taken = sorted.Take(maxResults).ToList();
var truncated = files.Count > maxResults;
var sb2 = new StringBuilder();
foreach (var f in taken)
{
var rel = Path.GetRelativePath(baseDir, f);
if (sortBy == "size")
{
var size = new FileInfo(f).Length;
sb2.AppendLine($"{rel} ({FormatSize(size)})");
}
else if (sortBy == "modified")
{
var ts = new FileInfo(f).LastWriteTime;
sb2.AppendLine($"{rel} ({ts:yyyy-MM-dd HH:mm:ss})");
}
else
{
sb2.AppendLine(rel);
}
}
var summary = truncated
? $"{taken.Count}개 파일 표시 (전체 {files.Count}개, 제한 {maxResults}개):"
: $"{taken.Count}개 파일 발견:";
return Task.FromResult(ToolResult.Ok($"{summary}\n{sb2.ToString().TrimEnd()}"));
}
catch (Exception ex)
{
@@ -59,11 +136,81 @@ public class GlobTool : IAgentTool
}
}
private static string ExtractSearchPattern(string globPattern)
/// <summary>
/// Decomposes a glob pattern into (searchDirectory, fileNamePattern, recursive).
/// Examples:
/// "**/*.cs" → (baseDir, "*.cs", true)
/// "*.txt" → (baseDir, "*.txt", false)
/// "src/**/*.json" → (baseDir/src, "*.json", true)
/// "src/models/*.cs" → (baseDir/src/models, "*.cs", false)
/// "src/models/Foo.cs" → (baseDir/src/models, "Foo.cs", false)
/// </summary>
private static (string searchDir, string filePattern, bool recursive) DecomposePattern(
string pattern, string baseDir)
{
// **/*.cs → *.cs, src/**/*.json → *.json
var parts = globPattern.Replace('/', '\\').Split('\\');
var last = parts[^1];
return string.IsNullOrEmpty(last) || last == "**" ? "*" : last;
// Normalise separators
var normalised = pattern.Replace('\\', '/');
var segments = normalised.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
return (baseDir, "*", true);
// The last segment is always the file-name pattern (may contain * or ?)
var filePattern = segments[^1];
var dirSegments = segments[..^1]; // everything before the last segment
bool recursive = false;
var pathParts = new List<string>();
foreach (var seg in dirSegments)
{
if (seg == "**")
{
recursive = true;
// Do not add ** itself to the path; it means "any depth"
}
else
{
pathParts.Add(seg);
}
}
// If the file pattern itself is **, treat as recursive wildcard
if (filePattern == "**")
{
recursive = true;
filePattern = "*";
}
var searchDir = pathParts.Count > 0
? Path.Combine(new[] { baseDir }.Concat(pathParts).ToArray())
: baseDir;
return (searchDir, filePattern, recursive);
}
/// <summary>Converts a simple glob segment (*, ?, literals) to a full-match regex string.</summary>
private static string GlobSegmentToRegex(string glob)
{
var sb = new StringBuilder("^");
foreach (var ch in glob)
{
switch (ch)
{
case '*': sb.Append(".*"); break;
case '?': sb.Append('.'); break;
case '.': sb.Append("\\."); break;
default: sb.Append(Regex.Escape(ch.ToString())); break;
}
}
sb.Append('$');
return sb.ToString();
}
private static string FormatSize(long bytes)
{
if (bytes >= 1_048_576) return $"{bytes / 1_048_576.0:F1} MB";
if (bytes >= 1_024) return $"{bytes / 1_024.0:F1} KB";
return $"{bytes} B";
}
}

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@@ -15,22 +15,38 @@ public class GrepTool : IAgentTool
{
Properties = new()
{
["pattern"] = new() { Type = "string", Description = "Search pattern (regex supported)" },
["path"] = new() { Type = "string", Description = "File or directory to search in. Optional, defaults to work folder." },
["glob"] = new() { Type = "string", Description = "File pattern filter (e.g. '*.cs', '*.json'). Optional." },
["context_lines"] = new() { Type = "integer", Description = "Number of context lines before/after each match (0-5). Default 0." },
["case_sensitive"] = new() { Type = "boolean", Description = "Case-sensitive search. Default false (case-insensitive)." },
["pattern"] = new() { Type = "string", Description = "Search pattern (regex supported)" },
["path"] = new() { Type = "string", Description = "File or directory to search in. Optional, defaults to work folder." },
["glob"] = new() { Type = "string", Description = "File pattern filter (e.g. '*.cs', '*.json'). Optional." },
["context_lines"] = new() { Type = "integer", Description = "Number of context lines before AND after each match (0-10). Shorthand for setting both before_lines and after_lines. Default 0." },
["before_lines"] = new() { Type = "integer", Description = "Lines to show before each match (0-10). Default 0. Overrides context_lines for the before side." },
["after_lines"] = new() { Type = "integer", Description = "Lines to show after each match (0-10). Default 0. Overrides context_lines for the after side." },
["case_sensitive"] = new() { Type = "boolean", Description = "Case-sensitive search. Default false (case-insensitive)." },
["max_matches"] = new() { Type = "integer", Description = "Maximum total matches to return. Default 100, max 500." },
["files_only"] = new() { Type = "boolean", Description = "If true, return only file paths (no content). Default false." },
["whole_word"] = new() { Type = "boolean", Description = "If true, match whole words only (wraps pattern in \\b...\\b). Default false." },
["invert"] = new() { Type = "boolean", Description = "If true, return lines NOT matching the pattern. Default false." },
["max_file_size_kb"] = new() { Type = "integer", Description = "Skip files larger than this size in KB. Default 1000." },
},
Required = ["pattern"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 5) : 0;
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var pattern = args.GetProperty("pattern").GetString() ?? "";
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var maxMatches = args.TryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
var filesOnly = args.TryGetProperty("files_only", out var fo) && fo.GetBoolean();
var wholeWord = args.TryGetProperty("whole_word", out var ww) && ww.GetBoolean();
var invert = args.TryGetProperty("invert", out var inv) && inv.GetBoolean();
var maxFileSizeKb = args.TryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
// context_lines is the base for both sides; before/after_lines override individually
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
var beforeLines = args.TryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
var afterLines = args.TryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
@@ -41,8 +57,10 @@ public class GrepTool : IAgentTool
try
{
// Build effective regex pattern
var effectivePattern = wholeWord ? $@"\b{pattern}\b" : pattern;
var regexOpts = RegexOptions.Compiled | (caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase);
var regex = new Regex(pattern, regexOpts, TimeSpan.FromSeconds(5));
var regex = new Regex(effectivePattern, regexOpts, TimeSpan.FromSeconds(5));
var filePattern = string.IsNullOrEmpty(globFilter) ? "*" : globFilter;
@@ -54,52 +72,110 @@ public class GrepTool : IAgentTool
else
return Task.FromResult(ToolResult.Fail($"경로가 존재하지 않습니다: {baseDir}"));
var sb = new StringBuilder();
long maxFileSizeBytes = (long)maxFileSizeKb * 1024;
// files_only mode: collect matching file paths only
if (filesOnly)
{
var matchingFiles = new List<string>();
foreach (var file in files)
{
if (ct.IsCancellationRequested) break;
if (!context.IsPathAllowed(file)) continue;
if (IsBinaryFile(file)) continue;
if (new FileInfo(file).Length > maxFileSizeBytes) continue;
try
{
var read = TextFileCodec.ReadAllText(file);
var lines = TextFileCodec.SplitLines(read.Text);
bool hit = lines.Any(line =>
invert ? !regex.IsMatch(line) : regex.IsMatch(line));
if (hit)
{
var rel = Directory.Exists(context.WorkFolder)
? Path.GetRelativePath(context.WorkFolder, file)
: file;
matchingFiles.Add(rel);
}
}
catch { /* 읽기 실패 파일 무시 */ }
}
if (matchingFiles.Count == 0)
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다."));
var fileList = string.Join("\n", matchingFiles);
return Task.FromResult(ToolResult.Ok($"{matchingFiles.Count}개 파일 발견:\n{fileList}"));
}
// Normal mode: show matching lines with context
var sb = new StringBuilder();
int matchCount = 0;
int fileCount = 0;
const int maxMatches = 100;
int fileCount = 0;
foreach (var file in files)
{
if (ct.IsCancellationRequested) break;
if (!context.IsPathAllowed(file)) continue;
if (IsBinaryFile(file)) continue;
if (new FileInfo(file).Length > maxFileSizeBytes) continue;
try
{
var read = TextFileCodec.ReadAllText(file);
var read = TextFileCodec.ReadAllText(file);
var lines = TextFileCodec.SplitLines(read.Text);
bool fileHit = false;
for (int i = 0; i < lines.Length && matchCount < maxMatches; i++)
// First pass: find all matching line indices in this file
var hitIndices = new List<int>();
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
if (!fileHit)
{
var rel = Directory.Exists(context.WorkFolder)
? Path.GetRelativePath(context.WorkFolder, file)
: file;
sb.AppendLine($"\n{rel}:");
fileHit = true;
fileCount++;
}
// 컨텍스트 라인 (before)
if (contextLines > 0)
{
for (int c = Math.Max(0, i - contextLines); c < i; c++)
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
}
sb.AppendLine($" {i + 1}: {lines[i].TrimEnd()}");
// 컨텍스트 라인 (after)
if (contextLines > 0)
{
for (int c = i + 1; c <= Math.Min(lines.Length - 1, i + contextLines); c++)
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
sb.AppendLine(" ---");
}
matchCount++;
}
bool isMatch = regex.IsMatch(lines[i]);
if (invert ? !isMatch : isMatch)
hitIndices.Add(i);
}
if (hitIndices.Count == 0) continue;
var rel = Directory.Exists(context.WorkFolder)
? Path.GetRelativePath(context.WorkFolder, file)
: file;
// File header with per-file match count
sb.AppendLine();
sb.AppendLine($"── {rel} ({hitIndices.Count}개 일치) ──");
int fileMatchCount = 0;
// Second pass: emit hits with context, avoiding duplicate lines
int lastEmittedLine = -1;
foreach (int idx in hitIndices)
{
if (matchCount >= maxMatches) break;
int from = Math.Max(0, idx - beforeLines);
int to = Math.Min(lines.Length - 1, idx + afterLines);
// If there's a gap between the previous context block and this one, add separator
if (lastEmittedLine >= 0 && from > lastEmittedLine + 1)
sb.AppendLine(" ···");
for (int c = Math.Max(from, lastEmittedLine + 1); c <= to; c++)
{
bool isHit = invert ? !regex.IsMatch(lines[c]) : regex.IsMatch(lines[c]);
var marker = isHit ? ">" : " ";
sb.AppendLine($" {marker} {c + 1,5}: {lines[c].TrimEnd()}");
}
lastEmittedLine = to;
matchCount++;
fileMatchCount++;
}
fileCount++;
}
catch { /* 읽기 실패 파일 무시 */ }
@@ -109,7 +185,10 @@ public class GrepTool : IAgentTool
if (matchCount == 0)
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 결과가 없습니다."));
var header = $"{fileCount}개 파일에서 {matchCount}개 일치{(matchCount >= maxMatches ? " ( )" : "")}:";
var limitNote = matchCount >= maxMatches ? $" (제한 {maxMatches}개 도달)" : "";
var invertNote = invert ? " [반전 검색]" : "";
var header = $"{fileCount}개 파일에서 {matchCount}개 일치{limitNote}{invertNote}:";
return Task.FromResult(ToolResult.Ok(header + sb));
}
catch (RegexParseException)

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@@ -9,6 +9,7 @@ namespace AxCopilot.Services.Agent;
/// HTML (.html) 보고서를 생성하는 내장 스킬.
/// 테마 무드(mood)를 선택하면 TemplateService에서 해당 CSS를 가져와 적용합니다.
/// TOC, 커버 페이지, 섹션 번호 등 고급 문서 기능을 지원합니다.
/// sections 파라미터로 구조화된 콘텐츠 블록(heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi)을 지원합니다.
/// </summary>
public class HtmlSkill : IAgentTool
{
@@ -18,6 +19,8 @@ public class HtmlSkill : IAgentTool
"badges (.badge-blue/green/red/yellow/purple), CSS bar charts (.chart-bar), " +
"progress bars (.progress), timelines (.timeline), grid layouts (.grid-2/3/4), " +
"and auto section numbering. " +
"Use 'sections' array for structured content (heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi) " +
"instead of raw HTML body. Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " +
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.";
public ToolParameterSchema Parameters => new()
@@ -30,29 +33,76 @@ public class HtmlSkill : IAgentTool
"div.callout-info/warning/tip/danger for callouts, span.badge-blue/green/red for badges, " +
"div.chart-bar>div.bar-item for charts, div.grid-2/3/4 for grid layouts, " +
"div.timeline>div.timeline-item for timelines, div.progress for progress bars." },
["sections"] = new()
{
Type = "array",
Description = "Structured content blocks. Each object has a 'type' field. " +
"Types: 'heading' {level:1-4, text}, " +
"'paragraph' {text} — supports **bold** *italic* `code` [link](url) inline markdown, " +
"'callout' {style:'info|warning|tip|danger', title, text}, " +
"'table' {headers:[], rows:[[]]}, " +
"'chart' {kind:'bar|horizontal_bar', title, data:[{label, value, color}]}, " +
"'cards' {items:[{title, body, badge, icon}]}, " +
"'list' {style:'bullet|number', items:['item', ' - sub-item']}, " +
"'quote' {text, author}, " +
"'divider', " +
"'kpi' {items:[{label, value, change, positive:bool}]}. " +
"When both body and sections are provided, sections are appended after body."
},
["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard. Default: modern" },
["accent_color"] = new() { Type = "string", Description = "Hex color string (e.g. '#2E75B6') that overrides the CSS primary/accent color. Affects buttons, headings, borders, chart bars." },
["style"] = new() { Type = "string", Description = "Optional additional CSS. Appended after mood+shared CSS." },
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" },
["numbered"] = new() { Type = "boolean", Description = "Auto-number h2/h3 sections (1., 1-1., etc). Default: false" },
["print"] = new() { Type = "boolean", Description = "If true, adds @media print CSS: removes shadows, adds borders, forces white background, page-breaks before h2, adjusts font sizes. Default: false" },
["cover"] = new()
{
Type = "object",
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
},
},
Required = ["path", "title", "body"]
Required = ["title"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.GetProperty("title").GetString() ?? "Report";
var body = args.GetProperty("body").GetString() ?? "";
// 필수 파라미터 안전 추출
if (!args.TryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
return ToolResult.Fail("필수 파라미터 누락: 'title'");
// path 미제공 시 title로 자동 생성
args.TryGetProperty("path", out var pathEl);
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.GetString()))
pathEl = default; // 아래에서 title 기반으로 생성
// body와 sections 둘 다 없으면 오류
bool hasBody = args.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
if (!hasBody && !hasSections)
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
var title = titleEl.GetString() ?? "Report";
// path가 없으면 title에서 안전한 파일명 생성
string path;
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.GetString()))
{
path = pathEl.GetString()!;
}
else
{
var safeName = Regex.Replace(title, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safeName.Length > 60) safeName = safeName[..60].TrimEnd();
if (string.IsNullOrWhiteSpace(safeName)) safeName = "report";
path = safeName + ".html";
}
var body = hasBody ? (bodyEl.GetString() ?? "") : "";
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.GetBoolean();
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.GetBoolean();
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.TryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var accentColor = args.TryGetProperty("accent_color", out var accentEl) ? accentEl.GetString() : null;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -73,12 +123,29 @@ public class HtmlSkill : IAgentTool
// 스타일 결정: mood CSS + shared CSS + custom
var style = TemplateService.GetCss(mood);
// accent_color 주입
if (!string.IsNullOrEmpty(accentColor))
style = InjectAccentColor(style, accentColor);
// print CSS 추가
if (usePrint)
style += "\n" + GeneratePrintCss();
if (!string.IsNullOrEmpty(customStyle))
style += "\n" + customStyle;
var moodInfo = TemplateService.GetMood(mood);
var moodLabel = moodInfo != null ? $" · {moodInfo.Icon} {moodInfo.Label}" : "";
// sections → HTML 변환
if (hasSections)
{
var sectionsHtml = RenderSections(sectionsEl, accentColor);
// body + sections 합산 (body 있으면 뒤에 붙이기)
body = hasBody ? body + "\n" + sectionsHtml : sectionsHtml;
}
// 섹션 번호 자동 부여 — h2, h3에 class="numbered" 추가
if (useNumbered)
body = AddNumberedClass(body);
@@ -133,6 +200,9 @@ public class HtmlSkill : IAgentTool
if (useToc) features.Add("목차");
if (useNumbered) features.Add("섹션번호");
if (hasCover) features.Add("커버페이지");
if (hasSections) features.Add("구조화섹션");
if (!string.IsNullOrEmpty(accentColor)) features.Add($"색상:{accentColor}");
if (usePrint) features.Add("인쇄최적화");
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
return ToolResult.Ok(
@@ -145,6 +215,407 @@ public class HtmlSkill : IAgentTool
}
}
// ─────────────────────────────────────────────────────────────────────────
// sections 렌더링
// ─────────────────────────────────────────────────────────────────────────
/// <summary>sections 배열을 순회하여 HTML 문자열로 변환</summary>
private static string RenderSections(JsonElement sections, string? accentColor)
{
var sb = new StringBuilder();
foreach (var section in sections.EnumerateArray())
{
if (!section.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString() ?? "";
switch (type.ToLowerInvariant())
{
case "heading":
sb.AppendLine(RenderHeading(section));
break;
case "paragraph":
sb.AppendLine(RenderParagraph(section));
break;
case "callout":
sb.AppendLine(RenderCallout(section));
break;
case "table":
sb.AppendLine(RenderTable(section));
break;
case "chart":
sb.AppendLine(RenderChart(section, accentColor));
break;
case "cards":
sb.AppendLine(RenderCards(section));
break;
case "list":
sb.AppendLine(RenderList(section));
break;
case "quote":
sb.AppendLine(RenderQuote(section));
break;
case "divider":
sb.AppendLine("<hr class=\"section-divider\">");
break;
case "kpi":
sb.AppendLine(RenderKpi(section));
break;
}
}
return sb.ToString();
}
private static string RenderHeading(JsonElement s)
{
var level = s.TryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
return $"<h{level}>{Escape(text)}</h{level}>";
}
private static string RenderParagraph(JsonElement s)
{
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
return $"<p>{MarkdownToHtml(text)}</p>";
}
private static string RenderCallout(JsonElement s)
{
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "info" : "info";
var title = s.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
var text = s.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "";
var icon = style switch { "warning" => "⚠️", "tip" => "💡", "danger" => "🚨", _ => "" };
var sb = new StringBuilder();
sb.AppendLine($"<div class=\"callout-{style}\">");
if (!string.IsNullOrEmpty(title))
sb.AppendLine($"<strong>{icon} {Escape(title)}</strong>");
sb.AppendLine($"<p>{MarkdownToHtml(text)}</p>");
sb.AppendLine("</div>");
return sb.ToString();
}
private static string RenderTable(JsonElement s)
{
var sb = new StringBuilder();
sb.AppendLine("<div style=\"overflow-x:auto\">");
sb.AppendLine("<table>");
if (s.TryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
{
sb.AppendLine("<thead><tr>");
foreach (var h in headers.EnumerateArray())
sb.Append($"<th>{Escape(h.GetString() ?? "")}</th>");
sb.AppendLine("</tr></thead>");
}
if (s.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
{
sb.AppendLine("<tbody>");
foreach (var row in rows.EnumerateArray())
{
sb.Append("<tr>");
if (row.ValueKind == JsonValueKind.Array)
foreach (var cell in row.EnumerateArray())
sb.Append($"<td>{MarkdownToHtml(cell.GetString() ?? "")}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
}
sb.AppendLine("</table>");
sb.AppendLine("</div>");
return sb.ToString();
}
private static string RenderChart(JsonElement s, string? accentColor)
{
var kind = s.TryGetProperty("kind", out var k) ? k.GetString() ?? "bar" : "bar";
var chartTitle = s.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
if (!s.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return "";
var items = new List<(string label, double value, string color)>();
double maxVal = 0;
foreach (var item in data.EnumerateArray())
{
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
var value = item.TryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
var color = item.TryGetProperty("color", out var cl) ? cl.GetString() ?? "#2E75B6" : "#2E75B6";
items.Add((label, value, color));
if (value > maxVal) maxVal = value;
}
if (items.Count == 0) return "";
// Horizontal bar chart SVG
var defaultAccent = string.IsNullOrEmpty(accentColor) ? "#2E75B6" : accentColor;
int rowHeight = 44;
int paddingTop = string.IsNullOrEmpty(chartTitle) ? 12 : 38;
int paddingBottom = 20;
int svgHeight = paddingTop + items.Count * rowHeight + paddingBottom;
int labelWidth = 120;
int valueWidth = 56;
int barAreaWidth = 560; // nominal; SVG is 100% wide so we use viewBox
int totalWidth = labelWidth + barAreaWidth + valueWidth;
var sb = new StringBuilder();
sb.AppendLine($"<div class=\"chart-section\" style=\"margin:1.5rem 0\">");
if (!string.IsNullOrEmpty(chartTitle))
sb.AppendLine($"<div style=\"font-weight:600;font-size:1rem;margin-bottom:8px\">{Escape(chartTitle)}</div>");
sb.AppendLine($"<svg viewBox=\"0 0 {totalWidth} {svgHeight}\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" style=\"display:block\">");
// background grid lines (light)
int gridSteps = 5;
for (int i = 0; i <= gridSteps; i++)
{
int x = labelWidth + (int)(barAreaWidth * i / gridSteps);
sb.AppendLine($" <line x1=\"{x}\" y1=\"{paddingTop - 6}\" x2=\"{x}\" y2=\"{paddingTop + items.Count * rowHeight}\" stroke=\"#e5e7eb\" stroke-width=\"1\"/>");
}
// bars
for (int i = 0; i < items.Count; i++)
{
var (label, value, color) = items[i];
int y = paddingTop + i * rowHeight;
int barHeight = 24;
int barY = y + (rowHeight - barHeight) / 2;
double ratio = maxVal > 0 ? value / maxVal : 0;
int barW = Math.Max(4, (int)(barAreaWidth * ratio));
string barColor = color == "#2E75B6" && !string.IsNullOrEmpty(accentColor) ? defaultAccent : color;
// label (right-aligned in label area)
sb.AppendLine($" <text x=\"{labelWidth - 8}\" y=\"{barY + barHeight / 2 + 5}\" text-anchor=\"end\" font-size=\"13\" fill=\"#374151\" font-family=\"sans-serif\">{Escape(label)}</text>");
// bar
sb.AppendLine($" <rect x=\"{labelWidth}\" y=\"{barY}\" width=\"{barW}\" height=\"{barHeight}\" rx=\"3\" fill=\"{barColor}\" opacity=\"0.9\"/>");
// value label
var valStr = value == Math.Floor(value) ? ((long)value).ToString("N0") : value.ToString("G4");
sb.AppendLine($" <text x=\"{labelWidth + barW + 6}\" y=\"{barY + barHeight / 2 + 5}\" font-size=\"12\" fill=\"#6b7280\" font-family=\"sans-serif\">{Escape(valStr)}</text>");
}
sb.AppendLine("</svg>");
sb.AppendLine("</div>");
return sb.ToString();
}
private static string RenderCards(JsonElement s)
{
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"cards-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin:1rem 0\">");
foreach (var item in items.EnumerateArray())
{
var cardTitle = item.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
var cardBody = item.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
var badge = item.TryGetProperty("badge", out var bg) ? bg.GetString() : null;
var icon = item.TryGetProperty("icon", out var ic) ? ic.GetString() : null;
sb.AppendLine("<div class=\"card\" style=\"border:1px solid #e5e7eb;border-radius:8px;padding:1.1rem;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.07)\">");
sb.Append("<div style=\"display:flex;align-items:center;gap:8px;margin-bottom:6px\">");
if (!string.IsNullOrEmpty(icon))
sb.Append($"<span style=\"font-size:1.4rem\">{icon}</span>");
sb.Append($"<strong style=\"font-size:0.95rem\">{Escape(cardTitle)}</strong>");
if (!string.IsNullOrEmpty(badge))
sb.Append($"<span style=\"margin-left:auto;font-size:0.72rem;background:#eff6ff;color:#2563eb;border-radius:4px;padding:1px 7px;font-weight:600\">{Escape(badge)}</span>");
sb.AppendLine("</div>");
if (!string.IsNullOrEmpty(cardBody))
sb.AppendLine($"<div style=\"font-size:0.88rem;color:#6b7280;line-height:1.5\">{MarkdownToHtml(cardBody)}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
return sb.ToString();
}
private static string RenderList(JsonElement s)
{
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "bullet" : "bullet";
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var tag = style == "number" ? "ol" : "ul";
var sb = new StringBuilder();
// Support simple indentation via leading spaces/dashes
// Items starting with " - " or " * " are treated as sub-items
sb.AppendLine($"<{tag}>");
bool inSubList = false;
foreach (var item in items.EnumerateArray())
{
var text = item.GetString() ?? "";
bool isSub = text.StartsWith(" ");
if (isSub && !inSubList)
{
sb.AppendLine($"<{tag} style=\"margin-top:4px\">");
inSubList = true;
}
else if (!isSub && inSubList)
{
sb.AppendLine($"</{tag}>");
inSubList = false;
}
var cleaned = isSub ? Regex.Replace(text.TrimStart(), @"^[-*]\s*", "") : text;
sb.AppendLine($"<li>{MarkdownToHtml(cleaned)}</li>");
}
if (inSubList) sb.AppendLine($"</{tag}>");
sb.AppendLine($"</{tag}>");
return sb.ToString();
}
private static string RenderQuote(JsonElement s)
{
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var author = s.TryGetProperty("author", out var a) ? a.GetString() : null;
var sb = new StringBuilder();
sb.AppendLine("<blockquote style=\"border-left:4px solid #d1d5db;margin:1.5rem 0;padding:.75rem 1.25rem;background:#f9fafb;border-radius:0 6px 6px 0\">");
sb.AppendLine($"<p style=\"margin:0;font-style:italic;color:#374151\">{MarkdownToHtml(text)}</p>");
if (!string.IsNullOrEmpty(author))
sb.AppendLine($"<footer style=\"margin-top:8px;font-size:0.85rem;color:#6b7280\">— {Escape(author)}</footer>");
sb.AppendLine("</blockquote>");
return sb.ToString();
}
private static string RenderKpi(JsonElement s)
{
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"kpi-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin:1.25rem 0\">");
foreach (var item in items.EnumerateArray())
{
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
var value = item.TryGetProperty("value", out var vl) ? vl.GetString() ?? "" : "";
var change = item.TryGetProperty("change", out var ch) ? ch.GetString() : null;
var positive = item.TryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
var changeColor = positive ? "#16a34a" : "#dc2626";
var changeArrow = positive ? "▲" : "▼";
sb.AppendLine("<div style=\"background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:1.1rem 1.25rem;box-shadow:0 1px 4px rgba(0,0,0,.06)\">");
sb.AppendLine($"<div style=\"font-size:0.82rem;color:#6b7280;font-weight:500;text-transform:uppercase;letter-spacing:.04em\">{Escape(label)}</div>");
sb.AppendLine($"<div style=\"font-size:1.75rem;font-weight:700;color:#111827;margin:.25rem 0\">{Escape(value)}</div>");
if (!string.IsNullOrEmpty(change))
sb.AppendLine($"<div style=\"font-size:0.85rem;color:{changeColor};font-weight:600\">{changeArrow} {Escape(change)}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
return sb.ToString();
}
// ─────────────────────────────────────────────────────────────────────────
// Markdown 인라인 변환
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 인라인 마크다운을 HTML로 변환합니다.
/// 지원: **bold**, *italic*, `code`, [text](url), \n → br
/// HTML 특수문자는 먼저 이스케이프한 뒤 변환합니다.
/// </summary>
private static string MarkdownToHtml(string text)
{
if (string.IsNullOrEmpty(text)) return text;
// 1. HTML 이스케이프 (먼저 처리해서 XSS 방지)
text = text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
// 2. [text](url) → <a> — 이스케이프 후 처리 (url의 &amp; 등 고려)
text = Regex.Replace(text,
@"\[([^\]]+)\]\((https?://[^\)]+)\)",
m => $"<a href=\"{m.Groups[2].Value}\" target=\"_blank\" rel=\"noopener\">{m.Groups[1].Value}</a>");
// 3. **bold**
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
// 4. *italic* (single asterisk, not preceded/followed by another)
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
// 5. `code`
text = Regex.Replace(text, @"`([^`]+)`", "<code>$1</code>");
// 6. newline → <br>
text = text.Replace("\n", "<br>");
return text;
}
// ─────────────────────────────────────────────────────────────────────────
// accent_color 주입
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// CSS의 :root { } 블록에 --accent 및 --accent-light CSS 변수를 주입합니다.
/// :root가 없으면 맨 앞에 새로 추가합니다.
/// </summary>
private static string InjectAccentColor(string css, string hexColor)
{
// Compute a lighter version by mixing with white at 80%
var accentLight = LightenHex(hexColor, 0.82);
var varBlock = $"--accent: {hexColor}; --accent-light: {accentLight};";
// Try to inject into existing :root { }
if (Regex.IsMatch(css, @":root\s*\{"))
{
return Regex.Replace(css, @"(:root\s*\{)", $"$1\n {varBlock}");
}
else
{
// Prepend :root block
return $":root {{\n {varBlock}\n}}\n" + css;
}
}
/// <summary>hex 색상을 white와 mix하여 밝은 버전 생성 (ratio=0~1, 높을수록 더 밝음)</summary>
private static string LightenHex(string hex, double ratio)
{
hex = hex.TrimStart('#');
if (hex.Length == 3) hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]);
if (hex.Length != 6) return hex;
try
{
int r = Convert.ToInt32(hex[..2], 16);
int g = Convert.ToInt32(hex[2..4], 16);
int b = Convert.ToInt32(hex[4..6], 16);
r = (int)Math.Round(r + (255 - r) * ratio);
g = (int)Math.Round(g + (255 - g) * ratio);
b = (int)Math.Round(b + (255 - b) * ratio);
return $"#{r:X2}{g:X2}{b:X2}";
}
catch { return "#" + hex; }
}
// ─────────────────────────────────────────────────────────────────────────
// print CSS
// ─────────────────────────────────────────────────────────────────────────
private static string GeneratePrintCss() => @"
@media print {
body { background: #fff !important; font-size: 11pt; }
.container { max-width: 100% !important; padding: 0 !important; }
* { box-shadow: none !important; text-shadow: none !important; }
a { color: inherit; text-decoration: underline; }
a[href]::after { content: "" ("" attr(href) "")""; font-size: 0.8em; color: #555; }
a[href^=""#""]::after { content: """"; }
h2 { page-break-before: always; }
h1, h2, h3, h4 { page-break-after: avoid; }
table { border-collapse: collapse !important; }
th, td { border: 1px solid #999 !important; }
tr { page-break-inside: avoid; }
blockquote { border: 1px solid #bbb !important; page-break-inside: avoid; }
.callout-info, .callout-warning, .callout-tip, .callout-danger {
border: 1px solid #bbb !important;
background: #fff !important;
page-break-inside: avoid;
}
.kpi-grid > div, .cards-grid > div { border: 1px solid #ccc !important; }
nav.toc { page-break-after: always; }
.cover-page { page-break-after: always; background: none !important; color: #000 !important; }
}";
// ─────────────────────────────────────────────────────────────────────────
// 기존 헬퍼 메서드 (변경 없음)
// ─────────────────────────────────────────────────────────────────────────
/// <summary>h2, h3 태그에 id 속성이 없으면 자동 부여</summary>
private static string EnsureHeadingIds(string html)
{

View File

@@ -1,35 +1,88 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Markdown (.md) 문서를 생성하는 내장 스킬.
/// LLM이 마크다운 내용을 전달하면 파일로 저장합니다.
/// sections 배열로 구조화된 콘텐츠 블록(heading/paragraph/table/list/callout/code/quote/divider/toc)을 지원합니다.
/// frontmatter, toc 옵션으로 메타데이터와 목차를 자동 생성할 수 있습니다.
/// content 파라미터로 기존 방식의 원시 마크다운도 계속 지원합니다.
/// </summary>
public class MarkdownSkill : IAgentTool
{
public string Name => "markdown_create";
public string Description => "Create a Markdown (.md) document file. Provide the content in Markdown format.";
public string Description =>
"Create a Markdown (.md) document. " +
"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. " +
"Use 'content' for raw markdown (backward compatible).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.md). Relative to work folder." },
["content"] = new() { Type = "string", Description = "Markdown content to write" },
["title"] = new() { Type = "string", Description = "Optional document title. If provided, prepends '# title' at the top." },
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." },
["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." },
["content"] = new() { Type = "string", Description = "원시 마크다운 내용 (하위 호환). sections가 없을 때 사용합니다." },
["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 = ["path", "content"]
Required = []
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var title = args.TryGetProperty("title", out var t) ? t.GetString() : null;
// ── 필수 파라미터 ──────────────────────────────────────────────────
var hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.TryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasFrontmatter= args.TryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
if (!hasSections && !hasContent)
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
// path 미제공 시 title에서 자동 생성
string path;
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
{
path = pathEl.GetString()!;
}
else
{
var baseTitle = (args.TryGetProperty("title", out var autoT) ? autoT.GetString() : 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.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null;
var useToc = args.TryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "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))
@@ -43,27 +96,285 @@ public class MarkdownSkill : IAgentTool
try
{
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
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.GetString() ?? ""
: 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();
}
sb.Append(content);
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
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.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString()?.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.GetString() ?? "";
var headings = ParseHeadingsFromContent(raw);
if (headings.Count > 0)
{
RenderToc(sb, headings);
sb.AppendLine();
}
}
sb.Append(contentEl.GetString() ?? "");
}
// ── 파일 쓰기 ──────────────────────────────────────────────────
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} lines)",
$"Markdown 문서 저장 완료: {fullPath} ({lines}줄, 인코딩: {encodingName})",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"Markdown 저장 실패: {ex.Message}");
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.TryGetProperty("type", out var t) || t.GetString() != "heading") continue;
var level = s.TryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
var text = s.TryGetProperty("text", out var xEl) ? xEl.GetString() ?? "" : "";
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.TryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
sb.AppendLine($"{new string('#', level)} {text}");
sb.AppendLine();
}
private static void RenderParagraph(StringBuilder sb, JsonElement s)
{
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
sb.AppendLine();
}
}
private static void RenderTable(StringBuilder sb, JsonElement s)
{
if (!s.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return;
if (!s.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return;
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.GetString() ?? "")).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.TryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
return;
var ordered = s.TryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
int idx = 1;
foreach (var item in itemsEl.EnumerateArray())
{
var text = item.ValueKind == JsonValueKind.String ? item.GetString() ?? "" : 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.TryGetProperty("style", out var stEl) ? stEl.GetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
// 표준 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.TryGetProperty("language", out var lEl) ? lEl.GetString() ?? "" : "";
var code = s.TryGetProperty("code", out var cEl) ? cEl.GetString() ?? "" : "";
sb.AppendLine($"```{lang}");
sb.AppendLine(code);
sb.AppendLine("```");
sb.AppendLine();
}
private static void RenderQuote(StringBuilder sb, JsonElement s)
{
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
var author = s.TryGetProperty("author", out var aEl) ? aEl.GetString() : null;
foreach (var line in text.Split('\n'))
sb.AppendLine($"> {line}");
if (!string.IsNullOrEmpty(author))
sb.AppendLine($">");
sb.AppendLine($"> — {author}");
sb.AppendLine();
}
}

View File

@@ -0,0 +1,173 @@
namespace AxCopilot.Services.Agent;
public static class ModelExecutionProfileCatalog
{
public sealed record ExecutionPolicy(
string Key,
string Label,
bool ForceInitialToolCall,
bool ForceToolCallAfterPlan,
double? ToolTemperatureCap,
int NoToolResponseThreshold,
int NoToolRecoveryMaxRetries,
int PlanExecutionRetryMax,
int DocumentPlanRetryMax,
bool PreferAggressiveDocumentFallback,
bool ReduceEarlyMemoryPressure,
bool EnablePostToolVerification,
bool EnableCodeQualityGates,
bool EnableDocumentVerificationGate,
bool EnableParallelReadBatch,
int MaxParallelReadBatch,
int CodeVerificationGateMaxRetries,
int HighImpactBuildTestGateMaxRetries,
int FinalReportGateMaxRetries,
int CodeDiffGateMaxRetries,
int RecentExecutionGateMaxRetries,
int ExecutionSuccessGateMaxRetries,
int DocumentVerificationGateMaxRetries,
int TerminalEvidenceGateMaxRetries);
public static string Normalize(string? key)
{
var normalized = (key ?? "").Trim().ToLowerInvariant();
return normalized switch
{
"tool_call_strict" => "tool_call_strict",
"reasoning_first" => "reasoning_first",
"fast_readonly" => "fast_readonly",
"document_heavy" => "document_heavy",
_ => "balanced",
};
}
public static ExecutionPolicy Get(string? key)
=> Normalize(key) switch
{
"tool_call_strict" => new ExecutionPolicy(
"tool_call_strict",
"도구 호출 우선",
ForceInitialToolCall: true,
ForceToolCallAfterPlan: true,
ToolTemperatureCap: 0.2,
NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 1,
PlanExecutionRetryMax: 1,
DocumentPlanRetryMax: 1,
PreferAggressiveDocumentFallback: true,
ReduceEarlyMemoryPressure: true,
EnablePostToolVerification: false,
EnableCodeQualityGates: true,
EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 8,
CodeVerificationGateMaxRetries: 1,
HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1,
CodeDiffGateMaxRetries: 1,
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1),
"reasoning_first" => new ExecutionPolicy(
"reasoning_first",
"추론 우선",
ForceInitialToolCall: false,
ForceToolCallAfterPlan: false,
ToolTemperatureCap: 0.45,
NoToolResponseThreshold: 2,
NoToolRecoveryMaxRetries: 2,
PlanExecutionRetryMax: 2,
DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: false,
EnablePostToolVerification: true,
EnableCodeQualityGates: true,
EnableDocumentVerificationGate: true,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 2,
HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1,
CodeDiffGateMaxRetries: 1,
RecentExecutionGateMaxRetries: 1,
ExecutionSuccessGateMaxRetries: 1,
DocumentVerificationGateMaxRetries: 1,
TerminalEvidenceGateMaxRetries: 1),
"fast_readonly" => new ExecutionPolicy(
"fast_readonly",
"읽기 속도 우선",
ForceInitialToolCall: true,
ForceToolCallAfterPlan: false,
ToolTemperatureCap: 0.25,
NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 1,
PlanExecutionRetryMax: 1,
DocumentPlanRetryMax: 1,
PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: true,
EnablePostToolVerification: false,
EnableCodeQualityGates: false,
EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 10,
CodeVerificationGateMaxRetries: 0,
HighImpactBuildTestGateMaxRetries: 0,
FinalReportGateMaxRetries: 0,
CodeDiffGateMaxRetries: 0,
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 0),
"document_heavy" => new ExecutionPolicy(
"document_heavy",
"문서 생성 우선",
ForceInitialToolCall: true,
ForceToolCallAfterPlan: true,
ToolTemperatureCap: 0.35,
NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 1,
PlanExecutionRetryMax: 1,
DocumentPlanRetryMax: 0,
PreferAggressiveDocumentFallback: true,
ReduceEarlyMemoryPressure: true,
EnablePostToolVerification: false,
EnableCodeQualityGates: false,
EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 0,
HighImpactBuildTestGateMaxRetries: 0,
FinalReportGateMaxRetries: 0,
CodeDiffGateMaxRetries: 0,
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1),
_ => new ExecutionPolicy(
"balanced",
"균형",
ForceInitialToolCall: true,
ForceToolCallAfterPlan: true,
ToolTemperatureCap: 0.35,
NoToolResponseThreshold: 2,
NoToolRecoveryMaxRetries: 2,
PlanExecutionRetryMax: 2,
DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: false,
EnablePostToolVerification: true,
EnableCodeQualityGates: true,
EnableDocumentVerificationGate: true,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 2,
HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1,
CodeDiffGateMaxRetries: 1,
RecentExecutionGateMaxRetries: 1,
ExecutionSuccessGateMaxRetries: 1,
DocumentVerificationGateMaxRetries: 1,
TerminalEvidenceGateMaxRetries: 1),
};
}

View File

@@ -1,16 +1,21 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구.</summary>
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구 (최대 20개).</summary>
public class MultiReadTool : IAgentTool
{
private const int MaxFiles = 20;
private const int DefaultMaxLines = 300;
private const int HardMaxLines = 2000;
public string Name => "multi_read";
public string Description =>
"Read multiple files in a single call (max 10). " +
$"Read multiple files in a single call (max {MaxFiles}). " +
"Returns concatenated contents with file headers. " +
"Supports line offset, per-file line limits, and optional encoding display. " +
"More efficient than calling file_read multiple times.";
public ToolParameterSchema Parameters => new()
@@ -19,14 +24,24 @@ public class MultiReadTool : IAgentTool
{
["paths"] = new()
{
Type = "array",
Description = "List of file paths to read (max 10)",
Items = new() { Type = "string", Description = "File path" },
Type = "array",
Description = $"List of file paths to read (max {MaxFiles})",
Items = new() { Type = "string", Description = "File path (absolute or relative to work folder)" },
},
["max_lines"] = new()
{
Type = "integer",
Description = "Max lines per file (default 200)",
Type = "integer",
Description = $"Max lines to read per file (default {DefaultMaxLines}, max {HardMaxLines})",
},
["offset"] = new()
{
Type = "integer",
Description = "1-based line offset: skip this many lines from the start of each file before reading (default 1 = start from first line)",
},
["show_encoding"] = new()
{
Type = "boolean",
Description = "If true, include the detected encoding in each file's header (default false)",
},
},
Required = ["paths"],
@@ -34,61 +49,126 @@ public class MultiReadTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var maxLines = args.TryGetProperty("max_lines", out var ml) ? ml.GetInt32() : 200;
if (maxLines <= 0) maxLines = 200;
// --- Parse parameters ---
var maxLines = DefaultMaxLines;
if (args.TryGetProperty("max_lines", out var mlEl))
{
maxLines = mlEl.GetInt32();
if (maxLines <= 0) maxLines = DefaultMaxLines;
if (maxLines > HardMaxLines) maxLines = HardMaxLines;
}
// offset is 1-based; convert to 0-based skip count
var offsetParam = 1;
if (args.TryGetProperty("offset", out var offEl))
{
offsetParam = offEl.GetInt32();
if (offsetParam < 1) offsetParam = 1;
}
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1)
var showEncoding = args.TryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
// --- Validate paths array ---
if (!args.TryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
var paths = new List<string>();
var rawPaths = new List<string>();
foreach (var p in pathsEl.EnumerateArray())
{
var s = p.GetString();
if (!string.IsNullOrEmpty(s)) paths.Add(s);
if (!string.IsNullOrEmpty(s)) rawPaths.Add(s);
}
if (paths.Count == 0)
if (rawPaths.Count == 0)
return Task.FromResult(ToolResult.Fail("읽을 파일이 없습니다."));
if (paths.Count > 10)
return Task.FromResult(ToolResult.Fail("최대 10개 파일만 지원합니다."));
if (rawPaths.Count > MaxFiles)
return Task.FromResult(ToolResult.Fail($"최대 {MaxFiles}개 파일만 지원합니다. (요청: {rawPaths.Count}개)"));
var sb = new StringBuilder();
// --- Read each file ---
var sb = new StringBuilder();
var readCount = 0;
foreach (var rawPath in paths)
foreach (var rawPath in rawPaths)
{
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
sb.AppendLine($"═══ {Path.GetFileName(path)} ═══");
sb.AppendLine($"Path: {path}");
var path = FileReadTool.ResolvePath(rawPath, context.WorkFolder);
var fileName = Path.GetFileName(path);
if (!context.IsPathAllowed(path))
{
AppendHeader(sb, fileName, path, encodingLabel: null, showEncoding, totalLines: null);
sb.AppendLine("[접근 차단됨]");
sb.AppendLine();
continue;
}
else if (!File.Exists(path))
if (!File.Exists(path))
{
AppendHeader(sb, fileName, path, encodingLabel: null, showEncoding, totalLines: null);
sb.AppendLine("[파일 없음]");
sb.AppendLine();
continue;
}
else
try
{
try
var read = TextFileCodec.ReadAllText(path);
var allLines = TextFileCodec.SplitLines(read.Text);
var totalLines = allLines.Length;
var encLabel = showEncoding ? read.Encoding.WebName : null;
AppendHeader(sb, fileName, path, encLabel, showEncoding, totalLines);
// Apply offset (skip) and max_lines limit
var startIdx = Math.Min(skipLines, totalLines);
var available = totalLines - startIdx;
var takeCount = Math.Min(available, maxLines);
var truncated = available > maxLines;
for (var i = 0; i < takeCount; i++)
{
var lines = File.ReadLines(path).Take(maxLines).ToList();
for (var i = 0; i < lines.Count; i++)
sb.AppendLine($"{i + 1}\t{lines[i]}");
if (lines.Count >= maxLines)
sb.AppendLine($"... (이후 생략, max_lines={maxLines})");
readCount++;
}
catch (Exception ex)
{
sb.AppendLine($"[읽기 오류: {ex.Message}]");
var lineNum = startIdx + i + 1; // 1-based line number in the original file
sb.Append(lineNum);
sb.Append('\t');
sb.AppendLine(allLines[startIdx + i]);
}
if (truncated)
sb.AppendLine($"... (이후 생략: {available - takeCount}줄 더 있음, max_lines={maxLines})");
readCount++;
}
catch (Exception ex)
{
sb.AppendLine($"[읽기 오류: {ex.Message}]");
}
sb.AppendLine();
}
return Task.FromResult(ToolResult.Ok($"{readCount}/{paths.Count}개 파일 읽기 완료.\n\n{sb}"));
var summary = $"{readCount}/{rawPaths.Count}개 파일 읽기 완료 " +
$"(max_lines={maxLines}, offset={offsetParam})\n\n{sb}";
return Task.FromResult(ToolResult.Ok(summary));
}
/// <summary>
/// 파일 헤더 블록을 StringBuilder에 추가합니다.
/// </summary>
private static void AppendHeader(
StringBuilder sb,
string fileName,
string fullPath,
string? encodingLabel,
bool showEncoding,
int? totalLines)
{
sb.AppendLine($"═══ {fileName} ═══");
sb.AppendLine($"Path: {fullPath}");
if (totalLines.HasValue)
sb.AppendLine($"Total lines: {totalLines.Value:N0}");
if (showEncoding && encodingLabel != null)
sb.AppendLine($"Encoding: {encodingLabel}");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,10 @@ internal static class ToolResultPresentationCatalog
return "build_test";
if (tool.Contains("git") || tool.Contains("diff"))
return "git";
if (tool is "html_create" or "docx_create" or "excel_create" or "xlsx_create"
or "csv_create" or "markdown_create" or "md_create" or "script_create"
or "pptx_create")
return "document";
if (tool.Contains("document") || tool.Contains("format") || tool.Contains("template"))
return "document";
if (tool.Contains("skill"))