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

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

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

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

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

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

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

View File

@@ -1655,3 +1655,7 @@ MIT License
- `claw-code`의 post-compact attachments 흐름을 참고해, AX도 요약/경계 메시지에 오래된 첨부 참조를 다시 실어 compact 뒤 컨텍스트 연속성을 보강했습니다. - `claw-code`의 post-compact attachments 흐름을 참고해, AX도 요약/경계 메시지에 오래된 첨부 참조를 다시 실어 compact 뒤 컨텍스트 연속성을 보강했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 오래된 메시지에서 첨부 파일 이름과 이미지 개수를 수집해 `microcompact_boundary`와 요약 메시지에 함께 기록합니다. - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 오래된 메시지에서 첨부 파일 이름과 이미지 개수를 수집해 `microcompact_boundary`와 요약 메시지에 함께 기록합니다.
- 요약 메시지에는 `AttachedFiles`도 같이 보존해, compact 이후에도 관련 파일 참조가 query view로 이어질 수 있게 맞췄습니다. - 요약 메시지에는 `AttachedFiles`도 같이 보존해, compact 이후에도 관련 파일 참조가 query view로 이어질 수 있게 맞췄습니다.
- 업데이트: 2026-04-12 22:44 (KST)
- OpenAI/vLLM 호환 경로의 tool history 직렬화를 더 가볍게 만들어, 최근 구간만 구조화된 `tool_calls/tool` 형식을 유지하고 오래된 구간은 평탄한 transcript로 낮췄습니다.
- [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs)는 최근 비-system 메시지 8개만 구조화된 tool history로 유지하고, 그 이전의 `_tool_use_blocks``tool_result`는 plain assistant/user transcript로 변환해 재전송합니다.
- 이로써 strict provider에서 오래된 `tool_calls/tool` 이력이 계속 누적되던 부담과 pairing 위험을 함께 줄였습니다.

