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

@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Forms;
using Microsoft.Win32;
using Microsoft.Extensions.DependencyInjection;
using AxCopilot.Core;
using AxCopilot.Handlers;
using AxCopilot.Services;
@@ -119,6 +120,9 @@ public partial class App : System.Windows.Application
_appState.AttachChatSession(_chatSessionState);
_appState.LoadFromSettings(settings);
// ─── DI 컨테이너 초기화 (기존 인스턴스 등록, 점진적 전환) ──────────────
ConfigureDependencyInjection(settings, _memoryService, _chatSessionState, _appState);
_indexService = new IndexService(settings);
var indexService = _indexService;
// 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지
@@ -1008,4 +1012,32 @@ public partial class App : System.Windows.Application
_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",
"color": "#3B82F6",
"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": "어떤 기능을 개발할까요? (프로젝트 폴더를 먼저 선택하세요)"
}

View File

@@ -6,6 +6,6 @@
"symbol": "\uE71B",
"color": "#10B981",
"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": "어떤 코드를 리뷰할까요?"
}

View File

@@ -6,6 +6,6 @@
"symbol": "\uE777",
"color": "#6366F1",
"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": "어떤 코드를 리팩터링할까요?"
}

View File

@@ -6,6 +6,6 @@
"symbol": "\uE8A5",
"color": "#F59E0B",
"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": "어떤 문서를 작성할까요? (예: 프로젝트 기획서 작성)"
}

View File

@@ -6,6 +6,6 @@
"symbol": "\uE9F9",
"color": "#3B82F6",
"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": "어떤 보고서를 작성할까요? (예: 삼성디스플레이 연혁 보고서)"
}

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 전용, 외부 네트워크 통신 없음) -->
<!-- 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" />
@@ -114,6 +115,11 @@
<Resource Include="Assets\mascot.png" Condition="Exists('Assets\mascot.png')" />
<Resource Include="Assets\mascot.jpg" Condition="Exists('Assets\mascot.jpg')" />
<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 다운로드 불가 대응) -->
<Content Include="Assets\SearchEngines\*.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -125,6 +131,14 @@
</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">

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
{
var storage = new ChatStorageService();
var storage = ServiceLocator.Get<IChatStorageService>();
var metas = storage.LoadAllMeta();
foreach (var conv in metas.Take(5))
{

View File

@@ -185,6 +185,14 @@ public class LauncherSettings
[JsonPropertyName("enableIconAnimation")]
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>
[JsonPropertyName("enableRandomPlaceholder")]
public bool EnableRandomPlaceholder { get; set; } = true;
@@ -804,10 +812,18 @@ public class LlmSettings
[JsonPropertyName("recentPromptTemplates")]
public List<string> RecentPromptTemplates { get; set; } = new();
/// <summary>작업 폴더 경로. 빈 문자열이면 미선택.</summary>
/// <summary>작업 폴더 경로. 빈 문자열이면 미선택. (레거시 — 탭별 경로 미지정 시 폴백)</summary>
[JsonPropertyName("workFolder")]
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>
[JsonPropertyName("recentWorkFolders")]
public List<string> RecentWorkFolders { get; set; } = new();
@@ -885,9 +901,9 @@ public class LlmSettings
[JsonPropertyName("maxTestFixIterations")]
public int MaxTestFixIterations { get; set; } = 5;
/// <summary>에이전트 로그 표시 수준. simple | detailed | debug</summary>
/// <summary>에이전트 로그 표시 수준. hidden | simple | detailed | debug</summary>
[JsonPropertyName("agentLogLevel")]
public string AgentLogLevel { get; set; } = "simple";
public string AgentLogLevel { get; set; } = "detailed";
/// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary>
[JsonPropertyName("agentUiExpressionLevel")]
@@ -1055,6 +1071,14 @@ public class LlmSettings
[JsonPropertyName("enableChatRainbowGlow")]
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>
[JsonPropertyName("agentTheme")]
public string AgentTheme { get; set; } = "system";
@@ -1069,6 +1093,11 @@ public class LlmSettings
[JsonPropertyName("notifyOnComplete")]
public bool NotifyOnComplete { get; set; } = false;
/// <summary>코워크 작업 완료 후 문서 자동 처리 방식.
/// "none" = 아무것도 안하기, "open" = 문서 실행(기본 앱), "preview" = 미리보기 뷰어.</summary>
[JsonPropertyName("coworkOnComplete")]
public string CoworkOnComplete { get; set; } = "none";
/// <summary>AI 대화창에서 팁 알림 표시 여부.</summary>
[JsonPropertyName("showTips")]
public bool ShowTips { get; set; } = false;
@@ -1315,6 +1344,18 @@ public class CodeSettings
/// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary>
[JsonPropertyName("enableCronTools")]
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,6 +107,28 @@ public class HexToColorConverter : IValueConverter
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>
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 _showPrefixBadge;
private bool _enableIconAnimation;
private bool _enableChatIconRandomAnimation;
private string _chatIconGlowIntensity = "medium";
private bool _enableRainbowGlow;
private bool _enableSelectionGlow;
private bool _enableRandomPlaceholder;
@@ -849,6 +851,18 @@ public class SettingsViewModel : INotifyPropertyChanged
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
{
get => _enableRainbowGlow;
@@ -1115,6 +1129,8 @@ public class SettingsViewModel : INotifyPropertyChanged
_rememberPosition = s.Launcher.RememberPosition;
_showPrefixBadge = s.Launcher.ShowPrefixBadge;
_enableIconAnimation = s.Launcher.EnableIconAnimation;
_enableChatIconRandomAnimation = s.Launcher.EnableChatIconRandomAnimation;
_chatIconGlowIntensity = s.Launcher.ChatIconGlowIntensity ?? "medium";
_enableRainbowGlow = s.Launcher.EnableRainbowGlow;
_enableSelectionGlow = s.Launcher.EnableSelectionGlow;
_enableRandomPlaceholder = s.Launcher.EnableRandomPlaceholder;
@@ -1573,6 +1589,8 @@ public class SettingsViewModel : INotifyPropertyChanged
s.Launcher.RememberPosition = _rememberPosition;
s.Launcher.ShowPrefixBadge = _showPrefixBadge;
s.Launcher.EnableIconAnimation = _enableIconAnimation;
s.Launcher.EnableChatIconRandomAnimation = _enableChatIconRandomAnimation;
s.Launcher.ChatIconGlowIntensity = _chatIconGlowIntensity;
s.Launcher.EnableRainbowGlow = _enableRainbowGlow;
s.Launcher.EnableSelectionGlow = _enableSelectionGlow;
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(
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;
}
}
@@ -109,7 +128,17 @@ public partial class ChatWindow
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary;
var result = _chatEngine.AppendAgentRun(
session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary);
_currentConversation = result.CurrentConversation;
// 방어: 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;
}
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)
{
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
{
Text = resultText,
@@ -1071,7 +1080,7 @@ public partial class ChatWindow
FontWeight = FontWeights.SemiBold,
Foreground = fg,
Opacity = 0.8,
Margin = new Thickness(0, 2, 0, 2),
Margin = new Thickness(40, 2, 0, 2),
};
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)
{
_failedOnlyFilter = false;
_runningOnlyFilter = false;
_archiveFilter = null;
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi();
UpdateConversationSortUi();
UpdateArchiveFilterUi();
}
private void PersistConversationListPreferences()

