AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리

- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
This commit is contained in:
2026-04-12 22:02:14 +09:00
parent b8f4df1892
commit fb0bea41f7
137 changed files with 18532 additions and 1144 deletions

View File

@@ -24,6 +24,8 @@ public partial class AgentLoopService
public bool BroadScanDetected { get; set; }
public bool SelectiveHit { get; set; }
public bool CorrectiveHintInjected { get; set; }
/// <summary>스킬 런타임이 allowed-tools를 명시했으면 true — 탐색 필터링을 건너뜀.</summary>
public bool SkillAllowedToolsActive { get; set; }
}
private static IReadOnlyCollection<IAgentTool> FilterExplorationToolsForCurrentIteration(
@@ -36,6 +38,11 @@ public partial class AgentLoopService
if (tools.Count == 0)
return tools;
// 스킬 런타임 정책으로 allowed-tools가 명시된 경우 탐색 필터링을 건너뜀
// — 스킬이 의도적으로 허용한 도구(folder_map 등)를 정책이 차단하면 안 됨
if (state.SkillAllowedToolsActive)
return tools;
// 문서 생성 모드: 생성 도구를 최우선, 탐색 도구를 뒤로 배치
if (state.Scope == ExplorationScope.DirectCreation)
{

View File

@@ -0,0 +1,185 @@
using System.IO;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static readonly HashSet<string> FolderMapRecoveryIgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
"packages", ".nuget", "TestResults", "coverage", ".next",
"target", ".gradle", ".cargo",
};
private string BuildToolResultFeedbackMessage(
ContentBlock call,
ToolResult result,
RunState runState,
AgentContext context,
List<ChatMessage> messages,
ExplorationTrackingState explorationState,
int iteration)
{
var message = BuildLoopToolResultMessage(call, result, runState);
if (!TryBuildFolderMapEmptyRecoveryMessage(
call,
result,
context,
messages,
explorationState,
iteration,
out var recoveryAppendix))
return message;
return $"{message}\n\n{recoveryAppendix}";
}
private bool TryBuildFolderMapEmptyRecoveryMessage(
ContentBlock call,
ToolResult result,
AgentContext context,
List<ChatMessage> messages,
ExplorationTrackingState explorationState,
int iteration,
out string recoveryAppendix)
{
recoveryAppendix = "";
if (!result.Success
|| !string.Equals(call.ToolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| !LooksLikeEmptyFolderMapResult(result.Output))
return false;
var baseDir = ResolveFolderMapTargetPath(call.ToolInput, context);
if (string.IsNullOrWhiteSpace(baseDir) || !Directory.Exists(baseDir))
return false;
var candidates = CollectFolderMapRecoveryCandidates(baseDir, context, maxResults: 6);
if (candidates.Count == 0)
return false;
var displayPath = Path.GetRelativePath(context.WorkFolder, baseDir).Replace('\\', '/');
if (string.IsNullOrWhiteSpace(displayPath) || displayPath == ".")
displayPath = ".";
var suggestedPatterns = candidates
.Select(path => Path.GetExtension(path))
.Where(ext => !string.IsNullOrWhiteSpace(ext))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2)
.Select(ext => $"**/*{ext}")
.ToList();
var candidateList = string.Join("\n", candidates.Select(path => $"- {path}"));
var patternHint = suggestedPatterns.Count > 0
? $"\nSuggested glob patterns:\n- {string.Join("\n- ", suggestedPatterns)}"
: "";
recoveryAppendix =
"[Recovery] folder_map returned 0 files and 0 dirs, but a fallback scan found candidate paths. " +
"Do not repeat folder_map for the same path. Switch to glob first, then read only the best candidates.\n" +
$"Fallback candidates under '{displayPath}':\n{candidateList}{patternHint}";
messages.Add(new ChatMessage
{
Role = "system",
Content =
"[System:FolderMapEmptyRecovery] folder_map returned an empty result, but direct file scanning found visible candidates.\n" +
$"Path: {displayPath}\n" +
"Do not call folder_map again for the same path unless the user explicitly asks for structure only.\n" +
"Use glob to narrow the set, then use file_read or document_read on the best matches.\n" +
$"Candidates:\n{candidateList}{patternHint}"
});
explorationState.CorrectiveHintInjected = true;
explorationState.BroadScanDetected = true;
explorationState.SelectiveHit = false;
WorkflowLogService.LogTransition(
_conversationId,
_currentRunId,
iteration,
"folder_map_empty_recovery",
$"{displayPath} -> {candidates.Count} candidates");
EmitEvent(
AgentEventType.Thinking,
"folder_map",
"빈 folder_map 결과를 복구하는 중 · 파일 후보로 다시 좁히는 중");
return true;
}
private static string ResolveFolderMapTargetPath(JsonElement? toolInput, AgentContext context)
{
if (toolInput is not { ValueKind: JsonValueKind.Object } input)
return context.WorkFolder;
var rawPath = input.SafeTryGetProperty("path", out var pathElement)
? pathElement.SafeGetString() ?? ""
: "";
return string.IsNullOrWhiteSpace(rawPath)
? context.WorkFolder
: FileReadTool.ResolvePath(rawPath, context.WorkFolder);
}
private static bool LooksLikeEmptyFolderMapResult(string? output)
{
if (string.IsNullOrWhiteSpace(output))
return false;
return output.Contains("0 files, 0 dirs", StringComparison.OrdinalIgnoreCase)
|| output.Contains("0 files, 0 directories", StringComparison.OrdinalIgnoreCase);
}
private static List<string> CollectFolderMapRecoveryCandidates(string baseDir, AgentContext context, int maxResults)
{
var candidates = new List<string>(maxResults);
var pending = new Stack<(string Path, int Depth)>();
pending.Push((baseDir, 0));
while (pending.Count > 0 && candidates.Count < maxResults)
{
var (current, depth) = pending.Pop();
try
{
foreach (var file in Directory.EnumerateFiles(current))
{
if (candidates.Count >= maxResults)
break;
var fileName = Path.GetFileName(file);
if (string.IsNullOrWhiteSpace(fileName)
|| fileName.StartsWith('.')
|| !context.IsPathAllowed(file))
continue;
candidates.Add(Path.GetRelativePath(context.WorkFolder, file).Replace('\\', '/'));
}
if (depth >= 3)
continue;
foreach (var dir in Directory.EnumerateDirectories(current))
{
var name = Path.GetFileName(dir);
if (string.IsNullOrWhiteSpace(name)
|| name.StartsWith('.')
|| FolderMapRecoveryIgnoredDirs.Contains(name)
|| !context.IsPathAllowed(dir))
continue;
pending.Push((dir, depth + 1));
}
}
catch
{
}
}
return candidates;
}
}

View File

@@ -25,11 +25,11 @@ public partial class AgentLoopService
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential)
ClassifyToolCalls(List<LlmService.ContentBlock> calls)
private static (List<ContentBlock> Parallel, List<ContentBlock> Sequential)
ClassifyToolCalls(List<ContentBlock> calls)
{
var parallel = new List<LlmService.ContentBlock>();
var sequential = new List<LlmService.ContentBlock>();
var parallel = new List<ContentBlock>();
var sequential = new List<ContentBlock>();
var collectParallelPrefix = true;
foreach (var call in calls)
@@ -81,7 +81,7 @@ public partial class AgentLoopService
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
private async Task ExecuteToolsInParallelAsync(
List<LlmService.ContentBlock> calls,
List<ContentBlock> calls,
List<ChatMessage> messages,
AgentContext context,
List<string> planSteps,
@@ -100,13 +100,13 @@ public partial class AgentLoopService
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var executableCalls = new List<LlmService.ContentBlock>();
var executableCalls = new List<ContentBlock>();
foreach (var call in calls)
{
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
? call
: new LlmService.ContentBlock
: new ContentBlock
{
Type = call.Type,
Text = call.Text,

View File

@@ -0,0 +1,103 @@
using System.IO;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private sealed class PathAccessTrackingState
{
public string? LastPath { get; set; }
public int ConsecutiveCount { get; set; }
}
private bool TryHandleRepeatedPathAccessTransition(
ContentBlock call,
AgentContext context,
PathAccessTrackingState state,
List<ChatMessage> messages,
string? lastModifiedCodeFilePath,
bool requireHighImpactCodeVerification,
TaskTypePolicy taskPolicy)
{
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
var currentPath = ExtractTrackedTargetPath(call, context);
var shouldTrack = !string.IsNullOrWhiteSpace(currentPath) && IsPathBoundReadOnlyInspectionTool(call.ToolName);
if (!shouldTrack)
{
if (ResetsRepeatedPathTracking(call.ToolName))
{
state.LastPath = null;
state.ConsecutiveCount = 0;
}
return false;
}
if (string.Equals(state.LastPath, currentPath, StringComparison.OrdinalIgnoreCase))
state.ConsecutiveCount++;
else
{
state.LastPath = currentPath;
state.ConsecutiveCount = 1;
}
if (state.ConsecutiveCount < 4)
return false;
messages.Add(new ChatMessage
{
Role = "user",
Content =
"[System:RepeatedPathGuard] 동일 경로를 읽기 도구로 반복 확인하고 있습니다.\n" +
$"반복 경로: {currentPath}\n" +
"지금은 같은 파일/경로를 다시 읽지 말고, 다음 우선순위로 전환하세요.\n" +
"1. grep/glob으로 관련 호출부, 참조 지점, 테스트 파일을 찾기\n" +
"2. git_tool(diff)로 실제 변경 범위를 확인하기\n" +
"3. 필요한 경우에만 file_edit 또는 build_run/test_loop로 다음 단계 진행하기\n" +
"같은 경로에 대한 file_read/document_read/grep 반복은 중단하세요."
});
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"같은 경로 반복 접근을 감지해 흐름을 전환합니다 · {Path.GetFileName(currentPath)}");
state.ConsecutiveCount = 0;
state.LastPath = null;
return true;
}
private static bool IsPathBoundReadOnlyInspectionTool(string toolName)
=> toolName is "file_read" or "document_read" or "grep" or "glob";
private static bool ResetsRepeatedPathTracking(string toolName)
=> toolName is "file_edit" or "file_write" or "file_manage" or "git_tool" or "build_run" or "test_loop" or "folder_map";
private static string? ExtractTrackedTargetPath(ContentBlock call, AgentContext context)
{
if (call.ToolInput is not { ValueKind: JsonValueKind.Object } input)
return null;
var path = input.SafeTryGetProperty("path", out var pathProp)
? pathProp.SafeGetString()
: null;
if (string.IsNullOrWhiteSpace(path)
&& input.SafeTryGetProperty("project_path", out var projectPathProp))
path = projectPathProp.SafeGetString();
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
return FileReadTool.ResolvePath(path, context.WorkFolder);
}
catch
{
return path;
}
}
}

View File

@@ -114,6 +114,12 @@ public partial class AgentLoopService
if (!_docFallbackAttempted && !documentPlanWasCalled)
return (false, false);
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
{
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (true, false);
}
var verificationEnabled = executionPolicy.EnablePostToolVerification
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
var shouldVerify = ShouldRunPostToolVerification(
@@ -156,6 +162,9 @@ public partial class AgentLoopService
if (!shouldVerify)
return false;
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
return false;
if (string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
{
var highImpactCodeChange = IsHighImpactCodeModification(ActiveTab ?? "", call.ToolName, result);

View File

@@ -179,7 +179,7 @@ public partial class AgentLoopService
return sawDiff;
}
// 蹂€寃??꾧뎄 ?먯껜媛€ ?놁쑝硫?diff 寃뚯씠???꾧뎄?몄? ?뺤씤
// 변경 도구 자체가 없으면 diff 게이트 도구인지 확인
return true;
}
@@ -198,8 +198,8 @@ public partial class AgentLoopService
"insertions",
"deletions",
"file changed",
"異붽?",
"?낅뜲?댄듃");
"추가",
"업데이트");
}
private bool TryApplyDocumentArtifactGateTransition(
@@ -392,9 +392,9 @@ public partial class AgentLoopService
{
var content = m.Content ?? "";
if (content.StartsWith("{\"_tool_use_blocks\""))
content = "[?꾧뎄 ?몄텧 ?붿빟]";
content = "[도구 실행 요약]";
else if (content.StartsWith("{\"type\":\"tool_result\""))
content = "[?꾧뎄 ?ㅽ뻾 寃곌낵 ?붿빟]";
content = "[도구 실행 결과 요약]";
else if (content.Length > 180)
content = content[..180] + "...";
return $"- [{m.Role}] {content}";
@@ -403,7 +403,7 @@ public partial class AgentLoopService
var summary = summaryLines.Count > 0
? string.Join("\n", summaryLines)
: "- ?댁쟾 ?€??留λ씫???먮룞 異뺤빟?섏뿀?듬땲??";
: "- 이전 대화 맥락이 자동 축약되었습니다";
var tail = nonSystem.Skip(Math.Max(0, nonSystem.Count - keepTailCount)).ToList();
@@ -414,7 +414,7 @@ public partial class AgentLoopService
{
Role = "user",
Timestamp = DateTime.Now,
Content = $"[?쒖뒪???먮룞 留λ씫 異뺤빟]\n?꾨옒???댁쟾 ?€?붿쓽 ?듭떖 ?붿빟?낅땲??\n{summary}"
Content = $"[시스템 자동 맥락 축약]\n아래는 이전 대화의 축소 요약입니다.\n{summary}"
});
messages.AddRange(tail);
return true;
@@ -458,7 +458,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"?숈씪 ?꾧뎄/?뚮씪誘명꽣 諛섎났 ?ㅽ뙣 ?⑦꽩 媛먯? - ?ㅻⅨ ?묎렐?쇰줈 ?꾪솚?⑸땲??({repeatedFailedToolSignatureCount}/{maxRetry})");
$"동일 도구/파라미터 반복 실패 패턴 감지 - 다른 접근으로 전환합니다({repeatedFailedToolSignatureCount}/{maxRetry})");
return true;
}
@@ -479,7 +479,7 @@ public partial class AgentLoopService
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId,
call.ToolName,
$"[NO_PROGRESS_LOOP_GUARD] ?숈씪???쎄린 ?꾧뎄 ?몄텧??{repeatedSameSignatureCount}??諛섎났?섏뿀?듬땲?? {toolCallSignature}\n" +
$"[NO_PROGRESS_LOOP_GUARD] 동일한 읽기 도구 실행이 {repeatedSameSignatureCount}회 반복되었습니다. {toolCallSignature}\n" +
"Stop repeating the same read-only call and switch to a concrete next action."));
messages.Add(new ChatMessage
{
@@ -493,7 +493,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"臾댁쓽誘명븳 ?쎄린 ?꾧뎄 諛섎났 猷⑦봽媛€ 媛먯??섏뼱 ?ㅻⅨ ?꾨왂?쇰줈 ?꾪솚?⑸땲??({repeatedSameSignatureCount}??");
$"무의미한 읽기 도구 반복 루프가 감지되어 다른 방향으로 전환합니다({repeatedSameSignatureCount}회)");
return true;
}
@@ -520,7 +520,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"?쎄린 ?꾩슜 ?꾧뎄媛€ ?곗냽 {consecutiveReadOnlySuccessTools}???ㅽ뻾?섏뼱 ?뺤껜媛€ 媛먯??섏뿀?듬땲?? ?ㅽ뻾 ?④퀎濡??꾪솚?⑸땲??(threshold={threshold})");
$"읽기 전용 도구가 연속 {consecutiveReadOnlySuccessTools}회 실행되어 정체가 감지되었습니다. 실행 전략으로 전환합니다(threshold={threshold})");
return true;
}
@@ -554,7 +554,7 @@ public partial class AgentLoopService
EmitEvent(
AgentEventType.Thinking,
"",
$"?ㅽ뻾 吏꾩쟾???놁뼱 媛뺤젣 蹂듦뎄 ?④퀎濡??쒖옉?⑸땲??({runState.NoProgressRecoveryRetry}/2)");
$"실행 진전이 없어 강제 복구 전략으로 시작합니다({runState.NoProgressRecoveryRetry}/2)");
return true;
}
@@ -629,11 +629,11 @@ public partial class AgentLoopService
"forbidden",
"not found",
"invalid",
"?ㅽ뙣",
"?ㅻ쪟",
"?덉쇅",
"?쒓컙 珥덇낵",
"沅뚰븳 嫄곕?",
"실패",
"오류",
"예외",
"시간 초과",
"권한 거부",
"李⑤떒",
"찾을 수");
}
@@ -1052,7 +1052,7 @@ public partial class AgentLoopService
result.Output)
});
}
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: ?ㅽ뙣 遺꾩꽍 ???ъ떆??({consecutiveErrors}/{maxRetry})");
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도({consecutiveErrors}/{maxRetry})");
return true;
}
@@ -1139,7 +1139,7 @@ public partial class AgentLoopService
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);
}
}
@@ -1160,7 +1160,7 @@ public partial class AgentLoopService
if (scopeShouldContinue)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 媛쒕컻?먭? ???묒뾽??嫄대꼫?곗뿀?듬땲??"));
call.ToolId, call.ToolName, scopeToolResultMessage ?? "[SKIPPED] 사용자에 의해 작업이 건너뛰어졌습니다"));
return (true, null);
}
}

