모델 프로파일 기반 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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 클립보드 히스토리 초기화 (메시지 펌프 시작 직후 — 런처 표시 불필요) ──
|
||||
|
||||
BIN
src/AxCopilot/Assets/1_1775393927824.png_260405.zip
Normal file
BIN
src/AxCopilot/Assets/1_1775393927824.png_260405.zip
Normal file
Binary file not shown.
BIN
src/AxCopilot/Assets/폴디_큐디.png
Normal file
BIN
src/AxCopilot/Assets/폴디_큐디.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
@@ -18,7 +18,8 @@ public class FuzzyEngine
|
||||
public FuzzyEngine(IndexService index)
|
||||
{
|
||||
_index = index;
|
||||
_index.IndexRebuilt += (_, _) => InvalidateQueryCache();
|
||||
// IndexRebuilt(전체 재색인)와 증분 갱신 모두에서 캐시 무효화
|
||||
_index.IndexEntriesChanged += (_, _) => InvalidateQueryCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
|
||||
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; }
|
||||
";
|
||||
}
|
||||
|
||||
@@ -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)); // 설정 임계치에서 압축 시작
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
// 2. [text](url) → <a> — 이스케이프 후 처리 (url의 & 등 고려)
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
173
src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs
Normal file
173
src/AxCopilot/Services/Agent/ModelExecutionProfileCatalog.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()}";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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=""
|
||||
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=""/>
|
||||
<!-- 단계 텍스트 (주) -->
|
||||
<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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="" 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="" 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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = "대기 중...";
|
||||
|
||||
Reference in New Issue
Block a user