모델 프로파일 기반 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

@@ -55,6 +55,30 @@
</Setter.Value>
</Setter>
</Style>
<!-- 전역 도움말 툴팁: Chat/Settings/AgentSettings 어디서든 동일하게 사용 -->
<Style x:Key="HelpTooltipStyle" TargetType="ToolTip">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="HasDropShadow" Value="False"/>
<Setter Property="MaxWidth" Value="320"/>
<Setter Property="Placement" Value="Bottom"/>
<Setter Property="Background" Value="#1F2440"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="TextElement.Foreground" Value="White"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Background="{TemplateBinding Background}"
CornerRadius="10"
Padding="14,10"
BorderBrush="{DynamicResource AccentColor}"
BorderThickness="1">
<ContentPresenter/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -115,9 +115,14 @@ public partial class App : System.Windows.Application
_indexService = new IndexService(settings);
var indexService = _indexService;
indexService.LoadCachedIndex();
if (indexService.HasCachedIndexLoaded)
indexService.StartWatchers();
// 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지
// FuzzyEngine은 IndexEntriesChanged 이벤트를 구독하여 캐시 로드 완료 시 자동 반영됨
_ = Task.Run(() =>
{
indexService.LoadCachedIndex();
if (indexService.HasCachedIndexLoaded)
indexService.StartWatchers();
});
var fuzzyEngine = new FuzzyEngine(indexService);
var commandResolver = new CommandResolver(fuzzyEngine, settings);
var contextManager = new ContextManager(settings);
@@ -283,7 +288,12 @@ public partial class App : System.Windows.Application
var vm = new LauncherViewModel(commandResolver, settings);
_launcher = new LauncherWindow(vm)
{
OpenSettingsAction = OpenSettings
OpenSettingsAction = OpenSettings,
SendToChatAction = msg =>
{
OpenAiChat();
_chatWindow?.SendInitialMessage(msg);
}
};
// ─── 클립보드 히스토리 초기화 (메시지 펌프 시작 직후 — 런처 표시 불필요) ──

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

View File

@@ -18,7 +18,8 @@ public class FuzzyEngine
public FuzzyEngine(IndexService index)
{
_index = index;
_index.IndexRebuilt += (_, _) => InvalidateQueryCache();
// IndexRebuilt(전체 재색인)와 증분 갱신 모두에서 캐시 무효화
_index.IndexEntriesChanged += (_, _) => InvalidateQueryCache();
}
/// <summary>

View File

@@ -777,6 +777,10 @@ public class LlmSettings
[JsonPropertyName("temperature")]
public double Temperature { get; set; } = 0.7;
/// <summary>등록 모델 프로파일의 자동 temperature 정책 사용 여부. 기본값 true.</summary>
[JsonPropertyName("useAutomaticProfileTemperature")]
public bool UseAutomaticProfileTemperature { get; set; } = true;
/// <summary>사내 서비스(Ollama/vLLM)용 등록 모델 목록. 별칭 + 암호화된 모델명.</summary>
[JsonPropertyName("registeredModels")]
public List<RegisteredModel> RegisteredModels { get; set; } = new();
@@ -1377,6 +1381,13 @@ public class RegisteredModel
[JsonPropertyName("service")]
public string Service { get; set; } = "ollama";
/// <summary>
/// 실행 프로파일. balanced | tool_call_strict | reasoning_first | fast_readonly | document_heavy
/// 모델 성향에 따라 도구 호출 강도, 재시도, 메모리/압축 주입량을 조절합니다.
/// </summary>
[JsonPropertyName("executionProfile")]
public string ExecutionProfile { get; set; } = "balanced";
/// <summary>이 모델 전용 서버 엔드포인트. 비어있으면 LlmSettings의 기본 엔드포인트 사용.</summary>
[JsonPropertyName("endpoint")]
public string Endpoint { get; set; } = "";

View File

@@ -243,6 +243,10 @@ public class DraftQueueItem
/// <summary>대화 내 개별 메시지.</summary>
public class ChatMessage
{
/// <summary>메시지 고유 ID — 버블 캐시 키로 사용. 저장된 JSON에 없으면 세션마다 새 GUID 할당.</summary>
[JsonPropertyName("msgId")]
public string MsgId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("role")]
public string Role { get; set; } = "user"; // "user" | "assistant" | "system"

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

View File

@@ -118,9 +118,10 @@ public sealed class ChatSessionStateService
// 새 대화를 시작한 직후처럼 아직 저장되지 않은 현재 대화가 있으면,
// 최신 저장 대화로 되돌아가지 말고 그 임시 세션을 그대로 유지합니다.
// HasActualContent를 사용하여 WorkFolder/Permission 같은 설정값만 있는 경우도 새 대화로 인식.
if (CurrentConversation != null
&& string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)
&& !HasPersistableContent(CurrentConversation))
&& !HasActualContent(CurrentConversation))
{
return CurrentConversation;
}
@@ -607,6 +608,20 @@ public sealed class ChatSessionStateService
return "Chat";
}
/// <summary>
/// 실제 사용자 상호작용 데이터가 있는지 확인합니다. WorkFolder/Permission 같은 설정값은 제외.
/// LoadOrCreateConversation의 새 대화 보호 가드에서 사용합니다.
/// </summary>
private static bool HasActualContent(ChatConversation conv)
{
if ((conv.Messages?.Count ?? 0) > 0) return true;
if ((conv.ExecutionEvents?.Count ?? 0) > 0) return true;
if ((conv.AgentRunHistory?.Count ?? 0) > 0) return true;
if ((conv.DraftQueueItems?.Count ?? 0) > 0) return true;
if (conv.Pinned) return true;
return false;
}
private static bool HasPersistableContent(ChatConversation conv)
{
if ((conv.Messages?.Count ?? 0) > 0) return true;

View File

@@ -92,10 +92,35 @@ public class ChatStorageService
// ── 메타 캐시 ─────────────────────────────────────────────────────────
private List<ChatConversation>? _metaCache;
private List<ChatConversation>? _metaOrderedCache;
private bool _metaDirty = true;
private bool _metaOrderDirty = true;
/// <summary>메타 캐시를 무효화합니다. 다음 LoadAllMeta 호출 시 디스크에서 다시 읽습니다.</summary>
public void InvalidateMetaCache() => _metaDirty = true;
public void InvalidateMetaCache()
{
_metaDirty = true;
_metaOrderDirty = true;
}
private void InvalidateMetaOrderCache() => _metaOrderDirty = true;
private List<ChatConversation> BuildOrderedMetaCache()
{
if (_metaCache == null)
return new List<ChatConversation>();
if (_metaOrderDirty || _metaOrderedCache == null)
{
_metaOrderedCache = _metaCache
.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
_metaOrderDirty = false;
}
return new List<ChatConversation>(_metaOrderedCache);
}
/// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary>
public void UpdateMetaCache(ChatConversation conv)
@@ -116,23 +141,21 @@ public class ChatStorageService
_metaCache[existing] = meta;
else
_metaCache.Add(meta);
InvalidateMetaOrderCache();
}
/// <summary>메타 캐시에서 항목을 제거합니다.</summary>
public void RemoveFromMetaCache(string id)
{
_metaCache?.RemoveAll(c => c.Id == id);
InvalidateMetaOrderCache();
}
/// <summary>모든 대화의 메타 정보(메시지 미포함)를 로드합니다. 캐시를 사용합니다.</summary>
public List<ChatConversation> LoadAllMeta()
{
if (!_metaDirty && _metaCache != null)
{
return _metaCache.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
}
return BuildOrderedMetaCache();
var result = new List<ChatConversation>();
if (!Directory.Exists(ConversationsDir))
@@ -184,10 +207,9 @@ public class ChatStorageService
_metaCache = result;
_metaDirty = false;
_metaOrderDirty = true;
return result.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
return BuildOrderedMetaCache();
}
/// <summary>특정 대화를 삭제합니다.</summary>

View File

@@ -62,6 +62,8 @@ public class IndexService : IDisposable
public IReadOnlyList<IndexEntry> Entries => _index;
public event EventHandler? IndexRebuilt;
/// <summary>전체 재색인 및 증분 갱신 모두에서 발생 — 쿼리 캐시 무효화 용도.</summary>
public event EventHandler? IndexEntriesChanged;
public event EventHandler<LauncherIndexProgressInfo>? IndexProgressChanged;
public TimeSpan LastIndexDuration { get; private set; }
public int LastIndexCount { get; private set; }
@@ -109,6 +111,7 @@ public class IndexService : IDisposable
LastIndexDuration = TimeSpan.Zero;
LastIndexCount = _index.Count;
LogService.Info($"런처 인덱스 캐시 로드: {entries.Count}개 파일 시스템 항목");
IndexEntriesChanged?.Invoke(this, EventArgs.Empty); // FuzzyEngine 캐시 무효화
}
catch (Exception ex)
{
@@ -171,6 +174,7 @@ public class IndexService : IDisposable
$"최근 색인 완료 · {LastIndexCount:N0}개 항목 · {LastIndexDuration.TotalSeconds:F1}초"));
LogService.Info($"런처 인덱싱 완료: {fileSystemEntries.Count}개 파일 시스템 항목 ({sw.Elapsed.TotalSeconds:F1}초)");
IndexRebuilt?.Invoke(this, EventArgs.Empty);
IndexEntriesChanged?.Invoke(this, EventArgs.Empty);
}
finally
{
@@ -329,6 +333,7 @@ public class IndexService : IDisposable
LastIndexCount,
LastIndexDuration,
$"최근 색인 완료 · {LastIndexCount:N0}개 항목");
IndexEntriesChanged?.Invoke(this, EventArgs.Empty); // 증분 갱신도 캐시 무효화 필요
LogService.Info($"런처 인덱스 증분 갱신: {triggerPath}");
}
@@ -516,7 +521,12 @@ public class IndexService : IDisposable
}
RegisterBuiltInApps(entries);
ComputeAllSearchCaches(entries);
// FS 항목은 이미 캐시 계산 완료 — 별칭·내장앱처럼 새로 추가된 항목만 계산
foreach (var entry in entries)
{
if (string.IsNullOrEmpty(entry.NameLower))
ComputeSearchCache(entry);
}
_index = entries;
}
@@ -798,6 +808,10 @@ public class IndexService : IDisposable
if (name.StartsWith(".", StringComparison.Ordinal) || IgnoredDirectoryNames.Contains(name))
continue;
// 파일과 동일하게 숨김·시스템 폴더도 색인에서 제외
if (ShouldIgnorePath(subDir))
continue;
entries.Add(CreateFolderEntry(subDir));
}
}

View File

