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:
@@ -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);
|
||||
};
|
||||
|
||||
// 타이머/알람 풍선 알림 서비스 연결
|
||||
|
||||
89
src/AxCopilot/Assets/ModelPrompts/deepseek.md
Normal file
89
src/AxCopilot/Assets/ModelPrompts/deepseek.md
Normal 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
|
||||
61
src/AxCopilot/Assets/ModelPrompts/gemma.md
Normal file
61
src/AxCopilot/Assets/ModelPrompts/gemma.md
Normal 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.
|
||||
97
src/AxCopilot/Assets/ModelPrompts/kimi.md
Normal file
97
src/AxCopilot/Assets/ModelPrompts/kimi.md
Normal 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
|
||||
45
src/AxCopilot/Assets/ModelPrompts/llama.md
Normal file
45
src/AxCopilot/Assets/ModelPrompts/llama.md
Normal 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
|
||||
44
src/AxCopilot/Assets/ModelPrompts/mistral.md
Normal file
44
src/AxCopilot/Assets/ModelPrompts/mistral.md
Normal 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
|
||||
65
src/AxCopilot/Assets/ModelPrompts/qwen.md
Normal file
65
src/AxCopilot/Assets/ModelPrompts/qwen.md
Normal 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.
|
||||
@@ -6,5 +6,5 @@
|
||||
"purpose": "업무 편의성 증가 및 시스템의 직관적인 연결을 위해 제작",
|
||||
"copyright": "© 2026 AX연구소",
|
||||
"blogUrl": "www.swarchitect.net",
|
||||
"contributors": "경윤영님, 윤지영님, 배지훈님"
|
||||
"contributors": "경윤영님, 윤지영님"
|
||||
}
|
||||
|
||||
@@ -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')" />
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace AxCopilot.Services.Agent;
|
||||
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
private enum ExplorationScope
|
||||
internal enum ExplorationScope
|
||||
{
|
||||
Localized,
|
||||
TopicBased,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)}",
|
||||
|
||||
@@ -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
|
||||
|
||||
307
src/AxCopilot/Services/Agent/AgentToolCatalog.cs
Normal file
307
src/AxCopilot/Services/Agent/AgentToolCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
=> "웹",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>");
|
||||
|
||||
41
src/AxCopilot/Services/Agent/ExecutionPolicyOverlay.cs
Normal file
41
src/AxCopilot/Services/Agent/ExecutionPolicyOverlay.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
167
src/AxCopilot/Services/Agent/HashAnchor.cs
Normal file
167
src/AxCopilot/Services/Agent/HashAnchor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 5–8 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=\"테마 전환\">🌙</button>");
|
||||
if (useToc && !isSidebarToc)
|
||||
sb.AppendLine("<button class=\"ax-fab-toc\" id=\"axFabToc\" onclick=\"document.querySelector('nav.toc')?.scrollIntoView({behavior:'smooth'})\" title=\"목차로 이동\">☰</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) </strong: → </strong>: (닫기 '>' 누락)
|
||||
/// 2) </em: → </em>: (같은 패턴)
|
||||
/// 3) <span class="...">text</strong> → <span>text</span> (태그 불일치)
|
||||
/// 4) <strong class="...">text</span> → <strong>text</strong> (태그 불일치)
|
||||
/// </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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
326
src/AxCopilot/Services/Agent/IntentGateService.cs
Normal file
326
src/AxCopilot/Services/Agent/IntentGateService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/AxCopilot/Services/Agent/IntentResult.cs
Normal file
21
src/AxCopilot/Services/Agent/IntentResult.cs
Normal 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
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
450
src/AxCopilot/Services/Agent/ModelPromptAdapter.cs
Normal file
450
src/AxCopilot/Services/Agent/ModelPromptAdapter.cs
Normal 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)";
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
src/AxCopilot/Services/Agent/SessionLearning.cs
Normal file
15
src/AxCopilot/Services/Agent/SessionLearning.cs
Normal 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
|
||||
);
|
||||
308
src/AxCopilot/Services/Agent/SessionLearningCollector.cs
Normal file
308
src/AxCopilot/Services/Agent/SessionLearningCollector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
137
src/AxCopilot/Services/Agent/SpawnAgentsTool.cs
Normal file
137
src/AxCopilot/Services/Agent/SpawnAgentsTool.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
144
src/AxCopilot/Services/Agent/SubAgentProfile.cs
Normal file
144
src/AxCopilot/Services/Agent/SubAgentProfile.cs
Normal 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" };
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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'?'🌙':'☀️';
|
||||
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'?'🌙':'☀️';}}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="테마 전환">🌙</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; }
|
||||
|
||||
@@ -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());
|
||||
|
||||
409
src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs
Normal file
409
src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -542,7 +542,7 @@ public sealed class AppStateService : IAppStateService
|
||||
var description = effective switch
|
||||
{
|
||||
"AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
|
||||
"Deny" => "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.",
|
||||
"Deny" => "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 새 파일 생성은 가능합니다.",
|
||||
"Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.",
|
||||
"BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.",
|
||||
_ => "파일 작업 전마다 사용자 확인을 요청합니다.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; // 평문도 아니면 원래 예외 재전파
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
21
src/AxCopilot/Themes/AgentClaudeDark.xaml
Normal file
21
src/AxCopilot/Themes/AgentClaudeDark.xaml
Normal 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>
|
||||
21
src/AxCopilot/Themes/AgentClaudeLight.xaml
Normal file
21
src/AxCopilot/Themes/AgentClaudeLight.xaml
Normal 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>
|
||||
21
src/AxCopilot/Themes/AgentClaudeSystem.xaml
Normal file
21
src/AxCopilot/Themes/AgentClaudeSystem.xaml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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="" 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="" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user