53
build-quick.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# AX Copilot - Quick Dev Build (no encryption, no obfuscation, no installer)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
APP="$ROOT/src/AxCopilot/AxCopilot.csproj"
OUT="$ROOT/dist"
APP_OUT="$OUT/AxCopilot"
RUNTIME="win-x64"
echo ""
echo "========================================"
echo " AX Copilot - Quick Build (dev)"
echo "========================================"
echo ""
# Stop running process (best-effort)
if command -v taskkill &>/dev/null; then
taskkill //IM AxCopilot.exe //F 2>/dev/null || true
fi
rm -rf "$OUT"
mkdir -p "$APP_OUT"
echo "[1/1] Publishing main app (self-contained $RUNTIME)..."
dotnet publish "$APP" \
-c Release \
-r "$RUNTIME" \
--self-contained true \
-o "$APP_OUT" \
--nologo \
-v minimal \
-p:DebugType=None \
-p:DebugSymbols=false \
-p:CopyOutputSymbolsToPublishDirectory=false \
-p:EnableSourceLink=false \
-p:PublishSingleFile=true \
-p:EnableCompressionInSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:PublishReadyToRun=true
# Cleanup debug/metadata artifacts
rm -f "$APP_OUT"/*.pdb "$APP_OUT"/*.xml "$APP_OUT"/*.deps.json "$APP_OUT"/*.runtimeconfig.json 2>/dev/null || true
echo ""
echo "========================================"
echo " Quick Build Complete!"
echo "========================================"
echo ""
echo " Output: $APP_OUT"
echo ""
echo " Skipped: encryption, obfuscation, AxKeyEncryptor, installer"
echo ""

View File

@@ -668,3 +668,14 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- compact 이후에도 “이 대화가 어떤 파일/이미지를 참고했는지”가 요약 메시지에서 다시 드러납니다. - compact 이후에도 “이 대화가 어떤 파일/이미지를 참고했는지”가 요약 메시지에서 다시 드러납니다.
- query view가 compact 이후 메시지를 다시 보낼 때, 파일 참조 continuity가 이전보다 더 자연스럽게 유지됩니다. - query view가 compact 이후 메시지를 다시 보낼 때, 파일 참조 continuity가 이전보다 더 자연스럽게 유지됩니다.
## OpenAI/vLLM tool history 직렬화 경량화 (2026-04-12 22:44 KST)
- `claw-code`가 최근 trajectory만 구조적으로 유지하고 오래된 tool history는 더 가볍게 다루는 방향을 참고해, AX도 OpenAI/vLLM 호환 요청 바디에서 오래된 tool history를 평탄화하도록 조정했습니다.
- `src/AxCopilot/Services/LlmService.ToolUse.cs`
- 최근 비-system 메시지 8개는 기존처럼 `assistant tool_calls` + `tool` 메시지 형식을 유지합니다.
- 그보다 오래된 `_tool_use_blocks`는 plain assistant transcript로, `tool_result`는 plain user transcript로 변환해 재전송합니다.
- 경계 계산에는 `AgentMessageInvariantHelper.AdjustStartIndexForToolPairs()`를 사용해 최근 구조화 구간의 pair invariant는 유지했습니다.
- 기대 효과
- OpenAI/vLLM 호환 서버에 오래된 구조화 tool history를 계속 실어 보내던 부담이 줄어듭니다.
- strict tool sequence 검사에 걸릴 가능성을 낮추면서도 최근 실행 흐름은 그대로 유지할 수 있습니다.

View File

@@ -1,6 +1,7 @@
using System.Windows; using System.Windows;
using System.Windows.Forms; using System.Windows.Forms;
using Microsoft.Win32; using Microsoft.Win32;
using Microsoft.Extensions.DependencyInjection;
using AxCopilot.Core; using AxCopilot.Core;
using AxCopilot.Handlers; using AxCopilot.Handlers;
using AxCopilot.Services; using AxCopilot.Services;
@@ -119,6 +120,9 @@ public partial class App : System.Windows.Application
_appState.AttachChatSession(_chatSessionState); _appState.AttachChatSession(_chatSessionState);
_appState.LoadFromSettings(settings); _appState.LoadFromSettings(settings);
// ─── DI 컨테이너 초기화 (기존 인스턴스 등록, 점진적 전환) ──────────────
ConfigureDependencyInjection(settings, _memoryService, _chatSessionState, _appState);
_indexService = new IndexService(settings); _indexService = new IndexService(settings);
var indexService = _indexService; var indexService = _indexService;
// 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지 // 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지
@@ -1008,4 +1012,32 @@ public partial class App : System.Windows.Application
_fileDialogWatcher.Stop(); _fileDialogWatcher.Stop();
} }
// ─── DI 컨테이너 구성 ─────────────────────────────────────────────────────
// 기존 수동 생성 인스턴스를 DI 컨테이너에 등록합니다.
// 향후 ViewModel/Window도 DI에서 resolve할 수 있도록 점진 확장합니다.
private static void ConfigureDependencyInjection(
SettingsService settings,
AgentMemoryService memoryService,
ChatSessionStateService chatSessionState,
AppStateService appState)
{
ServiceLocator.Configure(services =>
{
// ── Singleton: 기존 인스턴스 등록 ────────────────────────────────
services.AddSingleton<ISettingsService>(settings);
services.AddSingleton(settings); // 하위 호환: 구체 타입 직접 주입도 허용
services.AddSingleton<IChatStorageService, ChatStorageService>();
services.AddSingleton<IAppStateService>(appState);
services.AddSingleton(appState);
services.AddSingleton(chatSessionState);
services.AddSingleton(memoryService);
// ── Transient: 요청마다 새 인스턴스 ──────────────────────────────
services.AddTransient<ILlmService>(sp => new LlmService(sp.GetRequiredService<SettingsService>()));
services.AddSingleton<IModelRouterService>(sp => new ModelRouterService(sp.GetRequiredService<SettingsService>()));
});
LogService.Info("DI 컨테이너 초기화 완료");
}
} }

View File

@@ -6,6 +6,6 @@
"symbol": "\uE943", "symbol": "\uE943",
"color": "#3B82F6", "color": "#3B82F6",
"description": "새 기능 개발, 코드 작성, 프로젝트 구성", "description": "새 기능 개발, 코드 작성, 프로젝트 구성",
"systemPrompt": "당신은 AX Copilot Code Agent — 사내 소프트웨어 개발 전문 에이전트입니다.\n\n## 역할\n새 기능 개발, 코드 작성, 프로젝트 구성을 담당합니다.\n\n## 워크플로우\n1. dev_env_detect로 설치된 개발 도구 확인\n2. folder_map + grep으로 기존 코드베이스 구조 분석\n3. 기존 코드 패턴과 컨벤션을 파악 (네이밍, 아키텍처, 의존성)\n4. 단계별 구현 계획을 사용자에게 제시\n5. 승인 후 file_write/file_edit코드 작성\n6. build_run으로 빌드 및 테스트 검증\n\n## 핵심 원칙\n- 기존 코드 스타일과 아키텍처 패턴을 따르세요\n- SOLID 원칙과 DRY 원칙을 준수하세요\n- 적절한 에러 처리와 로깅을 포함하세요\n- 의미 있는 변수/함수 이름을 사용하세요\n- 복잡한 로직에는 주석을 추가하세요\n- 새 의존성 추가 시 사내 Nexus 저장소를 우선 사용하세요", "systemPrompt": "당신은 AX Copilot Code Agent — 사내 소프트웨어 개발 전문 에이전트입니다.\n\n## 역할\n새 기능 개발, 코드 작성, 프로젝트 구성을 담당합니다.\n\n## 워크플로우\n1. dev_env_detect로 설치된 개발 도구 확인\n2. grep/glob으로 관련 파일과 심볼을 먼저 좁히기\n3. file_read로 기존 코드 패턴과 컨벤션을 파악 (네이밍, 아키텍처, 의존성)\n4. 필요할 때만 계획을 제시하고, 바로 file_edit/file_write구현\n5. git diff, build_run, test_loop로 결과 검증\n6. folder_map은 폴더 구조 자체가 필요할 때만 사용\n\n## 핵심 원칙\n- 기존 코드 스타일과 아키텍처 패턴을 따르세요\n- SOLID 원칙과 DRY 원칙을 준수하세요\n- 적절한 에러 처리와 로깅을 포함하세요\n- 의미 있는 변수/함수 이름을 사용하세요\n- 복잡한 로직에는 주석을 추가하세요\n- 새 의존성 추가 시 사내 Nexus 저장소를 우선 사용하세요",
"placeholder": "어떤 기능을 개발할까요? (프로젝트 폴더를 먼저 선택하세요)" "placeholder": "어떤 기능을 개발할까요? (프로젝트 폴더를 먼저 선택하세요)"
} }

View File

@@ -6,6 +6,6 @@
"symbol": "\uE71B", "symbol": "\uE71B",
"color": "#10B981", "color": "#10B981",
"description": "코드 품질 분석, 모범 사례 검토, 개선 제안", "description": "코드 품질 분석, 모범 사례 검토, 개선 제안",
"systemPrompt": "당신은 AX Copilot Code Reviewer — 코드 품질 분석 전문 에이전트입니다.\n\n## 역할\n코드 리뷰를 수행하여 품질, 가독성, 유지보수성, 성능을 평가합니다.\n\n## 리뷰 관점 (Google Code Review 가이드 기반)\n1. **정확성**: 논리 오류, 경계 조건, null 처리\n2. **가독성**: 네이밍, 주석, 코드 구조\n3. **유지보수성**: 결합도, 응집도, 확장성\n4. **성능**: 불필요한 연산, 메모리 누수, N+1 쿼리\n5. **보안**: 입력 검증, SQL 인젝션, XSS, 하드코딩된 시크릿\n6. **테스트**: 테스트 커버리지, 엣지 케이스\n\n## 워크플로우\n1. folder_map으로 프로젝트 전체 구조 파악\n2. 대상 파일을 file_read로 꼼꼼히 읽기\n3. 관련 파일도 grep/glob으로 확인 (의존성, 호출 관계)\n4. 이슈별로 분류하여 리뷰 의견 제시:\n - [CRITICAL] 반드시 수정해야 하는 문제\n - [WARNING] 개선을 권장하는 부분\n - [INFO] 참고 사항\n - [GOOD] 잘 작성된 부분 (칭찬)\n5. 전체 코드 품질을 A~F 등급으로 평가\n6. 개선 우선순위 제안\n\n## 출력 형식\n리뷰 결과를 구조화된 보고서로 작성하세요:\n- 파일별 이슈 목록 (라인 번호 포함)\n- 종합 평가 및 등급\n- 개선 액션 플랜", "systemPrompt": "당신은 AX Copilot Code Reviewer — 코드 품질 분석 전문 에이전트입니다.\n\n## 역할\n코드 리뷰를 수행하여 품질, 가독성, 유지보수성, 성능을 평가합니다.\n\n## 리뷰 관점 (Google Code Review 가이드 기반)\n1. **정확성**: 논리 오류, 경계 조건, null 처리\n2. **가독성**: 네이밍, 주석, 코드 구조\n3. **유지보수성**: 결합도, 응집도, 확장성\n4. **성능**: 불필요한 연산, 메모리 누수, N+1 쿼리\n5. **보안**: 입력 검증, SQL 인젝션, XSS, 하드코딩된 시크릿\n6. **테스트**: 테스트 커버리지, 엣지 케이스\n\n## 워크플로우\n1. grep/glob으로 대상 파일과 관련 호출부를 먼저 좁히기\n2. 대상 파일을 file_read로 꼼꼼히 읽기\n3. 필요 시 git diff와 추가 file_read로 근거 보강\n4. 이슈별로 분류하여 리뷰 의견 제시:\n - [CRITICAL] 반드시 수정해야 하는 문제\n - [WARNING] 개선을 권장하는 부분\n - [INFO] 참고 사항\n - [GOOD] 잘 작성된 부분 (칭찬)\n5. 전체 코드 품질을 A~F 등급으로 평가\n6. 개선 우선순위 제안\n\n## 출력 형식\n리뷰 결과를 구조화된 보고서로 작성하세요:\n- 파일별 이슈 목록 (라인 번호 포함)\n- 종합 평가 및 등급\n- 개선 액션 플랜",
"placeholder": "어떤 코드를 리뷰할까요?" "placeholder": "어떤 코드를 리뷰할까요?"
} }

View File

@@ -6,6 +6,6 @@
"symbol": "\uE777", "symbol": "\uE777",
"color": "#6366F1", "color": "#6366F1",
"description": "코드 구조 개선, 중복 제거, 성능 최적화", "description": "코드 구조 개선, 중복 제거, 성능 최적화",
"systemPrompt": "당신은 AX Copilot Refactoring Agent — 코드 품질 개선 전문 에이전트입니다.\n\n## 역할\n기존 코드의 구조를 개선하고 기술 부채를 줄이는 리팩터링을 수행합니다.\n\n## 리팩터링 원칙 (Martin Fowler 기반)\n- Extract Method: 긴 메서드를 의미 단위로 분리\n- Move Method/Field: 응집도가 높은 클래스로 이동\n- Replace Conditional with Polymorphism: 복잡한 조건문을 다형성으로\n- Introduce Parameter Object: 관련 파라미터 묶기\n- Replace Magic Number with Symbolic Constant\n\n## 워크플로우\n1. folder_map + grep으로 대상 코드 구조 분석\n2. 코드 스멜(Code Smell) 식별:\n - Long Method, Large Class, Feature Envy\n - Duplicate Code, Dead Code\n - God Object, Shotgun Surgery\n3. 리팩터링 계획을 사용자에게 제시 (변경 전/후 설명)\n4. 승인 후 file_edit으로 점진적 수정 (한 번에 하나의 리팩터링)\n5. 각 단계마다 build_run으로 빌드/테스트 검증\n6. 동작 변경 없이 구조만 개선되었는지 확인\n\n## 주의사항\n- 기능을 변경하지 마세요 (행동 보존 리팩터링)\n- 테스트가 있으면 테스트를 먼저 실행하여 기준선 확보\n- 대규모 변경은 단계별로 나누어 진행\n- 변경 사항을 명확히 설명하세요", "systemPrompt": "당신은 AX Copilot Refactoring Agent — 코드 품질 개선 전문 에이전트입니다.\n\n## 역할\n기존 코드의 구조를 개선하고 기술 부채를 줄이는 리팩터링을 수행합니다.\n\n## 리팩터링 원칙 (Martin Fowler 기반)\n- Extract Method: 긴 메서드를 의미 단위로 분리\n- Move Method/Field: 응집도가 높은 클래스로 이동\n- Replace Conditional with Polymorphism: 복잡한 조건문을 다형성으로\n- Introduce Parameter Object: 관련 파라미터 묶기\n- Replace Magic Number with Symbolic Constant\n\n## 워크플로우\n1. grep/glob으로 대상 코드와 호출 범위를 먼저 좁히기\n2. file_read로 코드 스멜(Code Smell) 식별\n - Long Method, Large Class, Feature Envy\n - Duplicate Code, Dead Code\n - God Object, Shotgun Surgery\n3. 리팩터링 계획을 내부적으로 정리하고 필요할 때만 제시\n4. file_edit으로 점진적 수정 (한 번에 하나의 리팩터링)\n5. 각 단계마다 git diff, build_run, test_loop로 검증\n6. 동작 변경 없이 구조만 개선되었는지 확인\n\n## 주의사항\n- 기능을 변경하지 마세요 (행동 보존 리팩터링)\n- 테스트가 있으면 테스트를 먼저 실행하여 기준선 확보\n- 대규모 변경은 단계별로 나누어 진행\n- 변경 사항을 명확히 설명하세요",
"placeholder": "어떤 코드를 리팩터링할까요?" "placeholder": "어떤 코드를 리팩터링할까요?"
} }

View File

@@ -6,6 +6,6 @@
"symbol": "\uE8A5", "symbol": "\uE8A5",
"color": "#F59E0B", "color": "#F59E0B",
"description": "Word, Markdown, HTML 문서를 작성합니다", "description": "Word, Markdown, HTML 문서를 작성합니다",
"systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 문서를 작성합니다.\n\n## 핵심 원칙\n- 문서 내용을 **상세하고 완결성 있게** 작성합니다.\n- 목차, 소제목, 번호 매기기를 활용하여 구조화합니다.\n- 전문 용어에는 간단한 설명을 병기합니다.\n- 결과물은 Word(.docx), Markdown(.md), HTML(.html) 중 적합한 형식을 선택합니다.\n- 작업 전 계획을 설명하고 도구를 사용하여 파일을 생성합니다.\n\n## 문서 품질 가이드\n\n### HTML 문서 (html_create)\n- **toc: true** 로 목차 자동 생성. **numbered: true** 로 섹션 번호 자동 부여.\n- **cover** 파라미터로 커버 페이지 추가: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃: <div class=\"callout callout-info\">핵심 내용</div> (info/warning/tip/danger/note).\n- 배지: <span class=\"badge badge-blue\">완료</span>.\n- 타임라인: <div class=\"timeline\"><div class=\"timeline-item\">...</div></div>.\n- mood 추천: professional(공식), elegant(격식), minimal(학술), magazine(뉴스레터).\n\n### Word 문서 (docx_create)\n- **header** 파라미터로 머리글 추가. **footer** 에 {page}로 페이지 번호 삽입.\n- sections에서 type: \"table\" 로 스타일 테이블 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 섹션 간 페이지 구분.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문에 **볼드**, *이탤릭*, `코드` 인라인 서식 지원.\n- level: 1(대제목) / 2(소제목) 로 제목 크기 구분.\n\n## 사용 가능한 도구\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- markdown_create: Markdown 문서 생성\n- html_create: HTML 문서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- document_read: 기존 문서(PDF, DOCX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- template_render: 템플릿 기반 문서 렌더링\n- text_summarize: 긴 텍스트 요약\n\n## 중요: 반드시 도구를 사용하여 파일을 생성하세요\n\n문서 요청을 받으면 텍스트로만 답변하지 마세요. 반드시 html_create, docx_create 등 도구를 호출하여 실제 파일을 생성해야 합니다.\n\n### 기본 전략 (빠른 생성)\n- html_create 또는 docx_create를 직접 호출하여 완성된 문서를 한 번에 생성합니다.\n- 문서 내용을 모두 포함하여 도구를 호출하세요. 개요만 텍스트로 작성하고 끝내지 마세요.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — document_plan으로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble으로 하나의 문서로 결합합니다.", "systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 문서를 작성합니다.\n\n## 핵심 원칙\n- 문서 내용을 **상세하고 완결성 있게** 작성합니다.\n- 목차, 소제목, 번호 매기기를 활용하여 구조화합니다.\n- 전문 용어에는 간단한 설명을 병기합니다.\n- 결과물은 Word(.docx), Markdown(.md), HTML(.html) 중 적합한 형식을 선택합니다.\n- 필요할 때만 계획을 드러내고, 도구를 사용하여 실제 파일을 생성합니다.\n- 파일 탐색은 glob/grep과 document_read/file_read를 우선하고, folder_map은 폴더 구조 확인이 필요할 때만 사용합니다.\n\n## 문서 품질 가이드\n\n### HTML 문서 (html_create)\n- **toc: true** 로 목차 자동 생성. **numbered: true** 로 섹션 번호 자동 부여.\n- **cover** 파라미터로 커버 페이지 추가: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃: <div class=\"callout callout-info\">핵심 내용</div> (info/warning/tip/danger/note).\n- 배지: <span class=\"badge badge-blue\">완료</span>.\n- 타임라인: <div class=\"timeline\"><div class=\"timeline-item\">...</div></div>.\n- mood 추천: professional(공식), elegant(격식), minimal(학술), magazine(뉴스레터).\n\n### Word 문서 (docx_create)\n- **header** 파라미터로 머리글 추가. **footer** 에 {page}로 페이지 번호 삽입.\n- sections에서 type: \"table\" 로 스타일 테이블 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 섹션 간 페이지 구분.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문에 **볼드**, *이탤릭*, `코드` 인라인 서식 지원.\n- level: 1(대제목) / 2(소제목) 로 제목 크기 구분.\n\n## 사용 가능한 도구\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- markdown_create: Markdown 문서 생성\n- html_create: HTML 문서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- document_read: 기존 문서(PDF, DOCX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- template_render: 템플릿 기반 문서 렌더링\n- text_summarize: 긴 텍스트 요약\n\n## 중요: 반드시 도구를 사용하여 파일을 생성하세요\n\n문서 요청을 받으면 텍스트로만 답변하지 마세요. 반드시 html_create, docx_create 등 도구를 호출하여 실제 파일을 생성해야 합니다.\n\n### 기본 전략 (빠른 생성)\n- html_create 또는 docx_create를 직접 호출하여 완성된 문서를 한 번에 생성합니다.\n- 문서 내용을 모두 포함하여 도구를 호출하세요. 개요만 텍스트로 작성하고 끝내지 마세요.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — 필요할 때만 document_plan으로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble으로 하나의 문서로 결합합니다.",
"placeholder": "어떤 문서를 작성할까요? (예: 프로젝트 기획서 작성)" "placeholder": "어떤 문서를 작성할까요? (예: 프로젝트 기획서 작성)"
} }

View File

@@ -6,6 +6,6 @@
"symbol": "\uE9F9", "symbol": "\uE9F9",
"color": "#3B82F6", "color": "#3B82F6",
"description": "Excel, Word, HTML 보고서를 상세하게 작성합니다", "description": "Excel, Word, HTML 보고서를 상세하게 작성합니다",
"systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 보고서를 작성합니다.\n\n## 핵심 원칙\n- 데이터를 **상세하고 구체적으로** 작성합니다. 항목을 생략하지 않습니다.\n- 표(테이블)는 가능한 많은 행과 열을 포함합니다.\n- 수치 데이터는 단위를 명확히 표기합니다.\n- 결과물은 Excel(.xlsx), Word(.docx), HTML(.html) 중 가장 적합한 형식을 선택합니다.\n- 작업 전 계획을 설명하고 도구를 사용하여 파일을 생성합니다.\n\n## 문서 품질 가이드\n\n### HTML 보고서 (html_create)\n- **toc: true** 로 목차를 자동 생성하세요.\n- **numbered: true** 로 섹션 번호(1., 1-1.)를 자동 부여하세요.\n- **cover** 파라미터로 커버 페이지를 추가하세요: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃을 활용하세요: <div class=\"callout callout-info\">중요 정보</div> (info/warning/tip/danger/note).\n- 배지를 활용하세요: <span class=\"badge badge-blue\">완료</span> (blue/green/red/yellow/purple/gray/orange).\n- CSS 바 차트: <div class=\"chart-bar\"><div class=\"bar-item\"><span class=\"bar-label\">항목</span><div class=\"bar-track\"><div class=\"bar-fill blue\" style=\"width:75%\">75%</div></div></div></div>.\n- 그리드 레이아웃: <div class=\"grid-2\"> 또는 grid-3, grid-4로 카드 배치.\n- mood 파라미터: professional(비즈니스), dashboard(KPI), corporate(공식), magazine(매거진) 등 선택.\n\n### Excel (excel_create)\n- 기본 style: 'styled' — 파란 헤더, 줄무늬, 테두리 자동 적용.\n- **freeze_header: true** 로 헤더 행 틀 고정.\n- **summary_row** 로 합계/평균 행 자동 생성: {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}.\n- 수식은 셀 값에 '=SUM(B2:B10)' 형태로 입력.\n- **col_widths** 로 열 너비 지정: [20, 15, 12].\n\n### Word (docx_create)\n- **header/footer** 파라미터로 머리글/바닥글 추가. {page}로 페이지 번호.\n- sections에서 type: \"table\" 로 스타일 테이블 삽입 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 페이지 나누기.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문 텍스트에 **볼드**, *이탤릭*, `코드` 인라인 서식 사용 가능.\n\n## 사용 가능한 도구\n- excel_create: Excel 문서 생성 (서식, 수식, 틀 고정, 요약행 지원)\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- html_create: HTML 보고서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- markdown_create: Markdown 문서 생성\n- csv_create: CSV 파일 생성\n- document_read: 기존 문서(PDF, DOCX, XLSX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성 1단계)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립 (멀티패스 생성 3단계)\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- data_pivot: CSV/JSON 데이터 집계/피벗\n- text_summarize: 긴 텍스트 요약\n\n## 중요: 반드시 도구를 사용하여 파일을 생성하세요\n\n보고서 요청을 받으면 텍스트로만 답변하지 마세요. 반드시 html_create, docx_create, excel_create 등 도구를 호출하여 실제 파일을 생성해야 합니다.\n\n### 기본 전략 (빠른 생성)\n- html_create 또는 docx_create를 직접 호출하여 완성된 보고서를 한 번에 생성합니다.\n- 보고서 내용을 모두 포함하여 도구를 호출하세요. 개요만 텍스트로 작성하고 끝내지 마세요.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — document_plan 도구로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble 도구로 하나의 문서로 결합합니다.", "systemPrompt": "당신은 AX Copilot Agent입니다. 사용자가 요청한 보고서를 작성합니다.\n\n## 핵심 원칙\n- 데이터를 **상세하고 구체적으로** 작성합니다. 항목을 생략하지 않습니다.\n- 표(테이블)는 가능한 많은 행과 열을 포함합니다.\n- 수치 데이터는 단위를 명확히 표기합니다.\n- 결과물은 Excel(.xlsx), Word(.docx), HTML(.html) 중 가장 적합한 형식을 선택합니다.\n- 필요할 때만 계획을 드러내고, 도구를 사용하여 실제 파일을 생성합니다.\n- 파일 탐색은 glob/grep과 document_read/file_read를 우선하고, folder_map은 폴더 구조 확인이 필요할 때만 사용합니다.\n\n## 문서 품질 가이드\n\n### HTML 보고서 (html_create)\n- **toc: true** 로 목차를 자동 생성하세요.\n- **numbered: true** 로 섹션 번호(1., 1-1.)를 자동 부여하세요.\n- **cover** 파라미터로 커버 페이지를 추가하세요: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\"}.\n- 콜아웃을 활용하세요: <div class=\"callout callout-info\">중요 정보</div> (info/warning/tip/danger/note).\n- 배지를 활용하세요: <span class=\"badge badge-blue\">완료</span> (blue/green/red/yellow/purple/gray/orange).\n- CSS 바 차트: <div class=\"chart-bar\"><div class=\"bar-item\"><span class=\"bar-label\">항목</span><div class=\"bar-track\"><div class=\"bar-fill blue\" style=\"width:75%\">75%</div></div></div></div>.\n- 그리드 레이아웃: <div class=\"grid-2\"> 또는 grid-3, grid-4로 카드 배치.\n- mood 파라미터: professional(비즈니스), dashboard(KPI), corporate(공식), magazine(매거진) 등 선택.\n\n### Excel (excel_create)\n- 기본 style: 'styled' — 파란 헤더, 줄무늬, 테두리 자동 적용.\n- **freeze_header: true** 로 헤더 행 틀 고정.\n- **summary_row** 로 합계/평균 행 자동 생성: {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}.\n- 수식은 셀 값에 '=SUM(B2:B10)' 형태로 입력.\n- **col_widths** 로 열 너비 지정: [20, 15, 12].\n\n### Word (docx_create)\n- **header/footer** 파라미터로 머리글/바닥글 추가. {page}로 페이지 번호.\n- sections에서 type: \"table\" 로 스타일 테이블 삽입 (파란 헤더, 줄무늬).\n- type: \"pagebreak\" 로 페이지 나누기.\n- type: \"list\" 로 번호/불릿 목록: {\"type\": \"list\", \"style\": \"number\", \"items\": [...]}.\n- 본문 텍스트에 **볼드**, *이탤릭*, `코드` 인라인 서식 사용 가능.\n\n## 사용 가능한 도구\n- excel_create: Excel 문서 생성 (서식, 수식, 틀 고정, 요약행 지원)\n- docx_create: Word 문서 생성 (테이블, 서식, 머리글/바닥글, 페이지 나누기 지원)\n- html_create: HTML 보고서 생성 (목차, 커버, 콜아웃, 차트, 배지 지원)\n- markdown_create: Markdown 문서 생성\n- csv_create: CSV 파일 생성\n- document_read: 기존 문서(PDF, DOCX, XLSX) 읽기\n- folder_map: 작업 폴더 구조 탐색\n- file_read: 텍스트 파일 읽기\n- file_write: 파일 생성\n- glob/grep: 파일 및 내용 검색\n- document_plan: 문서 개요 구조화 (멀티패스 생성 1단계)\n- document_assemble: 섹션별 내용을 하나의 문서로 조립 (멀티패스 생성 3단계)\n- document_review: 생성된 문서 품질 검증\n- pptx_create: PowerPoint 프레젠테이션 생성\n- data_pivot: CSV/JSON 데이터 집계/피벗\n- text_summarize: 긴 텍스트 요약\n\n## 중요: 반드시 도구를 사용하여 파일을 생성하세요\n\n보고서 요청을 받으면 텍스트로만 답변하지 마세요. 반드시 html_create, docx_create, excel_create 등 도구를 호출하여 실제 파일을 생성해야 합니다.\n\n### 기본 전략 (빠른 생성)\n- html_create 또는 docx_create를 직접 호출하여 완성된 보고서를 한 번에 생성합니다.\n- 보고서 내용을 모두 포함하여 도구를 호출하세요. 개요만 텍스트로 작성하고 끝내지 마세요.\n\n### 멀티패스 전략 (고품질 설정 ON 시, 3페이지 이상)\n1단계 — 필요할 때만 document_plan 도구로 문서 구조를 설계합니다.\n2단계 — 각 섹션을 개별적으로 상세하게 작성합니다.\n3단계 — document_assemble 도구로 하나의 문서로 결합합니다.",
"placeholder": "어떤 보고서를 작성할까요? (예: 삼성디스플레이 연혁 보고서)" "placeholder": "어떤 보고서를 작성할까요? (예: 삼성디스플레이 연혁 보고서)"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

View File

@@ -0,0 +1,197 @@
from PIL import Image, ImageDraw, ImageFont
# 1. 색상 정의 (RGB 값) - 확장된 팔레트
B = (20, 20, 20) # Background (Dark Gray)
G1 = (121, 210, 180) # Mint Green (Main body)
G2 = (100, 180, 160) # Mint Green (Shadow)
W1 = (255, 255, 255) # White (Face/Belly)
W2 = (240, 240, 240) # White (Shadow)
P1 = (255, 180, 210) # Pink (Main Body)
P2 = (255, 150, 190) # Pink (Fluffy texture detail)
P3 = (240, 130, 170) # Pink (Shadow)
E1 = (0, 0, 0) # Eyes (Black)
E2 = (40, 40, 40) # Eyes (Pupil detail)
C_RED = (255, 80, 80) # Ear pattern Red
C_YLW = (255, 230, 80) # Ear pattern Yellow
C_BLU = (80, 150, 255) # Ear pattern Blue
C_PUR = (180, 80, 255) # Ear pattern Purple
C_GRN = (80, 220, 120) # Pink hair pattern Green
ORNG = (255, 145, 77) # Feet (Orange)
# 2. 텍스트 그리기 함수 (픽셀 폰트)
def draw_pixel_text(draw, text, position, font_size=16, color=(255, 255, 255)):
# 실제 픽셀 폰트를 사용하려면 폰트 파일이 필요합니다.
# 여기서는 Pillow의 기본 폰트를 사용하지만, 픽셀 폰트처럼 보이게 할 수 있습니다.
# 더 좋은 결과를 위해 실제 .ttf 픽셀 폰트를 로드하는 것이 좋습니다.
try:
font = ImageFont.truetype("Arial.ttf", font_size) # 실제 폰트 파일 경로
except IOError:
font = ImageFont.load_default() # 폰트 파일이 없으면 기본 폰트 사용
font_size = 12 # 기본 폰트 크기
draw.text(position, text, font=font, fill=color)
# 3. 캐릭터 그리기 함수 (개념적 블록 조합)
def draw_pixel_bunny(draw, position, size, pose, is_detailed=True):
# size: 캐릭터의 기본 크기 (예: 32x32)
# is_detailed: 원본의 단순화된 스타일 대신 더 정교한 디테일을 추가할지 여부
x_off, y_off = position
scale = size[0] // 32
# 기본 형태 그리기
# 머리
head_color = G1
head_size = int(size[0] * 0.5)
head_y = y_off + int(size[1] * 0.1)
draw.ellipse((x_off + int(size[0]*0.25), head_y,
x_off + int(size[0]*0.75), head_y + head_size),
fill=head_color)
# 얼굴 흰 부분
draw.ellipse((x_off + int(size[0]*0.3), head_y + int(head_size*0.1),
x_off + int(size[0]*0.7), head_y + int(head_size*0.9)),
fill=W1)
# 귀
draw.rectangle((x_off + int(size[0]*0.3), head_y - int(size[1]*0.1),
x_off + int(size[0]*0.4), head_y),
fill=head_color)
draw.rectangle((x_off + int(size[0]*0.6), head_y - int(size[1]*0.1),
x_off + int(size[0]*0.7), head_y),
fill=head_color)
# 귀 디테일 (원본의 X 패턴 대신 더 복잡한 줄무늬)
if is_detailed:
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.08),
x_off + int(size[0]*0.39), head_y - int(size[1]*0.06)), fill=C_RED)
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.06),
x_off + int(size[0]*0.39), head_y - int(size[1]*0.04)), fill=C_YLW)
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.04),
x_off + int(size[0]*0.39), head_y - int(size[1]*0.02)), fill=C_BLU)
draw.rectangle((x_off + int(size[0]*0.31), head_y - int(size[1]*0.02),
x_off + int(size[0]*0.39), head_y), fill=C_PUR)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.08),
x_off + int(size[0]*0.69), head_y - int(size[1]*0.06)), fill=C_RED)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.06),
x_off + int(size[0]*0.69), head_y - int(size[1]*0.04)), fill=C_YLW)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.04),
x_off + int(size[0]*0.69), head_y - int(size[1]*0.02)), fill=C_BLU)
draw.rectangle((x_off + int(size[0]*0.61), head_y - int(size[1]*0.02),
x_off + int(size[0]*0.69), head_y), fill=C_PUR)
# 눈
draw.ellipse((x_off + int(size[0]*0.4), head_y + int(head_size*0.3),
x_off + int(size[0]*0.48), head_y + int(head_size*0.5)),
fill=E1)
draw.ellipse((x_off + int(size[0]*0.52), head_y + int(head_size*0.3),
x_off + int(size[0]*0.6), head_y + int(head_size*0.5)),
fill=E1)
# 눈동자 디테일
if is_detailed:
draw.ellipse((x_off + int(size[0]*0.42), head_y + int(head_size*0.35),
x_off + int(size[0]*0.46), head_y + int(head_size*0.45)),
fill=W1)
draw.ellipse((x_off + int(size[0]*0.54), head_y + int(head_size*0.35),
x_off + int(size[0]*0.58), head_y + int(head_size*0.45)),
fill=W1)
# 몸통
draw.rectangle((x_off + int(size[0]*0.3), head_y + head_size,
x_off + int(size[0]*0.7), head_y + head_size + int(size[1]*0.3)),
fill=head_color)
# 배 흰 부분
draw.ellipse((x_off + int(size[0]*0.35), head_y + head_size + int(size[1]*0.05),
x_off + int(size[0]*0.65), head_y + head_size + int(size[1]*0.25)),
fill=W1)
# 다리
draw.rectangle((x_off + int(size[0]*0.35), head_y + head_size + int(size[1]*0.3),
x_off + int(size[0]*0.45), head_y + head_size + int(size[1]*0.4)),
fill=head_color)
draw.rectangle((x_off + int(size[0]*0.55), head_y + head_size + int(size[1]*0.3),
x_off + int(size[0]*0.65), head_y + head_size + int(size[1]*0.4)),
fill=head_color)
# 발 (주황색)
draw.rectangle((x_off + int(size[0]*0.35), head_y + head_size + int(size[1]*0.38),
x_off + int(size[0]*0.45), head_y + head_size + int(size[1]*0.43)),
fill=ORNG)
draw.rectangle((x_off + int(size[0]*0.55), head_y + head_size + int(size[1]*0.38),
x_off + int(size[0]*0.65), head_y + head_size + int(size[1]*0.43)),
fill=ORNG)
# 측면 포즈일 경우 디테일 추가
if pose == 'side':
# 눈 모양 변경
# ... 측면 포즈에 대한 더 많은 코드 ...
pass
def draw_pixel_pink_fluffball(draw, position, size, pose, is_detailed=True):
# draw_pixel_bunny와 유사한 구조, 하지만 털 질감을 추가
x_off, y_off = position
scale = size[0] // 32
# 머리 (털 질감)
head_y = y_off + int(size[1] * 0.1)
for _ in range(int(size[0]*0.5)):
for _ in range(int(size[0]*0.5)):
draw.point((x_off + int(size[0]*0.25) + _, head_y + _), fill=P1)
# ... 더 많은 털 질감과 디테일 코드 ...
pass
# 4. 이미지 크기 정의 및 배경 생성
canvas_width = 800
canvas_height = 600
img = Image.new('RGB', (canvas_width, canvas_height), color=B)
draw = ImageDraw.Draw(img)
# 5. 이미지 구성
# 상단 제목 그리기
draw_pixel_text(draw, "정교한 캐릭터 시트", (int(canvas_width*0.35), int(canvas_height*0.05)), font_size=32)
# 왼쪽 패널 그리기 (민트 토끼 캐릭터 '더 자세히' 업그레이드)
panel_bunny_y = int(canvas_height*0.2)
panel_title_bunny = "민트 토끼 슈트 캐릭터"
draw_pixel_text(draw, panel_title_bunny, (int(canvas_width*0.1), panel_bunny_y), font_size=20)
bunny_size_detailed = (200, 200) # 원본보다 더 큰 크기로 그려서 디테일 표현
bunny_pos_detailed = (int(canvas_width*0.1), panel_bunny_y + 40)
draw_pixel_bunny(draw, bunny_pos_detailed, bunny_size_detailed, 'front', is_detailed=True)
draw_pixel_text(draw, "정면", (int(canvas_width*0.1 + bunny_size_detailed[0]*0.4), panel_bunny_y + 40 + bunny_size_detailed[1]), font_size=16)
bunny_pos_detailed_side = (int(canvas_width*0.1 + bunny_size_detailed[0] + 50), panel_bunny_y + 40)
draw_pixel_bunny(draw, bunny_pos_detailed_side, bunny_size_detailed, 'side', is_detailed=True)
draw_pixel_text(draw, "측면 45도", (int(canvas_width*0.1 + bunny_size_detailed[0] + 50 + bunny_size_detailed[0]*0.3), panel_bunny_y + 40 + bunny_size_detailed[1]), font_size=16)
# 오른쪽 패널 그리기 (핑크 털뭉치 캐릭터 '더 자세히' 업그레이드)
panel_pink_y = int(canvas_height*0.2)
panel_title_pink = "핑크 털 뭉치 슈트 캐릭터"
draw_pixel_text(draw, panel_title_pink, (int(canvas_width*0.5), panel_pink_y), font_size=20)
pink_size_detailed = (200, 200) # 원본보다 더 큰 크기로 그려서 디테일 표현
pink_pos_detailed = (int(canvas_width*0.5), panel_pink_y + 40)
draw_pixel_pink_fluffball(draw, pink_pos_detailed, pink_size_detailed, 'front', is_detailed=True)
draw_pixel_text(draw, "정면", (int(canvas_width*0.5 + pink_size_detailed[0]*0.4), panel_pink_y + 40 + pink_size_detailed[1]), font_size=16)
pink_pos_detailed_side = (int(canvas_width*0.5 + pink_size_detailed[0] + 50), panel_pink_y + 40)
draw_pixel_pink_fluffball(draw, pink_pos_detailed_side, pink_size_detailed, 'side', is_detailed=True)
draw_pixel_text(draw, "측면 45도", (int(canvas_width*0.5 + pink_size_detailed[0] + 50 + pink_size_detailed[0]*0.3), panel_pink_y + 40 + pink_size_detailed[1]), font_size=16)
# 6. 보기 좋게 확대 및 픽셀 격자 표현 (옵션)
# 픽셀 아트 격자 효과를 주려면, 이미지를 작게 그린 후 크게 확대합니다.
# 이 코드는 개념적인 블록 그리기 방식을 사용하여 직접 디테일한 픽셀 아트를 생성하므로,
# 실제 격자 확대 효과는 생략합니다.
# 7. 결과 저장 및 보여주기
output_filename = "detailed_pixel_characters_sheet.png"
img.save(output_filename)
img.show()
print(f"정교한 캐릭터 시트 이미지가 '{output_filename}'으로 저장되었습니다. {canvas_width}x{canvas_height} 크기")

View File

@@ -68,6 +68,7 @@
<!-- 클립보드 히스토리 DPAPI 암호화 (Windows 전용, 외부 네트워크 통신 없음) --> <!-- 클립보드 히스토리 DPAPI 암호화 (Windows 전용, 외부 네트워크 통신 없음) -->
<!-- Windows 서비스 관리 (ServiceHandler — 로컬 전용, 외부 네트워크 통신 없음) --> <!-- Windows 서비스 관리 (ServiceHandler — 로컬 전용, 외부 네트워크 통신 없음) -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.2.0" /> <PackageReference Include="DocumentFormat.OpenXml" Version="3.2.0" />
<PackageReference Include="Markdig" Version="0.37.0" /> <PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
@@ -114,6 +115,11 @@
<Resource Include="Assets\mascot.png" Condition="Exists('Assets\mascot.png')" /> <Resource Include="Assets\mascot.png" Condition="Exists('Assets\mascot.png')" />
<Resource Include="Assets\mascot.jpg" Condition="Exists('Assets\mascot.jpg')" /> <Resource Include="Assets\mascot.jpg" Condition="Exists('Assets\mascot.jpg')" />
<Resource Include="Assets\mascot.webp" Condition="Exists('Assets\mascot.webp')" /> <Resource Include="Assets\mascot.webp" Condition="Exists('Assets\mascot.webp')" />
<Resource Include="Assets\foldy_qdy.png" Condition="Exists('Assets\foldy_qdy.png')" />
<Resource Include="Assets\pixel_art.png" Condition="Exists('Assets\pixel_art.png')" />
<!-- PPT 고품질 템플릿: 빌드에 포함하지 않음 (297MB).
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->
<!-- 검색 엔진 아이콘: 빌드 출력에 복사 (사내망에서 외부 favicon 다운로드 불가 대응) --> <!-- 검색 엔진 아이콘: 빌드 출력에 복사 (사내망에서 외부 favicon 다운로드 불가 대응) -->
<Content Include="Assets\SearchEngines\*.png"> <Content Include="Assets\SearchEngines\*.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -125,6 +131,14 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<!-- 마스코트 GIF 애니메이션: 빌드 출력 Assets/gif/ 폴더에 복사 -->
<ItemGroup>
<Content Include="Assets\gif\*.gif">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Assets\gif\%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
<!-- 내장 스킬 파일: 빌드 출력 skills/ 폴더에 복사 --> <!-- 내장 스킬 파일: 빌드 출력 skills/ 폴더에 복사 -->
<ItemGroup> <ItemGroup>
<Content Include="skills\*.skill.md"> <Content Include="skills\*.skill.md">

View File

@@ -0,0 +1,422 @@
<Project>
<PropertyGroup>
<AssemblyName>AxCopilot</AssemblyName>
<IntermediateOutputPath>obj\Release\</IntermediateOutputPath>
<BaseIntermediateOutputPath>obj\</BaseIntermediateOutputPath>
<MSBuildProjectExtensionsPath>E:\AX Copilot - Claude\src\AxCopilot\obj\</MSBuildProjectExtensionsPath>
<_TargetAssemblyProjectName>AxCopilot</_TargetAssemblyProjectName>
<RootNamespace>AxCopilot</RootNamespace>
</PropertyGroup>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>AxCopilot</RootNamespace>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<!--
★ 버전 관리 규칙 ★
이 <Version> 값 하나만 변경하면 앱 전체에 자동 반영됩니다:
- 설정 창 하단 버전 표시 (SettingsWindow.xaml.cs → SetVersionText())
- Windows 속성 탭 파일 버전 / 제품 버전
- DEVELOPMENT.md 변경 이력은 수동으로 함께 업데이트하세요.
설정 스키마 버전(마이그레이션)은 Services/SettingsService.cs → CurrentSettingsVersion 을 별도로 관리합니다.
-->
<Version>0.7.3</Version>
<Company>AX</Company>
<Product>AX Copilot</Product>
<Description>AI 기반 업무 자동화 런처 &amp; 코파일럿</Description>
<!-- ScreenCaptureHandler의 LockBits 포인터 연산에 필요 -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 배포: build.bat에서 self-contained / framework-dependent 두 종류로 publish -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!-- ─── 디컴파일 방지 ─────────────────────────────────────────────── -->
<!-- 디버그 심볼 제거 (Release) -->
<DebugType Condition="'$(Configuration)'=='Release'">none</DebugType>
<DebugSymbols Condition="'$(Configuration)'=='Release'">false</DebugSymbols>
<!-- 임베디드 소스 제거 -->
<EmbedAllSources>false</EmbedAllSources>
<!-- Suppress source link -->
<EnableSourceLink>false</EnableSourceLink>
<DeterministicSourcePaths>false</DeterministicSourcePaths>
</PropertyGroup>
<!-- Release 빌드 시 추가 난독화 설정 -->
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<Optimize>true</Optimize>
<!-- 사용하지 않는 멤버 제거 (IL trimming) -->
<PublishTrimmed>false</PublishTrimmed>
<!-- PDB 제거 -->
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
<!-- 배포판 보호 수준 강화 -->
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<!-- UseWindowsForms의 암묵적 using(System.Windows.Forms)이 WPF의
System.Windows.Input과 충돌(KeyEventArgs 모호성)하므로 전역 제거.
System.Drawing도 전역 제거: System.Windows.Media의 Color/ColorConverter/FontFamily와
모호한 참조 충돌 방지. Drawing 타입이 필요한 파일에서만 명시적 using으로 추가. -->
<ItemGroup>
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AxCopilot.SDK\AxCopilot.SDK.csproj" />
</ItemGroup>
<!-- 클립보드 히스토리 DPAPI 암호화 (Windows 전용, 외부 네트워크 통신 없음) -->
<!-- Windows 서비스 관리 (ServiceHandler — 로컬 전용, 외부 네트워크 통신 없음) -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.2.0" />
<PackageReference Include="Markdig" Version="0.37.0" />
<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.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" />
</ItemGroup>
<!-- Assets -->
<ItemGroup>
<!-- 가이드 원본 HTML: 편집용. 출력에 복사하지 않음 (암호화 버전 사용) -->
<None Include="Assets\AX Copilot 사용가이드.htm" />
<None Include="Assets\AX Copilot 개발자가이드.htm" />
<!-- 암호화된 가이드: 빌드 출력에 복사 (앱 내장 뷰어로 복호화) -->
<Content Include="Assets\guide_user.enc">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\guide_dev.enc">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- 아이콘: 앱 내장 리소스 + 출력에도 복사 (트레이용) -->
<None Update="Assets\icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- about.json: 빌드 시 exe에 내장되는 리소스. 런타임 파일 수정 불가. -->
<!-- 격려/명언 문구: 빌드 시 내장. 인터넷 연결 불필요. -->
<!-- 대화 주제 프리셋: 9종 시스템 프롬프트 (빌드 시 내장) -->
<EmbeddedResource Include="Assets\Presets\*.json" />
<!-- 마스코트 이미지: 내장 리소스 (EXE에 포함됨, 교체 시 재빌드 필요) -->
<!-- PPT 고품질 템플릿: 빌드에 포함하지 않음 (297MB).
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->
<!-- 검색 엔진 아이콘: 빌드 출력에 복사 (사내망에서 외부 favicon 다운로드 불가 대응) -->
<Content Include="Assets\SearchEngines\*.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- 시스템 프롬프트: 빌드 출력에 복사 (동적 로딩) -->
<Content Include="Assets\system_prompt.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>system_prompt.txt</Link>
</Content>
</ItemGroup>
<!-- 마스코트 GIF 애니메이션: 빌드 출력 Assets/gif/ 폴더에 복사 -->
<ItemGroup>
<Content Include="Assets\gif\*.gif">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Assets\gif\%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
<!-- 내장 스킬 파일: 빌드 출력 skills/ 폴더에 복사 -->
<ItemGroup>
<Content Include="skills\*.skill.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>skills\%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
<!-- 테마 리소스 딕셔너리 -->
<ItemGroup>
</ItemGroup>
<!-- 단위 테스트 프로젝트에서 internal 멤버 접근 허용 -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>AxCopilot.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Accessibility.dll" />
<ReferencePath Include="E:\AX Copilot - Claude\src\AxCopilot.SDK\bin\Release\net8.0-windows\AxCopilot.SDK.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\documentformat.openxml\3.2.0\lib\net8.0\DocumentFormat.OpenXml.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\documentformat.openxml.framework\3.2.0\lib\net8.0\DocumentFormat.OpenXml.Framework.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\markdig\0.37.0\lib\net8.0\Markdig.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.CSharp.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.data.sqlite.core\8.0.0\lib\net8.0\Microsoft.Data.Sqlite.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.extensions.dependencyinjection.abstractions\8.0.2\lib\net8.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.extensions.dependencyinjection\8.0.1\lib\net8.0\Microsoft.Extensions.DependencyInjection.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.VisualBasic.Core.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.VisualBasic.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.VisualBasic.Forms.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.web.webview2\1.0.2903.40\lib_manual\netcoreapp3.0\Microsoft.Web.WebView2.Core.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.web.webview2\1.0.2903.40\lib_manual\netcoreapp3.0\Microsoft.Web.WebView2.WinForms.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.web.webview2\1.0.2903.40\lib_manual\net5.0-windows10.0.17763.0\Microsoft.Web.WebView2.Wpf.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.Registry.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.Registry.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\Microsoft.Win32.SystemEvents.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.windows.sdk.net.ref\10.0.17763.57\lib\net8.0\Microsoft.Windows.SDK.NET.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\mscorlib.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\netstandard.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationCore.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Aero.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Aero2.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.AeroLite.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Classic.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Luna.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationFramework.Royale.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\PresentationUI.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\qrcoder\1.6.0\lib\net6.0-windows7.0\QRCoder.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\ReachFramework.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\sqlitepclraw.bundle_e_sqlite3\2.1.6\lib\netstandard2.0\SQLitePCLRaw.batteries_v2.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\sqlitepclraw.core\2.1.6\lib\netstandard2.0\SQLitePCLRaw.core.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\sqlitepclraw.provider.e_sqlite3\2.1.6\lib\net6.0-windows7.0\SQLitePCLRaw.provider.e_sqlite3.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.AppContext.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Buffers.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.CodeDom.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.Concurrent.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.Immutable.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.NonGeneric.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Collections.Specialized.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.Annotations.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.DataAnnotations.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.EventBasedAsync.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ComponentModel.TypeConverter.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Configuration.ConfigurationManager.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Configuration.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Console.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Core.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Data.Common.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Data.DataSetExtensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Data.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Design.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Contracts.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Debug.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.DiagnosticSource.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.EventLog.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.FileVersionInfo.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.PerformanceCounter.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Process.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.StackTrace.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.TextWriterTraceListener.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Tools.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.TraceSource.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Diagnostics.Tracing.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.DirectoryServices.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Drawing.Common.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Drawing.Design.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Drawing.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Drawing.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Dynamic.Runtime.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Formats.Asn1.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Formats.Tar.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Globalization.Calendars.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Globalization.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Globalization.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.Brotli.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.FileSystem.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Compression.ZipFile.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.DriveInfo.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.FileSystem.Watcher.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.IsolatedStorage.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.MemoryMappedFiles.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.IO.Packaging.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Pipes.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.Pipes.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.IO.UnmanagedMemoryStream.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.Expressions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.Parallel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Linq.Queryable.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Memory.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Http.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Http.Json.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.HttpListener.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Mail.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.NameResolution.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.NetworkInformation.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Ping.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Quic.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Requests.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Security.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.ServicePoint.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.Sockets.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebClient.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebHeaderCollection.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebProxy.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebSockets.Client.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Net.WebSockets.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Numerics.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Numerics.Vectors.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ObjectModel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Printing.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.DispatchProxy.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Emit.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Emit.ILGeneration.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Emit.Lightweight.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Metadata.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Reflection.TypeExtensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Resources.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Resources.Reader.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Resources.ResourceManager.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Resources.Writer.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.CompilerServices.Unsafe.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.CompilerServices.VisualC.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Handles.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.InteropServices.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.InteropServices.JavaScript.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.InteropServices.RuntimeInformation.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Intrinsics.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Loader.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Numerics.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Formatters.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Json.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Runtime.Serialization.Xml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Claims.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Algorithms.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Cng.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Csp.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Encoding.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.OpenSsl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Pkcs.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.ProtectedData.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.X509Certificates.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Cryptography.Xml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Security.Permissions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Principal.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.Principal.Windows.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Security.SecureString.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ServiceModel.Web.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ServiceProcess.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\system.serviceprocess.servicecontroller\8.0.1\lib\net8.0\System.ServiceProcess.ServiceController.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encoding.CodePages.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encoding.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encoding.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Encodings.Web.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.Json.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Text.RegularExpressions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Threading.AccessControl.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Channels.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Overlapped.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.Dataflow.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Tasks.Parallel.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Thread.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.ThreadPool.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Threading.Timer.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Transactions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Transactions.Local.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.ValueTuple.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Web.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Web.HttpUtility.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Controls.Ribbon.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Windows.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Extensions.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.Design.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.Design.Editors.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Forms.Primitives.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Input.Manipulations.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Windows.Presentation.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\System.Xaml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.Linq.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.ReaderWriter.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.Serialization.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XDocument.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XmlDocument.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XmlSerializer.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XPath.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\ref\net8.0\System.Xml.XPath.XDocument.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.core\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Core.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.fonts\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Fonts.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.tokenization\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Tokenization.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\uglytoad.pdfpig.tokens\1.7.0-custom-5\lib\net6.0\UglyToad.PdfPig.Tokens.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationClient.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationClientSideProviders.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationProvider.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\UIAutomationTypes.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\WindowsBase.dll" />
<ReferencePath Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\ref\net8.0\WindowsFormsIntegration.dll" />
<ReferencePath Include="C:\Users\admin\.nuget\packages\microsoft.windows.sdk.net.ref\10.0.17763.57\lib\net8.0\WinRT.Runtime.dll" />
</ItemGroup>
<ItemGroup>
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\AboutWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\AgentSettingsWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\AgentStatsDashboardWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\BatchRenameWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ChatWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ClipboardImagePreviewWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ColorPickResultWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\CommandPaletteWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\DockBarWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\EyeDropperWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\GuideViewerWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\HelpDetailWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\LargeTypeWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\LauncherWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\MacroEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\PreviewWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\QuickLookWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\RegionSelectWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ReminderPopupWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ResourceMonitorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ScheduleEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SessionEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SettingsWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\ShortcutHelpWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SkillEditorWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\SkillGalleryWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\StatisticsWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\TextActionPopup.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\TrayMenuWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\Views\WorkflowAnalyzerWindow.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\App.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\AxCopilot_Content.g.cs" />
<Compile Include="E:\AX Copilot - Claude\src\AxCopilot\obj\Release\net8.0-windows10.0.17763.0\win-x64\GeneratedInternalTypeHelper.g.cs" />
</ItemGroup>
<ItemGroup>
<Analyzer Include="C:\Program Files\dotnet\sdk\10.0.201\Sdks\Microsoft.NET.Sdk\targets\..\analyzers\Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll" />
<Analyzer Include="C:\Program Files\dotnet\sdk\10.0.201\Sdks\Microsoft.NET.Sdk\targets\..\analyzers\Microsoft.CodeAnalysis.NetAnalyzers.dll" />
<Analyzer Include="C:\Users\admin\.nuget\packages\microsoft.net.illink.tasks\8.0.25\analyzers\dotnet\cs\ILLink.CodeFixProvider.dll" />
<Analyzer Include="C:\Users\admin\.nuget\packages\microsoft.net.illink.tasks\8.0.25\analyzers\dotnet\cs\ILLink.RoslynAnalyzer.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.ComInterfaceGenerator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.JavaScript.JSImportGenerator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.LibraryImportGenerator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/Microsoft.Interop.SourceGeneration.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/System.Text.Json.SourceGeneration.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.25\analyzers/dotnet/cs/System.Text.RegularExpressions.Generator.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\analyzers/dotnet/System.Windows.Forms.Analyzers.dll" />
<Analyzer Include="C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.25\analyzers/dotnet/cs/System.Windows.Forms.Analyzers.CSharp.dll" />
<Analyzer Include="C:\Users\admin\.nuget\packages\microsoft.windows.sdk.net.ref\10.0.17763.57\analyzers/dotnet/cs/WinRT.SourceGenerator.dll" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
</Project>

View File

@@ -64,7 +64,7 @@ public class ChatHandler : IActionHandler
try try
{ {
var storage = new ChatStorageService(); var storage = ServiceLocator.Get<IChatStorageService>();
var metas = storage.LoadAllMeta(); var metas = storage.LoadAllMeta();
foreach (var conv in metas.Take(5)) foreach (var conv in metas.Take(5))
{ {

View File

@@ -185,6 +185,14 @@ public class LauncherSettings
[JsonPropertyName("enableIconAnimation")] [JsonPropertyName("enableIconAnimation")]
public bool EnableIconAnimation { get; set; } = true; public bool EnableIconAnimation { get; set; } = true;
/// <summary>채팅 본문 런처 아이콘 랜덤 애니메이션. false이면 숨쉬기만. 기본 false.</summary>
[JsonPropertyName("enableChatIconRandomAnimation")]
public bool EnableChatIconRandomAnimation { get; set; } = false;
/// <summary>채팅 본문 런처 아이콘 글로우 강도. strong/medium/weak. 기본 medium.</summary>
[JsonPropertyName("chatIconGlowIntensity")]
public string ChatIconGlowIntensity { get; set; } = "medium";
/// <summary>런처 안내 문구 랜덤 출력 활성화. false이면 고정 문구. 기본 true.</summary> /// <summary>런처 안내 문구 랜덤 출력 활성화. false이면 고정 문구. 기본 true.</summary>
[JsonPropertyName("enableRandomPlaceholder")] [JsonPropertyName("enableRandomPlaceholder")]
public bool EnableRandomPlaceholder { get; set; } = true; public bool EnableRandomPlaceholder { get; set; } = true;
@@ -804,10 +812,18 @@ public class LlmSettings
[JsonPropertyName("recentPromptTemplates")] [JsonPropertyName("recentPromptTemplates")]
public List<string> RecentPromptTemplates { get; set; } = new(); public List<string> RecentPromptTemplates { get; set; } = new();
/// <summary>작업 폴더 경로. 빈 문자열이면 미선택.</summary> /// <summary>작업 폴더 경로. 빈 문자열이면 미선택. (레거시 — 탭별 경로 미지정 시 폴백)</summary>
[JsonPropertyName("workFolder")] [JsonPropertyName("workFolder")]
public string WorkFolder { get; set; } = ""; public string WorkFolder { get; set; } = "";
/// <summary>코워크(Cowork) 탭 전용 작업 폴더.</summary>
[JsonPropertyName("coworkWorkFolder")]
public string CoworkWorkFolder { get; set; } = "";
/// <summary>코드(Code) 탭 전용 작업 폴더.</summary>
[JsonPropertyName("codeWorkFolder")]
public string CodeWorkFolder { get; set; } = "";
/// <summary>최근 사용한 작업 폴더 목록.</summary> /// <summary>최근 사용한 작업 폴더 목록.</summary>
[JsonPropertyName("recentWorkFolders")] [JsonPropertyName("recentWorkFolders")]
public List<string> RecentWorkFolders { get; set; } = new(); public List<string> RecentWorkFolders { get; set; } = new();
@@ -885,9 +901,9 @@ public class LlmSettings
[JsonPropertyName("maxTestFixIterations")] [JsonPropertyName("maxTestFixIterations")]
public int MaxTestFixIterations { get; set; } = 5; public int MaxTestFixIterations { get; set; } = 5;
/// <summary>에이전트 로그 표시 수준. simple | detailed | debug</summary> /// <summary>에이전트 로그 표시 수준. hidden | simple | detailed | debug</summary>
[JsonPropertyName("agentLogLevel")] [JsonPropertyName("agentLogLevel")]
public string AgentLogLevel { get; set; } = "simple"; public string AgentLogLevel { get; set; } = "detailed";
/// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary> /// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary>
[JsonPropertyName("agentUiExpressionLevel")] [JsonPropertyName("agentUiExpressionLevel")]
@@ -1055,6 +1071,14 @@ public class LlmSettings
[JsonPropertyName("enableChatRainbowGlow")] [JsonPropertyName("enableChatRainbowGlow")]
public bool EnableChatRainbowGlow { get; set; } = false; public bool EnableChatRainbowGlow { get; set; } = false;
/// <summary>새로운 계획 뷰어(V2 사이드바 레이아웃) 사용. 기본 true.</summary>
[JsonPropertyName("enableNewPlanViewer")]
public bool EnableNewPlanViewer { get; set; } = true;
/// <summary>새로운 채팅 렌더링(V2 상세 이력) 사용. 기본 false.</summary>
[JsonPropertyName("enableNewChatRendering")]
public bool EnableNewChatRendering { get; set; } = false;
/// <summary>AX Agent 전용 테마. system | light | dark</summary> /// <summary>AX Agent 전용 테마. system | light | dark</summary>
[JsonPropertyName("agentTheme")] [JsonPropertyName("agentTheme")]
public string AgentTheme { get; set; } = "system"; public string AgentTheme { get; set; } = "system";
@@ -1069,6 +1093,11 @@ public class LlmSettings
[JsonPropertyName("notifyOnComplete")] [JsonPropertyName("notifyOnComplete")]
public bool NotifyOnComplete { get; set; } = false; public bool NotifyOnComplete { get; set; } = false;
/// <summary>코워크 작업 완료 후 문서 자동 처리 방식.
/// "none" = 아무것도 안하기, "open" = 문서 실행(기본 앱), "preview" = 미리보기 뷰어.</summary>
[JsonPropertyName("coworkOnComplete")]
public string CoworkOnComplete { get; set; } = "none";
/// <summary>AI 대화창에서 팁 알림 표시 여부.</summary> /// <summary>AI 대화창에서 팁 알림 표시 여부.</summary>
[JsonPropertyName("showTips")] [JsonPropertyName("showTips")]
public bool ShowTips { get; set; } = false; public bool ShowTips { get; set; } = false;
@@ -1315,6 +1344,18 @@ public class CodeSettings
/// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary> /// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enableCronTools")] [JsonPropertyName("enableCronTools")]
public bool EnableCronTools { get; set; } = true; public bool EnableCronTools { get; set; } = true;
/// <summary>
/// Code 탭 빈 화면 마스코트 캐릭터 출동 수준.
/// "none"=출동 안하기, "one"=한명만(1), "few"=적게(3), "mid"=중간(6), "all"=전부(9+)
/// 기본 "none" (메모리 절약).
/// </summary>
[JsonPropertyName("mascotLevel")]
public string MascotLevel { get; set; } = "none";
/// <summary>(하위호환) 이전 bool 설정 — 무시됨. MascotLevel로 대체.</summary>
[JsonPropertyName("enableMascotCharacter")]
public bool EnableMascotCharacter { get; set; } = false;
} }
/// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary> /// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary>

View File

@@ -63,6 +63,10 @@ public class ChatConversation
[JsonPropertyName("pinned")] [JsonPropertyName("pinned")]
public bool Pinned { get; set; } = false; public bool Pinned { get; set; } = false;
/// <summary>아카이브된 대화는 목록에서 기본 숨김 처리.</summary>
[JsonPropertyName("archived")]
public bool Archived { get; set; } = false;
/// <summary>대화가 속한 탭. "Chat" | "Cowork" | "Code".</summary> /// <summary>대화가 속한 탭. "Chat" | "Cowork" | "Code".</summary>
[JsonPropertyName("tab")] [JsonPropertyName("tab")]
public string Tab { get; set; } = "Chat"; public string Tab { get; set; } = "Chat";
@@ -126,7 +130,7 @@ public class ChatConversation
public List<DraftQueueItem> DraftQueueItems { get; set; } = new(); public List<DraftQueueItem> DraftQueueItems { get; set; } = new();
[JsonPropertyName("showExecutionHistory")] [JsonPropertyName("showExecutionHistory")]
public bool ShowExecutionHistory { get; set; } = true; public bool ShowExecutionHistory { get; set; } = false;
[JsonPropertyName("agentRunHistory")] [JsonPropertyName("agentRunHistory")]
public List<ChatAgentRunRecord> AgentRunHistory { get; set; } = new(); public List<ChatAgentRunRecord> AgentRunHistory { get; set; } = new();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,11 @@ namespace AxCopilot.Services.Agent;
public partial class AgentLoopService public partial class AgentLoopService
{ {
private static (bool ShouldRun, List<LlmService.ContentBlock> ParallelBatch, List<LlmService.ContentBlock> SequentialBatch) private static (bool ShouldRun, List<ContentBlock> ParallelBatch, List<ContentBlock> SequentialBatch)
CreateParallelExecutionPlan(bool parallelEnabled, List<LlmService.ContentBlock> toolCalls, int maxParallelBatch) CreateParallelExecutionPlan(bool parallelEnabled, List<ContentBlock> toolCalls, int maxParallelBatch)
{ {
if (!parallelEnabled || toolCalls.Count <= 1) if (!parallelEnabled || toolCalls.Count <= 1)
return (false, new List<LlmService.ContentBlock>(), toolCalls); return (false, new List<ContentBlock>(), toolCalls);
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls); var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
if (maxParallelBatch > 0 && parallelBatch.Count > maxParallelBatch) if (maxParallelBatch > 0 && parallelBatch.Count > maxParallelBatch)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ public class FolderMapTool : IAgentTool
public string Name => "folder_map"; public string Name => "folder_map";
public string Description => public string Description =>
"Generate a directory tree map of the work folder or a specified subfolder. " + "Generate a directory tree map of the work folder or a specified subfolder. " +
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files. " + "Shows folders and files in a tree structure. Use this only when the user explicitly asks for folder contents, directory structure, or workspace layout. " +
"Do not use it to locate a specific file when glob/grep or a targeted read would work. " +
"Supports sorting, size filtering, date filtering, and multi-extension filtering."; "Supports sorting, size filtering, date filtering, and multi-extension filtering.";
public ToolParameterSchema Parameters => new() public ToolParameterSchema Parameters => new()
@@ -22,7 +23,7 @@ public class FolderMapTool : IAgentTool
{ {
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." }, ["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." }, ["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: false for conservative first-pass exploration." }, ["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." }, ["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new() ["extensions"] = new()
{ {
@@ -65,7 +66,7 @@ public class FolderMapTool : IAgentTool
var maxDepth = Math.Min(depth, 10); var maxDepth = Math.Min(depth, 10);
// ── include_files ───────────────────────────────────────────────── // ── include_files ─────────────────────────────────────────────────
var includeFiles = false; var includeFiles = true;
if (args.SafeTryGetProperty("include_files", out var inc)) if (args.SafeTryGetProperty("include_files", out var inc))
{ {
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False) if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F3F4F6"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FCFCFD"/> <SolidColorBrush x:Key="ItemBackground" Color="#F3F4F6"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E4E7EC"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E4E7EC"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECEFF3"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#ECEFF3"/>
<SolidColorBrush x:Key="PrimaryText" Color="#181A1F"/> <SolidColorBrush x:Key="PrimaryText" Color="#181A1F"/>

View File

@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#FAF6EF"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFDFC"/> <SolidColorBrush x:Key="ItemBackground" Color="#FAF6EF"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#F3E5D5"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#F3E5D5"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/> <SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/>

View File

@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/> <SolidColorBrush x:Key="ItemBackground" Color="#F8FAFC"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EAF2FF"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#EAF2FF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/> <SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>

View File

@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#ECEFF4"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/> <SolidColorBrush x:Key="ItemBackground" Color="#ECEFF4"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E5E9F0"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E5E9F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#E5E9F0"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#E5E9F0"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2E3440"/> <SolidColorBrush x:Key="PrimaryText" Color="#2E3440"/>

View File

@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/> <SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/> <SolidColorBrush x:Key="ItemBackground" Color="#F0F2F8"/>
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/> <SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F7"/> <SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F7"/>
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/> <SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>

View File

@@ -107,6 +107,28 @@ public class HexToColorConverter : IValueConverter
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <summary>"#RRGGBB" 헥스 문자열 → SolidColorBrush. DataTemplate 아이콘 색상에 사용.</summary>
public class HexToBrushConverter : IValueConverter
{
public object Convert(object value, Type t, object p, CultureInfo c)
{
if (value is string hex && !string.IsNullOrEmpty(hex))
{
try
{
var color = (Color)ColorConverter.ConvertFromString(hex);
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
catch { /* 잘못된 값이면 기본 반환 */ }
}
return new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80));
}
public object ConvertBack(object v, Type t, object p, CultureInfo c) =>
throw new NotImplementedException();
}
/// <summary>null 또는 빈 문자열이면 Collapsed, 값이 있으면 Visible.</summary> /// <summary>null 또는 빈 문자열이면 Collapsed, 값이 있으면 Visible.</summary>
public class NullToCollapsedConverter : IValueConverter public class NullToCollapsedConverter : IValueConverter
{ {

View File

@@ -0,0 +1,276 @@
using System.Collections.ObjectModel;
using AxCopilot.Models;
namespace AxCopilot.ViewModels;
/// <summary>
/// ChatWindow의 MVVM ViewModel.
/// XAML 바인딩 대상 속성을 노출합니다.
/// 기존 코드비하인드와 공존하며 점진적으로 확장합니다.
/// </summary>
public class ChatWindowViewModel : ViewModelBase
{
// ── 탭 상태 ──────────────────────────────────────────────
private string _activeTab = "Chat";
public string ActiveTab
{
get => _activeTab;
set
{
if (SetProperty(ref _activeTab, value))
{
OnPropertyChanged(nameof(IsChat));
OnPropertyChanged(nameof(IsCowork));
OnPropertyChanged(nameof(IsCode));
OnPropertyChanged(nameof(TabDisplayName));
}
}
}
public bool IsChat => ActiveTab == "Chat";
public bool IsCowork => ActiveTab == "Cowork";
public bool IsCode => ActiveTab == "Code";
public string TabDisplayName => ActiveTab switch
{
"Chat" => "AX Agent",
"Cowork" => "Cowork",
"Code" => "Code",
_ => ActiveTab,
};
// ── 대화 제목 ────────────────────────────────────────────
private string _chatTitle = "";
public string ChatTitle
{
get => _chatTitle;
set => SetProperty(ref _chatTitle, value);
}
// ── 스트리밍 상태 ────────────────────────────────────────
private bool _isStreaming;
public bool IsStreaming
{
get => _isStreaming;
set
{
if (SetProperty(ref _isStreaming, value))
{
OnPropertyChanged(nameof(CanSend));
OnPropertyChanged(nameof(SendButtonIcon));
OnPropertyChanged(nameof(StreamingStatusText));
}
}
}
public bool CanSend => !IsStreaming;
public string SendButtonIcon => IsStreaming ? "\uE711" : "\uE724"; // Stop : Send
public string StreamingStatusText => IsStreaming ? "응답 생성 중..." : "";
// ── 모델 정보 ────────────────────────────────────────────
private string _modelLabel = "";
public string ModelLabel
{
get => _modelLabel;
set => SetProperty(ref _modelLabel, value);
}
private string _serviceLabel = "";
public string ServiceLabel
{
get => _serviceLabel;
set => SetProperty(ref _serviceLabel, value);
}
// ── 토큰 사용량 ─────────────────────────────────────────
private int _contextTokens;
public int ContextTokens
{
get => _contextTokens;
set
{
if (SetProperty(ref _contextTokens, value))
{
OnPropertyChanged(nameof(ContextUsagePercent));
OnPropertyChanged(nameof(ContextUsageText));
}
}
}
private int _maxContextTokens = 128000;
public int MaxContextTokens
{
get => _maxContextTokens;
set
{
if (SetProperty(ref _maxContextTokens, value))
{
OnPropertyChanged(nameof(ContextUsagePercent));
OnPropertyChanged(nameof(ContextUsageText));
}
}
}
public double ContextUsagePercent => MaxContextTokens > 0
? Math.Min(100.0, ContextTokens * 100.0 / MaxContextTokens)
: 0;
public string ContextUsageText => MaxContextTokens > 0
? $"{ContextTokens:N0} / {MaxContextTokens:N0}"
: "";
// ── 대화 목록 ────────────────────────────────────────────
public ObservableCollection<ConversationItemViewModel> Conversations { get; } = new();
private int _remainingConversationCount;
public int RemainingConversationCount
{
get => _remainingConversationCount;
set
{
if (SetProperty(ref _remainingConversationCount, value))
OnPropertyChanged(nameof(HasMoreConversations));
}
}
public bool HasMoreConversations => RemainingConversationCount > 0;
private string _emptyConversationText = "";
public string EmptyConversationText
{
get => _emptyConversationText;
set => SetProperty(ref _emptyConversationText, value);
}
private string _selectedCategory = "";
public string SelectedCategory
{
get => _selectedCategory;
set => SetProperty(ref _selectedCategory, value);
}
private bool _failedOnlyFilter;
public bool FailedOnlyFilter
{
get => _failedOnlyFilter;
set => SetProperty(ref _failedOnlyFilter, value);
}
private bool _runningOnlyFilter;
public bool RunningOnlyFilter
{
get => _runningOnlyFilter;
set => SetProperty(ref _runningOnlyFilter, value);
}
private bool _sortByRecent;
public bool SortByRecent
{
get => _sortByRecent;
set => SetProperty(ref _sortByRecent, value);
}
// ── Git 상태 ─────────────────────────────────────────────
private string _gitBranchName = "";
public string GitBranchName
{
get => _gitBranchName;
set
{
if (SetProperty(ref _gitBranchName, value))
OnPropertyChanged(nameof(HasGitBranch));
}
}
public bool HasGitBranch => !string.IsNullOrEmpty(GitBranchName);
// ── 작업 폴더 ────────────────────────────────────────────
private string _workFolder = "";
public string WorkFolder
{
get => _workFolder;
set
{
if (SetProperty(ref _workFolder, value))
{
OnPropertyChanged(nameof(HasWorkFolder));
OnPropertyChanged(nameof(WorkFolderDisplay));
}
}
}
public bool HasWorkFolder => !string.IsNullOrEmpty(WorkFolder);
public string WorkFolderDisplay => string.IsNullOrEmpty(WorkFolder) ? "폴더 선택" : WorkFolder;
}
/// <summary>대화 목록 항목 ViewModel — DataTemplate 바인딩 대상.</summary>
public class ConversationItemViewModel : ViewModelBase
{
public string Id { get; init; } = "";
private string _title = "";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
private string _updatedAtText = "";
public string UpdatedAtText
{
get => _updatedAtText;
set => SetProperty(ref _updatedAtText, value);
}
private bool _pinned;
public bool Pinned
{
get => _pinned;
set => SetProperty(ref _pinned, value);
}
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
private bool _isRunning;
public bool IsRunning
{
get => _isRunning;
set => SetProperty(ref _isRunning, value);
}
public string Category { get; init; } = "general";
public string Symbol { get; init; } = "\uE8BD";
public string ColorHex { get; init; } = "#6B7280";
public string Tab { get; init; } = "Chat";
public string Preview { get; init; } = "";
public string? ParentId { get; init; }
public int AgentRunCount { get; init; }
public int FailedAgentRunCount { get; init; }
public string LastAgentRunSummary { get; init; } = "";
public string WorkFolder { get; init; } = "";
// ── 그룹 ──
public string Group { get; init; } = "오늘";
public int GroupOrder { get; init; }
// ── 표시용 computed ──
public bool IsBranch => !string.IsNullOrEmpty(ParentId);
public string IconText => Pinned ? "\uE718" : IsBranch ? "\uE8A5" : Symbol;
public string RunStatusText => FailedAgentRunCount > 0
? $"실패 {FailedAgentRunCount}"
: AgentRunCount > 0 ? $"실행 {AgentRunCount}" : "";
public bool HasRunStatus => AgentRunCount > 0 || FailedAgentRunCount > 0;
public bool HasFailed => FailedAgentRunCount > 0;
}

View File

@@ -163,6 +163,8 @@ public class SettingsViewModel : INotifyPropertyChanged
private bool _rememberPosition; private bool _rememberPosition;
private bool _showPrefixBadge; private bool _showPrefixBadge;
private bool _enableIconAnimation; private bool _enableIconAnimation;
private bool _enableChatIconRandomAnimation;
private string _chatIconGlowIntensity = "medium";
private bool _enableRainbowGlow; private bool _enableRainbowGlow;
private bool _enableSelectionGlow; private bool _enableSelectionGlow;
private bool _enableRandomPlaceholder; private bool _enableRandomPlaceholder;
@@ -849,6 +851,18 @@ public class SettingsViewModel : INotifyPropertyChanged
set { _enableIconAnimation = value; OnPropertyChanged(); } set { _enableIconAnimation = value; OnPropertyChanged(); }
} }
public bool EnableChatIconRandomAnimation
{
get => _enableChatIconRandomAnimation;
set { _enableChatIconRandomAnimation = value; OnPropertyChanged(); }
}
public string ChatIconGlowIntensity
{
get => _chatIconGlowIntensity;
set { _chatIconGlowIntensity = value; OnPropertyChanged(); }
}
public bool EnableRainbowGlow public bool EnableRainbowGlow
{ {
get => _enableRainbowGlow; get => _enableRainbowGlow;
@@ -1115,6 +1129,8 @@ public class SettingsViewModel : INotifyPropertyChanged
_rememberPosition = s.Launcher.RememberPosition; _rememberPosition = s.Launcher.RememberPosition;
_showPrefixBadge = s.Launcher.ShowPrefixBadge; _showPrefixBadge = s.Launcher.ShowPrefixBadge;
_enableIconAnimation = s.Launcher.EnableIconAnimation; _enableIconAnimation = s.Launcher.EnableIconAnimation;
_enableChatIconRandomAnimation = s.Launcher.EnableChatIconRandomAnimation;
_chatIconGlowIntensity = s.Launcher.ChatIconGlowIntensity ?? "medium";
_enableRainbowGlow = s.Launcher.EnableRainbowGlow; _enableRainbowGlow = s.Launcher.EnableRainbowGlow;
_enableSelectionGlow = s.Launcher.EnableSelectionGlow; _enableSelectionGlow = s.Launcher.EnableSelectionGlow;
_enableRandomPlaceholder = s.Launcher.EnableRandomPlaceholder; _enableRandomPlaceholder = s.Launcher.EnableRandomPlaceholder;
@@ -1573,6 +1589,8 @@ public class SettingsViewModel : INotifyPropertyChanged
s.Launcher.RememberPosition = _rememberPosition; s.Launcher.RememberPosition = _rememberPosition;
s.Launcher.ShowPrefixBadge = _showPrefixBadge; s.Launcher.ShowPrefixBadge = _showPrefixBadge;
s.Launcher.EnableIconAnimation = _enableIconAnimation; s.Launcher.EnableIconAnimation = _enableIconAnimation;
s.Launcher.EnableChatIconRandomAnimation = _enableChatIconRandomAnimation;
s.Launcher.ChatIconGlowIntensity = _chatIconGlowIntensity;
s.Launcher.EnableRainbowGlow = _enableRainbowGlow; s.Launcher.EnableRainbowGlow = _enableRainbowGlow;
s.Launcher.EnableSelectionGlow = _enableSelectionGlow; s.Launcher.EnableSelectionGlow = _enableSelectionGlow;
s.Launcher.EnableRandomPlaceholder = _enableRandomPlaceholder; s.Launcher.EnableRandomPlaceholder = _enableRandomPlaceholder;

View File

@@ -0,0 +1,41 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace AxCopilot.ViewModels;
/// <summary>
/// MVVM ViewModel 기반 클래스.
/// INotifyPropertyChanged 구현과 SetProperty 헬퍼를 제공합니다.
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>속성 변경 알림을 발생시킵니다.</summary>
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
/// <summary>
/// 백킹 필드를 업데이트하고, 값이 변경된 경우 PropertyChanged를 발생시킵니다.
/// </summary>
/// <returns>값이 실제로 변경되었으면 true.</returns>
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
/// <summary>
/// 백킹 필드를 업데이트하고, 값이 변경된 경우 추가 콜백을 실행합니다.
/// </summary>
protected bool SetProperty<T>(ref T field, T value, Action onChanged, [CallerMemberName] string? propertyName = null)
{
if (!SetProperty(ref field, value, propertyName))
return false;
onChanged();
return true;
}
}

View File

@@ -87,7 +87,26 @@ public partial class ChatWindow
{ {
var result = _chatEngine.AppendExecutionEvent( var result = _chatEngine.AppendExecutionEvent(
session, _storage, _currentConversation, activeTab, eventTab, evt); session, _storage, _currentConversation, activeTab, eventTab, evt);
_currentConversation = result.CurrentConversation; // 방어: 결과 대화가 빈 대화로 교체되는 것을 방지
// EnsureCurrentConversation이 기존 대화 대신 새 빈 대화를 생성하는 경우 발생
var resultConv = result.CurrentConversation;
var currentMsgCount = _currentConversation?.Messages?.Count ?? 0;
var resultMsgCount = resultConv?.Messages?.Count ?? 0;
if (resultConv != null && resultMsgCount == 0 && currentMsgCount > 0
&& _currentConversation != null
&& string.Equals(
_currentConversation.Tab?.Trim(),
resultConv.Tab?.Trim(),
StringComparison.OrdinalIgnoreCase))
{
// 기존 대화 유지 — session에도 복원
session.CurrentConversation = _currentConversation;
LogService.Info($"[EventProc] 대화 교체 차단: 기존 msgCount={currentMsgCount}, 결과 msgCount={resultMsgCount}, convId={_currentConversation.Id?[..Math.Min(8, _currentConversation.Id?.Length ?? 0)]}");
}
else
{
_currentConversation = resultConv;
}
pendingPersist = result.UpdatedConversation; pendingPersist = result.UpdatedConversation;
} }
} }
@@ -109,7 +128,17 @@ public partial class ChatWindow
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary; var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary;
var result = _chatEngine.AppendAgentRun( var result = _chatEngine.AppendAgentRun(
session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary); session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary);
// 방어: Complete 이벤트에서도 대화 교체 보호
var completeMsgCount = result.CurrentConversation?.Messages?.Count ?? 0;
var existingMsgCount = _currentConversation?.Messages?.Count ?? 0;
if (completeMsgCount == 0 && existingMsgCount > 0 && _currentConversation != null)
{
session.CurrentConversation = _currentConversation;
}
else
{
_currentConversation = result.CurrentConversation; _currentConversation = result.CurrentConversation;
}
pendingPersist = result.UpdatedConversation; pendingPersist = result.UpdatedConversation;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1064,6 +1064,15 @@ public partial class ChatWindow
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg) private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
{ {
outerStack.Children.Clear(); outerStack.Children.Clear();
// 부모 컨테이너(Border)의 테두리·배경 제거 — 승인 후 깔끔하게
if (outerStack.Parent is Border containerBorder)
{
containerBorder.BorderThickness = new Thickness(0);
containerBorder.Background = Brushes.Transparent;
containerBorder.Padding = new Thickness(0, 2, 0, 2);
}
var resultLabel = new TextBlock var resultLabel = new TextBlock
{ {
Text = resultText, Text = resultText,
@@ -1071,7 +1080,7 @@ public partial class ChatWindow
FontWeight = FontWeights.SemiBold, FontWeight = FontWeights.SemiBold,
Foreground = fg, Foreground = fg,
Opacity = 0.8, Opacity = 0.8,
Margin = new Thickness(0, 2, 0, 2), Margin = new Thickness(40, 2, 0, 2),
}; };
outerStack.Children.Add(resultLabel); outerStack.Children.Add(resultLabel);
} }