@@ -25,10 +25,16 @@ public partial class LlmService
}
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
/// <param name="forceToolCall">
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
/// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에 사용하세요.
/// Claude/Gemini는 지원하지 않으므로 vLLM/Ollama/OpenAI 계열에만 적용됩니다.
/// </param>
public async Task<List<ContentBlock>> SendWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct = default)
CancellationToken ct = default,
bool forceToolCall = false)
{
var activeService = ResolveService();
EnsureOperationModeAllowsLlmService(activeService);
@@ -36,7 +42,7 @@ public partial class LlmService
{
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
};
}
@@ -428,7 +434,8 @@ public partial class LlmService
// ─── OpenAI Compatible (Ollama / vLLM) Function Calling ──────────
private async Task<List<ContentBlock>> SendOpenAiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
bool forceToolCall = false)
{
var activeService = ResolveService();
@@ -438,17 +445,19 @@ public partial class LlmService
? ResolveEndpointForService(activeService)
: resolvedEp;
var registered = GetActiveRegisteredModel();
if (UsesIbmDeploymentChatApi(activeService, registered, endpoint))
{
throw new ToolCallNotSupportedException(
"IBM 배포형 vLLM 연결은 OpenAI 도구 호출 형식과 다를 수 있어 일반 대화 경로로 폴백합니다.");
}
var isIbmDeployment = UsesIbmDeploymentChatApi(activeService, registered, endpoint);
var body = BuildOpenAiToolBody(messages, tools);
var body = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall)
: BuildOpenAiToolBody(messages, tools, forceToolCall);
var url = activeService.ToLowerInvariant() == "ollama"
? endpoint.TrimEnd('/') + "/api/chat"
: endpoint.TrimEnd('/') + "/v1/chat/completions";
string url;
if (isIbmDeployment)
url = BuildIbmDeploymentChatUrl(endpoint, stream: false);
else if (activeService.ToLowerInvariant() == "ollama")
url = endpoint.TrimEnd('/') + "/api/chat";
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
@@ -473,7 +482,19 @@ public partial class LlmService
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
}
var respJson = await resp.Content.ReadAsStringAsync(ct);
var rawResp = await resp.Content.ReadAsStringAsync(ct);
// SSE 형식 응답 사전 처리 (stream:false 요청에도 SSE로 응답하는 경우)
var respJson = ExtractJsonFromSseIfNeeded(rawResp);
// 비-JSON 응답(IBM 도구 호출 미지원 등) → ToolCallNotSupportedException으로 폴백 트리거
{
var trimmedResp = respJson.TrimStart();
if (!trimmedResp.StartsWith('{') && !trimmedResp.StartsWith('['))
throw new ToolCallNotSupportedException(
$"vLLM 응답이 JSON이 아닙니다 (도구 호출 미지원 가능성): {respJson[..Math.Min(120, respJson.Length)]}");
}
using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement;
@@ -540,7 +561,7 @@ public partial class LlmService
return blocks;
}
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
@@ -648,6 +669,7 @@ public partial class LlmService
var activeService = ResolveService();
var activeModel = ResolveModel();
var executionPolicy = GetActiveExecutionPolicy();
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama)
{
@@ -657,7 +679,7 @@ public partial class LlmService
messages = msgs,
tools = toolDefs,
stream = false,
options = new { temperature = ResolveTemperature() }
options = new { temperature = ResolveToolTemperature() }
};
}
@@ -667,15 +689,150 @@ public partial class LlmService
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = false,
["temperature"] = ResolveTemperature(),
["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
};
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall)
body["tool_choice"] = "required";
var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort;
return body;
}
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
private object BuildIbmToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
{
var msgs = new List<object>();
// 시스템 프롬프트
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
msgs.Add(new { role = "system", content = systemPrompt });
foreach (var m in messages)
{
if (m.Role == "system") continue;
// tool_result → OpenAI role:"tool" 형식 (watsonx /text/chat 지원)
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
msgs.Add(new
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(),
content = root.GetProperty("content").GetString(),
});
continue;
}
catch { }
}
// _tool_use_blocks → OpenAI tool_calls 형식
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
var textContent = "";
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").GetString();
if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").GetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").GetString() ?? "",
arguments = argsJson,
}
});
}
}
msgs.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent,
tool_calls = toolCallsList,
});
continue;
}
catch { }
}
msgs.Add(new { role = m.Role == "assistant" ? "assistant" : "user", content = m.Content });
}
// OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원)
var toolDefs = tools.Select(t =>
{
var paramDict = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = t.Parameters.Properties.ToDictionary(
kv => kv.Key,
kv => BuildPropertySchema(kv.Value, false)),
};
if (t.Parameters.Required is { Count: > 0 })
paramDict["required"] = t.Parameters.Required;
return new
{
type = "function",
function = new
{
name = t.Name,
description = t.Description,
parameters = paramDict,
}
};
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
if (forceToolCall)
{
return new
{
messages = msgs,
tools = toolDefs,
tool_choice = "required",
parameters = new
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
}
return new
{
messages = msgs,
tools = toolDefs,
parameters = new
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
}
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>

View File

@@ -25,7 +25,10 @@ public partial class LlmService : IDisposable
private string? _systemPrompt;
private const int MaxRetries = 2;
private static readonly TimeSpan ChunkTimeout = TimeSpan.FromSeconds(30);
// 첫 청크: 모델이 컨텍스트를 처리하는 시간 (대용량 컨텍스트에서 3분까지 허용)
private static readonly TimeSpan FirstChunkTimeout = TimeSpan.FromSeconds(180);
// 이후 청크: 스트리밍이 시작된 후 청크 간 최대 간격
private static readonly TimeSpan SubsequentChunkTimeout = TimeSpan.FromSeconds(45);
private static readonly string SigmoidApiHost = string.Concat("api.", "an", "thr", "opic.com");
private static readonly string SigmoidApiVersionHeader = string.Concat("an", "thr", "opic-version");
private const string SigmoidApiVersion = "2023-06-01";
@@ -93,7 +96,12 @@ public partial class LlmService : IDisposable
public (string service, string model) GetCurrentModelInfo() => (ResolveService(), ResolveModel());
/// <summary>오버라이드를 고려한 실제 서비스명.</summary>
private string ResolveService() => NormalizeServiceName(_serviceOverride ?? _settings.Settings.Llm.Service);
private string ResolveService()
{
string? svc;
lock (_overrideLock) svc = _serviceOverride;
return NormalizeServiceName(svc ?? _settings.Settings.Llm.Service);
}
private static bool IsExternalLlmService(string normalizedService)
=> normalizedService is "gemini" or "sigmoid";
@@ -129,12 +137,29 @@ public partial class LlmService : IDisposable
/// <summary>오버라이드를 고려한 실제 모델명.</summary>
private string ResolveModel()
{
if (_modelOverride != null) return _modelOverride;
return ResolveModelName();
string? mdl;
lock (_overrideLock) mdl = _modelOverride;
return mdl ?? ResolveModelName();
}
private double ResolveTemperature() => _temperatureOverride ?? _settings.Settings.Llm.Temperature;
internal string GetActiveExecutionProfileKey()
=> Agent.ModelExecutionProfileCatalog.Normalize(GetActiveRegisteredModel()?.ExecutionProfile);
internal Agent.ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy()
=> Agent.ModelExecutionProfileCatalog.Get(GetActiveExecutionProfileKey());
internal double ResolveToolTemperature()
{
var resolved = ResolveTemperature();
if (!_settings.Settings.Llm.UseAutomaticProfileTemperature)
return resolved;
var cap = GetActiveExecutionPolicy().ToolTemperatureCap;
return cap.HasValue ? Math.Min(resolved, cap.Value) : resolved;
}
private string? ResolveReasoningEffort() => _reasoningEffortOverride;
private static bool LooksLikeEncryptedPayload(string value)
@@ -371,6 +396,49 @@ public partial class LlmService : IDisposable
if (m.Role == "system")
continue;
// assistant 메시지에 _tool_use_blocks 포함 시 텍스트만 추출
// (IBM vLLM은 OpenAI tool_use 형식을 이해하지 못함)
if (m.Role == "assistant" && m.Content.Contains("_tool_use_blocks"))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocks))
{
var parts = new List<string>();
foreach (var block in blocks.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString();
if (type == "text" && block.TryGetProperty("text", out var textEl))
parts.Add(textEl.GetString() ?? "");
else if (type == "tool_use" && block.TryGetProperty("name", out var nameEl))
parts.Add($"[도구 호출: {nameEl.GetString()}]");
}
var content = string.Join("\n", parts).Trim();
if (!string.IsNullOrEmpty(content))
msgs.Add(new { role = "assistant", content });
continue;
}
}
catch { /* 파싱 실패 시 아래에서 원본 사용 */ }
}
// user 메시지에 tool_result JSON 포함 시 평문으로 변환
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "tool" : "tool";
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : "";
msgs.Add(new { role = "user", content = $"[{toolName} 결과]\n{toolContent}" });
continue;
}
catch { /* 파싱 실패 시 아래에서 원본 사용 */ }
}
msgs.Add(new
{
role = m.Role == "assistant" ? "assistant" : "user",
@@ -656,10 +724,20 @@ public partial class LlmService : IDisposable
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var firstChunkReceived = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
if (line == null)
{
if (!firstChunkReceived)
LogService.Warn($"Ollama 첫 청크 타임아웃 ({(int)FirstChunkTimeout.TotalSeconds}초) — 모델이 응답하지 않습니다");
else
yield return "\n\n*(응답이 중간에 끊겼습니다 — 연결 시간 초과)*";
break;
}
firstChunkReceived = true;
if (string.IsNullOrEmpty(line)) continue;
string? text = null;
@@ -669,7 +747,6 @@ public partial class LlmService : IDisposable
if (doc.RootElement.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("content", out var c))
text = c.GetString();
// Ollama: done=true 시 토큰 사용량 포함
if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean())
TryParseOllamaUsage(doc.RootElement);
}
@@ -721,11 +798,16 @@ public partial class LlmService : IDisposable
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
var respBody = await resp.Content.ReadAsStringAsync(ct);
return SafeParseJson(respBody, root =>
// IBM vLLM이 stream:false 요청에도 SSE 형식(id:/event/data: 라인)으로 응답하는 경우 처리
var effectiveBody = ExtractJsonFromSseIfNeeded(respBody);
return SafeParseJson(effectiveBody, root =>
{
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi)
{
// SSE에서 누적된 텍스트가 이미 하나의 JSON이 아닐 수 있으므로 재추출
var parsed = ExtractIbmDeploymentText(root);
return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed;
}
@@ -736,6 +818,81 @@ public partial class LlmService : IDisposable
}, "vLLM 응답");
}
/// <summary>
/// IBM vLLM이 stream:false 요청에도 SSE 포맷(id:/event/data: 라인)을 반환할 때
/// "data: {...}" 라인에서 JSON만 추출합니다. 일반 JSON이면 그대로 반환합니다.
/// </summary>
private static string ExtractJsonFromSseIfNeeded(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return raw;
var trimmed = raw.TrimStart();
// 일반 JSON이면 그대로
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
return raw;
// SSE 포맷: "data: {...}" 라인 중 마지막 유효한 것 사용
// (stream:false지만 SSE로 오면 보통 단일 data 라인 + [DONE])
string? lastDataJson = null;
var sb = new System.Text.StringBuilder();
bool collectingChunks = false;
foreach (var line in raw.Split('\n'))
{
var l = line.TrimEnd('\r').Trim();
if (!l.StartsWith("data: ", StringComparison.Ordinal)) continue;
var data = l["data: ".Length..].Trim();
if (data == "[DONE]") break;
if (string.IsNullOrEmpty(data)) continue;
// choices[].delta.content 형식(스트리밍 청크)인 경우 텍스트를 누적
// 단일 완성 응답(choices[].message)이면 바로 반환
lastDataJson = data;
try
{
using var doc = JsonDocument.Parse(data);
// 스트리밍 청크(delta) → content 누적
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
{
var first = ch[0];
if (first.TryGetProperty("delta", out var delta)
&& delta.TryGetProperty("content", out var cnt))
{
var txt = cnt.GetString();
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
}
else if (first.TryGetProperty("message", out _))
{
// 완성 응답 → 이 JSON을 그대로 사용
return data;
}
}
// IBM results[] 형식
else if (doc.RootElement.TryGetProperty("results", out var res) && res.GetArrayLength() > 0)
{
return data;
}
}
catch { /* 파싱 실패 라인 무시 */ }
}
// 청크를 누적한 경우 OpenAI message 형식으로 재조립
if (collectingChunks && sb.Length > 0)
{
var assembled = System.Text.Json.JsonSerializer.Serialize(new
{
choices = new[]
{
new { message = new { content = sb.ToString() } }
}
});
return assembled;
}
// 마지막 data 라인을 그대로 사용
return lastDataJson ?? raw;
}
private async IAsyncEnumerable<string> StreamOpenAiCompatibleAsync(
List<ChatMessage> messages,
[EnumeratorCancellation] CancellationToken ct)
@@ -759,10 +916,20 @@ public partial class LlmService : IDisposable
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var firstChunkReceived = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
if (line == null)
{
if (!firstChunkReceived)
LogService.Warn($"vLLM 첫 청크 타임아웃 ({(int)FirstChunkTimeout.TotalSeconds}초) — 모델이 응답하지 않습니다");
else
yield return "\n\n*(응답이 중간에 끊겼습니다 — 연결 시간 초과)*";
break;
}
firstChunkReceived = true;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
@@ -882,10 +1049,20 @@ public partial class LlmService : IDisposable
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var firstChunkReceived = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
if (line == null)
{
if (!firstChunkReceived)
LogService.Warn($"Gemini 첫 청크 타임아웃 ({(int)FirstChunkTimeout.TotalSeconds}초)");
else
yield return "\n\n*(응답이 중간에 끊겼습니다 — 연결 시간 초과)*";
break;
}
firstChunkReceived = true;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
string? parsed = null;
@@ -1016,10 +1193,20 @@ public partial class LlmService : IDisposable
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var firstChunkReceived = false;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
if (line == null)
{
if (!firstChunkReceived)
LogService.Warn($"Claude 첫 청크 타임아웃 ({(int)FirstChunkTimeout.TotalSeconds}초)");
else
yield return "\n\n*(응답이 중간에 끊겼습니다 — 연결 시간 초과)*";
break;
}
firstChunkReceived = true;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
@@ -1201,19 +1388,18 @@ public partial class LlmService : IDisposable
return resp;
}
/// <summary>스트리밍 ReadLine에 청크 타임아웃 적용</summary>
private static async Task<string?> ReadLineWithTimeoutAsync(StreamReader reader, CancellationToken ct)
/// <summary>스트리밍 ReadLine에 청크 타임아웃 적용. 타임아웃 시 null 반환.</summary>
private static async Task<string?> ReadLineWithTimeoutAsync(StreamReader reader, CancellationToken ct, TimeSpan timeout)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(ChunkTimeout);
cts.CancelAfter(timeout);
try
{
return await reader.ReadLineAsync(cts.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
LogService.Warn("스트리밍 청크 타임아웃 (30초 무응답)");
return null; // 타임아웃 시 스트림 종료
return null;
}
}

View File

@@ -1,7 +1,10 @@
using System.Text.RegularExpressions;
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
namespace AxCopilot.Services;
@@ -313,12 +316,19 @@ public static class MarkdownRenderer
if (pm.Index > lastIndex)
AddPlainTextWithCodeSymbols(inlines, text[lastIndex..pm.Index]);
// 파일 경로 — 파란색 강조
inlines.Add(new Run(pm.Value)
// 파일 경로 — 파란색 강조 + 클릭 시 파일 열기
var matchedPath = pm.Value;
var hyperlink = new Hyperlink(new Run(matchedPath))
{
Foreground = FilePathBrush,
FontWeight = FontWeights.Medium,
});
TextDecorations = null,
Cursor = Cursors.Hand,
};
hyperlink.MouseEnter += (_, _) => hyperlink.TextDecorations = TextDecorations.Underline;
hyperlink.MouseLeave += (_, _) => hyperlink.TextDecorations = null;
hyperlink.Click += (_, _) => OpenFilePath(matchedPath);
inlines.Add(hyperlink);
lastIndex = pm.Index + pm.Length;
}
@@ -327,6 +337,19 @@ public static class MarkdownRenderer
AddPlainTextWithCodeSymbols(inlines, text[lastIndex..]);
}
private static void OpenFilePath(string path)
{
try
{
var expanded = Environment.ExpandEnvironmentVariables(path);
if (File.Exists(expanded))
Process.Start(new ProcessStartInfo(expanded) { UseShellExecute = true });
else if (Directory.Exists(expanded))
Process.Start("explorer.exe", $"\"{expanded}\"");
}
catch { /* 경로가 잘못됐거나 앱이 없으면 무시 */ }
}
private static void AddPlainTextWithCodeSymbols(InlineCollection inlines, string text)
{
if (string.IsNullOrEmpty(text))

View File

@@ -63,11 +63,11 @@ public static class TokenEstimator
};
}
/// <summary>토큰 수를 읽기 쉬운 문자열로 포맷합니다.</summary>
/// <summary>토큰 수를 읽기 쉬운 문자열로 포맷합니다. 이진 단위(1K=1024) 사용.</summary>
public static string Format(int count) => count switch
{
>= 1_000_000 => $"{count / 1_000_000.0:0.#}M",
>= 1_000 => $"{count / 1_000.0:0.#}K",
>= 1_048_576 => $"{count / 1_048_576.0:0.#}M", // 1024*1024
>= 1_024 => $"{count / 1_024.0:0.#}K", // 1024
_ => count.ToString(),
};

View File

@@ -76,20 +76,37 @@ public partial class LauncherViewModel
private int _widgetRefreshTick;
public void UpdateWidgets()
=> UpdateWidgets(true, true, true, true, true);
public void UpdateWidgets(
bool includePerf,
bool includePomo,
bool includeNote,
bool includeCalendar,
bool includeServerStatus)
{
_widgetRefreshTick++;
if (_widgetRefreshTick % 5 == 0)
if (includeNote && _widgetRefreshTick % 5 == 0)
_widgetNoteCount = GetNoteCount();
OnPropertyChanged(nameof(Widget_PerfText));
OnPropertyChanged(nameof(Widget_PomoText));
OnPropertyChanged(nameof(Widget_PomoRunning));
OnPropertyChanged(nameof(Widget_NoteText));
OnPropertyChanged(nameof(Widget_OllamaOnline));
OnPropertyChanged(nameof(Widget_LlmOnline));
OnPropertyChanged(nameof(Widget_McpOnline));
OnPropertyChanged(nameof(Widget_McpName));
OnPropertyChanged(nameof(Widget_CalText));
if (includePerf)
OnPropertyChanged(nameof(Widget_PerfText));
if (includePomo)
{
OnPropertyChanged(nameof(Widget_PomoText));
OnPropertyChanged(nameof(Widget_PomoRunning));
}
if (includeNote)
OnPropertyChanged(nameof(Widget_NoteText));
if (includeServerStatus)
{
OnPropertyChanged(nameof(Widget_OllamaOnline));
OnPropertyChanged(nameof(Widget_LlmOnline));
OnPropertyChanged(nameof(Widget_McpOnline));
OnPropertyChanged(nameof(Widget_McpName));
}
if (includeCalendar)
OnPropertyChanged(nameof(Widget_CalText));
}
private static int GetNoteCount()

View File