View File

@@ -8,6 +8,7 @@ using System.Windows.Media;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.ViewModels;
namespace AxCopilot.Views;
@@ -43,6 +44,115 @@ public partial class ChatWindow
ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave;
ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown;
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)
@@ -119,17 +229,8 @@ public partial class ChatWindow
return;
}
if (_streamingTabs.Contains(_activeTab))
{
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();
}
// 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
StopStreamingIfActive();
var conv = _storage.Load(tag.Id);
if (conv == null)
@@ -218,6 +319,7 @@ public partial class ChatWindow
LastFailedAt = runSummary.LastFailedAt,
LastCompletedAt = runSummary.LastCompletedAt,
WorkFolder = c.WorkFolder ?? "",
Archived = c.Archived,
IsRunning = _currentConversation?.Id == c.Id
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
@@ -227,15 +329,22 @@ public partial class ChatWindow
// LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
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.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase))
).ToList();
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase);
}).ToList();
// Count를 한 번의 루프로 계산 (3번 순회 → 1번)
int failedCount = 0, runningCount = 0, spotlightCount = 0;
@@ -300,7 +409,133 @@ public partial class ChatWindow
.ThenByDescending(i => i.UpdatedAt))
.ToList();
// 스크롤 위치 보존: 대화 전환 시 목록이 맨 위로 점프하는 현상 방지
double savedScrollOffset = 0;
bool hasScrollViewer = ConversationListScrollViewer != null;
if (hasScrollViewer)
savedScrollOffset = ConversationListScrollViewer!.VerticalOffset;
SyncConversationsToViewModel(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)
@@ -514,7 +749,7 @@ public partial class ChatWindow
var icon = new TextBlock
{
Text = iconText,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontFamily = s_segoeIconFont,
FontSize = 10.5,
Foreground = iconBrush,
VerticalAlignment = VerticalAlignment.Center,
@@ -587,7 +822,7 @@ public partial class ChatWindow
Content = new TextBlock
{
Text = "\uE70F",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontFamily = s_segoeIconFont,
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
},
@@ -628,4 +863,85 @@ public partial class ChatWindow
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
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontFamily = s_segoeIconFont,
FontSize = 12,
Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center,
@@ -218,21 +218,11 @@ public partial class ChatWindow
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;
EnterTitleEditMode(tb, conversationId, titleColor);
return;
}
}
}
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
EnterTitleEditMode(titleBlock, conversationId, titleColor);
}
}));
@@ -267,7 +257,7 @@ public partial class ChatWindow
infoSp.Children.Add(new TextBlock
{
Text = catSymbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontFamily = s_segoeIconFont,
FontSize = 12,
Foreground = catBrush,
VerticalAlignment = VerticalAlignment.Center,
@@ -324,7 +314,7 @@ public partial class ChatWindow
var catIcon = new TextBlock
{
Text = symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontFamily = s_segoeIconFont,
FontSize = 12,
Foreground = BrushFromHex(color),
VerticalAlignment = VerticalAlignment.Center,
@@ -406,6 +396,26 @@ public partial class ChatWindow
}
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, () =>
{
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;
var folder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(folder))
{
FolderPathLabel.Text = folder;
FolderPathLabel.ToolTip = folder;
}
else
{
FolderPathLabel.Text = "폴더를 선택하세요.";
FolderPathLabel.ToolTip = null;
}
ViewModel.WorkFolder = GetCurrentWorkFolder();
UpdateFolderSelectButtonStyle();
LoadConversationSettings();
LoadCompactionMetricsFromConversation();
@@ -129,7 +120,7 @@ public partial class ChatWindow
memory.Load(workFolder);
var docs = memory.InstructionDocuments;
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 recentIncludeEntries = AuditLogService.LoadRecent("MemoryInclude", maxCount: 5, daysBack: 3);
@@ -143,7 +134,7 @@ public partial class ChatWindow
var panel = new StackPanel { Margin = new Thickness(2) };
panel.Children.Add(new TextBlock
{
Text = "硫붾え由??곹깭",
Text = "메모리 상태",
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
@@ -151,7 +142,7 @@ public partial class ChatWindow
});
panel.Children.Add(new TextBlock
{
Text = $"怨꾩링??洹쒖튃 {docs.Count}媛?쨌 ?숈뒿 硫붾え由?{learned}媛?쨌 {includePolicy}",
Text = $"규칙 문서 {docs.Count}개 · 학습 메모리 {learned}개 · {includePolicy}",
FontSize = 11.5,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
@@ -163,7 +154,7 @@ public partial class ChatWindow
panel.Children.Add(CreateSurfacePopupSeparator());
panel.Children.Add(new TextBlock
{
Text = "?곸슜 以?洹쒖튃",
Text = "적용 중 규칙",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
@@ -177,7 +168,7 @@ public partial class ChatWindow
{
panel.Children.Add(new TextBlock
{
Text = $"??{docs.Count - 6}媛?洹쒖튃",
Text = $"{docs.Count - 6}개 규칙",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(8, 2, 8, 4),
@@ -188,7 +179,7 @@ public partial class ChatWindow
panel.Children.Add(CreateSurfacePopupSeparator());
panel.Children.Add(new TextBlock
{
Text = "理쒓렐 include 媛먯궗",
Text = "최근 include 감사",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
@@ -199,7 +190,7 @@ public partial class ChatWindow
{
panel.Children.Add(new TextBlock
{
Text = "媛먯궗 濡쒓렇媛€ 爰쇱졇 ?덉뼱 include ?대젰?€ 湲곕줉?섏? ?딆뒿?덈떎.",
Text = "감사 로그가 꺼져 있어 include 이력이 기록되지 않습니다.",
FontSize = 11,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
@@ -210,7 +201,7 @@ public partial class ChatWindow
{
panel.Children.Add(new TextBlock
{
Text = "理쒓렐 3?쇨컙 include 媛먯궗 湲곕줉???놁뒿?덈떎.",
Text = "최근 3일간 include 감사 기록이 없습니다.",
FontSize = 11,
Foreground = secondaryText,
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) };
stack.Children.Add(new TextBlock
{
Text = $"[{doc.Label}] ?곗꽑?쒖쐞 {doc.Priority}",
Text = $"[{doc.Label}] 우선순위 {doc.Priority}",
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
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)
{
var statusBrush = entry.Success ? okBrush : dangerBrush;
var statusText = entry.Success ? "?덉슜" : "李⑤떒";
var statusText = entry.Success ? "허용" : "차단";
var resultBrush = entry.Success ? secondaryText : warnBrush;
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };

View File

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

View File

@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
@@ -14,6 +15,13 @@ public partial class ChatWindow
if (MessageList == null) return;
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return;
// V2 분기
if (_settings.Settings.Llm.EnableNewChatRendering)
{
ShowAgentLiveCardV2(runTab);
return;
}
RemoveAgentLiveCard(animated: false);
_agentLiveStartTime = DateTime.UtcNow;
@@ -52,23 +60,25 @@ public partial class ChatWindow
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var liveIcon = CreateMiniLauncherIcon(pixelSize: 4.0);
if (!IsLightweightLiveProgressMode(runTab))
var (agentName, _, _) = GetAgentIdentity();
var (liveIconHost, livePixels, liveGlows, liveRotate, liveScale) = CreateMiniLauncherIconEx(4.0, "none");
{
liveIcon.BeginAnimation(
UIElement.OpacityProperty,
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(750))
// 모든 모드에서 동일한 순차 점멸 애니메이션 적용
var canvas = liveIconHost.Children.OfType<Canvas>().FirstOrDefault();
if (canvas != null)
{
var animState = new ChatIconAnimState
{
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new SineEase()
});
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
};
StartChatIconAnimation(animState);
}
}
Grid.SetColumn(liveIcon, 0);
headerGrid.Children.Add(liveIcon);
var (agentName, _, _) = GetAgentIdentity();
Grid.SetColumn(liveIconHost, 0);
headerGrid.Children.Add(liveIconHost);
var nameTb = new TextBlock
{
Text = agentName,
@@ -108,7 +118,7 @@ public partial class ChatWindow
{
Text = "준비 중...",
FontSize = 12,
FontFamily = new FontFamily("Segoe UI, Malgun Gothic"),
FontFamily = s_segoeUiFont,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
};
@@ -168,7 +178,7 @@ public partial class ChatWindow
{
Text = $" {subItem}",
FontSize = 10.5,
FontFamily = new FontFamily("Segoe UI, Malgun Gothic"),
FontFamily = s_segoeUiFont,
Foreground = secondary,
Opacity = 0.62,
TextTrimming = TextTrimming.CharacterEllipsis,
@@ -181,12 +191,19 @@ public partial class ChatWindow
private void RemoveAgentLiveCard(bool animated = true)
{
// V2 분기 — V2 라이브 컨테이너도 함께 정리
if (_v2LiveContainer != null)
RemoveAgentLiveCardV2(animated);
_agentLiveElapsedTimer?.Stop();
_agentLiveElapsedTimer = null;
if (_agentLiveContainer == null)
return;
// 라이브 카드에 연결된 아이콘 애니메이션 상태 제거
_chatIconAnimStates.RemoveAll(s => s.Host != null && !s.Host.IsVisible);
var toRemove = _agentLiveContainer;
_agentLiveContainer = null;
_agentLiveStatusText = null;

View File

@@ -0,0 +1,784 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace AxCopilot.Views;
/// <summary>
/// 마스코트 GIF 애니메이션 시스템:
/// Assets/gif/*.gif 에서 애니메이션 GIF를 로드하고, 배경색을 자동 투명화한 후
/// 프레임 단위로 재생합니다. GIF가 없으면 기존 정적 이미지 폴백.
/// 로딩은 백그라운드 스레드에서 수행하여 UI 렉을 방지합니다.
/// </summary>
public partial class ChatWindow
{
// ─── 상수 ──────────────────────────────────────────────
private const int MascotMaxH = 220; // 캐릭터 최대 높이(px)
private const int MascotMaxW = 200; // 캐릭터 최대 폭(px)
private const int BgTolerance = 45; // 배경 투명화 색상 허용 오차 (어두운 배경 대응)
// ─── 상태 ──────────────────────────────────────────────
private DispatcherTimer? _mascotCycleTimer; // GIF 간 교체 타이머
private DispatcherTimer? _gifFrameTimer; // 프레임 재생 타이머
private bool _mascotImageLoaded;
private bool _mascotLoading; // 백그라운드 로딩 중 플래그
private double _mascotAreaW;
// GIF 데이터
private readonly List<GifAnimation> _gifAnimations = new();
private int _currentGifIndex;
private int _currentFrameIndex;
private GifAnimation? _activeGif;
/// <summary>단일 GIF 파일의 디코딩된 프레임 + 딜레이 정보.</summary>
private sealed class GifAnimation
{
public string Name { get; init; } = "";
public BitmapSource[] Frames { get; init; } = [];
public int[] DelaysMs { get; init; } = [];
public double DisplayW { get; init; }
public double DisplayH { get; init; }
public bool UseNearestNeighbor { get; init; }
}
// ═══════════════════════════════════════════════════════════
// 초기화: GIF 로드 · 배경 투명화 · 크기 보정 (백그라운드)
// ═══════════════════════════════════════════════════════════
private void InitializeMascot()
{
if (_mascotImageLoaded || _mascotLoading) return;
_mascotLoading = true;
var maxCount = MascotMaxGifCount;
if (maxCount <= 0)
{
_mascotImageLoaded = true;
_mascotLoading = false;
return;
}
// 백그라운드 스레드에서 GIF 로드 (UI 렉 방지)
// 첫 GIF를 먼저 전달하여 즉시 표시, 나머지는 이후 추가
Task.Run(() =>
{
try
{
var gifDir = FindGifDirectory();
if (gifDir == null || !Directory.Exists(gifDir))
{
Dispatcher.BeginInvoke(() =>
{
_mascotImageLoaded = true;
_mascotLoading = false;
Services.LogService.Info("마스코트 GIF 없음 — 정적 이미지 폴백");
}, DispatcherPriority.Background);
return;
}
var gifFiles = Directory.GetFiles(gifDir, "*.gif");
// 랜덤 셔플 후 maxCount만큼만 선택
var rng = Random.Shared;
for (int i = gifFiles.Length - 1; i > 0; i--)
{
int j = rng.Next(i + 1);
(gifFiles[i], gifFiles[j]) = (gifFiles[j], gifFiles[i]);
}
var selectedFiles = gifFiles.Length > maxCount
? gifFiles[..maxCount]
: gifFiles;
bool firstDelivered = false;
foreach (var path in selectedFiles)
{
try
{
var gif = DecodeGif(path);
if (gif == null || gif.Frames.Length == 0) continue;
Dispatcher.BeginInvoke(() =>
{
_gifAnimations.Add(gif);
if (!firstDelivered)
{
firstDelivered = true;
MascotImage.Stretch = Stretch.Uniform;
MascotImage.RenderTransformOrigin = new Point(0.5, 1.0);
Services.LogService.Info($"마스코트 첫 GIF 로드 완료: {gif.Name} (최대 {maxCount}개)");
// 첫 GIF 즉시 재생 시작
if (EmptyState.Visibility == Visibility.Visible
&& string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
StartMascotAnimation();
}
}, DispatcherPriority.Background);
}
catch (Exception ex)
{
Services.LogService.Warn($"GIF 로드 실패 ({Path.GetFileName(path)}): {ex.Message}");
}
}
// 전체 로딩 완료
Dispatcher.BeginInvoke(() =>
{
_mascotImageLoaded = true;
_mascotLoading = false;
Services.LogService.Info($"마스코트 GIF {_gifAnimations.Count}개 로드 완료 (설정: {maxCount}개)");
}, DispatcherPriority.Background);
}
catch (Exception ex)
{
Dispatcher.BeginInvoke(() =>
{
_mascotLoading = false;
Services.LogService.Warn($"마스코트 초기화 실패: {ex.Message}");
});
}
});
}
/// <summary>백그라운드 스레드에서 GIF 파일들을 로드합니다.</summary>
private static List<GifAnimation> LoadGifAnimationsOffThread()
{
var result = new List<GifAnimation>();
var gifDir = FindGifDirectory();
if (gifDir == null || !Directory.Exists(gifDir))
return result;
var gifFiles = Directory.GetFiles(gifDir, "*.gif");
Array.Sort(gifFiles, StringComparer.OrdinalIgnoreCase);
foreach (var path in gifFiles)
{
try
{
var gif = DecodeGif(path);
if (gif != null && gif.Frames.Length > 0)
result.Add(gif);
}
catch (Exception ex)
{
Services.LogService.Warn($"GIF 로드 실패 ({Path.GetFileName(path)}): {ex.Message}");
}
}
return result;
}
/// <summary>GIF 폴더 경로를 탐색합니다.</summary>
private static string? FindGifDirectory()
{
var exeDir = AppContext.BaseDirectory;
// 1. 실행 파일 옆 Assets/gif/
var candidate = Path.Combine(exeDir, "Assets", "gif");
if (Directory.Exists(candidate)) return candidate;
// 2. %APPDATA%/AXCopilot/gif/
var appData = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AXCopilot", "gif");
if (Directory.Exists(appData)) return appData;
// 3. 개발 환경
var devDir = Path.Combine(exeDir, "..", "..", "..", "Assets", "gif");
if (Directory.Exists(devDir)) return Path.GetFullPath(devDir);
return null;
}
/// <summary>GIF 파일을 디코딩하고 배경 투명화 + 크기 보정합니다.</summary>
private static GifAnimation? DecodeGif(string path)
{
GifBitmapDecoder decoder;
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
}
if (decoder.Frames.Count == 0)
return null;
int frameCount = decoder.Frames.Count;
var frames = new BitmapSource[frameCount];
var delays = new int[frameCount];
// 원본 크기로 캔버스 생성 (GIF disposal 처리용)
int canvasW = decoder.Frames[0].PixelWidth;
int canvasH = decoder.Frames[0].PixelHeight;
// GIF 전역 크기 (메타데이터에서)
try
{
var gifMeta = decoder.Metadata as BitmapMetadata;
if (gifMeta != null)
{
var gw = gifMeta.GetQuery("/logscrdesc/Width");
var gh = gifMeta.GetQuery("/logscrdesc/Height");
if (gw is ushort w && gh is ushort h && w > 0 && h > 0)
{ canvasW = w; canvasH = h; }
}
}
catch { }
// 첫 프레임에서 배경색 샘플링
var firstRendered = RenderFullFrame(decoder.Frames[0], canvasW, canvasH);
var bgColor = SampleBackgroundColor(firstRendered);
// 캔버스 기반 프레임 합성 (disposal method 처리)
int stride = canvasW * 4;
var canvas = new byte[stride * canvasH];
var prevCanvas = new byte[stride * canvasH]; // disposal=3 복원용
for (int i = 0; i < frameCount; i++)
{
var bmpFrame = decoder.Frames[i];
int disposal = GetDisposalMethod(bmpFrame);
// disposal=3 이전 상태 보존
if (disposal == 3)
Array.Copy(canvas, prevCanvas, canvas.Length);
// 프레임을 캔버스에 합성
ComposeFrame(canvas, canvasW, canvasH, bmpFrame);
// 배경 투명화 적용
var framePixels = new byte[canvas.Length];
Array.Copy(canvas, framePixels, canvas.Length);
if (bgColor.HasValue)
RemoveBackgroundInPlace(framePixels, canvasW, canvasH, bgColor.Value);
var bmp = BitmapSource.Create(canvasW, canvasH, 96, 96,
PixelFormats.Bgra32, null, framePixels, stride);
bmp.Freeze();
frames[i] = bmp;
// 프레임 딜레이
delays[i] = GetFrameDelay(bmpFrame) * 10; // 1/100초 → ms
if (delays[i] < 20) delays[i] = 100;
// disposal 처리
switch (disposal)
{
case 2: // 배경으로 복원 (해당 영역 클리어)
ClearFrameRegion(canvas, canvasW, canvasH, bmpFrame);
break;
case 3: // 이전 상태로 복원
Array.Copy(prevCanvas, canvas, canvas.Length);
break;
// 0,1: 그대로 유지
}
}
// 크기 보정
var (dispW, dispH, useNN) = CalculateDisplaySize(canvasW, canvasH);
return new GifAnimation
{
Name = Path.GetFileNameWithoutExtension(path),
Frames = frames,
DelaysMs = delays,
DisplayW = dispW,
DisplayH = dispH,
UseNearestNeighbor = useNN,
};
}
/// <summary>단일 BitmapFrame을 전체 캔버스 크기의 BGRA32로 렌더합니다.</summary>
private static BitmapSource RenderFullFrame(BitmapFrame frame, int canvasW, int canvasH)
{
int stride = canvasW * 4;
var pixels = new byte[stride * canvasH];
ComposeFrame(pixels, canvasW, canvasH, frame);
var bmp = BitmapSource.Create(canvasW, canvasH, 96, 96, PixelFormats.Bgra32, null, pixels, stride);
bmp.Freeze();
return bmp;
}
/// <summary>프레임을 캔버스 버퍼에 합성합니다 (오프셋 + 알파).</summary>
private static void ComposeFrame(byte[] canvas, int canvasW, int canvasH, BitmapFrame frame)
{
// 프레임 오프셋 읽기
int offX = 0, offY = 0;
try
{
var meta = frame.Metadata as BitmapMetadata;
if (meta != null)
{
var left = meta.GetQuery("/imgdesc/Left");
var top = meta.GetQuery("/imgdesc/Top");
if (left is ushort l) offX = l;
if (top is ushort t) offY = t;
}
}
catch { }
// BGRA32 변환
var converted = frame.Format == PixelFormats.Bgra32
? (BitmapSource)frame
: new FormatConvertedBitmap(frame, PixelFormats.Bgra32, null, 0);
int fw = converted.PixelWidth, fh = converted.PixelHeight;
int fStride = fw * 4;
var fPixels = new byte[fStride * fh];
converted.CopyPixels(fPixels, fStride, 0);
int cStride = canvasW * 4;
for (int y = 0; y < fh; y++)
{
int cy = y + offY;
if (cy < 0 || cy >= canvasH) continue;
for (int x = 0; x < fw; x++)
{
int cx = x + offX;
if (cx < 0 || cx >= canvasW) continue;
int fi = y * fStride + x * 4;
int ci = cy * cStride + cx * 4;
byte fa = fPixels[fi + 3];
if (fa == 255)
{
canvas[ci] = fPixels[fi];
canvas[ci + 1] = fPixels[fi + 1];
canvas[ci + 2] = fPixels[fi + 2];
canvas[ci + 3] = 255;
}
else if (fa > 0)
{
// 알파 블렌딩
int srcA = fa, dstA = canvas[ci + 3];
int outA = srcA + dstA * (255 - srcA) / 255;
if (outA > 0)
{
canvas[ci] = (byte)((fPixels[fi] * srcA + canvas[ci] * dstA * (255 - srcA) / 255) / outA);
canvas[ci + 1] = (byte)((fPixels[fi + 1] * srcA + canvas[ci + 1] * dstA * (255 - srcA) / 255) / outA);
canvas[ci + 2] = (byte)((fPixels[fi + 2] * srcA + canvas[ci + 2] * dstA * (255 - srcA) / 255) / outA);
canvas[ci + 3] = (byte)outA;
}
}
}
}
}
/// <summary>프레임 영역을 투명으로 초기화합니다 (disposal=2).</summary>
private static void ClearFrameRegion(byte[] canvas, int canvasW, int canvasH, BitmapFrame frame)
{
int offX = 0, offY = 0;
try
{
var meta = frame.Metadata as BitmapMetadata;
if (meta != null)
{
var left = meta.GetQuery("/imgdesc/Left");
var top = meta.GetQuery("/imgdesc/Top");
if (left is ushort l) offX = l;
if (top is ushort t) offY = t;
}
}
catch { }
int fw = frame.PixelWidth, fh = frame.PixelHeight;
int cStride = canvasW * 4;
for (int y = 0; y < fh; y++)
{
int cy = y + offY;
if (cy < 0 || cy >= canvasH) continue;
for (int x = 0; x < fw; x++)
{
int cx = x + offX;
if (cx < 0 || cx >= canvasW) continue;
int ci = cy * cStride + cx * 4;
canvas[ci] = canvas[ci + 1] = canvas[ci + 2] = canvas[ci + 3] = 0;
}
}
}
/// <summary>4개 코너에서 배경색을 샘플링합니다.</summary>
private static (byte R, byte G, byte B)? SampleBackgroundColor(BitmapSource frame)
{
int w = frame.PixelWidth, h = frame.PixelHeight;
if (w < 2 || h < 2) return null;
int stride = w * 4;
var pixels = new byte[stride * h];
frame.CopyPixels(pixels, stride, 0);
// 4코너 + 각 변 중앙 (8포인트 샘플링)
var points = new[] {
(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
(w / 2, 0), (w / 2, h - 1), (0, h / 2), (w - 1, h / 2)
};
int rSum = 0, gSum = 0, bSum = 0, count = 0;
foreach (var (cx, cy) in points)
{
int idx = cy * stride + cx * 4;
byte a = pixels[idx + 3];
if (a < 128) continue;
rSum += pixels[idx + 2];
gSum += pixels[idx + 1];
bSum += pixels[idx];
count++;
}
if (count == 0) return null;
return ((byte)(rSum / count), (byte)(gSum / count), (byte)(bSum / count));
}
/// <summary>
/// 다중 패스 BFS로 외곽 배경색을 투명화합니다 (in-place).
/// GIF가 겹층 배경(예: 검정 테두리 → 회색 배경)을 가진 경우,
/// 1차 패스에서 테두리를 제거하고, 투명화 경계에서 새로 노출된
/// 색상을 감지하여 2차 패스로 내부 배경까지 제거합니다.
/// </summary>
private static void RemoveBackgroundInPlace(byte[] pixels, int w, int h, (byte R, byte G, byte B) bg)
{
int stride = w * 4;
// 최대 3회 패스 (테두리 → 1차 배경 → 2차 배경)
for (int pass = 0; pass < 3; pass++)
{
var visited = new bool[w * h];
var queue = new Queue<int>(w * 2 + h * 2);
// 시드: 모든 외곽 픽셀 + 투명 픽셀에 인접한 불투명 픽셀
for (int x = 0; x < w; x++)
{
TryEnqueue(x, 0);
TryEnqueue(x, h - 1);
}
for (int y = 1; y < h - 1; y++)
{
TryEnqueue(0, y);
TryEnqueue(w - 1, y);
}
// 이전 패스에서 투명화된 픽셀 옆의 불투명 픽셀도 시드로 추가
if (pass > 0)
{
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int idx = y * stride + x * 4;
if (pixels[idx + 3] >= 128) continue; // 불투명 → 스킵
// 이 픽셀은 투명 — 4방향 이웃 중 불투명한 것을 시드로
if (x > 0) TryEnqueue(x - 1, y);
if (x < w - 1) TryEnqueue(x + 1, y);
if (y > 0) TryEnqueue(x, y - 1);
if (y < h - 1) TryEnqueue(x, y + 1);
}
}
int removed = 0;
while (queue.Count > 0)
{
int pos = queue.Dequeue();
int px = pos % w, py = pos / w;
int idx = py * stride + px * 4;
pixels[idx + 3] = 0; // alpha → 0
removed++;
if (px > 0) TryEnqueue(px - 1, py);
if (px < w - 1) TryEnqueue(px + 1, py);
if (py > 0) TryEnqueue(px, py - 1);
if (py < h - 1) TryEnqueue(px, py + 1);
}
if (removed == 0) break; // 더 이상 제거할 것이 없음
// 다음 패스를 위해 새로 노출된 경계의 색상을 샘플링
bg = SampleExposedBorderColor(pixels, w, h, stride) ?? bg;
void TryEnqueue(int x, int y)
{
int pos = y * w + x;
if (visited[pos]) return;
visited[pos] = true;
int idx = y * stride + x * 4;
byte a = pixels[idx + 3];
if (a < 128) { queue.Enqueue(pos); return; } // 이미 투명
byte b = pixels[idx], g = pixels[idx + 1], r = pixels[idx + 2];
if (Math.Abs(r - bg.R) <= BgTolerance
&& Math.Abs(g - bg.G) <= BgTolerance
&& Math.Abs(b - bg.B) <= BgTolerance)
{
queue.Enqueue(pos);
}
}
}
}
/// <summary>투명 픽셀 경계에 접한 불투명 픽셀의 대표 색상을 구합니다.</summary>
private static (byte R, byte G, byte B)? SampleExposedBorderColor(byte[] pixels, int w, int h, int stride)
{
long rSum = 0, gSum = 0, bSum = 0;
int count = 0;
// 성능 최적화: 전체 스캔 대신 일정 간격으로 샘플링
int step = Math.Max(1, Math.Min(w, h) / 100);
for (int y = 0; y < h; y += step)
for (int x = 0; x < w; x += step)
{
int idx = y * stride + x * 4;
if (pixels[idx + 3] >= 128) continue; // 불투명 → 스킵 (투명 픽셀만 검사)
// 투명 픽셀의 4방향 이웃 중 불투명 것의 색상 수집
int[] dx = { -1, 1, 0, 0 }, dy = { 0, 0, -1, 1 };
for (int d = 0; d < 4; d++)
{
int nx = x + dx[d], ny = y + dy[d];
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
int ni = ny * stride + nx * 4;
if (pixels[ni + 3] < 128) continue;
rSum += pixels[ni + 2];
gSum += pixels[ni + 1];
bSum += pixels[ni];
count++;
}
}
if (count < 4) return null;
return ((byte)(rSum / count), (byte)(gSum / count), (byte)(bSum / count));
}
/// <summary>GIF 프레임의 딜레이 메타데이터를 읽습니다.</summary>
private static int GetFrameDelay(BitmapFrame frame)
{
try
{
var metadata = frame.Metadata as BitmapMetadata;
if (metadata != null)
{
var delay = metadata.GetQuery("/grctlext/Delay");
if (delay is ushort d) return d;
}
}
catch { }
return 10; // 기본 100ms
}
/// <summary>GIF 프레임의 disposal method를 읽습니다.</summary>
private static int GetDisposalMethod(BitmapFrame frame)
{
try
{
var metadata = frame.Metadata as BitmapMetadata;
if (metadata != null)
{
var disposal = metadata.GetQuery("/grctlext/Disposal");
if (disposal is byte d) return d;
}
}
catch { }
return 0;
}
/// <summary>표시 크기를 계산합니다 (종횡비 유지, 최대/최소 제한).</summary>
private static (double w, double h, bool nearestNeighbor) CalculateDisplaySize(int origW, int origH)
{
if (origW <= 0 || origH <= 0)
return (MascotMaxW, MascotMaxH, false);
double scale = Math.Min((double)MascotMaxW / origW, (double)MascotMaxH / origH);
double dispW = origW * scale;
double dispH = origH * scale;
// 너무 작은 원본(< 50px) → NearestNeighbor 스케일링
bool useNN = origW < 50 || origH < 50;
return (dispW, dispH, useNN);
}
// ═══════════════════════════════════════════════════════════
// 시작 · 정지 · 프레임 재생
// ═══════════════════════════════════════════════════════════
/// <summary>설정에서 마스코트 출동 수준을 반환합니다.</summary>
private string MascotLevel => (_settings?.Settings?.Llm?.Code?.MascotLevel ?? "none").Trim().ToLowerInvariant();
/// <summary>마스코트 출동 수준에 따른 최대 GIF 로드 수.</summary>
private int MascotMaxGifCount => MascotLevel switch
{
"one" => 1,
"few" => 3,
"mid" => 6,
"all" => int.MaxValue,
_ => 0, // "none"
};
/// <summary>설정에서 마스코트 캐릭터 표시 여부를 확인합니다.</summary>
private bool IsMascotEnabled => MascotLevel != "none";
private void StartMascotAnimation()
{
if (MascotCanvas == null) return;
// 마스코트는 코드 탭에서만 + 설정이 ON일 때만 표시
if (!string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)
|| !IsMascotEnabled)
{
MascotCanvas.Visibility = Visibility.Collapsed;
if (EmptyIcon != null) EmptyIcon.Visibility = Visibility.Visible;
return;
}
// 백그라운드 로딩 시작 (아직 안 했으면)
if (!_mascotImageLoaded && !_mascotLoading)
{
InitializeMascot();
// 로딩 완료 시 자동으로 StartMascotAnimation 재호출됨
MascotCanvas.Visibility = Visibility.Visible;
EmptyIcon.Visibility = Visibility.Collapsed;
return;
}
if (_gifAnimations.Count == 0)
{
// GIF 없음 또는 아직 로딩 중 — 기존 정적 이미지 폴백
MascotCanvas.Visibility = Visibility.Visible;
EmptyIcon.Visibility = Visibility.Collapsed;
return;
}
MascotCanvas.Visibility = Visibility.Visible;
EmptyIcon.Visibility = Visibility.Collapsed;
_mascotAreaW = EmptyState.ActualWidth > 0 ? EmptyState.ActualWidth : 800;
var areaH = EmptyState.ActualHeight > 0 ? EmptyState.ActualHeight : 400;
// 첫 GIF 시작
_currentGifIndex = 0;
StartGifPlayback(_gifAnimations[0]);
PositionMascot(areaH);
// 레이아웃 완료 후 위치 재보정 (EmptyState가 Visible 직후 ActualHeight가 0일 수 있음)
void onLayoutUpdated(object? s, EventArgs e)
{
EmptyState.LayoutUpdated -= onLayoutUpdated;
if (MascotCanvas.Visibility == Visibility.Visible && EmptyState.ActualHeight > 0)
PositionMascot(EmptyState.ActualHeight);
}
EmptyState.LayoutUpdated += onLayoutUpdated;
// 복수 GIF 교체 타이머
if (_gifAnimations.Count > 1)
{
if (_mascotCycleTimer == null)
{
_mascotCycleTimer = new DispatcherTimer(DispatcherPriority.Background);
_mascotCycleTimer.Tick += MascotCycleTimer_Tick;
}
_mascotCycleTimer.Interval = GetTotalGifDuration(_gifAnimations[0]);
_mascotCycleTimer.Start();
}
}
private void StopMascotAnimation()
{
_mascotCycleTimer?.Stop();
_gifFrameTimer?.Stop();
_activeGif = null;
if (MascotCanvas != null) MascotCanvas.Visibility = Visibility.Collapsed;
if (EmptyIcon != null) EmptyIcon.Visibility = Visibility.Visible;
if (MascotScale != null) { MascotScale.ScaleX = 1; MascotScale.ScaleY = 1; }
if (MascotRotate != null) MascotRotate.Angle = 0;
if (MascotTranslate != null) { MascotTranslate.X = 0; MascotTranslate.Y = 0; }
}
private void StartGifPlayback(GifAnimation gif)
{
_activeGif = gif;
_currentFrameIndex = 0;
MascotImage.Width = gif.DisplayW;
MascotImage.Height = gif.DisplayH;
MascotImage.Visibility = Visibility.Visible;
RenderOptions.SetBitmapScalingMode(MascotImage,
gif.UseNearestNeighbor ? BitmapScalingMode.NearestNeighbor : BitmapScalingMode.HighQuality);
if (gif.Frames.Length > 0)
MascotImage.Source = gif.Frames[0];
if (gif.Frames.Length > 1)
{
if (_gifFrameTimer == null)
{
_gifFrameTimer = new DispatcherTimer(DispatcherPriority.Render);
_gifFrameTimer.Tick += GifFrameTimer_Tick;
}
_gifFrameTimer.Interval = TimeSpan.FromMilliseconds(gif.DelaysMs[0]);
_gifFrameTimer.Start();
}
}
private void GifFrameTimer_Tick(object? sender, EventArgs e)
{
if (_activeGif == null || _activeGif.Frames.Length == 0) return;
_currentFrameIndex++;
// 마지막 프레임에 도달하면 해당 프레임을 표시하고 타이머 정지
// (마지막 모습을 유지한 채 cycle 타이머가 다음 GIF로 전환)
if (_currentFrameIndex >= _activeGif.Frames.Length)
{
_currentFrameIndex = _activeGif.Frames.Length - 1;
MascotImage.Source = _activeGif.Frames[_currentFrameIndex];
_gifFrameTimer?.Stop();
return;
}
MascotImage.Source = _activeGif.Frames[_currentFrameIndex];
if (_gifFrameTimer != null)
_gifFrameTimer.Interval = TimeSpan.FromMilliseconds(
_activeGif.DelaysMs[_currentFrameIndex]);
}
private void MascotCycleTimer_Tick(object? sender, EventArgs e)
{
if (_gifAnimations.Count <= 1) return;
_currentGifIndex = (_currentGifIndex + 1) % _gifAnimations.Count;
_gifFrameTimer?.Stop();
var nextGif = _gifAnimations[_currentGifIndex];
StartGifPlayback(nextGif);
if (_mascotCycleTimer != null)
_mascotCycleTimer.Interval = GetTotalGifDuration(nextGif);
if (EmptyState.ActualWidth > 0) _mascotAreaW = EmptyState.ActualWidth;
var areaH = EmptyState.ActualHeight > 0 ? EmptyState.ActualHeight : 400;
PositionMascot(areaH);
}
private void PositionMascot(double areaH)
{
if (_activeGif == null) return;
Canvas.SetLeft(MascotImage, 20);
// 채팅 입력창 바로 위에 위치 (하단 여백 최소화)
Canvas.SetTop(MascotImage, areaH - _activeGif.DisplayH);
}
private static TimeSpan GetTotalGifDuration(GifAnimation gif)
{
long totalMs = 0;
foreach (var d in gif.DelaysMs) totalMs += d;
// GIF 1회 재생 + 마지막 모습 유지 시간 (7~12초 랜덤)
var holdMs = Random.Shared.Next(7000, 12001);
var total = totalMs + holdMs;
return TimeSpan.FromMilliseconds(total);
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
@@ -49,9 +50,9 @@ public partial class ChatWindow
{
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(11, 7, 11, 7),
HorizontalAlignment = HorizontalAlignment.Right,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 10, 14, 10),
HorizontalAlignment = HorizontalAlignment.Stretch,
};
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
@@ -158,18 +159,30 @@ public partial class ChatWindow
ApplyMessageEntryAnimation(container);
var (agentName, _, _) = GetAgentIdentity();
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 1.5) };
header.Children.Add(CreateMiniLauncherIcon(pixelSize: 4.0));
var (iconHost, iconPixels, iconGlows, iconRotate, iconScale) = CreateMiniLauncherIconEx(4.0, "none");
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 4, 0, 0) };
header.Children.Add(iconHost);
header.Children.Add(new TextBlock
{
Text = agentName,
FontSize = 11.5,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
container.Children.Add(header);
// 아이콘 애니메이션 적용
var canvas = iconHost.Children.OfType<Canvas>().FirstOrDefault();
if (canvas != null)
{
var animState = new ChatIconAnimState
{
Host = iconHost, Canvas = canvas, Pixels = iconPixels,
Glows = iconGlows, Rotate = iconRotate, Scale = iconScale,
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
};
StartChatIconAnimation(animState);
}
var contentCard = new Border
{
@@ -297,6 +310,18 @@ public partial class ChatWindow
contentCard.Child = contentStack;
container.Children.Add(contentCard);
// 에이전트 이름 푸터 (메시지 본문 아래)
container.Children.Add(header);
// 어시스턴트 메시지에 파일 경로가 포함되어 있으면 프리뷰/열기 퀵 액션 추가
var outputFilePath = ExtractOutputFilePathFromContent(content);
if (!string.IsNullOrEmpty(outputFilePath) && System.IO.File.Exists(outputFilePath))
{
var quickActions = BuildFileQuickActions(outputFilePath);
quickActions.Margin = new Thickness(2, 4, 0, 2);
container.Children.Add(quickActions);
}
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
@@ -345,4 +370,29 @@ public partial class ChatWindow
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container;
AddTranscriptElement(container);
}
/// <summary>
/// 어시스턴트 메시지 텍스트에서 출력 파일 경로(절대 경로)를 추출합니다.
/// "완료: C:\...\file.ext" 패턴을 우선 찾고, 없으면 일반 절대 경로를 검색합니다.
/// </summary>
private static string? ExtractOutputFilePathFromContent(string content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
// 패턴 1: "완료: C:\path\file.ext" 또는 "완료: E:\path\file.ext"
var completionMatch = System.Text.RegularExpressions.Regex.Match(
content,
@"완료:\s*([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
if (completionMatch.Success)
return completionMatch.Groups[1].Value.TrimEnd('.');
// 패턴 2: 임의의 절대 경로 (알려진 문서 확장자)
var absMatch = System.Text.RegularExpressions.Regex.Match(
content,
@"([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
if (absMatch.Success)
return absMatch.Groups[1].Value.TrimEnd('.');
return null;
}
}

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