View File

@@ -0,0 +1,622 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Microsoft.Win32;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── 대화 분기 (Fork) ──────────────────────────────────────────────
private void ForkConversation(
ChatConversation source,
int atIndex,
string? branchHint = null,
string? branchContextMessage = null,
string? branchContextRunId = null)
{
var branchCount = _storage.LoadAllMeta()
.Count(m => m.ParentId == source.Id) + 1;
var fork = ChatSession?.CreateBranchConversation(source, atIndex, branchCount, branchHint, branchContextMessage, branchContextRunId)
?? new ChatConversation
{
Title = source.Title,
Tab = source.Tab,
Category = source.Category,
WorkFolder = source.WorkFolder,
SystemCommand = source.SystemCommand,
ParentId = source.Id,
BranchLabel = $"분기 {branchCount}",
BranchAtIndex = atIndex,
};
try
{
_storage.Save(fork);
ShowToast($"분기 생성: {fork.Title}");
// 분기 대화로 전환
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, fork, _storage) ?? fork;
SyncTabConversationIdsFromSession();
}
ViewModel.ChatTitle = fork.Title;
RenderMessages();
RefreshConversationList();
}
catch (Exception ex)
{
ShowToast($"분기 실패: {ex.Message}", "\uE783");
}
}
// ─── 커맨드 팔레트 ─────────────────────────────────────────────────
private void OpenCommandPalette()
{
var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this };
palette.ShowDialog();
}
private void ExecuteCommand(string commandId)
{
switch (commandId)
{
case "tab:chat": TabChat.IsChecked = true; break;
case "tab:cowork": TabCowork.IsChecked = true; break;
case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break;
case "new_conversation": StartNewConversation(); break;
case "search_conversation": ToggleMessageSearch(); break;
case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break;
case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break;
case "open_statistics": new StatisticsWindow().Show(); break;
case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break;
case "toggle_devmode":
var llm = _settings.Settings.Llm;
llm.DevMode = !llm.DevMode;
_settings.Save();
UpdateAnalyzerButtonVisibility();
ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
break;
case "open_audit_log":
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
break;
case "paste_clipboard":
try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { }
break;
case "export_conversation": ExportConversation(); break;
}
}
private void ExportConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0) return;
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"{conv.Title}",
DefaultExt = ".md",
Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt"
};
if (dlg.ShowDialog() != true) return;
var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant();
string content;
if (ext == ".json")
{
content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
}
else if (dlg.FileName.EndsWith(".pdf.html"))
{
// PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시
content = PdfExportService.BuildHtml(conv);
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
PdfExportService.OpenInBrowser(dlg.FileName);
ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다");
return;
}
else if (ext == ".html")
{
content = ExportToHtml(conv);
}
else
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# {conv.Title}");
sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_");
sb.AppendLine();
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
var label = msg.Role == "user" ? "**사용자**" : "**AI**";
sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})");
sb.AppendLine();
sb.AppendLine(msg.Content);
if (msg.AttachedFiles is { Count: > 0 })
{
sb.AppendLine();
sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_");
}
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
content = sb.ToString();
}
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
}
private static string ExportToHtml(ChatConversation conv)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
sb.AppendLine($"<title>{System.Net.WebUtility.HtmlEncode(conv.Title)}</title>");
sb.AppendLine("<style>body{font-family:'Segoe UI',sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}");
sb.AppendLine(".msg{margin:12px 0;padding:12px 16px;border-radius:12px}.user{background:#2d2d5e;margin-left:60px}.ai{background:#1e1e3a;margin-right:60px}");
sb.AppendLine(".meta{font-size:11px;color:#888;margin-bottom:6px}.content{white-space:pre-wrap;line-height:1.6}");
sb.AppendLine("h1{text-align:center;color:#8b6dff}pre{background:#111;padding:12px;border-radius:8px;overflow-x:auto}</style></head><body>");
sb.AppendLine($"<h1>{System.Net.WebUtility.HtmlEncode(conv.Title)}</h1>");
sb.AppendLine($"<p style='text-align:center;color:#888'>생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}</p>");
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
var cls = msg.Role == "user" ? "user" : "ai";
var label = msg.Role == "user" ? "사용자" : "AI";
sb.AppendLine($"<div class='msg {cls}'>");
sb.AppendLine($"<div class='meta'>{label} · {msg.Timestamp:HH:mm}</div>");
sb.AppendLine($"<div class='content'>{System.Net.WebUtility.HtmlEncode(msg.Content)}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</body></html>");
return sb.ToString();
}
// ─── 버튼 이벤트 ──────────────────────────────────────────────────────
private void ChatWindow_KeyDown(object sender, KeyEventArgs e)
{
var mod = Keyboard.Modifiers;
// Ctrl 단축키
if (mod == ModifierKeys.Control)
{
switch (e.Key)
{
case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.W: Close(); e.Handled = true; break;
case Key.E: ExportConversation(); e.Handled = true; break;
case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break;
case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.F: ToggleMessageSearch(); e.Handled = true; break;
case Key.K: OpenSidebarSearch(); e.Handled = true; break;
case Key.D1: TabChat.IsChecked = true; e.Handled = true; break;
case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break;
case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break;
}
}
// Ctrl+Shift 단축키
if (mod == (ModifierKeys.Control | ModifierKeys.Shift))
{
switch (e.Key)
{
case Key.C:
// 마지막 AI 응답 복사
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null)
{
var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant");
if (lastAi != null)
try { Clipboard.SetText(lastAi.Content); } catch { }
}
e.Handled = true;
break;
case Key.R:
// 마지막 응답 재생성
_ = RegenerateLastAsync();
e.Handled = true;
break;
case Key.D:
// 모든 대화 삭제
BtnDeleteAll_Click(this, new RoutedEventArgs());
e.Handled = true;
break;
case Key.P:
// 커맨드 팔레트
OpenCommandPalette();
e.Handled = true;
break;
}
}
// Escape: 검색 바 닫기 또는 스트리밍 중지
if (e.Key == Key.Escape)
{
if (SidebarSearchEditor?.Visibility == Visibility.Visible) { CloseSidebarSearch(clearText: true); e.Handled = true; }
else if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; }
else if (_isStreaming) { StopGeneration(); e.Handled = true; }
}
// 슬래시 명령 팝업 키 처리
if (TryHandleSlashNavigationKey(e))
return;
if (PermissionPopup.IsOpen && e.Key == Key.Escape)
{
PermissionPopup.IsOpen = false;
e.Handled = true;
}
}
private bool TryHandleSlashNavigationKey(KeyEventArgs e)
{
if (!SlashPopup.IsOpen)
return false;
switch (e.Key)
{
case Key.Escape:
SlashPopup.IsOpen = false;
_slashPalette.SelectedIndex = -1;
e.Handled = true;
return true;
case Key.Up:
SlashPopup_ScrollByDelta(120);
e.Handled = true;
return true;
case Key.Down:
SlashPopup_ScrollByDelta(-120);
e.Handled = true;
return true;
case Key.PageUp:
SlashPopup_ScrollByDelta(600);
e.Handled = true;
return true;
case Key.PageDown:
SlashPopup_ScrollByDelta(-600);
e.Handled = true;
return true;
case Key.Home:
{
var visible = GetVisibleSlashOrderedIndices();
_slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches);
UpdateSlashSelectionVisualState();
EnsureSlashSelectionVisible();
e.Handled = true;
return true;
}
case Key.End:
{
var visible = GetVisibleSlashOrderedIndices();
_slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches);
UpdateSlashSelectionVisualState();
EnsureSlashSelectionVisible();
e.Handled = true;
return true;
}
case Key.Tab when _slashPalette.SelectedIndex >= 0:
case Key.Enter when _slashPalette.SelectedIndex >= 0:
ExecuteSlashSelectedItem();
e.Handled = true;
return true;
default:
return false;
}
}
private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration();
private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
var activeLoop = GetAgentLoop(_activeTab);
if (activeLoop.IsPaused)
{
activeLoop.Resume();
PauseIcon.Text = "\uE769"; // 일시정지 아이콘
BtnPause.ToolTip = "일시정지";
}
else
{
_ = activeLoop.PauseAsync();
PauseIcon.Text = "\uE768"; // 재생 아이콘
BtnPause.ToolTip = "재개";
}
}
private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation();
// ─── 메시지 내 검색 (Ctrl+F) ─────────────────────────────────────────
private List<int> _searchMatchIndices = new();
private int _searchCurrentIndex = -1;
private void ToggleMessageSearch()
{
if (MessageSearchBar.Visibility == Visibility.Visible)
CloseMessageSearch();
else
{
MessageSearchBar.Visibility = Visibility.Visible;
SearchTextBox.Focus();
SearchTextBox.SelectAll();
}
}
private void CloseMessageSearch()
{
MessageSearchBar.Visibility = Visibility.Collapsed;
SearchTextBox.Text = "";
SearchResultCount.Text = "";
_searchMatchIndices.Clear();
_searchCurrentIndex = -1;
// 하이라이트 제거
ClearSearchHighlights();
}
private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
var query = SearchTextBox.Text.Trim();
if (string.IsNullOrEmpty(query))
{
SearchResultCount.Text = "";
_searchMatchIndices.Clear();
_searchCurrentIndex = -1;
ClearSearchHighlights();
return;
}
// 현재 대화의 메시지에서 검색
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
_searchMatchIndices.Clear();
for (int i = 0; i < conv.Messages.Count; i++)
{
if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase))
_searchMatchIndices.Add(i);
}
if (_searchMatchIndices.Count > 0)
{
_searchCurrentIndex = 0;
SearchResultCount.Text = $"1/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
else
{
_searchCurrentIndex = -1;
SearchResultCount.Text = "결과 없음";
}
}
private void SearchPrev_Click(object sender, RoutedEventArgs e)
{
if (_searchMatchIndices.Count == 0) return;
_searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count;
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
private void SearchNext_Click(object sender, RoutedEventArgs e)
{
if (_searchMatchIndices.Count == 0) return;
_searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count;
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch();
private void HighlightSearchResult()
{
if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return;
var msgIndex = _searchMatchIndices[_searchCurrentIndex];
// MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤
// 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로
// (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동
if (msgIndex < GetTranscriptElementCount())
{
var element = GetTranscriptElementAt(msgIndex) as FrameworkElement;
element?.BringIntoView();
}
else if (GetTranscriptElementCount() > 0)
{
// 범위 밖이면 마지막 자식으로 이동
(GetTranscriptElementAt(GetTranscriptElementCount() - 1) as FrameworkElement)?.BringIntoView();
}
}
private void ClearSearchHighlights()
{
// 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요
}
// ─── 메시지 우클릭 컨텍스트 메뉴 ───────────────────────────────────────
private void ShowMessageContextMenu(string content, string role)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
var (popup, panel) = CreateThemedPopupMenu();
// 복사
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () =>
{
try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { }
}));
// 마크다운 복사
panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () =>
{
try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { }
}));
// 인용하여 답장
panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () =>
{
var quote = content.Length > 200 ? content[..200] + "..." : content;
var lines = quote.Split('\n');
var quoted = string.Join("\n", lines.Select(l => $"> {l}"));
InputBox.Text = quoted + "\n\n";
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text.Length;
}));
AddPopupMenuSeparator(panel, borderBrush);
// 재생성 (AI 응답만)
if (role == "assistant")
{
panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync()));
}
// 대화 분기 (Fork)
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () =>
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content);
if (idx < 0) return;
ForkConversation(conv, idx);
}));
AddPopupMenuSeparator(panel, borderBrush);
// 이후 메시지 모두 삭제
var msgContent = content;
var msgRole = role;
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () =>
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent);
if (idx < 0) return;
var removeCount = conv.Messages.Count - idx;
if (CustomMessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?",
"메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
return;
conv.Messages.RemoveRange(idx, removeCount);
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
RenderMessages();
ShowToast($"{removeCount}개 메시지 삭제됨");
}));
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
}
// ─── 팁 알림 ──────────────────────────────────────────────────────
private static readonly string[] Tips =
[
"💡 작업 폴더에 AGENTS.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.",
"💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.",
"💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.",
"💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.",
"💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.",
"💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.",
"💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.",
"💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.",
"💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.",
"💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.",
"💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.",
"💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)",
"💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.",
"💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.",
"💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.",
"💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.",
"💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.",
"💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.",
"💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.",
"💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.",
"💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.",
"💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url)) .",
"💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!",
"💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.",
];
private int _tipIndex;
private DispatcherTimer? _tipDismissTimer;
private void ShowRandomTip()
{
if (!_settings.Settings.Llm.ShowTips) return;
if (_activeTab != "Cowork" && _activeTab != "Code") return;
var tip = Tips[_tipIndex % Tips.Length];
_tipIndex++;
// 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상)
ShowTip(tip);
}
private void ShowTip(string message)
{
// 두 타이머 모두 중지
_tipDismissTimer?.Stop();
_toastHideTimer?.Stop();
_toastHideTimer = null;
ToastText.Text = message;
ToastIcon.Text = "\uE82F"; // 전구 아이콘
ToastBorder.Visibility = Visibility.Visible;
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
var duration = _settings.Settings.Llm.TipDurationSeconds;
if (duration <= 0)
{
// duration=0: 자동 숨기기 없음 — 기본 3초로 폴백하여 영구 잔류 방지
duration = 3;
}
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) };
_tipDismissTimer = timer;
timer.Tick += (_, _) =>
{
if (_tipDismissTimer != timer) return;
timer.Stop();
_tipDismissTimer = 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);
};
timer.Start();
}
}