@@ -1245,6 +1245,7 @@ public class SettingsViewModel : INotifyPropertyChanged
Alias = rm.Alias,
EncryptedModelName = rm.EncryptedModelName,
Service = rm.Service,
ExecutionProfile = rm.ExecutionProfile ?? "balanced",
Endpoint = rm.Endpoint,
ApiKey = rm.ApiKey,
AllowInsecureTls = rm.AllowInsecureTls,
@@ -1679,6 +1680,7 @@ public class SettingsViewModel : INotifyPropertyChanged
Alias = rm.Alias,
EncryptedModelName = rm.EncryptedModelName,
Service = rm.Service,
ExecutionProfile = rm.ExecutionProfile ?? "balanced",
Endpoint = rm.Endpoint,
ApiKey = rm.ApiKey,
AllowInsecureTls = rm.AllowInsecureTls,
@@ -1998,6 +2000,7 @@ public class RegisteredModelRow : INotifyPropertyChanged
private string _alias = "";
private string _encryptedModelName = "";
private string _service = "ollama";
private string _executionProfile = "balanced";
private string _endpoint = "";
private string _apiKey = "";
private bool _allowInsecureTls;
@@ -2023,6 +2026,12 @@ public class RegisteredModelRow : INotifyPropertyChanged
set { _service = value; OnPropertyChanged(); OnPropertyChanged(nameof(ServiceLabel)); }
}
public string ExecutionProfile
{
get => _executionProfile;
set { _executionProfile = value; OnPropertyChanged(); OnPropertyChanged(nameof(ProfileLabel)); }
}
/// <summary>이 모델 전용 서버 엔드포인트. 비어있으면 기본 엔드포인트 사용.</summary>
public string Endpoint
{
@@ -2098,6 +2107,15 @@ public class RegisteredModelRow : INotifyPropertyChanged
/// <summary>서비스 라벨</summary>
public string ServiceLabel => _service == "vllm" ? "vLLM" : "Ollama";
public string ProfileLabel => (_executionProfile ?? "balanced").Trim().ToLowerInvariant() switch
{
"tool_call_strict" => "도구 호출 우선",
"reasoning_first" => "추론 우선",
"fast_readonly" => "읽기 속도 우선",
"document_heavy" => "문서 생성 우선",
_ => "균형",
};
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -309,6 +310,10 @@ public partial class ChatWindow
private static string BuildReadableProcessFeedSummary(AgentEvent evt, string transcriptBadgeLabel, string itemDisplayName)
{
var phaseLabel = ResolveProgressPhaseLabel(evt);
if (!string.IsNullOrWhiteSpace(phaseLabel))
return phaseLabel;
return evt.Type switch
{
AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
@@ -412,6 +417,10 @@ public partial class ChatWindow
{
var parts = new List<string>();
var phaseMeta = ResolveProgressPhaseMeta(evt);
if (!string.IsNullOrWhiteSpace(phaseMeta))
parts.Add(phaseMeta);
var normalizedElapsedMs = NormalizeProgressElapsedMs(evt.ElapsedMs);
if (normalizedElapsedMs > 0)
{
@@ -431,6 +440,61 @@ public partial class ChatWindow
return string.Join(" · ", parts);
}
private static string? ResolveProgressPhaseLabel(AgentEvent evt)
{
var summary = (evt.Summary ?? string.Empty).Trim();
var toolName = (evt.ToolName ?? string.Empty).Trim();
if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
return "컨텍스트 압축 중...";
if (string.Equals(toolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
return "처리 중...";
if (summary.Contains("html_create", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("document_assemble", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("docx_create", StringComparison.OrdinalIgnoreCase))
return "문서 결과 생성 중...";
if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase)
|| summary.Contains("verification", StringComparison.OrdinalIgnoreCase))
return "결과 검증 중...";
if (summary.Contains("diff", StringComparison.OrdinalIgnoreCase))
return "변경 내용 확인 중...";
if (evt.Type == AgentEventType.ToolCall && !string.IsNullOrWhiteSpace(toolName))
{
return toolName switch
{
"file_read" or "directory_list" or "glob" or "grep" or "folder_map" or "multi_read" => "파일 탐색 중...",
"file_edit" or "file_write" or "html_create" or "docx_create" or "markdown_create" => "산출물 생성 중...",
"build_run" or "test_loop" => "실행 결과 확인 중...",
_ => null,
};
}
return null;
}
private static string? ResolveProgressPhaseMeta(AgentEvent evt)
{
var summary = evt.Summary ?? string.Empty;
var toolName = evt.ToolName ?? string.Empty;
if (evt.Type == AgentEventType.Planning)
return "계획";
if (evt.Type == AgentEventType.StepStart || evt.Type == AgentEventType.StepDone)
return "단계";
if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
return "압축";
if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase) || summary.Contains("verification", StringComparison.OrdinalIgnoreCase))
return "검증";
if (summary.Contains("fallback", StringComparison.OrdinalIgnoreCase) || summary.Contains("자동 생성", StringComparison.OrdinalIgnoreCase))
return "폴백";
if (summary.Contains("재시도", StringComparison.OrdinalIgnoreCase) || summary.Contains("retry", StringComparison.OrdinalIgnoreCase))
return "재시도";
if (evt.Type == AgentEventType.ToolCall)
return "도구";
return null;
}
private Border CreateReadableProgressFeedCard(
string summary,
Brush primaryText,
@@ -909,6 +973,161 @@ public partial class ChatWindow
}
}
// ─── 에이전트 실행 통합 진행 카드 ─────────────────────────────────────────
private void AddLiveRunProgressCard(IReadOnlyList<AgentEvent> steps)
{
if (steps.Count == 0) return;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var accentColor = (accentBrush as SolidColorBrush)?.Color ?? Color.FromRgb(0x59, 0xA5, 0xF5);
var cardBg = new SolidColorBrush(Color.FromArgb(0x0D, accentColor.R, accentColor.G, accentColor.B));
var cardBorder = new SolidColorBrush(Color.FromArgb(0x2A, accentColor.R, accentColor.G, accentColor.B));
var doneBulletColor = new SolidColorBrush(Color.FromRgb(0x22, 0xC5, 0x5E));
var outerStack = new StackPanel();
// 헤더 행: 펄스 점 + "작업 진행 중"
var headerRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(14, 8, 14, 6),
};
var headerDot = new Border
{
Width = 8,
Height = 8,
CornerRadius = new CornerRadius(999),
Background = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
};
ApplyLiveWaitingPulseToMarker(headerDot);
headerRow.Children.Add(headerDot);
headerRow.Children.Add(new TextBlock
{
Text = "작업 진행 중",
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
});
outerStack.Children.Add(headerRow);
// 구분선
outerStack.Children.Add(new Border
{
Height = 1,
Background = cardBorder,
Margin = new Thickness(14, 0, 14, 6),
});
// 스텝 목록
var stepsPanel = new StackPanel
{
Margin = new Thickness(14, 0, 14, 8),
};
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
var isLast = i == steps.Count - 1;
var itemGrid = new Grid { Margin = new Thickness(0, 2, 0, 2) };
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(18) });
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
// 불릿: 완료는 ✓, 진행 중은 펄스 점
if (isLast)
{
var liveDot = new Border
{
Width = 7,
Height = 7,
CornerRadius = new CornerRadius(999),
Background = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 0, 0),
};
ApplyLiveWaitingPulseToMarker(liveDot);
itemGrid.Children.Add(liveDot);
}
else
{
itemGrid.Children.Add(new TextBlock
{
Text = "✓",
FontSize = 9,
Foreground = doneBulletColor,
VerticalAlignment = VerticalAlignment.Center,
});
}
// 스텝 레이블
var transcriptLabel = GetTranscriptBadgeLabel(step);
var itemDisplayName = GetAgentItemDisplayName(step.ToolName);
var label = BuildReadableProcessFeedSummary(step, transcriptLabel, itemDisplayName);
if (string.IsNullOrWhiteSpace(label)) label = transcriptLabel;
var labelBlock = new TextBlock
{
Text = label,
FontSize = isLast ? 12 : 11.5,
FontWeight = isLast ? FontWeights.SemiBold : FontWeights.Normal,
Foreground = isLast ? primaryText : secondaryText,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(4, 0, 0, 0),
};
Grid.SetColumn(labelBlock, 1);
itemGrid.Children.Add(labelBlock);
// 경과 시간 메타 (완료된 스텝만)
if (!isLast)
{
var meta = BuildReadableProgressMetaText(step);
if (!string.IsNullOrWhiteSpace(meta))
{
var metaBlock = new TextBlock
{
Text = meta,
FontSize = 10,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
Opacity = 0.8,
};
Grid.SetColumn(metaBlock, 2);
itemGrid.Children.Add(metaBlock);
}
}
stepsPanel.Children.Add(itemGrid);
}
outerStack.Children.Add(stepsPanel);
var card = new Border
{
Background = cardBg,
BorderBrush = cardBorder,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Margin = new Thickness(12, 4, 12, 4),
HorizontalAlignment = HorizontalAlignment.Stretch,
Child = outerStack,
};
MessagePanel.Children.Add(card);
}
private void AddAgentEventBanner(AgentEvent evt)
{
var logLevel = _settings.Settings.Llm.AgentLogLevel;

View File

@@ -81,7 +81,8 @@ public partial class ChatWindow
if (string.IsNullOrWhiteSpace(text))
return;
if (_isStreaming && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase))
// 현재 탭이 스트리밍 중일 때만 우선순위를 "next"로 낮춤 — 다른 탭 스트리밍은 무관
if (_streamingTabs.Contains(_activeTab) && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase))
priority = "next";
HideSlashChip(restoreText: false);
@@ -97,19 +98,30 @@ public partial class ChatWindow
if (queuedItem == null)
return;
if (!_isStreaming && startImmediatelyWhenIdle)
if (!_streamingTabs.Contains(_activeTab) && startImmediatelyWhenIdle)
{
StartNextQueuedDraftIfAny(queuedItem.Id);
return;
}
var runningTab = _streamRunTab;
var runningLabel = runningTab switch
{
"Cowork" => "코워크",
"Code" => "코드",
"Chat" => "채팅",
_ => runningTab ?? "다른 탭",
};
var suffix = !string.IsNullOrEmpty(runningTab) && !string.Equals(runningTab, _activeTab, StringComparison.OrdinalIgnoreCase)
? $" ({runningLabel} 실행 완료 후 자동 실행)"
: " (실행 완료 후 자동 실행)";
var toast = queuedItem.Kind switch
{
"command" => "명령이 대기열에 추가되었습니다.",
"direct" => "직접 실행 요청이 대기열에 추가되었습니다.",
"steering" => "조정 요청이 대기열에 추가되었습니다.",
"followup" => "후속 작업이 대기열에 추가되었습니다.",
_ => "메시지가 대기열에 추가되었습니다.",
"command" => "명령이 대기열에 추가되었습니다." + suffix,
"direct" => "직접 실행 요청이 대기열에 추가되었습니다." + suffix,
"steering" => "조정 요청이 대기열에 추가되었습니다." + suffix,
"followup" => "후속 작업이 대기열에 추가되었습니다." + suffix,
_ => "메시지가 대기열에 추가되었습니다." + suffix,
};
ShowToast(toast);
}
@@ -135,16 +147,7 @@ public partial class ChatWindow
RebuildDraftQueuePanel(items);
}
private bool IsDraftQueueExpanded()
=> _expandedDraftQueueTabs.Contains(_activeTab);
private void ToggleDraftQueueExpanded()
{
if (!_expandedDraftQueueTabs.Add(_activeTab))
_expandedDraftQueueTabs.Remove(_activeTab);
RefreshDraftQueueUi();
}
// --- Queue panel (Codex style) ---
private void RebuildDraftQueuePanel(IReadOnlyList<DraftQueueItem> items)
{
@@ -154,6 +157,8 @@ public partial class ChatWindow
DraftQueuePanel.Children.Clear();
var visibleItems = items
.Where(x => !string.Equals(x.State, "completed", StringComparison.OrdinalIgnoreCase) // 완료 항목 제거
&& !string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase)) // 수행 중인 항목은 채팅창에서 이미 표시됨
.OrderBy(GetDraftStateRank)
.ThenBy(GetDraftPriorityRank)
.ThenBy(x => x.CreatedAt)
@@ -165,206 +170,142 @@ public partial class ChatWindow
return;
}
var summary = _appState.GetDraftQueueSummary(_activeTab);
var shouldShowQueue =
IsDraftQueueExpanded()
|| summary.RunningCount > 0
|| summary.QueuedCount > 0
|| summary.FailedCount > 0;
if (!shouldShowQueue)
{
DraftQueuePanel.Visibility = Visibility.Collapsed;
return;
}
DraftQueuePanel.Visibility = Visibility.Visible;
DraftQueuePanel.Children.Add(CreateDraftQueueSummaryStrip(summary, IsDraftQueueExpanded()));
if (!IsDraftQueueExpanded())
// 단일 통합 컨테이너
var containerBorder = new Border
{
DraftQueuePanel.Children.Add(CreateCompactDraftQueuePanel(visibleItems, summary));
return;
}
const int maxPerSection = 3;
var runningItems = visibleItems
.Where(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
.Take(maxPerSection)
.ToList();
var queuedItems = visibleItems
.Where(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
.Take(maxPerSection)
.ToList();
var blockedItems = visibleItems
.Where(IsDraftBlocked)
.Take(maxPerSection)
.ToList();
var completedItems = visibleItems
.Where(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
.Take(maxPerSection)
.ToList();
var failedItems = visibleItems
.Where(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
.Take(maxPerSection)
.ToList();
AddDraftQueueSection("실행 중", runningItems, summary.RunningCount);
AddDraftQueueSection("다음 작업", queuedItems, summary.QueuedCount);
AddDraftQueueSection("보류", blockedItems, summary.BlockedCount);
AddDraftQueueSection("완료", completedItems, summary.CompletedCount);
AddDraftQueueSection("실패", failedItems, summary.FailedCount);
if (summary.CompletedCount > 0 || summary.FailedCount > 0)
{
var footer = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 2, 0, 0),
};
if (summary.CompletedCount > 0)
footer.Children.Add(CreateDraftQueueActionButton($"완료 정리 {summary.CompletedCount}", ClearCompletedDrafts, BrushFromHex("#ECFDF5")));
if (summary.FailedCount > 0)
footer.Children.Add(CreateDraftQueueActionButton($"실패 정리 {summary.FailedCount}", ClearFailedDrafts, BrushFromHex("#FEF2F2")));
DraftQueuePanel.Children.Add(footer);
}
}
private UIElement CreateCompactDraftQueuePanel(IReadOnlyList<DraftQueueItem> items, AppStateService.DraftQueueSummaryState summary)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
var focusItem = items.FirstOrDefault(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
?? items.FirstOrDefault(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
?? items.FirstOrDefault(IsDraftBlocked)
?? items.FirstOrDefault(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
?? items.FirstOrDefault(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase));
var container = new Border
{
Background = background,
BorderBrush = borderBrush,
Background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#1E1E2A"),
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#3A3A4A"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(12, 10, 12, 10),
CornerRadius = new CornerRadius(10),
Margin = new Thickness(0, 0, 0, 4),
};
var root = new Grid();
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
container.Child = root;
var innerStack = new StackPanel();
containerBorder.Child = innerStack;
var left = new StackPanel();
left.Children.Add(new TextBlock
for (int i = 0; i < visibleItems.Count; i++)
{
Text = focusItem == null
? "대기열 항목이 준비되면 여기에서 요약됩니다."
: $"{GetDraftStateLabel(focusItem)} · {GetDraftKindLabel(focusItem)}",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
left.Children.Add(new TextBlock
{
Text = focusItem?.Text ?? BuildDraftQueueCompactSummaryText(summary),
FontSize = 10.5,
Foreground = secondaryText,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(0, 4, 0, 0),
MaxWidth = 520,
});
Grid.SetColumn(left, 0);
root.Children.Add(left);
innerStack.Children.Add(CreateDraftQueueRow(visibleItems[i]));
var action = CreateDraftQueueActionButton("상세", ToggleDraftQueueExpanded);
action.Margin = new Thickness(12, 0, 0, 0);
Grid.SetColumn(action, 1);
root.Children.Add(action);
return container;
}
private static string BuildDraftQueueCompactSummaryText(AppStateService.DraftQueueSummaryState summary)
{
var parts = new List<string>();
if (summary.RunningCount > 0) parts.Add($"실행 {summary.RunningCount}");
if (summary.QueuedCount > 0) parts.Add($"다음 {summary.QueuedCount}");
if (summary.FailedCount > 0) parts.Add($"실패 {summary.FailedCount}");
return parts.Count == 0 ? "대기열 0" : string.Join(" · ", parts);
}
private void AddDraftQueueSection(string label, IReadOnlyList<DraftQueueItem> items, int totalCount)
{
if (DraftQueuePanel == null || totalCount <= 0)
return;
DraftQueuePanel.Children.Add(CreateDraftQueueSectionLabel($"{label} · {totalCount}"));
foreach (var item in items)
DraftQueuePanel.Children.Add(CreateDraftQueueCard(item));
if (totalCount > items.Count)
{
DraftQueuePanel.Children.Add(new TextBlock
// 구분선 (마지막 항목 제외)
if (i < visibleItems.Count - 1)
{
Text = $"추가 항목 {totalCount - items.Count}개",
Margin = new Thickness(8, -2, 0, 8),
FontSize = 10.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#7A7F87"),
});
innerStack.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#2E2E3E"),
Margin = new Thickness(10, 0, 10, 0),
});
}
}
DraftQueuePanel.Children.Add(containerBorder);
// 실패 항목 정리 버튼 (실패만 — 완료는 자동 제거됨)
var summary = _appState.GetDraftQueueSummary(_activeTab);
if (summary.FailedCount > 0)
{
var failBtn = CreateQueueFooterButton($"실패 정리 ({summary.FailedCount})", ClearFailedDrafts);
DraftQueuePanel.Children.Add(failBtn);
}
}
private UIElement CreateDraftQueueSummaryStrip(AppStateService.DraftQueueSummaryState summary, bool isExpanded)
// --- Codex-style row ---
private Border CreateDraftQueueRow(DraftQueueItem item)
{
var root = new Grid
var isRunning = string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase);
var isFailed = string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase);
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#E5E5EA");
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A");
var row = new Border
{
Margin = new Thickness(0, 0, 0, 8),
Padding = new Thickness(10, 7, 8, 7),
};
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var wrap = new WrapPanel();
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // state icon
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // actions
row.Child = grid;
if (summary.RunningCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("실행 중", summary.RunningCount.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"));
if (summary.QueuedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("다음", summary.QueuedCount.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"));
if (isExpanded && summary.BlockedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("보류", summary.BlockedCount.ToString(), "#FFF7ED", "#FDBA74", "#C2410C"));
if (isExpanded && summary.CompletedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("완료", summary.CompletedCount.ToString(), "#ECFDF5", "#BBF7D0", "#166534"));
if (summary.FailedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("실패", summary.FailedCount.ToString(), "#FEF2F2", "#FECACA", "#991B1B"));
// State icon
var stateIcon = new TextBlock
{
Text = GetDraftStateIcon(item),
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = GetDraftStateIconBrush(item),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
Width = 14,
};
Grid.SetColumn(stateIcon, 0);
grid.Children.Add(stateIcon);
if (wrap.Children.Count == 0)
wrap.Children.Add(CreateQueueSummaryPill("대기열", "0", "#F8FAFC", "#E2E8F0", "#475569"));
// Message text
var msgText = new TextBlock
{
Text = item.Text,
FontSize = 12,
Foreground = isFailed ? (TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A")) : primaryText,
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
};
Grid.SetColumn(msgText, 1);
grid.Children.Add(msgText);
Grid.SetColumn(wrap, 0);
root.Children.Add(wrap);
// Right actions
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(actions, 2);
grid.Children.Add(actions);
var toggle = CreateDraftQueueActionButton(isExpanded ? "간단히" : "상세 보기", ToggleDraftQueueExpanded);
toggle.Margin = new Thickness(10, 0, 0, 0);
Grid.SetColumn(toggle, 1);
root.Children.Add(toggle);
if (!isRunning)
{
// Kind chip (↪ 조정 style)
actions.Children.Add(CreateKindChip(item, secondaryText));
}
return root;
if (!isRunning && !isFailed)
{
// Run now button
actions.Children.Add(CreateRowIconButton("\uE768", "지금 실행", () => QueueDraftForImmediateRun(item.Id)));
}
if (!isRunning)
{
// Edit button
actions.Children.Add(CreateRowIconButton("\uE70F", "편집", () => PopDraftToEditor(item.Id)));
}
// Delete
actions.Children.Add(CreateRowIconButton("\uE74D", isRunning ? "취소" : "삭제", () => RemoveDraftFromQueue(item.Id)));
return row;
}
private Border CreateQueueSummaryPill(string label, string value, string bgHex, string borderHex, string fgHex)
// Kind chip on the right — "↪ 조정" style
private Border CreateKindChip(DraftQueueItem item, Brush defaultForeground)
{
var (kindIcon, kindLabel) = GetDraftKindChipContent(item);
var foreground = GetDraftKindChipColor(item);
return new Border
{
Background = BrushFromHex(bgHex),
BorderBrush = BrushFromHex(borderHex),
BorderBrush = foreground,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 0),
CornerRadius = new CornerRadius(5),
Padding = new Thickness(5, 2, 6, 2),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new StackPanel
{
Orientation = Orientation.Horizontal,
@@ -372,169 +313,108 @@ public partial class ChatWindow
{
new TextBlock
{
Text = label,
Text = kindIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = BrushFromHex(fgHex),
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 3, 0),
},
new TextBlock
{
Text = $" {value}",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(fgHex),
}
Text = kindLabel,
FontSize = 10.5,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center,
},
}
}
};
}
private TextBlock CreateDraftQueueSectionLabel(string text)
// Row icon button — flat, no border
private Button CreateRowIconButton(string icon, string tooltip, Action onClick)
{
return new TextBlock
var btn = new Button
{
Text = text,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Margin = new Thickness(8, 0, 8, 6),
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"),
};
}
private Border CreateDraftQueueCard(DraftQueueItem item)
{
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
var neutralSurface = BrushFromHex("#F5F6F8");
var (kindIcon, kindForeground) = GetDraftKindVisual(item);
var (stateBackground, stateBorder, stateForeground) = GetDraftStateBadgeColors(item);
var (priorityBackground, priorityBorder, priorityForeground) = GetDraftPriorityBadgeColors(item.Priority);
var container = new Border
{
Background = background,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 8),
};
var root = new Grid();
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
container.Child = root;
var left = new StackPanel();
Grid.SetColumn(left, 0);
root.Children.Add(left);
var header = new StackPanel
{
Orientation = Orientation.Horizontal,
};
header.Children.Add(new TextBlock
{
Text = kindIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = kindForeground,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
header.Children.Add(CreateDraftQueueBadge(GetDraftKindLabel(item), BrushFromHex("#F8FAFC"), borderBrush, kindForeground));
header.Children.Add(CreateDraftQueueBadge(GetDraftStateLabel(item), stateBackground, stateBorder, stateForeground));
header.Children.Add(CreateDraftQueueBadge(GetDraftPriorityLabel(item.Priority), priorityBackground, priorityBorder, priorityForeground));
left.Children.Add(header);
left.Children.Add(new TextBlock
{
Text = item.Text,
FontSize = 12.5,
Foreground = primaryText,
Margin = new Thickness(0, 6, 0, 0),
TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 520,
});
var meta = $"{item.CreatedAt:HH:mm}";
if (item.AttemptCount > 0)
meta += $" · 시도 {item.AttemptCount}";
if (item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now)
meta += $" · 재시도 {item.NextRetryAt.Value:HH:mm:ss}";
if (!string.IsNullOrWhiteSpace(item.LastError))
meta += $" · {TruncateForStatus(item.LastError, 36)}";
left.Children.Add(new TextBlock
{
Text = meta,
FontSize = 10.5,
Foreground = secondaryText,
Margin = new Thickness(0, 6, 0, 0),
});
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Top,
};
Grid.SetColumn(actions, 1);
root.Children.Add(actions);
if (!string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
actions.Children.Add(CreateDraftQueueActionButton("실행", () => QueueDraftForImmediateRun(item.Id)));
if (string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
{
actions.Children.Add(CreateDraftQueueActionButton("대기", () => ResetDraftInQueue(item.Id), neutralSurface));
}
actions.Children.Add(CreateDraftQueueActionButton("삭제", () => RemoveDraftFromQueue(item.Id), neutralSurface));
return container;
}
private Border CreateDraftQueueBadge(string text, Brush background, Brush borderBrush, Brush foreground)
{
return new Border
{
Background = background,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(7, 2, 7, 2),
Margin = new Thickness(0, 0, 6, 0),
Child = new TextBlock
Content = new TextBlock
{
Text = text,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = foreground,
}
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
},
Width = 24,
Height = 24,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"),
Cursor = Cursors.Hand,
ToolTip = tooltip,
};
btn.Click += (_, _) => onClick();
return btn;
}
private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null)
// Footer button (failed clear)
private Button CreateQueueFooterButton(string label, Action onClick)
{
var btn = new Button
{
Content = label,
Margin = new Thickness(6, 0, 0, 0),
Padding = new Thickness(10, 5, 10, 5),
MinWidth = 48,
Margin = new Thickness(0, 2, 0, 0),
Padding = new Thickness(10, 4, 10, 4),
FontSize = 11,
Background = background ?? BrushFromHex("#EEF2FF"),
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#D4D4D8"),
Background = Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#3A3A4A"),
BorderThickness = new Thickness(1),
Foreground = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827"),
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"),
HorizontalAlignment = HorizontalAlignment.Left,
Cursor = Cursors.Hand,
};
btn.Click += (_, _) => onClick();
return btn;
}
// Pop queued draft back into the InputBox for editing
private void PopDraftToEditor(string draftId)
{
string? text = null;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
var item = session.GetDraftQueueItems(_activeTab)
.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
if (item != null)
{
text = item.Text;
if (session.RemoveDraft(_activeTab, draftId, _storage))
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
}
}
if (InputBox != null && text != null)
{
InputBox.Text = text;
InputBox.CaretIndex = text.Length;
InputBox.Focus();
UpdateInputBoxHeight();
}
RefreshDraftQueueUi();
}
// --- Icon-only button (kept for compatibility) ---
private Button CreateIconButton(string icon, string tooltip, Action onClick)
=> CreateRowIconButton(icon, tooltip, onClick);
// --- Ranking helpers ---
private static int GetDraftStateRank(DraftQueueItem item)
=> string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0
: IsDraftBlocked(item) ? 1
@@ -550,52 +430,74 @@ public partial class ChatWindow
_ => 2,
};
private static string GetDraftPriorityLabel(string? priority)
=> priority?.ToLowerInvariant() switch
// --- State icon ---
private static string GetDraftStateIcon(DraftQueueItem item)
{
if (IsDraftBlocked(item)) return "\uE9F5"; // 시계
return item.State?.ToLowerInvariant() switch
{
"now" => "지금",
"later" => "나중",
_ => "다음",
"running" => "\uE895", // 회전 (재생 아이콘)
"failed" => "\uE783", // 경고
"completed" => "\uE73E", // 체크
_ => "\uE76C", // 대기 점
};
}
private Brush GetDraftStateIconBrush(DraftQueueItem item)
{
if (IsDraftBlocked(item)) return BrushFromHex("#C2410C");
return item.State?.ToLowerInvariant() switch
{
"running" => TryFindResource("AccentColor") as Brush ?? BrushFromHex("#5B8AF5"),
"failed" => BrushFromHex("#DC2626"),
"completed" => BrushFromHex("#16A34A"),
_ => TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"),
};
}
// --- Kind chip ---
private static (string Icon, string Label) GetDraftKindChipContent(DraftQueueItem item)
=> item.Kind?.ToLowerInvariant() switch
{
"followup" => ("\uE8A5", "후속"),
"steering" => ("\uE7C3", "조정"),
"command" => ("\uE756", "명령"),
"direct" => ("\uE8A7", "직접"),
_ => ("\uE8BD", "메시지"),
};
private Brush GetDraftKindChipColor(DraftQueueItem item)
=> item.Kind?.ToLowerInvariant() switch
{
"followup" => BrushFromHex("#0F766E"),
"steering" => BrushFromHex("#B45309"),
"command" => BrushFromHex("#7C3AED"),
"direct" => BrushFromHex("#2563EB"),
_ => TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280"),
};
// --- Legacy helpers (used by other partial classes) ---
private static string GetDraftKindLabel(DraftQueueItem item)
=> item.Kind?.ToLowerInvariant() switch
{
"followup" => "후속 작업",
"followup" => "후속",
"steering" => "조정",
"command" => "명령",
"direct" => "직접 실행",
"command" => "명령",
"direct" => "직접",
_ => "메시지",
};
private (string Icon, Brush Foreground) GetDraftKindVisual(DraftQueueItem item)
=> item.Kind?.ToLowerInvariant() switch
{
"followup" => ("\uE8A5", BrushFromHex("#0F766E")),
"steering" => ("\uE7C3", BrushFromHex("#B45309")),
"command" => ("\uE756", BrushFromHex("#7C3AED")),
"direct" => ("\uE8A7", BrushFromHex("#2563EB")),
_ => ("\uE8BD", BrushFromHex("#475569")),
};
private static string GetDraftStateLabel(DraftQueueItem item)
=> IsDraftBlocked(item) ? "재시도 대기"
: item.State?.ToLowerInvariant() switch
{
"running" => "실행 중",
"failed" => "실패",
"running" => "실행 중",
"failed" => "실패",
"completed" => "완료",
_ => "대기",
};
private Brush GetDraftStateBrush(DraftQueueItem item)
=> IsDraftBlocked(item) ? BrushFromHex("#B45309")
: item.State?.ToLowerInvariant() switch
{
"running" => BrushFromHex("#2563EB"),
"failed" => BrushFromHex("#DC2626"),
"completed" => BrushFromHex("#059669"),
_ => BrushFromHex("#7C3AED"),
_ => "대기",
};
private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item)
@@ -603,22 +505,17 @@ public partial class ChatWindow
? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C"))
: item.State?.ToLowerInvariant() switch
{
"running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")),
"failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
"running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")),
"failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
"completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
_ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")),
_ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")),
};
private static (Brush Background, Brush Border, Brush Foreground) GetDraftPriorityBadgeColors(string? priority)
=> priority?.ToLowerInvariant() switch
{
"now" => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
"later" => (BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
_ => (BrushFromHex("#FEF3C7"), BrushFromHex("#FDE68A"), BrushFromHex("#92400E")),
};
private static bool IsDraftBlocked(DraftQueueItem item)
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase)
&& item.NextRetryAt.HasValue
&& item.NextRetryAt.Value > DateTime.Now;
private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null)
=> CreateQueueFooterButton(label, onClick);
}