View File

@@ -4,11 +4,11 @@ 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, int maxParallelBatch)
private static (bool ShouldRun, List<ContentBlock> ParallelBatch, List<ContentBlock> SequentialBatch)
CreateParallelExecutionPlan(bool parallelEnabled, List<ContentBlock> toolCalls, int maxParallelBatch)
{
if (!parallelEnabled || toolCalls.Count <= 1)
return (false, new List<LlmService.ContentBlock>(), toolCalls);
return (false, new List<ContentBlock>(), toolCalls);
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
if (maxParallelBatch > 0 && parallelBatch.Count > maxParallelBatch)

View File

@@ -1,4 +1,4 @@
using System;
using System;
namespace AxCopilot.Services.Agent;
@@ -125,7 +125,7 @@ internal static class AgentTranscriptDisplayCatalog
{
var summary = (evt.Summary ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(summary))
return summary;
return StripNonBmpCharacters(summary);
return evt.Type switch
{
@@ -265,13 +265,16 @@ internal static class AgentTranscriptDisplayCatalog
if (resultPresentation != null)
{
// ToolResult의 GroupKey를 선행 ToolCall과 동일한 activity 그룹으로 설정
// → ProcessFeed에서 ToolCall 카드를 ToolResult로 교체(머지)
var resultGroup = ResolveActivityGroup(toolName, summary);
return new AgentTranscriptRowPresentation(
TranscriptRowKind.ToolResult,
"결과",
resultPresentation.Label,
resultPresentation.Description,
$"result:{resultPresentation.Kind}:{resultPresentation.StatusKind}",
false,
$"activity:{resultGroup}",
true,
resultPresentation.NeedsAttention);
}
@@ -420,4 +423,32 @@ internal static class AgentTranscriptDisplayCatalog
_ => "도구",
};
}
/// <summary>
/// WPF 기본 폰트(Segoe UI)에서 렌더링되지 않는 비-BMP 유니코드 문자(이모지 등)를 제거합니다.
/// LLM 응답에 이모지가 포함되면 깨져서 표시되는 문제를 방지합니다.
/// </summary>
public static string StripNonBmpCharacters(string text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
// Consolas / Segoe UI에서 렌더링 불가한 비-BMP 유니코드(이모지, 서로게이트 쌍) 제거
var sb = new System.Text.StringBuilder(text.Length);
for (int i = 0; i < text.Length; i++)
{
var c = text[i];
if (char.IsHighSurrogate(c))
{
if (i + 1 < text.Length && char.IsLowSurrogate(text[i + 1]))
i++;
continue;
}
if (char.IsLowSurrogate(c)) continue;
if (c >= 0x2600 && c <= 0x27BF) continue; // Misc symbols, Dingbats
if (c >= 0x2B50 && c <= 0x2B55) continue; // Additional symbols
if (c >= 0xFE00 && c <= 0xFE0F) continue; // Variation selectors
sb.Append(c);
}
return sb.ToString().Trim();
}
}

View File

@@ -100,15 +100,18 @@ public class BuildRunTool : IAgentTool
}
else
{
// "run" 액션은 빌드로 대체 — 프로그램 실행은 사용자가 직접 수행
command = action switch
{
"build" => project.BuildCommand,
"test" => project.TestCommand,
"run" => project.RunCommand,
"run" => project.BuildCommand, // run → build로 대체 (실행은 사용자 몫)
"lint" => string.IsNullOrEmpty(project.LintCommand) ? null : project.LintCommand,
"format" => string.IsNullOrEmpty(project.FormatCommand) ? null : project.FormatCommand,
_ => project.BuildCommand,
};
if (action == "run")
action = "build"; // 출력 메시지에도 build로 표시
if (command == null)
return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다.");
}

View File

