Compare commits

..

22 Commits

Author SHA1 Message Date
YoVinchen 0cf9627e8b fix(skill): correct skill doc URL branch and path resolution
Use the actual branch returned by download_repo instead of the
configured branch, fixing 404s when repos default to master but
the URL was hardcoded to main. Also switch URL format from /tree/
to /blob/ and always point to the SKILL.md file.

Closes farion1231/cc-switch#968
2026-02-09 11:25:22 +08:00
makoMako 5502c74a79 feat: rename Qwen Coder preset to Bailian (#965)
- replace Qwen Coder preset with Bailian for Claude and OpenCode
- add Bailian icon asset and metadata mapping
- remove Bailian preset default model values
- rename Claude preset Kimi k2 to Kimi
2026-02-08 23:32:40 +08:00
myjustify fa7c9514cc feat(env): add Volta package manager path detection (#969)
Add ~/.volta/bin to CLI version scanning paths to support tools
installed via Volta (claude, codex, gemini, etc.).
2026-02-08 23:30:53 +08:00
Dex Miller d82027f107 feat(pricing): add claude-opus-4-6 and gpt-5.3-codex models, use incremental seeding (#943)
- Add claude-opus-4-6-20260206 pricing (same as opus-4-5)
- Add gpt-5.3-codex series pricing (same as gpt-5.2-codex)
- Change seed_model_pricing to INSERT OR IGNORE for incremental upsert
- Remove count==0 guard in ensure_model_pricing_seeded so new models
  are appended on every startup without overwriting user customizations
2026-02-07 11:06:48 +08:00
Jason ded9980fbf fix(provider): remove /v1 suffix from AIGoCode OpenCode base URL 2026-02-06 22:39:07 +08:00
Jason 3d91c381d9 fix(provider): unify AIGoCode API base URLs to https://api.aigocode.com 2026-02-06 22:39:07 +08:00
funnytime b8538a6996 feat: circular reveal animation for theme switching (#905) 2026-02-06 22:14:29 +08:00
Dex Miller 87b80c66b2 feat(usage): enhance dashboard with auto-refresh control and robust formatting (#942)
* style: format code and apply clippy lint fixes

* feat(usage): enhance dashboard with auto-refresh control and robust formatting

- Add configurable auto-refresh interval toggle (off/5s/10s/30s/60s) to usage dashboard
- Extract shared format utilities (fmtUsd, fmtInt, parseFiniteNumber, getLocaleFromLanguage)
- Refactor request log time filtering to rolling vs fixed mode with validation
- Use stable serializable query keys instead of filter objects
- Handle NaN/Infinity safely in number formatting across all usage components
- Use RFC 3339 date format in backend trend data
2026-02-06 22:00:33 +08:00
Dex Miller 14fa749ca9 fix(opencode): reject save when no models configured (#932)
Add validation to require at least one model before saving an OpenCode
provider. Shows a localized toast error when models are empty.
2026-02-06 16:03:39 +08:00
Dex Miller 95bc0e38df style: format code and apply clippy lint fixes (#941) 2026-02-06 16:02:57 +08:00
Jason 92785a8078 docs: add AICoding.sh sponsor to README files
Add AICoding.sh as a new sponsor with translations for all three
language versions (EN, ZH, JA).
2026-02-04 21:49:00 +08:00
Jason 1d97570a94 refactor(terminal): unify terminal selection using global settings
- Remove terminal selector from Session Manager page
- Backend now reads terminal preference from global settings
- Add terminal name mapping (iterm2 → iterm) for compatibility
- Add WezTerm support to macOS terminal options
- Add WezTerm translations for zh/en/ja
2026-02-04 11:03:44 +08:00
Jason d98183f3da fix(i18n): replace hardcoded Chinese strings in Session Manager
- Add i18n keys for relative time (justNow, minutesAgo, hoursAgo, daysAgo)
- Add i18n keys for role labels (roleUser, roleSystem, roleTool)
- Add i18n keys for UI elements (tocTitle, searchSessions, clickToCopyPath)
- Update formatRelativeTime and getRoleLabel to accept t function
- Add useTranslation hook to SessionToc component
2026-02-04 11:03:43 +08:00
Jason 68a0c304d8 feat(ui): hide provider test button
Provider request formats are complex and varied, making it difficult
to create a unified test mechanism. Users may incorrectly assume
request format issues indicate provider unavailability.

Code is commented out (not deleted) for easy restoration if needed.
2026-02-04 11:03:43 +08:00
Jason 60007ee4e8 fix(windows): restore default home dir resolution to prevent data loss
v3.10.3 introduced HOME env priority on Windows for test isolation,
which caused database path to change when HOME differs from USERPROFILE
(common in Git/MSYS environments), making providers appear to disappear.

Changes:
- Use CC_SWITCH_TEST_HOME for test isolation instead of HOME
- Add legacy fallback to detect v3.10.3 database location on Windows
- Add logging for legacy path detection to aid debugging
2026-02-04 11:03:43 +08:00
funnytime 7bd29d721e fix: prevent window flicker on silent startup (#901)
Closes #892
2026-02-04 10:37:54 +08:00
funnytime c153e7104e fix: titlebar does not follow theme in dark mode (#903) 2026-02-04 10:37:14 +08:00
PeanutSplash e65360e68a refactor(ui): extract shared components and deduplicate MCP/Skills panels (#897)
* refactor(ui): add tooltips and icons to MCP and Skills panels

* refactor: deduplicate UnifiedSkillsPanel and UnifiedMcpPanel shared code
2026-02-04 10:10:45 +08:00
TinsFox f0e8ba1d8f feat: session manger (#867)
* feat: init session manger

* feat: persist selected app to localStorage

- Save app selection when switching providers
- Restore last selected app on page load

* feat: persist current view to localStorage

- Save view selection when switching tabs
- Restore last selected view on page load

* styles: update ui

* feat: Improve macOS Terminal activation and refactor Kitty launch to use  command with user's default shell.

* fix: session view

* feat: toc

* feat: Implement FlexSearch for improved session search functionality.

* feat: Redesign session manager search and filter UI for a more compact and dynamic experience.

* refactor: modularize session manager by extracting components and utility functions into dedicated files.

* feat: Enhance session terminal launching with support for iTerm2, Ghostty, WezTerm, and Alacritty, including UI and custom configuration options.

* feat: Conditionally render terminal selection and resume session buttons only on macOS.
2026-02-02 11:12:30 +08:00
Jassy930 58153333ce fix(stream_check): respect auth_mode for Claude health checks (#824)
Previously, check_claude_stream always added the x-api-key header,
ignoring the provider's auth_mode setting. This caused health check
failures for proxy services that only support Bearer authentication.

Now the function respects the auth.strategy field:
- AuthStrategy::Anthropic: Authorization Bearer + x-api-key
- AuthStrategy::ClaudeAuth: Authorization Bearer only
- AuthStrategy::Bearer: Authorization Bearer only

This aligns with the behavior of ClaudeAdapter::add_auth_headers
and fixes health checks for proxy providers with auth_mode="bearer_only".

Changes:
- Modified check_claude_stream to conditionally add x-api-key header
- Added AuthStrategy import
- Added test_auth_strategy_imports unit test

Tests: All passing (7/7 for stream_check module)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 09:28:04 +08:00
stmoonar d098ecad64 fix(skills): handle Windows path separator in installed status matching (#868)
* fix(skills): handle Windows path separator in installed status matching

Use regex to split directory path by both / and \ to correctly extract
the install name on Windows, fixing the issue where installed skills
were not showing as installed in the discovery page filter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(skills): add repository filter to skills discovery page

- Add dropdown to filter skills by repository (owner/name)
- Extract unique repos from discoverable skills list
- Add truncate style for long repo names with hover title
- Add i18n translations for zh/en/ja

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:05:05 +08:00
makoMako 809a1fcf84 feat(ui): dynamic endpoint hint based on API format selection (#860)
- Add apiHintOAI i18n key for OpenAI Chat Completions format hint
- Update ClaudeFormFields to show format-specific endpoint hints
- When API format is "openai_chat", show OAI-specific hint
- Maintains consistency between hint and selected API format

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:21:43 +08:00
79 changed files with 4044 additions and 555 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ release/
*.tsbuildinfo
.npmrc
CLAUDE.md
AGENTS.md
# AGENTS.md
GEMINI.md
/.claude
/.codex
+4
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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>
## スクリーンショット
+5
View File
@@ -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

+3
View File
@@ -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",
+97
View File
@@ -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
+268
View File
@@ -0,0 +1,268 @@
# 会话管理(Session Manager)需求文档(PRD / Markdown
> 目标:对 **Codex / Claude Code** 的本地会话记录进行可视化管理,并提供“一键复制 / 一键终端恢复”能力。
> 范围:**v1 仅 macOS**,但必须预留多平台扩展入口。
---
## 1. 背景与问题
开发者同时使用 Codex CLI、Claude Code 时,常见痛点:
- 会话记录落在本地不同位置,**难以发现/检索**
- 找到会话后,恢复命令需要记忆或翻历史,**恢复成本高**
- 恢复时经常忘了当时的工作目录,导致命令在错误目录运行
- 希望在常用终端(macOS Terminal、kitty 等)中直接恢复,提高效率
---
## 2. 目标与非目标
### 2.1 Goalsv1 必达)
1. 扫描并展示本机所有 Codex / Claude Code 会话:列表 + 详情(会话内容)
2. 支持恢复会话:
- 复制恢复命令(按钮)
- 复制会话目录(按钮,若能获取/推断)
- 可选:直接在终端执行恢复(macOS Terminal、kitty;可扩展)
3. 仅 macOS 支持,但代码结构需支持未来扩展 Windows/Linux
### 2.2 Non-Goalsv1 不做)
- 不新增/依赖云端 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 列表
- 支持 ProviderCodex、Claude Code(可扩展)
- 支持全量扫描 + 增量更新
- 支持缺失/异常文件的容错(不中断 UI)
**FR-2** 本地索引(Cache/DB
- 用于加速列表加载与搜索
- 索引字段至少包含:sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空)
**FR-3** 数据源路径探测(可配置 + 多候选)
- 默认使用常见路径;允许用户在 Settings 覆盖
- 若无法探测到 provider 安装/数据目录:在 UI 显示未启用/不可用状态,但不报错崩溃
---
### 5.2 会话列表(List
**FR-4** 列表展示字段(建议最小集)
- ProviderCodex / Claude
- Session 标识(id/short id
- 最近活跃时间(lastActiveAt
- 目录(projectDir,若未知显示 “Unknown”)
- 摘要(summary:最后一条/首条截断或规则生成)
**FR-5** 列表交互
- 搜索(跨会话,关键词匹配 transcript/summary/目录)
- 过滤:Provider、是否有目录、时间范围
- 排序:最近活跃(默认)、最早、按目录
**FR-6** 空态/异常态
- 未发现任何会话:给出“如何启用/设置路径”的指引
- 发现会话但无法解析内容:列表仍可显示基本信息,并在详情页提示“解析失败”
---
### 5.3 会话详情(Detail
**FR-7** 会话内容展示
- 时间线展示消息(roleuser/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
- 支持 kittyv1 要求)
- 执行策略:
- `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
- FutureTerminalLauncherWindows / 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 视图)
- 会话清理/归档(磁盘管理)
---
+18 -1
View File
@@ -324,6 +324,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
home.join(".local/bin"), // Native install (official recommended)
home.join(".npm-global/bin"),
home.join("n/bin"), // n version manager
home.join(".volta/bin"), // Volta package manager
];
#[cfg(target_os = "macos")]
@@ -652,6 +653,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 +756,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 +956,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())
}
+2
View File
@@ -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::*;
+59
View File
@@ -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
View File
@@ -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
}
/// 获取应用配置文件路径
+47 -10
View File
@@ -919,7 +919,16 @@ impl Database {
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.5 系列 (Latest Models)
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
"Claude Opus 4.6",
"5",
"25",
"0.50",
"6.25",
),
// Claude 4.5 系列
(
"claude-opus-4-5-20251101",
"Claude Opus 4.5",
@@ -1025,6 +1034,40 @@ impl Database {
"0.175",
"0",
),
// GPT-5.3 Codex 系列
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.3-codex-low",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-medium",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-high",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-xhigh",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
// GPT-5.1 系列
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
@@ -1212,7 +1255,7 @@ impl Database {
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
"INSERT OR IGNORE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
@@ -1239,14 +1282,8 @@ impl Database {
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
if count == 0 {
Self::seed_model_pricing(conn)?;
}
Ok(())
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
Self::seed_model_pricing(conn)
}
// --- 辅助方法 ---
+7
View File
@@ -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
+104 -29
View File
@@ -174,6 +174,29 @@ impl SkillService {
Self
}
/// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件)
fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String {
format!("https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}")
}
/// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式
fn extract_doc_path_from_url(url: &str) -> Option<String> {
let marker = if url.contains("/blob/") {
"/blob/"
} else if url.contains("/tree/") {
"/tree/"
} else {
return None;
};
let (_, tail) = url.split_once(marker)?;
let (_, path) = tail.split_once('/')?;
if path.is_empty() {
return None;
}
Some(path.to_string())
}
// ========== 路径管理 ==========
/// 获取 SSOT 目录(~/.cc-switch/skills/
@@ -298,6 +321,8 @@ impl SkillService {
let dest = ssot_dir.join(&install_name);
let mut repo_branch = skill.repo_branch.clone();
// 如果已存在则跳过下载
if !dest.exists() {
let repo = SkillRepo {
@@ -308,7 +333,7 @@ impl SkillService {
};
// 下载仓库
let temp_dir = timeout(
let (temp_dir, used_branch) = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
@@ -324,6 +349,7 @@ impl SkillService {
Some("checkNetwork"),
))
})??;
repo_branch = used_branch;
// 复制到 SSOT
let source = temp_dir.join(&skill.directory);
@@ -338,8 +364,39 @@ impl SkillService {
Self::copy_dir_recursive(&source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir);
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
if repo_branch != skill.repo_branch {
log::info!(
"Skill {}/{} 分支自动回退: {} -> {}",
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
repo_branch
);
}
}
let doc_path = skill
.readme_url
.as_deref()
.and_then(Self::extract_doc_path_from_url)
.map(|path| {
if path.ends_with("/SKILL.md") || path == "SKILL.md" {
path
} else {
format!("{}/SKILL.md", path.trim_end_matches('/'))
}
})
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
let readme_url = Some(Self::build_skill_doc_url(
&skill.repo_owner,
&skill.repo_name,
&repo_branch,
&doc_path,
));
// 创建 InstalledSkill 记录
let installed_skill = InstalledSkill {
id: skill.key.clone(),
@@ -352,8 +409,8 @@ impl SkillService {
directory: install_name.clone(),
repo_owner: Some(skill.repo_owner.clone()),
repo_name: Some(skill.repo_name.clone()),
repo_branch: Some(skill.repo_branch.clone()),
readme_url: skill.readme_url.clone(),
repo_branch: Some(repo_branch),
readme_url,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
};
@@ -862,24 +919,26 @@ impl SkillService {
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let (temp_dir, resolved_branch) =
timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
let scan_dir = temp_dir.clone();
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
let mut resolved_repo = repo.clone();
resolved_repo.branch = resolved_branch;
self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;
let _ = fs::remove_dir_all(&temp_dir);
@@ -907,7 +966,15 @@ impl SkillService {
.to_string()
};
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
let doc_path = skill_md
.strip_prefix(base_dir)
.unwrap_or(skill_md.as_path())
.to_string_lossy()
.replace('\\', "/");
if let Ok(skill) =
self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)
{
skills.push(skill);
}
@@ -931,6 +998,7 @@ impl SkillService {
&self,
skill_md: &Path,
directory: &str,
doc_path: &str,
repo: &SkillRepo,
) -> Result<DiscoverableSkill> {
let meta = self.parse_skill_metadata(skill_md)?;
@@ -940,9 +1008,11 @@ impl SkillService {
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, directory
readme_url: Some(Self::build_skill_doc_url(
&repo.owner,
&repo.name,
&repo.branch,
doc_path,
)),
repo_owner: repo.owner.clone(),
repo_name: repo.name.clone(),
@@ -994,16 +1064,21 @@ impl SkillService {
}
/// 下载仓库
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep();
let branches = if repo.branch.is_empty() {
vec!["main", "master"]
} else {
vec![repo.branch.as_str(), "main", "master"]
};
let mut branches = Vec::new();
if !repo.branch.is_empty() {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
branches.push("main");
}
if !branches.contains(&"master") {
branches.push("master");
}
let mut last_error = None;
for branch in branches {
@@ -1014,7 +1089,7 @@ impl SkillService {
match self.download_and_extract(&url, &temp_path).await {
Ok(_) => {
return Ok(temp_path);
return Ok((temp_path, branch.to_string()));
}
Err(e) => {
last_error = Some(e);
+30 -6
View File
@@ -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);
}
}
+1 -1
View File
@@ -272,7 +272,7 @@ impl Database {
.single()
.unwrap_or_else(Local::now);
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
let date = bucket_start.to_rfc3339();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
+60
View File
@@ -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('"', "\\\"")
}
+1
View File
@@ -19,6 +19,7 @@
"height": 650,
"minWidth": 900,
"minHeight": 600,
"visible": false,
"resizable": true,
"fullscreen": false,
"center": true
+1
View File
@@ -6,6 +6,7 @@
"label": "main",
"title": "CC Switch",
"titleBarStyle": "Visible",
"visible": false,
"minWidth": 900,
"minHeight": 600
}
+3
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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;
+34
View File
@@ -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>
);
};
+48
View File
@@ -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>
);
};
+21
View File
@@ -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>
);
};
+190
View File
@@ -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>
);
}
+57 -136
View File
@@ -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>
);
};
+3 -3
View File
@@ -7,13 +7,13 @@ export function ModeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const toggleTheme = () => {
const toggleTheme = (event: React.MouseEvent) => {
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
// 否则切换到 dark
if (theme === "dark") {
setTheme("light");
setTheme("light", event);
} else {
setTheme("dark");
setTheme("dark", event);
}
};
+10 -8
View File
@@ -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)}
/>
)}
@@ -746,7 +746,7 @@ export function ProviderForm({
return;
}
// OpenCode: validate provider key
// OpenCode: validate provider key and models
if (appId === "opencode") {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!opencodeProviderKey.trim()) {
@@ -761,6 +761,11 @@ export function ProviderForm({
toast.error(t("opencode.providerKeyDuplicate"));
return;
}
// Validate that at least one model is configured
if (Object.keys(opencodeModels).length === 0) {
toast.error(t("opencode.modelsRequired"));
return;
}
}
// 非官方供应商必填校验:端点和 API Key
+78
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+78
View File
@@ -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 = [
+4 -4
View File
@@ -19,21 +19,21 @@ export function ThemeSettings() {
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<ThemeButton
active={theme === "light"}
onClick={() => setTheme("light")}
onClick={(e) => setTheme("light", e)}
icon={Sun}
>
{t("settings.themeLight")}
</ThemeButton>
<ThemeButton
active={theme === "dark"}
onClick={() => setTheme("dark")}
onClick={(e) => setTheme("dark", e)}
icon={Moon}
>
{t("settings.themeDark")}
</ThemeButton>
<ThemeButton
active={theme === "system"}
onClick={() => setTheme("system")}
onClick={(e) => setTheme("system", e)}
icon={Monitor}
>
{t("settings.themeSystem")}
@@ -45,7 +45,7 @@ export function ThemeSettings() {
interface ThemeButtonProps {
active: boolean;
onClick: () => void;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}
+62 -7
View File
@@ -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) =>
+61 -155
View File
@@ -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;
+74 -3
View File
@@ -5,6 +5,7 @@ import React, {
useMemo,
useState,
} from "react";
import { invoke } from "@tauri-apps/api/core";
type Theme = "light" | "dark" | "system";
@@ -16,7 +17,7 @@ interface ThemeProviderProps {
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
setTheme: (theme: Theme, event?: React.MouseEvent) => void;
}
const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
@@ -94,11 +95,81 @@ 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,
setTheme: (nextTheme: Theme) => {
setThemeState(nextTheme);
setTheme: (nextTheme: Theme, event?: React.MouseEvent) => {
// Skip if same theme
if (nextTheme === theme) return;
// Set transition origin coordinates from click event
const x = event?.clientX ?? window.innerWidth / 2;
const y = event?.clientY ?? window.innerHeight / 2;
document.documentElement.style.setProperty(
"--theme-transition-x",
`${x}px`,
);
document.documentElement.style.setProperty(
"--theme-transition-y",
`${y}px`,
);
// Use View Transitions API if available, otherwise fall back to instant change
if (document.startViewTransition) {
document.startViewTransition(() => {
setThemeState(nextTheme);
});
} else {
setThemeState(nextTheme);
}
},
}),
[theme],
+48
View File
@@ -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 };
+1 -1
View File
@@ -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}
+30
View File
@@ -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 };
+11 -4
View File
@@ -8,10 +8,17 @@ import {
TableRow,
} from "@/components/ui/table";
import { useModelStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
export function ModelStatsTable() {
interface ModelStatsTableProps {
refreshIntervalMs: number;
}
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats();
const { data: stats, isLoading } = useModelStats({
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -60,10 +67,10 @@ export function ModelStatsTable() {
{stat.totalTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)}
{fmtUsd(stat.totalCost, 4)}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.avgCostPerRequest).toFixed(6)}
{fmtUsd(stat.avgCostPerRequest, 6)}
</TableCell>
</TableRow>
))
+12 -3
View File
@@ -8,10 +8,19 @@ import {
TableRow,
} from "@/components/ui/table";
import { useProviderStats } from "@/lib/query/usage";
import { fmtUsd } from "./format";
export function ProviderStatsTable() {
interface ProviderStatsTableProps {
refreshIntervalMs: number;
}
export function ProviderStatsTable({
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats();
const { data: stats, isLoading } = useProviderStats({
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
@@ -63,7 +72,7 @@ export function ProviderStatsTable() {
{stat.totalTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)}
{fmtUsd(stat.totalCost, 4)}
</TableCell>
<TableCell className="text-right">
{stat.successRate.toFixed(1)}%
+159 -58
View File
@@ -21,44 +21,122 @@ import { useRequestLogs, usageKeys } from "@/lib/query/usage";
import { useQueryClient } from "@tanstack/react-query";
import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
export function RequestLogTable() {
interface RequestLogTableProps {
refreshIntervalMs: number;
}
const ONE_DAY_SECONDS = 24 * 60 * 60;
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
// 默认时间范围:过去24小时
const getDefaultFilters = (): LogFilters => {
const getRollingRange = () => {
const now = Math.floor(Date.now() / 1000);
const oneDayAgo = now - 24 * 60 * 60;
const oneDayAgo = now - ONE_DAY_SECONDS;
return { startDate: oneDayAgo, endDate: now };
};
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters);
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters);
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0);
const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize);
const { data: result, isLoading } = useRequestLogs({
filters: appliedFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS,
page,
pageSize,
options: {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
});
const logs = result?.data ?? [];
const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => {
setFilters(tempFilters);
setValidationError(null);
if (draftTimeMode === "fixed") {
const start = draftFilters.startDate;
const end = draftFilters.endDate;
if (typeof start !== "number" || typeof end !== "number") {
setValidationError(
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
);
return;
}
if (start > end) {
setValidationError(
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
);
return;
}
if (end - start > MAX_FIXED_RANGE_SECONDS) {
setValidationError(
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
);
return;
}
}
setAppliedTimeMode(draftTimeMode);
setAppliedFilters((prev) => {
const next = { ...prev, ...draftFilters };
if (draftTimeMode === "rolling") {
delete next.startDate;
delete next.endDate;
}
return next;
});
setPage(0);
};
const handleReset = () => {
const defaults = getDefaultFilters();
setTempFilters(defaults);
setFilters(defaults);
setValidationError(null);
setAppliedTimeMode("rolling");
setDraftTimeMode("rolling");
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const handleRefresh = () => {
const key = {
timeMode: appliedTimeMode,
rollingWindowSeconds:
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
appType: appliedFilters.appType,
providerName: appliedFilters.providerName,
model: appliedFilters.model,
statusCode: appliedFilters.statusCode,
startDate:
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
};
queryClient.invalidateQueries({
queryKey: usageKeys.logs(filters, page, pageSize),
queryKey: usageKeys.logs(key, page, pageSize),
});
};
@@ -84,12 +162,11 @@ export function RequestLogTable() {
return Math.floor(timestamp / 1000);
};
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const rollingRangeForDisplay =
draftTimeMode === "rolling" ? getRollingRange() : null;
return (
<div className="space-y-4">
@@ -97,10 +174,10 @@ export function RequestLogTable() {
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3">
<Select
value={tempFilters.appType || "all"}
value={draftFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
setDraftFilters({
...draftFilters,
appType: v === "all" ? undefined : v,
})
}
@@ -117,11 +194,16 @@ export function RequestLogTable() {
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
setDraftFilters({
...draftFilters,
statusCode:
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
})
}
>
@@ -144,10 +226,10 @@ export function RequestLogTable() {
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="pl-9 bg-background"
value={tempFilters.providerName || ""}
value={draftFilters.providerName || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
@@ -156,10 +238,10 @@ export function RequestLogTable() {
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="w-[180px] bg-background"
value={tempFilters.model || ""}
value={draftFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
@@ -174,14 +256,18 @@ export function RequestLogTable() {
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.startDate
? timestampToLocalDatetime(tempFilters.startDate)
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.startDate ??
draftFilters.startDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({
...tempFilters,
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
startDate: timestamp,
});
}}
@@ -191,14 +277,18 @@ export function RequestLogTable() {
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.endDate
? timestampToLocalDatetime(tempFilters.endDate)
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.endDate ??
draftFilters.endDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({
...tempFilters,
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
endDate: timestamp,
});
}}
@@ -234,6 +324,10 @@ export function RequestLogTable() {
</Button>
</div>
</div>
{validationError && (
<div className="text-sm text-red-600">{validationError}</div>
)}
</div>
{isLoading ? (
@@ -293,9 +387,7 @@ export function RequestLogTable() {
logs.map((log) => (
<TableRow key={log.requestId}>
<TableCell>
{new Date(log.createdAt * 1000).toLocaleString(
dateLocale,
)}
{new Date(log.createdAt * 1000).toLocaleString(locale)}
</TableCell>
<TableCell>
{log.providerName || t("usage.unknownProvider")}
@@ -321,19 +413,19 @@ export function RequestLogTable() {
)}
</TableCell>
<TableCell className="text-right">
{log.inputTokens.toLocaleString()}
{fmtInt(log.inputTokens, locale)}
</TableCell>
<TableCell className="text-right">
{log.outputTokens.toLocaleString()}
{fmtInt(log.outputTokens, locale)}
</TableCell>
<TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()}
{fmtInt(log.cacheReadTokens, locale)}
</TableCell>
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
{fmtInt(log.cacheCreationTokens, locale)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{parseFloat(log.costMultiplier) !== 1 ? (
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
<span className="text-orange-600">
×{log.costMultiplier}
</span>
@@ -342,24 +434,30 @@ export function RequestLogTable() {
)}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}
{fmtUsd(log.totalCostUsd, 6)}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
{(() => {
const durationSec =
(log.durationMs ?? log.latencyMs) / 1000;
const durationColor =
durationSec <= 5
const durationMs =
typeof log.durationMs === "number"
? log.durationMs
: log.latencyMs;
const durationSec = durationMs / 1000;
const durationColor = Number.isFinite(durationSec)
? durationSec <= 5
? "bg-green-100 text-green-800"
: durationSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900";
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
>
{Math.round(durationSec)}s
{Number.isFinite(durationSec)
? `${Math.round(durationSec)}s`
: "--"}
</span>
);
})()}
@@ -367,17 +465,20 @@ export function RequestLogTable() {
log.firstTokenMs != null &&
(() => {
const firstSec = log.firstTokenMs / 1000;
const firstColor =
firstSec <= 5
const firstColor = Number.isFinite(firstSec)
? firstSec <= 5
? "bg-green-100 text-green-800"
: firstSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900";
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
>
{firstSec.toFixed(1)}s
{Number.isFinite(firstSec)
? `${firstSec.toFixed(1)}s`
: "--"}
</span>
);
})()}
+55 -25
View File
@@ -8,11 +8,28 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion";
import { BarChart3, ListFilter, Activity } from "lucide-react";
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
export function UsageDashboard() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
const changeRefreshInterval = () => {
const currentIndex = refreshIntervalOptionsMs.indexOf(
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
);
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex];
setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
};
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
@@ -34,32 +51,45 @@ export function UsageDashboard() {
onValueChange={(v) => setTimeRange(v as TimeRange)}
className="w-full sm:w-auto"
>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<TabsTrigger
value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
<div className="flex w-full sm:w-auto items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-10 px-2 text-xs text-muted-foreground"
title={t("common.refresh", "刷新")}
onClick={changeRefreshInterval}
>
{t("usage.today")}
</TabsTrigger>
<TabsTrigger
value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last7days")}
</TabsTrigger>
<TabsTrigger
value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last30days")}
</TabsTrigger>
</TabsList>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
</Button>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<TabsTrigger
value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.today")}
</TabsTrigger>
<TabsTrigger
value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last7days")}
</TabsTrigger>
<TabsTrigger
value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last30days")}
</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
<UsageSummaryCards days={days} />
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageTrendChart days={days} />
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
@@ -86,15 +116,15 @@ export function UsageDashboard() {
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable />
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable />
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent>
<TabsContent value="models" className="mt-0">
<ModelStatsTable />
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
</TabsContent>
</motion.div>
</Tabs>
+11 -4
View File
@@ -4,19 +4,26 @@ import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion";
import { fmtUsd, parseFiniteNumber } from "./format";
interface UsageSummaryCardsProps {
days: number;
refreshIntervalMs: number;
}
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
export function UsageSummaryCards({
days,
refreshIntervalMs,
}: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days);
const { data: summary, isLoading } = useUsageSummary(days, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0");
const totalCost = parseFiniteNumber(summary?.totalCost);
const inputTokens = summary?.totalInputTokens ?? 0;
const outputTokens = summary?.totalOutputTokens ?? 0;
@@ -37,7 +44,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
},
{
title: t("usage.totalCost"),
value: `$${totalCost.toFixed(4)}`,
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
icon: DollarSign,
color: "text-green-500",
bg: "bg-green-500/10",
+21 -12
View File
@@ -11,14 +11,26 @@ import {
} from "recharts";
import { useUsageTrends } from "@/lib/query/usage";
import { Loader2 } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
interface UsageTrendChartProps {
days: number;
refreshIntervalMs: number;
}
export function UsageTrendChart({ days }: UsageTrendChartProps) {
export function UsageTrendChart({
days,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days);
const { data: trends, isLoading } = useUsageTrends(days, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
if (isLoading) {
return (
@@ -29,15 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
}
const isToday = days === 1;
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const language = i18n.resolvedLanguage || i18n.language || "en";
const dateLocale = getLocaleFromLanguage(language);
const chartData =
trends?.map((stat) => {
const pointDate = new Date(stat.date);
const cost = parseFiniteNumber(stat.totalCost);
return {
rawDate: stat.date,
label: isToday
@@ -56,7 +65,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost),
cost: cost ?? null,
};
}) || [];
@@ -79,9 +88,9 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
/>
<span className="font-medium">{entry.name}:</span>
<span>
{entry.name.includes(t("usage.cost", "成本"))
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}`
: entry.value.toLocaleString()}
{entry.dataKey === "cost"
? fmtUsd(entry.value, 6)
: fmtInt(entry.value, dateLocale)}
</span>
</div>
))}
+39
View File
@@ -0,0 +1,39 @@
export function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function fmtInt(
value: unknown,
locale?: string,
fallback: string = "--",
): string {
const num = parseFiniteNumber(value);
if (num == null) return fallback;
return new Intl.NumberFormat(locale).format(Math.trunc(num));
}
export function fmtUsd(
value: unknown,
digits: number,
fallback: string = "--",
): string {
const num = parseFiniteNumber(value);
if (num == null) return fallback;
return `$${num.toFixed(digits)}`;
}
export function getLocaleFromLanguage(language: string): string {
if (!language) return "en-US";
if (language.startsWith("zh")) return "zh-CN";
if (language.startsWith("ja")) return "ja-JP";
return "en-US";
}
+55
View File
@@ -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",
},
};
+5 -9
View File
@@ -125,24 +125,20 @@ export const providerPresets: ProviderPreset[] = [
iconColor: "#0F62FE",
},
{
name: "Qwen Coder",
name: "Bailian",
websiteUrl: "https://bailian.console.aliyun.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "qwen3-max",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3-max",
ANTHROPIC_DEFAULT_SONNET_MODEL: "qwen3-max",
ANTHROPIC_DEFAULT_OPUS_MODEL: "qwen3-max",
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
icon: "bailian",
iconColor: "#624AFF",
},
{
name: "Kimi k2",
name: "Kimi",
websiteUrl: "https://platform.moonshot.cn/console",
settingsConfig: {
env: {
@@ -438,7 +434,7 @@ export const providerPresets: ProviderPreset[] = [
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.aigocode.com/api",
ANTHROPIC_BASE_URL: "https://api.aigocode.com",
ANTHROPIC_AUTH_TOKEN: "",
},
},
+1 -1
View File
@@ -183,7 +183,7 @@ requires_openai_auth = true`,
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"aigocode",
"https://api.aigocode.com/openai",
"https://api.aigocode.com",
"gpt-5.2",
),
endpointCandidates: ["https://api.aigocode.com"],
+3 -3
View File
@@ -102,17 +102,17 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com/gemini",
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com",
GEMINI_MODEL: "gemini-3-pro",
},
},
baseURL: "https://api.aigocode.com/gemini",
baseURL: "https://api.aigocode.com",
model: "gemini-3-pro",
description: "AIGoCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
endpointCandidates: ["https://api.aigocode.com/gemini"],
endpointCandidates: ["https://api.aigocode.com"],
icon: "aigocode",
iconColor: "#5B7FFF",
},
+1
View File
@@ -10,6 +10,7 @@ const iconMappings = {
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
glm: { icon: "zhipu", iconColor: "#0F62FE" },
qwen: { icon: "qwen", iconColor: "#FF6A00" },
bailian: { icon: "bailian", iconColor: "#624AFF" },
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
kimi: { icon: "kimi", iconColor: "#6366F1" },
+6 -8
View File
@@ -137,23 +137,21 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
{
name: "Qwen Coder",
name: "Bailian",
websiteUrl: "https://bailian.console.aliyun.com",
apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Qwen Coder",
name: "Bailian",
options: {
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "",
},
models: {
"qwen3-max": { name: "Qwen3 Max" },
},
models: {},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
icon: "bailian",
iconColor: "#624AFF",
templateValues: {
baseURL: {
label: "Base URL",
@@ -651,7 +649,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
npm: "@ai-sdk/anthropic",
name: "AIGoCode",
options: {
baseURL: "https://api.aigocode.com/v1",
baseURL: "https://api.aigocode.com",
apiKey: "",
},
models: {
+134
View File
@@ -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]);
}
+5 -5
View File
@@ -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 };
+46 -2
View File
@@ -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",
@@ -592,6 +633,7 @@
"modelId": "Model ID",
"modelName": "Display Name",
"noModels": "No models configured",
"modelsRequired": "Please add at least one model",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
@@ -1098,7 +1140,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",
+46 -2
View File
@@ -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": "設定内容を入力してください",
@@ -592,6 +633,7 @@
"modelId": "モデル ID",
"modelName": "表示名",
"noModels": "モデルが設定されていません",
"modelsRequired": "モデルを少なくとも1つ追加してください",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
@@ -1096,7 +1138,9 @@
"placeholder": "状態で絞り込み",
"all": "すべて",
"installed": "インストール済み",
"uninstalled": "未インストール"
"uninstalled": "未インストール",
"repo": "リポジトリで絞り込み",
"allRepos": "すべてのリポジトリ"
},
"noResults": "一致するスキルが見つかりませんでした",
"noInstalled": "インストールされたスキルがありません",
+46 -2
View File
@@ -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": "请填写配置内容",
@@ -592,6 +633,7 @@
"modelId": "模型 ID",
"modelName": "显示名称",
"noModels": "暂无模型配置",
"modelsRequired": "请至少添加一个模型配置",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
@@ -1098,7 +1140,9 @@
"placeholder": "状态筛选",
"all": "全部",
"installed": "已安装",
"uninstalled": "未安装"
"uninstalled": "未安装",
"repo": "仓库筛选",
"allRepos": "全部仓库"
},
"noResults": "未找到匹配的技能",
"noInstalled": "暂无已安装的技能",
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill-opacity=".4"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill-opacity=".8"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill-opacity=".8"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill-opacity=".6"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill-opacity=".3"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -60,6 +60,7 @@ export const icons: Record<string, string> = {
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
nvidia: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>`,
bailian: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill-opacity=".4"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill-opacity=".8"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill-opacity=".8"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill-opacity=".6"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill-opacity=".3"></path></svg>`,
};
export const iconList = Object.keys(icons);
+7
View File
@@ -44,6 +44,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["ernie", "wenxin"],
defaultColor: "#2932E1",
},
bailian: {
name: "bailian",
displayName: "Bailian",
category: "ai-provider",
keywords: ["bailian", "dashscope", "aliyun", "alibaba"],
defaultColor: "#624AFF",
},
bytedance: {
name: "bytedance",
displayName: "bytedance",
+39
View File
@@ -215,3 +215,42 @@ input[type="password"]::-ms-reveal,
input[type="password"]::-ms-clear {
display: none;
}
/* Theme transition animation using View Transitions API */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* Old snapshot stays behind, new snapshot animates on top */
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
/* Circular expand animation from click position */
@keyframes theme-circle-expand {
from {
clip-path: circle(0% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%));
}
to {
clip-path: circle(150% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%));
}
}
/* Apply animation to new snapshot - works for both light and dark transitions */
::view-transition-new(root) {
animation: theme-circle-expand 0.4s ease-out;
}
/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
::view-transition-new(root) {
animation: none;
}
}
+1
View File
@@ -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";
+28
View File
@@ -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
View File
@@ -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 });
},
+34 -2
View File
@@ -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,
});
};
+92 -23
View File
@@ -2,6 +2,35 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import type { LogFilters } from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
type UsageQueryOptions = {
refetchInterval?: number | false;
refetchIntervalInBackground?: boolean;
};
type RequestLogsTimeMode = "rolling" | "fixed";
type RequestLogsQueryArgs = {
filters: LogFilters;
timeMode: RequestLogsTimeMode;
page?: number;
pageSize?: number;
rollingWindowSeconds?: number;
options?: UsageQueryOptions;
};
type RequestLogsKey = {
timeMode: RequestLogsTimeMode;
rollingWindowSeconds?: number;
appType?: string;
providerName?: string;
model?: string;
statusCode?: number;
startDate?: number;
endDate?: number;
};
// Query keys
export const usageKeys = {
all: ["usage"] as const,
@@ -9,8 +38,21 @@ export const usageKeys = {
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const,
logs: (filters: LogFilters, page: number, pageSize: number) =>
[...usageKeys.all, "logs", filters, page, pageSize] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[
...usageKeys.all,
"logs",
key.timeMode,
key.rollingWindowSeconds ?? 0,
key.appType ?? "",
key.providerName ?? "",
key.model ?? "",
key.statusCode ?? -1,
key.startDate ?? 0,
key.endDate ?? 0,
page,
pageSize,
] as const,
detail: (requestId: string) =>
[...usageKeys.all, "detail", requestId] as const,
pricing: () => [...usageKeys.all, "pricing"] as const,
@@ -25,58 +67,85 @@ const getWindow = (days: number) => {
};
// Hooks
export function useUsageSummary(days: number) {
export function useUsageSummary(days: number, options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.summary(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
});
}
export function useUsageTrends(days: number) {
export function useUsageTrends(days: number, options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useProviderStats() {
export function useProviderStats(options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useModelStats() {
export function useModelStats(options?: UsageQueryOptions) {
return useQuery({
queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
export function useRequestLogs(
filters: LogFilters,
page: number = 0,
pageSize: number = 20,
) {
const getRollingRange = (windowSeconds: number) => {
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - windowSeconds;
return { startDate, endDate };
};
export function useRequestLogs({
filters,
timeMode,
page = 0,
pageSize = 20,
rollingWindowSeconds = 24 * 60 * 60,
options,
}: RequestLogsQueryArgs) {
const key: RequestLogsKey = {
timeMode,
rollingWindowSeconds:
timeMode === "rolling" ? rollingWindowSeconds : undefined,
appType: filters.appType,
providerName: filters.providerName,
model: filters.model,
statusCode: filters.statusCode,
startDate: timeMode === "fixed" ? filters.startDate : undefined,
endDate: timeMode === "fixed" ? filters.endDate : undefined,
};
return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
queryKey: usageKeys.logs(key, page, pageSize),
queryFn: () => {
const effectiveFilters =
timeMode === "rolling"
? { ...filters, ...getRollingRange(rollingWindowSeconds) }
: filters;
return usageApi.getRequestLogs(effectiveFilters, page, pageSize);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
});
}
+18
View File
@@ -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