View File

@@ -10,7 +10,7 @@ public partial class ChatWindow
{
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
|| TokenUsageSummaryText == null || TokenUsageHintText == null
|| TokenUsageThresholdMarker == null || CompactNowLabel == null)
|| CompactNowLabel == null)
return;
var showContextUsage = _activeTab is "Cowork" or "Code";
@@ -79,7 +79,6 @@ public partial class ChatWindow
}
TokenUsageArc.Stroke = progressBrush;
TokenUsageThresholdMarker.Fill = progressBrush;
var percentText = $"{Math.Round(usageRatio * 100):0}%";
TokenUsagePercentText.Text = percentText;
TokenUsageSummaryText.Text = $"컨텍스트 {percentText}";
@@ -101,8 +100,7 @@ public partial class ChatWindow
TokenUsageCard.ToolTip = null;
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 14, 14, 11);
PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 14, 14, 11, 2.5);
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 15, 15, 11);
}
private void TokenUsageCard_MouseEnter(object sender, MouseEventArgs e)

View File

@@ -464,15 +464,16 @@ public partial class ChatWindow
return;
}
if (_isStreaming)
if (_streamingTabs.Contains(_activeTab))
{
_streamCts?.Cancel();
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_isStreaming = false;
_streamingTabs.Remove(_activeTab);
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
}
var conv = _storage.Load(item.Id);

View File

@@ -37,13 +37,14 @@ public partial class ChatWindow
var bubble = new Border
{
Background = userBubbleBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(11, 7, 11, 7),
HorizontalAlignment = HorizontalAlignment.Right,
};
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
@@ -120,6 +121,7 @@ public partial class ChatWindow
if (animate)
ApplyMessageEntryAnimation(wrapper);
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = wrapper;
MessagePanel.Children.Add(wrapper);
return;
}
@@ -129,6 +131,7 @@ public partial class ChatWindow
var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush);
if (animate)
ApplyMessageEntryAnimation(compactCard);
if (message.MsgId != null) _elementCache[$"m_{message.MsgId}"] = compactCard;
MessagePanel.Children.Add(compactCard);
return;
}
@@ -146,14 +149,7 @@ public partial class ChatWindow
var (agentName, _, _) = GetAgentIdentity();
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 1.5) };
header.Children.Add(new TextBlock
{
Text = "\uE945",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
header.Children.Add(CreateMiniLauncherIcon(pixelSize: 4.0));
header.Children.Add(new TextBlock
{
Text = agentName,
@@ -167,12 +163,13 @@ public partial class ChatWindow
var contentCard = new Border
{
Background = assistantBubbleBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(11, 8, 11, 8),
};
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
contentCard.SetResourceReference(Border.BackgroundProperty, "ItemBackground");
var contentStack = new StackPanel();
var app = System.Windows.Application.Current as App;
@@ -337,6 +334,7 @@ public partial class ChatWindow
ShowMessageContextMenu(aiContent, "assistant");
};
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container;
MessagePanel.Children.Add(container);
}
}

View File

@@ -11,9 +11,57 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
/// <summary>런처 아이콘과 동일한 2×2 컬러 픽셀 다이아몬드를 생성합니다.</summary>
internal static FrameworkElement CreateMiniLauncherIcon(double pixelSize = 4.0)
{
const double gap = 0.75;
var total = pixelSize * 2 + gap;
var canvas = new System.Windows.Controls.Canvas
{
Width = total,
Height = total,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new RotateTransform(45),
};
void AddPixel(double left, double top, string colorHex)
{
var rect = new System.Windows.Shapes.Rectangle
{
Width = pixelSize,
Height = pixelSize,
RadiusX = 1.0,
RadiusY = 1.0,
Fill = new SolidColorBrush((Color)System.Windows.Media.ColorConverter.ConvertFromString(colorHex)),
};
System.Windows.Controls.Canvas.SetLeft(rect, left);
System.Windows.Controls.Canvas.SetTop(rect, top);
canvas.Children.Add(rect);
}
AddPixel(0, 0, "#4488FF");
AddPixel(pixelSize + gap, 0, "#44DD66");
AddPixel(0, pixelSize + gap, "#44DD66");
AddPixel(pixelSize + gap, pixelSize + gap, "#FF4466");
// Wrap in a container so the rotated canvas doesn't disturb layout
var host = new Grid
{
Width = total,
Height = total,
VerticalAlignment = VerticalAlignment.Center,
};
host.Children.Add(canvas);
return host;
}
/// <summary>좋아요/싫어요 피드백 버튼을 생성합니다.</summary>
private Button CreateFeedbackButton(
string iconGlyph,
string activeGlyph,
string tooltip,
Brush normalColor,
Brush activeColor,
@@ -54,12 +102,18 @@ public partial class ChatWindow
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(0),
ToolTip = tooltip
ToolTip = tooltip,
// Remove WPF default hover chrome — visual states handled entirely by chip/icon
Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<ContentPresenter/>" +
"</ControlTemplate>"),
};
void RefreshVisual()
{
var active = isActive();
icon.Text = active ? activeGlyph : iconGlyph;
icon.Foreground = active ? activeColor : normalColor;
chip.Background = active ? activeBackground : Brushes.Transparent;
chip.BorderBrush = active ? activeColor : Brushes.Transparent;
@@ -67,15 +121,22 @@ public partial class ChatWindow
RefreshVisual();
var hoverBackground = new SolidColorBrush(Color.FromArgb(18, 128, 128, 128));
btn.MouseEnter += (_, _) =>
{
if (!isActive())
{
icon.Foreground = hoverBrush;
chip.Background = hoverBackground;
}
};
btn.MouseLeave += (_, _) =>
{
if (!isActive())
{
icon.Foreground = normalColor;
chip.Background = Brushes.Transparent;
}
};
btn.Click += (_, _) =>
{
@@ -140,7 +201,8 @@ public partial class ChatWindow
}
var likeBtn = CreateFeedbackButton(
"\uE8E1",
"\uE8E1", // 좋아요 아웃라인
"\uEB51", // 좋아요 채움 (활성)
"좋아요",
btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)),
@@ -154,7 +216,8 @@ public partial class ChatWindow
});
var dislikeBtn = CreateFeedbackButton(
"\uE8E0",
"\uE8E0", // 싫어요 아웃라인
"\uEB50", // 싫어요 채움 (활성)
"싫어요",
btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)),
@@ -416,6 +479,13 @@ public partial class ChatWindow
}
}
// 수정된 메시지(및 이후 잘린 메시지) 캐시 무효화
lock (_convLock)
{
for (var i = userMsgIdx; i < conv.Messages.Count; i++)
_elementCache.Remove($"m_{conv.Messages[i].MsgId}");
}
RenderMessages(preserveViewport: true);
AutoScrollIfNeeded();