@@ -135,7 +135,7 @@ public class DocumentAssemblerTool : IAgentTool
var pageEstimate = Math.Max(1, totalWords / 500);
return ToolResult.Ok(
$"✅ 문서 조립 완료: {Path.GetFileName(fullPath)}\n" +
$"✅ 문서 조립 완료: {fullPath}\n" +
$" 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n" +
$"{resultMsg}", fullPath);
}
@@ -241,14 +241,30 @@ public class DocumentAssemblerTool : IAgentTool
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
// 기본 스타일 파트 추가 (styles.xml — 없으면 Word에서 글꼴/서식 깨짐)
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
stylesPart.Styles = CreateDefaultDocxStyles();
stylesPart.Styles.Save();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
// 한글 호환 글꼴 설정 헬퍼
static DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new()
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
};
// 제목
var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "48" }
});
@@ -267,6 +283,7 @@ public class DocumentAssemblerTool : IAgentTool
var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" },
Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" },
@@ -281,6 +298,11 @@ public class DocumentAssemblerTool : IAgentTool
{
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" }
});
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim())
{
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
@@ -293,9 +315,59 @@ public class DocumentAssemblerTool : IAgentTool
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
}
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
// 첫 번째에 넣으면 Word가 무시하거나 문서가 깨짐
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
mainPart.Document.Save();
return " ✓ DOCX 조립 완료";
}
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
private static DocumentFormat.OpenXml.Wordprocessing.Styles CreateDefaultDocxStyles()
{
var styles = new DocumentFormat.OpenXml.Wordprocessing.Styles();
// 문서 기본 글꼴 설정
var docDefaults = new DocumentFormat.OpenXml.Wordprocessing.DocDefaults(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" },
new DocumentFormat.OpenXml.Wordprocessing.Languages { Val = "ko-KR", EastAsia = "ko-KR" }
)
),
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160", Line = "259", LineRule = DocumentFormat.OpenXml.Wordprocessing.LineSpacingRuleValues.Auto }
)
)
);
styles.AppendChild(docDefaults);
// Normal 스타일
var normalStyle = new DocumentFormat.OpenXml.Wordprocessing.Style
{
Type = DocumentFormat.OpenXml.Wordprocessing.StyleValues.Paragraph,
StyleId = "Normal",
Default = true
};
normalStyle.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.StyleName { Val = "Normal" });
styles.AppendChild(normalStyle);
return styles;
}
private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections)
{
var sb = new StringBuilder();

View File

@@ -316,7 +316,7 @@ public class DocumentPlannerTool : IAgentTool
folder_data_usage = folderDataUsage,
step1 = hasRefData
? "Reference data is already provided above. Skip to step 3."
: "Use folder_map to scan the work folder, then use document_read to read RELEVANT files only.",
: "Use glob or grep to narrow relevant files first, then use document_read or file_read on the best candidates only. Use folder_map only when the user explicitly asks for folder structure or existing materials in a folder.",
step2 = hasRefData
? "(skipped)"
: "Summarize the key findings from the folder documents relevant to the topic.",
@@ -420,6 +420,12 @@ public class DocumentPlannerTool : IAgentTool
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
// 기본 스타일 파트 추가 (styles.xml — 없으면 Word에서 글꼴/서식 깨짐)
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
stylesPart.Styles = CreateDefaultDocxStyles();
stylesPart.Styles.Save();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
@@ -438,6 +444,15 @@ public class DocumentPlannerTool : IAgentTool
}
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
}
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
mainPart.Document.Save();
}
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
@@ -447,6 +462,13 @@ public class DocumentPlannerTool : IAgentTool
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
};
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
@@ -460,6 +482,45 @@ public class DocumentPlannerTool : IAgentTool
body.AppendChild(para);
}
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
private static DocumentFormat.OpenXml.Wordprocessing.Styles CreateDefaultDocxStyles()
{
var styles = new DocumentFormat.OpenXml.Wordprocessing.Styles();
var docDefaults = new DocumentFormat.OpenXml.Wordprocessing.DocDefaults(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" },
new DocumentFormat.OpenXml.Wordprocessing.Languages { Val = "ko-KR", EastAsia = "ko-KR" }
)
),
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesDefault(
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesBaseStyle(
new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160", Line = "259", LineRule = DocumentFormat.OpenXml.Wordprocessing.LineSpacingRuleValues.Auto }
)
)
);
styles.AppendChild(docDefaults);
var normalStyle = new DocumentFormat.OpenXml.Wordprocessing.Style
{
Type = DocumentFormat.OpenXml.Wordprocessing.StyleValues.Paragraph,
StyleId = "Normal",
Default = true
};
normalStyle.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.StyleName { Val = "Normal" });
styles.AppendChild(normalStyle);
return styles;
}
// ─── Markdown 생성 ──────────────────────────────────────────────────────
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
@@ -631,7 +692,7 @@ public class DocumentPlannerTool : IAgentTool
return normalized;
var intent = $"{docType} {topic}".ToLowerInvariant();
if (ContainsAny(intent, "대시보드", "dashboard", "분석", "analysis", "지표", "통계"))
if (ContainsAny(intent, "대시보드", "dashboard", "지표"))
return "dashboard";
if (ContainsAny(intent, "기획", "아이디어", "creative", "브레인스토밍"))
return "creative";
@@ -639,11 +700,15 @@ public class DocumentPlannerTool : IAgentTool
return "corporate";
if (ContainsAny(intent, "가이드", "manual", "guide", "매뉴얼"))
return "minimal";
// 보고서/분석: corporate 무드 (배경색 포함)
if (ContainsAny(intent, "보고서", "report", "분석", "analysis", "통계"))
return "corporate";
return docType switch
{
"proposal" => "corporate",
"analysis" => "dashboard",
"analysis" => "corporate",
"report" => "corporate",
"manual" or "guide" => "minimal",
"presentation" => "creative",
"minutes" => "professional",

View File

@@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using A = DocumentFormat.OpenXml.Drawing;
using UglyToad.PdfPig;
namespace AxCopilot.Services.Agent;
@@ -17,7 +18,7 @@ public class DocumentReaderTool : IAgentTool
public string Name => "document_read";
public string Description =>
"Read a document file and extract its text content. " +
"Supports: PDF (.pdf), Word (.docx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " +
"Supports: PDF (.pdf), Word (.docx), PowerPoint (.pptx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " +
"BibTeX (.bib), RIS (.ris). " +
"For large files, use 'offset' to read from a specific character position (chunked reading). " +
"For large PDFs, use 'pages' parameter to read specific page ranges (e.g., '1-5', '10-20'). " +
@@ -79,6 +80,7 @@ public class DocumentReaderTool : IAgentTool
{
".pdf" => await Task.Run(() => ReadPdf(fullPath, extractMax, pagesParam, sectionParam), ct),
".docx" => await Task.Run(() => ReadDocx(fullPath, extractMax), ct),
".pptx" => await Task.Run(() => ReadPptx(fullPath, extractMax), ct),
".xlsx" => await Task.Run(() => ReadXlsx(fullPath, sheetParam, extractMax), ct),
".bib" => await Task.Run(() => ReadBibTeX(fullPath, extractMax), ct),
".ris" => await Task.Run(() => ReadRis(fullPath, extractMax), ct),
@@ -478,6 +480,71 @@ public class DocumentReaderTool : IAgentTool
return Truncate(sb.ToString(), maxChars);
}
// ─── PPTX ───────────────────────────────────────────────────────────────
private static string ReadPptx(string path, int maxChars)
{
var sb = new StringBuilder();
using var doc = PresentationDocument.Open(path, false);
var presentation = doc.PresentationPart;
if (presentation?.Presentation?.SlideIdList == null)
return "(빈 프레젠테이션)";
var slideIds = presentation.Presentation.SlideIdList.Elements<DocumentFormat.OpenXml.Presentation.SlideId>().ToList();
sb.AppendLine($"PowerPoint: {slideIds.Count}개 슬라이드");
sb.AppendLine();
int slideNum = 0;
foreach (var slideId in slideIds)
{
if (sb.Length >= maxChars) break;
slideNum++;
var relId = slideId.RelationshipId?.Value;
if (relId == null) continue;
var slidePart = (SlidePart)presentation.GetPartById(relId);
var slide = slidePart.Slide;
if (slide == null) continue;
sb.AppendLine($"--- Slide {slideNum} ---");
// 슬라이드 내 모든 텍스트 추출 (도형, 텍스트 박스, 테이블 등)
var texts = slide.Descendants<A.Text>()
.Select(t => t.Text)
.Where(t => !string.IsNullOrWhiteSpace(t));
// 단락(Paragraph) 단위로 그룹핑하여 줄바꿈 유지
var paragraphs = slide.Descendants<A.Paragraph>();
foreach (var para in paragraphs)
{
var paraText = string.Join("", para.Descendants<A.Text>().Select(t => t.Text));
if (!string.IsNullOrWhiteSpace(paraText))
sb.AppendLine(paraText);
}
// 슬라이드 노트가 있으면 포함
var notesPart = slidePart.NotesSlidePart;
if (notesPart != null)
{
var noteTexts = notesPart.NotesSlide.Descendants<A.Paragraph>()
.Select(p => string.Join("", p.Descendants<A.Text>().Select(t => t.Text)))
.Where(t => !string.IsNullOrWhiteSpace(t))
.ToList();
if (noteTexts.Count > 0)
{
sb.AppendLine(" [노트]");
foreach (var note in noteTexts)
sb.AppendLine($" {note}");
}
}
sb.AppendLine();
}
return Truncate(sb.ToString(), maxChars);
}
// ─── XLSX ───────────────────────────────────────────────────────────────
private static string ReadXlsx(string path, string sheetParam, int maxChars)

View File

@@ -38,7 +38,9 @@ public class DocxSkill : IAgentTool
"• 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`.",
"• Icon: {\"type\": \"icon\", \"name\": \"checkmark|warning|rocket|star|...\", \"text\": \"optional label text\", \"size\": 28}\n" +
"Body text supports inline formatting: **bold**, *italic*, `code`. " +
"Inline icons in body text: use {icon:name} syntax e.g. '{icon:checkmark} 완료됨'.",
Items = new() { Type = "object" }
},
["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." },
@@ -136,6 +138,12 @@ public class DocxSkill : IAgentTool
using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
// 기본 스타일 파트 추가 (styles.xml)
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylesPart.Styles = CreateDefaultDocxStyles();
stylesPart.Styles.Save();
mainPart.Document = new Document();
var body = mainPart.Document.AppendChild(new Body());
@@ -194,6 +202,12 @@ public class DocxSkill : IAgentTool
continue;
}
if (blockType == "icon")
{
body.Append(CreateIconParagraph(section));
continue;
}
// 일반 섹션 (heading + body)
var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
@@ -212,6 +226,8 @@ public class DocxSkill : IAgentTool
sectionCount++;
}
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
EnsureSectionProperties(body);
mainPart.Document.Save();
var parts = new List<string>();
@@ -250,7 +266,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(),
FontSize = new FontSize { Val = "44" }, // 22pt
Color = new Color { Val = theme.Title },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
para.Append(run);
return para;
@@ -280,7 +296,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 = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
para.Append(run);
return para;
@@ -299,9 +315,16 @@ public class DocxSkill : IAgentTool
return para;
}
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary>
/// <summary>**bold**, *italic*, `code`, {icon:name} 인라인 서식을 Run으로 변환</summary>
private static void AppendFormattedRuns(Paragraph para, string text)
{
// 먼저 {icon:name} 인라인 아이콘을 심볼로 치환
text = System.Text.RegularExpressions.Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
return IconLibrary.Contains(name) ? IconLibrary.Resolve(name) : m.Value;
});
// 패턴: **bold** | *italic* | `code` | 일반텍스트
var regex = new System.Text.RegularExpressions.Regex(
@"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`");
@@ -331,7 +354,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", EastAsia = "맑은 고딕" };
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "Noto Sans KR" };
run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading
{
@@ -360,7 +383,7 @@ public class DocxSkill : IAgentTool
run.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" }, // 11pt
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
return run;
}
@@ -410,7 +433,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 = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}
});
para.ParagraphProperties = new ParagraphProperties
@@ -448,7 +471,7 @@ public class DocxSkill : IAgentTool
RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "20" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}
});
para.ParagraphProperties = new ParagraphProperties
@@ -503,7 +526,7 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "22" },
Bold = (listStyle == "number" && !isSub) ? new Bold() : null,
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
para.Append(prefixRun);
@@ -511,7 +534,7 @@ public class DocxSkill : IAgentTool
textRun.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
para.Append(textRun);
@@ -552,7 +575,7 @@ public class DocxSkill : IAgentTool
Bold = new Bold(),
FontSize = new FontSize { Val = "22" },
Color = new Color { Val = borderColor },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
titlePara.Append(titleRun);
body.Append(titlePara);
@@ -642,7 +665,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", EastAsia = "맑은 고딕" };
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas", EastAsia = "Noto Sans KR" };
run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading
{
@@ -679,6 +702,57 @@ public class DocxSkill : IAgentTool
// 머리글/바닥글
// ═══════════════════════════════════════════════════
/// <summary>Body의 마지막에 SectionProperties를 추가합니다 (OOXML 규격: 반드시 마지막 자식).</summary>
private static void EnsureSectionProperties(Body body)
{
// 기존 sectPr이 마지막이 아닌 위치에 있으면 제거 후 재추가
var existing = body.GetFirstChild<SectionProperties>();
if (existing != null)
{
// 이미 마지막 자식이면 유지
if (existing == body.LastChild) return;
existing.Remove();
}
body.AppendChild(new SectionProperties(
new PageSize { Width = 11906, Height = 16838 }, // A4
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
}
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
private static Styles CreateDefaultDocxStyles()
{
var styles = new Styles();
var docDefaults = new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
new RunFonts
{
Ascii = "맑은 고딕",
HighAnsi = "맑은 고딕",
EastAsia = "맑은 고딕",
ComplexScript = "맑은 고딕"
},
new FontSize { Val = "22" },
new Languages { Val = "ko-KR", EastAsia = "ko-KR" }
)
),
new ParagraphPropertiesDefault(
new ParagraphPropertiesBaseStyle(
new SpacingBetweenLines { After = "160", Line = "259", LineRule = LineSpacingRuleValues.Auto }
)
)
);
styles.AppendChild(docDefaults);
var normalStyle = new Style { Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
normalStyle.AppendChild(new StyleName { Val = "Normal" });
styles.AppendChild(normalStyle);
return styles;
}
private static void AddHeaderFooter(MainDocumentPart mainPart, Body body,
string? headerText, string? footerText, bool showPageNumbers)
{
@@ -693,7 +767,7 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "18" }, // 9pt
Color = new Color { Val = "808080" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}
});
para.ParagraphProperties = new ParagraphProperties
@@ -766,10 +840,44 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
}
};
/// <summary>아이콘 블록: 큰 심볼 + 선택적 라벨 텍스트.</summary>
private static Paragraph CreateIconParagraph(System.Text.Json.JsonElement section)
{
var iconName = section.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
var label = section.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
var fontSize = section.SafeTryGetProperty("size", out var sz) ? sz.GetInt32() : 28;
var symbol = IconLibrary.Resolve(iconName);
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines { Line = "360" },
};
// 아이콘 심볼 (큰 폰트)
var iconRun = new Run(new Text(symbol + " ") { Space = SpaceProcessingModeValues.Preserve });
iconRun.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = (fontSize * 2).ToString() }, // half-pt 단위
RunFonts = new RunFonts { Ascii = "Segoe UI Emoji", HighAnsi = "Segoe UI Emoji", EastAsia = "Segoe UI Emoji" },
};
para.Append(iconRun);
// 라벨 텍스트 (일반 폰트)
if (!string.IsNullOrWhiteSpace(label))
{
var labelRun = CreateRun(label);
labelRun.RunProperties!.FontSize = new FontSize { Val = "24" }; // 12pt
para.Append(labelRun);
}
return para;
}
private static Run CreatePageNumberRun()
{
var run = new Run();
@@ -777,7 +885,7 @@ public class DocxSkill : IAgentTool
{
FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" },
RunFonts = new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "맑은 고딕" },
RunFonts = new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" },
};
run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin });
run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve });

View File

@@ -172,7 +172,7 @@ internal static class DocxToHtmlConverter
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.7; padding: 32px 28px; font-size: 13.5px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 24px 0 10px; color: #111; }}
h1.title {{ font-size: 26px; text-align: center; margin-bottom: 4px; }}

View File

@@ -17,7 +17,9 @@ public class ExcelSkill : IAgentTool
"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), number formatting, " +
"themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks.";
"themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks. " +
"Inline icons: use {icon:name} in cell text (e.g. '{icon:checkmark} 완료', '{icon:warning} 주의'). " +
"170+ built-in icons: checkmark, warning, star, rocket, chart_up, chart_down, etc.";
public ToolParameterSchema Parameters => new()
{
@@ -340,12 +342,16 @@ public class ExcelSkill : IAgentTool
var strVal = cellVal.ToString();
// {icon:name} 인라인 아이콘 → 유니코드 심볼로 치환
if (strVal.Contains("{icon:"))
strVal = ResolveInlineIcons(strVal);
if (strVal.StartsWith('='))
{
cell.CellFormula = new CellFormula(strVal);
cell.DataType = null;
}
else if (cellVal.ValueKind == JsonValueKind.Number)
else if (cellVal.ValueKind == JsonValueKind.Number && !strVal.Contains("{icon:"))
{
cell.DataType = CellValues.Number;
cell.CellValue = new CellValue(cellVal.GetDouble().ToString());
@@ -500,18 +506,18 @@ public class ExcelSkill : IAgentTool
var fonts = new Fonts(
new Font( // 0: default
new FontSize { Val = 11 },
new FontName { Val = "맑은 고딕" }
new FontName { Val = "Noto Sans KR" }
),
new Font( // 1: bold white (header)
new Bold(),
new FontSize { Val = 11 },
new Color { Rgb = theme.HeaderFg },
new FontName { Val = "맑은 고딕" }
new FontName { Val = "Noto Sans KR" }
),
new Font( // 2: bold (summary row)
new Bold(),
new FontSize { Val = 11 },
new FontName { Val = "맑은 고딕" }
new FontName { Val = "Noto Sans KR" }
)
);
stylesheet.Append(fonts);
@@ -799,4 +805,12 @@ public class ExcelSkill : IAgentTool
private static string GetCellReference(int colIndex, int rowIndex)
=> $"{GetColumnLetter(colIndex)}{rowIndex + 1}";
/// <summary>{icon:name} 패턴을 유니코드 심볼로 치환합니다.</summary>
private static string ResolveInlineIcons(string text)
=> System.Text.RegularExpressions.Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
return IconLibrary.Contains(name) ? IconLibrary.Resolve(name) : m.Value;
});
}

View File

