mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a052af0060 | |||
| 92785a8078 | |||
| 1d97570a94 | |||
| d98183f3da | |||
| 68a0c304d8 | |||
| 60007ee4e8 | |||
| 7bd29d721e | |||
| c153e7104e | |||
| e65360e68a | |||
| f0e8ba1d8f | |||
| 58153333ce | |||
| d098ecad64 | |||
| 809a1fcf84 |
+1
-1
@@ -8,7 +8,7 @@ release/
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
# AGENTS.md
|
||||
GEMINI.md
|
||||
/.claude
|
||||
/.codex
|
||||
|
||||
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows Home Dir Regression**: Prevent providers/settings “disappearing” after upgrading from v3.10.2 → v3.10.3 when `HOME` differs from the real user profile directory; restore default path resolution and auto-detect the v3.10.3 legacy database location.
|
||||
|
||||
---
|
||||
|
||||
## [3.10.3] - 2026-01-30
|
||||
|
||||
@@ -48,6 +48,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -48,6 +48,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
@@ -49,6 +49,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 界面预览
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -57,10 +57,12 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
@@ -72,6 +74,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"flexsearch": "^0.8.212",
|
||||
"framer-motion": "^12.23.25",
|
||||
"i18next": "^25.5.2",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
|
||||
Generated
+97
@@ -59,6 +59,9 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -71,6 +74,9 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-visually-hidden':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -104,6 +110,9 @@ importers:
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
flexsearch:
|
||||
specifier: ^0.8.212
|
||||
version: 0.8.212
|
||||
framer-motion:
|
||||
specifier: ^12.23.25
|
||||
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -1117,6 +1126,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10':
|
||||
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||
peerDependencies:
|
||||
@@ -1174,6 +1196,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -1323,56 +1358,67 @@ packages:
|
||||
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
|
||||
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.46.2':
|
||||
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.46.2':
|
||||
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.46.2':
|
||||
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.46.2':
|
||||
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.46.2':
|
||||
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
|
||||
@@ -1429,30 +1475,35 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
|
||||
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
|
||||
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
|
||||
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.8.1':
|
||||
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
|
||||
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
|
||||
@@ -1995,6 +2046,9 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
flexsearch@0.8.212:
|
||||
resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==}
|
||||
|
||||
form-data@4.0.4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2211,24 +2265,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.1:
|
||||
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.1:
|
||||
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.1:
|
||||
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.1:
|
||||
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
||||
@@ -3905,6 +3963,23 @@ snapshots:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
@@ -3979,6 +4054,26 @@ snapshots:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
@@ -4758,6 +4853,8 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
flexsearch@0.8.212: {}
|
||||
|
||||
form-data@4.0.4:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 会话管理(Session Manager)需求文档(PRD / Markdown)
|
||||
|
||||
> 目标:对 **Codex / Claude Code** 的本地会话记录进行可视化管理,并提供“一键复制 / 一键终端恢复”能力。
|
||||
> 范围:**v1 仅 macOS**,但必须预留多平台扩展入口。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
开发者同时使用 Codex CLI、Claude Code 时,常见痛点:
|
||||
- 会话记录落在本地不同位置,**难以发现/检索**
|
||||
- 找到会话后,恢复命令需要记忆或翻历史,**恢复成本高**
|
||||
- 恢复时经常忘了当时的工作目录,导致命令在错误目录运行
|
||||
- 希望在常用终端(macOS Terminal、kitty 等)中直接恢复,提高效率
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 Goals(v1 必达)
|
||||
1. 扫描并展示本机所有 Codex / Claude Code 会话:列表 + 详情(会话内容)
|
||||
2. 支持恢复会话:
|
||||
- 复制恢复命令(按钮)
|
||||
- 复制会话目录(按钮,若能获取/推断)
|
||||
- 可选:直接在终端执行恢复(macOS Terminal、kitty;可扩展)
|
||||
3. 仅 macOS 支持,但代码结构需支持未来扩展 Windows/Linux
|
||||
|
||||
### 2.2 Non-Goals(v1 不做)
|
||||
- 不新增/依赖云端 API;默认不上传任何内容
|
||||
- 不承诺解析所有 provider 的全部内部格式(尽量兼容、可配置、可降级)
|
||||
- 不做复杂的团队协作/分享/同步(后续版本再考虑)
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户画像与使用场景
|
||||
|
||||
### 3.1 典型用户
|
||||
- 高频使用多个 AI 编程工具的工程师/技术负责人/PM
|
||||
- 多项目、多分支并行,频繁“中断—恢复—继续推进”
|
||||
|
||||
### 3.2 核心场景(Top)
|
||||
1. **找回会话**:我记得一个会话讨论过某段逻辑 → 搜索关键词 → 打开详情
|
||||
2. **快速恢复**:我想继续昨天的会话 → 复制恢复命令 / 一键在终端恢复
|
||||
3. **回到正确目录**:恢复前先复制目录或自动 cd 到目录
|
||||
|
||||
---
|
||||
|
||||
## 4. 产品形态与信息架构
|
||||
|
||||
### 4.1 信息架构
|
||||
- Session Manager
|
||||
- 会话列表(List)
|
||||
- 会话详情(Detail)
|
||||
- 设置(Settings)
|
||||
- Provider 配置(路径/启用禁用)
|
||||
- 终端集成(默认终端、权限提示、降级策略)
|
||||
- 索引与隐私选项(是否缓存、缓存大小、敏感信息遮罩)
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能需求(Functional Requirements)
|
||||
|
||||
### 5.1 会话发现与索引(Discovery & Indexing)
|
||||
**FR-1** 扫描本地会话数据源,生成统一的 Session 列表
|
||||
- 支持 Provider:Codex、Claude Code(可扩展)
|
||||
- 支持全量扫描 + 增量更新
|
||||
- 支持缺失/异常文件的容错(不中断 UI)
|
||||
|
||||
**FR-2** 本地索引(Cache/DB)
|
||||
- 用于加速列表加载与搜索
|
||||
- 索引字段至少包含:sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空)
|
||||
|
||||
**FR-3** 数据源路径探测(可配置 + 多候选)
|
||||
- 默认使用常见路径;允许用户在 Settings 覆盖
|
||||
- 若无法探测到 provider 安装/数据目录:在 UI 显示未启用/不可用状态,但不报错崩溃
|
||||
|
||||
---
|
||||
|
||||
### 5.2 会话列表(List)
|
||||
**FR-4** 列表展示字段(建议最小集)
|
||||
- Provider(Codex / Claude)
|
||||
- Session 标识(id/short id)
|
||||
- 最近活跃时间(lastActiveAt)
|
||||
- 目录(projectDir,若未知显示 “Unknown”)
|
||||
- 摘要(summary:最后一条/首条截断或规则生成)
|
||||
|
||||
**FR-5** 列表交互
|
||||
- 搜索(跨会话,关键词匹配 transcript/summary/目录)
|
||||
- 过滤:Provider、是否有目录、时间范围
|
||||
- 排序:最近活跃(默认)、最早、按目录
|
||||
|
||||
**FR-6** 空态/异常态
|
||||
- 未发现任何会话:给出“如何启用/设置路径”的指引
|
||||
- 发现会话但无法解析内容:列表仍可显示基本信息,并在详情页提示“解析失败”
|
||||
|
||||
---
|
||||
|
||||
### 5.3 会话详情(Detail)
|
||||
**FR-7** 会话内容展示
|
||||
- 时间线展示消息(role:user/assistant/tool 等)
|
||||
- 支持在当前会话内搜索 + 高亮
|
||||
- 展示元信息:
|
||||
- provider、sessionId、创建/最近活跃时间
|
||||
- projectDir(可空)
|
||||
- 原始文件路径(可选显示,便于 debug)
|
||||
|
||||
**FR-8** 性能策略
|
||||
- 默认按需加载(打开详情才加载全文)
|
||||
- 对超长 transcript 支持分页/虚拟列表(防止卡顿)
|
||||
|
||||
---
|
||||
|
||||
### 5.4 恢复能力(Resume / Restore)
|
||||
#### 5.4.1 复制恢复命令(必做)
|
||||
**FR-9** “复制恢复命令”按钮
|
||||
- 根据 provider 生成恢复命令(模板可配置)
|
||||
- 点击后写入剪贴板,并 toast 提示成功
|
||||
|
||||
> 说明:不同版本 CLI 命令可能略有差异,建议将命令模板做成可配置项(Settings),默认提供推荐模板。
|
||||
|
||||
#### 5.4.2 复制会话目录(尽量做)
|
||||
**FR-10** “复制会话目录”按钮
|
||||
- 当 projectDir 可得时启用;不可得时置灰,并提示原因(无法推断目录)
|
||||
- 复制内容为可直接 `cd` 的绝对路径(或原样)
|
||||
|
||||
#### 5.4.3 一键终端恢复(可选但强烈建议)
|
||||
**FR-11** “在终端恢复”按钮(或下拉菜单)
|
||||
- 默认目标:macOS Terminal
|
||||
- 支持 kitty(v1 要求)
|
||||
- 执行策略:
|
||||
- `cd "<projectDir>" && <resumeCommand>`(若 projectDir 为空则仅执行 resumeCommand)
|
||||
- 失败降级:
|
||||
- 无权限/终端不可用 → 自动降级为“仅复制命令”,并提示用户如何修复(例如开启 Automation 权限、kitty remote control)
|
||||
|
||||
**FR-12** 终端目标选择与记忆
|
||||
- 下拉选择:Terminal / kitty /(预留 iTerm2)/ 仅复制
|
||||
- 记住上次选择作为默认
|
||||
|
||||
---
|
||||
|
||||
## 6. 平台与扩展性设计(macOS v1 + Future-proof)
|
||||
|
||||
### 6.1 Provider Adapter 抽象(必须)
|
||||
统一接口(示例):
|
||||
- `detect(): boolean`
|
||||
- `scanSessions(): SessionMeta[]`
|
||||
- `loadTranscript(sessionId): Message[]`
|
||||
- `getResumeCommand(sessionId): string`
|
||||
- `getProjectDir(sessionId): string | null`
|
||||
|
||||
### 6.2 Terminal Launcher 抽象(必须)
|
||||
- `launch(command: string, cwd?: string, targetTerminal: TerminalKind): Result`
|
||||
- macOS v1 实现:TerminalLauncherMac
|
||||
- Future:TerminalLauncherWindows / TerminalLauncherLinux
|
||||
|
||||
### 6.3 Path Resolver(必须)
|
||||
- `resolveProviderDataPaths(providerId): string[]`
|
||||
- v1 返回 macOS 默认候选;允许 Settings 覆盖
|
||||
|
||||
---
|
||||
|
||||
## 7. 隐私与安全(Privacy & Security)
|
||||
|
||||
**默认原则:全本地、只读、不上传。**
|
||||
- transcript 默认不出网
|
||||
- 本地索引默认仅存必要字段(可选:是否缓存全文内容)
|
||||
- 提供“敏感信息遮罩”(可选):
|
||||
- 简单正则:token/key/password 等
|
||||
- 提示用户:会话内容可能包含敏感信息,导出/复制时注意
|
||||
|
||||
---
|
||||
|
||||
## 8. 非功能需求(Non-Functional Requirements)
|
||||
|
||||
### 8.1 性能
|
||||
- 首次打开:列表可在 1s 内展示(允许先展示缓存,再后台增量刷新)
|
||||
- 搜索:在 1k 会话量级可用(建立索引或增量缓存)
|
||||
- 详情页:打开后 300ms 内渲染骨架屏,内容流式/分段加载
|
||||
|
||||
### 8.2 稳定性
|
||||
- 任一 provider 数据源损坏不影响整体(隔离失败)
|
||||
- 扫描过程可中断/可重试
|
||||
|
||||
### 8.3 可观测性(可选)
|
||||
- 本地日志:扫描耗时、解析失败原因、终端启动失败原因(便于 debug)
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键数据结构(建议)
|
||||
|
||||
### 9.1 SessionMeta
|
||||
- `providerId: "codex" | "claude" | string`
|
||||
- `sessionId: string`
|
||||
- `title?: string`
|
||||
- `summary?: string`
|
||||
- `projectDir?: string | null`
|
||||
- `createdAt?: number`
|
||||
- `lastActiveAt?: number`
|
||||
- `sourcePath?: string`
|
||||
|
||||
### 9.2 Message
|
||||
- `role: "user" | "assistant" | "tool" | "system" | string`
|
||||
- `content: string`
|
||||
- `ts?: number`
|
||||
- `raw?: any`(保留原始字段,便于兼容未来格式)
|
||||
|
||||
---
|
||||
|
||||
## 10. 交互流程(UX Flows)
|
||||
|
||||
### 10.1 Flow A:搜索并查看
|
||||
1) 打开 Session Manager → 看到列表
|
||||
2) 输入关键词搜索 → 命中会话
|
||||
3) 点击会话 → 进入详情 → 浏览内容 / 在会话内搜索
|
||||
|
||||
### 10.2 Flow B:复制恢复命令
|
||||
1) 列表或详情页点击“复制恢复命令”
|
||||
2) toast 成功 → 用户粘贴到终端执行
|
||||
|
||||
### 10.3 Flow C:一键终端恢复
|
||||
1) 详情页点击“在终端恢复”(默认 Terminal)
|
||||
2) 系统打开终端新窗口/新 tab
|
||||
3) 自动执行:`cd projectDir && resumeCommand`
|
||||
4) 失败 → toast 提示,并提供“复制命令”降级路径
|
||||
|
||||
---
|
||||
|
||||
## 11. 边界情况与降级策略
|
||||
|
||||
- 无法获取 projectDir:仍可恢复(只执行 resume),目录按钮置灰
|
||||
- 无法解析 transcript:列表仍显示,详情提示“无法解析”,可提供“打开原始文件路径”
|
||||
- CLI 命令模板不匹配:允许 Settings 自定义模板;默认模板可更新
|
||||
- 终端权限问题(Automation):提示用户在系统设置中开启对应权限,并允许降级为复制命令
|
||||
- kitty 未开启 remote control:提示如何配置,降级为复制命令
|
||||
|
||||
---
|
||||
|
||||
## 12. 里程碑与交付(建议)
|
||||
|
||||
### M1(核心可用)
|
||||
- Provider 扫描:Codex / Claude
|
||||
- 列表 + 详情(可读)
|
||||
- 复制恢复命令
|
||||
- 复制目录(若可得)
|
||||
|
||||
### M2(效率提升)
|
||||
- 跨会话搜索、过滤/排序
|
||||
- 增量索引与文件监听(可选)
|
||||
- “在 macOS Terminal 恢复”
|
||||
|
||||
### M3(终端覆盖与可扩展)
|
||||
- “在 kitty 恢复”
|
||||
- 终端目标下拉与记忆
|
||||
- 插件化接口/扩展点文档
|
||||
|
||||
---
|
||||
|
||||
## 13. 后续功能候选(Backlog / Ideas)
|
||||
|
||||
- 收藏/Pin 会话
|
||||
- 会话标签(项目/主题/状态)
|
||||
- 会话摘要(本地生成)
|
||||
- Fork 会话继续(避免污染原会话)
|
||||
- 导出 Markdown/JSONL
|
||||
- 按项目聚合(Repo 视图)
|
||||
- 会话清理/归档(磁盘管理)
|
||||
|
||||
---
|
||||
@@ -652,6 +652,7 @@ exec bash --norc --noprofile
|
||||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
};
|
||||
|
||||
@@ -754,7 +755,7 @@ fn launch_macos_open_app(
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
|
||||
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -954,3 +955,18 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置窗口主题(Windows/macOS 标题栏颜色)
|
||||
/// theme: "dark" | "light" | "system"
|
||||
#[tauri::command]
|
||||
pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {
|
||||
use tauri::Theme;
|
||||
|
||||
let tauri_theme = match theme.as_str() {
|
||||
"dark" => Some(Theme::Dark),
|
||||
"light" => Some(Theme::Light),
|
||||
_ => None, // system default
|
||||
};
|
||||
|
||||
window.set_theme(tauri_theme).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod proxy;
|
||||
mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
@@ -29,6 +30,7 @@ pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use proxy::*;
|
||||
pub use session_manager::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::session_manager;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sessions() -> Result<Vec<session_manager::SessionMeta>, String> {
|
||||
let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to scan sessions: {e}"))?;
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_session_messages(
|
||||
providerId: String,
|
||||
sourcePath: String,
|
||||
) -> Result<Vec<session_manager::SessionMessage>, String> {
|
||||
let provider_id = providerId.clone();
|
||||
let source_path = sourcePath.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::load_messages(&provider_id, &source_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load session messages: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch_session_terminal(
|
||||
command: String,
|
||||
cwd: Option<String>,
|
||||
custom_config: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let command = command.clone();
|
||||
let cwd = cwd.clone();
|
||||
let custom_config = custom_config.clone();
|
||||
|
||||
// Read preferred terminal from global settings
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
// Map global setting terminal names to session terminal names
|
||||
// Global uses "iterm2", session terminal uses "iterm"
|
||||
let target = match preferred.as_deref() {
|
||||
Some("iterm2") => "iterm".to_string(),
|
||||
Some(t) => t.to_string(),
|
||||
None => "terminal".to_string(), // Default to Terminal.app on macOS
|
||||
};
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::terminal::launch_terminal(
|
||||
&target,
|
||||
&command,
|
||||
cwd.as_deref(),
|
||||
custom_config.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch terminal: {e}"))??;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
+42
-5
@@ -7,16 +7,25 @@ use crate::error::AppError;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
///
|
||||
/// On Windows, respects the `HOME` environment variable (if set) to support
|
||||
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
|
||||
/// ## Windows 注意事项
|
||||
///
|
||||
/// - `dirs::home_dir()` 在 Windows 上使用 `SHGetKnownFolderPath(FOLDERID_Profile)`,
|
||||
/// 返回的是真实用户目录(类似 `C:\\Users\\Alice`),与 v3.10.2 行为一致。
|
||||
/// - 不要直接使用 `HOME` 环境变量:它可能由 Git/Cygwin/MSYS 等第三方工具注入,
|
||||
/// 且不一定等于用户目录,可能导致 `.cc-switch/cc-switch.db` 路径变化,从而“看起来像数据丢失”。
|
||||
///
|
||||
/// ## 测试隔离
|
||||
///
|
||||
/// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME`
|
||||
/// 显式覆盖 home dir(仅用于测试/调试场景)。
|
||||
pub fn get_home_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") {
|
||||
let trimmed = home.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
@@ -81,7 +90,35 @@ pub fn get_app_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
||||
return custom;
|
||||
}
|
||||
get_home_dir().join(".cc-switch")
|
||||
|
||||
let default_dir = get_home_dir().join(".cc-switch");
|
||||
|
||||
// 兼容 v3.10.3:当用户环境存在 `HOME` 且与真实用户目录不同,
|
||||
// v3.10.3 可能在 `HOME/.cc-switch/` 下创建/使用了数据库。
|
||||
// 这里仅在“默认位置没有数据库”时回退到旧位置,避免再次出现“供应商消失”问题,
|
||||
// 同时也避免新安装因为 `HOME` 被设置而写入非预期路径。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let default_db = default_dir.join("cc-switch.db");
|
||||
if !default_db.exists() {
|
||||
if let Ok(home_env) = std::env::var("HOME") {
|
||||
let trimmed = home_env.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let legacy_dir = PathBuf::from(trimmed).join(".cc-switch");
|
||||
if legacy_dir.join("cc-switch.db").exists() {
|
||||
log::info!(
|
||||
"Detected v3.10.3 legacy database at {}, using it instead of {}",
|
||||
legacy_dir.display(),
|
||||
default_dir.display()
|
||||
);
|
||||
return legacy_dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default_dir
|
||||
}
|
||||
|
||||
/// 获取应用配置文件路径
|
||||
|
||||
@@ -21,6 +21,7 @@ mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod services;
|
||||
mod session_manager;
|
||||
mod settings;
|
||||
mod store;
|
||||
mod tray;
|
||||
@@ -933,6 +934,10 @@ pub fn run() {
|
||||
commands::stream_check_all_providers,
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
// Session manager
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
@@ -951,6 +956,8 @@ pub fn run() {
|
||||
commands::test_proxy_url,
|
||||
commands::get_upstream_proxy_status,
|
||||
commands::scan_local_proxies,
|
||||
// Window theme control
|
||||
commands::set_window_theme,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::time::Instant;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo};
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
|
||||
/// 健康状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -303,12 +303,18 @@ impl StreamCheckService {
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置 headers
|
||||
let response = client
|
||||
// 根据 auth.strategy 构建认证 headers
|
||||
let mut request_builder = client
|
||||
.post(&url)
|
||||
// 认证 headers(双重认证)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key));
|
||||
|
||||
// 只有 Anthropic 官方策略才添加 x-api-key
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
request_builder = request_builder.header("x-api-key", &auth.api_key);
|
||||
}
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置其他 headers
|
||||
let response = request_builder
|
||||
// Anthropic 必需 headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header(
|
||||
@@ -703,4 +709,22 @@ mod tests {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(arch_name, "x86_64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_strategy_imports() {
|
||||
// 验证 AuthStrategy 枚举可以正常使用
|
||||
let anthropic = AuthStrategy::Anthropic;
|
||||
let claude_auth = AuthStrategy::ClaudeAuth;
|
||||
let bearer = AuthStrategy::Bearer;
|
||||
|
||||
// 验证不同的策略是不相等的
|
||||
assert_ne!(anthropic, claude_auth);
|
||||
assert_ne!(anthropic, bearer);
|
||||
assert_ne!(claude_auth, bearer);
|
||||
|
||||
// 验证相同策略是相等的
|
||||
assert_eq!(anthropic, AuthStrategy::Anthropic);
|
||||
assert_eq!(claude_auth, AuthStrategy::ClaudeAuth);
|
||||
assert_eq!(bearer, AuthStrategy::Bearer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
use providers::{claude, codex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionMeta {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_active_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resume_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ts: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let mut sessions = Vec::new();
|
||||
sessions.extend(codex::scan_sessions());
|
||||
sessions.extend(claude::scan_sessions());
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0);
|
||||
b_ts.cmp(&a_ts)
|
||||
});
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
|
||||
let path = Path::new(source_path);
|
||||
match provider_id {
|
||||
"codex" => codex::load_messages(path),
|
||||
"claude" => claude::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::get_claude_config_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_claude_config_dir().join("projects");
|
||||
let mut files = Vec::new();
|
||||
collect_jsonl_files(&root, &mut files);
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for path in files {
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(message) => message,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let role = message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
messages.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if is_agent_session(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if session_id.is_none() {
|
||||
session_id = value
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if project_dir.is_none() {
|
||||
project_dir = value
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
}
|
||||
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(message) => message,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: Some(format!("claude --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_agent_session(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("agent-"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||
path.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.to_string())
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
if !root.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_jsonl_files(&path, files);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::codex_config::get_codex_config_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_codex_config_dir().join("sessions");
|
||||
let mut files = Vec::new();
|
||||
collect_jsonl_files(&root, &mut files);
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for path in files {
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = match value.get("payload") {
|
||||
Some(payload) => payload,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let role = payload
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
messages.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
}
|
||||
|
||||
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if session_id.is_none() {
|
||||
session_id = payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if project_dir.is_none() {
|
||||
project_dir = payload
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
created_at.get_or_insert(ts);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = match value.get("payload") {
|
||||
Some(payload) => payload,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: Some(format!("codex resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||
let file_name = path.file_name()?.to_string_lossy();
|
||||
let re =
|
||||
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
.ok()?;
|
||||
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
if !root.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_jsonl_files(&path, files);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
mod utils;
|
||||
@@ -0,0 +1,77 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
||||
let raw = value.as_str()?;
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.ok()
|
||||
.map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())
|
||||
}
|
||||
|
||||
pub fn extract_text(content: &Value) -> String {
|
||||
match content {
|
||||
Value::String(text) => text.to_string(),
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(extract_text_from_item)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Value::Object(map) => map
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text_from_item(item: &Value) -> Option<String> {
|
||||
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(content) = item.get("content") {
|
||||
let text = extract_text(content);
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn truncate_summary(text: &str, max_chars: usize) -> String {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let mut result = trimmed.chars().take(max_chars).collect::<String>();
|
||||
result.push_str("...");
|
||||
result
|
||||
}
|
||||
|
||||
pub fn path_basename(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let normalized = trimmed.trim_end_matches(['/', '\\']);
|
||||
let last = normalized
|
||||
.split(['/', '\\'])
|
||||
.next_back()
|
||||
.filter(|segment| !segment.is_empty())?;
|
||||
Some(last.to_string())
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::process::Command;
|
||||
|
||||
pub fn launch_terminal(
|
||||
target: &str,
|
||||
command: &str,
|
||||
cwd: Option<&str>,
|
||||
custom_config: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if command.trim().is_empty() {
|
||||
return Err("Resume command is empty".to_string());
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err("Terminal resume is only supported on macOS".to_string());
|
||||
}
|
||||
|
||||
match target {
|
||||
"terminal" => launch_macos_terminal(command, cwd),
|
||||
"iTerm" | "iterm" => launch_iterm(command, cwd),
|
||||
"ghostty" => launch_ghostty(command, cwd),
|
||||
"kitty" => launch_kitty(command, cwd),
|
||||
"wezterm" => launch_wezterm(command, cwd),
|
||||
"alacritty" => launch_alacritty(command, cwd),
|
||||
"custom" => launch_custom(command, cwd, custom_config),
|
||||
_ => Err(format!("Unsupported terminal target: {target}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
let escaped = escape_osascript(&full_command);
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{escaped}"
|
||||
end tell"#
|
||||
);
|
||||
|
||||
let status = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Terminal: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Terminal command execution failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
let escaped = escape_osascript(&full_command);
|
||||
// iTerm2 AppleScript to create a new window and execute command
|
||||
let script = format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
create window with default profile
|
||||
tell current session of current window
|
||||
write text "{escaped}"
|
||||
end tell
|
||||
end tell"#
|
||||
);
|
||||
|
||||
let status = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch iTerm: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("iTerm command execution failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Ghostty usage: open -na Ghostty --args +work-dir=... -e shell -c command
|
||||
|
||||
// Using `open` to launch.
|
||||
let mut args = vec!["-na", "Ghostty", "--args"];
|
||||
|
||||
// Ghostty uses --working-directory for working directory (or +work-dir, but --working-directory is standard in newer versions/compat)
|
||||
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
|
||||
// Documentation says --working-directory is supported in CLI.
|
||||
let work_dir_arg = if let Some(dir) = cwd {
|
||||
format!("--working-directory={dir}")
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
if !work_dir_arg.is_empty() {
|
||||
args.push(&work_dir_arg);
|
||||
}
|
||||
|
||||
// Command execution
|
||||
args.push("-e");
|
||||
|
||||
// We pass the command and its arguments separately.
|
||||
// The previous issue was passing the entire "cmd args" string as a single argument to -e,
|
||||
// which led Ghostty to look for a binary named "cmd args".
|
||||
// Splitting by whitespace allows Ghostty to see ["cmd", "args"].
|
||||
// Note: This assumes simple commands without quoted arguments containing spaces.
|
||||
let full_command = build_shell_command(command, None);
|
||||
for part in full_command.split_whitespace() {
|
||||
args.push(part);
|
||||
}
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
|
||||
// 获取用户默认 shell
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
|
||||
let status = Command::new("open")
|
||||
.arg("-na")
|
||||
.arg("kitty")
|
||||
.arg("--args")
|
||||
.arg("-e")
|
||||
.arg(&shell)
|
||||
.arg("-l")
|
||||
.arg("-c")
|
||||
.arg(&full_command)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Kitty: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Kitty. Make sure it is installed.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// wezterm start --cwd ... -- command
|
||||
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
|
||||
|
||||
let full_command = build_shell_command(command, None);
|
||||
|
||||
let mut args = vec!["-na", "WezTerm", "--args", "start"];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
args.push("--cwd");
|
||||
args.push(dir);
|
||||
}
|
||||
|
||||
// Invoke shell to run the command string (to handle pipes, etc)
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
args.push("--");
|
||||
args.push(&shell);
|
||||
args.push("-c");
|
||||
args.push(&full_command);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch WezTerm.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
|
||||
let full_command = build_shell_command(command, None);
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
|
||||
let mut args = vec!["-na", "Alacritty", "--args"];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
args.push("--working-directory");
|
||||
args.push(dir);
|
||||
}
|
||||
|
||||
args.push("-e");
|
||||
args.push(&shell);
|
||||
args.push("-c");
|
||||
args.push(&full_command);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Alacritty: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Alacritty.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_custom(
|
||||
command: &str,
|
||||
cwd: Option<&str>,
|
||||
custom_config: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let template = custom_config.ok_or("No custom terminal config provided")?;
|
||||
|
||||
if template.trim().is_empty() {
|
||||
return Err("Custom terminal command template is empty".to_string());
|
||||
}
|
||||
|
||||
let cmd_str = command;
|
||||
let dir_str = cwd.unwrap_or(".");
|
||||
|
||||
let final_cmd_line = template
|
||||
.replace("{command}", cmd_str)
|
||||
.replace("{cwd}", dir_str);
|
||||
|
||||
// Execute via sh -c
|
||||
let status = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&final_cmd_line)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Custom terminal execution returned error code".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
|
||||
match cwd {
|
||||
Some(dir) if !dir.trim().is_empty() => {
|
||||
format!("cd {} && {}", shell_escape(dir), command)
|
||||
}
|
||||
_ => command.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(value: &str) -> String {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{escaped}\"")
|
||||
}
|
||||
|
||||
fn escape_osascript(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"height": 650,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"visible": false,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"label": "main",
|
||||
"title": "CC Switch",
|
||||
"titleBarStyle": "Visible",
|
||||
"visible": false,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ pub fn ensure_test_home() -> &'static Path {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
std::fs::create_dir_all(&base).expect("create test home");
|
||||
// Windows 上 `dirs::home_dir()` 不受 HOME/USERPROFILE 影响(走 Known Folder API),
|
||||
// 用 CC_SWITCH_TEST_HOME 显式覆盖,以确保测试不会污染真实用户目录。
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &base);
|
||||
std::env::set_var("HOME", &base);
|
||||
#[cfg(windows)]
|
||||
std::env::set_var("USERPROFILE", &base);
|
||||
|
||||
+57
-5
@@ -12,10 +12,11 @@ import {
|
||||
Book,
|
||||
Wrench,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Download,
|
||||
History,
|
||||
BarChart2,
|
||||
Download,
|
||||
FolderArchive,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -54,6 +55,7 @@ import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { McpIcon } from "@/components/BrandIcons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
@@ -63,22 +65,59 @@ type View =
|
||||
| "skillsDiscovery"
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal";
|
||||
| "universal"
|
||||
| "sessions";
|
||||
|
||||
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
const getInitialApp = (): AppId => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
||||
if (saved && VALID_APPS.includes(saved)) {
|
||||
return saved;
|
||||
}
|
||||
return "claude";
|
||||
};
|
||||
|
||||
const VIEW_STORAGE_KEY = "cc-switch-last-view";
|
||||
const VALID_VIEWS: View[] = [
|
||||
"providers",
|
||||
"settings",
|
||||
"prompts",
|
||||
"skills",
|
||||
"skillsDiscovery",
|
||||
"mcp",
|
||||
"agents",
|
||||
"universal",
|
||||
"sessions",
|
||||
];
|
||||
|
||||
const getInitialView = (): View => {
|
||||
const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;
|
||||
if (saved && VALID_VIEWS.includes(saved)) {
|
||||
return saved;
|
||||
}
|
||||
return "providers";
|
||||
};
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
||||
const [currentView, setCurrentView] = useState<View>("providers");
|
||||
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
|
||||
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
|
||||
}, [currentView]);
|
||||
|
||||
// Get settings for visibleApps
|
||||
const { data: settingsData } = useSettingsQuery();
|
||||
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
||||
@@ -581,6 +620,9 @@ function App() {
|
||||
<UniversalProviderPanel />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "sessions":
|
||||
return <SessionManagerPage />;
|
||||
default:
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -734,6 +776,7 @@ function App() {
|
||||
t("universalProvider.title", {
|
||||
defaultValue: "统一供应商",
|
||||
})}
|
||||
{currentView === "sessions" && t("sessionManager.title")}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -948,6 +991,15 @@ function App() {
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AppSwitcherProps {
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
|
||||
export function AppSwitcher({
|
||||
activeApp,
|
||||
@@ -19,6 +20,7 @@ export function AppSwitcher({
|
||||
}: AppSwitcherProps) {
|
||||
const handleSwitch = (app: AppId) => {
|
||||
if (app === activeApp) return;
|
||||
localStorage.setItem(STORAGE_KEY, app);
|
||||
onSwitch(app);
|
||||
};
|
||||
const iconSize = 20;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
|
||||
interface AppCountBarProps {
|
||||
totalLabel: string;
|
||||
counts: Record<AppId, number>;
|
||||
}
|
||||
|
||||
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
totalLabel,
|
||||
counts,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||
<Badge variant="outline" className="bg-background/50 h-7 px-3">
|
||||
{totalLabel}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||
{APP_IDS.map((app) => (
|
||||
<Badge
|
||||
key={app}
|
||||
variant="secondary"
|
||||
className={APP_ICON_MAP[app].badgeClass}
|
||||
>
|
||||
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
|
||||
<span className="font-bold ml-1">{counts[app]}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
|
||||
interface AppToggleGroupProps {
|
||||
apps: Record<AppId, boolean>;
|
||||
onToggle: (app: AppId, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||
apps,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{APP_IDS.map((app) => {
|
||||
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||
const enabled = apps[app];
|
||||
return (
|
||||
<Tooltip key={app}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(app, !enabled)}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
|
||||
enabled ? activeClass : "opacity-35 hover:opacity-70"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>
|
||||
{label}
|
||||
{enabled ? " ✓" : ""}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
interface ListItemRowProps {
|
||||
isLast?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ListItemRow: React.FC<ListItemRowProps> = ({
|
||||
isLast,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
|
||||
!isLast ? "border-b border-border-default" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>iTerm2</title>
|
||||
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Alacritty</title>
|
||||
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>WezTerm</title>
|
||||
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Ghostty</title>
|
||||
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
// Official icon is complex and has fixed width/height/viewBox in original.
|
||||
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
|
||||
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
|
||||
// 850 - 812 = 38. So it's confusing.
|
||||
// Let's copy the raw SVG content but adapt it to be a component.
|
||||
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
|
||||
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
|
||||
// However, official icons often have brand colors. The user said "official icon", which implies color.
|
||||
// But usually in a dropdown we might want monochrome or original color.
|
||||
// simple-icons are usually monochrome.
|
||||
// Let's keep Kitty as original color since it's complex, OR mono if it works?
|
||||
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
|
||||
// If it looks weird in dark mode/light mode, we might need to adjust.
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 240 240"
|
||||
{...props} // Allow overriding width/height
|
||||
>
|
||||
<g transform="translate(0 -812.362)">
|
||||
<rect
|
||||
width="100.446"
|
||||
height="161.551"
|
||||
x="72.824"
|
||||
y="850.13"
|
||||
ry="0"
|
||||
style={{
|
||||
fill: "#ddd",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
stroke: "none",
|
||||
strokeWidth: 5.86876726,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
strokeMiterlimit: 4,
|
||||
strokeDasharray: "none",
|
||||
strokeOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
|
||||
style={{ strokeWidth: 1.41299629 }}
|
||||
/>
|
||||
<path
|
||||
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#c0c81f",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 3.09913683,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#784421",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
}}
|
||||
/>
|
||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||
<path
|
||||
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||
<path
|
||||
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#483737",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#c0c81f",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 3.09913683,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useAllMcpServers,
|
||||
useToggleMcpApp,
|
||||
@@ -13,19 +13,19 @@ import type { McpServer } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import McpFormModal from "./McpFormModal";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { Edit3, Trash2 } from "lucide-react";
|
||||
import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
import { toast } from "sonner";
|
||||
import { APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
|
||||
interface UnifiedMcpPanelProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 MCP 管理面板
|
||||
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
||||
*/
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
openAdd: () => void;
|
||||
openImport: () => void;
|
||||
@@ -45,26 +45,22 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
// Queries and Mutations
|
||||
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||
const toggleAppMutation = useToggleMcpApp();
|
||||
const deleteServerMutation = useDeleteMcpServer();
|
||||
const importMutation = useImportMcpFromApps();
|
||||
|
||||
// Convert serversMap to array for easier rendering
|
||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||
if (!serversMap) return [];
|
||||
return Object.entries(serversMap);
|
||||
}, [serversMap]);
|
||||
|
||||
// Count enabled servers per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
if (server.apps.claude) counts.claude++;
|
||||
if (server.apps.codex) counts.codex++;
|
||||
if (server.apps.gemini) counts.gemini++;
|
||||
if (server.apps.opencode) counts.opencode++;
|
||||
for (const app of APP_IDS) {
|
||||
if (server.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [serverEntries]);
|
||||
@@ -77,9 +73,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,9 +100,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,9 +120,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("common.success"), { closeButton: true });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -143,18 +133,11 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
|
||||
</div>
|
||||
</div>
|
||||
<AppCountBar
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
/>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -173,22 +156,24 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{serverEntries.map(([id, server]) => (
|
||||
<UnifiedMcpListItem
|
||||
key={id}
|
||||
id={id}
|
||||
server={server}
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="rounded-xl border border-border-default overflow-hidden">
|
||||
{serverEntries.map(([id, server], index) => (
|
||||
<UnifiedMcpListItem
|
||||
key={id}
|
||||
id={id}
|
||||
server={server}
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
isLast={index === serverEntries.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Modal */}
|
||||
{isFormOpen && (
|
||||
<McpFormModal
|
||||
editingId={editingId || undefined}
|
||||
@@ -205,7 +190,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
@@ -221,16 +205,13 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
||||
|
||||
/**
|
||||
* 统一 MCP 列表项组件
|
||||
* 展示服务器名称、描述,以及三个应用的复选框
|
||||
*/
|
||||
interface UnifiedMcpListItemProps {
|
||||
id: string;
|
||||
server: McpServer;
|
||||
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
@@ -239,12 +220,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
onToggleApp,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const name = server.name || id;
|
||||
const description = server.description || "";
|
||||
|
||||
// 匹配预设元信息
|
||||
const meta = mcpPresets.find((p) => p.id === id);
|
||||
const docsUrl = server.docs || meta?.docs;
|
||||
const homepageUrl = server.homepage || meta?.homepage;
|
||||
@@ -261,126 +242,66 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:服务器信息 */}
|
||||
<ListItemRow isLast={isLast}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{name}</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm text-foreground truncate">
|
||||
{name}
|
||||
</span>
|
||||
{docsUrl && (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
|
||||
title={t("mcp.presets.docs")}
|
||||
>
|
||||
{t("mcp.presets.docs")}
|
||||
</Button>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{!description && tags && tags.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground/70 truncate">
|
||||
<p className="text-xs text-muted-foreground/60 truncate">
|
||||
{tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-claude`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-claude`}
|
||||
checked={server.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AppToggleGroup
|
||||
apps={server.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-codex`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-codex`}
|
||||
checked={server.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-gemini`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-gemini`}
|
||||
checked={server.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-opencode`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.opencode")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-opencode`}
|
||||
checked={server.apps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "opencode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onEdit(id)}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
<Edit3 size={14} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
onClick={() => onDelete(id)}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItemRow>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||
// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import {
|
||||
@@ -93,8 +93,8 @@ export function ProviderList({
|
||||
[appId, opencodeLiveIds],
|
||||
);
|
||||
|
||||
// 流式健康检查
|
||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
// 流式健康检查 - 功能已隐藏
|
||||
// const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
@@ -139,9 +139,10 @@ export function ProviderList({
|
||||
[appId, addToQueue, removeFromQueue],
|
||||
);
|
||||
|
||||
const handleTest = (provider: Provider) => {
|
||||
checkProvider(provider.id, provider.name);
|
||||
};
|
||||
// handleTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
|
||||
// const handleTest = (provider: Provider) => {
|
||||
// checkProvider(provider.id, provider.name);
|
||||
// };
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
@@ -229,8 +230,9 @@ export function ProviderList({
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onTest={appId !== "opencode" ? handleTest : undefined}
|
||||
isTesting={isChecking(provider.id)}
|
||||
// onTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
|
||||
// onTest={appId !== "opencode" ? handleTest : undefined}
|
||||
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
// 故障转移相关:联动状态
|
||||
|
||||
@@ -163,7 +163,11 @@ export function ClaudeFormFields({
|
||||
value={baseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||
hint={t("providerForm.apiHint")}
|
||||
hint={
|
||||
apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ChevronRight, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import type { SessionMeta } from "@/types";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
formatSessionTitle,
|
||||
getProviderIconName,
|
||||
getProviderLabel,
|
||||
getSessionKey,
|
||||
} from "./utils";
|
||||
|
||||
interface SessionItemProps {
|
||||
session: SessionMeta;
|
||||
isSelected: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: SessionItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const title = formatSessionTitle(session);
|
||||
const lastActive = session.lastActiveAt || session.createdAt || undefined;
|
||||
const sessionKey = getSessionKey(session);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/60 border border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Play,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query";
|
||||
import { sessionsApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isMac } from "@/lib/platform";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import { SessionItem } from "./SessionItem";
|
||||
import { SessionMessageItem } from "./SessionMessageItem";
|
||||
import { SessionTocDialog, SessionTocSidebar } from "./SessionToc";
|
||||
import {
|
||||
formatSessionTitle,
|
||||
formatTimestamp,
|
||||
getBaseName,
|
||||
getProviderIconName,
|
||||
getProviderLabel,
|
||||
getSessionKey,
|
||||
} from "./utils";
|
||||
|
||||
type ProviderFilter = "all" | "codex" | "claude";
|
||||
|
||||
export function SessionManagerPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, refetch } = useSessionsQuery();
|
||||
const sessions = data ?? [];
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
// 使用 FlexSearch 全文搜索
|
||||
const { search: searchSessions } = useSessionSearch({
|
||||
sessions,
|
||||
providerFilter,
|
||||
});
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
return searchSessions(search);
|
||||
}, [searchSessions, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSessions.length === 0) {
|
||||
setSelectedKey(null);
|
||||
return;
|
||||
}
|
||||
const exists = selectedKey
|
||||
? filteredSessions.some(
|
||||
(session) => getSessionKey(session) === selectedKey,
|
||||
)
|
||||
: false;
|
||||
if (!exists) {
|
||||
setSelectedKey(getSessionKey(filteredSessions[0]));
|
||||
}
|
||||
}, [filteredSessions, selectedKey]);
|
||||
|
||||
const selectedSession = useMemo(() => {
|
||||
if (!selectedKey) return null;
|
||||
return (
|
||||
filteredSessions.find(
|
||||
(session) => getSessionKey(session) === selectedKey,
|
||||
) || null
|
||||
);
|
||||
}, [filteredSessions, selectedKey]);
|
||||
|
||||
const { data: messages = [], isLoading: isLoadingMessages } =
|
||||
useSessionMessagesQuery(
|
||||
selectedSession?.providerId,
|
||||
selectedSession?.sourcePath,
|
||||
);
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
return messages
|
||||
.map((msg, index) => ({ msg, index }))
|
||||
.filter(({ msg }) => msg.role.toLowerCase() === "user")
|
||||
.map(({ msg, index }) => ({
|
||||
index,
|
||||
preview:
|
||||
msg.content.slice(0, 50) + (msg.content.length > 50 ? "..." : ""),
|
||||
ts: msg.ts,
|
||||
}));
|
||||
}, [messages]);
|
||||
|
||||
const scrollToMessage = (index: number) => {
|
||||
const el = messageRefs.current.get(index);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setActiveMessageIndex(index);
|
||||
setTocDialogOpen(false); // 关闭弹窗
|
||||
// 清除高亮状态
|
||||
setTimeout(() => setActiveMessageIndex(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。
|
||||
// 如果要严格清理,需要用 useRef 存 timer id。
|
||||
// 但对于 2秒的高亮清除,通常不清理也没大问题。
|
||||
// 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理)
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (text: string, successMessage: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMessage);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractErrorMessage(error) ||
|
||||
t("common.error", { defaultValue: "Copy failed" }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!selectedSession?.resumeCommand) return;
|
||||
|
||||
if (!isMac()) {
|
||||
await handleCopy(
|
||||
selectedSession.resumeCommand,
|
||||
t("sessionManager.resumeCommandCopied"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsApi.launchTerminal({
|
||||
command: selectedSession.resumeCommand,
|
||||
cwd: selectedSession.projectDir ?? undefined,
|
||||
});
|
||||
toast.success(t("sessionManager.terminalLaunched"));
|
||||
} catch (error) {
|
||||
const fallback = selectedSession.resumeCommand;
|
||||
await handleCopy(fallback, t("sessionManager.resumeFallbackCopied"));
|
||||
toast.error(extractErrorMessage(error) || t("sessionManager.openFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||
{/* 主内容区域 - 左右分栏 */}
|
||||
<div className="flex-1 overflow-hidden grid gap-4 md:grid-cols-[320px_1fr]">
|
||||
{/* 左侧会话列表 */}
|
||||
<Card className="flex flex-col overflow-hidden">
|
||||
<CardHeader className="py-2 px-3 border-b">
|
||||
{isSearchOpen ? (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.searchSessions")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: providerFilter === "codex"
|
||||
? "openai"
|
||||
: "claude"
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon icon="apps" name="all" size={14} />
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="size-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sessionManager.noSessions")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredSessions.map((session) => {
|
||||
const isSelected =
|
||||
selectedKey !== null &&
|
||||
getSessionKey(session) === selectedKey;
|
||||
|
||||
return (
|
||||
<SessionItem
|
||||
key={getSessionKey(session)}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
onSelect={setSelectedKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右侧会话详情 */}
|
||||
<Card
|
||||
className="flex flex-col overflow-hidden min-h-0"
|
||||
ref={detailRef}
|
||||
>
|
||||
{!selectedSession ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8">
|
||||
<MessageSquare className="size-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("sessionManager.selectSession")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 详情头部 */}
|
||||
<CardHeader className="py-3 px-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* 左侧:会话信息 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(
|
||||
selectedSession.providerId,
|
||||
)}
|
||||
name={selectedSession.providerId}
|
||||
size={20}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(selectedSession.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
{formatSessionTitle(selectedSession)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{formatTimestamp(
|
||||
selectedSession.lastActiveAt ??
|
||||
selectedSession.createdAt,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSession.projectDir && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleCopy(
|
||||
selectedSession.projectDir!,
|
||||
t("sessionManager.projectDirCopied"),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{getBaseName(selectedSession.projectDir)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<p className="font-mono text-xs break-all">
|
||||
{selectedSession.projectDir}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("sessionManager.clickToCopyPath")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮组 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isMac() && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void handleResume()}
|
||||
disabled={!selectedSession.resumeCommand}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("sessionManager.resume", {
|
||||
defaultValue: "恢复会话",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectedSession.resumeCommand
|
||||
? t("sessionManager.resumeTooltip", {
|
||||
defaultValue: "在终端中恢复此会话",
|
||||
})
|
||||
: t("sessionManager.noResumeCommand", {
|
||||
defaultValue: "此会话无法恢复",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 恢复命令预览 */}
|
||||
{selectedSession.resumeCommand && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted/60 px-3 py-1.5 font-mono text-xs text-muted-foreground truncate">
|
||||
{selectedSession.resumeCommand}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() =>
|
||||
void handleCopy(
|
||||
selectedSession.resumeCommand!,
|
||||
t("sessionManager.resumeCommandCopied"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.copyCommand", {
|
||||
defaultValue: "复制命令",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* 消息列表区域 */}
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div className="flex h-full">
|
||||
{/* 消息列表 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("sessionManager.conversationHistory", {
|
||||
defaultValue: "对话记录",
|
||||
})}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{messages.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isLoadingMessages ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="size-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sessionManager.emptySession")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message, index) => (
|
||||
<SessionMessageItem
|
||||
key={`${message.role}-${index}`}
|
||||
message={message}
|
||||
index={index}
|
||||
isActive={activeMessageIndex === index}
|
||||
setRef={(el) => {
|
||||
if (el) messageRefs.current.set(index, el);
|
||||
}}
|
||||
onCopy={(content) =>
|
||||
handleCopy(
|
||||
content,
|
||||
t("sessionManager.messageCopied", {
|
||||
defaultValue: "已复制消息内容",
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 右侧目录 - 类似少数派 (大屏幕) */}
|
||||
<SessionTocSidebar
|
||||
items={userMessagesToc}
|
||||
onItemClick={scrollToMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 浮动目录按钮 (小屏幕) */}
|
||||
<SessionTocDialog
|
||||
items={userMessagesToc}
|
||||
onItemClick={scrollToMessage}
|
||||
open={tocDialogOpen}
|
||||
onOpenChange={setTocDialogOpen}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionMessage } from "@/types";
|
||||
import { formatTimestamp, getRoleLabel, getRoleTone } from "./utils";
|
||||
|
||||
interface SessionMessageItemProps {
|
||||
message: SessionMessage;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
setRef: (el: HTMLDivElement | null) => void;
|
||||
onCopy: (content: string) => void;
|
||||
}
|
||||
|
||||
export function SessionMessageItem({
|
||||
message,
|
||||
isActive,
|
||||
setRef,
|
||||
onCopy,
|
||||
}: SessionMessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRef}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2.5 relative group transition-all",
|
||||
message.role.toLowerCase() === "user"
|
||||
? "bg-primary/5 border-primary/20 ml-8"
|
||||
: message.role.toLowerCase() === "assistant"
|
||||
? "bg-blue-500/5 border-blue-500/20 mr-8"
|
||||
: "bg-muted/40 border-border/60",
|
||||
isActive && "ring-2 ring-primary ring-offset-2",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onCopy(message.content)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.copyMessage", {
|
||||
defaultValue: "复制内容",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5 pr-6">
|
||||
<span className={cn("font-semibold", getRoleTone(message.role))}>
|
||||
{getRoleLabel(message.role, t)}
|
||||
</span>
|
||||
{message.ts && (
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimestamp(message.ts)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { List, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface TocItem {
|
||||
index: number;
|
||||
preview: string;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
interface SessionTocSidebarProps {
|
||||
items: TocItem[];
|
||||
onItemClick: (index: number) => void;
|
||||
}
|
||||
|
||||
export function SessionTocSidebar({
|
||||
items,
|
||||
onItemClick,
|
||||
}: SessionTocSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
if (items.length <= 2) return null;
|
||||
|
||||
return (
|
||||
<div className="w-64 border-l shrink-0 hidden xl:block">
|
||||
<div className="p-3 border-b">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<List className="size-3.5" />
|
||||
<span>{t("sessionManager.tocTitle")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100%-40px)]">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{items.map((item, tocIndex) => (
|
||||
<button
|
||||
key={item.index}
|
||||
type="button"
|
||||
onClick={() => onItemClick(item.index)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded text-xs transition-colors",
|
||||
"hover:bg-muted/80 text-muted-foreground hover:text-foreground",
|
||||
"flex items-start gap-2",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium">
|
||||
{tocIndex + 1}
|
||||
</span>
|
||||
<span className="line-clamp-2 leading-snug">{item.preview}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionTocDialogProps {
|
||||
items: TocItem[];
|
||||
onItemClick: (index: number) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SessionTocDialog({
|
||||
items,
|
||||
onItemClick,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SessionTocDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
if (items.length <= 2) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="fixed bottom-20 right-4 xl:hidden size-10 rounded-full shadow-lg z-30"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-md max-h-[70vh] flex flex-col p-0 gap-0"
|
||||
zIndex="alert"
|
||||
onInteractOutside={() => onOpenChange(false)}
|
||||
onEscapeKeyDown={() => onOpenChange(false)}
|
||||
>
|
||||
<DialogHeader className="px-4 py-3 relative border-b">
|
||||
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<List className="size-4 text-primary" />
|
||||
{t("sessionManager.tocTitle")}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1.5 hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto max-h-[calc(70vh-80px)]">
|
||||
<div className="p-3 pb-4 space-y-1">
|
||||
{items.map((item, tocIndex) => (
|
||||
<button
|
||||
key={item.index}
|
||||
type="button"
|
||||
onClick={() => onItemClick(item.index)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all",
|
||||
"hover:bg-primary/10 text-foreground",
|
||||
"flex items-start gap-3",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold">
|
||||
{tocIndex + 1}
|
||||
</span>
|
||||
<span className="line-clamp-2 leading-relaxed pt-0.5">
|
||||
{item.preview}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { SessionMeta } from "@/types";
|
||||
|
||||
export const getSessionKey = (session: SessionMeta) =>
|
||||
`${session.providerId}:${session.sessionId}:${session.sourcePath ?? ""}`;
|
||||
|
||||
export const getBaseName = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
const normalized = trimmed.replace(/[\\/]+$/, "");
|
||||
const parts = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || trimmed;
|
||||
};
|
||||
|
||||
export const formatTimestamp = (value?: number) => {
|
||||
if (!value) return "";
|
||||
return new Date(value).toLocaleString();
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (
|
||||
value: number | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
if (!value) return "";
|
||||
const now = Date.now();
|
||||
const diff = now - value;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return t("sessionManager.justNow");
|
||||
if (minutes < 60) return t("sessionManager.minutesAgo", { count: minutes });
|
||||
if (hours < 24) return t("sessionManager.hoursAgo", { count: hours });
|
||||
if (days < 7) return t("sessionManager.daysAgo", { count: days });
|
||||
return new Date(value).toLocaleDateString();
|
||||
};
|
||||
|
||||
export const getProviderLabel = (
|
||||
providerId: string,
|
||||
t: (key: string) => string,
|
||||
) => {
|
||||
const key = `apps.${providerId}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? providerId : translated;
|
||||
};
|
||||
|
||||
// 根据 providerId 获取对应的图标名称
|
||||
export const getProviderIconName = (providerId: string) => {
|
||||
if (providerId === "codex") return "openai";
|
||||
if (providerId === "claude") return "claude";
|
||||
return providerId;
|
||||
};
|
||||
|
||||
export const getRoleTone = (role: string) => {
|
||||
const normalized = role.toLowerCase();
|
||||
if (normalized === "assistant") return "text-blue-500";
|
||||
if (normalized === "user") return "text-emerald-500";
|
||||
if (normalized === "system") return "text-amber-500";
|
||||
if (normalized === "tool") return "text-purple-500";
|
||||
return "text-muted-foreground";
|
||||
};
|
||||
|
||||
export const getRoleLabel = (role: string, t: (key: string) => string) => {
|
||||
const normalized = role.toLowerCase();
|
||||
if (normalized === "assistant") return "AI";
|
||||
if (normalized === "user") return t("sessionManager.roleUser");
|
||||
if (normalized === "system") return t("sessionManager.roleSystem");
|
||||
if (normalized === "tool") return t("sessionManager.roleTool");
|
||||
return role;
|
||||
};
|
||||
|
||||
export const formatSessionTitle = (session: SessionMeta) => {
|
||||
return (
|
||||
session.title ||
|
||||
getBaseName(session.projectDir) ||
|
||||
session.sessionId.slice(0, 8)
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ const MACOS_TERMINALS = [
|
||||
{ value: "alacritty", labelKey: "settings.terminal.options.macos.alacritty" },
|
||||
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
|
||||
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
|
||||
{ value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" },
|
||||
] as const;
|
||||
|
||||
const WINDOWS_TERMINALS = [
|
||||
|
||||
@@ -20,13 +20,13 @@ import {
|
||||
useSkillRepos,
|
||||
useAddSkillRepo,
|
||||
useRemoveSkillRepo,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppType;
|
||||
initialApp?: AppId;
|
||||
}
|
||||
|
||||
export interface SkillsPageHandle {
|
||||
@@ -43,6 +43,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
const { t } = useTranslation();
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterRepo, setFilterRepo] = useState<string>("all");
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
"all" | "installed" | "uninstalled"
|
||||
>("all");
|
||||
@@ -80,12 +81,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
|
||||
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
||||
|
||||
// 从可发现技能中提取所有仓库选项
|
||||
const repoOptions = useMemo(() => {
|
||||
if (!discoverableSkills) return [];
|
||||
const repoSet = new Set<string>();
|
||||
discoverableSkills.forEach((s) => {
|
||||
if (s.repoOwner && s.repoName) {
|
||||
repoSet.add(`${s.repoOwner}/${s.repoName}`);
|
||||
}
|
||||
});
|
||||
return Array.from(repoSet).sort();
|
||||
}, [discoverableSkills]);
|
||||
|
||||
// 为发现列表补齐 installed 状态,供 SkillCard 使用
|
||||
const skills: DiscoverableSkillItem[] = useMemo(() => {
|
||||
if (!discoverableSkills) return [];
|
||||
return discoverableSkills.map((d) => {
|
||||
// 同时处理 / 和 \ 路径分隔符(兼容 Windows 和 Unix)
|
||||
const installName =
|
||||
d.directory.split("/").pop()?.toLowerCase() ||
|
||||
d.directory.split(/[/\\]/).pop()?.toLowerCase() ||
|
||||
d.directory.toLowerCase();
|
||||
// 使用 directory + repoOwner + repoName 组合判断是否已安装
|
||||
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
|
||||
@@ -179,12 +193,21 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
|
||||
// 过滤技能列表
|
||||
const filteredSkills = useMemo(() => {
|
||||
const byStatus = skills.filter((skill) => {
|
||||
// 按仓库筛选
|
||||
const byRepo = skills.filter((skill) => {
|
||||
if (filterRepo === "all") return true;
|
||||
const skillRepo = `${skill.repoOwner}/${skill.repoName}`;
|
||||
return skillRepo === filterRepo;
|
||||
});
|
||||
|
||||
// 按安装状态筛选
|
||||
const byStatus = byRepo.filter((skill) => {
|
||||
if (filterStatus === "installed") return skill.installed;
|
||||
if (filterStatus === "uninstalled") return !skill.installed;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (!searchQuery.trim()) return byStatus;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
@@ -199,7 +222,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
directory.includes(query)
|
||||
);
|
||||
});
|
||||
}, [skills, searchQuery, filterStatus]);
|
||||
}, [skills, searchQuery, filterRepo, filterStatus]);
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
||||
@@ -228,7 +251,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 搜索框 */}
|
||||
{/* 搜索框和筛选器 */}
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -240,7 +263,39 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
className="pl-9 pr-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
{/* 仓库筛选 */}
|
||||
<div className="w-full md:w-56">
|
||||
<Select value={filterRepo} onValueChange={setFilterRepo}>
|
||||
<SelectTrigger className="bg-card border shadow-sm text-foreground">
|
||||
<SelectValue
|
||||
placeholder={t("skills.filter.repo")}
|
||||
className="text-left truncate"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-card text-foreground shadow-lg max-h-64 min-w-[var(--radix-select-trigger-width)]">
|
||||
<SelectItem
|
||||
value="all"
|
||||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||||
>
|
||||
{t("skills.filter.allRepos")}
|
||||
</SelectItem>
|
||||
{repoOptions.map((repo) => (
|
||||
<SelectItem
|
||||
key={repo}
|
||||
value={repo}
|
||||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||||
title={repo}
|
||||
>
|
||||
<span className="truncate block max-w-[200px]">
|
||||
{repo}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 安装状态筛选 */}
|
||||
<div className="w-full md:w-36">
|
||||
<Select
|
||||
value={filterStatus}
|
||||
onValueChange={(val) =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useInstalledSkills,
|
||||
useToggleSkillApp,
|
||||
@@ -11,20 +11,20 @@ import {
|
||||
useImportSkillsFromApps,
|
||||
useInstallSkillsFromZip,
|
||||
type InstalledSkill,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi, skillsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 Skills 管理面板
|
||||
* v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端
|
||||
*/
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
@@ -44,7 +44,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
|
||||
// Queries and Mutations
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
@@ -53,30 +52,22 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
const installFromZipMutation = useInstallSkillsFromZip();
|
||||
|
||||
// Count enabled skills per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
if (skill.apps.claude) counts.claude++;
|
||||
if (skill.apps.codex) counts.codex++;
|
||||
if (skill.apps.gemini) counts.gemini++;
|
||||
if (skill.apps.opencode) counts.opencode++;
|
||||
for (const app of APP_IDS) {
|
||||
if (skill.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [skills]);
|
||||
|
||||
const handleToggleApp = async (
|
||||
id: string,
|
||||
app: AppType,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => {
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,9 +84,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -110,9 +99,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
setImportDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,25 +111,16 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFromZip = async () => {
|
||||
try {
|
||||
// 打开文件选择对话框
|
||||
const filePath = await skillsApi.openZipFileDialog();
|
||||
if (!filePath) {
|
||||
// 用户取消选择
|
||||
return;
|
||||
}
|
||||
if (!filePath) return;
|
||||
|
||||
// 默认使用 claude 作为当前应用
|
||||
const currentApp: AppType = "claude";
|
||||
|
||||
// 安装 Skills
|
||||
const currentApp: AppId = "claude";
|
||||
const installed = await installFromZipMutation.mutateAsync({
|
||||
filePath,
|
||||
currentApp,
|
||||
@@ -166,9 +144,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("skills.installFailed"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("skills.installFailed"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,18 +156,11 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
||||
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||
{t("skills.apps.opencode")}: {enabledCounts.opencode}
|
||||
</div>
|
||||
</div>
|
||||
<AppCountBar
|
||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||
counts={enabledCounts}
|
||||
/>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -210,20 +179,22 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{skills.map((skill) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="rounded-xl border border-border-default overflow-hidden">
|
||||
{skills.map((skill, index) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
isLast={index === skills.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
@@ -234,7 +205,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import Dialog */}
|
||||
{importDialogOpen && unmanagedSkills && (
|
||||
<ImportSkillsDialog
|
||||
skills={unmanagedSkills}
|
||||
@@ -248,19 +218,18 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
|
||||
|
||||
/**
|
||||
* 已安装 Skill 列表项组件
|
||||
*/
|
||||
interface InstalledSkillListItemProps {
|
||||
skill: InstalledSkill;
|
||||
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
|
||||
onToggleApp: (id: string, app: AppId, enabled: boolean) => void;
|
||||
onUninstall: () => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
skill,
|
||||
onToggleApp,
|
||||
onUninstall,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -273,7 +242,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 生成来源标签
|
||||
const sourceLabel = useMemo(() => {
|
||||
if (skill.repoOwner && skill.repoName) {
|
||||
return `${skill.repoOwner}/${skill.repoName}`;
|
||||
@@ -282,118 +250,56 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
}, [skill.repoOwner, skill.repoName, t]);
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:Skill 信息 */}
|
||||
<ListItemRow isLast={isLast}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{skill.name}</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm text-foreground truncate">
|
||||
{skill.name}
|
||||
</span>
|
||||
{skill.readmeUrl && (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="h-6 px-2"
|
||||
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground/50 flex-shrink-0">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={skill.description}
|
||||
>
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-claude`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-claude`}
|
||||
checked={skill.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AppToggleGroup
|
||||
apps={skill.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-codex`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-codex`}
|
||||
checked={skill.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-gemini`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-gemini`}
|
||||
checked={skill.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-opencode`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.opencode")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-opencode`}
|
||||
checked={skill.apps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "opencode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:删除按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
onClick={onUninstall}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("skills.uninstall")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItemRow>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入 Skills 对话框
|
||||
*/
|
||||
interface ImportSkillsDialogProps {
|
||||
skills: Array<{
|
||||
directory: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
@@ -94,6 +95,54 @@ export function ThemeProvider({
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [theme]);
|
||||
|
||||
// Sync native window theme (Windows/macOS title bar)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const updateNativeTheme = async (nativeTheme: string) => {
|
||||
if (isCancelled) return;
|
||||
try {
|
||||
await invoke("set_window_theme", { theme: nativeTheme });
|
||||
} catch (e) {
|
||||
// Ignore errors (e.g., when not running in Tauri)
|
||||
console.debug("Failed to set native window theme:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine current effective theme
|
||||
if (theme === "system") {
|
||||
const isDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
updateNativeTheme(isDark ? "dark" : "light");
|
||||
} else {
|
||||
updateNativeTheme(theme);
|
||||
}
|
||||
|
||||
// Listen to system theme changes for native window when in "system" mode
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (theme === "system" && !isCancelled) {
|
||||
updateNativeTheme(mediaQuery.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
|
||||
if (theme === "system") {
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (theme === "system") {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -82,7 +82,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm pl-7 pr-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
export interface AppConfig {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
activeClass: string;
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APP_IDS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
claude: {
|
||||
label: "Claude",
|
||||
icon: <ClaudeIcon size={14} />,
|
||||
activeClass:
|
||||
"bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
||||
badgeClass:
|
||||
"bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5",
|
||||
},
|
||||
codex: {
|
||||
label: "Codex",
|
||||
icon: <CodexIcon size={14} />,
|
||||
activeClass:
|
||||
"bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
badgeClass:
|
||||
"bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5",
|
||||
},
|
||||
gemini: {
|
||||
label: "Gemini",
|
||||
icon: <GeminiIcon size={14} />,
|
||||
activeClass:
|
||||
"bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400",
|
||||
badgeClass:
|
||||
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
|
||||
},
|
||||
opencode: {
|
||||
label: "OpenCode",
|
||||
icon: (
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="OpenCode"
|
||||
size={14}
|
||||
showFallback={false}
|
||||
/>
|
||||
),
|
||||
activeClass:
|
||||
"bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
|
||||
badgeClass:
|
||||
"bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import FlexSearch from "flexsearch";
|
||||
import type { SessionMeta } from "@/types";
|
||||
|
||||
// FlexSearch Index 类型
|
||||
type FlexSearchIndex = InstanceType<typeof FlexSearch.Index>;
|
||||
|
||||
interface UseSessionSearchOptions {
|
||||
sessions: SessionMeta[];
|
||||
providerFilter: string;
|
||||
}
|
||||
|
||||
interface UseSessionSearchResult {
|
||||
search: (query: string) => SessionMeta[];
|
||||
isIndexing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 FlexSearch 实现会话全文搜索
|
||||
* 索引会话元数据(标题、摘要、项目目录等)
|
||||
*/
|
||||
export function useSessionSearch({
|
||||
sessions,
|
||||
providerFilter,
|
||||
}: UseSessionSearchOptions): UseSessionSearchResult {
|
||||
const [isIndexing, setIsIndexing] = useState(false);
|
||||
|
||||
// 会话元数据索引
|
||||
const indexRef = useRef<FlexSearchIndex | null>(null);
|
||||
// 索引 ID 到 session 的映射
|
||||
const sessionByIdxRef = useRef<SessionMeta[]>([]);
|
||||
|
||||
// 初始化索引
|
||||
useEffect(() => {
|
||||
setIsIndexing(true);
|
||||
|
||||
// 创建索引实例
|
||||
// 使用 forward tokenizer 支持中文前缀搜索
|
||||
const index = new FlexSearch.Index({
|
||||
tokenize: "forward",
|
||||
resolution: 9,
|
||||
});
|
||||
|
||||
// 索引所有会话
|
||||
sessions.forEach((session, idx) => {
|
||||
// 索引会话元数据
|
||||
const metaContent = [
|
||||
session.sessionId,
|
||||
session.title,
|
||||
session.summary,
|
||||
session.projectDir,
|
||||
session.sourcePath,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
index.add(idx, metaContent);
|
||||
});
|
||||
|
||||
indexRef.current = index;
|
||||
sessionByIdxRef.current = sessions;
|
||||
|
||||
setIsIndexing(false);
|
||||
}, [sessions]);
|
||||
|
||||
// 搜索函数
|
||||
const search = useCallback(
|
||||
(query: string): SessionMeta[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
|
||||
// 先按 provider 过滤
|
||||
let filtered = sessions;
|
||||
if (providerFilter !== "all") {
|
||||
filtered = sessions.filter((s) => s.providerId === providerFilter);
|
||||
}
|
||||
|
||||
// 如果没有搜索词,返回按时间排序的结果
|
||||
if (!needle) {
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aTs = a.lastActiveAt ?? a.createdAt ?? 0;
|
||||
const bTs = b.lastActiveAt ?? b.createdAt ?? 0;
|
||||
return bTs - aTs;
|
||||
});
|
||||
}
|
||||
|
||||
const index = indexRef.current;
|
||||
|
||||
if (!index) {
|
||||
// 索引未就绪,使用简单搜索
|
||||
return filtered
|
||||
.filter((session) => {
|
||||
const haystack = [
|
||||
session.sessionId,
|
||||
session.title,
|
||||
session.summary,
|
||||
session.projectDir,
|
||||
session.sourcePath,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aTs = a.lastActiveAt ?? a.createdAt ?? 0;
|
||||
const bTs = b.lastActiveAt ?? b.createdAt ?? 0;
|
||||
return bTs - aTs;
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 FlexSearch 搜索
|
||||
const results = index.search(needle, { limit: 100 }) as number[];
|
||||
|
||||
// 转换为 session 并过滤
|
||||
const matchedSessions = results
|
||||
.map((idx) => sessionByIdxRef.current[idx])
|
||||
.filter(
|
||||
(session) =>
|
||||
session &&
|
||||
(providerFilter === "all" || session.providerId === providerFilter),
|
||||
);
|
||||
|
||||
// 按时间排序
|
||||
return matchedSessions.sort((a, b) => {
|
||||
const aTs = a.lastActiveAt ?? a.createdAt ?? 0;
|
||||
const bTs = b.lastActiveAt ?? b.createdAt ?? 0;
|
||||
return bTs - aTs;
|
||||
});
|
||||
},
|
||||
[sessions, providerFilter],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ search, isIndexing }), [search, isIndexing]);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type AppType,
|
||||
type DiscoverableSkill,
|
||||
type InstalledSkill,
|
||||
} from "@/lib/api/skills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
/**
|
||||
* 查询所有已安装的 Skills
|
||||
@@ -38,7 +38,7 @@ export function useInstallSkill() {
|
||||
currentApp,
|
||||
}: {
|
||||
skill: DiscoverableSkill;
|
||||
currentApp: AppType;
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.installUnified(skill, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
@@ -73,7 +73,7 @@ export function useToggleSkillApp() {
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
app: AppType;
|
||||
app: AppId;
|
||||
enabled: boolean;
|
||||
}) => skillsApi.toggleApp(id, app, enabled),
|
||||
onSuccess: () => {
|
||||
@@ -158,7 +158,7 @@ export function useInstallSkillsFromZip() {
|
||||
currentApp,
|
||||
}: {
|
||||
filePath: string;
|
||||
currentApp: AppType;
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.installFromZip(filePath, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
@@ -169,4 +169,4 @@ export function useInstallSkillsFromZip() {
|
||||
|
||||
// ========== 辅助类型 ==========
|
||||
|
||||
export type { InstalledSkill, DiscoverableSkill, AppType };
|
||||
export type { InstalledSkill, DiscoverableSkill, AppId };
|
||||
|
||||
@@ -300,7 +300,8 @@
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
"ghostty": "Ghostty",
|
||||
"wezterm": "WezTerm"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "Command Prompt",
|
||||
@@ -400,6 +401,45 @@
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "Session Manager",
|
||||
"subtitle": "Manage Codex and Claude Code sessions",
|
||||
"searchPlaceholder": "Search by content, directory, or ID",
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
"sessionList": "Sessions",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"noSessions": "No sessions found",
|
||||
"selectSession": "Select a session to view details",
|
||||
"noSummary": "No summary available",
|
||||
"lastActive": "Last active",
|
||||
"projectDir": "Project directory",
|
||||
"sourcePath": "Source file",
|
||||
"copyResumeCommand": "Copy resume command",
|
||||
"resumeCommandCopied": "Resume command copied",
|
||||
"openInTerminal": "Resume in terminal",
|
||||
"terminalTargetTerminal": "Terminal",
|
||||
"terminalTargetKitty": "kitty",
|
||||
"terminalTargetCopy": "Copy only",
|
||||
"terminalLaunched": "Terminal launched",
|
||||
"openFailed": "Failed to launch terminal",
|
||||
"resumeFallbackCopied": "Resume command copied for manual use",
|
||||
"copyProjectDir": "Copy directory",
|
||||
"projectDirCopied": "Directory copied",
|
||||
"copySourcePath": "Copy source file",
|
||||
"sourcePathCopied": "Source file copied",
|
||||
"loadingMessages": "Loading transcript...",
|
||||
"emptySession": "No messages available",
|
||||
"clickToCopyPath": "Click to copy path",
|
||||
"tocTitle": "Contents",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{{count}} min ago",
|
||||
"hoursAgo": "{{count}} hr ago",
|
||||
"daysAgo": "{{count}} days ago",
|
||||
"roleUser": "User",
|
||||
"roleSystem": "System",
|
||||
"roleTool": "Tool"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "Received provider switch event:",
|
||||
"setupListenerFailed": "Failed to setup provider switch listener:",
|
||||
@@ -458,6 +498,7 @@
|
||||
"fastModelPlaceholder": "e.g., GLM-4.5-Air",
|
||||
"modelHint": "💡 Leave blank to use provider's default model",
|
||||
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
|
||||
"apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash",
|
||||
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
|
||||
"fillSupplierName": "Please fill in provider name",
|
||||
"fillConfigContent": "Please fill in configuration content",
|
||||
@@ -1098,7 +1139,9 @@
|
||||
"placeholder": "Filter by status",
|
||||
"all": "All",
|
||||
"installed": "Installed",
|
||||
"uninstalled": "Not installed"
|
||||
"uninstalled": "Not installed",
|
||||
"repo": "Filter by repo",
|
||||
"allRepos": "All repos"
|
||||
},
|
||||
"noResults": "No matching skills found",
|
||||
"noInstalled": "No skills installed",
|
||||
|
||||
@@ -300,7 +300,8 @@
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
"ghostty": "Ghostty",
|
||||
"wezterm": "WezTerm"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "コマンドプロンプト",
|
||||
@@ -400,6 +401,45 @@
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
"subtitle": "Codex / Claude Code のセッションを管理",
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
"sessionList": "セッション一覧",
|
||||
"loadingSessions": "セッションを読み込み中...",
|
||||
"noSessions": "セッションが見つかりません",
|
||||
"selectSession": "セッションを選択してください",
|
||||
"noSummary": "概要なし",
|
||||
"lastActive": "最終アクティブ",
|
||||
"projectDir": "プロジェクトディレクトリ",
|
||||
"sourcePath": "元ファイル",
|
||||
"copyResumeCommand": "再開コマンドをコピー",
|
||||
"resumeCommandCopied": "再開コマンドをコピーしました",
|
||||
"openInTerminal": "ターミナルで再開",
|
||||
"terminalTargetTerminal": "Terminal",
|
||||
"terminalTargetKitty": "kitty",
|
||||
"terminalTargetCopy": "コピーのみ",
|
||||
"terminalLaunched": "ターミナルを起動しました",
|
||||
"openFailed": "ターミナルの起動に失敗しました",
|
||||
"resumeFallbackCopied": "再開コマンドをコピーしました(手動で実行してください)",
|
||||
"copyProjectDir": "ディレクトリをコピー",
|
||||
"projectDirCopied": "ディレクトリをコピーしました",
|
||||
"copySourcePath": "元ファイルをコピー",
|
||||
"sourcePathCopied": "元ファイルをコピーしました",
|
||||
"loadingMessages": "内容を読み込み中...",
|
||||
"emptySession": "表示できる内容がありません",
|
||||
"clickToCopyPath": "クリックしてパスをコピー",
|
||||
"tocTitle": "目次",
|
||||
"justNow": "たった今",
|
||||
"minutesAgo": "{{count}}分前",
|
||||
"hoursAgo": "{{count}}時間前",
|
||||
"daysAgo": "{{count}}日前",
|
||||
"roleUser": "ユーザー",
|
||||
"roleSystem": "システム",
|
||||
"roleTool": "ツール"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
|
||||
"setupListenerFailed": "プロバイダー切り替えリスナーの設定に失敗:",
|
||||
@@ -458,6 +498,7 @@
|
||||
"fastModelPlaceholder": "例: GLM-4.5-Air",
|
||||
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
|
||||
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
|
||||
"fillSupplierName": "プロバイダー名を入力してください",
|
||||
"fillConfigContent": "設定内容を入力してください",
|
||||
@@ -1096,7 +1137,9 @@
|
||||
"placeholder": "状態で絞り込み",
|
||||
"all": "すべて",
|
||||
"installed": "インストール済み",
|
||||
"uninstalled": "未インストール"
|
||||
"uninstalled": "未インストール",
|
||||
"repo": "リポジトリで絞り込み",
|
||||
"allRepos": "すべてのリポジトリ"
|
||||
},
|
||||
"noResults": "一致するスキルが見つかりませんでした",
|
||||
"noInstalled": "インストールされたスキルがありません",
|
||||
|
||||
@@ -300,7 +300,8 @@
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
"ghostty": "Ghostty",
|
||||
"wezterm": "WezTerm"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "命令提示符",
|
||||
@@ -400,6 +401,45 @@
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
"subtitle": "管理 Codex 与 Claude Code 会话记录",
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
"sessionList": "会话列表",
|
||||
"loadingSessions": "加载会话中...",
|
||||
"noSessions": "未发现会话",
|
||||
"selectSession": "请选择会话查看详情",
|
||||
"noSummary": "暂无摘要",
|
||||
"lastActive": "最近活跃",
|
||||
"projectDir": "项目目录",
|
||||
"sourcePath": "原始文件",
|
||||
"copyResumeCommand": "复制恢复命令",
|
||||
"resumeCommandCopied": "恢复命令已复制",
|
||||
"openInTerminal": "在终端恢复",
|
||||
"terminalTargetTerminal": "Terminal",
|
||||
"terminalTargetKitty": "kitty",
|
||||
"terminalTargetCopy": "仅复制",
|
||||
"terminalLaunched": "终端已启动",
|
||||
"openFailed": "终端启动失败",
|
||||
"resumeFallbackCopied": "已复制恢复命令,可手动粘贴到终端",
|
||||
"copyProjectDir": "复制目录",
|
||||
"projectDirCopied": "目录已复制",
|
||||
"copySourcePath": "复制原始文件",
|
||||
"sourcePathCopied": "原始文件已复制",
|
||||
"loadingMessages": "加载会话内容中...",
|
||||
"emptySession": "该会话暂无可展示内容",
|
||||
"clickToCopyPath": "点击复制路径",
|
||||
"tocTitle": "对话目录",
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{{count}} 分钟前",
|
||||
"hoursAgo": "{{count}} 小时前",
|
||||
"daysAgo": "{{count}} 天前",
|
||||
"roleUser": "用户",
|
||||
"roleSystem": "系统",
|
||||
"roleTool": "工具"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "收到供应商切换事件:",
|
||||
"setupListenerFailed": "设置供应商切换监听器失败:",
|
||||
@@ -458,6 +498,7 @@
|
||||
"fastModelPlaceholder": "例如: GLM-4.5-Air",
|
||||
"modelHint": "💡 留空将使用供应商的默认模型",
|
||||
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
|
||||
"apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾",
|
||||
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
|
||||
"fillSupplierName": "请填写供应商名称",
|
||||
"fillConfigContent": "请填写配置内容",
|
||||
@@ -1098,7 +1139,9 @@
|
||||
"placeholder": "状态筛选",
|
||||
"all": "全部",
|
||||
"installed": "已安装",
|
||||
"uninstalled": "未安装"
|
||||
"uninstalled": "未安装",
|
||||
"repo": "仓库筛选",
|
||||
"allRepos": "全部仓库"
|
||||
},
|
||||
"noResults": "未找到匹配的技能",
|
||||
"noInstalled": "暂无已安装的技能",
|
||||
|
||||
@@ -7,6 +7,7 @@ export { skillsApi } from "./skills";
|
||||
export { usageApi } from "./usage";
|
||||
export { vscodeApi } from "./vscode";
|
||||
export { proxyApi } from "./proxy";
|
||||
export { sessionsApi } from "./sessions";
|
||||
export * as configApi from "./config";
|
||||
export type { ProviderSwitchEvent } from "./providers";
|
||||
export type { Prompt } from "./prompts";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SessionMessage, SessionMeta } from "@/types";
|
||||
|
||||
export const sessionsApi = {
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
return await invoke("list_sessions");
|
||||
},
|
||||
|
||||
async getMessages(
|
||||
providerId: string,
|
||||
sourcePath: string,
|
||||
): Promise<SessionMessage[]> {
|
||||
return await invoke("get_session_messages", { providerId, sourcePath });
|
||||
},
|
||||
|
||||
async launchTerminal(options: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
customConfig?: string | null;
|
||||
}): Promise<boolean> {
|
||||
const { command, cwd, customConfig } = options;
|
||||
return await invoke("launch_session_terminal", {
|
||||
command,
|
||||
cwd,
|
||||
customConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
+8
-15
@@ -1,8 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
/** Skill 应用启用状态 */
|
||||
export interface SkillApps {
|
||||
@@ -80,7 +80,7 @@ export const skillsApi = {
|
||||
/** 安装 Skill(统一安装) */
|
||||
async installUnified(
|
||||
skill: DiscoverableSkill,
|
||||
currentApp: AppType,
|
||||
currentApp: AppId,
|
||||
): Promise<InstalledSkill> {
|
||||
return await invoke("install_skill_unified", { skill, currentApp });
|
||||
},
|
||||
@@ -91,11 +91,7 @@ export const skillsApi = {
|
||||
},
|
||||
|
||||
/** 切换 Skill 的应用启用状态 */
|
||||
async toggleApp(
|
||||
id: string,
|
||||
app: AppType,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
async toggleApp(id: string, app: AppId, enabled: boolean): Promise<boolean> {
|
||||
return await invoke("toggle_skill_app", { id, app, enabled });
|
||||
},
|
||||
|
||||
@@ -117,7 +113,7 @@ export const skillsApi = {
|
||||
// ========== 兼容旧 API ==========
|
||||
|
||||
/** 获取技能列表(兼容旧 API) */
|
||||
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
||||
async getAll(app: AppId = "claude"): Promise<Skill[]> {
|
||||
if (app === "claude") {
|
||||
return await invoke("get_skills");
|
||||
}
|
||||
@@ -125,7 +121,7 @@ export const skillsApi = {
|
||||
},
|
||||
|
||||
/** 安装技能(兼容旧 API) */
|
||||
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
||||
async install(directory: string, app: AppId = "claude"): Promise<boolean> {
|
||||
if (app === "claude") {
|
||||
return await invoke("install_skill", { directory });
|
||||
}
|
||||
@@ -133,10 +129,7 @@ export const skillsApi = {
|
||||
},
|
||||
|
||||
/** 卸载技能(兼容旧 API) */
|
||||
async uninstall(
|
||||
directory: string,
|
||||
app: AppType = "claude",
|
||||
): Promise<boolean> {
|
||||
async uninstall(directory: string, app: AppId = "claude"): Promise<boolean> {
|
||||
if (app === "claude") {
|
||||
return await invoke("uninstall_skill", { directory });
|
||||
}
|
||||
@@ -170,7 +163,7 @@ export const skillsApi = {
|
||||
/** 从 ZIP 文件安装 Skills */
|
||||
async installFromZip(
|
||||
filePath: string,
|
||||
currentApp: AppType,
|
||||
currentApp: AppId,
|
||||
): Promise<InstalledSkill[]> {
|
||||
return await invoke("install_skills_from_zip", { filePath, currentApp });
|
||||
},
|
||||
|
||||
@@ -3,8 +3,20 @@ import {
|
||||
type UseQueryResult,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import { providersApi, settingsApi, usageApi, type AppId } from "@/lib/api";
|
||||
import type { Provider, Settings, UsageResult } from "@/types";
|
||||
import {
|
||||
providersApi,
|
||||
settingsApi,
|
||||
usageApi,
|
||||
sessionsApi,
|
||||
type AppId,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
Provider,
|
||||
Settings,
|
||||
UsageResult,
|
||||
SessionMeta,
|
||||
SessionMessage,
|
||||
} from "@/types";
|
||||
|
||||
const sortProviders = (
|
||||
providers: Record<string, Provider>,
|
||||
@@ -132,3 +144,23 @@ export const useUsageQuery = (
|
||||
lastQueriedAt: query.dataUpdatedAt || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const useSessionsQuery = () => {
|
||||
return useQuery<SessionMeta[]>({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async () => sessionsApi.list(),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSessionMessagesQuery = (
|
||||
providerId?: string,
|
||||
sourcePath?: string,
|
||||
) => {
|
||||
return useQuery<SessionMessage[]>({
|
||||
queryKey: ["sessionMessages", providerId, sourcePath],
|
||||
queryFn: async () => sessionsApi.getMessages(providerId!, sourcePath!),
|
||||
enabled: Boolean(providerId && sourcePath),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -213,6 +213,24 @@ export interface Settings {
|
||||
preferredTerminal?: string;
|
||||
}
|
||||
|
||||
export interface SessionMeta {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
projectDir?: string | null;
|
||||
createdAt?: number;
|
||||
lastActiveAt?: number;
|
||||
sourcePath?: string;
|
||||
resumeCommand?: string;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
// MCP 服务器连接参数(宽松:允许扩展字段)
|
||||
export interface McpServerSpec {
|
||||
// 可选:社区常见 .mcp.json 中 stdio 配置可不写 type
|
||||
|
||||
Reference in New Issue
Block a user