Compare commits

..

55 Commits

Author SHA1 Message Date
YoVinchen a353518e9b Merge branch 'main' into feat/omo-integration 2026-02-08 23:43:13 +08:00
YoVinchen 6388d24c9a fix(omo): replace hardcoded isZh strings with proper i18n t() calls 2026-02-08 23:41:48 +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
YoVinchen a20ff157bf fix(omo): use standard provider action styles for OMO switch button 2026-02-08 23:22:20 +08:00
YoVinchen da27e22f77 feat(omo): add model/variant dropdowns from enabled providers
Replace model text inputs with Select dropdowns sourced from enabled
OpenCode providers, add thinking-level variant selection, and prevent
auto-enabling newly added OMO providers.
2026-02-08 23:07:49 +08:00
YoVinchen 5490a540f1 feat(omo): integrate Oh My OpenCode profile management into Provider system
Adds full-stack OMO support: backend config read/write/import, OMO-specific
provider CRUD with exclusive switching, frontend profile editor with
agent/category/model configuration, global config management, and i18n support.
2026-02-08 19:27:19 +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
Jason e5fea048a1 fix(ci): add xdg-utils for ARM64 AppImage and suppress dead_code warnings
- Add xdg-utils dependency for xdg-mime binary required by AppImage bundler
- Remove unused McpStatus struct from gemini_mcp.rs (duplicate of claude_mcp.rs)
- Add #![allow(dead_code)] to proxy models reserved for future type-safe API
2026-01-31 21:26:24 +08:00
Jason b8bd1d30d9 docs(changelog): add Linux ARM64 and error display fix to v3.10.3 2026-01-31 20:46:20 +08:00
Jason 4abf259a6d feat(ci): add Linux ARM64 build support
- Add ubuntu-22.04-arm runner to build matrix
- Rename Linux artifacts with architecture suffix (x86_64/arm64)
- Update pnpm cache key with runner.arch to avoid cross-arch pollution
- Add linux-aarch64 platform to latest.json for Tauri updater
2026-01-31 20:38:18 +08:00
Jason faa82a5b86 fix(mutations): use extractErrorMessage for complete error display
Provider add/update/delete mutations were using error.message directly,
which doesn't extract Tauri invoke errors properly. Now using the same
extractErrorMessage pattern as the rest of the codebase.
2026-01-31 20:15:39 +08:00
Jason b5b45c2703 chore: bump version to 3.10.3
- Update version in package.json, Cargo.toml, and tauri.conf.json
- Add missing changelog entries for OpenCode API key link and AICodeMirror preset
- Fix Prettier formatting for new hook files
2026-01-31 17:29:09 +08:00
Jason bd8a323600 feat(opencode): add API key link support for OpenCode provider form
Enable API key link feature for OpenCode app, allowing users to access
provider websites for key registration directly from the form.
2026-01-31 17:16:36 +08:00
Jason 151e43a808 feat(providers): add AICodeMirror partner preset for all apps
Register AICodeMirror icon, add i18n partner promotion texts (zh/en/ja),
and fix trailing whitespace in codexProviderPresets.
2026-01-31 16:05:50 +08:00
Jason 9d2bf08fe0 docs(changelog): release v3.10.3
Add changelog entry for v3.10.3 feature release including:
- API format selector and presets
- Pricing config enhancement
- Skills ZIP install
- Preferred terminal selection
- Silent startup option
- OpenCode environment check and directory sync
- NVIDIA NIM and n1n.ai presets
- Multiple bug fixes for Codex, proxy URL, tray menu, etc.
2026-01-30 23:18:22 +08:00
Jason 05c21e016f feat(settings): add OpenCode support to environment check and one-click install
- Add OpenCode version detection with Go path scanning
- Add GitHub Releases API for fetching latest OpenCode version
- Add OpenCode install command to one-click install section
- Update i18n hints to include OpenCode across all locales
- Fix SettingsPage indentation formatting
2026-01-30 22:23:18 +08:00
Jason 57713dd336 feat(claude): show proxy hint when switching to OpenAI Chat format provider
Display an info toast when switching to a provider that uses OpenAI Chat
format (apiFormat === "openai_chat"), reminding users to enable the proxy
service. Move toast logic from mutation to useProviderActions for better
control over notification content based on provider properties.
2026-01-30 16:40:41 +08:00
Jason 1ed122a8bd refactor(proxy): remove DeepSeek max_tokens clamp from transform layer
The max_tokens restriction was too aggressive and should be handled
upstream or by the provider itself. Simplify anthropic_to_openai by
removing provider parameter since model mapping is already done by
proxy::model_mapper.
2026-01-30 16:40:41 +08:00
Jason 78e341ccb9 feat(providers): add NVIDIA NIM preset for Claude and OpenCode
- Add NVIDIA NIM provider preset with API configuration
- Add nvidia.svg icon and register in icon system
- Add nvidia metadata with keywords and default color (#74B71B)
2026-01-30 16:40:41 +08:00
Jason 162800e18e feat(claude): add apiFormat support for provider presets
Allow preset providers to specify API format (anthropic or openai_chat),
enabling third-party proxy services that use OpenAI Chat Completions format.
2026-01-30 16:40:41 +08:00
Jason 065d5db843 fix(proxy): improve URL building and remove redundant model mapping
- Add model parameter to request logs for better debugging
- Fix duplicate /v1/v1 in URL when both base_url and endpoint have version
- Extend ?beta=true parameter to /v1/chat/completions endpoint
- Remove model mapping from transform layer (now handled by model_mapper)
- Add DeepSeek max_tokens clamping (1-8192 range)
2026-01-30 16:40:41 +08:00
Jason 70a18c1141 refactor(claude): migrate api_format from settings_config to meta
Move api_format storage from settings_config to ProviderMeta to prevent
polluting ~/.claude/settings.json when switching providers.

- Add api_format field to ProviderMeta (Rust + TypeScript)
- Update ProviderForm to read/write apiFormat from meta
- Maintain backward compatibility for legacy settings_config.api_format
  and openrouter_compat_mode fields (read-only fallback)
- Strip api_format from settings_config before writing to live config
2026-01-30 16:40:41 +08:00
Jason 964767ebaf fix(i18n): update apiFormatOpenAIChat label to mention proxy requirement
Change label from "Requires conversion" to "Requires proxy" for clarity:
- zh: "需转换" → "需开启代理"
- en: "Requires conversion" → "Requires proxy"
- ja: "変換が必要" → "プロキシが必要"
2026-01-30 16:40:41 +08:00
Jason 0c25687e09 fix(claude): improve backward compatibility for openrouter_compat_mode
Extend backward compatibility support for legacy openrouter_compat_mode field:
- Support number type (1 = enabled, 0 = disabled)
- Support string type ("true"/"1" = enabled)
- Add corresponding test cases for number and string types
2026-01-30 16:40:41 +08:00
Jason 81b975c47c feat(claude): add API format selector for third-party providers
Replace the OpenRouter-specific compatibility toggle with a generic
API format selector that allows all Claude providers to choose between:

- Anthropic Messages (native): Direct passthrough, no conversion
- OpenAI Chat Completions: Enables Anthropic ↔ OpenAI format conversion

Changes:
- Add ClaudeApiFormat type ("anthropic" | "openai_chat") to types.ts
- Replace openRouterCompatToggle with apiFormat dropdown in ClaudeFormFields
- Update ProviderForm to manage apiFormat state via settingsConfig.api_format
- Refactor claude.rs: add get_api_format() method, update needs_transform()
- Maintain backward compatibility with legacy openrouter_compat_mode field
- Update i18n translations (zh, en, ja)
2026-01-30 16:40:41 +08:00
n1n.ai e44423c307 feat: add n1n.ai provider preset (#667)
Co-authored-by: n1n <team@n1n.ai>
2026-01-30 12:45:01 +08:00
Jason 08d9bb4cab style(dao): format proxy tests with cargo fmt 2026-01-29 10:09:45 +08:00
Jason 987fc46e06 feat(skills): add install from ZIP file feature
- Add open_zip_file_dialog command for selecting ZIP files
- Add install_from_zip service method with recursive skill scanning
- Add install_skills_from_zip Tauri command
- Add frontend API methods and useInstallSkillsFromZip hook
- Add "Install from ZIP" button in Skills management page
- Support local skill ID format: local:{directory}
- Add i18n translations for new feature and error messages
2026-01-29 10:09:45 +08:00
Jason e3d335be2d feat(settings): prioritize native install path for Claude Code detection
- Move ~/.local/bin to first position in version scan search paths
- Update one-click install commands to use official native installation
  (curl script) instead of npm for Claude Code
2026-01-29 10:09:45 +08:00
Andrew Leng 0dd823ae3a fix(codex): fix 404 errors and connection timeout with custom base_url (#760)
* fix(proxy): fix Codex 404 errors with custom base_url prefixes

- handlers.rs:268: Remove hardcoded /v1 prefix in Codex forwarding
- codex.rs:140: Only add /v1 for origin-only base_urls, dedupe /v1/v1
- stream_check.rs:364: Try /responses first, fallback to /v1/responses
- provider.rs:427: Don't force /v1 for custom prefix base_urls

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

* fix(codex): always add /v1 for custom prefix base_urls

Changed logic to always add /v1 prefix unless base_url already ends with /v1.
This fixes 504 timeout errors with relay services that expect /v1 in the path.

- Most relay services follow OpenAI standard format: /v1/responses
- Users can opt-out by adding /v1 to their base_url configuration
- Updated test case to reflect new behavior

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

* fix(proxy): allow system proxy on localhost with different ports

- Only bypass system proxy if it points to CC Switch's own port (15721)
- Allow other localhost proxies (e.g., Clash on 7890) to be used
- Add INFO level logging for request URLs to aid debugging

This fixes connection timeout issues when using local proxy tools.

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

* fix(codex): don't add /v1 for custom prefix base_urls

Reverted logic to not add /v1 for base_urls with custom prefixes.
Many relay services use custom paths without /v1.

- Pure origin (e.g., https://api.openai.com) → adds /v1
- With /v1 (e.g., https://api.openai.com/v1) → no change
- Custom prefix (e.g., https://example.com/openai) → no /v1

This fixes 404 errors with relay services that don't use /v1 in their paths.

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

* fix(proxy): use dynamic port for system proxy detection

Instead of hardcoding port 15721, now uses the actual configured
listen_port from proxy settings.

- Added set_proxy_port() to update the port when proxy server starts
- Added get_proxy_port() to retrieve current port for detection
- Updated server.rs to call set_proxy_port() on startup
- Updated tests to reflect new behavior

This allows users to change the proxy port in settings without
breaking the system proxy detection logic.

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

* fix(proxy): change default proxy port from 15721 to 5000

Update the default fallback port in get_proxy_port() from 15721 to 5000
to match the project's standard default port configuration.

Also updated test cases to use port 5000 consistently.

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

* fix(proxy): revert default port back to 15721

Revert the default fallback port in get_proxy_port() from 5000 back to 15721
to align with the project's updated default port configuration.

Also updated test cases to use port 15721 consistently.

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

---------

Co-authored-by: ozbombor <ozbombor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:10:17 +08:00
方程 164635f638 style(tray): 简化标题标签并优化菜单分隔符 (#796)
Co-authored-by: franco <1787003204@q.comq>
2026-01-28 22:53:38 +08:00
makoMako fcb5163710 fix(settings): correct Gemini default visibility to true (#818)
Frontend fallback value for visibleApps had gemini: false, which was
inconsistent with backend default (gemini: true). This caused Gemini
to be hidden by default on first install.

Fixes #817

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:35:10 +08:00
Jason 3095bf8e5c chore(presets): upgrade Kimi/Moonshot providers to k2.5 model
- Update Moonshot preset models from kimi-k2-thinking to kimi-k2.5
- Update OpenCode Kimi preset name and model to k2.5
- Remove explicit model config from Kimi preset (use API defaults)
2026-01-27 23:38:34 +08:00
Jason a48502235c docs(sponsors): add AICodeMirror and reorder sponsor list
- Add AICodeMirror as new sponsor with logo
- Sync changes across all README languages (EN/ZH/JA)
2026-01-27 22:51:22 +08:00
Jason c74f801d66 fix(settings): correct footer layout in advanced settings tab
- Establish proper flexbox height chain from App to SettingsPage
- Move save button to fixed footer outside scrollable area
- Align footer styling with FullScreenPanel for consistency
2026-01-27 10:46:27 +08:00
Dex Miller 785e1b5add Feat/pricing config enhancement (#781)
* feat(db): add pricing config fields to proxy_config table

- Add default_cost_multiplier field per app type
- Add pricing_model_source field (request/response)
- Add request_model field to proxy_request_logs table
- Implement schema migration v5

* feat(api): add pricing config commands and provider meta fields

- Add get/set commands for default cost multiplier
- Add get/set commands for pricing model source
- Extend ProviderMeta with cost_multiplier and pricing_model_source
- Register new commands in Tauri invoke handler

* fix(proxy): apply cost multiplier to total cost only

- Move multiplier calculation from per-item to total cost
- Add resolve_pricing_config for provider-level override
- Include request_model and cost_multiplier in usage logs
- Return new fields in get_request_logs API

* feat(ui): add pricing config UI and usage log enhancements

- Add pricing config section to provider advanced settings
- Refactor PricingConfigPanel to compact table layout
- Display all three apps (Claude/Codex/Gemini) in one view
- Add multiplier column and request model display to logs
- Add frontend API wrappers for pricing config

* feat(i18n): add pricing config translations

- Add zh/en/ja translations for pricing defaults config
- Add translations for multiplier, requestModel, responseModel
- Add provider pricing config translations

* fix(pricing): align backfill cost calculation with real-time logic

- Fix backfill to deduct cache_read_tokens from input (avoid double billing)
- Apply multiplier only to total cost, not to each item
- Add multiplier display in request detail panel with i18n support
- Use AppError::localized for backend error messages
- Fix init_proxy_config_rows to use per-app default values
- Fix silent failure in set_default_cost_multiplier/set_pricing_model_source
- Add clippy allow annotation for test mutex across await

* style: format code with cargo fmt and prettier

* fix(tests): correct error type assertions in proxy DAO tests

The tests expected AppError::InvalidInput but the DAO functions use
AppError::localized() which returns AppError::Localized variant.
Updated assertions to match the correct error type with key validation.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-27 10:43:05 +08:00
136 changed files with 10051 additions and 2051 deletions
+27 -16
View File
@@ -20,6 +20,8 @@ jobs:
include:
- os: windows-2022
- os: ubuntu-22.04
- os: ubuntu-22.04-arm
arch: arm64
- os: macos-14
steps:
@@ -57,7 +59,8 @@ jobs:
rpm \
flatpak \
flatpak-builder \
elfutils
elfutils \
xdg-utils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -85,8 +88,8 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-store-
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
@@ -256,10 +259,11 @@ jobs:
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
ARCH="${{ matrix.arch || 'x86_64' }}"
# Updater artifact: AppImage(含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
echo "AppImage copied: $NEW_APPIMAGE"
@@ -269,18 +273,16 @@ jobs:
# 额外上传 .deb(用于手动安装,不参与 Updater)
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
cp "$DEB" "release-assets/$NEW_DEB"
echo "Deb package copied: $NEW_DEB"
cp "$DEB" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.deb"
echo "Deb package copied: CC-Switch-${VERSION}-Linux-${ARCH}.deb"
else
echo "No .deb found (optional)"
fi
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
if [ -n "$RPM" ]; then
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
cp "$RPM" "release-assets/$NEW_RPM"
echo "RPM package copied: $NEW_RPM"
cp "$RPM" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
echo "RPM package copied: CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
else
echo "No .rpm found (optional)"
fi
@@ -312,7 +314,8 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
@@ -360,7 +363,8 @@ jobs:
# 初始化空平台映射
mac_url=""; mac_sig=""
win_url=""; win_sig=""
linux_url=""; linux_sig=""
linux_x64_url=""; linux_x64_sig=""
linux_arm64_url=""; linux_arm64_sig=""
shopt -s nullglob
for sig in dl/*.sig; do
base=${sig%.sig}
@@ -371,8 +375,10 @@ jobs:
*.tar.gz)
# 视为 macOS updater artifact
mac_url="$url"; mac_sig="$sig_content";;
*.AppImage|*.appimage)
linux_url="$url"; linux_sig="$sig_content";;
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
*.msi|*.exe)
win_url="$url"; win_sig="$sig_content";;
esac
@@ -399,9 +405,14 @@ jobs:
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
first=0
fi
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
echo " \"linux-x86_64\": {\"signature\": \"$linux_x64_sig\", \"url\": \"$linux_x64_url\"}"
first=0
fi
if [ -n "$linux_arm64_url" ] && [ -n "$linux_arm64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-aarch64\": {\"signature\": \"$linux_arm64_sig\", \"url\": \"$linux_arm64_url\"}"
first=0
fi
echo ' }'
+1 -1
View File
@@ -8,7 +8,7 @@ release/
*.tsbuildinfo
.npmrc
CLAUDE.md
AGENTS.md
# AGENTS.md
GEMINI.md
/.claude
/.codex
+53
View File
@@ -7,6 +7,59 @@ 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
### Feature Release
This release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.
### Added
- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages
- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)
- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format
- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services
- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy
- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)
- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support
- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)
- **Silent Startup**: Option to prevent window popup on launch (#713)
- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases
- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support
- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon
- **n1n.ai Preset**: New provider preset (#667)
- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon
- **Linux ARM64**: CI build support for Linux ARM64 architecture
### Changed
- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json
- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer
- **Terminal Functions**: Consolidate redundant terminal launch functions
- **Home Dir Utility**: Consolidate get_home_dir into single public function
- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model
### Fixed
- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)
- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint
- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types
- **Gemini Visibility**: Correct Gemini default visibility to true (#818)
- **Footer Layout**: Correct footer layout in advanced settings tab
- **Claude Code Detection**: Prioritize native install path for detection
- **Tray Menu**: Simplify title labels and optimize menu separators (#796)
- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)
- **Windows Tests**: Stabilize test environment (#644)
- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement
- **Error Display**: Use extractErrorMessage for complete error display in mutations
- **Sponsors**: Add AICodeMirror and reorder sponsor list
---
## [3.10.2] - 2026-01-24
+13 -2
View File
@@ -33,8 +33,9 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<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>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
@@ -42,6 +43,16 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<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
+13 -2
View File
@@ -33,8 +33,9 @@
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<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>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
@@ -42,6 +43,16 @@
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<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>
## スクリーンショット
+14 -2
View File
@@ -31,6 +31,18 @@
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
@@ -38,8 +50,8 @@
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
<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: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,162 @@
/**
* 统一供应商(Universal Provider)预设配置
*
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
* 适用于 NewAPI 等支持多种协议的 API 网关。
*/
import type {
UniversalProvider,
UniversalProviderApps,
UniversalProviderModels,
} from "@/types";
/**
* 统一供应商预设接口
*/
export interface UniversalProviderPreset {
/** 预设名称 */
name: string;
/** 供应商类型标识 */
providerType: string;
/** 默认启用的应用 */
defaultApps: UniversalProviderApps;
/** 默认模型配置 */
defaultModels: UniversalProviderModels;
/** 网站链接 */
websiteUrl?: string;
/** 图标名称 */
icon?: string;
/** 图标颜色 */
iconColor?: string;
/** 描述 */
description?: string;
/** 是否为自定义模板(允许用户完全自定义) */
isCustomTemplate?: boolean;
}
/**
* NewAPI 默认模型配置
*/
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-sonnet-4-20250514",
haikuModel: "claude-haiku-4-20250514",
sonnetModel: "claude-sonnet-4-20250514",
opusModel: "claude-sonnet-4-20250514",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-2.5-pro",
},
};
const N1N_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-3-5-sonnet-20240620",
haikuModel: "claude-3-haiku-20240307",
sonnetModel: "claude-3-5-sonnet-20240620",
opusModel: "claude-3-opus-20240229",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-1.5-pro-latest",
},
};
/**
* 统一供应商预设列表
*/
export const universalProviderPresets: UniversalProviderPreset[] = [
{
name: "n1n.ai",
providerType: "n1n",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: N1N_DEFAULT_MODELS,
websiteUrl: "https://n1n.ai",
icon: "openai",
iconColor: "#000000",
description:
"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台",
},
{
name: "NewAPI",
providerType: "newapi",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
websiteUrl: "https://www.newapi.pro",
icon: "newapi",
iconColor: "#00A67E",
description:
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
},
{
name: "自定义网关",
providerType: "custom_gateway",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
icon: "openai",
iconColor: "#6366F1",
description: "自定义配置的 API 网关",
isCustomTemplate: true,
},
];
/**
* 根据预设创建统一供应商
*/
export function createUniversalProviderFromPreset(
preset: UniversalProviderPreset,
id: string,
baseUrl: string,
apiKey: string,
customName?: string,
): UniversalProvider {
return {
id,
name: customName || preset.name,
providerType: preset.providerType,
apps: { ...preset.defaultApps },
baseUrl,
apiKey,
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
websiteUrl: preset.websiteUrl,
icon: preset.icon,
iconColor: preset.iconColor,
createdAt: Date.now(),
};
}
/**
* 获取预设的显示名称(用于 UI)
*/
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
return preset.name;
}
/**
* 根据类型查找预设
*/
export function findPresetByType(
providerType: string,
): UniversalProviderPreset | undefined {
return universalProviderPresets.find((p) => p.providerType === providerType);
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.10.2",
"version": "3.10.3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -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 视图)
- 会话清理/归档(磁盘管理)
---
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.10.2"
version = "3.10.3"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.10.2"
version = "3.10.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+13 -24
View File
@@ -9,7 +9,6 @@ use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(config::get_claude_config_status())
@@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
}
}
/// 获取 Claude Code 配置文件路径
#[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -82,7 +79,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -104,7 +100,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: AppHandle,
@@ -136,14 +131,12 @@ pub async fn pick_directory(
}
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
let config_path = config::get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
@@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
@@ -171,13 +163,11 @@ pub async fn get_claude_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
@@ -195,7 +185,6 @@ pub async fn set_claude_common_config_snippet(
Ok(())
}
/// 获取通用配置片段(统一接口)
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
@@ -207,25 +196,19 @@ pub async fn get_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" => {
// 验证 JSON 格式
"claude" | "gemini" | "omo" => {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
// 注意:TOML 验证较为复杂,暂时跳过
}
"codex" => {}
_ => {}
}
}
@@ -240,14 +223,20 @@ pub async fn set_common_config_snippet(
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
if app_type == "omo"
&& state
.db
.get_current_omo_provider("opencode")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(state.inner())
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
+14
View File
@@ -109,3 +109,17 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
Ok(result.map(|p| p.to_string()))
}
/// 打开 ZIP 文件选择对话框
#[tauri::command]
pub async fn open_zip_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("ZIP", &["zip"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}
+55 -5
View File
@@ -89,7 +89,7 @@ pub struct ToolVersion {
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let tools = vec!["claude", "codex", "gemini", "opencode"];
let mut results = Vec::new();
// 使用全局 HTTP 客户端(已包含代理配置)
@@ -116,6 +116,7 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
_ => None,
};
@@ -148,6 +149,29 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
/// Helper function to fetch latest version from GitHub releases
async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
match client
.get(&url)
.header("User-Agent", "cc-switch")
.header("Accept", "application/vnd.github+json")
.send()
.await
{
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.strip_prefix('v').unwrap_or(s).to_string())
} else {
None
}
}
Err(_) => None,
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
@@ -224,7 +248,7 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini"].contains(&tool),
["claude", "codex", "gemini", "opencode"].contains(&tool),
"unexpected tool name: {tool}"
);
@@ -295,11 +319,12 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
let home = dirs::home_dir().unwrap_or_default();
// 常见的 npm 全局安装路径
// 常见的安装路径(原生安装优先)
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".local/bin"), // Native install (official recommended)
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join("n/bin"), // n version manager
home.join(".volta/bin"), // Volta package manager
];
#[cfg(target_os = "macos")]
@@ -348,6 +373,14 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
// 添加 Go 路径支持 (opencode 使用 go install 安装)
if tool == "opencode" {
search_paths.push(home.join("go/bin")); // go install 默认路径
if let Ok(gopath) = std::env::var("GOPATH") {
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
}
}
// 在每个路径中查找工具
for path in &search_paths {
let tool_path = if cfg!(target_os = "windows") {
@@ -405,6 +438,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
_ => None,
}?;
@@ -619,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
};
@@ -721,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);
@@ -921,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())
}
+4
View File
@@ -8,10 +8,12 @@ mod global_proxy;
mod import_export;
mod mcp;
mod misc;
mod omo;
mod plugin;
mod prompt;
mod provider;
mod proxy;
mod session_manager;
mod settings;
pub mod skill;
mod stream_check;
@@ -25,10 +27,12 @@ pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use omo::*;
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::*;
+50
View File
@@ -0,0 +1,50 @@
use tauri::State;
use crate::services::omo::OmoLocalFileData;
use crate::services::OmoService;
use crate::store::AppState;
#[tauri::command]
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
OmoService::read_local_file().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
let provider = state
.db
.get_current_omo_provider("opencode")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
#[tauri::command]
pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
for (id, p) in &providers {
if p.category.as_deref() == Some("omo") {
state
.db
.clear_omo_provider_current("opencode", id)
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
Ok(count)
}
+6 -47
View File
@@ -8,7 +8,6 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee
use crate::store::AppState;
use std::str::FromStr;
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
@@ -18,14 +17,12 @@ pub fn get_providers(
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 获取当前供应商ID
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 添加供应商
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
@@ -36,7 +33,6 @@ pub fn add_provider(
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 更新供应商
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
@@ -47,7 +43,6 @@ pub fn update_provider(
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 删除供应商
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
@@ -60,17 +55,18 @@ pub fn delete_provider(
.map_err(|e| e.to_string())
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(app_type, &id)
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
ProviderService::switch(state, app_type, id)
}
@@ -108,14 +104,12 @@ pub fn import_default_config_test_hook(
import_default_config_internal(state, app_type)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
}
/// 查询供应商用量
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
@@ -129,7 +123,6 @@ pub async fn queryProviderUsage(
.map_err(|e| e.to_string())
}
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
@@ -162,14 +155,12 @@ pub async fn testUsageScript(
.map_err(|e| e.to_string())
}
/// 读取当前生效的配置内容
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
@@ -180,7 +171,6 @@ pub async fn test_api_endpoints(
.map_err(|e| e.to_string())
}
/// 获取自定义端点列表
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
@@ -192,7 +182,6 @@ pub fn get_custom_endpoints(
.map_err(|e| e.to_string())
}
/// 添加自定义端点
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
@@ -205,7 +194,6 @@ pub fn add_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 删除自定义端点
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
@@ -218,7 +206,6 @@ pub fn remove_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 更新端点最后使用时间
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
@@ -231,7 +218,6 @@ pub fn update_endpoint_last_used(
.map_err(|e| e.to_string())
}
/// 更新多个供应商的排序
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
@@ -242,24 +228,16 @@ pub fn update_providers_sort_order(
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
@@ -270,7 +248,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
@@ -278,7 +255,6 @@ pub fn get_universal_providers(
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
@@ -287,7 +263,6 @@ pub fn get_universal_provider(
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
@@ -298,13 +273,11 @@ pub fn upsert_universal_provider(
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
@@ -314,13 +287,11 @@ pub fn delete_universal_provider(
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
@@ -330,29 +301,17 @@ pub fn sync_universal_provider(
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
+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)
}
+13
View File
@@ -249,3 +249,16 @@ pub fn remove_skill_repo(
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 从 ZIP 文件安装 Skills
#[tauri::command]
pub fn install_skills_from_zip(
file_path: String,
current_app: String,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
let app_type = parse_app_type(&current_app)?;
let path = std::path::Path::new(&file_path);
SkillService::install_from_zip(&app_state.db, path, &app_type).map_err(|e| e.to_string())
}
+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
}
/// 获取应用配置文件路径
+2
View File
@@ -4,6 +4,7 @@
pub mod failover;
pub mod mcp;
pub mod omo;
pub mod prompts;
pub mod providers;
pub mod proxy;
@@ -15,3 +16,4 @@ pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
pub use omo::OmoGlobalConfig;
+73
View File
@@ -0,0 +1,73 @@
use crate::database::Database;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoGlobalConfig {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sisyphus_agent: Option<serde_json::Value>,
#[serde(default)]
pub disabled_agents: Vec<String>,
#[serde(default)]
pub disabled_mcps: Vec<String>,
#[serde(default)]
pub disabled_hooks: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_task: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_automation_engine: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub other_fields: Option<serde_json::Value>,
pub updated_at: String,
}
impl Default for OmoGlobalConfig {
fn default() -> Self {
Self {
id: "global".to_string(),
schema_url: None,
sisyphus_agent: None,
disabled_agents: vec![],
disabled_mcps: vec![],
disabled_hooks: vec![],
disabled_skills: vec![],
lsp: None,
experimental: None,
background_task: None,
browser_automation_engine: None,
claude_code: None,
other_fields: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
}
impl Database {
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting("common_config_omo")?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
None => Ok(OmoGlobalConfig::default()),
}
}
pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> {
let json_str = serde_json::to_string(config)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
self.set_setting("common_config_omo", &json_str)?;
Ok(())
}
}
+133 -24
View File
@@ -1,7 +1,3 @@
//! 供应商数据访问对象
//!
//! 提供供应商(Provider)的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
@@ -9,8 +5,18 @@ use indexmap::IndexMap;
use rusqlite::params;
use std::collections::HashMap;
type OmoProviderRow = (
String,
String,
String,
Option<String>,
Option<i64>,
Option<usize>,
Option<String>,
String,
);
impl Database {
/// 获取指定应用类型的所有供应商
pub fn get_all_providers(
&self,
app_type: &str,
@@ -66,7 +72,6 @@ impl Database {
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
provider.id = id.clone();
// 加载 endpoints
let mut stmt_endpoints = conn.prepare(
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -103,7 +108,6 @@ impl Database {
Ok(providers)
}
/// 获取当前激活的供应商 ID
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
@@ -123,7 +127,6 @@ impl Database {
}
}
/// 根据 ID 获取单个供应商
pub fn get_provider_by_id(
&self,
id: &str,
@@ -174,21 +177,15 @@ impl Database {
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta:取出 endpoints 以便单独处理
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
@@ -202,7 +199,6 @@ impl Database {
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
tx.execute(
"UPDATE providers SET
name = ?1,
@@ -241,7 +237,6 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
@@ -268,7 +263,6 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
@@ -283,7 +277,6 @@ impl Database {
Ok(())
}
/// 删除供应商
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
@@ -294,21 +287,18 @@ impl Database {
Ok(())
}
/// 设置当前供应商
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 重置所有为 0
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的当前供应商
tx.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
@@ -319,7 +309,6 @@ impl Database {
Ok(())
}
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
pub fn update_provider_settings_config(
&self,
app_type: &str,
@@ -341,7 +330,6 @@ impl Database {
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
app_type: &str,
@@ -357,7 +345,6 @@ impl Database {
Ok(())
}
/// 移除自定义端点
pub fn remove_custom_endpoint(
&self,
app_type: &str,
@@ -372,4 +359,126 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn set_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let updated = tx
.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
if updated != 1 {
return Err(AppError::Database(format!(
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
)));
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn is_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
match conn.query_row(
"SELECT is_current FROM providers
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
|row| row.get(0),
) {
Ok(is_current) => Ok(is_current),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
pub fn clear_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET is_current = 0
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
FROM providers
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
LIMIT 1",
params![app_type],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
},
);
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
match row_data {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(AppError::Database(e.to_string())),
};
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
))
})?;
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
crate::provider::ProviderMeta::default()
} else {
serde_json::from_str(&meta_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse OMO provider meta (provider_id={id}): {e}"
))
})?
};
Ok(Some(Provider {
id,
name,
settings_config,
website_url: None,
category,
created_at,
sort_index,
notes,
meta: Some(meta),
icon: None,
icon_color: None,
in_failover_queue: false,
}))
}
}
+14 -2
View File
@@ -876,7 +876,13 @@ mod tests {
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(err, AppError::Localized { key: "error.invalidMultiplier", .. }));
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidMultiplier",
..
}
));
Ok(())
}
@@ -897,7 +903,13 @@ mod tests {
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(err, AppError::Localized { key: "error.invalidPricingMode", .. }));
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidPricingMode",
..
}
));
Ok(())
}
+1
View File
@@ -33,6 +33,7 @@ mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
pub use dao::OmoGlobalConfig;
use crate::config::get_app_config_dir;
use crate::error::AppError;
+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)
}
// --- 辅助方法 ---
+2
View File
@@ -52,6 +52,8 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("OMO 配置文件不存在")]
OmoConfigNotFound,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
-9
View File
@@ -1,4 +1,3 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
@@ -7,14 +6,6 @@ use crate::config::atomic_write;
use crate::error::AppError;
use crate::gemini_config::get_gemini_settings_path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json
fn user_config_path() -> PathBuf {
get_gemini_settings_path()
+35
View File
@@ -21,6 +21,7 @@ mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod session_manager;
mod settings;
mod store;
mod tray;
@@ -502,6 +503,28 @@ pub fn run() {
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
}
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
.db
.get_all_providers("opencode")
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
.unwrap_or(false);
if !has_omo {
match crate::services::OmoService::import_from_local(&app_state) {
Ok(provider) => {
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO config from local: {e}");
}
}
}
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
@@ -850,6 +873,7 @@ pub fn run() {
commands::import_config_from_file,
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_file_dialog,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
@@ -879,6 +903,7 @@ pub fn run() {
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
commands::install_skills_from_zip,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
@@ -931,6 +956,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,
@@ -949,6 +978,12 @@ pub fn run() {
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// Window theme control
commands::set_window_theme,
commands::read_omo_local_file,
commands::get_current_omo_provider_id,
commands::get_omo_provider_count,
commands::disable_current_omo,
]);
let app = builder
+54 -71
View File
@@ -1,26 +1,3 @@
//! OpenCode 配置文件读写模块
//!
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json
//! {
//! "$schema": "https://opencode.ai/config.json",
//! "provider": {
//! "my-provider": {
//! "npm": "@ai-sdk/openai-compatible",
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
//! }
//! },
//! "mcp": {
//! "my-server": { "type": "local", "command": ["..."] }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::provider::OpenCodeProviderConfig;
@@ -29,52 +6,29 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenCode 配置目录
///
/// 默认路径: `~/.config/opencode/`
/// 可通过 settings.opencode_config_dir 覆盖
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.config/opencode
dirs::home_dir()
.map(|h| h.join(".config").join("opencode"))
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
}
/// 获取 OpenCode 配置文件路径
///
/// 返回 `~/.config/opencode/opencode.json`
pub fn get_opencode_config_path() -> PathBuf {
get_opencode_dir().join("opencode.json")
}
/// 获取 OpenCode 环境变量文件路径(如果存在)
///
/// 返回 `~/.config/opencode/.env`
#[allow(dead_code)]
pub fn get_opencode_env_path() -> PathBuf {
get_opencode_dir().join(".env")
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenCode 配置文件
///
/// 返回完整的配置 JSON 对象
pub fn read_opencode_config() -> Result<Value, AppError> {
let path = get_opencode_config_path();
if !path.exists() {
// Return empty config with schema
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
@@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
}
/// 写入 OpenCode 配置文件(原子写入)
///
/// 使用临时文件 + 重命名确保原子性
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
write_json_file(&path, config)?;
log::debug!("OpenCode config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -110,7 +55,6 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
// ============================================================================
// Provider Functions (Typed - using OpenCodeProviderConfig)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
@@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
}
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
@@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// MCP Functions
// ============================================================================
/// 获取所有 MCP 服务器配置
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置 MCP 服务器配置
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除 MCP 服务器配置
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -208,3 +137,57 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
if plugin_name.starts_with("oh-my-opencode")
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
arr.retain(|v| {
v.as_str()
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
}
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
if !already_exists {
arr.push(Value::String(plugin_name.to_string()));
}
}
None => {
config["plugin"] = json!([plugin_name]);
}
}
write_opencode_config(&config)
}
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
.map(|s| {
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.unwrap_or(true)
});
if arr.is_empty() {
config.as_object_mut().map(|obj| obj.remove("plugin"));
}
}
write_opencode_config(&config)
}
+59 -4
View File
@@ -230,6 +230,11 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
}
impl ProviderManager {
@@ -441,11 +446,18 @@ impl UniversalProvider {
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
// Codex/OpenAI 的 base_url 既可能是纯 origin(需要补 /v1),也可能包含自定义前缀(不应强行补版本
let base_trimmed = self.base_url.trim_end_matches('/');
let origin_only = match base_trimmed.split_once("://") {
Some((_scheme, rest)) => !rest.contains('/'),
None => !base_trimmed.contains('/'),
};
let codex_base_url = if base_trimmed.ends_with("/v1") {
base_trimmed.to_string()
} else if origin_only {
format!("{base_trimmed}/v1")
} else {
format!("{}/v1", self.base_url.trim_end_matches('/'))
base_trimmed.to_string()
};
// 生成 Codex 的 config.toml 内容
@@ -880,4 +892,47 @@ mod tests {
assert!(config.options.headers.is_none());
assert!(config.options.extra.is_empty());
}
#[test]
fn universal_codex_provider_origin_base_url_adds_v1() {
let mut p = UniversalProvider::new(
"id".to_string(),
"Test".to_string(),
"custom".to_string(),
"https://api.openai.com".to_string(),
"sk-test".to_string(),
);
p.apps.codex = true;
let provider = p.to_codex_provider().expect("should build codex provider");
let toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config should be a toml string");
assert!(toml.contains("base_url = \"https://api.openai.com/v1\""));
}
#[test]
fn universal_codex_provider_custom_prefix_does_not_force_v1() {
let mut p = UniversalProvider::new(
"id".to_string(),
"Test".to_string(),
"custom".to_string(),
"https://example.com/openai".to_string(),
"sk-test".to_string(),
);
p.apps.codex = true;
let provider = p.to_codex_provider().expect("should build codex provider");
let toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config should be a toml string");
assert!(toml.contains("base_url = \"https://example.com/openai\""));
assert!(!toml.contains("https://example.com/openai/v1"));
}
}
+5 -1
View File
@@ -666,7 +666,11 @@ impl RequestForwarder {
// 输出请求信息日志
let tag = adapter.name();
log::debug!("[{tag}] >>> 请求 URL: {url}");
let request_model = filtered_body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
+2 -2
View File
@@ -284,7 +284,7 @@ pub async fn handle_chat_completions(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/chat/completions",
"/chat/completions",
body,
headers,
ctx.get_providers(),
@@ -325,7 +325,7 @@ pub async fn handle_responses(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/responses",
"/responses",
body,
headers,
ctx.get_providers(),
+61 -6
View File
@@ -17,6 +17,33 @@ static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
/// 当前代理 URL(用于日志和状态查询)
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
/// CC Switch 代理服务器当前监听的端口
static CC_SWITCH_PROXY_PORT: OnceCell<RwLock<u16>> = OnceCell::new();
/// 设置 CC Switch 代理服务器的监听端口
///
/// 应在代理服务器启动时调用,以便系统代理检测能正确识别自己的端口
pub fn set_proxy_port(port: u16) {
if let Some(lock) = CC_SWITCH_PROXY_PORT.get() {
if let Ok(mut current_port) = lock.write() {
*current_port = port;
log::debug!("[GlobalProxy] Updated CC Switch proxy port to {port}");
}
} else {
let _ = CC_SWITCH_PROXY_PORT.set(RwLock::new(port));
log::debug!("[GlobalProxy] Initialized CC Switch proxy port to {port}");
}
}
/// 获取 CC Switch 代理服务器的监听端口
fn get_proxy_port() -> u16 {
CC_SWITCH_PROXY_PORT
.get()
.and_then(|lock| lock.read().ok())
.map(|port| *port)
.unwrap_or(15721) // 默认端口作为回退
}
/// 初始化全局 HTTP 客户端
///
/// 应在应用启动时调用一次。
@@ -258,9 +285,17 @@ fn proxy_points_to_loopback(value: &str) -> bool {
.unwrap_or(false)
}
// 检查是否指向 CC Switch 自己的代理端口
// 只有指向自己的代理才需要跳过,避免递归
fn is_cc_switch_proxy_port(port: Option<u16>) -> bool {
let cc_switch_port = get_proxy_port();
port == Some(cc_switch_port)
}
if let Ok(parsed) = url::Url::parse(value) {
if let Some(host) = parsed.host_str() {
return host_is_loopback(host);
// 只有当主机是 loopback 且端口是 CC Switch 的端口时才返回 true
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
}
return false;
}
@@ -268,7 +303,7 @@ fn proxy_points_to_loopback(value: &str) -> bool {
let with_scheme = format!("http://{value}");
if let Ok(parsed) = url::Url::parse(&with_scheme) {
if let Some(host) = parsed.host_str() {
return host_is_loopback(host);
return host_is_loopback(host) && is_cc_switch_proxy_port(parsed.port());
}
}
@@ -448,16 +483,30 @@ mod tests {
#[test]
fn test_proxy_points_to_loopback() {
assert!(proxy_points_to_loopback("http://127.0.0.1:7890"));
assert!(proxy_points_to_loopback("socks5://localhost:1080"));
assert!(proxy_points_to_loopback("127.0.0.1:7890"));
// 设置 CC Switch 代理端口为 15721(默认值)
set_proxy_port(15721);
// 只有指向 CC Switch 自己端口的 loopback 地址才返回 true
assert!(proxy_points_to_loopback("http://127.0.0.1:15721"));
assert!(proxy_points_to_loopback("socks5://localhost:15721"));
assert!(proxy_points_to_loopback("127.0.0.1:15721"));
// 其他 loopback 端口不应该被跳过(允许使用其他本地代理工具)
assert!(!proxy_points_to_loopback("http://127.0.0.1:7890"));
assert!(!proxy_points_to_loopback("socks5://localhost:1080"));
// 非 loopback 地址不应该被跳过
assert!(!proxy_points_to_loopback("http://192.168.1.10:7890"));
assert!(!proxy_points_to_loopback("http://192.168.1.10:15721"));
}
#[test]
fn test_system_proxy_points_to_loopback() {
let _guard = env_lock().lock().unwrap();
// 设置 CC Switch 代理端口
set_proxy_port(15721);
let keys = [
"HTTP_PROXY",
"http_proxy",
@@ -471,9 +520,15 @@ mod tests {
std::env::remove_var(key);
}
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
// 指向 CC Switch 端口的代理应该被跳过
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:15721");
assert!(system_proxy_points_to_loopback());
// 指向其他端口的本地代理不应该被跳过
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:7890");
assert!(!system_proxy_points_to_loopback());
// 非 loopback 地址不应该被跳过
std::env::set_var("HTTP_PROXY", "http://10.0.0.2:7890");
assert!(!system_proxy_points_to_loopback());
+162 -36
View File
@@ -1,11 +1,15 @@
//! Claude (Anthropic) Provider Adapter
//!
//! 支持透传模式和 OpenRouter 兼容模式
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
//!
//! ## API 格式
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
@@ -48,22 +52,52 @@ impl ClaudeAdapter {
false
}
/// 检测 OpenRouter 是否启用兼容模
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
if !self.is_openrouter(provider) {
return false;
/// 获取 API 格
///
/// 从 provider.meta.api_format 读取格式设置:
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
fn get_api_format(&self, provider: &Provider) -> &'static str {
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
if let Some(meta) = provider.meta.as_ref() {
if let Some(api_format) = meta.api_format.as_deref() {
return if api_format == "openai_chat" {
"openai_chat"
} else {
"anthropic"
};
}
}
// 2) Backward compatibility: legacy settings_config.api_format
if let Some(api_format) = provider
.settings_config
.get("api_format")
.and_then(|v| v.as_str())
{
return if api_format == "openai_chat" {
"openai_chat"
} else {
"anthropic"
};
}
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
let raw = provider.settings_config.get("openrouter_compat_mode");
match raw {
Some(serde_json::Value::Bool(enabled)) => *enabled,
let enabled = match raw {
Some(serde_json::Value::Bool(v)) => *v,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
// OpenRouter now supports Claude Code compatible API, default to passthrough
_ => false,
};
if enabled {
"openai_chat"
} else {
"anthropic"
}
}
@@ -218,15 +252,23 @@ impl ProviderAdapter for ClaudeAdapter {
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
let base = format!(
let mut base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
);
// 为 /v1/messages 端点添加 ?beta=true 参数
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
while base.contains("/v1/v1") {
base = base.replace("/v1/v1", "/v1");
}
// 为 Claude 相关端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
&& !endpoint.contains('?')
{
format!("{base}?beta=true")
} else {
base
@@ -253,21 +295,19 @@ impl ProviderAdapter for ClaudeAdapter {
}
}
fn needs_transform(&self, _provider: &Provider) -> bool {
// NOTE:
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 格式转换
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
self.is_openrouter_compat_enabled(_provider)
fn needs_transform(&self, provider: &Provider) -> bool {
// 根据 api_format 配置决定是否需要格式转换
// - "anthropic" (默认): 直接透传,无需转换
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
self.get_api_format(provider) == "openai_chat"
}
fn transform_request(
&self,
body: serde_json::Value,
provider: &Provider,
_provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
super::transform::anthropic_to_openai(body, provider)
super::transform::anthropic_to_openai(body)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
@@ -278,6 +318,7 @@ impl ProviderAdapter for ClaudeAdapter {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderMeta;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
@@ -297,6 +338,23 @@ mod tests {
}
}
fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Claude".to_string(),
settings_config: config,
website_url: None,
category: Some("claude".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: Some(meta),
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = ClaudeAdapter::new();
@@ -459,6 +517,7 @@ mod tests {
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
// Default: no transform (anthropic format) - no meta
let anthropic_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
@@ -466,29 +525,96 @@ mod tests {
}));
assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(!adapter.needs_transform(&openrouter_provider));
// Explicit anthropic format in meta: no transform
let explicit_anthropic = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
},
);
assert!(!adapter.needs_transform(&explicit_anthropic));
// OpenRouter provider with explicit compat mode enabled should transform
let openrouter_enabled = create_provider(json!({
// Legacy settings_config.api_format: openai_chat should enable transform
let legacy_settings_api_format = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"api_format": "openai_chat"
}));
assert!(adapter.needs_transform(&legacy_settings_api_format));
// Legacy openrouter_compat_mode: bool/number/string should enable transform
let legacy_openrouter_bool = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&openrouter_enabled));
assert!(adapter.needs_transform(&legacy_openrouter_bool));
let openrouter_disabled = create_provider(json!({
let legacy_openrouter_num = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"openrouter_compat_mode": false
"openrouter_compat_mode": 1
}));
assert!(!adapter.needs_transform(&openrouter_disabled));
assert!(adapter.needs_transform(&legacy_openrouter_num));
let legacy_openrouter_str = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"openrouter_compat_mode": "true"
}));
assert!(adapter.needs_transform(&legacy_openrouter_str));
// OpenAI Chat format in meta: needs transform
let openai_chat_provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
},
);
assert!(adapter.needs_transform(&openai_chat_provider));
// meta takes precedence over legacy settings_config fields
let meta_precedence_over_settings = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
},
"api_format": "openai_chat",
"openrouter_compat_mode": true
}),
ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
},
);
assert!(!adapter.needs_transform(&meta_precedence_over_settings));
// Unknown format in meta: default to anthropic (no transform)
let unknown_format = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
api_format: Some("unknown".to_string()),
..Default::default()
},
);
assert!(!adapter.needs_transform(&unknown_format));
}
}
+40 -3
View File
@@ -141,10 +141,33 @@ impl ProviderAdapter for CodexAdapter {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// OpenAI/Codex 的 base_url 可能是:
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
// - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接)
// 去除重复的 /v1/v1
if url.contains("/v1/v1") {
// 检查 base_url 是否已经包含 /v1
let already_has_v1 = base_trimmed.ends_with("/v1");
// 检查是否是纯 origin(没有路径部分)
let origin_only = match base_trimmed.split_once("://") {
Some((_scheme, rest)) => !rest.contains('/'),
None => !base_trimmed.contains('/'),
};
let mut url = if already_has_v1 {
// 已经有 /v1,直接拼接
format!("{base_trimmed}/{endpoint_trimmed}")
} else if origin_only {
// 纯 origin,添加 /v1
format!("{base_trimmed}/v1/{endpoint_trimmed}")
} else {
// 自定义前缀,不添加 /v1,直接拼接
format!("{base_trimmed}/{endpoint_trimmed}")
};
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
while url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
@@ -223,6 +246,20 @@ mod tests {
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_origin_adds_v1() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_custom_prefix_no_v1() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://example.com/openai", "/responses");
assert_eq!(url, "https://example.com/openai/responses");
}
#[test]
fn test_build_url_dedup_v1() {
let adapter = CodexAdapter::new();
@@ -2,6 +2,8 @@
//!
//! 用于 Anthropic Messages API 的请求/响应格式转换
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -2,6 +2,8 @@
//!
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
+14 -171
View File
@@ -3,77 +3,16 @@
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
/// 从 Provider 配置中获取模型映射
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
let env = provider.settings_config.get("env");
let model_lower = model.to_lowercase();
// 检测 thinking 参数
let has_thinking = body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if let Some(env) = env {
// 如果启用 thinking,优先使用推理模型
if has_thinking {
if let Some(m) = env
.get("ANTHROPIC_REASONING_MODEL")
.and_then(|v| v.as_str())
{
log::debug!("[Transform] 使用推理模型: {m}");
return m.to_string();
}
}
// 根据模型类型选择配置模型
if model_lower.contains("haiku") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("opus") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("sonnet") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
// 默认使用 ANTHROPIC_MODEL
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
return m.to_string();
}
}
model.to_string()
}
/// Anthropic 请求 → OpenAI 请求
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
let mut result = json!({});
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
let mapped_model = get_model_from_provider(model, provider, &body);
result["model"] = json!(mapped_model);
result["model"] = json!(model);
}
let mut messages = Vec::new();
@@ -381,45 +320,16 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
mod tests {
use super::*;
fn create_provider(env_config: Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: json!({"env": env_config}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_openrouter_provider() -> Provider {
create_provider(json!({
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
}))
}
#[test]
fn test_anthropic_to_openai_simple() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "claude-3-opus");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"], "Hello");
@@ -427,7 +337,6 @@ mod tests {
#[test]
fn test_anthropic_to_openai_with_system() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -435,7 +344,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
@@ -446,7 +355,6 @@ mod tests {
#[test]
fn test_anthropic_to_openai_with_tools() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
@@ -458,14 +366,13 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
@@ -478,7 +385,7 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
@@ -487,7 +394,6 @@ mod tests {
#[test]
fn test_anthropic_to_openai_tool_result() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
@@ -499,7 +405,7 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
@@ -563,78 +469,15 @@ mod tests {
}
#[test]
fn test_model_mapping_from_provider() {
let provider = create_openrouter_provider();
let body = json!({"model": "test"});
// sonnet 模型
assert_eq!(
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
"anthropic/claude-sonnet-4.5"
);
// haiku 模型
assert_eq!(
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
"anthropic/claude-haiku-4.5"
);
// opus 模型
assert_eq!(
get_model_from_provider("claude-opus-4-5", &provider, &body),
"anthropic/claude-opus-4.5"
);
}
#[test]
fn test_anthropic_to_openai_model_mapping() {
let provider = create_openrouter_provider();
fn test_model_passthrough() {
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
let input = json!({
"model": "claude-sonnet-4-5-20250929",
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
#[test]
fn test_thinking_parameter_detection() {
let mut provider = create_openrouter_provider();
// 添加推理模型配置
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "enabled"},
"messages": [{"role": "user", "content": "Solve this problem"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用推理模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
}
#[test]
fn test_thinking_parameter_disabled() {
let mut provider = create_openrouter_provider();
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用普通模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "gpt-4o");
}
}
+3
View File
@@ -98,6 +98,9 @@ impl ProxyServer {
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 更新全局代理端口,用于系统代理检测
crate::proxy::http_client::set_proxy_port(self.config.listen_port);
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
+3 -2
View File
@@ -1,4 +1,4 @@
use super::provider::ProviderService;
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
@@ -181,7 +181,8 @@ impl ConfigService {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&settings_path, &provider.settings_config)?;
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&settings_path, &settings)?;
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
+2
View File
@@ -2,6 +2,7 @@ pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod omo;
pub mod prompt;
pub mod provider;
pub mod proxy;
@@ -12,6 +13,7 @@ pub mod usage_stats;
pub use config::ConfigService;
pub use mcp::McpService;
pub use omo::OmoService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
+437
View File
@@ -0,0 +1,437 @@
use crate::config::write_json_file;
use crate::database::OmoGlobalConfig;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoLocalFileData {
pub agents: Option<Value>,
pub categories: Option<Value>,
pub other_fields: Option<Value>,
pub global: OmoGlobalConfig,
pub file_path: String,
pub last_modified: Option<String>,
}
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
pub struct OmoService;
impl OmoService {
fn config_path() -> PathBuf {
get_opencode_dir().join("oh-my-opencode.jsonc")
}
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
let config_path = Self::config_path();
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let cleaned = Self::strip_jsonc_comments(&content);
let parsed: Value = serde_json::from_str(&cleaned)
.map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?;
parsed
.as_object()
.cloned()
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
}
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
const KNOWN_KEYS: [&str; 13] = [
"$schema",
"agents",
"categories",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"disabled_skills",
"lsp",
"experimental",
"background_task",
"browser_automation_engine",
"claude_code",
];
let mut other = Map::new();
for (k, v) in obj {
if !KNOWN_KEYS.contains(&k.as_str()) {
other.insert(k.clone(), v.clone());
}
}
other
}
fn extract_string_array(val: &Value) -> Vec<String> {
val.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn merge_global_from_obj(obj: &Map<String, Value>, global: &mut OmoGlobalConfig) {
if let Some(v) = obj.get("$schema") {
global.schema_url = v.as_str().map(|s| s.to_string());
}
for (key, target) in [
("disabled_agents", &mut global.disabled_agents),
("disabled_mcps", &mut global.disabled_mcps),
("disabled_hooks", &mut global.disabled_hooks),
("disabled_skills", &mut global.disabled_skills),
] {
if let Some(v) = obj.get(key) {
*target = Self::extract_string_array(v);
}
}
for (key, target) in [
("sisyphus_agent", &mut global.sisyphus_agent),
("lsp", &mut global.lsp),
("experimental", &mut global.experimental),
("background_task", &mut global.background_task),
(
"browser_automation_engine",
&mut global.browser_automation_engine,
),
("claude_code", &mut global.claude_code),
] {
if let Some(v) = obj.get(key) {
*target = Some(v.clone());
}
}
}
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
if let Some(v) = value {
result.insert(key.to_string(), v.clone());
}
}
fn insert_string_array(result: &mut Map<String, Value>, key: &str, values: &[String]) {
if !values.is_empty() {
result.insert(
key.to_string(),
serde_json::to_value(values).unwrap_or(Value::Array(vec![])),
);
}
}
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
if let Some(Value::Object(map)) = value {
for (k, v) in map {
result.insert(k.clone(), v.clone());
}
}
}
pub fn delete_config_file() -> Result<(), AppError> {
let config_path = Self::config_path();
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("OMO config file deleted: {config_path:?}");
}
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
Ok(())
}
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
let global = state.db.get_omo_global_config()?;
let current_omo = state.db.get_current_omo_provider("opencode")?;
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").cloned();
let categories = p.settings_config.get("categories").cloned();
let other_fields = p.settings_config.get("otherFields").cloned();
let use_common_config = p
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.unwrap_or(true);
(agents, categories, other_fields, use_common_config)
});
let merged = Self::merge_config(&global, profile_data.as_ref());
let config_path = Self::config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
log::info!("OMO config written to {config_path:?}");
Ok(())
}
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
let mut result = Map::new();
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
if use_common_config {
if let Some(url) = &global.schema_url {
result.insert("$schema".to_string(), Value::String(url.clone()));
}
Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent);
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
Self::insert_opt_value(
&mut result,
"browser_automation_engine",
&global.browser_automation_engine,
);
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
}
if let Some((agents, categories, other_fields, _)) = profile_data {
Self::insert_opt_value(&mut result, "agents", agents);
Self::insert_opt_value(&mut result, "categories", categories);
Self::insert_object_entries(&mut result, other_fields.as_ref());
}
Value::Object(result)
}
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
let actual_path = Self::resolve_local_config_path()?;
Self::import_from_path(state, &actual_path)
}
fn import_from_path(
state: &AppState,
path: &std::path::Path,
) -> Result<crate::provider::Provider, AppError> {
let obj = Self::read_jsonc_object(path)?;
let mut settings = Map::new();
if let Some(agents) = obj.get("agents") {
settings.insert("agents".to_string(), agents.clone());
}
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields(&obj);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let mut global = state.db.get_omo_global_config()?;
Self::merge_global_from_obj(&obj, &mut global);
global.updated_at = chrono::Utc::now().to_rfc3339();
state.db.save_omo_global_config(&global)?;
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
let settings_config =
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
let provider = crate::provider::Provider {
id: provider_id,
name,
settings_config,
website_url: None,
category: Some("omo".to_string()),
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
};
state.db.save_provider("opencode", &provider)?;
state
.db
.set_omo_provider_current("opencode", &provider.id)?;
Self::write_config_to_file(state)?;
Ok(provider)
}
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path()?;
let metadata = std::fs::metadata(&actual_path).ok();
let last_modified = metadata
.and_then(|m| m.modified().ok())
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
let obj = Self::read_jsonc_object(&actual_path)?;
let agents = obj.get("agents").cloned();
let categories = obj.get("categories").cloned();
let other = Self::extract_other_fields(&obj);
let other_fields = if other.is_empty() {
None
} else {
Some(Value::Object(other))
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(&obj, &mut global);
Ok(OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path: actual_path.to_string_lossy().to_string(),
last_modified,
})
}
fn strip_jsonc_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escape = false;
while let Some(&c) = chars.peek() {
if in_string {
result.push(c);
chars.next();
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
in_string = false;
}
} else if c == '"' {
in_string = true;
result.push(c);
chars.next();
} else if c == '/' {
chars.next();
match chars.peek() {
Some('/') => {
chars.next();
while let Some(&nc) = chars.peek() {
if nc == '\n' {
break;
}
chars.next();
}
}
Some('*') => {
chars.next();
while let Some(nc) = chars.next() {
if nc == '*' {
if let Some(&'/') = chars.peek() {
chars.next();
break;
}
}
}
}
_ => {
result.push('/');
}
}
} else {
result.push(c);
chars.next();
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_jsonc_comments() {
let input = r#"{
// This is a comment
"key": "value", // inline comment
/* multi
line */
"key2": "val//ue"
}"#;
let result = OmoService::strip_jsonc_comments(input);
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["key"], "value");
assert_eq!(parsed["key2"], "val//ue");
}
#[test]
fn test_merge_config_empty() {
let global = OmoGlobalConfig::default();
let merged = OmoService::merge_config(&global, None);
assert!(merged.is_object());
}
#[test]
fn test_merge_config_with_profile() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
fn test_merge_config_without_common_config() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, false);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("$schema"));
assert!(!obj.contains_key("disabled_agents"));
assert!(obj.contains_key("agents"));
}
}
+14 -1
View File
@@ -19,6 +19,18 @@ use super::gemini_auth::{
};
use super::normalize_claude_models_in_value;
pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
let mut v = settings.clone();
if let Some(obj) = v.as_object_mut() {
// Internal-only fields - never write to Claude Code settings.json
obj.remove("api_format");
obj.remove("apiFormat");
obj.remove("openrouter_compat_mode");
obj.remove("openrouterCompatMode");
}
v
}
/// Live configuration snapshot for backup/restore
#[derive(Clone)]
#[allow(dead_code)]
@@ -97,7 +109,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
write_json_file(&path, &provider.settings_config)?;
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&path, &settings)?;
}
AppType::Codex => {
let obj = provider
+89 -14
View File
@@ -26,6 +26,7 @@ pub use live::{
};
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::write_live_snapshot;
// Internal re-exports
@@ -163,6 +164,12 @@ impl ProviderService {
// OpenCode uses additive mode - always write to live config
if matches!(app_type, AppType::OpenCode) {
// OMO providers use exclusive mode and write to dedicated config file.
if provider.category.as_deref() == Some("omo") {
// Do not auto-enable newly added OMO providers.
// Users must explicitly switch/apply an OMO provider to activate it.
return Ok(true);
}
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -196,6 +203,15 @@ impl ProviderService {
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
if provider.category.as_deref() == Some("omo") {
let is_omo_current = state
.db
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
if is_omo_current {
crate::services::OmoService::write_config_to_file(state)?;
}
return Ok(true);
}
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -241,6 +257,35 @@ impl ProviderService {
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// OpenCode uses additive mode - no current provider concept
if matches!(app_type, AppType::OpenCode) {
let is_omo = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
if is_omo {
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
let omo_count = state
.db
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
if omo_count <= 1 && was_current {
return Err(AppError::Message(
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
crate::services::OmoService::delete_config_file()?;
}
return Ok(());
}
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
// Also remove from live config
@@ -266,10 +311,32 @@ impl ProviderService {
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
/// but keep it available for future use.
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
pub fn remove_from_live_config(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
remove_opencode_provider_from_live(id)?;
let is_omo = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
if is_omo {
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
let still_has_current =
state.db.get_current_omo_provider("opencode")?.is_some();
if still_has_current {
crate::services::OmoService::write_config_to_file(state)?;
} else {
crate::services::OmoService::delete_config_file()?;
}
} else {
remove_opencode_provider_from_live(id)?;
}
}
// Future: add other additive mode apps here
_ => {
@@ -301,6 +368,11 @@ impl ProviderService {
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// OMO providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") {
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
@@ -372,25 +444,28 @@ impl ProviderService {
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
state.db.set_omo_provider_current(app_type.as_str(), id)?;
crate::services::OmoService::write_config_to_file(state)?;
return Ok(());
}
// Backfill: Backfill current live config to current provider
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
if let Some(current_id) = current_id {
if current_id != id {
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
match (current_id, matches!(app_type, AppType::OpenCode)) {
(Some(current_id), false) if current_id != id => {
// Only backfill when switching to a different provider.
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow.
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
}
}
_ => {}
}
// OpenCode uses additive mode - skip setting is_current (no such concept)
+2 -1
View File
@@ -1654,7 +1654,8 @@ impl ProxyService {
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
let path = get_claude_settings_path();
write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}"))
let settings = crate::services::provider::sanitize_claude_settings_for_live(config);
write_json_file(&path, &settings).map_err(|e| format!("写入 Claude 配置失败: {e}"))
}
fn read_codex_live(&self) -> Result<Value, String> {
+187
View File
@@ -1110,6 +1110,193 @@ impl SkillService {
Ok(())
}
// ========== 从 ZIP 文件安装 ==========
/// 从本地 ZIP 文件安装 Skills
///
/// 流程:
/// 1. 解压 ZIP 到临时目录
/// 2. 扫描目录查找包含 SKILL.md 的技能
/// 3. 复制到 SSOT 并保存到数据库
/// 4. 同步到当前应用目录
pub fn install_from_zip(
db: &Arc<Database>,
zip_path: &Path,
current_app: &AppType,
) -> Result<Vec<InstalledSkill>> {
// 解压到临时目录
let temp_dir = Self::extract_local_zip(zip_path)?;
// 扫描所有包含 SKILL.md 的目录
let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?;
if skill_dirs.is_empty() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"NO_SKILLS_IN_ZIP",
&[],
Some("checkZipContent"),
)));
}
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
let existing_skills = db.get_all_installed_skills()?;
for skill_dir in skill_dirs {
// 获取目录名称作为安装名
let install_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
// 检查是否已有同名 directory 的 skill
let conflict = existing_skills
.values()
.find(|s| s.directory.eq_ignore_ascii_case(&install_name));
if let Some(existing) = conflict {
log::warn!(
"Skill directory '{}' already exists (from {}), skipping",
install_name,
existing.id
);
continue;
}
// 解析元数据
let skill_md = skill_dir.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| install_name.clone()),
meta.description,
),
Err(_) => (install_name.clone(), None),
}
} else {
(install_name.clone(), None)
};
// 复制到 SSOT
let dest = ssot_dir.join(&install_name);
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
}
Self::copy_dir_recursive(&skill_dir, &dest)?;
// 创建 InstalledSkill 记录
let skill = InstalledSkill {
id: format!("local:{install_name}"),
name,
description,
directory: install_name.clone(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
};
// 保存到数据库
db.save_skill(&skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
log::info!(
"Skill {} installed from ZIP, enabled for {:?}",
skill.name,
current_app
);
installed.push(skill);
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
Ok(installed)
}
/// 解压本地 ZIP 文件到临时目录
fn extract_local_zip(zip_path: &Path) -> Result<PathBuf> {
let file = fs::File::open(zip_path)
.with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file)
.with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?;
if archive.is_empty() {
return Err(anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkZipContent"),
)));
}
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = match file.enclosed_name() {
Some(path) => path.to_owned(),
None => continue,
};
let outpath = temp_path.join(&file_path);
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
fs::create_dir_all(parent)?;
}
let mut outfile = fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
Ok(temp_path)
}
/// 递归扫描目录查找包含 SKILL.md 的技能目录
fn scan_skills_in_dir(dir: &Path) -> Result<Vec<PathBuf>> {
let mut skill_dirs = Vec::new();
Self::scan_skills_recursive(dir, &mut skill_dirs)?;
Ok(skill_dirs)
}
/// 递归扫描辅助函数
fn scan_skills_recursive(current: &Path, results: &mut Vec<PathBuf>) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current.join("SKILL.md");
if skill_md.exists() {
results.push(current.to_path_buf());
// 找到后不再递归子目录(一个 skill 目录)
return Ok(());
}
// 递归子目录
if let Ok(entries) = fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// 跳过隐藏目录
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') {
continue;
}
Self::scan_skills_recursive(&path, results)?;
}
}
}
Ok(())
}
// ========== 仓库管理(保留原有逻辑)==========
/// 列出仓库
+78 -40
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(
@@ -373,11 +379,15 @@ impl StreamCheckService {
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") {
format!("{base}/responses")
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
// Responses 端点为 `/responses`。
//
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
let urls = if base.ends_with("/v1") {
vec![format!("{base}/responses")]
} else {
format!("{base}/v1/responses")
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
@@ -399,40 +409,50 @@ impl StreamCheckService {
body["reasoning"] = json!({ "effort": effort });
}
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(&url)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
for (i, url) in urls.iter().enumerate() {
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(url)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
let status = response.status().as_u16();
let status = response.status().as_u16();
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
// 回退策略:仅当首选 URL 返回 404 时尝试下一个
if i == 0 && status == 404 && urls.len() > 1 {
continue;
}
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
} else {
Err(AppError::Message("No response data received".to_string()))
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => return Ok((status, actual_model)),
Err(e) => return Err(AppError::Message(format!("Stream read failed: {e}"))),
}
}
return Err(AppError::Message("No response data received".to_string()));
}
Err(AppError::Message(
"No valid Codex responses endpoint found".to_string(),
))
}
/// Gemini 流式检查
@@ -689,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('"', "\\\"")
}
+8 -5
View File
@@ -63,7 +63,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
header_label: "Claude",
log_name: "Claude",
},
TrayAppSection {
@@ -71,7 +71,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
header_label: "Codex",
log_name: "Codex",
},
TrayAppSection {
@@ -79,7 +79,7 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
header_label: "Gemini",
log_name: "Gemini",
},
];
@@ -391,13 +391,16 @@ pub fn create_tray_menu(
&tray_texts,
app_state,
)?;
// 在每个 section 后添加分隔符
menu_builder = menu_builder.separator();
}
// 分隔符和退出菜单
// 退出菜单(分隔符已在上面的 section 循环中添加)
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder = menu_builder.item(&quit_item);
menu_builder
.build()
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.10.2",
"version": "3.10.3",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -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);
+97 -69
View File
@@ -8,13 +8,14 @@ import {
Plus,
Settings,
ArrowLeft,
// Bot, // TODO: Agents 功能开发中,暂时不需要
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";
@@ -53,6 +54,8 @@ 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";
import { useDisableCurrentOmo } from "@/lib/query/omo";
type View =
| "providers"
@@ -62,23 +65,58 @@ 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);
// Get settings for visibleApps
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
const { data: settingsData } = useSettingsQuery();
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
@@ -87,7 +125,6 @@ function App() {
opencode: true,
};
// Get first visible app for fallback
const getFirstVisibleApp = (): AppId => {
if (visibleApps.claude) return "claude";
if (visibleApps.codex) return "codex";
@@ -96,7 +133,6 @@ function App() {
return "claude"; // fallback
};
// If current active app is hidden, switch to first visible app
useEffect(() => {
if (!visibleApps[activeApp]) {
setActiveApp(getFirstVisibleApp());
@@ -105,7 +141,6 @@ function App() {
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
const [confirmAction, setConfirmAction] = useState<{
provider: Provider;
action: "remove" | "delete";
@@ -113,7 +148,6 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
@@ -124,15 +158,12 @@ function App() {
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态
const {
isRunning: isProxyRunning,
takeoverStatus,
status: proxyStatus,
} = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
const activeProviderId = useMemo(() => {
const target = proxyStatus?.active_targets?.find(
(t) => t.app_type === activeApp,
@@ -140,7 +171,6 @@ function App() {
return target?.provider_id;
}, [proxyStatus?.active_targets, activeApp]);
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
isProxyRunning,
});
@@ -148,7 +178,6 @@ function App() {
const currentProviderId = data?.currentProviderId ?? "";
const hasSkillsSupport = true;
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
addProvider,
updateProvider,
@@ -157,7 +186,23 @@ function App() {
saveUsageScript,
} = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
disableOmoMutation.mutate(undefined, {
onSuccess: () => {
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
},
onError: (error: Error) => {
toast.error(
t("omo.disableFailed", {
defaultValue: "停用 OMO 失败: {{error}}",
error: extractErrorMessage(error),
}),
);
},
});
};
useEffect(() => {
let unsubscribe: (() => void) | undefined;
@@ -181,7 +226,6 @@ function App() {
};
}, [activeApp, refetch]);
// 监听统一供应商同步事件,刷新所有应用的供应商列表
useEffect(() => {
let unsubscribe: (() => void) | undefined;
@@ -189,10 +233,7 @@ function App() {
try {
const { listen } = await import("@tauri-apps/api/event");
unsubscribe = await listen("universal-provider-synced", async () => {
// 统一供应商同步后刷新所有应用的供应商列表
// 使用 invalidateQueries 使所有 providers 查询失效
await queryClient.invalidateQueries({ queryKey: ["providers"] });
// 同时更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
@@ -213,7 +254,6 @@ function App() {
};
}, [queryClient]);
// 应用启动时检测所有应用的环境变量冲突
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
@@ -238,7 +278,6 @@ function App() {
checkEnvOnStartup();
}, []);
// 应用启动时检查是否刚完成了配置迁移
useEffect(() => {
const checkMigration = async () => {
try {
@@ -257,7 +296,6 @@ function App() {
checkMigration();
}, [t]);
// 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT)
useEffect(() => {
const checkSkillsMigration = async () => {
try {
@@ -286,14 +324,12 @@ function App() {
checkSkillsMigration();
}, [t, queryClient]);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
try {
const conflicts = await checkEnvConflicts(activeApp);
if (conflicts.length > 0) {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`),
@@ -319,7 +355,6 @@ function App() {
checkEnvOnSwitch();
}, [activeApp]);
// 全局键盘快捷键
const currentViewRef = useRef(currentView);
useEffect(() => {
@@ -328,17 +363,14 @@ function App() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Cmd/Ctrl + , 打开设置
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setCurrentView("settings");
return;
}
// ESC 键返回
if (event.key !== "Escape" || event.defaultPrevented) return;
// 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理
if (document.body.style.overflow === "hidden") return;
const view = currentViewRef.current;
@@ -356,7 +388,6 @@ function App() {
};
}, []);
// 打开网站链接
const handleOpenWebsite = async (url: string) => {
try {
await settingsApi.openExternal(url);
@@ -370,22 +401,17 @@ function App() {
}
};
// 编辑供应商
const handleEditProvider = async (provider: Provider) => {
await updateProvider(provider);
setEditingProvider(null);
};
// 确认删除/移除供应商
const handleConfirmAction = async () => {
if (!confirmAction) return;
const { provider, action } = confirmAction;
if (action === "remove") {
// Remove from live config only (for additive mode apps like OpenCode)
// Does NOT delete from database - provider remains in the list
await providersApi.removeFromLiveConfig(provider.id, activeApp);
// Invalidate queries to refresh the isInConfig state
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
@@ -396,13 +422,11 @@ function App() {
{ closeButton: true },
);
} else {
// Delete from database
await deleteProvider(provider.id);
}
setConfirmAction(null);
};
// Generate a unique provider key for OpenCode duplication
const generateUniqueOpencodeKey = (
originalKey: string,
existingKeys: string[],
@@ -413,7 +437,6 @@ function App() {
return baseKey;
}
// If -copy already exists, try -copy-2, -copy-3, ...
let counter = 2;
while (existingKeys.includes(`${baseKey}-${counter}`)) {
counter++;
@@ -421,9 +444,7 @@ function App() {
return `${baseKey}-${counter}`;
};
// 复制供应商
const handleDuplicateProvider = async (provider: Provider) => {
// 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它
const newSortIndex =
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
@@ -442,7 +463,6 @@ function App() {
iconColor: provider.iconColor,
};
// OpenCode: generate unique provider key (used as ID)
if (activeApp === "opencode") {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
@@ -451,7 +471,6 @@ function App() {
);
}
// 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1
if (provider.sortIndex !== undefined) {
const updates = Object.values(providers)
.filter(
@@ -465,7 +484,6 @@ function App() {
sortIndex: p.sortIndex! + 1,
}));
// 先更新现有供应商的 sortIndex,为新供应商腾出位置
if (updates.length > 0) {
try {
await providersApi.updateSortOrder(updates, activeApp);
@@ -481,11 +499,9 @@ function App() {
}
}
// 3️⃣ 添加复制的供应商
await addProvider(duplicatedProvider);
};
// 打开提供商终端
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
@@ -505,10 +521,8 @@ function App() {
}
};
// 导入配置成功后刷新
const handleImportSuccess = async () => {
try {
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
@@ -580,10 +594,12 @@ function App() {
<UniversalProviderPanel />
</div>
);
case "sessions":
return <SessionManagerPage />;
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait">
<motion.div
@@ -605,7 +621,9 @@ function App() {
}
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onEdit={(provider) => {
setEditingProvider(provider);
}}
onDelete={(provider) =>
setConfirmAction({ provider, action: "delete" })
}
@@ -615,6 +633,9 @@ function App() {
setConfirmAction({ provider, action: "remove" })
: undefined
}
onDisableOmo={
activeApp === "opencode" ? handleDisableOmo : undefined
}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
@@ -635,6 +656,7 @@ function App() {
<AnimatePresence mode="wait">
<motion.div
key={currentView}
className="flex-1 min-h-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -651,13 +673,11 @@ function App() {
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
>
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
@@ -666,7 +686,6 @@ function App() {
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
@@ -732,6 +751,7 @@ function App() {
t("universalProvider.title", {
defaultValue: "统一供应商",
})}
{currentView === "sessions" && t("sessionManager.title")}
</h1>
</div>
) : (
@@ -777,7 +797,7 @@ function App() {
setSettingsDefaultTab("usage");
setCurrentView("settings");
}}
title={t("settings.usage.title", {
title={t("usage.title", {
defaultValue: "使用统计",
})}
className="hover:bg-black/5 dark:hover:bg-white/5"
@@ -828,6 +848,17 @@ function App() {
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
@@ -914,18 +945,6 @@ function App() {
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="w-4 h-4" />
</Button>
)} */}
<Button
variant="ghost"
size="sm"
@@ -935,6 +954,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"
@@ -959,8 +987,8 @@ function App() {
</div>
</header>
<main className="flex-1 pb-12 animate-fade-in ">
<div className="pb-12">{renderContent()}</div>
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
{renderContent()}
</main>
<AddProviderDialog
+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);
}
};
+1 -13
View File
@@ -17,7 +17,6 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
@@ -36,7 +35,6 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode doesn't support universal providers
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
@@ -45,7 +43,6 @@ export function AddProviderDialog({
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
// Handle universal provider save
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
try {
@@ -73,7 +70,6 @@ export function AddProviderDialog({
[t, onOpenChange],
);
// Close universal form and return to main dialog
const handleUniversalFormClose = useCallback(() => {
setUniversalFormOpen(false);
setSelectedUniversalPreset(null);
@@ -86,7 +82,6 @@ export function AddProviderDialog({
unknown
>;
// 构造基础提交数据
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
@@ -98,7 +93,6 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
// OpenCode: pass providerKey for ID generation
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
@@ -107,8 +101,7 @@ export function AddProviderDialog({
providerData.meta?.custom_endpoints &&
Object.keys(providerData.meta.custom_endpoints).length > 0;
if (!hasCustomEndpoints) {
// 收集端点候选(仅在缺少自定义端点时兜底)
if (!hasCustomEndpoints && values.presetCategory !== "omo") {
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => {
@@ -163,7 +156,6 @@ export function AddProviderDialog({
}
}
}
// Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally
}
if (appId === "claude") {
@@ -187,7 +179,6 @@ export function AddProviderDialog({
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "opencode") {
// OpenCode uses options.baseURL
const options = parsedConfig.options as
| Record<string, any>
| undefined;
@@ -221,7 +212,6 @@ export function AddProviderDialog({
[appId, onSubmit, onOpenChange],
);
// 动态 footer:根据当前 Tab 显示不同按钮
const footer =
!showUniversalTab || activeTab === "app-specific" ? (
<>
@@ -296,7 +286,6 @@ export function AddProviderDialog({
</TabsContent>
</Tabs>
) : (
// OpenCode: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
@@ -306,7 +295,6 @@ export function AddProviderDialog({
/>
)}
{/* Universal Provider Form Modal */}
{showUniversalTab && (
<UniversalProviderFormModal
isOpen={universalFormOpen}
+40 -20
View File
@@ -19,20 +19,20 @@ import type { AppId } from "@/lib/api";
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
/** OpenCode: 是否已添加到配置 */
isInConfig?: boolean;
isTesting?: boolean;
isProxyTakeover?: boolean;
isOmo?: boolean;
isLastOmo?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: () => void;
onDisableOmo?: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
@@ -44,6 +44,8 @@ export function ProviderActions({
isInConfig = false,
isTesting,
isProxyTakeover = false,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDuplicate,
@@ -51,8 +53,8 @@ export function ProviderActions({
onConfigureUsage,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
@@ -60,19 +62,20 @@ export function ProviderActions({
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
// OpenCode 使用累加模式
const isOpenCodeMode = appId === "opencode";
const isOpenCodeMode = appId === "opencode" && !isOmo;
// 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移)
const isFailoverMode =
!isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover;
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isOpenCodeMode) {
// OpenCode 模式:切换配置状态(添加/移除)
if (isOmo) {
if (isCurrent) {
onDisableOmo?.();
} else {
onSwitch();
}
} else if (isOpenCodeMode) {
if (isInConfig) {
// Use onRemoveFromConfig if available, otherwise fall back to onDelete
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
@@ -82,17 +85,33 @@ export function ProviderActions({
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
// 普通模式:切换供应商
onSwitch();
}
};
// 主按钮的状态和样式
const getMainButtonState = () => {
// OpenCode 累加模式
if (isOmo) {
if (isCurrent) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
return {
disabled: false,
variant: "default" as const,
className: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
}
if (isOpenCodeMode) {
if (isInConfig) {
return {
@@ -114,7 +133,6 @@ export function ProviderActions({
};
}
// 故障转移模式
if (isFailoverMode) {
if (isInFailoverQueue) {
return {
@@ -136,7 +154,6 @@ export function ProviderActions({
};
}
// 普通模式
if (isCurrent) {
return {
disabled: true,
@@ -161,8 +178,11 @@ export function ProviderActions({
const buttonState = getMainButtonState();
// OpenCode 模式下删除按钮始终可用(主按钮"移除"是从 live 配置移除,删除是从数据库删除)
const canDelete = isOpenCodeMode ? true : !isCurrent;
const canDelete = isOmo
? !(isLastOmo && isCurrent)
: isOpenCodeMode
? true
: !isCurrent;
return (
<div className="flex items-center gap-1.5">
+22 -40
View File
@@ -27,11 +27,13 @@ interface ProviderCardProps {
isCurrent: boolean;
appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
isOmo?: boolean;
isLastOmo?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
@@ -41,7 +43,6 @@ interface ProviderCardProps {
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
dragHandleProps?: DragHandleProps;
// 故障转移相关
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...
isInFailoverQueue?: boolean; // 是否在故障转移队列中
@@ -50,17 +51,14 @@ interface ProviderCardProps {
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
// 优先级 1: 备注
if (provider.notes?.trim()) {
return provider.notes.trim();
}
// 优先级 2: 官网地址
if (provider.websiteUrl) {
return provider.websiteUrl;
}
// 优先级 3: 从配置中提取请求地址
const config = provider.settingsConfig;
if (config && typeof config === "object") {
@@ -89,10 +87,13 @@ export function ProviderCard({
isCurrent,
appId,
isInConfig = true,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onConfigureUsage,
onOpenWebsite,
onDuplicate,
@@ -102,7 +103,6 @@ export function ProviderCard({
isProxyRunning,
isProxyTakeover = false,
dragHandleProps,
// 故障转移相关
isAutoFailoverEnabled = false,
failoverPriority,
isInFailoverQueue = false,
@@ -111,7 +111,6 @@ export function ProviderCard({
}: ProviderCardProps) {
const { t } = useTranslation();
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
const fallbackUrlText = t("provider.notConfigured", {
@@ -122,24 +121,18 @@ export function ProviderCard({
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
// 判断是否为可点击的 URL(备注不可点击)
const isClickableUrl = useMemo(() => {
// 如果有备注,则不可点击
if (provider.notes?.trim()) {
return false;
}
// 如果显示的是回退文本,也不可点击
if (displayUrl === fallbackUrlText) {
return false;
}
// 其他情况(官网地址或请求地址)可点击
return true;
}, [provider.notes, displayUrl, fallbackUrlText]);
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
@@ -153,21 +146,17 @@ export function ProviderCard({
const hasMultiplePlans =
usage?.success && usage.data && usage.data.length > 1;
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
setIsExpanded(true);
}
}, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
@@ -175,7 +164,6 @@ export function ProviderCard({
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
@@ -188,32 +176,27 @@ export function ProviderCard({
onOpenWebsite(displayUrl);
};
// 判断是否是"当前使用中"的供应商
// - OpenCode(累加模式):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider =
appId === "opencode"
const isActiveProvider = isOmo
? isCurrent
: appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
const shouldUseBlue =
(isOmo && isActiveProvider) ||
(!isOmo && !isProxyTakeover && isActiveProvider);
return (
<div
className={cn(
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group",
// hover 时的边框效果
isAutoFailoverEnabled || isProxyTakeover
? "hover:border-emerald-500/50"
: "hover:border-border-active",
// 当前激活的供应商边框样式
shouldUseGreen &&
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
@@ -225,7 +208,6 @@ export function ProviderCard({
<div
className={cn(
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
shouldUseGreen && "from-emerald-500/10",
shouldUseBlue && "from-blue-500/10",
!isActiveProvider && "from-primary/10",
@@ -248,7 +230,6 @@ export function ProviderCard({
<GripVertical className="h-4 w-4" />
</button>
{/* 供应商图标 */}
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
@@ -264,14 +245,18 @@ export function ProviderCard({
{provider.name}
</h3>
{/* 健康状态徽章 */}
{isOmo && (
<span className="inline-flex items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
OMO
</span>
)}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
/>
)}
{/* 故障转移优先级徽章 */}
{isAutoFailoverEnabled &&
isInFailoverQueue &&
failoverPriority && (
@@ -318,10 +303,8 @@ export function ProviderCard({
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
@@ -342,7 +325,6 @@ export function ProviderCard({
inline={true}
/>
)}
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
{hasMultiplePlans && (
<button
onClick={(e) => {
@@ -366,7 +348,6 @@ export function ProviderCard({
</div>
</div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div
ref={actionsRef}
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
@@ -377,6 +358,8 @@ export function ProviderCard({
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOmo={isOmo}
isLastOmo={isLastOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
@@ -388,10 +371,10 @@ export function ProviderCard({
? () => onRemoveFromConfig(provider)
: undefined
}
onDisableOmo={onDisableOmo}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
@@ -400,7 +383,6 @@ export function ProviderCard({
</div>
</div>
{/* 展开的完整套餐列表 */}
{isExpanded && hasMultiplePlans && (
<div className="mt-4 pt-4 border-t border-border-default">
<UsageFooter
+54 -48
View File
@@ -20,7 +20,6 @@ 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 { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
@@ -29,6 +28,7 @@ import {
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -40,8 +40,8 @@ interface ProviderListProps {
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -61,6 +61,7 @@ export function ProviderList({
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -77,14 +78,12 @@ export function ProviderList({
appId,
);
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: opencodeLiveIds } = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode",
});
// OpenCode: 判断供应商是否已添加到 opencode.json
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
@@ -93,20 +92,18 @@ export function ProviderList({
[appId, opencodeLiveIds],
);
// 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
const isOpenCode = appId === "opencode";
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
@@ -118,7 +115,6 @@ export function ProviderList({
[isFailoverModeActive, failoverQueue],
);
// 判断供应商是否在故障转移队列中
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
@@ -127,7 +123,6 @@ export function ProviderList({
[isFailoverModeActive, failoverQueue],
);
// 切换供应商的故障转移队列状态
const handleToggleFailover = useCallback(
(providerId: string, enabled: boolean) => {
if (enabled) {
@@ -139,10 +134,6 @@ export function ProviderList({
[appId, addToQueue, removeFromQueue],
);
const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name);
};
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -214,35 +205,44 @@ export function ProviderList({
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{filteredProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={appId !== "opencode" ? handleTest : undefined}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
// 故障转移相关:联动状态
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/>
))}
{filteredProviders.map((provider) => {
const isOmo = provider.category === "omo";
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
return (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={
isOmo ? isOmoCurrent : provider.id === currentProviderId
}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
isOmo={isOmo}
isLastOmo={
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
isTesting={false} // isChecking(provider.id) - 测试功能已隐藏
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover}
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/>
);
})}
</div>
</SortableContext>
</DndContext>
@@ -332,11 +332,13 @@ interface SortableProviderCardProps {
isCurrent: boolean;
appId: AppId;
isInConfig: boolean;
isOmo: boolean;
isLastOmo: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
/** OpenCode: remove from live config (not delete from database) */
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -345,7 +347,6 @@ interface SortableProviderCardProps {
isTesting: boolean;
isProxyRunning: boolean;
isProxyTakeover: boolean;
// 故障转移相关
isAutoFailoverEnabled: boolean;
failoverPriority?: number;
isInFailoverQueue: boolean;
@@ -358,10 +359,13 @@ function SortableProviderCard({
isCurrent,
appId,
isInConfig,
isOmo,
isLastOmo,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -397,10 +401,13 @@ function SortableProviderCard({
isCurrent={isCurrent}
appId={appId}
isInConfig={isInConfig}
isOmo={isOmo}
isLastOmo={isLastOmo}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDuplicate={onDuplicate}
onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
@@ -416,7 +423,6 @@ function SortableProviderCard({
listeners,
isDragging,
}}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
failoverPriority={failoverPriority}
isInFailoverQueue={isInFailoverQueue}
@@ -1,10 +1,16 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import type { ProviderCategory } from "@/types";
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
interface EndpointCandidate {
@@ -59,10 +65,9 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
// OpenRouter Compat
showOpenRouterCompatToggle: boolean;
openRouterCompatEnabled: boolean;
onOpenRouterCompatChange: (enabled: boolean) => void;
// API Format (for third-party providers that use OpenAI Chat Completions format)
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
}
export function ClaudeFormFields({
@@ -95,9 +100,8 @@ export function ClaudeFormFields({
defaultOpusModel,
onModelChange,
speedTestEndpoints,
showOpenRouterCompatToggle,
openRouterCompatEnabled,
onOpenRouterCompatChange,
apiFormat,
onApiFormatChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -159,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)}
/>
)}
@@ -180,25 +188,34 @@ export function ClaudeFormFields({
/>
)}
{showOpenRouterCompatToggle && (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("providerForm.openrouterCompatMode", {
defaultValue: "OpenRouter 兼容模式",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.openrouterCompatModeHint", {
defaultValue:
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
})}
</p>
</div>
<Switch
checked={openRouterCompatEnabled}
onCheckedChange={onOpenRouterCompatChange}
/>
{/* API 格式选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</FormLabel>
<Select value={apiFormat} onValueChange={onApiFormatChange}>
<SelectTrigger id="apiFormat" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需转换)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.apiFormatHint", {
defaultValue: "选择供应商 API 的输入格式",
})}
</p>
</div>
)}
@@ -0,0 +1,161 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, FolderInput, Loader2 } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import {
OmoGlobalConfigFields,
type OmoGlobalConfigFieldsRef,
} from "./OmoGlobalConfigFields";
import type { OmoGlobalConfig } from "@/types/omo";
interface OmoCommonConfigEditorProps {
previewValue: string;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
isModalOpen: boolean;
onEditClick: () => void;
onModalClose: () => void;
onSave: () => Promise<void>;
isSaving: boolean;
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
fieldsKey: number;
}
export function OmoCommonConfigEditor({
previewValue,
useCommonConfig,
onCommonConfigToggle,
isModalOpen,
onEditClick,
onModalClose,
onSave,
isSaving,
onGlobalConfigStateChange,
globalConfigRef,
fieldsKey,
}: OmoCommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const [isImporting, setIsImporting] = useState(false);
useEffect(() => {
const syncDarkMode = () =>
setIsDarkMode(document.documentElement.classList.contains("dark"));
syncDarkMode();
const observer = new MutationObserver(syncDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleImportLocal = async () => {
if (!globalConfigRef.current) return;
setIsImporting(true);
try {
await globalConfigRef.current.importFromLocal();
} finally {
setIsImporting(false);
}
};
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("provider.configJson")}</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("omo.writeCommonConfig", {
defaultValue: "Write to common config",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("omo.editCommonConfig", { defaultValue: "Edit common config" })}
</button>
</div>
<JsonEditor
value={previewValue}
onChange={() => {}}
darkMode={isDarkMode}
rows={14}
showValidation={false}
language="json"
/>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("omo.editCommonConfigTitle", {
defaultValue: "Edit OMO Common Config",
})}
onClose={onModalClose}
footer={
<>
<Button
type="button"
variant="outline"
onClick={handleImportLocal}
disabled={isImporting}
className="gap-2"
>
{isImporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<FolderInput className="w-4 h-4" />
)}
{t("common.import", { defaultValue: "Import" })}
</Button>
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={onSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("omo.commonConfigHint", {
defaultValue:
"OMO common config will be merged into all OMO configs that enable it",
})}
</p>
<OmoGlobalConfigFields
key={fieldsKey}
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
onStateChange={onGlobalConfigStateChange}
hideSaveButtons
/>
</div>
</FullScreenPanel>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,739 @@
import {
useState,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Save,
Loader2,
X,
FolderInput,
RotateCcw,
ChevronsUpDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { OmoGlobalConfig } from "@/types/omo";
import {
OMO_DISABLEABLE_AGENTS,
OMO_DISABLEABLE_MCPS,
OMO_DISABLEABLE_HOOKS,
OMO_DISABLEABLE_SKILLS,
OMO_DEFAULT_SCHEMA_URL,
OMO_SISYPHUS_AGENT_PLACEHOLDER,
OMO_LSP_PLACEHOLDER,
OMO_EXPERIMENTAL_PLACEHOLDER,
OMO_BACKGROUND_TASK_PLACEHOLDER,
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
OMO_CLAUDE_CODE_PLACEHOLDER,
} from "@/types/omo";
import {
useOmoGlobalConfig,
useSaveOmoGlobalConfig,
useReadOmoLocalFile,
} from "@/lib/query/omo";
interface PresetOption {
readonly value: string;
readonly label: string;
}
export interface OmoGlobalConfigFieldsRef {
buildCurrentConfig: () => OmoGlobalConfig;
buildCurrentConfigStrict: () => OmoGlobalConfig;
importFromLocal: () => Promise<void>;
}
interface OmoGlobalConfigFieldsProps {
onStateChange?: (config: OmoGlobalConfig) => void;
hideSaveButtons?: boolean;
}
type OmoAdvancedFieldKey =
| "lspStr"
| "experimentalStr"
| "backgroundTaskStr"
| "browserStr"
| "claudeCodeStr";
const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
key: OmoAdvancedFieldKey;
labelKey: string;
defaultLabel: string;
placeholder: string;
minHeight: string;
}> = [
{
key: "lspStr",
labelKey: "omo.advancedLsp",
defaultLabel: "LSP Config",
placeholder: OMO_LSP_PLACEHOLDER,
minHeight: "200px",
},
{
key: "experimentalStr",
labelKey: "omo.advancedExperimental",
defaultLabel: "Experimental Features",
placeholder: OMO_EXPERIMENTAL_PLACEHOLDER,
minHeight: "120px",
},
{
key: "backgroundTaskStr",
labelKey: "omo.advancedBackgroundTask",
defaultLabel: "Background Tasks",
placeholder: OMO_BACKGROUND_TASK_PLACEHOLDER,
minHeight: "250px",
},
{
key: "browserStr",
labelKey: "omo.advancedBrowserAutomation",
defaultLabel: "Browser Automation",
placeholder: OMO_BROWSER_AUTOMATION_PLACEHOLDER,
minHeight: "80px",
},
{
key: "claudeCodeStr",
labelKey: "omo.advancedClaudeCode",
defaultLabel: "Claude Code",
placeholder: OMO_CLAUDE_CODE_PLACEHOLDER,
minHeight: "180px",
},
];
function TagListEditor({
label,
values,
onChange,
placeholder,
presets,
}: {
label: string;
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
presets?: readonly PresetOption[];
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const toggleValue = (v: string) => {
if (values.includes(v)) {
onChange(values.filter((x) => x !== v));
} else {
onChange([...values, v]);
}
};
const customValue = search.trim();
const canAddCustom = customValue.length > 0 && !values.includes(customValue);
const triggerText =
values.length === 0
? placeholder || t("omo.selectPlaceholder", { defaultValue: "Select..." })
: values.length === 1
? values[0]
: `${values[0]} +${values.length - 1}`;
const availablePresets = presets?.filter(
(p) =>
!search.trim() ||
p.label.toLowerCase().includes(search.toLowerCase()) ||
p.value.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">{label}</Label>
{values.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-muted-foreground"
onClick={() => onChange([])}
>
{t("omo.clear", { defaultValue: "Clear" })}
</Button>
)}
</div>
{values.length > 0 && (
<div className="flex flex-wrap gap-1">
{values.map((v, i) => (
<Badge
key={`${v}-${i}`}
variant="secondary"
className="text-xs gap-1"
>
{v}
<button
type="button"
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center justify-between w-full h-8 px-3 rounded-md border border-input bg-background text-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
open && "ring-2 ring-ring",
)}
aria-expanded={open}
>
<span
className={cn(
"truncate",
values.length > 0 ? "text-foreground" : "text-muted-foreground",
)}
>
{triggerText}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] p-0 z-[120]"
>
<div className="p-1.5 border-b border-border/30">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && canAddCustom) {
e.preventDefault();
onChange([...values, customValue]);
setSearch("");
}
}}
placeholder={
placeholder ||
t("omo.searchOrType", {
defaultValue: "Search or type custom value...",
})
}
className="h-7 text-sm"
autoFocus
/>
</div>
{canAddCustom && (
<button
type="button"
className="w-full px-2.5 py-1.5 text-left text-sm border-b border-border/30 hover:bg-accent"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange([...values, customValue]);
setSearch("");
}}
>
+ {customValue}
</button>
)}
<div className="max-h-48 overflow-auto py-1">
{availablePresets && availablePresets.length > 0 ? (
availablePresets.map((p) => {
const checked = values.includes(p.value);
return (
<DropdownMenuCheckboxItem
key={p.value}
checked={checked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={() => toggleValue(p.value)}
className="text-sm"
>
{p.label}
</DropdownMenuCheckboxItem>
);
})
) : (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t("omo.noMatches", { defaultValue: "No matches" })}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function JsonTextareaField({
label,
value,
onChange,
placeholder,
minHeight,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: string;
}) {
return (
<div className="space-y-1.5">
<Label className="text-sm">{label}</Label>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "{}"}
className="font-mono text-sm"
style={{ minHeight: minHeight || "100px" }}
/>
</div>
);
}
export const OmoGlobalConfigFields = forwardRef<
OmoGlobalConfigFieldsRef,
OmoGlobalConfigFieldsProps
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
const { t } = useTranslation();
const { data: config } = useOmoGlobalConfig();
const saveMutation = useSaveOmoGlobalConfig();
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
const [disabledHooks, setDisabledHooks] = useState<string[]>([]);
const [disabledSkills, setDisabledSkills] = useState<string[]>([]);
const [lspStr, setLspStr] = useState("");
const [experimentalStr, setExperimentalStr] = useState("");
const [backgroundTaskStr, setBackgroundTaskStr] = useState("");
const [browserStr, setBrowserStr] = useState("");
const [claudeCodeStr, setClaudeCodeStr] = useState("");
const [otherFieldsStr, setOtherFieldsStr] = useState("");
const [loaded, setLoaded] = useState(false);
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
setSisyphusAgentStr(
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
);
setDisabledAgents(global.disabledAgents || []);
setDisabledMcps(global.disabledMcps || []);
setDisabledHooks(global.disabledHooks || []);
setDisabledSkills(global.disabledSkills || []);
setLspStr(global.lsp ? JSON.stringify(global.lsp, null, 2) : "");
setExperimentalStr(
global.experimental ? JSON.stringify(global.experimental, null, 2) : "",
);
setBackgroundTaskStr(
global.backgroundTask
? JSON.stringify(global.backgroundTask, null, 2)
: "",
);
setBrowserStr(
global.browserAutomationEngine
? JSON.stringify(global.browserAutomationEngine, null, 2)
: "",
);
setClaudeCodeStr(
global.claudeCode ? JSON.stringify(global.claudeCode, null, 2) : "",
);
setOtherFieldsStr(
global.otherFields ? JSON.stringify(global.otherFields, null, 2) : "",
);
}, []);
useEffect(() => {
if (config && !loaded) {
applyGlobalState(config);
setLoaded(true);
}
}, [config, loaded, applyGlobalState]);
const parseJsonField = useCallback(
(
fieldName: string,
raw: string,
strict: boolean,
): Record<string, unknown> | undefined => {
if (!raw.trim()) return undefined;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
if (strict) {
throw new Error(
t("omo.jsonMustBeObject", {
field: fieldName,
defaultValue: "{{field}} must be a JSON object",
}),
);
}
return undefined;
}
return parsed as Record<string, unknown>;
} catch (error) {
if (strict) {
if (error instanceof Error) {
throw error;
}
throw new Error(
t("omo.jsonInvalid", {
field: fieldName,
defaultValue: "{{field}} contains invalid JSON",
}),
);
}
return undefined;
}
},
[t],
);
const buildCurrentConfigInternal = useCallback(
(strict: boolean): OmoGlobalConfig => {
return {
id: "global",
schemaUrl: schemaUrl || undefined,
sisyphusAgent: parseJsonField(
t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
}),
sisyphusAgentStr,
strict,
),
disabledAgents,
disabledMcps,
disabledHooks,
disabledSkills,
lsp: parseJsonField(
t("omo.advancedLsp", { defaultValue: "LSP" }),
lspStr,
strict,
),
experimental: parseJsonField(
t("omo.advancedExperimental", { defaultValue: "Experimental" }),
experimentalStr,
strict,
),
backgroundTask: parseJsonField(
t("omo.advancedBackgroundTask", {
defaultValue: "Background Task",
}),
backgroundTaskStr,
strict,
),
browserAutomationEngine: parseJsonField(
t("omo.advancedBrowserAutomation", {
defaultValue: "Browser Automation",
}),
browserStr,
strict,
),
claudeCode: parseJsonField(
t("omo.advancedClaudeCode", { defaultValue: "Claude Code" }),
claudeCodeStr,
strict,
),
otherFields: parseJsonField(
t("omo.otherFields", {
defaultValue: "Other Config",
}),
otherFieldsStr,
strict,
),
updatedAt: new Date().toISOString(),
};
},
[
schemaUrl,
sisyphusAgentStr,
disabledAgents,
disabledMcps,
disabledHooks,
disabledSkills,
lspStr,
experimentalStr,
backgroundTaskStr,
browserStr,
claudeCodeStr,
otherFieldsStr,
parseJsonField,
],
);
const buildCurrentConfig = useCallback(
() => buildCurrentConfigInternal(false),
[buildCurrentConfigInternal],
);
const buildCurrentConfigStrict = useCallback(
() => buildCurrentConfigInternal(true),
[buildCurrentConfigInternal],
);
useEffect(() => {
if (loaded && onStateChange) {
onStateChange(buildCurrentConfig());
}
}, [loaded, onStateChange, buildCurrentConfig]);
const handleSaveGlobal = useCallback(async () => {
try {
const result = buildCurrentConfigStrict();
await saveMutation.mutateAsync(result);
toast.success(
t("omo.globalConfigSaved", {
defaultValue: "Global config saved",
}),
);
} catch (err) {
toast.error(String(err));
}
}, [buildCurrentConfigStrict, saveMutation, t]);
const disabledCount =
disabledAgents.length +
disabledMcps.length +
disabledHooks.length +
disabledSkills.length;
const advancedFieldValues: Record<OmoAdvancedFieldKey, string> = {
lspStr,
experimentalStr,
backgroundTaskStr,
browserStr,
claudeCodeStr,
};
const advancedFieldSetters: Record<
OmoAdvancedFieldKey,
(value: string) => void
> = {
lspStr: setLspStr,
experimentalStr: setExperimentalStr,
backgroundTaskStr: setBackgroundTaskStr,
browserStr: setBrowserStr,
claudeCodeStr: setClaudeCodeStr,
};
const disabledEditorConfigs = [
{
key: "agents",
label: t("omo.disabledAgents", { defaultValue: "Agents" }),
values: disabledAgents,
onChange: setDisabledAgents,
placeholder: t("omo.disabledAgentsPlaceholder", {
defaultValue: "Disabled Agents",
}),
presets: OMO_DISABLEABLE_AGENTS,
},
{
key: "mcps",
label: t("omo.disabledMcps", { defaultValue: "MCPs" }),
values: disabledMcps,
onChange: setDisabledMcps,
placeholder: t("omo.disabledMcpsPlaceholder", {
defaultValue: "Disabled MCPs",
}),
presets: OMO_DISABLEABLE_MCPS,
},
{
key: "hooks",
label: t("omo.disabledHooks", { defaultValue: "Hooks" }),
values: disabledHooks,
onChange: setDisabledHooks,
placeholder: t("omo.disabledHooksPlaceholder", {
defaultValue: "Disabled Hooks",
}),
presets: OMO_DISABLEABLE_HOOKS,
},
{
key: "skills",
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
values: disabledSkills,
onChange: setDisabledSkills,
placeholder: t("omo.disabledSkillsPlaceholder", {
defaultValue: "Disabled Skills",
}),
presets: OMO_DISABLEABLE_SKILLS,
},
] as const;
const readLocalFile = useReadOmoLocalFile();
const handleImportGlobalFromLocal = useCallback(async () => {
try {
const data = await readLocalFile.mutateAsync();
applyGlobalState(data.global);
toast.success(
t("omo.importGlobalSuccess", {
defaultValue: "Imported global config from local file (unsaved)",
}),
);
} catch (err) {
toast.error(
t("omo.importGlobalFailed", {
error: String(err),
defaultValue: "Failed to read local file: {{error}}",
}),
);
}
}, [readLocalFile, applyGlobalState, t]);
useImperativeHandle(
ref,
() => ({
buildCurrentConfig,
buildCurrentConfigStrict,
importFromLocal: handleImportGlobalFromLocal,
}),
[buildCurrentConfig, buildCurrentConfigStrict, handleImportGlobalFromLocal],
);
return (
<div className="space-y-4">
{!hideSaveButtons && (
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={readLocalFile.isPending}
onClick={handleImportGlobalFromLocal}
>
{readLocalFile.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<FolderInput className="h-3.5 w-3.5 mr-1" />
)}
{t("omo.importLocal", { defaultValue: "Import Local" })}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={saveMutation.isPending}
onClick={handleSaveGlobal}
>
{saveMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1" />
)}
{t("omo.saveGlobalConfig", { defaultValue: "Save Global Config" })}
</Button>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">
{t("omo.schemaUrl", { defaultValue: "$schema" })}
</Label>
{schemaUrl !== OMO_DEFAULT_SCHEMA_URL && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs px-1.5"
onClick={() => setSchemaUrl(OMO_DEFAULT_SCHEMA_URL)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
{t("omo.resetDefault", { defaultValue: "Reset" })}
</Button>
)}
</div>
<Input
value={schemaUrl}
onChange={(e) => setSchemaUrl(e.target.value)}
placeholder={OMO_DEFAULT_SCHEMA_URL}
className="text-sm h-8"
/>
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
})}
</Label>
<Textarea
value={sisyphusAgentStr}
onChange={(e) => setSisyphusAgentStr(e.target.value)}
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
className="font-mono text-sm"
style={{ minHeight: "140px" }}
/>
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold">
{t("omo.disabledItems", { defaultValue: "Disabled Items" })}
</Label>
{disabledCount > 0 && (
<Badge variant="secondary" className="text-xs h-5">
{disabledCount}
</Badge>
)}
</div>
{disabledEditorConfigs.map((editor) => (
<TagListEditor
key={editor.key}
label={editor.label}
values={editor.values}
onChange={editor.onChange}
placeholder={editor.placeholder}
presets={editor.presets}
/>
))}
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
</Label>
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
<JsonTextareaField
key={field.key}
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
value={advancedFieldValues[field.key]}
onChange={advancedFieldSetters[field.key]}
placeholder={field.placeholder}
minHeight={field.minHeight}
/>
))}
<JsonTextareaField
label={t("omo.otherFields", {
defaultValue: "Other Config",
})}
value={otherFieldsStr}
onChange={setOtherFieldsStr}
/>
</div>
</div>
);
});
@@ -139,6 +139,8 @@ interface OpenCodeFormFieldsProps {
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
// Base URL
baseUrl: string;
@@ -161,6 +163,8 @@ export function OpenCodeFormFields({
category,
shouldShowApiKeyLink,
websiteUrl,
isPartner,
partnerPromotionKey,
baseUrl,
onBaseUrlChange,
models,
@@ -376,6 +380,8 @@ export function OpenCodeFormFields({
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
/>
{/* Base URL */}
File diff suppressed because it is too large Load Diff
@@ -40,7 +40,6 @@ export function ProviderPresetSelector({
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
// 根据分类获取提示文字
const getCategoryHint = (): React.ReactNode => {
switch (category) {
case "official":
@@ -63,6 +62,11 @@ export function ProviderPresetSelector({
return t("providerForm.customApiKeyHint", {
defaultValue: "💡 自定义配置需手动填写所有必要字段",
});
case "omo":
return t("providerForm.omoHint", {
defaultValue:
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
});
default:
return t("providerPreset.hint", {
defaultValue: "选择预设后可继续调整下方字段。",
@@ -70,7 +74,6 @@ export function ProviderPresetSelector({
}
};
// 渲染预设按钮的图标
const renderPresetIcon = (
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
) => {
@@ -91,7 +94,6 @@ export function ProviderPresetSelector({
}
};
// 获取预设按钮的样式类名
const getPresetButtonClass = (
isSelected: boolean,
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
@@ -100,18 +102,15 @@ export function ProviderPresetSelector({
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) {
// 如果有自定义主题,使用自定义颜色
if (preset.theme?.backgroundColor) {
return `${baseClass} text-white`;
}
// 默认使用主题蓝色
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
}
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
};
// 获取预设按钮的内联样式(用于自定义背景色)
const getPresetButtonStyle = (
isSelected: boolean,
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset,
@@ -130,7 +129,6 @@ export function ProviderPresetSelector({
<div className="space-y-3">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div className="flex flex-wrap gap-2">
{/* 自定义按钮 */}
<button
type="button"
onClick={() => onPresetChange("custom")}
@@ -143,7 +141,6 @@ export function ProviderPresetSelector({
{t("providerPreset.custom")}
</button>
{/* 预设按钮 */}
{categoryKeys.map((category) => {
const entries = groupedPresets[category];
if (!entries || entries.length === 0) return null;
@@ -174,7 +171,6 @@ export function ProviderPresetSelector({
})}
</div>
{/* 统一供应商预设(新的一行) */}
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
@@ -196,7 +192,6 @@ export function ProviderPresetSelector({
</span>
</button>
))}
{/* 管理统一供应商按钮 */}
{onManageUniversalProviders && (
<button
type="button"
@@ -4,10 +4,15 @@ import type { ProviderCategory } from "@/types";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets";
type PresetEntry = {
id: string;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
preset:
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset;
};
interface UseApiKeyLinkProps {
@@ -74,7 +79,10 @@ export function useApiKeyLink({
return {
shouldShowApiKeyLink:
appId === "claude" || appId === "codex" || appId === "gemini"
appId === "claude" ||
appId === "codex" ||
appId === "gemini" ||
appId === "opencode"
? shouldShowApiKeyLink
: false,
websiteUrl: getWebsiteUrl,
@@ -4,6 +4,7 @@ import type { AppId } from "@/lib/api";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
interface UseProviderCategoryProps {
appId: AppId;
@@ -42,7 +43,9 @@ export function useProviderCategory({
if (!selectedPresetId) return;
// 从预设 ID 提取索引
const match = selectedPresetId.match(/^(claude|codex|gemini)-(\d+)$/);
const match = selectedPresetId.match(
/^(claude|codex|gemini|opencode)-(\d+)$/,
);
if (!match) return;
const [, type, indexStr] = match;
@@ -67,6 +70,11 @@ export function useProviderCategory({
if (preset) {
setCategory(preset.category || undefined);
}
} else if (type === "opencode" && appId === "opencode") {
const preset = opencodeProviderPresets[index];
if (preset) {
setCategory(preset.category || undefined);
}
}
}, [appId, selectedPresetId, isEditMode, initialCategory]);
+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 -8
View File
@@ -32,9 +32,14 @@ interface ToolVersion {
error: string | null;
}
const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
curl -fsSL https://claude.ai/install.sh | bash
# Codex
npm i -g @openai/codex@latest
npm i -g @google/gemini-cli@latest`;
# Gemini CLI
npm i -g @google/gemini-cli@latest
# OpenCode
curl -fsSL https://opencode.ai/install | bash`;
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
@@ -312,10 +317,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3 px-1">
{["claude", "codex", "gemini"].map((toolName, index) => {
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.name ?? toolName;
// Special case for OpenCode (capital C), others use capitalize
const displayName =
toolName === "opencode"
? "OpenCode"
: toolName.charAt(0).toUpperCase() + toolName.slice(1);
const title = tool?.version || tool?.error || t("common.unknown");
return (
@@ -330,9 +339,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">
{displayName}
</span>
<span className="text-sm font-medium">{displayName}</span>
</div>
{isLoadingTools ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -31,7 +31,7 @@ export function AppVisibilitySettings({
const visibleApps: VisibleApps = settings.visibleApps ?? {
claude: true,
codex: true,
gemini: false,
gemini: true,
opencode: true,
};
+393 -388
View File
@@ -207,7 +207,7 @@ export function SettingsPage({
};
return (
<div className="flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
<div className="flex flex-col h-full overflow-hidden px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -229,427 +229,432 @@ export function SettingsPage({
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<LanguageSettings
value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })}
/>
<ThemeSettings />
<AppVisibilitySettings
settings={settings}
onChange={handleAutoSave}
/>
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
<SkillSyncMethodSettings
value={settings.skillSyncMethod ?? "auto"}
onChange={(method) =>
handleAutoSave({ skillSyncMethod: method })
}
/>
<TerminalSettings
value={settings.preferredTerminal}
onChange={(terminal) =>
handleAutoSave({ preferredTerminal: terminal })
}
/>
</motion.div>
) : null}
</TabsContent>
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<Accordion
type="multiple"
defaultValue={[]}
className="w-full space-y-4"
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<AccordionItem
value="directory"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<FolderSearch className="h-5 w-5 text-primary" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.configDir.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.configDir.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
opencodeDir={settings.opencodeConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
</AccordionContent>
</AccordionItem>
<LanguageSettings
value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })}
/>
<ThemeSettings />
<AppVisibilitySettings
settings={settings}
onChange={handleAutoSave}
/>
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
<SkillSyncMethodSettings
value={settings.skillSyncMethod ?? "auto"}
onChange={(method) =>
handleAutoSave({ skillSyncMethod: method })
}
/>
<TerminalSettings
value={settings.preferredTerminal}
onChange={(terminal) =>
handleAutoSave({ preferredTerminal: terminal })
}
/>
</motion.div>
) : null}
</TabsContent>
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
{settings ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<Accordion
type="multiple"
defaultValue={[]}
className="w-full space-y-4"
>
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<AccordionItem
value="directory"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<FolderSearch className="h-5 w-5 text-primary" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
{t("settings.advanced.configDir.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
{t("settings.advanced.configDir.description")}
</p>
</div>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-4 pl-4">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
opencodeDir={settings.opencodeConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
</div>
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
<ProxyPanel />
</AccordionContent>
</AccordionItem>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="failover"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.failover.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.failover.description")}
</p>
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
>
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p>
</div>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-4 pl-4">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6">
{/* 代理未运行时的提示 */}
{!isRunning && (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.failover.proxyRequired", {
defaultValue:
"需要先启动代理服务才能配置故障转移",
})}
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
<ProxyPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="failover"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.failover.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.failover.description")}
</p>
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6">
{/* 代理未运行时的提示 */}
{!isRunning && (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.failover.proxyRequired", {
defaultValue:
"需要先启动代理服务才能配置故障转移",
})}
</p>
</div>
)}
{/* 故障转移设置 - 按应用分组 */}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent
value="claude"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
{/* 故障转移设置 - 按应用分组 */}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent
value="claude"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="test"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.modelTest.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.modelTest.description")}
</p>
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ModelTestConfigPanel />
</AccordionContent>
</AccordionItem>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")}
</p>
<AccordionItem
value="test"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.modelTest.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.modelTest.description")}
</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ModelTestConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")}
</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-blue-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.data.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.data.description")}
</p>
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
</AccordionContent>
</AccordionItem>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="logConfig"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<ScrollText className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.logConfig.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.logConfig.description")}
</p>
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-blue-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.data.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.data.description")}
</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<LogConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
</AccordionContent>
</AccordionItem>
<div className="pt-4">
<Button
onClick={handleSave}
className="w-full h-12 text-base font-medium"
disabled={isSaving}
>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-5 w-5" />
{t("common.save")}
</>
)}
</Button>
</div>
</motion.div>
) : null}
</TabsContent>
<AccordionItem
value="logConfig"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<ScrollText className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.logConfig.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.logConfig.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<LogConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion>
</motion.div>
) : null}
</TabsContent>
<TabsContent value="about" className="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
<TabsContent value="about" className="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
<TabsContent value="usage" className="mt-0">
<UsageDashboard />
</TabsContent>
<TabsContent value="usage" className="mt-0">
<UsageDashboard />
</TabsContent>
</div>
{activeTab === "advanced" && settings && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="px-6 flex items-center justify-end gap-3">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</div>
</div>
)}
</div>
</Tabs>
)}
@@ -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) =>
+95 -143
View File
@@ -2,31 +2,33 @@ 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,
useUninstallSkill,
useScanUnmanagedSkills,
useImportSkillsFromApps,
useInstallSkillsFromZip,
type InstalledSkill,
type AppType,
} from "@/hooks/useSkills";
import type { AppId } from "@/lib/api/types";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
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;
openInstallFromZip: () => void;
}
const UnifiedSkillsPanel = React.forwardRef<
@@ -42,38 +44,30 @@ 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();
const { data: unmanagedSkills, refetch: scanUnmanaged } =
useScanUnmanagedSkills();
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) });
}
};
@@ -90,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) });
}
},
});
@@ -107,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) });
}
};
@@ -121,31 +111,56 @@ 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;
const currentApp: AppId = "claude";
const installed = await installFromZipMutation.mutateAsync({
filePath,
currentApp,
});
if (installed.length === 0) {
toast.info(t("skills.installFromZip.noSkillsFound"), {
closeButton: true,
});
} else if (installed.length === 1) {
toast.success(
t("skills.installFromZip.successSingle", { name: installed[0].name }),
{ closeButton: true },
);
} else {
toast.success(
t("skills.installFromZip.successMultiple", {
count: installed.length,
}),
{ closeButton: true },
);
}
} catch (error) {
toast.error(t("skills.installFailed"), { description: String(error) });
}
};
React.useImperativeHandle(ref, () => ({
openDiscovery: onOpenDiscovery,
openImport: handleOpenImport,
openInstallFromZip: handleInstallFromZip,
}));
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">
@@ -164,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}
@@ -188,7 +205,6 @@ const UnifiedSkillsPanel = React.forwardRef<
/>
)}
{/* Import Dialog */}
{importDialogOpen && unmanagedSkills && (
<ImportSkillsDialog
skills={unmanagedSkills}
@@ -202,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();
@@ -227,7 +242,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
}
};
// 生成来源标签
const sourceLabel = useMemo(() => {
if (skill.repoOwner && skill.repoName) {
return `${skill.repoOwner}/${skill.repoName}`;
@@ -236,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>
))

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