@@ -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 only when the user explicitly asks for folder contents, directory structure, or workspace layout. " +
"Do not use it to locate a specific file when glob/grep or a targeted read would work. " +
"Supports sorting, size filtering, date filtering, and multi-extension filtering.";
public ToolParameterSchema Parameters => new()
@@ -22,7 +23,7 @@ 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: 2." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: false for conservative first-pass exploration." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new()
{
@@ -65,7 +66,7 @@ public class FolderMapTool : IAgentTool
var maxDepth = Math.Min(depth, 10);
// ── include_files ─────────────────────────────────────────────────
var includeFiles = false;
var includeFiles = true;
if (args.SafeTryGetProperty("include_files", out var inc))
{
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)

View File

@@ -46,7 +46,10 @@ public class HtmlSkill : IAgentTool
"'list' {style:'bullet|number', items:['item', ' - sub-item']}, " +
"'quote' {text, author}, " +
"'divider', " +
"'kpi' {items:[{label, value, change, positive:bool}]}. " +
"'kpi' {items:[{label, value, change, positive:bool}]}, " +
"'icon' {name:'checkmark|warning|rocket|...', text:'optional label', size:48}. " +
"Inline icons in any text: use {icon:name} syntax (e.g. '{icon:checkmark} Done'). " +
"170+ built-in icons available. " +
"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" },
@@ -178,11 +181,16 @@ public class HtmlSkill : IAgentTool
}
else
{
// 커버가 없으면 기존 방식의 제목+메타
// 커버가 없으면 header-bar로 제목 표시
sb.AppendLine("<div class=\"header-bar\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"meta\">생성: {DateTime.Now:yyyy-MM-dd HH:mm} | AX Copilot{moodLabel}</div>");
sb.AppendLine("</div>");
}
// 본문을 body-content로 감싸서 좌우 여백 확보
sb.AppendLine("<div class=\"body-content\">");
// TOC
if (!string.IsNullOrEmpty(tocHtml))
sb.AppendLine(tocHtml);
@@ -198,7 +206,8 @@ public class HtmlSkill : IAgentTool
"$1</div>");
sb.AppendLine(wrappedBody);
sb.AppendLine("</div>");
sb.AppendLine("</div>"); // body-content
sb.AppendLine("</div>"); // container
sb.AppendLine("</body>");
sb.AppendLine("</html>");
@@ -268,6 +277,9 @@ public class HtmlSkill : IAgentTool
case "kpi":
sb.AppendLine(RenderKpi(section));
break;
case "icon":
sb.AppendLine(RenderIcon(section));
break;
}
}
return sb.ToString();
@@ -549,6 +561,15 @@ public class HtmlSkill : IAgentTool
// 7. 보존된 <br> 플레이스홀더를 복원
text = text.Replace("\x00BR\x00", "<br>");
// 8. {icon:name} 인라인 아이콘 → 유니코드 심볼 (span으로 감싸서 크기 조절)
text = Regex.Replace(text, @"\{icon:(\w+)\}", m =>
{
var name = m.Groups[1].Value;
if (!IconLibrary.Contains(name)) return m.Value;
var sym = IconLibrary.Resolve(name);
return $"<span class=\"inline-icon\" title=\"{name}\">{sym}</span>";
});
return text;
}
@@ -732,4 +753,21 @@ public class HtmlSkill : IAgentTool
private static string Escape(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>아이콘 블록: 큰 심볼 + 선택적 라벨 텍스트.</summary>
private static string RenderIcon(JsonElement s)
{
var name = s.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
var label = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
var size = s.SafeTryGetProperty("size", out var sz) ? sz.GetInt32() : 48;
var symbol = IconLibrary.Resolve(name);
var sb = new StringBuilder();
sb.Append($"<div class=\"icon-block\" style=\"display:flex;align-items:center;gap:12px;margin:16px 0\">");
sb.Append($"<span style=\"font-size:{size}px;line-height:1\">{symbol}</span>");
if (!string.IsNullOrWhiteSpace(label))
sb.Append($"<span style=\"font-size:1rem;color:#374151\">{MarkdownToHtml(label)}</span>");
sb.Append("</div>");
return sb.ToString();
}
}

View File

@@ -20,6 +20,14 @@ public interface IAgentTool
/// <summary>LLM function calling용 파라미터 JSON Schema.</summary>
ToolParameterSchema Parameters { get; }
/// <summary>
/// 도구가 활성화되는 탭 카테고리.
/// null이면 모든 탭에서 사용 가능.
/// "Cowork" = Cowork 전용, "Code" = Code 전용, "Chat" = Chat 전용.
/// 쉼표 구분으로 복수 탭 지정 가능: "Cowork,Code".
/// </summary>
string? TabCategory => null;
/// <summary>도구를 실행하고 결과를 반환합니다.</summary>
/// <param name="args">LLM이 생성한 JSON 파라미터</param>
/// <param name="context">실행 컨텍스트 (작업 폴더, 권한 등)</param>
@@ -171,16 +179,35 @@ public class AgentContext
}
// 작업 폴더 제한: 작업 폴더가 설정되어 있으면 하위 경로만 허용
// 사내 모드에서는 외부 경로도 사용자 승인 후 접근 가능 (CheckToolPermissionAsync에서 강제 승인 처리)
if (!string.IsNullOrEmpty(WorkFolder))
{
var workFull = Path.GetFullPath(WorkFolder);
if (!fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase))
{
// 사내 모드: 외부 경로는 IsPathAllowed에서 차단하지 않고 권한 검증 단계로 위임
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode))
return true; // CheckToolPermissionAsync에서 강제 승인 요청됨
return false;
}
}
return true;
}
/// <summary>경로가 워크스페이스 외부인지 확인합니다.</summary>
public bool IsOutsideWorkspace(string path)
{
if (string.IsNullOrEmpty(WorkFolder)) return false;
try
{
var fullPath = Path.GetFullPath(path);
var workFull = Path.GetFullPath(WorkFolder);
return !fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase);
}
catch { return true; }
}
/// <summary>
/// 파일 경로에 타임스탬프를 추가합니다.
/// 예: report.html → report_20260328_1430.html
@@ -241,6 +268,32 @@ public class AgentContext
var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(GetEffectiveToolPermission(toolName, target));
if (PermissionModeCatalog.IsDeny(effectivePerm)) return false;
// ── 사내 모드 보안 강화: 워크스페이스 외부 경로 접근 시 무조건 사용자 승인 ──
// BypassPermissions / AcceptEdits 모드여도 외부 경로는 강제 승인 필요
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode)
&& !string.IsNullOrWhiteSpace(target)
&& IsOutsideWorkspace(target))
{
if (AskPermission == null) return false;
var extTarget = target.Trim();
var extCacheKey = $"ext_ws|{toolName}|{extTarget}";
lock (_permissionLock)
{
if (_approvedPermissionCache.Contains(extCacheKey))
return true;
}
var extAllowed = await AskPermission(toolName, extTarget);
if (extAllowed)
{
lock (_permissionLock)
_approvedPermissionCache.Add(extCacheKey);
}
return extAllowed;
}
if (PermissionModeCatalog.IsAuto(effectivePerm)) return true;
if (AskPermission == null) return false;
@@ -482,4 +535,5 @@ public enum AgentEventType
StopRequested, // 중단 요청
Paused, // 에이전트 일시정지
Resumed, // 에이전트 재개
UserMessage, // 실행 중 사용자 메시지 주입
}

View File

@@ -4,19 +4,19 @@ namespace AxCopilot.Services.Agent;
internal interface IToolExecutionCoordinator
{
Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
LlmService.ContentBlock block,
Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct);
Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct,
string phaseLabel,
AgentLoopService.RunState? runState = null,
bool forceToolCall = false,
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null);
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<ToolStreamEvent, Task>? onStreamEventAsync = null);
}

View File

@@ -0,0 +1,240 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 모든 문서 스킬(PPTX, DOCX, XLSX, HTML)에서 공유하는 내장 아이콘/심볼 라이브러리.
/// 유니코드 심볼 기반이므로 외부 파일 0바이트 — 폰트가 지원하는 한 어디서든 렌더링됩니다.
///
/// 사용 예:
/// var symbol = IconLibrary.Resolve("checkmark"); // "✔"
/// var symbol = IconLibrary.Resolve("rocket"); // "🚀"
/// var symbol = IconLibrary.Resolve("없는이름"); // "❓" (폴백)
/// </summary>
internal static class IconLibrary
{
/// <summary>아이콘 이름으로 유니코드 심볼 문자열을 반환합니다. 없으면 ❓.</summary>
public static string Resolve(string? iconName)
{
if (string.IsNullOrWhiteSpace(iconName)) return "";
return Symbols.TryGetValue(iconName.Trim(), out var symbol) ? symbol : "❓";
}
/// <summary>아이콘 이름이 라이브러리에 존재하는지 확인합니다.</summary>
public static bool Contains(string? iconName)
=> !string.IsNullOrWhiteSpace(iconName) && Symbols.ContainsKey(iconName.Trim());
/// <summary>전체 아이콘 목록 (읽기 전용).</summary>
public static IReadOnlyDictionary<string, string> All => Symbols;
// ══════════════════════════════════════════════════════════════════════════
// 유니코드 심볼 매핑 (170종+)
// ══════════════════════════════════════════════════════════════════════════
private static readonly Dictionary<string, string> Symbols = new(StringComparer.OrdinalIgnoreCase)
{
// ── 상태/알림 ──
["checkmark"] = "✔",
["check"] = "✔",
["check_box"] = "☑",
["warning"] = "⚠",
["info"] = "",
["error"] = "✖",
["question"] = "❓",
["exclamation"] = "❗",
["prohibited"] = "🚫",
["sos"] = "🆘",
["new"] = "🆕",
["free"] = "🆓",
["ok"] = "🆗",
["cool"] = "🆒",
["up"] = "🆙",
["vs"] = "🆚",
["100"] = "💯",
// ── 사람/소통 ──
["people"] = "👥",
["person"] = "👤",
["thumbs_up"] = "👍",
["thumbs_down"] = "👎",
["clap"] = "👏",
["handshake"] = "🤝",
["wave_hand"] = "👋",
["pray"] = "🙏",
["muscle"] = "💪",
["brain"] = "🧠",
["eye"] = "👁",
["speech"] = "💬",
["thought"] = "💭",
// ── 사무/업무 ──
["phone"] = "📞",
["email"] = "📧",
["inbox"] = "📥",
["outbox"] = "📤",
["document"] = "📄",
["documents"] = "📑",
["folder"] = "📁",
["folder_open"] = "📂",
["calendar"] = "📅",
["clipboard"] = "📋",
["pushpin"] = "📌",
["pin"] = "📌",
["paperclip"] = "📎",
["ruler"] = "📏",
["scissors"] = "✂",
["pen"] = "🖊",
["pencil"] = "✏",
["memo"] = "📝",
["book"] = "📖",
["books"] = "📚",
["notebook"] = "📓",
["newspaper"] = "📰",
["label"] = "🏷",
["tag"] = "🏷",
["package"] = "📦",
["mailbox"] = "📬",
["fax"] = "📠",
["printer"] = "🖨",
["computer"] = "💻",
["desktop"] = "🖥",
["keyboard"] = "⌨",
["mouse"] = "🖱",
// ── 시간/시계 ──
["clock"] = "🕐",
["hourglass"] = "⏳",
["stopwatch"] = "⏱",
["alarm"] = "⏰",
["timer"] = "⏲",
// ── 보안/잠금 ──
["lock"] = "🔒",
["unlock"] = "🔓",
["key"] = "🔑",
["shield"] = "🛡",
// ── 검색/탐색 ──
["search"] = "🔍",
["magnify_right"] = "🔎",
["binoculars"] = "🔭",
["compass"] = "🧭",
["map"] = "🗺",
["globe"] = "🌐",
["globe_asia"] = "🌏",
["globe_americas"] = "🌎",
["globe_europe"] = "🌍",
// ── 도구/설정 ──
["wrench"] = "🔧",
["hammer"] = "🔨",
["nut_bolt"] = "🔩",
["link"] = "🔗",
["chain"] = "⛓",
["refresh"] = "🔄",
["recycle"] = "♻",
["gear"] = "⚙",
// ── 성취/보상 ──
["trophy"] = "🏆",
["medal"] = "🏅",
["medal_gold"] = "🥇",
["medal_silver"] = "🥈",
["medal_bronze"] = "🥉",
["crown"] = "👑",
["gem"] = "💎",
// ── 감정/기호 ──
["lightbulb"] = "💡",
["target"] = "🎯",
["rocket"] = "🚀",
["fire"] = "🔥",
["bell"] = "🔔",
["sparkle"] = "✨",
["sparkles"] = "✨",
["rainbow"] = "🌈",
["confetti"] = "🎉",
["party"] = "🎊",
["balloon"] = "🎈",
["gift"] = "🎁",
["heart"] = "❤",
["star"] = "⭐",
["thumbs"] = "👍",
// ── 화살표/방향 ──
["arrow_right"] = "➡",
["arrow_left"] = "⬅",
["arrow_up"] = "⬆",
["arrow_down"] = "⬇",
["arrow_up_right"] = "↗",
["arrow_down_right"]= "↘",
["arrow_curved"] = "↩",
// ── 금융/비즈니스 ──
["money"] = "💰",
["money_bag"] = "💰",
["dollar"] = "💵",
["credit_card"] = "💳",
["receipt"] = "🧾",
["chart_up"] = "📈",
["chart_down"] = "📉",
["bar_chart"] = "📊",
// ── 교통/이동 ──
["car"] = "🚗",
["bus"] = "🚌",
["train"] = "🚆",
["airplane"] = "✈",
["ship"] = "🚢",
["bicycle"] = "🚲",
// ── 건물/장소 ──
["building"] = "🏢",
["hospital"] = "🏥",
["school"] = "🏫",
["factory"] = "🏭",
["house"] = "🏠",
["store"] = "🏪",
["bank"] = "🏦",
// ── 자연/과학 ──
["tree"] = "🌳",
["leaf"] = "🍃",
["flower"] = "🌸",
["seedling"] = "🌱",
["earth"] = "🌍",
["water"] = "💧",
["snowflake"] = "❄",
["atom"] = "⚛",
["dna"] = "🧬",
["microscope"] = "🔬",
["telescope"] = "🔭",
["test_tube"] = "🧪",
["petri_dish"] = "🧫",
// ── 음식 ──
["coffee"] = "☕",
["pizza"] = "🍕",
// ── 수학/기호 ──
["plus"] = "",
["minus"] = "",
["multiply"] = "✖",
["divide"] = "➗",
["infinity"] = "♾",
["copyright"] = "©",
["trademark"] = "™",
["registered"] = "®",
// ── 기타 유용 ──
["battery"] = "🔋",
["plug"] = "🔌",
["magnet"] = "🧲",
["crystal_ball"] = "🔮",
["palette"] = "🎨",
["movie"] = "🎬",
["music"] = "🎵",
["headphone"] = "🎧",
["camera"] = "📷",
["video"] = "📹",
["satellite"] = "📡",
["antenna"] = "📡",
["megaphone"] = "📢",
["loudspeaker"] = "📢",
["mute"] = "🔇",
["speaker"] = "🔊",
["zzz"] = "💤",
["smiley"] = "😊",
["cloud"] = "☁",
["sun"] = "☀",
["moon"] = "🌙",
["lightning"] = "⚡",
["flag"] = "🚩",
["home"] = "🏠",
["cross"] = "✝",
["diamond"] = "💠",
["no_symbol"] = "🚫",
};
}