View File

@@ -46,6 +46,8 @@ public partial class ChatWindow
}
else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(result, "확인", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(result, "건너뛰기", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(result, "중단", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(result))
{
agentDecision = $"수정 요청: {result.Trim()}";

View File

@@ -56,7 +56,7 @@ public partial class ChatWindow
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
BtnPreviewToggle.Visibility = Visibility.Visible;
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.LimeGreen;
RebuildPreviewTabs();
LoadPreviewContent(filePath);
@@ -434,6 +434,7 @@ public partial class ChatWindow
{
_previewTabs.Clear();
_activePreviewTab = null;
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.Gray;
if (PreviewHeaderTitle != null) PreviewHeaderTitle.Text = "미리보기";
if (PreviewHeaderSubtitle != null) PreviewHeaderSubtitle.Text = "선택한 파일이 여기에 표시됩니다";
if (PreviewHeaderMeta != null) PreviewHeaderMeta.Text = "파일 메타";
@@ -466,7 +467,7 @@ public partial class ChatWindow
private void BtnClosePreview_Click(object sender, RoutedEventArgs e)
{
HidePreviewPanel();
BtnPreviewToggle.Visibility = Visibility.Collapsed;
// BtnPreviewToggle은 항상 표시 — 패널만 닫힘
}
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
@@ -477,6 +478,7 @@ public partial class ChatWindow
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.Gray;
}
else if (_previewTabs.Count > 0)
{
@@ -484,6 +486,7 @@ public partial class ChatWindow
PreviewSplitter.Visibility = Visibility.Visible;
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
if (PreviewDot != null) PreviewDot.Fill = System.Windows.Media.Brushes.LimeGreen;
RebuildPreviewTabs();
if (_activePreviewTab != null)
LoadPreviewContent(_activePreviewTab);

View File

@@ -161,8 +161,8 @@ public partial class ChatWindow
private void RefreshStatusTokenAggregate()
{
var promptTokens = Math.Max(0, _agentCumulativeInputTokens);
var completionTokens = Math.Max(0, _agentCumulativeOutputTokens);
var promptTokens = (int)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab));
var completionTokens = (int)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab));
if (promptTokens == 0 && completionTokens == 0)
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
@@ -32,9 +32,7 @@ public partial class ChatWindow
if (conversation?.ShowExecutionHistory ?? true)
return events;
return events
.Where(ShouldShowCollapsedProgressEvent)
.ToList();
return events.Where(ShouldShowCollapsedProgressEvent).ToList();
}
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
@@ -53,9 +51,19 @@ public partial class ChatWindow
return true;
}
// 문서 생성 ToolResult 성공 시 항상 표시 (미리보기 카드용)
if (restoredEvent.Type == AgentEventType.ToolResult && restoredEvent.Success
&& IsDocumentCreationTool(restoredEvent.ToolName))
return true;
return IsProcessFeedEvent(restoredEvent);
}
private static bool IsDocumentCreationTool(string? toolName) =>
toolName 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";
private List<(DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
IReadOnlyCollection<ChatMessage> visibleMessages,
IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
@@ -63,22 +71,52 @@ public partial class ChatWindow
var timeline = new List<(DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
foreach (var msg in visibleMessages)
timeline.Add((msg.Timestamp, 0, () => AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg)));
{
var capturedMsg = msg;
var cacheKey = $"m_{msg.MsgId}";
timeline.Add((msg.Timestamp, 0, () =>
{
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
if (_elementCache.TryGetValue(cacheKey, out var cached))
MessagePanel.Children.Add(cached);
else
AddMessageBubble(capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg);
}));
}
// 현재 실행 중인 run의 process feed 이벤트를 통합 카드로 대체 (히스토리 접힘 모드)
var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true;
var activeRunId = _isStreaming ? (_appState.AgentRun.RunId ?? "") : "";
foreach (var executionEvent in visibleEvents)
{
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
if (!showFullHistory && _isStreaming
&& !string.IsNullOrEmpty(activeRunId)
&& string.Equals(executionEvent.RunId, activeRunId, StringComparison.Ordinal))
{
var restoredCheck = ToAgentEvent(executionEvent);
if (IsProcessFeedEvent(restoredCheck))
continue; // 통합 카드로 대체 — 개별 pill 스킵
}
var restoredEvent = ToAgentEvent(executionEvent);
timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
}
// 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
if (!showFullHistory && _isStreaming && _currentRunProgressSteps.Count > 0)
{
var capturedSteps = _currentRunProgressSteps.ToList();
var cardTimestamp = capturedSteps[^1].Timestamp;
timeline.Add((cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
}
var liveProgressHint = GetLiveAgentProgressHint();
if (liveProgressHint != null)
timeline.Add((liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
return timeline
.OrderBy(x => x.Timestamp)
.ThenBy(x => x.Order)
.ToList();
return timeline.OrderBy(x => x.Timestamp).ThenBy(x => x.Order).ToList();
}
private Border CreateTimelineLoadMoreCard(int hiddenCount)
@@ -198,11 +236,7 @@ public partial class ChatWindow
};
var stack = new StackPanel();
var header = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 6),
};
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
header.Children.Add(new TextBlock
{
Text = icon,
@@ -222,12 +256,8 @@ public partial class ChatWindow
});
stack.Children.Add(header);
var lines = (message.Content ?? "")
.Replace("\r\n", "\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
var lines = (message.Content ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim()).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
foreach (var line in lines)
{

View File

@@ -1055,10 +1055,10 @@
<!-- 우: 프리뷰 토글 버튼 -->
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button x:Name="BtnPreviewToggle" Style="{StaticResource GhostBtn}"
Click="BtnPreviewToggle_Click" ToolTip="미리보기 패널" Visibility="Collapsed"
Click="BtnPreviewToggle_Click" ToolTip="미리보기 패널"
Padding="5,2.5" MinWidth="0">
<StackPanel Orientation="Horizontal">
<Ellipse x:Name="PreviewDot" Width="5" Height="5" Fill="#22C55E"
<Ellipse x:Name="PreviewDot" Width="5" Height="5" Fill="Gray"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="프리뷰" FontSize="10.5"
Foreground="{DynamicResource PrimaryText}"
@@ -1362,17 +1362,54 @@
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Background="{DynamicResource LauncherBackground}"
Padding="24,12,24,8">
Padding="24,12,24,8"
UseLayoutRounding="True">
<StackPanel x:Name="MessagePanel"
Margin="0,0,0,8"
MaxWidth="960"
HorizontalAlignment="Center">
HorizontalAlignment="Center"
UseLayoutRounding="True">
<StackPanel.RenderTransform>
<TranslateTransform/>
</StackPanel.RenderTransform>
</StackPanel>
</ScrollViewer>
<!-- ── 스트리밍 상태 바 (Claude 스타일 하단 플로팅) ── -->
<Border x:Name="StreamingStatusBar" Grid.Row="3"
VerticalAlignment="Bottom" HorizontalAlignment="Center"
Margin="0,0,0,14"
Visibility="Collapsed"
Background="{DynamicResource HintBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="20"
Padding="14,7,16,7"
IsHitTestVisible="False">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.10" Color="Black"/>
</Border.Effect>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="StatusBarIcon"
Text="&#xE895;"
FontFamily="Segoe MDL2 Assets"
FontSize="12"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"
Margin="0,0,8,0"
RenderTransformOrigin="0.5,0.5">
<TextBlock.RenderTransform>
<RotateTransform x:Name="StatusIconRotation"/>
</TextBlock.RenderTransform>
</TextBlock>
<TextBlock x:Name="StatusBarText"
Text="생각하는 중..."
FontSize="13"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- 빈 상태 -->
<Grid x:Name="EmptyState" Grid.Row="3"
HorizontalAlignment="Stretch"
@@ -1699,6 +1736,82 @@
HorizontalAlignment="Center"
VerticalAlignment="Bottom">
<StackPanel HorizontalAlignment="Stretch">
<!-- ── 펄스 닷 애니메이션 (AI 처리 중) ── -->
<Border x:Name="PulseDotBar"
Visibility="Collapsed"
HorizontalAlignment="Left"
Margin="6,0,0,10">
<StackPanel>
<!-- 레거시 펄스 점 (코드비하인드 참조용, 화면에 표시 안 함) -->
<Ellipse x:Name="PulseDot1" Width="7" Height="7" Fill="{DynamicResource AccentColor}" Opacity="0.3" Visibility="Collapsed"/>
<Ellipse x:Name="PulseDot2" Width="7" Height="7" Fill="{DynamicResource AccentColor}" Opacity="0.3" Visibility="Collapsed"/>
<Ellipse x:Name="PulseDot3" Width="7" Height="7" Fill="{DynamicResource AccentColor}" Opacity="0.3" Visibility="Collapsed"/>
<!-- 주 행: 다이아몬드 아이콘 + 상태 텍스트 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<!-- 미니 다이아몬드 아이콘 (런처 아이콘 축소판, 코드비하인드에서 애니메이션 적용) -->
<Canvas x:Name="StatusDiamondIcon" Width="16" Height="16"
VerticalAlignment="Center" ClipToBounds="False"
Margin="0,1,8,0">
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
<Canvas.RenderTransform>
<TransformGroup>
<RotateTransform x:Name="StatusIconRotate" Angle="45"/>
<ScaleTransform x:Name="StatusIconScale" ScaleX="1" ScaleY="1"/>
</TransformGroup>
</Canvas.RenderTransform>
<!-- 파란 픽셀 (좌상) -->
<Rectangle x:Name="StatusPixelBlue"
Canvas.Left="0.5" Canvas.Top="0.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#4488FF"/>
<!-- 초록 픽셀 (우상) -->
<Rectangle x:Name="StatusPixelGreen1"
Canvas.Left="8.5" Canvas.Top="0.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#44DD66"/>
<!-- 초록 픽셀 (좌하) -->
<Rectangle x:Name="StatusPixelGreen2"
Canvas.Left="0.5" Canvas.Top="8.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#44DD66"/>
<!-- 빨간 픽셀 (우하) -->
<Rectangle x:Name="StatusPixelRed"
Canvas.Left="8.5" Canvas.Top="8.5"
Width="6.5" Height="6.5"
RadiusX="1" RadiusY="1"
Fill="#FF4466"/>
</Canvas>
<!-- MDL2 아이콘 (호환성 유지, 숨김) -->
<TextBlock x:Name="PulseDotStatusIcon"
FontFamily="Segoe MDL2 Assets"
FontSize="11"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"
Margin="0,0,6,0"
Visibility="Collapsed"
Text="&#xE895;"/>
<!-- 단계 텍스트 (주) -->
<TextBlock x:Name="PulseDotStatusText"
FontSize="12"
FontFamily="Segoe UI, Malgun Gothic"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"
Text="생각하는 중..."/>
</StackPanel>
<!-- 세부 항목 목록 (파일별 진행 상황, 회색 서브텍스트) -->
<StackPanel x:Name="PulseDotSubItems"
Margin="24,3,0,0"/>
<!-- 레거시 단일 세부 텍스트 (숨김, 호환성 유지) -->
<TextBlock x:Name="PulseDotDetailText"
Visibility="Collapsed"
Text=""/>
</StackPanel>
</Border>
<Border x:Name="CodeRepoSummaryBar"
Visibility="Collapsed"
HorizontalAlignment="Stretch"
@@ -1910,12 +2023,13 @@
</StackPanel>
</Grid>
</Border>
<!-- 대기열 패널: 입력창 위에 독립적으로 표시 -->
<StackPanel x:Name="DraftQueuePanel"
Visibility="Collapsed"
Margin="0,0,0,6"/>
<!-- 무지개 글로우 + 입력 영역 (겹침 레이아웃) -->
<Grid>
<!-- 무지개 글로우 외부 테두리 (메시지 전송 시 애니메이션) -->
<StackPanel x:Name="DraftQueuePanel"
Visibility="Collapsed"
Margin="0,0,0,6"/>
<Border x:Name="InputGlowBorder" CornerRadius="18" Opacity="0"
Margin="-2" IsHitTestVisible="False">
<Border.BorderBrush>
@@ -1984,33 +2098,33 @@
Grid.Column="2"
Margin="6,0,0,0"
Padding="0"
Width="32"
Height="32"
Width="30"
Height="30"
CornerRadius="999"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Background="{DynamicResource LauncherBackground}"
BorderThickness="0"
Background="Transparent"
SnapsToDevicePixels="True"
VerticalAlignment="Center"
Visibility="Collapsed"
Cursor="Hand"
MouseEnter="TokenUsageCard_MouseEnter"
MouseLeave="TokenUsageCard_MouseLeave">
<Grid>
<Grid Width="24" Height="24" VerticalAlignment="Center" HorizontalAlignment="Center">
<Ellipse Stroke="{DynamicResource BorderColor}"
StrokeThickness="1.75"/>
<Path x:Name="TokenUsageArc"
Stroke="{DynamicResource AccentColor}"
StrokeThickness="2.25"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"/>
<Canvas IsHitTestVisible="False">
<Ellipse x:Name="TokenUsageThresholdMarker"
Width="4"
Height="4"
Fill="{DynamicResource AccentColor}"/>
</Canvas>
<Grid Width="28" Height="28">
<!-- Ring track (subtle background ring) -->
<Ellipse Width="22" Height="22"
Stroke="{DynamicResource BorderColor}"
StrokeThickness="2"
Opacity="0.45"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<!-- Usage arc (progress) -->
<Path x:Name="TokenUsageArc"
Stroke="{DynamicResource AccentColor}"
StrokeThickness="2"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"/>
<!-- Hidden data elements (referenced in code) -->
<StackPanel Visibility="Collapsed">
<TextBlock x:Name="TokenUsagePercentText"
Text="0%"
FontSize="7.5"
@@ -2018,17 +2132,15 @@
Foreground="{DynamicResource PrimaryText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
<StackPanel Visibility="Collapsed">
<TextBlock x:Name="TokenUsageSummaryText"
Text="컨텍스트"
FontSize="10.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock x:Name="TokenUsageHintText"
Text="0 / 0"
FontSize="9"
Foreground="{DynamicResource SecondaryText}"/>
Text="0 / 0"
FontSize="9"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<Button x:Name="BtnCompactNow"
Style="{StaticResource GhostBtn}"
@@ -3409,129 +3521,6 @@
Checked="ChkOverlayAiEnabled_Changed"
Unchecked="ChkOverlayAiEnabled_Changed"/>
</Grid>
<StackPanel Margin="0,4,0,12">
<TextBlock Text="대화 스타일"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="응답 결과물의 기본 형태와 문서 분위기를 여기서 저장합니다."
Margin="0,4,0,0"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<Grid x:Name="OverlayDefaultOutputFormatRow" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="문서 형태"
FontSize="12.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Border Style="{StaticResource OverlayHelpBadge}">
<TextBlock Text="?"
FontSize="10"
FontWeight="Bold"
Foreground="{DynamicResource AccentColor}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Border.ToolTip>
<ToolTip Style="{StaticResource HelpTooltipStyle}">
<TextBlock TextWrapping="Wrap"
Foreground="White"
FontSize="12"
LineHeight="18"
MaxWidth="280">문서나 결과물을 파일로 만들 때 우선 고려할 출력 형식입니다. 자동으로 두면 요청 내용을 보고 가장 맞는 형식을 선택합니다.</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<TextBlock Text="기본은 AI 자동 선택으로 두고 필요할 때만 바꿉니다."
Margin="0,4,0,0"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<ComboBox x:Name="CmbOverlayDefaultOutputFormat"
Grid.Column="1"
MinWidth="160"
Style="{StaticResource OverlayComboBox}"
SelectionChanged="CmbOverlayDefaultOutputFormat_SelectionChanged">
<ComboBoxItem Content="AI 자동 · 자동" Tag="auto"/>
<ComboBoxItem Content="AI 자동 · Word" Tag="docx"/>
<ComboBoxItem Content="AI 자동 · HTML 보고서" Tag="html"/>
<ComboBoxItem Content="AI 자동 · Excel" Tag="xlsx"/>
<ComboBoxItem Content="AI 자동 · PDF" Tag="pdf"/>
<ComboBoxItem Content="AI 자동 · Markdown" Tag="md"/>
<ComboBoxItem Content="AI 자동 · 텍스트" Tag="txt"/>
</ComboBox>
</Grid>
<Grid x:Name="OverlayDefaultMoodRow" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="디자인 스타일"
FontSize="12.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Border Style="{StaticResource OverlayHelpBadge}">
<TextBlock Text="?"
FontSize="10"
FontWeight="Bold"
Foreground="{DynamicResource AccentColor}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Border.ToolTip>
<ToolTip Style="{StaticResource HelpTooltipStyle}">
<TextBlock TextWrapping="Wrap"
Foreground="White"
FontSize="12"
LineHeight="18"
MaxWidth="280">HTML 보고서나 미리보기 화면을 만들 때 기본으로 적용할 분위기입니다. 같은 내용도 보고서 톤과 카드 스타일이 달라집니다.</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<TextBlock Text="HTML/미리보기 기본 스타일을 설정합니다."
Margin="0,4,0,0"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<ComboBox x:Name="CmbOverlayDefaultMood"
Grid.Column="1"
MinWidth="160"
Style="{StaticResource OverlayComboBox}"
SelectionChanged="CmbOverlayDefaultMood_SelectionChanged"/>
</Grid>
<Grid x:Name="OverlayAutoPreviewRow" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="문서 미리보기"
FontSize="12.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="파일 생성 시 오른쪽 프리뷰 패널을 자동으로 열지 정합니다."
Margin="0,4,0,0"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<ComboBox x:Name="CmbOverlayAutoPreview"
Grid.Column="1"
MinWidth="160"
Style="{StaticResource OverlayComboBox}"
SelectionChanged="CmbOverlayAutoPreview_SelectionChanged">
<ComboBoxItem Content="자동 표시" Tag="auto"/>
<ComboBoxItem Content="수동" Tag="manual"/>
<ComboBoxItem Content="비활성화" Tag="off"/>
</ComboBox>
</Grid>
<Grid x:Name="OverlayPdfExportPathRow" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -3576,53 +3565,6 @@
Foreground="{DynamicResource PrimaryText}"
FontSize="12"/>
</Grid>
<StackPanel Margin="0,2,0,12">
<TextBlock Text="대화 관리"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="대화 보관 기간과 저장 공간 정리를 여기서 바로 관리합니다."
Margin="0,4,0,0"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Margin="0,0,12,0">
<TextBlock Text="대화 보관 기간"
FontSize="12.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="설정 기간이 지나면 오래된 대화는 자동으로 정리됩니다."
Margin="0,4,0,0"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<WrapPanel Grid.Column="1" HorizontalAlignment="Right">
<Button x:Name="BtnOverlayRetention7"
Content="7일"
Padding="10,6"
Margin="0,0,6,0"
Click="BtnOverlayRetention_Click"/>
<Button x:Name="BtnOverlayRetention30"
Content="30일"
Padding="10,6"
Margin="0,0,6,0"
Click="BtnOverlayRetention_Click"/>
<Button x:Name="BtnOverlayRetention90"
Content="90일"
Padding="10,6"
Margin="0,0,6,0"
Click="BtnOverlayRetention_Click"/>
<Button x:Name="BtnOverlayRetentionUnlimited"
Content="무제한"
Padding="10,6"
Click="BtnOverlayRetention_Click"/>
</WrapPanel>
</Grid>
<Border Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
@@ -3721,65 +3663,15 @@
Padding="0,12,0,0"
Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="글로우 효과"
<TextBlock Text="채팅 글로우 효과"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="런처와 AX Agent의 글로우 연출을 여기서 조정합니다."
<TextBlock Text="AX Agent 채팅 입력창의 글로우 연출을 조정합니다. 런처 글로우는 트레이 아이콘 일반 설정에서 변경하세요."
Margin="0,4,0,10"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Margin="0,0,16,0">
<TextBlock Text="런처 무지개 글로우"
FontSize="12.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="AX Commander 테두리에 무지개 글로우를 표시합니다."
Margin="0,4,0,0"
FontSize="11.5"
TextWrapping="Wrap"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<CheckBox x:Name="ChkOverlayEnableLauncherRainbowGlow"
Grid.Column="1"
VerticalAlignment="Center"
Style="{StaticResource ToggleSwitch}"
Checked="ChkOverlayFeatureToggle_Changed"
Unchecked="ChkOverlayFeatureToggle_Changed"/>
</Grid>
</Border>
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Margin="0,0,16,0">
<TextBlock Text="런처 선택 글로우"
FontSize="12.5"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="선택된 런처 항목에 은은한 글로우를 표시합니다."
Margin="0,4,0,0"
FontSize="11.5"
TextWrapping="Wrap"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<CheckBox x:Name="ChkOverlayEnableSelectionGlow"
Grid.Column="1"
VerticalAlignment="Center"
Style="{StaticResource ToggleSwitch}"
Checked="ChkOverlayFeatureToggle_Changed"
Unchecked="ChkOverlayFeatureToggle_Changed"/>
</Grid>
</Border>
<Border Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -4047,7 +3939,7 @@
<Grid x:Name="OverlayTemperatureRow" Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="300"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Temperature"
@@ -4068,34 +3960,63 @@
LineHeight="18"
MaxWidth="280">낮을수록 더 일관되고 정확한 답변 쪽으로, 높을수록 더 자유롭고 창의적인 답변 쪽으로 기울어집니다. 일반 업무형 응답은 0.7 전후가 기본값입니다.</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</Border.ToolTip>
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Slider x:Name="SldOverlayTemperature"
Width="150"
Minimum="0"
Maximum="2"
TickFrequency="0.1"
IsSnapToTickEnabled="True"
ValueChanged="SldOverlayTemperature_ValueChanged"
VerticalAlignment="Center"/>
<Border Width="48"
Height="28"
Margin="10,0,0,0"
CornerRadius="8"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1">
<TextBlock x:Name="TxtOverlayTemperatureValue"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"
FontSize="11.5"/>
</Border>
<TextBox x:Name="TxtOverlayTemperature"
Visibility="Collapsed"
LostFocus="TxtOverlayTemperature_LostFocus"/>
<StackPanel Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,0,6">
<Border x:Name="OverlayTemperatureAutoCard"
Cursor="Hand"
Margin="0,0,8,0"
Padding="10,5"
CornerRadius="10"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
MouseLeftButtonUp="OverlayTemperatureAutoCard_MouseLeftButtonUp">
<TextBlock Text="자동"
FontSize="11.5"
Foreground="{DynamicResource PrimaryText}"/>
</Border>
<Border x:Name="OverlayTemperatureCustomCard"
Cursor="Hand"
Padding="10,5"
CornerRadius="10"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
MouseLeftButtonUp="OverlayTemperatureCustomCard_MouseLeftButtonUp">
<TextBlock Text="사용자 지정"
FontSize="11.5"
Foreground="{DynamicResource PrimaryText}"/>
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Slider x:Name="SldOverlayTemperature"
Width="150"
Minimum="0"
Maximum="2"
TickFrequency="0.1"
IsSnapToTickEnabled="True"
ValueChanged="SldOverlayTemperature_ValueChanged"
VerticalAlignment="Center"/>
<Border Width="48"
Height="28"
Margin="10,0,0,0"
CornerRadius="8"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1">
<TextBlock x:Name="TxtOverlayTemperatureValue"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryText}"
FontSize="11.5"/>
</Border>
<TextBox x:Name="TxtOverlayTemperature"
Visibility="Collapsed"
LostFocus="TxtOverlayTemperature_LostFocus"/>
</StackPanel>
</StackPanel>
</Grid>
<Grid x:Name="OverlayMaxRetryRow" Margin="0,8,0,0">
@@ -5870,4 +5791,3 @@
</Window>

File diff suppressed because it is too large Load Diff

View File

@@ -92,9 +92,10 @@ public partial class DockBarWindow : Window
private void StartRainbowGlow()
{
if (_glowTimer != null) return; // 이미 실행 중 — Tick 핸들러 중복 추가 방지
RainbowGlowBorder.Visibility = Visibility.Visible;
_glowTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) };
var startAngle = 0.0;
_glowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
_glowTimer.Tick += (_, _) =>
{
startAngle += 2;
@@ -109,6 +110,7 @@ public partial class DockBarWindow : Window
private void StopRainbowGlow()
{
_glowTimer?.Stop();
_glowTimer = null;
RainbowGlowBorder.Visibility = Visibility.Collapsed;
}

View File

@@ -13,14 +13,18 @@ public partial class LauncherWindow
internal void StartWidgetUpdates()
{
PerformanceMonitorService.Instance.StartPolling();
SyncWidgetPollingState();
PomodoroService.Instance.StateChanged -= OnPomoStateChanged;
PomodoroService.Instance.StateChanged += OnPomoStateChanged;
_vm.UpdateWidgets();
UpdateWidgetVisibility();
UpdateBatteryWidget();
_ = RefreshWeatherAsync();
RefreshVisibleWidgets(forceWeatherRefresh: true);
if (!ShouldRunWidgetTimer())
{
_widgetTimer?.Stop();
return;
}
if (_widgetTimer == null)
{
@@ -30,11 +34,19 @@ public partial class LauncherWindow
};
_widgetTimer.Tick += (_, _) =>
{
_vm.UpdateWidgets();
UpdateWidgetVisibility();
if (_vm.Widget_PerfText.Length > 0 && _widgetBatteryTick++ % 30 == 0)
if (!ShouldRunWidgetTimer())
{
_widgetTimer?.Stop();
SyncWidgetPollingState();
UpdateWidgetVisibility();
return;
}
SyncWidgetPollingState();
RefreshVisibleWidgets(forceWeatherRefresh: false);
if (ShouldShowBatteryWidget() && _widgetBatteryTick++ % 30 == 0)
UpdateBatteryWidget();
if (_widgetWeatherTick++ % 120 == 0)
if (ShouldShowWeatherWidget() && _widgetWeatherTick++ % 120 == 0)
_ = RefreshWeatherAsync();
};
}
@@ -54,12 +66,64 @@ public partial class LauncherWindow
{
Dispatcher.InvokeAsync(() =>
{
_vm.UpdateWidgets();
RefreshVisibleWidgets(forceWeatherRefresh: false);
UpdatePomoWidgetStyle();
UpdateWidgetVisibility();
});
}
private void RefreshVisibleWidgets(bool forceWeatherRefresh)
{
var launcher = CurrentApp?.SettingsService?.Settings?.Launcher;
if (launcher == null)
return;
_vm.UpdateWidgets(
includePerf: launcher.ShowWidgetPerf,
includePomo: launcher.ShowWidgetPomo,
includeNote: launcher.ShowWidgetNote,
includeCalendar: launcher.ShowWidgetCalendar,
includeServerStatus: false);
UpdateWidgetVisibility();
if (ShouldShowBatteryWidget())
UpdateBatteryWidget();
if (forceWeatherRefresh && ShouldShowWeatherWidget())
_ = RefreshWeatherAsync();
}
private void SyncWidgetPollingState()
{
if (ShouldShowPerfWidget())
PerformanceMonitorService.Instance.StartPolling();
else
PerformanceMonitorService.Instance.StopPolling();
}
private bool ShouldRunWidgetTimer()
{
var launcher = CurrentApp?.SettingsService?.Settings?.Launcher;
if (launcher == null)
return false;
return launcher.ShowWidgetPerf
|| launcher.ShowWidgetPomo
|| launcher.ShowWidgetNote
|| launcher.ShowWidgetWeather
|| launcher.ShowWidgetCalendar
|| launcher.ShowWidgetBattery;
}
private bool ShouldShowPerfWidget()
=> CurrentApp?.SettingsService?.Settings?.Launcher?.ShowWidgetPerf ?? false;
private bool ShouldShowWeatherWidget()
=> CurrentApp?.SettingsService?.Settings?.Launcher?.ShowWidgetWeather ?? false;
private bool ShouldShowBatteryWidget()
=> (CurrentApp?.SettingsService?.Settings?.Launcher?.ShowWidgetBattery ?? false) && _vm.Widget_BatteryVisible;
private void UpdatePomoWidgetStyle()
{
if (WgtPomo == null)
@@ -100,6 +164,10 @@ public partial class LauncherWindow
if (WidgetBar != null)
WidgetBar.Visibility = hasAny ? Visibility.Visible : Visibility.Collapsed;
SyncWidgetPollingState();
if (!hasAny)
_widgetTimer?.Stop();
}
private void WgtPerf_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)

View File

@@ -227,6 +227,8 @@
VerticalAlignment="Center" Cursor="Hand"
ClipToBounds="False"
MouseLeftButtonDown="DiamondIcon_Click"
MouseEnter="DiamondIcon_MouseEnter"
MouseLeave="DiamondIcon_MouseLeave"
Visibility="{Binding HasActivePrefix, Converter={StaticResource InverseBoolToVisibilityConverter}}">
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
<Canvas.RenderTransform>
@@ -236,32 +238,37 @@
</TransformGroup>
</Canvas.RenderTransform>
<!-- 마우스 오버 시 각 픽셀 색상이 퍼져나가는 글로우 (픽셀보다 먼저 렌더링 = 아래에 배치) -->
<!-- 글로우: 기본 Collapsed(GPU 렌더 제외) — 마우스 오버 시 코드에서 활성화 -->
<Rectangle x:Name="GlowBlue"
Canvas.Left="-2.5" Canvas.Top="-2.5"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#4488FF" Opacity="0">
Fill="#4488FF" Opacity="0"
Visibility="Collapsed">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<Rectangle x:Name="GlowGreen1"
Canvas.Left="9" Canvas.Top="-2.5"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#44DD66" Opacity="0">
Fill="#44DD66" Opacity="0"
Visibility="Collapsed">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<Rectangle x:Name="GlowGreen2"
Canvas.Left="-2.5" Canvas.Top="9"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#44DD66" Opacity="0">
Fill="#44DD66" Opacity="0"
Visibility="Collapsed">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<Rectangle x:Name="GlowRed"
Canvas.Left="9" Canvas.Top="9"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#FF4466" Opacity="0">
Fill="#FF4466" Opacity="0"
Visibility="Collapsed">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
@@ -290,46 +297,8 @@
RadiusX="1.5" RadiusY="1.5"
Fill="#FF4466" Opacity="1"/>
<!-- 마우스 오버 → 각 픽셀 색 글로우 페이드인/아웃 -->
<Canvas.Triggers>
<EventTrigger RoutedEvent="UIElement.MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="GlowBlue" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen1" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen2" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowRed" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="UIElement.MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="GlowBlue" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen1" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen2" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowRed" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Canvas.Triggers>
<!-- 애니메이션은 코드비하인드에서 5가지 효과 중 랜덤 적용 -->
<!-- 글로우 hover 애니메이션은 코드비하인드에서 관리 (Visibility 제어로 GPU 최적화) -->
<!-- 아이콘 픽셀 애니메이션도 코드비하인드에서 랜덤 적용 -->
</Canvas>
<!-- Prefix 배지 (prefix 있을 때) -->
@@ -564,6 +533,7 @@
ItemContainerStyle="{StaticResource ResultItemStyle}"
PreviewMouseLeftButtonUp="ResultList_PreviewMouseLeftButtonUp"
MouseDoubleClick="ResultList_MouseDoubleClick"
PreviewMouseRightButtonUp="ResultList_PreviewMouseRightButtonUp"
Visibility="{Binding Results.Count, Converter={StaticResource CountToVisibilityConverter}}">
<ListView.Resources>

View File

@@ -41,6 +41,9 @@ public partial class LauncherWindow : Window
/// <summary>Ctrl+, 단축키로 설정 창을 여는 콜백 (App.xaml.cs에서 주입)</summary>
public Action? OpenSettingsAction { get; set; }
/// <summary>항목을 AX Chat으로 보내는 콜백 (App.xaml.cs에서 주입). 인자: 전송할 메시지 텍스트.</summary>
public Action<string>? SendToChatAction { get; set; }
public LauncherWindow(LauncherViewModel vm)
{
_vm = vm;
@@ -204,7 +207,10 @@ public partial class LauncherWindow : Window
}
if (_vm.EnableIconAnimation && IsVisible)
ApplyRandomIconAnimation();
{
if (_iconStoryboard == null) // 이미 실행 중이면 재시작 하지 않음 (설정 변경 시 버벅임 방지)
ApplyRandomIconAnimation();
}
else
ResetIconAnimation();
@@ -315,7 +321,7 @@ public partial class LauncherWindow : Window
case 6: // 💫 바운스 등장 — 작아졌다가 탄력적으로 커짐 (PPT 바운스)
{
sb.RepeatBehavior = RepeatBehavior.Forever;
sb.RepeatBehavior = new RepeatBehavior(3); // 3회 반복 후 Completed → 다음 애니메이션
var bx = new DoubleAnimationUsingKeyFrames();
bx.KeyFrames.Add(new LinearDoubleKeyFrame(0.7, KT(0)));
bx.KeyFrames.Add(new EasingDoubleKeyFrame(1.15, KT(0.35), new BounceEase { Bounces = 2, Bounciness = 3 }));
@@ -571,6 +577,9 @@ public partial class LauncherWindow : Window
}
}
// 30fps로 제한 — 두 창이 동시에 열릴 때 렌더링 부하 절감
Timeline.SetDesiredFrameRate(sb, 30);
_iconStoryboard = sb;
sb.Completed += (_, _) =>
{
@@ -580,6 +589,39 @@ public partial class LauncherWindow : Window
sb.Begin(this, true);
}
/// <summary>글로우 요소들: hover 시 Visible, 비호버 시 Collapsed (BlurEffect GPU 렌더 최적화)</summary>
private void DiamondIcon_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e)
{
var glows = new[] { GlowBlue, GlowGreen1, GlowGreen2, GlowRed };
foreach (var glow in glows)
{
if (glow == null) continue;
glow.Visibility = Visibility.Visible;
glow.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
}
}
private void DiamondIcon_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e)
{
var glows = new[] { GlowBlue, GlowGreen1, GlowGreen2, GlowRed };
foreach (var glow in glows)
{
if (glow == null) continue;
var anim = new DoubleAnimation(0.9, 0, TimeSpan.FromMilliseconds(220))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } };
var capturedGlow = glow;
anim.Completed += (_, _) =>
{
capturedGlow.BeginAnimation(UIElement.OpacityProperty, null);
capturedGlow.Opacity = 0;
capturedGlow.Visibility = Visibility.Collapsed;
};
glow.BeginAnimation(UIElement.OpacityProperty, anim);
}
}
/// <summary>아이콘 클릭 시 다른 랜덤 애니메이션으로 전환.</summary>
private void DiamondIcon_Click(object sender, MouseButtonEventArgs e)
{
@@ -628,12 +670,13 @@ public partial class LauncherWindow : Window
/// <summary>런처 테두리 무지개 그라데이션 회전을 시작합니다.</summary>
private void StartRainbowGlow()
{
_rainbowTimer?.Stop();
if (_rainbowTimer != null) return; // 이미 실행 중
if (LauncherRainbowBrush == null || RainbowGlowBorder == null) return;
RainbowGlowBorder.Opacity = 1; // StopRainbowGlow에서 0으로 설정된 불투명도 복원
_rainbowTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(40)
Interval = TimeSpan.FromMilliseconds(150)
};
var startTime = DateTime.UtcNow;
_rainbowTimer.Tick += (_, _) =>
@@ -1016,12 +1059,19 @@ public partial class LauncherWindow : Window
break;
case Key.Right:
// 커서가 입력 끝에 있고 선택된 항목이 파일/앱이면 액션 서브메뉴 진입
if (InputBox.CaretIndex == InputBox.Text.Length
&& InputBox.Text.Length > 0
&& _vm.CanEnterActionMode())
// 커서가 입력 끝에 있을 때
if (InputBox.CaretIndex == InputBox.Text.Length && InputBox.Text.Length > 0)
{
_vm.EnterActionMode(_vm.SelectedItem!);
if (_vm.CanEnterActionMode())
{
// 파일/앱이면 액션 서브메뉴 진입
_vm.EnterActionMode(_vm.SelectedItem!);
}
else if (_vm.SelectedItem != null)
{
// 그 외 아이템이면 컨텍스트 메뉴 표시
ShowItemContextMenu(_vm.SelectedItem);
}
e.Handled = true;
}
break;
@@ -1459,21 +1509,17 @@ public partial class LauncherWindow : Window
IndexStatusText.Text = message;
IndexStatusText.Visibility = Visibility.Visible;
_indexStatusTimer ??= new System.Windows.Threading.DispatcherTimer();
_indexStatusTimer.Stop();
_indexStatusTimer.Interval = duration;
_indexStatusTimer.Tick -= IndexStatusTimer_Tick;
_indexStatusTimer.Tick += IndexStatusTimer_Tick;
_indexStatusTimer.Start();
}
private void IndexStatusTimer_Tick(object? sender, EventArgs e)
{
if (_indexStatusTimer == null)
return;
_indexStatusTimer.Stop();
IndexStatusText.Visibility = Visibility.Collapsed;
// 매번 새 타이머를 생성 — 이전 타이머의 stopped/fired 잔여 상태 영향 없음
_indexStatusTimer?.Stop();
var timer = new System.Windows.Threading.DispatcherTimer { Interval = duration };
_indexStatusTimer = timer;
timer.Tick += (_, _) =>
{
timer.Stop();
if (ReferenceEquals(_indexStatusTimer, timer)) // 더 새로운 타이머가 없을 때만 숨김
IndexStatusText.Visibility = Visibility.Collapsed;
};
timer.Start();
}
/// <summary>
@@ -1677,6 +1723,258 @@ public partial class LauncherWindow : Window
_ = _vm.ExecuteSelectedAsync();
}
private void ResultList_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
// 클릭한 ListViewItem 찾기
var dep = e.OriginalSource as DependencyObject;
while (dep != null && dep is not System.Windows.Controls.ListViewItem)
dep = System.Windows.Media.VisualTreeHelper.GetParent(dep);
if (dep is not System.Windows.Controls.ListViewItem lvi) return;
var item = lvi.Content as SDK.LauncherItem;
if (item == null) return;
// 해당 아이템을 선택 상태로 만들고 컨텍스트 메뉴 표시
_vm.SelectedItem = item;
ShowItemContextMenu(item);
e.Handled = true;
}
/// <summary>선택된 런처 아이템에 대한 컨텍스트 메뉴를 표시합니다.</summary>
private void ShowItemContextMenu(SDK.LauncherItem item)
{
var menu = new System.Windows.Controls.ContextMenu
{
Background = (System.Windows.Media.Brush)FindResource("LauncherBackground"),
BorderBrush = (System.Windows.Media.Brush)FindResource("BorderColor"),
BorderThickness = new Thickness(1),
Padding = new Thickness(4),
HasDropShadow = true
};
// ── 타입별 액션 추가 ────────────────────────────────────────────────
bool hasItems = false;
// 파일/폴더 아이템
if (item.Data is Services.IndexEntry entry)
{
var expandedPath = Environment.ExpandEnvironmentVariables(entry.Path);
bool isFolder = entry.Type == Services.IndexEntryType.Folder;
AddMenuItem(menu, "\uE8A5", "열기", () =>
{
Hide();
_ = _vm.ExecuteSelectedAsync();
});
AddMenuItem(menu, "\uEC50", isFolder ? "탐색기에서 열기" : "파일 위치 열기", () =>
{
Hide();
_ = Task.Run(() =>
{
try
{
if (isFolder)
System.Diagnostics.Process.Start("explorer.exe", $"\"{expandedPath}\"");
else
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{expandedPath}\"");
}
catch { }
});
});
AddSeparator(menu);
AddMenuItem(menu, "\uE8C8", "이름 복사", () =>
{
System.Windows.Clipboard.SetText(System.IO.Path.GetFileName(expandedPath));
ShowToast("이름 복사됨");
});
AddMenuItem(menu, "\uE71B", "경로 복사", () =>
{
System.Windows.Clipboard.SetText(expandedPath);
ShowToast("경로 복사됨");
});
if (SendToChatAction != null)
{
AddSeparator(menu);
AddMenuItem(menu, "\uE8BD", "AX Chat에서 분석하기", () =>
{
Hide();
var msg = isFolder
? $"다음 폴더를 분석해 주세요: {expandedPath}"
: $"다음 파일의 내용을 분석해 주세요: {expandedPath}";
SendToChatAction(msg);
});
AddMenuItem(menu, "\uE7C3", "AX Chat에 경로 붙여넣기", () =>
{
Hide();
SendToChatAction(expandedPath);
});
}
hasItems = true;
}
// 클립보드 아이템
else if (item.Data is Services.ClipboardEntry clipEntry)
{
AddMenuItem(menu, "\uE8C8", "클립보드에 복사", () =>
{
try { System.Windows.Clipboard.SetText(clipEntry.Text); }
catch { }
ShowToast("복사됨");
});
if (SendToChatAction != null)
{
AddSeparator(menu);
var preview = clipEntry.Text.Length > 80
? clipEntry.Text[..80].TrimEnd() + "…"
: clipEntry.Text;
AddMenuItem(menu, "\uE8BD", "AI에게 보내기", () =>
{
Hide();
SendToChatAction(clipEntry.Text);
});
}
hasItems = true;
}
// URL이 있는 아이템
else if (!string.IsNullOrEmpty(item.ActionUrl))
{
var url = item.ActionUrl;
AddMenuItem(menu, "\uE8A7", "브라우저에서 열기", () =>
{
Hide();
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true }); }
catch { }
});
AddMenuItem(menu, "\uE71B", "URL 복사", () =>
{
System.Windows.Clipboard.SetText(url);
ShowToast("URL 복사됨");
});
if (SendToChatAction != null)
{
AddSeparator(menu);
AddMenuItem(menu, "\uE8BD", "AI에게 링크 보내기", () =>
{
Hide();
SendToChatAction($"다음 링크를 분석해 주세요: {url}");
});
}
hasItems = true;
}
// 일반 텍스트/기타 (제목이라도 복사)
if (!hasItems || (item.Data is string || item.Data == null))
{
AddMenuItem(menu, "\uE8C8", "제목 복사", () =>
{
System.Windows.Clipboard.SetText(item.Title);
ShowToast("복사됨");
});
if (!string.IsNullOrEmpty(item.Subtitle))
{
AddMenuItem(menu, "\uE8C8", "부제목 복사", () =>
{
System.Windows.Clipboard.SetText(item.Subtitle);
ShowToast("복사됨");
});
}
if (SendToChatAction != null && !hasItems)
{
AddSeparator(menu);
AddMenuItem(menu, "\uE8BD", "AI에게 보내기", () =>
{
Hide();
SendToChatAction(item.Title);
});
}
}
// 메뉴가 비어있으면 표시하지 않음
if (menu.Items.Count == 0) return;
menu.PlacementTarget = ResultList;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
menu.IsOpen = true;
}
private void AddMenuItem(System.Windows.Controls.ContextMenu menu, string symbol, string header, Action action)
{
var item = new System.Windows.Controls.MenuItem
{
Header = BuildMenuItemHeader(symbol, header),
Background = System.Windows.Media.Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(8, 5, 12, 5),
};
item.Click += (_, _) => action();
// 컨텍스트 메뉴 항목 스타일 — 호버 시 강조색 배경
item.Style = BuildMenuItemStyle();
menu.Items.Add(item);
}
private static UIElement BuildMenuItemHeader(string symbol, string text)
{
var panel = new System.Windows.Controls.StackPanel { Orientation = System.Windows.Controls.Orientation.Horizontal };
panel.Children.Add(new System.Windows.Controls.TextBlock
{
Text = symbol,
FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = System.Windows.Media.Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Width = 20,
TextAlignment = TextAlignment.Center
});
panel.Children.Add(new System.Windows.Controls.TextBlock
{
Text = text,
FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"),
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0)
});
return panel;
}
private System.Windows.Style BuildMenuItemStyle()
{
var style = new System.Windows.Style(typeof(System.Windows.Controls.MenuItem));
style.Setters.Add(new Setter(System.Windows.Controls.MenuItem.ForegroundProperty,
FindResource("PrimaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.White));
style.Setters.Add(new Setter(System.Windows.Controls.MenuItem.BackgroundProperty,
System.Windows.Media.Brushes.Transparent));
var hoverTrigger = new Trigger
{
Property = System.Windows.Controls.MenuItem.IsHighlightedProperty,
Value = true
};
var accentBrush = FindResource("AccentColor") as System.Windows.Media.SolidColorBrush;
var hoverColor = accentBrush != null
? System.Windows.Media.Color.FromArgb(30, accentBrush.Color.R, accentBrush.Color.G, accentBrush.Color.B)
: System.Windows.Media.Color.FromArgb(30, 75, 94, 252);
hoverTrigger.Setters.Add(new Setter(System.Windows.Controls.MenuItem.BackgroundProperty,
new System.Windows.Media.SolidColorBrush(hoverColor)));
style.Triggers.Add(hoverTrigger);
return style;
}
private static void AddSeparator(System.Windows.Controls.ContextMenu menu)
{
menu.Items.Add(new System.Windows.Controls.Separator
{
Margin = new Thickness(6, 2, 6, 2),
Opacity = 0.3
});
}
private void Window_Deactivated(object sender, EventArgs e)
{
// 설정 기능 "포커스 잃으면 닫기"가 켜진 경우에만 자동 숨김
@@ -1688,6 +1986,10 @@ public partial class LauncherWindow : Window
if (CurrentApp?.SettingsService != null)
CurrentApp.SettingsService.SettingsChanged -= OnSettingsChanged;
StopRainbowGlow();
_iconStoryboard?.Stop();
_iconStoryboard = null;
base.OnClosed(e);
}
@@ -1704,12 +2006,16 @@ public partial class LauncherWindow : Window
if (isVisible)
{
StartWidgetUpdates();
ApplyVisualSettings(); // 숨겨져 있는 동안 중지된 애니메이션 재개
return;
}
_quickLookWindow?.Close();
_quickLookWindow = null;
StopWidgetUpdates();
StopRainbowGlow(); // 숨김 상태에서 CPU 낭비 방지
_iconStoryboard?.Stop(); // 숨김 상태에서 애니메이션 중지
_iconStoryboard = null;
SaveRememberedPosition();
}

View File

@@ -17,6 +17,7 @@ internal sealed class ModelRegistrationDialog : Window
// IBM/CP4D 인증 필드
private readonly ComboBox _authTypeBox;
private readonly ComboBox _executionProfileBox;
private readonly StackPanel _cp4dPanel;
private readonly TextBox _cp4dUrlBox;
private readonly TextBox _cp4dUsernameBox;
@@ -28,6 +29,7 @@ internal sealed class ModelRegistrationDialog : Window
public string ApiKey => _apiKeyBox.Text.Trim();
public bool AllowInsecureTls => _allowInsecureTlsCheck.IsChecked == true;
public string AuthType => (_authTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bearer";
public string ExecutionProfile => (_executionProfileBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "balanced";
public string Cp4dUrl => _cp4dUrlBox.Text.Trim();
public string Cp4dUsername => _cp4dUsernameBox.Text.Trim();
public string Cp4dPassword => _cp4dPasswordBox.Password.Trim();
@@ -35,7 +37,8 @@ internal sealed class ModelRegistrationDialog : Window
public ModelRegistrationDialog(string service, string existingAlias = "", string existingModel = "",
string existingEndpoint = "", string existingApiKey = "", bool existingAllowInsecureTls = false,
string existingAuthType = "bearer", string existingCp4dUrl = "",
string existingCp4dUsername = "", string existingCp4dPassword = "")
string existingCp4dUsername = "", string existingCp4dPassword = "",
string existingExecutionProfile = "balanced")
{
bool isEdit = !string.IsNullOrEmpty(existingAlias);
Title = isEdit ? "모델 편집" : "모델 추가";
@@ -185,6 +188,50 @@ internal sealed class ModelRegistrationDialog : Window
var modelBorder = new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _modelBox };
stack.Children.Add(modelBorder);
stack.Children.Add(new TextBlock
{
Text = "실행 프로파일",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 12, 0, 6),
});
stack.Children.Add(new TextBlock
{
Text = "모델 성향에 따라 도구 호출 강도, 읽기 속도, 문서 생성 흐름을 조절합니다.",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
});
_executionProfileBox = new ComboBox
{
FontSize = 13,
Padding = new Thickness(8, 6, 8, 6),
Foreground = primaryText,
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
};
var balancedProfile = new ComboBoxItem { Content = "균형", Tag = "balanced" };
var strictProfile = new ComboBoxItem { Content = "도구 호출 우선", Tag = "tool_call_strict" };
var reasoningProfile = new ComboBoxItem { Content = "추론 우선", Tag = "reasoning_first" };
var readonlyProfile = new ComboBoxItem { Content = "읽기 속도 우선", Tag = "fast_readonly" };
var documentProfile = new ComboBoxItem { Content = "문서 생성 우선", Tag = "document_heavy" };
_executionProfileBox.Items.Add(balancedProfile);
_executionProfileBox.Items.Add(strictProfile);
_executionProfileBox.Items.Add(reasoningProfile);
_executionProfileBox.Items.Add(readonlyProfile);
_executionProfileBox.Items.Add(documentProfile);
_executionProfileBox.SelectedItem = (existingExecutionProfile ?? "balanced").Trim().ToLowerInvariant() switch
{
"tool_call_strict" => strictProfile,
"reasoning_first" => reasoningProfile,
"fast_readonly" => readonlyProfile,
"document_heavy" => documentProfile,
_ => balancedProfile,
};
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _executionProfileBox });
// 구분선
stack.Children.Add(new Rectangle
{

View File

@@ -1,6 +1,4 @@
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
@@ -28,7 +26,6 @@ public partial class ReminderPopupWindow : Window
// ─── 타이머 ───────────────────────────────────────────────────────────────
private readonly DispatcherTimer _timer;
private readonly EventHandler _tickHandler;
private readonly CancellationTokenSource _autoCloseCts = new();
private readonly DateTime _closeAtUtc;
private readonly int _displaySeconds;
@@ -74,17 +71,17 @@ public partial class ReminderPopupWindow : Window
};
// ── 타이머 ──
// DispatcherPriority.Normal: Background보다 높아 에이전트 루프 이벤트 홍수 때도 안정적으로 발화.
_tickHandler = (_, _) =>
{
var remainingSeconds = Math.Max(0, (_closeAtUtc - DateTime.UtcNow).TotalSeconds);
var remainingSeconds = Math.Max(0.0, (_closeAtUtc - DateTime.UtcNow).TotalSeconds);
CountdownBar.Value = remainingSeconds;
if (remainingSeconds <= 0)
if (remainingSeconds <= 0.0)
Close();
};
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_timer = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(500) };
_timer.Tick += _tickHandler;
_timer.Start();
_ = StartAutoCloseAsync(_autoCloseCts.Token);
// ── Esc 키 닫기 ──
KeyDown += (_, e) =>
@@ -139,34 +136,10 @@ public partial class ReminderPopupWindow : Window
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
private async Task StartAutoCloseAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_displaySeconds), cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
await Dispatcher.InvokeAsync(() =>
{
if (IsVisible)
Close();
}, DispatcherPriority.Background, cancellationToken);
}
catch (TaskCanceledException)
{
}
catch (OperationCanceledException)
{
}
}
protected override void OnClosed(EventArgs e)
{
_autoCloseCts.Cancel();
_timer.Stop();
_timer.Tick -= _tickHandler;
_autoCloseCts.Dispose();
base.OnClosed(e);
}
}

