AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강

변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
This commit is contained in:
2026-04-14 17:52:46 +09:00
parent fa33b98f7e
commit 8cb08576d5
200 changed files with 13522 additions and 5764 deletions

View File

@@ -378,87 +378,72 @@ public partial class App : System.Windows.Application
var launcherSettings = _settings?.Settings.Launcher;
var enableTextAction = launcherSettings?.EnableTextAction == true;
// ── 런처 토글(닫기)은 텍스트 감지 없이 즉시 처리 ──
bool isVisible = false;
Dispatcher.Invoke(() => { isVisible = _launcher?.IsVisible == true; });
if (isVisible)
{
Dispatcher.Invoke(() => _launcher?.Hide());
return;
}
// ── 텍스트 감지 활성: 런처를 먼저 열고, 텍스트 감지를 비동기로 처리 ──
string? selectedText = enableTextAction ? TryGetSelectedText() : null;
// ── 텍스트 감지가 비활성이면 즉시 런처 표시 ──
if (!enableTextAction)
// BeginInvoke 사용 — Dispatcher.Invoke 데드락 방지
Dispatcher.BeginInvoke(() =>
{
Dispatcher.Invoke(() =>
// 런처 토글(닫기)
if (_launcher?.IsVisible == true)
{
_launcher.Hide();
return;
}
// 텍스트 감지 비활성 또는 선택 텍스트 없음
if (!enableTextAction || string.IsNullOrWhiteSpace(selectedText))
{
if (_launcher == null) return;
UsageStatisticsService.RecordLauncherOpen();
ShowLauncherWindow();
});
return;
}
return;
}
// 텍스트 감지 활성 + 선택 텍스트 있음 → 텍스트 액션 처리
var enabledCmds = launcherSettings?.TextActionCommands ?? new();
// ── 텍스트 감지 활성: 런처를 먼저 열고, 텍스트 감지를 비동기로 처리 ──
string? selectedText = TryGetSelectedText();
Dispatcher.Invoke(() =>
{
if (_launcher == null) return;
if (!string.IsNullOrWhiteSpace(selectedText))
// 활성 명령이 1개뿐이면 팝업 없이 바로 실행
if (enabledCmds.Count == 1)
{
var enabledCmds = launcherSettings?.TextActionCommands ?? new();
// 활성 명령이 1개뿐이면 팝업 없이 바로 실행
if (enabledCmds.Count == 1)
var directAction = TextActionPopup.AvailableCommands
.FirstOrDefault(c => c.Key == enabledCmds[0]);
if (!string.IsNullOrEmpty(directAction.Key))
{
var directAction = TextActionPopup.AvailableCommands
.FirstOrDefault(c => c.Key == enabledCmds[0]);
if (!string.IsNullOrEmpty(directAction.Key))
var actionResult = enabledCmds[0] switch
{
var actionResult = enabledCmds[0] switch
{
"translate" => TextActionPopup.ActionResult.Translate,
"summarize" => TextActionPopup.ActionResult.Summarize,
"grammar" => TextActionPopup.ActionResult.GrammarFix,
"explain" => TextActionPopup.ActionResult.Explain,
"rewrite" => TextActionPopup.ActionResult.Rewrite,
_ => TextActionPopup.ActionResult.None,
};
if (actionResult != TextActionPopup.ActionResult.None)
{
ExecuteTextAction(actionResult, selectedText);
return;
}
"translate" => TextActionPopup.ActionResult.Translate,
"summarize" => TextActionPopup.ActionResult.Summarize,
"grammar" => TextActionPopup.ActionResult.GrammarFix,
"explain" => TextActionPopup.ActionResult.Explain,
"rewrite" => TextActionPopup.ActionResult.Rewrite,
_ => TextActionPopup.ActionResult.None,
};
if (actionResult != TextActionPopup.ActionResult.None)
{
ExecuteTextAction(actionResult, selectedText);
return;
}
}
}
// 여러 개 → 팝업 표시
var popup = new TextActionPopup(selectedText, enabledCmds);
popup.Closed += (_, _) =>
{
switch (popup.SelectedAction)
{
case TextActionPopup.ActionResult.OpenLauncher:
UsageStatisticsService.RecordLauncherOpen();
ShowLauncherWindow();
break;
case TextActionPopup.ActionResult.None:
break; // Esc 또는 포커스 잃음
default:
// AI 명령 실행 → AX Agent 대화로 전달
ExecuteTextAction(popup.SelectedAction, popup.SelectedText);
break;
}
};
popup.Show();
}
else
// 여러 개 → 팝업 표시
var popup = new TextActionPopup(selectedText, enabledCmds);
popup.Closed += (_, _) =>
{
UsageStatisticsService.RecordLauncherOpen();
ShowLauncherWindow();
}
switch (popup.SelectedAction)
{
case TextActionPopup.ActionResult.OpenLauncher:
UsageStatisticsService.RecordLauncherOpen();
ShowLauncherWindow();
break;
case TextActionPopup.ActionResult.None:
break;
default:
ExecuteTextAction(popup.SelectedAction, popup.SelectedText);
break;
}
};
popup.Show();
});
}
@@ -600,9 +585,9 @@ public partial class App : System.Windows.Application
_trayMenu
.AddHeader(versionText)
.AddItem("\uE7C5", "AX Commander 호출하기", () =>
Dispatcher.Invoke(ShowLauncherWindow))
Dispatcher.Invoke(ShowLauncherWindow), hint: "double-click")
.AddItem("\uE8BD", "AX Agent 대화하기", () =>
Dispatcher.Invoke(OpenAiChat), out var aiTrayItem)
Dispatcher.Invoke(OpenAiChat), out var aiTrayItem, hint: "click")
.AddItem("\uE8A7", "독 바 표시", () =>
Dispatcher.Invoke(() => ToggleDockBar()))
.AddSeparator()
@@ -649,21 +634,50 @@ public partial class App : System.Windows.Application
: System.Windows.Visibility.Collapsed;
};
// 클릭 → WPF 메뉴 표시, 좌클릭 → 런처 토글
// 클릭 → 대화창, 더블클릭 → 런처, 우클릭 → 메뉴
// MouseClick은 버튼 정보를 제공하므로 Click 대신 사용 — Click은 모든 버튼에 발생하여 우클릭 메뉴와 충돌
System.Windows.Forms.Timer? _trayClickTimer = null;
_trayIcon.MouseClick += (_, e) =>
{
if (e.Button == System.Windows.Forms.MouseButtons.Left)
if (e.Button == System.Windows.Forms.MouseButtons.Right)
{
Dispatcher.Invoke(() =>
// 우클릭: 컨텍스트 메뉴 표시 (싱글클릭 타이머 간섭 방지)
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = null;
Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate());
return;
}
if (e.Button != System.Windows.Forms.MouseButtons.Left) return;
// 싱글/더블 클릭 구분: WinForms NotifyIcon은 DoubleClick 시에도 Click을 먼저 발생시키므로
// 타이머를 사용하여 더블클릭 대기 후 싱글 클릭 액션을 실행
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = new System.Windows.Forms.Timer { Interval = SystemInformation.DoubleClickTime };
_trayClickTimer.Tick += (s, _) =>
{
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = null;
// 싱글 클릭: 대화창 열기
Dispatcher.BeginInvoke(() =>
{
if (settings.Settings.AiEnabled)
OpenAiChat();
else
ShowLauncherWindow();
});
}
else if (e.Button == System.Windows.Forms.MouseButtons.Right)
Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate());
};
_trayClickTimer.Start();
};
_trayIcon.MouseDoubleClick += (_, e) =>
{
if (e.Button != System.Windows.Forms.MouseButtons.Left) return;
// 싱글 클릭 타이머 취소 → 런처만 열림
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = null;
Dispatcher.BeginInvoke(ShowLauncherWindow);
};
// 타이머/알람 풍선 알림 서비스 연결

View File

@@ -0,0 +1,89 @@
# DeepSeek Model — Detailed Execution Prompt
## Execution Philosophy
You are a senior software engineer assistant. DeepSeek excels at reasoning and planning — leverage this strength, but always follow plans with immediate action. Never produce a plan-only response.
## Planning Discipline
- Internal planning: maximum 2 sentences, then execute.
- Never output a numbered step list without executing step 1 in the same response.
- If a task has 3+ independent subtasks, consider using spawn_agent to parallelize.
- Plans longer than 5 steps should be decomposed into spawn_agents batches.
## Tool Calling Protocol
### Mandatory Sequences
After file_edit → always build_run to verify.
After 3+ file_edits → run test_loop for regression testing.
After build_run failure → read error → fix → build_run again (max 3 attempts).
After test_loop failure → read failure details → fix specific test → re-run.
### Parallel Opportunities
Recognize and exploit parallelism:
- Reading multiple files → single multi_read call
- Independent grep searches → multiple grep calls in one response
- Independent file edits in different files → safe to do simultaneously if no cross-dependencies
### Build Verification Chain
```
file_edit → build_run → (pass? continue : fix → build_run → continue)
```
This chain is MANDATORY. Never skip build verification after code changes.
## Code Quality Standards
1. **Minimal Changes**: Modify only what's necessary. Don't refactor unrelated code.
2. **Type Safety**: Preserve or improve type safety. Never add `any` or suppress warnings without justification.
3. **Error Handling**: New code must handle failure cases. Check for null, empty, out-of-range.
4. **Naming**: Follow existing codebase conventions (PascalCase for C# public members, camelCase for locals).
5. **Comments**: Add comments only for non-obvious logic. Comments in the existing language of the codebase.
## Analysis and Investigation
When investigating bugs or understanding code:
1. Start with folder_map to understand project structure
2. Use grep to find relevant code patterns
3. Read the specific files involved
4. Trace the call chain (caller → callee) before proposing fixes
5. Check for similar patterns elsewhere that might need the same fix
### Root Cause Analysis Format
When reporting findings:
- **Symptom**: What the user observes
- **Root Cause**: The actual code defect (cite file:line)
- **Fix**: Minimal code change with rationale
- **Verification**: How to confirm the fix works
## Document Generation
For document/report tasks:
1. Use document_plan first for multi-section documents
2. Gather all data via tools before writing
3. Use appropriate format (html_create for rich docs, markdown_create for technical docs)
4. Include data tables, code snippets, and evidence from actual project files
5. Review the generated document with document_review
## Multi-Agent Delegation
Use spawn_agent / spawn_agents when:
- Task has 2+ independent research questions → spawn researchers
- Need to review code AND gather metrics simultaneously → spawn reviewer + researcher
- Writing a document while investigating code → spawn writer + researcher
Each sub-agent must have:
- Clear, atomic task description
- Specific expected output format
- Appropriate profile (researcher/coder/writer/reviewer/planner)
## Response Format
- Explanations: concise, action-oriented
- Code changes: show diff context, explain the "why" not the "what"
- Final summary: bullet points of changes made + verification results

View File

@@ -0,0 +1,61 @@
# Gemma Model — Detailed Execution Prompt
## Core Constraints
Gemma has a small context window. Every token counts. Follow these rules strictly.
## Rules (3 only)
1. ALWAYS use tools. Respond ONLY with tool calls when action is needed.
2. ONE tool per response. Wait for the result before the next step.
3. NEVER guess file contents. Read first, then act.
## Tool Priority
When unsure what to do:
- file_read: see what's in a file
- grep: find where something is
- glob: find files by name pattern
- folder_map: see project structure
- build_run: check if code compiles
- file_edit: change code (read first!)
## Editing Sequence
```
file_read → file_edit → build_run
```
Always this order. Never skip steps.
## Tool Call Format (STRICT)
Use ONLY this exact format — do NOT use pipe-wrapped tokens, `call;`, or any variant:
```
<tool_call>
{"name": "TOOL_NAME", "arguments": {"param": "value"}}
</tool_call>
```
Forbidden (examples of WRONG formats the server will reject):
- `<|tool_call>call;name{...}<tool_call|>`
- `<|tool_call|>name{...}<|/tool_call|>`
- Any output with `<|"|>` as a string delimiter
Arguments MUST be valid JSON with double-quoted keys and string values.
## Output Rules
- Maximum 2 sentences between tool calls
- No preamble. No "I'll help you with..."
- No numbered plans. Just act.
- Final answer: 1-3 bullet points of what changed
## Language
- Code: match the existing codebase
- Explanation: match user's language
- Keep it short
START WITH A TOOL CALL NOW.

View File

@@ -0,0 +1,97 @@
# Kimi/Moonshot Model — Detailed Execution Prompt
## Execution Rules
Kimi has a large context window (128K+) but tends toward verbose explanations. Counteract this with strict conciseness rules.
### Conciseness Protocol
- Maximum 3 sentences of explanation between tool calls.
- Never repeat what a tool result already shows.
- Never explain what you're "about to do" — just do it.
- If the user can see the tool result, don't summarize it.
### Mandatory Verification
After EVERY file_edit → immediately call build_run.
This is non-negotiable. No exceptions. The sequence is:
```
file_read → file_edit → build_run → (pass? next task : fix → build_run)
```
## Structured Analysis Format
When analyzing code, documents, or issues, ALWAYS use this format:
### For Code Analysis
```
## [Finding Title]
- **Evidence**: [file:line] — [exact code snippet]
- **Impact**: P0 (critical) / P1 (high) / P2 (medium) / P3 (low)
- **Category**: bug / performance / security / maintainability / style
- **Recommendation**: [specific action with code example]
```
### For Document Review
```
## [Section/Issue]
- **Location**: [section name or page]
- **Issue**: [concise description]
- **Severity**: error / warning / suggestion
- **Fix**: [specific correction]
```
## Tool Usage Patterns
### Investigation Pattern
1. folder_map → understand structure
2. grep → find relevant files
3. file_read → examine specific code
4. Analyze and report using structured format
### Fix Pattern
1. file_read → understand current state
2. file_edit → apply fix (exact old_string match)
3. build_run → verify compilation
4. test_loop → verify no regression (if tests exist)
5. Brief summary of change
### Document Creation Pattern
1. Research: gather data via tools (file_read, grep, code_review)
2. Plan: document_plan for structure
3. Create: html_create / docx_create / markdown_create
4. Review: document_review for quality check
## Code Editing Standards
1. Read before edit — ALWAYS.
2. Minimal diff — change only the necessary lines.
3. Preserve formatting — match existing indentation, spacing, brace style.
4. Type-safe changes — no implicit `any`, no null coercion without checks.
5. Build after edit — ALWAYS run build_run.
## Multi-File Operations
When a task requires changes to multiple files:
1. Plan the dependency order (models → services → views)
2. Edit files in dependency order
3. Build after each file (not just at the end)
4. If build fails on file N, fix before proceeding to file N+1
## Response Style
- Use Korean for explanations when user writes in Korean
- Use English for tool parameters and code
- Technical terms: keep in English (don't translate class names, method names, etc.)
- Numbers and data: use exact values from tool results, never estimate
## Error Recovery
If a tool call fails:
1. Identify the error type (path not found? permission? syntax?)
2. Fix the specific issue
3. Retry with corrected parameters
4. After 2 failures: try alternative approach, explain briefly why

View File

@@ -0,0 +1,45 @@
# Llama Model — Detailed Execution Prompt
## Execution Rules
You are a code assistant with tool access. Use tools to gather information and make changes. Do not guess or speculate when tools can provide the answer.
## Tool Calling Protocol
1. Start with tools — read files, search code, understand structure before acting.
2. Call multiple independent tools in the same response when possible.
3. After code edits, ALWAYS run build_run to verify.
4. After 3+ edits, run test_loop for regression testing.
### Common Patterns
**Investigate**: folder_map → grep → file_read → analyze
**Fix**: file_read → file_edit → build_run → (test_loop if applicable)
**Create**: research → document_plan → create → review
## Code Quality
- Minimal changes: only modify what's needed
- Read before edit: always
- Build after edit: always
- Match existing style: indentation, naming, comments
- Handle errors: check null, empty, edge cases
## Response Style
- Concise: max 3 sentences between tool calls
- Action-oriented: do, don't describe plans to do
- Structured: use bullet points for multi-item results
- Match user's language for explanations
## Error Recovery
On tool failure: read error → fix parameters → retry (max 2 attempts) → try alternative approach.
## Analysis Format
When reporting findings:
- **What**: brief description
- **Where**: file:line reference
- **Impact**: severity (P0-P3)
- **Fix**: specific recommendation

View File

@@ -0,0 +1,44 @@
# Mistral/Mixtral Model — Detailed Execution Prompt
## Execution Philosophy
Mistral excels at reasoning. Use this strength for analysis and planning, but always follow reasoning with tool execution in the same response.
## Tool Calling Protocol
1. Think briefly (1-2 sentences max), then act with tools.
2. Parallel calls: when tasks are independent, call multiple tools at once.
3. After code edits: build_run is mandatory.
4. After investigation: summarize with structured findings.
### Verification Chain
```
file_read → file_edit → build_run → (pass? continue : diagnose → fix → build_run)
```
## Code Standards
- Read before edit: mandatory
- Minimal diff: change only what's needed
- Type safety: preserve or improve
- Build verification: after every edit
- Test coverage: run test_loop after 3+ edits
## Analysis Protocol
When analyzing code or issues:
1. Gather evidence via tools (grep, file_read, code_review)
2. Trace the relevant call chain
3. Report findings with:
- **Finding**: concise description
- **Evidence**: file:line with code reference
- **Severity**: P0/P1/P2/P3
- **Recommendation**: specific fix
## Response Format
- Reasoning: brief, inline (not separate section)
- Actions: tool calls immediately after reasoning
- Results: bullet-point summary
- Language: match user's language for explanations, English for code

View File

@@ -0,0 +1,65 @@
# Qwen Model — Detailed Execution Prompt
## Critical Behavior Rules
[MUST] Start EVERY response with a tool call. No text before tool_call.
[MUST] Call multiple independent tools in the same response when possible.
[NEVER] Say "알겠습니다", "네", "확인했습니다", "I understand" before a tool call.
[NEVER] Output text-only when a tool action is still needed.
[NEVER] Repeat the user's request back to them — just do it.
## Tool Calling Protocol
You MUST follow this protocol for every turn:
1. Read the user's request
2. Immediately call the first relevant tool (file_read, grep, glob, folder_map, etc.)
3. After receiving tool results, call the next tool or produce your final answer
4. If you are unsure, call a tool to gather information — do NOT guess
### When to Use Each Tool
- **file_read**: When you need to see file contents. ALWAYS read before editing.
- **grep / glob**: When searching for code patterns or files. Use grep for content, glob for filenames.
- **file_edit**: When modifying files. You MUST read the file first. Use exact old_string match.
- **build_run**: After ANY file edit, run the build to verify. Do not skip this step.
- **test_loop**: After 3+ file edits, run tests to catch regressions.
- **folder_map**: To understand project structure before diving into files.
### Parallel Tool Calls
When multiple tools are independent, call them ALL in the same response:
GOOD: Call file_read for 3 different files simultaneously
BAD: Read file A, wait, read file B, wait, read file C
### Error Recovery
If a tool call fails:
1. Read the error message carefully
2. Fix the parameters (wrong path? wrong old_string?)
3. Try again with corrected parameters
4. After 2 failures on the same operation, try an alternative approach
## Code Editing Rules
1. ALWAYS read the file before editing (file_read → file_edit)
2. Use the EXACT string from the file as old_string — copy precisely
3. After editing, run build_run to verify the build passes
4. If build fails, read the error, fix the issue, build again
5. Keep changes minimal — change only what's needed
## Response Format
- Between tool calls: maximum 1 sentence of explanation
- Final answer: concise summary of what was done
- Never list what you "plan to do" — just do it
- Use bullet points for multi-item results
## Language
- Tool parameters: always in the language of the existing code
- Explanations to user: match the user's language (Korean if they write Korean)
- Code comments: match existing codebase conventions
REMINDER: Your FIRST output in EVERY response MUST be a tool_call. Begin now.

View File

@@ -6,5 +6,5 @@
"purpose": "업무 편의성 증가 및 시스템의 직관적인 연결을 위해 제작",
"copyright": "© 2026 AX연구소",
"blogUrl": "www.swarchitect.net",
"contributors": "경윤영님, 윤지영님, 배지훈님"
"contributors": "경윤영님, 윤지영님"
}

View File

@@ -74,6 +74,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="System.IO.Hashing" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
@@ -111,6 +112,8 @@
<Resource Include="Assets\Quotes\greetings.json" />
<!-- 대화 주제 프리셋: 9종 시스템 프롬프트 (빌드 시 내장) -->
<EmbeddedResource Include="Assets\Presets\*.json" />
<!-- 모델별 상세 프롬프트: 임베디드 리소스 (EXE에 포함, 개발자만 수정 가능) -->
<EmbeddedResource Include="Assets\ModelPrompts\*.md" />
<!-- 마스코트 이미지: 내장 리소스 (EXE에 포함됨, 교체 시 재빌드 필요) -->
<Resource Include="Assets\mascot.png" Condition="Exists('Assets\mascot.png')" />
<Resource Include="Assets\mascot.jpg" Condition="Exists('Assets\mascot.jpg')" />

View File

@@ -9,7 +9,7 @@ public class AppSettings
/// <summary>
/// AI 기능 활성화 여부. false이면 ! 명령어 차단 + 설정의 AX Agent 탭 숨김.
/// claw code 미포함 배포는 false로 설정합니다.
/// AI 엔진 미포함 배포는 false로 설정합니다.
/// </summary>
[JsonPropertyName("ai_enabled")]
public bool AiEnabled { get; set; } = true;
@@ -881,6 +881,10 @@ public class LlmSettings
[JsonPropertyName("autoPreview")]
public string AutoPreview { get; set; } = "off";
/// <summary>미리보기 패널 너비 (픽셀). 드래그 조절 후 저장.</summary>
[JsonPropertyName("previewPanelWidth")]
public double PreviewPanelWidth { get; set; } = 420;
/// <summary>에이전트 최대 루프 반복 횟수.</summary>
[JsonPropertyName("maxAgentIterations")]
public int MaxAgentIterations { get; set; } = 25;
@@ -905,6 +909,10 @@ public class LlmSettings
[JsonPropertyName("agentLogLevel")]
public string AgentLogLevel { get; set; } = "detailed";
/// <summary>IBM+Qwen 조합 상세 진단 로깅 활성화. true 시 [IBM진단] 태그 Debug 로그 출력. 기본 false.</summary>
[JsonPropertyName("enableIbmDiagnosticLog")]
public bool EnableIbmDiagnosticLog { get; set; } = false;
/// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary>
[JsonPropertyName("agentUiExpressionLevel")]
public string AgentUiExpressionLevel { get; set; } = "balanced";
@@ -999,7 +1007,7 @@ public class LlmSettings
/// <summary>서브에이전트 최대 동시 실행 수. 기본 3.</summary>
[JsonPropertyName("maxSubAgents")]
public int MaxSubAgents { get; set; } = 3;
public int MaxSubAgents { get; set; } = 5;
/// <summary>PDF 내보내기 기본 경로. 빈 문자열이면 바탕화면.</summary>
[JsonPropertyName("pdfExportPath")]
@@ -1071,7 +1079,7 @@ public class LlmSettings
[JsonPropertyName("enableChatRainbowGlow")]
public bool EnableChatRainbowGlow { get; set; } = false;
/// <summary>새로운 계획 뷰어(V2 사이드바 레이아웃) 사용. 기본 true.</summary>
/// <summary>새로운 뷰어 표시 버전(V2 사이드바 레이아웃) 사용. 기본 true.</summary>
[JsonPropertyName("enableNewPlanViewer")]
public bool EnableNewPlanViewer { get; set; } = true;
@@ -1079,6 +1087,58 @@ public class LlmSettings
[JsonPropertyName("enableNewChatRendering")]
public bool EnableNewChatRendering { get; set; } = false;
/// <summary>IntentGate LLM 폴백 활성화. 키워드 분류 confidence가 낮을 때 LLM 1-shot 분류 사용.</summary>
[JsonPropertyName("enableIntentGateLlmFallback")]
public bool EnableIntentGateLlmFallback { get; set; } = false;
/// <summary>IntentGate confidence 임계값. 이 값 미만이면 LLM 폴백 발동 (EnableIntentGateLlmFallback=true 시).</summary>
[JsonPropertyName("intentGateConfidenceThreshold")]
public double IntentGateConfidenceThreshold { get; set; } = 0.6;
/// <summary>세션 내 누적 학습 활성화. 도구 결과에서 학습 포인트를 자동 수집하여 후속 반복에 주입.</summary>
[JsonPropertyName("enableSessionLearnings")]
public bool EnableSessionLearnings { get; set; } = true;
/// <summary>최대 누적 학습 항목 수. FIFO로 관리.</summary>
[JsonPropertyName("maxSessionLearnings")]
public int MaxSessionLearnings { get; set; } = 10;
/// <summary>워크스페이스 컨텍스트 자동 생성 (.ax-context.md) 활성화.</summary>
[JsonPropertyName("enableAutoWorkspaceContext")]
public bool EnableAutoWorkspaceContext { get; set; } = true;
/// <summary>
/// 모델별 프롬프트 전략 수준.
/// "off": 모든 모델에 동일한 기본 프롬프트 사용 (기존 동작).
/// "basic": 모델 패밀리별 가벼운 프롬프트 어댑테이션 (규칙 추가/재배치).
/// "detailed": 모델별 전용 프롬프트 파일 적용 (임베디드 리소스, 수백 줄급 상세 지침).
/// </summary>
[JsonPropertyName("modelPromptLevel")]
public string ModelPromptLevel { get; set; } = "off";
/// <summary>이전 호환: EnableModelSpecificPrompting=true → "basic" 으로 마이그레이션.</summary>
[JsonPropertyName("enableModelSpecificPrompting")]
public bool EnableModelSpecificPrompting
{
get => !string.Equals(ModelPromptLevel, "off", StringComparison.OrdinalIgnoreCase);
set
{
if (value && string.Equals(ModelPromptLevel, "off", StringComparison.OrdinalIgnoreCase))
ModelPromptLevel = "basic";
else if (!value)
ModelPromptLevel = "off";
}
}
/// <summary>
/// Hash-Anchored Edits 활성화.
/// true: file_read 출력에 라인별 해시 앵커를 포함하고, file_edit에서 앵커 기반 편집 지원.
/// 편집 성공률이 크게 향상되며 스테일 편집을 자동 감지합니다.
/// false: 기존 라인번호 + old_string 방식 유지.
/// </summary>
[JsonPropertyName("enableHashAnchoredEdits")]
public bool EnableHashAnchoredEdits { get; set; } = false;
/// <summary>AX Agent 전용 테마. system | light | dark</summary>
[JsonPropertyName("agentTheme")]
public string AgentTheme { get; set; } = "system";
@@ -1137,7 +1197,7 @@ public class LlmSettings
public List<string> BlockedPaths { get; set; } = new()
{
"*\\Windows\\*", "*\\Program Files\\*", "*\\Program Files (x86)\\*",
"*\\System32\\*", "*\\AppData\\Local\\*", "*Documents*",
"*\\System32\\*", "*\\AppData\\Local\\*",
};
/// <summary>차단할 파일 확장자 목록.</summary>
@@ -1441,6 +1501,13 @@ public class RegisteredModel
[JsonPropertyName("executionProfile")]
public string ExecutionProfile { get; set; } = "balanced";
/// <summary>
/// 프롬프트 전략 패밀리. qwen | deepseek | kimi | gemma | llama | mistral | phi | yi | gemini | claude | default
/// 비어있으면 모델명에서 자동 감지합니다.
/// </summary>
[JsonPropertyName("promptFamily")]
public string PromptFamily { get; set; } = "";
/// <summary>이 모델 전용 서버 엔드포인트. 비어있으면 LlmSettings의 기본 엔드포인트 사용.</summary>
[JsonPropertyName("endpoint")]
public string Endpoint { get; set; } = "";

View File

@@ -130,7 +130,7 @@ public class ChatConversation
public List<DraftQueueItem> DraftQueueItems { get; set; } = new();
[JsonPropertyName("showExecutionHistory")]
public bool ShowExecutionHistory { get; set; } = false;
public bool ShowExecutionHistory { get; set; } = true;
[JsonPropertyName("agentRunHistory")]
public List<ChatAgentRunRecord> AgentRunHistory { get; set; } = new();

View File

@@ -55,11 +55,13 @@ public static class AgentHookRunner
if (!string.Equals(hook.Timing, timing, StringComparison.OrdinalIgnoreCase)) continue;
// 도구 이름 매칭: "*" = 전체, 그 외 정확 매칭 (대소문자 무시)
if (hook.ToolName != "*" &&
!string.Equals(hook.ToolName, toolName, StringComparison.OrdinalIgnoreCase))
var hookToolName = AgentToolCatalog.CanonicalizeHookTarget(hook.ToolName);
var canonicalToolName = AgentToolCatalog.Canonicalize(toolName);
if (hookToolName != "*" &&
!string.Equals(hookToolName, canonicalToolName, StringComparison.OrdinalIgnoreCase))
continue;
var result = await ExecuteHookAsync(hook, toolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct);
var result = await ExecuteHookAsync(hook, canonicalToolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct);
results.Add(result);
}

View File

@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private enum ExplorationScope
internal enum ExplorationScope
{
Localized,
TopicBased,

View File

@@ -16,14 +16,6 @@ public partial class AgentLoopService
}
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
private static readonly HashSet<string> ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "skill_manager", "json_tool",
"regex_tool", "base64_tool", "hash_tool", "image_analyze",
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<ContentBlock> Parallel, List<ContentBlock> Sequential)
ClassifyToolCalls(List<ContentBlock> calls)
@@ -35,12 +27,9 @@ public partial class AgentLoopService
foreach (var call in calls)
{
var requestedToolName = call.ToolName ?? "";
var normalizedToolName = NormalizeAliasToken(requestedToolName);
var classificationToolName = ToolAliasMap.TryGetValue(normalizedToolName, out var mappedToolName)
? mappedToolName
: requestedToolName;
var classificationToolName = AgentToolCatalog.Canonicalize(requestedToolName);
if (collectParallelPrefix && ReadOnlyTools.Contains(classificationToolName))
if (collectParallelPrefix && AgentToolCatalog.IsReadOnly(classificationToolName))
parallel.Add(call);
else
{

View File

@@ -188,12 +188,20 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
// IntentGate: 통합 의도 분류
var intentGate = new IntentGateService(_llm);
var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false);
var explorationState = new ExplorationTrackingState
{
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
Scope = intentResult.SuggestedScope,
SelectiveHit = true,
};
var pathAccessState = new PathAccessTrackingState();
// P3: 누적 학습 — 도구 결과에서 자동 학습 포인트 수집
var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings)
? new SessionLearningCollector(_settings.Settings.Llm.MaxSessionLearnings)
: null;
DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null;
@@ -236,9 +244,10 @@ public partial class AgentLoopService
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
var statsModifiedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적
var taskType = ClassifyTaskType(userQuery, ActiveTab);
var taskType = intentResult.TaskType;
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
var executionPolicy = _llm.GetActiveExecutionPolicy();
var executionPolicy = ExecutionPolicyMerger.Apply(
_llm.GetActiveExecutionPolicy(), intentResult.PolicyOverlay);
var consecutiveNoToolResponses = 0;
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
@@ -256,6 +265,19 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
// P5: 복합 요청 감지 시 DecompositionHint 주입
if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint))
{
messages.Add(new ChatMessage
{
Role = "user",
Content = $"[System:DecompositionHint]\n{intentResult.DecompositionHint}\n" +
"Consider using spawn_agents to run independent sub-tasks in parallel.",
MetaKind = "decomposition_hint",
});
}
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
taskPolicy,
@@ -370,6 +392,11 @@ public partial class AgentLoopService
// PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시
}
// ── 실행 중 설정 변경 반영 ──
// 사용자가 UI에서 권한 모드나 사내/사외 모드를 바꾼 경우 다음 반복부터 즉시 적용.
// (현재 진행 중인 LLM 호출이나 도구 실행에는 영향 없음 — 다음 사이클부터)
SyncContextFromSettings(context);
// Context Condenser: 토큰 초과 시 이전 대화 자동 압축
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
{
@@ -475,6 +502,22 @@ public partial class AgentLoopService
}
}
// P3: 누적 학습 메시지 주입 (매 반복 갱신)
if (sessionLearnings is { Count: > 0 })
{
var learningMsg = sessionLearnings.BuildInjectionMessage();
if (learningMsg != null)
{
messages.RemoveAll(m => m.MetaKind == "session_learnings");
messages.Insert(0, new ChatMessage
{
Role = "user",
Content = learningMsg,
MetaKind = "session_learnings",
});
}
}
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
@@ -782,9 +825,20 @@ public partial class AgentLoopService
? taskPolicy.TaskType is "bugfix" or "feature" or "refactor"
: IsDocumentCreationRequest(userQuery);
// probe 전용 도구(dev_env_detect, memory 등)만 사용된 경우도
// 실질적 작업이 이뤄지지 않은 것으로 간주하고 NoToolCallLoop 복구를 유도한다.
// (Code/Cowork 양쪽 모두 — 문서 작성 요청도 probe-only로 끝나서는 안 됨)
var onlyProbeToolsUsed =
requiresConcreteArtifactOrEdit
&& totalToolCalls > 0
&& !HasSubstantiveCodeToolUsage(statsUsedTools);
// probe-only 상태는 "1회 no-tool 응답"만으로도 즉시 복구 — 기본 임계(2회)는 느림
var effectiveNoToolThreshold = onlyProbeToolsUsed ? 1 : noToolResponseThreshold;
if (requiresConcreteArtifactOrEdit
&& totalToolCalls == 0
&& consecutiveNoToolResponses >= noToolResponseThreshold
&& (totalToolCalls == 0 || onlyProbeToolsUsed)
&& consecutiveNoToolResponses >= effectiveNoToolThreshold
&& runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries)
{
runState.NoToolCallLoopRetry++;
@@ -1034,6 +1088,18 @@ public partial class AgentLoopService
terminalEvidenceGateRetryMax))
continue;
// 방어: 파이프-래핑 tool_call 토큰 등 원본 마크업이 최종 assistant 응답에 남아있으면 제거
// (패턴 4 폴백 파싱도 실패하여 사용자 화면에 "<|tool_call>call;foo{...}<tool_call|>" 가
// 그대로 노출되는 증상을 방지)
if (!string.IsNullOrEmpty(textResponse))
{
var cleaned = LlmService.StripToolCallTokens(textResponse);
if (!string.Equals(cleaned, textResponse, StringComparison.Ordinal))
{
LogService.Debug("[AgentLoop] 최종 응답에 미파싱 tool_call 토큰 발견 — 정화");
textResponse = cleaned;
}
}
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
EmitEvent(AgentEventType.Complete, "",
@@ -1513,6 +1579,9 @@ public partial class AgentLoopService
lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName;
// P3: 누적 학습 — 도구 결과에서 학습 포인트 추출
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success);
if (!result.Success)
{
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
@@ -1698,7 +1767,7 @@ public partial class AgentLoopService
{
if (consumedExtraIteration)
iteration++;
return result.Output;
return result.Output ?? "";
}
var consumedVerificationIteration = await TryApplyPostToolVerificationTransitionAsync(
@@ -1957,8 +2026,9 @@ public partial class AgentLoopService
{
foreach (var name in disabledToolNames)
{
if (!string.IsNullOrWhiteSpace(name))
disabled.Add(name);
var canonical = AgentToolCatalog.Canonicalize(name);
if (!string.IsNullOrWhiteSpace(canonical))
disabled.Add(canonical);
}
}
@@ -1986,9 +2056,7 @@ public partial class AgentLoopService
if (string.IsNullOrWhiteSpace(normalized))
continue;
var alias = NormalizeAliasToken(normalized);
if (ToolAliasMap.TryGetValue(alias, out var mapped))
normalized = mapped;
normalized = AgentToolCatalog.Canonicalize(normalized);
result.Add(normalized);
}
@@ -2006,7 +2074,7 @@ public partial class AgentLoopService
{
var normalized = token.Trim().Trim('`', '"', '\'');
if (!string.IsNullOrWhiteSpace(normalized))
result.Add(normalized);
result.Add(AgentToolCatalog.Canonicalize(normalized));
}
return result;
@@ -2132,7 +2200,7 @@ public partial class AgentLoopService
}
private static bool IsForkCompliantTool(string toolName)
=> toolName is "spawn_agent" or "wait_agents";
=> toolName is "spawn_agent" or "spawn_agents" or "wait_agents";
private static bool ShouldEnforceForkExecution(
bool enforceForkExecution,
@@ -3164,6 +3232,33 @@ public partial class AgentLoopService
return true;
}
/// <summary>
/// Code 작업에서 "실질적" 도구 사용이 있었는지 판별한다.
/// dev_env_detect/memory/notify 같은 probe·메타 도구만 호출된 경우엔 false를 반환하여
/// 메인 루프가 NoToolCallLoop 복구를 통해 실제 작업을 유도하도록 한다.
/// </summary>
private static bool HasSubstantiveCodeToolUsage(IEnumerable<string> usedTools)
{
// 실질적 진행으로 간주하는 도구(읽기/수정/검색/빌드/테스트/LSP/git 등)
var substantive = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"file_read", "file_write", "file_edit", "file_manage",
"multi_read", "file_info",
"grep", "glob", "code_search", "lsp_code_intel",
"git_tool", "build_run", "test_loop",
"script_create", "process",
"html_create", "markdown_create", "docx_create",
"excel_create", "csv_create", "pptx_create", "chart_create",
"document_plan", "sub_agent", "wait_agents"
};
foreach (var t in usedTools)
{
if (!string.IsNullOrWhiteSpace(t) && substantive.Contains(t))
return true;
}
return false;
}
private static bool HasAnyBuildOrTestEvidence(List<ChatMessage> messages)
{
foreach (var message in messages.AsEnumerable().Reverse())
@@ -3338,8 +3433,8 @@ public partial class AgentLoopService
return false;
if (highImpact && !hasSuccessfulBuildAndTestEvidence)
return false;
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && !hasDocumentVerificationEvidence)
return false;
// docs 작업: 검증 도구(document_review 등) 호출이 있으면 그 언급도 필요하지만,
// 호출이 없어도 기본 요약(변경+검증 키워드)만으로 FinalReport를 트리거할 수 있게 허용.
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)
&& hasDocumentVerificationEvidence
&& !hasDocumentVerificationToolMention)
@@ -4062,8 +4157,8 @@ public partial class AgentLoopService
string unknownToolName,
IReadOnlyCollection<string> activeToolNames)
{
var normalizedUnknown = NormalizeAliasToken(unknownToolName);
var aliasHint = ToolAliasMap.TryGetValue(normalizedUnknown, out var mappedCandidate)
var mappedCandidate = AgentToolCatalog.Canonicalize(unknownToolName);
var aliasHint = !string.Equals(mappedCandidate, unknownToolName, StringComparison.OrdinalIgnoreCase)
&& activeToolNames.Any(name => string.Equals(name, mappedCandidate, StringComparison.OrdinalIgnoreCase))
? $"- 자동 매핑 후보: {unknownToolName} → {mappedCandidate}\n"
: "";
@@ -4133,67 +4228,6 @@ public partial class AgentLoopService
"- 다음 실행에서는 허용 도구 예시에서 직접 고를 수 있으면 바로 바꾸고, 그래도 애매할 때만 tool_search를 사용하세요.";
}
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)
{
["read"] = "file_read",
["readfile"] = "file_read",
["read_file"] = "file_read",
["write"] = "file_write",
["writefile"] = "file_write",
["write_file"] = "file_write",
["edit"] = "file_edit",
["editfile"] = "file_edit",
["edit_file"] = "file_edit",
["bash"] = "process",
["shell"] = "process",
["terminal"] = "process",
["run"] = "process",
["ls"] = "glob",
["listfiles"] = "glob",
["list_files"] = "glob",
["grep"] = "grep",
["greptool"] = "grep",
["grep_tool"] = "grep",
["rg"] = "grep",
["ripgrep"] = "grep",
["search"] = "grep",
["globfiles"] = "glob",
["glob_files"] = "glob",
// claw-code 계열 도구명 호환
["webfetch"] = "http_tool",
["websearch"] = "http_tool",
["askuserquestion"] = "user_ask",
["lsp"] = "lsp_code_intel",
["listmcpresourcestool"] = "mcp_list_resources",
["readmcpresourcetool"] = "mcp_read_resource",
["agent"] = "spawn_agent",
["spawnagent"] = "spawn_agent",
["task"] = "spawn_agent",
["sendmessage"] = "notify_tool",
["shellcommand"] = "process",
["execute"] = "process",
["codesearch"] = "search_codebase",
["code_search"] = "search_codebase",
["powershell"] = "process",
["toolsearch"] = "tool_search",
["todowrite"] = "todo_write",
["taskcreate"] = "task_create",
["taskget"] = "task_get",
["tasklist"] = "task_list",
["taskupdate"] = "task_update",
["taskstop"] = "task_stop",
["taskoutput"] = "task_output",
["enterworktree"] = "enter_worktree",
["exitworktree"] = "exit_worktree",
["teamcreate"] = "team_create",
["teamdelete"] = "team_delete",
["croncreate"] = "cron_create",
["crondelete"] = "cron_delete",
["cronlist"] = "cron_list",
["config"] = "project_rules",
["skill"] = "skill_manager",
};
private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
{
var requested = requestedToolName.Trim();
@@ -4205,15 +4239,13 @@ public partial class AgentLoopService
if (!string.IsNullOrWhiteSpace(direct))
return direct;
var normalizedRequested = NormalizeAliasToken(requested);
if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped))
{
var mappedDirect = activeToolNames.FirstOrDefault(name =>
string.Equals(name, mapped, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(mappedDirect))
return mappedDirect;
}
var canonicalRequested = AgentToolCatalog.Canonicalize(requested);
var mappedDirect = activeToolNames.FirstOrDefault(name =>
string.Equals(name, canonicalRequested, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(mappedDirect))
return mappedDirect;
var normalizedRequested = NormalizeAliasToken(requested);
var normalizedMatch = activeToolNames.FirstOrDefault(name =>
string.Equals(NormalizeAliasToken(name), normalizedRequested, StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(normalizedMatch))
@@ -4223,20 +4255,7 @@ public partial class AgentLoopService
}
private static string NormalizeAliasToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "";
Span<char> buffer = stackalloc char[value.Length];
var idx = 0;
foreach (var ch in value)
{
if (ch is '_' or '-' or ' ')
continue;
buffer[idx++] = char.ToLowerInvariant(ch);
}
return new string(buffer[..idx]);
}
=> AgentToolCatalog.NormalizeToken(value);
private static string BuildNoProgressAbortResponse(
TaskTypePolicy taskPolicy,
@@ -4555,7 +4574,7 @@ public partial class AgentLoopService
AskPermission = AskPermissionCallback,
UserDecision = UserDecisionCallback,
UserAskCallback = UserAskCallback,
ToolPermissions = new Dictionary<string, string>(llm.ToolPermissions ?? new(), StringComparer.OrdinalIgnoreCase),
ToolPermissions = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary<string, string>()),
ActiveTab = ActiveTab,
OperationMode = _settings.Settings.OperationMode,
DevMode = llm.DevMode,
@@ -4563,6 +4582,57 @@ public partial class AgentLoopService
};
}
/// <summary>
/// 실행 중인 컨텍스트의 권한/운영모드/차단목록을 현재 설정값으로 갱신합니다.
/// 에이전트 루프가 실행되는 동안 사용자가 UI에서 권한 모드를 바꾸거나
/// 사내/사외 모드를 전환했을 때 다음 도구 호출부터 즉시 반영되도록 합니다.
///
/// 주의: WorkFolder와 ActiveTab은 실행 중 변경되지 않아야 하므로 건드리지 않음.
/// (중간에 바뀌면 이미 진행 중인 도구 호출이 다른 워크스페이스를 바라보게 됨)
/// </summary>
private void SyncContextFromSettings(AgentContext context, bool emitChangeEvents = true)
{
if (context == null) return;
var llm = _settings.Settings.Llm;
var newPermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
var oldPermission = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
if (!string.Equals(newPermission, oldPermission, StringComparison.OrdinalIgnoreCase))
{
context.Permission = newPermission;
if (emitChangeEvents)
EmitEvent(AgentEventType.Thinking, "",
$"[설정 변경 감지] 권한 모드: {oldPermission} → {newPermission}");
// 권한 변경 시 이전 세션 승인 캐시는 유지 (사용자가 이미 허용한 건은 그대로)
}
var newOpMode = AxCopilot.Services.OperationModePolicy.Normalize(_settings.Settings.OperationMode);
var oldOpMode = AxCopilot.Services.OperationModePolicy.Normalize(context.OperationMode);
if (!string.Equals(newOpMode, oldOpMode, StringComparison.OrdinalIgnoreCase))
{
context.OperationMode = newOpMode;
if (emitChangeEvents)
EmitEvent(AgentEventType.Thinking, "",
$"[설정 변경 감지] 운영 모드: {oldOpMode} → {newOpMode}");
}
// 차단 경로/확장자는 참조 자체가 설정 객체와 동일할 수 있어 Clear+AddRange 대신 새 리스트로 교체
if (!ReferenceEquals(context.BlockedPaths, llm.BlockedPaths))
context.BlockedPaths = llm.BlockedPaths ?? new();
if (!ReferenceEquals(context.BlockedExtensions, llm.BlockedExtensions))
context.BlockedExtensions = llm.BlockedExtensions ?? new();
// 도구별 권한 오버라이드는 훅이 런타임에 쓰는 경로도 있으므로, 설정값과 훅 값을 병합.
// 설정에서 제거된 키는 삭제하되, 훅이 추가한 키(설정에 없는)는 유지.
var desired = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary<string, string>());
foreach (var kv in desired)
context.ToolPermissions[kv.Key] = kv.Value;
// 설정에서 사라진 키 중, 훅이 추가하지 않은 것만 정리하기는 훅 추적 비용이 커서 생략.
context.DevMode = llm.DevMode;
context.DevModeStepApproval = llm.DevModeStepApproval;
}
/// <summary>
/// 탭(Cowork/Code)별 작업 폴더를 결정합니다.
/// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백.
@@ -4661,7 +4731,23 @@ public partial class AgentLoopService
var primary = TryReadString(input, "path", "filePath", "destination", "url", "command", "project_path", "cwd");
if (string.IsNullOrWhiteSpace(primary))
{
// ── 권한 대상 보정 ──
// html_create/markdown_create 등은 path 생략 시 WorkFolder 하위에 자동 생성된다.
// 이 경우 권한 검사 대상을 WorkFolder 자체로 간주해야 한다.
// 이전에는 toolName("html_create")을 target으로 리턴했는데, IsOutsideWorkspace가
// Path.GetFullPath("html_create") = <앱 CWD>/html_create 로 해석하여
// "워크스페이스 외부"로 오인 → 사내 모드에서 BypassPermissions여도 강제 승인창이 떴다.
if (toolName is "file_write" or "file_edit" or "file_manage"
or "html_create" or "markdown_create" or "docx_create"
or "excel_create" or "csv_create" or "pptx_create"
or "chart_create" or "script_create"
&& !string.IsNullOrWhiteSpace(context.WorkFolder))
{
return context.WorkFolder;
}
return toolName;
}
if ((toolName is "file_write" or "file_edit" or "file_manage" or "open_external" or "html_create" or "markdown_create" or "docx_create" or "excel_create" or "csv_create" or "pptx_create" or "chart_create" or "script_create")
&& !Path.IsPathRooted(primary)
@@ -4858,11 +4944,7 @@ public partial class AgentLoopService
if (trimmed is "*" or "default")
return trimmed;
var normalizedRequested = NormalizeAliasToken(trimmed);
if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped))
return mapped;
return trimmed;
return AgentToolCatalog.Canonicalize(trimmed);
}
private async Task RunPermissionLifecycleHooksAsync(
@@ -4907,6 +4989,11 @@ public partial class AgentLoopService
AgentContext context,
List<ChatMessage>? messages = null)
{
// 권한 검사 직전 한 번 더 동기화 — 한 iteration 안에서 여러 툴 콜이 있을 때
// 사용자가 중간에 권한/운영모드를 바꿨으면 즉시 반영되도록.
// 반복 호출이므로 Thinking 이벤트는 조용히 스킵(이미 iteration 시작 시 알림 발생).
SyncContextFromSettings(context, emitChangeEvents: false);
var target = DescribeToolTarget(toolName, input, context);
var requestPayload = JsonSerializer.Serialize(new
{
@@ -5073,8 +5160,10 @@ public partial class AgentLoopService
var toolName = call.ToolName ?? "";
var input = call.ToolInput;
// 사외모드 + 권한 건너뛰기: 모든 도구 승인 생략
if (!AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
// 권한 건너뛰기: 도구 승인 생략
// 사내모드에서도 동일하게 적용 — 외부 URL 접근, 워크스페이스 외부 경로 접근 등
// 실질적인 위험은 OperationModePolicy(IsBlockedAgentToolInInternalMode) 와
// IAgentTool.CheckToolPermissionAsync 의 IsOutsideWorkspace 체크에서 이미 방어됨
{
var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
if (PermissionModeCatalog.IsBypassPermissions(effectivePerm))

View File

@@ -646,7 +646,7 @@ public partial class AgentLoopService
if (!success)
return 0;
return ReadOnlyTools.Contains(toolName ?? "")
return AgentToolCatalog.IsReadOnly(toolName)
? current + 1
: 0;
}
@@ -681,7 +681,7 @@ public partial class AgentLoopService
if (repeatedSameSignatureCount < GetReadOnlySignatureLoopThreshold())
return false;
return ReadOnlyTools.Contains(toolName ?? "");
return AgentToolCatalog.IsReadOnly(toolName);
}
private static int GetReadOnlySignatureLoopThreshold()
@@ -1124,7 +1124,12 @@ public partial class AgentLoopService
AgentContext context,
List<ChatMessage> messages)
{
if (context.DevModeStepApproval && UserDecisionCallback != null)
// 권한 자동화 모드(Bypass/AcceptEdits/DontAsk) 또는 Plan 모드에서는 DevStepApproval도 생략.
// - Bypass/AcceptEdits/DontAsk: 사용자가 "매 스텝 확인 없이 진행"을 명시적으로 선택한 상태
// - Plan: 읽기/조사만 자동 진행하며 계획 세우기가 목적 — 매 스텝 확인은 목적에 반함
var skipDevApproval = PermissionModeCatalog.IsAuto(context.Permission)
|| PermissionModeCatalog.IsPlan(context.Permission);
if (context.DevModeStepApproval && !skipDevApproval && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
$"[DEV] 도구 '{call.ToolName}' 실행을 확인하시겠습니까?\n{FormatToolCallSummary(call)}",

View File

@@ -100,7 +100,7 @@ public partial class AgentLoopService
var shouldRequestStructuredFinalReport =
taskPolicy.IsReviewTask
|| requireHighImpactCodeVerification
|| taskPolicy.TaskType is "bugfix" or "feature" or "refactor";
|| taskPolicy.TaskType is "bugfix" or "feature" or "refactor" or "docs";
if (executionPolicy.FinalReportGateMaxRetries > 0
&& shouldRequestStructuredFinalReport
&& !hasBlockingCodeEvidenceGap

View File

@@ -0,0 +1,307 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal sealed record AgentToolMetadata(
string CanonicalName,
string SettingsCategory,
string SettingsIcon,
string SettingsIconColor,
int ExposureBucket = 1,
string? TabCategory = null,
bool IsReadOnly = false);
internal static class AgentToolCatalog
{
private static readonly IReadOnlyDictionary<string, AgentToolMetadata> s_metadata =
new Dictionary<string, AgentToolMetadata>(StringComparer.OrdinalIgnoreCase)
{
["file_read"] = new("file_read", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["file_write"] = new("file_write", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code"),
["file_edit"] = new("file_edit", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code"),
["glob"] = new("glob", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["grep"] = new("grep", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["folder_map"] = new("folder_map", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true),
["document_read"] = new("document_read", "파일/검색", "\uE8B7", "#F59E0B", 0, "Cowork,Code", true),
["file_manage"] = new("file_manage", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code"),
["file_info"] = new("file_info", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true),
["multi_read"] = new("multi_read", "파일/검색", "\uE8B7", "#F59E0B", 1, "Cowork,Code", true),
["file_watch"] = new("file_watch", "파일/검색", "\uE8B7", "#F59E0B", 1, "Code"),
["open_external"] = new("open_external", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["process"] = new("process", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Cowork,Code"),
["build_run"] = new("build_run", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Code"),
["dev_env_detect"] = new("dev_env_detect", "프로세스/빌드", "\uE756", "#06B6D4", 0, "Code", true),
["search_codebase"] = new("search_codebase", "코드 분석", "\uE943", "#818CF8", 1, "Code", true),
["code_review"] = new("code_review", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["lsp_code_intel"] = new("lsp_code_intel", "코드 분석", "\uE943", "#818CF8", 0, "Code", true),
["test_loop"] = new("test_loop", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["git_tool"] = new("git_tool", "코드 분석", "\uE943", "#818CF8", 0, "Code"),
["snippet_runner"] = new("snippet_runner", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["diff_preview"] = new("diff_preview", "코드 분석", "\uE943", "#818CF8", 1, "Code", true),
["project_rules"] = new("project_rules", "코드 분석", "\uE943", "#818CF8", 1, "Code"),
["excel_create"] = new("excel_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["docx_create"] = new("docx_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["csv_create"] = new("csv_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["markdown_create"] = new("markdown_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["html_create"] = new("html_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["chart_create"] = new("chart_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["script_create"] = new("script_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["pptx_create"] = new("pptx_create", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["document_plan"] = new("document_plan", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["document_assemble"] = new("document_assemble", "문서 생성", "\uE8A5", "#34D399", 0, "Cowork"),
["document_review"] = new("document_review", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["format_convert"] = new("format_convert", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["template_render"] = new("template_render", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["text_summarize"] = new("text_summarize", "문서 생성", "\uE8A5", "#34D399", 1, "Cowork"),
["json_tool"] = new("json_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["regex_tool"] = new("regex_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["diff_tool"] = new("diff_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["base64_tool"] = new("base64_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["hash_tool"] = new("hash_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["datetime_tool"] = new("datetime_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["math_eval"] = new("math_eval", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["sql_tool"] = new("sql_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"),
["xml_tool"] = new("xml_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"),
["data_pivot"] = new("data_pivot", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork"),
["encoding_tool"] = new("encoding_tool", "데이터 처리", "\uE9F5", "#F59E0B", 1, "Cowork,Code", true),
["clipboard_tool"] = new("clipboard_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["notify_tool"] = new("notify_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["env_tool"] = new("env_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code", true),
["zip_tool"] = new("zip_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["http_tool"] = new("http_tool", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork,Code"),
["image_analyze"] = new("image_analyze", "시스템/환경", "\uE770", "#06B6D4", 1, "Cowork"),
["spawn_agent"] = new("spawn_agent", "에이전트", "\uE99A", "#F472B6", 2, "Code"),
["spawn_agents"] = new("spawn_agents", "에이전트", "\uE99A", "#F472B6", 2, "Code"),
["wait_agents"] = new("wait_agents", "에이전트", "\uE99A", "#F472B6", 2, "Code"),
["memory"] = new("memory", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true),
["skill_manager"] = new("skill_manager", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true),
["tool_search"] = new("tool_search", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code", true),
["user_ask"] = new("user_ask", "에이전트", "\uE99A", "#F472B6", 1, "Cowork,Code"),
["mcp_list_resources"] = new("mcp_list_resources", "에이전트", "\uE99A", "#F472B6", 2, "Cowork,Code", true),
["mcp_read_resource"] = new("mcp_read_resource", "에이전트", "\uE99A", "#F472B6", 2, "Cowork,Code", true),
["task_tracker"] = new("task_tracker", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["todo_write"] = new("todo_write", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_create"] = new("task_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_get"] = new("task_get", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_list"] = new("task_list", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_update"] = new("task_update", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_stop"] = new("task_stop", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["task_output"] = new("task_output", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["enter_worktree"] = new("enter_worktree", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["exit_worktree"] = new("exit_worktree", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["team_create"] = new("team_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["team_delete"] = new("team_delete", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["cron_create"] = new("cron_create", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["cron_delete"] = new("cron_delete", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["cron_list"] = new("cron_list", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["suggest_actions"] = new("suggest_actions", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["checkpoint"] = new("checkpoint", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
["playbook"] = new("playbook", "에이전트", "\uE99A", "#F472B6", 3, "Code"),
};
private static readonly IReadOnlyDictionary<string, string> s_aliasMap =
BuildAliasMap();
public static string Canonicalize(string? toolName)
{
if (string.IsNullOrWhiteSpace(toolName))
return "";
var trimmed = toolName.Trim();
if (s_metadata.ContainsKey(trimmed))
return trimmed;
var normalized = NormalizeToken(trimmed);
return s_aliasMap.TryGetValue(normalized, out var canonical)
? canonical
: trimmed;
}
public static string NormalizeToken(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return "";
Span<char> buffer = stackalloc char[value.Length];
var idx = 0;
foreach (var ch in value)
{
if (ch is '_' or '-' or ' ')
continue;
buffer[idx++] = char.ToLowerInvariant(ch);
}
return new string(buffer[..idx]);
}
public static AgentToolMetadata GetMetadata(string? toolName)
{
var canonical = Canonicalize(toolName);
return s_metadata.TryGetValue(canonical, out var metadata)
? metadata
: new AgentToolMetadata(canonical, "기타", "\uE10C", "#94A3B8");
}
public static string? GetTabCategory(string? toolName)
=> GetMetadata(toolName).TabCategory;
public static int GetExposureBucket(string? toolName)
=> GetMetadata(toolName).ExposureBucket;
public static bool IsReadOnly(string? toolName)
=> GetMetadata(toolName).IsReadOnly;
public static IReadOnlyCollection<string> CanonicalizeMany(IEnumerable<string>? names)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (names == null)
return result.ToList().AsReadOnly();
foreach (var name in names)
{
var canonical = Canonicalize(name);
if (!string.IsNullOrWhiteSpace(canonical))
result.Add(canonical);
}
return result.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList().AsReadOnly();
}
public static Dictionary<string, string> CanonicalizePermissionMap(IDictionary<string, string>? permissions)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (permissions == null)
return result;
foreach (var kv in permissions)
{
var normalizedKey = CanonicalizePermissionKey(kv.Key);
if (!string.IsNullOrWhiteSpace(normalizedKey))
result[normalizedKey] = kv.Value;
}
return result;
}
public static List<AgentHookEntry> CanonicalizeHooks(IEnumerable<AgentHookEntry>? hooks)
{
var result = new List<AgentHookEntry>();
if (hooks == null)
return result;
foreach (var hook in hooks)
{
result.Add(new AgentHookEntry
{
Name = hook.Name,
ToolName = CanonicalizeHookTarget(hook.ToolName),
Timing = hook.Timing,
ScriptPath = hook.ScriptPath,
Arguments = hook.Arguments,
Enabled = hook.Enabled,
});
}
return result;
}
public static string CanonicalizeHookTarget(string? toolName)
{
if (string.IsNullOrWhiteSpace(toolName))
return "*";
var trimmed = toolName.Trim();
return string.Equals(trimmed, "*", StringComparison.Ordinal)
? trimmed
: Canonicalize(trimmed);
}
private static string CanonicalizePermissionKey(string? key)
{
if (string.IsNullOrWhiteSpace(key))
return "";
var trimmed = key.Trim();
if (string.Equals(trimmed, "*", StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, "default", StringComparison.OrdinalIgnoreCase))
return trimmed.ToLowerInvariant();
var atIndex = trimmed.IndexOf('@');
if (atIndex > 0 && atIndex < trimmed.Length - 1)
return $"{Canonicalize(trimmed[..atIndex])}@{trimmed[(atIndex + 1)..].Trim()}";
var pipeIndex = trimmed.IndexOf('|');
if (pipeIndex > 0 && pipeIndex < trimmed.Length - 1)
return $"{Canonicalize(trimmed[..pipeIndex])}|{trimmed[(pipeIndex + 1)..].Trim()}";
var openIndex = trimmed.IndexOf('(');
if (openIndex > 0 && trimmed.EndsWith(")", StringComparison.Ordinal))
return $"{Canonicalize(trimmed[..openIndex])}{trimmed[openIndex..]}";
return Canonicalize(trimmed);
}
private static IReadOnlyDictionary<string, string> BuildAliasMap()
{
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var metadata in s_metadata.Values)
{
aliases[NormalizeToken(metadata.CanonicalName)] = metadata.CanonicalName;
}
RegisterAliases(aliases, "zip_tool", "zip");
RegisterAliases(aliases, "json_tool", "json");
RegisterAliases(aliases, "regex_tool", "regex");
RegisterAliases(aliases, "base64_tool", "base64");
RegisterAliases(aliases, "hash_tool", "hash");
RegisterAliases(aliases, "datetime_tool", "datetime");
RegisterAliases(aliases, "math_eval", "math", "math_tool");
RegisterAliases(aliases, "encoding_tool", "encoding");
RegisterAliases(aliases, "http_tool", "http", "webfetch", "websearch");
RegisterAliases(aliases, "clipboard_tool", "clipboard");
RegisterAliases(aliases, "notify_tool", "notify", "sendmessage");
RegisterAliases(aliases, "env_tool", "env");
RegisterAliases(aliases, "git_tool", "git");
RegisterAliases(aliases, "lsp_code_intel", "lsp");
RegisterAliases(aliases, "project_rules", "project_rule", "config");
RegisterAliases(aliases, "snippet_runner", "snippet_run");
RegisterAliases(aliases, "search_codebase", "code_search", "codesearch");
RegisterAliases(aliases, "script_create", "batch_create", "batch_skill");
RegisterAliases(aliases, "markdown_create", "md_create", "markdown_skill");
RegisterAliases(aliases, "excel_create", "xlsx_create", "excel_skill");
RegisterAliases(aliases, "docx_create", "docx_skill");
RegisterAliases(aliases, "csv_create", "csv_skill");
RegisterAliases(aliases, "html_create", "html_skill");
RegisterAliases(aliases, "chart_create", "chart_skill");
RegisterAliases(aliases, "pptx_create", "pptx_skill");
RegisterAliases(aliases, "document_plan", "document_planner");
RegisterAliases(aliases, "document_assemble", "document_assembler");
RegisterAliases(aliases, "spawn_agent", "sub_agent", "agent", "task", "spawnagent");
RegisterAliases(aliases, "spawn_agents", "batchagent", "spawnagents");
RegisterAliases(aliases, "tool_search", "toolsearch");
RegisterAliases(aliases, "user_ask", "askuserquestion");
RegisterAliases(aliases, "skill_manager", "skill");
RegisterAliases(aliases, "mcp_list_resources", "listmcpresourcestool");
RegisterAliases(aliases, "mcp_read_resource", "readmcpresourcetool");
RegisterAliases(aliases, "process", "bash", "shell", "terminal", "run", "powershell", "shellcommand", "execute");
RegisterAliases(aliases, "glob", "ls", "listfiles", "list_files", "globfiles", "glob_files", "search_files", "find");
RegisterAliases(aliases, "grep", "grep_tool", "greptool", "rg", "ripgrep", "search_content");
RegisterAliases(aliases, "file_read", "read", "readfile", "read_file");
RegisterAliases(aliases, "file_write", "write", "writefile", "write_file");
RegisterAliases(aliases, "file_edit", "edit", "editfile", "edit_file");
return aliases;
}
private static void RegisterAliases(IDictionary<string, string> aliases, string canonicalName, params string[] values)
{
foreach (var value in values)
aliases[NormalizeToken(value)] = canonicalName;
}
}

View File

@@ -81,6 +81,7 @@ internal static class AgentTranscriptDisplayCatalog
"task_stop" => "작업 중지",
"task_output" => "작업 출력",
"spawn_agent" => "서브에이전트",
"spawn_agents" => "배치 에이전트",
"wait_agents" => "에이전트 대기",
_ => normalized.Replace('_', ' ').Trim(),
};
@@ -416,7 +417,7 @@ internal static class AgentTranscriptDisplayCatalog
=> "제안",
"process" or "bash" or "powershell"
=> "명령",
"spawn_agent" or "wait_agents"
"spawn_agent" or "spawn_agents" or "wait_agents"
=> "에이전트",
"web_fetch" or "http"
=> "웹",

View File

@@ -5,8 +5,8 @@ namespace AxCopilot.Services.Agent;
/// <summary>
/// AX Agent execution-prep engine.
/// Inspired by the `claw-code` split between input preparation and session execution,
/// so the UI layer stops owning message assembly and final assistant commit logic.
/// UI 레이어가 메시지 조립과 최종 어시스턴트 커밋 로직을 직접 소유하지 않도록
/// 입력 준비(preparation)와 세션 실행(execution)을 분리하는 패턴입니다.
/// </summary>
public sealed class AxAgentExecutionEngine
{

View File

@@ -394,7 +394,7 @@ public static class ContextCondenser
/// <summary>
/// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다.
/// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다.
/// LLM 호출 전에 토큰을 한 번 더 줄이는 마이크로 압축 단계입니다.
/// </summary>
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
{
@@ -653,6 +653,10 @@ public static class ContextCondenser
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content)) return false;
// P3: 세션 학습 메시지는 압축 대상에서 제외 — 매 반복 갱신되므로 항상 보존
if (string.Equals(message.MetaKind, "session_learnings", StringComparison.OrdinalIgnoreCase))
return false;
return message.MetaKind != null
|| content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)
|| content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)

View File

@@ -218,7 +218,9 @@ public class DocumentAssemblerTool : IAgentTool
var (heading, content, level) = sections[i];
var tag = level <= 1 ? "h2" : "h3";
sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}</{tag}>");
sb.AppendLine($"<div class=\"section-content\">{content}</div>");
// LLM이 생성한 깨진 태그 자동 수정
var sanitized = HtmlSkill.SanitizeHtmlTagsPublic(content);
sb.AppendLine($"<div class=\"section-content\">{sanitized}</div>");
}
sb.AppendLine("</div>");

View File

@@ -0,0 +1,41 @@
using static AxCopilot.Services.Agent.ModelExecutionProfileCatalog;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 기존 ExecutionPolicy 위에 적용할 sparse override.
/// null인 필드는 base policy 값을 그대로 사용합니다.
/// </summary>
public sealed record ExecutionPolicyOverlay(
double? ToolTemperatureCap = null,
int? NoToolResponseThreshold = null,
int? NoToolRecoveryMaxRetries = null,
bool? ForceInitialToolCall = null,
bool? EnableCodeQualityGates = null,
bool? EnableDocumentVerificationGate = null,
bool? ReduceEarlyMemoryPressure = null,
int? MaxParallelReadBatch = null
);
/// <summary>
/// base ExecutionPolicy + overlay 병합 유틸.
/// </summary>
public static class ExecutionPolicyMerger
{
public static ExecutionPolicy Apply(ExecutionPolicy basePolicy, ExecutionPolicyOverlay? overlay)
{
if (overlay is null) return basePolicy;
return basePolicy with
{
ToolTemperatureCap = overlay.ToolTemperatureCap ?? basePolicy.ToolTemperatureCap,
NoToolResponseThreshold = overlay.NoToolResponseThreshold ?? basePolicy.NoToolResponseThreshold,
NoToolRecoveryMaxRetries = overlay.NoToolRecoveryMaxRetries ?? basePolicy.NoToolRecoveryMaxRetries,
ForceInitialToolCall = overlay.ForceInitialToolCall ?? basePolicy.ForceInitialToolCall,
EnableCodeQualityGates = overlay.EnableCodeQualityGates ?? basePolicy.EnableCodeQualityGates,
EnableDocumentVerificationGate = overlay.EnableDocumentVerificationGate ?? basePolicy.EnableDocumentVerificationGate,
ReduceEarlyMemoryPressure = overlay.ReduceEarlyMemoryPressure ?? basePolicy.ReduceEarlyMemoryPressure,
MaxParallelReadBatch = overlay.MaxParallelReadBatch ?? basePolicy.MaxParallelReadBatch,
};
}
}

View File

@@ -1,34 +1,52 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴).</summary>
/// <summary>
/// 파일의 특정 부분을 수정하는 도구.
/// 두 가지 모드 지원:
/// 1. 기존 모드: old_string → new_string 패턴 매칭
/// 2. 앵커 모드: hash anchor 위치 기반 편집 (pos 파라미터 사용)
/// </summary>
public class FileEditTool : IAgentTool
{
public string Name => "file_edit";
public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique.";
public string Description =>
"Edit a file. Two modes:\n" +
"1. String mode: old_string + new_string (replace exact match). Set replace_all=true for all occurrences.\n" +
"2. Anchor mode: Use pos (e.g. \"11#VK\") from file_read hash_anchor output + op (replace/delete/insert_before/insert_after) + lines. " +
"Hash anchors detect stale edits — if the file changed since you read it, the edit is rejected.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
// ── 공통 ──
["path"] = new() { Type = "string", Description = "File path to edit" },
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" },
["new_string"] = new() { Type = "string", Description = "Replacement string" },
// ── 기존 string 모드 ──
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace (string mode)" },
["new_string"] = new() { Type = "string", Description = "Replacement string (string mode)" },
["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." },
// ── 앵커 모드 ──
["pos"] = new() { Type = "string", Description = "Hash-anchored position, e.g. \"11#VK\" or \"11#VK-15#MB\" for range (anchor mode)" },
["op"] = new() { Type = "string", Description = "Operation: replace, delete, insert_before, insert_after (anchor mode, default: replace)" },
["lines"] = new()
{
Type = "array",
Description = "New lines to insert/replace (anchor mode). Each element is a string.",
Items = new() { Type = "string" }
},
},
Required = ["path", "old_string", "new_string"]
Required = ["path"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").SafeGetString() ?? "";
var oldStr = args.GetProperty("old_string").SafeGetString() ?? "";
var newStr = args.GetProperty("new_string").SafeGetString() ?? "";
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -40,6 +58,248 @@ public class FileEditTool : IAgentTool
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
// 모드 판별: pos 파라미터가 있으면 앵커 모드
if (args.SafeTryGetProperty("pos", out var posEl) && !string.IsNullOrWhiteSpace(posEl.SafeGetString()))
return await ExecuteAnchorModeAsync(args, fullPath, posEl.SafeGetString()!, ct);
return await ExecuteStringModeAsync(args, fullPath, ct);
}
// ════════════════════════════════════════════════════════════
// 앵커 모드 (Hash-Anchored Edits)
// ════════════════════════════════════════════════════════════
private async Task<ToolResult> ExecuteAnchorModeAsync(
JsonElement args, string fullPath, string posStr, CancellationToken ct)
{
try
{
var op = "replace";
if (args.SafeTryGetProperty("op", out var opEl))
op = opEl.SafeGetString()?.ToLowerInvariant() ?? "replace";
// 새 라인 파싱
var newLines = new List<string>();
if (args.SafeTryGetProperty("lines", out var linesEl) && linesEl.ValueKind == JsonValueKind.Array)
{
foreach (var item in linesEl.EnumerateArray())
newLines.Add(item.SafeGetString() ?? "");
}
// op=delete 외에는 lines 필수
if (op != "delete" && newLines.Count == 0)
return ToolResult.Fail("lines array is required for replace/insert operations.");
// 파일 읽기
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
var fileLines = TextFileCodec.SplitLines(read.Text);
// 위치 파싱 (단일: "11#VK", 범위: "11#VK-15#MB")
var dashIdx = posStr.IndexOf('-', 1); // 첫 글자 이후부터 검색 (음수 라인번호 방지)
int startLine, endLine;
string startAnchor, endAnchor;
if (dashIdx > 0)
{
// 범위
if (!HashAnchor.TryParsePosition(posStr[..dashIdx], out startLine, out startAnchor))
return ToolResult.Fail($"Invalid start position: {posStr[..dashIdx]}. Format: LINENUM#HASH");
if (!HashAnchor.TryParsePosition(posStr[(dashIdx + 1)..], out endLine, out endAnchor))
return ToolResult.Fail($"Invalid end position: {posStr[(dashIdx + 1)..]}. Format: LINENUM#HASH");
}
else
{
// 단일 라인
if (!HashAnchor.TryParsePosition(posStr, out startLine, out startAnchor))
return ToolResult.Fail($"Invalid position: {posStr}. Format: LINENUM#HASH (e.g. 11#VK)");
endLine = startLine;
endAnchor = startAnchor;
}
if (startLine > endLine)
return ToolResult.Fail($"Start line ({startLine}) must be ≤ end line ({endLine}).");
// 해시 앵커 검증 (스테일 감지)
var positions = new List<(int, string)> { (startLine, startAnchor) };
if (endLine != startLine)
positions.Add((endLine, endAnchor));
var (allValid, errorDetail) = HashAnchor.ValidatePositions(fileLines, positions);
if (!allValid)
return ToolResult.Fail(errorDetail!);
// 편집 적용
var result = ApplyAnchorOperation(fileLines, op, startLine, endLine, newLines);
if (!result.Success)
return ToolResult.Fail(result.Error!);
// diff 생성 (변경 전후)
var diffPreview = GenerateAnchorDiff(fileLines, result.NewFileLines!, startLine, endLine, op, fullPath);
// 파일 쓰기
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
var newContent = string.Join("\n", result.NewFileLines!);
await TextFileCodec.WriteAllTextAsync(fullPath, newContent, writeEncoding, ct);
// 변경된 영역의 새 앵커를 반환 (연쇄 편집 지원)
var updatedAnchors = BuildUpdatedAnchors(result.NewFileLines!, startLine, newLines.Count, op);
return ToolResult.Ok(
$"파일 수정 완료 (anchored {op}): {fullPath}\n\n{diffPreview}\n\n{updatedAnchors}",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"앵커 편집 실패: {ex.Message}");
}
}
private readonly record struct AnchorEditResult(bool Success, string? Error, string[]? NewFileLines);
private static AnchorEditResult ApplyAnchorOperation(
string[] fileLines, string op, int startLine, int endLine, List<string> newLines)
{
var result = new List<string>(fileLines.Length + newLines.Count);
var startIdx = startLine - 1; // 0-based
var endIdx = endLine - 1; // 0-based
switch (op)
{
case "replace":
// 앞부분
for (int i = 0; i < startIdx; i++)
result.Add(fileLines[i]);
// 새 라인
result.AddRange(newLines);
// 뒷부분
for (int i = endIdx + 1; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
case "delete":
for (int i = 0; i < startIdx; i++)
result.Add(fileLines[i]);
for (int i = endIdx + 1; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
case "insert_before":
for (int i = 0; i < startIdx; i++)
result.Add(fileLines[i]);
result.AddRange(newLines);
for (int i = startIdx; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
case "insert_after":
for (int i = 0; i <= endIdx; i++)
result.Add(fileLines[i]);
result.AddRange(newLines);
for (int i = endIdx + 1; i < fileLines.Length; i++)
result.Add(fileLines[i]);
break;
default:
return new(false, $"Unknown operation: {op}. Use replace, delete, insert_before, or insert_after.", null);
}
return new(true, null, result.ToArray());
}
/// <summary>변경 후 영역의 새 앵커를 생성하여 연쇄 편집을 지원합니다.</summary>
private static string BuildUpdatedAnchors(string[] newFileLines, int startLine, int newLineCount, string op)
{
if (newFileLines.Length == 0) return "";
// 변경 영역 근처 라인의 새 앵커
var sb = new StringBuilder();
sb.AppendLine("Updated anchors (for chained edits):");
var showStart = Math.Max(0, startLine - 2);
var showEnd = op switch
{
"delete" => Math.Min(newFileLines.Length, startLine + 2),
_ => Math.Min(newFileLines.Length, startLine + newLineCount + 1),
};
var anchors = HashAnchor.ComputeAnchors(newFileLines);
for (int i = showStart; i < showEnd; i++)
{
sb.AppendLine(HashAnchor.FormatLine(newFileLines[i], i + 1, anchors[i]));
}
return sb.ToString().TrimEnd();
}
/// <summary>앵커 편집의 diff 프리뷰를 생성합니다.</summary>
private static string GenerateAnchorDiff(
string[] oldLines, string[] newLines, int startLine, int endLine, string op, string filePath)
{
var sb = new StringBuilder();
var fileName = Path.GetFileName(filePath);
sb.AppendLine($"--- {fileName} (before)");
sb.AppendLine($"+++ {fileName} (after)");
const int ctx = 2;
var startIdx = startLine - 1;
var endIdx = endLine - 1;
var ctxStart = Math.Max(0, startIdx - ctx);
sb.AppendLine($"@@ -{ctxStart + 1} @@");
// 앞쪽 컨텍스트
for (int i = ctxStart; i < startIdx && i < oldLines.Length; i++)
sb.AppendLine($" {oldLines[i].TrimEnd('\r')}");
// 삭제된 라인
if (op is "replace" or "delete")
{
for (int i = startIdx; i <= endIdx && i < oldLines.Length; i++)
sb.AppendLine($"-{oldLines[i].TrimEnd('\r')}");
}
// 추가된 라인 (new content)
if (op is "replace" or "insert_before" or "insert_after")
{
// 새 파일에서 삽입된 라인 범위를 추적
var insertStart = op switch
{
"insert_before" => startIdx,
"insert_after" => endIdx + 1,
_ => startIdx, // replace
};
var insertCount = op == "replace"
? newLines.Length - oldLines.Length + (endIdx - startIdx + 1)
: newLines.Length - oldLines.Length;
// 심플하게: old→new 차이를 보여줌
for (int i = insertStart; i < insertStart + Math.Max(0, insertCount) && i < newLines.Length; i++)
sb.AppendLine($"+{newLines[i].TrimEnd('\r')}");
}
// 뒤쪽 컨텍스트
var afterStart = endIdx + 1;
var afterEnd = Math.Min(oldLines.Length, afterStart + ctx);
for (int i = afterStart; i < afterEnd; i++)
sb.AppendLine($" {oldLines[i].TrimEnd('\r')}");
return sb.ToString().TrimEnd();
}
// ════════════════════════════════════════════════════════════
// 기존 String 모드 (하위 호환)
// ════════════════════════════════════════════════════════════
private async Task<ToolResult> ExecuteStringModeAsync(JsonElement args, string fullPath, CancellationToken ct)
{
var oldStr = args.SafeTryGetProperty("old_string", out var osEl) ? osEl.SafeGetString() ?? "" : "";
var newStr = args.SafeTryGetProperty("new_string", out var nsEl) ? nsEl.SafeGetString() ?? "" : "";
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
if (string.IsNullOrEmpty(oldStr))
return ToolResult.Fail("old_string이 필요합니다. 앵커 모드를 사용하려면 pos 파라미터를 지정하세요.");
try
{
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
@@ -48,15 +308,13 @@ public class FileEditTool : IAgentTool
var count = CountOccurrences(content, oldStr);
if (count == 0)
{
// LLM이 수정할 수 있도록 파일 내용 일부를 함께 반환
var hint = BuildNotFoundHint(content, oldStr);
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.{hint}");
}
if (!replaceAll && count > 1)
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
// Diff Preview: 변경 내용을 컨텍스트와 함께 표시
var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
var diffPreview = GenerateStringDiff(content, oldStr, newStr, fullPath);
var updated = content.Replace(oldStr, newStr);
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
@@ -74,13 +332,12 @@ public class FileEditTool : IAgentTool
}
/// <summary>변경 전/후 diff를 생성합니다 (unified diff 스타일).</summary>
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
private static string GenerateStringDiff(string content, string oldStr, string newStr, string filePath)
{
var lines = content.Split('\n');
var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal);
if (matchIdx < 0) return "";
// 변경 시작 줄 번호 계산
var startLine = content[..matchIdx].Count(c => c == '\n');
var oldLines = oldStr.Split('\n');
var newLines = newStr.Split('\n');
@@ -90,26 +347,21 @@ public class FileEditTool : IAgentTool
sb.AppendLine($"--- {fileName} (before)");
sb.AppendLine($"+++ {fileName} (after)");
// 컨텍스트 라인 수
const int ctx = 2;
var ctxStart = Math.Max(0, startLine - ctx);
var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx);
sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@");
// 앞쪽 컨텍스트
for (int i = ctxStart; i < startLine; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
// 삭제 라인
foreach (var line in oldLines)
sb.AppendLine($"-{line.TrimEnd('\r')}");
// 추가 라인
foreach (var line in newLines)
sb.AppendLine($"+{line.TrimEnd('\r')}");
// 뒤쪽 컨텍스트
var afterEnd = startLine + oldLines.Length;
for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
@@ -124,7 +376,6 @@ public class FileEditTool : IAgentTool
var sb = new StringBuilder();
// 유사 행 검색: old_string의 첫 줄로 근사 매치 시도
var firstLine = oldStr.Split('\n')[0].Trim().TrimEnd('\r');
if (firstLine.Length >= 8)
{
@@ -144,7 +395,6 @@ public class FileEditTool : IAgentTool
}
}
// 파일이 짧으면 전체 내용 표시
if (sb.Length == 0)
{
var preview = content.Length > 2000 ? content[..2000] + "\n...(truncated)" : content;

View File

@@ -3,11 +3,14 @@ using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 내용을 읽어 반환하는 도구.</summary>
/// <summary>파일 내용을 읽어 반환하는 도구. hash_anchor=true 시 라인별 해시 앵커를 포함합니다.</summary>
public class FileReadTool : IAgentTool
{
public string Name => "file_read";
public string Description => "Read the contents of a file. Returns the text content with line numbers.";
public string Description =>
"Read the contents of a file. Returns the text content with line numbers.\n" +
"When hash_anchor=true, each line includes a 2-char hash anchor (e.g. \"11#VK| code\") " +
"that can be used with file_edit's anchored mode for precise, conflict-safe edits.";
public ToolParameterSchema Parameters => new()
{
@@ -16,6 +19,7 @@ public class FileReadTool : IAgentTool
["path"] = new() { Type = "string", Description = "File path to read (absolute or relative to work folder)" },
["offset"] = new() { Type = "integer", Description = "Starting line number (1-based). Optional, default 1." },
["limit"] = new() { Type = "integer", Description = "Maximum number of lines to read. Optional, default 500." },
["hash_anchor"] = new() { Type = "boolean", Description = "If true, output each line as LINENUM#HASH| content for anchored editing. Default: use global setting." },
},
Required = ["path"]
};
@@ -28,6 +32,9 @@ public class FileReadTool : IAgentTool
var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
// hash_anchor: 명시적 파라미터 > 전역 설정
var useHashAnchor = ResolveHashAnchorMode(args);
var fullPath = ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
@@ -44,10 +51,21 @@ public class FileReadTool : IAgentTool
var startIdx = Math.Max(0, offset - 1);
var endIdx = Math.Min(total, startIdx + limit);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}");
var sb = new System.Text.StringBuilder((endIdx - startIdx) * 80);
if (useHashAnchor)
{
var anchors = HashAnchor.ComputeAnchors(lines);
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName}, hash_anchor=on)");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine(HashAnchor.FormatLine(lines[i], i + 1, anchors[i]));
}
else
{
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}");
}
return Task.FromResult(ToolResult.Ok(sb.ToString(), fullPath));
}
@@ -64,4 +82,18 @@ public class FileReadTool : IAgentTool
return Path.GetFullPath(Path.Combine(workFolder, path));
return Path.GetFullPath(path);
}
/// <summary>
/// hash_anchor 모드를 결정합니다.
/// 명시적 파라미터 > 전역 설정(EnableHashAnchoredEdits).
/// </summary>
internal static bool ResolveHashAnchorMode(JsonElement args)
{
if (args.SafeTryGetProperty("hash_anchor", out var haEl))
return haEl.GetBoolean();
// 전역 설정 참조
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.EnableHashAnchoredEdits ?? false;
}
}

View File

@@ -0,0 +1,167 @@
using System.IO.Hashing;
using System.Text;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Hash-anchored edits 인프라.
/// 파일 읽기 시 각 라인에 2글자 해시 앵커를 부여하고,
/// 편집 시 해시 앵커로 대상 라인을 정확히 식별 + 스테일 감지.
/// </summary>
internal static class HashAnchor
{
// oh-my-openagent 호환 알파벳 (16자 → 2글자 조합 = 256가지)
private const string Alphabet = "ZPMQVRWSNKTXJBYH";
/// <summary>
/// 라인 내용 + 라인 번호(1-based)로부터 2글자 해시 앵커를 생성합니다.
/// </summary>
public static string ComputeAnchor(string lineContent, int lineNumber)
{
// 정규화: CR 제거 + 후행 공백 제거
var normalized = lineContent.TrimEnd('\r').TrimEnd();
// 빈 줄/공백만 있는 줄 → 라인번호를 시드로 사용 (충돌 감소)
uint hash;
if (IsBlankOrWhitespace(normalized))
{
Span<byte> numBuf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(numBuf, lineNumber);
hash = XxHash32(numBuf);
}
else
{
var bytes = Encoding.UTF8.GetBytes(normalized);
hash = XxHash32(bytes);
}
// 8비트로 축소 → 알파벳 2글자로 인코딩
var reduced = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24));
var hi = Alphabet[(reduced >> 4) & 0x0F];
var lo = Alphabet[reduced & 0x0F];
return $"{hi}{lo}";
}
/// <summary>
/// 파일 전체 라인에 대해 해시 앵커를 배열로 반환합니다.
/// anchors[i]는 lines[i]의 앵커 (0-based).
/// </summary>
public static string[] ComputeAnchors(string[] lines)
{
var anchors = new string[lines.Length];
for (int i = 0; i < lines.Length; i++)
anchors[i] = ComputeAnchor(lines[i], i + 1);
return anchors;
}
/// <summary>
/// "LINENUM#HASH" 형식의 위치 문자열을 파싱합니다.
/// 예: "11#VK" → lineNumber=11, anchor="VK"
/// </summary>
public static bool TryParsePosition(string pos, out int lineNumber, out string anchor)
{
lineNumber = 0;
anchor = "";
if (string.IsNullOrWhiteSpace(pos))
return false;
var hashIdx = pos.IndexOf('#');
if (hashIdx < 1 || hashIdx >= pos.Length - 1)
return false;
if (!int.TryParse(pos.AsSpan(0, hashIdx), out lineNumber) || lineNumber < 1)
return false;
anchor = pos[(hashIdx + 1)..].Trim();
return anchor.Length == 2;
}
/// <summary>
/// 앵커가 현재 파일 라인과 일치하는지 검증합니다.
/// </summary>
public static bool Validate(string lineContent, int lineNumber, string expectedAnchor)
{
var actual = ComputeAnchor(lineContent, lineNumber);
return string.Equals(actual, expectedAnchor, StringComparison.Ordinal);
}
/// <summary>
/// 해시 앵커가 포함된 파일 읽기 출력을 생성합니다.
/// 형식: "LINENUM#HASH| content"
/// </summary>
public static string FormatLine(string lineContent, int lineNumber, string anchor)
{
return $"{lineNumber}#{anchor}| {lineContent.TrimEnd('\r')}";
}
/// <summary>
/// 해시 앵커가 포함된 파일 전체 출력을 생성합니다.
/// </summary>
public static string FormatLines(string[] lines, string[] anchors, int startIdx, int endIdx)
{
var sb = new StringBuilder((endIdx - startIdx) * 80);
for (int i = startIdx; i < endIdx && i < lines.Length; i++)
{
var lineNum = i + 1;
sb.Append(lineNum);
sb.Append('#');
sb.Append(anchors[i]);
sb.Append("| ");
sb.AppendLine(lines[i].TrimEnd('\r'));
}
return sb.ToString();
}
/// <summary>
/// 여러 앵커 위치를 검증하고, 불일치가 있으면 상세 에러를 반환합니다.
/// </summary>
public static (bool AllValid, string? ErrorDetail) ValidatePositions(
string[] lines, List<(int LineNumber, string Anchor)> positions)
{
var mismatches = new List<string>();
foreach (var (lineNum, expectedAnchor) in positions)
{
if (lineNum < 1 || lineNum > lines.Length)
{
mismatches.Add($" Line {lineNum}: out of range (file has {lines.Length} lines)");
continue;
}
var actual = ComputeAnchor(lines[lineNum - 1], lineNum);
if (!string.Equals(actual, expectedAnchor, StringComparison.Ordinal))
{
var preview = lines[lineNum - 1].TrimEnd('\r');
if (preview.Length > 80) preview = preview[..80] + "...";
mismatches.Add($" Line {lineNum}: expected #{expectedAnchor}, got #{actual} — \"{preview}\"");
}
}
if (mismatches.Count == 0)
return (true, null);
var detail = $"Hash anchor mismatch — file was modified since last read. Re-read the file to get fresh anchors.\n" +
string.Join("\n", mismatches);
return (false, detail);
}
// ════════════════════════════════════════════
// 내부 유틸
// ════════════════════════════════════════════
private static bool IsBlankOrWhitespace(string s)
{
foreach (var c in s)
{
if (c != ' ' && c != '\t')
return false;
}
return true;
}
private static uint XxHash32(ReadOnlySpan<byte> data)
{
// System.IO.Hashing 사용
return System.IO.Hashing.XxHash32.HashToUInt32(data);
}
}

View File

@@ -15,13 +15,15 @@ public class HtmlSkill : IAgentTool
{
public string Name => "html_create";
public string Description => "Create a styled HTML (.html) document with rich formatting. " +
"REQUIRED: 'title' AND 'body' (HTML string). " +
"If you prefer structured blocks, set body=\"\" and provide 'sections' array instead. " +
"NEVER call this tool with only title — you MUST include body (or sections). " +
"Supports: table of contents (toc), cover page, callouts (.callout-info/warning/tip/danger), " +
"badges (.badge-blue/green/red/yellow/purple), CSS bar charts (.chart-bar), " +
"progress bars (.progress), timelines (.timeline), grid layouts (.grid-2/3/4), " +
"and auto section numbering. " +
"Use 'sections' array for structured content (heading/paragraph/callout/table/chart/cards/list/quote/divider/kpi) " +
"instead of raw HTML body. Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " +
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.";
"Use 'accent_color' to override theme accent. Use 'print' for print-ready output. " +
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard, seminar, seminar-toc.";
public ToolParameterSchema Parameters => new()
{
@@ -29,10 +31,17 @@ public class HtmlSkill : IAgentTool
{
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title (shown in browser tab and header)" },
["body"] = new() { Type = "string", Description = "HTML body content. Use semantic tags: h2/h3 for sections, " +
["body"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] HTML body content. " +
"This is the MAIN document content — always include rich, meaningful HTML. " +
"Content depth guideline: at least 58 h2 sections, each with 2+ paragraphs of substantive prose (≥2 sentences per paragraph). " +
"Add variety: tables, lists, callouts, charts, KPIs — not just plain text. " +
"IMPORTANT: when 'numbered' is true, DO NOT prefix headings with numbers yourself (e.g. '<h2>1. 개요</h2>') — " +
"the renderer auto-numbers via CSS. Write headings as plain text: '<h2>개요</h2>'. " +
"Use semantic tags: h2/h3 for sections, " +
"div.callout-info/warning/tip/danger for callouts, span.badge-blue/green/red for badges, " +
"div.chart-bar>div.bar-item for charts, div.grid-2/3/4 for grid layouts, " +
"div.timeline>div.timeline-item for timelines, div.progress for progress bars." },
"div.timeline>div.timeline-item for timelines, div.progress for progress bars. " +
"If you want to use 'sections' array instead, pass body=\"\" (empty string)." },
["sections"] = new()
{
Type = "array",
@@ -52,7 +61,7 @@ public class HtmlSkill : IAgentTool
"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" },
["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard, seminar, seminar-toc. Default: modern" },
["accent_color"] = new() { Type = "string", Description = "Hex color string (e.g. '#2E75B6') that overrides the CSS primary/accent color. Affects buttons, headings, borders, chart bars." },
["style"] = new() { Type = "string", Description = "Optional additional CSS. Appended after mood+shared CSS." },
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" },
@@ -64,7 +73,7 @@ public class HtmlSkill : IAgentTool
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
},
},
Required = ["title"]
Required = ["title", "body"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
@@ -78,11 +87,21 @@ public class HtmlSkill : IAgentTool
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
pathEl = default; // 아래에서 title 기반으로 생성
// body와 sections 둘 다 없으면 오류
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
// body와 sections 둘 다 없으면 오류 — 모델이 재호출 시 참고할 수 있도록 상세 가이드 포함
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl)
&& bodyEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(bodyEl.SafeGetString());
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl)
&& sectionsEl.ValueKind == JsonValueKind.Array
&& sectionsEl.GetArrayLength() > 0;
if (!hasBody && !hasSections)
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
{
return ToolResult.Fail(
"필수 파라미터 누락: 'body' (HTML 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" +
"다시 호출할 때는 title 외에 반드시 body를 포함하세요. 예:\n" +
"{\"name\":\"html_create\",\"arguments\":{\"title\":\"...\",\"body\":\"<h2>개요</h2><p>...</p><h2>상세</h2><p>...</p>\",\"mood\":\"modern\"}}\n" +
"sections 배열을 사용하려면 body=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요.");
}
var title = titleEl.SafeGetString() ?? "Report";
// path가 없으면 title에서 안전한 파일명 생성
@@ -101,7 +120,10 @@ public class HtmlSkill : IAgentTool
var body = hasBody ? (bodyEl.SafeGetString() ?? "") : "";
var customStyle = args.SafeTryGetProperty("style", out var s) ? s.SafeGetString() : null;
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
var useToc = args.SafeTryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
// toc는 명시적으로 true/false를 전달하거나, 생략 시 자동 판단 (body에 h2가 3개 이상이면 true)
var hasTocArg = args.SafeTryGetProperty("toc", out var tocVal)
&& (tocVal.ValueKind == JsonValueKind.True || tocVal.ValueKind == JsonValueKind.False);
var useToc = hasTocArg && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
@@ -149,6 +171,9 @@ public class HtmlSkill : IAgentTool
body = hasBody ? body + "\n" + sectionsHtml : sectionsHtml;
}
// HTML 태그 위생화 — LLM이 생성한 깨진 태그 자동 수정
body = SanitizeHtmlTags(body);
// 섹션 번호 자동 부여 — h2, h3에 class="numbered" 추가
if (useNumbered)
body = AddNumberedClass(body);
@@ -156,15 +181,30 @@ public class HtmlSkill : IAgentTool
// h2/h3에서 id 속성 자동 부여 (TOC 앵커용)
body = EnsureHeadingIds(body);
// TOC 생성
var tocHtml = useToc ? GenerateToc(body) : "";
// toc 인자가 명시되지 않았으면 h2 개수로 자동 판단 (3개 이상 → TOC 생성)
if (!hasTocArg && !useToc)
{
var h2Count = Regex.Matches(body, @"<h2\b", RegexOptions.IgnoreCase).Count;
if (h2Count >= 3)
{
useToc = true;
Services.LogService.Debug($"[html_create] toc 자동 활성화 — h2={h2Count}개 감지");
}
}
// TOC 생성 — useNumbered면 TOC 항목도 번호 표시 (본문 CSS 카운터와 일치)
var tocHtml = useToc ? GenerateToc(body, useNumbered) : "";
// 커버 페이지 생성
var coverHtml = hasCover ? GenerateCover(coverVal, title) : "";
// 다크 테마 기본 무드 결정
var isDarkDefault = mood is "dark" or "seminar" or "seminar-toc" or "dashboard";
var defaultTheme = isDarkDefault ? "dark" : "light";
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine($"<html lang=\"ko\" data-theme=\"{defaultTheme}\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
@@ -172,6 +212,22 @@ public class HtmlSkill : IAgentTool
sb.AppendLine($"<style>{style}</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
var isSidebarToc = mood == "seminar-toc";
// 테마 전환 버튼 + 플로팅 목차 버튼
sb.AppendLine("<button class=\"ax-theme-toggle\" onclick=\"axToggleTheme()\" title=\"테마 전환\">&#127769;</button>");
if (useToc && !isSidebarToc)
sb.AppendLine("<button class=\"ax-fab-toc\" id=\"axFabToc\" onclick=\"document.querySelector('nav.toc')?.scrollIntoView({behavior:'smooth'})\" title=\"목차로 이동\">&#9776;</button>");
// seminar-toc: page-wrapper + sidebar TOC 구<><EAB5AC><EFBFBD>
if (isSidebarToc)
{
sb.AppendLine("<div class=\"page-wrapper\">");
if (useToc && !string.IsNullOrEmpty(tocHtml))
sb.AppendLine(GenerateSidebarToc(body));
}
sb.AppendLine("<div class=\"container\">");
// 커버 페이지
@@ -191,8 +247,8 @@ public class HtmlSkill : IAgentTool
// 본문을 body-content로 감싸서 좌우 여백 확보
sb.AppendLine("<div class=\"body-content\">");
// TOC
if (!string.IsNullOrEmpty(tocHtml))
// TOC (inline — seminar-toc는 사이드바로 이미 출력했으므로 스킵)
if (!string.IsNullOrEmpty(tocHtml) && !isSidebarToc)
sb.AppendLine(tocHtml);
// 본문 — table 태그에 반응형 래퍼 자동 추가
@@ -208,6 +264,14 @@ public class HtmlSkill : IAgentTool
sb.AppendLine("</div>"); // body-content
sb.AppendLine("</div>"); // container
if (isSidebarToc)
sb.AppendLine("</div>"); // page-wrapper
// 테마 전환 + 플로팅 TOC / 스크롤 스파이 스크립트
sb.AppendLine(TemplateService.ThemeToggleScript);
if (isSidebarToc && useToc)
sb.AppendLine(SidebarTocScrollSpyScript);
sb.AppendLine("</body>");
sb.AppendLine("</html>");
@@ -619,6 +683,50 @@ public class HtmlSkill : IAgentTool
catch { return "#" + hex; }
}
// ─────────────────────────────────────────────────────────────────────────
// HTML 태그 위생화 — LLM이 생성한 깨진/불일치 태그 자동 수정
// ─────────────────────────────────────────────────────────────────────────
/// <summary>외부에서도 호출 가능한 태그 위생화 래퍼.</summary>
public static string SanitizeHtmlTagsPublic(string html) => SanitizeHtmlTags(html);
/// <summary>
/// LLM이 생성한 HTML에서 흔한 태그 오류를 자동 수정합니다.
/// 1) &lt;/strong: → &lt;/strong&gt;: (닫기 '>' 누락)
/// 2) &lt;/em: → &lt;/em&gt;: (같은 패턴)
/// 3) &lt;span class="..."&gt;text&lt;/strong&gt; → &lt;span&gt;text&lt;/span&gt; (태그 불일치)
/// 4) &lt;strong class="..."&gt;text&lt;/span&gt; → &lt;strong&gt;text&lt;/strong&gt; (태그 불일치)
/// </summary>
private static string SanitizeHtmlTags(string html)
{
if (string.IsNullOrEmpty(html)) return html;
// 패턴 1: </tag: 또는 </tag; 패턴 — '>' 누락으로 닫기 태그가 깨진 경우
// 예: </strong: 한종희 → </strong>: 한종희
// </em; 내용 → </em>; 내용
html = Regex.Replace(html, @"</(strong|em|b|i|u|span|a|code|mark)([^>a-zA-Z])", "</$1>$2");
// 패턴 2: <span ...>text</strong> 또는 <span ...>text</b> → </span>으로 교정
// 닫기 태그가 열기 태그와 불일치할 때 — 열기 태그 기준으로 닫기 태그를 수정
html = Regex.Replace(html,
@"<(span|strong|em|b|i|u|a|code|mark)(\s[^>]*)?>([^<]*)</(strong|em|b|i|u|span|a|code|mark)>",
match =>
{
var openTag = match.Groups[1].Value;
var attrs = match.Groups[2].Value;
var content = match.Groups[3].Value;
var closeTag = match.Groups[4].Value;
// 열기/닫기 태그가 불일치하면 열기 태그 기준으로 닫기 교정
if (!string.Equals(openTag, closeTag, StringComparison.OrdinalIgnoreCase))
return $"<{openTag}{attrs}>{content}</{openTag}>";
return match.Value; // 일치하면 그대로
});
return html;
}
// ─────────────────────────────────────────────────────────────────────────
// print CSS
// ─────────────────────────────────────────────────────────────────────────
@@ -669,10 +777,11 @@ public class HtmlSkill : IAgentTool
});
}
/// <summary>h2, h3에 class="numbered" 추가</summary>
/// <summary>h2, h3에 class="numbered" 추가. LLM이 본문에 이미 붙여놓은 "1. " / "1-1. " 접두 번호는 제거 (CSS 카운터와 중복 방지).</summary>
private static string AddNumberedClass(string html)
{
return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
// 1) h2/h3 여는 태그에 numbered 클래스 부여
html = Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
{
var tag = match.Groups[1].Value;
var attrs = match.Groups[2].Value;
@@ -687,10 +796,20 @@ public class HtmlSkill : IAgentTool
return $"<{tag}{attrs} class=\"numbered\">";
});
// 2) h2/h3 본문 앞에 "1. " / "1-1. " / "1) " 등 기존 번호가 있으면 제거.
// CSS 카운터(h2.numbered::before { content: counter(section) '. '; })와 중복되면
// "1. 1. 기업 개요"처럼 번호가 두 번 찍힌다.
html = Regex.Replace(html,
@"(<(h[23])\b[^>]*\bclass\s*=\s*""[^""]*\bnumbered\b[^""]*""[^>]*>)\s*(\d+([.\-)]\s*\d+)*[.\-)]\s+)",
"$1",
RegexOptions.IgnoreCase);
return html;
}
/// <summary>body HTML에서 h2/h3을 파싱해 목차 HTML 생성</summary>
private static string GenerateToc(string html)
/// <summary>body HTML에서 h2/h3을 파싱해 목차 HTML 생성. numbered=true면 본문과 동일한 계층 번호(1., 1-1., ...)를 TOC 항목 앞에 표시.</summary>
private static string GenerateToc(string html, bool numbered = false)
{
var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)</\1>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
@@ -702,6 +821,9 @@ public class HtmlSkill : IAgentTool
sb.AppendLine("<h2>📋 목차</h2>");
sb.AppendLine("<ul>");
var h2Counter = 0;
var h3Counter = 0;
foreach (Match h in headings)
{
var level = h.Groups[1].Value.ToLower();
@@ -709,7 +831,24 @@ public class HtmlSkill : IAgentTool
// 태그 내부 텍스트에서 HTML 태그 제거
var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim();
var cssClass = level == "h3" ? " class=\"toc-h3\"" : "";
sb.AppendLine($"<li{cssClass}><a href=\"#{id}\">{text}</a></li>");
string prefix = "";
if (numbered)
{
if (level == "h2")
{
h2Counter++;
h3Counter = 0;
prefix = $"{h2Counter}. ";
}
else // h3
{
h3Counter++;
prefix = $"{Math.Max(1, h2Counter)}-{h3Counter}. ";
}
}
sb.AppendLine($"<li{cssClass}><a href=\"#{id}\">{prefix}{text}</a></li>");
}
sb.AppendLine("</ul>");
@@ -717,6 +856,58 @@ public class HtmlSkill : IAgentTool
return sb.ToString();
}
/// <summary>body HTML에서 h2/h3를 파싱해 고정 사이드바 TOC HTML 생성 (seminar-toc 전용)</summary>
private static string GenerateSidebarToc(string html)
{
var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)</\1>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (headings.Count == 0) return "";
var sb = new StringBuilder();
sb.AppendLine("<div class=\"toc\" id=\"toc\">");
sb.AppendLine("<h3>Table of Contents</h3>");
sb.AppendLine("<div class=\"toc-grid\">");
var sectionNum = 0;
foreach (Match h in headings)
{
var level = h.Groups[1].Value.ToLower();
var id = h.Groups[2].Value;
var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim();
if (level == "h2")
{
sectionNum++;
sb.AppendLine($"<a href=\"#{id}\"><span class=\"toc-num\">{sectionNum}</span> {Escape(text)}</a>");
}
else
{
sb.AppendLine($"<a href=\"#{id}\" class=\"toc-sub\">{Escape(text)}</a>");
}
}
sb.AppendLine("</div>");
sb.AppendLine("</div>");
return sb.ToString();
}
private const string SidebarTocScrollSpyScript = """
<script>
(function(){
var links=document.querySelectorAll('.toc a[href^="#"]');
var sections=[];
links.forEach(function(l){var id=l.getAttribute('href').slice(1);var el=document.getElementById(id);if(el)sections.push({el:el,link:l});});
function update(){
var cur=null;
for(var i=sections.length-1;i>=0;i--){if(sections[i].el.getBoundingClientRect().top<=80){cur=sections[i];break;}}
links.forEach(function(l){l.classList.remove('active');});
if(cur){cur.link.classList.add('active');var toc=document.getElementById('toc');if(toc){var lr=cur.link.getBoundingClientRect(),tr=toc.getBoundingClientRect();if(lr.top<tr.top+60||lr.bottom>tr.bottom-20)cur.link.scrollIntoView({block:'center',behavior:'smooth'});}}
}
window.addEventListener('scroll',update,{passive:true});update();
})();
</script>
""";
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
private static string GenerateCover(JsonElement cover, string fallbackTitle)
{

View File

@@ -98,13 +98,14 @@ public class AgentContext
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
"process", "build_run", "git_tool", "http_tool", "open_external", "snippet_runner",
"spawn_agent", "test_loop",
"spawn_agent", "spawn_agents", "test_loop",
};
private static readonly HashSet<string> DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase)
{
"process",
"build_run",
"spawn_agent",
"spawn_agents",
"snippet_runner",
"test_loop",
};
@@ -113,12 +114,12 @@ public class AgentContext
"file_write", "file_edit", "file_manage",
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
"todo_write", "skill_manager", "project_rule", "task_create", "task_update", "task_stop",
"team_create", "team_delete", "cron_create", "cron_delete", "zip",
"todo_write", "skill_manager", "project_rules", "task_create", "task_update", "task_stop",
"team_create", "team_delete", "cron_create", "cron_delete", "zip_tool",
};
private static readonly HashSet<string> ProcessLikeTools = new(StringComparer.OrdinalIgnoreCase)
{
"process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "git_tool",
"process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "spawn_agents", "git_tool",
};
private readonly object _permissionLock = new();
@@ -126,29 +127,31 @@ public class AgentContext
/// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; set; } = "";
/// <summary>파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny</summary>
public string Permission { get; init; } = "Default";
/// <summary>파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny.
/// 실행 중 사용자가 UI에서 권한을 바꾸면 SyncContextFromSettings를 통해 업데이트됨.</summary>
public string Permission { get; set; } = "Default";
/// <summary>도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드.</summary>
public Dictionary<string, string> ToolPermissions { get; init; } = new();
public Dictionary<string, string> ToolPermissions { get; set; } = new();
/// <summary>차단 경로 패턴 목록.</summary>
public List<string> BlockedPaths { get; init; } = new();
public List<string> BlockedPaths { get; set; } = new();
/// <summary>차단 확장자 목록.</summary>
public List<string> BlockedExtensions { get; init; } = new();
public List<string> BlockedExtensions { get; set; } = new();
/// <summary>현재 활성 탭. "Chat" | "Cowork" | "Code".</summary>
public string ActiveTab { get; init; } = "Chat";
public string ActiveTab { get; set; } = "Chat";
/// <summary>운영 모드. internal(사내) | external(사외).</summary>
public string OperationMode { get; init; } = AxCopilot.Services.OperationModePolicy.InternalMode;
/// <summary>운영 모드. internal(사내) | external(사외).
/// 실행 중 사용자가 설정에서 모드를 바꾸면 SyncContextFromSettings를 통해 업데이트됨.</summary>
public string OperationMode { get; set; } = AxCopilot.Services.OperationModePolicy.InternalMode;
/// <summary>개발자 모드: 상세 이력 표시.</summary>
public bool DevMode { get; init; }
public bool DevMode { get; set; }
/// <summary>개발자 모드: 도구 실행 전 매번 사용자 승인 대기.</summary>
public bool DevModeStepApproval { get; init; }
public bool DevModeStepApproval { get; set; }
/// <summary>권한 확인 콜백 (Ask 모드). 반환값: true=승인, false=거부.</summary>
public Func<string, string, Task<bool>>? AskPermission { get; init; }
@@ -199,6 +202,17 @@ public class AgentContext
public bool IsOutsideWorkspace(string path)
{
if (string.IsNullOrEmpty(WorkFolder)) return false;
if (string.IsNullOrWhiteSpace(path)) return false;
// ── 방어: path 형태가 아닌 식별자(도구명 등)가 잘못 전달되면 내부로 간주 ──
// DescribeToolTarget가 primary 없을 때 toolName("html_create")을 반환하는 경로의 fallback.
// 이 경우 Path.GetFullPath는 앱 CWD 기준으로 해석되어 "외부"로 오판될 위험이 있음.
var looksLikePath = path.Contains('/')
|| path.Contains('\\')
|| Path.IsPathRooted(path)
|| Path.HasExtension(path);
if (!looksLikePath) return false;
try
{
var fullPath = Path.GetFullPath(path);
@@ -239,12 +253,15 @@ public class AgentContext
public string GetEffectiveToolPermission(string toolName, string? target)
{
toolName ??= "";
var normalizedToolName = toolName.Trim();
var normalizedToolName = AgentToolCatalog.Canonicalize(toolName);
if (TryResolvePatternPermission(toolName, target, out var patternPermission))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission));
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
if (ToolPermissions.TryGetValue(normalizedToolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue(toolName, out toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue("*", out var wildcardPerm) &&
@@ -254,7 +271,7 @@ public class AgentContext
!string.IsNullOrWhiteSpace(defaultPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm));
var fallback = SensitiveTools.Contains(toolName)
var fallback = SensitiveTools.Contains(normalizedToolName)
? PermissionModeCatalog.NormalizeGlobalMode(Permission)
: PermissionModeCatalog.AcceptEdits;
return ResolveModeForTool(normalizedToolName, fallback);
@@ -320,7 +337,7 @@ public class AgentContext
if (ToolPermissions.Count == 0 || string.IsNullOrWhiteSpace(target))
return false;
var normalizedTool = toolName.Trim();
var normalizedTool = AgentToolCatalog.Canonicalize(toolName);
var normalizedTarget = target.Trim();
foreach (var kv in ToolPermissions)
@@ -385,7 +402,7 @@ public class AgentContext
var at = trimmed.IndexOf('@');
if (at > 0 && at < trimmed.Length - 1)
{
ruleTool = trimmed[..at].Trim();
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..at].Trim());
rulePattern = trimmed[(at + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
@@ -393,7 +410,7 @@ public class AgentContext
var pipe = trimmed.IndexOf('|');
if (pipe > 0 && pipe < trimmed.Length - 1)
{
ruleTool = trimmed[..pipe].Trim();
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..pipe].Trim());
rulePattern = trimmed[(pipe + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
@@ -401,7 +418,7 @@ public class AgentContext
var open = trimmed.IndexOf('(');
if (open > 0 && trimmed.EndsWith(")", StringComparison.Ordinal))
{
ruleTool = trimmed[..open].Trim();
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..open].Trim());
rulePattern = trimmed[(open + 1)..^1].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
@@ -460,9 +477,9 @@ public class AgentContext
return ApplyDangerousAutoGuard(toolName, normalizedMode);
}
private static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName);
private static bool IsWriteTool(string toolName) => WriteTools.Contains(AgentToolCatalog.Canonicalize(toolName));
private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(toolName);
private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(AgentToolCatalog.Canonicalize(toolName));
private string ApplyDangerousAutoGuard(string toolName, string permission)
{
@@ -472,7 +489,7 @@ public class AgentContext
if (PermissionModeCatalog.IsAuto(permission)
&& !PermissionModeCatalog.IsBypassPermissions(permission)
&& !PermissionModeCatalog.IsDontAsk(permission)
&& DangerousAutoTools.Contains(toolName))
&& DangerousAutoTools.Contains(AgentToolCatalog.Canonicalize(toolName)))
return PermissionModeCatalog.Default;
return permission;

View File

@@ -0,0 +1,326 @@
using static AxCopilot.Services.Agent.AgentLoopService;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 사용자 입력을 분석하여 최적 실행 프로파일을 결정하는 2단계 의도 분류기.
/// Stage 1: 키워드 기반 빠른 분류 (ClassifyTaskType + IntentDetector 통합)
/// Stage 2: (taskType, intentCategory) 조합별 ExecutionPolicyOverlay 매핑
/// Stage 3: (선택적) LLM 1-shot 분류 — confidence가 낮을 때만 발동
/// </summary>
internal sealed class IntentGateService
{
private readonly ILlmService? _llm;
/// <summary>DetectComplexTask에서 매번 재생성 방지용 정적 배열.</summary>
private static readonly string[] Conjunctions =
{
"그리고", "하고", "다음에", "이후에", "그런 다음",
" and then ", " after that ", " also ", " additionally "
};
private static readonly string[] ActionVerbs =
{
"해줘", "해 줘", "만들어", "수정해", "분석해", "작성해",
"검토해", "확인해", "추가해", "삭제해", "변경해"
};
/// <summary>입력 길이 제한 — 50KB 이상은 잘라서 처리.</summary>
private const int MaxInputLength = 50_000;
public IntentGateService(ILlmService? llm = null) => _llm = llm;
/// <summary>
/// 사용자 쿼리를 분석하여 IntentResult를 생성합니다.
/// </summary>
public Task<IntentResult> ClassifyAsync(
string userQuery, string? activeTab, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
// 안전 가드: null/과도한 길이
var safeQuery = userQuery ?? "";
if (safeQuery.Length > MaxInputLength)
safeQuery = safeQuery[..MaxInputLength];
// 한 번만 lowercase 변환 후 하위 메서드에 전달
var lowerQuery = safeQuery.ToLowerInvariant();
// ── Stage 1: 키워드 분류 ──
var taskType = ClassifyTaskTypeKeyword(safeQuery, activeTab);
var (intentCategory, intentConfidence) = IntentDetector.Detect(safeQuery);
// 종합 confidence: taskType 확정도 + IntentDetector 확신도 가중 평균
var taskTypeConfidence = ComputeTaskTypeConfidence(taskType, lowerQuery);
var combinedConfidence = Math.Min(1.0,
taskTypeConfidence * 0.6 + intentConfidence * 0.4);
// ── Stage 2: 프로파일 매핑 ──
var overlay = MapToOverlay(taskType, intentCategory, activeTab);
var scope = ClassifyScopeFromIntent(lowerQuery, activeTab, taskType, intentCategory);
// ── 복합 요청 감지 (P5 연동) ──
var (isComplex, hint) = DetectComplexTask(lowerQuery);
var result = new IntentResult(
TaskType: taskType,
IntentCategory: intentCategory,
Confidence: Math.Round(combinedConfidence, 2, MidpointRounding.AwayFromZero),
PolicyOverlay: overlay,
SuggestedScope: scope,
IsComplexTask: isComplex,
DecompositionHint: hint
);
return Task.FromResult(result);
}
// ════════════════════════════════════════════════════════════
// Stage 1: 키워드 기반 작업 유형 분류
// ════════════════════════════════════════════════════════════
/// <summary>
/// ClassifyTaskType 로직을 통합한 키워드 분류. 기존 AgentLoopService.ClassifyTaskType과 동일 로직.
/// </summary>
internal static string ClassifyTaskTypeKeyword(string? userQuery, string? activeTab)
{
var q = userQuery ?? "";
if (ContainsAny(q, "review", "리뷰", "검토", "code review", "점검"))
return "review";
if (ContainsAny(q, "bug", "fix", "error", "failure", "broken", "오류", "버그", "수정", "고쳐", "깨짐", "실패"))
return "bugfix";
if (ContainsAny(q, "refactor", "cleanup", "rename", "reorganize", "리팩터링", "정리", "개편", "구조 개선"))
return "refactor";
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& ContainsAny(q, "report", "document", "proposal", "분석서", "보고서", "문서", "제안서"))
return "docs";
if (ContainsAny(q, "feature", "implement", "add", "support", "추가", "구현", "지원", "기능"))
return "feature";
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) ? "feature" : "general";
}
/// <summary>
/// taskType 키워드 매칭 강도로 confidence를 산출합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
private static double ComputeTaskTypeConfidence(string taskType, string lowerQuery)
{
// "general"은 폴백이므로 confidence 낮음
if (string.Equals(taskType, "general", StringComparison.Ordinal)) return 0.3;
// 직접 매칭 키워드 수 세기
var hitCount = taskType switch
{
"review" => CountHits(lowerQuery, "review", "리뷰", "검토", "code review", "점검"),
"bugfix" => CountHits(lowerQuery, "bug", "fix", "error", "오류", "버그", "수정", "고쳐", "실패"),
"refactor" => CountHits(lowerQuery, "refactor", "cleanup", "리팩터링", "정리", "개편"),
"docs" => CountHits(lowerQuery, "report", "document", "보고서", "문서", "제안서"),
"feature" => CountHits(lowerQuery, "feature", "implement", "add", "추가", "구현", "기능"),
_ => 0,
};
return hitCount switch
{
>= 3 => 0.95,
2 => 0.85,
1 => 0.7,
_ => 0.5,
};
}
private static int CountHits(string lower, params string[] keywords)
{
var count = 0;
foreach (var kw in keywords)
{
if (lower.Contains(kw, StringComparison.OrdinalIgnoreCase))
count++;
}
return count;
}
// ════════════════════════════════════════════════════════════
// Stage 2: 프로파일 매핑
// ════════════════════════════════════════════════════════════
/// <summary>
/// (taskType, intentCategory) 조합에 따라 ExecutionPolicy overlay를 생성합니다.
/// </summary>
private static ExecutionPolicyOverlay? MapToOverlay(
string taskType, string intentCategory, string? activeTab)
{
return (taskType, intentCategory) switch
{
// 코드 수정 관련
("bugfix", "coding" or "general") => new(
ToolTemperatureCap: 0.2,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
("bugfix", _) => new(
ToolTemperatureCap: 0.25,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
("feature", "coding" or "general") => new(
ToolTemperatureCap: 0.3,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
("refactor", _) => new(
ToolTemperatureCap: 0.25,
ForceInitialToolCall: true,
EnableCodeQualityGates: true),
// 문서 생성
("docs", "document" or "creative" or "general") => new(
EnableDocumentVerificationGate: true,
ReduceEarlyMemoryPressure: true),
("docs", _) => new(
EnableDocumentVerificationGate: true),
// 리뷰/분석
("review", "analysis" or "coding" or "general") => new(
ToolTemperatureCap: 0.3,
EnableCodeQualityGates: true,
ForceInitialToolCall: true),
("review", _) => new(
ToolTemperatureCap: 0.3,
ForceInitialToolCall: true),
// general + 순수 대화 (Chat 탭)
("general", _) when string.Equals(activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
=> null, // Chat 탭은 도구 없음, overlay 불필요
// general + 문서 의도
("general", "document") => new(
EnableDocumentVerificationGate: true),
// general + 분석 의도
("general", "analysis") => new(
ToolTemperatureCap: 0.35,
MaxParallelReadBatch: 8),
// 기타: base policy 그대로
_ => null,
};
}
// ════════════════════════════════════════════════════════════
// 탐색 범위 결정
// ════════════════════════════════════════════════════════════
/// <summary>
/// IntentGate 결과를 기반으로 ExplorationScope를 결정합니다.
/// <paramref name="lowerQuery"/>는 이미 ToLowerInvariant 처리된 문자열입니다.
/// </summary>
private static ExplorationScope ClassifyScopeFromIntent(
string lowerQuery, string? activeTab, string taskType, string intentCategory)
{
if (string.IsNullOrWhiteSpace(lowerQuery))
return ExplorationScope.OpenEnded;
// docs 타입이면서 생성 동사가 있으면 DirectCreation
if (taskType == "docs" && !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
if (HasCreationVerb(lowerQuery))
return ExplorationScope.DirectCreation;
}
// document 인텐트 + 생성 동사 → DirectCreation
if (intentCategory == "document"
&& !string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasCreationVerb(lowerQuery))
return ExplorationScope.DirectCreation;
// RepoWide
if (ContainsAny(lowerQuery, "전체", "전반", "코드베이스 전체",
"repo-wide", "repository-wide", "전체 구조", "아키텍처", "전체 점검"))
return ExplorationScope.RepoWide;
// Localized
if (lowerQuery.Contains('.') || lowerQuery.Contains('/') || lowerQuery.Contains('\\') ||
ContainsAny(lowerQuery, "file ", "class ", "method ", "function ", "line ",
"bug", "오류", "버그", "예외"))
return ExplorationScope.Localized;
// TopicBased
if (ContainsAny(lowerQuery, "정리", "요약", "보고서", "주제", "관련", "분석"))
return ExplorationScope.TopicBased;
// 탭 기반 기본값
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
? ExplorationScope.Localized
: ExplorationScope.OpenEnded;
}
private static bool HasCreationVerb(string lower)
=> ContainsAny(lower,
"작성해", "써줘", "써 줘", "만들어", "생성해",
"만들어줘", "만들어 줘", "생성해줘", "생성해 줘",
"write", "create", "draft", "generate", "compose",
"작성하", "작성을", "생성하", "생성을",
"작성 부탁", "만들어 부탁");
// ════════════════════════════════════════════════════════════
// 복합 요청 감지 (P5 연동)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 복합 요청을 감지합니다. <paramref name="lowerQuery"/>는 이미 lowercase 변환된 문자열입니다.
/// </summary>
private static (bool IsComplex, string? Hint) DetectComplexTask(string lowerQuery)
{
if (lowerQuery.Length < 20)
return (false, null);
// 접속사/열거 패턴 감지 (클래스 수준 static readonly 배열 사용)
var conjunctionCount = 0;
foreach (var conj in Conjunctions)
{
if (lowerQuery.Contains(conj, StringComparison.Ordinal))
conjunctionCount++;
}
// 동사 열거 패턴 (클래스 수준 static readonly 배열 사용)
var verbCount = 0;
foreach (var verb in ActionVerbs)
{
var idx = 0;
while ((idx = lowerQuery.IndexOf(verb, idx, StringComparison.Ordinal)) >= 0)
{
verbCount++;
idx += verb.Length;
}
}
if (conjunctionCount >= 2 || verbCount >= 3)
{
return (true, "이 요청에 여러 독립 작업이 포함되어 있습니다. spawn_agents로 병렬 처리를 고려하세요.");
}
return (false, null);
}
// ════════════════════════════════════════════════════════════
// 공통 유틸
// ════════════════════════════════════════════════════════════
private static bool ContainsAny(string text, params string[] keywords)
{
foreach (var kw in keywords)
{
if (text.Contains(kw, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// IntentGate 분류 결과. 작업 유형, 인텐트 카테고리, 실행 정책 오버레이를 포함합니다.
/// </summary>
internal sealed record IntentResult(
/// <summary>작업 유형: review, bugfix, refactor, feature, docs, general</summary>
string TaskType,
/// <summary>인텐트 카테고리: coding, translation, analysis, creative, document, math, general</summary>
string IntentCategory,
/// <summary>분류 확신도 0.0~1.0</summary>
double Confidence,
/// <summary>기존 ExecutionPolicy 위에 덮어쓸 sparse override (null이면 base 그대로)</summary>
ExecutionPolicyOverlay? PolicyOverlay,
/// <summary>제안 탐색 범위</summary>
AgentLoopService.ExplorationScope SuggestedScope,
/// <summary>복합 요청 감지 (P5 spawn_agents 연동)</summary>
bool IsComplexTask,
/// <summary>복합 요청 분해 힌트 (P5 연동)</summary>
string? DecompositionHint
);

View File

@@ -16,9 +16,11 @@ public class MarkdownSkill : IAgentTool
public string Name => "markdown_create";
public string Description =>
"Create a Markdown (.md) document. " +
"REQUIRED: 'content' (raw markdown string). " +
"Alternative: set content=\"\" and provide 'sections' array for structured blocks. " +
"NEVER call this tool with only title — you MUST include content (or sections). " +
"Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " +
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents. " +
"Use 'content' for raw markdown (backward compatible).";
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents.";
public ToolParameterSchema Parameters => new()
{
@@ -26,7 +28,8 @@ public class MarkdownSkill : IAgentTool
{
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." },
["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." },
["content"] = new() { Type = "string", Description = "원시 마크다운 내용 (하위 호환). sections가 없을 때 사용합니다." },
["content"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] 원시 마크다운 내용. 문서의 주요 내용을 여기에 작성하세요. " +
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요." },
["sections"] = new()
{
Type = "array",
@@ -51,18 +54,28 @@ public class MarkdownSkill : IAgentTool
["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." },
["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." },
},
Required = []
Required = ["content"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 ──────────────────────────────────────────────────
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl)
&& sectionsEl.ValueKind == JsonValueKind.Array
&& sectionsEl.GetArrayLength() > 0;
var hasContent = args.SafeTryGetProperty("content", out var contentEl)
&& contentEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(contentEl.SafeGetString());
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
if (!hasSections && !hasContent)
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
{
return ToolResult.Fail(
"필수 파라미터 누락: 'content' (마크다운 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" +
"다시 호출할 때는 title 외에 반드시 content를 포함하세요. 예:\n" +
"{\"name\":\"markdown_create\",\"arguments\":{\"title\":\"...\",\"content\":\"## 개요\\n...\\n\\n## 상세\\n...\"}}\n" +
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요.");
}
// path 미제공 시 title에서 자동 생성
string path;

View File

@@ -0,0 +1,450 @@
using System.IO;
using System.Reflection;
using System.Text;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 모델 패밀리별 프롬프트 전략 어댑터.
/// 모델 이름에서 패밀리를 자동 감지하고, 패밀리별 시스템 프롬프트 변환/보강을 수행합니다.
///
/// 3단계 프롬프트 수준:
/// - "off": 변환 없음 (기존 동작)
/// - "basic": 가벼운 규칙 추가/재배치
/// - "detailed": 임베디드 리소스의 모델별 전용 프롬프트 파일 적용 (수백 줄급)
/// </summary>
internal static class ModelPromptAdapter
{
// 임베디드 리소스 캐시 (한 번 로드 후 재사용)
private static readonly Dictionary<string, string> _detailedPromptCache = new(StringComparer.OrdinalIgnoreCase);
private static bool _detailedPromptsLoaded;
private static readonly object _loadLock = new();
// ════════════════════════════════════════════════════════════
// 모델 패밀리 감지
// ════════════════════════════════════════════════════════════
/// <summary>
/// 모델명/별칭에서 패밀리를 자동 감지합니다.
/// 매칭되지 않으면 "default"를 반환합니다.
/// </summary>
public static string DetectModelFamily(string? modelName)
{
if (string.IsNullOrWhiteSpace(modelName))
return "default";
var lower = modelName.ToLowerInvariant();
if (lower.Contains("qwen")) return "qwen";
if (lower.Contains("deepseek")) return "deepseek";
if (lower.Contains("kimi") || lower.Contains("moonshot") || lower.StartsWith("k1")) return "kimi";
if (lower.Contains("gemma")) return "gemma";
if (lower.Contains("llama")) return "llama";
if (lower.Contains("mistral") || lower.Contains("mixtral")) return "mistral";
if (lower.StartsWith("yi-") || lower.Contains("/yi-")) return "yi";
if (lower.Contains("phi-") || lower.Contains("phi3") || lower.Contains("phi4")) return "phi";
if (lower.Contains("gemini")) return "gemini";
if (lower.Contains("claude")) return "claude";
return "default";
}
/// <summary>
/// 모델 패밀리에 추천되는 기본 ExecutionProfile 키를 반환합니다.
/// </summary>
public static string GetRecommendedExecutionProfile(string modelFamily)
=> modelFamily switch
{
"qwen" => "tool_call_strict",
"gemma" => "tool_call_strict",
"kimi" => "balanced",
"deepseek" => "balanced",
"llama" => "balanced",
"mistral" => "reasoning_first",
"phi" => "tool_call_strict",
"yi" => "balanced",
"gemini" => "reasoning_first",
"claude" => "reasoning_first",
_ => "balanced",
};
// ════════════════════════════════════════════════════════════
// 프롬프트 전략 적용 (3단계)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 프롬프트 수준에 따라 시스템 프롬프트를 어댑테이션합니다.
/// </summary>
/// <param name="basePrompt">원본 시스템 프롬프트</param>
/// <param name="modelFamily">감지된 모델 패밀리</param>
/// <param name="level">"off"/"basic"/"detailed"</param>
public static string AdaptSystemPrompt(string basePrompt, string modelFamily, string level = "basic")
{
if (string.Equals(level, "off", StringComparison.OrdinalIgnoreCase))
return basePrompt;
if (string.Equals(modelFamily, "default", StringComparison.Ordinal))
return basePrompt;
if (string.Equals(level, "detailed", StringComparison.OrdinalIgnoreCase))
return AdaptDetailed(basePrompt, modelFamily);
// basic (기본)
return AdaptBasic(basePrompt, modelFamily);
}
/// <summary>이전 호환: level 없이 호출 시 basic 적용.</summary>
public static string AdaptSystemPrompt(string basePrompt, string modelFamily)
=> AdaptBasic(basePrompt, modelFamily);
/// <summary>
/// 모델 패밀리별 프롬프트 예산(최대 토큰)을 반환합니다.
/// 0이면 제한 없음.
/// </summary>
public static int GetPromptBudget(string modelFamily)
=> modelFamily switch
{
"qwen" => 2000,
"gemma" => 800,
"phi" => 1000,
"kimi" => 0,
"deepseek" => 0,
_ => 0,
};
/// <summary>모델 패밀리의 한국어 라벨을 반환합니다.</summary>
public static string GetFamilyLabel(string modelFamily)
=> modelFamily switch
{
"qwen" => "Qwen",
"deepseek" => "DeepSeek",
"kimi" => "Kimi/Moonshot",
"gemma" => "Gemma",
"llama" => "Llama",
"mistral" => "Mistral/Mixtral",
"yi" => "Yi",
"phi" => "Phi",
"gemini" => "Gemini",
"claude" => "Claude",
_ => "기본",
};
// ════════════════════════════════════════════════════════════
// Basic 모드 (가벼운 규칙 추가)
// ════════════════════════════════════════════════════════════
private static string AdaptBasic(string basePrompt, string modelFamily)
{
var strategy = GetBasicStrategy(modelFamily);
return strategy.Adapt(basePrompt);
}
private static IModelPromptStrategy GetBasicStrategy(string modelFamily)
=> modelFamily switch
{
"qwen" => QwenBasicStrategy.Instance,
"deepseek" => DeepSeekBasicStrategy.Instance,
"kimi" => KimiBasicStrategy.Instance,
"gemma" => GemmaBasicStrategy.Instance,
_ => DefaultStrategy.Instance,
};
// ════════════════════════════════════════════════════════════
// Detailed 모드 (임베디드 리소스 프롬프트)
// ════════════════════════════════════════════════════════════
/// <summary>
/// 임베디드 리소스에서 모델별 상세 프롬프트를 로드하고 기본 프롬프트 앞에 삽입합니다.
/// 프롬프트 구조: [상세 모델 프롬프트] + [구분선] + [기본 프롬프트(메타정보 추출)]
/// </summary>
private static string AdaptDetailed(string basePrompt, string modelFamily)
{
var detailedPrompt = LoadDetailedPrompt(modelFamily);
if (string.IsNullOrEmpty(detailedPrompt))
{
// 상세 프롬프트가 없으면 basic으로 폴백
return AdaptBasic(basePrompt, modelFamily);
}
var sb = new StringBuilder(detailedPrompt.Length + basePrompt.Length + 200);
// 상세 모델 프롬프트 (임베디드 리소스)
sb.AppendLine(detailedPrompt);
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
// 기본 프롬프트에서 메타 정보 추출 (날짜, 작업 폴더, 도구 권한 등)
// 이 부분은 세션마다 달라지므로 반드시 포함해야 함
sb.Append(ExtractSessionContext(basePrompt));
var result = sb.ToString();
// 토큰 예산 적용
var budget = GetPromptBudget(modelFamily);
if (budget > 0)
return TruncateToTokenBudget(result, budget);
return result;
}
/// <summary>
/// 기본 프롬프트에서 세션별 동적 컨텍스트를 추출합니다.
/// (날짜, 작업 폴더, 권한, 워크스페이스 컨텍스트 등)
/// </summary>
private static string ExtractSessionContext(string basePrompt)
{
var sb = new StringBuilder();
sb.AppendLine("## Session Context");
var lines = basePrompt.Split('\n');
var inContextSection = false;
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');
var trimmed = line.Trim();
// 항상 포함할 메타 라인
if (trimmed.StartsWith("Today's date", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Current work folder", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("File permission", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Active tab", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Available Tools", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Enabled:", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Disabled:", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine(line);
continue;
}
// Workspace Context 섹션 전체 포함
if (trimmed.StartsWith("## Workspace Context", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Project Rule", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Session Learning", StringComparison.OrdinalIgnoreCase))
{
inContextSection = true;
sb.AppendLine(line);
continue;
}
// 새 섹션 시작 시 컨텍스트 섹션 종료
if (inContextSection)
{
if (trimmed.StartsWith("## ") && !trimmed.StartsWith("## Workspace")
&& !trimmed.StartsWith("## Project Rule") && !trimmed.StartsWith("## Session"))
{
inContextSection = false;
}
else
{
sb.AppendLine(line);
}
}
}
return sb.ToString();
}
/// <summary>
/// 임베디드 리소스에서 모델 패밀리별 상세 프롬프트를 로드합니다.
/// 캐시되어 있으면 캐시에서 반환합니다.
/// </summary>
internal static string? LoadDetailedPrompt(string modelFamily)
{
EnsureDetailedPromptsLoaded();
_detailedPromptCache.TryGetValue(modelFamily, out var prompt);
return prompt;
}
private static void EnsureDetailedPromptsLoaded()
{
if (_detailedPromptsLoaded) return;
lock (_loadLock)
{
if (_detailedPromptsLoaded) return;
var assembly = Assembly.GetExecutingAssembly();
var prefix = "AxCopilot.Assets.ModelPrompts.";
foreach (var name in assembly.GetManifestResourceNames())
{
if (!name.StartsWith(prefix) || !name.EndsWith(".md"))
continue;
try
{
using var stream = assembly.GetManifestResourceStream(name);
if (stream == null) continue;
using var reader = new StreamReader(stream, Encoding.UTF8);
var content = reader.ReadToEnd();
// 파일명에서 패밀리 키 추출: AxCopilot.Assets.ModelPrompts.qwen.md → "qwen"
var familyKey = name[prefix.Length..^3]; // ".md" 제거
_detailedPromptCache[familyKey] = content;
}
catch
{
// 로드 실패는 무시 — basic 폴백 사용
}
}
_detailedPromptsLoaded = true;
}
}
// ════════════════════════════════════════════════════════════
// Basic 전략 구현
// ════════════════════════════════════════════════════════════
private interface IModelPromptStrategy
{
string Adapt(string basePrompt);
}
private sealed class DefaultStrategy : IModelPromptStrategy
{
public static readonly DefaultStrategy Instance = new();
public string Adapt(string basePrompt) => basePrompt;
}
// ─── Qwen Basic ───────────────────────────────────────
private sealed class QwenBasicStrategy : IModelPromptStrategy
{
public static readonly QwenBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.AppendLine("[MUST] Start every response with a tool call. No text before tool_call.");
sb.AppendLine("[MUST] Call multiple independent tools in the same response.");
sb.AppendLine("[NEVER] Say '알겠습니다', '네', '확인했습니다' before a tool call.");
sb.AppendLine("[NEVER] Output text-only when a tool action is still needed.");
sb.AppendLine();
var bodyStart = basePrompt.IndexOf("---", StringComparison.Ordinal);
if (bodyStart >= 0)
sb.Append(basePrompt[bodyStart..]);
else
sb.Append(basePrompt);
sb.AppendLine();
sb.AppendLine("REMINDER: Your first output MUST be a tool_call, not text. Begin now.");
return TruncateToTokenBudget(sb.ToString(), 2000);
}
}
// ─── DeepSeek Basic ───────────────────────────────────
private sealed class DeepSeekBasicStrategy : IModelPromptStrategy
{
public static readonly DeepSeekBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.Append(basePrompt);
sb.AppendLine();
sb.AppendLine("## DeepSeek Execution Rules");
sb.AppendLine("- If you plan internally, keep planning under 2 sentences. Execute immediately after.");
sb.AppendLine("- Never output a plan without following it with tool calls in the same response.");
sb.AppendLine("- After editing code files, verify the build passes before making more changes.");
sb.AppendLine("- After 3+ file edits, run test_loop for regression testing.");
sb.AppendLine("- Use spawn_agent for independent subtasks that can run in parallel.");
return sb.ToString();
}
}
// ─── Kimi Basic ───────────────────────────────────────
private sealed class KimiBasicStrategy : IModelPromptStrategy
{
public static readonly KimiBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.Append(basePrompt);
sb.AppendLine();
sb.AppendLine("## Kimi Execution Rules");
sb.AppendLine("- Be concise. Maximum 3 sentences of explanation between tool calls.");
sb.AppendLine("- After every file_edit, immediately call build_run to verify.");
sb.AppendLine("- When analyzing code or documents, structure findings as:");
sb.AppendLine(" ## Finding Title");
sb.AppendLine(" - Evidence: [cite file:line]");
sb.AppendLine(" - Impact: [severity]");
sb.AppendLine(" - Recommendation: [action]");
sb.AppendLine("- For multi-section documents, use document_plan first.");
return sb.ToString();
}
}
// ─── Gemma Basic ──────────────────────────────────────
private sealed class GemmaBasicStrategy : IModelPromptStrategy
{
public static readonly GemmaBasicStrategy Instance = new();
public string Adapt(string basePrompt)
{
var sb = new StringBuilder();
sb.AppendLine("You are AX Copilot, a code assistant with tools.");
sb.AppendLine("RULES:");
sb.AppendLine("1. Always use tools. Respond ONLY with tool calls when action is needed.");
sb.AppendLine("2. One tool per response. Wait for the result before the next step.");
sb.AppendLine("3. Never guess file contents. Read first, then act.");
sb.AppendLine();
var bodyStart = basePrompt.IndexOf("---", StringComparison.Ordinal);
if (bodyStart >= 0)
{
foreach (var line in basePrompt[bodyStart..].Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Today's date", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("Current work folder", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("File permission", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("## Workspace Context", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("- Name:", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("- Build System:", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("- Primary Language:", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine(trimmed);
}
}
}
return TruncateToTokenBudget(sb.ToString(), 800);
}
}
// ════════════════════════════════════════════════════════════
// 유틸
// ════════════════════════════════════════════════════════════
/// <summary>
/// 대략적 토큰 수 기준으로 프롬프트를 자릅니다.
/// 한국어 1자 ≈ 1.5 토큰, 영어 1단어 ≈ 1.3 토큰 근사.
/// budget=0이면 자르지 않습니다.
/// </summary>
private static string TruncateToTokenBudget(string text, int budgetTokens)
{
if (budgetTokens <= 0 || string.IsNullOrEmpty(text))
return text;
var charLimit = (int)(budgetTokens * 3.5);
if (text.Length <= charLimit)
return text;
var truncated = text[..charLimit];
var lastNewline = truncated.LastIndexOf('\n');
if (lastNewline > charLimit / 2)
truncated = truncated[..lastNewline];
return truncated + "\n...(truncated for model context budget)";
}
}

View File

@@ -43,6 +43,11 @@ public class MultiReadTool : IAgentTool
Type = "boolean",
Description = "If true, include the detected encoding in each file's header (default false)",
},
["hash_anchor"] = new()
{
Type = "boolean",
Description = "If true, output each line as LINENUM#HASH| content for anchored editing. Default: use global setting.",
},
},
Required = ["paths"],
};
@@ -68,6 +73,7 @@ public class MultiReadTool : IAgentTool
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1)
var showEncoding = args.SafeTryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
var useHashAnchor = FileReadTool.ResolveHashAnchorMode(args);
// --- Validate paths array ---
if (!args.SafeTryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
@@ -125,12 +131,23 @@ public class MultiReadTool : IAgentTool
var takeCount = Math.Min(available, maxLines);
var truncated = available > maxLines;
string[]? anchors = null;
if (useHashAnchor)
anchors = HashAnchor.ComputeAnchors(allLines);
for (var i = 0; i < takeCount; i++)
{
var lineNum = startIdx + i + 1; // 1-based line number in the original file
sb.Append(lineNum);
sb.Append('\t');
sb.AppendLine(allLines[startIdx + i]);
if (useHashAnchor && anchors != null)
{
sb.AppendLine(HashAnchor.FormatLine(allLines[startIdx + i], lineNum, anchors[startIdx + i]));
}
else
{
sb.Append(lineNum);
sb.Append('\t');
sb.AppendLine(allLines[startIdx + i]);
}
}
if (truncated)

View File

@@ -11,6 +11,12 @@ internal static class PermissionModePresentationCatalog
{
public static readonly IReadOnlyList<PermissionModePresentation> Ordered = new[]
{
new PermissionModePresentation(
PermissionModeCatalog.Deny,
"\uE72E",
"읽기 전용",
"기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 요청에 따른 새 파일 생성은 가능합니다.",
"#6B7280"),
new PermissionModePresentation(
PermissionModeCatalog.Default,
"\uE8D7",
@@ -23,6 +29,12 @@ internal static class PermissionModePresentationCatalog
"편집 자동 승인",
"모든 파일 편집을 자동 승인합니다.",
"#107C10"),
new PermissionModePresentation(
PermissionModeCatalog.Plan,
"\uE769",
"계획 모드",
"파일을 읽고 분석한 뒤, 실행 전에 계획을 먼저 보여줍니다.",
"#D97706"),
new PermissionModePresentation(
PermissionModeCatalog.BypassPermissions,
"\uE814",

View File

@@ -0,0 +1,15 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 세션 내 자동 수집된 학습 항목.
/// </summary>
internal sealed record SessionLearning(
/// <summary>카테고리: build_config, code_location, project_structure, error_pattern, dependency</summary>
string Category,
/// <summary>학습 내용 텍스트</summary>
string Content,
/// <summary>추출 시점</summary>
DateTime ExtractedAt,
/// <summary>출처 도구명</summary>
string SourceTool
);

View File

@@ -0,0 +1,308 @@
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 에이전트 루프 실행 중 도구 결과에서 학습 포인트를 자동 추출하여
/// 후속 반복에 컨텍스트로 주입하는 세션 내 단기 학습 수집기.
/// </summary>
internal sealed class SessionLearningCollector
{
private readonly List<SessionLearning> _learnings = new();
private readonly object _lock = new();
private readonly int _maxLearnings;
/// <summary>도구 출력이 이 크기를 초과하면 앞부분만 사용하여 메모리 보호.</summary>
private const int MaxOutputAnalysisLength = 32_000;
public SessionLearningCollector(int maxLearnings = 10)
=> _maxLearnings = maxLearnings;
public int Count { get { lock (_lock) return _learnings.Count; } }
/// <summary>
/// 도구 실행 결과에서 학습 포인트를 추출합니다.
/// 추출 규칙에 매칭되면 자동으로 저장됩니다.
/// </summary>
public void TryExtract(string toolName, string toolOutput, bool success)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(toolOutput))
return;
// 대용량 출력 보호: 앞부분만 분석 (Split('\n') 메모리 폭발 방지)
var safeOutput = toolOutput.Length > MaxOutputAnalysisLength
? toolOutput[..MaxOutputAnalysisLength]
: toolOutput;
var tool = toolName.ToLowerInvariant();
SessionLearning? learning = null;
try
{
learning = tool switch
{
"build_run" or "test_loop" when !success
=> ExtractBuildConfig(tool, safeOutput),
"grep" or "glob" when success
=> ExtractCodeLocation(tool, safeOutput),
"file_read" when success && IsProjectMetaFile(safeOutput)
=> ExtractProjectStructure(tool, safeOutput),
"dev_env_detect" when success
=> ExtractDependency(tool, safeOutput),
_ when !success && IsFileOperationTool(tool)
=> ExtractErrorPattern(tool, safeOutput),
_ => null,
};
}
catch
{
// 추출 실패는 무시 — 학습 수집은 부수 효과
}
if (learning is null) return;
// 중복 방지: 동일 카테고리+내용이 이미 있으면 건너뜀
lock (_lock)
{
if (_learnings.Any(l => l.Category == learning.Category
&& l.Content.Equals(learning.Content, StringComparison.OrdinalIgnoreCase)))
return;
_learnings.Add(learning);
// FIFO: 초과 시 가장 오래된 항목 일괄 제거 (RemoveAt(0) 반복 대비 O(n) → O(1))
var excess = _learnings.Count - _maxLearnings;
if (excess > 0)
_learnings.RemoveRange(0, excess);
}
}
/// <summary>
/// 현재 누적 학습을 시스템 메시지 형태로 포맷합니다.
/// 학습이 없으면 null을 반환합니다.
/// </summary>
public string? BuildInjectionMessage()
{
List<SessionLearning> snapshot;
lock (_lock)
{
if (_learnings.Count == 0) return null;
snapshot = _learnings.ToList();
}
var sb = new StringBuilder();
sb.AppendLine("[System:SessionLearnings] 이 세션에서 자동 수집된 학습 사항:");
foreach (var l in snapshot)
{
sb.AppendLine($"- [{l.Category}] {l.Content}");
}
sb.AppendLine("위 내용을 참고하여 동일 실수를 반복하지 마세요.");
return sb.ToString().TrimEnd();
}
/// <summary>모든 학습 초기화.</summary>
public void Clear()
{
lock (_lock) _learnings.Clear();
}
// ════════════════════════════════════════════════════════════
// 추출 규칙
// ════════════════════════════════════════════════════════════
/// <summary>빌드/테스트 실패에서 프로젝트 설정 학습.</summary>
private static SessionLearning? ExtractBuildConfig(string tool, string output)
{
var sb = new StringBuilder();
// .NET 타겟 프레임워크 감지
var tfmMatch = Regex.Match(output, @"net\d+\.\d+(?:-windows[\d.]*)?", RegexOptions.IgnoreCase);
if (tfmMatch.Success)
sb.Append($"타겟: {tfmMatch.Value}");
// 에러 코드 추출 (CS, TS, etc.)
var errorCodes = Regex.Matches(output, @"\b(CS|TS|E)\d{4}\b");
if (errorCodes.Count > 0)
{
var codes = errorCodes.Cast<Match>().Select(m => m.Value).Distinct().Take(5);
if (sb.Length > 0) sb.Append(", ");
sb.Append($"에러: {string.Join(", ", codes)}");
}
// 빌드 시스템 감지
if (output.Contains("MSBuild", StringComparison.OrdinalIgnoreCase))
{
if (sb.Length > 0) sb.Append(", ");
sb.Append("빌드: MSBuild");
}
else if (output.Contains("npm", StringComparison.OrdinalIgnoreCase)
|| output.Contains("node", StringComparison.OrdinalIgnoreCase))
{
if (sb.Length > 0) sb.Append(", ");
sb.Append("빌드: npm/node");
}
// 주요 에러 메시지 첫 줄 (Split 대신 라인별 스캔으로 메모리 절약)
var errorLine = FindFirstMatchingLine(output,
l => l.Contains("error", StringComparison.OrdinalIgnoreCase)
&& l.Length > 10 && l.Length < 200);
if (errorLine != null)
{
if (sb.Length > 0) sb.Append(" — ");
sb.Append(Truncate(errorLine, 120));
}
return sb.Length > 0
? new("build_config", sb.ToString(), DateTime.Now, tool)
: null;
}
/// <summary>grep/glob 결과에서 코드 위치 패턴 학습.</summary>
private static SessionLearning? ExtractCodeLocation(string tool, string output)
{
// 파일 경로 추출
var paths = Regex.Matches(output, @"(?:^|\s)([\w./\\-]+\.\w{1,6})(?:\s|:|$)", RegexOptions.Multiline)
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.Where(p => p.Contains('/') || p.Contains('\\'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList();
if (paths.Count < 2) return null; // 단일 파일이면 학습 가치 낮음
// 공통 디렉토리 패턴 추출
var dirs = paths
.Select(p => string.Join("/", p.Replace('\\', '/').Split('/').SkipLast(1)))
.Where(d => !string.IsNullOrEmpty(d))
.GroupBy(d => d)
.OrderByDescending(g => g.Count())
.FirstOrDefault();
if (dirs == null) return null;
var content = $"관련 파일이 {dirs.Key}/ 에 집중 ({paths.Count}개 파일)";
return new("code_location", content, DateTime.Now, tool);
}
/// <summary>프로젝트 메타 파일 읽기에서 구조 학습.</summary>
private static SessionLearning? ExtractProjectStructure(string tool, string output)
{
var sb = new StringBuilder();
// csproj: TargetFramework, PackageReference
var tfm = Regex.Match(output, @"<TargetFramework[s]?>(.*?)</TargetFramework[s]?>", RegexOptions.IgnoreCase);
if (tfm.Success)
sb.Append($"프레임워크: {tfm.Groups[1].Value}");
var packages = Regex.Matches(output, @"<PackageReference\s+Include=""([^""]+)""", RegexOptions.IgnoreCase)
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.Take(8)
.ToList();
if (packages.Count > 0)
{
if (sb.Length > 0) sb.Append(", ");
sb.Append($"주요 패키지: {string.Join(", ", packages)}");
}
// package.json: name, dependencies
var pkgName = Regex.Match(output, @"""name""\s*:\s*""([^""]+)""");
if (pkgName.Success)
{
if (sb.Length > 0) sb.Append(", ");
sb.Append($"패키지: {pkgName.Groups[1].Value}");
}
return sb.Length > 0
? new("project_structure", Truncate(sb.ToString(), 200), DateTime.Now, tool)
: null;
}
/// <summary>런타임 감지 결과에서 의존성 학습.</summary>
private static SessionLearning? ExtractDependency(string tool, string output)
{
if (output.Length < 10) return null;
// 주요 런타임/SDK 정보만 추출 (Split 대신 라인별 스캔)
var lines = FindMatchingLines(output, 4,
l => l.Length > 5 && l.Length < 150
&& (l.Contains("SDK", StringComparison.OrdinalIgnoreCase)
|| l.Contains("runtime", StringComparison.OrdinalIgnoreCase)
|| l.Contains("version", StringComparison.OrdinalIgnoreCase)
|| l.Contains("node", StringComparison.OrdinalIgnoreCase)
|| l.Contains("python", StringComparison.OrdinalIgnoreCase)));
if (lines.Count == 0) return null;
return new("dependency", string.Join("; ", lines), DateTime.Now, tool);
}
/// <summary>파일 조작 실패에서 에러 패턴 학습.</summary>
private static SessionLearning? ExtractErrorPattern(string tool, string output)
{
if (output.Length < 10) return null;
var firstLine = FindFirstMatchingLine(output, l => l.Length > 5);
if (firstLine == null) return null;
return new("error_pattern", $"{tool}: {Truncate(firstLine, 150)}", DateTime.Now, tool);
}
// ════════════════════════════════════════════════════════════
// 유틸
// ════════════════════════════════════════════════════════════
private static bool IsProjectMetaFile(string output)
=> output.Contains("<Project", StringComparison.OrdinalIgnoreCase) // csproj
|| output.Contains("\"dependencies\"", StringComparison.OrdinalIgnoreCase) // package.json
|| output.Contains("[package]", StringComparison.OrdinalIgnoreCase) // Cargo.toml
|| output.Contains("\"name\":", StringComparison.OrdinalIgnoreCase); // package.json
private static bool IsFileOperationTool(string tool)
=> tool is "file_write" or "file_edit" or "file_read" or "file_manage";
private static string Truncate(string text, int maxLen)
=> text.Length <= maxLen ? text : text[..maxLen] + "...";
/// <summary>
/// Split('\n') 없이 라인별 스캔하여 첫 매칭 라인을 반환합니다.
/// 대용량 출력에서 전체 배열 할당을 방지합니다.
/// </summary>
private static string? FindFirstMatchingLine(string text, Func<string, bool> predicate)
{
var span = text.AsSpan();
while (span.Length > 0)
{
var newlineIdx = span.IndexOf('\n');
var lineSpan = newlineIdx >= 0 ? span[..newlineIdx] : span;
var line = lineSpan.Trim().ToString();
if (predicate(line))
return line;
if (newlineIdx < 0) break;
span = span[(newlineIdx + 1)..];
}
return null;
}
/// <summary>
/// Split('\n') 없이 라인별 스캔하여 최대 maxCount개의 매칭 라인을 반환합니다.
/// </summary>
private static List<string> FindMatchingLines(string text, int maxCount, Func<string, bool> predicate)
{
var results = new List<string>(maxCount);
var span = text.AsSpan();
while (span.Length > 0 && results.Count < maxCount)
{
var newlineIdx = span.IndexOf('\n');
var lineSpan = newlineIdx >= 0 ? span[..newlineIdx] : span;
var line = lineSpan.Trim().ToString();
if (predicate(line))
results.Add(line);
if (newlineIdx < 0) break;
span = span[(newlineIdx + 1)..];
}
return results;
}
}

View File

@@ -175,7 +175,6 @@ public static class SkillService
/// <summary>
/// paths 전면조건 스킬 활성화.
/// claw-code의 conditional skill 활성화 패턴과 동일하게
/// file path 입력이 매칭될 때만 동적으로 활성화합니다.
/// </summary>
public static string[] ActivateConditionalSkillsForPaths(IEnumerable<string> filePaths, string cwd)
@@ -704,6 +703,7 @@ public static class SkillService
if (ToolNameMap.TryGetValue(normalized, out var mapped))
normalized = mapped;
normalized = AgentToolCatalog.Canonicalize(normalized);
tools.Add(normalized);
}

View File

@@ -0,0 +1,137 @@
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// P5: 배치 서브에이전트 생성 도구.
/// 여러 서브에이전트를 한 번에 생성하고 통합 결과를 반환합니다.
/// </summary>
public class SpawnAgentsTool : IAgentTool
{
public string Name => "spawn_agents";
public string Description =>
"Create multiple sub-agents in batch for parallel research or task execution.\n" +
"Each agent runs independently with its own task and optional profile.\n" +
"Collect results later with wait_agents.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["agents"] = new ToolProperty
{
Type = "array",
Description = "List of sub-agent definitions. Each has: id (unique identifier), task (work description), profile (optional: researcher/coder/writer/reviewer/planner).",
Items = new ToolProperty
{
Type = "object",
Description = "Sub-agent definition with id, task, and optional profile."
}
},
},
Required = new() { "agents" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.SafeTryGetProperty("agents", out var agentsEl) || agentsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("agents array is required.");
var agentDefs = new List<(string Id, string Task, string? Profile)>();
foreach (var item in agentsEl.EnumerateArray())
{
var id = item.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? "" : "";
var task = item.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "" : "";
var profile = item.SafeTryGetProperty("profile", out var profEl) ? profEl.SafeGetString() : null;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(task))
return ToolResult.Fail($"Each agent must have non-empty 'id' and 'task'. Found invalid entry.");
agentDefs.Add((id, task, profile));
}
if (agentDefs.Count == 0)
return ToolResult.Fail("agents array is empty.");
// 용량 사전 검증 (빠른 실패용 — 실제 원자성은 SubAgentTool.ExecuteAsync 내부 lock에서 보장)
var app = System.Windows.Application.Current as App;
var maxAgents = app?.SettingsService?.Settings.Llm.MaxSubAgents ?? 5;
var activeTasks = SubAgentTool.ActiveTasks;
var running = activeTasks.Values.Count(x => x.CompletedAt == null);
if (running + agentDefs.Count > maxAgents)
return ToolResult.Fail(
$"Cannot spawn {agentDefs.Count} agents: {running} already running, max is {maxAgents}.");
// 중복 ID 검사
var duplicateIds = agentDefs.GroupBy(a => a.Id, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicateIds.Count > 0)
return ToolResult.Fail($"Duplicate agent ids: {string.Join(", ", duplicateIds)}");
// 기존 활성 태스크와 ID 충돌 검사
var conflictIds = agentDefs.Where(a => activeTasks.ContainsKey(a.Id)).Select(a => a.Id).ToList();
if (conflictIds.Count > 0)
return ToolResult.Fail($"Agent ids already exist: {string.Join(", ", conflictIds)}");
// 각 에이전트를 SubAgentTool을 통해 생성
var spawnTool = new SubAgentTool();
var results = new List<(string Id, bool Success, string Message)>();
var cancelled = false;
foreach (var (id, task, profile) in agentDefs)
{
// 루프 중 취소 감지 — 이미 생성된 에이전트는 유지하고 나머지만 건너뜀
if (ct.IsCancellationRequested)
{
cancelled = true;
results.Add((id, false, "Cancelled: batch spawn interrupted."));
continue;
}
// SubAgentTool.ExecuteAsync용 JsonElement 구성
var jsonObj = new Dictionary<string, object?>
{
["id"] = id,
["task"] = task,
};
if (!string.IsNullOrWhiteSpace(profile))
jsonObj["profile"] = profile;
var jsonStr = JsonSerializer.Serialize(jsonObj);
using var doc = JsonDocument.Parse(jsonStr);
var result = await spawnTool.ExecuteAsync(doc.RootElement, context, ct).ConfigureAwait(false);
results.Add((id, result.Success, result.Output ?? ""));
}
// 통합 결과 메시지
var sb = new StringBuilder();
var successCount = results.Count(r => r.Success);
var failCount = results.Count(r => !r.Success);
sb.AppendLine($"Batch spawn: {successCount} started, {failCount} failed (total: {agentDefs.Count}){(cancelled ? " [partially cancelled]" : "")}");
sb.AppendLine();
foreach (var (id, success, message) in results)
{
var status = success ? "✓" : "✗";
var profileName = agentDefs.First(a => a.Id == id).Profile ?? "researcher";
sb.AppendLine($"{status} [{id}] profile={profileName}");
if (!success)
sb.AppendLine($" Error: {message}");
}
sb.AppendLine();
sb.AppendLine("Use wait_agents to collect results when ready.");
return failCount == agentDefs.Count
? ToolResult.Fail(sb.ToString().TrimEnd())
: ToolResult.Ok(sb.ToString().TrimEnd());
}
}

View File

@@ -0,0 +1,144 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 서브에이전트 실행 프로파일. 작업 유형별로 다른 system prompt, 도구, temperature를 적용합니다.
/// </summary>
internal sealed record SubAgentProfile(
string Name,
string Description,
string SystemPromptPrefix,
string[] EnabledToolNames,
string[] DisabledToolNames,
string FilePermission,
double? TemperatureOverride
);
/// <summary>
/// 5개 빌트인 서브에이전트 프로파일 카탈로그.
/// </summary>
internal static class SubAgentProfileCatalog
{
// ── 기본 읽기 전용 도구 세트 (기존 SubAgentTool과 동일) ──
private static readonly string[] ResearcherTools =
{
"file_read", "glob", "grep", "folder_map", "document_read",
"dev_env_detect", "git_tool", "lsp_code_intel", "code_search",
"code_review", "project_rule", "skill_manager", "json_tool",
"regex_tool", "diff_tool", "base64_tool", "hash_tool",
"datetime_tool", "math_tool", "xml_tool", "multi_read",
"file_info", "document_review",
};
private static readonly string[] WriterExtraTools =
{
"html_create", "docx_create", "markdown_create", "csv_create",
"excel_create", "pptx_create", "document_plan", "file_write",
};
private static readonly string[] CoderExtraTools =
{
"file_write", "file_edit", "build_run", "process",
"test_loop", "snippet_runner",
};
private static readonly string[] ReviewerExtraTools =
{
"code_review", "document_review", "git_tool",
};
// ── 항상 비활성화할 도구 (서브에이전트 재귀 방지) ──
private static readonly string[] AlwaysDisabled =
{
"spawn_agent", "spawn_agents", "wait_agents",
"memory", "notify", "open_external", "user_ask",
"checkpoint", "diff_preview", "playbook", "http_tool",
"clipboard", "sql_tool",
};
/// <summary>
/// 프로파일 이름으로 프로파일을 반환합니다. null/미지정이면 researcher(기본).
/// </summary>
public static SubAgentProfile Get(string? profileName)
{
return (profileName?.Trim().ToLowerInvariant()) switch
{
"coder" => new SubAgentProfile(
Name: "coder",
Description: "코드 수정 가능한 서브에이전트",
SystemPromptPrefix:
"You are a coding sub-agent for AX Copilot.\n" +
"You can read, write, and edit files, and run builds and tests.\n" +
"Focus on making the minimal correct change. Verify with build/test after editing.\n" +
"Do not ask the user questions.",
EnabledToolNames: ResearcherTools.Concat(CoderExtraTools).Distinct().ToArray(),
DisabledToolNames: AlwaysDisabled,
FilePermission: "AcceptEdits",
TemperatureOverride: 0.2),
"writer" => new SubAgentProfile(
Name: "writer",
Description: "문서 생성 서브에이전트",
SystemPromptPrefix:
"You are a document creation sub-agent for AX Copilot.\n" +
"You can create documents (HTML, DOCX, Markdown, Excel, PPT) and write files.\n" +
"Focus on producing well-structured, complete documents.\n" +
"Do not ask the user questions.",
EnabledToolNames: ResearcherTools.Concat(WriterExtraTools).Distinct().ToArray(),
DisabledToolNames: AlwaysDisabled,
FilePermission: "AcceptEdits",
TemperatureOverride: 0.35),
"reviewer" => new SubAgentProfile(
Name: "reviewer",
Description: "코드 리뷰 서브에이전트",
SystemPromptPrefix:
"You are a review sub-agent for AX Copilot.\n" +
"You perform code reviews and document reviews.\n" +
"Produce structured findings with P0-P3 severity ratings.\n" +
"Focus on concrete defects, regressions, and missing tests.\n" +
"Do not ask the user questions. Do not edit files.",
EnabledToolNames: ResearcherTools.Concat(ReviewerExtraTools).Distinct().ToArray(),
DisabledToolNames: AlwaysDisabled.Concat(new[] { "file_write", "file_edit", "process" }).ToArray(),
FilePermission: "Deny",
TemperatureOverride: 0.25),
"planner" => new SubAgentProfile(
Name: "planner",
Description: "작업 분해/계획 서브에이전트",
SystemPromptPrefix:
"You are a planning sub-agent for AX Copilot.\n" +
"Decompose tasks into ordered steps, identify the minimum file set,\n" +
"and highlight the primary risk for each step.\n" +
"Do not ask the user questions. Do not edit files.",
EnabledToolNames: new[]
{
"folder_map", "glob", "grep", "file_read", "document_read",
"dev_env_detect", "lsp_code_intel", "multi_read", "file_info",
"project_rule",
},
DisabledToolNames: AlwaysDisabled.Concat(new[] { "file_write", "file_edit", "process" }).ToArray(),
FilePermission: "Deny",
TemperatureOverride: 0.3),
// researcher (기본) — 기존 SubAgentTool 동작과 완전히 동일
_ => new SubAgentProfile(
Name: "researcher",
Description: "읽기 전용 조사 서브에이전트",
SystemPromptPrefix:
"You are a focused sub-agent for AX Copilot.\n" +
"You are running a bounded, read-only investigation.\n" +
"Use tools to inspect the project, gather evidence, and produce an actionable result.\n" +
"Do not ask the user questions.\n" +
"Do not attempt file edits, command execution, notifications, or external side effects.\n" +
"Prefer direct evidence from files and tool results over speculation.\n" +
"If something is uncertain, say so briefly and identify what evidence is missing.",
EnabledToolNames: ResearcherTools,
DisabledToolNames: AlwaysDisabled,
FilePermission: "Deny",
TemperatureOverride: null),
};
}
/// <summary>프로파일 목록 (도움말/description용).</summary>
public static readonly string[] AllProfileNames = { "researcher", "coder", "writer", "reviewer", "planner" };
}

View File

@@ -35,6 +35,11 @@ public class SubAgentTool : IAgentTool
Type = "string",
Description = "A unique sub-agent identifier used by wait_agents."
},
["profile"] = new ToolProperty
{
Type = "string",
Description = "Execution profile: researcher (default, read-only), coder (can edit/build), writer (doc creation), reviewer (code review), planner (task decomposition)."
},
},
Required = new() { "task", "id" }
};
@@ -46,6 +51,7 @@ public class SubAgentTool : IAgentTool
{
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
var profileName = args.SafeTryGetProperty("profile", out var p) ? p.SafeGetString() : null;
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
return Task.FromResult(ToolResult.Fail("task and id are required."));
@@ -80,7 +86,7 @@ public class SubAgentTool : IAgentTool
{
try
{
var result = await RunSubAgentAsync(id, task, context, cts.Token).ConfigureAwait(false);
var result = await RunSubAgentAsync(id, task, context, profileName, cts.Token).ConfigureAwait(false);
subTask.Result = result;
subTask.Success = true;
NotifyStatus(new SubAgentStatusEvent
@@ -144,11 +150,15 @@ public class SubAgentTool : IAgentTool
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
}
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, CancellationToken ct)
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, string? profileName, CancellationToken ct)
{
var settings = CreateSubAgentSettings(parentContext);
var profile = SubAgentProfileCatalog.Get(profileName);
var settings = CreateSubAgentSettings(parentContext, profile);
using var llm = new LlmService(settings);
using var tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(false);
// P2: 프로파일별 temperature override
if (profile.TemperatureOverride.HasValue)
llm.PushInferenceOverride(temperature: profile.TemperatureOverride.Value);
using var tools = await CreateSubAgentRegistryAsync(settings, profile).ConfigureAwait(false);
var loop = new AgentLoopService(llm, tools, settings)
{
@@ -160,7 +170,7 @@ public class SubAgentTool : IAgentTool
new()
{
Role = "system",
Content = BuildSubAgentSystemPrompt(task, parentContext),
Content = BuildSubAgentSystemPrompt(task, parentContext, profile),
},
new()
{
@@ -189,93 +199,151 @@ public class SubAgentTool : IAgentTool
return sb.ToString().TrimEnd();
}
private static SettingsService CreateSubAgentSettings(AgentContext parentContext)
private static SettingsService CreateSubAgentSettings(AgentContext parentContext, SubAgentProfile profile)
{
var settings = new SettingsService();
settings.Load();
var llm = settings.Settings.Llm;
llm.WorkFolder = parentContext.WorkFolder;
llm.FilePermission = "Deny";
llm.FilePermission = profile.FilePermission;
llm.AgentHooks = new();
llm.ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
llm.DisabledTools = new List<string>
{
"spawn_agent",
"wait_agents",
"file_write",
"file_edit",
"process",
"build_run",
"snippet_runner",
"memory",
"notify",
"open_external",
"user_ask",
"checkpoint",
"diff_preview",
"playbook",
"http_tool",
"clipboard",
"sql_tool",
};
llm.DisabledTools = profile.DisabledToolNames.ToList();
return settings;
}
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings)
/// <summary>도구 이름 → 팩토리 매핑 (인스턴스를 필요한 것만 생성).</summary>
private static readonly Dictionary<string, Func<IAgentTool>> ToolFactories =
new(StringComparer.OrdinalIgnoreCase)
{
["file_read"] = () => new FileReadTool(),
["glob"] = () => new GlobTool(),
["grep"] = () => new GrepTool(),
["folder_map"] = () => new FolderMapTool(),
["document_read"] = () => new DocumentReaderTool(),
["dev_env_detect"] = () => new DevEnvDetectTool(),
["git_tool"] = () => new GitTool(),
["lsp_code_intel"] = () => new LspTool(),
["code_search"] = () => new CodeSearchTool(),
["code_review"] = () => new CodeReviewTool(),
["project_rule"] = () => new ProjectRuleTool(),
["skill_manager"] = () => new SkillManagerTool(),
["json_tool"] = () => new JsonTool(),
["regex_tool"] = () => new RegexTool(),
["diff_tool"] = () => new DiffTool(),
["base64_tool"] = () => new Base64Tool(),
["hash_tool"] = () => new HashTool(),
["datetime_tool"] = () => new DateTimeTool(),
["math_tool"] = () => new MathTool(),
["xml_tool"] = () => new XmlTool(),
["multi_read"] = () => new MultiReadTool(),
["file_info"] = () => new FileInfoTool(),
["document_review"] = () => new DocumentReviewTool(),
// coder 프로파일용
["file_write"] = () => new FileWriteTool(),
["file_edit"] = () => new FileEditTool(),
["build_run"] = () => new BuildRunTool(),
["process"] = () => new ProcessTool(),
["test_loop"] = () => new TestLoopTool(),
["snippet_runner"] = () => new SnippetRunnerTool(),
// writer 프로파일용
["html_create"] = () => new HtmlSkill(),
["docx_create"] = () => new DocxSkill(),
["markdown_create"] = () => new MarkdownSkill(),
["csv_create"] = () => new CsvSkill(),
["excel_create"] = () => new ExcelSkill(),
["pptx_create"] = () => new PptxSkill(),
["document_plan"] = () => new DocumentPlannerTool(),
};
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings, SubAgentProfile profile)
{
var registry = new ToolRegistry();
registry.Register(new FileReadTool());
registry.Register(new GlobTool());
registry.Register(new GrepTool());
registry.Register(new FolderMapTool());
registry.Register(new DocumentReaderTool());
registry.Register(new DevEnvDetectTool());
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new CodeSearchTool());
registry.Register(new CodeReviewTool());
registry.Register(new ProjectRuleTool());
registry.Register(new SkillManagerTool());
registry.Register(new JsonTool());
registry.Register(new RegexTool());
registry.Register(new DiffTool());
registry.Register(new Base64Tool());
registry.Register(new HashTool());
registry.Register(new DateTimeTool());
registry.Register(new MathTool());
registry.Register(new XmlTool());
registry.Register(new MultiReadTool());
registry.Register(new FileInfoTool());
registry.Register(new DocumentReviewTool());
// 필요한 도구만 인스턴스 생성 (기존: 전체 63개 생성 후 필터 → 개선: 필요한 것만 팩토리 호출)
foreach (var name in profile.EnabledToolNames)
{
if (ToolFactories.TryGetValue(name, out var factory))
registry.Register(factory());
}
await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false);
return registry;
}
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext)
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext, SubAgentProfile profile)
{
var sb = new StringBuilder();
sb.AppendLine("You are a focused sub-agent for AX Copilot.");
sb.AppendLine("You are running a bounded, read-only investigation.");
sb.AppendLine("Use tools to inspect the project, gather evidence, and produce an actionable result.");
sb.AppendLine("Do not ask the user questions.");
sb.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects.");
sb.AppendLine("Prefer direct evidence from files and tool results over speculation.");
sb.AppendLine("If something is uncertain, say so briefly and identify what evidence is missing.");
// P2: 프로파일별 시스템 프롬프트 접두사 사용
sb.AppendLine(profile.SystemPromptPrefix);
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Current work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
sb.AppendLine($"Current tab: {parentContext.ActiveTab}");
// P4: 워크스페이스 컨텍스트 자동 주입
var wsContext = WorkspaceContextGenerator.LoadContext(parentContext.WorkFolder);
if (!string.IsNullOrWhiteSpace(wsContext))
{
sb.AppendLine();
sb.AppendLine("Workspace context:");
sb.AppendLine(wsContext.Length > 2000 ? wsContext[..2000] + "\n...(truncated)" : wsContext);
}
sb.AppendLine();
sb.AppendLine("Investigation rules:");
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
// 프로파일별 작업 규칙
switch (profile.Name)
{
case "coder":
sb.AppendLine("Coding rules:");
sb.AppendLine("1. Read the relevant files first to understand existing patterns.");
sb.AppendLine("2. Make the minimal correct change — do not refactor unrelated code.");
sb.AppendLine("3. After editing, verify with build_run or test_loop.");
sb.AppendLine("4. If the build fails, fix the issue immediately.");
sb.AppendLine("5. Report what was changed and the verification result.");
break;
case "writer":
sb.AppendLine("Document creation rules:");
sb.AppendLine("1. Inspect existing documents or source files for context.");
sb.AppendLine("2. Produce well-structured, complete documents.");
sb.AppendLine("3. Use appropriate formatting for the target format.");
sb.AppendLine("4. Verify file was created successfully.");
break;
case "reviewer":
sb.AppendLine("Review rules:");
sb.AppendLine("1. Start by reading the directly relevant files.");
sb.AppendLine("2. Rate each finding P0 (critical) through P3 (minor).");
sb.AppendLine("3. Prioritize concrete defects, regressions, and missing tests.");
sb.AppendLine("4. Cite exact file paths and line ranges as evidence.");
sb.AppendLine("5. Do not suggest edits — only report findings.");
break;
case "planner":
sb.AppendLine("Planning rules:");
sb.AppendLine("1. Inspect the codebase to understand the current architecture.");
sb.AppendLine("2. Decompose the task into ordered steps with clear dependencies.");
sb.AppendLine("3. Identify the minimum file set for each step.");
sb.AppendLine("4. Highlight the primary risk for each step.");
sb.AppendLine("5. Suggest a validation strategy.");
break;
default: // researcher
sb.AppendLine("Investigation rules:");
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
break;
}
var workflowHints = BuildSubAgentWorkflowHints(task, parentContext.ActiveTab);
if (!string.IsNullOrWhiteSpace(workflowHints))
{

View File

@@ -22,8 +22,33 @@ public static class TemplateService
new("corporate", "기업 공식", "🏢", "보수적인 레이아웃, 로고 영역, 페이지 번호 — 사내 공식 보고서"),
new("magazine", "매거진", "📰", "멀티 컬럼, 큰 히어로 헤더, 인용 강조 — 뉴스레터·매거진"),
new("dashboard", "대시보드", "📈", "KPI 카드, 차트 영역, 그리드 레이아웃 — 데이터 대시보드"),
new("seminar", "세미나", "🎓", "다크/라이트 테마 전환, 그라데이션 히어로, 파이프라인 다이어그램 — 기술 세미나·발표 자료"),
new("seminar-toc", "세미나 (사이드 목차)", "📑", "좌측 고정 사이드바 목차 + 세미나 스타일 — 긴 기술 문서·레퍼런스"),
];
/// <summary>테마 전환 + 플로팅 TOC JS 스크립트 (HTML에 삽입용).</summary>
public const string ThemeToggleScript = """
<script>
function axToggleTheme(){
var h=document.documentElement,t=h.getAttribute('data-theme')==='dark'?'light':'dark';
h.setAttribute('data-theme',t);
var b=document.querySelector('.ax-theme-toggle');
if(b) b.innerHTML=t==='dark'?'&#127769;':'&#9728;&#65039;';
try{localStorage.setItem('ax-doc-theme',t);}catch(e){}
}
(function(){
try{var s=localStorage.getItem('ax-doc-theme');
if(s){document.documentElement.setAttribute('data-theme',s);
var b=document.querySelector('.ax-theme-toggle');
if(b) b.innerHTML=s==='dark'?'&#127769;':'&#9728;&#65039;';}}catch(e){}
var fab=document.getElementById('axFabToc');
if(fab){window.addEventListener('scroll',function(){
fab.classList.toggle('visible',window.scrollY>300);
});}
})();
</script>
""";
// ── 커스텀 무드 저장소 ──
private static readonly Dictionary<string, Models.CustomMoodEntry> _customMoods = new(StringComparer.OrdinalIgnoreCase);
@@ -67,6 +92,8 @@ public static class TemplateService
"corporate" => CssCorporate,
"magazine" => CssMagazine,
"dashboard" => CssDashboard,
"seminar" => CssSeminar,
"seminar-toc" => CssSeminarSidebar,
_ => CssModern,
};
return moodCss + "\n" + CssShared;
@@ -100,18 +127,21 @@ public static class TemplateService
.Build();
var bodyHtml = Markdown.ToHtml(markdown, pipeline);
var css = GetCss(moodKey);
var defaultTheme = moodKey is "dark" or "seminar" or "seminar-toc" or "dashboard" ? "dark" : "light";
return $"""
<!DOCTYPE html>
<html lang="ko">
<html lang="ko" data-theme="{defaultTheme}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>{css}</style>
</head>
<body>
<button class="ax-theme-toggle" onclick="axToggleTheme()" title="테마 전환">&#127769;</button>
<div class="container">
{bodyHtml}
</div>
{ThemeToggleScript}
</body>
</html>
""";
@@ -167,6 +197,8 @@ public static class TemplateService
"corporate" => new("#f3f4f6", "#ffffff", "#1f2937", "#6b7280", "#1e40af", "#e5e7eb"),
"magazine" => new("#f9fafb", "#ffffff", "#111827", "#6b7280", "#dc2626", "#f3f4f6"),
"dashboard" => new("#0f172a", "#1e293b", "#f1f5f9", "#94a3b8", "#3b82f6", "#334155"),
"seminar" => new("#0f1117", "#161822", "#e2e8f0", "#8892a8", "#6C8EEF", "#2a2d3e"),
"seminar-toc" => new("#0f1117", "#161822", "#e2e8f0", "#8892a8", "#6C8EEF", "#2a2d3e"),
_ => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"),
};
}
@@ -595,6 +627,240 @@ public static class TemplateService
""";
#endregion
#region Seminar
private const string CssSeminar = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root {
--accent: #6C8EEF; --accent2: #A78BFA; --green: #34D399; --amber: #FBBF24;
--red: #F87171; --cyan: #22D3EE; --transition: 0.3s ease;
}
[data-theme="dark"] {
--bg: #0f1117; --bg2: #0b0d13; --surface: #161822; --surface2: #1c1f2e; --surface3: #22253a;
--border: #2a2d3e; --text: #e2e8f0; --text-dim: #8892a8; --text-inv: #1a1a2e;
--hero-grad1: #161822; --hero-grad2: #0f1117; --hero-glow: rgba(108,142,239,0.12);
--shadow: rgba(0,0,0,0.3); --code-bg: rgba(108,142,239,0.1); --code-border: rgba(108,142,239,0.15);
}
[data-theme="light"] {
--bg: #f8fafc; --bg2: #f1f5f9; --surface: #ffffff; --surface2: #f1f5f9; --surface3: #e8ecf2;
--border: #e2e8f0; --text: #1e293b; --text-dim: #64748b; --text-inv: #ffffff;
--hero-grad1: #eef2ff; --hero-grad2: #f8fafc; --hero-glow: rgba(108,142,239,0.08);
--shadow: rgba(0,0,0,0.06); --code-bg: rgba(108,142,239,0.06); --code-border: rgba(108,142,239,0.12);
--accent: #4f6fd9; --accent2: #8b6fc0; --green: #16a34a; --amber: #d97706; --red: #dc2626; --cyan: #0891b2;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: 'Inter', 'Segoe UI', 'Noto Sans KR', sans-serif;
background: var(--bg); color: var(--text); line-height: 1.75; padding: 0;
-webkit-font-smoothing: antialiased; transition: background var(--transition), color var(--transition); }
.container { max-width: 980px; margin: 0 auto; padding: 0 28px; background: transparent; }
/* ── Hero ── */
.cover-page, .header-bar { position: relative; padding: 60px 40px 48px; text-align: center; overflow: hidden;
background: linear-gradient(180deg, var(--hero-grad1) 0%, var(--hero-grad2) 100%);
border-radius: 0; margin: 0 -28px 32px; transition: background var(--transition); }
.cover-page::before, .header-bar::before { content: ''; position: absolute; top: -120px; left: 50%;
transform: translateX(-50%); width: 600px; height: 600px;
background: radial-gradient(circle, var(--hero-glow) 0%, transparent 70%); pointer-events: none; }
.cover-page h1, .header-bar h1 { font-size: 36px; font-weight: 800; letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--text), var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 8px; color: var(--text); }
.cover-page .cover-subtitle { font-size: 16px; color: var(--text-dim); }
.cover-page .cover-meta, .meta { font-size: 12px; color: var(--text-dim); margin-bottom: 24px; }
.header-bar .meta { margin-bottom: 0; margin-top: 8px; }
/* ── Headings ── */
h1 { font-size: 28px; font-weight: 800; color: var(--text); margin-bottom: 4px; }
h2 { font-size: 20px; font-weight: 700; margin: 40px 0 16px; color: var(--text);
padding-bottom: 12px; border-bottom: 2px solid var(--border);
display: flex; align-items: center; gap: 12px; transition: border-color var(--transition); }
h3 { font-size: 16px; font-weight: 700; color: var(--accent); margin: 28px 0 10px; }
h4 { font-size: 14px; font-weight: 600; color: var(--text); margin: 16px 0 8px;
border-left: 3px solid var(--accent); padding-left: 10px; }
/* ── Text ── */
p { margin: 10px 0; font-size: 14px; }
ul, ol { margin: 8px 0 12px 20px; font-size: 14px; }
li { margin: 4px 0; }
li::marker { color: var(--text-dim); }
strong { font-weight: 700; }
em { color: var(--accent); font-style: normal; font-weight: 600; }
/* ── Code ── */
code { font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace;
background: var(--code-bg); border: 1px solid var(--code-border);
border-radius: 4px; padding: 1px 6px; font-size: 12.5px; color: var(--cyan); }
pre { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 18px; overflow-x: auto; font-size: 12.5px; margin: 14px 0; line-height: 1.55;
transition: all var(--transition); }
pre code { background: transparent; border: none; padding: 0; color: var(--text-dim); }
/* ── Cards ── */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 20px 24px; margin-bottom: 16px; transition: all var(--transition); }
.card:hover { box-shadow: 0 4px 16px var(--shadow); }
.card-header { font-size: 14px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
/* ── Tables ── */
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
th { background: var(--surface2); padding: 10px 14px; text-align: left; font-weight: 700;
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim);
border-bottom: 2px solid var(--border); transition: all var(--transition); }
td { padding: 10px 14px; border-bottom: 1px solid var(--border); vertical-align: top;
transition: all var(--transition); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); }
/* ── Badges ── */
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.5px; display: inline-block; margin: 2px 4px 2px 0; }
.badge-green { background: rgba(52,211,153,0.15); color: var(--green); }
.badge-amber, .badge-yellow { background: rgba(251,191,36,0.15); color: var(--amber); }
.badge-blue { background: rgba(108,142,239,0.15); color: var(--accent); }
.badge-purple { background: rgba(167,139,250,0.15); color: var(--accent2); }
.badge-red { background: rgba(248,113,113,0.15); color: var(--red); }
.badge-cyan { background: rgba(34,211,238,0.15); color: var(--cyan); }
/* ── Info boxes ── */
.callout { border-radius: 10px; padding: 16px 20px; margin: 16px 0; font-size: 13.5px;
display: flex; gap: 12px; align-items: flex-start; border-left: 4px solid;
transition: all var(--transition); }
.callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.callout-info { background: rgba(108,142,239,0.07); border-color: var(--accent); }
.callout-info::before { content: '💡'; }
.callout-tip { background: rgba(52,211,153,0.07); border-color: var(--green); }
.callout-tip::before { content: '✅'; }
.callout-warning { background: rgba(251,191,36,0.07); border-color: var(--amber); }
.callout-warning::before { content: ''; }
.callout-danger { background: rgba(248,113,113,0.07); border-color: var(--red); }
.callout-danger::before { content: '🚨'; }
.callout-note { background: rgba(167,139,250,0.07); border-color: var(--accent2); }
.callout-note::before { content: '📝'; }
/* ── Flow diagram ── */
.flow { display: flex; align-items: center; gap: 0; margin: 20px 0; flex-wrap: wrap; justify-content: center; }
.flow-step { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 18px; text-align: center; min-width: 120px; transition: all var(--transition); }
.flow-step .num { font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 4px; }
.flow-step .label { font-size: 13px; font-weight: 600; }
.flow-step .desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.flow-arrow { color: var(--text-dim); font-size: 18px; margin: 0 6px; flex-shrink: 0; }
/* ── Pipeline ── */
.pipeline { margin: 20px 0; }
.pipeline-stage { display: flex; align-items: flex-start; gap: 16px; padding: 14px 0;
border-left: 2px solid var(--border); margin-left: 14px; padding-left: 24px; position: relative; }
.pipeline-stage::before { content: ''; position: absolute; left: -7px; top: 18px;
width: 12px; height: 12px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--accent); transition: all var(--transition); }
.pipeline-stage:last-child { border-left-color: transparent; }
.pipeline-stage .stage-num { font-size: 10px; font-weight: 700; color: var(--accent);
text-transform: uppercase; letter-spacing: 1px; min-width: 60px; padding-top: 2px; }
.pipeline-stage .stage-body { flex: 1; }
.pipeline-stage .stage-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.pipeline-stage .stage-desc { font-size: 12.5px; color: var(--text-dim); }
/* ── Stat cards ── */
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 18px 14px; text-align: center; transition: all var(--transition); }
.stat-card .number { font-size: 30px; font-weight: 800; color: var(--accent); }
.stat-card .label { font-size: 11px; color: var(--text-dim); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
/* ── Grids ── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin: 16px 0; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0; }
/* ── Profile cards ── */
.profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0; }
.profile-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 18px 20px; transition: all var(--transition); }
.profile-card:hover { box-shadow: 0 4px 16px var(--shadow); }
.profile-card .name { font-size: 14px; font-weight: 700; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.profile-card .desc { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; }
.profile-card .props { font-size: 12px; }
.profile-card .props dt { color: var(--text-dim); float: left; width: 110px; }
.profile-card .props dd { margin-bottom: 3px; }
/* ── Arch diagram ── */
.arch-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
padding: 24px 28px; margin: 20px 0; font-family: 'Cascadia Code', monospace;
font-size: 11.5px; line-height: 1.55; overflow-x: auto; white-space: pre;
color: var(--text-dim); transition: all var(--transition); }
.arch-diagram .hl { color: var(--accent); font-weight: 600; }
.arch-diagram .g { color: var(--green); }
.arch-diagram .a { color: var(--amber); }
.arch-diagram .r { color: var(--red); }
.arch-diagram .p { color: var(--accent2); }
.arch-diagram .c { color: var(--cyan); }
/* ── Tags ── */
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
.tag { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11.5px;
font-weight: 500; background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); transition: all var(--transition); }
.tag.accent { border-color: rgba(108,142,239,0.3); color: var(--accent); background: rgba(108,142,239,0.06); }
/* ── Blockquote ── */
blockquote { border-left: 3px solid var(--accent); padding: 12px 20px; margin: 16px 0;
background: rgba(108,142,239,0.05); border-radius: 0 10px 10px 0; font-size: 14px;
color: var(--text-dim); transition: all var(--transition); }
/* ── Separator ── */
.divider, hr { border: none; height: 1px;
background: linear-gradient(90deg, transparent, var(--border), transparent); margin: 40px 0; }
/* ── Responsive ── */
@media (max-width: 720px) {
.grid-2, .profile-grid { grid-template-columns: 1fr; }
.grid-3, .grid-4 { grid-template-columns: 1fr 1fr; }
.flow { flex-direction: column; }
.flow-arrow { transform: rotate(90deg); }
.cover-page h1, .header-bar h1 { font-size: 24px; }
.arch-diagram { font-size: 10px; padding: 16px; }
}
""";
private const string CssSeminarSidebar = CssSeminar + """
/* ═══════ Sidebar TOC Layout Override ═══════ */
.page-wrapper { display: flex; min-height: 100vh; }
.container { max-width: 980px; padding: 0 28px; margin-left: 280px; margin-right: auto; }
@supports (margin-left: max(0px, 0px)) {
.container { margin-left: max(280px, calc((100vw + 280px - 980px) / 2)); }
}
.toc {
position: fixed; top: 0; left: 0; width: 280px; height: 100vh;
overflow-y: auto; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; z-index: 100; box-shadow: 2px 0 16px var(--shadow);
transition: all var(--transition);
}
.toc h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 2px;
color: var(--text-dim); margin-bottom: 14px; font-weight: 700; padding: 0 8px; }
.toc-grid { display: flex; flex-direction: column; gap: 2px; }
.toc a { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text);
padding: 6px 8px; border-radius: 6px; font-size: 12.5px; transition: all 0.15s; }
.toc a:hover { background: var(--surface2); color: var(--accent); }
.toc a.active { background: rgba(108,142,239,0.1); color: var(--accent); font-weight: 600; }
.toc-num { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
border-radius: 5px; font-size: 10px; font-weight: 700;
background: rgba(108,142,239,0.1); color: var(--accent); flex-shrink: 0; }
.toc-sub { padding-left: 28px; font-size: 11.5px; color: var(--text-dim); }
.toc-sub:hover { color: var(--accent); }
.toc-divider { height: 1px; background: var(--border); margin: 6px 0; }
.toc::-webkit-scrollbar { width: 4px; }
.toc::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
@media (max-width: 960px) {
.toc { position: static; width: 100%; height: auto; max-height: none;
border-right: none; border-bottom: 1px solid var(--border);
box-shadow: 0 4px 16px var(--shadow); }
.container { margin-left: 0; }
.page-wrapper { flex-direction: column; }
}
""";
#endregion
// ════════════════════════════════════════════════════════════════════
// 공통 CSS 컴포넌트 (모든 무드에 자동 첨부)
// ════════════════════════════════════════════════════════════════════
@@ -602,6 +868,66 @@ public static class TemplateService
#region Shared
private const string CssShared = """
/* ── 범용 다크 모드 (CSS 변수 미사용 무드용) ── */
[data-theme="dark"] body { background: #0f172a; color: #e2e8f0; }
[data-theme="dark"] .container { background: #1e293b; color: #e2e8f0; border-color: #334155; }
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: #e2e8f0; }
[data-theme="dark"] p, [data-theme="dark"] li { color: #cbd5e1; }
[data-theme="dark"] th { background: #334155; color: #e2e8f0; border-color: #475569; }
[data-theme="dark"] td { border-color: #334155; color: #cbd5e1; }
[data-theme="dark"] tr:hover td { background: #1e293b; }
[data-theme="dark"] tr:nth-child(even) td { background: #1e293b; }
[data-theme="dark"] code { background: rgba(99,102,241,0.15); color: #a5b4fc; border-color: rgba(99,102,241,0.2); }
[data-theme="dark"] pre { background: #0f172a; color: #e2e8f0; }
[data-theme="dark"] blockquote { background: rgba(99,102,241,0.08); color: #cbd5e1; }
[data-theme="dark"] .card { background: #1e293b; border-color: #334155; color: #e2e8f0; }
[data-theme="dark"] nav.toc { background: #1e293b; border-color: #334155; }
[data-theme="dark"] nav.toc a { color: #818cf8; }
[data-theme="dark"] .callout { background: rgba(99,102,241,0.08); border-color: #6366f1; color: #cbd5e1; }
[data-theme="dark"] .callout-info { background: rgba(59,130,246,0.1); border-color: #3b82f6; }
[data-theme="dark"] .callout-warning { background: rgba(245,158,11,0.1); border-color: #f59e0b; }
[data-theme="dark"] .callout-tip { background: rgba(34,197,94,0.1); border-color: #22c55e; }
[data-theme="dark"] .callout-danger { background: rgba(239,68,68,0.1); border-color: #ef4444; }
[data-theme="dark"] .highlight, [data-theme="dark"] .highlight-box { background: rgba(99,102,241,0.1); }
[data-theme="dark"] .cover-page { background: linear-gradient(135deg, #312e81 0%, #4c1d95 100%); }
[data-theme="dark"] .header-bar { border-color: #334155; }
[data-theme="dark"] .meta { color: #94a3b8; }
[data-theme="dark"] .chart-bar .bar-track { background: #334155; }
[data-theme="dark"] .divider, [data-theme="dark"] .divider-thick { border-color: #334155; }
[data-theme="dark"] hr { background: #334155; }
[data-theme="dark"] .kpi-card, [data-theme="dark"] .chart-area { background: #1e293b; border-color: #334155; color: #e2e8f0; }
[data-theme="dark"] .kpi-card .kpi-value { color: #e2e8f0; }
[data-theme="dark"] .kpi-card .kpi-label { color: #94a3b8; }
[data-theme="dark"] .badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; }
[data-theme="dark"] .badge-green { background: rgba(34,197,94,0.15); color: #4ade80; }
[data-theme="dark"] .badge-red { background: rgba(239,68,68,0.15); color: #f87171; }
[data-theme="dark"] .badge-yellow { background: rgba(245,158,11,0.15); color: #fbbf24; }
[data-theme="dark"] .badge-purple { background: rgba(139,92,246,0.15); color: #a78bfa; }
[data-theme="dark"] .badge-gray { background: rgba(107,114,128,0.15); color: #9ca3af; }
[data-theme="light"] body { } /* light 기본값 — 각 무드 CSS가 우선 */
/* ── 테마 토글 버튼 ── */
.ax-theme-toggle { position: fixed; top: 16px; right: 16px; z-index: 1000;
width: 40px; height: 40px; border-radius: 50%; border: 1px solid #d1d5db;
background: #fff; color: #374151; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; }
.ax-theme-toggle:hover { transform: scale(1.1); box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
[data-theme="dark"] .ax-theme-toggle { background: #1e293b; color: #e2e8f0; border-color: #334155; }
/* ── 플로팅 TOC 버튼 ── */
.ax-fab-toc { position: fixed; bottom: 24px; right: 24px; z-index: 999;
width: 44px; height: 44px; border-radius: 12px; border: 1px solid #d1d5db;
background: #fff; color: #4b5efc; font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px rgba(0,0,0,0.1); transition: all 0.3s ease;
opacity: 0; pointer-events: none; transform: translateY(12px); }
.ax-fab-toc.visible { opacity: 1; pointer-events: auto; transform: translateY(0); }
.ax-fab-toc:hover { background: #4b5efc; color: #fff; transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(75,94,252,0.3); }
[data-theme="dark"] .ax-fab-toc { background: #1e293b; border-color: #334155; }
[data-theme="dark"] .ax-fab-toc:hover { background: #4b5efc; }
/* ── 목차 (TOC) ── */
nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px;
padding: 20px 28px; margin: 24px 0 32px; }
@@ -718,6 +1044,7 @@ public static class TemplateService
/* ── 인쇄/PDF 최적화 ── */
@media print {
.ax-theme-toggle, .ax-fab-toc { display: none !important; }
body { background: #fff !important; padding: 0 !important; }
.container { box-shadow: none !important; border: none !important;
max-width: none !important; padding: 20px !important; }

View File

@@ -15,7 +15,7 @@ public class ToolRegistry : IDisposable
/// <summary>도구를 이름으로 찾습니다.</summary>
public IAgentTool? Get(string name) =>
_tools.TryGetValue(name, out var tool) ? tool : null;
_tools.TryGetValue(AgentToolCatalog.Canonicalize(name), out var tool) ? tool : null;
/// <summary>도구를 등록합니다.</summary>
public void Register(IAgentTool tool) => _tools[tool.Name] = tool;
@@ -60,7 +60,7 @@ public class ToolRegistry : IDisposable
public IReadOnlyCollection<IAgentTool> GetActiveTools(IEnumerable<string>? disabledNames = null)
{
if (disabledNames == null) return All;
var disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase);
var disabled = new HashSet<string>(AgentToolCatalog.CanonicalizeMany(disabledNames), StringComparer.OrdinalIgnoreCase);
if (disabled.Count == 0) return All;
return OrderToolsForExposure(_tools.Values.Where(t => !disabled.Contains(t.Name)))
.ToList()
@@ -71,7 +71,7 @@ public class ToolRegistry : IDisposable
public IReadOnlyCollection<IAgentTool> GetActiveToolsForTab(string activeTab, IEnumerable<string>? disabledNames = null)
{
var disabled = disabledNames != null
? new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase)
? new HashSet<string>(AgentToolCatalog.CanonicalizeMany(disabledNames), StringComparer.OrdinalIgnoreCase)
: null;
return OrderToolsForExposure(_tools.Values.Where(t =>
@@ -90,17 +90,7 @@ public class ToolRegistry : IDisposable
private static int GetToolExposureBucket(IAgentTool tool)
{
return tool.Name switch
{
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "document_read"
or "process" or "dev_env_detect" or "build_run" or "git_tool" or "lsp_code_intel"
or "document_plan" or "document_assemble" or "docx_create" or "html_create" or "markdown_create"
or "excel_create" or "csv_create" or "pptx_create" or "chart_create" => 0,
"folder_map" or "document_review" or "format_convert" or "tool_search" or "code_search" => 1,
"mcp_list_resources" or "mcp_read_resource" or "spawn_agent" or "wait_agents" => 2,
_ when tool.Name.StartsWith("task_", StringComparison.OrdinalIgnoreCase) => 3,
_ => 1
};
return AgentToolCatalog.GetExposureBucket(tool.Name);
}
/// <summary>도구가 해당 탭에서 사용 가능한지 확인합니다.</summary>
@@ -122,114 +112,14 @@ public class ToolRegistry : IDisposable
/// IAgentTool.TabCategory가 null인 도구는 이 맵을 참조합니다.
/// 키: 도구 이름, 값: 허용 탭 (쉼표 구분). 맵에 없으면 = 모든 탭.
/// </summary>
private static readonly Dictionary<string, string> ToolTabOverrides = new(StringComparer.OrdinalIgnoreCase)
{
// ════════════════════════════════════════════════════════════
// Chat = 순수 대화 (도구 없음). 아래 맵에 없는 공통 도구도
// Chat에선 제외하려면 여기에 "Cowork,Code"로 등록.
// ════════════════════════════════════════════════════════════
// ── 파일/검색 기본 도구: Cowork + Code ──
["file_read"] = "Cowork,Code",
["file_write"] = "Cowork,Code",
["file_edit"] = "Cowork,Code",
["glob"] = "Cowork,Code",
["grep"] = "Cowork,Code",
["process"] = "Cowork,Code",
["folder_map"] = "Cowork,Code",
["document_read"] = "Cowork,Code",
["file_manage"] = "Cowork,Code",
["file_info"] = "Cowork,Code",
["multi_read"] = "Cowork,Code",
["zip"] = "Cowork,Code",
["open_external"] = "Cowork,Code",
// ── 데이터/유틸리티: Cowork + Code ──
["json"] = "Cowork,Code",
["regex"] = "Cowork,Code",
["base64"] = "Cowork,Code",
["hash"] = "Cowork,Code",
["datetime"] = "Cowork,Code",
["math"] = "Cowork,Code",
["encoding"] = "Cowork,Code",
["http"] = "Cowork,Code",
["clipboard"] = "Cowork,Code",
["env"] = "Cowork,Code",
["notify"] = "Cowork,Code",
["user_ask"] = "Cowork,Code",
["memory"] = "Cowork,Code",
["skill_manager"] = "Cowork,Code",
["tool_search"] = "Cowork,Code",
["mcp_list_resources"] = "Cowork,Code",
["mcp_read_resource"] = "Cowork,Code",
// ── 문서 생성/처리: Cowork 전용 ──
["xlsx_create"] = "Cowork",
["excel_create"] = "Cowork",
["docx_create"] = "Cowork",
["csv_create"] = "Cowork",
["md_create"] = "Cowork",
["markdown_create"] = "Cowork",
["html_create"] = "Cowork",
["chart_create"] = "Cowork",
["batch_create"] = "Cowork",
["pptx_create"] = "Cowork",
["document_plan"] = "Cowork",
["document_assemble"] = "Cowork",
["document_review"] = "Cowork",
["format_convert"] = "Cowork",
["data_pivot"] = "Cowork",
["template_render"] = "Cowork",
["text_summarize"] = "Cowork",
["sql"] = "Cowork",
["xml"] = "Cowork",
["image_analyze"] = "Cowork",
// ── 개발 도구: Code 전용 ──
["dev_env_detect"] = "Code",
["build_run"] = "Code",
["git"] = "Code",
["lsp"] = "Code",
["code_search"] = "Code",
["code_review"] = "Code",
["project_rule"] = "Code",
["snippet_run"] = "Code",
["diff"] = "Code",
["diff_preview"] = "Code",
["sub_agent"] = "Code",
["wait_agents"] = "Code",
["test_loop"] = "Code",
["file_watch"] = "Code",
// ── 태스크/워크트리/팀: Code 전용 ──
["task_tracker"] = "Code",
["todo_write"] = "Code",
["task_create"] = "Code",
["task_get"] = "Code",
["task_list"] = "Code",
["task_update"] = "Code",
["task_stop"] = "Code",
["task_output"] = "Code",
["enter_worktree"] = "Code",
["exit_worktree"] = "Code",
["team_create"] = "Code",
["team_delete"] = "Code",
["cron_create"] = "Code",
["cron_delete"] = "Code",
["cron_list"] = "Code",
["checkpoint"] = "Code",
["suggest_actions"] = "Code",
["playbook"] = "Code",
};
/// <summary>도구의 실질 탭 카테고리를 결정합니다 (IAgentTool.TabCategory → 오버라이드 맵 순).</summary>
private static string? ResolveTabCategory(IAgentTool tool)
{
// 도구 자체에 TabCategory가 명시되어 있으면 우선
if (!string.IsNullOrEmpty(tool.TabCategory))
return tool.TabCategory;
// 오버라이드 맵에서 조회
return ToolTabOverrides.TryGetValue(tool.Name, out var cat) ? cat : null;
return AgentToolCatalog.GetTabCategory(tool.Name);
}
/// <summary>IDisposable 도구를 모두 해제합니다.</summary>
@@ -286,6 +176,7 @@ public class ToolRegistry : IDisposable
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new SubAgentTool());
registry.Register(new SpawnAgentsTool());
registry.Register(new WaitAgentsTool());
registry.Register(new CodeSearchTool());
registry.Register(new TestLoopTool());

View File

@@ -0,0 +1,409 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 작업 폴더의 구조/기술스택/컨벤션을 분석하여 .ax-context.md를 자동 생성합니다.
/// LLM 호출 없이 순수 파일 시스템 분석으로 동작합니다.
/// </summary>
internal static class WorkspaceContextGenerator
{
private const string ContextFileName = ".ax-context.md";
private const int MaxDepth = 3;
private const int MaxReadmeChars = 2000;
private const int MaxContextChars = 4000;
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
{
".git", "node_modules", "bin", "obj", ".vs", "__pycache__", ".idea",
".vscode", "dist", "build", "target", ".next", ".nuget", "packages",
".ax", "coverage", ".mypy_cache", "venv", ".venv", "env",
};
/// <summary>
/// .ax-context.md가 없으면 생성합니다. 이미 있으면 기존 내용을 반환합니다.
/// </summary>
public static async Task<string?> EnsureContextAsync(string workFolder, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder))
return null;
var path = Path.Combine(workFolder, ContextFileName);
if (File.Exists(path))
return LoadContext(workFolder);
return await GenerateAsync(workFolder, ct).ConfigureAwait(false);
}
/// <summary>
/// 강제 재생성합니다.
/// </summary>
public static async Task<string> GenerateAsync(string workFolder, CancellationToken ct = default)
{
var sb = new StringBuilder();
sb.AppendLine("# Workspace Context (auto-generated)");
sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd}");
sb.AppendLine();
// 1. 프로젝트 기본 정보
var buildSystem = DetectBuildSystem(workFolder);
var extDist = GetExtensionDistribution(workFolder, ct);
var primaryLang = extDist.FirstOrDefault();
sb.AppendLine("## Project");
var projectName = Path.GetFileName(workFolder);
sb.AppendLine($"- Name: {projectName}");
if (buildSystem != null)
sb.AppendLine($"- Build System: {buildSystem}");
if (primaryLang.Key != null)
sb.AppendLine($"- Primary Language: {GetLanguageName(primaryLang.Key)} ({primaryLang.Key}: {primaryLang.Value} files)");
// Git 정보
var gitInfo = await GetGitInfoAsync(workFolder, ct).ConfigureAwait(false);
if (gitInfo.Branch != null)
sb.AppendLine($"- Git Branch: {gitInfo.Branch}");
if (gitInfo.Remote != null)
sb.AppendLine($"- Git Remote: {gitInfo.Remote}");
sb.AppendLine();
// 2. 디렉토리 구조
sb.AppendLine("## Structure");
var tree = BuildDirectoryTree(workFolder, MaxDepth);
foreach (var line in tree.Take(30)) // 최대 30줄
sb.AppendLine(line);
sb.AppendLine();
// 3. 확장자 분포
if (extDist.Count > 0)
{
sb.AppendLine("## File Distribution");
sb.AppendLine(string.Join(", ", extDist.Take(10).Select(kv => $"{kv.Key}: {kv.Value}")));
sb.AppendLine();
}
// 4. 기존 컨텍스트 파일 감지
var contextFiles = DetectContextFiles(workFolder);
if (contextFiles.Count > 0)
{
sb.AppendLine("## Existing Context Files");
foreach (var cf in contextFiles)
sb.AppendLine($"- {cf}");
sb.AppendLine();
}
// 5. README 요약
var readmeSummary = ExtractReadmeSummary(workFolder);
if (readmeSummary != null)
{
sb.AppendLine("## README Summary");
sb.AppendLine(readmeSummary);
sb.AppendLine();
}
var content = sb.ToString().TrimEnd();
// 파일 저장
try
{
var path = Path.Combine(workFolder, ContextFileName);
await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false);
}
catch
{
// 저장 실패는 무시 — 읽기 전용 폴더 등
}
return content;
}
/// <summary>
/// 기존 .ax-context.md를 읽습니다. 없으면 null.
/// </summary>
public static string? LoadContext(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return null;
var path = Path.Combine(workFolder, ContextFileName);
if (!File.Exists(path)) return null;
try
{
var content = File.ReadAllText(path);
return content.Length > MaxContextChars
? content[..MaxContextChars] + "\n...(truncated)"
: content;
}
catch { return null; }
}
// ════════════════════════════════════════════════════════════
// 분석 로직
// ════════════════════════════════════════════════════════════
private static string? DetectBuildSystem(string folder)
{
var checks = new (string Pattern, string Name)[]
{
("*.sln", ".NET (Solution)"),
("*.csproj", ".NET"),
("package.json", "Node.js"),
("Cargo.toml", "Rust"),
("go.mod", "Go"),
("pom.xml", "Java (Maven)"),
("build.gradle", "Java (Gradle)"),
("pyproject.toml", "Python"),
("requirements.txt", "Python"),
("Makefile", "Make"),
("CMakeLists.txt", "CMake"),
};
foreach (var (pattern, name) in checks)
{
try
{
if (Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly).Length > 0)
return name;
}
catch { /* 무시 */ }
}
return null;
}
private static List<KeyValuePair<string, int>> GetExtensionDistribution(
string folder, CancellationToken ct)
{
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var file in Directory.EnumerateFiles(folder, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 5,
}))
{
ct.ThrowIfCancellationRequested();
// 경로 세그먼트 단위로 SkipDirs 검사 (정확한 디렉토리명 매칭)
var dir = Path.GetDirectoryName(file) ?? "";
if (ShouldSkipPath(dir))
continue;
var ext = Path.GetExtension(file);
if (string.IsNullOrEmpty(ext) || ext.Length > 8) continue;
counts.TryGetValue(ext, out var count);
counts[ext] = count + 1;
}
}
catch (OperationCanceledException) { throw; }
catch { /* 무시 */ }
return counts.OrderByDescending(kv => kv.Value).ToList();
}
private static List<string> BuildDirectoryTree(string root, int maxDepth)
{
var result = new List<string>();
BuildTreeRecursive(root, root, 0, maxDepth, result);
return result;
}
private static void BuildTreeRecursive(string root, string current, int depth, int maxDepth, List<string> result)
{
if (depth >= maxDepth || result.Count >= 30) return;
IEnumerable<string> dirs;
try { dirs = Directory.GetDirectories(current); }
catch { return; }
foreach (var dir in dirs.OrderBy(d => d))
{
if (result.Count >= 30) break;
var name = Path.GetFileName(dir);
if (SkipDirs.Contains(name)) continue;
// 심링크/junction 무한루프 방지: 속성 검사
try
{
var attrs = File.GetAttributes(dir);
if (attrs.HasFlag(FileAttributes.ReparsePoint))
continue;
}
catch { continue; }
// TopDirectoryOnly로 제한하여 대규모 디렉토리 탐색 방지
int fileCount;
try
{
fileCount = Directory.EnumerateFiles(dir, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 3,
}).Take(10_000).Count(); // 최대 10K개만 카운트
}
catch { fileCount = 0; }
var indent = new string(' ', depth * 2);
var relativePath = Path.GetRelativePath(root, dir).Replace('\\', '/');
result.Add($"{indent}{relativePath}/ ({fileCount}{(fileCount >= 10_000 ? "+" : "")} files)");
BuildTreeRecursive(root, dir, depth + 1, maxDepth, result);
}
}
private static string? ExtractReadmeSummary(string folder)
{
var names = new[] { "README.md", "readme.md", "README", "README.txt" };
foreach (var name in names)
{
var path = Path.Combine(folder, name);
if (!File.Exists(path)) continue;
try
{
var text = File.ReadAllText(path);
if (text.Length > MaxReadmeChars)
text = text[..MaxReadmeChars];
// 첫 번째 단락 추출 (제목 제외)
var lines = text.Split('\n');
var paragraphLines = new List<string>();
var foundContent = false;
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('#') && !foundContent) continue; // 제목 건너뛰기
if (string.IsNullOrWhiteSpace(trimmed))
{
if (foundContent && paragraphLines.Count > 0) break;
continue;
}
foundContent = true;
paragraphLines.Add(trimmed);
}
if (paragraphLines.Count > 0)
return string.Join(" ", paragraphLines);
}
catch { /* 무시 */ }
}
return null;
}
private static List<string> DetectContextFiles(string folder)
{
var files = new List<string>();
var names = new[] { "AGENTS.md", "AX.md", "CLAUDE.md", ".clinerules", ".ax-rules" };
foreach (var name in names)
{
if (File.Exists(Path.Combine(folder, name)))
files.Add(name);
}
var axDir = Path.Combine(folder, ".ax");
if (Directory.Exists(axDir))
{
try
{
var rulesDir = Path.Combine(axDir, "rules");
if (Directory.Exists(rulesDir))
{
var ruleFiles = Directory.GetFiles(rulesDir, "*.md");
if (ruleFiles.Length > 0)
files.Add($".ax/rules/ ({ruleFiles.Length} files)");
}
}
catch { /* 무시 */ }
}
return files;
}
private static async Task<(string? Branch, string? Remote)> GetGitInfoAsync(
string folder, CancellationToken ct)
{
if (!Directory.Exists(Path.Combine(folder, ".git")))
return (null, null);
string? branch = null;
string? remote = null;
try
{
branch = await RunGitAsync(folder, "rev-parse --abbrev-ref HEAD", ct).ConfigureAwait(false);
remote = await RunGitAsync(folder, "remote get-url origin", ct).ConfigureAwait(false);
}
catch { /* Git 없거나 실패 */ }
return (branch?.Trim(), remote?.Trim());
}
private static async Task<string?> RunGitAsync(string folder, string args, CancellationToken ct)
{
try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = args,
WorkingDirectory = folder,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
await process.WaitForExitAsync(ct).ConfigureAwait(false);
return process.ExitCode == 0 ? output.Trim() : null;
}
catch { return null; }
}
/// <summary>경로의 각 디렉토리 세그먼트가 SkipDirs에 해당하는지 검사.</summary>
private static bool ShouldSkipPath(string dirPath)
{
var span = dirPath.AsSpan();
while (span.Length > 0)
{
var sepIdx = span.IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var segment = sepIdx >= 0 ? span[..sepIdx] : span;
if (segment.Length > 0 && SkipDirs.Contains(segment.ToString()))
return true;
if (sepIdx < 0) break;
span = span[(sepIdx + 1)..];
}
return false;
}
private static string GetLanguageName(string ext) => ext.ToLowerInvariant() switch
{
".cs" => "C#",
".ts" or ".tsx" => "TypeScript",
".js" or ".jsx" => "JavaScript",
".py" => "Python",
".rs" => "Rust",
".go" => "Go",
".java" => "Java",
".cpp" or ".cc" or ".cxx" => "C++",
".c" => "C",
".rb" => "Ruby",
".php" => "PHP",
".swift" => "Swift",
".kt" => "Kotlin",
".xaml" => "XAML",
".html" or ".htm" => "HTML",
".css" => "CSS",
_ => ext.TrimStart('.').ToUpperInvariant(),
};
}

View File

@@ -542,7 +542,7 @@ public sealed class AppStateService : IAppStateService
var description = effective switch
{
"AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
"Deny" => "파일 읽기만 허용하고 생성/수정/삭제 차단합니다.",
"Deny" => "기존 파일 읽기만 가능하며 수정/삭제 차단되고, 새 파일 생성은 가능합니다.",
"Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.",
"BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.",
_ => "파일 작업 전마다 사용자 확인을 요청합니다.",

View File

@@ -158,11 +158,27 @@ public sealed class ChatSessionStateService
var normalizedTab = NormalizeTab(tab);
var created = new ChatConversation { Tab = normalizedTab };
// Code/Cowork 탭: 매 대화마다 폴더를 새로 선택하도록 빈 상태로 시작
// ── 버그 수정: 현재 사용자가 선택한 권한(FilePermission)을 새 대화에 승계 ──
// 이전 버그: Permission=null인 상태로 생성되면 LoadConversationSettings가
// DefaultAgentPermission(별도 필드, 기본 "Deny")으로 폴백하고
// _settings.Llm.FilePermission을 덮어써버림.
// → UI엔 "권한 건너뛰기"가 표시돼도 실제 실행 시엔 Default/Deny 모드라
// html_create/document_plan 등에서 승인 창이 뜸.
// Chat 탭은 기본 Deny가 안전하므로 승계하지 않음 (기존 동작 유지).
if (string.Equals(normalizedTab, "Code", StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalizedTab, "Cowork", StringComparison.OrdinalIgnoreCase))
{
created.WorkFolder = "";
try
{
var currentPerm = AxCopilot.Services.Agent.PermissionModeCatalog.NormalizeGlobalMode(
settings.Settings.Llm.FilePermission);
// Deny는 새 대화의 기본으로는 부적절(사용자가 의도적으로 Deny를 선택했을 가능성도 있지만
// Cowork/Code에서는 에이전트 실행 자체가 주 목적이므로 그대로 두면 혼란).
// 단, 단순 승계가 사용자 의도에 가장 부합하므로 그대로 반영.
created.Permission = currentPerm;
}
catch { /* 설정 접근 실패 시 Permission=null 유지 (fallback 경로) */ }
CurrentConversation = created;
return created;
}
@@ -271,13 +287,20 @@ public sealed class ChatSessionStateService
{
var normalizedTab = NormalizeTab(tab);
conversation.Tab = normalizedTab;
NormalizeLoadedConversation(conversation);
var normalized = NormalizeLoadedConversation(conversation);
CurrentConversation = conversation;
if (remember && !string.IsNullOrWhiteSpace(conversation.Id))
RememberConversation(normalizedTab, conversation.Id);
try { storage?.Save(conversation); } catch { }
// 대화 "선택"만으로는 대화 내용이 변하지 않음. 기존에는 무조건 Save()를 호출해
// storage.Save()가 UpdatedAt = DateTime.Now로 갱신 → 목록에서 맨 위로 올라가는 부작용.
// 실제 정규화(NormalizeLoadedConversation)가 일어난 경우에만 저장한다.
// — 다른 경로(EnsureCurrentConversation 등)도 이미 if(normalized) 패턴을 따른다.
if (normalized)
{
try { storage?.Save(conversation); } catch { }
}
return conversation;
}

View File

@@ -90,6 +90,9 @@ public class ChatStorageService : IChatStorageService
}
}
/// <summary>대화를 비동기로 로드합니다 (UI 스레드 블록 방지).</summary>
public Task<ChatConversation?> LoadAsync(string id) => Task.Run(() => Load(id));
// ── 메타 캐시 ─────────────────────────────────────────────────────────
private List<ChatConversation>? _metaCache;
private List<ChatConversation>? _metaOrderedCache;
@@ -193,6 +196,7 @@ public class ChatStorageService : IChatStorageService
return result;
}
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Lock.EnterReadLock();
try
{
@@ -202,7 +206,7 @@ public class ChatStorageService : IChatStorageService
{
var json = CryptoService.DecryptFromFile(file);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null)
if (conv != null && !string.IsNullOrWhiteSpace(conv.Id) && seenIds.Add(conv.Id))
{
var meta = new ChatConversation
{

View File

@@ -201,12 +201,42 @@ public static class CryptoService
File.WriteAllBytes(filePath, enc);
}
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화</summary>
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화. 평문 JSON 파일은 그대로 반환.</summary>
public static string DecryptFromFile(string filePath)
{
if (!File.Exists(filePath)) return "";
var enc = File.ReadAllBytes(filePath);
var plain = DecryptBytes(enc);
return Encoding.UTF8.GetString(plain);
var raw = File.ReadAllBytes(filePath);
if (raw.Length == 0) return "";
// 평문 JSON 파일 감지: UTF-8 텍스트가 '{' 또는 '[' 로 시작하면 암호화되지 않은 것으로 간주
if (raw.Length > 0 && (raw[0] == (byte)'{' || raw[0] == (byte)'[' || raw[0] == 0xEF /* BOM */))
{
try
{
return Encoding.UTF8.GetString(raw);
}
catch
{
// BOM이었지만 유효한 UTF-8이 아닌 경우 → 암호화된 데이터로 처리
}
}
try
{
var plain = DecryptBytes(raw);
return Encoding.UTF8.GetString(plain);
}
catch (System.Security.Cryptography.CryptographicException)
{
// 복호화 실패 시 평문 텍스트로 한 번 더 시도 (마이그레이션/손상 대응)
try
{
var text = Encoding.UTF8.GetString(raw);
if (text.Contains('"') && (text.TrimStart().StartsWith('{') || text.TrimStart().StartsWith('[')))
return text;
}
catch { /* 무시 */ }
throw; // 평문도 아니면 원래 예외 재전파
}
}
}

View File

@@ -7,6 +7,7 @@ public interface IChatStorageService
{
void Save(ChatConversation conversation);
ChatConversation? Load(string id);
Task<ChatConversation?> LoadAsync(string id);
List<ChatConversation> LoadAllMeta();
void InvalidateMetaCache();
void UpdateMetaCache(ChatConversation conv);

View File

@@ -528,6 +528,12 @@ public partial class LlmService
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (isIbmDeployment)
{
IbmDiagInfo($"[IBM진단] ToolUse.Send: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
IbmDiagDebug($"[IBM진단] ToolUse.Send 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "" : json)}");
}
// Raw 요청 로깅 (상세 로그 활성 시)
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
@@ -543,6 +549,8 @@ public partial class LlmService
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment)
IbmDiagError($"[IBM진단] ToolUse.Send API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (forceToolCall && (int)resp.StatusCode == 400)
@@ -596,6 +604,23 @@ public partial class LlmService
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
// 패턴 4: 파이프-래핑 커스텀 포맷 (FastAPI로 호스팅된 Gemma 계열, 일부 IBM/Kimi/GLM 배포에서 leak)
// 예: <|tool_call>call;document_read{path:<|"|>전략보고서.html<|"|>}<tool_call|>
// - 앞 여는 태그 `<|tool_call>` / 닫는 태그 `<tool_call|>` 혹은 `</tool_call|>`
// - 본문은 `call;NAME{args}` 또는 `NAME{args}` 형태
// - args 내부의 `<|"|>` 는 따옴표로 디코딩, 비인용 키는 따옴표 부여
private static readonly System.Text.RegularExpressions.Regex ToolCallPipeWrappedRegex = new(
@"<\|\s*tool_call\s*\|?>\s*(?:call\s*;\s*)?(\w+)\s*(\{[\s\S]*?\})\s*<\s*/?\s*tool_call\s*\|\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex PipeQuoteDecodeRegex = new(
@"<\|\s*""\s*\|>",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex UnquotedJsonKeyRegex = new(
@"(?<=[\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:",
System.Text.RegularExpressions.RegexOptions.Compiled);
internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
@@ -628,9 +653,39 @@ public partial class LlmService
}
}
// 패턴 4: 파이프-래핑 커스텀 포맷 (<|tool_call>call;NAME{args}<tool_call|>)
if (results.Count == 0)
{
foreach (System.Text.RegularExpressions.Match m in ToolCallPipeWrappedRegex.Matches(text))
{
var name = m.Groups[1].Value;
var rawArgs = m.Groups[2].Value;
// `<|"|>` → `"` 디코딩
var decoded = PipeQuoteDecodeRegex.Replace(rawArgs, "\"");
// 비인용 키를 JSON 키로 변환 ({path:"x"} → {"path":"x"})
var normalized = UnquotedJsonKeyRegex.Replace(decoded, "\"$1\":");
var block = TryParseToolCallJsonWithName(name, normalized);
if (block != null) results.Add(block);
}
}
return results;
}
/// <summary>
/// 텍스트에서 파싱된 tool_call 태그(4가지 형식 전부)를 제거합니다.
/// 폴백 파싱으로 도구 호출이 추출된 경우, 사용자 화면에 원본 토큰이 남지 않도록 최종 표시 텍스트를 정화.
/// </summary>
internal static string StripToolCallTokens(string text)
{
if (string.IsNullOrEmpty(text)) return text;
text = ToolCallTagRegex.Replace(text, "");
text = ToolCallFunctionRegex.Replace(text, "");
text = ToolCallJsonRegex.Replace(text, "");
text = ToolCallPipeWrappedRegex.Replace(text, "");
return text.Trim();
}
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
private static ContentBlock? TryParseToolCallJson(string json)
{
@@ -1049,8 +1104,11 @@ public partial class LlmService
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송
// 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// tool_choice: OpenAI 표준 필드만 전송.
// 이전에는 `tool_choice` + `tool_choice_option` 둘 다 보내 구버전 호환을 시도했지만,
// 최신 IBM vLLM 배포는 다음 오류로 요청을 거부합니다:
// "400 Json document validation error: tool_choice_option should not be defined if a value is given for ToolChoice"
// 구버전 배포(tool_choice_option 전용)는 상위 ToolCallNotSupportedException 폴백이 처리함.
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
if (forceToolCall && useToolChoice)
{
@@ -1059,7 +1117,6 @@ public partial class LlmService
messages = msgs,
tools = toolDefs,
tool_choice = "required",
tool_choice_option = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
@@ -1239,8 +1296,19 @@ public partial class LlmService
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
}
// 사용자 화면에 원본 tool_call 토큰이 남지 않도록 텍스트 정화
var cleanedText = StripToolCallTokens(textBlock.Text);
result.Remove(textBlock);
if (!string.IsNullOrWhiteSpace(cleanedText))
result.Add(new ContentBlock { Type = "text", Text = cleanedText });
result.AddRange(extracted);
LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)");
var toolNames = string.Join(", ", extracted.Select(e => e.ToolName));
IbmDiagInfo($"[IBM진단] 텍스트 폴백에서 도구 호출 {extracted.Count}건 추출: [{toolNames}]");
}
else if (usesIbmDeploymentApi)
{
var preview = textBlock.Text.Length > 300 ? textBlock.Text[..300] + "…" : textBlock.Text;
IbmDiagError($"[IBM진단] 응답에 tool_calls 없음, 텍스트 폴백 파싱도 실패. 응답 텍스트: {preview}");
}
}
}
@@ -1275,6 +1343,11 @@ public partial class LlmService
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (isIbmDeployment)
{
IbmDiagInfo($"[IBM진단] ToolUse.Stream: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
IbmDiagDebug($"[IBM진단] ToolUse.Stream 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "" : json)}");
}
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
@@ -1287,6 +1360,8 @@ public partial class LlmService
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment)
IbmDiagError($"[IBM진단] ToolUse.Stream API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
if (forceToolCall && (int)resp.StatusCode == 400)
{
LogService.Warn(isIbmDeployment
@@ -1329,11 +1404,18 @@ public partial class LlmService
{
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream: ContentType={contentType}");
if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase)
&& !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase))
{
// 비-SSE 전체 JSON 응답 (Ollama stream:false 등)
var rawJson = await resp.Content.ReadAsStringAsync(ct);
if (usesIbmDeploymentApi)
{
var preview = rawJson.Length > 500 ? rawJson[..500] + "…" : rawJson;
IbmDiagInfo($"[IBM진단] ToolUse 비-SSE 응답(전체 JSON): len={rawJson.Length}자\n 미리보기: {preview}");
}
var respJson = ExtractJsonFromSseIfNeeded(rawJson);
var trimmed = respJson.TrimStart();
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
@@ -1362,6 +1444,7 @@ public partial class LlmService
var firstChunkReceived = false;
var toolAccumulators = new Dictionary<int, ToolCallAccumulator>();
var lastIbmGeneratedText = "";
var ibmToolChunkCount = 0;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
@@ -1380,12 +1463,41 @@ public partial class LlmService
firstChunkReceived = true;
var data = line["data: ".Length..].Trim();
if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase))
{
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream 완료: 총 {ibmToolChunkCount}개 청크, toolAccumulators={toolAccumulators.Count}개");
break;
}
using var doc = JsonDocument.Parse(data);
JsonDocument doc;
try
{
doc = JsonDocument.Parse(data);
}
catch (JsonException jex)
{
if (usesIbmDeploymentApi)
{
var preview = data.Length > 500 ? data[..500] + "…" : data;
IbmDiagError($"[IBM진단] ToolUse.ParseStream JSON 파싱 실패: {jex.Message}\n 원본: {preview}");
}
continue;
}
using (doc)
{
var root = doc.RootElement;
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi)
{
ibmToolChunkCount++;
if (ibmToolChunkCount <= 3 || ibmToolChunkCount % 50 == 0)
{
var preview = data.Length > 300 ? data[..300] + "…" : data;
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream chunk#{ibmToolChunkCount}: {preview}");
}
}
if (usesIbmDeploymentApi &&
root.SafeTryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
@@ -1393,6 +1505,7 @@ public partial class LlmService
var detail = root.SafeTryGetProperty("message", out var msgEl)
? msgEl.SafeGetString()
: "IBM vLLM 도구 호출 응답 오류";
IbmDiagError($"[IBM진단] ToolUse.ParseStream 서버 오류: {detail}");
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
}
@@ -1497,13 +1610,25 @@ public partial class LlmService
toolAccumulators[index] = acc;
}
// IBM vLLM은 첫 청크 이후 후속 delta에서 id/name을 ""(빈 문자열)로 다시 보내는 경우가 있음.
// 빈 값으로 덮어쓰면 누적된 name/id가 사라져 TryCreateCompletedToolCallAsync의
// IsNullOrWhiteSpace(acc.Name) 체크에 걸려 도구 호출이 방출되지 않는다.
// → 비-공백 값일 때만 갱신한다.
if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.SafeGetString() ?? acc.Id;
{
var idStr = idEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(idStr))
acc.Id = idStr;
}
if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
{
if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.SafeGetString() ?? acc.Name;
{
var nameStr = nameEl.SafeGetString();
if (!string.IsNullOrWhiteSpace(nameStr))
acc.Name = nameStr;
}
if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl))
{
@@ -1539,6 +1664,7 @@ public partial class LlmService
}
}
}
} // using (doc)
}
foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index))

View File

@@ -26,6 +26,25 @@ public partial class LlmService : ILlmService
private string? _systemPrompt;
private const int MaxRetries = 2;
/// <summary>IBM+Qwen 진단 로그 활성 여부 (EnableIbmDiagnosticLog 설정 연동).</summary>
private bool IsIbmDiagEnabled => _settings.Settings.Llm.EnableIbmDiagnosticLog;
/// <summary>IBM 진단 전용 Debug 로그. EnableIbmDiagnosticLog=true 일 때만 출력.</summary>
private void IbmDiagDebug(string msg)
{
if (IsIbmDiagEnabled) LogService.Info($"[IBM진단:DBG] {msg}");
}
/// <summary>IBM 진단 전용 Info 로그. EnableIbmDiagnosticLog=true 일 때만 출력.</summary>
private void IbmDiagInfo(string msg)
{
if (IsIbmDiagEnabled) LogService.Info(msg);
}
/// <summary>IBM 진단 전용 Error 로그. 설정 무관하게 항상 출력 (에러는 항상 기록).</summary>
private static void IbmDiagError(string msg) => LogService.Error(msg);
// 첫 청크: 모델이 컨텍스트를 처리하는 시간 (대용량 컨텍스트에서 3분까지 허용)
private static readonly TimeSpan FirstChunkTimeout = TimeSpan.FromSeconds(180);
// 이후 청크: 스트리밍이 시작된 후 청크 간 최대 간격
@@ -357,9 +376,11 @@ public partial class LlmService : ILlmService
return false;
var normalizedEndpoint = (endpoint ?? "").Trim().ToLowerInvariant();
return normalizedEndpoint.Contains("/ml/") ||
var result = normalizedEndpoint.Contains("/ml/") ||
normalizedEndpoint.Contains("/deployments/") ||
normalizedEndpoint.Contains("/text/chat");
LogService.Debug($"[IBM진단] UsesIbmDeploymentChatApi: service={service}, authType={authType}, endpoint={endpoint?.Length ?? 0}자, result={result}");
return result;
}
private string BuildIbmDeploymentChatUrl(string endpoint, bool stream)
@@ -369,14 +390,18 @@ public partial class LlmService : ILlmService
throw new InvalidOperationException("IBM 배포형 vLLM 엔드포인트가 비어 있습니다.");
var normalized = trimmed.ToLowerInvariant();
string url;
if (normalized.Contains("/text/chat_stream"))
return stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
if (normalized.Contains("/text/chat"))
return stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
if (normalized.Contains("/deployments/"))
return trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
url = stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
else if (normalized.Contains("/text/chat"))
url = stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
else if (normalized.Contains("/deployments/"))
url = trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
else
url = trimmed;
return trimmed;
IbmDiagDebug($"[IBM진단] BuildUrl: stream={stream}, url={url}");
return url;
}
private object BuildIbmDeploymentBody(List<ChatMessage> messages)
@@ -384,6 +409,7 @@ public partial class LlmService : ILlmService
var msgs = new List<object>();
if (!string.IsNullOrWhiteSpace(_systemPrompt))
msgs.Add(new { role = "system", content = _systemPrompt });
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody: messages={messages.Count}건, systemPrompt={(_systemPrompt?.Length ?? 0)}자");
foreach (var m in messages)
{
@@ -440,13 +466,16 @@ public partial class LlmService : ILlmService
});
}
var temperature = ResolveTemperature();
var maxTokens = ResolveOpenAiCompatibleMaxTokens();
IbmDiagDebug($"[IBM진단] BuildIbmDeploymentBody 완료: finalMessages={msgs.Count}건, temp={temperature}, maxTokens={maxTokens}");
return new
{
messages = msgs,
parameters = new
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
temperature,
max_new_tokens = maxTokens
},
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
chat_template_kwargs = new { enable_thinking = false },
@@ -509,11 +538,21 @@ public partial class LlmService : ILlmService
if (registered != null &&
registered.AuthType.Equals("ibm_iam", StringComparison.OrdinalIgnoreCase))
{
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
: GetDefaultApiKey(llm, activeService);
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
return token;
IbmDiagDebug($"[IBM진단] IBM IAM 인증 시도: model={modelName}, hasApiKey={!string.IsNullOrWhiteSpace(registered.ApiKey)}");
try
{
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
: GetDefaultApiKey(llm, activeService);
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
IbmDiagDebug($"[IBM진단] IBM IAM 토큰 발급 성공: tokenLen={token?.Length ?? 0}");
return token;
}
catch (Exception ex)
{
IbmDiagError($"[IBM진단] IBM IAM 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
}
// CP4D 인증 방식인 경우
@@ -523,10 +562,20 @@ public partial class LlmService : ILlmService
registered.AuthType.Equals("cp4d_api_key", StringComparison.OrdinalIgnoreCase)) &&
!string.IsNullOrWhiteSpace(registered.Cp4dUrl))
{
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
var token = await Cp4dTokenService.GetTokenAsync(
registered.Cp4dUrl, registered.Cp4dUsername, password, ct);
return token;
IbmDiagDebug($"[IBM진단] CP4D 인증 시도: authType={registered.AuthType}, cp4dUrl={registered.Cp4dUrl}, user={registered.Cp4dUsername}");
try
{
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
var token = await Cp4dTokenService.GetTokenAsync(
registered.Cp4dUrl, registered.Cp4dUsername, password, ct);
IbmDiagDebug($"[IBM진단] CP4D 토큰 발급 성공: tokenLen={token?.Length ?? 0}");
return token;
}
catch (Exception ex)
{
IbmDiagError($"[IBM진단] CP4D 토큰 발급 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
}
// 기본 Bearer 인증 — 기존 API 키 반환
@@ -802,15 +851,38 @@ public partial class LlmService : ILlmService
: ep.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
if (usesIbmDeploymentApi)
IbmDiagInfo($"[IBM진단] SendOpenAi(비스트리밍): url={url}, bodyLen={json.Length}자, messages={messages.Count}건");
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
HttpResponseMessage resp;
try
{
resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
}
catch (Exception ex)
{
if (usesIbmDeploymentApi)
IbmDiagError($"[IBM진단] SendOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
using (resp)
{
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (usesIbmDeploymentApi)
{
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "(null)";
var preview = respBody.Length > 500 ? respBody[..500] + "…" : respBody;
IbmDiagInfo($"[IBM진단] SendOpenAi 응답: HTTP {(int)resp.StatusCode}, ContentType={contentType}, bodyLen={respBody.Length}자");
IbmDiagDebug($"[IBM진단] SendOpenAi 응답본문: {preview}");
}
// IBM vLLM이 stream:false 요청에도 SSE 형식(id:/event/data: 라인)으로 응답하는 경우 처리
var effectiveBody = ExtractJsonFromSseIfNeeded(respBody);
@@ -834,6 +906,7 @@ public partial class LlmService : ILlmService
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
}, "vLLM 응답");
} // using (resp)
}
/// <summary>
@@ -931,14 +1004,39 @@ public partial class LlmService : ILlmService
? BuildIbmDeploymentChatUrl(ep, stream: true)
: ep.TrimEnd('/') + "/v1/chat/completions";
if (usesIbmDeploymentApi)
{
var bodyJson = JsonSerializer.Serialize(body);
IbmDiagInfo($"[IBM진단] StreamOpenAi: url={url}, bodyLen={bodyJson.Length}자, messages={messages.Count}건");
IbmDiagDebug($"[IBM진단] StreamOpenAi 요청본문(앞500자): {(bodyJson.Length > 500 ? bodyJson[..500] + "" : bodyJson)}");
}
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
HttpResponseMessage resp;
try
{
resp = await SendWithErrorClassificationAsync(req, allowInsecureTls, ct);
}
catch (Exception ex)
{
if (usesIbmDeploymentApi)
IbmDiagError($"[IBM진단] StreamOpenAi 요청 실패: {ex.GetType().Name}: {ex.Message}");
throw;
}
if (usesIbmDeploymentApi)
{
var ct2 = resp.Content.Headers.ContentType?.MediaType ?? "(null)";
IbmDiagInfo($"[IBM진단] StreamOpenAi 연결 성공: HTTP {(int)resp.StatusCode}, ContentType={ct2}");
}
using var stream2 = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream2);
var firstChunkReceived = false;
var ibmChunkCount = 0;
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
@@ -954,7 +1052,12 @@ public partial class LlmService : ILlmService
firstChunkReceived = true;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
if (data == "[DONE]")
{
if (usesIbmDeploymentApi)
IbmDiagDebug($"[IBM진단] StreamOpenAi 완료: 총 {ibmChunkCount}개 청크 수신");
break;
}
string? text = null;
try
@@ -963,12 +1066,21 @@ public partial class LlmService : ILlmService
TryParseOpenAiUsage(doc.RootElement);
if (usesIbmDeploymentApi)
{
ibmChunkCount++;
// 첫 3개 청크 + 이후 50개마다 로깅 (과도한 로그 방지)
if (ibmChunkCount <= 3 || ibmChunkCount % 50 == 0)
{
var preview = data.Length > 300 ? data[..300] + "…" : data;
IbmDiagDebug($"[IBM진단] StreamOpenAi chunk#{ibmChunkCount}: {preview}");
}
if (doc.RootElement.SafeTryGetProperty("status", out var status) &&
string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = doc.RootElement.SafeTryGetProperty("message", out var message)
? message.SafeGetString()
: "IBM vLLM 스트리밍 오류";
IbmDiagError($"[IBM진단] StreamOpenAi 서버 오류 응답: {detail}");
throw new InvalidOperationException(detail);
}
@@ -1029,7 +1141,13 @@ public partial class LlmService : ILlmService
}
catch (JsonException ex)
{
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
if (usesIbmDeploymentApi)
{
var preview = data.Length > 500 ? data[..500] + "…" : data;
IbmDiagError($"[IBM진단] StreamOpenAi JSON 파싱 오류: {ex.Message}\n 청크 내용: {preview}");
}
else
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (!string.IsNullOrEmpty(text)) yield return text;
}

View File

@@ -0,0 +1,21 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#2B2A27"/>
<SolidColorBrush x:Key="ItemBackground" Color="#1F1E1B"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#141310"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#343432"/>
<SolidColorBrush x:Key="PrimaryText" Color="#FAF9F5"/>
<SolidColorBrush x:Key="SecondaryText" Color="#C2C0B6"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#9A9893"/>
<SolidColorBrush x:Key="AccentColor" Color="#D97757"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#4C4A45"/>
<SolidColorBrush x:Key="HintBackground" Color="#393937"/>
<SolidColorBrush x:Key="HintText" Color="#C2A68A"/>
<SolidColorBrush x:Key="BorderColor" Color="#433F3A"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#8A6B4F"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#716C64"/>
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#34D399"/>
<SolidColorBrush x:Key="WarningColor" Color="#FBBF24"/>
<SolidColorBrush x:Key="ErrorColor" Color="#F87171"/>
</ResourceDictionary>

View File

@@ -0,0 +1,21 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F4F3EE"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E5E2DB"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EDEBE5"/>
<SolidColorBrush x:Key="PrimaryText" Color="#141413"/>
<SolidColorBrush x:Key="SecondaryText" Color="#6B6A68"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#73726C"/>
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#E8E6E0"/>
<SolidColorBrush x:Key="HintBackground" Color="#EEECE2"/>
<SolidColorBrush x:Key="HintText" Color="#6B5A4A"/>
<SolidColorBrush x:Key="BorderColor" Color="#E0DDD6"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#CFB79C"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C4BEB2"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -0,0 +1,21 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F4F3EE"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E5E2DB"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EDEBE5"/>
<SolidColorBrush x:Key="PrimaryText" Color="#141413"/>
<SolidColorBrush x:Key="SecondaryText" Color="#6B6A68"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#73726C"/>
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#E8E6E0"/>
<SolidColorBrush x:Key="HintBackground" Color="#EEECE2"/>
<SolidColorBrush x:Key="HintText" Color="#6B5A4A"/>
<SolidColorBrush x:Key="BorderColor" Color="#E0DDD6"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#CFB79C"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C4BEB2"/>
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -1,18 +1,21 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#30302E"/>
<SolidColorBrush x:Key="ItemBackground" Color="#262624"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#141413"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#343432"/>
<SolidColorBrush x:Key="PrimaryText" Color="#FAF9F5"/>
<SolidColorBrush x:Key="SecondaryText" Color="#C2C0B6"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#8F8D84"/>
<SolidColorBrush x:Key="AccentColor" Color="#D97757"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#4C4A45"/>
<SolidColorBrush x:Key="HintBackground" Color="#393836"/>
<SolidColorBrush x:Key="HintText" Color="#E0B089"/>
<SolidColorBrush x:Key="BorderColor" Color="#595651"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#9B7558"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#716C64"/>
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
<SolidColorBrush x:Key="LauncherBackground" Color="#0F172A"/>
<SolidColorBrush x:Key="ItemBackground" Color="#111827"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#1E293B"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#1F2937"/>
<SolidColorBrush x:Key="PrimaryText" Color="#E5E7EB"/>
<SolidColorBrush x:Key="SecondaryText" Color="#94A3B8"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#64748B"/>
<SolidColorBrush x:Key="AccentColor" Color="#38BDF8"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#334155"/>
<SolidColorBrush x:Key="HintBackground" Color="#0B1220"/>
<SolidColorBrush x:Key="HintText" Color="#93C5FD"/>
<SolidColorBrush x:Key="BorderColor" Color="#334155"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#5BA3D9"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#475569"/>
<SolidColorBrush x:Key="ShadowColor" Color="#8B000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#10B981"/>
<SolidColorBrush x:Key="WarningColor" Color="#F59E0B"/>
<SolidColorBrush x:Key="ErrorColor" Color="#EF4444"/>
</ResourceDictionary>

View File

@@ -1,18 +1,21 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#F2EBDD"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E8DEC9"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECE4D3"/>
<SolidColorBrush x:Key="PrimaryText" Color="#141413"/>
<SolidColorBrush x:Key="SecondaryText" Color="#3D3D3A"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#73726C"/>
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#E3E0D7"/>
<SolidColorBrush x:Key="HintBackground" Color="#F8F3EA"/>
<SolidColorBrush x:Key="HintText" Color="#8B5637"/>
<SolidColorBrush x:Key="BorderColor" Color="#DDD9D0"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#CFB79C"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C4BEB2"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="ItemBackground" Color="#F8FAFC"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EAF2FF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>
<SolidColorBrush x:Key="SecondaryText" Color="#475569"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#64748B"/>
<SolidColorBrush x:Key="AccentColor" Color="#0284C7"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#CBD5E1"/>
<SolidColorBrush x:Key="HintBackground" Color="#EFF6FF"/>
<SolidColorBrush x:Key="HintText" Color="#1D4ED8"/>
<SolidColorBrush x:Key="BorderColor" Color="#CBD5E1"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#4A90B8"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#94A3B8"/>
<SolidColorBrush x:Key="ShadowColor" Color="#33000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -1,18 +1,21 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#F2EBDD"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E8DEC9"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECE4D3"/>
<SolidColorBrush x:Key="PrimaryText" Color="#141413"/>
<SolidColorBrush x:Key="SecondaryText" Color="#3D3D3A"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#73726C"/>
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#E3E0D7"/>
<SolidColorBrush x:Key="HintBackground" Color="#F8F3EA"/>
<SolidColorBrush x:Key="HintText" Color="#8B5637"/>
<SolidColorBrush x:Key="BorderColor" Color="#DDD9D0"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#CFB79C"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C4BEB2"/>
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
<SolidColorBrush x:Key="LauncherBackground" Color="#111827"/>
<SolidColorBrush x:Key="ItemBackground" Color="#1F2937"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#243244"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#2A3748"/>
<SolidColorBrush x:Key="PrimaryText" Color="#F1F5F9"/>
<SolidColorBrush x:Key="SecondaryText" Color="#9CA3AF"/>
<SolidColorBrush x:Key="PlaceholderText" Color="#6B7280"/>
<SolidColorBrush x:Key="AccentColor" Color="#22D3EE"/>
<SolidColorBrush x:Key="SeparatorColor" Color="#374151"/>
<SolidColorBrush x:Key="HintBackground" Color="#0F172A"/>
<SolidColorBrush x:Key="HintText" Color="#A5F3FC"/>
<SolidColorBrush x:Key="BorderColor" Color="#4B5563"/>
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#5BBAD9"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#4B5563"/>
<SolidColorBrush x:Key="ShadowColor" Color="#8B000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#10B981"/>
<SolidColorBrush x:Key="WarningColor" Color="#F59E0B"/>
<SolidColorBrush x:Key="ErrorColor" Color="#EF4444"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#7A8598"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#636A76"/>
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#059669"/>
<SolidColorBrush x:Key="WarningColor" Color="#D97706"/>
<SolidColorBrush x:Key="ErrorColor" Color="#DC2626"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#A7B0BE"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#AAB3C0"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#A7B0BE"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#AAB3C0"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#C47E46"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#755B48"/>
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#2DD4A0"/>
<SolidColorBrush x:Key="WarningColor" Color="#FBBF24"/>
<SolidColorBrush x:Key="ErrorColor" Color="#F87171"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#D89B62"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#CDB8A6"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#D89B62"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#CDB8A6"/>
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#047857"/>
<SolidColorBrush x:Key="WarningColor" Color="#B45309"/>
<SolidColorBrush x:Key="ErrorColor" Color="#B91C1C"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#81A1C1"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#66738A"/>
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#A3BE8C"/>
<SolidColorBrush x:Key="WarningColor" Color="#EBCB8B"/>
<SolidColorBrush x:Key="ErrorColor" Color="#BF616A"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#81A1C1"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B0BAC8"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#8FAE7E"/>
<SolidColorBrush x:Key="WarningColor" Color="#C89B3F"/>
<SolidColorBrush x:Key="ErrorColor" Color="#A5404A"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#81A1C1"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B0BAC8"/>
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#8FAE7E"/>
<SolidColorBrush x:Key="WarningColor" Color="#C89B3F"/>
<SolidColorBrush x:Key="ErrorColor" Color="#A5404A"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#64748B"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#475569"/>
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#4ADE80"/>
<SolidColorBrush x:Key="WarningColor" Color="#FCD34D"/>
<SolidColorBrush x:Key="ErrorColor" Color="#FB7185"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#94A3B8"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#94A3B8"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#15803D"/>
<SolidColorBrush x:Key="WarningColor" Color="#A16207"/>
<SolidColorBrush x:Key="ErrorColor" Color="#9F1239"/>
</ResourceDictionary>

View File

@@ -15,4 +15,7 @@
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#94A3B8"/>
<SolidColorBrush x:Key="ScrollbarThumb" Color="#94A3B8"/>
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
<SolidColorBrush x:Key="SuccessColor" Color="#15803D"/>
<SolidColorBrush x:Key="WarningColor" Color="#A16207"/>
<SolidColorBrush x:Key="ErrorColor" Color="#9F1239"/>
</ResourceDictionary>

View File

@@ -747,7 +747,7 @@ public class SettingsViewModel : INotifyPropertyChanged
set
{
var normalized = string.IsNullOrWhiteSpace(value) ? "claude" : value.Trim().ToLowerInvariant();
_agentThemePreset = normalized == "claw" ? "claude" : normalized;
_agentThemePreset = normalized;
OnPropertyChanged();
}
}
@@ -1175,7 +1175,7 @@ public class SettingsViewModel : INotifyPropertyChanged
_aiEnabled = _service.Settings.AiEnabled;
_operationMode = string.IsNullOrWhiteSpace(_service.Settings.OperationMode) ? "internal" : _service.Settings.OperationMode;
_agentTheme = string.IsNullOrWhiteSpace(llm.AgentTheme) ? "system" : llm.AgentTheme;
_agentThemePreset = string.IsNullOrWhiteSpace(llm.AgentThemePreset) ? "claude" : (llm.AgentThemePreset.Trim().ToLowerInvariant() == "claw" ? "claude" : llm.AgentThemePreset);
_agentThemePreset = string.IsNullOrWhiteSpace(llm.AgentThemePreset) ? "claude" : llm.AgentThemePreset;
_agentLogLevel = llm.AgentLogLevel;
_agentUiExpressionLevel = (llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant() switch
{
@@ -1286,6 +1286,7 @@ public class SettingsViewModel : INotifyPropertyChanged
EncryptedModelName = rm.EncryptedModelName,
Service = rm.Service,
ExecutionProfile = rm.ExecutionProfile ?? "balanced",
PromptFamily = rm.PromptFamily ?? "",
Endpoint = rm.Endpoint,
ApiKey = rm.ApiKey,
AllowInsecureTls = rm.AllowInsecureTls,
@@ -1726,6 +1727,7 @@ public class SettingsViewModel : INotifyPropertyChanged
EncryptedModelName = rm.EncryptedModelName,
Service = rm.Service,
ExecutionProfile = rm.ExecutionProfile ?? "balanced",
PromptFamily = rm.PromptFamily ?? "",
Endpoint = rm.Endpoint,
ApiKey = rm.ApiKey,
AllowInsecureTls = rm.AllowInsecureTls,
@@ -2052,6 +2054,7 @@ public class RegisteredModelRow : INotifyPropertyChanged
private string _encryptedModelName = "";
private string _service = "ollama";
private string _executionProfile = "balanced";
private string _promptFamily = "";
private string _endpoint = "";
private string _apiKey = "";
private bool _allowInsecureTls;
@@ -2083,6 +2086,13 @@ public class RegisteredModelRow : INotifyPropertyChanged
set { _executionProfile = value; OnPropertyChanged(); OnPropertyChanged(nameof(ProfileLabel)); }
}
/// <summary>프롬프트 전략 패밀리. 비어있으면 모델명에서 자동 감지.</summary>
public string PromptFamily
{
get => _promptFamily;
set { _promptFamily = value; OnPropertyChanged(); }
}
/// <summary>이 모델 전용 서버 엔드포인트. 비어있으면 기본 엔드포인트 사용.</summary>
public string Endpoint
{

View File

@@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Copilot — 정보"
Width="460" SizeToContent="Height"
Width="520" SizeToContent="Height" MaxHeight="720"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
@@ -37,6 +37,76 @@
</Setter.Value>
</Setter>
</Style>
<!-- 커스텀 스크롤바 Thumb -->
<Style x:Key="AboutScrollThumb" TargetType="Thumb">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="ThumbBd"
CornerRadius="3"
Background="#CCCCDD"
Margin="1,0"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ThumbBd" Property="Background" Value="#AAAACC"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="ThumbBd" Property="Background" Value="#8888BB"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 커스텀 세로 스크롤바 -->
<Style x:Key="AboutScrollBar" TargetType="ScrollBar">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Width" Value="6"/>
<Setter Property="MinWidth" Value="6"/>
<Setter Property="Margin" Value="0,4,4,4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb Style="{StaticResource AboutScrollThumb}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ScrollViewer에 커스텀 스크롤바 적용 -->
<Style x:Key="AboutScrollViewer" TargetType="ScrollViewer">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollViewer">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ScrollContentPresenter Grid.Column="0"/>
<ScrollBar x:Name="PART_VerticalScrollBar"
Grid.Column="1"
Style="{StaticResource AboutScrollBar}"
Orientation="Vertical"
Maximum="{TemplateBinding ScrollableHeight}"
ViewportSize="{TemplateBinding ViewportHeight}"
Value="{TemplateBinding VerticalOffset}"
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Margin="20">
@@ -49,7 +119,7 @@
<Grid>
<Grid.RowDefinitions>
<!-- 상단 헤더 그라데이션 -->
<RowDefinition Height="210"/>
<RowDefinition Height="195"/>
<!-- 본문 -->
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
@@ -190,13 +260,16 @@
</Grid>
<!-- ══ 본문 ══ -->
<StackPanel Grid.Row="1" Margin="32,24,32,20">
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Style="{StaticResource AboutScrollViewer}">
<StackPanel Margin="28,18,28,16">
<!-- 구분선 -->
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,20"/>
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,14"/>
<!-- 개발 목적 -->
<Border Background="#F7F8FF" CornerRadius="12" Padding="16,13" Margin="0,0,0,20">
<Border Background="#F7F8FF" CornerRadius="12" Padding="14,11" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE946;" FontFamily="Segoe MDL2 Assets"
@@ -214,7 +287,7 @@
</Border>
<!-- 개발자 정보 -->
<StackPanel Margin="0,0,0,16">
<StackPanel Margin="0,0,0,12">
<TextBlock Text="개발자 정보"
FontSize="10.5" FontWeight="SemiBold"
Foreground="#AAAACC" Margin="0,0,0,10"/>
@@ -291,7 +364,65 @@
</StackPanel>
<!-- 구분선 -->
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,14"/>
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,10"/>
<!-- 오픈소스 라이선스 -->
<StackPanel Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="&#xE90F;" FontFamily="Segoe MDL2 Assets"
FontSize="10" Foreground="#4B5EFC"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="오픈소스 라이선스"
FontSize="10.5" FontWeight="SemiBold"
Foreground="#AAAACC"/>
</StackPanel>
<Border Background="#FAFBFF" CornerRadius="10" Padding="14,10" Margin="0,0,0,8">
<StackPanel>
<TextBlock Text="Agentic 엔진 참조 프로젝트" FontSize="10" FontWeight="SemiBold"
Foreground="#6668AA" Margin="0,0,0,6"/>
<TextBlock FontSize="11" Foreground="#555577" TextWrapping="Wrap" LineHeight="17">
<Run FontWeight="SemiBold">OpenHands</Run>
<Run>(전 OpenDevin) — MIT License</Run>
<LineBreak/>
<Run Foreground="#9999BB">© 2024 OpenHands Contributors</Run>
<LineBreak/>
<Run FontWeight="SemiBold">OpenCode</Run>
<Run> — Apache License 2.0</Run>
<LineBreak/>
<Run Foreground="#9999BB">© 2024 OpenCode Contributors</Run>
</TextBlock>
</StackPanel>
</Border>
<Border Background="#FAFBFF" CornerRadius="10" Padding="14,10">
<StackPanel>
<TextBlock Text="주요 라이브러리" FontSize="10" FontWeight="SemiBold"
Foreground="#6668AA" Margin="0,0,0,6"/>
<TextBlock FontSize="10.5" Foreground="#555577" TextWrapping="Wrap" LineHeight="16">
<Run FontWeight="SemiBold">Markdig</Run><Run> — BSD 2-Clause</Run>
<Run Foreground="#CCCCDD"> | </Run>
<Run FontWeight="SemiBold">DocumentFormat.OpenXml</Run><Run> — MIT</Run>
<LineBreak/>
<Run FontWeight="SemiBold">UglyToad.PdfPig</Run><Run> — Apache 2.0</Run>
<Run Foreground="#CCCCDD"> | </Run>
<Run FontWeight="SemiBold">QRCoder</Run><Run> — MIT</Run>
<LineBreak/>
<Run FontWeight="SemiBold">WebView2</Run><Run> — MIT</Run>
<Run Foreground="#CCCCDD"> | </Run>
<Run FontWeight="SemiBold">Microsoft.Data.Sqlite</Run><Run> — MIT</Run>
</TextBlock>
</StackPanel>
</Border>
<TextBlock Text="모든 오픈소스 라이선스는 상업적 사용을 허용하며, 원저작자 표기를 포함합니다."
FontSize="9.5" Foreground="#BBBBCC" Margin="0,8,0,0"
TextWrapping="Wrap" LineHeight="14"
TextAlignment="Center" HorizontalAlignment="Center"/>
</StackPanel>
<!-- 구분선 -->
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,10"/>
<!-- 버전/빌드 정보 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
@@ -304,6 +435,7 @@
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Grid>

View File

@@ -135,8 +135,8 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Margin="0,0,8,0"
Padding="12,8"
Margin="0,0,10,0"
MouseLeftButtonUp="AgentTabBasicCard_MouseLeftButtonUp">
<TextBlock Text="기본" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -145,8 +145,8 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Margin="0,0,8,0"
Padding="12,8"
Margin="0,0,10,0"
MouseLeftButtonUp="AgentTabChatCard_MouseLeftButtonUp">
<TextBlock Text="채팅" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -155,8 +155,8 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Margin="0,0,8,0"
Padding="12,8"
Margin="0,0,10,0"
MouseLeftButtonUp="AgentTabCoworkCard_MouseLeftButtonUp">
<TextBlock Text="코워크" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -165,8 +165,8 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Margin="0,0,8,0"
Padding="12,8"
Margin="0,0,10,0"
MouseLeftButtonUp="AgentTabCodeCard_MouseLeftButtonUp">
<TextBlock Text="코드" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -175,8 +175,8 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Margin="0,0,8,0"
Padding="12,8"
Margin="0,0,10,0"
MouseLeftButtonUp="AgentTabDevCard_MouseLeftButtonUp">
<TextBlock Text="개발자" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -185,8 +185,8 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Margin="0,0,8,0"
Padding="12,8"
Margin="0,0,10,0"
MouseLeftButtonUp="AgentTabToolsCard_MouseLeftButtonUp">
<TextBlock Text="도구" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -195,7 +195,7 @@
CornerRadius="10"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Padding="10,7"
Padding="12,8"
MouseLeftButtonUp="AgentTabEtcCard_MouseLeftButtonUp">
<TextBlock Text="스킬/차단" FontSize="12" Foreground="{DynamicResource PrimaryText}"/>
</Border>
@@ -208,10 +208,10 @@
<StackPanel Margin="18,14,18,16">
<StackPanel x:Name="PanelBasic">
<TextBlock Text="기본 상태"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Grid Margin="0,8,0,0" Visibility="Collapsed">
<Grid Margin="0,10,0,0" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -222,14 +222,14 @@
FontSize="12"/>
<TextBlock Text="비활성화하면 AX Agent 대화와 관련 설정이 숨겨집니다."
Foreground="{DynamicResource SecondaryText}"
FontSize="11"
Margin="0,2,0,0"/>
FontSize="11.5"
Margin="0,3,0,0"/>
</StackPanel>
<CheckBox x:Name="ChkAiEnabled"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0" Visibility="Collapsed">
<Grid Margin="0,10,0,0" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -240,8 +240,8 @@
FontSize="12"/>
<TextBlock Text="계획, 승인 카드, 보조 설명의 정보 밀도를 조정합니다."
Foreground="{DynamicResource SecondaryText}"
FontSize="11"
Margin="0,2,0,0"/>
FontSize="11.5"
Margin="0,3,0,0"/>
</StackPanel>
<WrapPanel Grid.Column="1">
<Border x:Name="DisplayModeRichCard"
@@ -276,13 +276,13 @@
</WrapPanel>
</Grid>
<Border Height="1" Margin="0,10,0,10" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="테마"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<WrapPanel Margin="0,8,0,0">
<WrapPanel Margin="0,10,0,0">
<Border x:Name="ThemeSystemCard"
Cursor="Hand"
CornerRadius="10"
@@ -315,17 +315,17 @@
</Border>
</WrapPanel>
<Border Height="1" Margin="0,10,0,10" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
</StackPanel>
<StackPanel x:Name="PanelChat">
<TextBlock Text="모델 및 연결"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="서비스를 선택하고 모델, 연결 옵션, 운영 모드를 조정합니다."
Margin="0,4,0,10"
FontSize="11"
FontSize="11.5"
Foreground="{DynamicResource SecondaryText}"/>
<WrapPanel>
<Border x:Name="SvcOllamaCard"
@@ -372,7 +372,7 @@
<TextBox x:Name="ModelInput"
Visibility="Collapsed"
Margin="0,6,0,8"
Padding="8,6"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
@@ -391,7 +391,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0" Visibility="Collapsed">
<Grid Margin="0,10,0,0" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -418,7 +418,7 @@
Style="{StaticResource OutlineHoverBtn}"
Click="BtnOperationMode_Click"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -445,7 +445,7 @@
Style="{StaticResource OutlineHoverBtn}"
Click="BtnDefaultOutputFormat_Click"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -472,15 +472,15 @@
Click="BtnDefaultMood_Click"/>
</Grid>
<Border Height="1" Margin="0,10,0,10" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
</StackPanel>
<StackPanel x:Name="PanelCowork" Visibility="Collapsed">
<TextBlock Text="권한 및 실행"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -507,7 +507,7 @@
Style="{StaticResource OutlineHoverBtn}"
Click="BtnPermissionMode_Click"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -533,7 +533,7 @@
Style="{StaticResource OutlineHoverBtn}"
Click="BtnReasoningMode_Click"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -545,7 +545,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -557,7 +557,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -569,7 +569,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="120"/>
@@ -591,7 +591,7 @@
</StackPanel>
<TextBox x:Name="TxtMaxAgentIterations"
Grid.Column="1"
Padding="8,5"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
@@ -602,10 +602,10 @@
<StackPanel x:Name="PanelCode" Visibility="Collapsed">
<TextBlock Text="코드 실행"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -617,7 +617,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -629,7 +629,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -641,7 +641,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -653,7 +653,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -668,13 +668,13 @@
</StackPanel>
<StackPanel x:Name="PanelDev" Visibility="Collapsed">
<Border Height="1" Margin="0,10,0,10" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="컨텍스트 및 오류 관리"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -686,7 +686,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="120"/>
@@ -696,14 +696,14 @@
VerticalAlignment="Center"/>
<TextBox x:Name="TxtContextCompactTriggerPercent"
Grid.Column="1"
Padding="8,5"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Foreground="{DynamicResource PrimaryText}"
FontSize="12"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="120"/>
@@ -723,14 +723,14 @@
</StackPanel>
<TextBox x:Name="TxtMaxContextTokens"
Grid.Column="1"
Padding="8,5"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Foreground="{DynamicResource PrimaryText}"
FontSize="12"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="120"/>
@@ -750,7 +750,7 @@
</StackPanel>
<TextBox x:Name="TxtMaxRetryOnError"
Grid.Column="1"
Padding="8,5"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
@@ -758,14 +758,50 @@
FontSize="12"/>
</Grid>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="진단 및 디버깅"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Margin="0,0,12,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="IBM+Qwen 진단 로그"
Foreground="{DynamicResource PrimaryText}"
FontSize="12"
VerticalAlignment="Center"/>
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border.ToolTip>
<ToolTip Style="{StaticResource HelpTooltipStyle}">
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18" MaxWidth="300">IBM watsonx + Qwen 조합 사용 시 요청/응답/인증/파싱 등 상세 진단 로그를 기록합니다. 로그 파일: %APPDATA%\AxCopilot\logs\</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<TextBlock Text="활성화하면 [IBM진단] 태그로 인증, 요청, 응답, 파싱 과정을 상세 기록합니다."
Foreground="{DynamicResource SecondaryText}"
FontSize="11.5"
Margin="0,3,0,0"/>
</StackPanel>
<CheckBox x:Name="ChkEnableIbmDiagnosticLog"
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
</StackPanel>
<StackPanel x:Name="PanelTools" Visibility="Collapsed">
<TextBlock Text="도구 및 검증"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -787,7 +823,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -809,7 +845,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -821,7 +857,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -833,39 +869,39 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Border Height="1" Margin="0,12,0,12" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="도구 노출"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="AX Agent에 노출할 도구를 내부 설정에서 바로 켜고 끕니다."
Margin="0,4,0,8"
FontSize="11"
FontSize="11.5"
Foreground="{DynamicResource SecondaryText}"/>
<StackPanel x:Name="ToolCardsPanel"/>
<Border Height="1" Margin="0,12,0,12" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="도구 훅"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<StackPanel x:Name="HookListPanel" Margin="0,8,0,0"/>
<StackPanel x:Name="HookListPanel" Margin="0,10,0,0"/>
<Button Content="훅 추가"
HorizontalAlignment="Left"
Margin="0,8,0,0"
Margin="0,10,0,0"
Style="{StaticResource OutlineHoverBtn}"
Click="BtnAddHook_Click"/>
</StackPanel>
<StackPanel x:Name="PanelEtc" Visibility="Collapsed">
<TextBlock Text="스킬/차단"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="스킬 폴더와 슬래시/드래그 동작, 폴백 모델과 MCP 서버를 관리합니다."
Margin="0,4,0,8"
FontSize="11"
FontSize="11.5"
Foreground="{DynamicResource SecondaryText}"/>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@@ -891,8 +927,8 @@
<TextBox x:Name="TxtSkillsFolderPath"
Grid.Row="1"
Grid.Column="0"
Margin="0,8,0,0"
Padding="8,5"
Margin="0,10,0,0"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
@@ -911,17 +947,17 @@
Content="열기"
Click="BtnOpenSkillFolder_Click"/>
</Grid>
<Border Height="1" Margin="0,12,0,12" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="로드된 스킬"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="현재 AX Agent에서 사용할 수 있는 슬래시 스킬 목록입니다."
Margin="0,4,0,8"
FontSize="11"
FontSize="11.5"
Foreground="{DynamicResource SecondaryText}"/>
<StackPanel x:Name="SkillListPanel"/>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="120"/>
@@ -941,14 +977,14 @@
</StackPanel>
<TextBox x:Name="TxtSlashPopupPageSize"
Grid.Column="1"
Padding="8,5"
Padding="10,7"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Foreground="{DynamicResource PrimaryText}"
FontSize="12"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -970,7 +1006,7 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Grid Margin="0,8,0,0">
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -992,13 +1028,13 @@
Grid.Column="1"
Style="{StaticResource ToggleSwitch}"/>
</Grid>
<Border Height="1" Margin="0,12,0,12" Background="{DynamicResource SeparatorColor}"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<TextBlock Text="폴백 모델"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<StackPanel x:Name="FallbackModelsPanel" Margin="0,8,0,0"/>
<Border Height="1" Margin="0,12,0,12" Background="{DynamicResource SeparatorColor}"/>
<StackPanel x:Name="FallbackModelsPanel" Margin="0,10,0,0"/>
<Border Height="1" Margin="0,14,0,14" Background="{DynamicResource SeparatorColor}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
@@ -1006,7 +1042,7 @@
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="MCP 서버"
FontSize="13"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
@@ -1024,7 +1060,7 @@
Content="서버 추가"
Click="BtnAddMcpServer_Click"/>
</Grid>
<StackPanel x:Name="McpServerListPanel" Margin="0,8,0,0"/>
<StackPanel x:Name="McpServerListPanel" Margin="0,10,0,0"/>
</StackPanel>
</StackPanel>
</ScrollViewer>

View File

@@ -1,4 +1,5 @@
using System.Windows;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
@@ -59,6 +60,7 @@ public partial class AgentSettingsWindow : Window
TxtContextCompactTriggerPercent.Text = Math.Clamp(_llm.ContextCompactTriggerPercent, 10, 95).ToString();
TxtMaxContextTokens.Text = Math.Max(1024, _llm.MaxContextTokens).ToString();
TxtMaxRetryOnError.Text = Math.Clamp(_llm.MaxRetryOnError, 0, 10).ToString();
ChkEnableIbmDiagnosticLog.IsChecked = _llm.EnableIbmDiagnosticLog;
ChkEnableSkillSystem.IsChecked = _llm.EnableSkillSystem;
ChkEnableToolHooks.IsChecked = _llm.EnableToolHooks;
@@ -77,7 +79,8 @@ public partial class AgentSettingsWindow : Window
TxtSlashPopupPageSize.Text = Math.Clamp(_llm.SlashPopupPageSize, 3, 20).ToString();
ChkEnableDragDropAiActions.IsChecked = _llm.EnableDragDropAiActions;
ChkDragDropAutoSend.IsChecked = _llm.DragDropAutoSend;
_disabledTools = new HashSet<string>(_llm.DisabledTools ?? new(), StringComparer.OrdinalIgnoreCase);
_disabledTools = new HashSet<string>(AgentToolCatalog.CanonicalizeMany(_llm.DisabledTools ?? new()), StringComparer.OrdinalIgnoreCase);
_llm.AgentHooks = AgentToolCatalog.CanonicalizeHooks(_llm.AgentHooks);
RefreshServiceCards();
RefreshThemeCards();
@@ -141,13 +144,27 @@ public partial class AgentSettingsWindow : Window
private void ShowPanel(string panel)
{
_activePanel = panel;
PanelBasic.Visibility = panel == "basic" ? Visibility.Visible : Visibility.Collapsed;
PanelChat.Visibility = panel == "chat" ? Visibility.Visible : Visibility.Collapsed;
PanelCowork.Visibility = panel == "cowork" ? Visibility.Visible : Visibility.Collapsed;
PanelCode.Visibility = panel == "code" ? Visibility.Visible : Visibility.Collapsed;
PanelDev.Visibility = panel == "dev" ? Visibility.Visible : Visibility.Collapsed;
PanelTools.Visibility = panel == "tools" ? Visibility.Visible : Visibility.Collapsed;
PanelEtc.Visibility = panel == "etc" ? Visibility.Visible : Visibility.Collapsed;
var panels = new (FrameworkElement Element, string Key)[]
{
(PanelBasic, "basic"), (PanelChat, "chat"), (PanelCowork, "cowork"),
(PanelCode, "code"), (PanelDev, "dev"), (PanelTools, "tools"), (PanelEtc, "etc")
};
foreach (var (element, key) in panels)
{
if (key == panel)
{
element.Opacity = 0;
element.Visibility = Visibility.Visible;
element.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new System.Windows.Media.Animation.QuadraticEase() });
}
else
{
element.BeginAnimation(UIElement.OpacityProperty, null);
element.Visibility = Visibility.Collapsed;
}
}
RefreshTabCards();
if (panel == "tools")
LoadToolCards();
@@ -520,6 +537,7 @@ public partial class AgentSettingsWindow : Window
_llm.ContextCompactTriggerPercent = ParseInt(TxtContextCompactTriggerPercent.Text, 80, 10, 95);
_llm.MaxContextTokens = ParseInt(TxtMaxContextTokens.Text, 4096, 1024, 200000);
_llm.MaxRetryOnError = ParseInt(TxtMaxRetryOnError.Text, 3, 0, 10);
_llm.EnableIbmDiagnosticLog = ChkEnableIbmDiagnosticLog.IsChecked == true;
_llm.EnableSkillSystem = ChkEnableSkillSystem.IsChecked == true;
_llm.EnableToolHooks = ChkEnableToolHooks.IsChecked == true;
@@ -538,7 +556,10 @@ public partial class AgentSettingsWindow : Window
_llm.SlashPopupPageSize = ParseInt(TxtSlashPopupPageSize.Text, 7, 3, 20);
_llm.EnableDragDropAiActions = ChkEnableDragDropAiActions.IsChecked == true;
_llm.DragDropAutoSend = ChkDragDropAutoSend.IsChecked == true;
_llm.DisabledTools = _disabledTools.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
_llm.DisabledTools = AgentToolCatalog.CanonicalizeMany(_disabledTools)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
_llm.AgentHooks = AgentToolCatalog.CanonicalizeHooks(_llm.AgentHooks);
_settings.Settings.AiEnabled = true;
_settings.Settings.OperationMode = OperationModePolicy.Normalize(_operationMode);
@@ -705,24 +726,13 @@ public partial class AgentSettingsWindow : Window
_toolCardsLoaded = true;
using var tools = ToolRegistry.CreateDefault();
var categories = new Dictionary<string, List<IAgentTool>>
{
["파일/검색"] = new(),
["문서/리뷰"] = new(),
["코드/개발"] = new(),
["시스템/유틸"] = new(),
};
var toolCategoryMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["file_read"] = "파일/검색", ["file_write"] = "파일/검색", ["file_edit"] = "파일/검색", ["glob"] = "파일/검색", ["grep"] = "파일/검색",
["document_review"] = "문서/리뷰", ["format_convert"] = "문서/리뷰", ["template_render"] = "문서/리뷰", ["text_summarize"] = "문서/리뷰",
["build_run"] = "코드/개발", ["git_tool"] = "코드/개발", ["lsp"] = "코드/개발", ["code_review"] = "코드/개발", ["test_loop"] = "코드/개발",
["process"] = "시스템/유틸", ["notify"] = "시스템/유틸", ["clipboard"] = "시스템/유틸", ["env"] = "시스템/유틸", ["skill_manager"] = "시스템/유틸",
};
var categories = new Dictionary<string, List<IAgentTool>>(StringComparer.OrdinalIgnoreCase);
foreach (var tool in tools.All)
{
var category = toolCategoryMap.TryGetValue(tool.Name, out var mapped) ? mapped : "시스템/유틸";
var category = AgentToolCatalog.GetMetadata(tool.Name).SettingsCategory;
if (!categories.ContainsKey(category))
categories[category] = new List<IAgentTool>();
categories[category].Add(tool);
}
@@ -866,7 +876,7 @@ public partial class AgentSettingsWindow : Window
}
var nameBox = AddField("이름", existing?.Name ?? "");
var toolBox = AddField("대상 도구 (* = 전체)", existing?.ToolName ?? "*");
var toolBox = AddField("대상 도구 (* = 전체)", AgentToolCatalog.CanonicalizeHookTarget(existing?.ToolName ?? "*"));
var pathBox = AddField("스크립트 경로", existing?.ScriptPath ?? "");
var argsBox = AddField("인수", existing?.Arguments ?? "");
@@ -886,7 +896,7 @@ public partial class AgentSettingsWindow : Window
var entry = new AgentHookEntry
{
Name = nameBox.Text.Trim(),
ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(),
ToolName = AgentToolCatalog.CanonicalizeHookTarget(toolBox.Text),
Timing = pre.IsChecked == true ? "pre" : "post",
ScriptPath = pathBox.Text.Trim(),
Arguments = argsBox.Text.Trim(),

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
@@ -18,6 +19,7 @@ public partial class ChatWindow
private int _processFeedMergeCount;
private Border? _lastExpandedDetailPanel;
private TextBlock? _lastExpandedArrow;
private TextBlock? _lastExpandedPreview;
private readonly Dictionary<TranscriptRowKind, int> _transcriptRowKindCounts = new();
private static Color ResolveLiveProgressAccentColor(Brush accentBrush)
@@ -36,6 +38,16 @@ public partial class ChatWindow
return elapsedMs > maxReasonableElapsed ? 0 : elapsedMs;
}
private static string GetPreviewLines(string text, int maxLines)
{
if (string.IsNullOrEmpty(text)) return "";
var lines = text.Split('\n');
var preview = string.Join("\n", lines.Take(maxLines));
if (lines.Length > maxLines)
preview += $"\n… +{lines.Length - maxLines}줄";
return preview;
}
private Border CreateCompactEventPill(
string summary,
Brush primaryText,
@@ -56,10 +68,10 @@ public partial class ChatWindow
BorderBrush = liveWaitingStyle
? new SolidColorBrush(Color.FromArgb(0x46, liveAccentColor.R, liveAccentColor.G, liveAccentColor.B))
: borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(12, 6, 12, 2),
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 4, 0, 4),
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = pillMaxWidth,
Child = new Grid
@@ -124,9 +136,10 @@ public partial class ChatWindow
{
Text = text,
FontSize = 14,
FontWeight = FontWeights.Medium,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
}
};
}
@@ -198,16 +211,20 @@ public partial class ChatWindow
}
/// <summary>이전에 펼쳐진 상세 패널을 접고, 새 패널을 펼쳐진 상태로 등록합니다.</summary>
private void CollapseLastAndExpandNew(Border? newPanel, TextBlock? newArrow)
private void CollapseLastAndExpandNew(Border? newPanel, TextBlock? newArrow, TextBlock? newPreview = null)
{
if (_lastExpandedDetailPanel != null && _lastExpandedDetailPanel.Visibility == Visibility.Visible)
{
_lastExpandedDetailPanel.Visibility = Visibility.Collapsed;
if (_lastExpandedArrow?.RenderTransform is RotateTransform rt)
rt.Angle = 0;
// Show preview for the collapsed panel
if (_lastExpandedPreview != null)
_lastExpandedPreview.Visibility = Visibility.Visible;
}
_lastExpandedDetailPanel = newPanel;
_lastExpandedArrow = newArrow;
_lastExpandedPreview = newPreview;
}
private void TrackTranscriptRowKind(TranscriptRowKind kind)
@@ -344,13 +361,18 @@ public partial class ChatWindow
stack.Children.Add(headerRow);
// 상세 내용 패널 (최신 이벤트는 펼침, 이전 이벤트는 자동 접힘)
// 상세 내용 패널 (현재 실행 중인 도구만 펼침, 나머지는 접힘 + 미리보기)
if (hasBody)
{
// 현재 스트리밍/실행 중인 도구인지 판단 (ToolCall = 실행 중, ToolResult = 완료)
var isActivelyStreaming = evt.Type == AgentEventType.ToolCall
|| evt.Type == AgentEventType.SkillCall;
var bodyPanel = new StackPanel();
var bodyText = body.Length > 600 ? body[..600] + "…" : body;
bodyPanel.Children.Add(new TextBlock
{
Text = body.Length > 600 ? body[..600] + "…" : body,
Text = bodyText,
FontSize = 11,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
@@ -373,30 +395,55 @@ public partial class ChatWindow
});
}
// ScrollViewer로 감싸서 MaxHeight 300px 제한
var detailScroll = new ScrollViewer
{
MaxHeight = 300,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
Content = bodyPanel,
};
var detailPanel = new Border
{
Visibility = Visibility.Visible,
Visibility = isActivelyStreaming ? Visibility.Visible : Visibility.Collapsed,
Background = hintBg,
BorderThickness = new Thickness(0),
Margin = new Thickness(13, 0, 0, 4),
Padding = new Thickness(10, 6, 10, 6),
CornerRadius = new CornerRadius(4),
Child = bodyPanel,
Child = detailScroll,
};
// 접힌 상태일 때 보여줄 3줄 미리보기
var previewText = new TextBlock
{
Text = GetPreviewLines(bodyText, 3),
FontSize = 11.5,
Foreground = secondaryText,
Opacity = 0.7,
TextWrapping = TextWrapping.Wrap,
MaxHeight = 54,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(12, 2, 12, 0),
Visibility = isActivelyStreaming ? Visibility.Collapsed : Visibility.Visible,
};
stack.Children.Add(previewText);
stack.Children.Add(detailPanel);
// 이전 패널 접고 새 패널 등록
CollapseLastAndExpandNew(detailPanel, arrowText);
if (arrowText.RenderTransform is RotateTransform initRt)
// 이전 패널 접고 새 패널 등록 (미리보기 포함)
CollapseLastAndExpandNew(detailPanel, arrowText, previewText);
if (isActivelyStreaming && arrowText.RenderTransform is RotateTransform initRt)
initRt.Angle = 90;
// 클릭으로 토글
// 클릭으로 토글 (미리보기 연동)
headerRow.Cursor = Cursors.Hand;
headerRow.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
var isExpanded = detailPanel.Visibility == Visibility.Visible;
detailPanel.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible;
previewText.Visibility = isExpanded ? Visibility.Visible : Visibility.Collapsed;
if (arrowText.RenderTransform is RotateTransform rt)
rt.Angle = isExpanded ? 0 : 90;
};
@@ -677,11 +724,12 @@ public partial class ChatWindow
var summaryText = new TextBlock
{
Text = summary,
FontSize = liveWaitingStyle ? 13.5 : 12.5,
FontSize = liveWaitingStyle ? 14 : 12.5,
FontWeight = liveWaitingStyle ? FontWeights.SemiBold : FontWeights.Normal,
Foreground = liveWaitingStyle ? primaryText : secondaryText,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
};
var metaTextBlock = new TextBlock
@@ -833,11 +881,12 @@ public partial class ChatWindow
var summaryText = new TextBlock
{
Text = summary,
FontSize = liveWaitingStyle ? 13.5 : 12.75,
FontSize = liveWaitingStyle ? 14 : 12.75,
FontWeight = liveWaitingStyle ? FontWeights.SemiBold : FontWeights.Medium,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
};
var metaTextBlock = new TextBlock
@@ -1943,6 +1992,13 @@ public partial class ChatWindow
}
AddTranscriptElement(stack2);
// 문서 생성 도구 완료 시 Preview 패널 자동 열기 (Claude Artifacts 스타일)
if (evt.Type == AgentEventType.ToolResult && evt.Success
&& !string.IsNullOrWhiteSpace(evt.FilePath))
{
TryAutoPreviewFile(evt.FilePath, evt.ToolName);
}
}
/// <summary>완료 이벤트: 구분선 + 요약 텍스트 (테두리/배경 없이).</summary>

View File

@@ -201,10 +201,11 @@ public partial class ChatWindow
var tb = new TextBlock
{
Text = $" {text}",
FontSize = 10.5,
FontSize = 12.5,
FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"),
Foreground = secondary,
Opacity = 0.60,
LineHeight = 18,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 380,
Margin = new Thickness(0, 0, 0, 1),
@@ -972,9 +973,10 @@ public partial class ChatWindow
submitEditBtn.Child = new TextBlock
{
Text = "피드백 전송",
FontSize = 12,
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White
Foreground = Brushes.White,
LineHeight = 18,
};
ApplyHoverScaleAnimation(submitEditBtn, 1.05);
submitEditBtn.MouseLeftButtonUp += (_, _) =>
@@ -1076,10 +1078,11 @@ public partial class ChatWindow
var resultLabel = new TextBlock
{
Text = resultText,
FontSize = 12,
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = fg,
Opacity = 0.8,
LineHeight = 18,
Margin = new Thickness(40, 2, 0, 2),
};
outerStack.Children.Add(resultLabel);

View File

@@ -87,7 +87,7 @@ public partial class ChatWindow
case "toggle_devmode":
var llm = _settings.Settings.Llm;
llm.DevMode = !llm.DevMode;
_settings.Save();
ScheduleSettingsSave();
UpdateAnalyzerButtonVisibility();
ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
break;

View File

@@ -80,10 +80,17 @@ public partial class ChatWindow
return;
// 코워크/코드 탭에서 작업 폴더 미지정 시 전송 차단
if (_activeTab is "Cowork" or "Code" && string.IsNullOrWhiteSpace(GetCurrentWorkFolder()))
// ★ SendMessageAsync(line 5317)와 동일한 엄격 검사: 대화의 WorkFolder만 본다
// (전역 폴백 허용 시 Queue는 통과하나 SendMessage에서 차단되어 입력만 지워지는 버그 방지)
if (_activeTab is "Cowork" or "Code")
{
HighlightFolderSelectButton();
return;
string? convWorkFolder;
lock (_convLock) convWorkFolder = _currentConversation?.WorkFolder;
if (string.IsNullOrWhiteSpace(convWorkFolder) || !System.IO.Directory.Exists(convWorkFolder))
{
HighlightFolderSelectButton();
return;
}
}
var text = BuildComposerDraftText();
@@ -178,6 +185,15 @@ public partial class ChatWindow
DraftPreviewText.Text = string.Empty;
RebuildDraftQueuePanel(items);
// Update queue badge
var queueCount = items?.Count(i => i.State == "queued") ?? 0;
if (QueueBadge != null)
{
QueueBadge.Visibility = queueCount > 0 ? Visibility.Visible : Visibility.Collapsed;
if (QueueBadgeText != null)
QueueBadgeText.Text = queueCount.ToString();
}
}
// --- Queue panel (Codex style) ---
@@ -442,6 +458,25 @@ public partial class ChatWindow
RefreshDraftQueueUi();
}
// Pop the last queued draft back into the editor (Alt+Up shortcut)
private void PopLastQueuedDraftToEditor()
{
string? lastId = null;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
var lastQueued = session.GetDraftQueueItems(_activeTab)
.LastOrDefault(x => string.Equals(x.State, "queued", StringComparison.OrdinalIgnoreCase));
lastId = lastQueued?.Id;
}
}
if (lastId != null)
PopDraftToEditor(lastId);
}
// --- Icon-only button (kept for compatibility) ---
private Button CreateIconButton(string icon, string tooltip, Action onClick)
=> CreateRowIconButton(icon, tooltip, onClick);

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -100,7 +101,7 @@ public partial class ChatWindow
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(vm.Id)), DispatcherPriority.Input);
}
private void HandleConversationItemClickById(string id, bool isSelected)
private async void HandleConversationItemClickById(string id, bool isSelected)
{
try
{
@@ -119,8 +120,15 @@ public partial class ChatWindow
// 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
StopStreamingIfActive();
var conv = _storage.Load(id);
if (conv == null) return;
var conv = await _storage.LoadAsync(id);
if (conv == null)
{
// 파일이 손상되었거나 존재하지 않는 유령 항목 — 메타 캐시에서 제거 후 목록 갱신
LogService.Warn($"대화 로드 실패 (유령 항목 제거): {id}");
_storage.RemoveFromMetaCache(id);
RefreshConversationList();
return;
}
lock (_convLock)
{
@@ -234,11 +242,18 @@ public partial class ChatWindow
var conv = _storage.Load(tag.Id);
if (conv == null)
{
LogService.Error($"대화 로드 실패: {tag.Id} — 파일이 손상되었거나 복호화할 수 없습니다.");
ShowToast("대화 내역을 불러올 수 없습니다. 파일이 손상되었을 수 있습니다.", "\uE783");
return;
}
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
// 저장된 대화를 다시 열 때 실행 이력을 완전히 표시 (스트리밍 중 접힌 상태 해제)
if (_currentConversation != null)
_currentConversation.ShowExecutionHistory = true;
SyncTabConversationIdsFromSession();
}
@@ -264,6 +279,8 @@ public partial class ChatWindow
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
if (_currentConversation != null)
_currentConversation.ShowExecutionHistory = true;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
@@ -276,9 +293,9 @@ public partial class ChatWindow
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(tag.Id)), DispatcherPriority.Input);
}
public void RefreshConversationList()
public async void RefreshConversationList()
{
var metas = _storage.LoadAllMeta();
var metas = await Task.Run(() => _storage.LoadAllMeta());
var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
.Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets))
.Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.Llm.CustomPresets));
@@ -421,7 +438,7 @@ public partial class ChatWindow
// 저장된 스크롤 위치 복원
if (hasScrollViewer && savedScrollOffset > 0)
{
Dispatcher.BeginInvoke(new Action(() =>
_ = Dispatcher.BeginInvoke(new Action(() =>
{
ConversationListScrollViewer?.ScrollToVerticalOffset(savedScrollOffset);
}), System.Windows.Threading.DispatcherPriority.Loaded);
@@ -720,7 +737,7 @@ public partial class ChatWindow
Background = isSelected
? new SolidColorBrush(Color.FromArgb(0x10, 0x4B, 0x5E, 0xFC))
: Brushes.Transparent,
CornerRadius = new CornerRadius(5),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(7, 4.5, 7, 4.5),
Margin = isBranch ? new Thickness(10, 1, 0, 1) : new Thickness(0, 1, 0, 1),
Cursor = Cursors.Hand,

View File

@@ -37,7 +37,7 @@ public partial class ChatWindow
BuildFileTree();
}
_settings.Save();
ScheduleSettingsSave();
}
private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
@@ -125,6 +125,18 @@ public partial class ChatWindow
}
}
/// <summary>파일 트리 노드의 원시 데이터(디스크 I/O 결과) — UI 스레드 외부에서 안전하게 수집.</summary>
private sealed class FileTreeNodeData
{
public string FullName { get; set; } = "";
public string DisplayName { get; set; } = "";
public bool IsDirectory { get; set; }
public long Size { get; set; }
public int Depth { get; set; }
public bool HasChildren { get; set; }
public List<FileTreeNodeData> Children { get; set; } = new();
}
private void BuildFileTree()
{
// A-3: Clear 전에 핸들러 명시적 해제 — 클로저 참조로 인한 GC 방해 방지
@@ -139,8 +151,132 @@ public partial class ChatWindow
}
FileBrowserTitle.Text = $"파일 탐색기 — {Path.GetFileName(folder)}";
var count = 0;
PopulateDirectory(new DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
// 로딩 상태 표시
var loadingItem = new TreeViewItem { Header = "로딩 중...", IsEnabled = false };
FileTreeView.Items.Add(loadingItem);
// I/O는 백그라운드 스레드에서 수행. UI 빌드는 Dispatcher에서.
_ = Task.Run(() =>
{
var nodes = new List<FileTreeNodeData>();
var count = 0;
try
{
CollectDirectoryNodes(new DirectoryInfo(folder), nodes, 0, ref count);
}
catch { /* 읽기 실패는 무시 */ }
Dispatcher.BeginInvoke(new Action(() =>
{
try
{
if (FileTreeView.Items.Contains(loadingItem))
FileTreeView.Items.Remove(loadingItem);
foreach (var n in nodes)
{
var item = CreateTreeViewItemFromData(n);
if (item != null) FileTreeView.Items.Add(item);
}
}
catch { }
}), DispatcherPriority.Background);
});
}
/// <summary>백그라운드 스레드에서 실행 — 파일/폴더 메타데이터만 수집 (UI 객체 생성 X).</summary>
private void CollectDirectoryNodes(DirectoryInfo dir, List<FileTreeNodeData> bucket, int depth, ref int count)
{
if (depth > 4 || count > 200) return;
try
{
foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
{
if (count > 200) break;
if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue;
count++;
var node = new FileTreeNodeData
{
FullName = subDir.FullName,
DisplayName = subDir.Name,
IsDirectory = true,
Depth = depth,
HasChildren = depth < 3,
};
if (depth >= 3)
{
CollectDirectoryNodes(subDir, node.Children, depth + 1, ref count);
}
bucket.Add(node);
}
}
catch { }
try
{
foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
{
if (count > 200) break;
count++;
bucket.Add(new FileTreeNodeData
{
FullName = file.FullName,
DisplayName = file.Name,
IsDirectory = false,
Size = file.Length,
Depth = depth,
});
}
}
catch { }
}
/// <summary>UI 스레드 — FileTreeNodeData → TreeViewItem 변환.</summary>
private TreeViewItem? CreateTreeViewItemFromData(FileTreeNodeData node)
{
try
{
if (node.IsDirectory)
{
var dirItem = new TreeViewItem
{
Header = CreateSurfaceFileTreeHeader("\uED25", node.DisplayName, null),
Tag = node.FullName,
IsExpanded = node.Depth < 1,
};
if (node.HasChildren)
{
dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." });
dirItem.Expanded += FileTreeItem_Expanded;
}
else
{
foreach (var child in node.Children)
{
var childItem = CreateTreeViewItemFromData(child);
if (childItem != null) dirItem.Items.Add(childItem);
}
}
return dirItem;
}
var ext = Path.GetExtension(node.DisplayName).ToLowerInvariant();
var icon = GetFileIcon(ext);
var size = FormatFileSize(node.Size);
var fileItem = new TreeViewItem
{
Header = CreateSurfaceFileTreeHeader(icon, node.DisplayName, size),
Tag = node.FullName,
};
fileItem.MouseDoubleClick += FileTreeItem_DoubleClick;
fileItem.MouseRightButtonUp += FileTreeItem_RightClick;
return fileItem;
}
catch
{
return null;
}
}
private void PopulateDirectory(DirectoryInfo dir, ItemCollection items, int depth, ref int count)

View File

@@ -33,6 +33,30 @@ public partial class ChatWindow
private bool _fileMentionIndexBuildPending;
private const int FileMentionIndexLimit = 4000;
// 키입력마다 정규식+후보 필터링이 도는 것을 막기 위한 디바운스 타이머.
private System.Windows.Threading.DispatcherTimer? _fileMentionDebounceTimer;
private string _fileMentionPendingText = "";
private void ScheduleFileMentionRefresh(string text)
{
_fileMentionPendingText = text;
if (_fileMentionDebounceTimer == null)
{
_fileMentionDebounceTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(120),
};
_fileMentionDebounceTimer.Tick += (_, _) =>
{
_fileMentionDebounceTimer!.Stop();
try { RefreshFileMentionSuggestions(_fileMentionPendingText); }
catch { }
};
}
_fileMentionDebounceTimer.Stop();
_fileMentionDebounceTimer.Start();
}
private void RefreshFileMentionSuggestions(string text)
{
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)

View File

@@ -15,135 +15,7 @@ public partial class ChatWindow
if (MessageList == null) return;
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return;
// V2 분기
if (_settings.Settings.Llm.EnableNewChatRendering)
{
ShowAgentLiveCardV2(runTab);
return;
}
RemoveAgentLiveCard(animated: false);
_agentLiveStartTime = DateTime.UtcNow;
_agentLiveSubItemTexts.Clear();
_agentLiveCurrentCategory = null;
var msgMaxWidth = GetMessageMaxWidth();
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0, 4, 0, 6),
Opacity = IsLightweightLiveProgressMode(runTab) ? 1 : 0,
RenderTransform = IsLightweightLiveProgressMode(runTab)
? Transform.Identity
: new TranslateTransform(0, 8),
};
if (!IsLightweightLiveProgressMode(runTab))
{
container.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(260)));
((TranslateTransform)container.RenderTransform).BeginAnimation(
TranslateTransform.YProperty,
new DoubleAnimation(8, 0, TimeSpan.FromMilliseconds(280))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
});
}
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 3) };
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var (agentName, _, _) = GetAgentIdentity();
var (liveIconHost, livePixels, liveGlows, liveRotate, liveScale) = CreateMiniLauncherIconEx(4.0, "none");
{
// 모든 모드에서 동일한 순차 점멸 애니메이션 적용
var canvas = liveIconHost.Children.OfType<Canvas>().FirstOrDefault();
if (canvas != null)
{
var animState = new ChatIconAnimState
{
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
};
StartChatIconAnimation(animState);
}
}
Grid.SetColumn(liveIconHost, 0);
headerGrid.Children.Add(liveIconHost);
var nameTb = new TextBlock
{
Text = agentName,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(nameTb, 1);
headerGrid.Children.Add(nameTb);
_agentLiveElapsedText = new TextBlock
{
Text = "",
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.50,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(_agentLiveElapsedText, 2);
headerGrid.Children.Add(_agentLiveElapsedText);
container.Children.Add(headerGrid);
var card = new Border
{
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(13, 10, 13, 10),
};
card.SetResourceReference(Border.BackgroundProperty, "ItemBackground");
var cardStack = new StackPanel();
_agentLiveStatusText = new TextBlock
{
Text = "준비 중...",
FontSize = 12,
FontFamily = s_segoeUiFont,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
};
cardStack.Children.Add(_agentLiveStatusText);
_agentLiveSubItems = new StackPanel { Margin = new Thickness(0, 6, 0, 0) };
cardStack.Children.Add(_agentLiveSubItems);
card.Child = cardStack;
container.Children.Add(card);
_agentLiveContainer = container;
AddTranscriptElement(container);
ForceScrollToEnd();
_agentLiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_agentLiveElapsedTimer.Tick += (_, _) =>
{
if (_agentLiveElapsedText == null)
return;
var sec = (int)(DateTime.UtcNow - _agentLiveStartTime).TotalSeconds;
_agentLiveElapsedText.Text = sec > 0 ? $"{sec}초 경과" : "";
};
_agentLiveElapsedTimer.Start();
ShowAgentLiveCardV2(runTab);
}
private void UpdateAgentLiveCard(string message, string? subItem = null,

View File

@@ -4,6 +4,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using AxCopilot.Models;
using AxCopilot.Services;
@@ -40,19 +41,18 @@ public partial class ChatWindow
var msgMaxWidth = GetMessageMaxWidth();
var wrapper = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0, 4, 0, 6),
HorizontalAlignment = HorizontalAlignment.Right,
MaxWidth = msgMaxWidth * 0.85,
Margin = new Thickness(60, 8, 12, 10), // 좌측 여백 넓게 → 우측에 붙음
};
var bubble = new Border
{
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 10, 14, 10),
HorizontalAlignment = HorizontalAlignment.Stretch,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16, 12, 16, 12),
HorizontalAlignment = HorizontalAlignment.Right,
};
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
@@ -73,10 +73,10 @@ public partial class ChatWindow
{
Text = content,
TextAlignment = TextAlignment.Left,
FontSize = 12,
FontSize = 14,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 18,
LineHeight = 22,
};
}
@@ -86,7 +86,7 @@ public partial class ChatWindow
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Opacity = 0.8,
Opacity = 0,
Margin = new Thickness(0, 2, 0, 0),
};
var capturedUserContent = content;
@@ -110,7 +110,7 @@ public partial class ChatWindow
{
Text = timestamp.ToString("HH:mm"),
FontSize = 10.5,
Opacity = 0.52,
Opacity = 0,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
@@ -119,8 +119,21 @@ public partial class ChatWindow
Grid.SetColumn(timestampText, 2);
userBottomBar.Children.Add(timestampText);
wrapper.Children.Add(userBottomBar);
wrapper.MouseEnter += (_, _) => userActionBar.Opacity = 1;
wrapper.MouseLeave += (_, _) => userActionBar.Opacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1 : 0.8;
wrapper.MouseEnter += (_, _) =>
{
userActionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(0.7, TimeSpan.FromMilliseconds(150)));
timestampText.BeginAnimation(OpacityProperty,
new DoubleAnimation(0.5, TimeSpan.FromMilliseconds(150)));
};
wrapper.MouseLeave += (_, _) =>
{
var targetOpacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1.0 : 0.0;
userActionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200)));
timestampText.BeginAnimation(OpacityProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(200)));
};
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
var userContent = content;
@@ -150,10 +163,9 @@ public partial class ChatWindow
var assistantMaxWidth = GetMessageMaxWidth();
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Width = assistantMaxWidth,
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = assistantMaxWidth,
Margin = new Thickness(0, 6, 0, 6),
Margin = new Thickness(12, 10, 60, 10), // 우측 여백 넓게 → 좌측에 붙음
};
if (animate)
ApplyMessageEntryAnimation(container);
@@ -165,10 +177,10 @@ public partial class ChatWindow
header.Children.Add(new TextBlock
{
Text = agentName,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
FontSize = 12,
FontWeight = FontWeights.Medium,
Foreground = secondaryText,
Margin = new Thickness(4, 0, 0, 0),
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
// 아이콘 애니메이션 적용
@@ -327,7 +339,7 @@ public partial class ChatWindow
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(2, 2, 0, 0),
Opacity = 0.8,
Opacity = 0,
};
var btnColor = secondaryText;
var capturedContent = content;
@@ -356,8 +368,17 @@ public partial class ChatWindow
if (assistantMeta != null)
container.Children.Add(assistantMeta);
container.MouseEnter += (_, _) => actionBar.Opacity = 1;
container.MouseLeave += (_, _) => actionBar.Opacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1 : 0.8;
container.MouseEnter += (_, _) =>
{
actionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(1, TimeSpan.FromMilliseconds(150)));
};
container.MouseLeave += (_, _) =>
{
var targetOpacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1.0 : 0.0;
actionBar.BeginAnimation(OpacityProperty,
new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200)));
};
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
var aiContent = content;

View File

@@ -810,17 +810,17 @@ public partial class ChatWindow
}
element.Opacity = 0;
element.RenderTransform = new TranslateTransform(0, 16);
element.RenderTransform = new TranslateTransform(0, 10);
element.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350))
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(250))
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
});
((TranslateTransform)element.RenderTransform).BeginAnimation(
TranslateTransform.YProperty,
new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400))
new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(280))
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
});
}

View File

@@ -261,7 +261,7 @@ public partial class ChatWindow
if (_isInlineSettingsSyncing)
return;
_settings.Settings.Llm.Service = capturedService;
_settings.Save();
ScheduleSettingsSave();
UpdateModelLabel();
RefreshInlineSettingsPanel();
};
@@ -331,7 +331,7 @@ public partial class ChatWindow
if (_isInlineSettingsSyncing)
return;
_settings.Settings.Llm.Model = capturedId;
_settings.Save();
ScheduleSettingsSave();
UpdateModelLabel();
RefreshInlineSettingsPanel();
SetStatus($"모델 전환: {capturedLabel}", spinning: false);
@@ -360,8 +360,27 @@ public partial class ChatWindow
private void OpenAgentSettingsWindow()
{
RefreshOverlaySettingsPanel();
// Scale + fade-in animation
AgentSettingsOverlay.RenderTransform = new ScaleTransform(0.98, 0.98);
AgentSettingsOverlay.RenderTransformOrigin = new Point(0.5, 0.5);
AgentSettingsOverlay.Opacity = 0;
AgentSettingsOverlay.Visibility = Visibility.Visible;
var ease = new System.Windows.Media.Animation.CubicEase();
var scaleXAnim = new System.Windows.Media.Animation.DoubleAnimation(0.98, 1.0, TimeSpan.FromMilliseconds(200)) { EasingFunction = ease };
var scaleYAnim = new System.Windows.Media.Animation.DoubleAnimation(0.98, 1.0, TimeSpan.FromMilliseconds(200)) { EasingFunction = ease };
var fadeAnim = new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180));
((ScaleTransform)AgentSettingsOverlay.RenderTransform).BeginAnimation(ScaleTransform.ScaleXProperty, scaleXAnim);
((ScaleTransform)AgentSettingsOverlay.RenderTransform).BeginAnimation(ScaleTransform.ScaleYProperty, scaleYAnim);
AgentSettingsOverlay.BeginAnimation(UIElement.OpacityProperty, fadeAnim);
InlineSettingsPanel.IsOpen = false;
// 탭 RadioButton을 "공통"으로 확실히 리셋 — 재진입 시 탭/패널 불일치 방지
if (OverlayNavBasic != null)
OverlayNavBasic.IsChecked = true;
SetOverlaySection("basic");
Dispatcher.BeginInvoke(() =>
{
@@ -425,12 +444,14 @@ public partial class ChatWindow
llm.EnableHookPermissionUpdate = ChkOverlayEnableHookPermissionUpdate?.IsChecked == true;
llm.EnableCoworkVerification = ChkOverlayEnableCoworkVerification?.IsChecked == true;
llm.CoworkOnComplete = (CmbOverlayCoworkOnComplete?.SelectedItem as System.Windows.Controls.ComboBoxItem)?.Tag?.ToString() ?? "none";
llm.AutoPreview = (CmbOverlayAutoPreview?.SelectedItem as System.Windows.Controls.ComboBoxItem)?.Tag?.ToString() ?? "off";
llm.Code.EnableCodeVerification = ChkOverlayEnableCodeVerification?.IsChecked == true;
llm.Code.EnableCodeReview = ChkOverlayEnableCodeReview?.IsChecked == true;
llm.EnableImageInput = ChkOverlayEnableImageInput?.IsChecked == true;
llm.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true;
llm.EnableProjectRules = ChkOverlayEnableProjectRules?.IsChecked == true;
llm.EnableAgentMemory = ChkOverlayEnableAgentMemory?.IsChecked == true;
llm.EnableIbmDiagnosticLog = ChkOverlayEnableIbmDiagnosticLog?.IsChecked == true;
llm.Code.EnableWorktreeTools = ChkOverlayEnableWorktreeTools?.IsChecked == true;
llm.Code.EnableTeamTools = ChkOverlayEnableTeamTools?.IsChecked == true;
llm.Code.EnableCronTools = ChkOverlayEnableCronTools?.IsChecked == true;
@@ -442,8 +463,7 @@ public partial class ChatWindow
_settings.Settings.Launcher.EnableChatIconRandomAnimation = ChkOverlayEnableChatIconRandomAnim?.IsChecked == true;
_settings.Settings.Launcher.ChatIconGlowIntensity =
(CmbOverlayChatIconGlow?.SelectedItem as System.Windows.Controls.ComboBoxItem)?.Tag?.ToString() ?? "medium";
llm.EnableNewPlanViewer = ChkOverlayEnableNewPlanViewer?.IsChecked == true;
llm.EnableNewChatRendering = ChkOverlayEnableNewChatRendering?.IsChecked == true;
// V2 뷰어/렌더링 전환 완료 — 항상 V2 사용
CommitOverlayEndpointInput(normalizeOnInvalid: true);
CommitOverlayApiKeyInput();
@@ -467,7 +487,16 @@ public partial class ChatWindow
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
if (closeOverlay)
AgentSettingsOverlay.Visibility = Visibility.Collapsed;
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(120));
fadeOut.Completed += (_, _) =>
{
AgentSettingsOverlay.Visibility = Visibility.Collapsed;
AgentSettingsOverlay.BeginAnimation(UIElement.OpacityProperty, null);
AgentSettingsOverlay.Opacity = 1;
};
AgentSettingsOverlay.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
if (showToast)
ShowToast("AX Agent 설정이 저장되었습니다.");
InputBox.Focus();
@@ -475,7 +504,7 @@ public partial class ChatWindow
private void PersistOverlaySettingsState(bool refreshOverlayDeferredInputs)
{
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
ApplyAgentThemeResources();
UpdatePermissionUI();
@@ -628,6 +657,7 @@ public partial class ChatWindow
if (ChkOverlayEnableCoworkVerification != null)
ChkOverlayEnableCoworkVerification.IsChecked = llm.EnableCoworkVerification;
SelectComboBoxByTag(CmbOverlayCoworkOnComplete, llm.CoworkOnComplete);
SelectComboBoxByTag(CmbOverlayAutoPreview, llm.AutoPreview);
if (ChkOverlayEnableCodeVerification != null)
ChkOverlayEnableCodeVerification.IsChecked = llm.Code.EnableCodeVerification;
if (ChkOverlayEnableCodeReview != null)
@@ -640,6 +670,8 @@ public partial class ChatWindow
ChkOverlayEnableProjectRules.IsChecked = llm.EnableProjectRules;
if (ChkOverlayEnableAgentMemory != null)
ChkOverlayEnableAgentMemory.IsChecked = llm.EnableAgentMemory;
if (ChkOverlayEnableIbmDiagnosticLog != null)
ChkOverlayEnableIbmDiagnosticLog.IsChecked = llm.EnableIbmDiagnosticLog;
if (ChkOverlayEnableWorktreeTools != null)
ChkOverlayEnableWorktreeTools.IsChecked = llm.Code.EnableWorktreeTools;
if (ChkOverlayEnableTeamTools != null)
@@ -664,10 +696,7 @@ public partial class ChatWindow
ChkOverlayEnableChatIconRandomAnim.IsChecked = _settings.Settings.Launcher.EnableChatIconRandomAnimation;
if (CmbOverlayChatIconGlow != null)
SelectComboBoxByTag(CmbOverlayChatIconGlow, _settings.Settings.Launcher.ChatIconGlowIntensity ?? "medium");
if (ChkOverlayEnableNewPlanViewer != null)
ChkOverlayEnableNewPlanViewer.IsChecked = llm.EnableNewPlanViewer;
if (ChkOverlayEnableNewChatRendering != null)
ChkOverlayEnableNewChatRendering.IsChecked = llm.EnableNewChatRendering;
// V2 뷰어/렌더링 전환 완료 — 토글 제거됨
}
RefreshOverlayThemeCards();
@@ -1583,6 +1612,8 @@ public partial class ChatWindow
OverlayToggleCoworkVerification.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCoworkOnComplete != null)
OverlayToggleCoworkOnComplete.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleAutoPreview != null)
OverlayToggleAutoPreview.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCodeVerification != null)
OverlayToggleCodeVerification.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCodeReview != null)
@@ -1593,6 +1624,8 @@ public partial class ChatWindow
OverlayToggleProjectRules.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleAgentMemory != null)
OverlayToggleAgentMemory.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleIbmDiagnostic != null)
OverlayToggleIbmDiagnostic.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleWorktreeTools != null)
OverlayToggleWorktreeTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleTeamTools != null)
@@ -1605,8 +1638,7 @@ public partial class ChatWindow
OverlaySectionGlowEffects.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
if (OverlaySectionIconEffects != null)
OverlaySectionIconEffects.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
if (OverlaySectionPlanViewer != null)
OverlaySectionPlanViewer.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
// V2 전환 완료 — OverlaySectionPlanViewer 제거됨
if (showTools || showSkill || showBlock)
RefreshOverlayEtcPanels();
@@ -2814,7 +2846,7 @@ public partial class ChatWindow
=> "데이터 처리",
"clipboard_tool" or "notify_tool" or "env_tool" or "zip_tool" or "http_tool" or "open_external" or "image_analyze" or "file_watch"
=> "시스템/환경",
"spawn_agent" or "wait_agents" or "memory" or "skill_manager" or "user_ask" or "task_tracker" or "todo_write" or "task_create" or "task_get" or "task_list" or "task_update" or "task_stop" or "task_output" or "enter_plan_mode" or "exit_plan_mode" or "enter_worktree" or "exit_worktree" or "team_create" or "team_delete" or "cron_create" or "cron_delete" or "cron_list" or "suggest_actions" or "checkpoint" or "playbook"
"spawn_agent" or "spawn_agents" or "wait_agents" or "memory" or "skill_manager" or "user_ask" or "task_tracker" or "todo_write" or "task_create" or "task_get" or "task_list" or "task_update" or "task_stop" or "task_output" or "enter_plan_mode" or "exit_plan_mode" or "enter_worktree" or "exit_worktree" or "team_create" or "team_delete" or "cron_create" or "cron_delete" or "cron_list" or "suggest_actions" or "checkpoint" or "playbook"
=> "에이전트",
_ => "기타"
};
@@ -2859,7 +2891,7 @@ public partial class ChatWindow
SetOverlayCardSelection(OverlayThemeLightCard, selected == "light");
SetOverlayCardSelection(OverlayThemeDarkCard, selected == "dark");
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claude").ToLowerInvariant();
SetOverlayCardSelection(OverlayThemeStyleClawCard, preset is "claw" or "claude");
SetOverlayCardSelection(OverlayThemeStyleClaudeCard, preset == "claude");
SetOverlayCardSelection(OverlayThemeStyleCodexCard, preset == "codex");
SetOverlayCardSelection(OverlayThemeStyleNordCard, preset == "nord");
SetOverlayCardSelection(OverlayThemeStyleEmberCard, preset == "ember");
@@ -3312,7 +3344,7 @@ public partial class ChatWindow
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeStyleClawCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
private void OverlayThemeStyleClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentThemePreset = "claude";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
@@ -3426,7 +3458,8 @@ public partial class ChatWindow
private void CmbOverlayAutoPreview_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// 제거됨 (중복 설정 항목)
if (_isOverlaySettingsSyncing) return;
ApplyOverlaySettingsChanges(showToast: false, closeOverlay: false);
}
private void CmbOverlayOperationMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
@@ -3563,7 +3596,7 @@ public partial class ChatWindow
if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model))
llm.Model = candidates[0].Id;
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdateModelLabel();
RefreshInlineSettingsPanel();
@@ -3575,7 +3608,7 @@ public partial class ChatWindow
return;
_settings.Settings.Llm.Model = modelId;
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdateModelLabel();
RefreshInlineSettingsPanel();
@@ -3584,7 +3617,7 @@ public partial class ChatWindow
private void BtnInlineFastMode_Click(object sender, RoutedEventArgs e)
{
_settings.Settings.Llm.FreeTierMode = !_settings.Settings.Llm.FreeTierMode;
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
RefreshOverlayVisualState(loadDeferredInputs: false);
@@ -3594,7 +3627,7 @@ public partial class ChatWindow
{
var llm = _settings.Settings.Llm;
llm.AgentDecisionLevel = NextReasoning(llm.AgentDecisionLevel);
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
RefreshOverlayVisualState(loadDeferredInputs: false);
@@ -3604,7 +3637,7 @@ public partial class ChatWindow
{
var llm = _settings.Settings.Llm;
llm.FilePermission = NextPermission(llm.FilePermission);
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
@@ -3623,7 +3656,7 @@ public partial class ChatWindow
UpdateConditionalSkillActivation(reset: true);
}
_settings.Save();
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();

View File

@@ -85,7 +85,7 @@ public partial class ChatWindow
private void ApplyPermissionLevel(string level)
{
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(level);
try { _settings.Save(); } catch { }
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
@@ -97,6 +97,14 @@ public partial class ChatWindow
private void BtnPermission_Click(object sender, RoutedEventArgs e)
{
if (PermissionPopup == null) return;
// Dynamically retarget popup to whichever button was clicked (FolderBar or Inline)
if (sender is UIElement clickedElement &&
(ReferenceEquals(clickedElement, BtnPermissionInline) || ReferenceEquals(clickedElement, BtnPermission)))
{
PermissionPopup.PlacementTarget = clickedElement;
}
InitPermissionPanelDelegation();
_lastHoveredPermBorder = null;
PermissionItems.Children.Clear();
@@ -233,7 +241,7 @@ public partial class ChatWindow
toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode);
}
try { _settings.Save(); } catch { }
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
@@ -257,7 +265,37 @@ public partial class ChatWindow
{
var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
map[sectionKey] = expanded;
try { _settings.Save(); } catch { }
ScheduleSettingsSave();
}
/// <summary>Shift+Tab으로 권한 모드를 순환합니다 (Claude Code 스타일).</summary>
private void CyclePermissionMode()
{
var llm = _settings.Settings.Llm;
llm.FilePermission = NextPermission(llm.FilePermission);
ScheduleSettingsSave();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
// Toast 알림
var label = PermissionModeCatalog.ToDisplayLabel(llm.FilePermission);
var icon = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission) switch
{
"Plan" => "\uE769",
"AcceptEdits" => "\uE73E",
"BypassPermissions" => "\uE7BA",
"Deny" => "\uE711",
_ => "\uE8D7",
};
ShowToast(label, icon);
}
private void PlanModeBannerClose_Click(object sender, MouseButtonEventArgs e)
{
if (PlanModeBanner != null)
PlanModeBanner.Visibility = Visibility.Collapsed;
}
private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e)
@@ -269,18 +307,26 @@ public partial class ChatWindow
private void UpdatePermissionUI()
{
if (PermissionLabel == null || PermissionIcon == null) return;
// 계획 모드 배너 기본 숨김 — Plan 분기에서만 표시
if (PlanModeBanner != null)
PlanModeBanner.Visibility = Visibility.Collapsed;
ChatConversation? currentConversation;
lock (_convLock) currentConversation = _currentConversation;
var summary = _appState.GetPermissionSummary(currentConversation);
var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm);
if (PermissionLabelInline != null) PermissionLabelInline.Text = PermissionLabel.Text;
PermissionIcon.Text = perm switch
{
"AcceptEdits" => "\uE73E",
"Plan" => "\uE769",
"BypassPermissions" => "\uE7BA",
"Deny" => "\uE711",
_ => "\uE8D7",
};
if (PermissionIconInline != null) PermissionIconInline.Text = PermissionIcon.Text;
if (BtnPermission != null)
{
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
@@ -296,7 +342,9 @@ public partial class ChatWindow
{
var activeColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
PermissionLabel.Foreground = activeColor;
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = activeColor;
PermissionIcon.Foreground = activeColor;
if (PermissionIconInline != null) PermissionIconInline.Foreground = activeColor;
if (BtnPermission != null)
BtnPermission.BorderBrush = BrushFromHex("#86EFAC");
if (PermissionTopBanner != null)
@@ -314,7 +362,9 @@ public partial class ChatWindow
{
var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
PermissionLabel.Foreground = denyColor;
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = denyColor;
PermissionIcon.Foreground = denyColor;
if (PermissionIconInline != null) PermissionIconInline.Foreground = denyColor;
if (BtnPermission != null)
BtnPermission.BorderBrush = BrushFromHex("#86EFAC");
if (PermissionTopBanner != null)
@@ -324,15 +374,40 @@ public partial class ChatWindow
PermissionTopBannerIcon.Foreground = denyColor;
PermissionTopBannerTitle.Text = "현재 권한 모드 · 읽기 전용";
PermissionTopBannerTitle.Foreground = denyColor;
PermissionTopBannerText.Text = "파일 읽기만 허용하고 생성, 수정, 삭제 차단합니다.";
PermissionTopBannerText.Text = "기존 파일 읽기만 가능하며 수정/삭제 차단되고, 새 파일 생성은 가능합니다.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
}
else if (perm == PermissionModeCatalog.Plan)
{
var planColor = new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06));
PermissionLabel.Foreground = planColor;
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = planColor;
PermissionIcon.Foreground = planColor;
if (PermissionIconInline != null) PermissionIconInline.Foreground = planColor;
if (BtnPermission != null)
BtnPermission.BorderBrush = BrushFromHex("#FDE68A");
if (PermissionTopBanner != null)
{
PermissionTopBanner.BorderBrush = BrushFromHex("#FDE68A");
PermissionTopBannerIcon.Text = "\uE769";
PermissionTopBannerIcon.Foreground = planColor;
PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드";
PermissionTopBannerTitle.Foreground = planColor;
PermissionTopBannerText.Text = "파일을 읽고 분석한 뒤, 실행 전에 계획을 먼저 보여줍니다.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
// 계획 모드 배너 표시
if (PlanModeBanner != null)
PlanModeBanner.Visibility = Visibility.Visible;
}
else if (perm == PermissionModeCatalog.BypassPermissions)
{
var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C));
PermissionLabel.Foreground = autoColor;
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = autoColor;
PermissionIcon.Foreground = autoColor;
if (PermissionIconInline != null) PermissionIconInline.Foreground = autoColor;
if (BtnPermission != null)
BtnPermission.BorderBrush = BrushFromHex("#FDBA74");
if (PermissionTopBanner != null)
@@ -351,7 +426,9 @@ public partial class ChatWindow
var defaultFg = BrushFromHex("#2563EB");
var iconFg = new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB));
PermissionLabel.Foreground = defaultFg;
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = defaultFg;
PermissionIcon.Foreground = iconFg;
if (PermissionIconInline != null) PermissionIconInline.Foreground = iconFg;
if (BtnPermission != null)
BtnPermission.BorderBrush = BrushFromHex("#BFDBFE");
if (PermissionTopBanner != null)

View File

@@ -18,11 +18,29 @@ public partial class ChatWindow
var isToolApproval = options.Contains("확인") && !options.Contains("승인");
if (isToolApproval)
{
string? toolResult = null;
await Dispatcher.InvokeAsync(() =>
// ToolApprovalWindow.Show는 Dispatcher에서 동기 호출된다.
// 장시간 미응답 시 에이전트 루프가 멈추는 것을 방지하기 위해 10분 가드를 두고,
// 타임아웃 발생 시 CancellationToken으로 다이얼로그 자체를 닫아 UI를 정리한다.
using var toolTimeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var toolTcs = new TaskCompletionSource<string?>();
_ = Dispatcher.InvokeAsync(() =>
{
toolResult = ToolApprovalWindow.Show(this, planSummary, options);
try
{
var r = ToolApprovalWindow.Show(this, planSummary, options, toolTimeoutCts.Token);
toolTcs.TrySetResult(r);
}
catch (Exception ex)
{
toolTcs.TrySetException(ex);
}
});
var toolResult = await toolTcs.Task;
if (toolTimeoutCts.IsCancellationRequested && string.IsNullOrEmpty(toolResult))
{
// 타임아웃 — 안전한 선택(중단)으로 폴백
return options.Contains("중단") ? "중단" : (options.Contains("취소") ? "취소" : "건너뛰기");
}
return toolResult;
}
@@ -88,18 +106,9 @@ public partial class ChatWindow
if (_planViewerWindow != null && IsPlanWindowAlive())
return;
if (_settings.Settings.Llm.EnableNewPlanViewer)
{
var v2 = new PlanViewerWindowV2(this);
v2.Closing += (_, e) => { e.Cancel = true; v2.Hide(); };
_planViewerWindow = v2;
}
else
{
var v1 = new PlanViewerWindow(this);
v1.Closing += (_, e) => { e.Cancel = true; v1.Hide(); };
_planViewerWindow = v1;
}
var v2 = new PlanViewerWindowV2(this);
v2.Closing += (_, e) => { e.Cancel = true; v2.Hide(); };
_planViewerWindow = v2;
}
private bool IsPlanWindowAlive() => IsWindowAlive(_planViewerWindow as Window);

View File

@@ -103,7 +103,7 @@ public partial class ChatWindow
{
_settings.Settings.Llm.RecentWorkFolders.RemoveAll(
p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase));
_settings.Save();
ScheduleSettingsSave();
if (FolderMenuPopup.IsOpen)
ShowFolderMenu();
}));

View File

@@ -31,6 +31,16 @@ public partial class ChatWindow
private bool _webViewInitialized;
private Popup? _previewTabPopup;
// ── Code/Preview 듀얼 모드 (HTML 전용, Claude Artifacts 스타일) ──
private enum PreviewViewMode { Preview, Code }
private PreviewViewMode _previewViewMode = PreviewViewMode.Preview;
private string? _currentHtmlSourceCache; // 코드 보기용 HTML 소스 캐시
// ── 패널 슬라이드 애니메이션 ──
private DispatcherTimer? _previewAnimTimer;
private double _previewAnimTarget;
private double _previewAnimCurrent;
// ── A-2: PreviewTabPanel 이벤트 위임 ──
private bool _previewTabDelegationInitialized;
private Border? _lastHoveredPreviewTab;
@@ -42,6 +52,48 @@ public partial class ChatWindow
public Border? CloseButton { get; init; }
}
/// <summary>
/// 도구 실행 결과로 생성된 문서 파일을 Preview 패널에 자동으로 엽니다.
/// html_create, docx_create 등 문서 생성 도구 완료 시 호출됩니다.
/// </summary>
internal void TryAutoPreviewFile(string? filePath, string? toolName)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
return;
var ext = Path.GetExtension(filePath);
if (!_previewableExtensions.Contains(ext))
return;
// 문서 생성 도구에서만 자동 열기
var isDocTool = toolName is "html_create" or "docx_create" or "excel_create"
or "xlsx_create" or "markdown_create" or "csv_create" or "pptx_create"
or "format_convert";
if (!isDocTool)
return;
// AutoPreview 설정 확인: off → 열지 않음, manual → 이미 열린 탭만 새로고침, auto → 자유 열기
var autoPreview = _settings.Settings.Llm.AutoPreview;
if (string.Equals(autoPreview, "off", StringComparison.OrdinalIgnoreCase))
return;
// 이미 열려 있으면 새로고침만 (manual/auto 모두)
if (_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
{
_activePreviewTab = filePath;
RebuildPreviewTabs();
LoadPreviewContent(filePath);
return;
}
// manual 모드: 패널이 이미 열려 있을 때만 새 탭 추가
if (string.Equals(autoPreview, "manual", StringComparison.OrdinalIgnoreCase)
&& PreviewPanel.Visibility != Visibility.Visible)
return;
ShowPreviewPanel(filePath);
}
private void InitPreviewTabDelegation()
{
if (_previewTabDelegationInitialized || PreviewTabPanel == null)
@@ -170,8 +222,12 @@ public partial class ChatWindow
if (PreviewColumn.Width.Value < 100)
{
PreviewColumn.Width = new GridLength(420);
var savedWidth = _settings.Settings.Llm.PreviewPanelWidth;
if (savedWidth < 200) savedWidth = 420;
SplitterColumn.Width = new GridLength(5);
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
AnimatePreviewColumn(savedWidth);
}
PreviewPanel.Visibility = Visibility.Visible;
@@ -345,11 +401,14 @@ public partial class ChatWindow
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
SetPreviewHeader(filePath);
await UpdatePreviewModeBarAsync(filePath);
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
PreviewEmpty.Visibility = Visibility.Collapsed;
if (BtnPreviewCopy != null) BtnPreviewCopy.Visibility = Visibility.Collapsed;
if (PreviewLineNumbers != null) PreviewLineNumbers.Text = "";
if (!File.Exists(filePath))
{
@@ -377,7 +436,7 @@ public partial class ChatWindow
case ".md":
await EnsureWebViewInitializedAsync();
var mdText = File.ReadAllText(filePath);
var mdText = await Task.Run(() => File.ReadAllText(filePath));
if (mdText.Length > 50000)
mdText = mdText[..50000];
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood);
@@ -410,11 +469,13 @@ public partial class ChatWindow
case ".json":
case ".xml":
case ".log":
var text = File.ReadAllText(filePath);
var text = await Task.Run(() => File.ReadAllText(filePath));
if (text.Length > 50000)
text = text[..50000] + "\n\n... (이후 생략)";
PreviewTextBlock.Text = text;
UpdateLineNumbers(text);
PreviewTextScroll.Visibility = Visibility.Visible;
if (BtnPreviewCopy != null) BtnPreviewCopy.Visibility = Visibility.Visible;
break;
default:
@@ -459,6 +520,156 @@ public partial class ChatWindow
PreviewHeaderMeta.Text = state;
}
// ── Code/Preview 듀얼 모드 UI ──────────────────────────────────────
/// <summary>HTML 파일인 경우 Code/Preview 모드 바를 표시하고, 아니면 숨깁니다.</summary>
private async Task UpdatePreviewModeBarAsync(string filePath)
{
if (PreviewModeBar == null) return;
var ext = Path.GetExtension(filePath).ToLowerInvariant();
var isHtml = ext is ".html" or ".htm";
PreviewModeBar.Visibility = isHtml ? Visibility.Visible : Visibility.Collapsed;
if (isHtml)
{
_previewViewMode = PreviewViewMode.Preview;
try
{
var fi = new FileInfo(filePath);
if (fi.Exists && fi.Length < 500_000)
_currentHtmlSourceCache = await Task.Run(() => File.ReadAllText(filePath));
else
_currentHtmlSourceCache = $"(파일이 너무 큽니다: {fi.Length / 1024}KB)";
}
catch
{
_currentHtmlSourceCache = null;
}
}
else
{
_currentHtmlSourceCache = null;
}
ApplyPreviewModeTabStyle();
}
/// <summary>현재 모드에 맞게 탭 스타일(배경·글꼴)을 업데이트합니다.</summary>
private void ApplyPreviewModeTabStyle()
{
if (PreviewModeCodeTab == null || PreviewModePreviewTab == null) return;
var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#2563EB");
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var activeBg = new SolidColorBrush(Color.FromArgb(0x18, 0x25, 0x63, 0xEB));
var isCode = _previewViewMode == PreviewViewMode.Code;
PreviewModeCodeTab.Background = isCode ? activeBg : Brushes.Transparent;
PreviewModePreviewTab.Background = !isCode ? activeBg : Brushes.Transparent;
if (PreviewModeCodeLabel != null)
{
PreviewModeCodeLabel.Foreground = isCode ? accentBrush : secondaryText;
PreviewModeCodeLabel.FontWeight = isCode ? FontWeights.SemiBold : FontWeights.Normal;
}
if (PreviewModePreviewLabel != null)
{
PreviewModePreviewLabel.Foreground = !isCode ? accentBrush : secondaryText;
PreviewModePreviewLabel.FontWeight = !isCode ? FontWeights.SemiBold : FontWeights.Normal;
}
}
private void PreviewModeCodeTab_Click(object sender, MouseButtonEventArgs e)
{
if (_previewViewMode == PreviewViewMode.Code) return;
_previewViewMode = PreviewViewMode.Code;
ApplyPreviewModeTabStyle();
ShowHtmlCodeView();
}
private void PreviewModePreviewTab_Click(object sender, MouseButtonEventArgs e)
{
if (_previewViewMode == PreviewViewMode.Preview) return;
_previewViewMode = PreviewViewMode.Preview;
ApplyPreviewModeTabStyle();
ShowHtmlPreviewView();
}
/// <summary>HTML 소스 코드 보기로 전환합니다.</summary>
private void ShowHtmlCodeView()
{
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
PreviewEmpty.Visibility = Visibility.Collapsed;
var source = string.IsNullOrWhiteSpace(_currentHtmlSourceCache)
? "(소스를 불러올 수 없습니다)"
: _currentHtmlSourceCache;
PreviewTextBlock.Text = source;
UpdateLineNumbers(source);
PreviewTextScroll.Visibility = Visibility.Visible;
if (BtnPreviewCopy != null) BtnPreviewCopy.Visibility = Visibility.Visible;
}
/// <summary>HTML 렌더링 미리보기로 전환합니다.</summary>
private void ShowHtmlPreviewView()
{
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
PreviewEmpty.Visibility = Visibility.Collapsed;
if (BtnPreviewCopy != null) BtnPreviewCopy.Visibility = Visibility.Collapsed;
if (!string.IsNullOrWhiteSpace(_activePreviewTab) && File.Exists(_activePreviewTab))
{
var ext = Path.GetExtension(_activePreviewTab).ToLowerInvariant();
if (ext is ".html" or ".htm")
{
try
{
PreviewWebView.Source = new Uri(_activePreviewTab);
PreviewWebView.Visibility = Visibility.Visible;
}
catch (Exception ex)
{
LogService.Warn($"HTML 미리보기 전환 실패: {ex.Message}");
PreviewEmpty.Text = "미리보기 전환 오류";
PreviewEmpty.Visibility = Visibility.Visible;
}
}
}
}
// ── 줄 번호 + 복사 ─────────────────────────────────────────
private void UpdateLineNumbers(string text)
{
if (PreviewLineNumbers == null) return;
var lineCount = text.Split('\n').Length;
var sb = new StringBuilder();
for (var i = 1; i <= lineCount; i++)
sb.AppendLine(i.ToString());
PreviewLineNumbers.Text = sb.ToString().TrimEnd();
}
private void BtnPreviewCopy_Click(object sender, RoutedEventArgs e)
{
var text = PreviewTextBlock?.Text;
if (string.IsNullOrEmpty(text)) return;
try
{
Clipboard.SetText(text);
ShowToast("클립보드에 복사했습니다", "\uE8C8");
}
catch (Exception ex)
{
LogService.Warn($"복사 실패: {ex.Message}");
}
}
private async Task EnsureWebViewInitializedAsync()
{
if (_webViewInitialized)
@@ -567,16 +778,27 @@ public partial class ChatWindow
{
_previewTabs.Clear();
_activePreviewTab = null;
_currentHtmlSourceCache = null;
if (PreviewModeBar != null)
PreviewModeBar.Visibility = Visibility.Collapsed;
if (BtnPreviewCopy != null)
BtnPreviewCopy.Visibility = Visibility.Collapsed;
if (PreviewIcon != null)
PreviewIcon.Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
UpdatePreviewChevronState();
if (PreviewHeaderTitle != null) PreviewHeaderTitle.Text = "미리보기";
if (PreviewHeaderSubtitle != null) PreviewHeaderSubtitle.Text = "선택한 파일이 여기에 표시됩니다";
if (PreviewHeaderMeta != null) PreviewHeaderMeta.Text = "파일 메타";
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
// 현재 너비 저장 후 슬라이드 아웃 애니메이션
SavePreviewPanelWidth();
AnimatePreviewColumn(0, () =>
{
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
SplitterColumn.Width = new GridLength(0);
});
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
@@ -590,6 +812,53 @@ public partial class ChatWindow
}
}
// ── 슬라이드 애니메이션 ──────────────────────────────────────
private void AnimatePreviewColumn(double targetWidth, Action? onComplete = null)
{
_previewAnimTimer?.Stop();
_previewAnimTarget = targetWidth;
_previewAnimCurrent = PreviewColumn.Width.Value;
if (Math.Abs(_previewAnimCurrent - targetWidth) < 2)
{
PreviewColumn.Width = new GridLength(targetWidth);
onComplete?.Invoke();
return;
}
_previewAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(12) };
_previewAnimTimer.Tick += (_, _) =>
{
_previewAnimCurrent += (_previewAnimTarget - _previewAnimCurrent) * 0.25;
if (Math.Abs(_previewAnimCurrent - _previewAnimTarget) < 2)
{
_previewAnimCurrent = _previewAnimTarget;
_previewAnimTimer.Stop();
PreviewColumn.Width = new GridLength(Math.Max(0, _previewAnimCurrent));
onComplete?.Invoke();
return;
}
PreviewColumn.Width = new GridLength(Math.Max(0, _previewAnimCurrent));
};
_previewAnimTimer.Start();
}
private void SavePreviewPanelWidth()
{
var width = PreviewColumn.ActualWidth;
if (width > 100)
{
_settings.Settings.Llm.PreviewPanelWidth = width;
ScheduleSettingsSave();
}
}
private void PreviewSplitter_DragCompleted(object sender, System.Windows.Controls.Primitives.DragCompletedEventArgs e)
{
SavePreviewPanelWidth();
}
private void PreviewTabBar_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin)
@@ -620,19 +889,24 @@ public partial class ChatWindow
if (PreviewPanel.Visibility == Visibility.Visible)
{
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
SavePreviewPanelWidth();
if (PreviewIcon != null) PreviewIcon.Foreground = secondaryText;
AnimatePreviewColumn(0, () =>
{
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
SplitterColumn.Width = new GridLength(0);
});
}
else if (_previewTabs.Count > 0)
{
var savedWidth = _settings.Settings.Llm.PreviewPanelWidth;
if (savedWidth < 200) savedWidth = 420;
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
if (PreviewIcon != null) PreviewIcon.Foreground = accentBrush;
AnimatePreviewColumn(savedWidth);
RebuildPreviewTabs();
if (_activePreviewTab != null)
LoadPreviewContent(_activePreviewTab);
@@ -875,7 +1149,7 @@ public partial class ChatWindow
_previewTabPopup.IsOpen = true;
}
private void OpenPreviewPopupWindow(string filePath)
private async void OpenPreviewPopupWindow(string filePath)
{
if (!File.Exists(filePath))
return;
@@ -927,7 +1201,7 @@ public partial class ChatWindow
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await mdWv.EnsureCoreWebView2Async(env);
var mdSrc = File.ReadAllText(filePath);
var mdSrc = await Task.Run(() => File.ReadAllText(filePath));
if (mdSrc.Length > 100000)
mdSrc = mdSrc[..100000];
var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
@@ -978,7 +1252,7 @@ public partial class ChatWindow
break;
default:
var text = File.ReadAllText(filePath);
var text = await Task.Run(() => File.ReadAllText(filePath));
if (text.Length > 100000)
text = text[..100000] + "\n\n... (이후 생략)";
var sv = new ScrollViewer

View File

@@ -232,20 +232,15 @@ public partial class ChatWindow
if (viewportWidth < 200)
return false;
// claw-code처럼 메시지 축과 입력축이 같은 중심선을 공유하도록,
// 메시지 축과 입력축이 같은 중심선을 공유하도록
// 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다.
var contentWidth = Math.Max(360, viewportWidth - 24);
var messageWidth = Math.Clamp(contentWidth * 0.9, 360, 960);
var composerWidth = Math.Clamp(contentWidth * 0.86, 360, 900);
if (contentWidth < 760)
{
messageWidth = Math.Clamp(contentWidth - 10, 344, 820);
composerWidth = Math.Clamp(contentWidth - 14, 340, 780);
}
// 고정 최대폭 — 큰 창에서 안정적, 작은 창에서만 축소
var messageWidth = Math.Min(contentWidth - 10, 800);
var composerWidth = Math.Min(contentWidth - 24, 760);
var changed = false;
if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1)
if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 8)
{
_lastResponsiveMessageWidth = messageWidth;
if (MessageList != null)
@@ -255,7 +250,7 @@ public partial class ChatWindow
changed = true;
}
if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 1)
if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 8)
{
_lastResponsiveComposerWidth = composerWidth;
if (ComposerShell != null)
@@ -293,6 +288,7 @@ public partial class ChatWindow
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) };
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var aiIcon = new TextBlock
@@ -320,6 +316,29 @@ public partial class ChatWindow
Grid.SetColumn(aiNameTb, 1);
headerGrid.Children.Add(aiNameTb);
// 유니코드 스피너 (에이전트 이름 옆)
var spinChars = new[] { "\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "\u273d" };
var spinIndex = 0;
var spinnerText = new TextBlock
{
Text = spinChars[0],
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(6, 0, 0, 0),
};
spinnerText.SetResourceReference(TextBlock.ForegroundProperty, "AccentColor");
var spinTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(333) };
spinTimer.Tick += (_, _) =>
{
spinIndex = (spinIndex + 1) % spinChars.Length;
spinnerText.Text = spinChars[spinIndex];
};
spinTimer.Start();
_activeSpinnerTimer = spinTimer;
Grid.SetColumn(spinnerText, 2);
headerGrid.Children.Add(spinnerText);
// 실시간 경과 시간 (헤더 우측)
_elapsedLabel = new TextBlock
{
@@ -330,7 +349,7 @@ public partial class ChatWindow
VerticalAlignment = VerticalAlignment.Center,
Opacity = 0.5,
};
Grid.SetColumn(_elapsedLabel, 2);
Grid.SetColumn(_elapsedLabel, 3);
headerGrid.Children.Add(_elapsedLabel);
container.Children.Add(headerGrid);
@@ -601,11 +620,12 @@ public partial class ChatWindow
else
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
// 대기열 정리: 실행 중 + 대기 중 항목 모두 제거 (중지는 "전부 멈춤"을 의미)
// 대기열 정리: 실행 중인 항목만 취소, 대기 중 항목은 보존
lock (_convLock)
{
_draftQueueProcessor.CancelRunning(ChatSession, _activeTab, _storage);
_draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage);
// Queue preserved — user can manually clear or items will execute on next send
// _draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage);
_runningDraftId = null;
}
RefreshDraftQueueUi();

View File

@@ -35,7 +35,7 @@ public partial class ChatWindow
{
var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
map[sectionKey] = expanded;
try { _settings.Save(); } catch { }
ScheduleSettingsSave();
}
private bool AreAllSlashSectionsExpanded()
@@ -58,7 +58,7 @@ public partial class ChatWindow
{
_settings.Settings.Llm.FavoriteSlashCommands.Clear();
_settings.Settings.Llm.RecentSlashCommands.Clear();
try { _settings.Save(); } catch { }
ScheduleSettingsSave();
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
RenderSlashPage();
}
@@ -104,7 +104,7 @@ public partial class ChatWindow
recent.Insert(0, cmd);
if (recent.Count > maxRecent)
recent.RemoveRange(maxRecent, recent.Count - maxRecent);
try { _settings.Save(); } catch { }
ScheduleSettingsSave();
}
private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches)
@@ -619,7 +619,7 @@ public partial class ChatWindow
favs.RemoveRange(maxFavorites, favs.Count - maxFavorites);
}
_settings.Save();
ScheduleSettingsSave();
if (SlashPopup.IsOpen)
{

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Text;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
@@ -166,6 +167,9 @@ public partial class ChatWindow
sb.AppendLine("\n" + _currentConversation.SystemCommand);
}
// P4: 워크스페이스 컨텍스트 자동 생성 + 주입
sb.Append(LoadWorkspaceContext(workFolder));
// 프로젝트 문맥 파일 (AGENTS.md) 주입
sb.Append(LoadProjectContext(workFolder));
@@ -178,7 +182,7 @@ public partial class ChatWindow
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
return ApplyModelPromptAdaptation(sb.ToString());
}
private string BuildCodeSystemPrompt()
@@ -330,6 +334,9 @@ public partial class ChatWindow
sb.AppendLine("\n" + sysCmd);
}
// P4: 워크스페이스 컨텍스트 자동 생성 + 주입
sb.Append(LoadWorkspaceContext(workFolder));
// 프로젝트 문맥 파일 (AGENTS.md) 주입
sb.Append(LoadProjectContext(workFolder));
@@ -342,7 +349,52 @@ public partial class ChatWindow
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
return ApplyModelPromptAdaptation(sb.ToString());
}
/// <summary>
/// ModelPromptLevel에 따라 모델 패밀리별 프롬프트 어댑테이션을 적용합니다.
/// "off"이면 원본 그대로 반환, "basic"이면 가벼운 규칙 추가, "detailed"이면 전용 프롬프트 적용.
/// </summary>
private string ApplyModelPromptAdaptation(string basePrompt)
{
var level = _settings.Settings.Llm.ModelPromptLevel ?? "off";
if (string.Equals(level, "off", StringComparison.OrdinalIgnoreCase))
return basePrompt;
var modelFamily = ResolveCurrentModelFamily();
return ModelPromptAdapter.AdaptSystemPrompt(basePrompt, modelFamily, level);
}
/// <summary>
/// 현재 활성 모델의 프롬프트 패밀리를 결정합니다.
/// RegisteredModel.PromptFamily → 자동 감지 → "default" 순서.
/// </summary>
private string ResolveCurrentModelFamily()
{
try
{
var (_, modelName) = _llm.GetCurrentModelInfo();
// RegisteredModel에 명시적 PromptFamily가 설정되어 있으면 우선 사용
var llm = _settings.Settings.Llm;
var registered = llm.RegisteredModels.FirstOrDefault(m =>
{
var decrypted = CryptoService.DecryptIfEnabled(m.EncryptedModelName, llm.EncryptionEnabled);
return string.Equals(decrypted, modelName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.Alias, modelName, StringComparison.OrdinalIgnoreCase);
});
if (registered != null && !string.IsNullOrWhiteSpace(registered.PromptFamily))
return registered.PromptFamily.Trim().ToLowerInvariant();
// 자동 감지
return ModelPromptAdapter.DetectModelFamily(modelName);
}
catch
{
return "default";
}
}
private static string BuildSubAgentDelegationSection(bool codeMode)

View File

@@ -88,6 +88,7 @@ public partial class ChatWindow
SelectTopic(tag.Preset);
break;
case "etc":
Services.LogService.Info($"[EmptyState] HIDE ← TopicButton_etc, tab={_activeTab}");
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
break;
@@ -428,7 +429,7 @@ public partial class ChatWindow
});
}
_settings.Save();
ScheduleSettingsSave();
BuildTopicButtons();
}
@@ -491,7 +492,7 @@ public partial class ChatWindow
return;
_settings.Settings.Llm.CustomPresets.RemoveAll(item => item.Id == preset.CustomId);
_settings.Save();
ScheduleSettingsSave();
BuildTopicButtons();
};
stack.Children.Add(deleteItem);
@@ -562,6 +563,7 @@ public partial class ChatWindow
if (!hasConversation)
StartNewConversation();
ChatConversation? convToSave = null;
lock (_convLock)
{
if (_currentConversation == null)
@@ -570,25 +572,45 @@ public partial class ChatWindow
var session = ChatSession;
if (session != null)
{
// ★ 버벅임 수정: storage=null 전달로 동기 파일저장 스킵
// (아래에서 한 번에 Task.Run으로 비동기 저장)
_currentConversation = session.UpdateConversationMetadata(_activeTab, conversation =>
{
conversation.SystemCommand = preset.SystemPrompt;
conversation.Category = preset.Category;
}, _storage);
}, storage: null);
}
else
{
_currentConversation.SystemCommand = preset.SystemPrompt;
_currentConversation.Category = preset.Category;
}
convToSave = _currentConversation;
}
UpdateCategoryLabel();
SaveConversationSettings();
RefreshConversationList();
// SaveConversationSettings은 내부에서 또 동기 저장 — 여기서 인라인으로 필드만 반영하고 저장은 백그라운드로
ApplyConversationSettingsInMemory();
// 프리셋 선택은 현재 대화의 Category/SystemCommand만 바꿀 뿐,
// 대화 목록의 정렬/필터/제목에는 영향이 없으므로 RefreshConversationList 호출을 생략.
// (이전에는 매 클릭마다 _storage.LoadAllMeta() + 전체 LINQ 필터링이 UI 스레드로 돌아와 버벅임을 유발했음.)
UpdateSelectedPresetGuide();
// ★ 한 번만 비동기로 저장 — UI 스레드 차단 없음
if (convToSave != null)
{
var convCopy = convToSave;
_ = System.Threading.Tasks.Task.Run(() =>
{
try { _storage.Save(convCopy); }
catch (Exception ex) { Services.LogService.Debug($"프리셋 저장 실패: {ex.Message}"); }
});
}
if (EmptyState != null)
{
Services.LogService.Info($"[EmptyState] HIDE ← SelectTopic, tab={_activeTab}");
EmptyState.Visibility = Visibility.Collapsed;
}
InputBox.Focus();

View File

@@ -104,14 +104,12 @@ public partial class ChatWindow
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
EmptyState.Visibility = System.Windows.Visibility.Visible;
StartMascotAnimation();
ShowEmptyState();
}
else
{
// 메시지가 있거나 스트리밍 중 → EmptyState 강제 숨김
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
StopMascotAnimation();
HideEmptyState(animate: preserveViewport);
}
return;
}
@@ -127,77 +125,71 @@ public partial class ChatWindow
InvalidateTimelineCache();
}
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
StopMascotAnimation();
HideEmptyState(animate: preserveViewport);
// V2 렌더링 분기 — 설정 토글로 Claude Code 스타일 상세 이력 UI 활성화
if (_settings.Settings.Llm.EnableNewChatRendering)
// V2 렌더링 (Claude Code 스타일 상세 이력 UI)
RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport,
previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller);
// 렌더 완료 후 진단 — transcript에 실제로 요소가 추가되었는지 확인
var postRenderCount = GetTranscriptElementCount();
if (postRenderCount == 0 && visibleMessages.Count > 0)
Services.LogService.Warn($"[Render] POST-RENDER WARNING: transcript has 0 elements after rendering {visibleMessages.Count} messages! caller={caller}, convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}");
}
/// <summary>EmptyState를 표시합니다. 진행 중인 페이드 애니메이션을 취소하고 Opacity를 복원합니다.</summary>
private void ShowEmptyState([System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
{
var prev = EmptyState.Visibility;
++_emptyStateAnimationToken;
EmptyState.BeginAnimation(OpacityProperty, null);
EmptyState.Opacity = 1;
EmptyState.Visibility = System.Windows.Visibility.Visible;
Services.LogService.Info($"[EmptyState] SHOW ← {caller}, prev={prev}, opacity={EmptyState.Opacity}, tab={_activeTab}");
StartMascotAnimation();
}
/// <summary>
/// EmptyState를 숨깁니다. animate=true이면 150ms 페이드아웃, false이면 즉시 Collapsed.
/// 토큰 기반 무효화: 탭 전환이나 ShowEmptyState가 호출되면 진행 중인 Completed 콜백이 무시됩니다.
/// </summary>
private void HideEmptyState(bool animate, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null)
{
if (EmptyState.Visibility != System.Windows.Visibility.Visible)
{
RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport,
previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller);
StopMascotAnimation();
return;
}
try
Services.LogService.Info($"[EmptyState] HIDE ← {caller}, animate={animate}, tab={_activeTab}");
if (animate)
{
var renderPlan = BuildTranscriptRenderPlan(conv, visibleMessages, visibleEvents);
Services.LogService.Info($"[Render] plan: items={renderPlan.VisibleTimeline.Count}, hidden={renderPlan.HiddenCount}, " +
$"canIncremental={renderPlan.CanIncremental}, keys={renderPlan.NewKeys.Count}");
// B-3: 스트리밍 전용 빠른 경로 → 일반 인크리멘탈 → Diff(Virtual DOM) → 전체 재빌드
if (!TryApplyStreamingAppendRender(renderPlan)
&& !TryApplyIncrementalTranscriptRender(renderPlan)
&& !TryApplyDiffRender(renderPlan))
ApplyFullTranscriptRender(renderPlan);
PruneTranscriptElementCache(renderPlan.NewKeys);
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = renderPlan.ShowHistory;
}
catch (Exception renderEx)
{
Services.LogService.Error($"[Render] 렌더링 파이프라인 예외: {renderEx.GetType().Name}: {renderEx.Message}\n{renderEx.StackTrace}");
}
_lastRenderTicks = Environment.TickCount64; // 쓰로틀 타임스탬프 갱신
renderStopwatch.Stop();
// B-6: 스트리밍 중 로깅 빈도 축소 — 100ms 미만 렌더는 기록하지 않음 (UI 부하 감소)
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
{
AgentPerformanceLogService.LogMetric(
"transcript",
"render_messages",
conv.Id,
_activeTab ?? "",
renderStopwatch.ElapsedMilliseconds,
new
var token = ++_emptyStateAnimationToken;
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(150))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase()
};
fadeOut.Completed += (_, _) =>
{
if (token != _emptyStateAnimationToken)
{
preserveViewport,
streaming = _isStreaming,
lightweight = IsLightweightLiveProgressMode(),
visibleMessages = visibleMessages.Count,
visibleEvents = visibleEvents.Count,
transcriptElements = GetTranscriptElementCount(),
});
Services.LogService.Info($"[EmptyState] HIDE animation STALE (token {token} vs {_emptyStateAnimationToken}), tab={_activeTab}");
return;
}
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
EmptyState.Opacity = 1;
Services.LogService.Info($"[EmptyState] HIDE animation COMPLETED, tab={_activeTab}");
};
EmptyState.BeginAnimation(OpacityProperty, fadeOut);
}
if (!preserveViewport)
else
{
_ = Dispatcher.InvokeAsync(ScrollTranscriptToEnd, DispatcherPriority.Background);
return;
++_emptyStateAnimationToken;
EmptyState.BeginAnimation(OpacityProperty, null);
EmptyState.Opacity = 1;
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
}
_ = Dispatcher.InvokeAsync(() =>
{
if (_transcriptScrollViewer == null)
return;
var newScrollableHeight = GetTranscriptScrollableHeight();
var delta = newScrollableHeight - previousScrollableHeight;
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
ScrollTranscriptToVerticalOffset(targetOffset);
}, DispatcherPriority.Background);
StopMascotAnimation();
}
}

View File

@@ -21,6 +21,26 @@ public partial class ChatWindow
{
// ─── 프로젝트 문맥 파일 (AGENTS.md) ──────────────────────────────────
/// <summary>
/// P4: 워크스페이스 컨텍스트 자동 생성 파일(.ax-context.md)을 읽어 시스템 프롬프트에 주입합니다.
/// </summary>
private static string LoadWorkspaceContext(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return "";
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.EnableAutoWorkspaceContext ?? true))
return "";
var content = Services.Agent.WorkspaceContextGenerator.LoadContext(workFolder);
if (string.IsNullOrEmpty(content)) return "";
// 비동기 자동 생성 트리거 (파일이 아직 없을 때)
_ = Task.Run(() => Services.Agent.WorkspaceContextGenerator.EnsureContextAsync(workFolder));
return $"\n## Workspace Context (auto-detected)\n{content}\n";
}
/// <summary>
/// 작업 폴더에 AGENTS.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다.
/// 프로젝트 로컬 컨텍스트 규약 파일(AGENTS.md) 형식을 사용합니다.
@@ -111,7 +131,7 @@ public partial class ChatWindow
InputGlowBorder.Visibility = Visibility.Visible;
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 };
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180)));
new System.Windows.Media.Animation.DoubleAnimation(0, 0.45, TimeSpan.FromMilliseconds(200)));
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_rainbowTimer.Tick += (_, _) =>
@@ -177,21 +197,34 @@ public partial class ChatWindow
ToastIcon.Text = icon;
ToastBorder.Visibility = Visibility.Visible;
// 페이드인
// 슬라이드인 + 페이드인 (Claude 스타일)
var transform = ToastBorder.RenderTransform as System.Windows.Media.TranslateTransform;
if (transform == null)
{
transform = new System.Windows.Media.TranslateTransform();
ToastBorder.RenderTransform = transform;
}
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut } });
transform.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty,
new System.Windows.Media.Animation.DoubleAnimation(20, 0, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut } });
// 자동 숨기기 — 타이머 인스턴스를 로컬 변수로 캡처해 필드 재할당 간섭 방지
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) };
_toastHideTimer = timer;
timer.Tick += (_, _) =>
{
if (_toastHideTimer != timer) return; // 다른 ShowToast가 교체한 경우 무시
if (_toastHideTimer != timer) return;
timer.Stop();
_toastHideTimer = null;
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
var slideOut = new System.Windows.Media.Animation.DoubleAnimation(0, 20, TimeSpan.FromMilliseconds(300));
(ToastBorder.RenderTransform as System.Windows.Media.TranslateTransform)
?.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideOut);
};
timer.Start();
}
@@ -447,7 +480,7 @@ public partial class ChatWindow
item.MouseLeftButtonUp += (_, _) =>
{
_settings.Settings.Llm.AgentLogLevel = key;
_settings.Save();
ScheduleSettingsSave();
FormatMenuPopup.IsOpen = false;
if (_activeTab == "Cowork") BuildBottomBar();
else if (_activeTab == "Code") BuildCodeBottomBar();
@@ -668,7 +701,7 @@ public partial class ChatWindow
{
FormatMenuPopup.IsOpen = false;
_settings.Settings.Llm.DefaultOutputFormat = capturedKey;
_settings.Save();
ScheduleSettingsSave();
RefreshOverlaySettingsPanel();
BuildBottomBar();
};
@@ -815,7 +848,7 @@ public partial class ChatWindow
MoodMenuPopup.IsOpen = false;
_selectedMood = capturedMood.Key;
_settings.Settings.Llm.DefaultMood = capturedMood.Key;
_settings.Save();
ScheduleSettingsSave();
SaveConversationSettings();
RefreshOverlaySettingsPanel();
BuildBottomBar();
@@ -925,7 +958,7 @@ public partial class ChatWindow
Css = dlg.MoodCss,
});
}
_settings.Save();
ScheduleSettingsSave();
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildBottomBar();
}
@@ -984,7 +1017,7 @@ public partial class ChatWindow
{
_settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey);
if (_selectedMood == moodKey) _selectedMood = "modern";
_settings.Save();
ScheduleSettingsSave();
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildBottomBar();
}

View File

@@ -185,14 +185,46 @@ public partial class ChatWindow
Margin = new Thickness(0, 6, 0, 0),
};
var detailStack = new StackPanel();
var codeBg = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
// 도구 입력 파라미터 (ToolInput이 있으면 표시)
var toolInput = toolCall.ToolInput;
if (!string.IsNullOrWhiteSpace(toolInput))
{
detailStack.Children.Add(new TextBlock
{
Text = "입력",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Opacity = 0.7,
Margin = new Thickness(0, 0, 0, 3),
});
detailStack.Children.Add(MarkdownRenderer.Render(
$"```json\n{TruncateForDisplay(toolInput, 1200)}\n```",
primaryText, secondaryText, accentBrush, codeBg));
}
// 도구 결과 상세 내용
var resultSummary = toolResult.Summary;
if (!string.IsNullOrWhiteSpace(resultSummary) && resultSummary.Length > 80)
if (!string.IsNullOrWhiteSpace(resultSummary))
{
var codeBg = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
// 입력이 이미 있으면 "결과" 라벨 추가
if (detailStack.Children.Count > 0)
{
detailStack.Children.Add(new TextBlock
{
Text = "결과",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Opacity = 0.7,
Margin = new Thickness(0, 6, 0, 3),
});
}
detailStack.Children.Add(MarkdownRenderer.Render(
$"```\n{resultSummary}\n```", primaryText, secondaryText, accentBrush, codeBg));
$"```\n{TruncateForDisplay(resultSummary, 2000)}\n```",
primaryText, secondaryText, accentBrush, codeBg));
}
// 토큰 메타 정보
@@ -215,19 +247,22 @@ public partial class ChatWindow
}
detailBorder.Child = detailStack;
// 상세 내용이 없으면 화살표도 숨김
var hasDetail = detailStack.Children.Count > 0;
cardStack.Children.Add(detailBorder);
// 펼치기 토글 화살표
// 펼치기 토글 화살표 (상세 내용이 있을 때만 표시)
var arrowTb = new TextBlock
{
Text = "\uE76C", // 아래 화살표
FontFamily = s_segoeIconFont,
FontSize = 9,
Foreground = secondaryText,
Opacity = 0.5,
Opacity = 0.7,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 3, 0, 0),
Cursor = Cursors.Hand,
Visibility = hasDetail ? Visibility.Visible : Visibility.Collapsed,
};
cardStack.Children.Add(arrowTb);
@@ -243,11 +278,20 @@ public partial class ChatWindow
arrowTb.Text = isExpanded ? "\uE76C" : "\uE76B"; // 아래↔위
};
// 호버 효과
var normalBg = hintBg;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? hintBg;
card.MouseEnter += (_, _) => card.Background = hoverBg;
card.MouseLeave += (_, _) => card.Background = normalBg;
// 호버 효과 (부드러운 전환)
card.MouseEnter += (_, _) =>
{
card.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(120)));
card.Background = TryFindResource("ItemHoverBackground") as Brush ?? hintBg;
};
card.MouseLeave += (_, _) =>
{
card.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0.92, TimeSpan.FromMilliseconds(200)));
card.Background = hintBg;
};
card.Opacity = 0.92;
return outerGrid;
}
@@ -283,7 +327,7 @@ public partial class ChatWindow
var thinkLine = new Border
{
Width = 2,
Background = new SolidColorBrush(Color.FromArgb(0x40, 0x59, 0xA5, 0xF5)),
Background = new SolidColorBrush(Color.FromArgb(0x60, 0x59, 0xA5, 0xF5)),
CornerRadius = new CornerRadius(1),
Margin = new Thickness(12, 0, 8, 0),
};
@@ -292,8 +336,8 @@ public partial class ChatWindow
var thinkCard = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x0A, 0x59, 0xA5, 0xF5)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0x59, 0xA5, 0xF5)),
Background = new SolidColorBrush(Color.FromArgb(0x1C, 0x59, 0xA5, 0xF5)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x59, 0xA5, 0xF5)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 6, 10, 6),
@@ -316,7 +360,7 @@ public partial class ChatWindow
FontSize = 11,
FontStyle = FontStyles.Italic,
Foreground = secondaryText,
Opacity = 0.75,
Opacity = 0.88,
TextWrapping = TextWrapping.Wrap,
MaxWidth = msgMaxWidth - 60,
});
@@ -428,12 +472,12 @@ public partial class ChatWindow
var banner = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x66, 0xBB, 0x6A)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x66, 0xBB, 0x6A)),
Background = new SolidColorBrush(Color.FromArgb(0x24, 0x66, 0xBB, 0x6A)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x50, 0x66, 0xBB, 0x6A)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 6, 12, 6),
Margin = new Thickness(0, 4, 0, 4),
Padding = new Thickness(14, 8, 14, 8),
Margin = new Thickness(0, 6, 0, 4),
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = msgMaxWidth,
};
@@ -465,12 +509,12 @@ public partial class ChatWindow
var msgMaxWidth = GetMessageMaxWidth();
var banner = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x53, 0x50)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x53, 0x50)),
Background = new SolidColorBrush(Color.FromArgb(0x24, 0xEF, 0x53, 0x50)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x50, 0xEF, 0x53, 0x50)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 6, 12, 6),
Margin = new Thickness(0, 4, 0, 4),
Padding = new Thickness(14, 8, 14, 8),
Margin = new Thickness(0, 6, 0, 4),
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = msgMaxWidth,
};
@@ -598,4 +642,12 @@ public partial class ChatWindow
return path[..remaining] + ".../" + fileName;
}
/// <summary>긴 텍스트를 지정 길이로 잘라서 표시용으로 반환합니다.</summary>
private static string TruncateForDisplay(string text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
return text;
return text[..maxLength] + "\n… (truncated)";
}
}

View File

@@ -79,7 +79,7 @@ public partial class ChatWindow
Text = "",
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.50,
Opacity = 0.70,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
@@ -149,8 +149,8 @@ public partial class ChatWindow
var card = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B)),
Background = new SolidColorBrush(Color.FromArgb(0x1C, accentColor.R, accentColor.G, accentColor.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 6, 10, 6),
@@ -202,7 +202,8 @@ public partial class ChatWindow
_v2LiveToolCards[toolId] = card;
_v2LiveContainer.Children.Add(outerGrid);
ForceScrollToEnd();
// 사용자가 수동 스크롤 중이면 강제 스크롤하지 않음
AutoScrollIfNeeded();
break;
}
@@ -269,6 +270,30 @@ public partial class ChatWindow
: new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50));
}
// ★ 섹션 종료 후 자동 접기 — 완료된 카드는 1.2초 뒤 컴팩트 형태로 축소
// 얇은 줄이 빠르게 누적되어 공간 낭비되는 문제 해결
var cardToCollapse = pendingCard;
var outerGridToCollapse = parent;
var collapseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1200) };
collapseTimer.Tick += (_, _) =>
{
collapseTimer.Stop();
if (cardToCollapse == null) return;
// Padding 축소 + Opacity 감소로 "접힌" 느낌 연출
var padAnim = new ThicknessAnimation(
cardToCollapse.Padding,
new Thickness(8, 2, 8, 2),
TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase() };
var opAnim = new DoubleAnimation(1.0, 0.55, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase() };
cardToCollapse.BeginAnimation(Border.PaddingProperty, padAnim);
cardToCollapse.BeginAnimation(UIElement.OpacityProperty, opAnim);
if (outerGridToCollapse != null)
outerGridToCollapse.Margin = new Thickness(0, 1, 0, 1);
};
collapseTimer.Start();
_v2LastLiveToolCallId = null;
}
break;
@@ -301,12 +326,13 @@ public partial class ChatWindow
FontSize = 10.5,
FontStyle = FontStyles.Italic,
Foreground = secondaryText,
Opacity = 0.65,
Opacity = 0.82,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = msgMaxWidth - 60,
});
_v2LiveContainer.Children.Add(thinkRow);
ForceScrollToEnd();
// 사용자가 수동 스크롤 중이면 강제 스크롤하지 않음
AutoScrollIfNeeded();
break;
}
}

View File

@@ -29,16 +29,16 @@ public partial class ChatWindow
var msgMaxWidth = GetMessageMaxWidth();
var wrapper = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0, 12, 0, 4),
HorizontalAlignment = HorizontalAlignment.Right,
MaxWidth = msgMaxWidth * 0.85,
Margin = new Thickness(60, 12, 12, 4),
};
// 사용자 아이콘 + 이름 헤더
var header = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(2, 0, 0, 4),
};
header.Children.Add(new TextBlock
@@ -187,7 +187,7 @@ public partial class ChatWindow
var contentPanel = new Border
{
Background = Brushes.Transparent,
Padding = new Thickness(2, 4, 2, 4),
Padding = new Thickness(6, 4, 6, 4),
};
if (IsBranchContextMessage(content))
@@ -216,7 +216,7 @@ public partial class ChatWindow
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(2, 2, 0, 0),
Opacity = 0.7,
Opacity = 0,
};
var btnColor = secondaryText;
var capturedContent = content;
@@ -232,7 +232,7 @@ public partial class ChatWindow
{
Text = aiTimestamp.ToString("HH:mm"),
FontSize = 10,
Opacity = 0.52,
Opacity = 0.6,
Foreground = btnColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 1),
@@ -245,8 +245,12 @@ public partial class ChatWindow
if (assistantMeta != null)
container.Children.Add(assistantMeta);
container.MouseEnter += (_, _) => actionBar.Opacity = 1;
container.MouseLeave += (_, _) => actionBar.Opacity = 0.7;
container.MouseEnter += (_, _) =>
actionBar.BeginAnimation(OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0.8, TimeSpan.FromMilliseconds(150)));
container.MouseLeave += (_, _) =>
actionBar.BeginAnimation(OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, TimeSpan.FromMilliseconds(200)));
var aiContent = content;
container.MouseRightButtonUp += (_, re) =>

View File

@@ -47,19 +47,39 @@ public partial class ChatWindow
_elementCache.Clear();
}
// ★ 패널이 비어있는데 V2 캐시가 남아있는 경우 강제 리셋
// 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만
// 빈 대화 탭을 거치면 V2가 호출되지 않아 캐시가 잔류하는 버그 방지
if (GetTranscriptElementCount() == 0 && _v2LastRenderedKeys.Count > 0)
{
LogService.Info($"[V2Render] CACHE STALE RESET: panel=0 but cachedKeys={_v2LastRenderedKeys.Count}, forcing full rebuild");
_v2LastRenderedKeys.Clear();
_v2LastRenderedMessageCount = 0;
_v2LastRenderedEventCount = 0;
}
// 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합)
var timeline = BuildV2Timeline(visibleMessages, visibleEvents);
LogService.Info($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}");
// 새 키 목록 생성
var newKeys = new List<string>(timeline.Count);
foreach (var item in timeline)
newKeys.Add(item.Key);
// 인크리멘탈 렌더 시도: 기존 키가 새 키의 접두사인 경우 추가분만 렌더
// ★ 패널에 실제 엘리먼트가 있어야 인크리멘탈 가능 —
// 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만
// V2 캐시(_v2LastRenderedKeys)는 유지되어 "이미 렌더됨"으로 오판하는 버그 방지
var actualElementCount = GetTranscriptElementCount();
var canIncremental = _v2LastRenderedKeys.Count > 0
&& actualElementCount > 0
&& newKeys.Count >= _v2LastRenderedKeys.Count
&& KeysArePrefixMatch(_v2LastRenderedKeys, newKeys);
LogService.Info($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}");
if (canIncremental)
{
// 라이브 컨테이너가 있으면 임시 제거 (맨 끝에 다시 추가)
@@ -110,6 +130,8 @@ public partial class ChatWindow
_v2LastRenderedKeys.AddRange(newKeys);
_v2LastRenderedMessageCount = visibleMessages.Count;
_v2LastRenderedEventCount = visibleEvents.Count;
LogService.Info($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}");
}
catch (Exception ex)
{
@@ -119,12 +141,13 @@ public partial class ChatWindow
_lastRenderTicks = Environment.TickCount64;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = conv?.ShowExecutionHistory ?? true;
renderStopwatch.Stop();
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
{
AgentPerformanceLogService.LogMetric(
"transcript", "render_messages_v2", conv.Id, _activeTab ?? "",
"transcript", "render_messages_v2", conv?.Id ?? "", _activeTab ?? "",
renderStopwatch.ElapsedMilliseconds,
new { preserveViewport, streaming = _isStreaming, visibleMessages = visibleMessages.Count, visibleEvents = visibleEvents.Count });
}
@@ -172,12 +195,21 @@ public partial class ChatWindow
}
// 2. 실행 이벤트 추가 — ToolCall+ToolResult 쌍을 병합
// 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외
var eventIndex = 0;
var events = visibleEvents.ToList();
var liveCardCutoff = (_isStreaming && _v2LiveContainer != null)
? _v2LiveStartTime
: DateTime.MaxValue;
for (int i = 0; i < events.Count; i++)
{
var executionEvent = events[i];
// 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵
if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff)
continue;
var agentEvent = ToAgentEvent(executionEvent);
// SessionStart / UserPromptSubmit 숨김

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -200,14 +200,19 @@ internal sealed class CustomMessageBox : Window
return btn;
}
private static (string icon, Brush color) GetIconInfo(MessageBoxImage image) => image switch
private static (string icon, Brush color) GetIconInfo(MessageBoxImage image)
{
MessageBoxImage.Error => ("\uEA39", new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E))),
MessageBoxImage.Warning => ("\uE7BA", new SolidColorBrush(Color.FromRgb(0xDD, 0x6B, 0x20))),
MessageBoxImage.Information => ("\uE946", new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))),
MessageBoxImage.Question => ("\uE9CE", new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))),
_ => ("", Brushes.Transparent),
};
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
return image switch
{
MessageBoxImage.Error => ("\uEA39", new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E))),
MessageBoxImage.Warning => ("\uE7BA", new SolidColorBrush(Color.FromRgb(0xDD, 0x6B, 0x20))),
MessageBoxImage.Information => ("\uE946", accentBrush),
MessageBoxImage.Question => ("\uE9CE", accentBrush),
_ => ("", Brushes.Transparent),
};
}
// ─── 정적 호출 메서드 (기존 MessageBox.Show 시그니처 호환) ──────────────
@@ -276,9 +281,14 @@ internal sealed class CustomMessageBox : Window
if (string.IsNullOrEmpty(iconText))
{
iconText = "\uE946"; // default info icon
iconColor = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
iconColor = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
}
var toastFg = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var toastBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0xE8, 0x2A, 0x2B, 0x40));
var panel = new StackPanel { Orientation = Orientation.Horizontal };
panel.Children.Add(new TextBlock
{
@@ -294,13 +304,13 @@ internal sealed class CustomMessageBox : Window
Text = title,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
Foreground = toastFg,
VerticalAlignment = VerticalAlignment.Center,
});
var toast = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xE8, 0x2A, 0x2B, 0x40)),
Background = toastBg,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(16, 8, 16, 8),
HorizontalAlignment = HorizontalAlignment.Center,

Some files were not shown because too many files have changed in this diff Show More