View File

@@ -91,14 +91,39 @@ public partial class ChatWindow
: "에이전트 활동량과 실패를 우선으로 보는 중"; : "에이전트 활동량과 실패를 우선으로 보는 중";
} }
private void BtnArchiveFilter_Click(object sender, RoutedEventArgs e)
{
// null(일반만) → true(아카이브만) → null(일반만) 순환
_archiveFilter = _archiveFilter == null ? true : null;
UpdateArchiveFilterUi();
RefreshConversationList();
}
private void UpdateArchiveFilterUi()
{
if (BtnArchiveFilter == null || ArchiveFilterLabel == null) return;
var isActive = _archiveFilter == true;
BtnArchiveFilter.Background = isActive ? BrushFromHex("#FEF3C7") : Brushes.Transparent;
BtnArchiveFilter.BorderBrush = isActive ? BrushFromHex("#FCD34D") : Brushes.Transparent;
BtnArchiveFilter.BorderThickness = isActive ? new Thickness(1) : new Thickness(0);
ArchiveFilterLabel.Text = isActive ? "아카이브" : "보관";
ArchiveFilterLabel.Foreground = isActive
? BrushFromHex("#92400E")
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
BtnArchiveFilter.ToolTip = isActive ? "아카이브된 대화 보는 중 (클릭: 일반 대화로 전환)" : "아카이브된 대화 보기";
}
private void ApplyConversationListPreferences(ChatConversation? conv) private void ApplyConversationListPreferences(ChatConversation? conv)
{ {
_failedOnlyFilter = false; _failedOnlyFilter = false;
_runningOnlyFilter = false; _runningOnlyFilter = false;
_archiveFilter = null;
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase); _sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
UpdateConversationFailureFilterUi(); UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi(); UpdateConversationRunningFilterUi();
UpdateConversationSortUi(); UpdateConversationSortUi();
UpdateArchiveFilterUi();
} }
private void PersistConversationListPreferences() private void PersistConversationListPreferences()