View File

@@ -894,6 +894,52 @@
</Grid>
</Border>
<!-- 런처 무지개 글로우 -->
<Border Style="{StaticResource SettingsRow}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="9"
Background="#7C3AED" Margin="0,0,14,0" VerticalAlignment="Center">
<TextBlock Text="&#xE753;" FontFamily="Segoe MDL2 Assets" FontSize="17"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="런처 무지개 글로우" Style="{StaticResource RowLabel}"/>
<TextBlock Text="AX Commander 테두리에 무지개 글로우를 표시합니다" Style="{StaticResource RowHint}"/>
</StackPanel>
<CheckBox x:Name="ChkLauncherRainbowGlow" Grid.Column="2"
Style="{StaticResource ToggleSwitch}"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- 런처 선택 글로우 -->
<Border Style="{StaticResource SettingsRow}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="9"
Background="#5B4E7E" Margin="0,0,14,0" VerticalAlignment="Center">
<TextBlock Text="&#xE81C;" FontFamily="Segoe MDL2 Assets" FontSize="17"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="런처 선택 글로우" Style="{StaticResource RowLabel}"/>
<TextBlock Text="선택된 런처 항목에 은은한 글로우를 표시합니다" Style="{StaticResource RowHint}"/>
</StackPanel>
<CheckBox x:Name="ChkLauncherSelectionGlow" Grid.Column="2"
Style="{StaticResource ToggleSwitch}"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- 언어 -->
<Border Style="{StaticResource SettingsRow}">
<Grid>
@@ -3892,6 +3938,12 @@
Style="{StaticResource AgentSubTabStyle}"
Margin="0,0,6,6"
Checked="AgentContextTokensCard_Checked"/>
<RadioButton x:Name="AgentContextTokens128K"
Content="128K"
GroupName="AgentContextTokens"
Style="{StaticResource AgentSubTabStyle}"
Margin="0,0,6,6"
Checked="AgentContextTokensCard_Checked"/>
<RadioButton x:Name="AgentContextTokens256K"
Content="256K"
GroupName="AgentContextTokens"

