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:
@@ -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)
|
||||
{
|
||||
|
||||
185
src/AxCopilot/Services/Agent/AgentLoopExplorationRecovery.cs
Normal file
185
src/AxCopilot/Services/Agent/AgentLoopExplorationRecovery.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
103
src/AxCopilot/Services/Agent/AgentLoopPathStagnation.cs
Normal file
103
src/AxCopilot/Services/Agent/AgentLoopPathStagnation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}' 작업은 지원되지 않습니다.");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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; }}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, // 실행 중 사용자 메시지 주입
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
240
src/AxCopilot/Services/Agent/IconLibrary.cs
Normal file
240
src/AxCopilot/Services/Agent/IconLibrary.cs
Normal 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"] = "🚫",
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -282,8 +282,9 @@ public static class SkillService
|
||||
사용자가 지정한 파일 또는 작업 폴더의 주요 파일을 읽고 상세히 설명하세요.
|
||||
|
||||
다음 도구를 사용하세요:
|
||||
1. file_read — 파일 내용 읽기
|
||||
2. folder_map — 프로젝트 구조 파악 (필요시)
|
||||
1. glob/grep — 관련 파일 후보 찾기
|
||||
2. file_read — 파일 내용 읽기
|
||||
3. folder_map — 프로젝트 구조가 꼭 필요할 때만 사용
|
||||
|
||||
설명 포함 사항:
|
||||
- 파일의 역할과 책임
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
25
src/AxCopilot/Services/Interfaces/IAppStateService.cs
Normal file
25
src/AxCopilot/Services/Interfaces/IAppStateService.cs
Normal 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);
|
||||
}
|
||||
19
src/AxCopilot/Services/Interfaces/IChatStorageService.cs
Normal file
19
src/AxCopilot/Services/Interfaces/IChatStorageService.cs
Normal 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);
|
||||
}
|
||||
75
src/AxCopilot/Services/Interfaces/ILlmService.cs
Normal file
75
src/AxCopilot/Services/Interfaces/ILlmService.cs
Normal 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; }
|
||||
}
|
||||
9
src/AxCopilot/Services/Interfaces/IModelRouterService.cs
Normal file
9
src/AxCopilot/Services/Interfaces/IModelRouterService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>사용자 메시지의 인텐트를 분석하여 최적 모델을 선택하는 라우터 인터페이스.</summary>
|
||||
public interface IModelRouterService
|
||||
{
|
||||
/// <summary>사용자 메시지를 분석하여 최적 모델을 선택합니다.</summary>
|
||||
/// <returns>라우팅 결과. null이면 기본 모델 유지.</returns>
|
||||
ModelRouteResult? Route(string userMessage);
|
||||
}
|
||||
15
src/AxCopilot/Services/Interfaces/ISettingsService.cs
Normal file
15
src/AxCopilot/Services/Interfaces/ISettingsService.cs
Normal 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();
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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})";
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public record ModelRouteResult(
|
||||
/// - 사내 Ollama/vLLM 모델 추가: GetDefaultCapabilities()에 항목 추가
|
||||
/// - 확신도 조정: Route() 메서드의 confidence 체크 로직
|
||||
/// </summary>
|
||||
public class ModelRouterService
|
||||
public class ModelRouterService : IModelRouterService
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
49
src/AxCopilot/Services/ServiceLocator.cs
Normal file
49
src/AxCopilot/Services/ServiceLocator.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 전역 DI 서비스 로케이터.
|
||||
/// 기존 수동 new 패턴에서 DI 컨테이너로의 점진적 전환을 지원합니다.
|
||||
/// App.OnStartup()에서 Configure()를 호출하여 초기화합니다.
|
||||
///
|
||||
/// 사용법:
|
||||
/// var settings = ServiceLocator.Get<ISettingsService>();
|
||||
/// var storage = ServiceLocator.Get<IChatStorageService>();
|
||||
/// </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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user