View File

@@ -8,6 +8,7 @@ using System.Windows.Media;
using System.Windows.Threading; using System.Windows.Threading;
using AxCopilot.Models; using AxCopilot.Models;
using AxCopilot.Services; using AxCopilot.Services;
using AxCopilot.ViewModels;
namespace AxCopilot.Views; namespace AxCopilot.Views;
@@ -43,6 +44,115 @@ public partial class ChatWindow
ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave; ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave;
ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown; ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown;
ConversationPanel.PreviewMouseRightButtonUp += ConversationPanel_DelegatedRightButtonUp; ConversationPanel.PreviewMouseRightButtonUp += ConversationPanel_DelegatedRightButtonUp;
// 새 ItemsControl 이벤트 등록
if (ConversationItemsControl != null)
{
ConversationItemsControl.PreviewMouseLeftButtonDown += ConversationItemsControl_LeftButtonDown;
ConversationItemsControl.PreviewMouseRightButtonUp += ConversationItemsControl_RightButtonUp;
ConversationItemsControl.AddHandler(System.Windows.Controls.Primitives.ButtonBase.ClickEvent,
new RoutedEventHandler(ConversationItemsControl_ButtonClick));
}
}
private void ConversationItemsControl_ButtonClick(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is not Button btn) return;
var vm = FindDataContext<ConversationItemViewModel>(btn);
if (vm != null)
ShowConversationMenu(vm.Id);
}
private void ConversationItemsControl_LeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (FindAncestor<Button>(e.OriginalSource as DependencyObject) is not null)
return;
var vm = FindDataContext<ConversationItemViewModel>(e.OriginalSource as DependencyObject);
if (vm == null) return;
e.Handled = true;
HandleConversationItemClickById(vm.Id, vm.IsSelected);
}
private void ConversationItemsControl_RightButtonUp(object sender, MouseButtonEventArgs e)
{
var vm = FindDataContext<ConversationItemViewModel>(e.OriginalSource as DependencyObject);
if (vm == null) return;
e.Handled = true;
if (!vm.IsSelected)
{
var conv = _storage.Load(vm.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshDraftQueueUi();
}
}
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(vm.Id)), DispatcherPriority.Input);
}
private void HandleConversationItemClickById(string id, bool isSelected)
{
try
{
if (isSelected)
{
// 선택된 항목 클릭 → 이름 변경 모드
var titleBlock = FindConversationTitleBlock(id);
if (titleBlock != null)
{
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
EnterTitleEditMode(titleBlock, id, titleColor);
}
return;
}
// 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
StopStreamingIfActive();
var conv = _storage.Load(id);
if (conv == null) return;
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
ClearTranscriptElements(); // 이전 대화의 UI 요소 완전 제거
InvalidateTimelineCache(); // 타임라인 캐시 무효화 — 새 대화 데이터 반영 보장
RenderMessages();
EnsureEmptyStateConsistency(); // EmptyState 일관성 강제 검사
RefreshConversationList();
RefreshDraftQueueUi();
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
}
/// <summary>VisualTree를 올라가며 지정 타입의 DataContext를 찾습니다.</summary>
private static T? FindDataContext<T>(DependencyObject? element) where T : class
{
while (element != null)
{
if (element is FrameworkElement fe && fe.DataContext is T ctx)
return ctx;
element = VisualTreeHelper.GetParent(element);
}
return null;
} }
private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e) private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
@@ -119,17 +229,8 @@ public partial class ChatWindow
return; return;
} }
if (_streamingTabs.Contains(_activeTab)) // 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
{ StopStreamingIfActive();
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_streamingTabs.Remove(_activeTab);
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
}
var conv = _storage.Load(tag.Id); var conv = _storage.Load(tag.Id);
if (conv == null) if (conv == null)
@@ -218,6 +319,7 @@ public partial class ChatWindow
LastFailedAt = runSummary.LastFailedAt, LastFailedAt = runSummary.LastFailedAt,
LastCompletedAt = runSummary.LastCompletedAt, LastCompletedAt = runSummary.LastCompletedAt,
WorkFolder = c.WorkFolder ?? "", WorkFolder = c.WorkFolder ?? "",
Archived = c.Archived,
IsRunning = _currentConversation?.Id == c.Id IsRunning = _currentConversation?.Id == c.Id
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId) && !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase) && !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
@@ -227,15 +329,22 @@ public partial class ChatWindow
// LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거 // LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
items = items.Where(i => items = items.Where(i =>
string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase) {
&& (i.Pinned if (!string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase))
return false;
// 아카이브 필터: null=일반만(비아카이브), true=아카이브만, false(미사용)=전체
if (_archiveFilter == null && i.Archived) return false;
if (_archiveFilter == true && !i.Archived) return false;
return i.Pinned
|| !string.IsNullOrWhiteSpace(i.ParentId) || !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase) || !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview) || !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0 || i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0 || i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)) || !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase);
).ToList(); }).ToList();
// Count를 한 번의 루프로 계산 (3번 순회 → 1번) // Count를 한 번의 루프로 계산 (3번 순회 → 1번)
int failedCount = 0, runningCount = 0, spotlightCount = 0; int failedCount = 0, runningCount = 0, spotlightCount = 0;
@@ -300,7 +409,133 @@ public partial class ChatWindow
.ThenByDescending(i => i.UpdatedAt)) .ThenByDescending(i => i.UpdatedAt))
.ToList(); .ToList();
// 스크롤 위치 보존: 대화 전환 시 목록이 맨 위로 점프하는 현상 방지
double savedScrollOffset = 0;
bool hasScrollViewer = ConversationListScrollViewer != null;
if (hasScrollViewer)
savedScrollOffset = ConversationListScrollViewer!.VerticalOffset;
SyncConversationsToViewModel(items);
RenderConversationList(items); RenderConversationList(items);
// 저장된 스크롤 위치 복원
if (hasScrollViewer && savedScrollOffset > 0)
{
Dispatcher.BeginInvoke(new Action(() =>
{
ConversationListScrollViewer?.ScrollToVerticalOffset(savedScrollOffset);
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
}
private List<ConversationMeta>? _allFilteredItems;
private void SyncConversationsToViewModel(List<ConversationMeta> items)
{
var currentId = "";
lock (_convLock)
currentId = _currentConversation?.Id ?? "";
var spotlightIds = new HashSet<string>(
BuildConversationSpotlightItems(items).Select(s => s.Id));
ViewModel.Conversations.Clear();
if (items.Count == 0)
{
ViewModel.EmptyConversationText = _activeTab switch
{
"Cowork" => "Cowork 탭 대화가 없습니다",
"Code" => "Code 탭 대화가 없습니다",
_ => "Chat 탭 대화가 없습니다",
};
ViewModel.RemainingConversationCount = 0;
_allFilteredItems = null;
return;
}
ViewModel.EmptyConversationText = "";
// 스포트라이트 항목 먼저 (GroupOrder=0)
foreach (var item in items.Where(i => spotlightIds.Contains(i.Id)))
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, "집중 필요", 0));
// 날짜 그룹 항목 — 페이지네이션 (GroupOrder=1+)
var dateGrouped = items.Select(item =>
{
var group = GetConversationDateGroup(item.UpdatedAt);
var groupOrder = group switch { "오늘" => 1, "어제" => 2, _ => 3 };
return (item, group, groupOrder);
}).ToList();
var pageItems = dateGrouped
.Where(x => !spotlightIds.Contains(x.item.Id))
.Take(ConversationPageSize).ToList();
foreach (var (item, group, groupOrder) in pageItems)
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, group, groupOrder));
var totalNonSpotlight = dateGrouped.Count(x => !spotlightIds.Contains(x.item.Id));
var remaining = totalNonSpotlight - pageItems.Count;
ViewModel.RemainingConversationCount = remaining;
_allFilteredItems = remaining > 0 ? items : null;
}
/// <summary>ViewModel의 "더 보기" 요청을 처리하여 나머지 항목을 로드합니다.</summary>
internal void LoadAllConversations()
{
if (_allFilteredItems == null) return;
var items = _allFilteredItems;
_allFilteredItems = null;
var currentId = "";
lock (_convLock)
currentId = _currentConversation?.Id ?? "";
var spotlightIds = new HashSet<string>(
BuildConversationSpotlightItems(items).Select(s => s.Id));
ViewModel.Conversations.Clear();
// 스포트라이트
foreach (var item in items.Where(i => spotlightIds.Contains(i.Id)))
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, "집중 필요", 0));
// 전체 날짜 그룹 (스포트라이트 제외 — 이미 위에서 추가됨)
foreach (var item in items.Where(i => !spotlightIds.Contains(i.Id)))
{
var group = GetConversationDateGroup(item.UpdatedAt);
var groupOrder = group switch { "오늘" => 1, "어제" => 2, _ => 3 };
ViewModel.Conversations.Add(BuildConversationItemVm(item, currentId, group, groupOrder));
}
ViewModel.RemainingConversationCount = 0;
}
private static ConversationItemViewModel BuildConversationItemVm(
ConversationMeta item, string currentId, string group, int groupOrder)
{
return new ConversationItemViewModel
{
Id = item.Id,
Title = item.Title,
UpdatedAtText = item.UpdatedAtText,
Pinned = item.Pinned,
IsSelected = item.Id == currentId,
IsRunning = item.IsRunning,
Category = item.Category,
Symbol = item.Symbol,
ColorHex = item.ColorHex,
Tab = item.Tab,
Preview = item.Preview,
ParentId = item.ParentId,
AgentRunCount = item.AgentRunCount,
FailedAgentRunCount = item.FailedAgentRunCount,
LastAgentRunSummary = item.LastAgentRunSummary,
WorkFolder = item.WorkFolder,
Group = group,
GroupOrder = groupOrder,
};
} }
private void RenderConversationList(List<ConversationMeta> items) private void RenderConversationList(List<ConversationMeta> items)
@@ -514,7 +749,7 @@ public partial class ChatWindow
var icon = new TextBlock var icon = new TextBlock
{ {
Text = iconText, Text = iconText,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 10.5, FontSize = 10.5,
Foreground = iconBrush, Foreground = iconBrush,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
@@ -587,7 +822,7 @@ public partial class ChatWindow
Content = new TextBlock Content = new TextBlock
{ {
Text = "\uE70F", Text = "\uE70F",
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 9, FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
}, },
@@ -628,4 +863,85 @@ public partial class ChatWindow
ConversationPanel.Children.Add(border); ConversationPanel.Children.Add(border);
} }
private void LoadMoreBorder_Click(object sender, MouseButtonEventArgs e)
{
LoadAllConversations();
}
/// <summary>
/// 대화 ID에 해당하는 제목 TextBlock을 찾습니다.
/// ItemsControl(새) → ConversationPanel(레거시) 순서로 탐색합니다.
/// </summary>
private TextBlock? FindConversationTitleBlock(string conversationId)
{
// 1) 새 ItemsControl에서 검색
if (ConversationItemsControl is { Visibility: Visibility.Visible })
{
var titleBlock = FindTitleBlockInItemsControl(conversationId);
if (titleBlock != null) return titleBlock;
}
// 2) 레거시 ConversationPanel에서 검색
foreach (UIElement child in ConversationPanel.Children)
{
if (child is not Border b || b.Tag is not ConversationItemTag tag) continue;
if (tag.Id == conversationId && tag.TitleBlock != null)
return tag.TitleBlock;
}
return null;
}
/// <summary>
/// ItemsControl 비주얼 트리에서 특정 대화 ID의 ConvTitleBlock을 찾습니다.
/// GroupStyle 사용 시 ContainerFromIndex가 올바르게 동작하지 않으므로
/// 비주얼 트리를 직접 탐색하여 DataContext가 일치하는 ContentPresenter를 찾습니다.
/// </summary>
private TextBlock? FindTitleBlockInItemsControl(string conversationId)
{
if (ConversationItemsControl == null) return null;
// GroupStyle이 적용된 ItemsControl은 비주얼 트리를 직접 탐색해야 함
var presenters = new List<ContentPresenter>();
CollectContentPresenters(ConversationItemsControl, presenters);
foreach (var presenter in presenters)
{
if (presenter.DataContext is ConversationItemViewModel vm && vm.Id == conversationId)
{
var titleBlock = FindNamedDescendant<TextBlock>(presenter, "ConvTitleBlock");
if (titleBlock != null) return titleBlock;
}
}
return null;
}
/// <summary>비주얼 트리에서 모든 ContentPresenter를 수집합니다.</summary>
private static void CollectContentPresenters(DependencyObject parent, List<ContentPresenter> result)
{
int count = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is ContentPresenter cp && cp.DataContext is ConversationItemViewModel)
result.Add(cp);
else
CollectContentPresenters(child, result);
}
}
/// <summary>비주얼 트리를 DFS로 순회하며 지정 이름의 요소를 찾습니다.</summary>
private static T? FindNamedDescendant<T>(DependencyObject parent, string name) where T : FrameworkElement
{
int count = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T fe && fe.Name == name) return fe;
var result = FindNamedDescendant<T>(child, name);
if (result != null) return result;
}
return null;
}
} }