View File

@@ -169,11 +169,12 @@ public partial class SettingsWindow : Window
if (AgentOperationModeExternal != null) AgentOperationModeExternal.IsChecked = operationMode == OperationModePolicy.ExternalMode;
var maxContextTokens = _vm.LlmMaxContextTokens;
if (AgentContextTokens4K != null) AgentContextTokens4K.IsChecked = maxContextTokens <= 4096;
if (AgentContextTokens16K != null) AgentContextTokens16K.IsChecked = maxContextTokens > 4096 && maxContextTokens <= 16384;
if (AgentContextTokens64K != null) AgentContextTokens64K.IsChecked = maxContextTokens > 16384 && maxContextTokens <= 65536;
if (AgentContextTokens256K != null) AgentContextTokens256K.IsChecked = maxContextTokens > 65536 && maxContextTokens <= 262144;
if (AgentContextTokens1M != null) AgentContextTokens1M.IsChecked = maxContextTokens > 262144;
if (AgentContextTokens4K != null) AgentContextTokens4K.IsChecked = maxContextTokens <= 4_096;
if (AgentContextTokens16K != null) AgentContextTokens16K.IsChecked = maxContextTokens > 4_096 && maxContextTokens <= 16_384;
if (AgentContextTokens64K != null) AgentContextTokens64K.IsChecked = maxContextTokens > 16_384 && maxContextTokens <= 65_536;
if (AgentContextTokens128K != null) AgentContextTokens128K.IsChecked = maxContextTokens > 65_536 && maxContextTokens <= 131_072;
if (AgentContextTokens256K != null) AgentContextTokens256K.IsChecked = maxContextTokens > 131_072 && maxContextTokens <= 262_144;
if (AgentContextTokens1M != null) AgentContextTokens1M.IsChecked = maxContextTokens > 262_144;
var retentionDays = _vm.LlmRetentionDays;
if (AgentRetentionDays7 != null) AgentRetentionDays7.IsChecked = retentionDays == 7;
@@ -1170,6 +1171,14 @@ public partial class SettingsWindow : Window
ChkDockRainbowGlow.Checked += (_, _) => { launcher.DockBarRainbowGlow = true; svc.Save(); RefreshDock(); };
ChkDockRainbowGlow.Unchecked += (_, _) => { launcher.DockBarRainbowGlow = false; svc.Save(); RefreshDock(); };
ChkLauncherRainbowGlow.IsChecked = launcher.EnableRainbowGlow;
ChkLauncherRainbowGlow.Checked += (_, _) => { launcher.EnableRainbowGlow = true; svc.Save(); };
ChkLauncherRainbowGlow.Unchecked += (_, _) => { launcher.EnableRainbowGlow = false; svc.Save(); };
ChkLauncherSelectionGlow.IsChecked = launcher.EnableSelectionGlow;
ChkLauncherSelectionGlow.Checked += (_, _) => { launcher.EnableSelectionGlow = true; svc.Save(); };
ChkLauncherSelectionGlow.Unchecked += (_, _) => { launcher.EnableSelectionGlow = false; svc.Save(); };
SliderDockOpacity.Value = launcher.DockBarOpacity;
SliderDockOpacity.ValueChanged += (_, e) => { launcher.DockBarOpacity = e.NewValue; svc.Save(); RefreshDock(); };
@@ -1805,6 +1814,7 @@ public partial class SettingsWindow : Window
Alias = dlg.ModelAlias,
EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsEncryptionEnabled),
Service = currentService,
ExecutionProfile = dlg.ExecutionProfile,
Endpoint = dlg.Endpoint,
ApiKey = dlg.ApiKey,
AllowInsecureTls = dlg.AllowInsecureTls,
@@ -1828,13 +1838,15 @@ public partial class SettingsWindow : Window
var currentService = GetCurrentServiceSubTab();
var dlg = new ModelRegistrationDialog(currentService, row.Alias, currentModel,
row.Endpoint, row.ApiKey, row.AllowInsecureTls,
row.AuthType ?? "bearer", row.Cp4dUrl ?? "", row.Cp4dUsername ?? "", cp4dPw);
row.AuthType ?? "bearer", row.Cp4dUrl ?? "", row.Cp4dUsername ?? "", cp4dPw,
row.ExecutionProfile ?? "balanced");
dlg.Owner = this;
if (dlg.ShowDialog() == true)
{
row.Alias = dlg.ModelAlias;
row.EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsEncryptionEnabled);
row.Service = currentService;
row.ExecutionProfile = dlg.ExecutionProfile;
row.Endpoint = dlg.Endpoint;
row.ApiKey = dlg.ApiKey;
row.AllowInsecureTls = dlg.AllowInsecureTls;
@@ -2288,11 +2300,12 @@ public partial class SettingsWindow : Window
_vm.LlmMaxContextTokens = rb.Name switch
{
"AgentContextTokens16K" => 16384,
"AgentContextTokens64K" => 65536,
"AgentContextTokens256K" => 262144,
"AgentContextTokens1M" => 1_000_000,
_ => 4096,
"AgentContextTokens16K" => 16_384,
"AgentContextTokens64K" => 65_536,
"AgentContextTokens128K" => 131_072,
"AgentContextTokens256K" => 262_144,
"AgentContextTokens1M" => 1_000_000,
_ => 4_096,
};
}
@@ -2407,9 +2420,7 @@ public partial class SettingsWindow : Window
BorderBrush = isSelected
? (TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue)
: (TryFindResource("BorderColor") as Brush ?? Brushes.LightGray),
Background = isSelected
? (TryFindResource("ItemHoverBackground") as Brush ?? Brushes.AliceBlue)
: Brushes.Transparent,
Background = Brushes.Transparent, // 테마 배경색 대신 항상 투명 (다크 테마 흰 배경 방지)
Padding = new Thickness(12, 9, 12, 9),
Margin = new Thickness(0, 0, 8, 8),
Child = new StackPanel
@@ -2467,9 +2478,7 @@ public partial class SettingsWindow : Window
BorderBrush = isSelected
? (TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue)
: (TryFindResource("BorderColor") as Brush ?? Brushes.LightGray),
Background = isSelected
? (TryFindResource("ItemHoverBackground") as Brush ?? Brushes.AliceBlue)
: Brushes.Transparent,
Background = Brushes.Transparent, // 다크 테마 흰 배경 방지
Padding = new Thickness(12, 9, 12, 9),
Margin = new Thickness(0, 0, 8, 8),
Child = new StackPanel
@@ -4130,4 +4139,3 @@ public partial class SettingsWindow : Window
}