View File

@@ -87,18 +87,18 @@ public static class ModelExecutionProfileCatalog
DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: false,
EnablePostToolVerification: true,
EnablePostToolVerification: false,
EnableCodeQualityGates: true,
EnableDocumentVerificationGate: true,
EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 2,
CodeVerificationGateMaxRetries: 1,
HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1,
CodeDiffGateMaxRetries: 1,
RecentExecutionGateMaxRetries: 1,
ExecutionSuccessGateMaxRetries: 1,
DocumentVerificationGateMaxRetries: 1,
FinalReportGateMaxRetries: 0,
CodeDiffGateMaxRetries: 0,
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1),
"fast_readonly" => new ExecutionPolicy(
"fast_readonly",
@@ -128,8 +128,8 @@ public static class ModelExecutionProfileCatalog
"document_heavy" => new ExecutionPolicy(
"document_heavy",
"문서 생성 우선",
ForceInitialToolCall: true,
ForceToolCallAfterPlan: true,
ForceInitialToolCall: false,
ForceToolCallAfterPlan: false,
ToolTemperatureCap: 0.35,
NoToolResponseThreshold: 1,
NoToolRecoveryMaxRetries: 1,
@@ -162,18 +162,18 @@ public static class ModelExecutionProfileCatalog
DocumentPlanRetryMax: 2,
PreferAggressiveDocumentFallback: false,
ReduceEarlyMemoryPressure: false,
EnablePostToolVerification: true,
EnablePostToolVerification: false,
EnableCodeQualityGates: true,
EnableDocumentVerificationGate: true,
EnableDocumentVerificationGate: false,
EnableParallelReadBatch: true,
MaxParallelReadBatch: 6,
CodeVerificationGateMaxRetries: 2,
CodeVerificationGateMaxRetries: 1,
HighImpactBuildTestGateMaxRetries: 1,
FinalReportGateMaxRetries: 1,
CodeDiffGateMaxRetries: 1,
RecentExecutionGateMaxRetries: 1,
ExecutionSuccessGateMaxRetries: 1,
DocumentVerificationGateMaxRetries: 1,
FinalReportGateMaxRetries: 0,
CodeDiffGateMaxRetries: 0,
RecentExecutionGateMaxRetries: 0,
ExecutionSuccessGateMaxRetries: 0,
DocumentVerificationGateMaxRetries: 0,
TerminalEvidenceGateMaxRetries: 1),
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -165,7 +165,7 @@ internal static class PptxToHtmlConverter
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #f3f4f6; color: #1a1a1a;
body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #f3f4f6; color: #1a1a1a;
line-height: 1.6; padding: 20px; font-size: 13px; }}
.slide-info {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; text-align: center; }}
.slide-card {{ background: #fff; border-radius: 12px; padding: 28px 32px; margin-bottom: 16px;

View File

@@ -282,8 +282,9 @@ public static class SkillService
.
:
1. file_read
2. folder_map ()
1. glob/grep
2. file_read
3. folder_map
:
-

View File

@@ -90,7 +90,7 @@ public class TemplateRenderTool : IAgentTool
await TextFileCodec.WriteAllTextAsync(outputPath, rendered, TextFileCodec.Utf8NoBom, ct);
return ToolResult.Ok(
$"✅ 템플릿 렌더링 완료: {Path.GetFileName(outputPath)} ({rendered.Length:N0}자)",
$"✅ 템플릿 렌더링 완료: {outputPath} ({rendered.Length:N0}자)",
outputPath);
}

View File

@@ -182,7 +182,7 @@ public static class TemplateService
private const string CssModern = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 16px; padding: 56px 52px;
@@ -218,7 +218,7 @@ public static class TemplateService
#region Professional
private const string CssProfessional = """
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
body { font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 8px; padding: 48px;
@@ -257,7 +257,7 @@ public static class TemplateService
private const string CssCreative = """
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
body { font-family: 'Poppins', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: rgba(255,255,255,0.95);
@@ -336,7 +336,7 @@ public static class TemplateService
private const string CssElegant = """
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
body { font-family: 'Source Sans 3', 'Noto Sans KR', sans-serif;
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 4px; padding: 56px 52px;
@@ -375,7 +375,7 @@ public static class TemplateService
private const string CssDark = """
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #161b22;
border-radius: 12px; padding: 52px;
@@ -415,7 +415,7 @@ public static class TemplateService
private const string CssColorful = """
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
body { font-family: 'Nunito', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff;
@@ -455,7 +455,7 @@ public static class TemplateService
#region Corporate
private const string CssCorporate = """
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
body { font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff; padding: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
@@ -464,6 +464,9 @@ public static class TemplateService
.header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; }
.header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; }
.body-content { padding: 36px 40px 40px; }
.cover-page { margin: -36px -40px 40px -40px !important; border-radius: 0 !important;
width: auto !important; box-sizing: border-box !important;
left: 0; right: 0; }
h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; }
h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366;
border-left: 4px solid #ff6600; padding-left: 12px; }
@@ -497,7 +500,7 @@ public static class TemplateService
private const string CssMagazine = """
@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
body { font-family: 'Open Sans', 'Noto Sans KR', sans-serif;
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
.container { max-width: 1080px; margin: 0 auto; background: #fff;
border-radius: 2px; padding: 0; overflow: hidden;
@@ -548,7 +551,7 @@ public static class TemplateService
private const string CssDashboard = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; }
.container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; }
h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; }

View File

@@ -202,7 +202,7 @@ internal static class XlsxToHtmlConverter
<meta charset='UTF-8'>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
body {{ font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
line-height: 1.5; padding: 16px; font-size: 12.5px; }}
.sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }}
.sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer;

View File

@@ -9,7 +9,7 @@ namespace AxCopilot.Services;
/// 채팅 세션 외의 스킬, MCP, 권한, 도구 카탈로그 상태를 한 곳에 모아
/// 창별 로컬 필드 의존도를 줄이는 1차 계층입니다.
/// </summary>
public sealed class AppStateService
public sealed class AppStateService : IAppStateService
{
public sealed class SkillCatalogState
{
@@ -212,7 +212,7 @@ public sealed class AppStateService
NotifyStateChanged();
}
public void LoadFromSettings(SettingsService settings)
public void LoadFromSettings(ISettingsService settings)
{
var llm = settings.Settings.Llm;
Skills.Enabled = llm.EnableSkillSystem;

View File

@@ -51,7 +51,7 @@ public sealed class ChatSessionStateService
return CurrentConversation;
}
public void Load(SettingsService settings)
public void Load(ISettingsService settings)
{
var llm = settings.Settings.Llm;
ActiveTab = NormalizeTab(llm.LastActiveTab);
@@ -67,7 +67,7 @@ public sealed class ChatSessionStateService
}
}
public void Save(SettingsService settings)
public void Save(ISettingsService settings)
{
var llm = settings.Settings.Llm;
llm.LastActiveTab = NormalizeTab(ActiveTab);
@@ -87,7 +87,7 @@ public sealed class ChatSessionStateService
TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId;
}
public ChatConversation LoadOrCreateConversation(string tab, ChatStorageService storage, SettingsService settings)
public ChatConversation LoadOrCreateConversation(string tab, IChatStorageService storage, ISettingsService settings)
{
var normalizedTab = NormalizeTab(tab);
var rememberedId = GetConversationId(normalizedTab);
@@ -153,13 +153,19 @@ public sealed class ChatSessionStateService
return CreateFreshConversation(normalizedTab, settings);
}
public ChatConversation CreateFreshConversation(string tab, SettingsService settings)
public ChatConversation CreateFreshConversation(string tab, ISettingsService settings)
{
var normalizedTab = NormalizeTab(tab);
var created = new ChatConversation { Tab = normalizedTab };
var workFolder = settings.Settings.Llm.WorkFolder;
if (!string.IsNullOrWhiteSpace(workFolder) && normalizedTab != "Chat")
created.WorkFolder = workFolder;
// Code/Cowork 탭: 매 대화마다 폴더를 새로 선택하도록 빈 상태로 시작
if (string.Equals(normalizedTab, "Code", StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalizedTab, "Cowork", StringComparison.OrdinalIgnoreCase))
{
created.WorkFolder = "";
CurrentConversation = created;
return created;
}
CurrentConversation = created;
return created;
@@ -239,7 +245,7 @@ public sealed class ChatSessionStateService
return fork;
}
public void SaveCurrentConversation(ChatStorageService storage, string tab)
public void SaveCurrentConversation(IChatStorageService storage, string tab)
{
var conv = CurrentConversation;
if (conv == null) return;
@@ -261,7 +267,7 @@ public sealed class ChatSessionStateService
RememberConversation(tab, null);
}
public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, ChatStorageService? storage = null, bool remember = true)
public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, IChatStorageService? storage = null, bool remember = true)
{
var normalizedTab = NormalizeTab(tab);
conversation.Tab = normalizedTab;
@@ -275,7 +281,7 @@ public sealed class ChatSessionStateService
return conversation;
}
public ChatMessage AppendMessage(string tab, ChatMessage message, ChatStorageService? storage = null, bool useForTitle = false)
public ChatMessage AppendMessage(string tab, ChatMessage message, IChatStorageService? storage = null, bool useForTitle = false)
{
var conv = EnsureCurrentConversation(tab);
conv.Messages.Add(message);
@@ -294,7 +300,7 @@ public sealed class ChatSessionStateService
public ChatConversation UpdateConversationMetadata(
string tab,
Action<ChatConversation> apply,
ChatStorageService? storage = null,
IChatStorageService? storage = null,
bool ensureConversation = true)
{
var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) });
@@ -311,7 +317,7 @@ public sealed class ChatSessionStateService
string? dataUsage,
string? outputFormat,
string? mood,
ChatStorageService? storage = null)
IChatStorageService? storage = null)
{
return UpdateConversationMetadata(tab, conv =>
{
@@ -322,7 +328,7 @@ public sealed class ChatSessionStateService
}, storage);
}
public bool RemoveLastAssistantMessage(string tab, ChatStorageService? storage = null)
public bool RemoveLastAssistantMessage(string tab, IChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null || conv.Messages.Count == 0)
@@ -336,7 +342,7 @@ public sealed class ChatSessionStateService
return true;
}
public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, ChatStorageService? storage = null)
public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, IChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null)
@@ -353,7 +359,7 @@ public sealed class ChatSessionStateService
return true;
}
public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, ChatStorageService? storage = null)
public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, IChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null)
@@ -368,7 +374,7 @@ public sealed class ChatSessionStateService
return true;
}
public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, ChatStorageService? storage = null)
public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, IChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ExecutionEvents ??= new List<ChatExecutionEvent>();
@@ -417,7 +423,7 @@ public sealed class ChatSessionStateService
return conv;
}
public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, ChatStorageService? storage = null)
public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, IChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.AgentRunHistory ??= new List<ChatAgentRunRecord>();
@@ -448,7 +454,7 @@ public sealed class ChatSessionStateService
return conv;
}
public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", ChatStorageService? storage = null, string kind = "message")
public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", IChatStorageService? storage = null, string kind = "message")
{
var trimmed = text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(trimmed))
@@ -480,16 +486,16 @@ public sealed class ChatSessionStateService
return (conv.DraftQueueItems ?? []).ToList();
}
public bool MarkDraftRunning(string tab, string draftId, ChatStorageService? storage = null)
public bool MarkDraftRunning(string tab, string draftId, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage);
public bool MarkDraftCompleted(string tab, string draftId, ChatStorageService? storage = null)
public bool MarkDraftCompleted(string tab, string draftId, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage);
public bool MarkDraftFailed(string tab, string draftId, string? error, ChatStorageService? storage = null)
public bool MarkDraftFailed(string tab, string draftId, string? error, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage);
public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, ChatStorageService? storage = null)
public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, IChatStorageService? storage = null)
{
return UpdateDraftItem(tab, draftId, item =>
{
@@ -503,10 +509,10 @@ public sealed class ChatSessionStateService
}, storage);
}
public bool ResetDraftToQueued(string tab, string draftId, ChatStorageService? storage = null)
public bool ResetDraftToQueued(string tab, string draftId, IChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage);
public bool RemoveDraft(string tab, string draftId, ChatStorageService? storage = null)
public bool RemoveDraft(string tab, string draftId, IChatStorageService? storage = null)
{
if (string.IsNullOrWhiteSpace(draftId))
return false;
@@ -520,7 +526,7 @@ public sealed class ChatSessionStateService
return true;
}
public bool ToggleExecutionHistory(string tab, ChatStorageService? storage = null)
public bool ToggleExecutionHistory(string tab, IChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ShowExecutionHistory = !conv.ShowExecutionHistory;
@@ -528,7 +534,7 @@ public sealed class ChatSessionStateService
return conv.ShowExecutionHistory;
}
public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, ChatStorageService? storage = null)
public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, IChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ConversationFailedOnlyFilter = failedOnly;
@@ -758,7 +764,7 @@ public sealed class ChatSessionStateService
return result;
}
private void TouchConversation(ChatStorageService? storage, string tab)
private void TouchConversation(IChatStorageService? storage, string tab)
{
var conv = EnsureCurrentConversation(tab);
conv.UpdatedAt = DateTime.Now;
@@ -768,7 +774,7 @@ public sealed class ChatSessionStateService
try { storage?.Save(conv); } catch { }
}
private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, ChatStorageService? storage)
private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, IChatStorageService? storage)
{
if (string.IsNullOrWhiteSpace(draftId))
return false;

View File

@@ -11,7 +11,7 @@ namespace AxCopilot.Services;
/// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호.
/// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지.
/// </summary>
public class ChatStorageService
public class ChatStorageService : IChatStorageService
{
private static readonly string ConversationsDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -53,7 +53,7 @@ public class ChatStorageService
// 원자적 교체: 기존 파일이 있으면 덮어쓰기
if (File.Exists(path)) File.Delete(path);
File.Move(tempPath, path);
UpdateMetaCache(conversation);
UpdateMetaCacheUnsafe(conversation);
}
catch (Exception ex)
{
@@ -124,6 +124,20 @@ public class ChatStorageService
/// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary>
public void UpdateMetaCache(ChatConversation conv)
{
Lock.EnterWriteLock();
try
{
UpdateMetaCacheUnsafe(conv);
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>WriteLock이 이미 확보된 상태에서 호출. 내부 전용.</summary>
private void UpdateMetaCacheUnsafe(ChatConversation conv)
{
if (_metaCache == null) return;
var existing = _metaCache.FindIndex(c => c.Id == conv.Id);
@@ -146,6 +160,20 @@ public class ChatStorageService
/// <summary>메타 캐시에서 항목을 제거합니다.</summary>
public void RemoveFromMetaCache(string id)
{
Lock.EnterWriteLock();
try
{
RemoveFromMetaCacheUnsafe(id);
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>WriteLock이 이미 확보된 상태에서 호출. 내부 전용.</summary>
private void RemoveFromMetaCacheUnsafe(string id)
{
_metaCache?.RemoveAll(c => c.Id == id);
InvalidateMetaOrderCache();
@@ -220,7 +248,7 @@ public class ChatStorageService
try
{
if (File.Exists(path)) File.Delete(path);
RemoveFromMetaCache(id);
RemoveFromMetaCacheUnsafe(id);
}
catch (Exception ex)
{
@@ -293,7 +321,7 @@ public class ChatStorageService
if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff)
{
File.Delete(file);
RemoveFromMetaCache(conv.Id);
RemoveFromMetaCacheUnsafe(conv.Id);
count++;
}
}

View File

@@ -56,9 +56,9 @@ internal sealed class Cp4dTokenService
Content = JsonContent.Create(body)
};
using var resp = await _http.SendAsync(req, ct);
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
statusCode = resp.StatusCode;
var rawBody = await resp.Content.ReadAsStringAsync(ct);
var rawBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
lastErrorBody = rawBody;
@@ -71,9 +71,9 @@ internal sealed class Cp4dTokenService
return true;
}
var authorized = await TryAuthorizeAsync(new { username, password }, "username+password");
var authorized = await TryAuthorizeAsync(new { username, password }, "username+password").ConfigureAwait(false);
if (!authorized && !string.IsNullOrWhiteSpace(password))
authorized = await TryAuthorizeAsync(new { username, api_key = password }, "username+api_key");
authorized = await TryAuthorizeAsync(new { username, api_key = password }, "username+api_key").ConfigureAwait(false);
if (!authorized || string.IsNullOrWhiteSpace(json))
{

View File

@@ -11,7 +11,7 @@ public sealed class DraftQueueProcessorService
public bool CanStartNext(ChatSessionStateService? session, string tab)
=> session?.GetNextQueuedDraft(tab) != null;
public DraftQueueItem? TryStartNext(ChatSessionStateService? session, string tab, ChatStorageService? storage = null, string? preferredDraftId = null, TaskRunService? taskRuns = null)
public DraftQueueItem? TryStartNext(ChatSessionStateService? session, string tab, IChatStorageService? storage = null, string? preferredDraftId = null, TaskRunService? taskRuns = null)
{
if (session == null)
return null;
@@ -46,7 +46,7 @@ public sealed class DraftQueueProcessorService
?? next;
}
public bool Complete(ChatSessionStateService? session, string tab, string draftId, ChatStorageService? storage = null, TaskRunService? taskRuns = null)
public bool Complete(ChatSessionStateService? session, string tab, string draftId, IChatStorageService? storage = null, TaskRunService? taskRuns = null)
{
var completed = session?.MarkDraftCompleted(tab, draftId, storage) ?? false;
if (completed)
@@ -54,7 +54,7 @@ public sealed class DraftQueueProcessorService
return completed;
}
public bool HandleFailure(ChatSessionStateService? session, string tab, string draftId, string? error, bool cancelled = false, int maxAutoRetries = 3, ChatStorageService? storage = null, TaskRunService? taskRuns = null)
public bool HandleFailure(ChatSessionStateService? session, string tab, string draftId, string? error, bool cancelled = false, int maxAutoRetries = 3, IChatStorageService? storage = null, TaskRunService? taskRuns = null)
{
if (session == null)
return false;
@@ -83,7 +83,7 @@ public sealed class DraftQueueProcessorService
return handled;
}
public int PromoteReadyBlockedItems(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
public int PromoteReadyBlockedItems(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
{
if (session == null)
return 0;
@@ -102,18 +102,18 @@ public sealed class DraftQueueProcessorService
return promoted;
}
public int ClearCompleted(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
public int ClearCompleted(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
=> ClearByState(session, tab, "completed", storage);
public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
public int ClearFailed(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
=> ClearByState(session, tab, "failed", storage);
/// <summary>대기 중인 항목을 모두 제거합니다 (중지 시 사용).</summary>
public int ClearQueued(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
public int ClearQueued(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
=> ClearByState(session, tab, "queued", storage);
/// <summary>실행 중인 항목을 실패로 전환합니다 (중지 시 사용).</summary>
public int CancelRunning(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
public int CancelRunning(ChatSessionStateService? session, string tab, IChatStorageService? storage = null)
{
if (session == null) return 0;
int count = 0;
@@ -127,7 +127,7 @@ public sealed class DraftQueueProcessorService
return count;
}
private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage)
private static int ClearByState(ChatSessionStateService? session, string tab, string state, IChatStorageService? storage)
{
if (session == null)
return 0;

View File

@@ -42,15 +42,15 @@ internal sealed class IbmIamTokenService
"application/x-www-form-urlencoded");
req.Headers.Accept.ParseAdd("application/json");
using var resp = await _http.SendAsync(req, ct);
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var errBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
LogService.Warn($"IBM IAM 토큰 발급 실패: {resp.StatusCode} - {errBody}");
return null;
}
var json = await resp.Content.ReadAsStringAsync(ct);
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("access_token", out var tokenProp))
{

View File

@@ -124,7 +124,7 @@ public class IndexService : IDisposable
/// </summary>
public async Task BuildAsync(CancellationToken ct = default)
{
await _rebuildLock.WaitAsync(ct);
await _rebuildLock.WaitAsync(ct).ConfigureAwait(false);
var sw = Stopwatch.StartNew();
try
{
@@ -146,7 +146,7 @@ public class IndexService : IDisposable
$"스캔 중: {Path.GetFileName(dir)}",
EstimateRemainingText(progressBase, sw.Elapsed)));
await ScanDirectoryAsync(dir, fileSystemEntries, allowedExts, indexSpeed, ct);
await ScanDirectoryAsync(dir, fileSystemEntries, allowedExts, indexSpeed, ct).ConfigureAwait(false);
var completedProgress = paths.Count == 0 ? 1 : (double)(index + 1) / paths.Count;
ReportIndexProgress(LauncherIndexProgressInfo.Running(

View File

@@ -0,0 +1,25 @@
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>앱 전역 상태 요약 저장소 인터페이스.</summary>
public interface IAppStateService
{
ChatSessionStateService? ChatSession { get; }
void AttachChatSession(ChatSessionStateService session);
void LoadFromSettings(ISettingsService settings);
AppStateService.SkillCatalogState Skills { get; }
AppStateService.McpCatalogState Mcp { get; }
AppStateService.PermissionPolicyState Permissions { get; }
AppStateService.AgentCatalogState AgentCatalog { get; }
AppStateService.AgentRunState AgentRun { get; }
void UpsertTask(string id, string kind, string title, string summary, string status = "running", string? filePath = null);
void CompleteTask(string id, string? summary = null, string status = "completed");
void ApplyAgentEvent(AgentEvent evt);
AppStateService.BackgroundJobSummaryState GetBackgroundJobSummary();
AppStateService.PermissionSummaryState GetPermissionSummary(ChatConversation? conversation = null);
}

View File

@@ -0,0 +1,19 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>대화 내역 저장/로드/삭제 인터페이스.</summary>
public interface IChatStorageService
{
void Save(ChatConversation conversation);
ChatConversation? Load(string id);
List<ChatConversation> LoadAllMeta();
void InvalidateMetaCache();
void UpdateMetaCache(ChatConversation conv);
void RemoveFromMetaCache(string id);
void Delete(string id);
int DeleteAll();
int DeleteAllByTab(string tab);
int PurgeExpired(int retentionDays);
int PurgeForDiskSpace(double threshold = 0.98);
}

View File

@@ -0,0 +1,75 @@
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>런타임 연결 정보 스냅샷.</summary>
public record RuntimeConnectionSnapshot(
string Service,
string Model,
string Endpoint,
bool AllowInsecureTls,
bool HasApiKey);
/// <summary>LLM API 호출 인터페이스. 스트리밍/비스트리밍 모두 지원.</summary>
public interface ILlmService : IDisposable
{
/// <summary>현재 서비스/모델 정보 조회.</summary>
(string service, string model) GetCurrentModelInfo();
/// <summary>비스트리밍 전체 응답 요청.</summary>
Task<string> SendAsync(List<ChatMessage> messages, CancellationToken ct = default);
/// <summary>스트리밍 응답 요청 (SSE).</summary>
IAsyncEnumerable<string> StreamAsync(
List<ChatMessage> messages,
CancellationToken ct = default);
/// <summary>연결 테스트.</summary>
Task<(bool ok, string message)> TestConnectionAsync();
/// <summary>자동 라우팅용 서비스/모델 오버라이드 설정.</summary>
void PushRouteOverride(string service, string model);
/// <summary>서비스/모델 오버라이드 해제.</summary>
void ClearRouteOverride();
/// <summary>모델/추론 파라미터 오버라이드 Push.</summary>
void PushInferenceOverride(
string? service = null,
string? model = null,
double? temperature = null,
string? reasoningEffort = null);
/// <summary>가장 최근 Push 상태 복원.</summary>
void PopInferenceOverride();
/// <summary>가장 최근 요청의 토큰 사용량.</summary>
TokenUsage? LastTokenUsage { get; }
/// <summary>런타임 연결 정보 스냅샷 조회.</summary>
RuntimeConnectionSnapshot GetRuntimeConnectionSnapshot();
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
Task<List<ContentBlock>> SendWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct = default,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null);
/// <summary>도구 정의를 포함하여 스트리밍 요청.</summary>
IAsyncEnumerable<ToolStreamEvent> StreamWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
CancellationToken ct = default);
/// <summary>현재 활성 실행 정책 조회.</summary>
ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy();
/// <summary>현재 시스템 프롬프트 (컨텍스트 토큰 추정에 사용).</summary>
string? SystemPrompt { get; }
}

View File

@@ -0,0 +1,9 @@
namespace AxCopilot.Services;
/// <summary>사용자 메시지의 인텐트를 분석하여 최적 모델을 선택하는 라우터 인터페이스.</summary>
public interface IModelRouterService
{
/// <summary>사용자 메시지를 분석하여 최적 모델을 선택합니다.</summary>
/// <returns>라우팅 결과. null이면 기본 모델 유지.</returns>
ModelRouteResult? Route(string userMessage);
}

View File

@@ -0,0 +1,15 @@
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>애플리케이션 설정 읽기/쓰기 인터페이스.</summary>
public interface ISettingsService
{
AppSettings Settings { get; }
string? MigrationSummary { get; }
event EventHandler? SettingsChanged;
void Load();
void Save();
Task SaveAsync();
}

View File

@@ -62,9 +62,9 @@ public partial class LlmService
EnsureOperationModeAllowsLlmService(activeService);
return NormalizeServiceName(activeService) switch
{
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync),
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct).ConfigureAwait(false),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct).ConfigureAwait(false),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).ConfigureAwait(false),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
};
}
@@ -592,7 +592,7 @@ public partial class LlmService
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results;
@@ -690,9 +690,13 @@ public partial class LlmService
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages);
foreach (var m in messages)
for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++)
{
var m = messages[messageIndex];
var keepStructuredHistory = messageIndex >= structuredHistoryStart;
// tool_result 메시지 → OpenAI tool 응답 형식
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
@@ -700,6 +704,16 @@ public partial class LlmService
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
if (!keepStructuredHistory)
{
msgs.Add(new
{
role = "user",
content = BuildOpenAiToolResultTranscript(root),
});
continue;
}
msgs.Add(new
{
role = "tool",
@@ -718,6 +732,16 @@ public partial class LlmService
{
using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
if (!keepStructuredHistory)
{
msgs.Add(new
{
role = "assistant",
content = BuildOpenAiAssistantTranscript(blocksArr),
});
continue;
}
var textContent = "";
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
@@ -766,6 +790,12 @@ public partial class LlmService
}
}
// ── tool_calls ↔ tool 메시지 쌍 검증 ──
// 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데
// 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함.
// 깨진 tool_calls 메시지를 일반 assistant 텍스트로 평탄화하여 방지.
SanitizeToolCallPairs(msgs);
// OpenAI 도구 정의
var toolDefs = tools.Select(t =>
{
@@ -798,14 +828,20 @@ public partial class LlmService
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama)
{
return new
// Ollama /api/chat 전용 바디 — stream:false로 비스트리밍 응답
// Ollama 0.5.x+ 에서 tool_choice 파라미터 지원 (미지원 버전은 무시됨)
var ollamaBody = new Dictionary<string, object?>
{
model = activeModel,
messages = msgs,
tools = toolDefs,
stream = false,
options = new { temperature = ResolveToolTemperature() }
["model"] = activeModel,
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = false,
["options"] = new { temperature = ResolveToolTemperature() }
};
// Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용
if (forceToolCall)
ollamaBody["tool_choice"] = "required";
return ollamaBody;
}
var body = new Dictionary<string, object?>
@@ -830,6 +866,26 @@ public partial class LlmService
return body;
}
private static int GetStructuredToolHistoryStartIndex(IReadOnlyList<ChatMessage> messages)
{
const int protectedRecentNonSystemMessages = 8;
var nonSystemMessages = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
.ToList();
if (nonSystemMessages.Count <= protectedRecentNonSystemMessages)
return 0;
var tentativeStart = Math.Max(0, nonSystemMessages.Count - protectedRecentNonSystemMessages);
var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
nonSystemMessages.Select(x => x.message).ToList(),
tentativeStart,
out _);
return nonSystemMessages[Math.Max(0, adjustedStart)].index;
}
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
private object BuildIbmToolBody(
List<ChatMessage> messages,
@@ -1006,6 +1062,51 @@ public partial class LlmService
: string.Join("\n\n", parts);
}
private static string BuildOpenAiAssistantTranscript(JsonElement blocksArr)
{
var textSegments = new List<string>();
var toolNames = new List<string>();
foreach (var block in blocksArr.EnumerateArray())
{
var blockType = block.GetProperty("type").SafeGetString();
if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase))
{
var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
textSegments.Add(text.Trim());
continue;
}
if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase))
continue;
var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(name))
toolNames.Add(name.Trim());
}
var parts = new List<string>();
if (textSegments.Count > 0)
parts.Add(string.Join("\n\n", textSegments));
if (toolNames.Count > 0)
parts.Add($"[이전 도구 호출: {string.Join(", ", toolNames.Distinct(StringComparer.OrdinalIgnoreCase))}]");
return parts.Count == 0 ? "[이전 도구 호출]" : string.Join("\n\n", parts);
}
private static string BuildOpenAiToolResultTranscript(JsonElement root)
{
var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : "";
var header = string.IsNullOrWhiteSpace(toolName)
? "[이전 도구 결과]"
: $"[이전 도구 결과: {toolName}]";
return string.IsNullOrWhiteSpace(content)
? $"{header}\n(no output)"
: $"{header}\n{content}";
}
private static string BuildIbmToolResultTranscript(JsonElement root)
{
var toolCallId = root.SafeTryGetProperty("tool_use_id", out var idEl) ? idEl.SafeGetString() ?? "" : "";
@@ -1674,6 +1775,93 @@ public partial class LlmService
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>
/// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면
/// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다.
/// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다.
/// </summary>
private static void SanitizeToolCallPairs(List<object> msgs)
{
// ── 1패스: tool_calls assistant 메시지의 쌍 검증 ──
// tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용
var pairedToolIndices = new HashSet<int>();
for (int i = 0; i < msgs.Count; i++)
{
var msgType = msgs[i].GetType();
var toolCallsProp = msgType.GetProperty("tool_calls");
var roleProp = msgType.GetProperty("role");
if (toolCallsProp == null || roleProp == null) continue;
var role = roleProp.GetValue(msgs[i]) as string;
if (role != "assistant") continue;
var toolCalls = toolCallsProp.GetValue(msgs[i]);
if (toolCalls == null) continue;
int callCount = 0;
if (toolCalls is System.Collections.ICollection col) callCount = col.Count;
else if (toolCalls is System.Collections.IEnumerable en)
{
foreach (var _ in en) callCount++;
}
if (callCount == 0) continue;
// 바로 다음에 tool 역할 메시지가 callCount개 있는지 확인
int foundTools = 0;
for (int j = i + 1; j < msgs.Count && foundTools < callCount; j++)
{
var jType = msgs[j].GetType();
var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string;
if (jRole == "tool")
{
foundTools++;
pairedToolIndices.Add(j);
}
else
break;
}
if (foundTools < callCount)
{
// 쌍이 불완전 → assistant를 일반 텍스트로 교체
var contentProp = msgType.GetProperty("content");
var contentText = contentProp?.GetValue(msgs[i]) as string ?? "";
if (string.IsNullOrWhiteSpace(contentText))
contentText = "[이전 도구 호출 — 결과 누락으로 생략됨]";
msgs[i] = new { role = "assistant", content = contentText };
// 이 assistant에 딸린 불완전 tool 메시지도 user로 변환
for (int j = i + 1; j < msgs.Count; j++)
{
var jType = msgs[j].GetType();
var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string;
if (jRole != "tool") break;
var jContent = jType.GetProperty("content")?.GetValue(msgs[j]) as string ?? "";
msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" };
pairedToolIndices.Remove(j);
}
LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})");
}
}
// ── 2패스: 앞에 tool_calls가 없는 고아 tool 메시지를 user로 변환 ──
for (int i = 0; i < msgs.Count; i++)
{
if (pairedToolIndices.Contains(i)) continue;
var msgType = msgs[i].GetType();
var roleProp = msgType.GetProperty("role");
var role = roleProp?.GetValue(msgs[i]) as string;
if (role != "tool") continue;
var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? "";
msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" };
LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})");
}
}
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
private static object BuildPropertySchema(Agent.ToolProperty prop, bool upperCaseType)
{

View File

@@ -18,7 +18,7 @@ public record TokenUsage(int PromptTokens, int CompletionTokens)
/// LLM API 호출 서비스. Ollama / vLLM / Gemini / Claude 백엔드를 지원합니다.
/// 스트리밍(SSE) 및 비스트리밍 모두 지원합니다.
/// </summary>
public partial class LlmService : IDisposable
public partial class LlmService : ILlmService
{
private readonly HttpClient _http;
private readonly HttpClient _httpInsecure;
@@ -148,7 +148,7 @@ public partial class LlmService : IDisposable
internal string GetActiveExecutionProfileKey()
=> Agent.ModelExecutionProfileCatalog.Normalize(GetActiveRegisteredModel()?.ExecutionProfile);
internal Agent.ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy()
public Agent.ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy()
=> Agent.ModelExecutionProfileCatalog.Get(GetActiveExecutionProfileKey());
internal double ResolveToolTemperature()
@@ -220,13 +220,6 @@ public partial class LlmService : IDisposable
/// <summary>가장 최근 요청의 토큰 사용량. 스트리밍/비스트리밍 완료 후 갱신됩니다.</summary>
public TokenUsage? LastTokenUsage { get; private set; }
public record RuntimeConnectionSnapshot(
string Service,
string Model,
string Endpoint,
bool AllowInsecureTls,
bool HasApiKey);
public LlmService(SettingsService settings)
{
_settings = settings;
@@ -342,8 +335,8 @@ public partial class LlmService : IDisposable
{
return llm.RegisteredModels.FirstOrDefault(m =>
m.Service.Equals(service, StringComparison.OrdinalIgnoreCase) &&
(CryptoService.DecryptIfEnabled(m.EncryptedModelName, llm.EncryptionEnabled) == modelName ||
m.Alias == modelName));
(string.Equals(CryptoService.DecryptIfEnabled(m.EncryptedModelName, llm.EncryptionEnabled), modelName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(m.Alias, modelName, StringComparison.OrdinalIgnoreCase)));
}
private Models.RegisteredModel? GetActiveRegisteredModel()
@@ -572,7 +565,7 @@ public partial class LlmService : IDisposable
EnsureOperationModeAllowsLlmService(activeService);
try
{
return await SendWithServiceAsync(activeService, messages, ct);
return await SendWithServiceAsync(activeService, messages, ct).ConfigureAwait(false);
}
catch (Exception ex) when (llm.FallbackModels.Count > 0)
{
@@ -587,7 +580,7 @@ public partial class LlmService : IDisposable
EnsureOperationModeAllowsLlmService(fbService);
LogService.Warn($"모델 폴백: {activeService} → {fbService} ({ex.Message})");
LastFallbackInfo = $"{activeService} → {fbService}";
return await SendWithServiceAsync(fbService, messages, ct);
return await SendWithServiceAsync(fbService, messages, ct).ConfigureAwait(false);
}
catch { continue; }
}

View File

@@ -2,6 +2,7 @@
using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
@@ -68,8 +69,15 @@ public class LspClientService : IDisposable
textDocument = new
{
definition = new { dynamicRegistration = false },
implementation = new { dynamicRegistration = false },
references = new { dynamicRegistration = false },
documentSymbol = new { dynamicRegistration = false },
hover = new { dynamicRegistration = false },
callHierarchy = new { dynamicRegistration = false },
},
workspace = new
{
symbol = new { dynamicRegistration = false },
}
}
}, ct);
@@ -126,6 +134,79 @@ public class LspClientService : IDisposable
return ParseSymbols(result);
}
/// <summary>심볼의 hover 정보를 가져옵니다.</summary>
public async Task<string?> HoverAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var result = await SendRequestAsync("textDocument/hover", new
{
textDocument = new { uri = FileToUri(filePath) },
position = new { line, character }
}, ct);
return ParseHover(result);
}
/// <summary>인터페이스/추상 메서드 등의 구현 위치를 찾습니다.</summary>
public async Task<List<LspLocation>> GotoImplementationAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var result = await SendRequestAsync("textDocument/implementation", new
{
textDocument = new { uri = FileToUri(filePath) },
position = new { line, character }
}, ct);
return ParseLocations(result);
}
/// <summary>워크스페이스 전체 심볼을 검색합니다.</summary>
public async Task<List<LspWorkspaceSymbol>> SearchWorkspaceSymbolsAsync(string query, CancellationToken ct = default)
{
var result = await SendRequestAsync("workspace/symbol", new { query }, ct);
return ParseWorkspaceSymbols(result);
}
/// <summary>호출 계층의 기준 아이템을 준비합니다.</summary>
public async Task<List<LspCallHierarchyItem>> PrepareCallHierarchyAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var result = await SendRequestAsync("textDocument/prepareCallHierarchy", new
{
textDocument = new { uri = FileToUri(filePath) },
position = new { line, character }
}, ct);
return ParseCallHierarchyItems(result);
}
/// <summary>해당 심볼을 호출하는 상위 호출자를 찾습니다.</summary>
public async Task<List<LspCallHierarchyEntry>> GetIncomingCallsAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var items = await PrepareCallHierarchyAsync(filePath, line, character, ct);
if (items.Count == 0)
return new List<LspCallHierarchyEntry>();
var result = await SendRequestAsync("callHierarchy/incomingCalls", new
{
item = items[0].RawItem
}, ct);
return ParseIncomingCalls(result);
}
/// <summary>해당 심볼이 호출하는 하위 호출 대상을 찾습니다.</summary>
public async Task<List<LspCallHierarchyEntry>> GetOutgoingCallsAsync(string filePath, int line, int character, CancellationToken ct = default)
{
var items = await PrepareCallHierarchyAsync(filePath, line, character, ct);
if (items.Count == 0)
return new List<LspCallHierarchyEntry>();
var result = await SendRequestAsync("callHierarchy/outgoingCalls", new
{
item = items[0].RawItem
}, ct);
return ParseOutgoingCalls(result);
}
// ─── JSON-RPC 통신 ──────────────────────────────────────────────────────
private static readonly JsonSerializerOptions JsonOpts = new()
@@ -249,16 +330,7 @@ public class LspClientService : IDisposable
var elem = result.Value;
if (elem.ValueKind == JsonValueKind.Array && elem.GetArrayLength() > 0)
elem = elem[0];
if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range))
{
var start = range.GetProperty("start");
return new LspLocation
{
FilePath = UriToFile(uri.GetString() ?? ""),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
};
}
return ParseLocationElement(elem);
}
catch { }
return null;
@@ -267,21 +339,56 @@ public class LspClientService : IDisposable
private static List<LspLocation> ParseLocations(JsonElement? result)
{
var list = new List<LspLocation>();
if (result?.ValueKind != JsonValueKind.Array) return list;
foreach (var elem in result.Value.EnumerateArray())
if (result == null)
return list;
if (result.Value.ValueKind == JsonValueKind.Array)
{
foreach (var elem in result.Value.EnumerateArray())
{
var parsed = ParseLocationElement(elem);
if (parsed != null)
list.Add(parsed);
}
}
else
{
var parsed = ParseLocationElement(result.Value);
if (parsed != null)
list.Add(parsed);
}
return list;
}
private static LspLocation? ParseLocationElement(JsonElement elem)
{
try
{
if (elem.TryGetProperty("targetUri", out var targetUri) && elem.TryGetProperty("targetSelectionRange", out var targetSelectionRange))
{
var start = targetSelectionRange.GetProperty("start");
return new LspLocation
{
FilePath = UriToFile(targetUri.SafeGetString() ?? ""),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
};
}
if (elem.TryGetProperty("uri", out var uri) && elem.TryGetProperty("range", out var range))
{
var start = range.GetProperty("start");
list.Add(new LspLocation
return new LspLocation
{
FilePath = UriToFile(uri.GetString() ?? ""),
FilePath = UriToFile(uri.SafeGetString() ?? ""),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
});
};
}
}
return list;
catch { }
return null;
}
private static List<LspSymbol> ParseSymbols(JsonElement? result)
@@ -311,6 +418,193 @@ public class LspClientService : IDisposable
return list;
}
private static string? ParseHover(JsonElement? result)
{
if (result == null || result.Value.ValueKind != JsonValueKind.Object)
return null;
if (!result.Value.TryGetProperty("contents", out var contents))
return null;
try
{
return contents.ValueKind switch
{
JsonValueKind.String => contents.SafeGetString(),
JsonValueKind.Object => contents.TryGetProperty("value", out var value)
? value.SafeGetString()
: contents.GetRawText(),
JsonValueKind.Array => string.Join(
"\n\n",
contents.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("value", out var v)
? v.SafeGetString()
: item.SafeGetString())
.Where(text => !string.IsNullOrWhiteSpace(text))),
_ => contents.SafeGetString()
};
}
catch
{
return null;
}
}
private static List<LspWorkspaceSymbol> ParseWorkspaceSymbols(JsonElement? result)
{
var list = new List<LspWorkspaceSymbol>();
if (result?.ValueKind != JsonValueKind.Array)
return list;
foreach (var elem in result.Value.EnumerateArray())
{
try
{
var name = elem.TryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var kind = elem.TryGetProperty("kind", out var kindEl) ? SymbolKindName(kindEl.GetInt32()) : "symbol";
var location = elem.TryGetProperty("location", out var locationEl)
? ParseLocationElement(locationEl)
: null;
list.Add(new LspWorkspaceSymbol
{
Name = name,
Kind = kind,
Location = location,
});
}
catch { }
}
return list;
}
private static List<LspCallHierarchyItem> ParseCallHierarchyItems(JsonElement? result)
{
var list = new List<LspCallHierarchyItem>();
if (result == null)
return list;
if (result.Value.ValueKind == JsonValueKind.Array)
{
foreach (var elem in result.Value.EnumerateArray())
{
var item = ParseCallHierarchyItem(elem);
if (item != null)
list.Add(item);
}
}
else if (result.Value.ValueKind == JsonValueKind.Object)
{
var item = ParseCallHierarchyItem(result.Value);
if (item != null)
list.Add(item);
}
return list;
}
private static LspCallHierarchyItem? ParseCallHierarchyItem(JsonElement elem)
{
try
{
var uri = elem.TryGetProperty("uri", out var uriEl) ? uriEl.SafeGetString() ?? "" : "";
var name = elem.TryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "";
var kind = elem.TryGetProperty("kind", out var kindEl) ? SymbolKindName(kindEl.GetInt32()) : "symbol";
var selectionRange = elem.TryGetProperty("selectionRange", out var selectionRangeEl)
? selectionRangeEl
: elem.GetProperty("range");
var start = selectionRange.GetProperty("start");
return new LspCallHierarchyItem
{
Name = name,
Kind = kind,
Location = new LspLocation
{
FilePath = UriToFile(uri),
Line = start.GetProperty("line").GetInt32(),
Character = start.GetProperty("character").GetInt32(),
},
RawItem = elem.Clone(),
};
}
catch
{
return null;
}
}
private static List<LspCallHierarchyEntry> ParseIncomingCalls(JsonElement? result)
{
var list = new List<LspCallHierarchyEntry>();
if (result?.ValueKind != JsonValueKind.Array)
return list;
foreach (var elem in result.Value.EnumerateArray())
{
try
{
if (!elem.TryGetProperty("from", out var fromEl))
continue;
var item = ParseCallHierarchyItem(fromEl);
if (item == null)
continue;
var count = elem.TryGetProperty("fromRanges", out var ranges) && ranges.ValueKind == JsonValueKind.Array
? ranges.GetArrayLength()
: 0;
list.Add(new LspCallHierarchyEntry
{
Name = item.Name,
Kind = item.Kind,
Location = item.Location,
RangeCount = count,
});
}
catch { }
}
return list;
}
private static List<LspCallHierarchyEntry> ParseOutgoingCalls(JsonElement? result)
{
var list = new List<LspCallHierarchyEntry>();
if (result?.ValueKind != JsonValueKind.Array)
return list;
foreach (var elem in result.Value.EnumerateArray())
{
try
{
if (!elem.TryGetProperty("to", out var toEl))
continue;
var item = ParseCallHierarchyItem(toEl);
if (item == null)
continue;
var count = elem.TryGetProperty("fromRanges", out var ranges) && ranges.ValueKind == JsonValueKind.Array
? ranges.GetArrayLength()
: 0;
list.Add(new LspCallHierarchyEntry
{
Name = item.Name,
Kind = item.Kind,
Location = item.Location,
RangeCount = count,
});
}
catch { }
}
return list;
}
private static string SymbolKindName(int kind) => kind switch
{
1 => "file", 2 => "module", 3 => "namespace", 4 => "package",
@@ -408,3 +702,34 @@ public class LspSymbol
public int Line { get; init; }
public override string ToString() => $"[{Kind}] {Name} (line {Line + 1})";
}
/// <summary>워크스페이스 심볼 검색 결과.</summary>
public class LspWorkspaceSymbol
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public LspLocation? Location { get; init; }
public override string ToString() => Location == null
? $"[{Kind}] {Name}"
: $"[{Kind}] {Name} @ {Location}";
}
/// <summary>호출 계층 기준 아이템.</summary>
public class LspCallHierarchyItem
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public LspLocation Location { get; init; } = new();
public JsonElement RawItem { get; init; }
public override string ToString() => $"[{Kind}] {Name} @ {Location}";
}
/// <summary>호출 계층의 incoming/outgoing 엔트리.</summary>
public class LspCallHierarchyEntry
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "";
public LspLocation Location { get; init; } = new();
public int RangeCount { get; init; }
public override string ToString() => $"[{Kind}] {Name} @ {Location} (matches: {RangeCount})";
}

View File

@@ -21,7 +21,7 @@ public record ModelRouteResult(
/// - 사내 Ollama/vLLM 모델 추가: GetDefaultCapabilities()에 항목 추가
/// - 확신도 조정: Route() 메서드의 confidence 체크 로직
/// </summary>
public class ModelRouterService
public class ModelRouterService : IModelRouterService
{
private readonly SettingsService _settings;

View File

@@ -66,7 +66,7 @@ public static class PdfExportService
private static string GetPrintStyles() => @"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; font-size: 13px; color: #222; background: #fff; padding: 20px; }
body { font-family: 'Noto Sans KR', 'Segoe UI', sans-serif; font-size: 13px; color: #222; background: #fff; padding: 20px; }
.header { border-bottom: 2px solid #4B5EFC; padding-bottom: 12px; margin-bottom: 20px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1b2e; }
.header .meta { font-size: 11px; color: #888; margin-top: 4px; }

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
namespace AxCopilot.Services;
/// <summary>
/// 전역 DI 서비스 로케이터.
/// 기존 수동 new 패턴에서 DI 컨테이너로의 점진적 전환을 지원합니다.
/// App.OnStartup()에서 Configure()를 호출하여 초기화합니다.
///
/// 사용법:
/// var settings = ServiceLocator.Get&lt;ISettingsService&gt;();
/// var storage = ServiceLocator.Get&lt;IChatStorageService&gt;();
/// </summary>
public static class ServiceLocator
{
private static IServiceProvider? _provider;
/// <summary>DI 컨테이너를 구성합니다. 앱 시작 시 1회만 호출합니다.</summary>
public static IServiceProvider Configure(Action<IServiceCollection> configureServices)
{
var services = new ServiceCollection();
configureServices(services);
_provider = services.BuildServiceProvider(validateScopes: true);
return _provider;
}
/// <summary>이미 생성된 ServiceProvider를 직접 설정합니다.</summary>
public static void SetProvider(IServiceProvider provider)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>등록된 서비스를 가져옵니다.</summary>
public static T Get<T>() where T : notnull
{
if (_provider == null)
throw new InvalidOperationException("ServiceLocator가 초기화되지 않았습니다. App.OnStartup()에서 Configure()를 호출하세요.");
return _provider.GetRequiredService<T>();
}
/// <summary>등록된 서비스를 가져옵니다. 미등록 시 null을 반환합니다.</summary>
public static T? GetOptional<T>() where T : class
{
return _provider?.GetService<T>();
}
/// <summary>내부 ServiceProvider에 직접 접근합니다. (테스트/마이그레이션용)</summary>
public static IServiceProvider? Provider => _provider;
}

View File

@@ -5,7 +5,7 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
public class SettingsService
public class SettingsService : ISettingsService
{
private static readonly string AppDataDir = InitAppDataDir();
@@ -186,12 +186,13 @@ public class SettingsService
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(50 * (attempt + 1));
// SpinWait으로 짧은 지연 (Thread.Sleep보다 UI 블로킹 최소화)
Thread.SpinWait(50_000 * (attempt + 1));
}
catch (Exception ex) when (attempt < 2)
{
LogService.Warn($"settings.dat 저장 재시도 {attempt + 1}/3: {ex.Message}");
Thread.Sleep(50 * (attempt + 1));
Thread.SpinWait(50_000 * (attempt + 1));
}
finally
{
@@ -203,6 +204,42 @@ public class SettingsService
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>비동기 설정 저장. UI 스레드에서 호출 시 블로킹 없이 저장합니다.</summary>
public async Task SaveAsync()
{
EnsureDirectories();
NormalizeRuntimeSettings();
var json = JsonSerializer.Serialize(_settings, JsonOptions);
var encrypted = CryptoService.PortableEncrypt(json);
await Task.Run(() =>
{
lock (_saveLock)
{
var tmpPath = SettingsPath + ".tmp";
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
File.WriteAllText(tmpPath, encrypted);
File.Move(tmpPath, SettingsPath, overwrite: true);
break;
}
catch (Exception ex) when (attempt < 2)
{
LogService.Warn($"settings.dat 비동기 저장 재시도 {attempt + 1}/3: {ex.Message}");
Thread.Sleep(50 * (attempt + 1));
}
finally
{
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { }
}
}
}
}).ConfigureAwait(false);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private void NormalizeRuntimeSettings()
{
var expressionLevel = (_settings.Llm.AgentUiExpressionLevel ?? "").Trim().ToLowerInvariant();

View File

@@ -30,10 +30,34 @@ public static class TokenEstimator
{
int total = 0;
foreach (var m in messages)
total += Estimate(m.Content) + 4; // 메시지 오버헤드
{
var content = m.Content;
// _tool_use_blocks JSON은 API 전송 시 구조화된 형식으로 변환되므로
// 원시 JSON 길이 대비 약 40% 할인 적용 (JSON 래퍼 오버헤드 제거)
if (m.Role == "assistant" && content != null && content.StartsWith("{\"_tool_use_blocks\""))
total += (int)(Estimate(content) * 0.6) + 4;
// tool_result JSON도 구조화 변환됨
else if (m.Role == "user" && content != null && content.StartsWith("{\"type\":\"tool_result\""))
total += (int)(Estimate(content) * 0.7) + 4;
else
total += Estimate(content ?? "") + 4;
}
return total;
}
/// <summary>시스템 프롬프트 + 도구 정의에 의한 기본 오버헤드 토큰을 추정합니다.</summary>
/// <param name="systemPromptLength">시스템 프롬프트 문자 수</param>
/// <param name="toolCount">등록된 도구 수</param>
/// <returns>추정 토큰 수</returns>
public static int EstimateBaseOverhead(int systemPromptLength, int toolCount)
{
// 시스템 프롬프트 토큰
var sysTokens = systemPromptLength > 0 ? (int)(systemPromptLength / 3.5) : 0;
// 도구 정의: 이름 + 설명 + 파라미터 스키마 ≈ 평균 180토큰/도구
var toolTokens = toolCount * 180;
return sysTokens + toolTokens;
}
/// <summary>비용을 추정합니다 (USD 기준).</summary>
public static (double InputCost, double OutputCost) EstimateCost(
int promptTokens, int completionTokens, string service, string model)