View File

@@ -162,7 +162,7 @@ public partial class ChatWindow
var iconTb = new TextBlock var iconTb = new TextBlock
{ {
Text = icon, Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 12, FontSize = 12,
Foreground = iconColor, Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
@@ -218,21 +218,11 @@ public partial class ChatWindow
stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () => stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () =>
{ {
foreach (UIElement child in ConversationPanel.Children) var titleBlock = FindConversationTitleBlock(conversationId);
{ if (titleBlock != null)
if (child is not Border b || b.Child is not Grid g) continue;
foreach (UIElement gc in g.Children)
{
if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
{
if (conv != null && tb.Text == conv.Title)
{ {
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
EnterTitleEditMode(tb, conversationId, titleColor); EnterTitleEditMode(titleBlock, conversationId, titleColor);
return;
}
}
}
} }
})); }));
@@ -267,7 +257,7 @@ public partial class ChatWindow
infoSp.Children.Add(new TextBlock infoSp.Children.Add(new TextBlock
{ {
Text = catSymbol, Text = catSymbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 12, FontSize = 12,
Foreground = catBrush, Foreground = catBrush,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
@@ -324,7 +314,7 @@ public partial class ChatWindow
var catIcon = new TextBlock var catIcon = new TextBlock
{ {
Text = symbol, Text = symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 12, FontSize = 12,
Foreground = BrushFromHex(color), Foreground = BrushFromHex(color),
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
@@ -406,6 +396,26 @@ public partial class ChatWindow
} }
stack.Children.Add(CreateSeparator()); stack.Children.Add(CreateSeparator());
// 아카이브 토글
var isArchived = _storage.Load(conversationId)?.Archived ?? false;
stack.Children.Add(CreateMenuItem(
isArchived ? "\uE7B8" : "\uE7B7",
isArchived ? "아카이브 해제" : "아카이브 보관",
TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, () =>
{
var convToArchive = _storage.Load(conversationId);
if (convToArchive == null) return;
convToArchive.Archived = !convToArchive.Archived;
_storage.Save(convToArchive);
lock (_convLock)
{
if (_currentConversation?.Id == conversationId)
_currentConversation.Archived = convToArchive.Archived;
}
RefreshConversationList();
}));
stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () => stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () =>
{ {
var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제", var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제",

View File

@@ -0,0 +1,383 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace AxCopilot.Views;
public partial class ChatWindow
{
private sealed record FileMentionQuery(string Token, int Start, int Length);
private sealed record FileMentionCandidate(string RelativePath, string FileName, int Score);
private static readonly Regex FileMentionTokenRegex = new(
"(?<token>[^\\s\\\"'`()\\[\\]{}<>]{2,})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> FileMentionIgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
"packages", ".nuget", "TestResults", "coverage", ".next",
"target", ".gradle", ".cargo",
};
private readonly object _fileMentionIndexLock = new();
private List<string> _fileMentionIndexedPaths = new();
private string? _fileMentionIndexedFolder;
private DateTime _fileMentionIndexBuiltAtUtc = DateTime.MinValue;
private CancellationTokenSource? _fileMentionIndexCts;
private bool _fileMentionIndexBuildPending;
private const int FileMentionIndexLimit = 4000;
private void RefreshFileMentionSuggestions(string text)
{
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(GetCurrentWorkFolder()))
{
HideFileMentionSuggestions();
return;
}
if (SlashPopup?.IsOpen == true && text.StartsWith("/") && !text.Contains(' '))
{
HideFileMentionSuggestions();
return;
}
var query = TryExtractFileMentionQuery(text);
if (query == null)
{
HideFileMentionSuggestions();
return;
}
var workFolder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
{
HideFileMentionSuggestions();
return;
}
List<string> indexSnapshot;
bool canUseCurrentIndex;
lock (_fileMentionIndexLock)
{
canUseCurrentIndex =
string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase)
&& (DateTime.UtcNow - _fileMentionIndexBuiltAtUtc) < TimeSpan.FromMinutes(3)
&& _fileMentionIndexedPaths.Count > 0;
indexSnapshot = canUseCurrentIndex ? new List<string>(_fileMentionIndexedPaths) : [];
}
if (!canUseCurrentIndex)
{
RenderFileMentionSuggestions(query.Token, [], isLoading: true);
EnsureFileMentionIndexAsync(workFolder);
return;
}
var candidates = FindFileMentionCandidates(indexSnapshot, query.Token);
RenderFileMentionSuggestions(query.Token, candidates, isLoading: false);
}
private void EnsureFileMentionIndexAsync(string workFolder)
{
if (_fileMentionIndexBuildPending
&& string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase))
return;
_fileMentionIndexCts?.Cancel();
_fileMentionIndexCts = new CancellationTokenSource();
var localCts = _fileMentionIndexCts;
_fileMentionIndexBuildPending = true;
_fileMentionIndexedFolder = workFolder;
_ = Task.Run(() =>
{
var indexedPaths = BuildFileMentionIndex(workFolder, localCts.Token);
if (localCts.IsCancellationRequested)
return;
lock (_fileMentionIndexLock)
{
_fileMentionIndexedPaths = indexedPaths;
_fileMentionIndexedFolder = workFolder;
_fileMentionIndexBuiltAtUtc = DateTime.UtcNow;
}
Dispatcher?.Invoke(() =>
{
_fileMentionIndexBuildPending = false;
RefreshFileMentionSuggestions(InputBox?.Text ?? "");
});
}, localCts.Token).ContinueWith(_ =>
{
Dispatcher?.Invoke(() => { _fileMentionIndexBuildPending = false; });
}, TaskScheduler.Default);
}
private static List<string> BuildFileMentionIndex(string workFolder, CancellationToken ct)
{
var results = new List<string>(capacity: 512);
var pending = new Stack<string>();
pending.Push(workFolder);
while (pending.Count > 0 && results.Count < FileMentionIndexLimit)
{
ct.ThrowIfCancellationRequested();
var current = pending.Pop();
try
{
foreach (var dir in Directory.EnumerateDirectories(current))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(dir);
if (string.IsNullOrWhiteSpace(name)
|| name.StartsWith('.')
|| FileMentionIgnoredDirs.Contains(name))
continue;
pending.Push(dir);
}
foreach (var file in Directory.EnumerateFiles(current))
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(file);
if (string.IsNullOrWhiteSpace(fileName) || fileName.StartsWith('.'))
continue;
results.Add(Path.GetRelativePath(workFolder, file));
if (results.Count >= FileMentionIndexLimit)
break;
}
}
catch
{
}
}
return results;
}
private static FileMentionQuery? TryExtractFileMentionQuery(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var trimmedEnd = text.TrimEnd();
if (trimmedEnd.Length < 2)
return null;
var match = FileMentionTokenRegex.Match(trimmedEnd);
if (!match.Success)
return null;
var token = match.Groups["token"].Value.Trim();
if (token.Length < 2)
return null;
var looksFileLike = token.Contains('.')
|| token.Contains('/')
|| token.Contains('\\')
|| token.Contains('_')
|| token.Contains('-');
if (!looksFileLike)
{
var lower = trimmedEnd.ToLowerInvariant();
var fileContext = lower.Contains("파일")
|| lower.Contains("문서")
|| lower.Contains("폴더")
|| lower.Contains("file")
|| lower.Contains("document")
|| lower.Contains("folder")
|| lower.Contains("read ")
|| lower.Contains("open ");
if (!fileContext)
return null;
}
return new FileMentionQuery(
token,
match.Groups["token"].Index,
match.Groups["token"].Length);
}
private static List<FileMentionCandidate> FindFileMentionCandidates(
IReadOnlyCollection<string> indexedPaths,
string query)
{
if (indexedPaths.Count == 0 || string.IsNullOrWhiteSpace(query))
return [];
var normalizedQuery = query.Replace('\\', '/').Trim().ToLowerInvariant();
var queryFileName = Path.GetFileName(normalizedQuery);
var candidates = new List<FileMentionCandidate>();
foreach (var relativePath in indexedPaths)
{
var normalizedPath = relativePath.Replace('\\', '/');
var lowerPath = normalizedPath.ToLowerInvariant();
var fileName = Path.GetFileName(normalizedPath);
var lowerFileName = fileName.ToLowerInvariant();
if (!lowerPath.Contains(normalizedQuery) && !lowerFileName.Contains(queryFileName))
continue;
var score = 0;
if (string.Equals(lowerFileName, queryFileName, StringComparison.Ordinal))
score += 600;
else if (lowerFileName.StartsWith(queryFileName, StringComparison.Ordinal))
score += 420;
else if (lowerFileName.Contains(queryFileName, StringComparison.Ordinal))
score += 260;
if (string.Equals(lowerPath, normalizedQuery, StringComparison.Ordinal))
score += 520;
else if (lowerPath.EndsWith(normalizedQuery, StringComparison.Ordinal))
score += 300;
else if (lowerPath.Contains(normalizedQuery, StringComparison.Ordinal))
score += 140;
score += Math.Max(0, 80 - normalizedPath.Length);
candidates.Add(new FileMentionCandidate(normalizedPath, fileName, score));
}
return candidates
.OrderByDescending(c => c.Score)
.ThenBy(c => c.RelativePath.Length)
.ThenBy(c => c.RelativePath, StringComparer.OrdinalIgnoreCase)
.Take(6)
.ToList();
}
private void RenderFileMentionSuggestions(
string query,
IReadOnlyList<FileMentionCandidate> candidates,
bool isLoading)
{
if (FileMentionSuggestionCard == null
|| FileMentionSuggestionTitle == null
|| FileMentionSuggestionChipPanel == null)
return;
FileMentionSuggestionChipPanel.Children.Clear();
if (isLoading)
{
FileMentionSuggestionTitle.Text = $"파일 후보 찾는 중 · {query}";
FileMentionSuggestionCard.Visibility = Visibility.Visible;
return;
}
if (candidates.Count == 0)
{
HideFileMentionSuggestions();
return;
}
FileMentionSuggestionTitle.Text = $"파일 후보 · {query}";
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? itemBg;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? primaryText;
foreach (var candidate in candidates)
{
var chip = new Border
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
Tag = candidate.RelativePath,
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = candidate.FileName,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
stack.Children.Add(new TextBlock
{
Text = candidate.RelativePath,
Margin = new Thickness(0, 2, 0, 0),
FontSize = 10.5,
Foreground = secondaryText,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 240,
});
chip.Child = stack;
chip.MouseEnter += (_, _) => chip.Background = hoverBg;
chip.MouseLeave += (_, _) => chip.Background = itemBg;
chip.MouseLeftButtonUp += (_, _) => ApplyFileMentionSuggestion(candidate.RelativePath);
FileMentionSuggestionChipPanel.Children.Add(chip);
}
FileMentionSuggestionCard.Visibility = Visibility.Visible;
}
private void HideFileMentionSuggestions()
{
if (FileMentionSuggestionCard == null || FileMentionSuggestionChipPanel == null)
return;
FileMentionSuggestionChipPanel.Children.Clear();
FileMentionSuggestionCard.Visibility = Visibility.Collapsed;
}
private void ApplyFileMentionSuggestion(string relativePath)
{
if (InputBox == null || string.IsNullOrWhiteSpace(relativePath))
return;
var text = InputBox.Text ?? "";
var query = TryExtractFileMentionQuery(text);
var replacement = relativePath.Replace('\\', '/');
if (query != null)
{
text = text[..query.Start] + replacement + text[(query.Start + query.Length)..];
}
else
{
text = string.IsNullOrWhiteSpace(text) ? replacement : $"{text.TrimEnd()} {replacement}";
}
InputBox.Text = text;
InputBox.CaretIndex = text.Length;
InputBox.Focus();
HideFileMentionSuggestions();
}
private bool TryAcceptTopFileMentionSuggestion()
{
if (FileMentionSuggestionCard?.Visibility != Visibility.Visible
|| FileMentionSuggestionChipPanel == null
|| FileMentionSuggestionChipPanel.Children.Count == 0)
return false;
if (FileMentionSuggestionChipPanel.Children[0] is not Border firstChip
|| firstChip.Tag is not string relativePath)
return false;
ApplyFileMentionSuggestion(relativePath);
return true;
}
}

View File

@@ -66,17 +66,8 @@ public partial class ChatWindow
} }
FolderBar.Visibility = Visibility.Visible; FolderBar.Visibility = Visibility.Visible;
var folder = GetCurrentWorkFolder(); ViewModel.WorkFolder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(folder)) UpdateFolderSelectButtonStyle();
{
FolderPathLabel.Text = folder;
FolderPathLabel.ToolTip = folder;
}
else
{
FolderPathLabel.Text = "폴더를 선택하세요.";
FolderPathLabel.ToolTip = null;
}
LoadConversationSettings(); LoadConversationSettings();
LoadCompactionMetricsFromConversation(); LoadCompactionMetricsFromConversation();
@@ -129,7 +120,7 @@ public partial class ChatWindow
memory.Load(workFolder); memory.Load(workFolder);
var docs = memory.InstructionDocuments; var docs = memory.InstructionDocuments;
var learned = memory.All.Count; var learned = memory.All.Count;
var includePolicy = _settings.Settings.Llm.AllowExternalMemoryIncludes ? "?몃? include ?덉슜" : "?몃? include 李⑤떒"; var includePolicy = _settings.Settings.Llm.AllowExternalMemoryIncludes ? "외부 include 허용" : "외부 include 차단";
var auditEnabled = _settings.Settings.Llm.EnableAuditLog; var auditEnabled = _settings.Settings.Llm.EnableAuditLog;
var recentIncludeEntries = AuditLogService.LoadRecent("MemoryInclude", maxCount: 5, daysBack: 3); var recentIncludeEntries = AuditLogService.LoadRecent("MemoryInclude", maxCount: 5, daysBack: 3);
@@ -143,7 +134,7 @@ public partial class ChatWindow
var panel = new StackPanel { Margin = new Thickness(2) }; var panel = new StackPanel { Margin = new Thickness(2) };
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = "硫붾え由??곹깭", Text = "메모리 상태",
FontSize = 13, FontSize = 13,
FontWeight = FontWeights.SemiBold, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Foreground = primaryText,
@@ -151,7 +142,7 @@ public partial class ChatWindow
}); });
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = $"怨꾩링??洹쒖튃 {docs.Count}媛?쨌 ?숈뒿 硫붾え由?{learned}媛?쨌 {includePolicy}", Text = $"규칙 문서 {docs.Count}개 · 학습 메모리 {learned}개 · {includePolicy}",
FontSize = 11.5, FontSize = 11.5,
Foreground = secondaryText, Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap, TextWrapping = TextWrapping.Wrap,
@@ -163,7 +154,7 @@ public partial class ChatWindow
panel.Children.Add(CreateSurfacePopupSeparator()); panel.Children.Add(CreateSurfacePopupSeparator());
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = "?곸슜 以?洹쒖튃", Text = "적용 중 규칙",
FontSize = 12, FontSize = 12,
FontWeight = FontWeights.SemiBold, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Foreground = primaryText,
@@ -177,7 +168,7 @@ public partial class ChatWindow
{ {
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = $"??{docs.Count - 6}媛?洹쒖튃", Text = $"{docs.Count - 6}개 규칙",
FontSize = 11, FontSize = 11,
Foreground = secondaryText, Foreground = secondaryText,
Margin = new Thickness(8, 2, 8, 4), Margin = new Thickness(8, 2, 8, 4),
@@ -188,7 +179,7 @@ public partial class ChatWindow
panel.Children.Add(CreateSurfacePopupSeparator()); panel.Children.Add(CreateSurfacePopupSeparator());
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = "理쒓렐 include 媛먯궗", Text = "최근 include 감사",
FontSize = 12, FontSize = 12,
FontWeight = FontWeights.SemiBold, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Foreground = primaryText,
@@ -199,7 +190,7 @@ public partial class ChatWindow
{ {
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = "媛먯궗 濡쒓렇媛€ 爰쇱졇 ?덉뼱 include ?대젰?€ 湲곕줉?섏? ?딆뒿?덈떎.", Text = "감사 로그가 꺼져 있어 include 이력이 기록되지 않습니다.",
FontSize = 11, FontSize = 11,
Foreground = secondaryText, Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap, TextWrapping = TextWrapping.Wrap,
@@ -210,7 +201,7 @@ public partial class ChatWindow
{ {
panel.Children.Add(new TextBlock panel.Children.Add(new TextBlock
{ {
Text = "理쒓렐 3?쇨컙 include 媛먯궗 湲곕줉???놁뒿?덈떎.", Text = "최근 3일간 include 감사 기록이 없습니다.",
FontSize = 11, FontSize = 11,
Foreground = secondaryText, Foreground = secondaryText,
Margin = new Thickness(8, 0, 8, 6), Margin = new Thickness(8, 0, 8, 6),
@@ -266,7 +257,7 @@ public partial class ChatWindow
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) }; var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };
stack.Children.Add(new TextBlock stack.Children.Add(new TextBlock
{ {
Text = $"[{doc.Label}] ?곗꽑?쒖쐞 {doc.Priority}", Text = $"[{doc.Label}] 우선순위 {doc.Priority}",
FontSize = 11.5, FontSize = 11.5,
FontWeight = FontWeights.SemiBold, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Foreground = primaryText,
@@ -297,7 +288,7 @@ public partial class ChatWindow
private Border BuildMemoryPopupAuditRow(AuditEntry entry, Brush primaryText, Brush secondaryText, Brush okBrush, Brush warnBrush, Brush dangerBrush) private Border BuildMemoryPopupAuditRow(AuditEntry entry, Brush primaryText, Brush secondaryText, Brush okBrush, Brush warnBrush, Brush dangerBrush)
{ {
var statusBrush = entry.Success ? okBrush : dangerBrush; var statusBrush = entry.Success ? okBrush : dangerBrush;
var statusText = entry.Success ? "?덉슜" : "李⑤떒"; var statusText = entry.Success ? "허용" : "차단";
var resultBrush = entry.Success ? secondaryText : warnBrush; var resultBrush = entry.Success ? secondaryText : warnBrush;
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) }; var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };

View File

@@ -16,15 +16,13 @@ public partial class ChatWindow
{ {
_currentGitBranchName = branchName; _currentGitBranchName = branchName;
_currentGitTooltip = tooltip; _currentGitTooltip = tooltip;
ViewModel.GitBranchName = string.IsNullOrWhiteSpace(branchName) ? "브랜치 없음" : branchName;
if (BtnGitBranch != null) if (BtnGitBranch != null)
{ {
BtnGitBranch.Visibility = visibility; BtnGitBranch.Visibility = visibility;
BtnGitBranch.ToolTip = string.IsNullOrWhiteSpace(tooltip) ? "현재 Git 브랜치 상태" : tooltip; BtnGitBranch.ToolTip = string.IsNullOrWhiteSpace(tooltip) ? "현재 Git 브랜치 상태" : tooltip;
} }
if (GitBranchLabel != null)
GitBranchLabel.Text = string.IsNullOrWhiteSpace(branchName) ? "브랜치 없음" : branchName;
if (GitBranchFilesText != null) if (GitBranchFilesText != null)
GitBranchFilesText.Text = filesText; GitBranchFilesText.Text = filesText;
if (GitBranchAddedText != null) if (GitBranchAddedText != null)
@@ -493,7 +491,7 @@ public partial class ChatWindow
grid.Children.Add(new TextBlock grid.Children.Add(new TextBlock
{ {
Text = icon, Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 11, FontSize = 11,
Foreground = BrushFromHex(colorHex), Foreground = BrushFromHex(colorHex),
VerticalAlignment = VerticalAlignment.Top, VerticalAlignment = VerticalAlignment.Top,
@@ -527,7 +525,7 @@ public partial class ChatWindow
var chevron = new TextBlock var chevron = new TextBlock
{ {
Text = "\uE76C", Text = "\uE76C",
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 10, FontSize = 10,
Foreground = secondaryText, Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,

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