Compare commits
113 Commits
d0fa54f10e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c8b550242 | |||
| f34878cbd5 | |||
| b45ed524e1 | |||
| 6ca067c4a6 | |||
| fbaaf19391 | |||
| a686d822e7 | |||
| f8baea24f5 | |||
| 23b2352637 | |||
| 8617f66496 | |||
| f44b8b7dea | |||
| 4e6d5d0597 | |||
| a35c47ed32 | |||
| fe843fb314 | |||
| 594bb6ffe6 | |||
| aef5f51c89 | |||
| 0bfec6fb78 | |||
| 4e1dcf082c | |||
| 917e61af20 | |||
| 7093c77849 | |||
| 2e0362a88f | |||
| 18551a0aea | |||
| ae765fb543 | |||
| 80682552f4 | |||
| 13cd1e54ed | |||
| c75790f8c2 | |||
| dc2574bb17 | |||
| 45dfa70951 | |||
| 30e8218ba4 | |||
| 306245524d | |||
| 4992dca74f | |||
| f48e598cc1 | |||
| 36c04ccc07 | |||
| 339dc6c06b | |||
| 1636b9c26f | |||
| d9309b45fa | |||
| a19f69b2ff | |||
| 43ee9154a8 | |||
| bbb5c526b0 | |||
| 43dd6b5d71 | |||
| c389c6ff3f | |||
| 1f52bc1cc3 | |||
| cc12177252 | |||
| 4df5d5d874 | |||
| e4e3e49419 | |||
| 5bf323d4bf | |||
| 94dc325df4 | |||
| 606ecbe6cd | |||
| e439fd144e | |||
| 7889189e41 | |||
| 353c5ce471 | |||
| ccb39f0fe0 | |||
| 9877d347b1 | |||
| c2e96c0286 | |||
| 17d7b515ce | |||
| fd3af15e54 | |||
| 3961dc8ca2 | |||
| 98bc4ff24f | |||
| a5b511c38b | |||
| e8cd68cce7 | |||
| 1ad75b5896 | |||
| 8da0a069b7 | |||
| 2ae56b2510 | |||
| 71fd5f0bb7 | |||
| b3b301b9b6 | |||
| d45698d397 | |||
| 421a2c97f9 | |||
| 9d13456695 | |||
| d283cf26ba | |||
| 5401fcf7bb | |||
| 7c5396e239 | |||
| 817fc94f41 | |||
| 3feb1f0be4 | |||
| c4d050f2bf | |||
| a46b4bf9c0 | |||
| ec0ed7fb1c | |||
| 571d4bfaca | |||
| fdf95aa6ec | |||
| 7fd4d1ae5a | |||
| e747032501 | |||
| f11b8b74b7 | |||
| b4d69f5db3 | |||
| 3c3faab528 | |||
| 9aa99cdfe6 | |||
| 3ac8a7155f | |||
| 3e44f1fc4d | |||
| fa431f1666 | |||
| fda5c6bace | |||
| b3b5f8a79d | |||
| 8faa26b134 | |||
| 1b4566d192 | |||
| d0d66c1d52 | |||
| 9464dd0234 | |||
| d5c1266d3e | |||
| 3924bac9f9 | |||
| 9f5a9d315c | |||
| 4c1513a5da | |||
| ccaa24745e | |||
| 1ce6ccb030 | |||
| 2b21e8cdfb | |||
| 35ec073eb9 | |||
| 68524c1c94 | |||
| b4a506de96 | |||
| 82b42b3ba3 | |||
| 90bd77f945 | |||
| 95e40df354 | |||
| f9d18fba08 | |||
| f0af86cc1e | |||
| 13f0e23ed5 | |||
| 7cb27b70f8 | |||
| 61f82bdd10 | |||
| fa349c2057 | |||
| be7328184a | |||
| 905ea41ed3 |
375
README.md
375
README.md
@@ -7,6 +7,114 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
|
|||||||
개발 참고: Claw Code 동등성 작업 추적 문서
|
개발 참고: Claw Code 동등성 작업 추적 문서
|
||||||
`docs/claw-code-parity-plan.md`
|
`docs/claw-code-parity-plan.md`
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-07 09:19 (KST)
|
||||||
|
- AX Agent 하단 상태바의 전체 토큰 집계가 유휴 전환 후 사라지지 않도록 conversation aggregate 복원 경로를 추가했습니다. 실행 중 누적 토큰이 0이어도 현재 대화 전체의 prompt/completion 합계를 다시 계산해 상태바에 유지합니다.
|
||||||
|
- 컨텍스트 사용량 팝업이 마지막 실제 압축 결과와 맞지 않던 문제를 수정했습니다. 이제 최근 압축의 실제 before/after 토큰과 자동/수동 여부, 누적 압축 횟수와 절감 토큰을 기준으로 표시합니다.
|
||||||
|
- `전체 통계(total_stats)` 이벤트가 일반 진행 줄에 흡수되던 문제를 수정했습니다. 다시 전용 통계 카드/배너 경로로 노출되어 하단 전체 집계와 transcript 요약이 서로 더 일치합니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-07 02:23 (KST)
|
||||||
|
- AX Agent 직접 대화(Chat 탭) 경로에 실제 스트리밍 응답 연결을 복구했습니다. LLM 서비스는 원래 SSE/스트리밍을 지원하고 있었지만 UI 실행 경로가 최종 문자열만 받아 한 번에 붙이던 상태였고, 이제 설정상 스트리밍이 켜져 있으면 채팅 응답이 타자 치듯 점진적으로 표시됩니다.
|
||||||
|
- Cowork/Code는 기존처럼 agent loop 진행 메시지 중심을 유지하고, 직접 대화 재생성은 같은 스트리밍 경로를 공유하도록 정리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-07 02:31 (KST)
|
||||||
|
- Cowork/Code도 에이전트 루프가 끝난 뒤 최종 답변을 한 번에 붙이지 않고, 짧은 라이브 타이핑 프리뷰를 거친 다음 정식 메시지로 확정되도록 보강했습니다. 처리 중에는 진행 로그를 유지하고, 최종 응답 구간만 자연스럽게 이어지도록 정리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-07 02:03 (KST)
|
||||||
|
- Cowork 진행 표시가 오래 비거나 완료 후 실패처럼 깜박이던 흐름을 정리했습니다. 진행 힌트는 작업 시작 직후부터 즉시 보이게 하고, 중간 이벤트가 들어와도 불필요하게 사라지지 않도록 유지 로직을 조정했습니다.
|
||||||
|
- 프리셋/입력창/진행 카드에 남아 있던 깨진 한글을 복구했고, 내부 실행 게이트 문구도 정상 한국어로 정리해 Cowork 루프가 잘못된 안내 문구에 끌리지 않도록 보강했습니다.
|
||||||
|
- 중단 문구는 더 이상 무조건 사용자 취소처럼 보이지 않도록 `작업이 중단되었습니다` 기준으로 통일했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 20:05 (KST)
|
||||||
|
- AX Agent 입력 영역의 토큰 사용량 원형 카드와 전송 버튼 테두리를 더 깔끔하게 정리했습니다. 토큰 카드는 내부 원 두께와 여백을 줄여 덜 답답하게 보이게 했고, 전송 버튼은 불필요한 외곽 테두리를 제거해 더 매끈한 원형 버튼처럼 보이도록 조정했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 19:56 (KST)
|
||||||
|
- AX Agent 상단 탭의 세로 정렬을 미세 조정했습니다. 탭 버튼의 콘텐츠 정렬을 중앙 기준으로 다시 맞추고, 내부 패딩과 높이를 손봐 위아래 여백이 더 균형 있게 보이도록 보정했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 19:46 (KST)
|
||||||
|
- AX Agent 테마 프리셋에서 `Claw` 표기를 `Claude`로 바꿨고, 기존 저장값 `claw`는 자동으로 `claude`로 읽히도록 하위 호환을 유지했습니다.
|
||||||
|
- `Claude` 다크/라이트 팔레트는 실제 앱 톤에 더 가깝게, `Codex` 다크는 제공된 레퍼런스 화면처럼 중성 회갈색 계열로, `Nord`는 공식 팔레트 기준으로 다시 맞췄습니다. `Slate`도 더 표준적인 슬레이트 계열 명암으로 정리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 19:33 (KST)
|
||||||
|
- AX Agent 라이트 테마 계층을 다시 정리했습니다. `Codex/Claw` 라이트 팔레트의 배경, 선택 배경, 호버, 경계선, 보조 텍스트 톤을 더 따뜻하게 보정해 사이드바와 본문 위계가 레퍼런스처럼 자연스럽게 느껴지도록 조정했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 19:24 (KST)
|
||||||
|
- 런처 색인 진행 상태를 런처 하단 완료 문구 대신 설정창 `인덱싱 속도` 아래에서 확인할 수 있게 바꿨습니다. 전체 색인 중에는 진행률 바와 상태/남은 시간 문구가 보이고, 완료 후에는 최근 색인 결과가 같은 위치에 남습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 15:31 (KST)
|
||||||
|
- 코워크/코드 하단 우측의 권한 요청 버튼은 footer 작업 바와 더 자연스럽게 이어지도록 외곽 테두리를 제거해 플랫한 형태로 정리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 18:46 (KST)
|
||||||
|
- 런처 색인에서 `.git`, `node_modules`, `bin`, `obj`, `dist`, `packages`, `venv`, `__pycache__`, `target` 같은 무거운 폴더를 스캔/감시 대상에서 제외해 유휴 CPU와 재색인 부담을 줄였습니다.
|
||||||
|
- 런처 무지개 글로우 갱신 주기를 낮추고, AX Agent는 창 크기 변화 시 메시지 전체 재렌더를 짧게 묶어서 한 번만 반영하도록 바꿔 창 조작 시 버벅이는 느낌을 줄였습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 19:02 (KST)
|
||||||
|
- 설정-테마의 글로우 관련 항목이 저장만 되고 열린 창에 바로 반영되지 않던 문제를 수정했습니다. 런처는 설정 변경 이벤트를 직접 받아 테두리/아이콘 애니메이션/무지개 글로우/선택 글로우를 즉시 다시 적용하고, AX Agent도 스트리밍 중 입력창 글로우 설정을 바로 반영합니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 19:11 (KST)
|
||||||
|
- 런처 색인 상태 문구가 오래 남던 문제를 수정했습니다. 파일 watcher의 증분 갱신은 더 이상 `색인 완료` 이벤트를 다시 올리지 않고, 실제 전체 색인 완료 때만 런처 상태 문구가 갱신되도록 분리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 15:26 (KST)
|
||||||
|
- AX Agent 채팅/코워크/코드 하단 안내 문구를 현재 구현 기준으로 다시 정리했습니다. 입력창 워터마크는 탭 종류와 작업 폴더 선택 여부에 따라 실제 가능한 작업을 더 정확히 안내합니다.
|
||||||
|
- 선택된 프리셋 안내도 placeholder 문구 대신 실제 설명 중심으로 정리해, 프리셋 설명 카드와 입력창 워터마크가 서로 다른 역할로 보이도록 맞췄습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 15:18 (KST)
|
||||||
|
- 코워크/코드 하단 폴더 바에서 권한 선택 버튼 왼쪽에 보이던 세로 구분선(`|`)을 제거해 footer 작업 바가 더 단순하게 이어지도록 정리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 15:12 (KST)
|
||||||
|
- AX Agent 채팅/코워크/코드 공통 입력창 우측 상단의 `프리셋` 선택 버튼을 제거해 composer 상단을 더 단순하게 정리했습니다.
|
||||||
|
- 파일 첨부 버튼을 기존보다 약 1.5배 키우고, 전송 버튼도 같은 기준의 정사각 크기로 맞췄습니다. 두 버튼의 아이콘은 모두 상하좌우 중앙 정렬로 보정했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 15:04 (KST)
|
||||||
|
- AX Agent 사용자 메시지 하단 메타 행 레이아웃을 정리해, 시간 표시와 복사/편집 액션 버튼이 같은 위치에 겹쳐 보이던 문제를 수정했습니다.
|
||||||
|
- 사용자 메시지 하단 바는 이제 전용 컬럼을 나눠 시간과 액션이 항상 분리되어 보이며, hover 상태와 무관하게 메타 행이 안정적으로 유지됩니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 09:27 (KST)
|
||||||
|
- `claw-code`와 AX Agent를 다시 대조해 남은 품질 향상 작업을 3트랙으로 재정리했습니다. 앞으로 계획은 `사용자 체감 UI/UX`, `LLM·작업 처리`, `유지보수·추가기능 구조`로 분리해 관리합니다.
|
||||||
|
- `docs/claw-code-parity-plan.md`에 각 트랙별 참조 파일, AX 적용 위치, 완료 조건, 품질 판정 기준, 권장 실행 순서를 고정했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 09:14 (KST)
|
||||||
|
- AX Agent 워크트리 선택 팝업과 공통 선택 row 렌더를 `ChatWindow.SelectionPopupPresentation.cs`로 분리했습니다. 작업 위치/워크트리 전환 메뉴와 선택 상태 row 조립이 메인 창 코드 밖으로 이동해 footer 선택 UX를 별도 파일에서 정리할 수 있게 됐습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 대화 상태와 세션 orchestration 쪽에 더 집중하도록 정리했고, 향후 브랜치/워크트리/선택형 팝업 UX를 `claw-code` 기준으로 계속 다듬기 쉬운 구조를 만들었습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 09:03 (KST)
|
||||||
|
- AX Agent 공통 선택 팝업 조립 로직을 `ChatWindow.PopupPresentation.cs`로 분리했습니다. 테마 팝업 컨테이너, 공통 메뉴 아이템, 구분선, 최근 폴더 우클릭 컨텍스트 메뉴가 메인 창 코드 밖으로 이동해 footer/file-browser 쪽 팝업 품질 작업을 이어가기 쉬운 구조로 정리했습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 대화 상태와 런타임 orchestration 쪽에 더 집중하도록 정리했고, 공통 팝업 시각 언어를 한 곳에서 다듬을 수 있는 기반을 만들었습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 08:55 (KST)
|
||||||
|
- AX Agent 파일 브라우저 렌더를 `ChatWindow.FileBrowserPresentation.cs`로 분리했습니다. 파일 탐색기 열기/닫기, 폴더 트리 구성, 파일 헤더/아이콘/크기 표시, 우클릭 메뉴, 디바운스 새로고침 흐름이 메인 창 코드 밖으로 이동했습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 transcript·runtime orchestration 중심으로 더 정리됐고, claw-code 기준 사이드 surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 08:47 (KST)
|
||||||
|
- AX Agent 우측 프리뷰 패널 렌더를 `ChatWindow.PreviewPresentation.cs`로 분리했습니다. 프리뷰 탭 목록, 헤더, 파일 로드, CSV/텍스트/마크다운/HTML 표시, 숨김/열기, 우클릭 메뉴, 별도 창 미리보기 흐름이 메인 창 코드 밖으로 이동했습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 transcript 및 런타임 orchestration 중심으로 더 정리됐고, claw-code 기준 preview surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 08:39 (KST)
|
||||||
|
- AX Agent 하단 상태바 이벤트 처리와 회전 애니메이션을 `ChatWindow.StatusPresentation.cs`로 옮겼습니다. `UpdateStatusBar`, `StartStatusAnimation`, `StopStatusAnimation`이 상태 표현 파일로 이동해 메인 창 코드의 runtime/status 분기가 더 줄었습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 대화 실행 orchestration 중심으로 더 정리됐고, claw-code 기준 status line 정교화와 footer presentation 개선을 계속 이어가기 쉬운 구조로 맞췄습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 08:27 (KST)
|
||||||
|
- AX Agent 메시지 액션/메타/편집 렌더를 `ChatWindow.MessageInteractions.cs`로 분리했습니다. 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름이 메인 창 코드 밖으로 이동했습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리했고, claw-code 기준 메시지 타입 분리와 renderer 구조화를 계속 진행하기 쉬운 기반을 만들었습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 08:28 (KST)
|
||||||
|
- AX Agent 하단 composer와 대기열 UI 렌더를 `ChatWindow.ComposerQueuePresentation.cs`로 분리했습니다. 입력창 높이 계산, draft kind 해석, 후속 요청 큐 카드/요약 pill/배지/액션 버튼 생성 책임을 메인 창 코드에서 떼어냈습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 대기열 실행 orchestration과 세션 변경 흐름만 더 선명하게 남겨, claw-code 기준 입력부/queued command UX 개선을 계속하기 쉬운 구조로 정리했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 08:12 (KST)
|
||||||
|
- AX Agent 하단 작업 바 관련 presentation 메서드를 메인 창 코드에서 더 분리했습니다. `ChatWindow.FooterPresentation.cs`를 추가해 폴더 바, 선택된 프리셋 안내, Git 브랜치 버튼/팝업 렌더, 요약 pill 생성 책임을 별도 partial로 옮겼습니다.
|
||||||
|
- `ChatWindow.xaml.cs`는 대화 흐름과 런타임 orchestration 중심으로 더 정리했고, claw-code 기준으로 footer/preset/Git popup 품질 작업을 계속 이어가기 쉬운 구조를 만들었습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 01:37 (KST)
|
||||||
|
- AX Agent의 계획 승인 흐름을 더 transcript 우선 구조로 정리했습니다. 인라인 승인 카드가 기본 경로를 맡고, `계획` 버튼은 저장된 계획 요약/단계를 여는 상세 보기 역할만 하도록 분리했습니다.
|
||||||
|
- 상태선과 quick strip 계산도 presentation state로 더 모았습니다. runtime badge, compact strip, quick strip의 텍스트/강조색/노출 여부를 한 번에 계산해 창 코드의 직접 분기를 줄였습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 07:31 (KST)
|
||||||
|
- `ChatWindow.xaml.cs`에 몰려 있던 권한 팝업 렌더와 컨텍스트 사용량 카드 렌더를 별도 partial 파일로 분리했습니다. `ChatWindow.PermissionPresentation.cs`, `ChatWindow.ContextUsagePresentation.cs`를 추가해 권한 선택/권한 상태 배너/컨텍스트 사용량 hover 카드 책임을 메인 창 orchestration 코드에서 떼어냈습니다.
|
||||||
|
- 다음 단계에서 `permission / tool-result / footer` presentation catalog를 더 세밀하게 확장하기 쉽게 구조를 정리했고, 동작은 그대로 유지한 채 transcript/푸터 품질 개선 발판을 마련했습니다.
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-06 00:50 (KST)
|
||||||
|
- 채팅/코워크 프리셋 카드의 hover 설명 레이어를 카드 내부 오버레이 방식에서 안정적인 tooltip형 설명으로 바꿨습니다. 카드 배경/테두리만 반응하게 정리해 hover 시 반복 깜빡임을 줄였습니다.
|
||||||
|
|
||||||
- 업데이트: 2026-04-06 00:45 (KST)
|
- 업데이트: 2026-04-06 00:45 (KST)
|
||||||
- AX Agent 내부 설정 공통 탭에 `대화 스타일` 섹션 제목을 복구해, `문서 형태`와 `디자인 스타일` 저장 항목이 명확히 보이도록 정리했습니다.
|
- AX Agent 내부 설정 공통 탭에 `대화 스타일` 섹션 제목을 복구해, `문서 형태`와 `디자인 스타일` 저장 항목이 명확히 보이도록 정리했습니다.
|
||||||
- AX Agent 내부 설정 스킬 탭의 `MCP 서버` 영역에 `서버 추가` 버튼을 복원했고, 목록 카드 안에서 `활성화/비활성화`와 `삭제`까지 바로 관리할 수 있게 옮겼습니다.
|
- AX Agent 내부 설정 스킬 탭의 `MCP 서버` 영역에 `서버 추가` 버튼을 복원했고, 목록 카드 안에서 `활성화/비활성화`와 `삭제`까지 바로 관리할 수 있게 옮겼습니다.
|
||||||
@@ -1094,3 +1202,270 @@ MIT License
|
|||||||
- `claw-code`의 `SessionPreview`/`PreviewBox` 흐름을 참고해 AX Agent 프리뷰도 같은 시각 언어로 정리했다. 새 파일 [AgentPreviewSurfaceFactory.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentPreviewSurfaceFactory.cs)를 추가해 권한 프리뷰 카드의 제목/요약/본문 박스 구조를 공통화했다.
|
- `claw-code`의 `SessionPreview`/`PreviewBox` 흐름을 참고해 AX Agent 프리뷰도 같은 시각 언어로 정리했다. 새 파일 [AgentPreviewSurfaceFactory.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentPreviewSurfaceFactory.cs)를 추가해 권한 프리뷰 카드의 제목/요약/본문 박스 구조를 공통화했다.
|
||||||
- [PermissionRequestWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PermissionRequestWindow.cs)의 일반 프리뷰, 파일 편집 프리뷰, 파일 생성 2열 프리뷰를 이 공통 surface로 맞춰 `preview box` 언어를 통일했다.
|
- [PermissionRequestWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PermissionRequestWindow.cs)의 일반 프리뷰, 파일 편집 프리뷰, 파일 생성 2열 프리뷰를 이 공통 surface로 맞춰 `preview box` 언어를 통일했다.
|
||||||
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 우측 파일 프리뷰 패널에는 파일명/경로/형식·크기 메타를 보여주는 헤더를 추가하고, 텍스트 프리뷰 본문도 별도 bordered preview box 안에 렌더되게 바꿨다.
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 우측 파일 프리뷰 패널에는 파일명/경로/형식·크기 메타를 보여주는 헤더를 추가하고, 텍스트 프리뷰 본문도 별도 bordered preview box 안에 렌더되게 바꿨다.
|
||||||
|
- 업데이트: 2026-04-06 00:35 (KST)
|
||||||
|
- AX Agent 채팅/코워크 프리셋 카드에서 기본 ToolTip을 제거해 hover 시 깜빡이듯 반복되던 현상을 줄였습니다.
|
||||||
|
- 업데이트: 2026-04-06 00:42 (KST)
|
||||||
|
- 코드 탭 하단 Git 브랜치 버튼을 상태판 형태에서 단순한 브랜치 선택 버튼 형태로 정리했습니다.
|
||||||
|
- 업데이트: 2026-04-05 22:26 (KST)
|
||||||
|
- 코드 탭에서는 폴더 문서/파일을 기본 작업 전제로 삼도록 `폴더 내 데이터 활용`을 항상 `적극 활용(active)`으로 강제했다. 하단 채팅창의 데이터 활용 버튼은 코드 탭에서 숨기고, 내부 설정 오버레이의 같은 옵션도 코드 탭에서는 노출하지 않게 정리했다.
|
||||||
|
- 코워크/코드 탭의 사용자 메시지도 assistant 메시지와 같은 파일 경로 강조 렌더러를 쓰도록 바꿔, 폴더 하위 파일명이나 경로를 입력하면 채팅 본문에서 파란색으로 인식되게 맞췄다.
|
||||||
|
- 업데이트: 2026-04-05 22:29 (KST)
|
||||||
|
- AX Agent 채팅/코워크 프리셋을 선택할 때, 메시지도 입력도 없는 fresh conversation인데도 `새 대화`가 반복 생성되던 흐름을 보정했다. 이제 현재 대화가 이미 있으면 그 빈 대화에 프리셋만 적용하고, 실제 대화가 아예 없는 경우에만 새 대화를 만든다.
|
||||||
|
- 업데이트: 2026-04-05 22:32 (KST)
|
||||||
|
- AX Agent 내부 설정 개발자 탭의 `워크플로우 시각화`, `전체 호출·토큰 합계 표시`, `감사 로그` 토글이 누르자마자 꺼지는 문제를 수정했다. 각 토글의 변경 이벤트를 연결해 즉시 저장되도록 보정했다.
|
||||||
|
- 업데이트: 2026-04-05 22:36 (KST)
|
||||||
|
- AX Agent 내부 설정 `도구 훅 실행 타임아웃`과 `등록된 훅` 영역에서 잘림이 보이던 레이아웃을 보정했다. 슬라이더/값 배지 컬럼 폭과 `훅 추가` 버튼 최소 폭을 넉넉히 늘려 텍스트와 컨트롤이 서로 밀리지 않게 정리했다.
|
||||||
|
- 업데이트: 2026-04-05 22:40 (KST)
|
||||||
|
- AX Agent 테마를 다시 점검해 기존 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 2종을 추가했다. `Nord`는 차분한 블루그레이 업무형 톤, `Ember`는 따뜻한 앰버 문서 작업 톤으로 구성했다.
|
||||||
|
- 내부 설정 `테마 스타일` 카드에서도 새 프리셋을 바로 선택할 수 있게 연결했고, `system / light / dark` 모드 조합으로 같은 방식으로 적용되도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 00:58 (KST)
|
||||||
|
- AX Agent transcript 품질 향상을 위해 렌더 책임을 실제로 분리했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)를 추가해 `의견 요청`, `계획 승인`, `작업 요약` UI 로직을 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에서 분리했다.
|
||||||
|
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)를 추가해 권한 요청과 도구 결과를 `명령/네트워크/파일`, `성공/실패/거부/취소` 기준으로 나눠 transcript badge에 재사용하도록 정리했다.
|
||||||
|
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState`와 `GetOperationalStatusPresentation(...)`을 추가해 status/runtime summary 계산을 전용 요약 모델로 한 번 더 계층화했다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 상태선 갱신은 이제 이 presentation summary를 소비한다.
|
||||||
|
- 업데이트: 2026-04-06 01:12 (KST)
|
||||||
|
- AX Agent 코워크/코드의 `폴더 내 문서 활용`을 사용자 옵션에서 제거했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml) 에서 하단 버튼, 내부 설정 행, 구형 설정창 항목을 걷어냈다.
|
||||||
|
- 런타임은 옵션이 아닌 자동 정책으로 유지한다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 채팅은 `none`, 코워크는 `passive`, 코드는 `active`를 자동 적용하고, 더 이상 오버레이 저장 시 `FolderDataUsage`를 사용자 선택값으로 저장하지 않는다.
|
||||||
|
- 업데이트: 2026-04-06 01:24 (KST)
|
||||||
|
- `claw-code` 기준 transcript 품질 향상을 위해 권한 요청/도구 결과/도구 이름 display catalog를 다시 정리했다. [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)는 파일/문서/빌드/Git/웹/스킬/질문 카테고리를 더 명확한 한국어 display name과 badge label로 분류하고, [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령 실행 / 웹 요청 / 스킬 실행 / 의견 요청 / 파일 수정 / 파일 접근` 권한 요청을 타입별 presentation으로 나누도록 보강했다.
|
||||||
|
- [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success / error / reject / cancel`을 도구 종류에 따라 `파일 작업 완료`, `빌드/테스트 실패`, `웹 요청 거부`처럼 더 읽기 쉬운 결과 라벨로 바꾸도록 확장했다.
|
||||||
|
- transcript renderer 분리 2차로 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)를 추가해 `CreateCompactEventPill`, `AddAgentEventBanner`, `GetDecisionBadgeMeta`를 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 옮겼다. 이제 메인 파일은 대화 흐름과 상태 처리에 더 집중하고, 이벤트 배너 렌더는 별도 partial에서 관리한다.
|
||||||
|
- 업데이트: 2026-04-06 09:36 (KST)
|
||||||
|
- [OperationalStatusPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/OperationalStatusPresentationCatalog.cs)를 추가해 compact strip/quick strip의 색상, 노출 조건, 빠른 상태 배지 문구 계산을 전용 카탈로그로 분리했다. [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)의 `GetOperationalStatusPresentation(...)`은 이제 상태 집계 후 카탈로그 결과만 반환한다.
|
||||||
|
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)에 `Kind`, `Description` 메타를 추가했다. [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)는 이제 이벤트 요약이 비어 있을 때 이 설명을 transcript fallback으로 사용한다.
|
||||||
|
- [PermissionModePresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs) 에서 제거된 계획 모드 잔재를 걷어내고, [ChatWindow.PermissionPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs)의 권한 선택 UI와 상단 배너도 `권한 요청 / 편집 자동 승인 / 권한 건너뛰기 / 읽기 전용`만 다루도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 09:44 (KST)
|
||||||
|
- inline interaction renderer를 `의견 요청`과 `계획 승인`으로 다시 분리했다. [ChatWindow.UserAskPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs)에 사용자 질문 카드 렌더를, [ChatWindow.PlanApprovalPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PlanApprovalPresentation.cs)에 계획 승인/상세창 연동 흐름을 옮겨 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 메시지 타입 책임을 더 줄였다.
|
||||||
|
- 이번 단계까지 완료된 계획 항목은 `상태선 카탈로그화`, `권한/도구 결과 카탈로그 정교화`, `권한 UI 정리`, `의견 요청/계획 승인 renderer 분리`다. 남은 큰 축은 `footer/composer를 더 작업 바 중심으로 정리`와 `회귀 프롬프트 세트의 개발 루틴 고정`이다.
|
||||||
|
- 업데이트: 2026-04-06 09:58 (KST)
|
||||||
|
- footer/composer 구조 개선의 다음 단계로 Git 브랜치 팝업과 footer 요약 helper를 [ChatWindow.GitBranchPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.GitBranchPresentation.cs) 로 분리했다. [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)는 이제 폴더 바 상태와 선택된 프리셋 안내처럼 footer의 현재 상태 동기화 책임만 남긴다.
|
||||||
|
- [AX_AGENT_REGRESSION_PROMPTS.md](/E:/AX%20Copilot%20-%20Codex/docs/AX_AGENT_REGRESSION_PROMPTS.md)를 개발 루틴 문서로 강화했다. Chat/Cowork/Code 공통 프롬프트 세트에 `blank-reply`, `duplicate-banner`, `bad-approval-flow`, `queue-drift`, `restore-drift`, `status-noise` 실패 분류를 붙여, runtime/transcript 변경 뒤 어떤 묶음을 확인해야 하는지 바로 쓸 수 있게 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 10:07 (KST)
|
||||||
|
- 프리셋 카드와 주제 선택 흐름을 [ChatWindow.TopicPresetPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs) 로 분리했다. `BuildTopicButtons`, `ShowCustomPresetDialog`, `ShowCustomPresetContextMenu`, `SelectTopic`이 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 프리셋 UI와 대화 orchestration의 책임 경계가 더 분명해졌다.
|
||||||
|
- 업데이트: 2026-04-06 10:18 (KST)
|
||||||
|
- 좌측 대화 목록 렌더를 [ChatWindow.ConversationListPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs) 로 분리했다. `RefreshConversationList`, `RenderConversationList`, `AddLoadMoreButton`, `BuildConversationSpotlightItems`, `AddGroupHeader`, `AddConversationItem`이 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 메인 창은 transcript/runtime orchestration에 더 집중하고 목록 UI는 별도 presentation surface에서 관리되게 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 10:27 (KST)
|
||||||
|
- transcript 메시지 row 조립을 [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs) 로 분리했다. `AddMessageBubble(...)`가 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 사용자/assistant bubble, 분기 컨텍스트 카드, 액션 바와 메타 row 조립이 별도 presentation surface에서 관리되게 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 10:36 (KST)
|
||||||
|
- timeline 조립 helper를 [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs) 로 분리했다. `RenderMessages()`가 직접 처리하던 visible 메시지 필터링, execution event 노출 집계, timestamp/order 기반 timeline action 조립을 helper 메서드로 옮겨 메인 렌더 루프를 더 단순화했다.
|
||||||
|
- 업데이트: 2026-04-06 10:44 (KST)
|
||||||
|
- timeline presentation 정리를 이어서 진행했다. [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs) 에 `CreateTimelineLoadMoreCard`, `ToAgentEvent`, `IsCompactionMetaMessage`, `CreateCompactionMetaCard`까지 옮겨 `RenderMessages()` 주변의 timeline helper를 한 파일로 모았다.
|
||||||
|
- 업데이트: 2026-04-06 10:56 (KST)
|
||||||
|
- 대화 목록 관리 interaction을 [ChatWindow.ConversationManagementPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs) 로 분리했다. 제목 인라인 편집 `EnterTitleEditMode(...)` 와 대화 메뉴 `ShowConversationMenu(...)`가 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 고정/이름 변경/카테고리 변경/삭제 같은 목록 관리 UI 책임도 별도 presentation surface에서 다루게 정리했다.
|
||||||
|
- 이 단계까지 완료된 구조 개선은 상태선/권한/도구 결과 카탈로그화, inline ask/plan 분리, footer/Git/preset/list/message/timeline 분리, 그리고 conversation management 분리까지다. 이제 남은 건 큰 구조 개선이 아니라 개별 surface polish와 후속 UX 고도화 수준이다.
|
||||||
|
- 업데이트: 2026-04-06 11:03 (KST)
|
||||||
|
- 좌측 sidebar의 검색/새 대화 interaction을 [ChatWindow.SidebarInteractionPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SidebarInteractionPresentation.cs) 로 분리했다. 검색 트리거 hover, 새 대화 hover, 검색 열기/닫기 애니메이션이 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해, 메인 창은 runtime/transcript orchestration에 더 집중하고 sidebar UX는 별도 presentation surface에서 다루게 정리했다.
|
||||||
|
- 큰 구조 개선 계획 기준으로는 이제 sidebar interaction까지 분리 완료 상태이며, 이후 남는 작업은 공통 시각 언어 polish나 실제 사용 흐름 기반 미세 UX 튜닝 같은 후속 개선 영역이다.
|
||||||
|
- 업데이트: 2026-04-06 11:11 (KST)
|
||||||
|
- 좌측 대화 목록의 필터/정렬 interaction을 [ChatWindow.ConversationFilterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ConversationFilterPresentation.cs) 로 분리했다. 실행 중 보기, 최근/활동 정렬, 대화 목록 선호 저장/복원, 관련 버튼 UI 상태 갱신이 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 이동해 sidebar 상태 표현 책임이 더 응집도 있게 정리됐다.
|
||||||
|
- 이 단계까지 누적 완료된 구조 개선은 상태선/권한/도구 결과 카탈로그화, inline ask/plan 분리, footer/Git/preset/list/message/timeline/conversation management/sidebar interaction/filter 분리까지다. 이제 남는 건 큰 분리가 아니라 실제 시나리오 기반 polish와 공통 시각 언어 고도화다.
|
||||||
|
- 업데이트: 2026-04-06 11:20 (KST)
|
||||||
|
- preview/file browser의 popup과 row 스타일을 공통 surface helper로 통일했다. [ChatWindow.SurfaceVisualPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SurfaceVisualPresentation.cs)를 추가해 popup container, popup menu item, separator, file tree header를 공통 helper로 만들고, [ChatWindow.PreviewPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs) 와 [ChatWindow.FileBrowserPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FileBrowserPresentation.cs) 가 같은 surface 언어를 쓰도록 맞췄다.
|
||||||
|
- 이 단계는 큰 구조 분리 이후의 visual language polish 1차로, preview와 file browser가 서로 다른 위젯처럼 보이던 차이를 줄이고 이후 공통 popup/surface 확장을 쉽게 하는 기반을 마련했다.
|
||||||
|
- 업데이트: 2026-04-06 11:27 (KST)
|
||||||
|
- popup 계열 visual language 통일을 이어서 진행했다. [ChatWindow.PopupPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PopupPresentation.cs)의 공통 popup factory와 menu item 생성이 surface helper를 사용하도록 바뀌었고, [ChatWindow.SelectionPopupPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SelectionPopupPresentation.cs), [ChatWindow.PermissionPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs) 도 선택 row의 border/hover/background 규칙을 같은 언어로 맞췄다.
|
||||||
|
- 이 단계까지로 preview, file browser, worktree 선택, 권한 모드 popup이 거의 같은 시각 규칙을 공유하게 됐다. 남은 polish는 세부 spacing이나 색 강조처럼 더 미세한 조정 수준이다.
|
||||||
|
- 업데이트: 2026-04-06 11:34 (KST)
|
||||||
|
- footer 작업 바의 chip 버튼 시각 언어를 맞췄다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)에 `FooterChipBtn` 스타일을 추가하고, 하단의 `권한`, `Git 브랜치` 버튼이 같은 라운드/테두리/패딩 규칙을 쓰도록 정리했다.
|
||||||
|
- 이 단계는 구조 분리 이후의 visual polish 후속 작업으로, footer의 기능 버튼이 각각 다른 컨트롤처럼 보이던 차이를 줄이고 작업 바 전체를 하나의 도구 행처럼 느끼게 만드는 데 초점을 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 11:52 (KST)
|
||||||
|
- `claw-code` 대비 남아 있던 `도구/권한/스킬 표현 정교화` 1차를 반영했다. [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `bash`, `powershell`, `command`, `web_fetch`, `mcp`, `skill`, `question`, `file_edit`, `file_write`, `git`, `document`, `filesystem`까지 세분화하고 `ActionHint`, `Severity`, `RequiresPreview` 메타를 추가했다.
|
||||||
|
- [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 기존 `success / error / reject / cancel`에 더해 `approval_required`, `partial` 상태와 `FollowUpHint`, `NeedsAttention` 메타를 추가해 후속 안내 품질을 높일 기반을 마련했다.
|
||||||
|
- [SkillGalleryWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs)는 스킬 상세에 `모델`, `추론 강도`, `실행 컨텍스트`, `에이전트`, `모델 호출 비활성화`, `추천 상황`을 표시하도록 확장해, AX 스킬도 `claw-code`처럼 실행 정책이 보이는 방향으로 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 13:01 (KST)
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)에 `AppendAgentEventPresentationMeta(...)`와 공통 chip helper를 추가해, 권한 요청/도구 결과 metadata를 실제 transcript 카드에 반영했다.
|
||||||
|
- 권한 요청 이벤트는 이제 `ActionHint`, `Severity`, `RequiresPreview`를 이용해 `미리보기 권장`, `주의 필요`, `검토 권장` 같은 보조 chip과 안내 문구를 보여준다.
|
||||||
|
- 도구 결과 이벤트는 `FollowUpHint`, `NeedsAttention`, `StatusKind`를 이용해 `확인 필요`, `승인 후 계속`, `후속 점검` 같은 후속 행동 중심 안내를 transcript 안에서 바로 보여주도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 13:08 (KST)
|
||||||
|
- 같은 metadata가 모두 회색 chip으로 보이던 부분을 보강했다. [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 의 보조 chip 색을 `주의 필요=빨강`, `검토 권장/후속 점검=앰버`, `미리보기 권장=파랑`, `승인 후 계속=오렌지`로 나눠, 권한 요청과 도구 결과 상태가 시각적으로도 더 즉시 구분되게 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 13:14 (KST)
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)에 권한 요청/도구 결과 카테고리 chip을 추가했다. 이제 `명령 실행`, `파일 수정`, `웹 요청`, `Git`, `문서`, `스킬`, `MCP` 같은 종류가 상태 chip과 함께 보여서, 어떤 성격의 요청/결과인지 transcript에서 더 빨리 파악할 수 있다.
|
||||||
|
- 업데이트: 2026-04-06 13:20 (KST)
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 metadata 안내를 callout 구조로 바꿨다. 권한 요청은 `확인 포인트`, 도구 결과는 `다음 권장 작업` 카드형 안내로 보여줘, 같은 transcript 안에서도 요청과 결과의 UX가 더 분리되어 보이게 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 13:26 (KST)
|
||||||
|
- 파일 경로가 있는 권한 요청/도구 결과 카드에는 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 에서 `프리뷰 열기` 액션을 직접 붙였다. 이제 `미리보기 권장` 상태나 파일 기반 결과에서 transcript 카드만 보고 끝나는 것이 아니라, 바로 우측 preview panel을 열어 확인 흐름으로 이어질 수 있다.
|
||||||
|
- 업데이트: 2026-04-06 13:31 (KST)
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 callout을 상태별 제목/강조선 구조로 다듬었다. 권한 요청은 `확인 포인트`, 승인 완료는 `적용 내용`, 도구 결과는 `승인 필요`, `오류 확인`, `부분 완료 점검`, `다음 권장 작업`처럼 제목이 달라져 카드 의미가 더 즉시 읽히게 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 13:36 (KST)
|
||||||
|
- 파일 기반 transcript 카드의 액션 라벨도 상태별로 다르게 정리했다. [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 에서 권한 요청은 `변경 확인`, `작성 내용 보기`, 도구 결과는 `결과 보기`, `부분 결과 보기`, `오류 파일 보기`, `승인 전 미리보기`처럼 더 맥락에 맞는 버튼 라벨을 사용한다.
|
||||||
|
- 업데이트: 2026-04-06 14:06 (KST)
|
||||||
|
- IBM 연동형 vLLM 인증 경로를 점검한 결과, 기존 AX Agent는 등록 모델 인증 방식으로 `Bearer`와 `CP4D`만 지원하고 `IBM IAM` 토큰 교환은 지원하지 않았다. 이 때문에 IBM Cloud 계열 watsonx/vLLM 게이트웨이에 API 키를 직접 Bearer로 보내면 `인증 실패 - API 키가 유효하지 않습니다.` 오류가 발생할 수 있었다.
|
||||||
|
- [IbmIamTokenService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IbmIamTokenService.cs)를 추가하고 [LlmService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.cs)에 `ibm_iam` 인증 타입을 연결해, 등록 모델의 API 키를 IBM IAM access token으로 교환한 뒤 Bearer 헤더에 넣도록 보강했다.
|
||||||
|
- [ModelRegistrationDialog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ModelRegistrationDialog.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs)도 함께 갱신해 등록 모델 인증 방식에 `IBM IAM (토큰 교환)`이 보이고 저장/표시되도록 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 15:26 (KST)
|
||||||
|
- AX Agent 창을 PC에서 드래그로 이동할 때 버벅이던 문제를 줄이기 위해 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `WM_ENTERSIZEMOVE` / `WM_EXITSIZEMOVE` 기반 이동·리사이즈 감지를 추가했다.
|
||||||
|
- 이동/리사이즈 루프 중에는 창 루트를 `BitmapCache`로 묶고, `SizeChanged`에 연결돼 있던 `UpdateTopicPresetScrollMode()`, `UpdateResponsiveChatLayout()`, `RenderMessages()` 재계산은 잠시 지연시킨 뒤 루프 종료 시 한 번만 반영하도록 바꿨다.
|
||||||
|
- 이 변경으로 창을 끌 때마다 무거운 transcript/레이아웃이 반복 갱신되던 경로를 줄여, AX Agent 창 이동 체감 속도를 개선했다.
|
||||||
|
- 업데이트: 2026-04-06 15:35 (KST)
|
||||||
|
- AX Agent 내부 설정 공통 탭의 섹션 순서를 다시 정리했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 `서비스와 모델` 바로 아래에 `등록 모델 관리`를 연달아 배치해 흐름을 자연스럽게 맞췄다.
|
||||||
|
- `운영 모드`는 `대화 관리 / 대화 보관 기간 / 저장 공간` 아래쪽으로 이동시키고, 섹션 경계도 다시 `BorderThickness="0,0,0,1"` 기반으로 정리해 구분선이 끊기지 않도록 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 15:41 (KST)
|
||||||
|
- 채팅/코워크 빈 상태 화면의 세로 정렬 기준을 조정했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 상단 아이콘, 제목, 설명, 프리셋 목록을 하나의 세로 묶음으로 다시 구성해 화면 높이가 늘어나도 함께 상하 중앙 정렬되도록 수정했다.
|
||||||
|
- 이전처럼 프리셋 카드만 중앙에 오고 상단 설명 블록은 위쪽에 남아 보이던 레이아웃 불균형을 줄여, 빈 상태 화면 전체가 더 자연스럽게 가운데 정렬되도록 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 15:48 (KST)
|
||||||
|
- 일정 시간마다 표시되는 격려문구 알림 팝업의 자동 닫힘 경로를 점검하고 [ReminderPopupWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ReminderPopupWindow.xaml.cs)를 보강했다.
|
||||||
|
- 기존에는 `DispatcherTimer` 틱만으로 카운트다운과 닫힘을 함께 처리했는데, UI 틱이 밀리면 팝업 종료가 늦어질 여지가 있었다. 이제 카운트다운 표시와 실제 자동 종료를 분리해 `Task.Delay + CancellationToken` 기반 종료를 추가하고, 남은 시간은 절대 시각 기준으로 계산하도록 바꿨다.
|
||||||
|
- 이 변경으로 지정 시간이 지난 뒤에도 격려 팝업이 남아 있는 증상을 줄이고, 자동 닫힘이 더 안정적으로 동작하도록 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 16:02 (KST)
|
||||||
|
- 코워크 문서 생성이 항상 비슷한 HTML로 수렴하던 원인을 줄이기 위해 [DocumentPlannerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs), [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)의 기본 포맷/무드 선택 로직을 재정리했다.
|
||||||
|
- 이제 인자 없이 문서 생성 도구를 호출해도 무조건 `html + professional`로 고정되지 않고, `DefaultOutputFormat`, `DefaultMood`, 문서 유형(`proposal`, `analysis`, `manual`, `minutes` 등), 요청 주제 키워드를 함께 보고 `docx/html/markdown` 및 `corporate/dashboard/minimal/creative/professional`을 자동 선택한다.
|
||||||
|
- 이 변경으로 AX의 문서 생성 체인이 `claw-code`처럼 요청 기반 자유 작성 흐름에 더 가까워졌고, 코워크 결과물이 항상 비슷한 보고서형 HTML로 반복되던 현상을 줄일 기반을 마련했다.
|
||||||
|
- 업데이트: 2026-04-06 16:14 (KST)
|
||||||
|
- 배포판 보호 수준을 점검한 뒤 [AxCopilot.csproj](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/AxCopilot.csproj) Release 설정에 `Optimize`, `PublishSingleFile`, `EnableCompressionInSingleFile`, `IncludeNativeLibrariesForSelfExtract`, `PublishReadyToRun`을 추가했다.
|
||||||
|
- [build.bat](/E:/AX%20Copilot%20-%20Codex/build.bat) 도 같은 publish 속성을 명시적으로 넘기도록 갱신해, 배치 파일로 배포판을 만들 때 실제로 `Release + self-contained + single-file + ReadyToRun` 조합이 적용되도록 맞췄다.
|
||||||
|
- 현재 저장소에는 외부 난독화기(`tools\\obfuscator`)가 없어서 완전한 디컴파일 방지는 아니지만, 심볼 제거 수준에서 한 단계 더 강화된 배포 출력이 나오도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 16:20 (KST)
|
||||||
|
- single-file 배포를 켜면서 생긴 호환 경고도 함께 정리했다. [WebSearchHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/WebSearchHandler.cs)는 `Assembly.Location` 대신 `AppContext.BaseDirectory`를 사용하게 바꿨고, [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)는 버전 표시를 `AssemblyInformationalVersionAttribute` 기준으로 읽도록 수정했다.
|
||||||
|
- 이 수정까지 반영한 뒤 `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 기준 경고 0 / 오류 0을 다시 확인했다.
|
||||||
|
- 업데이트: 2026-04-06 16:28 (KST)
|
||||||
|
- 채팅/코워크/코드 입력창의 첨부/전송 버튼을 다시 심플한 컴포저 전용 스타일로 정리했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)에 `ComposerIconBtn`, `ComposerSendBtn` 스타일을 추가해 버튼 크기를 원복 수준으로 줄이고, 첨부와 전송 모두 상하좌우 중앙 정렬된 단순한 시각 언어를 사용하도록 맞췄다.
|
||||||
|
- 첨부 버튼은 과하게 커졌던 `48x48`에서 `34x34` 기준으로 줄였고, 전송 버튼도 같은 축의 `36x36` 원형 버튼으로 재구성했다. 전송 아이콘은 과한 MDL2 느낌 대신 단순한 상승 화살표 표현으로 바꿔, 참고 이미지처럼 더 담백한 조합이 되도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 16:39 (KST)
|
||||||
|
- AX Agent 내부 설정 오른쪽 본문 [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `ScrollViewer`에 `IsDeferredScrollingEnabled`, `PanningMode`, `BitmapCache`를 적용해 스크롤 시 느껴지던 버벅임을 줄였다.
|
||||||
|
- `저장 공간` 섹션의 `새로고침`, `대화 삭제`, `저장 공간 줄이기`는 기본 `Button` 대신 오버레이 전용 커스텀 액션 버튼 스타일(`OverlayActionBtn`)로 교체해 내부 설정 전체와 같은 시각 언어를 사용하도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 16:44 (KST)
|
||||||
|
- AX Agent 내부 설정 공통 탭의 `운영 모드` 섹션 구분선을 아래쪽에서 위쪽으로 옮겼다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `OverlaySectionOperationMode`를 `BorderThickness="0,1,0,0"` 기준으로 바꿔 저장 공간 섹션 아래에 선이 남지 않고, 운영 모드 시작선으로 보이도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 16:49 (KST)
|
||||||
|
- AX Agent 내부 설정 공통 탭에서 `서비스와 모델`과 `등록 모델 관리` 사이에 있던 구분선을 제거했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `OverlaySectionService` 하단 border를 없애고 간격만 남겨, 같은 흐름의 설정이 끊기지 않고 이어 보이도록 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 16:55 (KST)
|
||||||
|
- IBM/CP4D 계열 연결 점검 결과, 일부 환경은 `/icp4d-api/v1/authorize` 호출 시 `username + password`가 아니라 `username + api_key` JSON 본문을 요구하는 것을 확인했다.
|
||||||
|
- [Cp4dTokenService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Cp4dTokenService.cs) 에서 CP4D 토큰 요청을 먼저 `username + password`, 실패 시 `username + api_key`로 한 번 더 시도하도록 보강해, IBM 연결형 vLLM 환경 호환성을 높였다.
|
||||||
|
- 업데이트: 2026-04-06 17:01 (KST)
|
||||||
|
- 모델 등록 단계에서 IBM/CP4D 인증 방식을 명확히 고를 수 있게 분기했다. [ModelRegistrationDialog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ModelRegistrationDialog.cs)에 `CP4D (사용자 이름 + 비밀번호)`와 `CP4D (사용자 이름 + API 키)` 항목을 따로 추가하고, 선택에 따라 마지막 입력 필드 라벨이 `비밀번호` 또는 `API 키`로 바뀌도록 정리했다.
|
||||||
|
- [LlmService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs) 도 함께 갱신해 `cp4d_password`, `cp4d_api_key` 저장값을 공식 지원하고, 기존 `cp4d` 값은 비밀번호 방식으로 계속 호환되게 유지했다.
|
||||||
|
- 업데이트: 2026-04-06 17:09 (KST)
|
||||||
|
- 런처 하단에 자동으로 붙는 빠른 실행 칩을 별도 사용자 설정으로 분리했다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)에 `빠른 실행 칩 표시` 옵션을 추가했고, 기본값은 비활성으로 두었다.
|
||||||
|
- [LauncherViewModel.LauncherExtras.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.LauncherExtras.cs) 에서 이 설정이 꺼져 있으면 하단 빠른 실행 칩을 로드하지 않도록 바꿨다.
|
||||||
|
- [LauncherWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml) 에서는 하단 빠른 실행 칩 블록 자체를 중앙 정렬 기준으로 재배치하고, 각 칩 내부도 세로 중앙 정렬과 최소 높이를 맞춰 여러 개가 나타나도 정중앙에 더 가깝게 보이도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 17:18 (KST)
|
||||||
|
- AX Copilot가 유휴 상태에서도 CPU를 3~5% 정도 쓰는 원인을 점검한 뒤, 상시 백그라운드 경로 두 군데를 줄였다. [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 에서 `SchedulerService`는 앱 시작 즉시 무조건 타이머를 돌리지 않고 `Refresh()`로 활성 일정이 있을 때만 시작하도록 바꿨다.
|
||||||
|
- 같은 파일에서 `FileDialogWatcher`도 더 이상 앱 시작 시 무조건 시스템 전역 WinEvent 훅을 걸지 않고, `파일 대화상자 통합` 설정이 켜져 있을 때만 시작되도록 조정했다. 설정 저장 시 `SettingsChanged`를 받아 watcher/timer 상태를 즉시 다시 계산하도록 연결했다.
|
||||||
|
- [SchedulerService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SchedulerService.cs) 도 함께 정리해, 활성 일정이 하나도 없으면 타이머를 시작하지 않고, 실행 중에도 일정이 모두 비활성화되면 스스로 타이머를 정지하도록 바꿨다. 이 변경으로 런처와 AX Agent 창이 모두 닫힌 유휴 상태에서 불필요한 CPU 깨우기를 줄였다.
|
||||||
|
- 업데이트: 2026-04-06 17:24 (KST)
|
||||||
|
- 선택 텍스트 AI 명령의 기본값을 보수적으로 다시 조정했다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs) 에서 `선택 텍스트 명령 사용` 기본값을 `false`로 바꾸고, 활성 AI 명령 목록도 기본은 빈 리스트가 되도록 변경했다.
|
||||||
|
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs) 의 `BuildTextActionCommandsPanel()`에서 `최소 1개 유지`를 강제하던 로직을 제거해 `다시 쓰기`를 포함한 모든 텍스트 AI 명령을 실제로 비활성화할 수 있게 수정했다.
|
||||||
|
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml) 의 안내 문구도 현재 동작 기준으로 갱신해, 모든 명령을 꺼두면 선택 텍스트 팝업에는 `AX Commander 열기`만 남는다는 점을 명확히 안내하도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 17:35 (KST)
|
||||||
|
- 앱이 아무 창도 열지 않은 유휴 상태에서 PC를 무겁게 만들던 추가 초기화 경로를 줄였다. [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 에서 앱 시작 직후 숨겨진 [ChatWindow](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 를 미리 생성하던 `PrewarmChatWindow()` 호출을 제거해, AX Agent를 실제로 열기 전에는 무거운 UI 트리를 만들지 않도록 바꿨다.
|
||||||
|
- 같은 파일에서 [IndexService](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IndexService.cs) 의 전체 인덱스 빌드와 `FileSystemWatcher` 시작도 앱 시작 시 즉시 수행하지 않고, 사용자가 실제로 런처를 열 때 `EnsureIndexWarmupStarted()`로 한 번만 지연 시작하도록 바꿨다.
|
||||||
|
- 이 변경으로 런처/AX Agent를 열지 않은 상태에서 불필요한 전체 파일 스캔과 감시 훅, 숨겨진 대형 창 초기화가 줄어들어 PC 전체 체감 부하를 더 낮추도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 17:43 (KST)
|
||||||
|
- 추가로 앱 시작 시 [LauncherWindow](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs) 를 미리 생성하지 않고, 실제로 런처를 처음 열 때만 `EnsureLauncherCreated()`로 만들도록 바꿨다. 이로써 보이지 않는 상태의 런처 UI, 바인딩, 보조 타이머 준비 비용을 평소에는 지연시켰다.
|
||||||
|
- [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 에서 트레이 메뉴의 `PrepareForDisplay()` 사전 렌더 호출도 제거해, 사용하지도 않는 트레이 팝업 레이아웃 계산을 앱 시작 직후 강제로 하지 않도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 17:52 (KST)
|
||||||
|
- 런처 표시 체감 속도를 유지하기 위해 [LauncherWindow](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs) 사전 생성은 다시 복원했다. 대신 무거운 후보를 색인으로 더 좁히기 위해, [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs)의 인덱스 워밍업 진입점을 런처 표시 시점이 아니라 실제 검색 시점으로 옮겼다.
|
||||||
|
- [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 의 `SearchAsync(...)` 시작 시에만 `EnsureIndexWarmupStarted()`를 호출하도록 바꿔, 사용자가 런처를 단순 호출만 할 때는 전체 인덱스 스캔과 파일 감시가 돌지 않게 정리했다.
|
||||||
|
- 업데이트: 2026-04-06 18:02 (KST)
|
||||||
|
- IBM 연결형 vLLM에서 `model_id` 또는 `mode`를 body에 넣지 말라는 응답이 오던 문제를 수정했다. [LlmService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.cs)에 IBM/CP4D 인증 + `/ml/v1/deployments/.../text/chat` 계열 엔드포인트를 감지하는 분기를 추가하고, 이 경우 일반 OpenAI 호환 body 대신 `messages + parameters` 형태의 IBM deployment chat body를 사용하도록 바꿨다.
|
||||||
|
- 같은 파일에서 IBM deployment chat 경로는 `/v1/chat/completions`를 더 이상 강제로 붙이지 않고, 스트리밍 여부에 따라 `/text/chat` 또는 `/text/chat_stream` URL을 사용하도록 정리했다. 응답 파싱도 `results[].generated_text`, `output_text`, `choices[].message.content`를 함께 지원하게 확장했다.
|
||||||
|
- [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs) 에서는 IBM deployment chat API가 감지되면 OpenAI function-calling body를 그대로 보내지 않고 `ToolCallNotSupportedException`으로 일반 응답 경로 폴백을 유도하도록 안전장치를 추가했다.
|
||||||
|
- 업데이트: 2026-04-06 18:09 (KST)
|
||||||
|
- 채팅 메시지의 좋아요/싫어요 토글을 다시 정리했다. [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs) 에서 두 버튼이 각자 상태를 따로 들고 있던 구조를 없애고, 하나의 shared feedback 상태(`like/dislike/null`)를 기준으로 상호배타 토글되도록 재구성했다.
|
||||||
|
- 이제 `좋아요`도 즉시 색상/배경 상태가 바뀌고, `싫어요`를 다시 누르면 원래 상태(null)로 정상 해제된다. 버튼 시각 표현도 같은 glyph를 유지하되 active 색상과 라운드 chip 배경/테두리로 구분해, 특정 filled glyph가 보이지 않던 문제를 함께 줄였다.
|
||||||
|
- 업데이트: 2026-04-06 18:24 (KST)
|
||||||
|
- 런처 색인 구조를 임시 지연 실행에서 `영속 캐시 + watcher 증분 반영` 방식으로 바꿨다. [IndexService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IndexService.cs)는 이제 `%APPDATA%\\AxCopilot\\index\\launcher-index.json`에 파일 시스템 인덱스를 저장하고, 앱 시작 시 캐시를 즉시 로드해 첫 검색부터 이전 색인을 재사용한다.
|
||||||
|
- `FileSystemWatcher`도 더 이상 파일 하나 바뀔 때마다 3초 뒤 전체 재빌드를 때리지 않고, 생성/삭제/파일 이름 변경은 가능한 범위에서 해당 항목만 증분 반영한다. 디렉터리 이름 변경처럼 하위 경로 전체 영향이 큰 경우에만 전체 재색인으로 폴백한다.
|
||||||
|
- [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 는 앱 시작 시 캐시 로드와 watcher 시작을 바로 수행하고, 실제 무거운 전체 재색인은 첫 검색 시 `EnsureIndexWarmupStarted()`로 한 번만 보강 실행하도록 정리했다. 이 변경으로 런처는 즉시 검색 가능 상태를 유지하면서도, 평소엔 전체 재색인 비용을 반복해서 치르지 않게 됐다.
|
||||||
|
- 업데이트: 2026-04-06 18:34 (KST)
|
||||||
|
- 다른 앱에서 타이핑할 때도 AX Copilot가 키 입력 훅 경로에서 과하게 개입하던 부분을 줄였다. [InputListener.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/InputListener.cs)는 이제 모든 키다운마다 시스템 파일 대화상자 판정을 하지 않고, 실제 핫키 메인 키·캡처 메인 키·키 필터가 필요한 경우에만 억제 창 검사를 수행한다.
|
||||||
|
- [SnippetExpander.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/SnippetExpander.cs)는 추적 중이 아닐 때 `;` 시작 키 외에는 즉시 반환하도록 바꿨다. 이전에는 일반 타이핑 중에도 모든 키마다 `Ctrl/Alt/Shift` 상태를 읽고 버퍼 로직을 거쳤는데, 이제는 실제 스니펫 시작 상황에서만 그런 검사를 하게 되어 글로벌 키보드 훅의 평상시 부담을 줄였다.
|
||||||
|
업데이트: 2026-04-06 20:18 (KST)
|
||||||
|
- AX Agent 메시지 마크다운 렌더에 코드 심볼 강조를 추가해 Cowork/Code 답변의 파일 경로·camelCase/PascalCase·snake_case가 더 선명하게 보이도록 조정했다.
|
||||||
|
- 코드 탭 입력부 위에 저장소/브랜치/변경 수치를 보여주는 Git 요약 배너를 추가해 `claude-code` 스타일의 repo context를 더 빠르게 읽을 수 있게 맞췄다.
|
||||||
|
- 업데이트: 2026-04-06 20:28 (KST)
|
||||||
|
- AX Agent 코드 탭 입력부 위 저장소 요약줄을 더 `claude-code` 스타일에 가깝게 정리했습니다. `로컬/워크트리`, `upstream` 상태를 얇은 배지로 표시하고, 변경이 있을 때 액션 문구가 `변경 · 브랜치 보기`로 바뀌도록 했습니다.
|
||||||
|
- 코드 탭 저장소 요약줄에 `리뷰` 배지를 추가해 기존 slash 명령 `/review`로 바로 이어지게 했습니다. 입력 중인 문구가 있으면 유지한 채 리뷰 흐름만 얹도록 조정했습니다.
|
||||||
|
- 업데이트: 2026-04-06 20:34 (KST)
|
||||||
|
- Claude 라이트/시스템 테마의 표면 위계를 다시 맞췄습니다. 본문 기본 배경은 흰색으로, 좌측 패널과 카드 표면은 더 짙은 웜 베이지 톤으로 조정해서 사용자가 요청한 방향대로 역할이 뒤바뀌지 않게 정리했습니다.
|
||||||
|
- 업데이트: 2026-04-06 21:54 (KST)
|
||||||
|
- AX Agent 테마 팔레트를 다시 분리해 `Claude`, `Codex`, `Slate`, `Nord`, `Ember`가 서로 더 다른 인상으로 보이게 조정했습니다. 특히 `Codex`는 웜 베이지를 걷고 더 차갑고 중성적인 회백/차콜 표면 계열로 재구성했습니다.
|
||||||
|
- 입력창 포커스 시 거의 항상 주황 테두리처럼 보이던 경로를 제거하고, 각 테마의 `InputFocusBorderColor`를 따르도록 바꿨습니다. 같이 composer와 메시지 버블 라운딩도 더 둥글게 손봐 Codex 계열 박스 감각에 더 가깝게 맞췄습니다.
|
||||||
|
- 업데이트: 2026-04-06 22:01 (KST)
|
||||||
|
- AX Agent 내부 설정의 코드/공통 기능 토글들이 눌러도 다시 원래 상태로 돌아가던 문제를 수정했습니다. 내부 설정 오버레이에서 `Code 결과 검토`, `코드 리뷰 도구 활성화`, `도구 병렬 실행`, `Worktree/Team/Cron 도구`를 포함한 기능 토글들이 이제 즉시 저장 루틴을 타도록 연결했습니다.
|
||||||
|
- 업데이트: 2026-04-06 22:15 (KST)
|
||||||
|
- AX Agent 루프에 남아 있던 `무료 티어 모드` 대기를 Gemini 서비스에서만 적용하도록 좁혔습니다. 이제 예전 Gemini 무료 티어용 대기 설정이 vLLM/Ollama/Claude 같은 다른 서비스 작업을 불필요하게 늦추지 않습니다.
|
||||||
|
- AX Agent 내부 설정의 `Fast` 표기를 `Gemini 무료 티어 대기`로 바꾸고 설명 문구도 실제 동작 기준으로 수정했습니다. 사용자는 이제 내부 설정에서 이 대기를 명확히 끄고 켤 수 있습니다.
|
||||||
|
- Cowork/Code 중간 진행 정보가 hover처럼 우연히만 보이던 문제를 줄이기 위해 에이전트 이벤트 카드 스타일을 다시 조정했습니다. 단계 시작, 도구 호출, 대기/생각 중 상태가 더 큰 글씨와 얇은 배경 카드로 기본 노출되도록 정리해, 장시간 작업 중에도 “지금 무엇을 하는지”를 본문에서 바로 읽을 수 있게 했습니다.
|
||||||
|
- 업데이트: 2026-04-06 22:31 (KST)
|
||||||
|
- AX Agent의 중간 처리 메시지 형식을 `claw-code`에 더 가깝게 재정리했습니다. `Thinking / ToolCall / StepStart / Planning` 계열은 작은 상태칩보다 `요약줄 + 본문 설명` 구조로 보이게 바꿔, 장시간 작업 중 “무슨 작업을 하고 있는지”를 일반 메시지처럼 읽을 수 있게 했습니다.
|
||||||
|
- 진행 메시지의 요약줄은 좌측 chevron과 보조 텍스트 중심으로 정리하고, 상세 설명은 바로 아래 본문 텍스트로 분리해 `claw-code / Claude Code`의 처리 메시지 밀도와 감각에 더 가깝게 맞췄습니다.
|
||||||
|
- 업데이트: 2026-04-06 22:42 (KST)
|
||||||
|
- AX Agent의 라이브 대기/압축 진행 힌트를 `claw-code`처럼 더 읽기 쉬운 진행 한 줄로 보강했습니다. 이제 Cowork/Code에서 오래 걸릴 때 `처리 중...`, `컨텍스트 압축 중...` 같은 요약줄이 transcript에 살아 있는 진행 상태로 나타납니다.
|
||||||
|
- 같은 진행 줄 우측에는 `경과 시간`과 `현재 누적 토큰`이 함께 표시되어, 사용자가 “지금 멈춘 건지 아직 처리 중인지”를 기다릴 근거와 함께 바로 확인할 수 있게 조정했습니다.
|
||||||
|
- 업데이트: 2026-04-06 22:48 (KST)
|
||||||
|
- AX Agent의 라이브 대기 진행 줄에 작은 펄스 애니메이션을 추가했습니다. 오래 걸리는 `처리 중...`, `컨텍스트 압축 중...` 상태는 이제 좌측 마커가 은은하게 살아 움직여, 멈춘 로그가 아니라 실제 진행 중인 상태라는 점이 더 분명하게 보입니다.
|
||||||
|
- 업데이트: 2026-04-06 23:16 (KST)
|
||||||
|
- 전체 코드 기준 오류/성능 점검 중 발견된 런타임 핫패스를 정리했습니다. [SettingsService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SettingsService.cs) 에서 AX Agent 표현 수준을 매번 `rich`로 덮어쓰던 버그를 수정해, 저장된 `balanced/simple/rich` 값이 실제로 유지되도록 했습니다.
|
||||||
|
- [IndexService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IndexService.cs) 에는 `tmp/cache/log/bak/crdownload` 같은 임시 파일과 숨김/시스템 경로, `~$` Office 임시 파일을 색인/감시 대상에서 제외하는 규칙을 추가했습니다. 불필요한 증분 갱신과 재색인 노이즈를 줄여 런처가 백그라운드에서 먹는 CPU와 디스크 I/O를 완화하는 목적입니다.
|
||||||
|
- [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs)의 인덱스 상태 타이머는 매 호출마다 새 인스턴스를 만들지 않고 재사용하도록 바꿨고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)는 창이 숨김/최소화된 동안 transcript 재렌더를 지연했다가 다시 보일 때 한 번만 반영하도록 정리해 AX Agent 백그라운드 부담을 줄였습니다.
|
||||||
|
- 업데이트: 2026-04-06 23:26 (KST)
|
||||||
|
- AX Agent의 중간 진행 메시지를 `claw-code`에 더 가깝게 마무리했습니다. execution history를 접어 둔 상태에서도 `처리 중...`, `컨텍스트 압축 중...`, 중요한 thinking/tool 진행 이벤트는 transcript에 계속 보이도록 필터를 조정했습니다.
|
||||||
|
- 진행 줄 스타일도 카드형 박스보다 더 평평한 요약줄 위주로 정리했습니다. 일반 진행 이벤트는 borderless line처럼 보이고, 실제 장기 대기/압축 상태만 은은한 강조 배경과 펄스 마커를 유지해 “지금 살아 있는 작업”만 더 잘 드러나게 맞췄습니다.
|
||||||
|
- 업데이트: 2026-04-06 23:33 (KST)
|
||||||
|
- 런처 검색 반응성을 높이기 위해 [FuzzyEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/FuzzyEngine.cs)에 인덱스 버전 기준 쿼리 캐시를 추가했습니다. 색인이 같은 상태에서 반복 입력되는 쿼리는 결과를 다시 전부 계산하지 않고 즉시 재사용합니다.
|
||||||
|
- 앱 시작 직후 캐시된 인덱스가 없을 때는 런처 watcher를 먼저 모두 켜지 않도록 [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs)를 조정했습니다. 불필요한 감시기 오버헤드를 줄이고, 실제 첫 색인 완료 뒤에 watcher가 붙도록 정리했습니다.
|
||||||
|
- AX Agent는 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 최소화/백그라운드 상태일 때 task summary, 입력 보조 UI, 에이전트 상태 반영을 즉시 다시 그리지 않고 대기시켰다가 다시 활성화될 때 한 번에 flush 하도록 바꿨습니다.
|
||||||
|
- 업데이트: 2026-04-06 23:49 (KST)
|
||||||
|
- AX Agent 메모리 구조 강화를 시작했습니다. [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `관리형 / 사용자 / 프로젝트 / 로컬` 계층형 메모리 문서 로더를 추가해 `AXMEMORY.md`, `AXMEMORY.local.md`, `.ax/rules/*.md` 계열 파일을 현재 작업 폴더까지 발견하고 로드합니다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 시스템 프롬프트 메모리 섹션도 계층형 메모리 + 기존 학습 메모리를 함께 조립하도록 바꿨습니다. 이제 AX는 `claw-code`처럼 지속 메모리를 단순 전역/폴더 저장이 아니라 계층형 지시문 + 학습형 메모리의 조합으로 주입합니다.
|
||||||
|
- 업데이트: 2026-04-06 23:57 (KST)
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)를 확장해 계층형 메모리 관리 액션을 추가했습니다. 이제 `/memory`는 기존 학습 메모리 `save/search/list/delete` 외에 `save_scope`, `delete_scope`를 통해 `managed / user / project / local` 메모리 파일을 직접 다룰 수 있습니다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs) 에는 계층형 메모리 파일의 실제 저장/삭제 경로를 결정하고 내용을 append/remove 하는 로직을 추가했습니다. AX 메모리 구조가 이제 `읽기 전용 계층`이 아니라 `학습 메모리 + 계층형 메모리 파일`을 함께 관리하는 형태로 한 단계 더 올라왔습니다.
|
||||||
|
- 업데이트: 2026-04-07 00:06 (KST)
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `@include` 확장을 추가했습니다. 이제 `AXMEMORY.md` 안에서 `@./docs/architecture.md`, `@~/shared/rules.md`, 절대 경로 include를 사용할 수 있고, 텍스트 파일만 최대 5단계까지 재귀적으로 펼칩니다.
|
||||||
|
- 같은 파일에서 프로젝트 루트 판단도 강화했습니다. 이제 단순 현재 작업 폴더가 아니라 `.git`, `.sln`, `*.csproj`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` 같은 마커를 보고 프로젝트 루트를 먼저 잡은 뒤, 그 루트부터 현재 작업 디렉토리까지의 메모리 계층을 조립합니다.
|
||||||
|
- 업데이트: 2026-04-07 00:13 (KST)
|
||||||
|
- `claw-code`처럼 외부 메모리 include를 무조건 열어두지 않도록 안전 장치를 추가했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)에 `외부 메모리 include 허용` 설정을 추가했고 기본값은 `꺼짐`입니다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)는 이 설정이 꺼져 있으면 프로젝트 바깥으로 빠지는 상대 경로, 홈 경로(`@~/...`), 절대 경로 include를 모두 차단합니다. 즉 메모리 내용 관리는 계속 `/memory` 같은 명령으로 하되, include의 보안 정책만 설정으로 다루는 구조로 정리했습니다.
|
||||||
|
- 업데이트: 2026-04-07 00:22 (KST)
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `paths:` frontmatter 지원을 추가해 `.ax/rules/*.md` 같은 계층형 메모리 문서가 특정 작업 폴더 범위에서만 적용되도록 했습니다.
|
||||||
|
- 이제 메모리 문서 상단에 `---`, `paths:`, `- src/**`, `---` 형태를 쓰면 현재 작업 폴더가 프로젝트 루트 기준으로 그 패턴에 맞을 때만 로드됩니다. AX 메모리 규칙을 `claw-code`의 경로별 rule 파일처럼 더 세밀하게 제어할 수 있습니다.
|
||||||
|
- 업데이트: 2026-04-07 00:31 (KST)
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `description:` frontmatter 메타를 추가해 계층형 메모리 규칙 파일이 “무엇을 위한 규칙인지” 설명을 가질 수 있게 했습니다.
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs) 는 `show_scope` 액션을 새로 지원합니다. 이제 `/memory` 계열 명령으로 `managed / user / project / local` 메모리 파일의 실제 내용을 직접 확인할 수 있고, `list/search` 결과에도 `description`과 `paths` 범위가 함께 표시됩니다.
|
||||||
|
- 업데이트: 2026-04-07 00:39 (KST)
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 계층형 메모리 우선순위/병합 정책을 추가했습니다. 같은 내용의 규칙이 여러 계층에 중복될 경우 더 가까운 규칙만 남기고, 최종 메모리 문서는 `managed → user → project → local` 순으로 다시 정렬됩니다.
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs) 의 `list/search`는 이제 최종 우선순위 번호를 같이 보여줘, 어떤 규칙이 실제로 더 강하게 적용되는지 바로 확인할 수 있습니다.
|
||||||
|
- 업데이트: 2026-04-07 00:45 (KST)
|
||||||
|
- AX Copilot 메인 설정의 에이전트 메모리 영역에서 `관리형 / 사용자 / 프로젝트 / 로컬` 메모리 파일을 직접 열어 수정할 수 있게 했습니다. [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)에 계층형 메모리 편집 버튼과 전용 편집 다이얼로그를 추가했습니다.
|
||||||
|
- 메모리 편집 다이얼로그는 현재 테마를 따르는 안내 패널과 멀티라인 편집기를 제공하고, `description`/`paths` frontmatter 예시를 바로 볼 수 있습니다. 저장 시 해당 scope 파일을 즉시 갱신하고, 빈 내용으로 저장하면 파일을 삭제한 뒤 메모리 계층을 다시 로드합니다.
|
||||||
|
- 업데이트: 2026-04-07 00:52 (KST)
|
||||||
|
- 계층형 메모리 frontmatter를 더 확장해 `enabled:`와 `tags:`를 지원하도록 했습니다. 이제 실험용 규칙을 파일에 남겨둔 채 비활성화할 수 있고, 규칙 묶음을 태그 단위로 구분해 관리할 수 있습니다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)는 `enabled: false`인 규칙 파일을 메모리 계층에서 제외하고, [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)의 `list/search` 결과에는 `tags` 메타를 함께 보여줘 어떤 규칙군인지 더 빠르게 읽을 수 있게 했습니다.
|
||||||
|
- 업데이트: 2026-04-07 01:00 (KST)
|
||||||
|
- AX Agent 설정의 에이전트 메모리 섹션에 `적용 중 메모리 계층` 요약을 추가했습니다. 현재 컨텍스트에 반영된 계층형 규칙 수와 학습 메모리 수를 한 줄로 보고, 아래에서 활성 규칙 파일의 우선순위·설명·태그를 바로 확인할 수 있습니다.
|
||||||
|
- 메모리 파일을 편집하거나 학습 메모리를 초기화하면 이 요약이 즉시 다시 계산되도록 연결했고, `새로고침` 버튼도 추가해 현재 작업 폴더 기준 메모리 적용 상태를 바로 다시 확인할 수 있게 했습니다.
|
||||||
|
업데이트: 2026-04-07 01:15 (KST)
|
||||||
|
|
||||||
|
- AX Agent 메모리 구조를 추가 강화했습니다. `@include` 확장 시도는 이제 감사 로그에 `MemoryInclude` 항목으로 남고, Cowork/Code 하단 폴더 바에 현재 적용 중인 계층형 메모리/학습 메모리 상태가 요약 표시됩니다.
|
||||||
|
- 업데이트: 2026-04-07 01:26 (KST)
|
||||||
|
- Cowork/Code 하단 메모리 칩을 눌렀을 때 `적용 중 규칙`과 `최근 include 감사`를 바로 확인할 수 있는 상세 팝업을 추가했습니다. 이제 메모리 계층이 실제로 어떻게 적용되고 있는지 채팅 하단에서 바로 추적할 수 있습니다.
|
||||||
|
- 설정의 메모리 개요에도 `최근 include 감사` 요약을 추가해, 메모리 규칙 상태와 include 시도 결과를 같은 화면에서 함께 점검할 수 있게 했습니다.
|
||||||
|
- 업데이트: 2026-04-07 01:35 (KST)
|
||||||
|
- Cowork/Code 진행 표시 줄에도 `메모리 규칙 n개 · 학습 n개 적용 중` 근거가 함께 표시되도록 보강했습니다. 기다리는 동안 현재 어떤 메모리 계층이 반영되고 있는지 transcript에서 바로 확인할 수 있습니다.
|
||||||
|
- 메모리 include 감사는 `최근 3일` 기준으로 다시 집계해 보여주도록 정리했고, `/memory list`·`/memory search` 결과도 우선순위·레이어·설명·paths·tags를 두 줄 구조로 더 읽기 쉽게 정리했습니다.
|
||||||
|
- 업데이트: 2026-04-07 01:44 (KST)
|
||||||
|
- AX Agent footer/preset 안내에 남아 있던 한글 깨짐 문자열을 복구했습니다. Cowork/Code 입력창 워터마크, 선택된 프리셋 설명, 메모리 상태 팝업 문구가 정상 한글로 다시 표시됩니다.
|
||||||
|
- 업데이트: 2026-04-07 02:08 (KST)
|
||||||
|
- Cowork/Code 진행 표시가 오래 걸릴 때도 비어 보이지 않도록 live progress fallback을 보강했습니다. 이벤트가 계속 들어오는 경우에도 일정 시간이 지나면 `작업을 진행하는 중입니다...` 줄이 transcript에 표시됩니다.
|
||||||
|
- 진행 줄 재렌더 시 과한 fade 애니메이션을 제거해 깜박이듯 보이던 문제를 줄였고, 장시간 실행 뒤 내부 중단이 발생해도 더 이상 무조건 `사용자가 작업을 취소했습니다`로 표기하지 않도록 중립 문구로 정리했습니다.
|
||||||
|
- 깨진 한글이 남아 있던 [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs), [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs), [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 표시 문자열을 다시 복구했습니다.
|
||||||
|
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-07 02:11 (KST)
|
||||||
|
- AX Agent 내부 설정 공통 탭의 Gemini/Claude API 키 입력 필드를 PasswordBox에서 TextBox로 교체해, 오버레이 동기화 중에도 입력이 끊기거나 튕기지 않도록 수정했습니다.
|
||||||
|
- 업데이트: 2026-04-07 02:45 (KST)
|
||||||
|
- Cowork/Code 진행 카드의 경과 시간 계산을 보정했습니다. 스트리밍 시작 시각이 준비되기 전에 진행 힌트가 먼저 그려질 때 `수천만 시간`처럼 비정상값이 표시되던 문제를 막고, 6시간을 넘는 비현실적인 경과 시간은 자동 무시하도록 정리했습니다.
|
||||||
|
- AX Agent 입력창 글로우를 런처와 같은 리듬의 무지개 글로우로 다시 맞췄습니다. 글로우 외곽선 두께와 블러를 부드럽게 조정하고, 라이브 진행 카드도 테마 AccentColor 기반의 은은한 톤을 써서 주황색 고정 느낌을 줄였습니다.
|
||||||
|
- 일반 설정에 있던 `런처 무지개 글로우`, `선택 아이템 글로우`, `채팅 입력창 무지개 글로우`를 AX Agent 내부 설정으로 이동해, 이제 내부 설정에서 바로 런처/입력창 글로우를 함께 조정할 수 있습니다.
|
||||||
|
- 업데이트: 2026-04-07 02:56 (KST)
|
||||||
|
- AX Agent 내부 설정 개발자 탭의 `워크플로우 시각화`가 숨은 개발자 모드 의존 때문에 실제로 창을 띄우지 않던 문제를 수정했습니다. 이제 토글을 켜면 즉시 워크플로우 분석기 창이 열리고, 끄면 창이 숨겨집니다.
|
||||||
|
- 일반 설정에만 남아 있던 `문서 미리보기 자동 표시` 옵션을 AX Agent 내부 설정 공통 탭에도 복원해, Cowork/Code에서 프리뷰 자동 열기 정책을 내부 설정에서 바로 바꿀 수 있게 했습니다.
|
||||||
|
- 업데이트: 2026-04-07 03:03 (KST)
|
||||||
|
- Cowork/Code 하단 작업 바의 메모리 상태 칩을 숨겼습니다. 이제 footer에는 폴더, 권한, Git 같은 작업 상태만 남고 메모리 관련 표기는 노출되지 않습니다.
|
||||||
|
- 메모리 상태 버튼이 비노출일 때는 관련 팝업도 열리지 않도록 정리해, 상태 갱신이나 탭 전환 중 다시 나타나는 일이 없게 했습니다.
|
||||||
|
- 업데이트: 2026-04-07 03:13 (KST)
|
||||||
|
- Cowork/Code 실행 중 탭을 바꿀 때 작업을 즉시 취소하던 흐름을 제거했습니다. 이제 실행은 시작한 탭에서 계속 진행되고, 다른 탭으로 이동해도 작업이 사용자 취소처럼 끝나지 않습니다.
|
||||||
|
- 라이브 진행 힌트는 실행을 시작한 탭에서만 보이도록 조정해, Cowork 작업 중 Code 탭으로 이동했을 때 Code 쪽 transcript에 `처리 중...`이 따라 보이던 상태 오염을 막았습니다.
|
||||||
|
- 업데이트: 2026-04-07 09:32 (KST)
|
||||||
|
- Cowork/Code 장시간 실행 뒤 마지막 응답을 라이브 프리뷰로 붙이는 단계에서 `_streamCts` 필드를 다시 읽다가 `Object reference not set to an instance of an object.`로 실패할 수 있던 경로를 수정했습니다. 이제 실행 시작 시 캡처한 지역 토큰을 끝까지 재사용하고, 최종 타이핑 프리뷰 컨테이너가 준비되지 않으면 조용히 건너뛰도록 방어 로직을 추가했습니다.
|
||||||
|
- 같은 실패가 다시 생기면 원인을 더 빨리 찾을 수 있도록 AX Agent 실행 예외 전체를 앱 로그에 남기도록 보강했습니다.
|
||||||
|
|||||||
235
build.bat
235
build.bat
@@ -1,90 +1,199 @@
|
|||||||
@echo off
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
|
|
||||||
|
set "ROOT=%~dp0"
|
||||||
|
pushd "%ROOT%" >nul
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo AX Copilot - Build Script
|
echo AX Copilot - Build Script
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
set APP=src\AxCopilot\AxCopilot.csproj
|
set "APP=%ROOT%src\AxCopilot\AxCopilot.csproj"
|
||||||
set ENCRYPTOR=src\AxKeyEncryptor\AxKeyEncryptor.csproj
|
set "ENCRYPTOR=%ROOT%src\AxKeyEncryptor\AxKeyEncryptor.csproj"
|
||||||
set OFFLINE=src\AxCopilot.Installer\AxCopilot.Installer.csproj
|
set "INSTALLER=%ROOT%src\AxCopilot.Installer\AxCopilot.Installer.csproj"
|
||||||
set OUT=dist
|
set "INSTALLER_DIR=%ROOT%src\AxCopilot.Installer"
|
||||||
|
set "OUT=%ROOT%dist"
|
||||||
|
set "APP_OUT=%OUT%\AxCopilot"
|
||||||
|
set "ENCRYPTOR_OUT=%OUT%\AxKeyEncryptor"
|
||||||
|
set "PAYLOAD_ZIP=%INSTALLER_DIR%\payload.zip"
|
||||||
|
set "INSTALLER_EXE=%INSTALLER_DIR%\bin\Release\net48\AxCopilot_Setup.exe"
|
||||||
|
set "RUNTIME=win-x64"
|
||||||
|
set "OBFUSCATOR_EXE=%ROOT%tools\obfuscator\obfuscator.exe"
|
||||||
|
set "OBFUSCATOR_CONFIG=%ROOT%tools\obfuscator\AxCopilot.obfuscation.xml"
|
||||||
|
|
||||||
:: Kill running app
|
call :stop_process "AxCopilot" "AX Copilot"
|
||||||
tasklist /FI "IMAGENAME eq AxCopilot.exe" 2>nul | find /i "AxCopilot.exe" >nul
|
if errorlevel 1 goto :fail_running
|
||||||
if %ERRORLEVEL%==0 (
|
call :stop_process "AxCommander" "legacy AxCommander"
|
||||||
echo [0] Stopping AxCopilot...
|
if errorlevel 1 goto :fail_running
|
||||||
taskkill /IM AxCopilot.exe /F >nul 2>nul
|
|
||||||
timeout /t 2 /nobreak >nul
|
|
||||||
)
|
|
||||||
:: Kill legacy process
|
|
||||||
tasklist /FI "IMAGENAME eq AxCommander.exe" 2>nul | find /i "AxCommander.exe" >nul
|
|
||||||
if %ERRORLEVEL%==0 (
|
|
||||||
echo [0] Stopping legacy AxCommander...
|
|
||||||
taskkill /IM AxCommander.exe /F >nul 2>nul
|
|
||||||
timeout /t 2 /nobreak >nul
|
|
||||||
)
|
|
||||||
|
|
||||||
if exist "%OUT%" rd /s /q "%OUT%" 2>nul
|
if exist "%OUT%" rd /s /q "%OUT%" 2>nul
|
||||||
mkdir "%OUT%"
|
mkdir "%OUT%" || goto :fail_dist
|
||||||
mkdir "%OUT%\AxCopilot"
|
mkdir "%APP_OUT%" || goto :fail_dist
|
||||||
|
mkdir "%ENCRYPTOR_OUT%" || goto :fail_dist
|
||||||
|
|
||||||
:: ========================================
|
if exist "%PAYLOAD_ZIP%" del /q "%PAYLOAD_ZIP%" 2>nul
|
||||||
:: 1. Main app (self-contained, folder)
|
|
||||||
:: ========================================
|
echo [1/5] Building main app (self-contained %RUNTIME%)...
|
||||||
echo [1/4] Building main app (self-contained)...
|
dotnet publish "%APP%" ^
|
||||||
dotnet publish "%APP%" -c Release -o "%OUT%\AxCopilot" --self-contained true --nologo -v quiet
|
-c Release ^
|
||||||
if %ERRORLEVEL% NEQ 0 ( echo [FAILED] Main app build & pause & exit /b 1 )
|
-r %RUNTIME% ^
|
||||||
echo OK - dist\AxCopilot\
|
--self-contained true ^
|
||||||
|
-o "%APP_OUT%" ^
|
||||||
|
--nologo ^
|
||||||
|
-v minimal ^
|
||||||
|
-p:DebugType=None ^
|
||||||
|
-p:DebugSymbols=false ^
|
||||||
|
-p:CopyOutputSymbolsToPublishDirectory=false ^
|
||||||
|
-p:EnableSourceLink=false ^
|
||||||
|
-p:PublishSingleFile=true ^
|
||||||
|
-p:EnableCompressionInSingleFile=true ^
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true ^
|
||||||
|
-p:PublishReadyToRun=true
|
||||||
|
if errorlevel 1 goto :fail_app
|
||||||
|
echo OK - %APP_OUT%
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ========================================
|
echo [2/5] Checking obfuscation / anti-decompile status...
|
||||||
:: 2. AxKeyEncryptor (developer tool)
|
if exist "%OBFUSCATOR_EXE%" (
|
||||||
:: ========================================
|
if exist "%OBFUSCATOR_CONFIG%" (
|
||||||
echo [2/4] Building AxKeyEncryptor (WinForms)...
|
echo Optional obfuscator found.
|
||||||
mkdir "%OUT%\AxKeyEncryptor" 2>nul
|
echo Running: "%OBFUSCATOR_EXE%"
|
||||||
dotnet publish "%ENCRYPTOR%" -c Release -o "%OUT%\AxKeyEncryptor" --self-contained false --nologo -v quiet
|
"%OBFUSCATOR_EXE%" "%OBFUSCATOR_CONFIG%" "%APP_OUT%"
|
||||||
if %ERRORLEVEL% NEQ 0 ( echo [FAILED] AxKeyEncryptor build & pause & exit /b 1 )
|
if errorlevel 1 goto :fail_obfuscation
|
||||||
del /q "%OUT%\AxKeyEncryptor\*.pdb" 2>nul
|
echo OK - obfuscation step completed
|
||||||
echo OK - dist\AxKeyEncryptor\
|
) else (
|
||||||
|
echo WARNING - no external obfuscator configured.
|
||||||
|
echo Current protection is limited to symbol/source metadata removal only.
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo WARNING - no external obfuscator configured.
|
||||||
|
echo Current protection is limited to symbol/source metadata removal only.
|
||||||
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ========================================
|
echo [3/5] Building AxKeyEncryptor...
|
||||||
:: 3. Create payload ZIP for installer
|
dotnet publish "%ENCRYPTOR%" ^
|
||||||
:: ========================================
|
-c Release ^
|
||||||
echo [3/4] Creating installer payload ZIP...
|
-o "%ENCRYPTOR_OUT%" ^
|
||||||
powershell -NoProfile -Command "Compress-Archive -Path '%OUT%\AxCopilot\*' -DestinationPath 'src\AxCopilot.Installer\payload.zip' -Force"
|
--self-contained false ^
|
||||||
echo OK - payload.zip
|
--nologo ^
|
||||||
|
-v minimal ^
|
||||||
|
-p:DebugType=None ^
|
||||||
|
-p:DebugSymbols=false
|
||||||
|
if errorlevel 1 goto :fail_encryptor
|
||||||
|
echo OK - %ENCRYPTOR_OUT%
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ========================================
|
echo [4/5] Creating installer payload ZIP...
|
||||||
:: 4. Build installer (.NET Framework 4.8)
|
powershell -NoProfile -Command "Compress-Archive -Path '%APP_OUT%\*' -DestinationPath '%PAYLOAD_ZIP%' -Force"
|
||||||
:: ========================================
|
if errorlevel 1 goto :fail_payload
|
||||||
echo [4/4] Building installer (.NET Framework 4.8)...
|
if not exist "%PAYLOAD_ZIP%" goto :fail_payload
|
||||||
dotnet build "%OFFLINE%" -c Release --nologo -v quiet
|
echo OK - %PAYLOAD_ZIP%
|
||||||
if %ERRORLEVEL% NEQ 0 ( echo [FAILED] Installer build & pause & exit /b 1 )
|
echo.
|
||||||
copy /Y "src\AxCopilot.Installer\bin\Release\net48\AxCopilot_Setup.exe" "%OUT%\" >nul
|
|
||||||
|
echo [5/5] Building installer (.NET Framework 4.8)...
|
||||||
|
dotnet build "%INSTALLER%" -c Release --nologo -v minimal
|
||||||
|
if errorlevel 1 goto :fail_installer
|
||||||
|
if not exist "%INSTALLER_EXE%" goto :fail_installer_copy
|
||||||
|
copy /Y "%INSTALLER_EXE%" "%OUT%\" >nul
|
||||||
|
if errorlevel 1 goto :fail_installer_copy
|
||||||
for %%F in ("%OUT%\AxCopilot_Setup.exe") do echo OK - AxCopilot_Setup.exe (%%~zF bytes)
|
for %%F in ("%OUT%\AxCopilot_Setup.exe") do echo OK - AxCopilot_Setup.exe (%%~zF bytes)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ========================================
|
echo [Cleanup] Removing debug and metadata files from dist...
|
||||||
:: Cleanup
|
call :clean_publish_artifacts "%OUT%"
|
||||||
:: ========================================
|
call :clean_publish_artifacts "%APP_OUT%"
|
||||||
:: Remove debug symbols and metadata (anti-decompile)
|
call :clean_publish_artifacts "%ENCRYPTOR_OUT%"
|
||||||
del /q "%OUT%\*.pdb" 2>nul
|
if exist "%PAYLOAD_ZIP%" del /q "%PAYLOAD_ZIP%" 2>nul
|
||||||
del /q "%OUT%\AxCopilot\*.pdb" 2>nul
|
echo OK - cleaned
|
||||||
del /q "%OUT%\AxCopilot\*.xml" 2>nul
|
echo.
|
||||||
del /q "%OUT%\*.deps.json" 2>nul
|
|
||||||
del /q "%OUT%\*.runtimeconfig.json" 2>nul
|
|
||||||
del /q "src\AxCopilot.Installer\payload.zip" 2>nul
|
|
||||||
|
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo Build Complete!
|
echo Build Complete!
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo.
|
echo.
|
||||||
echo dist\AxCopilot\ Main app (EXE + DLL)
|
echo %APP_OUT% Main app
|
||||||
echo dist\AxKeyEncryptor\ Settings Encryptor (dev tool)
|
echo %ENCRYPTOR_OUT% Settings Encryptor
|
||||||
echo dist\AxCopilot_Setup.exe Installer (offline, .NET 4.8)
|
echo %OUT%\AxCopilot_Setup.exe Installer
|
||||||
echo.
|
echo.
|
||||||
pause
|
echo Note:
|
||||||
|
echo - Release/self-contained single-file publish applied
|
||||||
|
echo - ReadyToRun + compressed single-file bundle enabled
|
||||||
|
echo - PDB/XML/debug metadata removed from dist output
|
||||||
|
echo - External obfuscator is only applied when tools\obfuscator is configured
|
||||||
|
echo.
|
||||||
|
popd >nul
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:stop_process
|
||||||
|
set "PROC_NAME=%~1"
|
||||||
|
set "DISPLAY_NAME=%~2"
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$name='%PROC_NAME%';" ^
|
||||||
|
"$display='%DISPLAY_NAME%';" ^
|
||||||
|
"$procs = Get-Process -Name $name -ErrorAction SilentlyContinue;" ^
|
||||||
|
"if (-not $procs) { exit 0 }" ^
|
||||||
|
"Write-Host ('[0] Stopping ' + $display + '...');" ^
|
||||||
|
"$imageName = $name + '.exe';" ^
|
||||||
|
"foreach ($proc in $procs) {" ^
|
||||||
|
" try { if ($proc.MainWindowHandle -ne 0) { [void]$proc.CloseMainWindow() } } catch { }" ^
|
||||||
|
"}" ^
|
||||||
|
"Start-Sleep -Seconds 2;" ^
|
||||||
|
"& taskkill /IM $imageName /T /F > $null 2> $null;" ^
|
||||||
|
"Start-Sleep -Seconds 2;" ^
|
||||||
|
"$stillRunning = Get-Process -Name $name -ErrorAction SilentlyContinue;" ^
|
||||||
|
"if ($stillRunning) {" ^
|
||||||
|
" Write-Host ('[FAILED] Could not stop ' + $display + '. Access may be denied or the app may be running with higher privileges.') -ForegroundColor Red;" ^
|
||||||
|
" exit 1" ^
|
||||||
|
"}" ^
|
||||||
|
"exit 0"
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:clean_publish_artifacts
|
||||||
|
if not exist "%~1" exit /b 0
|
||||||
|
del /q "%~1\*.pdb" 2>nul
|
||||||
|
del /q "%~1\*.xml" 2>nul
|
||||||
|
del /q "%~1\*.deps.json" 2>nul
|
||||||
|
del /q "%~1\*.runtimeconfig.json" 2>nul
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:fail_dist
|
||||||
|
echo [FAILED] dist ??????밴쉐 ??쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_app
|
||||||
|
echo [FAILED] main app publish ??쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_obfuscation
|
||||||
|
echo [FAILED] obfuscation ??m???쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_encryptor
|
||||||
|
echo [FAILED] AxKeyEncryptor publish ??쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_payload
|
||||||
|
echo [FAILED] payload.zip ??밴쉐 ??쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_installer
|
||||||
|
echo [FAILED] installer build ??쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_installer_copy
|
||||||
|
echo [FAILED] installer exe 癰귣벊沅???쎈솭
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:fail_running
|
||||||
|
echo [FAILED] running AX Copilot process could not be stopped cleanly
|
||||||
|
goto :end_fail
|
||||||
|
|
||||||
|
:end_fail
|
||||||
|
if exist "%PAYLOAD_ZIP%" del /q "%PAYLOAD_ZIP%" 2>nul
|
||||||
|
popd >nul
|
||||||
|
exit /b 1
|
||||||
|
|||||||
@@ -1,22 +1,38 @@
|
|||||||
# AX Agent Regression Prompts
|
# AX Agent Regression Prompts
|
||||||
|
|
||||||
업데이트: 2026-04-05 22:18 (KST)
|
업데이트: 2026-04-06 09:58 (KST)
|
||||||
|
|
||||||
`claw-code`와 AX Agent를 같은 기준으로 비교하기 위한 회귀 프롬프트 세트입니다.
|
`claw-code`와 AX Agent를 같은 기준으로 비교하기 위한 공통 회귀 프롬프트 세트입니다.
|
||||||
|
|
||||||
|
## 사용 규칙
|
||||||
|
|
||||||
|
- 런타임 동작, transcript 렌더, 권한/계획/질문 UX, queue/compact/reopen 흐름에 영향을 주는 변경 뒤에는 이 문서를 기준으로 최소 1회 점검합니다.
|
||||||
|
- 모든 항목을 매번 수동 실행할 필요는 없지만, 관련 축이 바뀌었으면 해당 묶음은 반드시 확인합니다.
|
||||||
|
- 결과는 “문장이 똑같은가”가 아니라 “실행 경로와 사용자 체감 결과가 같은가”를 봅니다.
|
||||||
|
|
||||||
|
## 실패 분류
|
||||||
|
|
||||||
|
- `blank-reply`: 토큰은 소비됐는데 본문이 비어 있거나 assistant 카드가 비어 있음
|
||||||
|
- `duplicate-banner`: 같은 실행 이벤트가 transcript에 중복 표시됨
|
||||||
|
- `bad-approval-flow`: 권한/계획/질문 요청이 inline으로 안 닫히고 popup 의존이 커짐
|
||||||
|
- `queue-drift`: 후속 요청, retry, regenerate가 다른 실행 경로를 타거나 순서가 어긋남
|
||||||
|
- `restore-drift`: reopen 후 상태선, queue, 최신 메시지 상태가 달라짐
|
||||||
|
- `status-noise`: Cowork/Code 기본 상태선이 과하게 흔들리거나 debug 정보가 과노출됨
|
||||||
|
|
||||||
## Chat
|
## Chat
|
||||||
|
|
||||||
1. 기본 답변
|
1. 기본 응답
|
||||||
- 프롬프트: `회의 일정 조정 메일을 정중한 한국어로 써줘`
|
- 프롬프트: `회의 일정 조정 메일을 정중한 한국어로 써줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- 빈 assistant 카드 없음
|
- `blank-reply`
|
||||||
- 재생성/재시도 후 transcript 중복 없음
|
- `restore-drift`
|
||||||
|
|
||||||
2. 장문 설명
|
2. 장문 설명
|
||||||
- 프롬프트: `RAG와 fine-tuning 차이를 실무 관점으로 7가지로 설명해줘`
|
- 프롬프트: `RAG와 fine-tuning 차이를 실무 관점으로 7가지로 설명해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- 장문 렌더 유지
|
- 장문 렌더 안정성
|
||||||
- compact 이후 다음 턴 문맥 유지
|
- compact 이후 다음 턴 문맥 유지
|
||||||
|
- `blank-reply`
|
||||||
|
|
||||||
## Cowork
|
## Cowork
|
||||||
|
|
||||||
@@ -25,14 +41,16 @@
|
|||||||
- 확인:
|
- 확인:
|
||||||
- 작업 유형 반영
|
- 작업 유형 반영
|
||||||
- 계획 이후 실제 문서형 결과 흐름
|
- 계획 이후 실제 문서형 결과 흐름
|
||||||
- 기본 로그 과다 노출 없음
|
- 기본 로그 과노출 없음
|
||||||
|
- `bad-approval-flow`
|
||||||
|
|
||||||
4. 데이터형 작업
|
4. 데이터형 작업
|
||||||
- 프롬프트: `매출 CSV를 분석해서 월별 추세와 이상치를 요약해줘`
|
- 프롬프트: `매출 CSV를 분석해서 월별 추세와 이상치를 요약해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- 데이터 분석형 도구 선택
|
- 데이터 분석 도구 선택
|
||||||
- 결과 요약 품질
|
- 결과 요약 일관성
|
||||||
- runtime 노이즈 최소화
|
- runtime 노이즈 최소화
|
||||||
|
- `status-noise`
|
||||||
|
|
||||||
## Code
|
## Code
|
||||||
|
|
||||||
@@ -40,40 +58,71 @@
|
|||||||
- 프롬프트: `현재 프로젝트에서 설정 저장 버그 원인 찾고 수정해줘`
|
- 프롬프트: `현재 프로젝트에서 설정 저장 버그 원인 찾고 수정해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- 읽기/검색/수정 흐름 일관성
|
- 읽기/검색/수정 흐름 일관성
|
||||||
- diff 저장
|
- diff/저장/재오픈 시 transcript 보존
|
||||||
- reopen 시 transcript 보존
|
- `restore-drift`
|
||||||
|
|
||||||
6. 빌드/테스트
|
6. 빌드/테스트
|
||||||
- 프롬프트: `빌드 오류를 재현하고 수정한 뒤 다시 빌드해줘`
|
- 프롬프트: `빌드 오류를 재현하고 수정한 뒤 다시 빌드해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- build/test 루프
|
- build/test 루프
|
||||||
- 실패 후 재시도
|
- 실패 후 재시도
|
||||||
- 완료 메시지 정합성
|
- 완료 메시지 일관성
|
||||||
|
- `queue-drift`
|
||||||
|
|
||||||
## Cross-tab
|
## Cross-tab
|
||||||
|
|
||||||
7. 후속 큐
|
7. 후속 요청
|
||||||
- 순차 프롬프트:
|
- 프롬프트 순서:
|
||||||
- `이 창 레이아웃 문제 원인 찾아줘`
|
- `이 창 레이아웃 문제 원인 찾아줘`
|
||||||
- `끝나면 README도 같이 갱신해줘`
|
- `끝나면 README도 같이 갱신해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- queue chaining
|
- queue chaining
|
||||||
- 입력창 직접 변형 없이 다음 턴 수행
|
- 입력창 직접 변경 없이 다음 턴 실행
|
||||||
|
- `queue-drift`
|
||||||
|
|
||||||
8. compact 이후 연속성
|
8. compact 이후 연속성
|
||||||
- 프롬프트: `지금까지 논의한 내용을 5줄로 이어서 정리하고 다음 작업 제안해줘`
|
- 프롬프트: `지금까지 논의한 내용을 5줄로 이어서 정리하고 다음 작업 제안해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- token-only completion 없음
|
- token-only completion 없음
|
||||||
- compact 후 문맥 유지
|
- compact 후 문맥 유지
|
||||||
|
- `queue-drift`
|
||||||
|
|
||||||
9. 권한 승인
|
9. 권한 승인
|
||||||
- 프롬프트: `이 파일을 수정해서 저장해줘`
|
- 프롬프트: `이 파일을 수정해서 저장해줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- 승인 요청 transcript 표시
|
- 권한 요청 transcript 표시
|
||||||
- 승인/거부 후 결과 정합성
|
- 승인/거부 결과 일관성
|
||||||
|
- `bad-approval-flow`
|
||||||
|
|
||||||
10. slash / skill
|
10. slash / skill
|
||||||
- 프롬프트: `/bug-hunt src 폴더 잠재 버그 찾아줘`
|
- 프롬프트: `/bug-hunt src 폴더 잠재 버그 찾아줘`
|
||||||
- 확인:
|
- 확인:
|
||||||
- slash 진입과 일반 send 경로 동일성
|
- slash 진입과 일반 send 경로 동일성
|
||||||
- skill 실행 이유/결과 표기
|
- skill 실행 이유/결과 표기
|
||||||
|
- `queue-drift`
|
||||||
|
|
||||||
|
## 개발 루틴 고정
|
||||||
|
|
||||||
|
- transcript, permission, tool-result, queue, compact, reopen에 영향을 주는 변경은 커밋 전 아래를 기준으로 셀프 체크합니다.
|
||||||
|
- Chat 변경: 1, 2, 8
|
||||||
|
- Cowork 변경: 3, 4, 7, 8
|
||||||
|
- Code 변경: 5, 6, 7, 9, 10
|
||||||
|
- 체크 후 문서 이력에는 “어떤 묶음을 확인했는지”를 간단히 남깁니다.
|
||||||
|
## Tool / Permission Follow-up
|
||||||
|
|
||||||
|
11. 권한 거부 후 재시도
|
||||||
|
- 프롬프트 순서:
|
||||||
|
- `src 폴더에서 설정 파일을 수정해줘`
|
||||||
|
- 첫 권한 요청은 거부
|
||||||
|
- 같은 작업을 다시 요청
|
||||||
|
- 확인:
|
||||||
|
- `reject`와 `approval_required`가 같은 결과 카드처럼 보이지 않음
|
||||||
|
- 재시도 시 권한 메시지와 도구 결과가 중복되지 않음
|
||||||
|
- `bad-approval-flow`
|
||||||
|
|
||||||
|
12. 부분 성공 / 후속 안내
|
||||||
|
- 프롬프트: `여러 문서 파일을 한 번에 읽고 요약해줘`
|
||||||
|
- 확인:
|
||||||
|
- 일부 실패가 있으면 `partial` 계열 안내가 보이는지
|
||||||
|
- 후속 안내 문구가 단순 실패와 다르게 보이는지
|
||||||
|
- `status-noise`
|
||||||
|
|||||||
@@ -1,4 +1,41 @@
|
|||||||
# AX Copilot - 媛쒕컻 臾몄꽌
|
- Document update: 2026-04-07 02:11 (KST) - Replaced the AX Agent internal-settings service API key input from PasswordBox to TextBox so Gemini/Claude keys can be entered reliably even while the overlay is resyncing service state.
|
||||||
|
|
||||||
|
- Document update: 2026-04-07 02:03 (KST) - Fixed broken Korean strings in Cowork preset selection, composer guidance, and live progress/process-feed rendering. Rewrote ChatWindow.TopicPresetPresentation, normalized live progress copy, and cleaned up loop gate guidance strings that had become mojibake.
|
||||||
|
- Document update: 2026-04-07 02:03 (KST) - Adjusted AX Agent progress visibility so Cowork/Code now shows an immediate live hint when a run starts and no longer clears that hint on every intermediate agent event. Neutralized cancellation wording to 작업이 중단되었습니다 for non-user interruption paths.
|
||||||
|
|
||||||
|
# AX Copilot - 媛쒕컻 臾몄꽌
|
||||||
|
|
||||||
|
- Document update: 2026-04-06 20:05 (KST) - Refined the AX Agent composer token indicator and send button surface. The context-usage circle now uses a roomier 32px shell with thinner ring strokes and a smaller threshold marker for a cleaner outline, while the send button now drops its redundant outer border and uses a more precisely centered arrow glyph.
|
||||||
|
- Document update: 2026-04-06 19:56 (KST) - Fine-tuned the AX Agent top tab vertical centering. The shared TopTabBtn style now uses explicit centered content alignment plus slightly reduced wrapper/button padding and height so 채팅 / Cowork / 코드 sit more evenly within the segmented control.
|
||||||
|
- Document update: 2026-04-06 19:46 (KST) - Renamed the AX Agent `Claw` preset label to `Claude` while keeping `claw` as a backward-compatible stored value alias. New settings default to `claude`, and old saved values are normalized on load.
|
||||||
|
- Document update: 2026-04-06 19:46 (KST) - Re-tuned theme palettes against real-world references: `AgentClawDark/Light/System` now follow Claude-like warm neutrals more closely, `AgentCodexDark` now matches the neutral charcoal reference more closely, `AgentNordLight/Dark/System` use the official Nord palette values, and `AgentSlateLight/Dark/System` were normalized toward a more canonical slate UI range.
|
||||||
|
- Document update: 2026-04-06 19:33 (KST) - Rebalanced AX Agent light theme hierarchy so the UI no longer feels visually inverted. `AgentCodexLight`, `AgentCodexSystem`, and `AgentClawLight` now use warmer launcher backgrounds, softer selected/hover states, and slightly lower-contrast borders to separate sidebar and content surfaces more like the `claw-code` reference.
|
||||||
|
- Document update: 2026-04-06 19:24 (KST) - Added `LauncherIndexProgressInfo` to `IndexService` so full launcher indexing now emits real progress/status updates, including estimated remaining time during path scans and a persisted completion summary after the run ends.
|
||||||
|
- Document update: 2026-04-06 19:24 (KST) - Moved the user-facing launcher index progress surface into `SettingsWindow` under the index-speed control. The settings screen now shows a progress bar plus status/detail text while indexing is active and keeps the latest completion summary visible afterward.
|
||||||
|
- Document update: 2026-04-06 18:46 (KST) - Tightened launcher indexing for real-world projects by ignoring `.git`, `.vs`, `node_modules`, `bin`, `obj`, `dist`, `packages`, `venv`, `__pycache__`, and `target` directories during both watcher handling and full scans. The launcher now skips noisy build/cache trees instead of reacting to them like searchable content.
|
||||||
|
- Document update: 2026-04-06 18:46 (KST) - Replaced the launcher's recursive `EnumerateFiles(..., AllDirectories)` walk with an ignore-aware manual traversal so large repos avoid descending into dependency and build folders, and slowed the rainbow glow timer from `20ms` to `40ms`.
|
||||||
|
- Document update: 2026-04-06 18:46 (KST) - Added a deferred responsive-layout timer to `ChatWindow` so AX Agent no longer calls `RenderMessages()` on every `SizeChanged` event. Resizing and layout changes are now coalesced into a single update after a short delay, reducing the heavy-window feel during agent-window manipulation.
|
||||||
|
- Document update: 2026-04-06 19:02 (KST) - Fixed theme glow settings so they apply to already-open windows instead of only affecting the next open. `LauncherWindow` now listens to `SettingsChanged` and reapplies border, icon animation, rainbow glow, and selection glow immediately, while `ChatWindow.RefreshFromSavedSettings()` now re-evaluates chat rainbow glow during active streaming.
|
||||||
|
- Document update: 2026-04-06 19:11 (KST) - Fixed the launcher index-status banner so it no longer lingers after indexing has effectively finished. Incremental watcher updates now refresh the in-memory/persisted index silently, and only full rebuild completion raises the visible `IndexRebuilt` signal consumed by `LauncherWindow`.
|
||||||
|
- Document update: 2026-04-06 15:31 (KST) - Removed the visible outer border from the Cowork/Code footer permission selector so it reads as a flatter inline action in the bottom work bar.
|
||||||
|
- Document update: 2026-04-06 15:26 (KST) - Reworked bottom guidance copy for Chat/Cowork/Code around the actual AX Agent behavior. Input watermark text now comes from a shared helper that considers the active tab and whether a work folder is selected, instead of using overly broad static text.
|
||||||
|
- Document update: 2026-04-06 15:26 (KST) - Adjusted the selected preset guide so it prefers concise preset descriptions over raw placeholder prompts, keeping the footer guide and the composer watermark in distinct roles.
|
||||||
|
- Document update: 2026-04-06 15:18 (KST) - Removed the vertical separator immediately to the left of the footer permission selector in Cowork/Code so the folder bar reads as a cleaner continuous action strip.
|
||||||
|
- Document update: 2026-04-06 15:12 (KST) - Simplified the shared AX Agent composer by removing the top-right preset selector from Chat/Cowork/Code. Increased the attach button by roughly 1.5x, matched the send button to the same square footprint, and re-centered both icons for a cleaner balanced action row.
|
||||||
|
- Document update: 2026-04-06 15:04 (KST) - Fixed the AX Agent user-message footer row overlap. Sent-time metadata and copy/edit actions no longer share the same overlay cell and now render in dedicated columns so hover actions cannot collide with the timestamp.
|
||||||
|
- Document update: 2026-04-06 09:27 (KST) - Re-framed the remaining `claw-code` parity work into three explicit tracks: user-facing UI/UX quality, LLM/task-handling quality, and maintainability/extensibility structure. This separates visible polish from runtime truth and from architectural cleanup.
|
||||||
|
- Document update: 2026-04-06 09:27 (KST) - Captured the new track plan in `docs/claw-code-parity-plan.md` with reference files, AX apply targets, completion criteria, quality criteria, and recommended execution order so future work can be prioritized more deliberately.
|
||||||
|
|
||||||
|
- Document update: 2026-04-06 09:14 (KST) - Split worktree-selection popup rendering into `ChatWindow.SelectionPopupPresentation.cs`. The current workspace/worktree chooser and the shared selected-row popup card renderer now live outside `ChatWindow.xaml.cs`, reducing footer selection UI density in the main window file.
|
||||||
|
- Document update: 2026-04-06 09:14 (KST) - This keeps branch/worktree/footer chooser UX on the same presentation side of the codebase and leaves the main chat window more focused on runtime orchestration and conversation flow.
|
||||||
|
|
||||||
|
- Document update: 2026-04-06 09:03 (KST) - Split common themed popup construction out of `ChatWindow.xaml.cs` into `ChatWindow.PopupPresentation.cs`. Shared popup container creation, generic popup menu items/separators, and the recent-folder context menu now live in a dedicated partial instead of the main window orchestration file.
|
||||||
|
- Document update: 2026-04-06 09:03 (KST) - This keeps footer/file-browser popup styling on a single visual path and reduces direct popup composition inside the main chat window flow, making further `claw-code` style popup UX work easier to maintain.
|
||||||
|
|
||||||
|
- Document update: 2026-04-06 07:31 (KST) - Split permission presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.PermissionPresentation.cs`. Permission popup row construction, popup refresh, section expansion persistence, and permission banner/status styling now live in a dedicated partial instead of the main window orchestration file.
|
||||||
|
- Document update: 2026-04-06 07:31 (KST) - Split context usage card/popup rendering into `ChatWindow.ContextUsagePresentation.cs`. The Cowork/Code context usage ring, tooltip popup copy, hover close behavior, and screen-coordinate hit testing are now isolated from the rest of the chat window flow.
|
||||||
|
- Document update: 2026-04-06 01:37 (KST) - Reworked AX Agent plan approval toward a more transcript-native flow. The inline decision card remains the primary approval path, while the `계획` affordance now opens the stored plan as a detail-only surface instead of acting like a required popup step.
|
||||||
|
- Document update: 2026-04-06 01:37 (KST) - Expanded `OperationalStatusPresentationState` so runtime badge, compact strip, and quick-strip labels/colors/visibility are calculated together. `ChatWindow` now consumes a richer presentation model instead of branching on strip kinds and quick-strip counters independently.
|
||||||
|
|
||||||
- Document update: 2026-04-05 19:04 (KST) - Added transcript-facing tool/skill display normalization in `ChatWindow.xaml.cs`. Tool and skill events now use role-first badges (`도구`, `도구 결과`, `스킬`) with human-readable item labels such as `파일 읽기`, `빌드/실행`, `Git`, or `/skill-name`, instead of exposing raw snake_case names as the primary visual label.
|
- Document update: 2026-04-05 19:04 (KST) - Added transcript-facing tool/skill display normalization in `ChatWindow.xaml.cs`. Tool and skill events now use role-first badges (`도구`, `도구 결과`, `스킬`) with human-readable item labels such as `파일 읽기`, `빌드/실행`, `Git`, or `/skill-name`, instead of exposing raw snake_case names as the primary visual label.
|
||||||
- Document update: 2026-04-05 19:04 (KST) - Reduced task-summary observability noise by limiting permission/background observability sections to debug-level sessions. Default AX Agent runtime UX now stays closer to `claw-code`, where transcript reading flow remains primary and diagnostics stay secondary.
|
- Document update: 2026-04-05 19:04 (KST) - Reduced task-summary observability noise by limiting permission/background observability sections to debug-level sessions. Default AX Agent runtime UX now stays closer to `claw-code`, where transcript reading flow remains primary and diagnostics stay secondary.
|
||||||
@@ -4851,5 +4888,446 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
|||||||
- [PermissionRequestWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PermissionRequestWindow.cs) 의 `BuildPreviewCard`, `BuildFileEditPreviewCard`, `BuildFileWriteTwoColumnPreviewCard`는 이 helper를 쓰도록 정리해 권한 승인 프리뷰가 같은 preview 언어를 따르도록 맞췄다.
|
- [PermissionRequestWindow.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/PermissionRequestWindow.cs) 의 `BuildPreviewCard`, `BuildFileEditPreviewCard`, `BuildFileWriteTwoColumnPreviewCard`는 이 helper를 쓰도록 정리해 권한 승인 프리뷰가 같은 preview 언어를 따르도록 맞췄다.
|
||||||
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 우측 프리뷰 패널 헤더를 `파일명 / 경로 / 형식·크기 메타` 구조로 재배치하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `SetPreviewHeader`, `SetPreviewHeaderState`를 추가해 현재 탭 파일 메타를 표시하도록 했다.
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 우측 프리뷰 패널 헤더를 `파일명 / 경로 / 형식·크기 메타` 구조로 재배치하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `SetPreviewHeader`, `SetPreviewHeaderState`를 추가해 현재 탭 파일 메타를 표시하도록 했다.
|
||||||
- 텍스트 프리뷰 본문도 bordered preview box 안에 렌더되게 바꿔, AX Agent 파일 프리뷰와 transcript/승인 프리뷰 사이 시각 언어를 더 가깝게 맞췄다.
|
- 텍스트 프리뷰 본문도 bordered preview box 안에 렌더되게 바꿔, AX Agent 파일 프리뷰와 transcript/승인 프리뷰 사이 시각 언어를 더 가깝게 맞췄다.
|
||||||
|
- Document update: 2026-04-06 00:50 (KST) - Reworked Chat/Cowork preset hover behavior in `BuildTopicButtons()`. The previous inline hover label overlay was removed and replaced with a stable tooltip-style description so preset cards only change background/border on hover, eliminating the repeated flicker effect caused by overlay-style label toggling.
|
||||||
|
|
||||||
- Document update: 2026-04-06 00:45 (KST) - Restored the missing AX Agent internal-settings UX for conversation-style persistence by adding a visible `대화 스타일` section header above the existing `문서 형태` and `디자인 스타일` controls. The underlying save path already existed in `CmbOverlayDefaultOutputFormat_SelectionChanged` and `CmbOverlayDefaultMood_SelectionChanged`; this pass makes that persisted behavior explicit again in the overlay.
|
- Document update: 2026-04-06 00:45 (KST) - Restored the missing AX Agent internal-settings UX for conversation-style persistence by adding a visible `대화 스타일` section header above the existing `문서 형태` and `디자인 스타일` controls. The underlying save path already existed in `CmbOverlayDefaultOutputFormat_SelectionChanged` and `CmbOverlayDefaultMood_SelectionChanged`; this pass makes that persisted behavior explicit again in the overlay.
|
||||||
- Document update: 2026-04-06 00:45 (KST) - Restored MCP server management inside the AX Agent internal settings overlay. The skill tab now exposes `+ 서버 추가`, and overlay MCP cards support inline enable/disable and delete actions with immediate persistence through `PersistOverlaySettingsState(...)`.
|
- Document update: 2026-04-06 00:45 (KST) - Restored MCP server management inside the AX Agent internal settings overlay. The skill tab now exposes `+ 서버 추가`, and overlay MCP cards support inline enable/disable and delete actions with immediate persistence through `PersistOverlaySettingsState(...)`.
|
||||||
|
- 업데이트: 2026-04-06 00:35 (KST)
|
||||||
|
- `ChatWindow.xaml.cs`의 프리셋 카드 hover 처리에서 WPF 기본 ToolTip을 제거했습니다. 이제 hover는 배경/테두리만 바뀌고 tooltip에 의한 enter/leave 반복이 줄어듭니다.
|
||||||
|
- 업데이트: 2026-04-06 00:42 (KST)
|
||||||
|
- `ChatWindow` 하단 Git 브랜치 UI를 단순화했습니다. 변경 파일 수/추가/삭제 수치는 기본 버튼에서는 숨기고 브랜치명 중심의 선택 버튼처럼 보이게 조정했습니다.
|
||||||
|
- 업데이트: 2026-04-05 22:26 (KST)
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에 `IsFolderDataAlwaysEnabledTab()` helper를 추가하고, 코드 탭에서는 `_folderDataUsage`를 항상 `active`로 로드/저장하도록 고정했다. 이에 맞춰 `BtnDataUsage_Click`, `UpdateDataUsageUI()`, 오버레이의 `OverlayFolderDataUsageRow`, `BtnOverlayFolderDataUsage_Click`, `CmbOverlayFolderDataUsage_SelectionChanged`도 코드 탭에서는 숨김 또는 강제 active만 유지하게 정리했다.
|
||||||
|
- 같은 파일의 사용자 메시지 bubble 렌더에서 코워크/코드 탭은 plain `TextBlock` 대신 [MarkdownRenderer.Render](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/MarkdownRenderer.cs) 경로를 타도록 변경했다. 이로써 코워크/코드 사용자 입력 안의 파일명/파일 경로가 assistant 응답과 동일한 파란 강조 규칙을 쓰게 됐다.
|
||||||
|
- 업데이트: 2026-04-05 22:29 (KST)
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `SelectTopic(...)`에서 fresh conversation이 이미 있는 경우에는 `StartNewConversation()`를 다시 호출하지 않도록 보정했다. 기존에는 메시지도 입력도 없는 빈 대화에서 프리셋만 눌러도 새 conversation이 계속 생성되어 좌측 목록에 `새 대화`가 누적될 수 있었다.
|
||||||
|
- 현재는 `_currentConversation` 존재 여부를 기준으로 빈 대화 재사용과 실제 신규 생성 경로를 분리해, 프리셋 클릭은 같은 새 대화 안에서 메타데이터만 갱신하도록 맞췄다.
|
||||||
|
- 업데이트: 2026-04-05 22:32 (KST)
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 `ChkOverlayWorkflowVisualizer`, `ChkOverlayShowTotalCallStats`, `ChkOverlayEnableAuditLog`에 `Checked/Unchecked` 이벤트를 연결했다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 각 토글 전용 저장 handler를 추가해, 개발자 탭 옵션이 눌린 직후 오버레이 재동기화로 기본값으로 되돌아가던 문제를 해결했다.
|
||||||
|
- 업데이트: 2026-04-05 22:36 (KST)
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 의 훅 설정 섹션에서 `도구 훅 스크립트 제한 시간` row 오른쪽 컬럼을 `120 -> 180`, 값 배지 컬럼을 `44 -> 56`으로 늘렸다.
|
||||||
|
- 같은 파일의 `등록된 훅` 헤더 우측 `훅 추가` 버튼은 좌측 여백과 최소 폭을 키워, 작은 창에서도 텍스트 잘림 없이 보이도록 정리했다.
|
||||||
|
- 업데이트: 2026-04-05 22:40 (KST)
|
||||||
|
- AX Agent 테마 리소스 구조를 다시 점검한 뒤 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 프리셋을 추가했다. 새 리소스 파일은 [AgentNordLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordLight.xaml), [AgentNordDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordDark.xaml), [AgentNordSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordSystem.xaml), [AgentEmberLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberLight.xaml), [AgentEmberDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberDark.xaml), [AgentEmberSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberSystem.xaml) 이다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildAgentThemeDictionaryUri()`, `RefreshOverlayThemeCards()`에 새 프리셋 분기를 추가했고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `테마 스타일` 선택 카드에도 `Nord`, `Ember`를 노출했다.
|
||||||
|
- 업데이트: 2026-04-06 00:58 (KST)
|
||||||
|
- transcript renderer 분리 1차를 반영했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs)에 `ShowInlineUserAskAsync`, `CreatePlanDecisionCallback`, `ShowPlanButton`, `EnsurePlanViewerWindow` 계열을 옮기고, [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)에 `ShowTaskSummaryPopup`, `BuildTaskSummaryCard`를 옮겨 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 transcript 책임을 줄였다.
|
||||||
|
- 권한/도구 결과 presentation catalog를 추가했다. [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령/네트워크/파일/일반` 권한 요청·허용 메타를, [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success/error/reject/cancel` 기준의 도구 결과 badge 메타를 제공한다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)`는 이제 이 catalog를 사용해 권한 요청과 도구 결과 badge를 결정한다.
|
||||||
|
- [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState`와 `GetOperationalStatusPresentation(...)`을 추가해 status line/runtime summary 계산을 presentation layer로 분리했다. `UpdateTaskSummaryIndicators()`는 presentation summary만 소비하도록 바뀌었다.
|
||||||
|
- 업데이트: 2026-04-06 01:12 (KST)
|
||||||
|
- 코워크/코드의 `폴더 내 문서 활용`은 사용자 제어 옵션에서 제거하고 탭별 자동 정책으로 고정했다. [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) 에서 하단 데이터 활용 버튼과 AX Agent 내부 설정의 관련 row를 제거했고, [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [AgentSettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/AgentSettingsWindow.xaml) 의 대응 UI도 정리했다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 는 이제 `GetAutomaticFolderDataUsage()`만 사용해 채팅=`none`, 코워크=`passive`, 코드=`active`를 적용한다. 오버레이 저장에서도 `llm.FolderDataUsage`를 더 이상 사용자 입력으로 덮어쓰지 않으며, UI 클릭/선택 변경 핸들러는 자동 정책 유지용 no-op 수준으로 축소했다.
|
||||||
|
- 업데이트: 2026-04-06 01:24 (KST)
|
||||||
|
- transcript display catalog를 `claw-code` 기준으로 정교화했다. [AgentTranscriptDisplayCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs)는 도구/스킬 이름과 badge label을 `파일 / 문서 / 빌드 / Git / 웹 / 질문 / 제안 / 에이전트` 축으로 재정의했고, summary fallback 문구도 더 자연스러운 한국어로 정리했다.
|
||||||
|
- [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령 실행 / 웹 요청 / 스킬 실행 / 의견 요청 / 파일 수정 / 파일 접근` 권한 요청을 타입별 색상/라벨로 분기하게 바꿨다. [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 도구 결과를 `파일 작업`, `빌드/테스트`, `Git`, `문서`, `스킬`, `웹 요청`, `명령 실행` 기준으로 성공/실패 라벨을 더 세밀하게 반환한다.
|
||||||
|
- 이벤트 배너 renderer를 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 로 분리했다. 기존 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 있던 `CreateCompactEventPill`, `AddAgentEventBanner`, `GetDecisionBadgeMeta`를 별도 partial로 옮겨, 이후 `permission/tool-result/plan` 타입별 renderer 확장을 더 쉽게 할 수 있는 구조를 마련했다.
|
||||||
|
- Document update: 2026-04-06 08:12 (KST) - Split footer/preset/Git popup presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.FooterPresentation.cs`. The folder bar refresh, selected preset guide, Git branch surface update, Git popup assembly, and popup summary-pill/row helpers now live in a dedicated partial instead of the main window orchestration file.
|
||||||
|
- Document update: 2026-04-06 08:12 (KST) - This pass keeps the AX Agent transcript/runtime flow closer to the `claw-code` separation model by reducing UI assembly inside the main chat window file and isolating footer/prompt-adjacent presentation code for future parity work.
|
||||||
|
- Document update: 2026-04-06 08:28 (KST) - Split composer/draft queue presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.ComposerQueuePresentation.cs`. Input-box height calculation, draft-kind inference, queue summary pills, compact queue cards, expanded queue sections, queue badges, and queue action buttons now live in a dedicated partial.
|
||||||
|
- Document update: 2026-04-06 08:28 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused while preserving the same runtime behavior, and it aligns AX Agent more closely with the `claw-code` model of separating prompt/footer presentation from session execution logic.
|
||||||
|
- Document update: 2026-04-06 08:27 (KST) - Split message interaction presentation out of `ChatWindow.xaml.cs` into `ChatWindow.MessageInteractions.cs`. Feedback button creation, assistant response meta text, transcript entry animation, and inline user-message edit/regenerate flow are now grouped in a dedicated partial.
|
||||||
|
- Document update: 2026-04-06 08:27 (KST) - This pass further reduces renderer responsibility in the main chat window file and moves AX Agent closer to the `claw-code` structure where transcript orchestration and message interaction presentation are separated.
|
||||||
|
- Document update: 2026-04-06 08:39 (KST) - Moved runtime status bar event handling and spinner animation out of `ChatWindow.xaml.cs` into `ChatWindow.StatusPresentation.cs`. `UpdateStatusBar`, `StartStatusAnimation`, and `StopStatusAnimation` now live alongside the existing operational status presentation helpers.
|
||||||
|
- Document update: 2026-04-06 08:39 (KST) - This pass reduces direct status-line branching in the main chat window file and keeps AX Agent closer to the `claw-code` model where runtime/footer presentation is separated from session orchestration.
|
||||||
|
- Document update: 2026-04-06 08:47 (KST) - Split preview surface rendering out of `ChatWindow.xaml.cs` into `ChatWindow.PreviewPresentation.cs`. Preview tab tracking, panel open/close, tab bar rebuilding, preview header state, CSV/text/markdown/HTML loaders, context menu actions, and external popup preview are now grouped in a dedicated partial.
|
||||||
|
- Document update: 2026-04-06 08:47 (KST) - This keeps `ChatWindow.xaml.cs` focused on transcript/runtime orchestration and aligns AX Agent more closely with the `claw-code` model where preview/session surfaces are treated as separate presentation layers.
|
||||||
|
- Document update: 2026-04-06 08:55 (KST) - Split file browser presentation out of `ChatWindow.xaml.cs` into `ChatWindow.FileBrowserPresentation.cs`. File browser open/close handlers, folder tree population, file item header/icon/size formatting, context menu actions, and debounced refresh logic now live in a dedicated partial.
|
||||||
|
- Document update: 2026-04-06 08:55 (KST) - This keeps `ChatWindow.xaml.cs` more orchestration-focused and aligns AX Agent more closely with the `claw-code` model where sidebar/file surfaces are separated from transcript and runtime flow.
|
||||||
|
- Document update: 2026-04-06 09:36 (KST) - Added `OperationalStatusPresentationCatalog.cs` and moved compact-strip / quick-strip styling decisions out of `AppStateService.GetOperationalStatusPresentation(...)`. The service now delegates runtime/status presentation shaping to a dedicated catalog instead of mixing state aggregation with UI color logic.
|
||||||
|
- Document update: 2026-04-06 09:36 (KST) - Expanded `PermissionRequestPresentationCatalog.cs` and `ToolResultPresentationCatalog.cs` with `Kind` and `Description` metadata so permission/tool-result transcript entries carry typed explanatory text rather than badge-only labels. `ChatWindow.AgentEventRendering.cs` now uses that description as a fallback summary when the event summary is empty.
|
||||||
|
- Document update: 2026-04-06 09:36 (KST) - Removed the stale `Plan` option from `PermissionModePresentationCatalog.cs` and simplified `ChatWindow.PermissionPresentation.cs` so the permission popup and top-banner presentation only expose the live modes (`권한 요청`, `편집 자동 승인`, `권한 건너뛰기`, `읽기 전용`).
|
||||||
|
- Document update: 2026-04-06 09:44 (KST) - Split inline interaction rendering further by replacing `ChatWindow.InlineInteractions.cs` with `ChatWindow.UserAskPresentation.cs` and `ChatWindow.PlanApprovalPresentation.cs`. User-question cards and plan approval/detail flows now live in dedicated partials instead of sharing one mixed interaction file.
|
||||||
|
- Document update: 2026-04-06 09:44 (KST) - At this point the completed structure-improvement items are: status presentation cataloging, permission/tool-result catalog enrichment, permission UI cleanup, and ask/plan renderer separation. The remaining larger tracks are footer/composer work-bar refinement and enforcing the regression prompt ritual in day-to-day development.
|
||||||
|
- Document update: 2026-04-06 09:58 (KST) - Split Git branch popup assembly and footer-adjacent summary helpers out of `ChatWindow.FooterPresentation.cs` into `ChatWindow.GitBranchPresentation.cs`. The footer presentation partial now focuses on folder-bar state and selected-preset guide synchronization instead of mixed popup rendering.
|
||||||
|
- Document update: 2026-04-06 09:58 (KST) - Rewrote `docs/AX_AGENT_REGRESSION_PROMPTS.md` into a repeatable regression ritual with explicit failure classes (`blank-reply`, `duplicate-banner`, `bad-approval-flow`, `queue-drift`, `restore-drift`, `status-noise`) and required prompt bundles per change area so runtime/transcript work can be validated consistently.
|
||||||
|
- Document update: 2026-04-06 10:07 (KST) - Split topic preset rendering and selection flow out of `ChatWindow.xaml.cs` into `ChatWindow.TopicPresetPresentation.cs`. Preset card creation, custom preset dialogs/context menus, and `SelectTopic(...)` metadata application now live in a dedicated partial.
|
||||||
|
- Document update: 2026-04-06 10:07 (KST) - This keeps the main chat window more orchestration-focused and narrows the remaining maintainability work to small follow-up polish rather than any large structural split.
|
||||||
|
- Document update: 2026-04-06 10:18 (KST) - Split conversation list rendering out of `ChatWindow.xaml.cs` into `ChatWindow.ConversationListPresentation.cs`. Conversation meta filtering, spotlight calculation, grouped list rendering, load-more pagination, and sidebar conversation-row assembly now live in a dedicated partial.
|
||||||
|
- Document update: 2026-04-06 10:18 (KST) - This keeps the main chat window more focused on transcript/runtime orchestration while isolating sidebar list presentation for future UX tuning and parity work.
|
||||||
|
- Document update: 2026-04-06 10:27 (KST) - Split transcript message-row assembly out of `ChatWindow.xaml.cs` into `ChatWindow.MessageBubblePresentation.cs`. The shared `AddMessageBubble(...)` path for user/assistant bubbles, branch-context cards, inline action bars, and assistant meta rows now lives in a dedicated presentation partial.
|
||||||
|
- Document update: 2026-04-06 10:27 (KST) - This keeps the main chat window more orchestration-focused while making transcript row rendering easier to tune and extend independently.
|
||||||
|
- Document update: 2026-04-06 10:36 (KST) - Split timeline visibility filtering and render-action assembly out of `RenderMessages()` into `ChatWindow.TimelinePresentation.cs`. Visible message/event selection and timestamp-ordered timeline action construction now live in dedicated helpers.
|
||||||
|
- Document update: 2026-04-06 10:36 (KST) - This keeps `RenderMessages()` closer to a simple orchestration loop and reduces mixed responsibilities inside the main chat window file.
|
||||||
|
- Document update: 2026-04-06 10:44 (KST) - Continued timeline presentation cleanup by moving `CreateTimelineLoadMoreCard`, `ToAgentEvent`, `IsCompactionMetaMessage`, and `CreateCompactionMetaCard` into `ChatWindow.TimelinePresentation.cs`.
|
||||||
|
- Document update: 2026-04-06 10:44 (KST) - This consolidates timeline-related helpers in one place and leaves the main chat window file with less transcript-specific rendering logic around `RenderMessages()`.
|
||||||
|
- Document update: 2026-04-06 10:56 (KST) - Split conversation-management interactions out of `ChatWindow.xaml.cs` into `ChatWindow.ConversationManagementPresentation.cs`. Inline title editing and the conversation action popup (pin/unpin, rename, category change, delete) now live in a dedicated presentation partial.
|
||||||
|
- Document update: 2026-04-06 10:56 (KST) - With this pass, the remaining large structure-improvement track is effectively complete; follow-up work is now mostly UX polish and surface-level tuning rather than further decomposition of the main chat window orchestration file.
|
||||||
|
- Document update: 2026-04-06 11:03 (KST) - Split sidebar search/new-chat interactions out of `ChatWindow.xaml.cs` into `ChatWindow.SidebarInteractionPresentation.cs`. Hover state changes, sidebar search open/close transitions, and new-chat trigger behavior now live in a dedicated presentation partial.
|
||||||
|
- Document update: 2026-04-06 11:03 (KST) - This leaves the main chat window even more orchestration-focused and effectively closes the last obvious sidebar interaction block from the large structure-improvement plan. Remaining work is now follow-up UX polish rather than major decomposition.
|
||||||
|
- Document update: 2026-04-06 11:11 (KST) - Split conversation list filter/sort interactions and preference persistence out of `ChatWindow.xaml.cs` into `ChatWindow.ConversationFilterPresentation.cs`. Running-only toggles, recent/activity sort toggles, UI-state refresh, and conversation-list preference apply/persist logic now live in a dedicated presentation partial.
|
||||||
|
- Document update: 2026-04-06 11:11 (KST) - This keeps sidebar state presentation more cohesive and further narrows the main chat window file toward orchestration-only responsibilities. Remaining work is now post-plan polish rather than another major structural split.
|
||||||
|
- Document update: 2026-04-06 11:20 (KST) - Added `ChatWindow.SurfaceVisualPresentation.cs` and introduced shared surface helpers for popup containers, popup menu rows, separators, and file-tree headers. `ChatWindow.PreviewPresentation.cs` and `ChatWindow.FileBrowserPresentation.cs` now use the same popup/surface language instead of duplicating slightly different visual styles.
|
||||||
|
- Document update: 2026-04-06 11:20 (KST) - This is the first visual-language polish pass after the large structure split. It reduces the feeling that preview and file browser are separate widgets and makes future popup/surface refinements reusable.
|
||||||
|
- Document update: 2026-04-06 11:27 (KST) - Continued popup visual-language unification by switching `ChatWindow.PopupPresentation.cs` to the shared surface helpers and aligning selection/permission rows in `ChatWindow.SelectionPopupPresentation.cs` and `ChatWindow.PermissionPresentation.cs` with the same border, hover, and selected-state rules.
|
||||||
|
- Document update: 2026-04-06 11:27 (KST) - At this point preview, file browser, worktree chooser, and permission-mode popup surfaces all share nearly the same visual language; remaining work is minor spacing/color polish rather than another behavior or structure change.
|
||||||
|
- Document update: 2026-04-06 11:34 (KST) - Added `FooterChipBtn` to `ChatWindow.xaml` and aligned the footer work-bar buttons (`권한`, `Git 브랜치`) to the same rounded border, padding, and hover language instead of mixing outline and ghost button styles.
|
||||||
|
- Document update: 2026-04-06 11:34 (KST) - This is a footer polish follow-up after the structure split, aimed at making the bottom work bar feel like one coherent tool row rather than a set of unrelated controls.
|
||||||
|
- Document update: 2026-04-06 11:52 (KST) - Expanded `PermissionRequestPresentationCatalog.cs` into an action-level permission taxonomy (`bash`, `powershell`, `command`, `web_fetch`, `mcp`, `skill`, `question`, `file_edit`, `file_write`, `git`, `document`, `filesystem`) and added `ActionHint`, `Severity`, `RequiresPreview` metadata for later renderer refinement.
|
||||||
|
- Document update: 2026-04-06 11:52 (KST) - Expanded `ToolResultPresentationCatalog.cs` with richer result taxonomy and post-result guidance. AX now distinguishes `approval_required` and `partial` in addition to `success / error / reject / cancel`, and each result carries `FollowUpHint` and `NeedsAttention`.
|
||||||
|
- Document update: 2026-04-06 11:52 (KST) - Extended `SkillGalleryWindow.xaml.cs` to expose runtime-policy metadata (`model`, `effort`, `execution context`, `agent`, `disable model invocation`, `when to use`) so AX skills are easier to inspect and maintain like `claw-code` bundled skills.
|
||||||
|
- Document update: 2026-04-06 13:01 (KST) - Wired the new permission/result metadata into transcript rendering. `ChatWindow.AgentEventRendering.cs` now appends action-level guidance and attention chips to permission/tool-result event banners instead of using badge labels alone.
|
||||||
|
- Document update: 2026-04-06 13:01 (KST) - Permission events now surface `ActionHint`, `Severity`, and `RequiresPreview` through inline cues such as `미리보기 권장`, `주의 필요`, and `검토 권장`, while tool-result events surface `FollowUpHint`, `NeedsAttention`, and `StatusKind` through cues such as `확인 필요`, `승인 후 계속`, and `후속 점검`.
|
||||||
|
- Document update: 2026-04-06 13:08 (KST) - Refined the transcript chips to visually differentiate metadata states instead of rendering every cue in the same neutral style. `ChatWindow.AgentEventRendering.cs` now uses blue for preview guidance, red for high-severity attention, amber for review/partial follow-up, and orange for approval-required continuation cues.
|
||||||
|
- Document update: 2026-04-06 13:14 (KST) - Added kind/category chips to permission and tool-result transcript banners so the action domain is visible at a glance. `ChatWindow.AgentEventRendering.cs` now surfaces labels such as `명령 실행`, `파일 수정`, `웹 요청`, `Git`, `문서`, `스킬`, and `MCP` alongside the status-oriented chips.
|
||||||
|
- Document update: 2026-04-06 13:20 (KST) - Replaced the plain guidance line with typed callout boxes in `ChatWindow.AgentEventRendering.cs`. Permission requests now show an `확인 포인트` callout, while tool results show a `다음 권장 작업` callout, making the two transcript surfaces read differently even when they share the same chip language.
|
||||||
|
- Document update: 2026-04-06 13:26 (KST) - Added an inline `프리뷰 열기` action to file-backed permission/tool-result banners in `ChatWindow.AgentEventRendering.cs`. When a permission request requires preview or a tool result carries a real file path, the transcript card can now open the preview panel directly instead of forcing the user to find the file elsewhere first.
|
||||||
|
- Document update: 2026-04-06 13:31 (KST) - Refined the callout presentation in `ChatWindow.AgentEventRendering.cs` so the title and left accent strip vary by state. Permission requests now read as `확인 포인트` or `적용 내용`, while tool results read as `승인 필요`, `오류 확인`, `부분 완료 점검`, or `다음 권장 작업` depending on `StatusKind`.
|
||||||
|
- Document update: 2026-04-06 13:36 (KST) - Made file-backed transcript actions state-aware in `ChatWindow.AgentEventRendering.cs`. The inline preview button now changes label by context, such as `변경 확인`, `작성 내용 보기`, `부분 결과 보기`, `오류 파일 보기`, or `승인 전 미리보기`, instead of showing the same generic action for every case.
|
||||||
|
- Document update: 2026-04-06 14:06 (KST) - Diagnosed IBM-connected vLLM authentication failures and confirmed AX only supported `bearer` and `cp4d` registered-model auth modes. Added `IbmIamTokenService.cs` and wired a new `ibm_iam` auth mode into `LlmService.cs` so IBM Cloud API keys are exchanged for IAM access tokens before being sent as Bearer credentials.
|
||||||
|
- Document update: 2026-04-06 14:06 (KST) - Updated the registered-model schema and UI surfaces to expose the new auth mode. `AppSettings.cs`, `SettingsViewModel.cs`, `ModelRegistrationDialog.cs`, and the AX Agent overlay model list in `ChatWindow.xaml.cs` now save/display `IBM IAM` alongside existing `Bearer` and `CP4D` modes.
|
||||||
|
- Document update: 2026-04-06 15:26 (KST) - Improved AX Agent window drag performance by handling `WM_ENTERSIZEMOVE` / `WM_EXITSIZEMOVE` in `ChatWindow.xaml.cs`. The window now enters a lightweight move-size mode while being dragged or resized.
|
||||||
|
- Document update: 2026-04-06 15:26 (KST) - During move-size loops the root visual is temporarily cached with `BitmapCache`, and the expensive `UpdateTopicPresetScrollMode()`, `UpdateResponsiveChatLayout()`, and `RenderMessages()` refresh path is deferred until the move/resize operation ends. This reduces the “heavy” feeling when moving the AX Agent window on desktop PCs.
|
||||||
|
- Document update: 2026-04-06 15:35 (KST) - Reordered the AX Agent internal-settings common tab in `ChatWindow.xaml` so `서비스와 모델` and `등록 모델 관리` now appear consecutively. This matches the actual model-management flow more closely.
|
||||||
|
- Document update: 2026-04-06 15:35 (KST) - Moved the `운영 모드` chooser below the conversation management/storage area and restored section separators around the moved blocks so the common-tab layout reads in clearer grouped sections.
|
||||||
|
- Document update: 2026-04-06 15:41 (KST) - Reworked the empty-state layout in `ChatWindow.xaml` so the icon/title/description block and the preset grid live inside one vertically centered stack. This fixes the previous behavior where only the preset cards looked centered while the descriptive header stayed visually high on tall windows.
|
||||||
|
- Document update: 2026-04-06 15:48 (KST) - Hardened the unlock-reminder popup auto-close path in `ReminderPopupWindow.xaml.cs`. The window now tracks an absolute close deadline and uses a separate `Task.Delay + CancellationToken` fail-safe to close the popup even if the UI timer tick is delayed.
|
||||||
|
- Document update: 2026-04-06 15:48 (KST) - The countdown bar still updates through `DispatcherTimer`, but the actual close action is now guaranteed by the background delay path. This reduces cases where encouragement popups remain visible after the configured display duration.
|
||||||
|
- Document update: 2026-04-06 16:02 (KST) - Relaxed AX Cowork document-generation defaults so the planner/assembler path no longer collapses to `html + professional` whenever explicit arguments are omitted. `DocumentPlannerTool.cs` now resolves output format and mood from `Llm.DefaultOutputFormat`, `Llm.DefaultMood`, document type, and request keywords instead of hardcoding HTML/professional defaults.
|
||||||
|
- Document update: 2026-04-06 16:02 (KST) - `DocumentAssemblerTool.cs` now mirrors that resolution logic at assembly time, supporting `auto` as a first-class format input and inferring `docx/html/markdown` plus `corporate/dashboard/minimal/creative/professional` mood variants from title/section intent. This reduces repetitive HTML-style outputs and brings AX closer to the more request-driven document flow seen in `claw-code`.
|
||||||
|
- Document update: 2026-04-06 16:14 (KST) - Strengthened the default deployment hardening profile in `AxCopilot.csproj` Release settings by enabling `Optimize`, `PublishSingleFile`, `EnableCompressionInSingleFile`, `IncludeNativeLibrariesForSelfExtract`, and `PublishReadyToRun`.
|
||||||
|
- Document update: 2026-04-06 16:14 (KST) - Updated `build.bat` to pass the same publish properties explicitly, so installer/distribution builds made through the batch script now run as Release self-contained single-file ReadyToRun publishes by default. There is still no external obfuscator in the repo, so protection is improved but not equivalent to full obfuscation.
|
||||||
|
- Document update: 2026-04-06 16:20 (KST) - Fixed single-file compatibility warnings introduced by the hardened publish profile. `WebSearchHandler.cs` now uses `AppContext.BaseDirectory` instead of `Assembly.Location` for bundled asset lookup, and `SettingsWindow.xaml.cs` now reads the displayed version from `AssemblyInformationalVersionAttribute` rather than `FileVersionInfo.GetVersionInfo(asm.Location)`.
|
||||||
|
- Document update: 2026-04-06 16:20 (KST) - Re-ran the standard verification build after those fixes and restored the zero-warning requirement: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` now completes with 0 warnings and 0 errors.
|
||||||
|
- Document update: 2026-04-06 16:28 (KST) - Refined the chat/composer action buttons in `ChatWindow.xaml` by introducing dedicated `ComposerIconBtn` and `ComposerSendBtn` styles. The oversized attachment/send controls were reduced back toward their earlier footprint and given a simpler, more centered visual language.
|
||||||
|
- Document update: 2026-04-06 16:28 (KST) - The attachment button now uses a compact `34x34` ghost surface and the send button uses a matching `36x36` circular emphasis surface with a centered upward arrow glyph, replacing the larger prior styling and making the footer composer closer to the requested minimal reference.
|
||||||
|
- Document update: 2026-04-06 16:39 (KST) - Reduced the perceived lag while scrolling the AX Agent internal-settings overlay by enabling deferred scrolling and vertical panning on the right-side settings `ScrollViewer`, and by bitmap-caching the large stacked content block in `ChatWindow.xaml`.
|
||||||
|
- Document update: 2026-04-06 16:39 (KST) - Replaced the plain storage-management buttons in the internal settings overlay with a new `OverlayActionBtn` style so `새로고침`, `대화 삭제`, and `저장 공간 줄이기` follow the same custom rounded action language as the rest of the AX Agent settings UI.
|
||||||
|
- Document update: 2026-04-06 16:44 (KST) - Moved the separator for the AX Agent internal-settings `운영 모드` block from the bottom edge to the top edge in `ChatWindow.xaml` so the line reads as the start of the operation-mode section rather than trailing under the previous storage block.
|
||||||
|
- Document update: 2026-04-06 16:49 (KST) - Removed the separator between the AX Agent internal-settings `서비스와 모델` block and the adjacent `등록 모델 관리` block in `ChatWindow.xaml` so the two model-management sections read as one continuous flow instead of two unrelated groups.
|
||||||
|
- Document update: 2026-04-06 16:55 (KST) - Hardened `Cp4dTokenService.cs` for IBM/CP4D-style token endpoints that expect `username + api_key` instead of `username + password`. The service now tries `username/password` first and automatically retries with `username/api_key` before failing.
|
||||||
|
- Document update: 2026-04-06 17:01 (KST) - Split the CP4D auth choice at model-registration time. `ModelRegistrationDialog.cs` now exposes `CP4D (사용자 이름 + 비밀번호)` and `CP4D (사용자 이름 + API 키)` as separate auth types and changes the final credential label accordingly.
|
||||||
|
- Document update: 2026-04-06 17:01 (KST) - Updated `LlmService.cs`, `SettingsViewModel.cs`, and `AppSettings.cs` to formally support `cp4d_password` and `cp4d_api_key` while preserving legacy `cp4d` values as the password-based path for backward compatibility.
|
||||||
|
- Document update: 2026-04-06 17:09 (KST) - Added a dedicated launcher-setting toggle for the lower quick-action chip strip. `AppSettings.cs`, `SettingsViewModel.cs`, and `SettingsWindow.xaml` now expose `showLauncherBottomQuickActions`, and the default is off so the strip stays hidden unless the user opts in.
|
||||||
|
- Document update: 2026-04-06 17:09 (KST) - Updated `LauncherViewModel.LauncherExtras.cs` and `LauncherWindow.xaml` so lower quick-action chips are not loaded when disabled and render with centered block alignment plus centered chip content when enabled, reducing the off-center look when multiple chips appear.
|
||||||
|
- Document update: 2026-04-06 17:18 (KST) - Reduced idle CPU wakeups in `App.xaml.cs` by stopping two always-on background paths from starting unconditionally. `SchedulerService` now enters through `Refresh()` so the 30-second timer only exists when at least one enabled schedule is present, and the app now reevaluates that state when settings are saved.
|
||||||
|
- Document update: 2026-04-06 17:18 (KST) - `FileDialogWatcher` is no longer started at app boot when file-dialog integration is disabled. `App.xaml.cs` now toggles the global WinEvent hook through `UpdateFileDialogWatcherState()`, and `SchedulerService.cs` now self-stops when no enabled schedules remain. This directly targets the 3–5% idle CPU symptom reported while neither the launcher nor AX Agent was open.
|
||||||
|
- Document update: 2026-04-06 17:24 (KST) - Changed the default launcher text-action profile to opt-in. `AppSettings.cs` and `SettingsViewModel.cs` now default `EnableTextAction` to `false`, and `TextActionCommands` now defaults to an empty list instead of all commands enabled.
|
||||||
|
- Document update: 2026-04-06 17:24 (KST) - Removed the forced “at least one command must remain enabled” behavior from `SettingsWindow.xaml.cs`. The text-action command panel now allows every AI command, including `rewrite`, to be turned off, and the hint text in `SettingsWindow.xaml` now explains that the popup falls back to showing only `AX Commander 열기` when all AI commands are disabled.
|
||||||
|
- Document update: 2026-04-06 17:35 (KST) - Reduced another startup performance hotspot in `App.xaml.cs` by removing the idle-time `PrewarmChatWindow()` path. AX Agent is no longer instantiated in the background at app startup and is created only when the user actually opens it.
|
||||||
|
- Document update: 2026-04-06 17:35 (KST) - Changed launcher search warmup from eager startup work to on-demand initialization. `App.xaml.cs` now starts the first `IndexService.BuildAsync()` scan and `StartWatchers()` only when the launcher is actually shown through `ShowLauncherWindow()`, instead of running a full index scan and watcher hookup at boot even when the launcher is never opened.
|
||||||
|
- Document update: 2026-04-06 17:43 (KST) - Deferred `LauncherWindow` construction itself. `App.xaml.cs` now keeps only `LauncherViewModel` alive at startup and creates the actual `LauncherWindow` through `EnsureLauncherCreated()` the first time the launcher is shown, rather than instantiating the full hidden window at boot.
|
||||||
|
- Document update: 2026-04-06 17:43 (KST) - Removed the eager tray-menu warmup path (`PrepareForDisplay()`) from startup. This avoids doing popup layout/measure work for the tray menu until the user actually opens it, reducing idle desktop overhead further.
|
||||||
|
- Document update: 2026-04-06 17:52 (KST) - Restored eager `LauncherWindow` construction so launcher open latency stays low, but kept the tray-menu warmup removed. This keeps the launcher responsive while continuing to trim startup work elsewhere.
|
||||||
|
- Document update: 2026-04-06 17:52 (KST) - Moved index warmup from launcher-show time to actual search time. `LauncherViewModel.SearchAsync(...)` now triggers `App.EnsureIndexWarmupStarted()` only when the user performs a real search, so opening the launcher by itself no longer starts a full file scan and watcher bootstrap.
|
||||||
|
- Document update: 2026-04-06 18:02 (KST) - Added IBM deployment-chat detection in `LlmService.cs` for vLLM registered models using `ibm_iam` or `cp4d_*` auth with `/ml/v1/deployments/...`-style endpoints. These requests now use IBM deployment chat URLs (`/text/chat` or `/text/chat_stream`) instead of appending `/v1/chat/completions`.
|
||||||
|
- Document update: 2026-04-06 18:02 (KST) - Added an IBM deployment request body builder in `LlmService.cs` that omits OpenAI-style `model` and `stream` fields and sends `messages + parameters` instead. This directly addresses IBM responses complaining that `model_id` or `mode` must not be specified in the request body.
|
||||||
|
- Document update: 2026-04-06 18:02 (KST) - Hardened vLLM response handling for IBM deployment endpoints by accepting `results[].generated_text`, `output_text`, and `choices[].message.content`, and by short-circuiting tool-use requests in `LlmService.ToolUse.cs` with a `ToolCallNotSupportedException` so IBM deployment chat connections do not receive an incompatible OpenAI function-calling payload.
|
||||||
|
- Document update: 2026-04-06 18:09 (KST) - Reworked message feedback toggles in `ChatWindow.MessageInteractions.cs`. `좋아요/싫어요` no longer keep separate local state with sibling reset callbacks; both buttons now derive from one shared `like/dislike/null` state and persist that single value back to the conversation/session.
|
||||||
|
- Document update: 2026-04-06 18:09 (KST) - The feedback buttons now use the same glyph in both idle/active states and express activation through color plus rounded chip background/border, which avoids cases where the like filled-glyph was visually missing and ensures pressing `싫어요` again properly returns the message to an unselected state.
|
||||||
|
- Document update: 2026-04-06 18:24 (KST) - Reworked launcher indexing in `IndexService.cs` from a “full rebuild on every change” structure to a persisted cache plus incremental watcher model. Launcher file-system entries are now saved to `%APPDATA%\AxCopilot\index\launcher-index.json`, loaded immediately at startup, and merged with aliases/built-in app entries before the first search.
|
||||||
|
- Document update: 2026-04-06 18:24 (KST) - `FileSystemWatcher` changes are now handled incrementally where possible. File create/delete/rename events update only the affected launcher index entries and debounce cache persistence, while high-impact directory rename/delete cases still fall back to a full rebuild for correctness.
|
||||||
|
- Document update: 2026-04-06 18:24 (KST) - `App.xaml.cs` now restores eager cache availability by calling `LoadCachedIndex()` and `StartWatchers()` at startup, while keeping the heavier full scan on the existing `EnsureIndexWarmupStarted()` path. This keeps the launcher responsive from the first open without reintroducing continuous full rescans as idle background work.
|
||||||
|
- Document update: 2026-04-06 18:34 (KST) - Reduced the per-keystroke hot-path cost of the global keyboard hook. `InputListener.cs` now performs the suppressed-foreground-window check only when the current key could actually trigger the launcher hotkey, the capture hotkey, or a registered key filter, instead of doing the window-class lookup on every keydown.
|
||||||
|
- Document update: 2026-04-06 18:34 (KST) - `SnippetExpander.cs` now short-circuits immediately for normal typing unless snippet tracking is already active or the current key is the `;` trigger. This removes repeated `GetAsyncKeyState` and buffer logic from ordinary typing in other apps and makes the low-level hook substantially lighter under continuous input.
|
||||||
|
|
||||||
|
|
||||||
|
업데이트: 2026-04-06 20:18 (KST)
|
||||||
|
- Improved AX Agent markdown rendering for Cowork/Code by highlighting file paths plus code-like symbols, and added a compact repository summary banner above the Code composer to surface branch and diff context closer to `claude-code`.
|
||||||
|
## 2026-04-06 20:28 (KST)
|
||||||
|
|
||||||
|
- AX Agent 코드 탭의 저장소 요약줄을 `claude-code` 레퍼런스에 더 가깝게 정리했다.
|
||||||
|
- 저장소/브랜치 외에 `로컬/워크트리` 배지와 `upstream` 배지를 추가
|
||||||
|
- 변경이 있을 때 액션 라벨을 `변경 · 브랜치 보기`로 동적으로 변경
|
||||||
|
- 코드 탭 저장소 요약줄에서 `/review` slash 명령으로 바로 이어지는 `리뷰` 배지를 추가했다.
|
||||||
|
- 기존 입력 중인 텍스트는 유지하고 slash chip만 덧붙이도록 구성
|
||||||
|
## 2026-04-06 20:34 (KST)
|
||||||
|
|
||||||
|
- Claude 라이트/시스템 테마의 `LauncherBackground`와 `ItemBackground` 계열 팔레트를 재조정했다.
|
||||||
|
- 채팅 본문 기본 배경은 흰색
|
||||||
|
- 좌측 패널과 카드 표면은 더 짙은 웜 베이지 톤
|
||||||
|
- 선택/호버 배경도 같은 계열로 맞춰 표면 위계가 뒤집혀 보이던 문제를 수정
|
||||||
|
## 2026-04-06 21:54 (KST)
|
||||||
|
|
||||||
|
- AX Agent 테마 팔레트를 다시 분리해 `Claude`, `Codex`, `Slate`, `Nord`, `Ember`가 서로 더 다른 시각 언어를 갖도록 조정했다.
|
||||||
|
- `AgentCodexLight.xaml`, `AgentCodexDark.xaml`, `AgentCodexSystem.xaml`은 웜톤을 제거하고 더 차갑고 중성적인 회백/차콜 계열로 재구성
|
||||||
|
- `AgentClaw*`, `AgentSlate*`, `AgentNord*`, `AgentEmber*`에는 공통 `InputFocusBorderColor` 리소스를 추가해 포커스 테두리가 테마별 성격을 따르도록 통일
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 composer shell 라운딩을 키우고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에서 입력창 포커스 시 `AccentColor` 대신 `InputFocusBorderColor`를 사용하도록 변경했다.
|
||||||
|
- [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs)의 사용자/assistant 버블 코너 반경도 확대해, 전반적인 채팅 박스 인상이 더 부드럽고 테마별 표면 차이를 잘 받도록 정리했다.
|
||||||
|
## 2026-04-06 22:01 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 AX Agent 내부 설정 오버레이 토글들에 `Checked/Unchecked` 이벤트 연결을 복원했다.
|
||||||
|
- 대상: 이미지 입력, proactive compact, skill/tool hooks, cowork/code 검토, 병렬 도구, project rules, agent memory, worktree/team/cron 도구
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에 `ChkOverlayFeatureToggle_Changed(...)` 공통 저장 핸들러를 추가해, 토글 변경 즉시 `ApplyOverlaySettingsChanges(...)`를 타고 설정이 저장되도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 22:15 (KST)
|
||||||
|
|
||||||
|
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 `FreeTierMode` 대기를 Gemini 서비스에서만 적용하도록 변경했다.
|
||||||
|
- 예전 Gemini 무료 티어 대응용 호출 간 딜레이가 모든 서비스에 전역으로 묻어 다니지 않게 정리
|
||||||
|
- 대기 이벤트 문구도 `Gemini 무료 티어 대기`로 명확히 수정
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 내부 설정 공통/빠른 설정 문구를 실제 기능 기준으로 변경했다.
|
||||||
|
- `Fast` → `Gemini 무료 티어 대기`
|
||||||
|
- 설명 툴팁도 “Gemini 무료 티어처럼 호출 제한이 있는 환경에서만 쓰는 대기”라는 의미가 드러나도록 정리
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 에이전트 이벤트 배너를 다시 조정했다.
|
||||||
|
- 단계 시작 이벤트도 progress bar만 갱신하고 끝나는 것이 아니라 transcript pill로 함께 남기도록 변경
|
||||||
|
- `ToolCall` 이벤트를 기본 transcript에서 숨기지 않도록 복원
|
||||||
|
- 생각/단계/도구 호출 카드의 글씨 크기, 배경, 테두리, 패딩을 키워 hover로 우연히 보이는 tiny 로그 느낌을 줄임
|
||||||
|
|
||||||
|
## 2026-04-06 22:31 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 중간 처리 메시지 렌더를 `claude-code` 레퍼런스에 더 가깝게 재구성했다.
|
||||||
|
- `Planning`, `StepStart`, `StepDone`, `Thinking`, `ToolCall`, `SkillCall`을 별도 `process feed event`로 분리
|
||||||
|
- 기존의 작은 pill 카드 대신 `chevron 요약줄 + 본문 설명` 구조로 렌더
|
||||||
|
- 장시간 작업 중에도 단계/툴 실행/생각 중 상태가 일반 assistant 진행 메시지처럼 읽히도록 정리
|
||||||
|
- permission/result 카드와 진행 중 feed를 분리해, 실제 처리 메시지는 `claude-code / Claude Code`처럼 더 담백한 transcript 흐름으로 보이게 조정했다.
|
||||||
|
|
||||||
|
## 2026-04-06 22:42 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 라이브 진행 힌트 이벤트에 `ElapsedMs`, `InputTokens`, `OutputTokens`를 실시간으로 채워 넣도록 확장했다.
|
||||||
|
- Cowork/Code에서 스트리밍 중 오래 걸릴 때마다 현재 경과 시간과 누적 토큰 수가 진행 힌트 자체에 반영된다.
|
||||||
|
- 대기/압축 힌트는 초 단위 경과 시간이나 토큰 수가 바뀌면 자동 갱신되도록 비교 조건도 함께 보강했다.
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 process feed 요약줄에 우측 메타 영역을 추가했다.
|
||||||
|
- `처리 중...`, `컨텍스트 압축 중...` 같은 라이브 진행 줄에 `6m 46s · 38.8K tokens`처럼 기다릴 근거가 함께 노출된다.
|
||||||
|
- 라이브 대기/압축 상태는 기존 chevron 대신 강조 마커와 조금 더 진한 제목 톤을 써서 일반 이벤트보다 눈에 잘 들어오도록 구분했다.
|
||||||
|
|
||||||
|
## 2026-04-06 22:48 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 라이브 진행 줄에 작은 펄스 애니메이션을 추가했다.
|
||||||
|
- `처리 중...`, `컨텍스트 압축 중...`처럼 오래 걸리는 thinking 상태는 좌측 마커가 opacity/scale 펄스를 반복하며 살아 있는 상태임을 보여준다.
|
||||||
|
- transcript 분위기를 과하게 흔들지 않도록 마커 하나만 은은하게 움직이고, 본문 레이아웃은 그대로 유지해 `claude-code`의 담백한 진행감에 가깝게 맞췄다.
|
||||||
|
|
||||||
|
## 2026-04-06 23:16 (KST)
|
||||||
|
|
||||||
|
- 전체 코드 기준 오류/수행 속도 점검을 진행하면서 즉시 체감되는 핫패스를 우선 정리했다.
|
||||||
|
- [SettingsService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/SettingsService.cs)
|
||||||
|
- `NormalizeRuntimeSettings()`가 AX Agent 표현 수준을 매번 `rich`로 강제하던 문제를 수정했다.
|
||||||
|
- 이제 저장값은 `rich / balanced / simple`만 정상화하고, 알 수 없는 값만 기본 `balanced`로 복구한다.
|
||||||
|
- [IndexService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IndexService.cs)
|
||||||
|
- `.tmp`, `.temp`, `.cache`, `.log`, `.bak`, `.swp`, `.swo`, `.part`, `.download`, `.crdownload` 파일을 색인/증분 갱신 대상에서 제외했다.
|
||||||
|
- `~$` Office 임시 파일과 숨김/시스템 파일/폴더도 감시 대상에서 걸러 불필요한 `FileSystemWatcher` 이벤트와 증분 캐시 저장을 줄였다.
|
||||||
|
- 증분 추가 경로(`AddPathEntryIncrementally`)와 전체 스캔 경로(`ScanDirectoryAsync`) 모두 같은 규칙을 적용해 런처 색인 노이즈를 줄였다.
|
||||||
|
- [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs)
|
||||||
|
- 인덱스 상태 표시용 `DispatcherTimer`를 매번 새로 생성하지 않고 재사용하도록 바꿔, 상태 문구를 자주 바꿀 때 발생하던 타이머/이벤트 핸들러 churn을 줄였다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- AX Agent 창이 숨겨져 있거나 최소화된 동안에는 execution history rerender를 즉시 수행하지 않고, 다시 보일 때 한 번만 flush 하도록 바꿨다.
|
||||||
|
- 스트리밍/이벤트가 계속 들어와도 백그라운드 창에서 `RenderMessages()`가 반복 호출되는 비용을 줄이는 목적이다.
|
||||||
|
|
||||||
|
## 2026-04-06 23:26 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)
|
||||||
|
- execution history를 접어 둔 상태에서도 기다릴 근거가 필요한 진행 이벤트는 transcript에 남도록 `ShouldShowCollapsedProgressEvent(...)`를 추가했다.
|
||||||
|
- `Complete`, `Error`, `agent_wait`, `context_compaction`, 요약이 있는 thinking, process feed 계열 이벤트는 collapsed 상태에서도 계속 렌더된다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- `OnAgentEvent(...)`가 execution history가 꺼져 있어도 중요한 progress 이벤트면 rerender를 예약하도록 `ShouldRenderProgressEventWhenHistoryCollapsed(...)`를 도입했다.
|
||||||
|
- 이제 Cowork/Code에서 긴 대기나 압축이 발생할 때, hover로 우연히만 보이는 것이 아니라 transcript 기본 흐름에서 진행 상태를 읽을 수 있다.
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)
|
||||||
|
- 진행 줄 메타 표기를 `경과 시간 · 누적 토큰` 형식으로 통일하는 `BuildReadableProgressMetaText(...)`를 추가했다.
|
||||||
|
- 일반 진행 이벤트는 borderless에 가까운 평평한 line 스타일로, 실제 장기 대기/압축 상태만 강조 배경/테두리를 유지하는 `CreateReadableProgressFeedCard(...)`를 추가했다.
|
||||||
|
- 결과적으로 AX Agent의 중간 처리 피드가 `claw-code`처럼 “기다릴 수 있는 라이브 진행 줄”에 더 가까워졌다.
|
||||||
|
|
||||||
|
## 2026-04-06 23:33 (KST)
|
||||||
|
|
||||||
|
- [FuzzyEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/FuzzyEngine.cs)
|
||||||
|
- 인덱스 버전 기준 쿼리 캐시를 추가했다.
|
||||||
|
- 같은 인덱스 스냅샷에서 반복되는 검색어는 fuzzy 점수 계산을 다시 전부 돌리지 않고 캐시된 `FuzzyResult` 목록을 즉시 반환한다.
|
||||||
|
- 인덱스 재빌드가 완료되면 `IndexRebuilt` 이벤트를 받아 캐시를 자동으로 무효화한다.
|
||||||
|
- [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs)
|
||||||
|
- 앱 시작 시 캐시된 런처 인덱스가 실제로 로드된 경우에만 watcher를 즉시 시작하도록 조정했다.
|
||||||
|
- 캐시가 없는 상태에서 거대한 감시기를 먼저 켜는 오버헤드를 피하고, 초기 전체 색인이 끝난 뒤 watcher가 붙도록 정리했다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- 최소화/비활성/백그라운드 상태를 `IsBackgroundUiThrottleActive()`로 판단해 task summary, 입력 보조 UI, agent UI event flush를 바로 수행하지 않고 pending 상태로 넘긴다.
|
||||||
|
- 창이 다시 활성화되면 `FlushDeferredUiRefreshIfNeeded()`에서 누적된 갱신을 한 번에 반영해, 백그라운드 상태의 잦은 UI 타이머 churn을 줄였다.
|
||||||
|
|
||||||
|
## 2026-04-06 23:49 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- AX 메모리 구조 강화 1차로 `관리형 / 사용자 / 프로젝트 / 로컬` 계층형 메모리 문서를 지원하도록 확장했다.
|
||||||
|
- 탐색 대상은 `%ProgramData%\\AxCopilot\\memory\\AXMEMORY.md`, `%APPDATA%\\AxCopilot\\memory\\AXMEMORY.md`, 작업 디렉토리까지의 `AXMEMORY.md`, `.ax\\AXMEMORY.md`, `.ax\\rules\\*.md`, `AXMEMORY.local.md` 이다.
|
||||||
|
- 기존 암호화 저장형 학습 메모리(`_global.dat`, 작업 폴더 해시 `.dat`)는 그대로 유지하고, 새 계층형 문서는 `MemoryInstructionDocument` 컬렉션으로 별도 관리한다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- 시스템 프롬프트의 메모리 섹션을 `메모리 계층` + `학습 메모리` 2단 구조로 재편했다.
|
||||||
|
- 이전에는 학습 메모리만 단순 나열했지만, 이제 `claw-code`처럼 계층형 메모리 파일을 우선순위 순서로 조립하고 그 아래에 학습형 메모리를 추가한다.
|
||||||
|
- 로드 전에 `Count`를 먼저 확인해 다른 작업 폴더 메모리가 누락될 수 있던 경로도 함께 바로잡았다.
|
||||||
|
|
||||||
|
## 2026-04-06 23:57 (KST)
|
||||||
|
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)
|
||||||
|
- `save_scope`, `delete_scope` 액션을 추가해 계층형 메모리 파일도 도구 레벨에서 직접 다룰 수 있게 확장했다.
|
||||||
|
- `search`, `list`는 기존 학습 메모리뿐 아니라 계층형 메모리 문서(`MemoryInstructionDocument`)도 함께 보여주도록 바꿨다.
|
||||||
|
- 도구 실행마다 현재 `workFolder` 기준으로 `memoryService.Load(...)`를 먼저 호출해, 작업 폴더 컨텍스트에 맞는 메모리 계층이 반영되도록 정리했다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- `GetWritableInstructionPath(...)`, `SaveInstruction(...)`, `DeleteInstruction(...)`를 추가해 `managed / user / project / local` scope별 메모리 파일에 실제 내용을 append/remove 할 수 있게 했다.
|
||||||
|
- 1차 목표는 `claw-code`처럼 계층형 메모리를 “로드만 하는 구조”에서 “도구로 관리할 수 있는 구조”로 올리는 것이며, 현재는 bullet line append/remove 기반의 안전한 최소 구현을 적용했다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:06 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 계층형 메모리 파일에 `@include` 확장을 추가했다.
|
||||||
|
- 지원 형식은 `@filename`, `@./relative/path`, `@~/path/in/home`, 절대 경로이며, `#fragment`는 제거 후 해석한다.
|
||||||
|
- 비텍스트 확장자는 무시하고, 코드 블록 내부 include는 처리하지 않으며, 순환 참조는 차단하고 최대 5단계까지만 재귀 확장한다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 프로젝트 루트 판단을 단순 `workFolder` 기준에서 `.git`, `.sln`, `*.csproj`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` 마커 기반으로 강화했다.
|
||||||
|
- 이제 계층형 메모리 탐색은 프로젝트 루트부터 현재 작업 디렉토리까지 진행되고, `project/local` 메모리 파일 쓰기 경로도 같은 루트 판단을 사용한다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:13 (KST)
|
||||||
|
|
||||||
|
- [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs)
|
||||||
|
- `AllowExternalMemoryIncludes` 설정을 추가했다. 기본값은 `false`이며, 메모리 include가 프로젝트 바깥 경로를 읽는 것을 명시적으로 제어한다.
|
||||||
|
- [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)
|
||||||
|
- 에이전트 메모리 섹션에 `외부 메모리 include 허용` 토글을 추가했다.
|
||||||
|
- 메모리 내용 관리 자체는 `/memory` 도구로 하고, include 보안 정책만 설정에서 다루는 구조로 정리했다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- `@include` 해석 시 설정을 읽어, 외부 include가 꺼져 있으면 홈 경로(`@~/...`), 절대 경로, 프로젝트 바깥으로 벗어나는 상대 경로를 차단하도록 바꿨다.
|
||||||
|
- `claw-code`처럼 메모리 편집은 도구/명령 중심으로 하고, 외부 include는 별도 안전 정책으로 관리하는 구조를 목표로 한다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:22 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 계층형 메모리 문서에 YAML 유사 frontmatter `paths:` 규칙을 추가했다.
|
||||||
|
- 문서 상단이 `---`로 시작하면 `paths:` 아래의 `- pattern` 목록을 읽고, 현재 작업 폴더가 프로젝트 루트 기준 상대 경로로 그 패턴과 일치할 때만 해당 문서를 메모리 계층에 포함한다.
|
||||||
|
- frontmatter는 제거된 뒤 본문만 실제 메모리 콘텐츠로 주입되며, `paths:`가 없는 문서는 기존처럼 항상 적용된다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 간단한 glob 매처를 추가해 `*`, `**`, `?` 패턴을 지원한다.
|
||||||
|
- 현재는 `.ax/rules/*.md` 같은 프로젝트 규칙 파일을 `claw-code`의 경로 범위 rules와 비슷하게 운영할 수 있는 수준까지 올라온 상태다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:31 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 계층형 메모리 frontmatter에 `description:` 메타를 추가했다.
|
||||||
|
- `paths:` 범위 판정 뒤 실제 주입 문서에는 설명과 경로 범위를 함께 보관해, `/memory list`와 `/memory search`에서 규칙 파일의 의도를 더 잘 읽을 수 있게 했다.
|
||||||
|
- scope별 원본 메모리 파일을 직접 읽을 수 있는 `ReadInstructionFile(...)`도 추가했다.
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)
|
||||||
|
- `show_scope` 액션을 추가해 `managed / user / project / local` 메모리 파일의 실제 경로와 본문을 바로 확인할 수 있게 했다.
|
||||||
|
- `list`, `search` 출력에는 계층형 메모리 문서의 `description`과 `paths` 메타를 함께 노출해, `claw-code`의 rule 파일처럼 어떤 규칙이 어느 범위에 적용되는지 빠르게 읽을 수 있도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:39 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 계층형 메모리 로드 뒤 `NormalizeInstructionDocuments()`를 수행하도록 바꿨다.
|
||||||
|
- 동일한 규칙 내용이 여러 계층에 중복되면 더 나중에 로드된 문서, 즉 현재 작업 위치에 더 가까운 문서를 남기고 앞선 중복 문서는 제거한다.
|
||||||
|
- 최종 문서는 `managed → user → project → local` 순서로 다시 정렬하고, 각 문서에 최종 적용 우선순위 번호를 부여한다.
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)
|
||||||
|
- `list`, `search` 출력에 계층형 메모리 문서의 우선순위를 함께 노출하도록 정리했다.
|
||||||
|
- 사용자는 이제 `/memory` 결과에서 어떤 규칙이 실제로 더 강하게 적용되는지 바로 읽을 수 있다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:45 (KST)
|
||||||
|
|
||||||
|
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)
|
||||||
|
- 에이전트 메모리 섹션에 `관리형 편집`, `사용자 편집`, `프로젝트 편집`, `로컬 편집` 버튼을 추가했다.
|
||||||
|
- 메모리 관리는 `/memory` 명령만이 아니라 설정 UI에서도 직접 접근 가능하도록 확장했고, 같은 섹션 안에서 학습 메모리 초기화와 계층형 메모리 편집을 함께 다룰 수 있게 정리했다.
|
||||||
|
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)
|
||||||
|
- scope별 메모리 파일 경로를 계산해 실제 내용을 읽고 수정할 수 있는 `OpenMemoryScopeEditor(...)`를 추가했다.
|
||||||
|
- 편집 다이얼로그는 현재 테마 색을 사용하고, `description`/`paths` frontmatter 예시를 안내로 보여준다.
|
||||||
|
- 저장 시 해당 scope 메모리 파일을 즉시 갱신하고, 빈 내용이면 파일을 삭제한 뒤 `AgentMemoryService.Load(...)`를 다시 호출해 메모리 계층이 곧바로 반영되도록 했다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:52 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 계층형 메모리 frontmatter에 `enabled:`와 `tags:` 메타를 추가했다.
|
||||||
|
- `enabled: false`인 규칙 파일은 로드 단계에서 제외해 실험용/보관용 규칙을 파일에 남겨둔 채 비활성화할 수 있게 했다.
|
||||||
|
- `tags:`는 `- payments`, `- review` 같은 목록 형식으로 읽어 `MemoryInstructionDocument`에 함께 보관한다.
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)
|
||||||
|
- `list`, `search` 출력에 계층형 메모리 문서의 `tags` 메타를 함께 표시하도록 정리했다.
|
||||||
|
- 사용자는 이제 `/memory` 결과에서 규칙의 설명, 적용 경로, 우선순위뿐 아니라 태그 묶음까지 같이 확인할 수 있다.
|
||||||
|
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)
|
||||||
|
- 메모리 편집 다이얼로그의 frontmatter 예시를 `description / enabled / tags / paths` 기준으로 갱신해, 설정 UI에서도 현재 지원 메타 전체를 바로 참고할 수 있게 했다.
|
||||||
|
|
||||||
|
## 2026-04-07 01:00 (KST)
|
||||||
|
|
||||||
|
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)
|
||||||
|
- 에이전트 메모리 섹션에 `적용 중 메모리 계층` row를 추가했다.
|
||||||
|
- 현재 컨텍스트에 반영된 계층형 규칙 수, 학습 메모리 수, 활성 규칙 파일 요약을 보여주고 `새로고침` 버튼으로 즉시 재계산할 수 있게 했다.
|
||||||
|
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)
|
||||||
|
- `RefreshMemoryOverview()`를 추가해 현재 작업 폴더 기준 메모리 계층 적용 상태를 계산해 UI에 표시하도록 했다.
|
||||||
|
- 설정 창 로드 시, 메모리 파일 저장 후, 학습 메모리 초기화 후 모두 이 요약을 자동으로 갱신해 설정 UI와 실제 적용 상태가 어긋나지 않도록 정리했다.
|
||||||
|
업데이트: 2026-04-07 01:15 (KST)
|
||||||
|
|
||||||
|
- 메모리 구조 후속 고도화:
|
||||||
|
- `AgentMemoryService`에서 `@include` 시도 성공/차단을 감사 로그(`MemoryInclude`)로 기록
|
||||||
|
- `ChatWindow` 하단 폴더 바에 메모리 상태 요약(`메모리 n · 학습 n`) 추가
|
||||||
|
- 설정의 `외부 메모리 include 허용` 안내 문구를 감사 로그 기준으로 갱신
|
||||||
|
|
||||||
|
## 2026-04-07 01:26 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)
|
||||||
|
- Cowork/Code 하단 메모리 칩 클릭 시 여는 상세 팝업을 추가했다.
|
||||||
|
- 팝업은 `적용 중 규칙`, `최근 include 감사`를 같은 시각 언어로 묶어 보여주고, 현재 include 정책과 학습 메모리 개수도 함께 표시한다.
|
||||||
|
- include 감사 기록은 오늘 로그 중 최근 5건을 `허용/차단`, 시각, 요청 파라미터, 결과 기준으로 요약해 보여준다.
|
||||||
|
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)
|
||||||
|
- 메모리 개요 row에 `TxtMemoryOverviewAudit` 영역을 추가해 최근 include 감사 상태를 설정 화면에서도 바로 볼 수 있게 했다.
|
||||||
|
- [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)
|
||||||
|
- `RefreshMemoryOverview()`가 최근 include 감사 3건을 읽어 `허용/차단 · 시간 · 결과` 형식으로 요약해 표시하도록 확장했다.
|
||||||
|
|
||||||
|
## 2026-04-07 01:35 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)
|
||||||
|
- Cowork/Code 진행 표시 줄 아래에 `메모리 규칙 n개 · 학습 n개 적용 중` 보조 설명을 추가했다.
|
||||||
|
- 오래 걸리는 처리 중에도 현재 어떤 메모리 계층이 반영되는지 transcript에서 바로 알 수 있도록 정리했다.
|
||||||
|
- [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)
|
||||||
|
- 진행 표시용 메모리 근거 문자열 생성 helper를 추가해 footer 상태와 transcript 근거가 같은 데이터에 기반하도록 맞췄다.
|
||||||
|
- [AuditLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AuditLogService.cs)
|
||||||
|
- `LoadRecent(action, maxCount, daysBack)`를 추가해 최근 N일 감사 로그를 액션별로 쉽게 읽을 수 있게 했다.
|
||||||
|
- 정리 시점 판정도 `CreationTime` 대신 `LastWriteTime`을 사용하도록 바꿔 보관 정책 오차를 줄였다.
|
||||||
|
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)
|
||||||
|
- `/memory list`와 `/memory search`의 계층형 메모리 출력 형식을 두 줄 구조로 정리했다.
|
||||||
|
- 이제 각 규칙은 경로와 함께 `우선순위 · layer · description · paths · tags`를 한 번에 읽을 수 있다.
|
||||||
|
|
||||||
|
## 2026-04-07 01:44 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)
|
||||||
|
- Cowork/Code 입력창 워터마크, 선택된 프리셋 안내, 메모리 상태 팝업 문구에 섞여 들어간 깨진 한글 리터럴을 정상 문자열로 복구했다.
|
||||||
|
- `메모리 규칙 n개 · 학습 n개 적용 중` 근거 표시와 메모리 팝업 상세 문구도 읽기 쉬운 한글 기준으로 다시 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-07 02:08 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)
|
||||||
|
- Cowork/Code 워터마크, 선택된 프리셋 안내, 메모리 상태 팝업 관련 문자열을 다시 한 번 전면 교체해 남아 있던 한글 깨짐을 제거했다.
|
||||||
|
- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)
|
||||||
|
- 이전 대화 load more 카드와 압축 메타 카드의 깨진 표시 문자열을 정상 한국어로 복구했다.
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)
|
||||||
|
- `처리 중...`, `컨텍스트 압축 중...`, `단계 진행/완료`, `스킬 실행` 등 중간 진행 메시지 요약을 정상 한국어로 다시 맞췄다.
|
||||||
|
- live progress 줄에 들어가던 fade 애니메이션을 제거해 재렌더 시 깜박이던 문제를 줄였다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- 이벤트가 자주 들어와 idle 시간이 짧아도 일정 경과 시간 이후에는 `작업을 진행하는 중입니다...` 라이브 진행 줄이 뜨도록 fallback을 추가했다.
|
||||||
|
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)
|
||||||
|
- 내부 중단/취소가 발생했을 때 사용자 취소로 단정하는 문구를 중립적으로 조정해 오탐성 `사용자가 작업을 취소했습니다` 표시를 줄였다.
|
||||||
|
|
||||||
|
|
||||||
|
- Document update: 2026-04-07 02:23 (KST) - Reconnected the AX Agent direct-chat execution path to the existing SSE/streaming transport in `LlmService`. Chat replies no longer wait for the final full string before rendering; when streaming is enabled they now advance through the existing streaming container and typing-timer path.
|
||||||
|
- Document update: 2026-04-07 02:23 (KST) - Updated `AxAgentExecutionEngine.ResolveExecutionMode()` so non-agent chat can opt into streaming transport, and wired `ChatWindow.ExecutePreparedTurnAsync()` to consume `LlmService.StreamAsync(...)` for direct conversations while keeping Cowork/Code on the agent-loop progress-feed path.
|
||||||
|
- Document update: 2026-04-07 02:31 (KST) - Added a typed final-response preview for Cowork/Code agent-loop completions. Those tabs still use progress-feed execution during the loop, but once a final assistant answer is available it now passes through the same streaming container/typing presentation before the final transcript message is committed.
|
||||||
|
|
||||||
|
## 2026-04-07 02:45 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- Cowork/Code 실행 시작 시 `_streamStartTime`을 라이브 진행 힌트보다 먼저 설정하도록 순서를 조정했다.
|
||||||
|
- 유효한 시작 시각이 없을 때는 진행 힌트 경과 시간을 계산하지 않도록 막아 `수천만 시간` 같은 비정상 표시를 방지했다.
|
||||||
|
- 내부 설정 저장 시 `EnableChatRainbowGlow`, `Launcher.EnableRainbowGlow`, `Launcher.EnableSelectionGlow`도 함께 반영되도록 연결했다.
|
||||||
|
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)
|
||||||
|
- 진행 카드 메타에 쓰는 경과 시간을 6시간 상한으로 정규화해 비정상값을 자동 무시하도록 했다.
|
||||||
|
- 라이브 대기 카드와 진행 줄 배경을 고정 주황색 대신 테마 AccentColor 기반 반투명 톤으로 교체했다.
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)
|
||||||
|
- 입력창 글로우 외곽선의 두께와 블러를 완화해 런처 글로우와 더 비슷한 질감으로 조정했다.
|
||||||
|
- AX Agent 내부 설정 공통 탭에 `글로우 효과` 섹션을 추가해 런처 무지개 글로우, 런처 선택 글로우, 채팅 입력창 글로우를 내부 설정에서 바로 조정할 수 있게 했다.
|
||||||
|
- [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)
|
||||||
|
- 일반 설정 테마 섹션에 있던 런처/채팅 글로우 토글 3종을 제거해 AX Agent 내부 설정으로 UI를 일원화했다.
|
||||||
|
|
||||||
|
## 2026-04-07 02:56 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- `워크플로우 시각화`가 `DevMode`에 묶여 토글을 켜도 실제 분석기 창이 열리지 않던 의존을 제거했다.
|
||||||
|
- 설정 저장 후 `SyncWorkflowVisualizerWindow()`를 호출해, 토글을 켜면 분석기 창이 바로 열리고 끄면 즉시 숨겨지도록 연결했다.
|
||||||
|
- 내부 설정 공통 탭의 자동 프리뷰 콤보 값을 `Llm.AutoPreview`와 동기화하고, 변경 시 바로 저장되도록 `CmbOverlayAutoPreview_SelectionChanged`를 추가했다.
|
||||||
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)
|
||||||
|
- AX Agent 내부 설정 공통 탭에 `문서 미리보기` 콤보를 추가해 `자동 표시 / 수동 / 비활성화`를 내부 설정에서 직접 제어할 수 있게 했다.
|
||||||
|
|
||||||
|
## 2026-04-07 03:03 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.FooterPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.FooterPresentation.cs)
|
||||||
|
- Cowork/Code 하단 작업 바의 메모리 상태 칩을 항상 숨기도록 조정했다.
|
||||||
|
- 메모리 상태 버튼이 비노출일 때는 상태 문자열과 tooltip을 초기화하고, 클릭으로 상세 팝업이 열리지 않게 막아 footer에서 메모리 표기가 다시 노출되지 않도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-07 03:13 (KST)
|
||||||
|
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- Chat/Cowork/Code 탭 전환 시 실행 중 작업을 즉시 취소하던 `StopStreamingIfActive()` 호출을 제거했다. 이제 Cowork/Code 작업은 시작한 탭 기준으로 백그라운드에서 계속 진행된다.
|
||||||
|
- 실행 중 컨트롤 표시를 `RefreshStreamingControlsForActiveTab()`로 분리해, 현재 탭이 실행 소유 탭일 때만 정지/일시정지 버튼이 보이고 다른 탭에서는 일반 입력 상태처럼 보이도록 정리했다.
|
||||||
|
- 라이브 진행 힌트는 `_streamRunTab`과 현재 활성 탭이 일치할 때만 transcript에 렌더되도록 바꿔, Cowork 작업 중 Code 탭으로 이동했을 때 Code 탭에도 `처리 중...`이 따라 보이던 문제를 막았다.
|
||||||
|
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)
|
||||||
|
- Cowork/Code 장시간 실행 뒤 최종 응답을 `ShowTypedAssistantPreviewAsync(...)`로 붙이는 단계에서 `_streamCts` 필드를 다시 읽다가 null 참조로 실패할 수 있던 경로를 수정했다. 실행 시작 시 만든 `CancellationTokenSource`의 토큰을 지역 변수로 고정해 마지막 프리뷰 단계까지 재사용하도록 변경했다.
|
||||||
|
- 최종 타이핑 프리뷰 컨테이너가 정상적으로 준비되지 않은 경우 조용히 건너뛰도록 방어 로직을 추가했고, 타이핑 대기 중 취소가 들어오면 `OperationCanceledException`을 내부에서 흡수해 최종 실패로 뒤집히지 않도록 정리했다.
|
||||||
|
- 에이전트 실행 중 예외 전체를 `Services.LogService.Debug(...)`에 남겨, 이후 장시간 실행 실패 원인을 앱 로그에서 바로 추적할 수 있게 했다.
|
||||||
|
- Document update: 2026-04-07 09:19 (KST) - Restored the AX Agent footer/status total token aggregate so it no longer disappears after runs return to idle. The status strip now rehydrates totals from the current conversation message token sums when live loop counters are empty.
|
||||||
|
- Document update: 2026-04-07 09:19 (KST) - Corrected context-compaction popup accuracy by switching its detail copy to the last real compaction metrics (`before -> after`, automatic/manual kind, cumulative compaction count, cumulative saved tokens) instead of only the generic trigger-threshold text.
|
||||||
|
- Document update: 2026-04-07 09:19 (KST) - Prevented `total_stats` loop events from being swallowed into the generic process-feed path. AX Agent now routes those events back through the dedicated total-stats presentation so transcript summaries and footer token totals stay aligned.
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
- Updated: 2026-04-05 16:55 (KST)
|
- Updated: 2026-04-05 16:55 (KST)
|
||||||
- Current estimated parity vs `claw-code`: core execution engine `82%`, main chat UI `68%`, Cowork/Code status UX `63%`, internal settings linkage `88%`, overall AX Agent `74%`.
|
- Current estimated parity vs `claw-code`: core execution engine `82%`, main chat UI `68%`, Cowork/Code status UX `63%`, internal settings linkage `88%`, overall AX Agent `74%`.
|
||||||
- Engine-affecting settings should be handled conservatively during parity work. If a setting changes the main execution route, approval flow, or recovery behavior without representing a stable real-world user choice, it should be moved to developer-only UI or removed from user-facing surfaces.
|
- Engine-affecting settings should be handled conservatively during parity work. If a setting changes the main execution route, approval flow, or recovery behavior without representing a stable real-world user choice, it should be moved to developer-only UI or removed from user-facing surfaces.
|
||||||
|
- Updated: 2026-04-06 09:36 (KST)
|
||||||
|
- Progressed the maintainability track by moving runtime strip styling into `OperationalStatusPresentationCatalog.cs`, expanding permission/tool-result transcript catalogs with typed descriptions, and removing the stale plan-mode presentation branch from permission UI surfaces. The next structural focus remains footer/status/composer presentation slimming and regression ritual enforcement.
|
||||||
|
- Updated: 2026-04-06 09:44 (KST)
|
||||||
|
- Continued the maintainability track by splitting mixed inline interaction rendering into `ChatWindow.UserAskPresentation.cs` and `ChatWindow.PlanApprovalPresentation.cs`. This reduces message-type coupling inside the main window and keeps the next focus on footer/composer presentation and regression-routine formalization.
|
||||||
|
- Updated: 2026-04-06 09:58 (KST)
|
||||||
|
- Continued the maintainability track by splitting Git branch popup and footer-adjacent summary helpers into `ChatWindow.GitBranchPresentation.cs`, leaving `ChatWindow.FooterPresentation.cs` focused on folder bar state and preset-guide sync only.
|
||||||
|
- Formalized the regression ritual in `docs/AX_AGENT_REGRESSION_PROMPTS.md` by adding failure classes (`blank-reply`, `duplicate-banner`, `bad-approval-flow`, `queue-drift`, `restore-drift`, `status-noise`) and required prompt bundles per change area.
|
||||||
|
- Updated: 2026-04-06 10:07 (KST)
|
||||||
|
- Continued the maintainability track by moving topic preset rendering, custom preset context menus, and topic-selection application flow into `ChatWindow.TopicPresetPresentation.cs`. This reduces mixed preset UI logic inside `ChatWindow.xaml.cs` and keeps the main window closer to orchestration-only responsibility.
|
||||||
|
- Updated: 2026-04-06 11:52 (KST)
|
||||||
|
- Continued the tool/permission/skill sophistication track by expanding AX presentation catalogs toward `claw-code` specificity. `PermissionRequestPresentationCatalog.cs` now models action-level permission kinds plus severity/action hints, `ToolResultPresentationCatalog.cs` now models `approval_required`/`partial` result states plus follow-up guidance, and the AX skill gallery now exposes runtime-policy metadata that was previously hidden.
|
||||||
|
|
||||||
## Preserved History (Summary)
|
## Preserved History (Summary)
|
||||||
- Core loop guards and post-tool verification gates are already partially implemented.
|
- Core loop guards and post-tool verification gates are already partially implemented.
|
||||||
@@ -218,6 +229,97 @@
|
|||||||
- Remaining quality target:
|
- Remaining quality target:
|
||||||
- move more tool-result and permission-result presentation into smaller message-type-specific helpers, closer to `claw-code` component separation
|
- move more tool-result and permission-result presentation into smaller message-type-specific helpers, closer to `claw-code` component separation
|
||||||
|
|
||||||
|
## Focused Quality Tracks
|
||||||
|
- Updated: 2026-04-06 09:27 (KST)
|
||||||
|
- The remaining improvement work should now be managed in three parallel tracks so UX polish, runtime quality, and maintainability do not get mixed together.
|
||||||
|
|
||||||
|
### Track 1. User-Facing UI/UX Quality
|
||||||
|
- Reference:
|
||||||
|
- `src/components/Messages.tsx`
|
||||||
|
- `src/components/MessageRow.tsx`
|
||||||
|
- `src/components/StatusLine.tsx`
|
||||||
|
- `src/components/PromptInput/PromptInput.tsx`
|
||||||
|
- `src/components/PromptInput/PromptInputFooter.tsx`
|
||||||
|
- `src/components/SessionPreview.tsx`
|
||||||
|
- AX apply target:
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.xaml`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.MessageInteractions.cs`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.StatusPresentation.cs`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.FooterPresentation.cs`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs`
|
||||||
|
- Focus:
|
||||||
|
- keep transcript text dominant and metadata secondary
|
||||||
|
- make footer controls read as a task bar, not a settings strip
|
||||||
|
- unify preview surfaces, chooser popups, and approval cards under one visual language
|
||||||
|
- reduce visual noise from queue/status/diagnostic surfaces unless the state is actionable
|
||||||
|
- Completion criteria:
|
||||||
|
- message rows, footer, preview, and inline approval/question cards feel visually coherent
|
||||||
|
- chooser popups share the same spacing, hover behavior, and summary-row structure
|
||||||
|
- footer/status elements appear only when they convey useful state
|
||||||
|
- Quality criteria:
|
||||||
|
- a user can understand “what is happening now” from the transcript and footer without opening extra panels
|
||||||
|
- the interface remains readable under narrow widths without text clipping or layout jitter
|
||||||
|
|
||||||
|
### Track 2. LLM / Task Handling Quality
|
||||||
|
- Reference:
|
||||||
|
- `src/bootstrap/state.ts`
|
||||||
|
- `src/bridge/initReplBridge.ts`
|
||||||
|
- `src/bridge/sessionRunner.ts`
|
||||||
|
- `src/components/messages/AssistantToolUseMessage.tsx`
|
||||||
|
- `src/components/messages/PlanApprovalMessage.tsx`
|
||||||
|
- `src/components/permissions/*`
|
||||||
|
- AX apply target:
|
||||||
|
- `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`
|
||||||
|
- `src/AxCopilot/Services/Agent/AgentLoopService.cs`
|
||||||
|
- `src/AxCopilot/Services/Agent/AgentTranscriptDisplayCatalog.cs`
|
||||||
|
- `src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs`
|
||||||
|
- `src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.InlineInteractions.cs`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.TranscriptPolicy.cs`
|
||||||
|
- Focus:
|
||||||
|
- keep all entry routes (`send`, `retry`, `regenerate`, `queue`, `slash`) on the same prepared execution path
|
||||||
|
- distinguish `success / error / reject / cancel / needs-approval` tool results more clearly
|
||||||
|
- keep plan approval and user-question flows transcript-native by default
|
||||||
|
- minimize mismatches between execution state and what the user sees in the timeline
|
||||||
|
- Completion criteria:
|
||||||
|
- plan/permission/tool-result/question events all have consistent transcript-native lifecycles
|
||||||
|
- reopen/retry/queue/compact flows preserve the same visible runtime state
|
||||||
|
- tool failures and permission rejections are clearly distinguishable in transcript rendering
|
||||||
|
- Quality criteria:
|
||||||
|
- the same prompt under the same tab/settings uses the same execution route
|
||||||
|
- users can tell whether the agent succeeded, failed, was blocked, or is waiting for approval without reading raw diagnostics
|
||||||
|
|
||||||
|
### Track 3. Maintainability / Extensibility Structure
|
||||||
|
- Reference:
|
||||||
|
- `src/components/*` split by role in `claw-code`
|
||||||
|
- `src/components/messages/*`
|
||||||
|
- `src/components/permissions/*`
|
||||||
|
- `src/components/PromptInput/*`
|
||||||
|
- AX apply target:
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
|
||||||
|
- `src/AxCopilot/Views/ChatWindow.*.cs`
|
||||||
|
- `src/AxCopilot/Services/AppStateService.cs`
|
||||||
|
- `src/AxCopilot/Services/Agent/*.cs`
|
||||||
|
- Focus:
|
||||||
|
- continue shrinking `ChatWindow.xaml.cs` toward orchestration-only responsibility
|
||||||
|
- keep renderer, popup, footer, message-interaction, status, and preview logic in dedicated partials
|
||||||
|
- centralize presentation rules in catalogs/models instead of scattered UI string/visibility branches
|
||||||
|
- prepare the codebase for new permission types, new tool classes, and new transcript card types without re-bloating the main window file
|
||||||
|
- Completion criteria:
|
||||||
|
- `ChatWindow.xaml.cs` owns orchestration and runtime coordination more than direct UI element construction
|
||||||
|
- new message/permission/tool card types can be added via presentation catalogs or dedicated partials
|
||||||
|
- runtime summary and footer/status visibility derive from presentation models rather than ad-hoc branching
|
||||||
|
- Quality criteria:
|
||||||
|
- adding a new tool-result or approval type should mostly affect one catalog/renderer area
|
||||||
|
- future UI polish work should land in dedicated presentation files rather than expanding the main window file again
|
||||||
|
|
||||||
|
## Recommended Execution Order
|
||||||
|
- Updated: 2026-04-06 09:27 (KST)
|
||||||
|
1. Finish Track 2 consistency first whenever a UX issue is caused by runtime truth mismatch.
|
||||||
|
2. Apply Track 1 visual cleanup only after the state/message lifecycle is stable for that surface.
|
||||||
|
3. Fold each stable surface into Track 3 structure immediately so later changes do not reintroduce `ChatWindow.xaml.cs` sprawl.
|
||||||
|
4. Keep validating against `docs/AX_AGENT_REGRESSION_PROMPTS.md` after each change set, especially for `plan / permission / queue / compact / reopen`.
|
||||||
|
|
||||||
## Current Snapshot
|
## Current Snapshot
|
||||||
- Updated: 2026-04-05 19:42 (KST)
|
- Updated: 2026-04-05 19:42 (KST)
|
||||||
- Estimated parity:
|
- Estimated parity:
|
||||||
@@ -407,3 +509,15 @@
|
|||||||
- `BuildTopicButtons()` rebuild frequency
|
- `BuildTopicButtons()` rebuild frequency
|
||||||
- `OnAgentEvent` timeline churn during long Cowork/Code runs
|
- `OnAgentEvent` timeline churn during long Cowork/Code runs
|
||||||
- compact queue summary still needs one more pass to fully match `claw-code` footer minimalism
|
- compact queue summary still needs one more pass to fully match `claw-code` footer minimalism
|
||||||
|
|
||||||
|
## Progress Notes
|
||||||
|
- 업데이트: 2026-04-06 00:58 (KST)
|
||||||
|
- transcript renderer 분리 1차 완료
|
||||||
|
- AX 적용: [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)
|
||||||
|
- 완료 조건: `plan / ask / task-summary` 렌더 helper가 메인 `ChatWindow.xaml.cs` 밖으로 이동
|
||||||
|
- permission / tool-result presentation catalog 도입
|
||||||
|
- AX 적용: [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)
|
||||||
|
- 완료 조건: `AddAgentEventBanner(...)`가 권한/도구 결과 badge 메타를 inline switch가 아니라 catalog에서 해석
|
||||||
|
- runtime summary 전용 계층 1차 반영
|
||||||
|
- AX 적용: [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)
|
||||||
|
- 완료 조건: 상태선 UI가 `OperationalStatusPresentationState`를 소비해 strip/runtime badge visibility를 계산
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public partial class App : System.Windows.Application
|
|||||||
private DockBarWindow? _dockBar;
|
private DockBarWindow? _dockBar;
|
||||||
private FileDialogWatcher? _fileDialogWatcher;
|
private FileDialogWatcher? _fileDialogWatcher;
|
||||||
private volatile IndexService? _indexService;
|
private volatile IndexService? _indexService;
|
||||||
|
private int _indexWarmupStarted;
|
||||||
public IndexService? IndexService => _indexService;
|
public IndexService? IndexService => _indexService;
|
||||||
public SettingsService? SettingsService => _settings;
|
public SettingsService? SettingsService => _settings;
|
||||||
public ClipboardHistoryService? ClipboardHistoryService => _clipboardHistory;
|
public ClipboardHistoryService? ClipboardHistoryService => _clipboardHistory;
|
||||||
@@ -114,6 +115,9 @@ public partial class App : System.Windows.Application
|
|||||||
|
|
||||||
_indexService = new IndexService(settings);
|
_indexService = new IndexService(settings);
|
||||||
var indexService = _indexService;
|
var indexService = _indexService;
|
||||||
|
indexService.LoadCachedIndex();
|
||||||
|
if (indexService.HasCachedIndexLoaded)
|
||||||
|
indexService.StartWatchers();
|
||||||
var fuzzyEngine = new FuzzyEngine(indexService);
|
var fuzzyEngine = new FuzzyEngine(indexService);
|
||||||
var commandResolver = new CommandResolver(fuzzyEngine, settings);
|
var commandResolver = new CommandResolver(fuzzyEngine, settings);
|
||||||
var contextManager = new ContextManager(settings);
|
var contextManager = new ContextManager(settings);
|
||||||
@@ -183,7 +187,7 @@ public partial class App : System.Windows.Application
|
|||||||
commandResolver.RegisterHandler(new SessionHandler(settings));
|
commandResolver.RegisterHandler(new SessionHandler(settings));
|
||||||
commandResolver.RegisterHandler(new BatchRenameHandler());
|
commandResolver.RegisterHandler(new BatchRenameHandler());
|
||||||
_schedulerService = new SchedulerService(settings);
|
_schedulerService = new SchedulerService(settings);
|
||||||
_schedulerService.Start();
|
_schedulerService.Refresh();
|
||||||
commandResolver.RegisterHandler(new ScheduleHandler(settings));
|
commandResolver.RegisterHandler(new ScheduleHandler(settings));
|
||||||
commandResolver.RegisterHandler(new MacroHandler(settings));
|
commandResolver.RegisterHandler(new MacroHandler(settings));
|
||||||
commandResolver.RegisterHandler(new ContextHandler());
|
commandResolver.RegisterHandler(new ContextHandler());
|
||||||
@@ -287,14 +291,9 @@ public partial class App : System.Windows.Application
|
|||||||
() => _clipboardHistory?.Initialize(),
|
() => _clipboardHistory?.Initialize(),
|
||||||
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
||||||
|
|
||||||
// ─── ChatWindow 미리 생성 (앱 유휴 시점에 숨겨진 채로 초기화) ──────────
|
// ─── 런처 인덱스 캐시/감시 초기화 ─────────────────────────────────────
|
||||||
// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 열림
|
// 앱 시작 시엔 저장된 캐시를 즉시 로드하고, 파일 감시는 증분 반영 위주로 유지합니다.
|
||||||
Dispatcher.BeginInvoke(
|
// 무거운 전체 재색인은 실제 검색이 시작될 때 한 번만 보강 실행합니다.
|
||||||
() => PrewarmChatWindow(),
|
|
||||||
System.Windows.Threading.DispatcherPriority.SystemIdle);
|
|
||||||
|
|
||||||
// ─── 인덱스 빌드 (백그라운드) + 완료 후 FileSystemWatcher 시작 ────────
|
|
||||||
_ = indexService.BuildAsync().ContinueWith(_ => indexService.StartWatchers());
|
|
||||||
|
|
||||||
// ─── 글로벌 훅 + 스니펫 확장기 ───────────────────────────────────────
|
// ─── 글로벌 훅 + 스니펫 확장기 ───────────────────────────────────────
|
||||||
_inputListener = new InputListener();
|
_inputListener = new InputListener();
|
||||||
@@ -327,12 +326,21 @@ public partial class App : System.Windows.Application
|
|||||||
if (_launcher == null || _launcher.IsVisible) return;
|
if (_launcher == null || _launcher.IsVisible) return;
|
||||||
if (_settings?.Settings.Launcher.EnableFileDialogIntegration != true) return;
|
if (_settings?.Settings.Launcher.EnableFileDialogIntegration != true) return;
|
||||||
WindowTracker.Capture();
|
WindowTracker.Capture();
|
||||||
_launcher.Show();
|
ShowLauncherWindow();
|
||||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
||||||
() => _launcher.SetInputText("cd "));
|
() => _launcher.SetInputText("cd "));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
_fileDialogWatcher.Start();
|
UpdateFileDialogWatcherState();
|
||||||
|
|
||||||
|
settings.SettingsChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
UpdateFileDialogWatcherState();
|
||||||
|
_schedulerService?.Refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 독 바 자동 표시
|
// 독 바 자동 표시
|
||||||
if (settings.Settings.Launcher.DockBarAutoShow)
|
if (settings.Settings.Launcher.DockBarAutoShow)
|
||||||
@@ -366,7 +374,7 @@ public partial class App : System.Windows.Application
|
|||||||
{
|
{
|
||||||
if (_launcher == null) return;
|
if (_launcher == null) return;
|
||||||
UsageStatisticsService.RecordLauncherOpen();
|
UsageStatisticsService.RecordLauncherOpen();
|
||||||
_launcher.Show();
|
ShowLauncherWindow();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -414,7 +422,7 @@ public partial class App : System.Windows.Application
|
|||||||
{
|
{
|
||||||
case TextActionPopup.ActionResult.OpenLauncher:
|
case TextActionPopup.ActionResult.OpenLauncher:
|
||||||
UsageStatisticsService.RecordLauncherOpen();
|
UsageStatisticsService.RecordLauncherOpen();
|
||||||
_launcher.Show();
|
ShowLauncherWindow();
|
||||||
break;
|
break;
|
||||||
case TextActionPopup.ActionResult.None:
|
case TextActionPopup.ActionResult.None:
|
||||||
break; // Esc 또는 포커스 잃음
|
break; // Esc 또는 포커스 잃음
|
||||||
@@ -429,7 +437,7 @@ public partial class App : System.Windows.Application
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
UsageStatisticsService.RecordLauncherOpen();
|
UsageStatisticsService.RecordLauncherOpen();
|
||||||
_launcher.Show();
|
ShowLauncherWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -505,7 +513,7 @@ public partial class App : System.Windows.Application
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ! 프리픽스로 AX Agent에 전달
|
// ! 프리픽스로 AX Agent에 전달
|
||||||
_launcher?.Show();
|
ShowLauncherWindow();
|
||||||
_launcher?.SetInputText($"! {prompt}");
|
_launcher?.SetInputText($"! {prompt}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +580,7 @@ public partial class App : System.Windows.Application
|
|||||||
_trayMenu
|
_trayMenu
|
||||||
.AddHeader(versionText)
|
.AddHeader(versionText)
|
||||||
.AddItem("\uE7C5", "AX Commander 호출하기", () =>
|
.AddItem("\uE7C5", "AX Commander 호출하기", () =>
|
||||||
Dispatcher.Invoke(() => _launcher?.Show()))
|
Dispatcher.Invoke(ShowLauncherWindow))
|
||||||
.AddItem("\uE8BD", "AX Agent 대화하기", () =>
|
.AddItem("\uE8BD", "AX Agent 대화하기", () =>
|
||||||
Dispatcher.Invoke(OpenAiChat), out var aiTrayItem)
|
Dispatcher.Invoke(OpenAiChat), out var aiTrayItem)
|
||||||
.AddItem("\uE8A7", "독 바 표시", () =>
|
.AddItem("\uE8A7", "독 바 표시", () =>
|
||||||
@@ -631,7 +639,7 @@ public partial class App : System.Windows.Application
|
|||||||
if (settings.Settings.AiEnabled)
|
if (settings.Settings.AiEnabled)
|
||||||
OpenAiChat();
|
OpenAiChat();
|
||||||
else
|
else
|
||||||
_launcher?.Show();
|
ShowLauncherWindow();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (e.Button == System.Windows.Forms.MouseButtons.Right)
|
else if (e.Button == System.Windows.Forms.MouseButtons.Right)
|
||||||
@@ -666,14 +674,29 @@ public partial class App : System.Windows.Application
|
|||||||
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
|
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
|
||||||
private Views.ChatWindow? _chatWindow;
|
private Views.ChatWindow? _chatWindow;
|
||||||
|
|
||||||
/// <summary>
|
public void EnsureIndexWarmupStarted()
|
||||||
/// ChatWindow를 백그라운드에서 미리 생성합니다 (앱 시작 후 저우선순위로 호출).
|
|
||||||
/// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 Show/Activate만 수행합니다.
|
|
||||||
/// </summary>
|
|
||||||
internal void PrewarmChatWindow()
|
|
||||||
{
|
{
|
||||||
if (_chatWindow != null || _settings == null) return;
|
if (_indexService == null) return;
|
||||||
_chatWindow = new Views.ChatWindow(_settings);
|
if (Interlocked.Exchange(ref _indexWarmupStarted, 1) == 1) return;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _indexService.BuildAsync().ConfigureAwait(false);
|
||||||
|
_indexService.StartWatchers();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"런처 인덱스 초기화 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowLauncherWindow()
|
||||||
|
{
|
||||||
|
if (_launcher == null) return;
|
||||||
|
_launcher.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenAiChat()
|
private void OpenAiChat()
|
||||||
@@ -701,7 +724,7 @@ public partial class App : System.Windows.Application
|
|||||||
_dockBar.OnQuickSearch = query =>
|
_dockBar.OnQuickSearch = query =>
|
||||||
{
|
{
|
||||||
if (_launcher == null) return;
|
if (_launcher == null) return;
|
||||||
_launcher.Show();
|
ShowLauncherWindow();
|
||||||
_launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시
|
_launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시
|
||||||
if (!string.IsNullOrEmpty(query))
|
if (!string.IsNullOrEmpty(query))
|
||||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
||||||
@@ -716,7 +739,7 @@ public partial class App : System.Windows.Application
|
|||||||
_dockBar.OnOpenAgent = () =>
|
_dockBar.OnOpenAgent = () =>
|
||||||
{
|
{
|
||||||
if (_launcher == null) return;
|
if (_launcher == null) return;
|
||||||
_launcher.Show();
|
ShowLauncherWindow();
|
||||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
|
||||||
() => _launcher.SetInputText("!"));
|
() => _launcher.SetInputText("!"));
|
||||||
};
|
};
|
||||||
@@ -944,6 +967,7 @@ public partial class App : System.Windows.Application
|
|||||||
_chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기
|
_chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기
|
||||||
_inputListener?.Dispose();
|
_inputListener?.Dispose();
|
||||||
_clipboardHistory?.Dispose();
|
_clipboardHistory?.Dispose();
|
||||||
|
_fileDialogWatcher?.Dispose();
|
||||||
_indexService?.Dispose();
|
_indexService?.Dispose();
|
||||||
_schedulerService?.Dispose();
|
_schedulerService?.Dispose();
|
||||||
_sessionTracking?.Dispose();
|
_sessionTracking?.Dispose();
|
||||||
@@ -954,4 +978,18 @@ public partial class App : System.Windows.Application
|
|||||||
LogService.Info("=== AX Copilot 종료 ===");
|
LogService.Info("=== AX Copilot 종료 ===");
|
||||||
base.OnExit(e);
|
base.OnExit(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateFileDialogWatcherState()
|
||||||
|
{
|
||||||
|
if (_fileDialogWatcher == null || _settings == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_settings.Settings.Launcher.EnableFileDialogIntegration)
|
||||||
|
{
|
||||||
|
_fileDialogWatcher.Start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileDialogWatcher.Stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,16 @@
|
|||||||
|
|
||||||
<!-- Release 빌드 시 추가 난독화 설정 -->
|
<!-- Release 빌드 시 추가 난독화 설정 -->
|
||||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||||
|
<Optimize>true</Optimize>
|
||||||
<!-- 사용하지 않는 멤버 제거 (IL trimming) -->
|
<!-- 사용하지 않는 멤버 제거 (IL trimming) -->
|
||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<!-- PDB 제거 -->
|
<!-- PDB 제거 -->
|
||||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||||
|
<!-- 배포판 보호 수준 강화 -->
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- UseWindowsForms의 암묵적 using(System.Windows.Forms)이 WPF의
|
<!-- UseWindowsForms의 암묵적 using(System.Windows.Forms)이 WPF의
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ namespace AxCopilot.Core;
|
|||||||
public class FuzzyEngine
|
public class FuzzyEngine
|
||||||
{
|
{
|
||||||
private readonly IndexService _index;
|
private readonly IndexService _index;
|
||||||
|
private readonly object _cacheLock = new();
|
||||||
|
private readonly Dictionary<string, List<FuzzyResult>> _queryCache = new(StringComparer.Ordinal);
|
||||||
|
private readonly Queue<string> _queryCacheOrder = new();
|
||||||
|
private const int QueryCacheLimit = 64;
|
||||||
|
private int _indexGeneration;
|
||||||
|
|
||||||
public FuzzyEngine(IndexService index)
|
public FuzzyEngine(IndexService index)
|
||||||
{
|
{
|
||||||
_index = index;
|
_index = index;
|
||||||
|
_index.IndexRebuilt += (_, _) => InvalidateQueryCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -25,6 +31,13 @@ public class FuzzyEngine
|
|||||||
return Enumerable.Empty<FuzzyResult>();
|
return Enumerable.Empty<FuzzyResult>();
|
||||||
|
|
||||||
var normalized = query.Trim().ToLowerInvariant();
|
var normalized = query.Trim().ToLowerInvariant();
|
||||||
|
var cacheKey = $"{_indexGeneration}:{maxResults}:{normalized}";
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
if (_queryCache.TryGetValue(cacheKey, out var cached))
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
var entries = _index.Entries;
|
var entries = _index.Entries;
|
||||||
|
|
||||||
// 쿼리 언어 타입 1회 사전 분류 — 항목마다 재계산하지 않음
|
// 쿼리 언어 타입 1회 사전 분류 — 항목마다 재계산하지 않음
|
||||||
@@ -36,20 +49,56 @@ public class FuzzyEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 300개 초과 시 PLINQ 병렬 처리
|
// 300개 초과 시 PLINQ 병렬 처리
|
||||||
|
List<FuzzyResult> results;
|
||||||
if (entries.Count > 300)
|
if (entries.Count > 300)
|
||||||
{
|
{
|
||||||
return entries.AsParallel()
|
results = entries.AsParallel()
|
||||||
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
|
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
|
||||||
.Where(r => r.Score > 0)
|
.Where(r => r.Score > 0)
|
||||||
.OrderByDescending(r => r.Score)
|
.OrderByDescending(r => r.Score)
|
||||||
.Take(maxResults);
|
.Take(maxResults)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
results = entries
|
||||||
|
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
|
||||||
|
.Where(r => r.Score > 0)
|
||||||
|
.OrderByDescending(r => r.Score)
|
||||||
|
.Take(maxResults)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries
|
StoreCachedResults(cacheKey, results);
|
||||||
.Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean)))
|
return results;
|
||||||
.Where(r => r.Score > 0)
|
}
|
||||||
.OrderByDescending(r => r.Score)
|
|
||||||
.Take(maxResults);
|
private void InvalidateQueryCache()
|
||||||
|
{
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
_indexGeneration++;
|
||||||
|
_queryCache.Clear();
|
||||||
|
_queryCacheOrder.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StoreCachedResults(string cacheKey, List<FuzzyResult> results)
|
||||||
|
{
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
if (_queryCache.ContainsKey(cacheKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_queryCache[cacheKey] = results;
|
||||||
|
_queryCacheOrder.Enqueue(cacheKey);
|
||||||
|
|
||||||
|
while (_queryCacheOrder.Count > QueryCacheLimit)
|
||||||
|
{
|
||||||
|
var oldKey = _queryCacheOrder.Dequeue();
|
||||||
|
_queryCache.Remove(oldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>미리 계산된 캐시 필드를 활용하는 빠른 점수 계산.</summary>
|
/// <summary>미리 계산된 캐시 필드를 활용하는 빠른 점수 계산.</summary>
|
||||||
|
|||||||
@@ -177,12 +177,17 @@ public class InputListener : IDisposable
|
|||||||
if (wParam != WM_KEYDOWN && wParam != WM_SYSKEYDOWN)
|
if (wParam != WM_KEYDOWN && wParam != WM_SYSKEYDOWN)
|
||||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||||
|
|
||||||
|
var isHotkeyMainKey = vkCode == _hotkey.VkCode;
|
||||||
|
var isCaptureMainKey = _captureHotkeyEnabled && vkCode == _captureHotkey.VkCode;
|
||||||
|
var shouldRunKeyFilter = KeyFilter != null;
|
||||||
|
var needsSuppressedWindowCheck = isHotkeyMainKey || isCaptureMainKey || shouldRunKeyFilter;
|
||||||
|
|
||||||
// ─── 시스템 파일 대화상자에서는 핫키·스니펫 전부 비활성 ──────────────
|
// ─── 시스템 파일 대화상자에서는 핫키·스니펫 전부 비활성 ──────────────
|
||||||
if (IsSuppressedForegroundWindow())
|
if (needsSuppressedWindowCheck && IsSuppressedForegroundWindow())
|
||||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||||
|
|
||||||
// ─── 핫키 감지 ──────────────────────────────────────────────────────
|
// ─── 핫키 감지 ──────────────────────────────────────────────────────
|
||||||
if (!SuspendHotkey && vkCode == _hotkey.VkCode)
|
if (!SuspendHotkey && isHotkeyMainKey)
|
||||||
{
|
{
|
||||||
bool ctrlOk = !_hotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
bool ctrlOk = !_hotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||||
bool altOk = !_hotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
bool altOk = !_hotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
||||||
@@ -202,7 +207,7 @@ public class InputListener : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 글로벌 캡처 단축키 감지 ─────────────────────────────────────────
|
// ─── 글로벌 캡처 단축키 감지 ─────────────────────────────────────────
|
||||||
if (!SuspendHotkey && _captureHotkeyEnabled && vkCode == _captureHotkey.VkCode)
|
if (!SuspendHotkey && isCaptureMainKey)
|
||||||
{
|
{
|
||||||
bool ctrlOk = !_captureHotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
bool ctrlOk = !_captureHotkey.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||||
bool altOk = !_captureHotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
bool altOk = !_captureHotkey.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
||||||
@@ -221,7 +226,7 @@ public class InputListener : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 스니펫 키 필터 ─────────────────────────────────────────────────
|
// ─── 스니펫 키 필터 ─────────────────────────────────────────────────
|
||||||
if (KeyFilter?.Invoke(vkCode) == true)
|
if (shouldRunKeyFilter && KeyFilter?.Invoke(vkCode) == true)
|
||||||
return (IntPtr)1;
|
return (IntPtr)1;
|
||||||
|
|
||||||
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
return CallNextHookEx(_hookHandle, nCode, wParam, lParam);
|
||||||
|
|||||||
@@ -49,20 +49,30 @@ public class SnippetExpander
|
|||||||
// 자동 확장 비활성화 시 즉시 통과
|
// 자동 확장 비활성화 시 즉시 통과
|
||||||
if (!_settings.Settings.Launcher.SnippetAutoExpand) return false;
|
if (!_settings.Settings.Launcher.SnippetAutoExpand) return false;
|
||||||
|
|
||||||
// Ctrl/Alt 조합은 무시 (단축키와 충돌 방지)
|
// 추적 중이 아닐 때는 ';' 시작 키만 검사해서 훅 경로 부담을 최소화합니다.
|
||||||
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
|
if (!_tracking)
|
||||||
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
|
|
||||||
|
|
||||||
// ─── 트리거 시작: ';' 입력 ──────────────────────────────────────────
|
|
||||||
if (vkCode == VK_OEM_1 && (GetAsyncKeyState(VK_SHIFT) & 0x8000) == 0)
|
|
||||||
{
|
{
|
||||||
|
if (vkCode != VK_OEM_1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ((GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
_tracking = true;
|
_tracking = true;
|
||||||
_buffer.Clear();
|
_buffer.Clear();
|
||||||
_buffer.Append(';');
|
_buffer.Append(';');
|
||||||
return false; // ';'는 소비하지 않고 앱으로 전달
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_tracking) return false;
|
// Ctrl/Alt 조합은 무시 (단축키와 충돌 방지)
|
||||||
|
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
|
||||||
|
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
|
||||||
|
|
||||||
// ─── 영문자/숫자 — 버퍼에 추가 ─────────────────────────────────────
|
// ─── 영문자/숫자 — 버퍼에 추가 ─────────────────────────────────────
|
||||||
if ((vkCode >= 0x41 && vkCode <= 0x5A) || // A-Z
|
if ((vkCode >= 0x41 && vkCode <= 0x5A) || // A-Z
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class WebSearchHandler : IActionHandler
|
|||||||
|
|
||||||
/// <summary>내장 검색 엔진 아이콘 폴더 (Assets\SearchEngines)</summary>
|
/// <summary>내장 검색 엔진 아이콘 폴더 (Assets\SearchEngines)</summary>
|
||||||
private static readonly string _iconDir = Path.Combine(
|
private static readonly string _iconDir = Path.Combine(
|
||||||
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? ".",
|
AppContext.BaseDirectory,
|
||||||
"Assets", "SearchEngines");
|
"Assets", "SearchEngines");
|
||||||
|
|
||||||
public WebSearchHandler(SettingsService settings) => _settings = settings;
|
public WebSearchHandler(SettingsService settings) => _settings = settings;
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ public class LauncherSettings
|
|||||||
[JsonPropertyName("showWidgetBattery")]
|
[JsonPropertyName("showWidgetBattery")]
|
||||||
public bool ShowWidgetBattery { get; set; } = false;
|
public bool ShowWidgetBattery { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>런처 하단 빠른 실행 칩 표시 여부. 기본 false.</summary>
|
||||||
|
[JsonPropertyName("showLauncherBottomQuickActions")]
|
||||||
|
public bool ShowLauncherBottomQuickActions { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>단축키 헬프 창에서 아이콘 색상을 테마 AccentColor 기준으로 표시. 기본 true(테마색).</summary>
|
/// <summary>단축키 헬프 창에서 아이콘 색상을 테마 AccentColor 기준으로 표시. 기본 true(테마색).</summary>
|
||||||
[JsonPropertyName("shortcutHelpUseThemeColor")]
|
[JsonPropertyName("shortcutHelpUseThemeColor")]
|
||||||
public bool ShortcutHelpUseThemeColor { get; set; } = true;
|
public bool ShortcutHelpUseThemeColor { get; set; } = true;
|
||||||
@@ -227,13 +231,13 @@ public class LauncherSettings
|
|||||||
|
|
||||||
// ─── 선택 텍스트 AI 명령 설정 ───────────────────────────────────────────
|
// ─── 선택 텍스트 AI 명령 설정 ───────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>선택 텍스트 AI 명령 활성화. 기본 true.</summary>
|
/// <summary>선택 텍스트 AI 명령 활성화. 기본 false.</summary>
|
||||||
[JsonPropertyName("enableTextAction")]
|
[JsonPropertyName("enableTextAction")]
|
||||||
public bool EnableTextAction { get; set; } = true;
|
public bool EnableTextAction { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>활성화된 텍스트 AI 명령 목록. 가능한 값: translate, summarize, grammar, explain, rewrite</summary>
|
/// <summary>활성화된 텍스트 AI 명령 목록. 가능한 값: translate, summarize, grammar, explain, rewrite</summary>
|
||||||
[JsonPropertyName("textActionCommands")]
|
[JsonPropertyName("textActionCommands")]
|
||||||
public List<string> TextActionCommands { get; set; } = new() { "translate", "summarize", "grammar", "explain", "rewrite" };
|
public List<string> TextActionCommands { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>번역 기본 언어. 기본 "한국어↔영어 자동".</summary>
|
/// <summary>번역 기본 언어. 기본 "한국어↔영어 자동".</summary>
|
||||||
[JsonPropertyName("textActionTranslateLanguage")]
|
[JsonPropertyName("textActionTranslateLanguage")]
|
||||||
@@ -995,6 +999,10 @@ public class LlmSettings
|
|||||||
[JsonPropertyName("maxMemoryEntries")]
|
[JsonPropertyName("maxMemoryEntries")]
|
||||||
public int MaxMemoryEntries { get; set; } = 100;
|
public int MaxMemoryEntries { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>프로젝트 바깥 메모리 include 허용. 기본 false.</summary>
|
||||||
|
[JsonPropertyName("allowExternalMemoryIncludes")]
|
||||||
|
public bool AllowExternalMemoryIncludes { get; set; } = false;
|
||||||
|
|
||||||
// ─── 이미지 입력 (멀티모달) ──────────────────────────────────────────
|
// ─── 이미지 입력 (멀티모달) ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>이미지 입력(Ctrl+V 붙여넣기, 파일 첨부) 활성화. 기본 true.</summary>
|
/// <summary>이미지 입력(Ctrl+V 붙여넣기, 파일 첨부) 활성화. 기본 true.</summary>
|
||||||
@@ -1035,9 +1043,9 @@ public class LlmSettings
|
|||||||
[JsonPropertyName("agentTheme")]
|
[JsonPropertyName("agentTheme")]
|
||||||
public string AgentTheme { get; set; } = "system";
|
public string AgentTheme { get; set; } = "system";
|
||||||
|
|
||||||
/// <summary>AX Agent 전용 테마 스타일. claw | codex | slate</summary>
|
/// <summary>AX Agent 전용 테마 스타일. claude | codex | slate</summary>
|
||||||
[JsonPropertyName("agentThemePreset")]
|
[JsonPropertyName("agentThemePreset")]
|
||||||
public string AgentThemePreset { get; set; } = "claw";
|
public string AgentThemePreset { get; set; } = "claude";
|
||||||
|
|
||||||
// ─── 알림 ──────────────────────────────────────────────────────────
|
// ─── 알림 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1383,7 +1391,7 @@ public class RegisteredModel
|
|||||||
|
|
||||||
// ── CP4D (IBM Cloud Pak for Data) 인증 ──────────────────────────────
|
// ── CP4D (IBM Cloud Pak for Data) 인증 ──────────────────────────────
|
||||||
|
|
||||||
/// <summary>인증 방식. bearer (기본) | cp4d</summary>
|
/// <summary>인증 방식. bearer (기본) | ibm_iam | cp4d_password | cp4d_api_key</summary>
|
||||||
[JsonPropertyName("authType")]
|
[JsonPropertyName("authType")]
|
||||||
public string AuthType { get; set; } = "bearer";
|
public string AuthType { get; set; } = "bearer";
|
||||||
|
|
||||||
@@ -1395,7 +1403,7 @@ public class RegisteredModel
|
|||||||
[JsonPropertyName("cp4dUsername")]
|
[JsonPropertyName("cp4dUsername")]
|
||||||
public string Cp4dUsername { get; set; } = "";
|
public string Cp4dUsername { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>CP4D 비밀번호 또는 API 키 (EncryptionEnabled=true 시 암호화 저장)</summary>
|
/// <summary>CP4D 비밀번호 또는 API 키 (인증 방식에 따라 사용, EncryptionEnabled=true 시 암호화 저장)</summary>
|
||||||
[JsonPropertyName("cp4dPassword")]
|
[JsonPropertyName("cp4dPassword")]
|
||||||
public string Cp4dPassword { get; set; } = "";
|
public string Cp4dPassword { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,8 +338,8 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (decision == "취소")
|
if (decision == "취소")
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다");
|
||||||
return "작업이 취소되었습니다.";
|
return "작업이 중단되었습니다.";
|
||||||
}
|
}
|
||||||
else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps))
|
else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps))
|
||||||
{
|
{
|
||||||
@@ -378,8 +378,8 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (decision == "취소")
|
if (decision == "취소")
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다");
|
||||||
return "작업이 취소되었습니다.";
|
return "작업이 중단되었습니다.";
|
||||||
}
|
}
|
||||||
if (TryParseApprovedPlanDecision(decision, out var revisedPlanText, out var revisedPlanSteps))
|
if (TryParseApprovedPlanDecision(decision, out var revisedPlanText, out var revisedPlanSteps))
|
||||||
{
|
{
|
||||||
@@ -466,11 +466,16 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
|
EmitEvent(AgentEventType.Thinking, "", $"LLM에 요청 중... (반복 {iteration}/{maxIterations})");
|
||||||
|
|
||||||
// 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
|
// Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지)
|
||||||
if (llm.FreeTierMode && iteration > 1)
|
var activeService = (_settings.Settings.Llm.Service ?? "").Trim();
|
||||||
|
var shouldApplyFreeTierDelay =
|
||||||
|
llm.FreeTierMode
|
||||||
|
&& iteration > 1
|
||||||
|
&& string.Equals(activeService, "gemini", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (shouldApplyFreeTierDelay)
|
||||||
{
|
{
|
||||||
var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : 4;
|
var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : 4;
|
||||||
EmitEvent(AgentEventType.Thinking, "", $"무료 티어 모드: {delaySec}초 대기 중...");
|
EmitEvent(AgentEventType.Thinking, "", $"Gemini 무료 티어 대기: {delaySec}초 후 다음 호출을 진행합니다...");
|
||||||
await Task.Delay(delaySec * 1000, ct);
|
await Task.Delay(delaySec * 1000, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,8 +659,8 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (decision == "취소")
|
if (decision == "취소")
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다");
|
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다");
|
||||||
return "작업이 취소되었습니다.";
|
return "작업이 중단되었습니다.";
|
||||||
}
|
}
|
||||||
else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps))
|
else if (TryParseApprovedPlanDecision(decision, out var approvedPlanText, out var approvedPlanSteps))
|
||||||
{
|
{
|
||||||
@@ -1211,15 +1216,15 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다");
|
EmitEvent(AgentEventType.StopRequested, "", "작업 중단이 요청되었습니다");
|
||||||
await RunRuntimeHooksAsync(
|
await RunRuntimeHooksAsync(
|
||||||
"__stop_requested__",
|
"__stop_requested__",
|
||||||
"post",
|
"post",
|
||||||
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
|
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
|
||||||
"cancelled",
|
"cancelled",
|
||||||
success: false);
|
success: false);
|
||||||
EmitEvent(AgentEventType.Complete, "", "사용자가 작업을 취소했습니다.");
|
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
|
||||||
return "사용자가 작업을 취소했습니다.";
|
return "작업이 중단되었습니다.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1440,7 +1445,7 @@ public partial class AgentLoopService
|
|||||||
|
|
||||||
if (ct.IsCancellationRequested)
|
if (ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.StopRequested, "", "사용자가 작업 중단을 요청했습니다");
|
EmitEvent(AgentEventType.StopRequested, "", "작업 중단이 요청되었습니다");
|
||||||
await RunRuntimeHooksAsync(
|
await RunRuntimeHooksAsync(
|
||||||
"__stop_requested__",
|
"__stop_requested__",
|
||||||
"post",
|
"post",
|
||||||
@@ -4828,3 +4833,4 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,11 +142,11 @@ public partial class AgentLoopService
|
|||||||
messages.Add(new ChatMessage
|
messages.Add(new ChatMessage
|
||||||
{
|
{
|
||||||
Role = "user",
|
Role = "user",
|
||||||
Content = "[System:CodeDiffGate] 肄붾뱶 蹂寃??댄썑 diff 洹쇨굅媛 遺議깊빀?덈떎. " +
|
Content = "[System:CodeDiffGate] 코드 변경 이후 diff 근거가 부족합니다. " +
|
||||||
"git_tool ?꾧뎄濡?蹂寃??뚯씪怨??듭떖 diff瑜?癒쇱? ?뺤씤?섍퀬 ?붿빟?섏꽭?? " +
|
"git_tool 도구로 변경 파일과 핵심 diff를 먼저 확인하고 요약하세요. " +
|
||||||
"吏湲?利됱떆 git_tool ?꾧뎄瑜??몄텧?섏꽭??"
|
"지금 즉시 git_tool 도구를 호출하세요."
|
||||||
});
|
});
|
||||||
EmitEvent(AgentEventType.Thinking, "", "肄붾뱶 diff 洹쇨굅媛 遺議깊빐 git diff 寃利앹쓣 異붽??⑸땲??..");
|
EmitEvent(AgentEventType.Thinking, "", "코드 diff 근거가 부족해 git diff 검증을 추가합니다...");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ public partial class AgentLoopService
|
|||||||
Role = "user",
|
Role = "user",
|
||||||
Content = BuildRecentExecutionEvidencePrompt(taskPolicy)
|
Content = BuildRecentExecutionEvidencePrompt(taskPolicy)
|
||||||
});
|
});
|
||||||
EmitEvent(AgentEventType.Thinking, "", "理쒓렐 ?섏젙 ?댄썑 ?ㅽ뻾 洹쇨굅媛 遺議깊빐 build/test ?ш?利앹쓣 ?섑뻾?⑸땲??..");
|
EmitEvent(AgentEventType.Thinking, "", "최근 수정 이후 실행 근거가 부족해 build/test 재검증을 수행합니다...");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ public partial class AgentLoopService
|
|||||||
Role = "user",
|
Role = "user",
|
||||||
Content = BuildExecutionSuccessGatePrompt(taskPolicy)
|
Content = BuildExecutionSuccessGatePrompt(taskPolicy)
|
||||||
});
|
});
|
||||||
EmitEvent(AgentEventType.Thinking, "", "?ㅽ뙣???ㅽ뻾 洹쇨굅留??덉뼱 build/test瑜??ㅼ떆 ?깃났?쒖폒 寃利앺빀?덈떎...");
|
EmitEvent(AgentEventType.Thinking, "", "실패한 실행 근거만 있어 build/test 성공 결과를 다시 검증합니다...");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ public partial class AgentLoopService
|
|||||||
EmitEvent(
|
EmitEvent(
|
||||||
AgentEventType.Thinking,
|
AgentEventType.Thinking,
|
||||||
"",
|
"",
|
||||||
$"醫낅즺 ???ㅽ뻾 利앷굅媛 遺議깊빐 蹂닿컯 ?④퀎瑜?吏꾪뻾?⑸땲??({runState.TerminalEvidenceGateRetry}/{retryMax})");
|
$"종료 전 실행 증거가 부족해 보강 단계를 진행합니다 ({runState.TerminalEvidenceGateRetry}/{retryMax})");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,27 +305,27 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var fileHint = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "寃곌낵 臾몄꽌 ?뚯씪" : $"'{lastArtifactFilePath}'";
|
var fileHint = string.IsNullOrWhiteSpace(lastArtifactFilePath) ? "결과 문서 파일" : $"'{lastArtifactFilePath}'";
|
||||||
return "[System:TerminalEvidenceGate] ?꾩옱 醫낅즺 ?묐떟?먮뒗 ?ㅽ뻾 寃곌낵 利앷굅媛 遺議깊빀?덈떎. " +
|
return "[System:TerminalEvidenceGate] 현재 종료 응답에는 실행 결과 증거가 부족합니다. " +
|
||||||
$"{fileHint}???ㅼ젣濡??앹꽦/媛깆떊?섍퀬 file_read ?먮뒗 document_read濡?寃利앺븳 洹쇨굅瑜??④릿 ??醫낅즺?섏꽭??";
|
$"{fileHint}을 실제로 생성 또는 갱신하고 file_read 또는 document_read로 검증한 근거를 남긴 뒤 종료하세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "[System:TerminalEvidenceGate] ?꾩옱 醫낅즺 ?묐떟?먮뒗 ?ㅽ뻾 寃곌낵 利앷굅媛 遺議깊빀?덈떎. " +
|
return "[System:TerminalEvidenceGate] 현재 종료 응답에는 실행 결과 증거가 부족합니다. " +
|
||||||
"理쒖냼 1媛??댁긽??吏꾪뻾 ?꾧뎄(?섏젙/?ㅽ뻾/?앹꽦)瑜??깃났?쒗궎怨? 洹?寃곌낵瑜?洹쇨굅濡?理쒖쥌 ?묐떟???ㅼ떆 ?묒꽦?섏꽭??";
|
"최소 1개 이상의 진행 도구(수정/실행/생성)를 성공시키고 그 결과를 근거로 최종 응답을 다시 작성하세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildRecentExecutionEvidencePrompt(TaskTypePolicy taskPolicy)
|
private static string BuildRecentExecutionEvidencePrompt(TaskTypePolicy taskPolicy)
|
||||||
{
|
{
|
||||||
var taskHint = taskPolicy.TaskType switch
|
var taskHint = taskPolicy.TaskType switch
|
||||||
{
|
{
|
||||||
"bugfix" => "?ы쁽 寃쎈줈 湲곗??쇰줈 ?섏젙 吏?먯씠 ?ㅼ젣濡??닿껐?먮뒗吏 寃利앺븯?몄슂.",
|
"bugfix" => "재현 경로 기준으로 수정 지점이 실제로 해결됐는지 검증하세요.",
|
||||||
"feature" => "?좉퇋 ?숈옉 寃쎈줈???뺤긽/?ㅻ쪟 耳?댁뒪瑜?理쒖냼 1媛??댁긽 ?뺤씤?섏꽭??",
|
"feature" => "추가 동작 경로와 정상/오류 케이스를 최소 1개 이상 확인하세요.",
|
||||||
"refactor" => "湲곗〈 ?숈옉 蹂댁〈 ?щ?瑜??뚭? 愿?먯쑝濡??뺤씤?섏꽭??",
|
"refactor" => "기존 동작 보존 여부를 핵심 관점으로 확인하세요.",
|
||||||
_ => "?섏젙 ?곹뼢 踰붿쐞瑜?湲곗??쇰줈 ?ㅽ뻾 寃利앹쓣 吏꾪뻾?섏꽭??"
|
_ => "수정 영향 범위를 기준으로 실행 검증을 진행하세요."
|
||||||
};
|
};
|
||||||
|
|
||||||
return "[System:RecentExecutionGate] 留덉?留?肄붾뱶 ?섏젙 ?댄썑 build/test ?ㅽ뻾 洹쇨굅媛 ?놁뒿?덈떎. " +
|
return "[System:RecentExecutionGate] 마지막 코드 수정 이후 build/test 실행 근거가 없습니다. " +
|
||||||
"吏湲?利됱떆 build_run ?먮뒗 test_loop瑜??몄텧??理쒖떊 ?곹깭瑜?寃利앺븯怨?寃곌낵瑜??④린?몄슂. " +
|
"지금 즉시 build_run 또는 test_loop를 호출해 최신 상태를 검증하고 결과를 남기세요. " +
|
||||||
taskHint;
|
taskHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,14 +333,14 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
var taskHint = taskPolicy.TaskType switch
|
var taskHint = taskPolicy.TaskType switch
|
||||||
{
|
{
|
||||||
"bugfix" => "?ㅽ뙣 ?먯씤??癒쇱? ?섏젙?????숈씪 ?ы쁽 寃쎈줈 湲곗??쇰줈 ?뚯뒪?몃? ?ㅼ떆 ?듦낵?쒗궎?몄슂.",
|
"bugfix" => "실패 원인을 먼저 수정하고 동일 재현 경로 기준으로 테스트를 다시 통과시키세요.",
|
||||||
"feature" => "?듭떖 ?좉퇋 ?숈옉 寃쎈줈瑜??ы븿??build/test瑜??듦낵?쒗궎?몄슂.",
|
"feature" => "추가된 신규 동작 경로를 포함해 build/test를 통과시키세요.",
|
||||||
"refactor" => "?뚭? ?щ?瑜??뺤씤?????덈뒗 ?뚯뒪?몃? ?듦낵?쒗궎?몄슂.",
|
"refactor" => "동작 보존 여부를 확인할 수 있는 테스트를 통과시키세요.",
|
||||||
_ => "?섏젙 ?곹뼢 踰붿쐞瑜??뺤씤?????덈뒗 ?ㅽ뻾 寃利앹쓣 ?듦낵?쒗궎?몄슂."
|
_ => "수정 영향 범위를 확인할 수 있는 실행 검증을 통과시키세요."
|
||||||
};
|
};
|
||||||
|
|
||||||
return "[System:ExecutionSuccessGate] ?꾩옱 ?ㅽ뻾 洹쇨굅媛 ?ㅽ뙣 寃곌낵肉먯엯?덈떎. " +
|
return "[System:ExecutionSuccessGate] 현재 실행 근거가 실패 결과뿐입니다. " +
|
||||||
"醫낅즺?섏? 留먭퀬 build_run ?먮뒗 test_loop瑜??몄텧???깃났 寃곌낵瑜??뺣낫?섏꽭?? " +
|
"종료하지 말고 build_run 또는 test_loop를 호출해 성공 결과를 확보하세요. " +
|
||||||
taskHint;
|
taskHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
internal static class AgentTranscriptDisplayCatalog
|
internal static class AgentTranscriptDisplayCatalog
|
||||||
@@ -5,30 +7,44 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
|
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rawName))
|
if (string.IsNullOrWhiteSpace(rawName))
|
||||||
return slashPrefix ? "/스킬" : "도구";
|
return slashPrefix ? "/skill" : "도구";
|
||||||
|
|
||||||
var normalized = rawName.Trim();
|
var normalized = rawName.Trim();
|
||||||
var mapped = normalized.ToLowerInvariant() switch
|
var lowered = normalized.ToLowerInvariant();
|
||||||
|
var mapped = lowered switch
|
||||||
{
|
{
|
||||||
"file_read" => "파일 읽기",
|
"file_read" => "파일 읽기",
|
||||||
"file_write" => "파일 쓰기",
|
"file_write" => "파일 쓰기",
|
||||||
"file_edit" => "파일 편집",
|
"file_edit" => "파일 편집",
|
||||||
|
"file_watch" => "파일 변경 감시",
|
||||||
|
"file_info" => "파일 정보",
|
||||||
|
"file_manage" => "파일 관리",
|
||||||
|
"glob" => "파일 찾기",
|
||||||
|
"grep" => "내용 검색",
|
||||||
|
"folder_map" => "폴더 구조",
|
||||||
|
|
||||||
"document_reader" => "문서 읽기",
|
"document_reader" => "문서 읽기",
|
||||||
"document_planner" => "문서 계획",
|
"document_planner" => "문서 계획",
|
||||||
"document_assembler" => "문서 조합",
|
"document_assembler" => "문서 조합",
|
||||||
"document_review" => "문서 검토",
|
"document_review" => "문서 검토",
|
||||||
"format_convert" => "형식 변환",
|
"format_convert" => "형식 변환",
|
||||||
"code_search" => "코드 검색",
|
"template_render" => "템플릿 렌더",
|
||||||
"code_review" => "코드 리뷰",
|
|
||||||
"build_run" => "빌드/실행",
|
"build_run" => "빌드/실행",
|
||||||
|
"test_loop" => "테스트 루프",
|
||||||
|
"dev_env_detect" => "개발 환경 감지",
|
||||||
"git_tool" => "Git",
|
"git_tool" => "Git",
|
||||||
"process" => "프로세스",
|
"diff_tool" => "Diff",
|
||||||
"glob" => "파일 찾기",
|
"diff_preview" => "Diff 미리보기",
|
||||||
"grep" => "내용 검색",
|
|
||||||
"folder_map" => "폴더 맵",
|
"process" => "명령 실행",
|
||||||
"memory" => "메모리",
|
"bash" => "Bash",
|
||||||
|
"powershell" => "PowerShell",
|
||||||
|
"web_fetch" => "웹 요청",
|
||||||
|
"http" => "HTTP 요청",
|
||||||
"user_ask" => "의견 요청",
|
"user_ask" => "의견 요청",
|
||||||
"suggest_actions" => "다음 작업 제안",
|
"suggest_actions" => "다음 작업 제안",
|
||||||
|
|
||||||
"task_create" => "작업 생성",
|
"task_create" => "작업 생성",
|
||||||
"task_update" => "작업 업데이트",
|
"task_update" => "작업 업데이트",
|
||||||
"task_list" => "작업 목록",
|
"task_list" => "작업 목록",
|
||||||
@@ -43,7 +59,10 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
if (!slashPrefix)
|
if (!slashPrefix)
|
||||||
return mapped;
|
return mapped;
|
||||||
|
|
||||||
return normalized.StartsWith('/') ? normalized : "/" + normalized.Replace(' ', '-');
|
if (normalized.StartsWith('/'))
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
return "/" + lowered.Replace('_', '-').Replace(' ', '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetEventBadgeLabel(AgentEvent evt)
|
public static string GetEventBadgeLabel(AgentEvent evt)
|
||||||
@@ -59,22 +78,23 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
|
|
||||||
public static string GetTaskCategoryLabel(string? kind, string? title)
|
public static string GetTaskCategoryLabel(string? kind, string? title)
|
||||||
{
|
{
|
||||||
if (string.Equals(kind, "permission", System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(kind, "permission", StringComparison.OrdinalIgnoreCase))
|
||||||
return "권한";
|
return "권한";
|
||||||
if (string.Equals(kind, "queue", System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(kind, "queue", StringComparison.OrdinalIgnoreCase))
|
||||||
return "큐";
|
return "대기열";
|
||||||
if (string.Equals(kind, "hook", System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(kind, "hook", StringComparison.OrdinalIgnoreCase))
|
||||||
return "훅";
|
return "훅";
|
||||||
if (string.Equals(kind, "subagent", System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(kind, "subagent", StringComparison.OrdinalIgnoreCase))
|
||||||
return "에이전트";
|
return "에이전트";
|
||||||
if (string.Equals(kind, "tool", System.StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase))
|
||||||
return GetToolCategoryLabel(title);
|
return GetToolCategoryLabel(title);
|
||||||
|
|
||||||
return "작업";
|
return "작업";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string BuildEventSummary(AgentEvent evt, string displayName)
|
public static string BuildEventSummary(AgentEvent evt, string displayName)
|
||||||
{
|
{
|
||||||
var summary = (evt.Summary ?? "").Trim();
|
var summary = (evt.Summary ?? string.Empty).Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(summary))
|
if (!string.IsNullOrWhiteSpace(summary))
|
||||||
return summary;
|
return summary;
|
||||||
|
|
||||||
@@ -83,9 +103,11 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
AgentEventType.ToolCall => $"{displayName} 실행 준비",
|
AgentEventType.ToolCall => $"{displayName} 실행 준비",
|
||||||
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
|
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
|
||||||
AgentEventType.SkillCall => $"{displayName} 실행",
|
AgentEventType.SkillCall => $"{displayName} 실행",
|
||||||
AgentEventType.PermissionRequest => $"{displayName} 실행 전 사용자 확인 필요",
|
AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.",
|
||||||
AgentEventType.PermissionGranted => $"{displayName} 실행이 허용됨",
|
AgentEventType.PermissionGranted => $"{displayName} 실행 권한이 승인되었습니다.",
|
||||||
AgentEventType.PermissionDenied => $"{displayName} 실행이 거부됨",
|
AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.",
|
||||||
|
AgentEventType.Complete => "에이전트 작업이 완료되었습니다.",
|
||||||
|
AgentEventType.Error => "에이전트 실행 중 오류가 발생했습니다.",
|
||||||
_ => summary,
|
_ => summary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,14 +119,24 @@ internal static class AgentTranscriptDisplayCatalog
|
|||||||
|
|
||||||
return rawName.Trim().ToLowerInvariant() switch
|
return rawName.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage" => "파일",
|
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage"
|
||||||
"build_run" or "test_loop" or "dev_env_detect" => "빌드",
|
=> "파일",
|
||||||
"git_tool" or "diff_tool" or "diff_preview" => "Git",
|
"build_run" or "test_loop" or "dev_env_detect"
|
||||||
"document_reader" or "document_planner" or "document_assembler" or "document_review" or "format_convert" or "template_render" => "문서",
|
=> "빌드",
|
||||||
"user_ask" => "질문",
|
"git_tool" or "diff_tool" or "diff_preview"
|
||||||
"suggest_actions" => "제안",
|
=> "Git",
|
||||||
"process" => "실행",
|
"document_reader" or "document_planner" or "document_assembler" or "document_review" or "format_convert" or "template_render"
|
||||||
"spawn_agent" or "wait_agents" => "에이전트",
|
=> "문서",
|
||||||
|
"user_ask"
|
||||||
|
=> "질문",
|
||||||
|
"suggest_actions"
|
||||||
|
=> "제안",
|
||||||
|
"process" or "bash" or "powershell"
|
||||||
|
=> "명령",
|
||||||
|
"spawn_agent" or "wait_agents"
|
||||||
|
=> "에이전트",
|
||||||
|
"web_fetch" or "http"
|
||||||
|
=> "웹",
|
||||||
_ => "도구",
|
_ => "도구",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public sealed class AxAgentExecutionEngine
|
|||||||
if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||||
return new ExecutionMode(true, false, codeSystemPrompt);
|
return new ExecutionMode(true, false, codeSystemPrompt);
|
||||||
|
|
||||||
return new ExecutionMode(false, false, null);
|
return new ExecutionMode(false, streamingEnabled, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PreparedExecution PrepareExecution(
|
public PreparedExecution PrepareExecution(
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ namespace AxCopilot.Services.Agent;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DocumentAssemblerTool : IAgentTool
|
public class DocumentAssemblerTool : IAgentTool
|
||||||
{
|
{
|
||||||
|
private static string GetDefaultOutputFormat()
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
return app?.SettingsService?.Settings.Llm.DefaultOutputFormat ?? "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultMood()
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
return app?.SettingsService?.Settings.Llm.DefaultMood ?? "modern";
|
||||||
|
}
|
||||||
|
|
||||||
public string Name => "document_assemble";
|
public string Name => "document_assemble";
|
||||||
public string Description =>
|
public string Description =>
|
||||||
@@ -35,13 +46,13 @@ public class DocumentAssemblerTool : IAgentTool
|
|||||||
["format"] = new()
|
["format"] = new()
|
||||||
{
|
{
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Output format: html, docx, markdown. Default: html",
|
Description = "Output format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)",
|
||||||
Enum = ["html", "docx", "markdown"]
|
Enum = ["auto", "html", "docx", "markdown"]
|
||||||
},
|
},
|
||||||
["mood"] = new()
|
["mood"] = new()
|
||||||
{
|
{
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional"
|
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: inferred from settings and document intent"
|
||||||
},
|
},
|
||||||
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" },
|
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" },
|
||||||
["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." },
|
["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." },
|
||||||
@@ -55,8 +66,8 @@ public class DocumentAssemblerTool : IAgentTool
|
|||||||
{
|
{
|
||||||
var path = args.GetProperty("path").GetString() ?? "";
|
var path = args.GetProperty("path").GetString() ?? "";
|
||||||
var title = args.GetProperty("title").GetString() ?? "Document";
|
var title = args.GetProperty("title").GetString() ?? "Document";
|
||||||
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
|
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
|
||||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "professional" : "professional";
|
var requestedMood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? GetDefaultMood() : GetDefaultMood();
|
||||||
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
||||||
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
|
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
|
||||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
||||||
@@ -78,6 +89,9 @@ public class DocumentAssemblerTool : IAgentTool
|
|||||||
if (sections.Count == 0)
|
if (sections.Count == 0)
|
||||||
return ToolResult.Fail("조립할 섹션이 없습니다.");
|
return ToolResult.Fail("조립할 섹션이 없습니다.");
|
||||||
|
|
||||||
|
var format = ResolveDocumentFormat(requestedFormat, title, sections);
|
||||||
|
var mood = ResolveDocumentMood(requestedMood, title, sections);
|
||||||
|
|
||||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||||
|
|
||||||
@@ -318,6 +332,57 @@ public class DocumentAssemblerTool : IAgentTool
|
|||||||
return " ✓ Markdown 조립 완료";
|
return " ✓ Markdown 조립 완료";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveDocumentFormat(string? preferred, string title, List<(string Heading, string Content, int Level)> sections)
|
||||||
|
{
|
||||||
|
var normalized = (preferred ?? "auto").Trim().ToLowerInvariant();
|
||||||
|
if (normalized is "html" or "docx" or "markdown")
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
var intent = BuildIntentText(title, sections);
|
||||||
|
if (ContainsAny(intent, "docx", "word", "워드"))
|
||||||
|
return "docx";
|
||||||
|
if (ContainsAny(intent, "markdown", ".md", "마크다운", "readme", "가이드", "매뉴얼", "회의록"))
|
||||||
|
return "markdown";
|
||||||
|
if (ContainsAny(intent, "html", "웹 문서", "웹페이지", "대시보드"))
|
||||||
|
return "html";
|
||||||
|
if (ContainsAny(intent, "proposal", "제안", "제안서", "보고"))
|
||||||
|
return "docx";
|
||||||
|
if (ContainsAny(intent, "analysis", "분석", "지표", "통계"))
|
||||||
|
return "html";
|
||||||
|
|
||||||
|
return "docx";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveDocumentMood(string? preferred, string title, List<(string Heading, string Content, int Level)> sections)
|
||||||
|
{
|
||||||
|
var normalized = (preferred ?? "modern").Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized) && normalized != "modern")
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
var intent = BuildIntentText(title, sections);
|
||||||
|
if (ContainsAny(intent, "dashboard", "분석", "지표", "통계", "kpi"))
|
||||||
|
return "dashboard";
|
||||||
|
if (ContainsAny(intent, "기획", "아이디어", "브레인스토밍", "creative"))
|
||||||
|
return "creative";
|
||||||
|
if (ContainsAny(intent, "기업", "공식", "proposal", "제안서", "사내"))
|
||||||
|
return "corporate";
|
||||||
|
if (ContainsAny(intent, "가이드", "매뉴얼", "manual", "guide"))
|
||||||
|
return "minimal";
|
||||||
|
if (ContainsAny(intent, "회의록", "minutes"))
|
||||||
|
return "professional";
|
||||||
|
|
||||||
|
return "professional";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildIntentText(string title, List<(string Heading, string Content, int Level)> sections)
|
||||||
|
{
|
||||||
|
var headingText = string.Join(" ", sections.Select(s => s.Heading));
|
||||||
|
return $"{title} {headingText}".ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string text, params string[] keywords)
|
||||||
|
=> keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
private static int EstimateWordCount(string text)
|
private static int EstimateWordCount(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
return app?.SettingsService?.Settings.Llm.FolderDataUsage ?? "none";
|
return app?.SettingsService?.Settings.Llm.FolderDataUsage ?? "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultOutputFormat()
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
return app?.SettingsService?.Settings.Llm.DefaultOutputFormat ?? "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultMood()
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
return app?.SettingsService?.Settings.Llm.DefaultMood ?? "modern";
|
||||||
|
}
|
||||||
|
|
||||||
public string Name => "document_plan";
|
public string Name => "document_plan";
|
||||||
public string Description =>
|
public string Description =>
|
||||||
"Create a structured document outline/plan and optionally generate the document file immediately. " +
|
"Create a structured document outline/plan and optionally generate the document file immediately. " +
|
||||||
@@ -53,8 +65,8 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
["format"] = new()
|
["format"] = new()
|
||||||
{
|
{
|
||||||
Type = "string",
|
Type = "string",
|
||||||
Description = "Output document format: html, docx, markdown. Default: html",
|
Description = "Output document format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)",
|
||||||
Enum = ["html", "docx", "markdown"]
|
Enum = ["auto", "html", "docx", "markdown"]
|
||||||
},
|
},
|
||||||
["sections_hint"] = new()
|
["sections_hint"] = new()
|
||||||
{
|
{
|
||||||
@@ -75,7 +87,7 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
var topic = args.GetProperty("topic").GetString() ?? "";
|
var topic = args.GetProperty("topic").GetString() ?? "";
|
||||||
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
|
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
|
||||||
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
||||||
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
|
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
|
||||||
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
|
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
|
||||||
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
|
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
|
||||||
|
|
||||||
@@ -87,6 +99,8 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
|
|
||||||
var highQuality = IsMultiPassEnabled();
|
var highQuality = IsMultiPassEnabled();
|
||||||
var folderDataUsage = GetFolderDataUsage();
|
var folderDataUsage = GetFolderDataUsage();
|
||||||
|
var format = ResolveDocumentFormat(requestedFormat, docType, topic);
|
||||||
|
var mood = ResolveDocumentMood(GetDefaultMood(), docType, topic);
|
||||||
|
|
||||||
// 고품질 모드: 목표 페이지와 단어 수를 1.5배 확장
|
// 고품질 모드: 목표 페이지와 단어 수를 1.5배 확장
|
||||||
var effectivePages = highQuality ? (int)Math.Ceiling(targetPages * 1.5) : targetPages;
|
var effectivePages = highQuality ? (int)Math.Ceiling(targetPages * 1.5) : targetPages;
|
||||||
@@ -97,16 +111,16 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
|
|
||||||
// 폴더 데이터 활용 모드: 먼저 파일을 읽어야 하므로 별도 처리
|
// 폴더 데이터 활용 모드: 먼저 파일을 읽어야 하므로 별도 처리
|
||||||
if (folderDataUsage is "active" or "passive")
|
if (folderDataUsage is "active" or "passive")
|
||||||
return ExecuteSinglePassWithData(topic, docType, format, effectivePages, totalWords, sections, folderDataUsage, refSummary);
|
return ExecuteSinglePassWithData(topic, docType, format, mood, effectivePages, totalWords, sections, folderDataUsage, refSummary);
|
||||||
|
|
||||||
// 일반/고품질 모드 모두 동일 구조:
|
// 일반/고품질 모드 모두 동일 구조:
|
||||||
// 개요 반환 + document_assemble 즉시 호출 지시
|
// 개요 반환 + document_assemble 즉시 호출 지시
|
||||||
return ExecuteWithAssembleInstructions(topic, docType, format, effectivePages, totalWords, sections, highQuality);
|
return ExecuteWithAssembleInstructions(topic, docType, format, mood, effectivePages, totalWords, sections, highQuality);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 통합: 포맷별 body 골격 생성 + 즉시 호출 가능한 도구 파라미터 제시 ──────
|
// ─── 통합: 포맷별 body 골격 생성 + 즉시 호출 가능한 도구 파라미터 제시 ──────
|
||||||
|
|
||||||
private Task<ToolResult> ExecuteWithAssembleInstructions(string topic, string docType, string format,
|
private Task<ToolResult> ExecuteWithAssembleInstructions(string topic, string docType, string format, string mood,
|
||||||
int targetPages, int totalWords, List<SectionPlan> sections, bool highQuality)
|
int targetPages, int totalWords, List<SectionPlan> sections, bool highQuality)
|
||||||
{
|
{
|
||||||
var safeTitle = SanitizeFileName(topic);
|
var safeTitle = SanitizeFileName(topic);
|
||||||
@@ -121,13 +135,13 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
case "docx":
|
case "docx":
|
||||||
return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
|
return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
|
||||||
default: // html
|
default: // html
|
||||||
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
|
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label, mood);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>html_create 즉시 호출 가능한 body 골격 반환. 섹션 구조만 고정하고 내부 시각화는 LLM이 자유롭게 선택.</summary>
|
/// <summary>html_create 즉시 호출 가능한 body 골격 반환. 섹션 구조만 고정하고 내부 시각화는 LLM이 자유롭게 선택.</summary>
|
||||||
private Task<ToolResult> ExecuteWithHtmlScaffold(string topic, string fileName,
|
private Task<ToolResult> ExecuteWithHtmlScaffold(string topic, string fileName,
|
||||||
List<SectionPlan> sections, int targetPages, int totalWords, string label)
|
List<SectionPlan> sections, int targetPages, int totalWords, string label, string mood)
|
||||||
{
|
{
|
||||||
var bodySb = new StringBuilder();
|
var bodySb = new StringBuilder();
|
||||||
foreach (var s in sections)
|
foreach (var s in sections)
|
||||||
@@ -148,10 +162,10 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
output.AppendLine("## 즉시 실행: html_create 호출");
|
output.AppendLine("## 즉시 실행: html_create 호출");
|
||||||
output.AppendLine($"path: \"{fileName}\"");
|
output.AppendLine($"path: \"{fileName}\"");
|
||||||
output.AppendLine($"title: \"{topic}\"");
|
output.AppendLine($"title: \"{topic}\"");
|
||||||
output.AppendLine("toc: true, numbered: true, mood: \"professional\"");
|
output.AppendLine($"toc: true, numbered: true, mood: \"{mood}\"");
|
||||||
output.AppendLine($"cover: {{\"title\": \"{topic}\", \"author\": \"AX Copilot Agent\"}}");
|
output.AppendLine($"cover: {{\"title\": \"{topic}\", \"author\": \"AX Copilot Agent\"}}");
|
||||||
output.AppendLine();
|
output.AppendLine();
|
||||||
output.AppendLine("body에 아래 섹션 구조를 기반으로 각 섹션의 내용과 시각화를 자유롭게 작성하세요:");
|
output.AppendLine($"body에 아래 섹션 구조를 기반으로 각 섹션의 내용과 시각화를 자유롭게 작성하세요. (추천 디자인: {mood})");
|
||||||
output.AppendLine("(주석의 '활용 가능 요소'는 참고용이며, 내용에 맞게 다른 요소를 써도 됩니다)");
|
output.AppendLine("(주석의 '활용 가능 요소'는 참고용이며, 내용에 맞게 다른 요소를 써도 됩니다)");
|
||||||
output.AppendLine();
|
output.AppendLine();
|
||||||
output.AppendLine("--- body 시작 ---");
|
output.AppendLine("--- body 시작 ---");
|
||||||
@@ -275,7 +289,7 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
|
|
||||||
// ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ──
|
// ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ──
|
||||||
|
|
||||||
private Task<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format,
|
private Task<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format, string mood,
|
||||||
int targetPages, int totalWords, List<SectionPlan> sections, string folderDataUsage, string refSummary)
|
int targetPages, int totalWords, List<SectionPlan> sections, string folderDataUsage, string refSummary)
|
||||||
{
|
{
|
||||||
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
|
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
|
||||||
@@ -309,6 +323,7 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.",
|
step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.",
|
||||||
step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
|
step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
|
||||||
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
|
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
|
||||||
|
preferred_mood = format == "html" ? mood : null as string,
|
||||||
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
|
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -322,6 +337,7 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
return Task.FromResult(ToolResult.Ok(
|
return Task.FromResult(ToolResult.Ok(
|
||||||
$"📋 문서 개요가 생성되었습니다 [싱글패스+데이터활용] ({sections.Count}개 섹션, 목표 {targetPages}페이지/{totalWords}단어).\n" +
|
$"📋 문서 개요가 생성되었습니다 [싱글패스+데이터활용] ({sections.Count}개 섹션, 목표 {targetPages}페이지/{totalWords}단어).\n" +
|
||||||
$"{dataNote}\n" +
|
$"{dataNote}\n" +
|
||||||
|
$"{(format == "html" ? $"권장 디자인 무드: {mood}\n" : string.Empty)}" +
|
||||||
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
|
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +600,57 @@ public class DocumentPlannerTool : IAgentTool
|
|||||||
private static string Escape(string text)
|
private static string Escape(string text)
|
||||||
=> text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
=> text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
|
|
||||||
|
private static string ResolveDocumentFormat(string? preferred, string docType, string topic)
|
||||||
|
{
|
||||||
|
var normalized = (preferred ?? "auto").Trim().ToLowerInvariant();
|
||||||
|
if (normalized is "html" or "docx" or "markdown")
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
var intent = $"{docType} {topic}".ToLowerInvariant();
|
||||||
|
|
||||||
|
if (ContainsAny(intent, "docx", "word", "워드"))
|
||||||
|
return "docx";
|
||||||
|
if (ContainsAny(intent, "markdown", ".md", "md ", "마크다운", "readme", "가이드", "manual", "guide", "회의록", "minutes"))
|
||||||
|
return "markdown";
|
||||||
|
if (ContainsAny(intent, "html", "웹 문서", "웹페이지"))
|
||||||
|
return "html";
|
||||||
|
|
||||||
|
return docType switch
|
||||||
|
{
|
||||||
|
"manual" or "guide" or "minutes" => "markdown",
|
||||||
|
"proposal" or "presentation" => "docx",
|
||||||
|
"analysis" => "html",
|
||||||
|
_ => "docx",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveDocumentMood(string? preferred, string docType, string topic)
|
||||||
|
{
|
||||||
|
var normalized = (preferred ?? "modern").Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized) && normalized != "modern")
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
var intent = $"{docType} {topic}".ToLowerInvariant();
|
||||||
|
if (ContainsAny(intent, "대시보드", "dashboard", "분석", "analysis", "지표", "통계"))
|
||||||
|
return "dashboard";
|
||||||
|
if (ContainsAny(intent, "기획", "아이디어", "creative", "브레인스토밍"))
|
||||||
|
return "creative";
|
||||||
|
if (ContainsAny(intent, "기업", "공식", "proposal", "제안서", "사내 보고"))
|
||||||
|
return "corporate";
|
||||||
|
if (ContainsAny(intent, "가이드", "manual", "guide", "매뉴얼"))
|
||||||
|
return "minimal";
|
||||||
|
|
||||||
|
return docType switch
|
||||||
|
{
|
||||||
|
"proposal" => "corporate",
|
||||||
|
"analysis" => "dashboard",
|
||||||
|
"manual" or "guide" => "minimal",
|
||||||
|
"presentation" => "creative",
|
||||||
|
"minutes" => "professional",
|
||||||
|
_ => "professional",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
|
|||||||
@@ -14,18 +14,23 @@ public class MemoryTool : IAgentTool
|
|||||||
public string Description =>
|
public string Description =>
|
||||||
"프로젝트 규칙, 사용자 선호도, 학습 내용을 저장하고 검색합니다.\n" +
|
"프로젝트 규칙, 사용자 선호도, 학습 내용을 저장하고 검색합니다.\n" +
|
||||||
"대화 간 지속되는 메모리로, 새 대화에서도 이전에 학습한 내용을 활용할 수 있습니다.\n" +
|
"대화 간 지속되는 메모리로, 새 대화에서도 이전에 학습한 내용을 활용할 수 있습니다.\n" +
|
||||||
"- action=\"save\": 새 메모리 저장 (type, content 필수)\n" +
|
"- action=\"save\": 학습 메모리 저장 (type, content 필수)\n" +
|
||||||
|
"- action=\"save_scope\": 계층형 메모리 파일에 저장 (scope, content 필수)\n" +
|
||||||
|
"- action=\"show_scope\": 계층형 메모리 파일 본문 조회 (scope 필수)\n" +
|
||||||
"- action=\"search\": 관련 메모리 검색 (query 필수)\n" +
|
"- action=\"search\": 관련 메모리 검색 (query 필수)\n" +
|
||||||
"- action=\"list\": 현재 메모리 전체 목록\n" +
|
"- action=\"list\": 현재 메모리 전체 목록 + 계층형 메모리 파일 목록\n" +
|
||||||
"- action=\"delete\": 메모리 삭제 (id 필수)\n" +
|
"- action=\"delete\": 학습 메모리 삭제 (id 필수)\n" +
|
||||||
|
"- action=\"delete_scope\": 계층형 메모리 파일에서 삭제 (scope, query 필수)\n" +
|
||||||
|
"scope 종류: managed | user | project | local\n" +
|
||||||
"type 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)";
|
"type 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)";
|
||||||
|
|
||||||
public ToolParameterSchema Parameters => new()
|
public ToolParameterSchema Parameters => new()
|
||||||
{
|
{
|
||||||
Properties = new()
|
Properties = new()
|
||||||
{
|
{
|
||||||
["action"] = new() { Type = "string", Description = "save | search | list | delete" },
|
["action"] = new() { Type = "string", Description = "save | save_scope | show_scope | search | list | delete | delete_scope" },
|
||||||
["type"] = new() { Type = "string", Description = "메모리 유형: rule | preference | fact | correction. save 시 필수." },
|
["type"] = new() { Type = "string", Description = "메모리 유형: rule | preference | fact | correction. save 시 필수." },
|
||||||
|
["scope"] = new() { Type = "string", Description = "계층형 메모리 대상: managed | user | project | local. save_scope/delete_scope 시 필수." },
|
||||||
["content"] = new() { Type = "string", Description = "저장할 내용. save 시 필수." },
|
["content"] = new() { Type = "string", Description = "저장할 내용. save 시 필수." },
|
||||||
["query"] = new() { Type = "string", Description = "검색 쿼리. search 시 필수." },
|
["query"] = new() { Type = "string", Description = "검색 쿼리. search 시 필수." },
|
||||||
["id"] = new() { Type = "string", Description = "메모리 ID. delete 시 필수." },
|
["id"] = new() { Type = "string", Description = "메모리 ID. delete 시 필수." },
|
||||||
@@ -44,6 +49,8 @@ public class MemoryTool : IAgentTool
|
|||||||
if (memoryService == null)
|
if (memoryService == null)
|
||||||
return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다."));
|
return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다."));
|
||||||
|
|
||||||
|
memoryService.Load(context.WorkFolder);
|
||||||
|
|
||||||
if (!args.TryGetProperty("action", out var actionEl))
|
if (!args.TryGetProperty("action", out var actionEl))
|
||||||
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
|
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
|
||||||
var action = actionEl.GetString() ?? "";
|
var action = actionEl.GetString() ?? "";
|
||||||
@@ -51,10 +58,13 @@ public class MemoryTool : IAgentTool
|
|||||||
return Task.FromResult(action switch
|
return Task.FromResult(action switch
|
||||||
{
|
{
|
||||||
"save" => ExecuteSave(args, memoryService, context),
|
"save" => ExecuteSave(args, memoryService, context),
|
||||||
|
"save_scope" => ExecuteSaveScope(args, memoryService, context),
|
||||||
|
"show_scope" => ExecuteShowScope(args, memoryService, context),
|
||||||
"search" => ExecuteSearch(args, memoryService),
|
"search" => ExecuteSearch(args, memoryService),
|
||||||
"list" => ExecuteList(memoryService),
|
"list" => ExecuteList(memoryService),
|
||||||
"delete" => ExecuteDelete(args, memoryService),
|
"delete" => ExecuteDelete(args, memoryService),
|
||||||
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | search | list | delete 중 선택하세요."),
|
"delete_scope" => ExecuteDeleteScope(args, memoryService, context),
|
||||||
|
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | save_scope | show_scope | search | list | delete | delete_scope 중 선택하세요."),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,30 +92,59 @@ public class MemoryTool : IAgentTool
|
|||||||
return ToolResult.Fail("query가 필요합니다.");
|
return ToolResult.Fail("query가 필요합니다.");
|
||||||
|
|
||||||
var results = svc.GetRelevant(query, 10);
|
var results = svc.GetRelevant(query, 10);
|
||||||
if (results.Count == 0)
|
var docs = svc.InstructionDocuments
|
||||||
|
.Where(d => d.Content.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| d.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(8)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (results.Count == 0 && docs.Count == 0)
|
||||||
return ToolResult.Ok("관련 메모리가 없습니다.");
|
return ToolResult.Ok("관련 메모리가 없습니다.");
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine($"관련 메모리 {results.Count}개:");
|
if (docs.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"계층형 메모리 {docs.Count}개:");
|
||||||
|
foreach (var doc in docs)
|
||||||
|
sb.AppendLine(FormatInstructionDocument(doc));
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"학습 메모리 {results.Count}개:");
|
||||||
foreach (var e in results)
|
foreach (var e in results)
|
||||||
sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
|
sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
|
||||||
|
}
|
||||||
return ToolResult.Ok(sb.ToString());
|
return ToolResult.Ok(sb.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ToolResult ExecuteList(AgentMemoryService svc)
|
private static ToolResult ExecuteList(AgentMemoryService svc)
|
||||||
{
|
{
|
||||||
var all = svc.All;
|
var all = svc.All;
|
||||||
if (all.Count == 0)
|
var docs = svc.InstructionDocuments;
|
||||||
|
if (all.Count == 0 && docs.Count == 0)
|
||||||
return ToolResult.Ok("저장된 메모리가 없습니다.");
|
return ToolResult.Ok("저장된 메모리가 없습니다.");
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine($"전체 메모리 {all.Count}개:");
|
if (docs.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"계층형 메모리 파일 {docs.Count}개:");
|
||||||
|
foreach (var doc in docs)
|
||||||
|
sb.AppendLine(FormatInstructionDocument(doc));
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (all.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"학습 메모리 {all.Count}개:");
|
||||||
foreach (var group in all.GroupBy(e => e.Type))
|
foreach (var group in all.GroupBy(e => e.Type))
|
||||||
{
|
{
|
||||||
sb.AppendLine($"\n[{group.Key}]");
|
sb.AppendLine($"\n[{group.Key}]");
|
||||||
foreach (var e in group.OrderByDescending(e => e.UseCount))
|
foreach (var e in group.OrderByDescending(e => e.UseCount))
|
||||||
sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
|
sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return ToolResult.Ok(sb.ToString());
|
return ToolResult.Ok(sb.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,4 +158,68 @@ public class MemoryTool : IAgentTool
|
|||||||
? ToolResult.Ok($"메모리 삭제됨 (ID: {id})")
|
? ToolResult.Ok($"메모리 삭제됨 (ID: {id})")
|
||||||
: ToolResult.Fail($"해당 ID의 메모리를 찾을 수 없습니다: {id}");
|
: ToolResult.Fail($"해당 ID의 메모리를 찾을 수 없습니다: {id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||||
|
{
|
||||||
|
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
|
||||||
|
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(scope))
|
||||||
|
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return ToolResult.Fail("content가 필요합니다.");
|
||||||
|
|
||||||
|
var result = svc.SaveInstruction(scope, content, context.WorkFolder);
|
||||||
|
return result.Path.Length == 0
|
||||||
|
? ToolResult.Fail(result.Message)
|
||||||
|
: ToolResult.Ok($"{result.Message}\n경로: {result.Path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||||
|
{
|
||||||
|
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
|
||||||
|
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(scope))
|
||||||
|
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return ToolResult.Fail("query가 필요합니다.");
|
||||||
|
|
||||||
|
var result = svc.DeleteInstruction(scope, query, context.WorkFolder);
|
||||||
|
return result.Changed
|
||||||
|
? ToolResult.Ok($"{result.Message}\n경로: {result.Path}")
|
||||||
|
: ToolResult.Fail(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToolResult ExecuteShowScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||||
|
{
|
||||||
|
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(scope))
|
||||||
|
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||||
|
|
||||||
|
var path = svc.GetWritableInstructionPath(scope, context.WorkFolder);
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return ToolResult.Fail("해당 scope의 메모리 파일 경로를 결정할 수 없습니다.");
|
||||||
|
|
||||||
|
var content = svc.ReadInstructionFile(scope, context.WorkFolder);
|
||||||
|
if (content == null)
|
||||||
|
return ToolResult.Ok($"메모리 파일이 아직 없습니다.\n경로: {path}");
|
||||||
|
|
||||||
|
return ToolResult.Ok($"메모리 파일 경로: {path}\n\n{content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatInstructionDocument(MemoryInstructionDocument doc)
|
||||||
|
{
|
||||||
|
var meta = new List<string>
|
||||||
|
{
|
||||||
|
doc.Priority > 0 ? $"우선순위 {doc.Priority}" : "우선순위 미정",
|
||||||
|
$"layer: {doc.Layer}"
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(doc.Description))
|
||||||
|
meta.Add(doc.Description);
|
||||||
|
if (doc.Paths.Count > 0)
|
||||||
|
meta.Add($"paths: {string.Join(", ", doc.Paths)}");
|
||||||
|
if (doc.Tags.Count > 0)
|
||||||
|
meta.Add($"tags: {string.Join(", ", doc.Tags)}");
|
||||||
|
|
||||||
|
return $" • [{doc.Label}] {doc.Path}\n {string.Join(" · ", meta)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
internal static class OperationalStatusPresentationCatalog
|
||||||
|
{
|
||||||
|
public static AppStateService.OperationalStatusPresentationState Resolve(
|
||||||
|
AppStateService.OperationalStatusState status,
|
||||||
|
string tab,
|
||||||
|
bool hasLiveRuntimeActivity,
|
||||||
|
int runningConversationCount,
|
||||||
|
int spotlightConversationCount,
|
||||||
|
bool runningOnlyFilter,
|
||||||
|
bool sortConversationsByRecent)
|
||||||
|
{
|
||||||
|
var showCompactStrip = !string.Equals(tab, "Chat", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var (stripBackgroundHex, stripBorderHex, stripForegroundHex) = ResolveStripColors(status.StripKind, showCompactStrip);
|
||||||
|
|
||||||
|
var allowQuickStrip = !string.Equals(tab, "Chat", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var quickRunningActive = runningOnlyFilter && runningConversationCount > 0;
|
||||||
|
var quickHotActive = !sortConversationsByRecent && spotlightConversationCount > 0;
|
||||||
|
var showQuickStrip = allowQuickStrip && (quickRunningActive || quickHotActive);
|
||||||
|
|
||||||
|
return new AppStateService.OperationalStatusPresentationState
|
||||||
|
{
|
||||||
|
ShowRuntimeBadge = status.ShowRuntimeBadge && hasLiveRuntimeActivity,
|
||||||
|
RuntimeLabel = status.RuntimeLabel,
|
||||||
|
ShowLastCompleted = status.ShowLastCompleted,
|
||||||
|
LastCompletedText = status.LastCompletedText,
|
||||||
|
ShowCompactStrip = showCompactStrip,
|
||||||
|
StripKind = showCompactStrip ? status.StripKind : "none",
|
||||||
|
StripText = showCompactStrip ? status.StripText : "",
|
||||||
|
StripBackgroundHex = stripBackgroundHex,
|
||||||
|
StripBorderHex = stripBorderHex,
|
||||||
|
StripForegroundHex = stripForegroundHex,
|
||||||
|
ShowQuickStrip = showQuickStrip,
|
||||||
|
QuickRunningText = runningConversationCount > 0 ? $"진행 {runningConversationCount}" : "진행",
|
||||||
|
QuickHotText = spotlightConversationCount > 0 ? $"활동 {spotlightConversationCount}" : "활동",
|
||||||
|
QuickRunningActive = quickRunningActive,
|
||||||
|
QuickHotActive = quickHotActive,
|
||||||
|
QuickRunningBackgroundHex = quickRunningActive ? "#DBEAFE" : "#F8FAFC",
|
||||||
|
QuickRunningBorderHex = quickRunningActive ? "#93C5FD" : "#E5E7EB",
|
||||||
|
QuickRunningForegroundHex = quickRunningActive ? "#1D4ED8" : "#6B7280",
|
||||||
|
QuickHotBackgroundHex = quickHotActive ? "#F5F3FF" : "#F8FAFC",
|
||||||
|
QuickHotBorderHex = quickHotActive ? "#C4B5FD" : "#E5E7EB",
|
||||||
|
QuickHotForegroundHex = quickHotActive ? "#6D28D9" : "#6B7280",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string backgroundHex, string borderHex, string foregroundHex) ResolveStripColors(string stripKind, bool visible)
|
||||||
|
{
|
||||||
|
if (!visible)
|
||||||
|
return ("", "", "");
|
||||||
|
|
||||||
|
if (string.Equals(stripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ("#FFF7ED", "#FDBA74", "#C2410C");
|
||||||
|
|
||||||
|
if (string.Equals(stripKind, "failed_run", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(stripKind, "permission_denied", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ("#FEF2F2", "#FECACA", "#991B1B");
|
||||||
|
|
||||||
|
return ("", "", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
internal sealed record PermissionModePresentation(
|
internal sealed record PermissionModePresentation(
|
||||||
string Mode,
|
string Mode,
|
||||||
@@ -23,12 +23,6 @@ internal static class PermissionModePresentationCatalog
|
|||||||
"편집 자동 승인",
|
"편집 자동 승인",
|
||||||
"모든 파일 편집을 자동 승인합니다.",
|
"모든 파일 편집을 자동 승인합니다.",
|
||||||
"#107C10"),
|
"#107C10"),
|
||||||
new PermissionModePresentation(
|
|
||||||
PermissionModeCatalog.Plan,
|
|
||||||
"\uE7C3",
|
|
||||||
"계획 모드",
|
|
||||||
"변경하기 전에 계획을 먼저 만듭니다.",
|
|
||||||
"#4338CA"),
|
|
||||||
new PermissionModePresentation(
|
new PermissionModePresentation(
|
||||||
PermissionModeCatalog.BypassPermissions,
|
PermissionModeCatalog.BypassPermissions,
|
||||||
"\uE814",
|
"\uE814",
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
internal sealed record PermissionRequestPresentation(
|
||||||
|
string Kind,
|
||||||
|
string Icon,
|
||||||
|
string Label,
|
||||||
|
string Description,
|
||||||
|
string ActionHint,
|
||||||
|
string BackgroundHex,
|
||||||
|
string ForegroundHex,
|
||||||
|
string Severity,
|
||||||
|
bool RequiresPreview);
|
||||||
|
|
||||||
|
internal static class PermissionRequestPresentationCatalog
|
||||||
|
{
|
||||||
|
public static PermissionRequestPresentation Resolve(string? toolName, bool pending)
|
||||||
|
{
|
||||||
|
var tool = (toolName ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (tool.Contains("bash"))
|
||||||
|
return Build("bash", pending, "\uE756",
|
||||||
|
"Bash 실행 권한 요청", "Bash 실행 확인",
|
||||||
|
"셸 명령을 실행하기 전에 확인이 필요합니다.",
|
||||||
|
"Bash 실행이 승인되어 계속 진행합니다.",
|
||||||
|
"명령과 작업 위치를 확인하세요.",
|
||||||
|
"#FEF2F2", "#DC2626", "high", false);
|
||||||
|
|
||||||
|
if (tool.Contains("powershell"))
|
||||||
|
return Build("powershell", pending, "\uE756",
|
||||||
|
"PowerShell 권한 요청", "PowerShell 실행 확인",
|
||||||
|
"PowerShell 명령을 실행하기 전에 확인이 필요합니다.",
|
||||||
|
"PowerShell 실행이 승인되어 계속 진행합니다.",
|
||||||
|
"스크립트와 실행 범위를 확인하세요.",
|
||||||
|
"#FEF2F2", "#DC2626", "high", false);
|
||||||
|
|
||||||
|
if (tool.Contains("process") || tool.Contains("build") || tool.Contains("test"))
|
||||||
|
return Build("command", pending, "\uE756",
|
||||||
|
"명령 실행 권한 요청", "명령 실행 확인",
|
||||||
|
"명령 또는 빌드 작업 실행 전에 확인이 필요합니다.",
|
||||||
|
"명령 실행이 승인되어 계속 진행합니다.",
|
||||||
|
"실행 명령과 영향 범위를 확인하세요.",
|
||||||
|
"#FEF2F2", "#DC2626", "high", false);
|
||||||
|
|
||||||
|
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
||||||
|
return Build("web_fetch", pending, "\uE774",
|
||||||
|
"웹 요청 권한 요청", "웹 요청 확인",
|
||||||
|
"외부 요청을 보내기 전에 확인이 필요합니다.",
|
||||||
|
"웹 요청이 승인되어 계속 진행합니다.",
|
||||||
|
"조회 URL과 전송 범위를 확인하세요.",
|
||||||
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
|
||||||
|
if (tool.Contains("mcp"))
|
||||||
|
return Build("mcp", pending, "\uE943",
|
||||||
|
"MCP 도구 권한 요청", "MCP 도구 확인",
|
||||||
|
"연결된 MCP 도구 사용 전에 확인이 필요합니다.",
|
||||||
|
"MCP 도구 사용이 승인되어 계속 진행합니다.",
|
||||||
|
"서버와 호출 의도를 확인하세요.",
|
||||||
|
"#F5F3FF", "#7C3AED", "medium", false);
|
||||||
|
|
||||||
|
if (tool.Contains("skill"))
|
||||||
|
return Build("skill", pending, "\uE8A5",
|
||||||
|
"스킬 실행 권한 요청", "스킬 실행 확인",
|
||||||
|
"연결된 스킬을 실행하기 전에 확인이 필요합니다.",
|
||||||
|
"스킬 실행이 승인되어 계속 진행합니다.",
|
||||||
|
"허용 도구와 실행 컨텍스트를 확인하세요.",
|
||||||
|
"#F5F3FF", "#7C3AED", "medium", false);
|
||||||
|
|
||||||
|
if (tool.Contains("ask"))
|
||||||
|
return Build("question", pending, "\uE897",
|
||||||
|
"의견 요청 확인", "의견 요청 완료",
|
||||||
|
"사용자에게 선택이나 답변을 요청합니다.",
|
||||||
|
"사용자 답변을 받아 다음 단계로 진행합니다.",
|
||||||
|
"질문 의도와 선택지를 확인하세요.",
|
||||||
|
"#EFF6FF", "#2563EB", "low", false);
|
||||||
|
|
||||||
|
if (tool.Contains("file_edit") || tool.Contains("edit"))
|
||||||
|
return Build("file_edit", pending, "\uE70F",
|
||||||
|
"파일 수정 권한 요청", "파일 수정 확인",
|
||||||
|
"파일을 변경하기 전에 확인이 필요합니다.",
|
||||||
|
"파일 수정이 승인되어 계속 진행합니다.",
|
||||||
|
"변경 diff와 대상 파일을 확인하세요.",
|
||||||
|
"#FFF7ED", "#C2410C", "high", true);
|
||||||
|
|
||||||
|
if (tool.Contains("file_write") || tool.Contains("write"))
|
||||||
|
return Build("file_write", pending, "\uE70F",
|
||||||
|
"파일 쓰기 권한 요청", "파일 쓰기 확인",
|
||||||
|
"새 파일 작성 또는 덮어쓰기 전에 확인이 필요합니다.",
|
||||||
|
"파일 쓰기가 승인되어 계속 진행합니다.",
|
||||||
|
"작성 위치와 새 내용 미리보기를 확인하세요.",
|
||||||
|
"#FFF7ED", "#C2410C", "high", true);
|
||||||
|
|
||||||
|
if (tool.Contains("git"))
|
||||||
|
return Build("git", pending, "\uE8A7",
|
||||||
|
"Git 작업 권한 요청", "Git 작업 확인",
|
||||||
|
"브랜치나 커밋 상태를 바꾸기 전에 확인이 필요합니다.",
|
||||||
|
"Git 작업이 승인되어 계속 진행합니다.",
|
||||||
|
"브랜치와 변경 범위를 확인하세요.",
|
||||||
|
"#EFF6FF", "#2563EB", "medium", false);
|
||||||
|
|
||||||
|
if (tool.Contains("document") || tool.Contains("template") || tool.Contains("format"))
|
||||||
|
return Build("document", pending, "\uE8A5",
|
||||||
|
"문서 작업 권한 요청", "문서 작업 확인",
|
||||||
|
"문서 생성 또는 변환 작업 전에 확인이 필요합니다.",
|
||||||
|
"문서 작업이 승인되어 계속 진행합니다.",
|
||||||
|
"출력 형식과 저장 위치를 확인하세요.",
|
||||||
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
|
||||||
|
if (tool.Contains("file") || tool.Contains("glob") || tool.Contains("grep") || tool.Contains("folder"))
|
||||||
|
return Build("filesystem", pending, "\uE8A5",
|
||||||
|
"파일 접근 권한 요청", "파일 접근 확인",
|
||||||
|
"폴더나 파일 내용을 읽기 전에 확인이 필요합니다.",
|
||||||
|
"파일 접근이 승인되어 계속 진행합니다.",
|
||||||
|
"읽기 범위와 접근 경로를 확인하세요.",
|
||||||
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
|
||||||
|
return Build("generic", pending, "\uE897",
|
||||||
|
"권한 요청", "권한 확인",
|
||||||
|
"계속 진행하기 전에 사용자의 확인이 필요합니다.",
|
||||||
|
"요청이 승인되어 계속 진행합니다.",
|
||||||
|
"실행 의도와 대상 범위를 확인하세요.",
|
||||||
|
"#FFF7ED", "#C2410C", "medium", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PermissionRequestPresentation Build(
|
||||||
|
string kind,
|
||||||
|
bool pending,
|
||||||
|
string icon,
|
||||||
|
string pendingLabel,
|
||||||
|
string resolvedLabel,
|
||||||
|
string pendingDescription,
|
||||||
|
string resolvedDescription,
|
||||||
|
string actionHint,
|
||||||
|
string backgroundHex,
|
||||||
|
string foregroundHex,
|
||||||
|
string severity,
|
||||||
|
bool requiresPreview)
|
||||||
|
{
|
||||||
|
return new PermissionRequestPresentation(
|
||||||
|
kind,
|
||||||
|
pending ? icon : "\uE73E",
|
||||||
|
pending ? pendingLabel : resolvedLabel,
|
||||||
|
pending ? pendingDescription : resolvedDescription,
|
||||||
|
actionHint,
|
||||||
|
pending ? backgroundHex : "#ECFDF5",
|
||||||
|
pending ? foregroundHex : "#059669",
|
||||||
|
severity,
|
||||||
|
requiresPreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs
Normal file
247
src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
internal sealed record ToolResultPresentation(
|
||||||
|
string Kind,
|
||||||
|
string Icon,
|
||||||
|
string Label,
|
||||||
|
string Description,
|
||||||
|
string FollowUpHint,
|
||||||
|
string BackgroundHex,
|
||||||
|
string ForegroundHex,
|
||||||
|
string StatusKind,
|
||||||
|
bool NeedsAttention);
|
||||||
|
|
||||||
|
internal static class ToolResultPresentationCatalog
|
||||||
|
{
|
||||||
|
public static ToolResultPresentation Resolve(AgentEvent evt, string fallbackLabel)
|
||||||
|
{
|
||||||
|
var summary = (evt.Summary ?? string.Empty).Trim();
|
||||||
|
var tool = (evt.ToolName ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
var baseLabel = string.IsNullOrWhiteSpace(fallbackLabel) ? "도구 결과" : fallbackLabel;
|
||||||
|
var kind = ResolveKind(tool);
|
||||||
|
|
||||||
|
if (summary.Contains("취소", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
summary.Contains("중단", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
evt.Type == AgentEventType.StopRequested)
|
||||||
|
{
|
||||||
|
return new ToolResultPresentation(
|
||||||
|
"cancel",
|
||||||
|
"\uE711",
|
||||||
|
$"{baseLabel} 취소",
|
||||||
|
"요청이 중단되어 결과가 취소되었습니다.",
|
||||||
|
"필요하면 같은 요청을 다시 실행하세요.",
|
||||||
|
"#F8FAFC",
|
||||||
|
"#475569",
|
||||||
|
"cancel",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.Contains("거부", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
summary.Contains("반려", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
summary.Contains("권한 거부", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ToolResultPresentation(
|
||||||
|
"reject",
|
||||||
|
"\uE783",
|
||||||
|
$"{baseLabel} 거부",
|
||||||
|
"권한이 거부되어 작업이 중단되었습니다.",
|
||||||
|
"권한 모드를 바꾸거나 다시 승인하면 이어서 진행할 수 있습니다.",
|
||||||
|
"#FEF2F2",
|
||||||
|
"#DC2626",
|
||||||
|
"reject",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.Contains("승인 필요", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
summary.Contains("확인 필요", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ToolResultPresentation(
|
||||||
|
kind,
|
||||||
|
"\uE8D7",
|
||||||
|
$"{baseLabel} 승인 대기",
|
||||||
|
"다음 단계로 진행하려면 사용자 승인이 필요합니다.",
|
||||||
|
"승인 후 같은 작업 흐름이 이어집니다.",
|
||||||
|
"#FFF7ED",
|
||||||
|
"#C2410C",
|
||||||
|
"approval_required",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.Contains("부분", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
summary.Contains("일부", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ToolResultPresentation(
|
||||||
|
kind,
|
||||||
|
"\uE7BA",
|
||||||
|
$"{baseLabel} 부분 완료",
|
||||||
|
"일부 단계만 완료되어 후속 확인이나 재실행이 필요할 수 있습니다.",
|
||||||
|
"남은 단계나 누락된 결과를 확인하세요.",
|
||||||
|
"#FFFBEA",
|
||||||
|
"#A16207",
|
||||||
|
"partial",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!evt.Success || evt.Type == AgentEventType.Error)
|
||||||
|
{
|
||||||
|
return new ToolResultPresentation(
|
||||||
|
kind,
|
||||||
|
"\uE783",
|
||||||
|
BuildFailureLabel(kind, baseLabel),
|
||||||
|
BuildFailureDescription(kind),
|
||||||
|
BuildFailureFollowUp(kind),
|
||||||
|
"#FEF2F2",
|
||||||
|
"#DC2626",
|
||||||
|
"error",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ToolResultPresentation(
|
||||||
|
kind,
|
||||||
|
"\uE73E",
|
||||||
|
BuildSuccessLabel(kind, baseLabel),
|
||||||
|
BuildSuccessDescription(kind),
|
||||||
|
BuildSuccessFollowUp(kind),
|
||||||
|
"#ECFDF5",
|
||||||
|
"#16A34A",
|
||||||
|
"success",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveKind(string tool)
|
||||||
|
{
|
||||||
|
if (tool.Contains("file_edit"))
|
||||||
|
return "file_edit";
|
||||||
|
if (tool.Contains("file_write"))
|
||||||
|
return "file_write";
|
||||||
|
if (tool.Contains("file_read") || tool.Contains("glob") || tool.Contains("grep"))
|
||||||
|
return "filesystem";
|
||||||
|
if (tool.Contains("file"))
|
||||||
|
return "file";
|
||||||
|
if (tool.Contains("build") || tool.Contains("test"))
|
||||||
|
return "build_test";
|
||||||
|
if (tool.Contains("git") || tool.Contains("diff"))
|
||||||
|
return "git";
|
||||||
|
if (tool.Contains("document") || tool.Contains("format") || tool.Contains("template"))
|
||||||
|
return "document";
|
||||||
|
if (tool.Contains("skill"))
|
||||||
|
return "skill";
|
||||||
|
if (tool.Contains("mcp"))
|
||||||
|
return "mcp";
|
||||||
|
if (tool.Contains("ask"))
|
||||||
|
return "question";
|
||||||
|
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
|
||||||
|
return "web";
|
||||||
|
if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell"))
|
||||||
|
return "command";
|
||||||
|
return "generic";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSuccessLabel(string kind, string baseLabel)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"file_edit" => "파일 수정 완료",
|
||||||
|
"file_write" => "파일 쓰기 완료",
|
||||||
|
"filesystem" => "파일 탐색 완료",
|
||||||
|
"file" => "파일 작업 완료",
|
||||||
|
"build_test" => "빌드/테스트 완료",
|
||||||
|
"git" => "Git 작업 완료",
|
||||||
|
"document" => "문서 작업 완료",
|
||||||
|
"skill" => "스킬 실행 완료",
|
||||||
|
"mcp" => "MCP 도구 완료",
|
||||||
|
"question" => "의견 요청 완료",
|
||||||
|
"web" => "웹 요청 완료",
|
||||||
|
"command" => "명령 실행 완료",
|
||||||
|
_ => baseLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFailureLabel(string kind, string baseLabel)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"file_edit" => "파일 수정 실패",
|
||||||
|
"file_write" => "파일 쓰기 실패",
|
||||||
|
"filesystem" => "파일 탐색 실패",
|
||||||
|
"file" => "파일 작업 실패",
|
||||||
|
"build_test" => "빌드/테스트 실패",
|
||||||
|
"git" => "Git 작업 실패",
|
||||||
|
"document" => "문서 작업 실패",
|
||||||
|
"skill" => "스킬 실행 실패",
|
||||||
|
"mcp" => "MCP 도구 실패",
|
||||||
|
"question" => "의견 요청 실패",
|
||||||
|
"web" => "웹 요청 실패",
|
||||||
|
"command" => "명령 실행 실패",
|
||||||
|
_ => $"{baseLabel} 실패",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSuccessDescription(string kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"file_edit" => "파일 수정 결과가 저장되었습니다.",
|
||||||
|
"file_write" => "새 파일 작성 결과가 저장되었습니다.",
|
||||||
|
"filesystem" => "파일과 폴더 정보를 성공적으로 읽었습니다.",
|
||||||
|
"file" => "파일 관련 작업이 정상적으로 끝났습니다.",
|
||||||
|
"build_test" => "빌드 또는 테스트 단계가 성공적으로 끝났습니다.",
|
||||||
|
"git" => "Git 관련 작업이 정상적으로 끝났습니다.",
|
||||||
|
"document" => "문서 생성 또는 변환 작업이 완료되었습니다.",
|
||||||
|
"skill" => "선택한 스킬이 정상적으로 실행되었습니다.",
|
||||||
|
"mcp" => "등록된 MCP 도구 호출이 성공적으로 끝났습니다.",
|
||||||
|
"question" => "사용자 응답을 받아 다음 단계로 넘어갈 수 있습니다.",
|
||||||
|
"web" => "웹 요청이 정상적으로 끝났습니다.",
|
||||||
|
"command" => "명령 실행이 정상적으로 끝났습니다.",
|
||||||
|
_ => "요청한 작업이 정상적으로 완료되었습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFailureDescription(string kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"file_edit" => "파일 변경 과정에서 문제가 발생했습니다.",
|
||||||
|
"file_write" => "파일 작성 또는 저장 과정에서 문제가 발생했습니다.",
|
||||||
|
"filesystem" => "파일/폴더 접근 중 문제가 발생했습니다.",
|
||||||
|
"file" => "파일 처리 중 문제가 발생했습니다.",
|
||||||
|
"build_test" => "빌드 또는 테스트 단계에서 실패가 발생했습니다.",
|
||||||
|
"git" => "Git 관련 작업이 실패했습니다.",
|
||||||
|
"document" => "문서 생성 또는 변환 작업이 실패했습니다.",
|
||||||
|
"skill" => "스킬 실행 중 문제가 발생했습니다.",
|
||||||
|
"mcp" => "MCP 도구 호출 중 문제가 발생했습니다.",
|
||||||
|
"question" => "사용자 의견 요청 과정에서 문제가 발생했습니다.",
|
||||||
|
"web" => "웹 요청 처리에 실패했습니다.",
|
||||||
|
"command" => "명령 실행 중 오류가 발생했습니다.",
|
||||||
|
_ => "작업 처리 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSuccessFollowUp(string kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"file_edit" or "file_write" => "변경 내용을 preview나 diff에서 다시 확인할 수 있습니다.",
|
||||||
|
"build_test" => "출력 로그와 후속 수정 필요 여부를 확인하세요.",
|
||||||
|
"git" => "브랜치 상태나 변경 요약을 이어서 확인하세요.",
|
||||||
|
"document" => "생성된 산출물 경로를 열어 결과를 확인하세요.",
|
||||||
|
"skill" => "같은 스킬을 다른 입력으로 이어서 실행할 수 있습니다.",
|
||||||
|
_ => "필요하면 후속 요청을 이어서 실행할 수 있습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFailureFollowUp(string kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"file_edit" or "file_write" => "대상 파일 경로와 권한, diff를 다시 확인하세요.",
|
||||||
|
"build_test" => "실패 로그와 컴파일 오류 메시지를 먼저 확인하세요.",
|
||||||
|
"git" => "현재 브랜치, 잠금 상태, 충돌 여부를 확인하세요.",
|
||||||
|
"document" => "입력 데이터와 출력 형식, 저장 위치를 다시 확인하세요.",
|
||||||
|
"skill" => "허용 도구와 런타임 요구사항을 다시 확인하세요.",
|
||||||
|
"web" => "연결 상태와 요청 대상 URL을 다시 확인하세요.",
|
||||||
|
"mcp" => "MCP 서버 연결 상태와 도구 등록 상태를 다시 확인하세요.",
|
||||||
|
_ => "같은 요청을 재시도하기 전에 원인 메시지를 먼저 확인하세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ public class AgentMemoryService
|
|||||||
private static readonly string MemoryDir = Path.Combine(
|
private static readonly string MemoryDir = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"AxCopilot", "memory");
|
"AxCopilot", "memory");
|
||||||
|
private static readonly string ManagedMemoryDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"AxCopilot", "memory");
|
||||||
|
private const string MemoryFileName = "AXMEMORY.md";
|
||||||
|
private const string LocalMemoryFileName = "AXMEMORY.local.md";
|
||||||
|
private const int MaxIncludeDepth = 5;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -24,6 +30,7 @@ public class AgentMemoryService
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly List<MemoryEntry> _entries = new();
|
private readonly List<MemoryEntry> _entries = new();
|
||||||
|
private readonly List<MemoryInstructionDocument> _instructionDocuments = new();
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private string? _currentWorkFolder;
|
private string? _currentWorkFolder;
|
||||||
|
|
||||||
@@ -33,12 +40,98 @@ public class AgentMemoryService
|
|||||||
/// <summary>모든 메모리 항목 (읽기 전용).</summary>
|
/// <summary>모든 메모리 항목 (읽기 전용).</summary>
|
||||||
public IReadOnlyList<MemoryEntry> All { get { lock (_lock) return _entries.ToList(); } }
|
public IReadOnlyList<MemoryEntry> All { get { lock (_lock) return _entries.ToList(); } }
|
||||||
|
|
||||||
|
/// <summary>현재 로드된 계층형 메모리 문서 (읽기 전용).</summary>
|
||||||
|
public IReadOnlyList<MemoryInstructionDocument> InstructionDocuments
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _instructionDocuments.ToList(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetWritableInstructionPath(string scope, string? workFolder)
|
||||||
|
{
|
||||||
|
scope = (scope ?? "").Trim().ToLowerInvariant();
|
||||||
|
var projectRoot = ResolveProjectRoot(workFolder);
|
||||||
|
return scope switch
|
||||||
|
{
|
||||||
|
"managed" => Path.Combine(ManagedMemoryDir, MemoryFileName),
|
||||||
|
"user" => Path.Combine(MemoryDir, MemoryFileName),
|
||||||
|
"project" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, MemoryFileName),
|
||||||
|
"local" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, LocalMemoryFileName),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool Changed, string Path, string Message) SaveInstruction(string scope, string content, string? workFolder)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var path = GetWritableInstructionPath(scope, workFolder);
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return (false, "", "해당 scope에 저장할 경로를 결정할 수 없습니다.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
var existing = File.Exists(path) ? File.ReadAllText(path) : "";
|
||||||
|
if (existing.Contains(content, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return (false, path, "이미 같은 내용이 메모리 파일에 있습니다.");
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
if (!string.IsNullOrWhiteSpace(existing))
|
||||||
|
{
|
||||||
|
sb.Append(existing.TrimEnd());
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine($"- {content.Trim()}");
|
||||||
|
File.WriteAllText(path, sb.ToString());
|
||||||
|
Load(workFolder);
|
||||||
|
return (true, path, "메모리 파일에 지시를 추가했습니다.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"메모리 파일 저장 실패 ({path}): {ex.Message}");
|
||||||
|
return (false, path, $"메모리 파일 저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool Changed, string Path, string Message) DeleteInstruction(string scope, string query, string? workFolder)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var path = GetWritableInstructionPath(scope, workFolder);
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return (false, "", "해당 scope에 저장된 메모리 파일 경로를 찾을 수 없습니다.");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return (false, path, "메모리 파일이 아직 없습니다.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(path).ToList();
|
||||||
|
var filtered = lines.Where(line => !line.Contains(query, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
if (filtered.Count == lines.Count)
|
||||||
|
return (false, path, "삭제할 일치 항목을 찾지 못했습니다.");
|
||||||
|
|
||||||
|
File.WriteAllLines(path, filtered);
|
||||||
|
Load(workFolder);
|
||||||
|
return (true, path, "메모리 파일에서 일치 항목을 삭제했습니다.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"메모리 파일 삭제 실패 ({path}): {ex.Message}");
|
||||||
|
return (false, path, $"메모리 파일 삭제 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
|
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
|
||||||
public void Load(string? workFolder)
|
public void Load(string? workFolder)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_entries.Clear();
|
_entries.Clear();
|
||||||
|
_instructionDocuments.Clear();
|
||||||
_currentWorkFolder = workFolder;
|
_currentWorkFolder = workFolder;
|
||||||
|
|
||||||
// 전역 메모리
|
// 전역 메모리
|
||||||
@@ -51,6 +144,9 @@ public class AgentMemoryService
|
|||||||
var folderPath = GetFilePath(workFolder);
|
var folderPath = GetFilePath(workFolder);
|
||||||
LoadFromFile(folderPath);
|
LoadFromFile(folderPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoadInstructionDocuments(workFolder);
|
||||||
|
NormalizeInstructionDocuments();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +351,513 @@ public class AgentMemoryService
|
|||||||
return union > 0 ? (double)intersection / union : 0;
|
return union > 0 ? (double)intersection / union : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadInstructionDocuments(string? workFolder)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var projectRoot = ResolveProjectRoot(workFolder);
|
||||||
|
|
||||||
|
AddInstructionFileIfExists(Path.Combine(ManagedMemoryDir, MemoryFileName), "managed", "관리형 메모리", seen, projectRoot);
|
||||||
|
AddInstructionRuleFilesIfExists(Path.Combine(ManagedMemoryDir, "rules"), "managed", "관리형 메모리", seen, projectRoot);
|
||||||
|
|
||||||
|
AddInstructionFileIfExists(Path.Combine(MemoryDir, MemoryFileName), "user", "사용자 메모리", seen, projectRoot);
|
||||||
|
AddInstructionRuleFilesIfExists(Path.Combine(MemoryDir, "rules"), "user", "사용자 메모리", seen, projectRoot);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var normalizedProjectRoot = projectRoot ?? workFolder;
|
||||||
|
foreach (var directory in EnumerateDirectoryChain(normalizedProjectRoot, workFolder))
|
||||||
|
{
|
||||||
|
AddInstructionFileIfExists(Path.Combine(directory, MemoryFileName), "project", "프로젝트 메모리", seen, normalizedProjectRoot);
|
||||||
|
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen, normalizedProjectRoot);
|
||||||
|
AddInstructionRuleFilesIfExists(Path.Combine(directory, ".ax", "rules"), "project", "프로젝트 메모리", seen, normalizedProjectRoot);
|
||||||
|
AddInstructionFileIfExists(Path.Combine(directory, LocalMemoryFileName), "local", "로컬 메모리", seen, normalizedProjectRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInstructionRuleFilesIfExists(string rulesDirectory, string layer, string label, HashSet<string> seen, string? projectRoot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(rulesDirectory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var file in Directory.GetFiles(rulesDirectory, "*.md").OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
AddInstructionFileIfExists(file, layer, label, seen, projectRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"메모리 rules 로드 실패 ({rulesDirectory}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInstructionFileIfExists(string path, string layer, string label, HashSet<string> seen, string? projectRoot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
if (!seen.Add(fullPath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var content = ExpandInstructionIncludes(fullPath, projectRoot, new HashSet<string>(StringComparer.OrdinalIgnoreCase), 0);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var frontMatter = ParseFrontMatter(content);
|
||||||
|
if (!frontMatter.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (frontMatter.Paths.Count > 0 && !ShouldApplyToCurrentWorkFolder(frontMatter.Paths, projectRoot, _currentWorkFolder))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_instructionDocuments.Add(new MemoryInstructionDocument
|
||||||
|
{
|
||||||
|
Layer = layer,
|
||||||
|
Label = label,
|
||||||
|
Path = fullPath,
|
||||||
|
Content = frontMatter.Content.Trim(),
|
||||||
|
Paths = frontMatter.Paths,
|
||||||
|
Description = frontMatter.Description,
|
||||||
|
Tags = frontMatter.Tags,
|
||||||
|
Enabled = frontMatter.Enabled,
|
||||||
|
LoadOrder = _instructionDocuments.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"메모리 문서 로드 실패 ({path}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NormalizeInstructionDocuments()
|
||||||
|
{
|
||||||
|
if (_instructionDocuments.Count <= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var seenNormalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var selected = new List<MemoryInstructionDocument>();
|
||||||
|
|
||||||
|
foreach (var doc in _instructionDocuments.OrderByDescending(d => d.LoadOrder))
|
||||||
|
{
|
||||||
|
var normalized = NormalizeInstructionContent(doc.Content);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized) && !seenNormalized.Add(normalized))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
selected.Add(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = selected
|
||||||
|
.OrderBy(d => GetLayerRank(d.Layer))
|
||||||
|
.ThenBy(d => d.LoadOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (var i = 0; i < selected.Count; i++)
|
||||||
|
{
|
||||||
|
selected[i].Priority = i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_instructionDocuments.Clear();
|
||||||
|
_instructionDocuments.AddRange(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeInstructionContent(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var normalizedLines = content
|
||||||
|
.Replace("\r\n", "\n")
|
||||||
|
.Split('\n')
|
||||||
|
.Select(line => string.Join(" ", line.Split(' ', '\t').Where(part => part.Length > 0)).Trim())
|
||||||
|
.Where(line => line.Length > 0);
|
||||||
|
|
||||||
|
return string.Join("\n", normalizedLines).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetLayerRank(string layer) => layer switch
|
||||||
|
{
|
||||||
|
"managed" => 0,
|
||||||
|
"user" => 1,
|
||||||
|
"project" => 2,
|
||||||
|
"local" => 3,
|
||||||
|
_ => 9
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateDirectoryChain(string projectRoot, string workFolder)
|
||||||
|
{
|
||||||
|
var current = Path.GetFullPath(workFolder);
|
||||||
|
var normalizedRoot = Path.GetFullPath(projectRoot);
|
||||||
|
var stack = new Stack<string>();
|
||||||
|
|
||||||
|
while (!string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
stack.Push(current);
|
||||||
|
if (string.Equals(current, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
break;
|
||||||
|
var parent = Directory.GetParent(current)?.FullName;
|
||||||
|
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
|
||||||
|
break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
yield return stack.Pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveProjectRoot(string? workFolder)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var current = Path.GetFullPath(workFolder);
|
||||||
|
while (!string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(current, ".git")))
|
||||||
|
return current;
|
||||||
|
|
||||||
|
if (File.Exists(Path.Combine(current, ".git")) ||
|
||||||
|
File.Exists(Path.Combine(current, ".sln")) ||
|
||||||
|
Directory.EnumerateFiles(current, "*.sln", SearchOption.TopDirectoryOnly).Any() ||
|
||||||
|
Directory.EnumerateFiles(current, "*.csproj", SearchOption.TopDirectoryOnly).Any() ||
|
||||||
|
File.Exists(Path.Combine(current, "package.json")) ||
|
||||||
|
File.Exists(Path.Combine(current, "pyproject.toml")) ||
|
||||||
|
File.Exists(Path.Combine(current, "go.mod")) ||
|
||||||
|
File.Exists(Path.Combine(current, "Cargo.toml")))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = Directory.GetParent(current)?.FullName;
|
||||||
|
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
|
||||||
|
break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(workFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ExpandInstructionIncludes(string path, string? projectRoot, HashSet<string> visited, int depth)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (depth > MaxIncludeDepth)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
if (!visited.Add(fullPath))
|
||||||
|
return "";
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var lines = File.ReadAllLines(fullPath);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var inCodeBlock = false;
|
||||||
|
|
||||||
|
foreach (var originalLine in lines)
|
||||||
|
{
|
||||||
|
var line = originalLine;
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("```", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
inCodeBlock = !inCodeBlock;
|
||||||
|
sb.AppendLine(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inCodeBlock && trimmed.StartsWith("@", StringComparison.Ordinal) && trimmed.Length > 1)
|
||||||
|
{
|
||||||
|
var resolution = ResolveIncludePath(fullPath, trimmed, projectRoot, IsExternalMemoryIncludeAllowed());
|
||||||
|
LogIncludeAudit(fullPath, trimmed, resolution);
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolution.ResolvedPath))
|
||||||
|
{
|
||||||
|
var included = ExpandInstructionIncludes(resolution.ResolvedPath, projectRoot, visited, depth + 1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(included))
|
||||||
|
{
|
||||||
|
sb.AppendLine(included.TrimEnd());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.Remove(fullPath);
|
||||||
|
return sb.ToString().Trim();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"메모리 include 확장 실패 ({path}): {ex.Message}");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemoryIncludeResolution ResolveIncludePath(string currentFile, string includeDirective, string? projectRoot, bool allowExternal)
|
||||||
|
{
|
||||||
|
var target = includeDirective[1..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
return MemoryIncludeResolution.CreateFailure("", "빈 include 지시문");
|
||||||
|
|
||||||
|
var hashIndex = target.IndexOf('#');
|
||||||
|
if (hashIndex >= 0)
|
||||||
|
target = target[..hashIndex];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
return MemoryIncludeResolution.CreateFailure("", "주석만 포함된 include 지시문");
|
||||||
|
|
||||||
|
string resolved;
|
||||||
|
var externalCandidate = false;
|
||||||
|
if (target.StartsWith("~/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
resolved = Path.Combine(home, target[2..]);
|
||||||
|
externalCandidate = true;
|
||||||
|
}
|
||||||
|
else if (Path.IsPathRooted(target))
|
||||||
|
{
|
||||||
|
resolved = target;
|
||||||
|
externalCandidate = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var baseDir = Path.GetDirectoryName(currentFile) ?? "";
|
||||||
|
var relative = target.StartsWith("./", StringComparison.Ordinal) ? target[2..] : target;
|
||||||
|
resolved = Path.Combine(baseDir, relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved = Path.GetFullPath(resolved);
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(resolved);
|
||||||
|
if (!string.IsNullOrWhiteSpace(ext) &&
|
||||||
|
!new[] { ".md", ".txt", ".cs", ".json", ".yml", ".yaml", ".xml", ".props", ".targets" }
|
||||||
|
.Contains(ext, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return MemoryIncludeResolution.CreateFailure(resolved, $"지원하지 않는 확장자 {ext}");
|
||||||
|
|
||||||
|
if (!allowExternal)
|
||||||
|
{
|
||||||
|
if (externalCandidate)
|
||||||
|
return MemoryIncludeResolution.CreateFailure(resolved, "외부 include가 비활성화되어 있습니다");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(projectRoot) && !IsSubPathOf(projectRoot, resolved))
|
||||||
|
return MemoryIncludeResolution.CreateFailure(resolved, "프로젝트 바깥 경로는 허용되지 않습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File.Exists(resolved)
|
||||||
|
? MemoryIncludeResolution.CreateSuccess(resolved)
|
||||||
|
: MemoryIncludeResolution.CreateFailure(resolved, "포함할 파일을 찾지 못했습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSubPathOf(string baseDirectory, string candidatePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var basePath = Path.GetFullPath(baseDirectory)
|
||||||
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
|
+ Path.DirectorySeparatorChar;
|
||||||
|
var fullCandidate = Path.GetFullPath(candidatePath);
|
||||||
|
return fullCandidate.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(fullCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
||||||
|
basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsExternalMemoryIncludeAllowed()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
return app?.SettingsService?.Settings.Llm.AllowExternalMemoryIncludes ?? false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogIncludeAudit(string sourcePath, string directive, MemoryIncludeResolution resolution)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
if (app?.SettingsService?.Settings.Llm.EnableAuditLog != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
AuditLogService.Log(new AuditEntry
|
||||||
|
{
|
||||||
|
ConversationId = "",
|
||||||
|
Tab = "Memory",
|
||||||
|
Action = "MemoryInclude",
|
||||||
|
ToolName = "memory",
|
||||||
|
Parameters = $"{sourcePath} <= {directive}",
|
||||||
|
Result = resolution.Success
|
||||||
|
? $"허용: {resolution.ResolvedPath}"
|
||||||
|
: $"차단: {resolution.Reason}",
|
||||||
|
FilePath = resolution.ResolvedPath,
|
||||||
|
Success = resolution.Success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 감사 로그 실패는 메모리 로드에 영향 주지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ReadInstructionFile(string scope, string? workFolder)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var path = GetWritableInstructionPath(scope, workFolder);
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"메모리 파일 읽기 실패 ({path}): {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Content, List<string> Paths, string Description, List<string> Tags, bool Enabled) ParseFrontMatter(string content)
|
||||||
|
{
|
||||||
|
var lines = content.Replace("\r\n", "\n").Split('\n').ToList();
|
||||||
|
if (lines.Count < 3 || !string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal))
|
||||||
|
return (content, new List<string>(), "", new List<string>(), true);
|
||||||
|
|
||||||
|
var endIndex = -1;
|
||||||
|
for (var i = 1; i < lines.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
endIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endIndex < 1)
|
||||||
|
return (content, new List<string>(), "", new List<string>(), true);
|
||||||
|
|
||||||
|
var paths = new List<string>();
|
||||||
|
var tags = new List<string>();
|
||||||
|
var description = "";
|
||||||
|
var enabled = true;
|
||||||
|
var inPaths = false;
|
||||||
|
var inTags = false;
|
||||||
|
for (var i = 1; i < endIndex; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (line.StartsWith("description:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
description = line["description:".Length..].Trim().Trim('"');
|
||||||
|
inPaths = false;
|
||||||
|
inTags = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("enabled:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var raw = line["enabled:".Length..].Trim().Trim('"');
|
||||||
|
enabled = !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(raw, "0", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(raw, "off", StringComparison.OrdinalIgnoreCase);
|
||||||
|
inPaths = false;
|
||||||
|
inTags = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("paths:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
inPaths = true;
|
||||||
|
inTags = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("tags:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
inTags = true;
|
||||||
|
inPaths = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inPaths)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("-", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var pattern = line[1..].Trim().Trim('"');
|
||||||
|
if (!string.IsNullOrWhiteSpace(pattern))
|
||||||
|
paths.Add(pattern);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length > 0)
|
||||||
|
inPaths = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inTags)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("-", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var tag = line[1..].Trim().Trim('"');
|
||||||
|
if (!string.IsNullOrWhiteSpace(tag))
|
||||||
|
tags.Add(tag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length > 0)
|
||||||
|
inTags = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stripped = string.Join("\n", lines.Skip(endIndex + 1)).Trim();
|
||||||
|
return (stripped, paths, description, tags, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldApplyToCurrentWorkFolder(IReadOnlyList<string> patterns, string? projectRoot, string? currentWorkFolder)
|
||||||
|
{
|
||||||
|
if (patterns.Count == 0)
|
||||||
|
return true;
|
||||||
|
if (string.IsNullOrWhiteSpace(projectRoot) || string.IsNullOrWhiteSpace(currentWorkFolder))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var relative = Path.GetRelativePath(projectRoot, currentWorkFolder).Replace('\\', '/');
|
||||||
|
if (string.Equals(relative, ".", StringComparison.Ordinal))
|
||||||
|
relative = "";
|
||||||
|
|
||||||
|
return patterns.Any(pattern => GlobMatches(relative, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GlobMatches(string relativePath, string pattern)
|
||||||
|
{
|
||||||
|
var normalizedPattern = (pattern ?? "").Replace('\\', '/').Trim();
|
||||||
|
var normalizedPath = (relativePath ?? "").Replace('\\', '/').Trim('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedPattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var regex = "^" + System.Text.RegularExpressions.Regex.Escape(normalizedPattern)
|
||||||
|
.Replace(@"\*\*", "§§DOUBLESTAR§§")
|
||||||
|
.Replace(@"\*", "[^/]*")
|
||||||
|
.Replace(@"\?", "[^/]")
|
||||||
|
.Replace("§§DOUBLESTAR§§", ".*")
|
||||||
|
+ "$";
|
||||||
|
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(normalizedPath, regex, System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(normalizedPath))
|
||||||
|
normalizedPath += "/";
|
||||||
|
return System.Text.RegularExpressions.Regex.IsMatch(normalizedPath, regex, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
|
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
|
||||||
private static HashSet<string> Tokenize(string text)
|
private static HashSet<string> Tokenize(string text)
|
||||||
{
|
{
|
||||||
@@ -307,3 +910,58 @@ public class MemoryEntry
|
|||||||
[JsonPropertyName("workFolder")]
|
[JsonPropertyName("workFolder")]
|
||||||
public string? WorkFolder { get; set; }
|
public string? WorkFolder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>CLAUDE.md 스타일의 계층형 메모리 문서.</summary>
|
||||||
|
public class MemoryInstructionDocument
|
||||||
|
{
|
||||||
|
[JsonPropertyName("layer")]
|
||||||
|
public string Layer { get; set; } = "project";
|
||||||
|
|
||||||
|
[JsonPropertyName("label")]
|
||||||
|
public string Label { get; set; } = "프로젝트 메모리";
|
||||||
|
|
||||||
|
[JsonPropertyName("path")]
|
||||||
|
public string Path { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("paths")]
|
||||||
|
public List<string> Paths { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<string> Tags { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("enabled")]
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
[JsonPropertyName("loadOrder")]
|
||||||
|
public int LoadOrder { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("priority")]
|
||||||
|
public int Priority { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MemoryIncludeResolution
|
||||||
|
{
|
||||||
|
public string? ResolvedPath { get; init; }
|
||||||
|
public string Reason { get; init; } = "";
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public static MemoryIncludeResolution CreateSuccess(string path) => new()
|
||||||
|
{
|
||||||
|
ResolvedPath = path,
|
||||||
|
Success = true,
|
||||||
|
Reason = "허용"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MemoryIncludeResolution CreateFailure(string? path, string reason) => new()
|
||||||
|
{
|
||||||
|
ResolvedPath = string.IsNullOrWhiteSpace(path) ? null : path,
|
||||||
|
Success = false,
|
||||||
|
Reason = reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,6 +158,31 @@ public sealed class AppStateService
|
|||||||
public string StripText { get; init; } = "";
|
public string StripText { get; init; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class OperationalStatusPresentationState
|
||||||
|
{
|
||||||
|
public bool ShowRuntimeBadge { get; init; }
|
||||||
|
public string RuntimeLabel { get; init; } = "";
|
||||||
|
public bool ShowLastCompleted { get; init; }
|
||||||
|
public string LastCompletedText { get; init; } = "";
|
||||||
|
public bool ShowCompactStrip { get; init; }
|
||||||
|
public string StripKind { get; init; } = "none";
|
||||||
|
public string StripText { get; init; } = "";
|
||||||
|
public string StripBackgroundHex { get; init; } = "";
|
||||||
|
public string StripBorderHex { get; init; } = "";
|
||||||
|
public string StripForegroundHex { get; init; } = "";
|
||||||
|
public bool ShowQuickStrip { get; init; }
|
||||||
|
public string QuickRunningText { get; init; } = "";
|
||||||
|
public string QuickHotText { get; init; } = "";
|
||||||
|
public bool QuickRunningActive { get; init; }
|
||||||
|
public bool QuickHotActive { get; init; }
|
||||||
|
public string QuickRunningBackgroundHex { get; init; } = "#F8FAFC";
|
||||||
|
public string QuickRunningBorderHex { get; init; } = "#E5E7EB";
|
||||||
|
public string QuickRunningForegroundHex { get; init; } = "#6B7280";
|
||||||
|
public string QuickHotBackgroundHex { get; init; } = "#F8FAFC";
|
||||||
|
public string QuickHotBorderHex { get; init; } = "#E5E7EB";
|
||||||
|
public string QuickHotForegroundHex { get; init; } = "#6B7280";
|
||||||
|
}
|
||||||
|
|
||||||
public ChatSessionStateService? ChatSession { get; private set; }
|
public ChatSessionStateService? ChatSession { get; private set; }
|
||||||
public SkillCatalogState Skills { get; } = new();
|
public SkillCatalogState Skills { get; } = new();
|
||||||
public McpCatalogState Mcp { get; } = new();
|
public McpCatalogState Mcp { get; } = new();
|
||||||
@@ -620,6 +645,25 @@ public sealed class AppStateService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OperationalStatusPresentationState GetOperationalStatusPresentation(
|
||||||
|
string tab,
|
||||||
|
bool hasLiveRuntimeActivity,
|
||||||
|
int runningConversationCount,
|
||||||
|
int spotlightConversationCount,
|
||||||
|
bool runningOnlyFilter,
|
||||||
|
bool sortConversationsByRecent)
|
||||||
|
{
|
||||||
|
var status = GetOperationalStatus(tab);
|
||||||
|
return OperationalStatusPresentationCatalog.Resolve(
|
||||||
|
status,
|
||||||
|
tab,
|
||||||
|
hasLiveRuntimeActivity,
|
||||||
|
runningConversationCount,
|
||||||
|
spotlightConversationCount,
|
||||||
|
runningOnlyFilter,
|
||||||
|
sortConversationsByRecent);
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
|
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
|
||||||
=> ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>();
|
=> ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>();
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,24 @@ public static class AuditLogService
|
|||||||
return LoadFile(Path.Combine(AuditDir, fileName));
|
return LoadFile(Path.Combine(AuditDir, fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>최근 N일 내 특정 액션 감사 로그를 최신순으로 읽습니다.</summary>
|
||||||
|
public static List<AuditEntry> LoadRecent(string action, int maxCount = 20, int daysBack = 3)
|
||||||
|
{
|
||||||
|
var since = DateTime.Now.Date.AddDays(-Math.Max(0, daysBack - 1));
|
||||||
|
var entries = new List<AuditEntry>();
|
||||||
|
|
||||||
|
for (var day = DateTime.Now.Date; day >= since; day = day.AddDays(-1))
|
||||||
|
{
|
||||||
|
entries.AddRange(LoadDate(day)
|
||||||
|
.Where(x => string.Equals(x.Action, action, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.OrderByDescending(x => x.Timestamp)
|
||||||
|
.Take(Math.Max(1, maxCount))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private static List<AuditEntry> LoadFile(string filePath)
|
private static List<AuditEntry> LoadFile(string filePath)
|
||||||
{
|
{
|
||||||
var entries = new List<AuditEntry>();
|
var entries = new List<AuditEntry>();
|
||||||
@@ -112,7 +130,7 @@ public static class AuditLogService
|
|||||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||||
foreach (var f in Directory.GetFiles(AuditDir, "*.json"))
|
foreach (var f in Directory.GetFiles(AuditDir, "*.json"))
|
||||||
{
|
{
|
||||||
if (File.GetCreationTime(f) < cutoff)
|
if (File.GetLastWriteTime(f) < cutoff)
|
||||||
File.Delete(f);
|
File.Delete(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Http;
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -35,38 +36,53 @@ internal sealed class Cp4dTokenService
|
|||||||
|
|
||||||
var cacheKey = $"{cp4dUrl}|{username}";
|
var cacheKey = $"{cp4dUrl}|{username}";
|
||||||
|
|
||||||
// 캐시 확인 — 만료 1분 전까지 유효
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1))
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1))
|
||||||
return cached.Token;
|
return cached.Token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰 발급
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tokenUrl = cp4dUrl.TrimEnd('/') + "/icp4d-api/v1/authorize";
|
var tokenUrl = cp4dUrl.TrimEnd('/') + "/icp4d-api/v1/authorize";
|
||||||
var body = new { username, password };
|
string? json = null;
|
||||||
|
HttpStatusCode? statusCode = null;
|
||||||
|
string? lastErrorBody = null;
|
||||||
|
|
||||||
|
async Task<bool> TryAuthorizeAsync(object body, string bodyKind)
|
||||||
|
{
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
|
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
|
||||||
{
|
{
|
||||||
Content = JsonContent.Create(body)
|
Content = JsonContent.Create(body)
|
||||||
};
|
};
|
||||||
|
|
||||||
// CP4D는 자체 서명 인증서를 사용할 수 있으므로 TLS 오류 무시 옵션 (사내 환경)
|
|
||||||
using var resp = await _http.SendAsync(req, ct);
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
statusCode = resp.StatusCode;
|
||||||
|
var rawBody = await resp.Content.ReadAsStringAsync(ct);
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
lastErrorBody = rawBody;
|
||||||
LogService.Warn($"CP4D 토큰 발급 실패: {resp.StatusCode} - {errBody}");
|
LogService.Warn($"CP4D 토큰 발급 실패({bodyKind}): {resp.StatusCode} - {rawBody}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
json = rawBody;
|
||||||
|
LogService.Info($"CP4D 토큰 발급 성공({bodyKind})");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorized = await TryAuthorizeAsync(new { username, password }, "username+password");
|
||||||
|
if (!authorized && !string.IsNullOrWhiteSpace(password))
|
||||||
|
authorized = await TryAuthorizeAsync(new { username, api_key = password }, "username+api_key");
|
||||||
|
|
||||||
|
if (!authorized || string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
LogService.Warn($"CP4D 토큰 발급 최종 실패: {statusCode} - {lastErrorBody}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
// CP4D 응답 형식: {"token": "...", "_messageCode_": "...", "message": "..."}
|
|
||||||
if (!doc.RootElement.TryGetProperty("token", out var tokenProp))
|
if (!doc.RootElement.TryGetProperty("token", out var tokenProp))
|
||||||
{
|
{
|
||||||
LogService.Warn("CP4D 응답에 token 필드가 없습니다.");
|
LogService.Warn("CP4D 응답에 token 필드가 없습니다.");
|
||||||
@@ -74,12 +90,10 @@ internal sealed class Cp4dTokenService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var token = tokenProp.GetString();
|
var token = tokenProp.GetString();
|
||||||
if (string.IsNullOrEmpty(token)) return null;
|
if (string.IsNullOrEmpty(token))
|
||||||
|
return null;
|
||||||
|
|
||||||
// 토큰 만료 시간 — CP4D 기본 12시간, 안전하게 11시간으로 설정
|
|
||||||
var expiry = DateTime.UtcNow.AddHours(11);
|
var expiry = DateTime.UtcNow.AddHours(11);
|
||||||
|
|
||||||
// _messageCode_에서 만료 정보가 있으면 파싱 시도
|
|
||||||
if (doc.RootElement.TryGetProperty("accessTokenExpiry", out var expiryProp) &&
|
if (doc.RootElement.TryGetProperty("accessTokenExpiry", out var expiryProp) &&
|
||||||
expiryProp.TryGetInt64(out var expiryMs))
|
expiryProp.TryGetInt64(out var expiryMs))
|
||||||
{
|
{
|
||||||
@@ -105,12 +119,18 @@ internal sealed class Cp4dTokenService
|
|||||||
public static void InvalidateToken(string cp4dUrl, string username)
|
public static void InvalidateToken(string cp4dUrl, string username)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{cp4dUrl}|{username}";
|
var cacheKey = $"{cp4dUrl}|{username}";
|
||||||
lock (_lock) { _cache.Remove(cacheKey); }
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_cache.Remove(cacheKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>모든 캐시된 토큰을 초기화합니다.</summary>
|
/// <summary>모든 캐시된 토큰을 초기화합니다.</summary>
|
||||||
public static void ClearAllTokens()
|
public static void ClearAllTokens()
|
||||||
{
|
{
|
||||||
lock (_lock) { _cache.Clear(); }
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_cache.Clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/AxCopilot/Services/IbmIamTokenService.cs
Normal file
106
src/AxCopilot/Services/IbmIamTokenService.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IBM Cloud IAM 액세스 토큰 발급 및 캐싱 서비스.
|
||||||
|
/// API 키를 IAM 토큰으로 교환한 뒤 Bearer 토큰으로 재사용합니다.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class IbmIamTokenService
|
||||||
|
{
|
||||||
|
private static readonly HttpClient _http = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, (string Token, DateTime Expiry)> _cache = new();
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
private const string DefaultIamUrl = "https://iam.cloud.ibm.com/identity/token";
|
||||||
|
|
||||||
|
public static async Task<string?> GetTokenAsync(string apiKey, string? iamUrl = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var tokenUrl = string.IsNullOrWhiteSpace(iamUrl) ? DefaultIamUrl : iamUrl.Trim();
|
||||||
|
var cacheKey = $"{tokenUrl}|{apiKey}";
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1))
|
||||||
|
return cached.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
||||||
|
req.Content = new StringContent(
|
||||||
|
$"grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey={Uri.EscapeDataString(apiKey)}",
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/x-www-form-urlencoded");
|
||||||
|
req.Headers.Accept.ParseAdd("application/json");
|
||||||
|
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
LogService.Warn($"IBM IAM 토큰 발급 실패: {resp.StatusCode} - {errBody}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (!doc.RootElement.TryGetProperty("access_token", out var tokenProp))
|
||||||
|
{
|
||||||
|
LogService.Warn("IBM IAM 응답에 access_token 필드가 없습니다.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = tokenProp.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var expiry = DateTime.UtcNow.AddMinutes(55);
|
||||||
|
if (doc.RootElement.TryGetProperty("expiration", out var expirationProp) &&
|
||||||
|
expirationProp.TryGetInt64(out var expirationEpoch))
|
||||||
|
{
|
||||||
|
expiry = DateTimeOffset.FromUnixTimeSeconds(expirationEpoch).UtcDateTime;
|
||||||
|
}
|
||||||
|
else if (doc.RootElement.TryGetProperty("expires_in", out var expiresInProp) &&
|
||||||
|
expiresInProp.TryGetInt64(out var expiresInSeconds))
|
||||||
|
{
|
||||||
|
expiry = DateTime.UtcNow.AddSeconds(expiresInSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_cache[cacheKey] = (token, expiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogService.Info($"IBM IAM 토큰 발급 완료: {tokenUrl} (만료: {expiry:yyyy-MM-dd HH:mm} UTC)");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Error($"IBM IAM 토큰 발급 오류: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void InvalidateToken(string apiKey, string? iamUrl = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tokenUrl = string.IsNullOrWhiteSpace(iamUrl) ? DefaultIamUrl : iamUrl.Trim();
|
||||||
|
var cacheKey = $"{tokenUrl}|{apiKey}";
|
||||||
|
lock (_lock) { _cache.Remove(cacheKey); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearAllTokens()
|
||||||
|
{
|
||||||
|
lock (_lock) { _cache.Clear(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -431,13 +431,20 @@ public partial class LlmService
|
|||||||
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
|
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var activeService = ResolveService();
|
var activeService = ResolveService();
|
||||||
var body = BuildOpenAiToolBody(messages, tools);
|
|
||||||
|
|
||||||
// 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo)
|
// 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo)
|
||||||
var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo();
|
var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo();
|
||||||
var endpoint = string.IsNullOrEmpty(resolvedEp)
|
var endpoint = string.IsNullOrEmpty(resolvedEp)
|
||||||
? ResolveEndpointForService(activeService)
|
? ResolveEndpointForService(activeService)
|
||||||
: resolvedEp;
|
: resolvedEp;
|
||||||
|
var registered = GetActiveRegisteredModel();
|
||||||
|
if (UsesIbmDeploymentChatApi(activeService, registered, endpoint))
|
||||||
|
{
|
||||||
|
throw new ToolCallNotSupportedException(
|
||||||
|
"IBM 배포형 vLLM 연결은 OpenAI 도구 호출 형식과 다를 수 있어 일반 대화 경로로 폴백합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = BuildOpenAiToolBody(messages, tools);
|
||||||
|
|
||||||
var url = activeService.ToLowerInvariant() == "ollama"
|
var url = activeService.ToLowerInvariant() == "ollama"
|
||||||
? endpoint.TrimEnd('/') + "/api/chat"
|
? endpoint.TrimEnd('/') + "/api/chat"
|
||||||
|
|||||||
@@ -320,9 +320,106 @@ public partial class LlmService : IDisposable
|
|||||||
m.Alias == modelName));
|
m.Alias == modelName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Models.RegisteredModel? GetActiveRegisteredModel()
|
||||||
|
{
|
||||||
|
var llm = _settings.Settings.Llm;
|
||||||
|
return FindRegisteredModel(llm, ResolveService(), ResolveModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesIbmDeploymentChatApi(string service, Models.RegisteredModel? registered, string? endpoint)
|
||||||
|
{
|
||||||
|
if (!string.Equals(NormalizeServiceName(service), "vllm", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
if (registered == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var authType = (registered.AuthType ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (authType is not ("ibm_iam" or "cp4d" or "cp4d_password" or "cp4d_api_key"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var normalizedEndpoint = (endpoint ?? "").Trim().ToLowerInvariant();
|
||||||
|
return normalizedEndpoint.Contains("/ml/") ||
|
||||||
|
normalizedEndpoint.Contains("/deployments/") ||
|
||||||
|
normalizedEndpoint.Contains("/text/chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildIbmDeploymentChatUrl(string endpoint, bool stream)
|
||||||
|
{
|
||||||
|
var trimmed = (endpoint ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
throw new InvalidOperationException("IBM 배포형 vLLM 엔드포인트가 비어 있습니다.");
|
||||||
|
|
||||||
|
var normalized = trimmed.ToLowerInvariant();
|
||||||
|
if (normalized.Contains("/text/chat_stream"))
|
||||||
|
return stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (normalized.Contains("/text/chat"))
|
||||||
|
return stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
|
||||||
|
if (normalized.Contains("/deployments/"))
|
||||||
|
return trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private object BuildIbmDeploymentBody(List<ChatMessage> messages)
|
||||||
|
{
|
||||||
|
var msgs = new List<object>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(_systemPrompt))
|
||||||
|
msgs.Add(new { role = "system", content = _systemPrompt });
|
||||||
|
|
||||||
|
foreach (var m in messages)
|
||||||
|
{
|
||||||
|
if (m.Role == "system")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
msgs.Add(new
|
||||||
|
{
|
||||||
|
role = m.Role == "assistant" ? "assistant" : "user",
|
||||||
|
content = m.Content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
messages = msgs,
|
||||||
|
parameters = new
|
||||||
|
{
|
||||||
|
temperature = ResolveTemperature(),
|
||||||
|
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractIbmDeploymentText(JsonElement root)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
|
||||||
|
if (message.ValueKind == JsonValueKind.Object &&
|
||||||
|
message.TryGetProperty("content", out var content))
|
||||||
|
return content.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var first = results[0];
|
||||||
|
if (first.TryGetProperty("generated_text", out var generatedText))
|
||||||
|
return generatedText.GetString() ?? "";
|
||||||
|
if (first.TryGetProperty("output_text", out var outputText))
|
||||||
|
return outputText.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("generated_text", out var generated))
|
||||||
|
return generated.GetString() ?? "";
|
||||||
|
|
||||||
|
if (root.TryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
|
||||||
|
return messageValue.GetString() ?? "";
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 현재 활성 모델의 인증 헤더 값을 반환합니다.
|
/// 현재 활성 모델의 인증 헤더 값을 반환합니다.
|
||||||
/// CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
|
/// IBM IAM / CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal async Task<string?> ResolveAuthTokenAsync(CancellationToken ct = default)
|
internal async Task<string?> ResolveAuthTokenAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -331,9 +428,22 @@ public partial class LlmService : IDisposable
|
|||||||
var modelName = ResolveModel();
|
var modelName = ResolveModel();
|
||||||
var registered = FindRegisteredModel(llm, activeService, modelName);
|
var registered = FindRegisteredModel(llm, activeService, modelName);
|
||||||
|
|
||||||
|
// IBM Cloud IAM 인증 방식인 경우
|
||||||
|
if (registered != null &&
|
||||||
|
registered.AuthType.Equals("ibm_iam", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
|
||||||
|
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
|
||||||
|
: GetDefaultApiKey(llm, activeService);
|
||||||
|
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
// CP4D 인증 방식인 경우
|
// CP4D 인증 방식인 경우
|
||||||
if (registered != null &&
|
if (registered != null &&
|
||||||
registered.AuthType.Equals("cp4d", StringComparison.OrdinalIgnoreCase) &&
|
(registered.AuthType.Equals("cp4d", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
registered.AuthType.Equals("cp4d_password", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
registered.AuthType.Equals("cp4d_api_key", StringComparison.OrdinalIgnoreCase)) &&
|
||||||
!string.IsNullOrWhiteSpace(registered.Cp4dUrl))
|
!string.IsNullOrWhiteSpace(registered.Cp4dUrl))
|
||||||
{
|
{
|
||||||
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
|
var password = CryptoService.DecryptIfEnabled(registered.Cp4dPassword, llm.EncryptionEnabled);
|
||||||
@@ -349,7 +459,7 @@ public partial class LlmService : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HttpRequestMessage에 인증 헤더를 적용합니다.
|
/// HttpRequestMessage에 인증 헤더를 적용합니다.
|
||||||
/// CP4D 인증인 경우 자동 토큰 발급, 일반 Bearer인 경우 API 키를 사용합니다.
|
/// IBM IAM / CP4D 인증인 경우 자동 토큰 발급, 일반 Bearer인 경우 API 키를 사용합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ApplyAuthHeaderAsync(HttpRequestMessage req, CancellationToken ct)
|
private async Task ApplyAuthHeaderAsync(HttpRequestMessage req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -593,8 +703,14 @@ public partial class LlmService : IDisposable
|
|||||||
var llm = _settings.Settings.Llm;
|
var llm = _settings.Settings.Llm;
|
||||||
var (endpoint, _, allowInsecureTls) = ResolveServerInfo();
|
var (endpoint, _, allowInsecureTls) = ResolveServerInfo();
|
||||||
var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint;
|
var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint;
|
||||||
var body = BuildOpenAiBody(messages, stream: false);
|
var registered = GetActiveRegisteredModel();
|
||||||
var url = ep.TrimEnd('/') + "/v1/chat/completions";
|
var usesIbmDeploymentApi = UsesIbmDeploymentChatApi("vllm", registered, ep);
|
||||||
|
var body = usesIbmDeploymentApi
|
||||||
|
? BuildIbmDeploymentBody(messages)
|
||||||
|
: BuildOpenAiBody(messages, stream: false);
|
||||||
|
var url = usesIbmDeploymentApi
|
||||||
|
? BuildIbmDeploymentChatUrl(ep, stream: false)
|
||||||
|
: ep.TrimEnd('/') + "/v1/chat/completions";
|
||||||
var json = JsonSerializer.Serialize(body);
|
var json = JsonSerializer.Serialize(body);
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
@@ -608,6 +724,12 @@ public partial class LlmService : IDisposable
|
|||||||
return SafeParseJson(respBody, root =>
|
return SafeParseJson(respBody, root =>
|
||||||
{
|
{
|
||||||
TryParseOpenAiUsage(root);
|
TryParseOpenAiUsage(root);
|
||||||
|
if (usesIbmDeploymentApi)
|
||||||
|
{
|
||||||
|
var parsed = ExtractIbmDeploymentText(root);
|
||||||
|
return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
var choices = root.GetProperty("choices");
|
var choices = root.GetProperty("choices");
|
||||||
if (choices.GetArrayLength() == 0) return "(빈 응답)";
|
if (choices.GetArrayLength() == 0) return "(빈 응답)";
|
||||||
return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
|
return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
|
||||||
@@ -621,8 +743,14 @@ public partial class LlmService : IDisposable
|
|||||||
var llm = _settings.Settings.Llm;
|
var llm = _settings.Settings.Llm;
|
||||||
var (endpoint, _, allowInsecureTls) = ResolveServerInfo();
|
var (endpoint, _, allowInsecureTls) = ResolveServerInfo();
|
||||||
var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint;
|
var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint;
|
||||||
var body = BuildOpenAiBody(messages, stream: true);
|
var registered = GetActiveRegisteredModel();
|
||||||
var url = ep.TrimEnd('/') + "/v1/chat/completions";
|
var usesIbmDeploymentApi = UsesIbmDeploymentChatApi("vllm", registered, ep);
|
||||||
|
var body = usesIbmDeploymentApi
|
||||||
|
? BuildIbmDeploymentBody(messages)
|
||||||
|
: BuildOpenAiBody(messages, stream: true);
|
||||||
|
var url = usesIbmDeploymentApi
|
||||||
|
? BuildIbmDeploymentChatUrl(ep, stream: true)
|
||||||
|
: ep.TrimEnd('/') + "/v1/chat/completions";
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
|
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
|
||||||
await ApplyAuthHeaderAsync(req, ct);
|
await ApplyAuthHeaderAsync(req, ct);
|
||||||
@@ -644,6 +772,36 @@ public partial class LlmService : IDisposable
|
|||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(data);
|
using var doc = JsonDocument.Parse(data);
|
||||||
TryParseOpenAiUsage(doc.RootElement);
|
TryParseOpenAiUsage(doc.RootElement);
|
||||||
|
if (usesIbmDeploymentApi)
|
||||||
|
{
|
||||||
|
if (doc.RootElement.TryGetProperty("status", out var status) &&
|
||||||
|
string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var detail = doc.RootElement.TryGetProperty("message", out var message)
|
||||||
|
? message.GetString()
|
||||||
|
: "IBM vLLM 스트리밍 오류";
|
||||||
|
throw new InvalidOperationException(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.RootElement.TryGetProperty("results", out var results) &&
|
||||||
|
results.ValueKind == JsonValueKind.Array &&
|
||||||
|
results.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var first = results[0];
|
||||||
|
if (first.TryGetProperty("generated_text", out var generatedText))
|
||||||
|
text = generatedText.GetString();
|
||||||
|
else if (first.TryGetProperty("output_text", out var outputText))
|
||||||
|
text = outputText.GetString();
|
||||||
|
}
|
||||||
|
else if (doc.RootElement.TryGetProperty("choices", out var ibmChoices) && ibmChoices.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var delta = ibmChoices[0].GetProperty("delta");
|
||||||
|
if (delta.TryGetProperty("content", out var c))
|
||||||
|
text = c.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var choices = doc.RootElement.GetProperty("choices");
|
var choices = doc.RootElement.GetProperty("choices");
|
||||||
if (choices.GetArrayLength() > 0)
|
if (choices.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
@@ -652,6 +810,7 @@ public partial class LlmService : IDisposable
|
|||||||
text = c.GetString();
|
text = c.GetString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
|
LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}");
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ namespace AxCopilot.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MarkdownRenderer
|
public static class MarkdownRenderer
|
||||||
{
|
{
|
||||||
|
private static readonly Brush FilePathBrush = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6));
|
||||||
|
private static readonly Brush CodeSymbolBrush = new SolidColorBrush(Color.FromRgb(0xF2, 0x8C, 0x79));
|
||||||
|
|
||||||
public static StackPanel Render(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
|
public static StackPanel Render(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
|
||||||
{
|
{
|
||||||
var panel = new StackPanel();
|
var panel = new StackPanel();
|
||||||
@@ -272,19 +275,34 @@ public static class MarkdownRenderer
|
|||||||
/// <summary>파일 경로 활성 여부 (설정 연동).</summary>
|
/// <summary>파일 경로 활성 여부 (설정 연동).</summary>
|
||||||
public static bool EnableFilePathHighlight { get; set; } = true;
|
public static bool EnableFilePathHighlight { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>코드 심볼 강조 활성 여부 (설정/탭 연동).</summary>
|
||||||
|
public static bool EnableCodeSymbolHighlight { get; set; } = true;
|
||||||
|
|
||||||
|
private static readonly Regex CodeSymbolPattern = new(
|
||||||
|
@"(?<![\w/\\-])(" +
|
||||||
|
@"[A-Za-z_][A-Za-z0-9_]*\(\)|" + // Method()
|
||||||
|
@"[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*|" + // object.member
|
||||||
|
@"[A-Za-z_][A-Za-z0-9_]*_[A-Za-z0-9_]+|" + // snake_case / foo_bar
|
||||||
|
@"[a-z]+[A-Z][A-Za-z0-9_]*|" + // camelCase
|
||||||
|
@"(?:[A-Z][a-z0-9]+){2,}" + // PascalCase multi word
|
||||||
|
@")(?![\w/\\-])",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
/// <summary>일반 텍스트에서 파일 경로 패턴을 감지하여 파란색으로 강조합니다.</summary>
|
/// <summary>일반 텍스트에서 파일 경로 패턴을 감지하여 파란색으로 강조합니다.</summary>
|
||||||
private static void AddPlainTextWithFilePaths(InlineCollection inlines, string text)
|
private static void AddPlainTextWithFilePaths(InlineCollection inlines, string text)
|
||||||
{
|
{
|
||||||
if (!EnableFilePathHighlight)
|
if (!EnableFilePathHighlight && !EnableCodeSymbolHighlight)
|
||||||
{
|
{
|
||||||
inlines.Add(new Run(text));
|
inlines.Add(new Run(text));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var matches = FilePathPattern.Matches(text);
|
MatchCollection? matches = null;
|
||||||
if (matches.Count == 0)
|
if (EnableFilePathHighlight)
|
||||||
|
matches = FilePathPattern.Matches(text);
|
||||||
|
if (matches == null || matches.Count == 0)
|
||||||
{
|
{
|
||||||
inlines.Add(new Run(text));
|
AddPlainTextWithCodeSymbols(inlines, text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,18 +311,54 @@ public static class MarkdownRenderer
|
|||||||
{
|
{
|
||||||
// 매치 전 텍스트
|
// 매치 전 텍스트
|
||||||
if (pm.Index > lastIndex)
|
if (pm.Index > lastIndex)
|
||||||
inlines.Add(new Run(text[lastIndex..pm.Index]));
|
AddPlainTextWithCodeSymbols(inlines, text[lastIndex..pm.Index]);
|
||||||
|
|
||||||
// 파일 경로 — 파란색 강조
|
// 파일 경로 — 파란색 강조
|
||||||
inlines.Add(new Run(pm.Value)
|
inlines.Add(new Run(pm.Value)
|
||||||
{
|
{
|
||||||
Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6)), // #3B82F6
|
Foreground = FilePathBrush,
|
||||||
FontWeight = FontWeights.Medium,
|
FontWeight = FontWeights.Medium,
|
||||||
});
|
});
|
||||||
lastIndex = pm.Index + pm.Length;
|
lastIndex = pm.Index + pm.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 남은 텍스트
|
// 남은 텍스트
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
AddPlainTextWithCodeSymbols(inlines, text[lastIndex..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPlainTextWithCodeSymbols(InlineCollection inlines, string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!EnableCodeSymbolHighlight)
|
||||||
|
{
|
||||||
|
inlines.Add(new Run(text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches = CodeSymbolPattern.Matches(text);
|
||||||
|
if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
inlines.Add(new Run(text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastIndex = 0;
|
||||||
|
foreach (Match match in matches)
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
inlines.Add(new Run(text[lastIndex..match.Index]));
|
||||||
|
|
||||||
|
inlines.Add(new Run(match.Value)
|
||||||
|
{
|
||||||
|
Foreground = CodeSymbolBrush,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
});
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
if (lastIndex < text.Length)
|
||||||
inlines.Add(new Run(text[lastIndex..]));
|
inlines.Add(new Run(text[lastIndex..]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.Linq;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using AxCopilot.Models;
|
using AxCopilot.Models;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace AxCopilot.Services;
|
namespace AxCopilot.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// L5-6: 자동화 스케줄 백그라운드 서비스.
|
/// 예약 작업을 백그라운드에서 점검하고 조건이 맞으면 액션을 실행합니다.
|
||||||
/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다.
|
/// 활성 일정이 없을 때는 타이머를 돌리지 않아 유휴 CPU 사용을 줄입니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SchedulerService : IDisposable
|
public sealed class SchedulerService : IDisposable
|
||||||
{
|
{
|
||||||
@@ -21,38 +20,58 @@ public sealed class SchedulerService : IDisposable
|
|||||||
_settings = settings;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 시작 / 중지 ─────────────────────────────────────────────────────
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
// 30초 간격 체크 (즉시 1회 실행 후)
|
if (_disposed || _timer != null) return;
|
||||||
|
|
||||||
_timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
|
_timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
|
||||||
LogService.Info("SchedulerService 시작");
|
LogService.Info("SchedulerService 시작");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
_timer?.Change(Timeout.Infinite, Timeout.Infinite);
|
if (_timer == null) return;
|
||||||
|
|
||||||
|
_timer.Dispose();
|
||||||
|
_timer = null;
|
||||||
LogService.Info("SchedulerService 중지");
|
LogService.Info("SchedulerService 중지");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
if (HasEnabledSchedules())
|
||||||
|
{
|
||||||
|
Start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_timer?.Dispose();
|
Stop();
|
||||||
_timer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 트리거 검사 ─────────────────────────────────────────────────────
|
|
||||||
private void OnTick(object? _)
|
private void OnTick(object? _)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!HasEnabledSchedules())
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var schedules = _settings.Settings.Schedules;
|
var schedules = _settings.Settings.Schedules;
|
||||||
bool dirty = false;
|
var dirty = false;
|
||||||
|
|
||||||
foreach (var entry in schedules.ToList()) // ToList: 반복 중 수정 방지
|
foreach (var entry in schedules.ToList())
|
||||||
{
|
{
|
||||||
if (!ShouldFire(entry, now)) continue;
|
if (!ShouldFire(entry, now)) continue;
|
||||||
|
|
||||||
@@ -62,12 +81,15 @@ public sealed class SchedulerService : IDisposable
|
|||||||
entry.LastRun = now;
|
entry.LastRun = now;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
|
|
||||||
// once 트리거는 실행 후 비활성화
|
|
||||||
if (entry.TriggerType == "once")
|
if (entry.TriggerType == "once")
|
||||||
entry.Enabled = false;
|
entry.Enabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirty) _settings.Save();
|
if (dirty)
|
||||||
|
{
|
||||||
|
_settings.Save();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -75,23 +97,25 @@ public sealed class SchedulerService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 트리거 조건 검사 ─────────────────────────────────────────────────
|
private bool HasEnabledSchedules() =>
|
||||||
|
_settings.Settings.Schedules.Any(entry => entry.Enabled);
|
||||||
|
|
||||||
private static bool ShouldFire(ScheduleEntry entry, DateTime now)
|
private static bool ShouldFire(ScheduleEntry entry, DateTime now)
|
||||||
{
|
{
|
||||||
if (!entry.Enabled) return false;
|
if (!entry.Enabled) return false;
|
||||||
if (!TimeSpan.TryParse(entry.TriggerTime, out var triggerTime)) return false;
|
if (!TimeSpan.TryParse(entry.TriggerTime, out var triggerTime)) return false;
|
||||||
|
|
||||||
// 트리거 시각과 ±1분 이내인지 확인
|
|
||||||
var targetDt = now.Date + triggerTime;
|
var targetDt = now.Date + triggerTime;
|
||||||
if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false;
|
if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false;
|
||||||
|
|
||||||
// 오늘 이미 실행했는지 확인 (once 제외)
|
|
||||||
if (entry.TriggerType != "once" &&
|
if (entry.TriggerType != "once" &&
|
||||||
entry.LastRun.HasValue &&
|
entry.LastRun.HasValue &&
|
||||||
entry.LastRun.Value.Date == now.Date)
|
entry.LastRun.Value.Date == now.Date)
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
bool typeMatch = entry.TriggerType switch
|
var typeMatch = entry.TriggerType switch
|
||||||
{
|
{
|
||||||
"daily" => true,
|
"daily" => true,
|
||||||
"weekdays" => now.DayOfWeek >= DayOfWeek.Monday &&
|
"weekdays" => now.DayOfWeek >= DayOfWeek.Monday &&
|
||||||
@@ -107,13 +131,12 @@ public sealed class SchedulerService : IDisposable
|
|||||||
|
|
||||||
if (!typeMatch) return false;
|
if (!typeMatch) return false;
|
||||||
|
|
||||||
// ─── L6-4: 프로세스 조건 검사 ─────────────────────────────────────
|
|
||||||
if (!string.IsNullOrWhiteSpace(entry.ConditionProcess))
|
if (!string.IsNullOrWhiteSpace(entry.ConditionProcess))
|
||||||
{
|
{
|
||||||
var procName = entry.ConditionProcess.Trim()
|
var procName = entry.ConditionProcess.Trim()
|
||||||
.Replace(".exe", "", StringComparison.OrdinalIgnoreCase);
|
.Replace(".exe", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
bool isRunning = Process.GetProcessesByName(procName).Length > 0;
|
var isRunning = Process.GetProcessesByName(procName).Length > 0;
|
||||||
|
|
||||||
if (entry.ConditionProcessMustRun && !isRunning) return false;
|
if (entry.ConditionProcessMustRun && !isRunning) return false;
|
||||||
if (!entry.ConditionProcessMustRun && isRunning) return false;
|
if (!entry.ConditionProcessMustRun && isRunning) return false;
|
||||||
@@ -122,7 +145,6 @@ public sealed class SchedulerService : IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 액션 실행 ────────────────────────────────────────────────────────
|
|
||||||
private static void ExecuteAction(ScheduleEntry entry)
|
private static void ExecuteAction(ScheduleEntry entry)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -131,12 +153,14 @@ public sealed class SchedulerService : IDisposable
|
|||||||
{
|
{
|
||||||
case "app":
|
case "app":
|
||||||
if (!string.IsNullOrWhiteSpace(entry.ActionTarget))
|
if (!string.IsNullOrWhiteSpace(entry.ActionTarget))
|
||||||
|
{
|
||||||
Process.Start(new ProcessStartInfo
|
Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = entry.ActionTarget,
|
FileName = entry.ActionTarget,
|
||||||
Arguments = entry.ActionArgs ?? "",
|
Arguments = entry.ActionArgs ?? "",
|
||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "notification":
|
case "notification":
|
||||||
@@ -144,7 +168,7 @@ public sealed class SchedulerService : IDisposable
|
|||||||
? entry.Name
|
? entry.Name
|
||||||
: entry.ActionTarget;
|
: entry.ActionTarget;
|
||||||
Application.Current?.Dispatcher.Invoke(() =>
|
Application.Current?.Dispatcher.Invoke(() =>
|
||||||
NotificationService.Notify($"[스케줄] {entry.Name}", msg));
|
NotificationService.Notify($"[일정] {entry.Name}", msg));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,9 +178,6 @@ public sealed class SchedulerService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 유틸리티 (핸들러·편집기에서 공유) ──────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>지정 스케줄의 다음 실행 예정 시각을 계산합니다.</summary>
|
|
||||||
public static DateTime? ComputeNextRun(ScheduleEntry entry)
|
public static DateTime? ComputeNextRun(ScheduleEntry entry)
|
||||||
{
|
{
|
||||||
if (!entry.Enabled) return null;
|
if (!entry.Enabled) return null;
|
||||||
@@ -187,16 +208,16 @@ public sealed class SchedulerService : IDisposable
|
|||||||
|
|
||||||
private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func<DateTime, bool> dayFilter)
|
private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func<DateTime, bool> dayFilter)
|
||||||
{
|
{
|
||||||
for (int i = 0; i <= 7; i++)
|
for (var i = 0; i <= 7; i++)
|
||||||
{
|
{
|
||||||
var candidate = now.Date.AddDays(i) + t;
|
var candidate = now.Date.AddDays(i) + t;
|
||||||
if (candidate > now && dayFilter(candidate))
|
if (candidate > now && dayFilter(candidate))
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>트리거 유형 표시 이름.</summary>
|
|
||||||
public static string TriggerLabel(ScheduleEntry e) => e.TriggerType switch
|
public static string TriggerLabel(ScheduleEntry e) => e.TriggerType switch
|
||||||
{
|
{
|
||||||
"daily" => "매일",
|
"daily" => "매일",
|
||||||
@@ -209,6 +230,7 @@ public sealed class SchedulerService : IDisposable
|
|||||||
private static readonly string[] DayShort = ["일", "월", "화", "수", "목", "금", "토"];
|
private static readonly string[] DayShort = ["일", "월", "화", "수", "목", "금", "토"];
|
||||||
|
|
||||||
private static string WeekDayLabel(List<int> days) =>
|
private static string WeekDayLabel(List<int> days) =>
|
||||||
days.Count == 0 ? "매주(요일 미지정)" :
|
days.Count == 0
|
||||||
"매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d]));
|
? "매주(요일 미지정)"
|
||||||
|
: "매주 " + string.Join(", ", days.OrderBy(d => d).Select(d => DayShort[d]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,10 +174,14 @@ public class SettingsService
|
|||||||
|
|
||||||
private void NormalizeRuntimeSettings()
|
private void NormalizeRuntimeSettings()
|
||||||
{
|
{
|
||||||
// AX Agent 사용 기본 정책: 항상 활성화.
|
var expressionLevel = (_settings.Llm.AgentUiExpressionLevel ?? "").Trim().ToLowerInvariant();
|
||||||
if (!_settings.AiEnabled)
|
_settings.Llm.AgentUiExpressionLevel = expressionLevel switch
|
||||||
_settings.AiEnabled = true;
|
{
|
||||||
|
"rich" => "rich",
|
||||||
|
"balanced" => "balanced",
|
||||||
|
"simple" => "simple",
|
||||||
|
_ => "balanced"
|
||||||
|
};
|
||||||
_settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.FilePermission);
|
_settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.FilePermission);
|
||||||
_settings.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.DefaultAgentPermission);
|
_settings.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.DefaultAgentPermission);
|
||||||
if (_settings.Llm.ToolPermissions != null && _settings.Llm.ToolPermissions.Count > 0)
|
if (_settings.Llm.ToolPermissions != null && _settings.Llm.ToolPermissions.Count > 0)
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#181614"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#30302E"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#201D1A"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#262624"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#2A2622"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#141413"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#302B27"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#343432"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#F3EEE9"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#FAF9F5"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#C4BAB0"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#C2C0B6"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#9C9187"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#8F8D84"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#D07A47"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#D97757"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#403932"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#4C4A45"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#24201C"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#393836"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#E6B48B"/>
|
<SolidColorBrush x:Key="HintText" Color="#E0B089"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#433C35"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#595651"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#665B52"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#9B7558"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#716C64"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#F7F6F3"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#F2EBDD"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#EEE9E2"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E8DEC9"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F2EFEA"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECE4D3"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#1D1B18"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#141413"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#6B645D"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#3D3D3A"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#8B837B"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#73726C"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#E4DED6"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#E3E0D7"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#F3EEE8"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#F8F3EA"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#8B5637"/>
|
<SolidColorBrush x:Key="HintText" Color="#8B5637"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#DDD6CE"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#DDD9D0"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C7BDB2"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#CFB79C"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C4BEB2"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#F7F6F3"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#FFFFFF"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#F2EBDD"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#EEE9E2"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E8DEC9"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F2EFEA"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECE4D3"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#1D1B18"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#141413"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#6B645D"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#3D3D3A"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#8B837B"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#73726C"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#C96A36"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#E4DED6"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#E3E0D7"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#F3EEE8"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#F8F3EA"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#8B5637"/>
|
<SolidColorBrush x:Key="HintText" Color="#8B5637"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#DDD6CE"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#DDD9D0"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C7BDB2"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#CFB79C"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#C4BEB2"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#0B1020"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#2A2B2F"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#111827"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#23252A"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#18233B"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#14161A"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#132033"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#31343B"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#E5E7EB"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#EFF1F5"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#94A3B8"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#A9B1BE"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#64748B"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#7B8390"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#60A5FA"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#9AA9C2"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#243041"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#40444C"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#0F172A"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#282B31"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#93C5FD"/>
|
<SolidColorBrush x:Key="HintText" Color="#C4CFDF"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#2A3950"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#4C515B"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#475569"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#7A8598"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#636A76"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#FCFCFD"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#F3F4F6"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#FCFCFD"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#EEF2FF"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E4E7EC"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F5F7FB"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECEFF3"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#111827"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#181A1F"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#667085"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#5F6876"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#98A2B3"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#87909D"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#2563EB"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#54657F"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#E4E7EC"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#D7DCE4"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#F8FAFC"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#EEF2F6"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#1D4ED8"/>
|
<SolidColorBrush x:Key="HintText" Color="#43546C"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#E4E7EC"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#D2D8E1"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B8C0CC"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#A7B0BE"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#AAB3C0"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#F3F4F6"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#FCFCFD"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E9EEF9"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E4E7EC"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F2F6FB"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#ECEFF3"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#181A1F"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#64748B"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#5F6876"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#94A3B8"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#87909D"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#3B82F6"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#54657F"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#D9E2EC"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#D7DCE4"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#EFF6FF"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#EEF2F6"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#1D4ED8"/>
|
<SolidColorBrush x:Key="HintText" Color="#43546C"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#D9E2EC"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#D2D8E1"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#A7B3C3"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#A7B0BE"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#AAB3C0"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
18
src/AxCopilot/Themes/AgentEmberDark.xaml
Normal file
18
src/AxCopilot/Themes/AgentEmberDark.xaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<SolidColorBrush x:Key="LauncherBackground" Color="#1B1510"/>
|
||||||
|
<SolidColorBrush x:Key="ItemBackground" Color="#241C15"/>
|
||||||
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#32261D"/>
|
||||||
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#3A2C21"/>
|
||||||
|
<SolidColorBrush x:Key="PrimaryText" Color="#F6EEE6"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryText" Color="#D0B9A6"/>
|
||||||
|
<SolidColorBrush x:Key="PlaceholderText" Color="#A68F7E"/>
|
||||||
|
<SolidColorBrush x:Key="AccentColor" Color="#F29A54"/>
|
||||||
|
<SolidColorBrush x:Key="SeparatorColor" Color="#4A382B"/>
|
||||||
|
<SolidColorBrush x:Key="HintBackground" Color="#2B2119"/>
|
||||||
|
<SolidColorBrush x:Key="HintText" Color="#F6C08A"/>
|
||||||
|
<SolidColorBrush x:Key="BorderColor" Color="#4C392B"/>
|
||||||
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#C47E46"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#755B48"/>
|
||||||
|
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||||
|
</ResourceDictionary>
|
||||||
18
src/AxCopilot/Themes/AgentEmberLight.xaml
Normal file
18
src/AxCopilot/Themes/AgentEmberLight.xaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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="ItemSelectedBackground" Color="#F3E5D5"/>
|
||||||
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/>
|
||||||
|
<SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryText" Color="#7A6758"/>
|
||||||
|
<SolidColorBrush x:Key="PlaceholderText" Color="#A08D7D"/>
|
||||||
|
<SolidColorBrush x:Key="AccentColor" Color="#C96B2C"/>
|
||||||
|
<SolidColorBrush x:Key="SeparatorColor" Color="#E7D8C9"/>
|
||||||
|
<SolidColorBrush x:Key="HintBackground" Color="#F7EBDD"/>
|
||||||
|
<SolidColorBrush x:Key="HintText" Color="#9A531E"/>
|
||||||
|
<SolidColorBrush x:Key="BorderColor" Color="#E4D4C4"/>
|
||||||
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#D89B62"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#CDB8A6"/>
|
||||||
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
|
</ResourceDictionary>
|
||||||
18
src/AxCopilot/Themes/AgentEmberSystem.xaml
Normal file
18
src/AxCopilot/Themes/AgentEmberSystem.xaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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="ItemSelectedBackground" Color="#F3E5D5"/>
|
||||||
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#F6ECE0"/>
|
||||||
|
<SolidColorBrush x:Key="PrimaryText" Color="#2B2118"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryText" Color="#7A6758"/>
|
||||||
|
<SolidColorBrush x:Key="PlaceholderText" Color="#A08D7D"/>
|
||||||
|
<SolidColorBrush x:Key="AccentColor" Color="#C96B2C"/>
|
||||||
|
<SolidColorBrush x:Key="SeparatorColor" Color="#E7D8C9"/>
|
||||||
|
<SolidColorBrush x:Key="HintBackground" Color="#F7EBDD"/>
|
||||||
|
<SolidColorBrush x:Key="HintText" Color="#9A531E"/>
|
||||||
|
<SolidColorBrush x:Key="BorderColor" Color="#E4D4C4"/>
|
||||||
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#D89B62"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#CDB8A6"/>
|
||||||
|
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
|
||||||
|
</ResourceDictionary>
|
||||||
18
src/AxCopilot/Themes/AgentNordDark.xaml
Normal file
18
src/AxCopilot/Themes/AgentNordDark.xaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<SolidColorBrush x:Key="LauncherBackground" Color="#2E3440"/>
|
||||||
|
<SolidColorBrush x:Key="ItemBackground" Color="#3B4252"/>
|
||||||
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#434C5E"/>
|
||||||
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#4C566A"/>
|
||||||
|
<SolidColorBrush x:Key="PrimaryText" Color="#ECEFF4"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryText" Color="#D8DEE9"/>
|
||||||
|
<SolidColorBrush x:Key="PlaceholderText" Color="#A7B1C2"/>
|
||||||
|
<SolidColorBrush x:Key="AccentColor" Color="#88C0D0"/>
|
||||||
|
<SolidColorBrush x:Key="SeparatorColor" Color="#4C566A"/>
|
||||||
|
<SolidColorBrush x:Key="HintBackground" Color="#3B4252"/>
|
||||||
|
<SolidColorBrush x:Key="HintText" Color="#81A1C1"/>
|
||||||
|
<SolidColorBrush x:Key="BorderColor" Color="#4C566A"/>
|
||||||
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#81A1C1"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#66738A"/>
|
||||||
|
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||||
|
</ResourceDictionary>
|
||||||
18
src/AxCopilot/Themes/AgentNordLight.xaml
Normal file
18
src/AxCopilot/Themes/AgentNordLight.xaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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="ItemSelectedBackground" Color="#E5E9F0"/>
|
||||||
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#E5E9F0"/>
|
||||||
|
<SolidColorBrush x:Key="PrimaryText" Color="#2E3440"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryText" Color="#4C566A"/>
|
||||||
|
<SolidColorBrush x:Key="PlaceholderText" Color="#6B7280"/>
|
||||||
|
<SolidColorBrush x:Key="AccentColor" Color="#5E81AC"/>
|
||||||
|
<SolidColorBrush x:Key="SeparatorColor" Color="#D8DEE9"/>
|
||||||
|
<SolidColorBrush x:Key="HintBackground" Color="#E5E9F0"/>
|
||||||
|
<SolidColorBrush x:Key="HintText" Color="#5E81AC"/>
|
||||||
|
<SolidColorBrush x:Key="BorderColor" Color="#D8DEE9"/>
|
||||||
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#81A1C1"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B0BAC8"/>
|
||||||
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
|
</ResourceDictionary>
|
||||||
18
src/AxCopilot/Themes/AgentNordSystem.xaml
Normal file
18
src/AxCopilot/Themes/AgentNordSystem.xaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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="ItemSelectedBackground" Color="#E5E9F0"/>
|
||||||
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#E5E9F0"/>
|
||||||
|
<SolidColorBrush x:Key="PrimaryText" Color="#2E3440"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryText" Color="#4C566A"/>
|
||||||
|
<SolidColorBrush x:Key="PlaceholderText" Color="#6B7280"/>
|
||||||
|
<SolidColorBrush x:Key="AccentColor" Color="#5E81AC"/>
|
||||||
|
<SolidColorBrush x:Key="SeparatorColor" Color="#D8DEE9"/>
|
||||||
|
<SolidColorBrush x:Key="HintBackground" Color="#E5E9F0"/>
|
||||||
|
<SolidColorBrush x:Key="HintText" Color="#5E81AC"/>
|
||||||
|
<SolidColorBrush x:Key="BorderColor" Color="#D8DEE9"/>
|
||||||
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#81A1C1"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B0BAC8"/>
|
||||||
|
<SolidColorBrush x:Key="ShadowColor" Color="#26000000"/>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#10141F"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#0F172A"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#161C2B"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#111827"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#20283B"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#1E293B"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#1C2436"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#243041"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#E6EAF2"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#E2E8F0"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#9AA4B2"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#94A3B8"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#6B7280"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#64748B"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#818CF8"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#818CF8"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#2A3448"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#334155"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#161E33"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#172036"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#A5B4FC"/>
|
<SolidColorBrush x:Key="HintText" Color="#A5B4FC"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#2E3950"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#334155"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#4B5563"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#64748B"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#475569"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#99000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#F6F7FB"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E8ECF7"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F8"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F7"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#172033"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#667085"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#475569"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#98A2B3"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#64748B"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#4F46E5"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#6366F1"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#D8DFEA"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#CBD5E1"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#EEF2FF"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#EEF2FF"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#4338CA"/>
|
<SolidColorBrush x:Key="HintText" Color="#4F46E5"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#D8DFEA"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#CBD5E1"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#B3BDD0"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#94A3B8"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#94A3B8"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<SolidColorBrush x:Key="LauncherBackground" Color="#EDF1F7"/>
|
<SolidColorBrush x:Key="LauncherBackground" Color="#F8FAFC"/>
|
||||||
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
<SolidColorBrush x:Key="ItemBackground" Color="#FFFFFF"/>
|
||||||
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E3E8F5"/>
|
<SolidColorBrush x:Key="ItemSelectedBackground" Color="#E2E8F0"/>
|
||||||
<SolidColorBrush x:Key="ItemHoverBackground" Color="#E9EEF8"/>
|
<SolidColorBrush x:Key="ItemHoverBackground" Color="#EEF2F7"/>
|
||||||
<SolidColorBrush x:Key="PrimaryText" Color="#182132"/>
|
<SolidColorBrush x:Key="PrimaryText" Color="#0F172A"/>
|
||||||
<SolidColorBrush x:Key="SecondaryText" Color="#637083"/>
|
<SolidColorBrush x:Key="SecondaryText" Color="#475569"/>
|
||||||
<SolidColorBrush x:Key="PlaceholderText" Color="#8A96A8"/>
|
<SolidColorBrush x:Key="PlaceholderText" Color="#64748B"/>
|
||||||
<SolidColorBrush x:Key="AccentColor" Color="#6366F1"/>
|
<SolidColorBrush x:Key="AccentColor" Color="#6366F1"/>
|
||||||
<SolidColorBrush x:Key="SeparatorColor" Color="#D5DCEC"/>
|
<SolidColorBrush x:Key="SeparatorColor" Color="#CBD5E1"/>
|
||||||
<SolidColorBrush x:Key="HintBackground" Color="#EDE9FE"/>
|
<SolidColorBrush x:Key="HintBackground" Color="#EEF2FF"/>
|
||||||
<SolidColorBrush x:Key="HintText" Color="#4C1D95"/>
|
<SolidColorBrush x:Key="HintText" Color="#4F46E5"/>
|
||||||
<SolidColorBrush x:Key="BorderColor" Color="#D5DCEC"/>
|
<SolidColorBrush x:Key="BorderColor" Color="#CBD5E1"/>
|
||||||
<SolidColorBrush x:Key="ScrollbarThumb" Color="#AFB8CA"/>
|
<SolidColorBrush x:Key="InputFocusBorderColor" Color="#94A3B8"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollbarThumb" Color="#94A3B8"/>
|
||||||
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
<SolidColorBrush x:Key="ShadowColor" Color="#22000000"/>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ public partial class LauncherViewModel
|
|||||||
private CancellationTokenSource? _previewCts;
|
private CancellationTokenSource? _previewCts;
|
||||||
|
|
||||||
public ObservableCollection<QuickActionChip> QuickActionItems { get; } = new();
|
public ObservableCollection<QuickActionChip> QuickActionItems { get; } = new();
|
||||||
public bool ShowQuickActions => string.IsNullOrEmpty(_inputText) && QuickActionItems.Count > 0;
|
public bool ShowQuickActions =>
|
||||||
|
_settings.Settings.Launcher.ShowLauncherBottomQuickActions &&
|
||||||
|
string.IsNullOrEmpty(_inputText) &&
|
||||||
|
QuickActionItems.Count > 0;
|
||||||
|
|
||||||
public string PreviewText
|
public string PreviewText
|
||||||
{
|
{
|
||||||
@@ -81,6 +84,12 @@ public partial class LauncherViewModel
|
|||||||
{
|
{
|
||||||
QuickActionItems.Clear();
|
QuickActionItems.Clear();
|
||||||
|
|
||||||
|
if (!_settings.Settings.Launcher.ShowLauncherBottomQuickActions)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowQuickActions));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var topItems = UsageRankingService.GetTopItems(16);
|
var topItems = UsageRankingService.GetTopItems(16);
|
||||||
var added = 0;
|
var added = 0;
|
||||||
foreach (var (path, _) in topItems)
|
foreach (var (path, _) in topItems)
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
|||||||
|
|
||||||
private async Task SearchAsync(string query)
|
private async Task SearchAsync(string query)
|
||||||
{
|
{
|
||||||
|
(System.Windows.Application.Current as App)?.EnsureIndexWarmupStarted();
|
||||||
|
|
||||||
// CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급.
|
// CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급.
|
||||||
_searchCts = new CancellationTokenSource();
|
_searchCts = new CancellationTokenSource();
|
||||||
var ct = _searchCts.Token;
|
var ct = _searchCts.Token;
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
private bool _aiEnabled = true;
|
private bool _aiEnabled = true;
|
||||||
private string _operationMode = "internal";
|
private string _operationMode = "internal";
|
||||||
private string _agentTheme = "system";
|
private string _agentTheme = "system";
|
||||||
private string _agentThemePreset = "claw";
|
private string _agentThemePreset = "claude";
|
||||||
|
|
||||||
// 기능 토글
|
// 기능 토글
|
||||||
private bool _showNumberBadges;
|
private bool _showNumberBadges;
|
||||||
@@ -173,6 +173,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
private bool _showWidgetWeather;
|
private bool _showWidgetWeather;
|
||||||
private bool _showWidgetCalendar;
|
private bool _showWidgetCalendar;
|
||||||
private bool _showWidgetBattery;
|
private bool _showWidgetBattery;
|
||||||
|
private bool _showLauncherBottomQuickActions;
|
||||||
private bool _shortcutHelpUseThemeColor;
|
private bool _shortcutHelpUseThemeColor;
|
||||||
|
|
||||||
// LLM 공통 설정
|
// LLM 공통 설정
|
||||||
@@ -427,6 +428,13 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
set { _maxMemoryEntries = value; OnPropertyChanged(); }
|
set { _maxMemoryEntries = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _allowExternalMemoryIncludes;
|
||||||
|
public bool AllowExternalMemoryIncludes
|
||||||
|
{
|
||||||
|
get => _allowExternalMemoryIncludes;
|
||||||
|
set { _allowExternalMemoryIncludes = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── 이미지 입력 (멀티모달) ──
|
// ── 이미지 입력 (멀티모달) ──
|
||||||
private bool _enableImageInput = true;
|
private bool _enableImageInput = true;
|
||||||
public bool EnableImageInput
|
public bool EnableImageInput
|
||||||
@@ -713,7 +721,12 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
public string AgentThemePreset
|
public string AgentThemePreset
|
||||||
{
|
{
|
||||||
get => _agentThemePreset;
|
get => _agentThemePreset;
|
||||||
set { _agentThemePreset = string.IsNullOrWhiteSpace(value) ? "claw" : value.Trim().ToLowerInvariant(); OnPropertyChanged(); }
|
set
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(value) ? "claude" : value.Trim().ToLowerInvariant();
|
||||||
|
_agentThemePreset = normalized == "claw" ? "claude" : normalized;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string IndexSpeedHint => _indexSpeed switch
|
public string IndexSpeedHint => _indexSpeed switch
|
||||||
@@ -803,6 +816,12 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
set { _showWidgetBattery = value; OnPropertyChanged(); }
|
set { _showWidgetBattery = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool ShowLauncherBottomQuickActions
|
||||||
|
{
|
||||||
|
get => _showLauncherBottomQuickActions;
|
||||||
|
set { _showLauncherBottomQuickActions = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
public bool EnableIconAnimation
|
public bool EnableIconAnimation
|
||||||
{
|
{
|
||||||
get => _enableIconAnimation;
|
get => _enableIconAnimation;
|
||||||
@@ -835,7 +854,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
|
|
||||||
// ─── v1.4.0 신기능 설정 ──────────────────────────────────────────────────
|
// ─── v1.4.0 신기능 설정 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private bool _enableTextAction = true;
|
private bool _enableTextAction = false;
|
||||||
public bool EnableTextAction
|
public bool EnableTextAction
|
||||||
{
|
{
|
||||||
get => _enableTextAction;
|
get => _enableTextAction;
|
||||||
@@ -1085,6 +1104,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
_showWidgetWeather = s.Launcher.ShowWidgetWeather;
|
_showWidgetWeather = s.Launcher.ShowWidgetWeather;
|
||||||
_showWidgetCalendar = s.Launcher.ShowWidgetCalendar;
|
_showWidgetCalendar = s.Launcher.ShowWidgetCalendar;
|
||||||
_showWidgetBattery = s.Launcher.ShowWidgetBattery;
|
_showWidgetBattery = s.Launcher.ShowWidgetBattery;
|
||||||
|
_showLauncherBottomQuickActions = s.Launcher.ShowLauncherBottomQuickActions;
|
||||||
_shortcutHelpUseThemeColor = s.Launcher.ShortcutHelpUseThemeColor;
|
_shortcutHelpUseThemeColor = s.Launcher.ShortcutHelpUseThemeColor;
|
||||||
_enableTextAction = s.Launcher.EnableTextAction;
|
_enableTextAction = s.Launcher.EnableTextAction;
|
||||||
// v1.7.1: 파일 대화상자 통합 기본값을 false로 변경 (브라우저 업로드 오작동 방지)
|
// v1.7.1: 파일 대화상자 통합 기본값을 false로 변경 (브라우저 업로드 오작동 방지)
|
||||||
@@ -1118,7 +1138,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
_aiEnabled = _service.Settings.AiEnabled;
|
_aiEnabled = _service.Settings.AiEnabled;
|
||||||
_operationMode = string.IsNullOrWhiteSpace(_service.Settings.OperationMode) ? "internal" : _service.Settings.OperationMode;
|
_operationMode = string.IsNullOrWhiteSpace(_service.Settings.OperationMode) ? "internal" : _service.Settings.OperationMode;
|
||||||
_agentTheme = string.IsNullOrWhiteSpace(llm.AgentTheme) ? "system" : llm.AgentTheme;
|
_agentTheme = string.IsNullOrWhiteSpace(llm.AgentTheme) ? "system" : llm.AgentTheme;
|
||||||
_agentThemePreset = string.IsNullOrWhiteSpace(llm.AgentThemePreset) ? "claw" : llm.AgentThemePreset;
|
_agentThemePreset = string.IsNullOrWhiteSpace(llm.AgentThemePreset) ? "claude" : (llm.AgentThemePreset.Trim().ToLowerInvariant() == "claw" ? "claude" : llm.AgentThemePreset);
|
||||||
_agentLogLevel = llm.AgentLogLevel;
|
_agentLogLevel = llm.AgentLogLevel;
|
||||||
_agentUiExpressionLevel = (llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant() switch
|
_agentUiExpressionLevel = (llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
@@ -1139,6 +1159,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
_enableAgentMemory = llm.EnableAgentMemory;
|
_enableAgentMemory = llm.EnableAgentMemory;
|
||||||
_enableProjectRules = llm.EnableProjectRules;
|
_enableProjectRules = llm.EnableProjectRules;
|
||||||
_maxMemoryEntries = llm.MaxMemoryEntries;
|
_maxMemoryEntries = llm.MaxMemoryEntries;
|
||||||
|
_allowExternalMemoryIncludes = llm.AllowExternalMemoryIncludes;
|
||||||
_enableImageInput = llm.EnableImageInput;
|
_enableImageInput = llm.EnableImageInput;
|
||||||
_maxImageSizeKb = llm.MaxImageSizeKb > 0 ? llm.MaxImageSizeKb : 5120;
|
_maxImageSizeKb = llm.MaxImageSizeKb > 0 ? llm.MaxImageSizeKb : 5120;
|
||||||
_enableToolHooks = llm.EnableToolHooks;
|
_enableToolHooks = llm.EnableToolHooks;
|
||||||
@@ -1537,6 +1558,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
s.Launcher.ShowWidgetWeather = _showWidgetWeather;
|
s.Launcher.ShowWidgetWeather = _showWidgetWeather;
|
||||||
s.Launcher.ShowWidgetCalendar = _showWidgetCalendar;
|
s.Launcher.ShowWidgetCalendar = _showWidgetCalendar;
|
||||||
s.Launcher.ShowWidgetBattery = _showWidgetBattery;
|
s.Launcher.ShowWidgetBattery = _showWidgetBattery;
|
||||||
|
s.Launcher.ShowLauncherBottomQuickActions = _showLauncherBottomQuickActions;
|
||||||
s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor;
|
s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor;
|
||||||
s.Launcher.EnableTextAction = _enableTextAction;
|
s.Launcher.EnableTextAction = _enableTextAction;
|
||||||
s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration;
|
s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration;
|
||||||
@@ -1580,6 +1602,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
|||||||
s.Llm.EnableAgentMemory = _enableAgentMemory;
|
s.Llm.EnableAgentMemory = _enableAgentMemory;
|
||||||
s.Llm.EnableProjectRules = _enableProjectRules;
|
s.Llm.EnableProjectRules = _enableProjectRules;
|
||||||
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
|
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
|
||||||
|
s.Llm.AllowExternalMemoryIncludes = _allowExternalMemoryIncludes;
|
||||||
s.Llm.EnableImageInput = _enableImageInput;
|
s.Llm.EnableImageInput = _enableImageInput;
|
||||||
s.Llm.MaxImageSizeKb = _maxImageSizeKb;
|
s.Llm.MaxImageSizeKb = _maxImageSizeKb;
|
||||||
s.Llm.EnableToolHooks = _enableToolHooks;
|
s.Llm.EnableToolHooks = _enableToolHooks;
|
||||||
@@ -2028,7 +2051,7 @@ public class RegisteredModelRow : INotifyPropertyChanged
|
|||||||
private string _cp4dUsername = "";
|
private string _cp4dUsername = "";
|
||||||
private string _cp4dPassword = "";
|
private string _cp4dPassword = "";
|
||||||
|
|
||||||
/// <summary>인증 방식. bearer | cp4d</summary>
|
/// <summary>인증 방식. bearer | ibm_iam | cp4d_password | cp4d_api_key</summary>
|
||||||
public string AuthType
|
public string AuthType
|
||||||
{
|
{
|
||||||
get => _authType;
|
get => _authType;
|
||||||
@@ -2057,7 +2080,14 @@ public class RegisteredModelRow : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>인증 방식 라벨</summary>
|
/// <summary>인증 방식 라벨</summary>
|
||||||
public string AuthLabel => _authType == "cp4d" ? "CP4D" : "Bearer";
|
public string AuthLabel => (_authType ?? "bearer").ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"cp4d" => "CP4D 비밀번호",
|
||||||
|
"cp4d_password" => "CP4D 비밀번호",
|
||||||
|
"cp4d_api_key" => "CP4D API 키",
|
||||||
|
"ibm_iam" => "IBM IAM",
|
||||||
|
_ => "Bearer",
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>UI에 표시할 엔드포인트 요약</summary>
|
/// <summary>UI에 표시할 엔드포인트 요약</summary>
|
||||||
public string EndpointDisplay => string.IsNullOrEmpty(_endpoint) ? "(기본 서버)" : _endpoint;
|
public string EndpointDisplay => string.IsNullOrEmpty(_endpoint) ? "(기본 서버)" : _endpoint;
|
||||||
|
|||||||
@@ -391,7 +391,7 @@
|
|||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Style="{StaticResource ToggleSwitch}"/>
|
Style="{StaticResource ToggleSwitch}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid Margin="0,8,0,0">
|
<Grid Margin="0,8,0,0" Visibility="Collapsed">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
@@ -533,32 +533,6 @@
|
|||||||
Style="{StaticResource OutlineHoverBtn}"
|
Style="{StaticResource OutlineHoverBtn}"
|
||||||
Click="BtnReasoningMode_Click"/>
|
Click="BtnReasoningMode_Click"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid Margin="0,8,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<TextBlock Text="폴더 데이터 활용"
|
|
||||||
Foreground="{DynamicResource PrimaryText}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
|
||||||
<Border.ToolTip>
|
|
||||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
|
||||||
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18" MaxWidth="320">
|
|
||||||
현재 작업 폴더 파일과 문맥을 AI가 얼마나 적극적으로 참고할지 정합니다.
|
|
||||||
</TextBlock>
|
|
||||||
</ToolTip>
|
|
||||||
</Border.ToolTip>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
<Button x:Name="BtnFolderDataUsage"
|
|
||||||
Grid.Column="1"
|
|
||||||
MinWidth="120"
|
|
||||||
Style="{StaticResource OutlineHoverBtn}"
|
|
||||||
Click="BtnFolderDataUsage_Click"/>
|
|
||||||
</Grid>
|
|
||||||
<Grid Margin="0,8,0,0">
|
<Grid Margin="0,8,0,0">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ public partial class AgentSettingsWindow : Window
|
|||||||
private readonly LlmSettings _llm;
|
private readonly LlmSettings _llm;
|
||||||
private string _permissionMode = PermissionModeCatalog.Deny;
|
private string _permissionMode = PermissionModeCatalog.Deny;
|
||||||
private string _reasoningMode = "detailed";
|
private string _reasoningMode = "detailed";
|
||||||
private string _folderDataUsage = "active";
|
|
||||||
private string _operationMode = OperationModePolicy.InternalMode;
|
private string _operationMode = OperationModePolicy.InternalMode;
|
||||||
private string _displayMode = "rich";
|
private string _displayMode = "rich";
|
||||||
private string _defaultOutputFormat = "auto";
|
private string _defaultOutputFormat = "auto";
|
||||||
@@ -48,7 +47,6 @@ public partial class AgentSettingsWindow : Window
|
|||||||
ModelInput.Text = _selectedModel;
|
ModelInput.Text = _selectedModel;
|
||||||
_permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_llm.FilePermission);
|
_permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_llm.FilePermission);
|
||||||
_reasoningMode = string.IsNullOrWhiteSpace(_llm.AgentDecisionLevel) ? "detailed" : _llm.AgentDecisionLevel;
|
_reasoningMode = string.IsNullOrWhiteSpace(_llm.AgentDecisionLevel) ? "detailed" : _llm.AgentDecisionLevel;
|
||||||
_folderDataUsage = string.IsNullOrWhiteSpace(_llm.FolderDataUsage) ? "active" : _llm.FolderDataUsage;
|
|
||||||
_operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
_operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||||||
_displayMode = "rich";
|
_displayMode = "rich";
|
||||||
_defaultOutputFormat = string.IsNullOrWhiteSpace(_llm.DefaultOutputFormat) ? "auto" : _llm.DefaultOutputFormat;
|
_defaultOutputFormat = string.IsNullOrWhiteSpace(_llm.DefaultOutputFormat) ? "auto" : _llm.DefaultOutputFormat;
|
||||||
@@ -114,7 +112,6 @@ public partial class AgentSettingsWindow : Window
|
|||||||
BtnOperationMode.Content = BuildOperationModeLabel(_operationMode);
|
BtnOperationMode.Content = BuildOperationModeLabel(_operationMode);
|
||||||
BtnPermissionMode.Content = PermissionModeCatalog.ToDisplayLabel(_permissionMode);
|
BtnPermissionMode.Content = PermissionModeCatalog.ToDisplayLabel(_permissionMode);
|
||||||
BtnReasoningMode.Content = BuildReasoningModeLabel(_reasoningMode);
|
BtnReasoningMode.Content = BuildReasoningModeLabel(_reasoningMode);
|
||||||
BtnFolderDataUsage.Content = BuildFolderDataUsageLabel(_folderDataUsage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshDisplayModeCards()
|
private void RefreshDisplayModeCards()
|
||||||
@@ -173,16 +170,6 @@ public partial class AgentSettingsWindow : Window
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildFolderDataUsageLabel(string mode)
|
|
||||||
{
|
|
||||||
return (mode ?? "none").ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"active" => "적극 활용",
|
|
||||||
"passive" => "소극 활용",
|
|
||||||
_ => "활용하지 않음",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildOutputFormatLabel(string format)
|
private static string BuildOutputFormatLabel(string format)
|
||||||
{
|
{
|
||||||
return (format ?? "auto").ToLowerInvariant() switch
|
return (format ?? "auto").ToLowerInvariant() switch
|
||||||
@@ -469,17 +456,6 @@ public partial class AgentSettingsWindow : Window
|
|||||||
RefreshModeLabels();
|
RefreshModeLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BtnFolderDataUsage_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_folderDataUsage = _folderDataUsage switch
|
|
||||||
{
|
|
||||||
"none" => "passive",
|
|
||||||
"passive" => "active",
|
|
||||||
_ => "none",
|
|
||||||
};
|
|
||||||
RefreshModeLabels();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BtnDefaultOutputFormat_Click(object sender, RoutedEventArgs e)
|
private void BtnDefaultOutputFormat_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_defaultOutputFormat = (_defaultOutputFormat ?? "auto").ToLowerInvariant() switch
|
_defaultOutputFormat = (_defaultOutputFormat ?? "auto").ToLowerInvariant() switch
|
||||||
@@ -514,7 +490,6 @@ public partial class AgentSettingsWindow : Window
|
|||||||
_llm.FilePermission = _permissionMode;
|
_llm.FilePermission = _permissionMode;
|
||||||
_llm.DefaultAgentPermission = _permissionMode;
|
_llm.DefaultAgentPermission = _permissionMode;
|
||||||
_llm.AgentDecisionLevel = _reasoningMode;
|
_llm.AgentDecisionLevel = _reasoningMode;
|
||||||
_llm.FolderDataUsage = _folderDataUsage;
|
|
||||||
_llm.AgentUiExpressionLevel = "rich";
|
_llm.AgentUiExpressionLevel = "rich";
|
||||||
_llm.DefaultOutputFormat = _defaultOutputFormat;
|
_llm.DefaultOutputFormat = _defaultOutputFormat;
|
||||||
_llm.DefaultMood = _defaultMood;
|
_llm.DefaultMood = _defaultMood;
|
||||||
|
|||||||
1266
src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs
Normal file
1266
src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs
Normal file
File diff suppressed because it is too large
Load Diff
624
src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs
Normal file
624
src/AxCopilot/Views/ChatWindow.ComposerQueuePresentation.cs
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void UpdateInputBoxHeight()
|
||||||
|
{
|
||||||
|
if (InputBox == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = InputBox.Text ?? string.Empty;
|
||||||
|
var explicitLineCount = 1 + text.Count(ch => ch == '\n');
|
||||||
|
var displayMode = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant();
|
||||||
|
if (displayMode is not ("rich" or "balanced" or "simple"))
|
||||||
|
displayMode = "balanced";
|
||||||
|
|
||||||
|
var maxLines = displayMode switch
|
||||||
|
{
|
||||||
|
"rich" => 6,
|
||||||
|
"simple" => 4,
|
||||||
|
_ => 5,
|
||||||
|
};
|
||||||
|
const double baseHeight = 42;
|
||||||
|
const double lineStep = 22;
|
||||||
|
var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines);
|
||||||
|
var targetHeight = baseHeight + ((visibleLines - 1) * lineStep);
|
||||||
|
|
||||||
|
InputBox.MinLines = 1;
|
||||||
|
InputBox.MaxLines = maxLines;
|
||||||
|
InputBox.Height = targetHeight;
|
||||||
|
InputBox.VerticalScrollBarVisibility = explicitLineCount > maxLines
|
||||||
|
? ScrollBarVisibility.Auto
|
||||||
|
: ScrollBarVisibility.Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildComposerDraftText()
|
||||||
|
{
|
||||||
|
var rawText = InputBox?.Text?.Trim() ?? "";
|
||||||
|
return _slashPalette.ActiveCommand != null
|
||||||
|
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
|
||||||
|
: rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string InferDraftKind(string text, string? explicitKind = null)
|
||||||
|
{
|
||||||
|
var trimmed = text?.Trim() ?? "";
|
||||||
|
var requestedKind = explicitKind?.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (requestedKind is "followup" or "steering")
|
||||||
|
return requestedKind;
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "command";
|
||||||
|
|
||||||
|
if (requestedKind is "direct" or "message")
|
||||||
|
return requestedKind;
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("steer:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
trimmed.StartsWith("@steer ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
trimmed.StartsWith("조정:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "steering";
|
||||||
|
|
||||||
|
return "message";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueComposerDraft(string priority = "next", string? explicitKind = null, bool startImmediatelyWhenIdle = true)
|
||||||
|
{
|
||||||
|
if (InputBox == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = BuildComposerDraftText();
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_isStreaming && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase))
|
||||||
|
priority = "next";
|
||||||
|
|
||||||
|
HideSlashChip(restoreText: false);
|
||||||
|
ClearPromptCardPlaceholder();
|
||||||
|
|
||||||
|
var queuedItem = EnqueueDraftRequest(text, priority, explicitKind);
|
||||||
|
|
||||||
|
InputBox.Clear();
|
||||||
|
InputBox.Focus();
|
||||||
|
UpdateInputBoxHeight();
|
||||||
|
RefreshDraftQueueUi();
|
||||||
|
|
||||||
|
if (queuedItem == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_isStreaming && startImmediatelyWhenIdle)
|
||||||
|
{
|
||||||
|
StartNextQueuedDraftIfAny(queuedItem.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toast = queuedItem.Kind switch
|
||||||
|
{
|
||||||
|
"command" => "명령이 대기열에 추가되었습니다.",
|
||||||
|
"direct" => "직접 실행 요청이 대기열에 추가되었습니다.",
|
||||||
|
"steering" => "조정 요청이 대기열에 추가되었습니다.",
|
||||||
|
"followup" => "후속 작업이 대기열에 추가되었습니다.",
|
||||||
|
_ => "메시지가 대기열에 추가되었습니다.",
|
||||||
|
};
|
||||||
|
ShowToast(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDraftQueueUi()
|
||||||
|
{
|
||||||
|
if (DraftPreviewCard == null || DraftPreviewText == null || DraftQueuePanel == null || BtnDraftEnqueue == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
var session = ChatSession;
|
||||||
|
if (session != null)
|
||||||
|
_draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = _appState.GetDraftQueueItems(_activeTab);
|
||||||
|
|
||||||
|
DraftPreviewCard.Visibility = Visibility.Collapsed;
|
||||||
|
BtnDraftEnqueue.IsEnabled = false;
|
||||||
|
DraftPreviewText.Text = string.Empty;
|
||||||
|
|
||||||
|
RebuildDraftQueuePanel(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsDraftQueueExpanded()
|
||||||
|
=> _expandedDraftQueueTabs.Contains(_activeTab);
|
||||||
|
|
||||||
|
private void ToggleDraftQueueExpanded()
|
||||||
|
{
|
||||||
|
if (!_expandedDraftQueueTabs.Add(_activeTab))
|
||||||
|
_expandedDraftQueueTabs.Remove(_activeTab);
|
||||||
|
|
||||||
|
RefreshDraftQueueUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildDraftQueuePanel(IReadOnlyList<DraftQueueItem> items)
|
||||||
|
{
|
||||||
|
if (DraftQueuePanel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
DraftQueuePanel.Children.Clear();
|
||||||
|
|
||||||
|
var visibleItems = items
|
||||||
|
.OrderBy(GetDraftStateRank)
|
||||||
|
.ThenBy(GetDraftPriorityRank)
|
||||||
|
.ThenBy(x => x.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (visibleItems.Count == 0)
|
||||||
|
{
|
||||||
|
DraftQueuePanel.Visibility = Visibility.Collapsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = _appState.GetDraftQueueSummary(_activeTab);
|
||||||
|
var shouldShowQueue =
|
||||||
|
IsDraftQueueExpanded()
|
||||||
|
|| summary.RunningCount > 0
|
||||||
|
|| summary.QueuedCount > 0
|
||||||
|
|| summary.FailedCount > 0;
|
||||||
|
|
||||||
|
if (!shouldShowQueue)
|
||||||
|
{
|
||||||
|
DraftQueuePanel.Visibility = Visibility.Collapsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DraftQueuePanel.Visibility = Visibility.Visible;
|
||||||
|
DraftQueuePanel.Children.Add(CreateDraftQueueSummaryStrip(summary, IsDraftQueueExpanded()));
|
||||||
|
if (!IsDraftQueueExpanded())
|
||||||
|
{
|
||||||
|
DraftQueuePanel.Children.Add(CreateCompactDraftQueuePanel(visibleItems, summary));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxPerSection = 3;
|
||||||
|
var runningItems = visibleItems
|
||||||
|
.Where(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(maxPerSection)
|
||||||
|
.ToList();
|
||||||
|
var queuedItems = visibleItems
|
||||||
|
.Where(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
|
||||||
|
.Take(maxPerSection)
|
||||||
|
.ToList();
|
||||||
|
var blockedItems = visibleItems
|
||||||
|
.Where(IsDraftBlocked)
|
||||||
|
.Take(maxPerSection)
|
||||||
|
.ToList();
|
||||||
|
var completedItems = visibleItems
|
||||||
|
.Where(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(maxPerSection)
|
||||||
|
.ToList();
|
||||||
|
var failedItems = visibleItems
|
||||||
|
.Where(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(maxPerSection)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
AddDraftQueueSection("실행 중", runningItems, summary.RunningCount);
|
||||||
|
AddDraftQueueSection("다음 작업", queuedItems, summary.QueuedCount);
|
||||||
|
AddDraftQueueSection("보류", blockedItems, summary.BlockedCount);
|
||||||
|
AddDraftQueueSection("완료", completedItems, summary.CompletedCount);
|
||||||
|
AddDraftQueueSection("실패", failedItems, summary.FailedCount);
|
||||||
|
|
||||||
|
if (summary.CompletedCount > 0 || summary.FailedCount > 0)
|
||||||
|
{
|
||||||
|
var footer = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (summary.CompletedCount > 0)
|
||||||
|
footer.Children.Add(CreateDraftQueueActionButton($"완료 정리 {summary.CompletedCount}", ClearCompletedDrafts, BrushFromHex("#ECFDF5")));
|
||||||
|
|
||||||
|
if (summary.FailedCount > 0)
|
||||||
|
footer.Children.Add(CreateDraftQueueActionButton($"실패 정리 {summary.FailedCount}", ClearFailedDrafts, BrushFromHex("#FEF2F2")));
|
||||||
|
|
||||||
|
DraftQueuePanel.Children.Add(footer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UIElement CreateCompactDraftQueuePanel(IReadOnlyList<DraftQueueItem> items, AppStateService.DraftQueueSummaryState summary)
|
||||||
|
{
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
|
||||||
|
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
|
||||||
|
var focusItem = items.FirstOrDefault(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? items.FirstOrDefault(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
|
||||||
|
?? items.FirstOrDefault(IsDraftBlocked)
|
||||||
|
?? items.FirstOrDefault(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? items.FirstOrDefault(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var container = new Border
|
||||||
|
{
|
||||||
|
Background = background,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(12, 10, 12, 10),
|
||||||
|
Margin = new Thickness(0, 0, 0, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new Grid();
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
container.Child = root;
|
||||||
|
|
||||||
|
var left = new StackPanel();
|
||||||
|
left.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = focusItem == null
|
||||||
|
? "대기열 항목이 준비되면 여기에서 요약됩니다."
|
||||||
|
: $"{GetDraftStateLabel(focusItem)} · {GetDraftKindLabel(focusItem)}",
|
||||||
|
FontSize = 11,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
});
|
||||||
|
left.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = focusItem?.Text ?? BuildDraftQueueCompactSummaryText(summary),
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
Margin = new Thickness(0, 4, 0, 0),
|
||||||
|
MaxWidth = 520,
|
||||||
|
});
|
||||||
|
Grid.SetColumn(left, 0);
|
||||||
|
root.Children.Add(left);
|
||||||
|
|
||||||
|
var action = CreateDraftQueueActionButton("상세", ToggleDraftQueueExpanded);
|
||||||
|
action.Margin = new Thickness(12, 0, 0, 0);
|
||||||
|
Grid.SetColumn(action, 1);
|
||||||
|
root.Children.Add(action);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDraftQueueCompactSummaryText(AppStateService.DraftQueueSummaryState summary)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (summary.RunningCount > 0) parts.Add($"실행 {summary.RunningCount}");
|
||||||
|
if (summary.QueuedCount > 0) parts.Add($"다음 {summary.QueuedCount}");
|
||||||
|
if (summary.FailedCount > 0) parts.Add($"실패 {summary.FailedCount}");
|
||||||
|
return parts.Count == 0 ? "대기열 0" : string.Join(" · ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDraftQueueSection(string label, IReadOnlyList<DraftQueueItem> items, int totalCount)
|
||||||
|
{
|
||||||
|
if (DraftQueuePanel == null || totalCount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
DraftQueuePanel.Children.Add(CreateDraftQueueSectionLabel($"{label} · {totalCount}"));
|
||||||
|
foreach (var item in items)
|
||||||
|
DraftQueuePanel.Children.Add(CreateDraftQueueCard(item));
|
||||||
|
|
||||||
|
if (totalCount > items.Count)
|
||||||
|
{
|
||||||
|
DraftQueuePanel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"추가 항목 {totalCount - items.Count}개",
|
||||||
|
Margin = new Thickness(8, -2, 0, 8),
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#7A7F87"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UIElement CreateDraftQueueSummaryStrip(AppStateService.DraftQueueSummaryState summary, bool isExpanded)
|
||||||
|
{
|
||||||
|
var root = new Grid
|
||||||
|
{
|
||||||
|
Margin = new Thickness(0, 0, 0, 8),
|
||||||
|
};
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
var wrap = new WrapPanel();
|
||||||
|
|
||||||
|
if (summary.RunningCount > 0)
|
||||||
|
wrap.Children.Add(CreateQueueSummaryPill("실행 중", summary.RunningCount.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"));
|
||||||
|
if (summary.QueuedCount > 0)
|
||||||
|
wrap.Children.Add(CreateQueueSummaryPill("다음", summary.QueuedCount.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"));
|
||||||
|
if (isExpanded && summary.BlockedCount > 0)
|
||||||
|
wrap.Children.Add(CreateQueueSummaryPill("보류", summary.BlockedCount.ToString(), "#FFF7ED", "#FDBA74", "#C2410C"));
|
||||||
|
if (isExpanded && summary.CompletedCount > 0)
|
||||||
|
wrap.Children.Add(CreateQueueSummaryPill("완료", summary.CompletedCount.ToString(), "#ECFDF5", "#BBF7D0", "#166534"));
|
||||||
|
if (summary.FailedCount > 0)
|
||||||
|
wrap.Children.Add(CreateQueueSummaryPill("실패", summary.FailedCount.ToString(), "#FEF2F2", "#FECACA", "#991B1B"));
|
||||||
|
|
||||||
|
if (wrap.Children.Count == 0)
|
||||||
|
wrap.Children.Add(CreateQueueSummaryPill("대기열", "0", "#F8FAFC", "#E2E8F0", "#475569"));
|
||||||
|
|
||||||
|
Grid.SetColumn(wrap, 0);
|
||||||
|
root.Children.Add(wrap);
|
||||||
|
|
||||||
|
var toggle = CreateDraftQueueActionButton(isExpanded ? "간단히" : "상세 보기", ToggleDraftQueueExpanded);
|
||||||
|
toggle.Margin = new Thickness(10, 0, 0, 0);
|
||||||
|
Grid.SetColumn(toggle, 1);
|
||||||
|
root.Children.Add(toggle);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateQueueSummaryPill(string label, string value, string bgHex, string borderHex, string fgHex)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = BrushFromHex(bgHex),
|
||||||
|
BorderBrush = BrushFromHex(borderHex),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Padding = new Thickness(8, 3, 8, 3),
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
FontSize = 10,
|
||||||
|
Foreground = BrushFromHex(fgHex),
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $" {value}",
|
||||||
|
FontSize = 10,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = BrushFromHex(fgHex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock CreateDraftQueueSectionLabel(string text)
|
||||||
|
{
|
||||||
|
return new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontSize = 10.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Margin = new Thickness(8, 0, 8, 6),
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateDraftQueueCard(DraftQueueItem item)
|
||||||
|
{
|
||||||
|
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
|
||||||
|
var neutralSurface = BrushFromHex("#F5F6F8");
|
||||||
|
var (kindIcon, kindForeground) = GetDraftKindVisual(item);
|
||||||
|
var (stateBackground, stateBorder, stateForeground) = GetDraftStateBadgeColors(item);
|
||||||
|
var (priorityBackground, priorityBorder, priorityForeground) = GetDraftPriorityBadgeColors(item.Priority);
|
||||||
|
|
||||||
|
var container = new Border
|
||||||
|
{
|
||||||
|
Background = background,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(12, 10, 12, 10),
|
||||||
|
Margin = new Thickness(0, 0, 0, 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new Grid();
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
container.Child = root;
|
||||||
|
|
||||||
|
var left = new StackPanel();
|
||||||
|
Grid.SetColumn(left, 0);
|
||||||
|
root.Children.Add(left);
|
||||||
|
|
||||||
|
var header = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
};
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = kindIcon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = kindForeground,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
});
|
||||||
|
header.Children.Add(CreateDraftQueueBadge(GetDraftKindLabel(item), BrushFromHex("#F8FAFC"), borderBrush, kindForeground));
|
||||||
|
header.Children.Add(CreateDraftQueueBadge(GetDraftStateLabel(item), stateBackground, stateBorder, stateForeground));
|
||||||
|
header.Children.Add(CreateDraftQueueBadge(GetDraftPriorityLabel(item.Priority), priorityBackground, priorityBorder, priorityForeground));
|
||||||
|
left.Children.Add(header);
|
||||||
|
|
||||||
|
left.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Text,
|
||||||
|
FontSize = 12.5,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(0, 6, 0, 0),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
MaxWidth = 520,
|
||||||
|
});
|
||||||
|
|
||||||
|
var meta = $"{item.CreatedAt:HH:mm}";
|
||||||
|
if (item.AttemptCount > 0)
|
||||||
|
meta += $" · 시도 {item.AttemptCount}";
|
||||||
|
if (item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now)
|
||||||
|
meta += $" · 재시도 {item.NextRetryAt.Value:HH:mm:ss}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.LastError))
|
||||||
|
meta += $" · {TruncateForStatus(item.LastError, 36)}";
|
||||||
|
|
||||||
|
left.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = meta,
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(0, 6, 0, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
var actions = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
};
|
||||||
|
Grid.SetColumn(actions, 1);
|
||||||
|
root.Children.Add(actions);
|
||||||
|
|
||||||
|
if (!string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
|
||||||
|
actions.Children.Add(CreateDraftQueueActionButton("실행", () => QueueDraftForImmediateRun(item.Id)));
|
||||||
|
|
||||||
|
if (string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
actions.Children.Add(CreateDraftQueueActionButton("대기", () => ResetDraftInQueue(item.Id), neutralSurface));
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.Children.Add(CreateDraftQueueActionButton("삭제", () => RemoveDraftFromQueue(item.Id), neutralSurface));
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateDraftQueueBadge(string text, Brush background, Brush borderBrush, Brush foreground)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = background,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Padding = new Thickness(7, 2, 7, 2),
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontSize = 10,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = foreground,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null)
|
||||||
|
{
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Content = label,
|
||||||
|
Margin = new Thickness(6, 0, 0, 0),
|
||||||
|
Padding = new Thickness(10, 5, 10, 5),
|
||||||
|
MinWidth = 48,
|
||||||
|
FontSize = 11,
|
||||||
|
Background = background ?? BrushFromHex("#EEF2FF"),
|
||||||
|
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#D4D4D8"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Foreground = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827"),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
};
|
||||||
|
btn.Click += (_, _) => onClick();
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetDraftStateRank(DraftQueueItem item)
|
||||||
|
=> string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0
|
||||||
|
: IsDraftBlocked(item) ? 1
|
||||||
|
: string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) ? 2
|
||||||
|
: string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ? 3
|
||||||
|
: 4;
|
||||||
|
|
||||||
|
private static int GetDraftPriorityRank(DraftQueueItem item)
|
||||||
|
=> item.Priority?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"now" => 0,
|
||||||
|
"next" => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetDraftPriorityLabel(string? priority)
|
||||||
|
=> priority?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"now" => "지금",
|
||||||
|
"later" => "나중",
|
||||||
|
_ => "다음",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetDraftKindLabel(DraftQueueItem item)
|
||||||
|
=> item.Kind?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"followup" => "후속 작업",
|
||||||
|
"steering" => "조정",
|
||||||
|
"command" => "명령",
|
||||||
|
"direct" => "직접 실행",
|
||||||
|
_ => "메시지",
|
||||||
|
};
|
||||||
|
|
||||||
|
private (string Icon, Brush Foreground) GetDraftKindVisual(DraftQueueItem item)
|
||||||
|
=> item.Kind?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"followup" => ("\uE8A5", BrushFromHex("#0F766E")),
|
||||||
|
"steering" => ("\uE7C3", BrushFromHex("#B45309")),
|
||||||
|
"command" => ("\uE756", BrushFromHex("#7C3AED")),
|
||||||
|
"direct" => ("\uE8A7", BrushFromHex("#2563EB")),
|
||||||
|
_ => ("\uE8BD", BrushFromHex("#475569")),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetDraftStateLabel(DraftQueueItem item)
|
||||||
|
=> IsDraftBlocked(item) ? "재시도 대기"
|
||||||
|
: item.State?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"running" => "실행 중",
|
||||||
|
"failed" => "실패",
|
||||||
|
"completed" => "완료",
|
||||||
|
_ => "대기",
|
||||||
|
};
|
||||||
|
|
||||||
|
private Brush GetDraftStateBrush(DraftQueueItem item)
|
||||||
|
=> IsDraftBlocked(item) ? BrushFromHex("#B45309")
|
||||||
|
: item.State?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"running" => BrushFromHex("#2563EB"),
|
||||||
|
"failed" => BrushFromHex("#DC2626"),
|
||||||
|
"completed" => BrushFromHex("#059669"),
|
||||||
|
_ => BrushFromHex("#7C3AED"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item)
|
||||||
|
=> IsDraftBlocked(item)
|
||||||
|
? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C"))
|
||||||
|
: item.State?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")),
|
||||||
|
"failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
|
||||||
|
"completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
|
||||||
|
_ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (Brush Background, Brush Border, Brush Foreground) GetDraftPriorityBadgeColors(string? priority)
|
||||||
|
=> priority?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"now" => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
|
||||||
|
"later" => (BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
|
||||||
|
_ => (BrushFromHex("#FEF3C7"), BrushFromHex("#FDE68A"), BrushFromHex("#92400E")),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsDraftBlocked(DraftQueueItem item)
|
||||||
|
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& item.NextRetryAt.HasValue
|
||||||
|
&& item.NextRetryAt.Value > DateTime.Now;
|
||||||
|
}
|
||||||
159
src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
Normal file
159
src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void RefreshContextUsageVisual()
|
||||||
|
{
|
||||||
|
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
|
||||||
|
|| TokenUsageSummaryText == null || TokenUsageHintText == null
|
||||||
|
|| TokenUsageThresholdMarker == null || CompactNowLabel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var showContextUsage = _activeTab is "Cowork" or "Code";
|
||||||
|
TokenUsageCard.Visibility = showContextUsage ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (!showContextUsage)
|
||||||
|
{
|
||||||
|
if (TokenUsagePopup != null)
|
||||||
|
TokenUsagePopup.IsOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var llm = _settings.Settings.Llm;
|
||||||
|
var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000);
|
||||||
|
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
|
||||||
|
var triggerRatio = triggerPercent / 100.0;
|
||||||
|
|
||||||
|
int messageTokens;
|
||||||
|
lock (_convLock)
|
||||||
|
messageTokens = _currentConversation?.Messages?.Count > 0
|
||||||
|
? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var draftText = InputBox?.Text ?? "";
|
||||||
|
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
|
||||||
|
var currentTokens = Math.Max(0, messageTokens + draftTokens);
|
||||||
|
var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens);
|
||||||
|
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
|
||||||
|
Brush progressBrush = accentBrush;
|
||||||
|
string summary;
|
||||||
|
string compactLabel;
|
||||||
|
|
||||||
|
if (usageRatio >= 1.0)
|
||||||
|
{
|
||||||
|
progressBrush = Brushes.IndianRed;
|
||||||
|
summary = "컨텍스트 한도 초과";
|
||||||
|
compactLabel = "지금 압축";
|
||||||
|
}
|
||||||
|
else if (usageRatio >= triggerRatio)
|
||||||
|
{
|
||||||
|
progressBrush = Brushes.DarkOrange;
|
||||||
|
summary = llm.EnableProactiveContextCompact ? "곧 자동 압축" : "압축 한계 도달";
|
||||||
|
compactLabel = "압축 권장";
|
||||||
|
}
|
||||||
|
else if (usageRatio >= triggerRatio * 0.7)
|
||||||
|
{
|
||||||
|
progressBrush = Brushes.Goldenrod;
|
||||||
|
summary = "컨텍스트 사용 증가";
|
||||||
|
compactLabel = "미리 압축";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary = "컨텍스트 여유";
|
||||||
|
compactLabel = "압축";
|
||||||
|
}
|
||||||
|
|
||||||
|
string detailText;
|
||||||
|
if (_lastCompactionAt.HasValue && _lastCompactionBeforeTokens.HasValue && _lastCompactionAfterTokens.HasValue)
|
||||||
|
{
|
||||||
|
var compactType = _lastCompactionWasAutomatic ? "자동" : "수동";
|
||||||
|
detailText = $"{compactType} 압축 {Services.TokenEstimator.Format(_lastCompactionBeforeTokens.Value)} → {Services.TokenEstimator.Format(_lastCompactionAfterTokens.Value)}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
detailText = $"자동 압축 시작 {triggerPercent}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenUsageArc.Stroke = progressBrush;
|
||||||
|
TokenUsageThresholdMarker.Fill = progressBrush;
|
||||||
|
var percentText = $"{Math.Round(usageRatio * 100):0}%";
|
||||||
|
TokenUsagePercentText.Text = percentText;
|
||||||
|
TokenUsageSummaryText.Text = $"컨텍스트 {percentText}";
|
||||||
|
TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}";
|
||||||
|
CompactNowLabel.Text = compactLabel;
|
||||||
|
|
||||||
|
if (TokenUsagePopupTitle != null)
|
||||||
|
TokenUsagePopupTitle.Text = $"컨텍스트 창 {percentText}";
|
||||||
|
if (TokenUsagePopupUsage != null)
|
||||||
|
TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}";
|
||||||
|
if (TokenUsagePopupDetail != null)
|
||||||
|
TokenUsagePopupDetail.Text = _pendingPostCompaction
|
||||||
|
? $"compact 후 첫 응답 대기 중 · {detailText}"
|
||||||
|
: detailText;
|
||||||
|
if (TokenUsagePopupCompact != null)
|
||||||
|
TokenUsagePopupCompact.Text = _sessionCompactionCount > 0
|
||||||
|
? $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)} tokens"
|
||||||
|
: "AX Agent가 컨텍스트를 자동으로 관리합니다";
|
||||||
|
|
||||||
|
TokenUsageCard.ToolTip = null;
|
||||||
|
|
||||||
|
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 14, 14, 11);
|
||||||
|
PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 14, 14, 11, 2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TokenUsageCard_MouseEnter(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
_tokenUsagePopupCloseTimer.Stop();
|
||||||
|
if (TokenUsagePopup != null && TokenUsageCard?.Visibility == Visibility.Visible)
|
||||||
|
TokenUsagePopup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TokenUsageCard_MouseLeave(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
_tokenUsagePopupCloseTimer.Stop();
|
||||||
|
_tokenUsagePopupCloseTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TokenUsagePopup_MouseEnter(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
_tokenUsagePopupCloseTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TokenUsagePopup_MouseLeave(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
_tokenUsagePopupCloseTimer.Stop();
|
||||||
|
_tokenUsagePopupCloseTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseTokenUsagePopupIfIdle()
|
||||||
|
{
|
||||||
|
if (TokenUsagePopup == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cardHovered = IsMouseInsideElement(TokenUsageCard);
|
||||||
|
var popupHovered = TokenUsagePopup.Child is FrameworkElement popupChild && IsMouseInsideElement(popupChild);
|
||||||
|
if (!cardHovered && !popupHovered)
|
||||||
|
TokenUsagePopup.IsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMouseInsideElement(FrameworkElement? element)
|
||||||
|
{
|
||||||
|
if (element == null || !element.IsVisible || element.ActualWidth <= 0 || element.ActualHeight <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mouse = System.Windows.Forms.Control.MousePosition;
|
||||||
|
var point = element.PointFromScreen(new Point(mouse.X, mouse.Y));
|
||||||
|
return point.X >= 0 && point.Y >= 0 && point.X <= element.ActualWidth && point.Y <= element.ActualHeight;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return element.IsMouseOver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/AxCopilot/Views/ChatWindow.ConversationFilterPresentation.cs
Normal file
116
src/AxCopilot/Views/ChatWindow.ConversationFilterPresentation.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { }
|
||||||
|
|
||||||
|
private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_runningOnlyFilter = false;
|
||||||
|
UpdateConversationRunningFilterUi();
|
||||||
|
PersistConversationListPreferences();
|
||||||
|
RefreshConversationList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnQuickRunningFilter_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> BtnRunningOnlyFilter_Click(sender, e);
|
||||||
|
|
||||||
|
private void BtnQuickHotSort_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_sortConversationsByRecent = false;
|
||||||
|
UpdateConversationSortUi();
|
||||||
|
PersistConversationListPreferences();
|
||||||
|
RefreshConversationList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnConversationSort_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_sortConversationsByRecent = !_sortConversationsByRecent;
|
||||||
|
UpdateConversationSortUi();
|
||||||
|
PersistConversationListPreferences();
|
||||||
|
RefreshConversationList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateConversationFailureFilterUi()
|
||||||
|
{
|
||||||
|
_failedOnlyFilter = false;
|
||||||
|
UpdateSidebarModeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateConversationRunningFilterUi()
|
||||||
|
{
|
||||||
|
if (BtnRunningOnlyFilter == null || RunningOnlyFilterLabel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BtnRunningOnlyFilter.Background = _runningOnlyFilter
|
||||||
|
? BrushFromHex("#DBEAFE")
|
||||||
|
: Brushes.Transparent;
|
||||||
|
BtnRunningOnlyFilter.BorderBrush = _runningOnlyFilter
|
||||||
|
? BrushFromHex("#93C5FD")
|
||||||
|
: Brushes.Transparent;
|
||||||
|
BtnRunningOnlyFilter.BorderThickness = _runningOnlyFilter
|
||||||
|
? new Thickness(1)
|
||||||
|
: new Thickness(0);
|
||||||
|
RunningOnlyFilterLabel.Foreground = _runningOnlyFilter
|
||||||
|
? BrushFromHex("#1D4ED8")
|
||||||
|
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||||||
|
RunningOnlyFilterLabel.Text = _runningConversationCount > 0
|
||||||
|
? $"진행 {_runningConversationCount}"
|
||||||
|
: "진행";
|
||||||
|
BtnRunningOnlyFilter.ToolTip = _runningOnlyFilter
|
||||||
|
? "실행 중인 대화만 표시 중"
|
||||||
|
: _runningConversationCount > 0
|
||||||
|
? $"현재 실행 중인 대화 {_runningConversationCount}개 보기"
|
||||||
|
: "현재 실행 중인 대화만 보기";
|
||||||
|
UpdateSidebarModeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateConversationSortUi()
|
||||||
|
{
|
||||||
|
if (BtnConversationSort == null || ConversationSortLabel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ConversationSortLabel.Text = _sortConversationsByRecent ? "최근" : "활동";
|
||||||
|
BtnConversationSort.Background = _sortConversationsByRecent
|
||||||
|
? BrushFromHex("#EFF6FF")
|
||||||
|
: BrushFromHex("#F8FAFC");
|
||||||
|
BtnConversationSort.BorderBrush = _sortConversationsByRecent
|
||||||
|
? BrushFromHex("#93C5FD")
|
||||||
|
: BrushFromHex("#E2E8F0");
|
||||||
|
BtnConversationSort.BorderThickness = new Thickness(1);
|
||||||
|
ConversationSortLabel.Foreground = _sortConversationsByRecent
|
||||||
|
? BrushFromHex("#1D4ED8")
|
||||||
|
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||||||
|
BtnConversationSort.ToolTip = _sortConversationsByRecent
|
||||||
|
? "최신 업데이트 순으로 보는 중"
|
||||||
|
: "에이전트 활동량과 실패를 우선으로 보는 중";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyConversationListPreferences(ChatConversation? conv)
|
||||||
|
{
|
||||||
|
_failedOnlyFilter = false;
|
||||||
|
_runningOnlyFilter = false;
|
||||||
|
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
|
||||||
|
UpdateConversationFailureFilterUi();
|
||||||
|
UpdateConversationRunningFilterUi();
|
||||||
|
UpdateConversationSortUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistConversationListPreferences()
|
||||||
|
{
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
var session = _appState.ChatSession;
|
||||||
|
if (session == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
session.SaveConversationListPreferences(_activeTab, _failedOnlyFilter, _runningOnlyFilter, _sortConversationsByRecent, _storage);
|
||||||
|
_currentConversation = session.CurrentConversation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
525
src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs
Normal file
525
src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private const int ConversationPageSize = 50;
|
||||||
|
private List<ConversationMeta>? _pendingConversations;
|
||||||
|
|
||||||
|
public void RefreshConversationList()
|
||||||
|
{
|
||||||
|
var metas = _storage.LoadAllMeta();
|
||||||
|
var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
|
||||||
|
.Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets))
|
||||||
|
.Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.Llm.CustomPresets));
|
||||||
|
var presetMap = new Dictionary<string, (string Symbol, string Color)>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var p in allPresets)
|
||||||
|
presetMap.TryAdd(p.Category, (p.Symbol, p.Color));
|
||||||
|
|
||||||
|
var items = metas.Select(c =>
|
||||||
|
{
|
||||||
|
var symbol = ChatCategory.GetSymbol(c.Category);
|
||||||
|
var color = ChatCategory.GetColor(c.Category);
|
||||||
|
if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General)
|
||||||
|
{
|
||||||
|
if (presetMap.TryGetValue(c.Category, out var pm))
|
||||||
|
{
|
||||||
|
symbol = pm.Symbol;
|
||||||
|
color = pm.Color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory);
|
||||||
|
return new ConversationMeta
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
Title = c.Title,
|
||||||
|
Pinned = c.Pinned,
|
||||||
|
Category = c.Category,
|
||||||
|
Symbol = symbol,
|
||||||
|
ColorHex = color,
|
||||||
|
Tab = NormalizeTabName(c.Tab),
|
||||||
|
UpdatedAtText = FormatDate(c.UpdatedAt),
|
||||||
|
UpdatedAt = c.UpdatedAt,
|
||||||
|
Preview = c.Preview ?? "",
|
||||||
|
ParentId = c.ParentId,
|
||||||
|
AgentRunCount = runSummary.AgentRunCount,
|
||||||
|
FailedAgentRunCount = runSummary.FailedAgentRunCount,
|
||||||
|
LastAgentRunSummary = runSummary.LastAgentRunSummary,
|
||||||
|
LastFailedAt = runSummary.LastFailedAt,
|
||||||
|
LastCompletedAt = runSummary.LastCompletedAt,
|
||||||
|
WorkFolder = c.WorkFolder ?? "",
|
||||||
|
IsRunning = _currentConversation?.Id == c.Id
|
||||||
|
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
|
||||||
|
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase),
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
items = items.Where(i =>
|
||||||
|
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();
|
||||||
|
|
||||||
|
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
|
||||||
|
_runningConversationCount = items.Count(i => i.IsRunning);
|
||||||
|
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
|
||||||
|
UpdateConversationFailureFilterUi();
|
||||||
|
UpdateConversationRunningFilterUi();
|
||||||
|
UpdateConversationQuickStripUi();
|
||||||
|
|
||||||
|
if (_activeTab == "Cowork")
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_selectedCategory))
|
||||||
|
items = items.Where(i => string.Equals(i.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
else if (_activeTab == "Code")
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_selectedCategory))
|
||||||
|
items = items.Where(i => string.Equals(i.WorkFolder, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_selectedCategory == "__custom__")
|
||||||
|
{
|
||||||
|
var customCats = _settings.Settings.Llm.CustomPresets
|
||||||
|
.Select(c => $"custom_{c.Id}").ToHashSet();
|
||||||
|
items = items.Where(i => customCats.Contains(i.Category)).ToList();
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(_selectedCategory))
|
||||||
|
{
|
||||||
|
items = items.Where(i => i.Category == _selectedCategory).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var search = SearchBox?.Text?.Trim() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(search))
|
||||||
|
{
|
||||||
|
items = items.Where(i =>
|
||||||
|
i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase)
|
||||||
|
).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_runningOnlyFilter)
|
||||||
|
items = items.Where(i => i.IsRunning).ToList();
|
||||||
|
|
||||||
|
items = (_sortConversationsByRecent
|
||||||
|
? items.OrderByDescending(i => i.Pinned)
|
||||||
|
.ThenByDescending(i => i.UpdatedAt)
|
||||||
|
.ThenByDescending(i => i.FailedAgentRunCount > 0)
|
||||||
|
.ThenByDescending(i => i.AgentRunCount)
|
||||||
|
: items.OrderByDescending(i => i.Pinned)
|
||||||
|
.ThenByDescending(i => i.FailedAgentRunCount > 0)
|
||||||
|
.ThenByDescending(i => i.AgentRunCount)
|
||||||
|
.ThenByDescending(i => i.UpdatedAt))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
RenderConversationList(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderConversationList(List<ConversationMeta> items)
|
||||||
|
{
|
||||||
|
ConversationPanel.Children.Clear();
|
||||||
|
_pendingConversations = null;
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
var emptyText = _activeTab switch
|
||||||
|
{
|
||||||
|
"Cowork" => "Cowork 탭 대화가 없습니다",
|
||||||
|
"Code" => "Code 탭 대화가 없습니다",
|
||||||
|
_ => "Chat 탭 대화가 없습니다",
|
||||||
|
};
|
||||||
|
var empty = new TextBlock
|
||||||
|
{
|
||||||
|
Text = emptyText,
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 20, 0, 0),
|
||||||
|
};
|
||||||
|
ConversationPanel.Children.Add(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotlightItems = BuildConversationSpotlightItems(items);
|
||||||
|
if (spotlightItems.Count > 0)
|
||||||
|
{
|
||||||
|
AddGroupHeader("집중 필요");
|
||||||
|
foreach (var item in spotlightItems)
|
||||||
|
AddConversationItem(item);
|
||||||
|
|
||||||
|
ConversationPanel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Height = 1,
|
||||||
|
Margin = new Thickness(10, 8, 10, 4),
|
||||||
|
Background = BrushFromHex("#E5E7EB"),
|
||||||
|
Opacity = 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var allOrdered = new List<(string Group, ConversationMeta Item)>();
|
||||||
|
foreach (var item in items)
|
||||||
|
allOrdered.Add((GetConversationDateGroup(item.UpdatedAt), item));
|
||||||
|
|
||||||
|
var firstPage = allOrdered.Take(ConversationPageSize).ToList();
|
||||||
|
string? lastGroup = null;
|
||||||
|
foreach (var (group, item) in firstPage)
|
||||||
|
{
|
||||||
|
if (group != lastGroup)
|
||||||
|
{
|
||||||
|
AddGroupHeader(group);
|
||||||
|
lastGroup = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddConversationItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allOrdered.Count > ConversationPageSize)
|
||||||
|
{
|
||||||
|
_pendingConversations = items;
|
||||||
|
AddLoadMoreButton(allOrdered.Count - ConversationPageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLoadMoreButton(int remaining)
|
||||||
|
{
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var btn = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Padding = new Thickness(8, 10, 8, 10),
|
||||||
|
Margin = new Thickness(6, 4, 6, 4),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
};
|
||||||
|
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"더 보기 ({remaining}개 남음)",
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = accentBrush,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
btn.Child = stack;
|
||||||
|
btn.MouseEnter += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b)
|
||||||
|
b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF));
|
||||||
|
};
|
||||||
|
btn.MouseLeave += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b)
|
||||||
|
b.Background = Brushes.Transparent;
|
||||||
|
};
|
||||||
|
btn.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_pendingConversations == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var all = _pendingConversations;
|
||||||
|
_pendingConversations = null;
|
||||||
|
ConversationPanel.Children.Clear();
|
||||||
|
|
||||||
|
string? lastGroup = null;
|
||||||
|
foreach (var item in all)
|
||||||
|
{
|
||||||
|
var group = GetConversationDateGroup(item.UpdatedAt);
|
||||||
|
if (!string.Equals(lastGroup, group, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AddGroupHeader(group);
|
||||||
|
lastGroup = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddConversationItem(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ConversationPanel.Children.Add(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetConversationDateGroup(DateTime updatedAt)
|
||||||
|
{
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var date = updatedAt.Date;
|
||||||
|
if (date == today)
|
||||||
|
return "오늘";
|
||||||
|
if (date == today.AddDays(-1))
|
||||||
|
return "어제";
|
||||||
|
return "이전";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ConversationMeta> BuildConversationSpotlightItems(List<ConversationMeta> items)
|
||||||
|
{
|
||||||
|
if (_failedOnlyFilter || _runningOnlyFilter)
|
||||||
|
return new List<ConversationMeta>();
|
||||||
|
|
||||||
|
var search = SearchBox?.Text?.Trim() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(search))
|
||||||
|
return new List<ConversationMeta>();
|
||||||
|
|
||||||
|
return items
|
||||||
|
.Where(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3)
|
||||||
|
.OrderByDescending(i => i.FailedAgentRunCount)
|
||||||
|
.ThenByDescending(i => i.AgentRunCount)
|
||||||
|
.ThenByDescending(i => i.UpdatedAt)
|
||||||
|
.Take(3)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddGroupHeader(string text)
|
||||||
|
{
|
||||||
|
var header = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
Margin = new Thickness(8, 10, 0, 4),
|
||||||
|
};
|
||||||
|
ConversationPanel.Children.Add(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddConversationItem(ConversationMeta item)
|
||||||
|
{
|
||||||
|
var isSelected = false;
|
||||||
|
lock (_convLock)
|
||||||
|
isSelected = _currentConversation?.Id == item.Id;
|
||||||
|
|
||||||
|
var isBranch = !string.IsNullOrEmpty(item.ParentId);
|
||||||
|
var border = new Border
|
||||||
|
{
|
||||||
|
Background = isSelected
|
||||||
|
? new SolidColorBrush(Color.FromArgb(0x10, 0x4B, 0x5E, 0xFC))
|
||||||
|
: Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(5),
|
||||||
|
Padding = new Thickness(7, 4.5, 7, 4.5),
|
||||||
|
Margin = isBranch ? new Thickness(10, 1, 0, 1) : new Thickness(0, 1, 0, 1),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
};
|
||||||
|
|
||||||
|
var grid = new Grid();
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16) });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
Brush iconBrush;
|
||||||
|
if (item.Pinned)
|
||||||
|
{
|
||||||
|
iconBrush = Brushes.Orange;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try { iconBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); }
|
||||||
|
catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol;
|
||||||
|
if (!string.IsNullOrEmpty(item.ParentId))
|
||||||
|
iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6));
|
||||||
|
|
||||||
|
var icon = new TextBlock
|
||||||
|
{
|
||||||
|
Text = iconText,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = iconBrush,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
Grid.SetColumn(icon, 0);
|
||||||
|
grid.Children.Add(icon);
|
||||||
|
|
||||||
|
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray;
|
||||||
|
|
||||||
|
var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
var title = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Title,
|
||||||
|
FontSize = 11.75,
|
||||||
|
FontWeight = isSelected ? FontWeights.SemiBold : FontWeights.Normal,
|
||||||
|
Foreground = titleColor,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
};
|
||||||
|
var date = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.UpdatedAtText,
|
||||||
|
FontSize = 9,
|
||||||
|
Foreground = dateColor,
|
||||||
|
Margin = new Thickness(0, 1.5, 0, 0),
|
||||||
|
};
|
||||||
|
stack.Children.Add(title);
|
||||||
|
stack.Children.Add(date);
|
||||||
|
|
||||||
|
if (item.IsRunning)
|
||||||
|
{
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = _appState.ActiveTasks.Count > 0 ? $"진행 중 {_appState.ActiveTasks.Count}" : "진행 중",
|
||||||
|
FontSize = 8.8,
|
||||||
|
FontWeight = FontWeights.Medium,
|
||||||
|
Foreground = BrushFromHex("#4F46E5"),
|
||||||
|
Margin = new Thickness(0, 1.5, 0, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.AgentRunCount > 0)
|
||||||
|
{
|
||||||
|
var runSummaryText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.FailedAgentRunCount > 0
|
||||||
|
? $"실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 26)}"
|
||||||
|
: $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}",
|
||||||
|
FontSize = 8.9,
|
||||||
|
Foreground = item.FailedAgentRunCount > 0
|
||||||
|
? BrushFromHex("#B91C1C")
|
||||||
|
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
|
||||||
|
Margin = new Thickness(0, 1.5, 0, 0),
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.LastAgentRunSummary))
|
||||||
|
{
|
||||||
|
runSummaryText.ToolTip = item.FailedAgentRunCount > 0
|
||||||
|
? $"최근 실패 포함\n{item.LastAgentRunSummary}"
|
||||||
|
: item.LastAgentRunSummary;
|
||||||
|
}
|
||||||
|
stack.Children.Add(runSummaryText);
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid.SetColumn(stack, 1);
|
||||||
|
grid.Children.Add(stack);
|
||||||
|
|
||||||
|
var catBtn = new Button
|
||||||
|
{
|
||||||
|
Content = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE70F",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 9,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
},
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Visibility = Visibility.Collapsed,
|
||||||
|
Width = 20,
|
||||||
|
Height = 20,
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
Opacity = 0.72,
|
||||||
|
ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경",
|
||||||
|
};
|
||||||
|
var capturedId = item.Id;
|
||||||
|
catBtn.Click += (_, _) => ShowConversationMenu(capturedId);
|
||||||
|
Grid.SetColumn(catBtn, 2);
|
||||||
|
grid.Children.Add(catBtn);
|
||||||
|
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
border.BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
border.BorderThickness = new Thickness(1.25, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
border.Child = grid;
|
||||||
|
|
||||||
|
var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF));
|
||||||
|
border.MouseEnter += (_, _) =>
|
||||||
|
{
|
||||||
|
if (!isSelected)
|
||||||
|
border.Background = hoverBg;
|
||||||
|
catBtn.Visibility = Visibility.Visible;
|
||||||
|
};
|
||||||
|
border.MouseLeave += (_, _) =>
|
||||||
|
{
|
||||||
|
if (!isSelected)
|
||||||
|
border.Background = Brushes.Transparent;
|
||||||
|
catBtn.Visibility = Visibility.Collapsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
border.MouseLeftButtonDown += (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
EnterTitleEditMode(title, item.Id, titleColor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isStreaming)
|
||||||
|
{
|
||||||
|
_streamCts?.Cancel();
|
||||||
|
_cursorTimer.Stop();
|
||||||
|
_typingTimer.Stop();
|
||||||
|
_elapsedTimer.Stop();
|
||||||
|
_activeStreamText = null;
|
||||||
|
_elapsedLabel = null;
|
||||||
|
_isStreaming = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conv = _storage.Load(item.Id);
|
||||||
|
if (conv == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||||
|
SyncTabConversationIdsFromSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveLastConversations();
|
||||||
|
UpdateChatTitle();
|
||||||
|
RenderMessages();
|
||||||
|
RefreshConversationList();
|
||||||
|
RefreshDraftQueueUi();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Error($"대화 전환 오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
border.MouseRightButtonUp += (_, me) =>
|
||||||
|
{
|
||||||
|
me.Handled = true;
|
||||||
|
if (!isSelected)
|
||||||
|
{
|
||||||
|
var conv = _storage.Load(item.Id);
|
||||||
|
if (conv != null)
|
||||||
|
{
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||||
|
SyncTabConversationIdsFromSession();
|
||||||
|
}
|
||||||
|
SaveLastConversations();
|
||||||
|
UpdateChatTitle();
|
||||||
|
RenderMessages();
|
||||||
|
RefreshDraftQueueUi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
|
||||||
|
};
|
||||||
|
|
||||||
|
ConversationPanel.Children.Add(border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parent = titleTb.Parent as StackPanel;
|
||||||
|
if (parent == null) return;
|
||||||
|
|
||||||
|
var idx = parent.Children.IndexOf(titleTb);
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
var editBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = titleTb.Text,
|
||||||
|
FontSize = 12.5,
|
||||||
|
Foreground = titleColor,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||||
|
CaretBrush = titleColor,
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
Margin = new Thickness(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
parent.Children.RemoveAt(idx);
|
||||||
|
parent.Children.Insert(idx, editBox);
|
||||||
|
|
||||||
|
var committed = false;
|
||||||
|
void CommitEdit()
|
||||||
|
{
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
|
||||||
|
var newTitle = editBox.Text.Trim();
|
||||||
|
if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text;
|
||||||
|
|
||||||
|
titleTb.Text = newTitle;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentIdx = parent.Children.IndexOf(editBox);
|
||||||
|
if (currentIdx >= 0)
|
||||||
|
{
|
||||||
|
parent.Children.RemoveAt(currentIdx);
|
||||||
|
parent.Children.Insert(currentIdx, titleTb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
var conv = _storage.Load(conversationId);
|
||||||
|
if (conv != null)
|
||||||
|
{
|
||||||
|
conv.Title = newTitle;
|
||||||
|
_storage.Save(conv);
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation?.Id == conversationId)
|
||||||
|
{
|
||||||
|
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage) ?? _currentConversation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateChatTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CancelEdit()
|
||||||
|
{
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentIdx = parent.Children.IndexOf(editBox);
|
||||||
|
if (currentIdx >= 0)
|
||||||
|
{
|
||||||
|
parent.Children.RemoveAt(currentIdx);
|
||||||
|
parent.Children.Insert(currentIdx, titleTb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
editBox.KeyDown += (_, ke) =>
|
||||||
|
{
|
||||||
|
if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); }
|
||||||
|
if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); }
|
||||||
|
};
|
||||||
|
editBox.LostFocus += (_, _) => CommitEdit();
|
||||||
|
|
||||||
|
editBox.Focus();
|
||||||
|
editBox.SelectAll();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Error($"제목 편집 오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowConversationMenu(string conversationId)
|
||||||
|
{
|
||||||
|
var conv = _storage.Load(conversationId);
|
||||||
|
var isPinned = conv?.Pinned ?? false;
|
||||||
|
|
||||||
|
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
|
||||||
|
|
||||||
|
var popup = new Popup
|
||||||
|
{
|
||||||
|
StaysOpen = false,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
PopupAnimation = PopupAnimation.Fade,
|
||||||
|
Placement = PlacementMode.MousePoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
var container = new Border
|
||||||
|
{
|
||||||
|
Background = bgBrush,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(6),
|
||||||
|
MinWidth = 200,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{
|
||||||
|
BlurRadius = 16,
|
||||||
|
ShadowDepth = 4,
|
||||||
|
Opacity = 0.3,
|
||||||
|
Color = Colors.Black,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel();
|
||||||
|
|
||||||
|
Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick)
|
||||||
|
{
|
||||||
|
var item = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(10, 7, 10, 7),
|
||||||
|
Margin = new Thickness(0, 1, 0, 1),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
};
|
||||||
|
var g = new Grid();
|
||||||
|
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
|
||||||
|
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
|
||||||
|
var iconTb = new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = iconColor,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
Grid.SetColumn(iconTb, 0);
|
||||||
|
g.Children.Add(iconTb);
|
||||||
|
|
||||||
|
var textTb = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontSize = 12.5,
|
||||||
|
Foreground = primaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
Grid.SetColumn(textTb, 1);
|
||||||
|
g.Children.Add(textTb);
|
||||||
|
|
||||||
|
item.Child = g;
|
||||||
|
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||||||
|
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||||||
|
item.MouseLeftButtonUp += (_, _) => { popup.IsOpen = false; onClick(); };
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
Border CreateSeparator() => new()
|
||||||
|
{
|
||||||
|
Height = 1,
|
||||||
|
Background = borderBrush,
|
||||||
|
Opacity = 0.3,
|
||||||
|
Margin = new Thickness(8, 4, 8, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
stack.Children.Add(CreateMenuItem(
|
||||||
|
isPinned ? "\uE77A" : "\uE718",
|
||||||
|
isPinned ? "고정 해제" : "상단 고정",
|
||||||
|
TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var c = _storage.Load(conversationId);
|
||||||
|
if (c == null) return;
|
||||||
|
|
||||||
|
c.Pinned = !c.Pinned;
|
||||||
|
_storage.Save(c);
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation?.Id == conversationId)
|
||||||
|
{
|
||||||
|
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current => current.Pinned = c.Pinned, _storage) ?? _currentConversation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RefreshConversationList();
|
||||||
|
}));
|
||||||
|
|
||||||
|
stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () =>
|
||||||
|
{
|
||||||
|
foreach (UIElement child in ConversationPanel.Children)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null)
|
||||||
|
{
|
||||||
|
var catKey = conv.Category ?? ChatCategory.General;
|
||||||
|
string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280";
|
||||||
|
var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey);
|
||||||
|
if (chatCat != default && chatCat.Key != ChatCategory.General)
|
||||||
|
{
|
||||||
|
catSymbol = chatCat.Symbol;
|
||||||
|
catLabel = chatCat.Label;
|
||||||
|
catColor = chatCat.Color;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var preset = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets)
|
||||||
|
.FirstOrDefault(p => p.Category == catKey);
|
||||||
|
if (preset != null)
|
||||||
|
{
|
||||||
|
catSymbol = preset.Symbol;
|
||||||
|
catLabel = preset.Label;
|
||||||
|
catColor = preset.Color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Children.Add(CreateSeparator());
|
||||||
|
var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var catBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(catColor));
|
||||||
|
infoSp.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = catSymbol,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = catBrush,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
});
|
||||||
|
infoSp.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = catLabel,
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = primaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText });
|
||||||
|
}
|
||||||
|
stack.Children.Add(infoSp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeTab == "Chat")
|
||||||
|
{
|
||||||
|
stack.Children.Add(CreateSeparator());
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "분류 변경",
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(10, 4, 0, 4),
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
});
|
||||||
|
|
||||||
|
var currentCategory = conv?.Category ?? ChatCategory.General;
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
|
||||||
|
foreach (var (key, label, symbol, color) in ChatCategory.All)
|
||||||
|
{
|
||||||
|
var capturedKey = key;
|
||||||
|
var isCurrentCat = capturedKey == currentCategory;
|
||||||
|
|
||||||
|
var catItem = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(10, 7, 10, 7),
|
||||||
|
Margin = new Thickness(0, 1, 0, 1),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
};
|
||||||
|
var catGrid = new Grid();
|
||||||
|
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
|
||||||
|
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
|
||||||
|
|
||||||
|
var catIcon = new TextBlock
|
||||||
|
{
|
||||||
|
Text = symbol,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = BrushFromHex(color),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
Grid.SetColumn(catIcon, 0);
|
||||||
|
catGrid.Children.Add(catIcon);
|
||||||
|
|
||||||
|
var catText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
FontSize = 12.5,
|
||||||
|
Foreground = primaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal,
|
||||||
|
};
|
||||||
|
Grid.SetColumn(catText, 1);
|
||||||
|
catGrid.Children.Add(catText);
|
||||||
|
|
||||||
|
if (isCurrentCat)
|
||||||
|
{
|
||||||
|
var check = CreateSimpleCheck(accentBrush, 14);
|
||||||
|
Grid.SetColumn(check, 2);
|
||||||
|
catGrid.Children.Add(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
catItem.Child = catGrid;
|
||||||
|
catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||||||
|
catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||||||
|
catItem.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
var c = _storage.Load(conversationId);
|
||||||
|
if (c == null) return;
|
||||||
|
|
||||||
|
c.Category = capturedKey;
|
||||||
|
var preset = Services.PresetService.GetByCategory(capturedKey);
|
||||||
|
if (preset != null)
|
||||||
|
{
|
||||||
|
c.SystemCommand = preset.SystemPrompt;
|
||||||
|
}
|
||||||
|
_storage.Save(c);
|
||||||
|
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation?.Id == conversationId)
|
||||||
|
{
|
||||||
|
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current =>
|
||||||
|
{
|
||||||
|
current.Category = capturedKey;
|
||||||
|
if (preset != null)
|
||||||
|
{
|
||||||
|
current.SystemCommand = preset.SystemPrompt;
|
||||||
|
}
|
||||||
|
}, _storage) ?? _currentConversation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCurrent;
|
||||||
|
lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; }
|
||||||
|
if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder))
|
||||||
|
{
|
||||||
|
_promptCardPlaceholder = preset.Placeholder;
|
||||||
|
UpdateWatermarkVisibility();
|
||||||
|
if (string.IsNullOrEmpty(InputBox.Text))
|
||||||
|
{
|
||||||
|
InputWatermark.Text = preset.Placeholder;
|
||||||
|
InputWatermark.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (isCurrent)
|
||||||
|
{
|
||||||
|
ClearPromptCardPlaceholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshConversationList();
|
||||||
|
};
|
||||||
|
stack.Children.Add(catItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Children.Add(CreateSeparator());
|
||||||
|
stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () =>
|
||||||
|
{
|
||||||
|
var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제",
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||||
|
if (result != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
_storage.Delete(conversationId);
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation?.Id == conversationId)
|
||||||
|
{
|
||||||
|
_currentConversation = null;
|
||||||
|
MessagePanel.Children.Clear();
|
||||||
|
EmptyState.Visibility = Visibility.Visible;
|
||||||
|
UpdateChatTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RefreshConversationList();
|
||||||
|
}));
|
||||||
|
|
||||||
|
container.Child = stack;
|
||||||
|
popup.Child = container;
|
||||||
|
popup.IsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
321
src/AxCopilot/Views/ChatWindow.FileBrowserPresentation.cs
Normal file
321
src/AxCopilot/Views/ChatWindow.FileBrowserPresentation.cs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> _ignoredDirs = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
|
||||||
|
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
|
||||||
|
".cache", ".next", ".nuxt", "coverage", ".terraform",
|
||||||
|
};
|
||||||
|
|
||||||
|
private DispatcherTimer? _fileBrowserRefreshTimer;
|
||||||
|
|
||||||
|
private void ToggleFileBrowser()
|
||||||
|
{
|
||||||
|
if (FileBrowserPanel.Visibility == Visibility.Visible)
|
||||||
|
{
|
||||||
|
FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||||||
|
_settings.Settings.Llm.ShowFileBrowser = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FileBrowserPanel.Visibility = Visibility.Visible;
|
||||||
|
_settings.Settings.Llm.ShowFileBrowser = true;
|
||||||
|
BuildFileTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
|
||||||
|
|
||||||
|
private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var folder = GetCurrentWorkFolder();
|
||||||
|
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
FileBrowserPanel.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildFileTree()
|
||||||
|
{
|
||||||
|
FileTreeView.Items.Clear();
|
||||||
|
var folder = GetCurrentWorkFolder();
|
||||||
|
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
|
||||||
|
{
|
||||||
|
FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileBrowserTitle.Text = $"파일 탐색기 — {Path.GetFileName(folder)}";
|
||||||
|
var count = 0;
|
||||||
|
PopulateDirectory(new DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateDirectory(DirectoryInfo dir, ItemCollection items, int depth, ref int count)
|
||||||
|
{
|
||||||
|
if (depth > 4 || count > 200)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
|
||||||
|
{
|
||||||
|
if (count > 200)
|
||||||
|
break;
|
||||||
|
if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.'))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
count++;
|
||||||
|
var dirItem = new TreeViewItem
|
||||||
|
{
|
||||||
|
Header = CreateSurfaceFileTreeHeader("\uED25", subDir.Name, null),
|
||||||
|
Tag = subDir.FullName,
|
||||||
|
IsExpanded = depth < 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (depth < 3)
|
||||||
|
{
|
||||||
|
dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." });
|
||||||
|
var capturedDir = subDir;
|
||||||
|
var capturedDepth = depth;
|
||||||
|
dirItem.Expanded += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
|
||||||
|
{
|
||||||
|
ti.Items.Clear();
|
||||||
|
var c = 0;
|
||||||
|
PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(dirItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
|
||||||
|
{
|
||||||
|
if (count > 200)
|
||||||
|
break;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
var ext = file.Extension.ToLowerInvariant();
|
||||||
|
var icon = GetFileIcon(ext);
|
||||||
|
var size = FormatFileSize(file.Length);
|
||||||
|
|
||||||
|
var fileItem = new TreeViewItem
|
||||||
|
{
|
||||||
|
Header = CreateSurfaceFileTreeHeader(icon, file.Name, size),
|
||||||
|
Tag = file.FullName,
|
||||||
|
};
|
||||||
|
|
||||||
|
var capturedPath = file.FullName;
|
||||||
|
fileItem.MouseDoubleClick += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
TryShowPreview(capturedPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
fileItem.MouseRightButtonUp += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (s is TreeViewItem ti)
|
||||||
|
ti.IsSelected = true;
|
||||||
|
ShowFileTreeContextMenu(capturedPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
items.Add(fileItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFileIcon(string ext) => ext switch
|
||||||
|
{
|
||||||
|
".html" or ".htm" => "\uEB41",
|
||||||
|
".xlsx" or ".xls" => "\uE9F9",
|
||||||
|
".docx" or ".doc" => "\uE8A5",
|
||||||
|
".pdf" => "\uEA90",
|
||||||
|
".csv" => "\uE80A",
|
||||||
|
".md" => "\uE70B",
|
||||||
|
".json" or ".xml" => "\uE943",
|
||||||
|
".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F",
|
||||||
|
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943",
|
||||||
|
".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756",
|
||||||
|
".txt" or ".log" => "\uE8A5",
|
||||||
|
_ => "\uE7C3",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatFileSize(long bytes) => bytes switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{bytes} B",
|
||||||
|
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||||
|
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ShowFileTreeContextMenu(string filePath)
|
||||||
|
{
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C));
|
||||||
|
|
||||||
|
var popup = new Popup
|
||||||
|
{
|
||||||
|
StaysOpen = false,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
PopupAnimation = PopupAnimation.Fade,
|
||||||
|
Placement = PlacementMode.MousePoint,
|
||||||
|
};
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(2) };
|
||||||
|
var container = CreateSurfacePopupContainer(panel, 200, new Thickness(6));
|
||||||
|
popup.Child = container;
|
||||||
|
|
||||||
|
void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null)
|
||||||
|
{
|
||||||
|
var item = CreateSurfacePopupMenuItem(icon, iconColor ?? secondaryText, label, () =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
action();
|
||||||
|
}, labelColor ?? primaryText);
|
||||||
|
panel.Children.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddSep()
|
||||||
|
{
|
||||||
|
panel.Children.Add(CreateSurfacePopupSeparator());
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
if (_previewableExtensions.Contains(ext))
|
||||||
|
AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
|
||||||
|
|
||||||
|
AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo { FileName = filePath, UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
AddItem("\uED25", "폴더에서 보기", () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start("explorer.exe", $"/select,\"{filePath}\"");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
AddItem("\uE8C8", "경로 복사", () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Clipboard.SetText(filePath);
|
||||||
|
ShowToast("경로 복사됨");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddSep();
|
||||||
|
|
||||||
|
AddItem("\uE8AC", "이름 변경", () =>
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(filePath) ?? "";
|
||||||
|
var oldName = Path.GetFileName(filePath);
|
||||||
|
var dlg = new InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
|
||||||
|
if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
|
||||||
|
{
|
||||||
|
var newPath = Path.Combine(dir, dlg.ResponseText.Trim());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Move(filePath, newPath);
|
||||||
|
BuildFileTree();
|
||||||
|
ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowToast($"이름 변경 실패: {ex.Message}", "\uE783");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddItem("\uE74D", "삭제", () =>
|
||||||
|
{
|
||||||
|
var result = CustomMessageBox.Show(
|
||||||
|
$"파일을 삭제하시겠습니까?\n{Path.GetFileName(filePath)}",
|
||||||
|
"파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||||
|
if (result == MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
BuildFileTree();
|
||||||
|
ShowToast("파일 삭제됨");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowToast($"삭제 실패: {ex.Message}", "\uE783");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, dangerBrush, dangerBrush);
|
||||||
|
|
||||||
|
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshFileTreeIfVisible()
|
||||||
|
{
|
||||||
|
if (FileBrowserPanel.Visibility != Visibility.Visible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_fileBrowserRefreshTimer?.Stop();
|
||||||
|
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||||
|
_fileBrowserRefreshTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_fileBrowserRefreshTimer.Stop();
|
||||||
|
BuildFileTree();
|
||||||
|
};
|
||||||
|
_fileBrowserRefreshTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
391
src/AxCopilot/Views/ChatWindow.FooterPresentation.cs
Normal file
391
src/AxCopilot/Views/ChatWindow.FooterPresentation.cs
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
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.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private Popup? _memoryStatusPopup;
|
||||||
|
|
||||||
|
private string BuildDefaultInputWatermark()
|
||||||
|
{
|
||||||
|
var hasFolder = !string.IsNullOrWhiteSpace(GetCurrentWorkFolder());
|
||||||
|
return _activeTab switch
|
||||||
|
{
|
||||||
|
"Cowork" => hasFolder
|
||||||
|
? "문서 작성, 데이터 분석, 파일 작업을 요청하세요. 필요하면 작업 폴더 파일도 함께 참고합니다."
|
||||||
|
: "문서 작성, 데이터 분석, 파일 작업을 요청하세요. 작업 폴더를 선택하면 관련 파일도 함께 참고합니다.",
|
||||||
|
"Code" => hasFolder
|
||||||
|
? "코드 수정, 원인 분석, 빌드·테스트를 요청하세요. 작업 폴더 코드를 참고하고 저장소 상태도 함께 보여줍니다."
|
||||||
|
: "작업 폴더를 선택한 뒤 코드 수정, 원인 분석, 빌드·테스트를 요청하세요.",
|
||||||
|
_ => "질문, 요약, 초안 작성, 아이디어 정리를 요청하세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshInputWatermarkText()
|
||||||
|
{
|
||||||
|
if (InputWatermark == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
InputWatermark.Text = string.IsNullOrWhiteSpace(_promptCardPlaceholder)
|
||||||
|
? BuildDefaultInputWatermark()
|
||||||
|
: _promptCardPlaceholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildSelectedPresetGuideDescription(TopicPreset preset)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(preset.Description))
|
||||||
|
return preset.Description.Trim();
|
||||||
|
|
||||||
|
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "선택한 작업 유형에 맞춰 문서·데이터·파일 작업 흐름으로 이어집니다.";
|
||||||
|
|
||||||
|
return "선택한 대화 주제에 맞춰 응답 방향과 초안 흐름이 정리됩니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFolderBar()
|
||||||
|
{
|
||||||
|
if (FolderBar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_activeTab == "Chat")
|
||||||
|
{
|
||||||
|
FolderBar.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
|
||||||
|
RefreshContextUsageVisual();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderBar.Visibility = Visibility.Visible;
|
||||||
|
var folder = GetCurrentWorkFolder();
|
||||||
|
if (!string.IsNullOrEmpty(folder))
|
||||||
|
{
|
||||||
|
FolderPathLabel.Text = folder;
|
||||||
|
FolderPathLabel.ToolTip = folder;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FolderPathLabel.Text = "폴더를 선택하세요";
|
||||||
|
FolderPathLabel.ToolTip = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadConversationSettings();
|
||||||
|
LoadCompactionMetricsFromConversation();
|
||||||
|
UpdatePermissionUI();
|
||||||
|
UpdateDataUsageUI();
|
||||||
|
UpdateMemoryStatusUi();
|
||||||
|
RefreshContextUsageVisual();
|
||||||
|
ScheduleGitBranchRefresh();
|
||||||
|
UpdateGitBranchUi(
|
||||||
|
_currentGitBranchName,
|
||||||
|
GitBranchFilesText?.Text ?? "",
|
||||||
|
GitBranchAddedText?.Text ?? "",
|
||||||
|
GitBranchDeletedText?.Text ?? "",
|
||||||
|
_currentGitTooltip ?? "",
|
||||||
|
BtnGitBranch?.Visibility ?? Visibility.Collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDataUsageUI()
|
||||||
|
{
|
||||||
|
_folderDataUsage = GetAutomaticFolderDataUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMemoryStatusUi()
|
||||||
|
{
|
||||||
|
if (BtnMemoryStatus == null || MemoryStatusLabel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BtnMemoryStatus.Visibility = Visibility.Collapsed;
|
||||||
|
MemoryStatusSeparator.Visibility = Visibility.Collapsed;
|
||||||
|
MemoryStatusLabel.Text = "메모리 없음";
|
||||||
|
BtnMemoryStatus.ToolTip = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnMemoryStatus_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (BtnMemoryStatus == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (BtnMemoryStatus.Visibility != Visibility.Visible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_memoryStatusPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||||||
|
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var memory = app?.MemoryService;
|
||||||
|
if (memory == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var workFolder = GetCurrentWorkFolder();
|
||||||
|
memory.Load(workFolder);
|
||||||
|
var docs = memory.InstructionDocuments;
|
||||||
|
var learned = memory.All.Count;
|
||||||
|
var includePolicy = _settings.Settings.Llm.AllowExternalMemoryIncludes ? "외부 include 허용" : "외부 include 차단";
|
||||||
|
var auditEnabled = _settings.Settings.Llm.EnableAuditLog;
|
||||||
|
var recentIncludeEntries = AuditLogService.LoadRecent("MemoryInclude", maxCount: 5, daysBack: 3);
|
||||||
|
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? secondaryText;
|
||||||
|
var okBrush = new SolidColorBrush(Color.FromRgb(0x22, 0x9A, 0x55));
|
||||||
|
var warnBrush = new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06));
|
||||||
|
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
|
||||||
|
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(2) };
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "메모리 상태",
|
||||||
|
FontSize = 13,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(8, 4, 8, 2),
|
||||||
|
});
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"계층형 규칙 {docs.Count}개 · 학습 메모리 {learned}개 · {includePolicy}",
|
||||||
|
FontSize = 11.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(8, 0, 8, 6),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docs.Count > 0)
|
||||||
|
{
|
||||||
|
panel.Children.Add(CreateSurfacePopupSeparator());
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "적용 중 규칙",
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(8, 6, 8, 4),
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var doc in docs.Take(6))
|
||||||
|
panel.Children.Add(BuildMemoryPopupRuleRow(doc, primaryText, secondaryText, accentBrush));
|
||||||
|
|
||||||
|
if (docs.Count > 6)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"외 {docs.Count - 6}개 규칙",
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(8, 2, 8, 4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.Children.Add(CreateSurfacePopupSeparator());
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "최근 include 감사",
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(8, 6, 8, 4),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!auditEnabled)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "감사 로그가 꺼져 있어 include 이력을 기록하지 않습니다.",
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(8, 0, 8, 6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (recentIncludeEntries.Count == 0)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "최근 3일간 include 감사 기록이 없습니다.",
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(8, 0, 8, 6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var entry in recentIncludeEntries)
|
||||||
|
panel.Children.Add(BuildMemoryPopupAuditRow(entry, primaryText, secondaryText, okBrush, warnBrush, dangerBrush));
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = CreateSurfacePopupContainer(panel, 340, new Thickness(8));
|
||||||
|
_memoryStatusPopup = new Popup
|
||||||
|
{
|
||||||
|
Child = container,
|
||||||
|
StaysOpen = false,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
PopupAnimation = PopupAnimation.Fade,
|
||||||
|
Placement = PlacementMode.Top,
|
||||||
|
PlacementTarget = BtnMemoryStatus,
|
||||||
|
VerticalOffset = -6,
|
||||||
|
};
|
||||||
|
_memoryStatusPopup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShortenMemoryPath(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
return path;
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(path);
|
||||||
|
return string.IsNullOrWhiteSpace(directory) ? fileName : $"{fileName} · {directory}";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border BuildMemoryPopupRuleRow(MemoryInstructionDocument doc, Brush primaryText, Brush secondaryText, Brush accentBrush)
|
||||||
|
{
|
||||||
|
var meta = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(doc.Description))
|
||||||
|
meta.Add(doc.Description.Trim());
|
||||||
|
if (doc.Tags.Count > 0)
|
||||||
|
meta.Add($"tags: {string.Join(", ", doc.Tags)}");
|
||||||
|
if (doc.Paths.Count > 0)
|
||||||
|
meta.Add($"paths: {string.Join(", ", doc.Paths.Take(2))}{(doc.Paths.Count > 2 ? "..." : "")}");
|
||||||
|
|
||||||
|
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"[{doc.Label}] 우선순위 {doc.Priority}",
|
||||||
|
FontSize = 11.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
});
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = meta.Count == 0 ? doc.Layer : $"{doc.Layer} · {string.Join(" · ", meta)}",
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrWhiteSpace(doc.Path))
|
||||||
|
{
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = ShortenMemoryPath(doc.Path),
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = accentBrush,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(8), Child = stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resultBrush = entry.Success ? secondaryText : warnBrush;
|
||||||
|
|
||||||
|
var stack = new StackPanel { Margin = new Thickness(8, 2, 8, 4) };
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{statusText} · {entry.Timestamp:HH:mm:ss}",
|
||||||
|
FontSize = 11.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = statusBrush,
|
||||||
|
});
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = entry.Parameters,
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = primaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
});
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrWhiteSpace(entry.Result) ? (entry.FilePath ?? "") : entry.Result,
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = resultBrush,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(8), Child = stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? BuildMemoryContextEvidenceText()
|
||||||
|
{
|
||||||
|
if (_activeTab == "Chat")
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var memory = app?.MemoryService;
|
||||||
|
if (memory == null || !_settings.Settings.Llm.EnableAgentMemory)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
memory.Load(GetCurrentWorkFolder());
|
||||||
|
var docs = memory.InstructionDocuments;
|
||||||
|
var learned = memory.All.Count;
|
||||||
|
if (docs.Count == 0 && learned == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var labels = docs.Take(2).Select(x => x.Label).ToList();
|
||||||
|
var labelText = labels.Count == 0 ? "" : $" · {string.Join(", ", labels)}";
|
||||||
|
return $"메모리 규칙 {docs.Count}개 · 학습 {learned}개 적용 중{labelText}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSelectedPresetGuide(ChatConversation? conversation = null)
|
||||||
|
{
|
||||||
|
if (SelectedPresetGuide == null || SelectedPresetGuideTitle == null || SelectedPresetGuideDesc == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
SelectedPresetGuide.Visibility = Visibility.Collapsed;
|
||||||
|
SelectedPresetGuideTitle.Text = "";
|
||||||
|
SelectedPresetGuideDesc.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation ??= _currentConversation;
|
||||||
|
var category = conversation?.Category?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(category))
|
||||||
|
{
|
||||||
|
SelectedPresetGuide.Visibility = Visibility.Collapsed;
|
||||||
|
SelectedPresetGuideTitle.Text = "";
|
||||||
|
SelectedPresetGuideDesc.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preset = PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets)
|
||||||
|
.FirstOrDefault(p => string.Equals(p.Category?.Trim(), category, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (preset == null)
|
||||||
|
{
|
||||||
|
SelectedPresetGuide.Visibility = Visibility.Collapsed;
|
||||||
|
SelectedPresetGuideTitle.Text = "";
|
||||||
|
SelectedPresetGuideDesc.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedPresetGuideTitle.Text = string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"선택된 작업 유형 · {preset.Label}"
|
||||||
|
: $"선택된 대화 주제 · {preset.Label}";
|
||||||
|
SelectedPresetGuideDesc.Text = BuildSelectedPresetGuideDescription(preset);
|
||||||
|
SelectedPresetGuide.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
482
src/AxCopilot/Views/ChatWindow.GitBranchPresentation.cs
Normal file
482
src/AxCopilot/Views/ChatWindow.GitBranchPresentation.cs
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void UpdateGitBranchUi(string? branchName, string filesText, string addedText, string deletedText, string tooltip, Visibility visibility)
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
_currentGitBranchName = branchName;
|
||||||
|
_currentGitTooltip = tooltip;
|
||||||
|
|
||||||
|
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)
|
||||||
|
GitBranchAddedText.Text = addedText;
|
||||||
|
if (GitBranchDeletedText != null)
|
||||||
|
GitBranchDeletedText.Text = deletedText;
|
||||||
|
if (GitBranchSeparator != null)
|
||||||
|
GitBranchSeparator.Visibility = visibility;
|
||||||
|
|
||||||
|
UpdateCodeRepoSummaryUi(branchName, filesText, addedText, deletedText, tooltip, visibility);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCodeRepoSummaryUi(string? branchName, string filesText, string addedText, string deletedText, string tooltip, Visibility visibility)
|
||||||
|
{
|
||||||
|
if (CodeRepoSummaryBar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var currentFolder = GetCurrentWorkFolder();
|
||||||
|
var isVisible = visibility == Visibility.Visible &&
|
||||||
|
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.IsNullOrWhiteSpace(currentFolder);
|
||||||
|
|
||||||
|
CodeRepoSummaryBar.Visibility = isVisible ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (!isVisible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var gitRoot = _currentGitRoot ?? ResolveGitRoot(currentFolder);
|
||||||
|
var repoName = !string.IsNullOrWhiteSpace(gitRoot)
|
||||||
|
? System.IO.Path.GetFileName(gitRoot.TrimEnd('\\', '/'))
|
||||||
|
: "저장소";
|
||||||
|
var normalizedBranch = string.IsNullOrWhiteSpace(branchName) ? "detached" : branchName!;
|
||||||
|
var normalizedGitRoot = string.IsNullOrWhiteSpace(gitRoot)
|
||||||
|
? ""
|
||||||
|
: gitRoot!.TrimEnd('\\', '/');
|
||||||
|
var normalizedCurrentFolder = string.IsNullOrWhiteSpace(currentFolder)
|
||||||
|
? ""
|
||||||
|
: currentFolder!.TrimEnd('\\', '/');
|
||||||
|
var isWorkspaceSubfolder = !string.IsNullOrWhiteSpace(normalizedGitRoot) &&
|
||||||
|
!string.IsNullOrWhiteSpace(normalizedCurrentFolder) &&
|
||||||
|
!string.Equals(normalizedGitRoot, normalizedCurrentFolder, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (CodeRepoSummaryRepoLabel != null)
|
||||||
|
CodeRepoSummaryRepoLabel.Text = repoName;
|
||||||
|
if (CodeRepoSummaryBranchLabel != null)
|
||||||
|
CodeRepoSummaryBranchLabel.Text = normalizedBranch;
|
||||||
|
|
||||||
|
if (CodeRepoSummaryDetailText != null)
|
||||||
|
{
|
||||||
|
var details = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(filesText))
|
||||||
|
details.Add($"변경 {filesText}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(addedText))
|
||||||
|
details.Add(addedText);
|
||||||
|
if (!string.IsNullOrWhiteSpace(deletedText))
|
||||||
|
details.Add(deletedText);
|
||||||
|
if (!string.IsNullOrWhiteSpace(_currentGitUpstreamStatus))
|
||||||
|
details.Add(_currentGitUpstreamStatus!);
|
||||||
|
|
||||||
|
CodeRepoSummaryDetailText.Text = details.Count > 0
|
||||||
|
? string.Join(" · ", details)
|
||||||
|
: "현재 저장소 컨텍스트와 변경 상태를 기준으로 작업합니다.";
|
||||||
|
CodeRepoSummaryDetailText.ToolTip = string.IsNullOrWhiteSpace(tooltip) ? null : tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CodeRepoSummaryAddedPill != null && CodeRepoSummaryAddedText != null)
|
||||||
|
{
|
||||||
|
var hasAdded = !string.IsNullOrWhiteSpace(addedText);
|
||||||
|
CodeRepoSummaryAddedPill.Visibility = hasAdded ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
CodeRepoSummaryAddedText.Text = hasAdded ? addedText : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CodeRepoSummaryDeletedPill != null && CodeRepoSummaryDeletedText != null)
|
||||||
|
{
|
||||||
|
var hasDeleted = !string.IsNullOrWhiteSpace(deletedText);
|
||||||
|
CodeRepoSummaryDeletedPill.Visibility = hasDeleted ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
CodeRepoSummaryDeletedText.Text = hasDeleted ? deletedText : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CodeRepoSummaryWorkspacePill != null && CodeRepoSummaryWorkspaceText != null)
|
||||||
|
{
|
||||||
|
CodeRepoSummaryWorkspacePill.Visibility = Visibility.Visible;
|
||||||
|
CodeRepoSummaryWorkspaceText.Text = isWorkspaceSubfolder ? "워크트리" : "로컬";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CodeRepoSummaryUpstreamPill != null && CodeRepoSummaryUpstreamText != null)
|
||||||
|
{
|
||||||
|
var hasUpstream = !string.IsNullOrWhiteSpace(_currentGitUpstreamStatus);
|
||||||
|
CodeRepoSummaryUpstreamPill.Visibility = hasUpstream ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
CodeRepoSummaryUpstreamText.Text = hasUpstream ? _currentGitUpstreamStatus! : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CodeRepoSummaryActionText != null)
|
||||||
|
{
|
||||||
|
var hasChanges = _currentGitChangedFileCount > 0 ||
|
||||||
|
!string.IsNullOrWhiteSpace(addedText) ||
|
||||||
|
!string.IsNullOrWhiteSpace(deletedText);
|
||||||
|
CodeRepoSummaryActionText.Text = hasChanges ? "변경 · 브랜치 보기" : "브랜치 보기";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CodeRepoSummaryReviewPill != null)
|
||||||
|
CodeRepoSummaryReviewPill.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CodeRepoSummaryReviewPill_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (!string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase) || InputBox == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var existingText = InputBox.Text;
|
||||||
|
ShowSlashChip("/review");
|
||||||
|
if (!string.IsNullOrWhiteSpace(existingText))
|
||||||
|
{
|
||||||
|
InputBox.Text = existingText;
|
||||||
|
InputBox.CaretIndex = InputBox.Text.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputBox.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void CodeRepoSummaryBar_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (BtnGitBranch == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await RefreshGitBranchStatusAsync();
|
||||||
|
BuildGitBranchPopup();
|
||||||
|
GitBranchPopup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildGitBranchPopup()
|
||||||
|
{
|
||||||
|
if (GitBranchItems == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GitBranchItems.Children.Clear();
|
||||||
|
|
||||||
|
var gitRoot = _currentGitRoot ?? ResolveGitRoot(GetCurrentWorkFolder());
|
||||||
|
var branchName = _currentGitBranchName ?? "detached";
|
||||||
|
var tooltip = _currentGitTooltip ?? "";
|
||||||
|
var fileText = GitBranchFilesText?.Text ?? "";
|
||||||
|
var addedText = GitBranchAddedText?.Text ?? "";
|
||||||
|
var deletedText = GitBranchDeletedText?.Text ?? "";
|
||||||
|
var query = (_gitBranchSearchText ?? "").Trim();
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSummaryStrip(new[]
|
||||||
|
{
|
||||||
|
("브랜치", string.IsNullOrWhiteSpace(branchName) ? "없음" : branchName, "#F8FAFC", "#E2E8F0", "#475569"),
|
||||||
|
("파일", string.IsNullOrWhiteSpace(fileText) ? "0" : fileText, "#EFF6FF", "#BFDBFE", "#1D4ED8"),
|
||||||
|
("최근", _recentGitBranches.Count.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"),
|
||||||
|
}));
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSectionLabel("현재 브랜치", new Thickness(8, 6, 8, 4)));
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE943",
|
||||||
|
branchName,
|
||||||
|
string.IsNullOrWhiteSpace(fileText) ? "현재 브랜치" : fileText,
|
||||||
|
true,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() => { }));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(addedText) || !string.IsNullOrWhiteSpace(deletedText))
|
||||||
|
{
|
||||||
|
var stats = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Margin = new Thickness(8, 2, 8, 8),
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(addedText))
|
||||||
|
stats.Children.Add(CreateMetricPill(addedText, "#16A34A"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(deletedText))
|
||||||
|
stats.Children.Add(CreateMetricPill(deletedText, "#DC2626"));
|
||||||
|
GitBranchItems.Children.Add(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(gitRoot))
|
||||||
|
{
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSectionLabel("저장소", new Thickness(8, 6, 8, 4)));
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uED25",
|
||||||
|
System.IO.Path.GetFileName(gitRoot.TrimEnd('\\', '/')),
|
||||||
|
gitRoot,
|
||||||
|
false,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() => { }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_currentGitUpstreamStatus))
|
||||||
|
{
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE8AB",
|
||||||
|
"업스트림",
|
||||||
|
_currentGitUpstreamStatus!,
|
||||||
|
false,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() => { }));
|
||||||
|
}
|
||||||
|
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSectionLabel("빠른 작업", new Thickness(8, 10, 8, 4)));
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE8C8",
|
||||||
|
"상태 요약 복사",
|
||||||
|
"브랜치, 변경 파일, 추가/삭제 라인 요약을 복사합니다.",
|
||||||
|
false,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(tooltip); } catch { }
|
||||||
|
GitBranchPopup.IsOpen = false;
|
||||||
|
}));
|
||||||
|
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE72B",
|
||||||
|
"다시 조회",
|
||||||
|
"Git 상태를 다시 불러옵니다.",
|
||||||
|
false,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
await RefreshGitBranchStatusAsync();
|
||||||
|
BuildGitBranchPopup();
|
||||||
|
}));
|
||||||
|
|
||||||
|
var filteredBranches = _currentGitBranches
|
||||||
|
.Where(branch => string.IsNullOrWhiteSpace(query)
|
||||||
|
|| branch.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(20)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var recentBranches = _recentGitBranches
|
||||||
|
.Where(branch => _currentGitBranches.Any(current => string.Equals(current, branch, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.Where(branch => string.IsNullOrWhiteSpace(query)
|
||||||
|
|| branch.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(5)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (recentBranches.Count > 0)
|
||||||
|
{
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSectionLabel($"최근 전환 · {recentBranches.Count}", new Thickness(8, 10, 8, 4)));
|
||||||
|
|
||||||
|
foreach (var branch in recentBranches)
|
||||||
|
{
|
||||||
|
var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
isCurrent ? "\uE73E" : "\uE8FD",
|
||||||
|
branch,
|
||||||
|
isCurrent ? "현재 브랜치" : "최근 사용 브랜치",
|
||||||
|
isCurrent,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
isCurrent ? null : () => _ = SwitchGitBranchAsync(branch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentGitBranches.Count > 0)
|
||||||
|
{
|
||||||
|
var branchSectionLabel = string.IsNullOrWhiteSpace(query)
|
||||||
|
? $"브랜치 전환 · {_currentGitBranches.Count}"
|
||||||
|
: $"브랜치 전환 · {filteredBranches.Count}/{_currentGitBranches.Count}";
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSectionLabel(branchSectionLabel, new Thickness(8, 10, 8, 4)));
|
||||||
|
|
||||||
|
foreach (var branch in filteredBranches)
|
||||||
|
{
|
||||||
|
if (recentBranches.Any(recent => string.Equals(recent, branch, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
isCurrent ? "\uE73E" : "\uE943",
|
||||||
|
branch,
|
||||||
|
isCurrent ? "현재 브랜치" : "이 브랜치로 전환",
|
||||||
|
isCurrent,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
isCurrent ? null : () => _ = SwitchGitBranchAsync(branch)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query) && filteredBranches.Count == 0)
|
||||||
|
{
|
||||||
|
GitBranchItems.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "검색 결과가 없습니다.",
|
||||||
|
FontSize = 11.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(10, 6, 10, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GitBranchItems.Children.Add(CreatePopupSectionLabel("브랜치 작업", new Thickness(8, 10, 8, 4)));
|
||||||
|
GitBranchItems.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE710",
|
||||||
|
"새 브랜치 생성",
|
||||||
|
"현재 작업을 기준으로 새 브랜치를 만들고 전환합니다.",
|
||||||
|
false,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() => _ = CreateGitBranchAsync()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock CreatePopupSectionLabel(string text, Thickness? margin = null)
|
||||||
|
{
|
||||||
|
return new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontSize = 10.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
Margin = margin ?? new Thickness(8, 8, 8, 4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private UIElement CreatePopupSummaryStrip(IEnumerable<(string Label, string Value, string BgHex, string BorderHex, string FgHex)> items)
|
||||||
|
{
|
||||||
|
var wrap = new WrapPanel
|
||||||
|
{
|
||||||
|
Margin = new Thickness(8, 6, 8, 6),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
wrap.Children.Add(CreateMetricPill($"{item.Label} {item.Value}", item.FgHex, item.BgHex, item.BorderHex));
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateMetricPill(string text, string colorHex)
|
||||||
|
=> CreateMetricPill(text, colorHex, $"{colorHex}18", $"{colorHex}44");
|
||||||
|
|
||||||
|
private Border CreateMetricPill(string text, string colorHex, string bgHex, string borderHex)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = BrushFromHex(bgHex),
|
||||||
|
BorderBrush = BrushFromHex(borderHex),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Padding = new Thickness(8, 3, 8, 3),
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontSize = 10.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = BrushFromHex(colorHex),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateFlatPopupRow(string icon, string title, string description, string colorHex, bool clickable, Action? onClick)
|
||||||
|
{
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
|
||||||
|
var borderColor = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
var border = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = borderColor,
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||||
|
Padding = new Thickness(8, 9, 8, 9),
|
||||||
|
Cursor = clickable ? Cursors.Hand : Cursors.Arrow,
|
||||||
|
Focusable = clickable,
|
||||||
|
};
|
||||||
|
KeyboardNavigation.SetIsTabStop(border, clickable);
|
||||||
|
|
||||||
|
var grid = new Grid();
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
grid.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = BrushFromHex(colorHex),
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(0, 1, 10, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
var textStack = new StackPanel();
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = title,
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = description,
|
||||||
|
FontSize = 10.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid.SetColumn(textStack, 1);
|
||||||
|
grid.Children.Add(textStack);
|
||||||
|
if (clickable)
|
||||||
|
{
|
||||||
|
var chevron = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE76C",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 10,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(8, 0, 0, 0),
|
||||||
|
};
|
||||||
|
Grid.SetColumn(chevron, 2);
|
||||||
|
grid.Children.Add(chevron);
|
||||||
|
}
|
||||||
|
|
||||||
|
border.Child = grid;
|
||||||
|
|
||||||
|
if (clickable && onClick != null)
|
||||||
|
{
|
||||||
|
border.MouseEnter += (_, _) => border.Background = hoverBrush;
|
||||||
|
border.MouseLeave += (_, _) => border.Background = Brushes.Transparent;
|
||||||
|
border.MouseLeftButtonUp += (_, _) => onClick();
|
||||||
|
border.KeyDown += (_, keyEvent) =>
|
||||||
|
{
|
||||||
|
if (keyEvent.Key is Key.Enter or Key.Space)
|
||||||
|
{
|
||||||
|
keyEvent.Handled = true;
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return border;
|
||||||
|
}
|
||||||
|
}
|
||||||
342
src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs
Normal file
342
src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
|
||||||
|
{
|
||||||
|
var isUser = role == "user";
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var itemBg = TryFindResource("ItemBackground") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
var hintBg = TryFindResource("HintBackground") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var userBubbleBg = hintBg;
|
||||||
|
var assistantBubbleBg = itemBg;
|
||||||
|
|
||||||
|
if (isUser)
|
||||||
|
{
|
||||||
|
var msgMaxWidth = GetMessageMaxWidth();
|
||||||
|
var wrapper = new StackPanel
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Width = msgMaxWidth,
|
||||||
|
MaxWidth = msgMaxWidth,
|
||||||
|
Margin = new Thickness(0, 4, 0, 6),
|
||||||
|
};
|
||||||
|
|
||||||
|
var bubble = new Border
|
||||||
|
{
|
||||||
|
Background = userBubbleBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(11, 7, 11, 7),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var userCodeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
||||||
|
MarkdownRenderer.EnableFilePathHighlight =
|
||||||
|
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||||||
|
MarkdownRenderer.EnableCodeSymbolHighlight = true;
|
||||||
|
bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MarkdownRenderer.EnableCodeSymbolHighlight = false;
|
||||||
|
bubble.Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = content,
|
||||||
|
TextAlignment = TextAlignment.Left,
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = primaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
LineHeight = 18,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.Children.Add(bubble);
|
||||||
|
|
||||||
|
var userActionBar = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Opacity = 0.8,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
};
|
||||||
|
var capturedUserContent = content;
|
||||||
|
var userBtnColor = secondaryText;
|
||||||
|
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(capturedUserContent); } catch { }
|
||||||
|
}));
|
||||||
|
userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor,
|
||||||
|
() => EnterEditMode(wrapper, capturedUserContent)));
|
||||||
|
|
||||||
|
var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) };
|
||||||
|
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
var timestamp = message?.Timestamp ?? DateTime.Now;
|
||||||
|
Grid.SetColumn(userActionBar, 1);
|
||||||
|
userBottomBar.Children.Add(userActionBar);
|
||||||
|
|
||||||
|
var timestampText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = timestamp.ToString("HH:mm"),
|
||||||
|
FontSize = 10.5,
|
||||||
|
Opacity = 0.52,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(8, 0, 2, 1),
|
||||||
|
};
|
||||||
|
Grid.SetColumn(timestampText, 2);
|
||||||
|
userBottomBar.Children.Add(timestampText);
|
||||||
|
wrapper.Children.Add(userBottomBar);
|
||||||
|
wrapper.MouseEnter += (_, _) => userActionBar.Opacity = 1;
|
||||||
|
wrapper.MouseLeave += (_, _) => userActionBar.Opacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1 : 0.8;
|
||||||
|
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
|
||||||
|
|
||||||
|
var userContent = content;
|
||||||
|
wrapper.MouseRightButtonUp += (_, re) =>
|
||||||
|
{
|
||||||
|
re.Handled = true;
|
||||||
|
ShowMessageContextMenu(userContent, "user");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (animate)
|
||||||
|
ApplyMessageEntryAnimation(wrapper);
|
||||||
|
MessagePanel.Children.Add(wrapper);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != null && IsCompactionMetaMessage(message))
|
||||||
|
{
|
||||||
|
var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush);
|
||||||
|
if (animate)
|
||||||
|
ApplyMessageEntryAnimation(compactCard);
|
||||||
|
MessagePanel.Children.Add(compactCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assistantMaxWidth = GetMessageMaxWidth();
|
||||||
|
var container = new StackPanel
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Width = assistantMaxWidth,
|
||||||
|
MaxWidth = assistantMaxWidth,
|
||||||
|
Margin = new Thickness(0, 6, 0, 6),
|
||||||
|
};
|
||||||
|
if (animate)
|
||||||
|
ApplyMessageEntryAnimation(container);
|
||||||
|
|
||||||
|
var (agentName, _, _) = GetAgentIdentity();
|
||||||
|
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 1.5) };
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE945",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 10,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = agentName,
|
||||||
|
FontSize = 11.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(4, 0, 0, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
container.Children.Add(header);
|
||||||
|
|
||||||
|
var contentCard = new Border
|
||||||
|
{
|
||||||
|
Background = assistantBubbleBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(11, 8, 11, 8),
|
||||||
|
};
|
||||||
|
var contentStack = new StackPanel();
|
||||||
|
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
MarkdownRenderer.EnableFilePathHighlight =
|
||||||
|
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||||||
|
MarkdownRenderer.EnableCodeSymbolHighlight =
|
||||||
|
string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
||||||
|
if (IsBranchContextMessage(content))
|
||||||
|
{
|
||||||
|
var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun();
|
||||||
|
var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3);
|
||||||
|
var branchCard = new Border
|
||||||
|
{
|
||||||
|
Background = hintBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(12, 10, 12, 10),
|
||||||
|
Margin = new Thickness(0, 0, 0, 6),
|
||||||
|
};
|
||||||
|
var branchStack = new StackPanel();
|
||||||
|
branchStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "분기 컨텍스트",
|
||||||
|
FontSize = 11,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(0, 0, 0, 6),
|
||||||
|
});
|
||||||
|
branchStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
||||||
|
|
||||||
|
if (branchFiles.Count > 0)
|
||||||
|
{
|
||||||
|
var filesWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
|
||||||
|
foreach (var path in branchFiles)
|
||||||
|
{
|
||||||
|
var fileButton = new Button
|
||||||
|
{
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(8, 3, 8, 3),
|
||||||
|
Margin = new Thickness(0, 0, 6, 6),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
ToolTip = path,
|
||||||
|
Content = new TextBlock
|
||||||
|
{
|
||||||
|
Text = System.IO.Path.GetFileName(path),
|
||||||
|
FontSize = 10,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var capturedPath = path;
|
||||||
|
fileButton.Click += (_, _) => OpenRunFilePath(capturedPath);
|
||||||
|
filesWrap.Children.Add(fileButton);
|
||||||
|
}
|
||||||
|
branchStack.Children.Add(filesWrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchRun != null)
|
||||||
|
{
|
||||||
|
var actionsWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
|
||||||
|
|
||||||
|
var followUpButton = new Button
|
||||||
|
{
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(8, 3, 8, 3),
|
||||||
|
Margin = new Thickness(0, 0, 6, 6),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Content = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "후속 작업 큐에 넣기",
|
||||||
|
FontSize = 10,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var capturedBranchRun = branchRun;
|
||||||
|
followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun);
|
||||||
|
actionsWrap.Children.Add(followUpButton);
|
||||||
|
|
||||||
|
var timelineButton = new Button
|
||||||
|
{
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(8, 3, 8, 3),
|
||||||
|
Margin = new Thickness(0, 0, 6, 6),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Content = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "관련 로그로 이동",
|
||||||
|
FontSize = 10,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId);
|
||||||
|
actionsWrap.Children.Add(timelineButton);
|
||||||
|
|
||||||
|
branchStack.Children.Add(actionsWrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
branchCard.Child = branchStack;
|
||||||
|
contentStack.Children.Add(branchCard);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
contentStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
||||||
|
}
|
||||||
|
|
||||||
|
contentCard.Child = contentStack;
|
||||||
|
container.Children.Add(contentCard);
|
||||||
|
|
||||||
|
var actionBar = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
Margin = new Thickness(2, 2, 0, 0),
|
||||||
|
Opacity = 0.8,
|
||||||
|
};
|
||||||
|
var btnColor = secondaryText;
|
||||||
|
var capturedContent = content;
|
||||||
|
|
||||||
|
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(capturedContent); } catch { }
|
||||||
|
}));
|
||||||
|
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
|
||||||
|
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
|
||||||
|
AddLinkedFeedbackButtons(actionBar, btnColor, message);
|
||||||
|
|
||||||
|
var aiTimestamp = message?.Timestamp ?? DateTime.Now;
|
||||||
|
actionBar.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = aiTimestamp.ToString("HH:mm"),
|
||||||
|
FontSize = 10.5,
|
||||||
|
Opacity = 0.52,
|
||||||
|
Foreground = btnColor,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(4, 0, 0, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
container.Children.Add(actionBar);
|
||||||
|
var assistantMeta = CreateAssistantMessageMetaText(message);
|
||||||
|
if (assistantMeta != null)
|
||||||
|
container.Children.Add(assistantMeta);
|
||||||
|
|
||||||
|
container.MouseEnter += (_, _) => actionBar.Opacity = 1;
|
||||||
|
container.MouseLeave += (_, _) => actionBar.Opacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1 : 0.8;
|
||||||
|
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
|
||||||
|
|
||||||
|
var aiContent = content;
|
||||||
|
container.MouseRightButtonUp += (_, re) =>
|
||||||
|
{
|
||||||
|
re.Handled = true;
|
||||||
|
ShowMessageContextMenu(aiContent, "assistant");
|
||||||
|
};
|
||||||
|
|
||||||
|
MessagePanel.Children.Add(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
437
src/AxCopilot/Views/ChatWindow.MessageInteractions.cs
Normal file
437
src/AxCopilot/Views/ChatWindow.MessageInteractions.cs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
/// <summary>좋아요/싫어요 피드백 버튼을 생성합니다.</summary>
|
||||||
|
private Button CreateFeedbackButton(
|
||||||
|
string iconGlyph,
|
||||||
|
string tooltip,
|
||||||
|
Brush normalColor,
|
||||||
|
Brush activeColor,
|
||||||
|
Func<bool> isActive,
|
||||||
|
Action toggle)
|
||||||
|
{
|
||||||
|
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var activeBackground = TryFindResource("ItemHoverBackground") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromArgb(18, 255, 255, 255));
|
||||||
|
|
||||||
|
var icon = new TextBlock
|
||||||
|
{
|
||||||
|
Text = iconGlyph,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = normalColor,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
RenderTransformOrigin = new Point(0.5, 0.5),
|
||||||
|
RenderTransform = new ScaleTransform(1, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
var chip = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(6, 4, 6, 4),
|
||||||
|
Margin = new Thickness(0, 0, 4, 0),
|
||||||
|
Child = icon
|
||||||
|
};
|
||||||
|
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Content = chip,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
ToolTip = tooltip
|
||||||
|
};
|
||||||
|
|
||||||
|
void RefreshVisual()
|
||||||
|
{
|
||||||
|
var active = isActive();
|
||||||
|
icon.Foreground = active ? activeColor : normalColor;
|
||||||
|
chip.Background = active ? activeBackground : Brushes.Transparent;
|
||||||
|
chip.BorderBrush = active ? activeColor : Brushes.Transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshVisual();
|
||||||
|
|
||||||
|
btn.MouseEnter += (_, _) =>
|
||||||
|
{
|
||||||
|
if (!isActive())
|
||||||
|
icon.Foreground = hoverBrush;
|
||||||
|
};
|
||||||
|
btn.MouseLeave += (_, _) =>
|
||||||
|
{
|
||||||
|
if (!isActive())
|
||||||
|
icon.Foreground = normalColor;
|
||||||
|
};
|
||||||
|
btn.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
toggle();
|
||||||
|
RefreshVisual();
|
||||||
|
|
||||||
|
var scale = (ScaleTransform)icon.RenderTransform;
|
||||||
|
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
|
||||||
|
{
|
||||||
|
EasingFunction = new ElasticEase
|
||||||
|
{
|
||||||
|
EasingMode = EasingMode.EaseOut,
|
||||||
|
Oscillations = 1,
|
||||||
|
Springiness = 5
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
|
||||||
|
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
|
||||||
|
};
|
||||||
|
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>좋아요/싫어요 버튼을 상호배타 토글로 추가합니다.</summary>
|
||||||
|
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
|
||||||
|
{
|
||||||
|
string? currentFeedback = message?.Feedback;
|
||||||
|
|
||||||
|
void PersistFeedback()
|
||||||
|
{
|
||||||
|
if (message == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var feedback = string.IsNullOrWhiteSpace(currentFeedback) ? null : currentFeedback;
|
||||||
|
var session = ChatSession;
|
||||||
|
if (session != null)
|
||||||
|
{
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
session.UpdateMessageFeedback(_activeTab, message, feedback, _storage);
|
||||||
|
_currentConversation = session.CurrentConversation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message.Feedback = feedback;
|
||||||
|
ChatConversation? conv;
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
conv = _currentConversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conv != null)
|
||||||
|
_storage.Save(conv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var likeBtn = CreateFeedbackButton(
|
||||||
|
"\uE8E1",
|
||||||
|
"좋아요",
|
||||||
|
btnColor,
|
||||||
|
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)),
|
||||||
|
() => string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase),
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
currentFeedback = string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? null
|
||||||
|
: "like";
|
||||||
|
PersistFeedback();
|
||||||
|
});
|
||||||
|
|
||||||
|
var dislikeBtn = CreateFeedbackButton(
|
||||||
|
"\uE8E0",
|
||||||
|
"싫어요",
|
||||||
|
btnColor,
|
||||||
|
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)),
|
||||||
|
() => string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase),
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
currentFeedback = string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? null
|
||||||
|
: "dislike";
|
||||||
|
PersistFeedback();
|
||||||
|
});
|
||||||
|
|
||||||
|
actionBar.Children.Add(likeBtn);
|
||||||
|
actionBar.Children.Add(dislikeBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock? CreateAssistantMessageMetaText(ChatMessage? message)
|
||||||
|
{
|
||||||
|
if (message == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (message.ResponseElapsedMs is > 0)
|
||||||
|
{
|
||||||
|
var elapsedMs = message.ResponseElapsedMs.Value;
|
||||||
|
parts.Add(elapsedMs < 1000
|
||||||
|
? $"{elapsedMs}ms"
|
||||||
|
: $"{(elapsedMs / 1000.0):0.0}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.PromptTokens > 0 || message.CompletionTokens > 0)
|
||||||
|
{
|
||||||
|
var totalTokens = message.PromptTokens + message.CompletionTokens;
|
||||||
|
parts.Add($"{FormatTokenCount(totalTokens)} tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextBlock
|
||||||
|
{
|
||||||
|
Text = string.Join(" • ", parts),
|
||||||
|
FontSize = 9.75,
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
Margin = new Thickness(4, 2, 0, 0),
|
||||||
|
Opacity = 0.72,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyMessageEntryAnimation(FrameworkElement element)
|
||||||
|
{
|
||||||
|
element.Opacity = 0;
|
||||||
|
element.RenderTransform = new TranslateTransform(0, 16);
|
||||||
|
element.BeginAnimation(UIElement.OpacityProperty,
|
||||||
|
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350))
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
});
|
||||||
|
((TranslateTransform)element.RenderTransform).BeginAnimation(
|
||||||
|
TranslateTransform.YProperty,
|
||||||
|
new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400))
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isEditing;
|
||||||
|
|
||||||
|
private void EnterEditMode(StackPanel wrapper, string originalText)
|
||||||
|
{
|
||||||
|
if (_isStreaming || _isEditing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isEditing = true;
|
||||||
|
var idx = MessagePanel.Children.IndexOf(wrapper);
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
_isEditing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var editPanel = new StackPanel
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
MaxWidth = 540,
|
||||||
|
Margin = wrapper.Margin,
|
||||||
|
};
|
||||||
|
|
||||||
|
var editBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = originalText,
|
||||||
|
FontSize = 13.5,
|
||||||
|
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||||||
|
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray,
|
||||||
|
CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||||||
|
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
||||||
|
BorderThickness = new Thickness(1.5),
|
||||||
|
Padding = new Thickness(14, 10, 14, 10),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
AcceptsReturn = false,
|
||||||
|
MaxHeight = 200,
|
||||||
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||||
|
};
|
||||||
|
var editBorder = new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Child = editBox,
|
||||||
|
ClipToBounds = true,
|
||||||
|
};
|
||||||
|
editPanel.Children.Add(editBorder);
|
||||||
|
|
||||||
|
var btnBar = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Margin = new Thickness(0, 6, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
||||||
|
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
var cancelBtn = new Button
|
||||||
|
{
|
||||||
|
Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush },
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Padding = new Thickness(12, 5, 12, 5),
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
};
|
||||||
|
cancelBtn.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
_isEditing = false;
|
||||||
|
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||||||
|
{
|
||||||
|
MessagePanel.Children[idx] = wrapper;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
btnBar.Children.Add(cancelBtn);
|
||||||
|
|
||||||
|
var sendBtn = new Button
|
||||||
|
{
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
};
|
||||||
|
sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(
|
||||||
|
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
|
||||||
|
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
|
||||||
|
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
|
||||||
|
"</Border></ControlTemplate>");
|
||||||
|
sendBtn.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
var newText = editBox.Text.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(newText))
|
||||||
|
{
|
||||||
|
_ = SubmitEditAsync(idx, newText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
btnBar.Children.Add(sendBtn);
|
||||||
|
|
||||||
|
editPanel.Children.Add(btnBar);
|
||||||
|
MessagePanel.Children[idx] = editPanel;
|
||||||
|
|
||||||
|
editBox.KeyDown += (_, ke) =>
|
||||||
|
{
|
||||||
|
if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None)
|
||||||
|
{
|
||||||
|
ke.Handled = true;
|
||||||
|
var newText = editBox.Text.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(newText))
|
||||||
|
{
|
||||||
|
_ = SubmitEditAsync(idx, newText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ke.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
ke.Handled = true;
|
||||||
|
_isEditing = false;
|
||||||
|
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||||||
|
{
|
||||||
|
MessagePanel.Children[idx] = wrapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editBox.Focus();
|
||||||
|
editBox.SelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitEditAsync(int bubbleIndex, string newText)
|
||||||
|
{
|
||||||
|
_isEditing = false;
|
||||||
|
if (_isStreaming)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatConversation conv;
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conv = _currentConversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userMsgIdx = -1;
|
||||||
|
var uiIdx = 0;
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < conv.Messages.Count; i++)
|
||||||
|
{
|
||||||
|
if (conv.Messages[i].Role == "system")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiIdx == bubbleIndex)
|
||||||
|
{
|
||||||
|
userMsgIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMsgIdx < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
var session = ChatSession;
|
||||||
|
if (session != null)
|
||||||
|
{
|
||||||
|
session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage);
|
||||||
|
_currentConversation = session.CurrentConversation;
|
||||||
|
conv = _currentConversation!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
conv.Messages[userMsgIdx].Content = newText;
|
||||||
|
while (conv.Messages.Count > userMsgIdx + 1)
|
||||||
|
{
|
||||||
|
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderMessages(preserveViewport: true);
|
||||||
|
AutoScrollIfNeeded();
|
||||||
|
|
||||||
|
await SendRegenerateAsync(conv);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ChatSession == null)
|
||||||
|
{
|
||||||
|
_storage.Save(conv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Services.LogService.Debug($"편집 저장 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshConversationList();
|
||||||
|
}
|
||||||
|
}
|
||||||
310
src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs
Normal file
310
src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void BtnPermission_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PermissionPopup == null) return;
|
||||||
|
PermissionItems.Children.Clear();
|
||||||
|
|
||||||
|
ChatConversation? currentConversation;
|
||||||
|
lock (_convLock) currentConversation = _currentConversation;
|
||||||
|
var coreLevels = PermissionModePresentationCatalog.Ordered.ToList();
|
||||||
|
var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||||||
|
|
||||||
|
void AddPermissionRows(Panel container, IEnumerable<PermissionModePresentation> levels)
|
||||||
|
{
|
||||||
|
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||||
|
var selectedBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||||
|
var selectedBorder = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#D6E4FF");
|
||||||
|
foreach (var item in levels)
|
||||||
|
{
|
||||||
|
var level = item.Mode;
|
||||||
|
var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var rowBorder = new Border
|
||||||
|
{
|
||||||
|
Background = isActive ? selectedBackground : Brushes.Transparent,
|
||||||
|
BorderBrush = isActive ? selectedBorder : Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(10, 10, 10, 10),
|
||||||
|
Margin = new Thickness(0, 0, 0, 4),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Focusable = true,
|
||||||
|
};
|
||||||
|
KeyboardNavigation.SetIsTabStop(rowBorder, true);
|
||||||
|
|
||||||
|
var row = new Grid();
|
||||||
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
row.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 15,
|
||||||
|
Foreground = BrushFromHex(item.ColorHex),
|
||||||
|
Margin = new Thickness(0, 0, 10, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
var textStack = new StackPanel();
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Title,
|
||||||
|
FontSize = 13.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||||||
|
});
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Description,
|
||||||
|
FontSize = 11.5,
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
LineHeight = 16,
|
||||||
|
MaxWidth = 220,
|
||||||
|
});
|
||||||
|
Grid.SetColumn(textStack, 1);
|
||||||
|
row.Children.Add(textStack);
|
||||||
|
|
||||||
|
var check = new TextBlock
|
||||||
|
{
|
||||||
|
Text = isActive ? "\uE73E" : "",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.Bold,
|
||||||
|
Foreground = BrushFromHex("#2563EB"),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(12, 0, 0, 0),
|
||||||
|
};
|
||||||
|
Grid.SetColumn(check, 2);
|
||||||
|
row.Children.Add(check);
|
||||||
|
|
||||||
|
rowBorder.Child = row;
|
||||||
|
rowBorder.MouseEnter += (_, _) => rowBorder.Background = isActive ? selectedBackground : hoverBackground;
|
||||||
|
rowBorder.MouseLeave += (_, _) => rowBorder.Background = isActive ? selectedBackground : Brushes.Transparent;
|
||||||
|
|
||||||
|
var capturedLevel = level;
|
||||||
|
void ApplyPermission()
|
||||||
|
{
|
||||||
|
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel);
|
||||||
|
try { _settings.Save(); } catch { }
|
||||||
|
_appState.LoadFromSettings(_settings);
|
||||||
|
UpdatePermissionUI();
|
||||||
|
SaveConversationSettings();
|
||||||
|
RefreshInlineSettingsPanel();
|
||||||
|
RefreshOverlayModeButtons();
|
||||||
|
PermissionPopup.IsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission();
|
||||||
|
rowBorder.KeyDown += (_, ke) =>
|
||||||
|
{
|
||||||
|
if (ke.Key is Key.Enter or Key.Space)
|
||||||
|
{
|
||||||
|
ke.Handled = true;
|
||||||
|
ApplyPermission();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.Children.Add(rowBorder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddPermissionRows(PermissionItems, coreLevels);
|
||||||
|
|
||||||
|
PermissionPopup.IsOpen = true;
|
||||||
|
Dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
TryFocusFirstPermissionElement(PermissionItems);
|
||||||
|
}, System.Windows.Threading.DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFocusFirstPermissionElement(DependencyObject root)
|
||||||
|
{
|
||||||
|
if (root is UIElement ui && ui.Focusable && ui.IsEnabled && ui.Visibility == Visibility.Visible)
|
||||||
|
return ui.Focus();
|
||||||
|
|
||||||
|
var childCount = VisualTreeHelper.GetChildrenCount(root);
|
||||||
|
for (var i = 0; i < childCount; i++)
|
||||||
|
{
|
||||||
|
var child = VisualTreeHelper.GetChild(root, i);
|
||||||
|
if (TryFocusFirstPermissionElement(child))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetToolPermissionOverride(string toolName, string? mode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(toolName)) return;
|
||||||
|
var toolPermissions = _settings.Settings.Llm.ToolPermissions ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingKey = toolPermissions.Keys.FirstOrDefault(x => string.Equals(x, toolName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(mode))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(existingKey))
|
||||||
|
toolPermissions.Remove(existingKey!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { _settings.Save(); } catch { }
|
||||||
|
_appState.LoadFromSettings(_settings);
|
||||||
|
UpdatePermissionUI();
|
||||||
|
SaveConversationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPermissionPopup()
|
||||||
|
{
|
||||||
|
if (PermissionPopup == null) return;
|
||||||
|
BtnPermission_Click(this, new RoutedEventArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool GetPermissionPopupSectionExpanded(string sectionKey, bool defaultValue = false)
|
||||||
|
{
|
||||||
|
var map = _settings.Settings.Llm.PermissionPopupSections;
|
||||||
|
if (map != null && map.TryGetValue(sectionKey, out var expanded))
|
||||||
|
return expanded;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPermissionPopupSectionExpanded(string sectionKey, bool expanded)
|
||||||
|
{
|
||||||
|
var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
map[sectionKey] = expanded;
|
||||||
|
try { _settings.Save(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PermissionTopBanner != null)
|
||||||
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePermissionUI()
|
||||||
|
{
|
||||||
|
if (PermissionLabel == null || PermissionIcon == null) return;
|
||||||
|
ChatConversation? currentConversation;
|
||||||
|
lock (_convLock) currentConversation = _currentConversation;
|
||||||
|
var summary = _appState.GetPermissionSummary(currentConversation);
|
||||||
|
var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
|
||||||
|
PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm);
|
||||||
|
PermissionIcon.Text = perm switch
|
||||||
|
{
|
||||||
|
"AcceptEdits" => "\uE73E",
|
||||||
|
"BypassPermissions" => "\uE7BA",
|
||||||
|
"Deny" => "\uE711",
|
||||||
|
_ => "\uE8D7",
|
||||||
|
};
|
||||||
|
if (BtnPermission != null)
|
||||||
|
{
|
||||||
|
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
||||||
|
BtnPermission.ToolTip = $"{summary.Description}\n운영 모드: {operationMode}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개";
|
||||||
|
BtnPermission.Background = Brushes.Transparent;
|
||||||
|
BtnPermission.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(_lastPermissionBannerMode, perm, StringComparison.OrdinalIgnoreCase))
|
||||||
|
_lastPermissionBannerMode = perm;
|
||||||
|
|
||||||
|
if (perm == PermissionModeCatalog.AcceptEdits)
|
||||||
|
{
|
||||||
|
var activeColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
|
||||||
|
PermissionLabel.Foreground = activeColor;
|
||||||
|
PermissionIcon.Foreground = activeColor;
|
||||||
|
if (BtnPermission != null)
|
||||||
|
BtnPermission.BorderBrush = BrushFromHex("#86EFAC");
|
||||||
|
if (PermissionTopBanner != null)
|
||||||
|
{
|
||||||
|
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
|
||||||
|
PermissionTopBannerIcon.Text = "\uE73E";
|
||||||
|
PermissionTopBannerIcon.Foreground = activeColor;
|
||||||
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 편집 자동 승인";
|
||||||
|
PermissionTopBannerTitle.Foreground = BrushFromHex("#166534");
|
||||||
|
PermissionTopBannerText.Text = "모든 파일 편집은 자동 승인하고, 명령 실행만 계속 확인합니다.";
|
||||||
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (perm == PermissionModeCatalog.Deny)
|
||||||
|
{
|
||||||
|
var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
|
||||||
|
PermissionLabel.Foreground = denyColor;
|
||||||
|
PermissionIcon.Foreground = denyColor;
|
||||||
|
if (BtnPermission != null)
|
||||||
|
BtnPermission.BorderBrush = BrushFromHex("#86EFAC");
|
||||||
|
if (PermissionTopBanner != null)
|
||||||
|
{
|
||||||
|
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
|
||||||
|
PermissionTopBannerIcon.Text = "\uE73E";
|
||||||
|
PermissionTopBannerIcon.Foreground = denyColor;
|
||||||
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 읽기 전용";
|
||||||
|
PermissionTopBannerTitle.Foreground = denyColor;
|
||||||
|
PermissionTopBannerText.Text = "파일 읽기만 허용하고 생성, 수정, 삭제는 차단합니다.";
|
||||||
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (perm == PermissionModeCatalog.BypassPermissions)
|
||||||
|
{
|
||||||
|
var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C));
|
||||||
|
PermissionLabel.Foreground = autoColor;
|
||||||
|
PermissionIcon.Foreground = autoColor;
|
||||||
|
if (BtnPermission != null)
|
||||||
|
BtnPermission.BorderBrush = BrushFromHex("#FDBA74");
|
||||||
|
if (PermissionTopBanner != null)
|
||||||
|
{
|
||||||
|
PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74");
|
||||||
|
PermissionTopBannerIcon.Text = "\uE814";
|
||||||
|
PermissionTopBannerIcon.Foreground = autoColor;
|
||||||
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기";
|
||||||
|
PermissionTopBannerTitle.Foreground = autoColor;
|
||||||
|
PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요.";
|
||||||
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var defaultFg = BrushFromHex("#2563EB");
|
||||||
|
var iconFg = new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB));
|
||||||
|
PermissionLabel.Foreground = defaultFg;
|
||||||
|
PermissionIcon.Foreground = iconFg;
|
||||||
|
if (BtnPermission != null)
|
||||||
|
BtnPermission.BorderBrush = BrushFromHex("#BFDBFE");
|
||||||
|
if (PermissionTopBanner != null)
|
||||||
|
{
|
||||||
|
if (perm == PermissionModeCatalog.Default)
|
||||||
|
{
|
||||||
|
PermissionTopBanner.BorderBrush = BrushFromHex("#BFDBFE");
|
||||||
|
PermissionTopBannerIcon.Text = "\uE8D7";
|
||||||
|
PermissionTopBannerIcon.Foreground = BrushFromHex("#1D4ED8");
|
||||||
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 요청";
|
||||||
|
PermissionTopBannerTitle.Foreground = BrushFromHex("#1D4ED8");
|
||||||
|
PermissionTopBannerText.Text = "변경하기 전에 항상 확인합니다.";
|
||||||
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/AxCopilot/Views/ChatWindow.PlanApprovalPresentation.cs
Normal file
183
src/AxCopilot/Views/ChatWindow.PlanApprovalPresentation.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
using AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
|
||||||
|
{
|
||||||
|
return async (planSummary, options) =>
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<string?>();
|
||||||
|
var steps = TaskDecomposer.ExtractSteps(planSummary);
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_pendingPlanSummary = planSummary;
|
||||||
|
_pendingPlanSteps = steps.ToList();
|
||||||
|
EnsurePlanViewerWindow();
|
||||||
|
_planViewerWindow?.LoadPlan(planSummary, steps, tcs);
|
||||||
|
ShowPlanButton(true);
|
||||||
|
AddDecisionButtons(tcs, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
|
||||||
|
if (completed != tcs.Task)
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_planViewerWindow?.Hide();
|
||||||
|
ResetPendingPlanPresentation();
|
||||||
|
});
|
||||||
|
return "취소";
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await tcs.Task;
|
||||||
|
var agentDecision = result;
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix);
|
||||||
|
}
|
||||||
|
else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(result, "확인", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(result))
|
||||||
|
{
|
||||||
|
agentDecision = $"수정 요청: {result.Trim()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_planViewerWindow?.SwitchToExecutionMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_planViewerWindow?.Hide();
|
||||||
|
ResetPendingPlanPresentation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentDecision;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePlanViewerWindow()
|
||||||
|
{
|
||||||
|
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_planViewerWindow = new PlanViewerWindow(this);
|
||||||
|
_planViewerWindow.Closing += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
_planViewerWindow.Hide();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPlanButton(bool show)
|
||||||
|
{
|
||||||
|
if (!show)
|
||||||
|
{
|
||||||
|
for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn")
|
||||||
|
{
|
||||||
|
if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep")
|
||||||
|
MoodIconPanel.Children.RemoveAt(i - 1);
|
||||||
|
if (i < MoodIconPanel.Children.Count)
|
||||||
|
MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in MoodIconPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is Border b && b.Tag?.ToString() == "PlanBtn")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var separator = new Border
|
||||||
|
{
|
||||||
|
Width = 1,
|
||||||
|
Height = 18,
|
||||||
|
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
|
||||||
|
Margin = new Thickness(4, 0, 4, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Tag = "PlanSep",
|
||||||
|
};
|
||||||
|
MoodIconPanel.Children.Add(separator);
|
||||||
|
|
||||||
|
var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981");
|
||||||
|
planBtn.Tag = "PlanBtn";
|
||||||
|
planBtn.MouseLeftButtonUp += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (string.IsNullOrWhiteSpace(_pendingPlanSummary) && _pendingPlanSteps.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EnsurePlanViewerWindow();
|
||||||
|
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText)
|
||||||
|
|| _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty)
|
||||||
|
|| !_planViewerWindow.Steps.SequenceEqual(_pendingPlanSteps))
|
||||||
|
{
|
||||||
|
_planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps);
|
||||||
|
}
|
||||||
|
_planViewerWindow.Show();
|
||||||
|
_planViewerWindow.Activate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
MoodIconPanel.Children.Add(planBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePlanViewerStep(AgentEvent evt)
|
||||||
|
{
|
||||||
|
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (evt.StepCurrent > 0)
|
||||||
|
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompletePlanViewer()
|
||||||
|
{
|
||||||
|
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
||||||
|
_planViewerWindow.MarkComplete();
|
||||||
|
ResetPendingPlanPresentation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetPendingPlanPresentation()
|
||||||
|
{
|
||||||
|
_pendingPlanSummary = null;
|
||||||
|
_pendingPlanSteps.Clear();
|
||||||
|
ShowPlanButton(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWindowAlive(Window? w)
|
||||||
|
{
|
||||||
|
if (w == null)
|
||||||
|
return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var _ = w.IsVisible;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/AxCopilot/Views/ChatWindow.PopupPresentation.cs
Normal file
113
src/AxCopilot/Views/ChatWindow.PopupPresentation.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private Popup? _sharedContextPopup;
|
||||||
|
|
||||||
|
private (Popup Popup, StackPanel Panel) CreateThemedPopupMenu(
|
||||||
|
UIElement? placementTarget = null,
|
||||||
|
PlacementMode placement = PlacementMode.MousePoint,
|
||||||
|
double minWidth = 200)
|
||||||
|
{
|
||||||
|
_sharedContextPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||||||
|
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(2) };
|
||||||
|
var container = CreateSurfacePopupContainer(panel, minWidth, new Thickness(6));
|
||||||
|
|
||||||
|
var popup = new Popup
|
||||||
|
{
|
||||||
|
Child = container,
|
||||||
|
StaysOpen = false,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
PopupAnimation = PopupAnimation.Fade,
|
||||||
|
Placement = placement,
|
||||||
|
PlacementTarget = placementTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
_sharedContextPopup = popup;
|
||||||
|
return (popup, panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreatePopupMenuItem(
|
||||||
|
Popup popup,
|
||||||
|
string icon,
|
||||||
|
string label,
|
||||||
|
Brush iconBrush,
|
||||||
|
Brush labelBrush,
|
||||||
|
Brush hoverBrush,
|
||||||
|
Action action)
|
||||||
|
{
|
||||||
|
var item = CreateSurfacePopupMenuItem(icon, iconBrush, label, () =>
|
||||||
|
{
|
||||||
|
popup.SetCurrentValue(Popup.IsOpenProperty, false);
|
||||||
|
action();
|
||||||
|
}, labelBrush);
|
||||||
|
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBrush; };
|
||||||
|
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPopupMenuSeparator(Panel panel, Brush brush)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Height = 1,
|
||||||
|
Margin = new Thickness(10, 4, 10, 4),
|
||||||
|
Background = brush,
|
||||||
|
Opacity = 0.35,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
|
||||||
|
private void ShowRecentFolderContextMenu(string folderPath)
|
||||||
|
{
|
||||||
|
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 warningBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
|
||||||
|
var (popup, panel) = CreateThemedPopupMenu();
|
||||||
|
|
||||||
|
panel.Children.Add(CreatePopupMenuItem(popup, "\uED25", "폴더 열기", secondaryText, primaryText, hoverBg, () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = folderPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "경로 복사", secondaryText, primaryText, hoverBg, () =>
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(folderPath); } catch { }
|
||||||
|
}));
|
||||||
|
|
||||||
|
AddPopupMenuSeparator(panel, borderBrush);
|
||||||
|
|
||||||
|
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "목록에서 삭제", warningBrush, warningBrush, hoverBg, () =>
|
||||||
|
{
|
||||||
|
_settings.Settings.Llm.RecentWorkFolders.RemoveAll(
|
||||||
|
p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_settings.Save();
|
||||||
|
if (FolderMenuPopup.IsOpen)
|
||||||
|
ShowFolderMenu();
|
||||||
|
}));
|
||||||
|
|
||||||
|
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
}
|
||||||
736
src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs
Normal file
736
src/AxCopilot/Views/ChatWindow.PreviewPresentation.cs
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
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 AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
|
||||||
|
private readonly List<string> _previewTabs = new();
|
||||||
|
private string? _activePreviewTab;
|
||||||
|
private bool _webViewInitialized;
|
||||||
|
private Popup? _previewTabPopup;
|
||||||
|
|
||||||
|
private static readonly string WebView2DataFolder =
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"AxCopilot", "WebView2");
|
||||||
|
|
||||||
|
private void TryShowPreview(string filePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
PreviewWindow.ShowPreview(filePath, _selectedMood);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPreviewPanel(string filePath)
|
||||||
|
{
|
||||||
|
if (!_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
|
||||||
|
_previewTabs.Add(filePath);
|
||||||
|
|
||||||
|
_activePreviewTab = filePath;
|
||||||
|
|
||||||
|
if (PreviewColumn.Width.Value < 100)
|
||||||
|
{
|
||||||
|
PreviewColumn.Width = new GridLength(420);
|
||||||
|
SplitterColumn.Width = new GridLength(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviewPanel.Visibility = Visibility.Visible;
|
||||||
|
PreviewSplitter.Visibility = Visibility.Visible;
|
||||||
|
BtnPreviewToggle.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
RebuildPreviewTabs();
|
||||||
|
LoadPreviewContent(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildPreviewTabs()
|
||||||
|
{
|
||||||
|
PreviewTabPanel.Children.Clear();
|
||||||
|
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
foreach (var tabPath in _previewTabs)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(tabPath);
|
||||||
|
var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var tabBorder = new Border
|
||||||
|
{
|
||||||
|
Background = isActive
|
||||||
|
? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF))
|
||||||
|
: Brushes.Transparent,
|
||||||
|
BorderBrush = isActive ? accentBrush : Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
|
||||||
|
Padding = new Thickness(8, 6, 4, 6),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
MaxWidth = _previewTabs.Count <= 3 ? 200 : (_previewTabs.Count <= 5 ? 140 : 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
var tabContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
tabContent.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = fileName,
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = isActive ? primaryText : secondaryText,
|
||||||
|
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
MaxWidth = tabBorder.MaxWidth - 30,
|
||||||
|
ToolTip = tabPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
var closeFg = isActive ? primaryText : secondaryText;
|
||||||
|
var closeBtnText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE711",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 10,
|
||||||
|
Foreground = closeFg,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
var closeBtn = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(3),
|
||||||
|
Padding = new Thickness(3, 2, 3, 2),
|
||||||
|
Margin = new Thickness(5, 0, 0, 0),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Visibility = isActive ? Visibility.Visible : Visibility.Hidden,
|
||||||
|
Child = closeBtnText,
|
||||||
|
Tag = "close",
|
||||||
|
};
|
||||||
|
|
||||||
|
var closePath = tabPath;
|
||||||
|
closeBtn.MouseEnter += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b)
|
||||||
|
{
|
||||||
|
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
|
||||||
|
if (b.Child is TextBlock tb)
|
||||||
|
tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
closeBtn.MouseLeave += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b)
|
||||||
|
{
|
||||||
|
b.Background = Brushes.Transparent;
|
||||||
|
if (b.Child is TextBlock tb)
|
||||||
|
tb.Foreground = closeFg;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
closeBtn.MouseLeftButtonUp += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
ClosePreviewTab(closePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
tabContent.Children.Add(closeBtn);
|
||||||
|
tabBorder.Child = tabContent;
|
||||||
|
|
||||||
|
var clickPath = tabPath;
|
||||||
|
tabBorder.MouseLeftButtonUp += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Handled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
_activePreviewTab = clickPath;
|
||||||
|
RebuildPreviewTabs();
|
||||||
|
LoadPreviewContent(clickPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
var ctxPath = tabPath;
|
||||||
|
tabBorder.MouseRightButtonUp += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
ShowPreviewTabContextMenu(ctxPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
var dblPath = tabPath;
|
||||||
|
tabBorder.MouseLeftButtonDown += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Handled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (e.ClickCount == 2)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
OpenPreviewPopupWindow(dblPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var capturedIsActive = isActive;
|
||||||
|
var capturedCloseBtn = closeBtn;
|
||||||
|
tabBorder.MouseEnter += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b && !capturedIsActive)
|
||||||
|
b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
|
||||||
|
if (!capturedIsActive)
|
||||||
|
capturedCloseBtn.Visibility = Visibility.Visible;
|
||||||
|
};
|
||||||
|
tabBorder.MouseLeave += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b && !capturedIsActive)
|
||||||
|
b.Background = Brushes.Transparent;
|
||||||
|
if (!capturedIsActive)
|
||||||
|
capturedCloseBtn.Visibility = Visibility.Hidden;
|
||||||
|
};
|
||||||
|
|
||||||
|
PreviewTabPanel.Children.Add(tabBorder);
|
||||||
|
|
||||||
|
if (!string.Equals(tabPath, _previewTabs[^1], StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
PreviewTabPanel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Width = 1,
|
||||||
|
Height = 14,
|
||||||
|
Background = borderBrush,
|
||||||
|
Margin = new Thickness(0, 4, 0, 4),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClosePreviewTab(string filePath)
|
||||||
|
{
|
||||||
|
_previewTabs.Remove(filePath);
|
||||||
|
|
||||||
|
if (_previewTabs.Count == 0)
|
||||||
|
{
|
||||||
|
HidePreviewPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(filePath, _activePreviewTab, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_activePreviewTab = _previewTabs[^1];
|
||||||
|
LoadPreviewContent(_activePreviewTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildPreviewTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void LoadPreviewContent(string filePath)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
SetPreviewHeader(filePath);
|
||||||
|
|
||||||
|
PreviewWebView.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewTextScroll.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewEmpty.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
SetPreviewHeaderState("파일을 찾을 수 없습니다");
|
||||||
|
PreviewEmpty.Text = "파일을 찾을 수 없습니다";
|
||||||
|
PreviewEmpty.Visibility = Visibility.Visible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (ext)
|
||||||
|
{
|
||||||
|
case ".html":
|
||||||
|
case ".htm":
|
||||||
|
await EnsureWebViewInitializedAsync();
|
||||||
|
PreviewWebView.Source = new Uri(filePath);
|
||||||
|
PreviewWebView.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ".csv":
|
||||||
|
LoadCsvPreview(filePath);
|
||||||
|
PreviewDataGrid.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ".md":
|
||||||
|
await EnsureWebViewInitializedAsync();
|
||||||
|
var mdText = File.ReadAllText(filePath);
|
||||||
|
if (mdText.Length > 50000)
|
||||||
|
mdText = mdText[..50000];
|
||||||
|
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood);
|
||||||
|
PreviewWebView.NavigateToString(mdHtml);
|
||||||
|
PreviewWebView.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ".txt":
|
||||||
|
case ".json":
|
||||||
|
case ".xml":
|
||||||
|
case ".log":
|
||||||
|
var text = File.ReadAllText(filePath);
|
||||||
|
if (text.Length > 50000)
|
||||||
|
text = text[..50000] + "\n\n... (이후 생략)";
|
||||||
|
PreviewTextBlock.Text = text;
|
||||||
|
PreviewTextScroll.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
SetPreviewHeaderState("지원되지 않는 형식");
|
||||||
|
PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다";
|
||||||
|
PreviewEmpty.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetPreviewHeaderState("미리보기 오류");
|
||||||
|
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
|
||||||
|
PreviewTextScroll.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPreviewHeader(string filePath)
|
||||||
|
{
|
||||||
|
if (PreviewHeaderTitle == null || PreviewHeaderSubtitle == null || PreviewHeaderMeta == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
var extension = Path.GetExtension(filePath).TrimStart('.').ToUpperInvariant();
|
||||||
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
var sizeText = fileInfo.Exists
|
||||||
|
? fileInfo.Length >= 1024 * 1024
|
||||||
|
? $"{fileInfo.Length / 1024d / 1024d:F1} MB"
|
||||||
|
: $"{Math.Max(1, fileInfo.Length / 1024d):F0} KB"
|
||||||
|
: "파일 없음";
|
||||||
|
|
||||||
|
PreviewHeaderTitle.Text = string.IsNullOrWhiteSpace(fileName) ? "미리보기" : fileName;
|
||||||
|
PreviewHeaderSubtitle.Text = filePath;
|
||||||
|
PreviewHeaderMeta.Text = string.IsNullOrWhiteSpace(extension)
|
||||||
|
? sizeText
|
||||||
|
: $"{extension} · {sizeText}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPreviewHeaderState(string state)
|
||||||
|
{
|
||||||
|
if (PreviewHeaderMeta != null && !string.IsNullOrWhiteSpace(state))
|
||||||
|
PreviewHeaderMeta.Text = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureWebViewInitializedAsync()
|
||||||
|
{
|
||||||
|
if (_webViewInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
|
||||||
|
userDataFolder: WebView2DataFolder);
|
||||||
|
await PreviewWebView.EnsureCoreWebView2Async(env);
|
||||||
|
_webViewInitialized = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadCsvPreview(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(filePath);
|
||||||
|
if (lines.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dt = new DataTable();
|
||||||
|
var headers = ParseCsvLine(lines[0]);
|
||||||
|
foreach (var h in headers)
|
||||||
|
dt.Columns.Add(h);
|
||||||
|
|
||||||
|
var maxRows = Math.Min(lines.Length, 501);
|
||||||
|
for (var i = 1; i < maxRows; i++)
|
||||||
|
{
|
||||||
|
var vals = ParseCsvLine(lines[i]);
|
||||||
|
var row = dt.NewRow();
|
||||||
|
for (var j = 0; j < Math.Min(vals.Length, headers.Length); j++)
|
||||||
|
row[j] = vals[j];
|
||||||
|
dt.Rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviewDataGrid.ItemsSource = dt.DefaultView;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PreviewTextBlock.Text = $"CSV 로드 오류: {ex.Message}";
|
||||||
|
PreviewTextScroll.Visibility = Visibility.Visible;
|
||||||
|
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ParseCsvLine(string line)
|
||||||
|
{
|
||||||
|
var fields = new List<string>();
|
||||||
|
var current = new StringBuilder();
|
||||||
|
var inQuotes = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < line.Length; i++)
|
||||||
|
{
|
||||||
|
var c = line[i];
|
||||||
|
if (inQuotes)
|
||||||
|
{
|
||||||
|
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
|
||||||
|
{
|
||||||
|
current.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else if (c == '"')
|
||||||
|
{
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
inQuotes = true;
|
||||||
|
}
|
||||||
|
else if (c == ',')
|
||||||
|
{
|
||||||
|
fields.Add(current.ToString());
|
||||||
|
current.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.Add(current.ToString());
|
||||||
|
return fields.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HidePreviewPanel()
|
||||||
|
{
|
||||||
|
_previewTabs.Clear();
|
||||||
|
_activePreviewTab = null;
|
||||||
|
if (PreviewHeaderTitle != null) PreviewHeaderTitle.Text = "미리보기";
|
||||||
|
if (PreviewHeaderSubtitle != null) PreviewHeaderSubtitle.Text = "선택한 파일이 여기에 표시됩니다";
|
||||||
|
if (PreviewHeaderMeta != null) PreviewHeaderMeta.Text = "파일 메타";
|
||||||
|
PreviewColumn.Width = new GridLength(0);
|
||||||
|
SplitterColumn.Width = new GridLength(0);
|
||||||
|
PreviewPanel.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewSplitter.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewWebView.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewTextScroll.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewDataGrid.Visibility = Visibility.Collapsed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_webViewInitialized)
|
||||||
|
PreviewWebView.CoreWebView2?.NavigateToString("<html></html>");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PreviewTabBar_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin)
|
||||||
|
{
|
||||||
|
var border = sender as Border;
|
||||||
|
border?.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnClosePreview_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
HidePreviewPanel();
|
||||||
|
BtnPreviewToggle.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PreviewPanel.Visibility == Visibility.Visible)
|
||||||
|
{
|
||||||
|
PreviewPanel.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewSplitter.Visibility = Visibility.Collapsed;
|
||||||
|
PreviewColumn.Width = new GridLength(0);
|
||||||
|
SplitterColumn.Width = new GridLength(0);
|
||||||
|
}
|
||||||
|
else if (_previewTabs.Count > 0)
|
||||||
|
{
|
||||||
|
PreviewPanel.Visibility = Visibility.Visible;
|
||||||
|
PreviewSplitter.Visibility = Visibility.Visible;
|
||||||
|
PreviewColumn.Width = new GridLength(420);
|
||||||
|
SplitterColumn.Width = new GridLength(5);
|
||||||
|
RebuildPreviewTabs();
|
||||||
|
if (_activePreviewTab != null)
|
||||||
|
LoadPreviewContent(_activePreviewTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnOpenExternal_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_activePreviewTab) || !File.Exists(_activePreviewTab))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = _activePreviewTab,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPreviewTabContextMenu(string filePath)
|
||||||
|
{
|
||||||
|
if (_previewTabPopup != null)
|
||||||
|
_previewTabPopup.IsOpen = false;
|
||||||
|
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
var stack = new StackPanel();
|
||||||
|
|
||||||
|
void AddItem(string icon, string iconColor, string label, Action action)
|
||||||
|
{
|
||||||
|
var iconBrush = string.IsNullOrEmpty(iconColor)
|
||||||
|
? secondaryText
|
||||||
|
: new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor));
|
||||||
|
var itemBorder = CreateSurfacePopupMenuItem(icon, iconBrush, label, () =>
|
||||||
|
{
|
||||||
|
_previewTabPopup!.IsOpen = false;
|
||||||
|
action();
|
||||||
|
}, primaryText, 13);
|
||||||
|
stack.Children.Add(itemBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddSeparator()
|
||||||
|
{
|
||||||
|
stack.Children.Add(CreateSurfacePopupSeparator());
|
||||||
|
}
|
||||||
|
|
||||||
|
AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = filePath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start("explorer.exe", $"/select,\"{filePath}\"");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath));
|
||||||
|
|
||||||
|
AddSeparator();
|
||||||
|
|
||||||
|
AddItem("\uE8C8", "", "경로 복사", () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Clipboard.SetText(filePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddSeparator();
|
||||||
|
|
||||||
|
AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath));
|
||||||
|
|
||||||
|
if (_previewTabs.Count > 1)
|
||||||
|
{
|
||||||
|
AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () =>
|
||||||
|
{
|
||||||
|
var keep = filePath;
|
||||||
|
_previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_activePreviewTab = keep;
|
||||||
|
RebuildPreviewTabs();
|
||||||
|
LoadPreviewContent(keep);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var popupBorder = CreateSurfacePopupContainer(stack, 180, new Thickness(4, 6, 4, 6));
|
||||||
|
|
||||||
|
_previewTabPopup = new Popup
|
||||||
|
{
|
||||||
|
Child = popupBorder,
|
||||||
|
Placement = PlacementMode.MousePoint,
|
||||||
|
StaysOpen = false,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
PopupAnimation = PopupAnimation.Fade,
|
||||||
|
};
|
||||||
|
_previewTabPopup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenPreviewPopupWindow(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
|
||||||
|
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
|
||||||
|
var win = new Window
|
||||||
|
{
|
||||||
|
Title = $"미리보기 — {fileName}",
|
||||||
|
Width = 900,
|
||||||
|
Height = 700,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||||
|
Background = bg,
|
||||||
|
};
|
||||||
|
|
||||||
|
FrameworkElement content;
|
||||||
|
|
||||||
|
switch (ext)
|
||||||
|
{
|
||||||
|
case ".html":
|
||||||
|
case ".htm":
|
||||||
|
var wv = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||||
|
wv.Loaded += async (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
|
||||||
|
userDataFolder: WebView2DataFolder);
|
||||||
|
await wv.EnsureCoreWebView2Async(env);
|
||||||
|
wv.Source = new Uri(filePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
content = wv;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ".md":
|
||||||
|
var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||||
|
var mdMood = _selectedMood;
|
||||||
|
mdWv.Loaded += async (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
|
||||||
|
userDataFolder: WebView2DataFolder);
|
||||||
|
await mdWv.EnsureCoreWebView2Async(env);
|
||||||
|
var mdSrc = File.ReadAllText(filePath);
|
||||||
|
if (mdSrc.Length > 100000)
|
||||||
|
mdSrc = mdSrc[..100000];
|
||||||
|
var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
|
||||||
|
mdWv.NavigateToString(html);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
content = mdWv;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ".csv":
|
||||||
|
var dg = new DataGrid
|
||||||
|
{
|
||||||
|
AutoGenerateColumns = true,
|
||||||
|
IsReadOnly = true,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
FontSize = 12,
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(filePath);
|
||||||
|
if (lines.Length > 0)
|
||||||
|
{
|
||||||
|
var dt = new DataTable();
|
||||||
|
var headers = ParseCsvLine(lines[0]);
|
||||||
|
foreach (var h in headers)
|
||||||
|
dt.Columns.Add(h);
|
||||||
|
for (var i = 1; i < Math.Min(lines.Length, 1001); i++)
|
||||||
|
{
|
||||||
|
var vals = ParseCsvLine(lines[i]);
|
||||||
|
var row = dt.NewRow();
|
||||||
|
for (var j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++)
|
||||||
|
row[j] = vals[j];
|
||||||
|
dt.Rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
dg.ItemsSource = dt.DefaultView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
content = dg;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
var text = File.ReadAllText(filePath);
|
||||||
|
if (text.Length > 100000)
|
||||||
|
text = text[..100000] + "\n\n... (이후 생략)";
|
||||||
|
var sv = new ScrollViewer
|
||||||
|
{
|
||||||
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||||
|
Padding = new Thickness(20),
|
||||||
|
Content = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
FontFamily = new FontFamily("Consolas"),
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = fg,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
content = sv;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
win.Content = content;
|
||||||
|
win.Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/AxCopilot/Views/ChatWindow.SelectionPopupPresentation.cs
Normal file
184
src/AxCopilot/Views/ChatWindow.SelectionPopupPresentation.cs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void ShowWorktreeMenu(UIElement placementTarget)
|
||||||
|
{
|
||||||
|
var (popup, panel) = CreateThemedPopupMenu(placementTarget, PlacementMode.Top, 320);
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var currentFolder = GetCurrentWorkFolder();
|
||||||
|
var root = string.IsNullOrWhiteSpace(currentFolder) ? "" : WorktreeStateStore.ResolveRoot(currentFolder);
|
||||||
|
var active = string.IsNullOrWhiteSpace(root) ? currentFolder : WorktreeStateStore.Load(root).Active;
|
||||||
|
var variants = GetAvailableWorkspaceVariants(root, active);
|
||||||
|
|
||||||
|
panel.Children.Add(CreatePopupSummaryStrip(new[]
|
||||||
|
{
|
||||||
|
("모드", string.Equals(active, root, StringComparison.OrdinalIgnoreCase) ? "로컬" : "워크트리", "#F8FAFC", "#E2E8F0", "#475569"),
|
||||||
|
("변형", variants.Count.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"),
|
||||||
|
}));
|
||||||
|
panel.Children.Add(CreatePopupSectionLabel("현재 작업 위치", new Thickness(8, 6, 8, 4)));
|
||||||
|
|
||||||
|
panel.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uED25",
|
||||||
|
"로컬",
|
||||||
|
string.IsNullOrWhiteSpace(root) ? "현재 워크스페이스" : root,
|
||||||
|
!string.IsNullOrWhiteSpace(root) && string.Equals(active, root, StringComparison.OrdinalIgnoreCase),
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
if (!string.IsNullOrWhiteSpace(root))
|
||||||
|
SwitchToWorkspace(root, root);
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(active) && !string.Equals(active, root, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
panel.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE7BA",
|
||||||
|
Path.GetFileName(active),
|
||||||
|
active,
|
||||||
|
true,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
SwitchToWorkspace(active, root);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variants.Count > 0)
|
||||||
|
{
|
||||||
|
panel.Children.Add(CreatePopupSectionLabel($"워크트리 / 복사본 · {variants.Count}", new Thickness(8, 10, 8, 4)));
|
||||||
|
foreach (var variant in variants)
|
||||||
|
{
|
||||||
|
var isActive = !string.IsNullOrWhiteSpace(active) &&
|
||||||
|
string.Equals(Path.GetFullPath(variant), Path.GetFullPath(active), StringComparison.OrdinalIgnoreCase);
|
||||||
|
panel.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE8B7",
|
||||||
|
Path.GetFileName(variant),
|
||||||
|
isActive ? $"현재 선택 · {variant}" : variant,
|
||||||
|
isActive,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
SwitchToWorkspace(variant, root);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.Children.Add(CreatePopupSectionLabel("새 작업 위치", new Thickness(8, 10, 8, 4)));
|
||||||
|
panel.Children.Add(CreatePopupMenuRow(
|
||||||
|
"\uE943",
|
||||||
|
"현재 브랜치로 워크트리 생성",
|
||||||
|
"Git 저장소면 분리된 작업 복사본을 만들고 전환합니다",
|
||||||
|
false,
|
||||||
|
accentBrush,
|
||||||
|
secondaryText,
|
||||||
|
primaryText,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
_ = CreateCurrentBranchWorktreeAsync();
|
||||||
|
}));
|
||||||
|
|
||||||
|
popup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreatePopupMenuRow(
|
||||||
|
string icon,
|
||||||
|
string title,
|
||||||
|
string description,
|
||||||
|
bool selected,
|
||||||
|
Brush accentBrush,
|
||||||
|
Brush secondaryText,
|
||||||
|
Brush primaryText,
|
||||||
|
Action? onClick)
|
||||||
|
{
|
||||||
|
var hintBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||||
|
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||||
|
var row = new Border
|
||||||
|
{
|
||||||
|
Background = selected ? hintBackground : Brushes.Transparent,
|
||||||
|
BorderBrush = selected ? (TryFindResource("AccentColor") as Brush ?? BrushFromHex("#D6E4FF")) : Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(12, 10, 12, 10),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Margin = new Thickness(0, 0, 0, 6),
|
||||||
|
};
|
||||||
|
|
||||||
|
var grid = new Grid();
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
var iconBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 14,
|
||||||
|
Foreground = selected ? accentBrush : secondaryText,
|
||||||
|
Margin = new Thickness(0, 1, 10, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
grid.Children.Add(iconBlock);
|
||||||
|
|
||||||
|
var textStack = new StackPanel();
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = title,
|
||||||
|
FontSize = 13.5,
|
||||||
|
FontWeight = selected ? FontWeights.SemiBold : FontWeights.Medium,
|
||||||
|
Foreground = primaryText,
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = description,
|
||||||
|
FontSize = 11.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(0, 3, 0, 0),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid.SetColumn(textStack, 1);
|
||||||
|
grid.Children.Add(textStack);
|
||||||
|
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
var check = CreateSimpleCheck(accentBrush, 14);
|
||||||
|
Grid.SetColumn(check, 2);
|
||||||
|
check.Margin = new Thickness(10, 0, 0, 0);
|
||||||
|
if (check is FrameworkElement element)
|
||||||
|
element.VerticalAlignment = VerticalAlignment.Center;
|
||||||
|
grid.Children.Add(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Child = grid;
|
||||||
|
row.MouseEnter += (_, _) => row.Background = selected ? hintBackground : hoverBackground;
|
||||||
|
row.MouseLeave += (_, _) => row.Background = selected ? hintBackground : Brushes.Transparent;
|
||||||
|
row.MouseLeftButtonUp += (_, _) => onClick?.Invoke();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/AxCopilot/Views/ChatWindow.SidebarInteractionPresentation.cs
Normal file
107
src/AxCopilot/Views/ChatWindow.SidebarInteractionPresentation.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void SidebarSearchTrigger_MouseEnter(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (SidebarSearchTrigger != null)
|
||||||
|
SidebarSearchTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||||||
|
if (SidebarSearchShortcutHint != null)
|
||||||
|
SidebarSearchShortcutHint.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SidebarSearchTrigger_MouseLeave(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (SidebarSearchTrigger != null)
|
||||||
|
SidebarSearchTrigger.Background = Brushes.Transparent;
|
||||||
|
if (SidebarSearchShortcutHint != null)
|
||||||
|
SidebarSearchShortcutHint.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SidebarSearchTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
OpenSidebarSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SidebarNewChatTrigger_MouseEnter(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (SidebarNewChatTrigger != null)
|
||||||
|
SidebarNewChatTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||||||
|
if (SidebarNewChatShortcutHint != null)
|
||||||
|
SidebarNewChatShortcutHint.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SidebarNewChatTrigger_MouseLeave(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (SidebarNewChatTrigger != null)
|
||||||
|
SidebarNewChatTrigger.Background = Brushes.Transparent;
|
||||||
|
if (SidebarNewChatShortcutHint != null)
|
||||||
|
SidebarNewChatShortcutHint.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SidebarNewChatTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
BtnNewChat_Click(sender, new RoutedEventArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnSidebarSearchClose_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
CloseSidebarSearch(clearText: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenSidebarSearch()
|
||||||
|
{
|
||||||
|
if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SidebarSearchTrigger.Visibility = Visibility.Collapsed;
|
||||||
|
SidebarSearchEditor.Visibility = Visibility.Visible;
|
||||||
|
SidebarSearchEditor.Opacity = 0;
|
||||||
|
SidebarSearchEditorScale.ScaleX = 0.85;
|
||||||
|
|
||||||
|
var duration = TimeSpan.FromMilliseconds(160);
|
||||||
|
SidebarSearchEditor.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, duration));
|
||||||
|
SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(0.85, 1, duration)
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
});
|
||||||
|
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
SearchBox?.Focus();
|
||||||
|
SearchBox?.SelectAll();
|
||||||
|
}), DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSidebarSearch(bool clearText)
|
||||||
|
{
|
||||||
|
if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (clearText && SearchBox != null && !string.IsNullOrEmpty(SearchBox.Text))
|
||||||
|
SearchBox.Text = "";
|
||||||
|
|
||||||
|
var duration = TimeSpan.FromMilliseconds(140);
|
||||||
|
var opacityAnim = new DoubleAnimation(1, 0, duration);
|
||||||
|
opacityAnim.Completed += (_, _) =>
|
||||||
|
{
|
||||||
|
SidebarSearchEditor.Visibility = Visibility.Collapsed;
|
||||||
|
SidebarSearchTrigger.Visibility = Visibility.Visible;
|
||||||
|
SidebarSearchTrigger.Background = Brushes.Transparent;
|
||||||
|
if (SidebarSearchShortcutHint != null)
|
||||||
|
SidebarSearchShortcutHint.Visibility = Visibility.Collapsed;
|
||||||
|
};
|
||||||
|
SidebarSearchEditor.BeginAnimation(OpacityProperty, opacityAnim);
|
||||||
|
SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(1, 0.85, duration)
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
322
src/AxCopilot/Views/ChatWindow.StatusPresentation.cs
Normal file
322
src/AxCopilot/Views/ChatWindow.StatusPresentation.cs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private Storyboard? _statusSpinStoryboard;
|
||||||
|
|
||||||
|
private AppStateService.OperationalStatusPresentationState BuildOperationalStatusPresentation()
|
||||||
|
{
|
||||||
|
var hasLiveRuntimeActivity = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (_runningConversationCount > 0 || _appState.ActiveTasks.Count > 0);
|
||||||
|
return _appState.GetOperationalStatusPresentation(
|
||||||
|
_activeTab,
|
||||||
|
hasLiveRuntimeActivity,
|
||||||
|
_runningConversationCount,
|
||||||
|
_spotlightConversationCount,
|
||||||
|
_runningOnlyFilter,
|
||||||
|
_sortConversationsByRecent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTaskSummaryIndicators()
|
||||||
|
{
|
||||||
|
var status = BuildOperationalStatusPresentation();
|
||||||
|
|
||||||
|
if (RuntimeActivityBadge != null)
|
||||||
|
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
|
||||||
|
if (RuntimeActivityLabel != null)
|
||||||
|
RuntimeActivityLabel.Text = status.RuntimeLabel;
|
||||||
|
|
||||||
|
if (LastCompletedLabel != null)
|
||||||
|
{
|
||||||
|
LastCompletedLabel.Text = status.LastCompletedText;
|
||||||
|
LastCompletedLabel.Visibility = status.ShowLastCompleted ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConversationStatusStrip != null && ConversationStatusStripLabel != null)
|
||||||
|
{
|
||||||
|
ConversationStatusStrip.Visibility = status.ShowCompactStrip
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
ConversationStatusStripLabel.Text = status.ShowCompactStrip ? status.StripText : "";
|
||||||
|
|
||||||
|
if (status.ShowCompactStrip)
|
||||||
|
{
|
||||||
|
ConversationStatusStrip.Background = BrushFromHex(status.StripBackgroundHex);
|
||||||
|
ConversationStatusStrip.BorderBrush = BrushFromHex(status.StripBorderHex);
|
||||||
|
ConversationStatusStripLabel.Foreground = BrushFromHex(status.StripForegroundHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateConversationQuickStripUi(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateConversationQuickStripUi()
|
||||||
|
{
|
||||||
|
UpdateConversationQuickStripUi(BuildOperationalStatusPresentation());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateConversationQuickStripUi(AppStateService.OperationalStatusPresentationState status)
|
||||||
|
{
|
||||||
|
if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null
|
||||||
|
|| BtnQuickRunningFilter == null || BtnQuickHotSort == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ConversationQuickStrip.Visibility = status.ShowQuickStrip
|
||||||
|
? Visibility.Visible
|
||||||
|
: Visibility.Collapsed;
|
||||||
|
|
||||||
|
QuickRunningLabel.Text = status.QuickRunningText;
|
||||||
|
QuickHotLabel.Text = status.QuickHotText;
|
||||||
|
|
||||||
|
BtnQuickRunningFilter.Background = BrushFromHex(status.QuickRunningBackgroundHex);
|
||||||
|
BtnQuickRunningFilter.BorderBrush = BrushFromHex(status.QuickRunningBorderHex);
|
||||||
|
BtnQuickRunningFilter.BorderThickness = new Thickness(1);
|
||||||
|
QuickRunningLabel.Foreground = BrushFromHex(status.QuickRunningForegroundHex);
|
||||||
|
|
||||||
|
BtnQuickHotSort.Background = BrushFromHex(status.QuickHotBackgroundHex);
|
||||||
|
BtnQuickHotSort.BorderBrush = BrushFromHex(status.QuickHotBorderHex);
|
||||||
|
BtnQuickHotSort.BorderThickness = new Thickness(1);
|
||||||
|
QuickHotLabel.Foreground = BrushFromHex(status.QuickHotForegroundHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetStatus(string text, bool spinning)
|
||||||
|
{
|
||||||
|
if (StatusLabel != null)
|
||||||
|
StatusLabel.Text = text;
|
||||||
|
|
||||||
|
if (spinning)
|
||||||
|
StartStatusAnimation();
|
||||||
|
else
|
||||||
|
StopStatusAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDecisionPending(string? summary)
|
||||||
|
{
|
||||||
|
var text = summary?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDecisionApproved(string? summary)
|
||||||
|
{
|
||||||
|
var text = summary?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDecisionRejected(string? summary)
|
||||||
|
{
|
||||||
|
var text = summary?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| text.Contains("취소", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDecisionStatusText(string? summary)
|
||||||
|
{
|
||||||
|
if (IsDecisionPending(summary))
|
||||||
|
return "계획 승인 대기 중";
|
||||||
|
if (IsDecisionApproved(summary))
|
||||||
|
return "계획 승인됨 · 실행 시작";
|
||||||
|
if (IsDecisionRejected(summary))
|
||||||
|
return "계획 반려됨 · 계획 수정";
|
||||||
|
return string.IsNullOrWhiteSpace(summary) ? "사용자 의사결정 대기 중" : TruncateForStatus(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (int PromptTokens, int CompletionTokens) GetConversationTokenAggregate()
|
||||||
|
{
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation?.Messages == null || _currentConversation.Messages.Count == 0)
|
||||||
|
return (0, 0);
|
||||||
|
|
||||||
|
var prompt = 0;
|
||||||
|
var completion = 0;
|
||||||
|
foreach (var message in _currentConversation.Messages)
|
||||||
|
{
|
||||||
|
prompt += Math.Max(0, message.PromptTokens);
|
||||||
|
completion += Math.Max(0, message.CompletionTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (prompt, completion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshStatusTokenAggregate()
|
||||||
|
{
|
||||||
|
var promptTokens = Math.Max(0, _agentCumulativeInputTokens);
|
||||||
|
var completionTokens = Math.Max(0, _agentCumulativeOutputTokens);
|
||||||
|
|
||||||
|
if (promptTokens == 0 && completionTokens == 0)
|
||||||
|
{
|
||||||
|
var aggregate = GetConversationTokenAggregate();
|
||||||
|
promptTokens = aggregate.PromptTokens;
|
||||||
|
completionTokens = aggregate.CompletionTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promptTokens > 0 || completionTokens > 0)
|
||||||
|
UpdateStatusTokens(promptTokens, completionTokens);
|
||||||
|
else if (StatusTokens != null)
|
||||||
|
{
|
||||||
|
StatusTokens.Text = "";
|
||||||
|
StatusTokens.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetStatusIdle()
|
||||||
|
{
|
||||||
|
StopStatusAnimation();
|
||||||
|
if (StatusLabel != null)
|
||||||
|
StatusLabel.Text = "대기 중";
|
||||||
|
if (StatusElapsed != null)
|
||||||
|
{
|
||||||
|
StatusElapsed.Text = "";
|
||||||
|
StatusElapsed.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshStatusTokenAggregate();
|
||||||
|
RefreshContextUsageVisual();
|
||||||
|
ScheduleGitBranchRefresh(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatusTokens(int inputTokens, int outputTokens)
|
||||||
|
{
|
||||||
|
if (StatusTokens == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var llm = _settings.Settings.Llm;
|
||||||
|
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
|
||||||
|
inputTokens, outputTokens, llm.Service, llm.Model);
|
||||||
|
var totalCost = inCost + outCost;
|
||||||
|
var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : "";
|
||||||
|
StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}";
|
||||||
|
StatusTokens.Visibility = Visibility.Visible;
|
||||||
|
RefreshContextUsageVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatusBar(AgentEvent evt)
|
||||||
|
{
|
||||||
|
var toolLabel = evt.ToolName switch
|
||||||
|
{
|
||||||
|
"file_read" or "document_read" => "파일 읽기",
|
||||||
|
"file_write" => "파일 쓰기",
|
||||||
|
"file_edit" => "파일 수정",
|
||||||
|
"html_create" => "HTML 생성",
|
||||||
|
"xlsx_create" => "Excel 생성",
|
||||||
|
"docx_create" => "Word 생성",
|
||||||
|
"csv_create" => "CSV 생성",
|
||||||
|
"md_create" => "Markdown 생성",
|
||||||
|
"folder_map" => "폴더 탐색",
|
||||||
|
"glob" => "파일 검색",
|
||||||
|
"grep" => "내용 검색",
|
||||||
|
"process" => "명령 실행",
|
||||||
|
_ => evt.ToolName,
|
||||||
|
};
|
||||||
|
|
||||||
|
var isDebugLogLevel = string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
switch (evt.Type)
|
||||||
|
{
|
||||||
|
case AgentEventType.Thinking:
|
||||||
|
SetStatus("생각 중...", spinning: true);
|
||||||
|
break;
|
||||||
|
case AgentEventType.Planning:
|
||||||
|
SetStatus($"계획 정리 중... {evt.StepTotal}단계", spinning: true);
|
||||||
|
break;
|
||||||
|
case AgentEventType.PermissionRequest:
|
||||||
|
SetStatus($"권한 확인 중 · {toolLabel}", spinning: false);
|
||||||
|
break;
|
||||||
|
case AgentEventType.PermissionGranted:
|
||||||
|
SetStatus($"권한 승인됨 · {toolLabel}", spinning: false);
|
||||||
|
break;
|
||||||
|
case AgentEventType.PermissionDenied:
|
||||||
|
SetStatus($"권한 거부됨 · {toolLabel}", spinning: false);
|
||||||
|
StopStatusAnimation();
|
||||||
|
break;
|
||||||
|
case AgentEventType.Decision:
|
||||||
|
SetStatus(GetDecisionStatusText(evt.Summary), spinning: IsDecisionPending(evt.Summary));
|
||||||
|
break;
|
||||||
|
case AgentEventType.ToolCall:
|
||||||
|
if (!isDebugLogLevel)
|
||||||
|
break;
|
||||||
|
SetStatus($"{toolLabel} 실행 중...", spinning: true);
|
||||||
|
break;
|
||||||
|
case AgentEventType.ToolResult:
|
||||||
|
SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false);
|
||||||
|
break;
|
||||||
|
case AgentEventType.StepStart:
|
||||||
|
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true);
|
||||||
|
break;
|
||||||
|
case AgentEventType.StepDone:
|
||||||
|
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true);
|
||||||
|
break;
|
||||||
|
case AgentEventType.SkillCall:
|
||||||
|
if (!isDebugLogLevel)
|
||||||
|
break;
|
||||||
|
SetStatus($"스킬 실행 중 · {TruncateForStatus(evt.Summary)}", spinning: true);
|
||||||
|
break;
|
||||||
|
case AgentEventType.Complete:
|
||||||
|
SetStatus("작업 완료", spinning: false);
|
||||||
|
StopStatusAnimation();
|
||||||
|
break;
|
||||||
|
case AgentEventType.Error:
|
||||||
|
SetStatus("오류 발생", spinning: false);
|
||||||
|
StopStatusAnimation();
|
||||||
|
break;
|
||||||
|
case AgentEventType.Paused:
|
||||||
|
if (!isDebugLogLevel)
|
||||||
|
break;
|
||||||
|
SetStatus("일시정지", spinning: false);
|
||||||
|
break;
|
||||||
|
case AgentEventType.Resumed:
|
||||||
|
if (!isDebugLogLevel)
|
||||||
|
break;
|
||||||
|
SetStatus("재개", spinning: true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartStatusAnimation()
|
||||||
|
{
|
||||||
|
if (_statusSpinStoryboard != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var anim = new DoubleAnimation
|
||||||
|
{
|
||||||
|
From = 0,
|
||||||
|
To = 360,
|
||||||
|
Duration = TimeSpan.FromSeconds(2),
|
||||||
|
RepeatBehavior = RepeatBehavior.Forever,
|
||||||
|
};
|
||||||
|
|
||||||
|
_statusSpinStoryboard = new Storyboard();
|
||||||
|
Storyboard.SetTarget(anim, StatusDiamond);
|
||||||
|
Storyboard.SetTargetProperty(anim,
|
||||||
|
new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)"));
|
||||||
|
_statusSpinStoryboard.Children.Add(anim);
|
||||||
|
_statusSpinStoryboard.Begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopStatusAnimation()
|
||||||
|
{
|
||||||
|
_statusSpinStoryboard?.Stop();
|
||||||
|
_statusSpinStoryboard = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/AxCopilot/Views/ChatWindow.SurfaceVisualPresentation.cs
Normal file
121
src/AxCopilot/Views/ChatWindow.SurfaceVisualPresentation.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private Border CreateSurfacePopupContainer(UIElement content, double minWidth = 180, Thickness? padding = null)
|
||||||
|
{
|
||||||
|
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = bg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = padding ?? new Thickness(6),
|
||||||
|
MinWidth = minWidth,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{
|
||||||
|
BlurRadius = 16,
|
||||||
|
ShadowDepth = 4,
|
||||||
|
Opacity = 0.34,
|
||||||
|
Color = Colors.Black,
|
||||||
|
},
|
||||||
|
Child = content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateSurfacePopupMenuItem(string icon, Brush iconBrush, string label, Action onClick, Brush? labelBrush = null, double fontSize = 12.5)
|
||||||
|
{
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||||
|
|
||||||
|
var item = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(10, 8, 14, 8),
|
||||||
|
Margin = new Thickness(0, 1, 0, 1),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
row.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = iconBrush,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 10, 0),
|
||||||
|
});
|
||||||
|
row.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
FontSize = fontSize,
|
||||||
|
Foreground = labelBrush ?? primaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
item.Child = row;
|
||||||
|
|
||||||
|
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
|
||||||
|
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
|
||||||
|
item.MouseLeftButtonUp += (_, _) => onClick();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateSurfacePopupSeparator()
|
||||||
|
{
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Height = 1,
|
||||||
|
Margin = new Thickness(10, 4, 10, 4),
|
||||||
|
Background = borderBrush,
|
||||||
|
Opacity = 0.3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private StackPanel CreateSurfaceFileTreeHeader(string icon, string name, string? sizeText)
|
||||||
|
{
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
sp.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 5, 0),
|
||||||
|
});
|
||||||
|
sp.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = name,
|
||||||
|
FontSize = 11.5,
|
||||||
|
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrEmpty(sizeText))
|
||||||
|
{
|
||||||
|
sp.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $" {sizeText}",
|
||||||
|
FontSize = 10,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
}
|
||||||
358
src/AxCopilot/Views/ChatWindow.TaskSummary.cs
Normal file
358
src/AxCopilot/Views/ChatWindow.TaskSummary.cs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private void RuntimeTaskSummary_Click(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
_taskSummaryTarget = sender as UIElement ?? RuntimeActivityBadge;
|
||||||
|
ShowTaskSummaryPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowTaskSummaryPopup()
|
||||||
|
{
|
||||||
|
if (_taskSummaryTarget == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_taskSummaryPopup != null)
|
||||||
|
_taskSummaryPopup.IsOpen = false;
|
||||||
|
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||||||
|
var popupBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(2) };
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "작업 요약",
|
||||||
|
FontSize = 11,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(8, 5, 8, 2),
|
||||||
|
});
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "현재 상태 요약",
|
||||||
|
FontSize = 8.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(8, 0, 8, 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatConversation? currentConversation;
|
||||||
|
lock (_convLock) currentConversation = _currentConversation;
|
||||||
|
AddTaskSummaryObservabilitySections(panel, currentConversation);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId))
|
||||||
|
{
|
||||||
|
var currentRun = new Border
|
||||||
|
{
|
||||||
|
Background = BrushFromHex("#F8FAFC"),
|
||||||
|
BorderBrush = BrushFromHex("#E2E8F0"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(8, 6, 8, 6),
|
||||||
|
Margin = new Thickness(6, 0, 6, 6),
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"실행 run {ShortRunId(_appState.AgentRun.RunId)}",
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
FontSize = 9.75,
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · step {_appState.AgentRun.LastIteration}",
|
||||||
|
Margin = new Thickness(0, 2, 0, 0),
|
||||||
|
Foreground = GetRunStatusBrush(_appState.AgentRun.Status),
|
||||||
|
FontSize = 9,
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary,
|
||||||
|
Margin = new Thickness(0, 3, 0, 0),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = Brushes.DimGray,
|
||||||
|
FontSize = 9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
panel.Children.Add(currentRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
var recentAgentRuns = _appState.GetRecentAgentRuns(1);
|
||||||
|
if (recentAgentRuns.Count > 0)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "마지막 실행",
|
||||||
|
FontSize = 9,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = Brushes.DimGray,
|
||||||
|
Margin = new Thickness(8, 0, 8, 2),
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var run in recentAgentRuns)
|
||||||
|
{
|
||||||
|
var runEvents = GetExecutionEventsForRun(run.RunId, 1);
|
||||||
|
var runFilePaths = GetExecutionEventFilePaths(run.RunId, 1);
|
||||||
|
var runDisplay = _appState.GetRunDisplay(run);
|
||||||
|
var runCardStack = new StackPanel
|
||||||
|
{
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = runDisplay.HeaderText,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = GetRunStatusBrush(run.Status),
|
||||||
|
FontSize = 9.5,
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = runDisplay.MetaText,
|
||||||
|
Margin = new Thickness(0, 1, 0, 0),
|
||||||
|
Foreground = secondaryText,
|
||||||
|
FontSize = 8.25,
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = TruncateForStatus(runDisplay.SummaryText, 92),
|
||||||
|
Margin = new Thickness(0, 1.5, 0, 0),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
FontSize = 8.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (runEvents.Count > 0 || runFilePaths.Count > 0)
|
||||||
|
{
|
||||||
|
var activitySummary = new StackPanel();
|
||||||
|
activitySummary.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"로그 {runEvents.Count} · 파일 {runFilePaths.Count}",
|
||||||
|
FontSize = 8,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(run.RunId))
|
||||||
|
{
|
||||||
|
var capturedRunId = run.RunId;
|
||||||
|
var timelineButton = CreateTaskSummaryActionButton(
|
||||||
|
"타임라인",
|
||||||
|
"#F8FAFC",
|
||||||
|
"#CBD5E1",
|
||||||
|
"#334155",
|
||||||
|
(_, _) => ScrollToRunInTimeline(capturedRunId),
|
||||||
|
trailingMargin: false);
|
||||||
|
timelineButton.Margin = new Thickness(0, 5, 0, 0);
|
||||||
|
activitySummary.Children.Add(timelineButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
runCardStack.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = BrushFromHex("#F8FAFC"),
|
||||||
|
BorderBrush = BrushFromHex("#E2E8F0"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(7),
|
||||||
|
Padding = new Thickness(6, 4, 6, 4),
|
||||||
|
Margin = new Thickness(0, 5, 0, 0),
|
||||||
|
Child = activitySummary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var capturedRun = run;
|
||||||
|
var followUpButton = CreateTaskSummaryActionButton(
|
||||||
|
"후속 큐",
|
||||||
|
"#ECFDF5",
|
||||||
|
"#BBF7D0",
|
||||||
|
"#166534",
|
||||||
|
(_, _) => EnqueueFollowUpFromRun(capturedRun),
|
||||||
|
trailingMargin: false);
|
||||||
|
followUpButton.Margin = new Thickness(0, 6, 0, 0);
|
||||||
|
runCardStack.Children.Add(followUpButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
|
||||||
|
{
|
||||||
|
var retryButton = CreateTaskSummaryActionButton(
|
||||||
|
"다시 시도",
|
||||||
|
"#FEF2F2",
|
||||||
|
"#FCA5A5",
|
||||||
|
"#991B1B",
|
||||||
|
(_, _) =>
|
||||||
|
{
|
||||||
|
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
|
||||||
|
RetryLastUserMessageFromConversation();
|
||||||
|
},
|
||||||
|
trailingMargin: false);
|
||||||
|
retryButton.Margin = new Thickness(0, 6, 0, 0);
|
||||||
|
runCardStack.Children.Add(retryButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = popupBackground,
|
||||||
|
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(7),
|
||||||
|
Padding = new Thickness(7, 5, 7, 5),
|
||||||
|
Margin = new Thickness(6, 0, 6, 4),
|
||||||
|
Child = runCardStack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTasks = FilterTaskSummaryItems(_appState.ActiveTasks).Take(3).ToList();
|
||||||
|
var recentTasks = FilterTaskSummaryItems(_appState.RecentTasks).Take(2).ToList();
|
||||||
|
|
||||||
|
foreach (var task in activeTasks)
|
||||||
|
panel.Children.Add(BuildTaskSummaryCard(task, active: true));
|
||||||
|
|
||||||
|
if (ShouldIncludeRecentTaskSummary(activeTasks))
|
||||||
|
{
|
||||||
|
foreach (var task in recentTasks)
|
||||||
|
panel.Children.Add(BuildTaskSummaryCard(task, active: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTasks.Count == 0 && recentTasks.Count == 0)
|
||||||
|
{
|
||||||
|
panel.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "표시할 작업 이력이 없습니다.",
|
||||||
|
Margin = new Thickness(10, 2, 10, 8),
|
||||||
|
Foreground = secondaryText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_taskSummaryPopup = new Popup
|
||||||
|
{
|
||||||
|
PlacementTarget = _taskSummaryTarget,
|
||||||
|
Placement = PlacementMode.Top,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
StaysOpen = false,
|
||||||
|
PopupAnimation = PopupAnimation.Fade,
|
||||||
|
Child = new Border
|
||||||
|
{
|
||||||
|
Background = popupBackground,
|
||||||
|
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(6),
|
||||||
|
Child = new ScrollViewer
|
||||||
|
{
|
||||||
|
Content = panel,
|
||||||
|
MaxHeight = 340,
|
||||||
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_taskSummaryPopup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active)
|
||||||
|
{
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
|
||||||
|
var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind);
|
||||||
|
var categoryLabel = GetTranscriptTaskCategory(task);
|
||||||
|
var displayTitle = string.Equals(task.Kind, "tool", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(task.Kind, "hook", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? GetAgentItemDisplayName(task.Title)
|
||||||
|
: task.Title;
|
||||||
|
var taskStack = new StackPanel();
|
||||||
|
var headerRow = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Margin = new Thickness(0, 0, 0, 2),
|
||||||
|
};
|
||||||
|
headerRow.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = kindIcon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 9.5,
|
||||||
|
Foreground = kindColor,
|
||||||
|
Margin = new Thickness(0, 0, 4, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
headerRow.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = active
|
||||||
|
? $"진행 중 · {displayTitle}"
|
||||||
|
: $"{GetTaskStatusLabel(task.Status)} · {displayTitle}",
|
||||||
|
FontSize = 9.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = active ? primaryText : secondaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
});
|
||||||
|
taskStack.Children.Add(headerRow);
|
||||||
|
|
||||||
|
taskStack.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = BrushFromHex("#F8FAFC"),
|
||||||
|
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Padding = new Thickness(6, 1, 6, 1),
|
||||||
|
Margin = new Thickness(0, 0, 0, 4),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = categoryLabel,
|
||||||
|
FontSize = 8,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(task.Summary))
|
||||||
|
{
|
||||||
|
taskStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = TruncateForStatus(task.Summary, 96),
|
||||||
|
FontSize = 8.75,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewChipRow = BuildReviewSignalChipRow(
|
||||||
|
kind: task.Kind,
|
||||||
|
toolName: task.Title,
|
||||||
|
title: displayTitle,
|
||||||
|
summary: task.Summary);
|
||||||
|
if (reviewChipRow != null)
|
||||||
|
taskStack.Children.Add(reviewChipRow);
|
||||||
|
|
||||||
|
var actionRow = BuildTaskSummaryActionRow(task, active);
|
||||||
|
if (actionRow != null)
|
||||||
|
taskStack.Children.Add(actionRow);
|
||||||
|
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = active ? BrushFromHex("#F8FAFC") : (TryFindResource("LauncherBackground") as Brush ?? Brushes.White),
|
||||||
|
BorderBrush = BrushFromHex("#E5E7EB"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(7),
|
||||||
|
Padding = new Thickness(8, 5, 8, 5),
|
||||||
|
Margin = new Thickness(8, 0, 8, 4),
|
||||||
|
Child = taskStack
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs
Normal file
261
src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private List<ChatMessage> GetVisibleTimelineMessages(ChatConversation? conversation)
|
||||||
|
{
|
||||||
|
return conversation?.Messages?.Where(msg =>
|
||||||
|
{
|
||||||
|
if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.IsNullOrWhiteSpace(msg.Content))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).ToList() ?? new List<ChatMessage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation)
|
||||||
|
{
|
||||||
|
var events = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
|
||||||
|
if (conversation?.ShowExecutionHistory ?? true)
|
||||||
|
return events;
|
||||||
|
|
||||||
|
return events
|
||||||
|
.Where(ShouldShowCollapsedProgressEvent)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
|
||||||
|
{
|
||||||
|
var restoredEvent = ToAgentEvent(executionEvent);
|
||||||
|
if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (restoredEvent.Type == AgentEventType.Thinking)
|
||||||
|
{
|
||||||
|
if (string.Equals(restoredEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(restoredEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(restoredEvent.Summary))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsProcessFeedEvent(restoredEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<(DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
|
||||||
|
IReadOnlyCollection<ChatMessage> visibleMessages,
|
||||||
|
IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
|
||||||
|
{
|
||||||
|
var timeline = new List<(DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
|
||||||
|
|
||||||
|
foreach (var msg in visibleMessages)
|
||||||
|
timeline.Add((msg.Timestamp, 0, () => AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg)));
|
||||||
|
|
||||||
|
foreach (var executionEvent in visibleEvents)
|
||||||
|
{
|
||||||
|
var restoredEvent = ToAgentEvent(executionEvent);
|
||||||
|
timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var liveProgressHint = GetLiveAgentProgressHint();
|
||||||
|
if (liveProgressHint != null)
|
||||||
|
timeline.Add((liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
|
||||||
|
|
||||||
|
return timeline
|
||||||
|
.OrderBy(x => x.Timestamp)
|
||||||
|
.ThenBy(x => x.Order)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateTimelineLoadMoreCard(int hiddenCount)
|
||||||
|
{
|
||||||
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155");
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B");
|
||||||
|
|
||||||
|
var loadMoreBtn = new Button
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(7, 3, 7, 3),
|
||||||
|
Cursor = System.Windows.Input.Cursors.Hand,
|
||||||
|
Foreground = primaryText,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
};
|
||||||
|
loadMoreBtn.Template = BuildMinimalIconButtonTemplate();
|
||||||
|
loadMoreBtn.Content = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE70D",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 8,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(0, 0, 4, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
},
|
||||||
|
new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"이전 대화 {hiddenCount:N0}개",
|
||||||
|
FontSize = 9.25,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg;
|
||||||
|
loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent;
|
||||||
|
loadMoreBtn.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
_timelineRenderLimit += TimelineRenderPageSize;
|
||||||
|
RenderMessages(preserveViewport: true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(10),
|
||||||
|
Margin = new Thickness(0, 2, 0, 8),
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Child = loadMoreBtn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent)
|
||||||
|
{
|
||||||
|
var parsedType = Enum.TryParse<AgentEventType>(executionEvent.Type, out var eventType)
|
||||||
|
? eventType
|
||||||
|
: AgentEventType.Thinking;
|
||||||
|
|
||||||
|
return new AgentEvent
|
||||||
|
{
|
||||||
|
Timestamp = executionEvent.Timestamp,
|
||||||
|
RunId = executionEvent.RunId,
|
||||||
|
Type = parsedType,
|
||||||
|
ToolName = executionEvent.ToolName,
|
||||||
|
Summary = executionEvent.Summary,
|
||||||
|
FilePath = executionEvent.FilePath,
|
||||||
|
Success = executionEvent.Success,
|
||||||
|
StepCurrent = executionEvent.StepCurrent,
|
||||||
|
StepTotal = executionEvent.StepTotal,
|
||||||
|
Steps = executionEvent.Steps,
|
||||||
|
ElapsedMs = executionEvent.ElapsedMs,
|
||||||
|
InputTokens = executionEvent.InputTokens,
|
||||||
|
OutputTokens = executionEvent.OutputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCompactionMetaMessage(ChatMessage? message)
|
||||||
|
{
|
||||||
|
var kind = message?.MetaKind ?? "";
|
||||||
|
return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
|
||||||
|
{
|
||||||
|
var icon = "\uE9CE";
|
||||||
|
var title = message.MetaKind switch
|
||||||
|
{
|
||||||
|
"session_memory_compaction" => "세션 메모리 압축",
|
||||||
|
"collapsed_boundary" => "압축 경계 병합",
|
||||||
|
_ => "Microcompact 경계",
|
||||||
|
};
|
||||||
|
|
||||||
|
var wrapper = new Border
|
||||||
|
{
|
||||||
|
Background = hintBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(12, 10, 12, 10),
|
||||||
|
Margin = new Thickness(10, 4, 150, 4),
|
||||||
|
MaxWidth = GetMessageMaxWidth(),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel();
|
||||||
|
var header = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Margin = new Thickness(0, 0, 0, 6),
|
||||||
|
};
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = accentBrush,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = title,
|
||||||
|
FontSize = 11,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
Margin = new Thickness(6, 0, 0, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
stack.Children.Add(header);
|
||||||
|
|
||||||
|
var lines = (message.Content ?? "")
|
||||||
|
.Replace("\r\n", "\n")
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(line => line.Trim())
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal);
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = isHeaderLine ? line.Trim('[', ']') : line,
|
||||||
|
FontSize = 10.5,
|
||||||
|
FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal,
|
||||||
|
Foreground = isHeaderLine ? primaryText : secondaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.MetaRunId))
|
||||||
|
{
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"run {message.MetaRunId}",
|
||||||
|
FontSize = 9.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Opacity = 0.7,
|
||||||
|
Margin = new Thickness(0, 6, 0, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.Child = stack;
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
520
src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs
Normal file
520
src/AxCopilot/Views/ChatWindow.TopicPresetPresentation.cs
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
/// <summary>프리셋 대화 주제 버튼을 동적으로 생성합니다.</summary>
|
||||||
|
private void BuildTopicButtons()
|
||||||
|
{
|
||||||
|
TopicButtonPanel.Children.Clear();
|
||||||
|
TopicButtonPanel.Visibility = Visibility.Visible;
|
||||||
|
if (TopicPresetScrollViewer != null)
|
||||||
|
TopicPresetScrollViewer.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
if (_activeTab == "Cowork" || _activeTab == "Code")
|
||||||
|
{
|
||||||
|
if (EmptyStateTitle != null)
|
||||||
|
EmptyStateTitle.Text = _activeTab == "Code" ? "코드 작업을 입력하세요" : "작업 유형을 선택하세요";
|
||||||
|
if (EmptyStateDesc != null)
|
||||||
|
EmptyStateDesc.Text = _activeTab == "Code"
|
||||||
|
? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다"
|
||||||
|
: "에이전트가 상세한 데이터를 작성합니다";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (EmptyStateTitle != null)
|
||||||
|
EmptyStateTitle.Text = "대화 주제를 선택하세요";
|
||||||
|
if (EmptyStateDesc != null)
|
||||||
|
EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeTab == "Code")
|
||||||
|
{
|
||||||
|
TopicButtonPanel.Visibility = Visibility.Collapsed;
|
||||||
|
if (TopicPresetScrollViewer != null)
|
||||||
|
TopicPresetScrollViewer.Visibility = Visibility.Collapsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
|
||||||
|
var cardBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
|
||||||
|
var cardHoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||||||
|
var cardBorder = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB");
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
void AttachTopicCardHover(Border card, Brush normalBackground, Brush hoverBackground)
|
||||||
|
{
|
||||||
|
card.MouseEnter += (sender, _) =>
|
||||||
|
{
|
||||||
|
if (sender is Border hovered)
|
||||||
|
{
|
||||||
|
hovered.Background = hoverBackground;
|
||||||
|
hovered.BorderBrush = TryFindResource("AccentColor") as Brush ?? cardBorder;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
card.MouseLeave += (sender, _) =>
|
||||||
|
{
|
||||||
|
if (sender is Border hovered)
|
||||||
|
{
|
||||||
|
hovered.Background = normalBackground;
|
||||||
|
hovered.BorderBrush = cardBorder;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var preset in presets)
|
||||||
|
{
|
||||||
|
var capturedPreset = preset;
|
||||||
|
var buttonColor = BrushFromHex(preset.Color);
|
||||||
|
|
||||||
|
var border = new Border
|
||||||
|
{
|
||||||
|
Background = cardBackground,
|
||||||
|
BorderBrush = cardBorder,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(14, 14, 14, 12),
|
||||||
|
Margin = new Thickness(6, 6, 6, 8),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Width = 148,
|
||||||
|
Height = 124,
|
||||||
|
ClipToBounds = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var contentGrid = new Grid();
|
||||||
|
var stack = new StackPanel
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
|
||||||
|
var iconCircle = new Border
|
||||||
|
{
|
||||||
|
Width = 34,
|
||||||
|
Height = 34,
|
||||||
|
CornerRadius = new CornerRadius(17),
|
||||||
|
Background = new SolidColorBrush(((SolidColorBrush)buttonColor).Color) { Opacity = 0.15 },
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 0, 9),
|
||||||
|
};
|
||||||
|
var iconBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = preset.Symbol,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 15,
|
||||||
|
Foreground = buttonColor,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
iconCircle.Child = iconBlock;
|
||||||
|
stack.Children.Add(iconCircle);
|
||||||
|
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = preset.Label,
|
||||||
|
FontSize = 15,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
TextAlignment = TextAlignment.Center,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
MaxWidth = 112,
|
||||||
|
});
|
||||||
|
|
||||||
|
contentGrid.Children.Add(stack);
|
||||||
|
|
||||||
|
if (capturedPreset.IsCustom)
|
||||||
|
{
|
||||||
|
var badge = new Border
|
||||||
|
{
|
||||||
|
Width = 16,
|
||||||
|
Height = 16,
|
||||||
|
CornerRadius = new CornerRadius(4),
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xFF, 0xFF)),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(2, 2, 0, 0),
|
||||||
|
};
|
||||||
|
badge.Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE710",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 8,
|
||||||
|
Foreground = buttonColor,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
contentGrid.Children.Add(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
border.Child = contentGrid;
|
||||||
|
AttachTopicCardHover(border, cardBackground, cardHoverBackground);
|
||||||
|
border.MouseLeftButtonDown += (_, _) => SelectTopic(capturedPreset);
|
||||||
|
|
||||||
|
if (capturedPreset.IsCustom)
|
||||||
|
{
|
||||||
|
border.MouseRightButtonUp += (sender, args) =>
|
||||||
|
{
|
||||||
|
args.Handled = true;
|
||||||
|
ShowCustomPresetContextMenu(sender as Border, capturedPreset);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
TopicButtonPanel.Children.Add(border);
|
||||||
|
}
|
||||||
|
|
||||||
|
var etcColor = BrushFromHex("#6B7280");
|
||||||
|
var etcBorder = new Border
|
||||||
|
{
|
||||||
|
Background = cardBackground,
|
||||||
|
BorderBrush = cardBorder,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(14, 14, 14, 12),
|
||||||
|
Margin = new Thickness(6, 6, 6, 8),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Width = 148,
|
||||||
|
Height = 124,
|
||||||
|
ClipToBounds = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var etcGrid = new Grid();
|
||||||
|
var etcStack = new StackPanel
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
|
||||||
|
var etcIconCircle = new Border
|
||||||
|
{
|
||||||
|
Width = 34,
|
||||||
|
Height = 34,
|
||||||
|
CornerRadius = new CornerRadius(17),
|
||||||
|
Background = new SolidColorBrush(((SolidColorBrush)etcColor).Color) { Opacity = 0.15 },
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 0, 9),
|
||||||
|
};
|
||||||
|
etcIconCircle.Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE70F",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 15,
|
||||||
|
Foreground = etcColor,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
etcStack.Children.Add(etcIconCircle);
|
||||||
|
etcStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "기타",
|
||||||
|
FontSize = 15,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
TextAlignment = TextAlignment.Center,
|
||||||
|
});
|
||||||
|
etcGrid.Children.Add(etcStack);
|
||||||
|
etcBorder.Child = etcGrid;
|
||||||
|
AttachTopicCardHover(etcBorder, cardBackground, cardHoverBackground);
|
||||||
|
etcBorder.MouseLeftButtonDown += (_, _) =>
|
||||||
|
{
|
||||||
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
InputBox.Focus();
|
||||||
|
};
|
||||||
|
TopicButtonPanel.Children.Add(etcBorder);
|
||||||
|
|
||||||
|
var addBorder = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = cardBorder,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(14, 14, 14, 12),
|
||||||
|
Margin = new Thickness(6, 6, 6, 8),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Width = 148,
|
||||||
|
Height = 124,
|
||||||
|
ClipToBounds = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var addGrid = new Grid();
|
||||||
|
var addStack = new StackPanel
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
|
||||||
|
addStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\uE710",
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 18,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 8, 0, 8),
|
||||||
|
});
|
||||||
|
addStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "프리셋 추가",
|
||||||
|
FontSize = 14,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
});
|
||||||
|
|
||||||
|
addGrid.Children.Add(addStack);
|
||||||
|
addBorder.Child = addGrid;
|
||||||
|
AttachTopicCardHover(addBorder, Brushes.Transparent, cardHoverBackground);
|
||||||
|
addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog();
|
||||||
|
TopicButtonPanel.Children.Add(addBorder);
|
||||||
|
|
||||||
|
UpdateTopicPresetScrollMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTopicPresetScrollMode()
|
||||||
|
{
|
||||||
|
if (TopicPresetScrollViewer == null || TopicButtonPanel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
if (TopicPresetScrollViewer == null || TopicButtonPanel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
TopicPresetScrollViewer.UpdateLayout();
|
||||||
|
var shouldScroll = TopicPresetScrollViewer.ExtentHeight > TopicPresetScrollViewer.ViewportHeight + 1;
|
||||||
|
TopicPresetScrollViewer.VerticalScrollBarVisibility = shouldScroll
|
||||||
|
? ScrollBarVisibility.Auto
|
||||||
|
: ScrollBarVisibility.Disabled;
|
||||||
|
TopicPresetScrollViewer.Padding = shouldScroll
|
||||||
|
? new Thickness(0, 2, 6, 0)
|
||||||
|
: new Thickness(0, 2, 0, 0);
|
||||||
|
}), System.Windows.Threading.DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowCustomPresetDialog(CustomPresetEntry? existing = null)
|
||||||
|
{
|
||||||
|
var dialog = new CustomPresetDialog(
|
||||||
|
existingName: existing?.Label ?? "",
|
||||||
|
existingDesc: existing?.Description ?? "",
|
||||||
|
existingPrompt: existing?.SystemPrompt ?? "",
|
||||||
|
existingColor: existing?.Color ?? "#6366F1",
|
||||||
|
existingSymbol: existing?.Symbol ?? "\uE713",
|
||||||
|
existingTab: existing?.Tab ?? _activeTab)
|
||||||
|
{
|
||||||
|
Owner = this,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dialog.ShowDialog() != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Label = dialog.PresetName;
|
||||||
|
existing.Description = dialog.PresetDescription;
|
||||||
|
existing.SystemPrompt = dialog.PresetSystemPrompt;
|
||||||
|
existing.Color = dialog.PresetColor;
|
||||||
|
existing.Symbol = dialog.PresetSymbol;
|
||||||
|
existing.Tab = dialog.PresetTab;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_settings.Settings.Llm.CustomPresets.Add(new CustomPresetEntry
|
||||||
|
{
|
||||||
|
Label = dialog.PresetName,
|
||||||
|
Description = dialog.PresetDescription,
|
||||||
|
SystemPrompt = dialog.PresetSystemPrompt,
|
||||||
|
Color = dialog.PresetColor,
|
||||||
|
Symbol = dialog.PresetSymbol,
|
||||||
|
Tab = dialog.PresetTab,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Save();
|
||||||
|
BuildTopicButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset)
|
||||||
|
{
|
||||||
|
if (anchor == null || preset.CustomId == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var popup = new Popup
|
||||||
|
{
|
||||||
|
PlacementTarget = anchor,
|
||||||
|
Placement = PlacementMode.Bottom,
|
||||||
|
StaysOpen = false,
|
||||||
|
AllowsTransparency = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var menuBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
|
||||||
|
var menuBorder = new Border
|
||||||
|
{
|
||||||
|
Background = menuBackground,
|
||||||
|
CornerRadius = new CornerRadius(10),
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(4),
|
||||||
|
MinWidth = 120,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{
|
||||||
|
BlurRadius = 12,
|
||||||
|
ShadowDepth = 2,
|
||||||
|
Opacity = 0.3,
|
||||||
|
Color = Colors.Black,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel();
|
||||||
|
|
||||||
|
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText);
|
||||||
|
editItem.MouseLeftButtonDown += (_, _) =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
var entry = _settings.Settings.Llm.CustomPresets.FirstOrDefault(item => item.Id == preset.CustomId);
|
||||||
|
if (entry != null)
|
||||||
|
ShowCustomPresetDialog(entry);
|
||||||
|
};
|
||||||
|
stack.Children.Add(editItem);
|
||||||
|
|
||||||
|
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)));
|
||||||
|
deleteItem.MouseLeftButtonDown += (_, _) =>
|
||||||
|
{
|
||||||
|
popup.IsOpen = false;
|
||||||
|
var result = CustomMessageBox.Show(
|
||||||
|
$"'{preset.Label}' 프리셋을 삭제하시겠습니까?",
|
||||||
|
"프리셋 삭제",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
if (result != MessageBoxResult.Yes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_settings.Settings.Llm.CustomPresets.RemoveAll(item => item.Id == preset.CustomId);
|
||||||
|
_settings.Save();
|
||||||
|
BuildTopicButtons();
|
||||||
|
};
|
||||||
|
stack.Children.Add(deleteItem);
|
||||||
|
|
||||||
|
menuBorder.Child = stack;
|
||||||
|
popup.Child = menuBorder;
|
||||||
|
popup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateContextMenuItem(string icon, string label, Brush foreground)
|
||||||
|
{
|
||||||
|
var item = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
CornerRadius = new CornerRadius(6),
|
||||||
|
Padding = new Thickness(10, 6, 14, 6),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
};
|
||||||
|
|
||||||
|
var stack = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = icon,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = foreground,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 8, 0),
|
||||||
|
});
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = foreground,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
});
|
||||||
|
item.Child = stack;
|
||||||
|
|
||||||
|
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
||||||
|
item.MouseEnter += (sender, _) =>
|
||||||
|
{
|
||||||
|
if (sender is Border hovered)
|
||||||
|
hovered.Background = hoverBackground;
|
||||||
|
};
|
||||||
|
item.MouseLeave += (sender, _) =>
|
||||||
|
{
|
||||||
|
if (sender is Border hovered)
|
||||||
|
hovered.Background = Brushes.Transparent;
|
||||||
|
};
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateContextMenuItem(string icon, string label, Brush foreground, Brush secondaryForeground)
|
||||||
|
=> CreateContextMenuItem(icon, label, foreground);
|
||||||
|
|
||||||
|
private void SelectTopic(Services.TopicPreset preset)
|
||||||
|
{
|
||||||
|
bool hasConversation;
|
||||||
|
bool hasMessages;
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
hasConversation = _currentConversation != null;
|
||||||
|
hasMessages = _currentConversation?.Messages.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasInput = !string.IsNullOrEmpty(InputBox.Text);
|
||||||
|
if (!hasConversation)
|
||||||
|
StartNewConversation();
|
||||||
|
|
||||||
|
lock (_convLock)
|
||||||
|
{
|
||||||
|
if (_currentConversation == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var session = ChatSession;
|
||||||
|
if (session != null)
|
||||||
|
{
|
||||||
|
_currentConversation = session.UpdateConversationMetadata(_activeTab, conversation =>
|
||||||
|
{
|
||||||
|
conversation.SystemCommand = preset.SystemPrompt;
|
||||||
|
conversation.Category = preset.Category;
|
||||||
|
}, _storage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentConversation.SystemCommand = preset.SystemPrompt;
|
||||||
|
_currentConversation.Category = preset.Category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateCategoryLabel();
|
||||||
|
SaveConversationSettings();
|
||||||
|
RefreshConversationList();
|
||||||
|
UpdateSelectedPresetGuide();
|
||||||
|
if (EmptyState != null)
|
||||||
|
EmptyState.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
InputBox.Focus();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(preset.Placeholder))
|
||||||
|
{
|
||||||
|
_promptCardPlaceholder = preset.Placeholder;
|
||||||
|
if (!hasMessages && !hasInput)
|
||||||
|
ShowPlaceholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMessages || hasInput)
|
||||||
|
ShowToast($"프리셋 변경 · {preset.Label}");
|
||||||
|
|
||||||
|
if (_activeTab == "Cowork")
|
||||||
|
BuildBottomBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs
Normal file
252
src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private async Task<string?> ShowInlineUserAskAsync(string question, List<string> options, string defaultValue)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<string?>();
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
AddUserAskCard(question, options, defaultValue, tcs);
|
||||||
|
});
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
|
||||||
|
if (completed != tcs.Task)
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(RemoveUserAskCard);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveUserAskCard()
|
||||||
|
{
|
||||||
|
if (_userAskCard == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
MessagePanel.Children.Remove(_userAskCard);
|
||||||
|
_userAskCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddUserAskCard(string question, List<string> options, string defaultValue, TaskCompletionSource<string?> tcs)
|
||||||
|
{
|
||||||
|
RemoveUserAskCard();
|
||||||
|
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var accentColor = ((SolidColorBrush)accentBrush).Color;
|
||||||
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromArgb(0x24, accentColor.R, accentColor.G, accentColor.B));
|
||||||
|
var itemBg = TryFindResource("ItemBackground") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B));
|
||||||
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromArgb(0x16, 0xFF, 0xFF, 0xFF));
|
||||||
|
var okBrush = BrushFromHex("#10B981");
|
||||||
|
var dangerBrush = BrushFromHex("#EF4444");
|
||||||
|
|
||||||
|
var container = new Border
|
||||||
|
{
|
||||||
|
Margin = new Thickness(40, 4, 90, 8),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
MaxWidth = Math.Max(420, GetMessageMaxWidth() - 36),
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(14),
|
||||||
|
Padding = new Thickness(14, 12, 14, 12),
|
||||||
|
};
|
||||||
|
|
||||||
|
var outer = new StackPanel();
|
||||||
|
outer.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "의견 요청",
|
||||||
|
FontSize = 12.5,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = primaryText,
|
||||||
|
});
|
||||||
|
outer.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = question,
|
||||||
|
Margin = new Thickness(0, 4, 0, 10),
|
||||||
|
FontSize = 12.5,
|
||||||
|
Foreground = primaryText,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
LineHeight = 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
Border? selectedOption = null;
|
||||||
|
string selectedResponse = defaultValue;
|
||||||
|
|
||||||
|
if (options.Count > 0)
|
||||||
|
{
|
||||||
|
var optionPanel = new WrapPanel
|
||||||
|
{
|
||||||
|
Margin = new Thickness(0, 0, 0, 10),
|
||||||
|
ItemWidth = double.NaN,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var option in options.Where(static option => !string.IsNullOrWhiteSpace(option)))
|
||||||
|
{
|
||||||
|
var optionLabel = option.Trim();
|
||||||
|
var optBorder = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Padding = new Thickness(10, 6, 10, 6),
|
||||||
|
Margin = new Thickness(0, 0, 8, 8),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = optionLabel,
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = primaryText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
optBorder.MouseEnter += (s, _) =>
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(selectedOption, s))
|
||||||
|
((Border)s).Background = hoverBg;
|
||||||
|
};
|
||||||
|
optBorder.MouseLeave += (s, _) =>
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(selectedOption, s))
|
||||||
|
((Border)s).Background = Brushes.Transparent;
|
||||||
|
};
|
||||||
|
optBorder.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
if (selectedOption != null)
|
||||||
|
{
|
||||||
|
selectedOption.Background = Brushes.Transparent;
|
||||||
|
selectedOption.BorderBrush = borderBrush;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedOption = optBorder;
|
||||||
|
selectedOption.Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
|
||||||
|
selectedOption.BorderBrush = accentBrush;
|
||||||
|
selectedResponse = optionLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
optionPanel.Children.Add(optBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
outer.Children.Add(optionPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
outer.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "직접 입력",
|
||||||
|
FontSize = 11.5,
|
||||||
|
Foreground = secondaryText,
|
||||||
|
Margin = new Thickness(0, 0, 0, 6),
|
||||||
|
});
|
||||||
|
|
||||||
|
var inputBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = defaultValue,
|
||||||
|
AcceptsReturn = true,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
MinHeight = 42,
|
||||||
|
MaxHeight = 100,
|
||||||
|
FontSize = 12.5,
|
||||||
|
Padding = new Thickness(10, 8, 10, 8),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Foreground = primaryText,
|
||||||
|
CaretBrush = primaryText,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
};
|
||||||
|
inputBox.TextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(inputBox.Text))
|
||||||
|
{
|
||||||
|
selectedResponse = inputBox.Text.Trim();
|
||||||
|
if (selectedOption != null)
|
||||||
|
{
|
||||||
|
selectedOption.Background = Brushes.Transparent;
|
||||||
|
selectedOption.BorderBrush = borderBrush;
|
||||||
|
selectedOption = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
outer.Children.Add(inputBox);
|
||||||
|
|
||||||
|
var buttonRow = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Margin = new Thickness(0, 12, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
Border BuildActionButton(string label, Brush bg, Brush fg)
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Background = bg,
|
||||||
|
BorderBrush = bg,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Padding = new Thickness(12, 7, 12, 7),
|
||||||
|
Margin = new Thickness(8, 0, 0, 0),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = fg,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancelBtn = BuildActionButton("취소", Brushes.Transparent, dangerBrush);
|
||||||
|
cancelBtn.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x44, 0x44));
|
||||||
|
cancelBtn.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
RemoveUserAskCard();
|
||||||
|
tcs.TrySetResult(null);
|
||||||
|
};
|
||||||
|
buttonRow.Children.Add(cancelBtn);
|
||||||
|
|
||||||
|
var submitBtn = BuildActionButton("전달", okBrush, Brushes.White);
|
||||||
|
submitBtn.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
var finalResponse = !string.IsNullOrWhiteSpace(inputBox.Text)
|
||||||
|
? inputBox.Text.Trim()
|
||||||
|
: selectedResponse?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(finalResponse))
|
||||||
|
finalResponse = defaultValue?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(finalResponse))
|
||||||
|
return;
|
||||||
|
|
||||||
|
RemoveUserAskCard();
|
||||||
|
tcs.TrySetResult(finalResponse);
|
||||||
|
};
|
||||||
|
buttonRow.Children.Add(submitBtn);
|
||||||
|
|
||||||
|
outer.Children.Add(buttonRow);
|
||||||
|
container.Child = outer;
|
||||||
|
_userAskCard = container;
|
||||||
|
|
||||||
|
container.Opacity = 0;
|
||||||
|
container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
|
||||||
|
MessagePanel.Children.Add(container);
|
||||||
|
ForceScrollToEnd();
|
||||||
|
inputBox.Focus();
|
||||||
|
inputBox.CaretIndex = inputBox.Text.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs
Normal file
259
src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
/// <summary>마우스 오버 시 살짝 확대하는 호버 애니메이션. 독립적 공간이 있는 버튼에만 적용합니다.</summary>
|
||||||
|
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
|
||||||
|
{
|
||||||
|
void EnsureTransform()
|
||||||
|
{
|
||||||
|
element.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||||
|
if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen)
|
||||||
|
element.RenderTransform = new ScaleTransform(1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.Loaded += (_, _) => EnsureTransform();
|
||||||
|
|
||||||
|
element.MouseEnter += (_, _) =>
|
||||||
|
{
|
||||||
|
EnsureTransform();
|
||||||
|
var st = (ScaleTransform)element.RenderTransform;
|
||||||
|
var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150))
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
};
|
||||||
|
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
|
||||||
|
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
|
||||||
|
};
|
||||||
|
|
||||||
|
element.MouseLeave += (_, _) =>
|
||||||
|
{
|
||||||
|
EnsureTransform();
|
||||||
|
var st = (ScaleTransform)element.RenderTransform;
|
||||||
|
var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200))
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
};
|
||||||
|
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
|
||||||
|
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.</summary>
|
||||||
|
private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5)
|
||||||
|
{
|
||||||
|
void EnsureTransform()
|
||||||
|
{
|
||||||
|
if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen)
|
||||||
|
element.RenderTransform = new TranslateTransform(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.Loaded += (_, _) => EnsureTransform();
|
||||||
|
|
||||||
|
element.MouseEnter += (_, _) =>
|
||||||
|
{
|
||||||
|
EnsureTransform();
|
||||||
|
var tt = (TranslateTransform)element.RenderTransform;
|
||||||
|
tt.BeginAnimation(
|
||||||
|
TranslateTransform.YProperty,
|
||||||
|
new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200))
|
||||||
|
{
|
||||||
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
element.MouseLeave += (_, _) =>
|
||||||
|
{
|
||||||
|
EnsureTransform();
|
||||||
|
var tt = (TranslateTransform)element.RenderTransform;
|
||||||
|
tt.BeginAnimation(
|
||||||
|
TranslateTransform.YProperty,
|
||||||
|
new DoubleAnimation(0, TimeSpan.FromMilliseconds(250))
|
||||||
|
{
|
||||||
|
EasingFunction = new ElasticEase
|
||||||
|
{
|
||||||
|
EasingMode = EasingMode.EaseOut,
|
||||||
|
Oscillations = 1,
|
||||||
|
Springiness = 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>심플한 V 체크 아이콘을 생성합니다.</summary>
|
||||||
|
private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14)
|
||||||
|
{
|
||||||
|
return new System.Windows.Shapes.Path
|
||||||
|
{
|
||||||
|
Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"),
|
||||||
|
Stroke = color,
|
||||||
|
StrokeThickness = 2,
|
||||||
|
StrokeStartLineCap = PenLineCap.Round,
|
||||||
|
StrokeEndLineCap = PenLineCap.Round,
|
||||||
|
StrokeLineJoin = PenLineJoin.Round,
|
||||||
|
Width = size,
|
||||||
|
Height = size,
|
||||||
|
Margin = new Thickness(0, 0, 10, 0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
|
||||||
|
private static void ApplyMenuItemHover(Border item)
|
||||||
|
{
|
||||||
|
var originalBg = item.Background?.Clone() ?? Brushes.Transparent;
|
||||||
|
if (originalBg.CanFreeze)
|
||||||
|
originalBg.Freeze();
|
||||||
|
|
||||||
|
item.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||||
|
item.RenderTransform = new ScaleTransform(1, 1);
|
||||||
|
|
||||||
|
item.MouseEnter += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b)
|
||||||
|
{
|
||||||
|
if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20)
|
||||||
|
b.Opacity = 0.85;
|
||||||
|
else
|
||||||
|
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
var st = item.RenderTransform as ScaleTransform;
|
||||||
|
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
|
||||||
|
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
|
||||||
|
};
|
||||||
|
|
||||||
|
item.MouseLeave += (s, _) =>
|
||||||
|
{
|
||||||
|
if (s is Border b)
|
||||||
|
{
|
||||||
|
b.Opacity = 1.0;
|
||||||
|
b.Background = originalBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
var st = item.RenderTransform as ScaleTransform;
|
||||||
|
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
|
||||||
|
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick)
|
||||||
|
{
|
||||||
|
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||||
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
|
||||||
|
?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
var icon = new TextBlock
|
||||||
|
{
|
||||||
|
Text = symbol,
|
||||||
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||||
|
FontSize = 10,
|
||||||
|
Foreground = foreground,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Content = icon,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Width = 24,
|
||||||
|
Height = 24,
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
Margin = new Thickness(0, 0, 2, 0),
|
||||||
|
ToolTip = tooltip
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.Template = BuildMinimalIconButtonTemplate();
|
||||||
|
btn.MouseEnter += (_, _) =>
|
||||||
|
{
|
||||||
|
icon.Foreground = hoverBrush;
|
||||||
|
btn.Background = hoverBg;
|
||||||
|
};
|
||||||
|
btn.MouseLeave += (_, _) =>
|
||||||
|
{
|
||||||
|
icon.Foreground = foreground;
|
||||||
|
btn.Background = Brushes.Transparent;
|
||||||
|
};
|
||||||
|
btn.Click += (_, _) => onClick();
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowMessageActionBar(StackPanel actionBar)
|
||||||
|
{
|
||||||
|
if (actionBar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
actionBar.Opacity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HideMessageActionBarIfNotSelected(StackPanel actionBar)
|
||||||
|
{
|
||||||
|
if (actionBar == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!ReferenceEquals(_selectedMessageActionBar, actionBar))
|
||||||
|
actionBar.Opacity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectMessageActionBar(StackPanel actionBar, Border? messageBorder = null)
|
||||||
|
{
|
||||||
|
if (_selectedMessageActionBar != null && !ReferenceEquals(_selectedMessageActionBar, actionBar))
|
||||||
|
_selectedMessageActionBar.Opacity = 0;
|
||||||
|
|
||||||
|
if (_selectedMessageBorder != null && !ReferenceEquals(_selectedMessageBorder, messageBorder))
|
||||||
|
ApplyMessageSelectionStyle(_selectedMessageBorder, false);
|
||||||
|
|
||||||
|
_selectedMessageActionBar = actionBar;
|
||||||
|
_selectedMessageActionBar.Opacity = 1;
|
||||||
|
_selectedMessageBorder = messageBorder;
|
||||||
|
if (_selectedMessageBorder != null)
|
||||||
|
ApplyMessageSelectionStyle(_selectedMessageBorder, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyMessageSelectionStyle(Border border, bool selected)
|
||||||
|
{
|
||||||
|
if (border == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
var defaultBorder = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||||
|
border.BorderBrush = selected ? accent : defaultBorder;
|
||||||
|
border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(1);
|
||||||
|
border.Effect = selected
|
||||||
|
? new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{
|
||||||
|
BlurRadius = 16,
|
||||||
|
ShadowDepth = 0,
|
||||||
|
Opacity = 0.10,
|
||||||
|
Color = Colors.Black,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlTemplate BuildMinimalIconButtonTemplate()
|
||||||
|
{
|
||||||
|
var template = new ControlTemplate(typeof(Button));
|
||||||
|
var border = new FrameworkElementFactory(typeof(Border));
|
||||||
|
border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty));
|
||||||
|
border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty));
|
||||||
|
border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty));
|
||||||
|
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(8));
|
||||||
|
border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty));
|
||||||
|
var presenter = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
presenter.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||||
|
presenter.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
border.AppendChild(presenter);
|
||||||
|
template.VisualTree = border;
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -428,21 +428,27 @@
|
|||||||
Grid.ColumnSpan="3"
|
Grid.ColumnSpan="3"
|
||||||
ItemsSource="{Binding QuickActionItems}"
|
ItemsSource="{Binding QuickActionItems}"
|
||||||
Margin="0,10,0,0"
|
Margin="0,10,0,0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
Visibility="{Binding ShowQuickActions, Converter={StaticResource BoolToVisibilityConverter}}">
|
Visibility="{Binding ShowQuickActions, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<WrapPanel Orientation="Horizontal"/>
|
<WrapPanel Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Border Margin="0,0,6,6"
|
<Border Margin="0,0,6,6"
|
||||||
Padding="9,5"
|
Padding="9,5"
|
||||||
|
MinHeight="32"
|
||||||
CornerRadius="10"
|
CornerRadius="10"
|
||||||
Cursor="Hand"
|
Cursor="Hand"
|
||||||
Background="{Binding Background}"
|
Background="{Binding Background}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource BorderColor}"
|
BorderBrush="{DynamicResource BorderColor}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
MouseLeftButtonUp="QuickActionChip_Click">
|
MouseLeftButtonUp="QuickActionChip_Click">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
@@ -453,7 +459,8 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Border.Style>
|
</Border.Style>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal"
|
||||||
|
VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding Symbol}"
|
<TextBlock Text="{Binding Symbol}"
|
||||||
FontFamily="Segoe MDL2 Assets"
|
FontFamily="Segoe MDL2 Assets"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ public partial class LauncherWindow : Window
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (CurrentApp?.SettingsService != null)
|
||||||
|
CurrentApp.SettingsService.SettingsChanged += OnSettingsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Window_Loaded(object sender, RoutedEventArgs e)
|
private void Window_Loaded(object sender, RoutedEventArgs e)
|
||||||
@@ -175,7 +178,20 @@ public partial class LauncherWindow : Window
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 런처 테두리 표시 설정
|
ApplyVisualSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
ApplyTheme();
|
||||||
|
ApplyVisualSettings();
|
||||||
|
}, System.Windows.Threading.DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyVisualSettings()
|
||||||
|
{
|
||||||
if (_vm.ShowLauncherBorder)
|
if (_vm.ShowLauncherBorder)
|
||||||
{
|
{
|
||||||
MainBorder.BorderThickness = new Thickness(1);
|
MainBorder.BorderThickness = new Thickness(1);
|
||||||
@@ -187,12 +203,12 @@ public partial class LauncherWindow : Window
|
|||||||
MainBorder.BorderBrush = System.Windows.Media.Brushes.Transparent;
|
MainBorder.BorderBrush = System.Windows.Media.Brushes.Transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_vm.EnableIconAnimation)
|
if (_vm.EnableIconAnimation && IsVisible)
|
||||||
ApplyRandomIconAnimation();
|
ApplyRandomIconAnimation();
|
||||||
else
|
else
|
||||||
ResetIconAnimation();
|
ResetIconAnimation();
|
||||||
|
|
||||||
if (_vm.EnableRainbowGlow)
|
if (_vm.EnableRainbowGlow && IsVisible)
|
||||||
StartRainbowGlow();
|
StartRainbowGlow();
|
||||||
else
|
else
|
||||||
StopRainbowGlow();
|
StopRainbowGlow();
|
||||||
@@ -617,7 +633,7 @@ public partial class LauncherWindow : Window
|
|||||||
|
|
||||||
_rainbowTimer = new System.Windows.Threading.DispatcherTimer
|
_rainbowTimer = new System.Windows.Threading.DispatcherTimer
|
||||||
{
|
{
|
||||||
Interval = TimeSpan.FromMilliseconds(20)
|
Interval = TimeSpan.FromMilliseconds(40)
|
||||||
};
|
};
|
||||||
var startTime = DateTime.UtcNow;
|
var startTime = DateTime.UtcNow;
|
||||||
_rainbowTimer.Tick += (_, _) =>
|
_rainbowTimer.Tick += (_, _) =>
|
||||||
@@ -1443,17 +1459,21 @@ public partial class LauncherWindow : Window
|
|||||||
IndexStatusText.Text = message;
|
IndexStatusText.Text = message;
|
||||||
IndexStatusText.Visibility = Visibility.Visible;
|
IndexStatusText.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
_indexStatusTimer?.Stop();
|
_indexStatusTimer ??= new System.Windows.Threading.DispatcherTimer();
|
||||||
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
|
_indexStatusTimer.Stop();
|
||||||
{
|
_indexStatusTimer.Interval = duration;
|
||||||
Interval = duration
|
_indexStatusTimer.Tick -= IndexStatusTimer_Tick;
|
||||||
};
|
_indexStatusTimer.Tick += IndexStatusTimer_Tick;
|
||||||
_indexStatusTimer.Tick += (_, _) =>
|
_indexStatusTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void IndexStatusTimer_Tick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
if (_indexStatusTimer == null)
|
||||||
|
return;
|
||||||
|
|
||||||
_indexStatusTimer.Stop();
|
_indexStatusTimer.Stop();
|
||||||
IndexStatusText.Visibility = Visibility.Collapsed;
|
IndexStatusText.Visibility = Visibility.Collapsed;
|
||||||
};
|
|
||||||
_indexStatusTimer.Start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1663,6 +1683,14 @@ public partial class LauncherWindow : Window
|
|||||||
if (_vm.CloseOnFocusLost) Hide();
|
if (_vm.CloseOnFocusLost) Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
if (CurrentApp?.SettingsService != null)
|
||||||
|
CurrentApp.SettingsService.SettingsChanged -= OnSettingsChanged;
|
||||||
|
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
|
||||||
private void Window_LocationChanged(object sender, EventArgs e)
|
private void Window_LocationChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
UpdateRememberedPositionCache();
|
UpdateRememberedPositionCache();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ internal sealed class ModelRegistrationDialog : Window
|
|||||||
private readonly TextBox _apiKeyBox;
|
private readonly TextBox _apiKeyBox;
|
||||||
private readonly CheckBox _allowInsecureTlsCheck;
|
private readonly CheckBox _allowInsecureTlsCheck;
|
||||||
|
|
||||||
// CP4D 인증 필드
|
// IBM/CP4D 인증 필드
|
||||||
private readonly ComboBox _authTypeBox;
|
private readonly ComboBox _authTypeBox;
|
||||||
private readonly StackPanel _cp4dPanel;
|
private readonly StackPanel _cp4dPanel;
|
||||||
private readonly TextBox _cp4dUrlBox;
|
private readonly TextBox _cp4dUrlBox;
|
||||||
@@ -271,10 +271,21 @@ internal sealed class ModelRegistrationDialog : Window
|
|||||||
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
|
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
|
||||||
};
|
};
|
||||||
var bearerItem = new ComboBoxItem { Content = "Bearer 토큰 (API 키)", Tag = "bearer" };
|
var bearerItem = new ComboBoxItem { Content = "Bearer 토큰 (API 키)", Tag = "bearer" };
|
||||||
var cp4dItem = new ComboBoxItem { Content = "CP4D (IBM Cloud Pak for Data)", Tag = "cp4d" };
|
var ibmIamItem = new ComboBoxItem { Content = "IBM IAM (토큰 교환)", Tag = "ibm_iam" };
|
||||||
|
var cp4dPasswordItem = new ComboBoxItem { Content = "CP4D (사용자 이름 + 비밀번호)", Tag = "cp4d_password" };
|
||||||
|
var cp4dApiKeyItem = new ComboBoxItem { Content = "CP4D (사용자 이름 + API 키)", Tag = "cp4d_api_key" };
|
||||||
_authTypeBox.Items.Add(bearerItem);
|
_authTypeBox.Items.Add(bearerItem);
|
||||||
_authTypeBox.Items.Add(cp4dItem);
|
_authTypeBox.Items.Add(ibmIamItem);
|
||||||
_authTypeBox.SelectedItem = existingAuthType == "cp4d" ? cp4dItem : bearerItem;
|
_authTypeBox.Items.Add(cp4dPasswordItem);
|
||||||
|
_authTypeBox.Items.Add(cp4dApiKeyItem);
|
||||||
|
_authTypeBox.SelectedItem = existingAuthType switch
|
||||||
|
{
|
||||||
|
"cp4d" => cp4dPasswordItem,
|
||||||
|
"cp4d_password" => cp4dPasswordItem,
|
||||||
|
"cp4d_api_key" => cp4dApiKeyItem,
|
||||||
|
"ibm_iam" => ibmIamItem,
|
||||||
|
_ => bearerItem,
|
||||||
|
};
|
||||||
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _authTypeBox });
|
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _authTypeBox });
|
||||||
|
|
||||||
// ── Bearer 인증: API 키 입력 ────────────────────────────────────────
|
// ── Bearer 인증: API 키 입력 ────────────────────────────────────────
|
||||||
@@ -295,8 +306,9 @@ internal sealed class ModelRegistrationDialog : Window
|
|||||||
apiKeyPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _apiKeyBox });
|
apiKeyPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _apiKeyBox });
|
||||||
stack.Children.Add(apiKeyPanel);
|
stack.Children.Add(apiKeyPanel);
|
||||||
|
|
||||||
// ── CP4D 인증: URL + 사용자명 + 비밀번호 ────────────────────────────
|
// ── CP4D 인증: URL + 사용자명 + 비밀번호/API 키 ───────────────────────
|
||||||
_cp4dPanel = new StackPanel { Visibility = existingAuthType == "cp4d" ? Visibility.Visible : Visibility.Collapsed };
|
var initialCp4dAuth = existingAuthType is "cp4d" or "cp4d_password" or "cp4d_api_key";
|
||||||
|
_cp4dPanel = new StackPanel { Visibility = initialCp4dAuth ? Visibility.Visible : Visibility.Collapsed };
|
||||||
|
|
||||||
_cp4dPanel.Children.Add(new TextBlock
|
_cp4dPanel.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
@@ -333,12 +345,13 @@ internal sealed class ModelRegistrationDialog : Window
|
|||||||
};
|
};
|
||||||
_cp4dPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _cp4dUsernameBox });
|
_cp4dPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _cp4dUsernameBox });
|
||||||
|
|
||||||
_cp4dPanel.Children.Add(new TextBlock
|
var cp4dSecretLabel = new TextBlock
|
||||||
{
|
{
|
||||||
Text = "비밀번호 / API 키",
|
Text = "비밀번호",
|
||||||
FontSize = 12, FontWeight = FontWeights.SemiBold,
|
FontSize = 12, FontWeight = FontWeights.SemiBold,
|
||||||
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
|
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
|
||||||
});
|
};
|
||||||
|
_cp4dPanel.Children.Add(cp4dSecretLabel);
|
||||||
_cp4dPasswordBox = new PasswordBox
|
_cp4dPasswordBox = new PasswordBox
|
||||||
{
|
{
|
||||||
Password = existingCp4dPassword,
|
Password = existingCp4dPassword,
|
||||||
@@ -351,14 +364,19 @@ internal sealed class ModelRegistrationDialog : Window
|
|||||||
stack.Children.Add(_cp4dPanel);
|
stack.Children.Add(_cp4dPanel);
|
||||||
|
|
||||||
// 인증 방식 전환 시 패널 표시/숨김
|
// 인증 방식 전환 시 패널 표시/숨김
|
||||||
_authTypeBox.SelectionChanged += (_, _) =>
|
void UpdateAuthPanels()
|
||||||
{
|
{
|
||||||
var isCp4d = AuthType == "cp4d";
|
var isCp4d = AuthType is "cp4d" or "cp4d_password" or "cp4d_api_key";
|
||||||
_cp4dPanel.Visibility = isCp4d ? Visibility.Visible : Visibility.Collapsed;
|
_cp4dPanel.Visibility = isCp4d ? Visibility.Visible : Visibility.Collapsed;
|
||||||
apiKeyPanel.Visibility = isCp4d ? Visibility.Collapsed : Visibility.Visible;
|
apiKeyPanel.Visibility = isCp4d ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
cp4dSecretLabel.Text = AuthType == "cp4d_api_key" ? "API 키" : "비밀번호";
|
||||||
|
}
|
||||||
|
_authTypeBox.SelectionChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
UpdateAuthPanels();
|
||||||
};
|
};
|
||||||
// 초기 상태 설정
|
// 초기 상태 설정
|
||||||
apiKeyPanel.Visibility = existingAuthType == "cp4d" ? Visibility.Collapsed : Visibility.Visible;
|
UpdateAuthPanels();
|
||||||
|
|
||||||
// 보안 안내
|
// 보안 안내
|
||||||
var securityNote = new StackPanel
|
var securityNote = new StackPanel
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ internal sealed class PermissionRequestWindow : Window
|
|||||||
{
|
{
|
||||||
stack.Children.Add(new TextBlock
|
stack.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
Text = "沅뚰븳 ?좏깮",
|
Text = "권한 선택",
|
||||||
FontSize = 12.5,
|
FontSize = 12.5,
|
||||||
FontWeight = FontWeights.SemiBold,
|
FontWeight = FontWeights.SemiBold,
|
||||||
Foreground = primary,
|
Foreground = primary,
|
||||||
@@ -197,24 +197,24 @@ internal sealed class PermissionRequestWindow : Window
|
|||||||
var optionList = new StackPanel();
|
var optionList = new StackPanel();
|
||||||
optionList.Children.Add(BuildOption(
|
optionList.Children.Add(BuildOption(
|
||||||
icon: "\uE73E",
|
icon: "\uE73E",
|
||||||
title: "?대쾲留??덉슜",
|
title: "이번만 허용",
|
||||||
description: uiProfile.ShowOptionDescription ? "?꾩옱 ?붿껌 1?뚮쭔 ?덉슜?⑸땲??" : "",
|
description: uiProfile.ShowOptionDescription ? "현재 요청 1회만 허용합니다." : "",
|
||||||
fg: new SolidColorBrush(Color.FromRgb(0x05, 0x96, 0x69)),
|
fg: new SolidColorBrush(Color.FromRgb(0x05, 0x96, 0x69)),
|
||||||
bg: hoverBg,
|
bg: hoverBg,
|
||||||
onClick: () => CloseWith(PermissionPromptResult.AllowOnce)));
|
onClick: () => CloseWith(PermissionPromptResult.AllowOnce)));
|
||||||
|
|
||||||
optionList.Children.Add(BuildOption(
|
optionList.Children.Add(BuildOption(
|
||||||
icon: "\uE8FB",
|
icon: "\uE8FB",
|
||||||
title: "?대쾲 ?ㅽ뻾 ?숈븞 ?덉슜",
|
title: "이번 실행 동안 허용",
|
||||||
description: uiProfile.ShowOptionDescription ? "?꾩옱 ?ㅽ뻾 以??숈씪 踰붿쐞 ?붿껌???먮룞 ?덉슜?⑸땲??" : "",
|
description: uiProfile.ShowOptionDescription ? "현재 실행 중 같은 범위 요청을 자동 허용합니다." : "",
|
||||||
fg: accent,
|
fg: accent,
|
||||||
bg: hoverBg,
|
bg: hoverBg,
|
||||||
onClick: () => CloseWith(PermissionPromptResult.AllowForSession)));
|
onClick: () => CloseWith(PermissionPromptResult.AllowForSession)));
|
||||||
|
|
||||||
optionList.Children.Add(BuildOption(
|
optionList.Children.Add(BuildOption(
|
||||||
icon: "\uE711",
|
icon: "\uE711",
|
||||||
title: "嫄곕?",
|
title: "거부",
|
||||||
description: uiProfile.ShowOptionDescription ? "?붿껌??李⑤떒?섍퀬 ???묒뾽 ?놁씠 怨꾩냽 吏꾪뻾?⑸땲??" : "",
|
description: uiProfile.ShowOptionDescription ? "요청을 차단하고 현재 작업은 중단 없이 계속 진행합니다." : "",
|
||||||
fg: new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
|
fg: new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
|
||||||
bg: hoverBg,
|
bg: hoverBg,
|
||||||
onClick: () => CloseWith(PermissionPromptResult.Reject)));
|
onClick: () => CloseWith(PermissionPromptResult.Reject)));
|
||||||
@@ -223,8 +223,8 @@ internal sealed class PermissionRequestWindow : Window
|
|||||||
stack.Children.Add(new TextBlock
|
stack.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
Text = uiProfile.Key == "simple"
|
Text = uiProfile.Key == "simple"
|
||||||
? "Esc: 嫄곕?"
|
? "Esc: 거부"
|
||||||
: "Esc: 嫄곕? | Enter: ?대쾲留??덉슜",
|
: "Esc: 거부 | Enter: 이번만 허용",
|
||||||
FontSize = 11,
|
FontSize = 11,
|
||||||
Foreground = secondary,
|
Foreground = secondary,
|
||||||
Margin = new Thickness(2, 12, 0, 0),
|
Margin = new Thickness(2, 12, 0, 0),
|
||||||
@@ -273,9 +273,9 @@ internal sealed class PermissionRequestWindow : Window
|
|||||||
{
|
{
|
||||||
return new PermissionPromptProfile(
|
return new PermissionPromptProfile(
|
||||||
HeaderIcon: "\uE8A5",
|
HeaderIcon: "\uE8A5",
|
||||||
HeaderTitle: "AX Agent ?뚯씪 沅뚰븳 ?붿껌",
|
HeaderTitle: "AX Agent 파일 권한 요청",
|
||||||
Headline: "?먯씠?꾪듃媛 ?뚯씪 蹂寃쎌쓣 ?붿껌?덉뒿?덈떎.",
|
Headline: "에이전트가 파일 변경을 요청했습니다.",
|
||||||
RiskLabel: "以묎컙 ?꾪뿕",
|
RiskLabel: "중간 위험",
|
||||||
RiskColor: Color.FromRgb(0xEA, 0x58, 0x0C));
|
RiskColor: Color.FromRgb(0xEA, 0x58, 0x0C));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,9 +283,9 @@ internal sealed class PermissionRequestWindow : Window
|
|||||||
{
|
{
|
||||||
return new PermissionPromptProfile(
|
return new PermissionPromptProfile(
|
||||||
HeaderIcon: "\uE756",
|
HeaderIcon: "\uE756",
|
||||||
HeaderTitle: "AX Agent 紐낅졊 ?ㅽ뻾 沅뚰븳 ?붿껌",
|
HeaderTitle: "AX Agent 명령 실행 권한 요청",
|
||||||
Headline: "?먯씠?꾪듃媛 ??紐낅졊 ?ㅽ뻾???붿껌?덉뒿?덈떎.",
|
Headline: "에이전트가 시스템 명령 실행을 요청했습니다.",
|
||||||
RiskLabel: "?믪? ?꾪뿕",
|
RiskLabel: "높은 위험",
|
||||||
RiskColor: Color.FromRgb(0xDC, 0x26, 0x26));
|
RiskColor: Color.FromRgb(0xDC, 0x26, 0x26));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,17 +293,17 @@ internal sealed class PermissionRequestWindow : Window
|
|||||||
{
|
{
|
||||||
return new PermissionPromptProfile(
|
return new PermissionPromptProfile(
|
||||||
HeaderIcon: "\uE774",
|
HeaderIcon: "\uE774",
|
||||||
HeaderTitle: "AX Agent ?ㅽ듃?뚰겕 沅뚰븳 ?붿껌",
|
HeaderTitle: "AX Agent 네트워크 권한 요청",
|
||||||
Headline: "?먯씠?꾪듃媛 ?몃? 由ъ냼???묎렐???붿껌?덉뒿?덈떎.",
|
Headline: "에이전트가 외부 리소스 접근을 요청했습니다.",
|
||||||
RiskLabel: "以묎컙 ?꾪뿕",
|
RiskLabel: "중간 위험",
|
||||||
RiskColor: Color.FromRgb(0xD9, 0x77, 0x06));
|
RiskColor: Color.FromRgb(0xD9, 0x77, 0x06));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PermissionPromptProfile(
|
return new PermissionPromptProfile(
|
||||||
HeaderIcon: "\uE897",
|
HeaderIcon: "\uE897",
|
||||||
HeaderTitle: "AX Agent 沅뚰븳 ?붿껌",
|
HeaderTitle: "AX Agent 권한 요청",
|
||||||
Headline: "怨꾩냽 吏꾪뻾?섎젮硫?沅뚰븳 ?뺤씤???꾩슂?⑸땲??",
|
Headline: "계속 진행하려면 권한 확인이 필요합니다.",
|
||||||
RiskLabel: "寃???꾩슂",
|
RiskLabel: "검토 필요",
|
||||||
RiskColor: Color.FromRgb(0x25, 0x63, 0xEB));
|
RiskColor: Color.FromRgb(0x25, 0x63, 0xEB));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,25 @@ internal sealed class PlanViewerWindow : Window
|
|||||||
_statusBar.Visibility = Visibility.Collapsed;
|
_statusBar.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void LoadPlanPreview(string planText, List<string> steps)
|
||||||
|
{
|
||||||
|
_planText = planText;
|
||||||
|
_steps = steps;
|
||||||
|
_tcs = null;
|
||||||
|
_currentStep = -1;
|
||||||
|
_isExecuting = false;
|
||||||
|
_expandedSteps.Clear();
|
||||||
|
if (_uiExpressionLevel == "rich")
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _steps.Count; i++)
|
||||||
|
_expandedSteps.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderSteps();
|
||||||
|
BuildCloseButton();
|
||||||
|
_statusBar.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<string?> ShowPlanAsync(string planText, List<string> steps, TaskCompletionSource<string?> tcs)
|
public Task<string?> ShowPlanAsync(string planText, List<string> steps, TaskCompletionSource<string?> tcs)
|
||||||
{
|
{
|
||||||
LoadPlan(planText, steps, tcs);
|
LoadPlan(planText, steps, tcs);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
@@ -26,7 +28,9 @@ public partial class ReminderPopupWindow : Window
|
|||||||
// ─── 타이머 ───────────────────────────────────────────────────────────────
|
// ─── 타이머 ───────────────────────────────────────────────────────────────
|
||||||
private readonly DispatcherTimer _timer;
|
private readonly DispatcherTimer _timer;
|
||||||
private readonly EventHandler _tickHandler;
|
private readonly EventHandler _tickHandler;
|
||||||
private int _remaining;
|
private readonly CancellationTokenSource _autoCloseCts = new();
|
||||||
|
private readonly DateTime _closeAtUtc;
|
||||||
|
private readonly int _displaySeconds;
|
||||||
|
|
||||||
public ReminderPopupWindow(
|
public ReminderPopupWindow(
|
||||||
string quoteText,
|
string quoteText,
|
||||||
@@ -56,9 +60,10 @@ public partial class ReminderPopupWindow : Window
|
|||||||
: "오늘 방금 시작했습니다";
|
: "오늘 방금 시작했습니다";
|
||||||
|
|
||||||
// ── 카운트다운 ──
|
// ── 카운트다운 ──
|
||||||
_remaining = Math.Max(3, cfg.DisplaySeconds);
|
_displaySeconds = Math.Max(3, cfg.DisplaySeconds);
|
||||||
CountdownBar.Maximum = _remaining;
|
_closeAtUtc = DateTime.UtcNow.AddSeconds(_displaySeconds);
|
||||||
CountdownBar.Value = _remaining;
|
CountdownBar.Maximum = _displaySeconds;
|
||||||
|
CountdownBar.Value = _displaySeconds;
|
||||||
|
|
||||||
// ── 위치: 레이아웃 완료 후 설정 ──
|
// ── 위치: 레이아웃 완료 후 설정 ──
|
||||||
Loaded += (_, _) =>
|
Loaded += (_, _) =>
|
||||||
@@ -71,13 +76,15 @@ public partial class ReminderPopupWindow : Window
|
|||||||
// ── 타이머 ──
|
// ── 타이머 ──
|
||||||
_tickHandler = (_, _) =>
|
_tickHandler = (_, _) =>
|
||||||
{
|
{
|
||||||
_remaining--;
|
var remainingSeconds = Math.Max(0, (_closeAtUtc - DateTime.UtcNow).TotalSeconds);
|
||||||
CountdownBar.Value = _remaining;
|
CountdownBar.Value = remainingSeconds;
|
||||||
if (_remaining <= 0) Close();
|
if (remainingSeconds <= 0)
|
||||||
|
Close();
|
||||||
};
|
};
|
||||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||||
_timer.Tick += _tickHandler;
|
_timer.Tick += _tickHandler;
|
||||||
_timer.Start();
|
_timer.Start();
|
||||||
|
_ = StartAutoCloseAsync(_autoCloseCts.Token);
|
||||||
|
|
||||||
// ── Esc 키 닫기 ──
|
// ── Esc 키 닫기 ──
|
||||||
KeyDown += (_, e) =>
|
KeyDown += (_, e) =>
|
||||||
@@ -132,10 +139,34 @@ public partial class ReminderPopupWindow : Window
|
|||||||
|
|
||||||
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
|
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private async Task StartAutoCloseAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_displaySeconds), cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (IsVisible)
|
||||||
|
Close();
|
||||||
|
}, DispatcherPriority.Background, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
|
_autoCloseCts.Cancel();
|
||||||
_timer.Stop();
|
_timer.Stop();
|
||||||
_timer.Tick -= _tickHandler;
|
_timer.Tick -= _tickHandler;
|
||||||
|
_autoCloseCts.Dispose();
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,36 @@
|
|||||||
<Setter Property="Margin" Value="2,18,0,8"/>
|
<Setter Property="Margin" Value="2,18,0,8"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="MemoryScopeButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ItemBackground}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryText}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Padding" Value="12,6"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,8,8"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentColor}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
|
||||||
<!-- ─── 도움말 아이콘 (? 버튼 + 커스텀 툴팁) ──────────────────────── -->
|
<!-- ─── 도움말 아이콘 (? 버튼 + 커스텀 툴팁) ──────────────────────── -->
|
||||||
<Style x:Key="HelpTooltipStyle" TargetType="ToolTip">
|
<Style x:Key="HelpTooltipStyle" TargetType="ToolTip">
|
||||||
@@ -1056,6 +1086,26 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="IndexProgressPanel"
|
||||||
|
Style="{StaticResource SettingsRow}"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock x:Name="IndexProgressStatusText"
|
||||||
|
Text="색인 대기 중"
|
||||||
|
Style="{StaticResource RowLabel}"/>
|
||||||
|
<ProgressBar x:Name="IndexProgressBar"
|
||||||
|
Height="8"
|
||||||
|
Margin="0,10,0,0"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="0"/>
|
||||||
|
<TextBlock x:Name="IndexProgressDetailText"
|
||||||
|
Text="필요할 때 전체 색인을 시작합니다."
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
Style="{StaticResource RowHint}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
<!-- 알림 패널 (하위 탭) — 기존 알림 탭 내용을 여기로 통합 -->
|
<!-- 알림 패널 (하위 탭) — 기존 알림 탭 내용을 여기로 통합 -->
|
||||||
@@ -1145,52 +1195,6 @@
|
|||||||
<!-- 테마 선택 패널 -->
|
<!-- 테마 선택 패널 -->
|
||||||
<ScrollViewer x:Name="ThemeSelectPanel" Grid.Row="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
<ScrollViewer x:Name="ThemeSelectPanel" Grid.Row="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<!-- ── 시각 효과 ── -->
|
|
||||||
<TextBlock Text="시각 효과" Style="{StaticResource SectionHeader}"/>
|
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
|
||||||
<Grid>
|
|
||||||
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<TextBlock Style="{StaticResource RowLabel}" Text="런처 무지개 글로우"/>
|
|
||||||
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
|
||||||
<Border.ToolTip>
|
|
||||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
|
||||||
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18">
|
|
||||||
AX Commander 테두리에 무지개빛 회전 애니메이션을 표시합니다.
|
|
||||||
<LineBreak/>GPU 가속을 사용하므로 저사양 PC에서는 끄는 것을 권장합니다.
|
|
||||||
</TextBlock>
|
|
||||||
</ToolTip>
|
|
||||||
</Border.ToolTip>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Style="{StaticResource RowHint}" Text="AX Commander 테두리에 무지개빛 글로우 효과를 표시합니다. 저사양 PC에서는 끄는 것을 권장합니다."/>
|
|
||||||
</StackPanel>
|
|
||||||
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
|
||||||
IsChecked="{Binding EnableRainbowGlow, Mode=TwoWay}"/>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
|
||||||
<Grid>
|
|
||||||
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
|
||||||
<TextBlock Style="{StaticResource RowLabel}" Text="선택 아이템 글로우"/>
|
|
||||||
<TextBlock Style="{StaticResource RowHint}" Text="선택된 항목에 은은한 글로우를 상시 표시합니다. 테마 AccentColor를 기반으로 색상이 적용됩니다."/>
|
|
||||||
</StackPanel>
|
|
||||||
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
|
||||||
IsChecked="{Binding EnableSelectionGlow, Mode=TwoWay}"/>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
|
||||||
<Grid>
|
|
||||||
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
|
||||||
<TextBlock Style="{StaticResource RowLabel}" Text="채팅 입력창 무지개 글로우"/>
|
|
||||||
<TextBlock Style="{StaticResource RowHint}" Text="메시지 전송 시 입력창 테두리에 무지개빛 글로우 효과를 표시합니다. 저사양 PC에서는 끄는 것을 권장합니다."/>
|
|
||||||
</StackPanel>
|
|
||||||
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
|
||||||
IsChecked="{Binding EnableChatRainbowGlow, Mode=TwoWay}"/>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<TextBlock Text="테마 선택" Style="{StaticResource SectionHeader}"/>
|
<TextBlock Text="테마 선택" Style="{StaticResource SectionHeader}"/>
|
||||||
<Border Background="White" CornerRadius="10" Padding="14,10" Margin="0,0,0,14">
|
<Border Background="White" CornerRadius="10" Padding="14,10" Margin="0,0,0,14">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
@@ -2868,7 +2872,7 @@
|
|||||||
<Border Style="{StaticResource SettingsRow}">
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Style="{StaticResource RowLabel}" Text="활성화할 AI 명령"/>
|
<TextBlock Style="{StaticResource RowLabel}" Text="활성화할 AI 명령"/>
|
||||||
<TextBlock Style="{StaticResource RowHint}" Text="최소 1개 이상 선택해야 합니다. 1개만 선택하면 팝업 없이 바로 실행됩니다."/>
|
<TextBlock Style="{StaticResource RowHint}" Text="원하는 명령만 켤 수 있습니다. 모두 끄면 선택 텍스트 팝업에는 AX Commander 열기만 표시됩니다."/>
|
||||||
<StackPanel x:Name="TextActionCommandsPanel" Margin="0,8,0,0"/>
|
<StackPanel x:Name="TextActionCommandsPanel" Margin="0,8,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -3046,6 +3050,17 @@
|
|||||||
|
|
||||||
<TextBlock Style="{StaticResource SectionHeader}" Text="하단 위젯"/>
|
<TextBlock Style="{StaticResource SectionHeader}" Text="하단 위젯"/>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="빠른 실행 칩 표시"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="자주 연 파일·폴더를 런처 하단에 작은 칩으로 표시합니다. 기본값은 꺼짐입니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding ShowLauncherBottomQuickActions, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<StackPanel HorizontalAlignment="Left">
|
<StackPanel HorizontalAlignment="Left">
|
||||||
@@ -4833,7 +4848,13 @@
|
|||||||
<Grid>
|
<Grid>
|
||||||
<StackPanel HorizontalAlignment="Left">
|
<StackPanel HorizontalAlignment="Left">
|
||||||
<TextBlock Style="{StaticResource RowLabel}" Text="메모리 관리"/>
|
<TextBlock Style="{StaticResource RowLabel}" Text="메모리 관리"/>
|
||||||
<TextBlock Style="{StaticResource RowHint}" Text="현재 작업 폴더의 메모리를 확인하거나 초기화합니다."/>
|
<TextBlock Style="{StaticResource RowHint}" Text="계층형 메모리 파일을 직접 열어 수정하거나, 학습 메모리를 초기화합니다."/>
|
||||||
|
<WrapPanel Margin="0,10,0,0">
|
||||||
|
<Button Style="{StaticResource MemoryScopeButton}" Content="관리형 편집" Click="BtnEditManagedMemory_Click"/>
|
||||||
|
<Button Style="{StaticResource MemoryScopeButton}" Content="사용자 편집" Click="BtnEditUserMemory_Click"/>
|
||||||
|
<Button Style="{StaticResource MemoryScopeButton}" Content="프로젝트 편집" Click="BtnEditProjectMemory_Click"/>
|
||||||
|
<Button Style="{StaticResource MemoryScopeButton}" Content="로컬 편집" Click="BtnEditLocalMemory_Click"/>
|
||||||
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
<Button Content="메모리 초기화" Click="BtnClearMemory_Click"
|
<Button Content="메모리 초기화" Click="BtnClearMemory_Click"
|
||||||
@@ -4851,6 +4872,39 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
<Border Style="{StaticResource AgentSettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="적용 중 메모리 계층"/>
|
||||||
|
<TextBlock x:Name="TxtMemoryOverviewSummary"
|
||||||
|
Style="{StaticResource RowHint}"
|
||||||
|
Text="현재 메모리 적용 상태를 불러오는 중입니다."/>
|
||||||
|
<TextBlock x:Name="TxtMemoryOverviewScopes"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource SecondaryText}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock x:Name="TxtMemoryOverviewAudit"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource SecondaryText}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<Button Style="{StaticResource MemoryScopeButton}" Content="새로고침" Click="BtnRefreshMemoryOverview_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource AgentSettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="외부 메모리 include 허용"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="AXMEMORY.md의 @include가 프로젝트 바깥 파일을 읽는 것을 허용합니다. 기본은 안전하게 꺼져 있으며, include 시도는 감사 로그에 기록됩니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding AllowExternalMemoryIncludes, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
@@ -4957,22 +5011,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Style="{StaticResource SectionHeader}" Text="폴더 데이터 활용"/>
|
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
|
||||||
<Grid>
|
|
||||||
<StackPanel HorizontalAlignment="Left">
|
|
||||||
<TextBlock Style="{StaticResource RowLabel}" Text="기본 데이터 활용 수준"/>
|
|
||||||
<TextBlock Style="{StaticResource RowHint}" Text="작업 폴더 내 문서/데이터를 보고서 작성에 활용하는 수준입니다."/>
|
|
||||||
</StackPanel>
|
|
||||||
<ComboBox HorizontalAlignment="Right" VerticalAlignment="Center"
|
|
||||||
Width="180" SelectedValue="{Binding FolderDataUsage, Mode=TwoWay}"
|
|
||||||
SelectedValuePath="Tag">
|
|
||||||
<ComboBoxItem Content="활용하지 않음" Tag="none"/>
|
|
||||||
<ComboBoxItem Content="소극 활용 (요청 시)" Tag="passive"/>
|
|
||||||
<ComboBoxItem Content="적극 활용 (자동 탐색)" Tag="active"/>
|
|
||||||
</ComboBox>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ── 품질 검증 ── -->
|
<!-- ── 품질 검증 ── -->
|
||||||
<TextBlock Style="{StaticResource SectionHeader}" Text="품질 검증"/>
|
<TextBlock Style="{StaticResource SectionHeader}" Text="품질 검증"/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.IO;
|
||||||
using System.Windows.Data;
|
using System.Windows.Data;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
@@ -15,6 +16,7 @@ public partial class SettingsWindow : Window
|
|||||||
private readonly SettingsViewModel _vm;
|
private readonly SettingsViewModel _vm;
|
||||||
private readonly Action<string> _previewCallback;
|
private readonly Action<string> _previewCallback;
|
||||||
private readonly Action _revertCallback;
|
private readonly Action _revertCallback;
|
||||||
|
private IndexService? _indexService;
|
||||||
private bool _saved;
|
private bool _saved;
|
||||||
private bool _isDisplayModeSyncing;
|
private bool _isDisplayModeSyncing;
|
||||||
|
|
||||||
@@ -59,9 +61,16 @@ public partial class SettingsWindow : Window
|
|||||||
catch { /* 인덱싱 실패해도 설정 저장은 완료 */ }
|
catch { /* 인덱싱 실패해도 설정 저장은 완료 */ }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_indexService != null)
|
||||||
|
_indexService.IndexProgressChanged -= IndexService_IndexProgressChanged;
|
||||||
|
};
|
||||||
|
|
||||||
Loaded += async (_, _) =>
|
Loaded += async (_, _) =>
|
||||||
{
|
{
|
||||||
|
BindIndexProgress();
|
||||||
|
RefreshMemoryOverview();
|
||||||
RefreshHotkeyBadges();
|
RefreshHotkeyBadges();
|
||||||
SetVersionText();
|
SetVersionText();
|
||||||
EnsureHotkeyInCombo();
|
EnsureHotkeyInCombo();
|
||||||
@@ -104,6 +113,35 @@ public partial class SettingsWindow : Window
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BindIndexProgress()
|
||||||
|
{
|
||||||
|
_indexService = (System.Windows.Application.Current as App)?.IndexService;
|
||||||
|
if (_indexService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_indexService.IndexProgressChanged -= IndexService_IndexProgressChanged;
|
||||||
|
_indexService.IndexProgressChanged += IndexService_IndexProgressChanged;
|
||||||
|
ApplyIndexProgress(_indexService.CurrentProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void IndexService_IndexProgressChanged(object? sender, LauncherIndexProgressInfo e)
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(() => ApplyIndexProgress(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyIndexProgress(LauncherIndexProgressInfo progress)
|
||||||
|
{
|
||||||
|
if (IndexProgressPanel == null || IndexProgressStatusText == null || IndexProgressBar == null || IndexProgressDetailText == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IndexProgressPanel.Visibility = Visibility.Visible;
|
||||||
|
IndexProgressStatusText.Text = progress.StatusText;
|
||||||
|
IndexProgressDetailText.Text = progress.DetailText;
|
||||||
|
IndexProgressBar.IsIndeterminate = progress.IsRunning && progress.ProgressPercent <= 0.01;
|
||||||
|
if (!IndexProgressBar.IsIndeterminate)
|
||||||
|
IndexProgressBar.Value = progress.ProgressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
private bool HasLegacyAgentTab()
|
private bool HasLegacyAgentTab()
|
||||||
=> MainSettingsTab != null && AgentTabItem != null && MainSettingsTab.Items.Contains(AgentTabItem);
|
=> MainSettingsTab != null && AgentTabItem != null && MainSettingsTab.Items.Contains(AgentTabItem);
|
||||||
|
|
||||||
@@ -1016,8 +1054,6 @@ public partial class SettingsWindow : Window
|
|||||||
};
|
};
|
||||||
cb.Unchecked += (_, _) =>
|
cb.Unchecked += (_, _) =>
|
||||||
{
|
{
|
||||||
// 최소 1개 유지
|
|
||||||
if (enabled.Count <= 1) { cb.IsChecked = true; return; }
|
|
||||||
enabled.RemoveAll(x => x == capturedKey);
|
enabled.RemoveAll(x => x == capturedKey);
|
||||||
svc.Save();
|
svc.Save();
|
||||||
};
|
};
|
||||||
@@ -1446,9 +1482,11 @@ public partial class SettingsWindow : Window
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var asm = System.Reflection.Assembly.GetExecutingAssembly();
|
var asm = System.Reflection.Assembly.GetExecutingAssembly();
|
||||||
// FileVersionInfo 에서 읽어야 csproj <Version> 이 반영됩니다.
|
var infoAttr = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
||||||
var fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(asm.Location);
|
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||||
var ver = fvi.ProductVersion ?? fvi.FileVersion ?? "?";
|
.FirstOrDefault();
|
||||||
|
var info = infoAttr?.InformationalVersion;
|
||||||
|
var ver = info ?? asm.GetName().Version?.ToString() ?? "?";
|
||||||
// 빌드 메타데이터 제거 (예: "1.0.3+gitabcdef" → "1.0.3")
|
// 빌드 메타데이터 제거 (예: "1.0.3+gitabcdef" → "1.0.3")
|
||||||
var plusIdx = ver.IndexOf('+');
|
var plusIdx = ver.IndexOf('+');
|
||||||
if (plusIdx > 0) ver = ver[..plusIdx];
|
if (plusIdx > 0) ver = ver[..plusIdx];
|
||||||
@@ -2942,9 +2980,280 @@ public partial class SettingsWindow : Window
|
|||||||
|
|
||||||
var app = System.Windows.Application.Current as App;
|
var app = System.Windows.Application.Current as App;
|
||||||
app?.MemoryService?.Clear();
|
app?.MemoryService?.Clear();
|
||||||
|
RefreshMemoryOverview();
|
||||||
CustomMessageBox.Show("에이전트 메모리가 초기화되었습니다.", "완료", MessageBoxButton.OK, MessageBoxImage.Information);
|
CustomMessageBox.Show("에이전트 메모리가 초기화되었습니다.", "완료", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BtnRefreshMemoryOverview_Click(object sender, RoutedEventArgs e) => RefreshMemoryOverview();
|
||||||
|
|
||||||
|
private void BtnEditManagedMemory_Click(object sender, RoutedEventArgs e) => OpenMemoryScopeEditor("managed", "관리형 메모리");
|
||||||
|
private void BtnEditUserMemory_Click(object sender, RoutedEventArgs e) => OpenMemoryScopeEditor("user", "사용자 메모리");
|
||||||
|
private void BtnEditProjectMemory_Click(object sender, RoutedEventArgs e) => OpenMemoryScopeEditor("project", "프로젝트 메모리");
|
||||||
|
private void BtnEditLocalMemory_Click(object sender, RoutedEventArgs e) => OpenMemoryScopeEditor("local", "로컬 메모리");
|
||||||
|
|
||||||
|
private void OpenMemoryScopeEditor(string scope, string title)
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var memory = app?.MemoryService;
|
||||||
|
if (memory == null)
|
||||||
|
{
|
||||||
|
CustomMessageBox.Show("메모리 서비스를 사용할 수 없습니다.", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var workFolder = _vm.Service.Settings.Llm.WorkFolder;
|
||||||
|
var path = memory.GetWritableInstructionPath(scope, workFolder);
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
CustomMessageBox.Show("해당 메모리 파일 경로를 결정할 수 없습니다. 작업 폴더를 먼저 확인하세요.", "메모리 편집", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = memory.ReadInstructionFile(scope, workFolder) ?? "";
|
||||||
|
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
|
||||||
|
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
|
||||||
|
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||||
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
|
||||||
|
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
|
||||||
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Gainsboro;
|
||||||
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
||||||
|
|
||||||
|
var dlg = new Window
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Width = 760,
|
||||||
|
Height = 620,
|
||||||
|
MinWidth = 620,
|
||||||
|
MinHeight = 480,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
Owner = this,
|
||||||
|
Background = bgBrush,
|
||||||
|
Foreground = fgBrush,
|
||||||
|
ShowInTaskbar = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new Grid { Margin = new Thickness(18) };
|
||||||
|
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||||
|
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||||
|
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||||
|
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||||
|
|
||||||
|
var header = new StackPanel { Margin = new Thickness(0, 0, 0, 12) };
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = title,
|
||||||
|
FontSize = 18,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = fgBrush
|
||||||
|
});
|
||||||
|
header.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"경로: {path}",
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = subFgBrush,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 6, 0, 0)
|
||||||
|
});
|
||||||
|
Grid.SetRow(header, 0);
|
||||||
|
root.Children.Add(header);
|
||||||
|
|
||||||
|
var hint = new Border
|
||||||
|
{
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(10),
|
||||||
|
Padding = new Thickness(12, 10, 12, 10),
|
||||||
|
Margin = new Thickness(0, 0, 0, 12),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "frontmatter 예시:\n---\ndescription: 결제 모듈 규칙\nenabled: true\ntags:\n - payments\n - review\npaths:\n - src/Payments/**\n---\n\n메모리 내용은 /memory 명령과 함께 사용되며, 빈 내용으로 저장하면 파일이 삭제됩니다.",
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = subFgBrush,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
LineHeight = 18
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Grid.SetRow(hint, 1);
|
||||||
|
root.Children.Add(hint);
|
||||||
|
|
||||||
|
var editor = new TextBox
|
||||||
|
{
|
||||||
|
Text = existing,
|
||||||
|
AcceptsReturn = true,
|
||||||
|
AcceptsTab = true,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||||
|
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||||
|
Background = Brushes.White,
|
||||||
|
Foreground = fgBrush,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(14),
|
||||||
|
FontSize = 13,
|
||||||
|
FontFamily = new FontFamily("Consolas, Malgun Gothic, Segoe UI")
|
||||||
|
};
|
||||||
|
Grid.SetRow(editor, 2);
|
||||||
|
root.Children.Add(editor);
|
||||||
|
|
||||||
|
var footer = new Grid { Margin = new Thickness(0, 14, 0, 0) };
|
||||||
|
footer.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
footer.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
footer.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
var openFolder = new Border
|
||||||
|
{
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(12, 7, 12, 7),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Child = new TextBlock { Text = "폴더 열기", FontSize = 12, Foreground = fgBrush }
|
||||||
|
};
|
||||||
|
openFolder.MouseEnter += (_, _) => openFolder.Background = hoverBg;
|
||||||
|
openFolder.MouseLeave += (_, _) => openFolder.Background = itemBg;
|
||||||
|
openFolder.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"/select,\"{path}\"",
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CustomMessageBox.Show($"폴더를 열 수 없습니다.\n{ex.Message}", "메모리 편집", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Grid.SetColumn(openFolder, 0);
|
||||||
|
footer.Children.Add(openFolder);
|
||||||
|
|
||||||
|
var cancelBtn = new Border
|
||||||
|
{
|
||||||
|
Background = itemBg,
|
||||||
|
BorderBrush = borderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(16, 7, 16, 7),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Margin = new Thickness(8, 0, 0, 0),
|
||||||
|
Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = fgBrush }
|
||||||
|
};
|
||||||
|
cancelBtn.MouseEnter += (_, _) => cancelBtn.Background = hoverBg;
|
||||||
|
cancelBtn.MouseLeave += (_, _) => cancelBtn.Background = itemBg;
|
||||||
|
cancelBtn.MouseLeftButtonUp += (_, _) => dlg.Close();
|
||||||
|
Grid.SetColumn(cancelBtn, 1);
|
||||||
|
footer.Children.Add(cancelBtn);
|
||||||
|
|
||||||
|
var saveBtn = new Border
|
||||||
|
{
|
||||||
|
Background = accentBrush,
|
||||||
|
CornerRadius = new CornerRadius(8),
|
||||||
|
Padding = new Thickness(16, 7, 16, 7),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
Margin = new Thickness(8, 0, 0, 0),
|
||||||
|
Child = new TextBlock { Text = "저장", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }
|
||||||
|
};
|
||||||
|
saveBtn.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
var text = editor.Text.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, editor.Text.Replace("\r\n", "\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
memory.Load(workFolder);
|
||||||
|
RefreshMemoryOverview();
|
||||||
|
CustomMessageBox.Show("메모리 파일이 저장되었습니다.", "메모리 편집", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
dlg.Close();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CustomMessageBox.Show($"메모리 파일 저장에 실패했습니다.\n{ex.Message}", "메모리 편집", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Grid.SetColumn(saveBtn, 2);
|
||||||
|
footer.Children.Add(saveBtn);
|
||||||
|
|
||||||
|
Grid.SetRow(footer, 3);
|
||||||
|
root.Children.Add(footer);
|
||||||
|
|
||||||
|
dlg.Content = root;
|
||||||
|
dlg.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshMemoryOverview()
|
||||||
|
{
|
||||||
|
if (TxtMemoryOverviewSummary == null || TxtMemoryOverviewScopes == null || TxtMemoryOverviewAudit == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var memory = app?.MemoryService;
|
||||||
|
if (memory == null)
|
||||||
|
{
|
||||||
|
TxtMemoryOverviewSummary.Text = "메모리 서비스를 사용할 수 없습니다.";
|
||||||
|
TxtMemoryOverviewScopes.Text = "";
|
||||||
|
TxtMemoryOverviewAudit.Text = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var workFolder = _vm.Service.Settings.Llm.WorkFolder;
|
||||||
|
memory.Load(workFolder);
|
||||||
|
|
||||||
|
var docs = memory.InstructionDocuments;
|
||||||
|
var learnedCount = memory.All.Count;
|
||||||
|
TxtMemoryOverviewSummary.Text = $"계층형 규칙 {docs.Count}개, 학습 메모리 {learnedCount}개가 현재 컨텍스트에 반영됩니다.";
|
||||||
|
|
||||||
|
if (docs.Count == 0)
|
||||||
|
{
|
||||||
|
TxtMemoryOverviewScopes.Text = "현재 활성화된 계층형 메모리 파일이 없습니다.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var lines = docs
|
||||||
|
.Take(6)
|
||||||
|
.Select(doc =>
|
||||||
|
{
|
||||||
|
var priority = doc.Priority > 0 ? $"우선순위 {doc.Priority}" : "우선순위 미정";
|
||||||
|
var description = string.IsNullOrWhiteSpace(doc.Description) ? "" : $" · {doc.Description}";
|
||||||
|
var tags = doc.Tags.Count > 0 ? $" · tags: {string.Join(", ", doc.Tags)}" : "";
|
||||||
|
return $"[{doc.Label}] {priority}{description}{tags}";
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (docs.Count > lines.Count)
|
||||||
|
lines.Add($"외 {docs.Count - lines.Count}개 규칙");
|
||||||
|
|
||||||
|
TxtMemoryOverviewScopes.Text = string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
var includeEntries = AuditLogService.LoadRecent("MemoryInclude", maxCount: 3, daysBack: 3)
|
||||||
|
.Take(3)
|
||||||
|
.Select(x =>
|
||||||
|
{
|
||||||
|
var status = x.Success ? "허용" : "차단";
|
||||||
|
return $"{status} · {x.Timestamp:HH:mm:ss} · {x.Result}";
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
TxtMemoryOverviewAudit.Text = includeEntries.Count == 0
|
||||||
|
? "최근 3일 include 감사 기록이 없습니다."
|
||||||
|
: "최근 3일 include 감사\n" + string.Join("\n", includeEntries);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 에이전트 훅 관리 ─────────────────────────────────────────────────
|
// ─── 에이전트 훅 관리 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private void AddHookBtn_Click(object sender, MouseButtonEventArgs e)
|
private void AddHookBtn_Click(object sender, MouseButtonEventArgs e)
|
||||||
|
|||||||
@@ -578,6 +578,18 @@ public partial class SkillGalleryWindow : Window
|
|||||||
AddMetaRow("런타임", skill.Requires, metaRow++);
|
AddMetaRow("런타임", skill.Requires, metaRow++);
|
||||||
if (!string.IsNullOrEmpty(skill.AllowedTools))
|
if (!string.IsNullOrEmpty(skill.AllowedTools))
|
||||||
AddMetaRow("허용 도구", skill.AllowedTools, metaRow++);
|
AddMetaRow("허용 도구", skill.AllowedTools, metaRow++);
|
||||||
|
if (!string.IsNullOrEmpty(skill.Model))
|
||||||
|
AddMetaRow("모델", skill.Model, metaRow++);
|
||||||
|
if (!string.IsNullOrEmpty(skill.Effort))
|
||||||
|
AddMetaRow("추론 강도", skill.Effort, metaRow++);
|
||||||
|
if (!string.IsNullOrEmpty(skill.ExecutionContext))
|
||||||
|
AddMetaRow("실행 컨텍스트", skill.ExecutionContext, metaRow++);
|
||||||
|
if (!string.IsNullOrEmpty(skill.Agent))
|
||||||
|
AddMetaRow("에이전트", skill.Agent, metaRow++);
|
||||||
|
if (skill.DisableModelInvocation)
|
||||||
|
AddMetaRow("모델 호출", "비활성화", metaRow++);
|
||||||
|
if (!string.IsNullOrEmpty(skill.WhenToUse))
|
||||||
|
AddMetaRow("추천 상황", skill.WhenToUse, metaRow++);
|
||||||
AddMetaRow("상태", skill.IsAvailable ? "✓ 사용 가능" : $"✗ {skill.UnavailableHint}", metaRow++);
|
AddMetaRow("상태", skill.IsAvailable ? "✓ 사용 가능" : $"✗ {skill.UnavailableHint}", metaRow++);
|
||||||
AddMetaRow("경로", skill.FilePath, metaRow++);
|
AddMetaRow("경로", skill.FilePath, metaRow++);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user