View File

@@ -13,29 +13,35 @@ namespace AxCopilot.Views;
/// </summary>
public partial class TrayMenuWindow : Window
{
private System.Windows.Threading.DispatcherTimer? _autoCloseTimer;
private readonly System.Windows.Threading.DispatcherTimer _autoCloseTimer;
private Size _cachedMenuSize;
private bool _isMenuSizeDirty = true;
private static readonly Brush HeaderTextBrush = new SolidColorBrush(Color.FromRgb(96, 96, 96));
public TrayMenuWindow()
{
InitializeComponent();
_autoCloseTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(400)
};
_autoCloseTimer.Tick += (_, _) =>
{
_autoCloseTimer.Stop();
Hide();
};
// 마우스가 메뉴 밖으로 나가면 일정 시간 후 자동 닫힘
MouseLeave += (_, _) =>
{
_autoCloseTimer?.Stop();
_autoCloseTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(400)
};
_autoCloseTimer.Tick += (_, _) => { _autoCloseTimer.Stop(); Hide(); };
_autoCloseTimer.Start();
};
// 마우스가 다시 메뉴 위로 오면 타이머 취소
MouseEnter += (_, _) =>
{
_autoCloseTimer?.Stop();
_autoCloseTimer.Stop();
};
}
@@ -53,6 +59,7 @@ public partial class TrayMenuWindow : Window
};
label.Foreground = HeaderTextBrush;
MenuPanel.Children.Add(label);
MarkMenuSizeDirty();
AddSeparator();
return this;
}
@@ -63,6 +70,7 @@ public partial class TrayMenuWindow : Window
var item = CreateItemBorder(glyph, text);
item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); };
MenuPanel.Children.Add(item);
MarkMenuSizeDirty();
return this;
}
@@ -73,6 +81,7 @@ public partial class TrayMenuWindow : Window
item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); };
MenuPanel.Children.Add(item);
itemRef = item;
MarkMenuSizeDirty();
return this;
}
@@ -145,6 +154,7 @@ public partial class TrayMenuWindow : Window
getChecked = () => isChecked;
setText = t => label.Text = t;
MenuPanel.Children.Add(border);
MarkMenuSizeDirty();
return this;
}
@@ -158,27 +168,29 @@ public partial class TrayMenuWindow : Window
};
sep.SetResourceReference(Border.BackgroundProperty, "SeparatorColor");
MenuPanel.Children.Add(sep);
MarkMenuSizeDirty();
return this;
}
// ─── 팝업 표시 ────────────────────────────────────────────────────────
/// <summary>표시 전에 메뉴 크기를 미리 계산해 첫 우클릭 지연을 줄입니다.</summary>
public void PrepareForDisplay()
{
EnsureMenuSize();
}
/// <summary>트레이 아이콘 근처에 메뉴를 표시합니다.</summary>
public void ShowAtTray()
{
var workArea = SystemParameters.WorkArea;
// 먼저 표시하여 ActualWidth/ActualHeight 확정
Opacity = 0;
Show();
UpdateLayout();
double menuW = ActualWidth;
double menuH = ActualHeight;
var menuSize = EnsureMenuSize();
double menuW = menuSize.Width;
double menuH = menuSize.Height;
// 마우스 커서 위치 (물리 픽셀 → WPF 논리 좌표)
var cursorPos = GetCursorPosition();
double dpiScale = VisualTreeHelper.GetDpi(this).PixelsPerDip;
double dpiScale = VisualTreeHelper.GetDpi(this).DpiScaleX;
double cx = cursorPos.X / dpiScale;
double cy = cursorPos.Y / dpiScale;
@@ -194,6 +206,12 @@ public partial class TrayMenuWindow : Window
Left = left;
Top = top;
Opacity = 0;
if (!IsVisible)
{
Show();
}
// 활성화하여 Deactivated 이벤트가 정상 작동하도록 보장
Activate();
@@ -213,6 +231,8 @@ public partial class TrayMenuWindow : Window
public void ShowWithUpdate()
{
Opening?.Invoke();
MarkMenuSizeDirty();
PrepareForDisplay();
ShowAtTray();
}
@@ -273,6 +293,28 @@ public partial class TrayMenuWindow : Window
Hide();
}
private void MarkMenuSizeDirty()
{
_isMenuSizeDirty = true;
}
private Size EnsureMenuSize()
{
if (!_isMenuSizeDirty && _cachedMenuSize.Width > 0 && _cachedMenuSize.Height > 0)
{
return _cachedMenuSize;
}
RootBorder.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
var desired = RootBorder.DesiredSize;
RootBorder.Arrange(new Rect(new Point(0, 0), desired));
RootBorder.UpdateLayout();
_cachedMenuSize = desired;
_isMenuSizeDirty = false;
return desired;
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out POINT lpPoint);

View File

@@ -16,7 +16,7 @@ namespace AxCopilot.Views;
/// </summary>
public partial class WorkflowAnalyzerWindow : Window
{
private readonly DateTime _startTime = DateTime.Now;
private DateTime _startTime = DateTime.Now;
private int _totalToolCalls;
private int _totalInputTokens;
private int _totalOutputTokens;
@@ -141,6 +141,7 @@ public partial class WorkflowAnalyzerWindow : Window
_toolTimeAccum.Clear();
_tokenTrend.Clear();
_lastEventMs = 0;
_startTime = DateTime.Now;
UpdateSummaryCards();
ClearBottleneckCharts();
StatusText.Text = "대기 중...";