Compare commits

..

37 Commits

Author SHA1 Message Date
YoVinchen 2eb1670e93 style: fix code formatting and defer templates migration 2026-01-14 11:42:47 +08:00
YoVinchen 929ac54cc9 Merge branch 'main' into feature/claude-code-templates
# Conflicts:
#	src-tauri/src/database/dao/mod.rs
#	src-tauri/src/database/schema.rs
#	src-tauri/src/lib.rs
#	src/App.tsx
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
2026-01-14 00:45:20 +08:00
Dex Miller f3343992f2 feat(proxy): add thinking signature rectifier for Claude API (#595)
* feat(proxy): add thinking signature rectifier for Claude API

Add automatic request rectification when Anthropic API returns signature
validation errors. This improves compatibility when switching between
different Claude providers or when historical messages contain incompatible
thinking block signatures.

- Add thinking_rectifier.rs module with trigger detection and rectification
- Integrate rectifier into forwarder error handling flow
- Remove thinking/redacted_thinking blocks and signature fields on retry
- Delete top-level thinking field when assistant message lacks thinking prefix

* fix(proxy): complete rectifier retry path with failover switch and chain continuation

- Add failover switch trigger on rectifier retry success when provider differs from start
- Replace direct error return with error categorization on rectifier retry failure
- Continue failover chain for retryable errors instead of terminating early

* feat(proxy): add rectifier config with master switch

- Add RectifierConfig struct with enabled and requestThinkingSignature fields
- Update should_rectify_thinking_signature to check master switch first
- Add tests for master switch functionality

* feat(db): add rectifier config storage in settings table

Store rectifier config as JSON in single key for extensibility

* feat(commands): add get/set rectifier config commands

* feat(ui): add rectifier config panel in advanced settings

- Add RectifierConfigPanel component with master switch and thinking signature toggle
- Add API wrapper for rectifier config
- Add i18n translations for zh/en/ja

* feat(proxy): integrate rectifier config into request forwarding

- Load rectifier config from database in RequestContext
- Pass config to RequestForwarder for runtime checking
- Use should_rectify_thinking_signature with config parameter

* test(proxy): add nested JSON error detection test for thinking rectifier

* fix(proxy): resolve HalfOpen permit leak and RectifierConfig default values

- Fix RectifierConfig::default() to return enabled=true (was false due to derive)
- Add release_permit_neutral() for releasing permits without affecting health stats
- Fix 3 permit leak points in rectifier retry branches
- Add unit tests for default values and permit release

* style(ui): format ProviderCard style attribute

* fix(rectifier): add detection for signature field required error

Add support for detecting "signature: Field required" error pattern
in the thinking signature rectifier. This enables automatic request
rectification when upstream API returns this specific validation error.
2026-01-14 00:12:13 +08:00
Jason 53f40b2d7a refactor(ui): unify pricing edit modal with FullScreenPanel
Replace Dialog component with FullScreenPanel in PricingEditModal
to match the UI style of other edit dialogs (provider, MCP).

Changes:
- Switch from small centered Dialog to full-screen panel
- Add back button in header and fixed footer for actions
- Add Save/Plus icons to submit button
2026-01-13 15:13:33 +08:00
Dex Miller 1393f89797 feat(misc): add WSL tool version detection with security hardening (#… (#627)
* feat(misc): add WSL tool version detection with security hardening (#608)

- Add WSL distro detection from UNC path (wsl$/wsl.localhost)
- Add distro name validation to prevent command injection
- Add defensive assertion for tool parameter
- Unify cfg macros to target_os = "windows"
- Standardize error message format to [WSL:{distro}]

Closes #608

* fix(misc): add CREATE_NO_WINDOW flag and unify error messages to English

- Add CREATE_NO_WINDOW flag to wsl.exe command to prevent console window flash
- Standardize error messages from Chinese to English for consistency

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-13 12:11:54 +08:00
Dex Miller f9d80b8dc3 feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests (#623)
- Add configurable test prompt field to StreamCheckConfig
- Implement Claude CLI-compatible request format with proper headers:
  - Authorization + x-api-key dual auth
  - anthropic-beta, anthropic-version headers
  - x-stainless-* SDK headers with dynamic OS/arch detection
  - URL with ?beta=true parameter
- Implement Codex CLI-compatible Responses API format:
  - /v1/responses endpoint
  - input array format with reasoning effort support
  - codex_cli_rs user-agent and originator headers
- Add dynamic OS name and CPU architecture detection
- Internationalize error messages (Chinese -> English)
- Add test prompt Textarea UI component with i18n support
- Remove obsolete testPromptDesc translation key
2026-01-13 11:36:19 +08:00
Dex Miller 8b92982112 Feature/global proxy (#596)
* refactor(proxy): simplify logging for better readability

- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.

* fix: replace unsafe unwrap() calls with proper error handling

- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).

* refactor(proxy): simplify verbose logging output

- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.

* feat(proxy): add structured log codes for i18n support

Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel

* feat(pricing): support @ separator in model name matching

- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching

* feat(proxy): add global proxy settings support

Add ability to configure a global HTTP/HTTPS proxy for all outbound
requests including provider API calls, speed tests, and stream checks.

* fix(proxy): improve validation and error handling in proxy config panels

- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation

* fix(proxy): harden error handling and input validation

- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels

* feat(proxy): add local proxy auto-scan and fix hot-reload

- Add scan_local_proxies command to detect common proxy ports
- Fix SkillService not using updated proxy after hot-reload
- Move global proxy settings to advanced tab
- Add error handling for scan failures

* fix(proxy): allow localhost input in proxy address field

* fix(proxy): restore request timeout and fix proxy hot-reload issues

- Add URL scheme validation in build_client (http/https/socks5/socks5h)
- Restore per-request timeout for speedtest, stream_check, usage_script, forwarder
- Fix set_global_proxy_url to validate before persisting to DB
- Mask proxy credentials in all log outputs
- Fix forwarder hot-reload by fetching client on each request

* style: format code with prettier

* fix(proxy): improve global proxy stability and error handling

- Fix RwLock silent failures with explicit error propagation
- Handle init() duplicate calls gracefully with warning log
- Align fallback client config with build_client settings
- Make scan_local_proxies async to avoid UI blocking
- Add mixed mode support for Clash 7890 port (http+socks5)
- Use multiple test targets for better proxy connectivity test
- Clear invalid proxy config on init failure
- Restore timeout constraints in usage_script
- Fix mask_url output for URLs without port
- Add structured error codes [GP-001 to GP-009]

* feat(proxy): add username/password authentication support

- Add separate username and password input fields
- Implement password visibility toggle with eye icon
- Add clear button to reset all proxy fields
- Auto-extract auth info from saved URL and merge on save
- Update i18n translations (zh/en/ja)

* fix(proxy): fix double encoding issue in proxy auth and add debug logs

- Remove encodeURIComponent in mergeAuth() since URL object's
  username/password setters already do percent-encoding automatically
- Add GP-010 debug log for database read operations
- Add GP-011 debug log to track incoming URL info (length, has_auth)
- Fix username.trim() in fallback branch for consistent behavior
2026-01-13 10:55:53 +08:00
Dex Miller 74b4d4ecbb fix(ui): auto-adapt usage block offset based on action buttons width (#613) 2026-01-12 23:07:02 +08:00
Dex Miller 99c910e58e fix(provider): persist endpoint auto-select state (#611)
- Add endpointAutoSelect field to ProviderMeta for persistence
- Lift autoSelect state from EndpointSpeedTest to ProviderForm
- Save auto-select preference when provider is saved
- Restore preference when editing existing provider

Fixes https://github.com/farion1231/cc-switch/issues/589
2026-01-12 16:26:17 +08:00
Dex Miller 8f7423f011 Feat/deeplink multi endpoints (#597)
* feat(deeplink): support comma-separated multiple endpoints in URL

Allow importing multiple API endpoints via single endpoint parameter.
First URL becomes primary endpoint, rest are added as custom endpoints.

* feat(deeplink): add usage query fields to deeplink generator

Add form fields for usage query configuration in deeplink HTML generator:
- usageEnabled, usageBaseUrl, usageApiKey
- usageScript, usageAutoInterval
- usageAccessToken, usageUserId

* fix(deeplink): auto-infer homepage and improve multi-endpoint display

- Auto-infer homepage from primary endpoint when not provided
- Display multiple endpoints as list in import dialog (primary marked)
- Update deeplink parser in deplink.html to show multi-endpoint info
- Add test for homepage inference from endpoint
- Minor log format fix in live.rs

* fix(deeplink): use primary endpoint for usage script base_url

- Fix usage_script.base_url getting comma-separated string when multiple endpoints
- Add i18n support for primary endpoint label in DeepLinkImportDialog
2026-01-12 15:57:45 +08:00
Jason c56523c9c0 Merge tianrking/main: feat: add provider-specific terminal button
Merged PR #452 which adds:
- Terminal button for Claude providers to launch with provider-specific config
- Cross-platform support (macOS/Linux/Windows)
- Auto-cleanup of temporary config files
2026-01-12 09:13:24 +08:00
Jason 6aef472fd2 fix(deeplink): prioritize GOOGLE_GEMINI_BASE_URL over GEMINI_BASE_URL
When merging Gemini config from local env file during deeplink import,
check GOOGLE_GEMINI_BASE_URL first (official variable name) before
falling back to GEMINI_BASE_URL.
2026-01-11 23:24:06 +08:00
Xyfer 4a8883ecc3 fix(mcp): skip cmd /c wrapper for WSL target paths (#592)
* fix(mcp): skip cmd /c wrapper for WSL target paths

When the Claude config directory is set to a WSL network path
(e.g., \wsl$\Ubuntu\home\user\.claude), the MCP export should
not wrap npx/npm commands with cmd /c since WSL runs Linux.

- Add is_wsl_path() to detect \wsl$\ and \wsl.localhost\ paths
- Skip wrap_command_for_windows() when target is WSL path
- Add comprehensive tests for various WSL distributions

* chore(mcp): add debug log for WSL path detection

* refactor(mcp): optimize is_wsl_path with next() and rename variable
2026-01-11 20:51:56 +08:00
Dex Miller 6dd809701b Refactor/simplify proxy logs (#585)
* refactor(proxy): simplify logging for better readability

- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.

* fix: replace unsafe unwrap() calls with proper error handling

- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).

* refactor(proxy): simplify verbose logging output

- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.

* feat(proxy): add structured log codes for i18n support

Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel

* feat(pricing): support @ separator in model name matching

- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching

* fix(proxy): improve validation and error handling in proxy config panels

- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation

* fix(proxy): harden error handling and input validation

- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels
2026-01-11 20:50:54 +08:00
Jason 76fa830688 fix(live): sync skills to app directories on config path change
When users change app config directories (claudeConfigDir, codexConfigDir,
geminiConfigDir), MCP servers were being synced to the new paths but Skills
were not. This adds Skill synchronization to sync_current_to_live() to ensure
installed Skills are also copied to the new app directories.
2026-01-11 16:39:50 +08:00
Jason c9a4938866 fix(provider-form): reset baseUrl and apiKey states when switching presets
Fix state synchronization in useBaseUrlState and useApiKeyState hooks
to properly clear values when config is reset. Previously, when switching
from a preset to "custom", the baseUrl and apiKey states would retain
their old values because the sync logic only updated when new values
existed, not when they were cleared.

Changes:
- useBaseUrlState: Always sync baseUrl to config value (empty if undefined)
- useApiKeyState: Remove hasApiKeyField check that prevented clearing
2026-01-11 16:39:50 +08:00
Jason 95ed6d6903 fix(usage): prevent usage script config from leaking between providers
Add key prop to UsageScriptModal to ensure component remounts when
switching between different providers. This fixes issue #569 where
configuring usage query for one provider would incorrectly apply
the same configuration to all providers.

The root cause was that useState initialization only runs on first
mount, and due to useLastValidValue hook keeping the modal rendered
during close animation, the component might not fully unmount when
switching providers rapidly.

Closes #569
2026-01-11 16:39:50 +08:00
Jason 83db457b10 refactor(proxy): disable OpenRouter compat mode by default and hide UI toggle
OpenRouter now natively supports Claude Code compatible API (/v1/messages),
so format transformation (Anthropic ↔ OpenAI) is no longer needed by default.

- Change default value from `true` to `false` in both frontend and backend
- Hide the "OpenRouter Compatibility Mode" toggle in provider form
- Users can still enable it manually by adding `"openrouter_compat_mode": true` in config JSON
- Update unit tests to reflect new default behavior
2026-01-11 16:39:50 +08:00
Xyfer 392756e373 fix(gemini): convert timeout params to Gemini CLI format (#580)
Claude Code/Codex uses startup_timeout_sec and tool_timeout_sec,
but Gemini CLI only supports a single timeout param in milliseconds.

- Collect startup and tool timeout separately with defaults
  - startup_timeout_sec default: 10s
  - tool_timeout_sec default: 60s
- Take max of both values as final timeout
- Support both sec and ms variants
- Remove original fields and insert Gemini-compatible timeout
2026-01-11 10:42:44 +08:00
Jason 6021274b82 fix(ci): temporarily remove Flatpak build
Flatpak build has persistent issues with libdbusmenu dependencies.
Removing it for now to allow release. Can be re-added later with
proper libayatana dependency configuration.
2026-01-09 22:01:33 +08:00
Jason b7fd70075c fix(flatpak): remove --enable-tests=no to fix HAVE_VALGRIND error
libdbusmenu's configure.ac has a bug where AM_CONDITIONAL([HAVE_VALGRIND])
is only defined when tests are enabled. Removing --enable-tests=no allows
the conditional to be properly defined.

Ref: https://bugs.launchpad.net/ubuntu/+source/libdbusmenu/+bug/1708938
2026-01-09 21:47:59 +08:00
Jason eeb6afef01 fix(flatpak): bundle intltool for libdbusmenu build
intltool was removed from org.gnome.Sdk in 2019. libdbusmenu's configure
script requires it even with --disable-nls. Using cleanup: ["*"] ensures
intltool is only used at build time.
2026-01-09 21:26:53 +08:00
Jason d3074eadb5 fix(flatpak): disable NLS for libdbusmenu-gtk3
The Flatpak SDK lacks intltool, causing libdbusmenu-gtk3 configure to
fail. Disabling NLS avoids this dependency.
2026-01-09 21:07:38 +08:00
Jason 31d34a4512 fix(ci): add elfutils for Flatpak build
The libayatana modules added in 2923627b require eu-strip to strip
debug symbols during flatpak-builder execution.
2026-01-09 20:44:23 +08:00
Jason 3ef86bdb99 chore: bump version to v3.9.1
- Update version in package.json, tauri.conf.json, Cargo.toml
- Update version badges and current version in README files
- Add v3.9.1 changelog entry with bug fixes and improvements
2026-01-09 20:19:38 +08:00
Jason df3f8a05c4 fix(presets): rename AiGoCode to AIGoCode
Update the display name casing for AIGoCode across all provider presets
and i18n files to match their official branding.
2026-01-09 20:03:24 +08:00
Jason 8e6fad7af2 fix(windows): correct window title and remove extra titlebar spacing
- Add missing "title" field to tauri.windows.conf.json to display
  "CC Switch" instead of default "Tauri app"
- Make DRAG_BAR_HEIGHT platform-aware: 0px on Windows/Linux (native
  titlebar), 28px on macOS (Overlay mode needs traffic light space)
- Apply same fix to FullScreenPanel component for consistency

Fixes the issue where Windows showed wrong title and had ~28px extra
blank space below the native titlebar introduced in v3.9.0.
2026-01-09 19:45:36 +08:00
Jason f22000a4df feat(presets): add AiGoCode icon and partner promotion
- Add AiGoCode colored icon (blue body #5B7FFF, purple star #7C6AEF)
- Add original SVG source file for reference
- Add icon metadata with keywords and default color
- Add iconColor to AiGoCode presets for Claude, Codex, and Gemini
- Add partner promotion messages in zh/en/ja locales
2026-01-09 16:29:13 +08:00
Dex Miller 412906fb09 feat(logging): add crash logging and improve log management (#562)
* feat(logging): add crash logging and improve log management

- Add panic hook to capture crash info to ~/.cc-switch/crash.log
  - Records timestamp, app version, OS/arch, thread info
  - Full stack trace with force_capture for release builds
  - Safe error handling (no nested panics)

- Enable logging for both Debug and Release builds
  - Info level for all builds
  - Output to console and ~/.cc-switch/logs/
  - 5MB max file size with rotation

- Add log cleanup on startup
  - Keep only 2 most recent log files
  - Works on all platforms

- Change panic strategy from "abort" to "unwind"
  - Required for backtrace capture in release builds

* fix(logging): use OnceLock for config dir and add URL redaction

- Use OnceLock to support custom config directory override for crash.log
- Add redact_url_for_log() to protect sensitive URL parameters in logs
- Change verbose deep link logs from info to debug level
- Move Store refresh before panic_hook init to ensure correct path

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-09 16:23:59 +08:00
w0x7ce f363bb1dd0 Merge origin/main into main
Resolved conflict in src-tauri/src/commands/misc.rs by combining imports from both sides.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 09:12:12 +08:00
w0x7ce a2becf0917 refactor(commands): improve terminal launch code structure and fix env vars
重构 open_provider_terminal 相关代码,提升可维护性和可读性。

主要改进:
- 将 launch_terminal_with_env 拆分为多个职责单一的小函数
  * write_claude_config: 写入配置文件
  * escape_shell_path: 转义 shell 路径
  * generate_wrapper_script: 生成包装脚本
  * launch_macos_terminal / launch_linux_terminal / launch_windows_terminal: 平台特定启动逻辑
- 使用 let Some else 提前返回模式,减少嵌套
- 修复 Gemini 环境变量名为 GEMINI_API_KEY(而非 GOOGLE_API_KEY)
- 完善临时文件清理逻辑:
  * macOS/Linux: 使用 trap EXIT 自动清理
  * Windows: 批处理文件自删除
- 代码格式化和 import 排序优化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 09:17:36 +08:00
tianrking debe4232bc Update misc.rs 2025-12-29 23:47:30 +08:00
w0x7ce 49a2e52b20 fix(ui): only show terminal button for Claude Code providers
open_provider_terminal 功能仅支持 Claude Code,因此只在 Claude 应用中显示终端按钮。

修改内容:
- 在 ProviderList 组件调用时,根据 activeApp 条件传递 onOpenTerminal
- 仅当 activeApp === "claude" 时传递 handleOpenTerminal 回调
- Codex 和 Gemini 不会显示终端按钮(onOpenTerminal 为 undefined)

影响范围:
- src/App.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 10:05:52 +08:00
w0x7ce ed9d9b5436 fix(commands): add auto-cleanup for temp config files when terminal closes
在 open_provider_terminal 功能中添加了临时配置文件的自动清理逻辑,
确保在用户关闭终端窗口时自动删除创建的临时配置文件。

修改内容:
- Linux: 使用 bash -c 嵌套包装脚本,通过 trap EXIT 信号在 shell 退出时清理配置文件
- macOS: 同样使用嵌套 bash + trap 机制来处理清理
- Windows: 保持原有的批处理文件自删除逻辑(del 命令)

技术细节:
- 之前使用 sh -c "...; exec $SHELL" 会导致 trap 失效
- 现在使用 bash -c 'trap ... EXIT; ...; exec bash --norc --noprofile'
- exec 会替换进程但保留 trap 信号处理器
- 当用户关闭终端时,EXIT 信号触发清理操作

影响范围:
- src-tauri/src/commands/misc.rs (launch_terminal_with_env 函数)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 09:36:55 +08:00
w0x7ce 390839a8d5 Merge upstream/main into main
Resolved conflicts in src-tauri/src/lib.rs:
- Kept both: open_provider_terminal (my feature) and universal provider commands (upstream)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 09:14:42 +08:00
w0x7ce 3dcbe313be feat: add provider-specific terminal button
Add a terminal button next to each provider card that opens a new terminal
window with that provider's specific API configuration. This allows using
different providers independently without changing the global setting.

Changes:
- Backend: Add `open_provider_terminal` command that extracts provider
  config and creates a temporary claude settings file
- Frontend: Add terminal button to provider cards with proper callback
  propagation through component hierarchy
- Support macOS (Terminal.app), Linux (gnome-terminal, konsole, etc.),
  and Windows (cmd)

Each provider gets a unique config file named `claude_<providerId>_<pid>.json`
in the temp directory, containing the provider's API configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 17:11:20 +08:00
YoVinchen 4bbd63bf2b feat(templates): add Claude Code Templates marketplace
- Add template repository management and component discovery
- Implement template installation for agents, commands, hooks, MCPs, skills, settings
- Support multi-app installation (Claude/Codex/Gemini)
- Add frontend components for browsing and installing templates
- Include i18n translations for zh/en/ja
2025-12-19 09:42:20 +08:00
119 changed files with 10873 additions and 1648 deletions
+3 -15
View File
@@ -56,7 +56,8 @@ jobs:
libssl-dev \ libssl-dev \
rpm \ rpm \
flatpak \ flatpak \
flatpak-builder flatpak-builder \
elfutils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0 # GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \ sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \ libgtk-3-dev \
@@ -283,19 +284,6 @@ jobs:
else else
echo "No .rpm found (optional)" echo "No .rpm found (optional)"
fi fi
# 额外上传 .flatpak(跨发行版;不参与 Updater
if [ -n "$DEB" ]; then
echo "Building Flatpak bundle from .deb..."
cp "$DEB" flatpak/cc-switch.deb
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
echo "Flatpak bundle created: $NEW_FLATPAK"
else
echo "Skip Flatpak build: no .deb found"
fi
- name: List prepared assets - name: List prepared assets
shell: bash shell: bash
@@ -324,7 +312,7 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew - **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`(绿色版) - **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或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`Flatpak - **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
--- ---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"` 提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
+24
View File
@@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [3.9.1] - 2026-01-09
### Bug Fix Release
This release focuses on stability improvements and crash prevention.
### Added
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
### Fixed
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
---
## [3.9.0] - 2026-01-07 ## [3.9.0] - 2026-01-07
### Stable Release ### Stable Release
+2 -2
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI # All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.9.0-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features ## Features
### Current Version: v3.9.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md) ### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
**v3.8.0 Major Update (2025-11-28)** **v3.8.0 Major Update (2025-11-28)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント # Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.9.0-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@
## 特長 ## 特長
### 現在のバージョン:v3.9.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md) ### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
**v3.8.0 メジャーアップデート (2025-11-28)** **v3.8.0 メジャーアップデート (2025-11-28)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具 # Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.9.0-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@
## 功能特性 ## 功能特性
### 当前版本:v3.9.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md) ### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
**v3.8.0 重大更新(2025-11-28** **v3.8.0 重大更新(2025-11-28**
+238 -775
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -22,6 +22,15 @@ finish-args:
- --filesystem=home - --filesystem=home
modules: modules:
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
- name: intltool
cleanup:
- "*"
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
# Required for tray icon support # Required for tray icon support
- name: libayatana-ido - name: libayatana-ido
buildsystem: cmake-ninja buildsystem: cmake-ninja
@@ -40,7 +49,7 @@ modules:
- --with-gtk=3 - --with-gtk=3
- --disable-dumper - --disable-dumper
- --disable-static - --disable-static
- --enable-tests=no - --disable-nls
sources: sources:
- type: archive - type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.9.0", "version": "3.9.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.9.0" version = "3.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@@ -727,6 +727,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"serial_test", "serial_test",
"sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.9.0" version = "3.9.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
@@ -61,6 +61,7 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
indexmap = { version = "2", features = ["serde"] } indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33" rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] } uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
+97 -2
View File
@@ -65,6 +65,30 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理 // 非 Windows 平台不做任何处理
} }
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
#[cfg(windows)]
fn is_wsl_path(path: &Path) -> bool {
use std::path::{Component, Prefix};
if let Some(Component::Prefix(prefix)) = path.components().next() {
match prefix.kind() {
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
let s = server.to_string_lossy();
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
}
_ => false,
}
} else {
false
}
}
#[cfg(not(windows))]
fn is_wsl_path(_path: &Path) -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct McpStatus { pub struct McpStatus {
@@ -371,6 +395,11 @@ pub fn set_mcp_servers_map(
}; };
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范 // 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
let is_wsl_target = is_wsl_path(&path);
if is_wsl_target {
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
}
let mut out: Map<String, Value> = Map::new(); let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() { for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() { let mut obj = if let Some(map) = spec.as_object() {
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
obj.remove("homepage"); obj.remove("homepage");
obj.remove("docs"); obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式 // Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式WSL 路径除外)
wrap_command_for_windows(&mut obj); if !is_wsl_target {
wrap_command_for_windows(&mut obj);
}
out.insert(id.clone(), Value::Object(obj)); out.insert(id.clone(), Value::Object(obj));
} }
@@ -545,4 +576,68 @@ mod tests {
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"])); assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
} }
} }
/// 测试 WSL 路径检测功能
#[test]
fn test_is_wsl_path_wsl_dollar() {
// wsl$ 格式 - 各种发行版
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
assert!(is_wsl_path(Path::new(
r"\\wsl$\openSUSE-Leap-15.2\home\user"
)));
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
}
#[cfg(not(windows))]
{
// 非 Windows 平台始终返回 false
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
}
}
#[test]
fn test_is_wsl_path_wsl_localhost() {
// wsl.localhost 格式
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\Ubuntu\home\user\.claude"
)));
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
)));
}
}
#[test]
fn test_is_wsl_path_case_insensitive() {
// 大小写不敏感
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
}
}
#[test]
fn test_is_wsl_path_non_wsl() {
// 非 WSL 路径
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
#[cfg(windows)]
{
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
}
}
} }
+9 -1
View File
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径 /// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf { pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() { if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom; return custom;
} }
dirs::home_dir().expect("无法获取用户主目录").join(".codex") get_home_dir().join(".codex")
} }
/// 获取 Codex auth.json 路径 /// 获取 Codex auth.json 路径
+247
View File
@@ -0,0 +1,247 @@
//! 全局出站代理相关命令
//!
//! 提供获取、设置和测试全局代理的 Tauri 命令。
use crate::proxy::http_client;
use crate::store::AppState;
use serde::Serialize;
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::time::{Duration, Instant};
/// 获取全局代理 URL
///
/// 返回当前配置的代理 URL,null 表示直连。
#[tauri::command]
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
log::debug!(
"[GlobalProxy] [GP-010] Read from database: {}",
result
.as_ref()
.map(|u| http_client::mask_url(u))
.unwrap_or_else(|| "None".to_string())
);
Ok(result)
}
/// 设置全局代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串:清除代理(直连)
///
/// 执行顺序:先验证 → 写 DB → 再应用
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
#[tauri::command]
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
// 调试:显示接收到的 URL 信息(不包含敏感内容)
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
log::debug!(
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
url.len(),
has_auth
);
let url_opt = if url.trim().is_empty() {
None
} else {
Some(url.as_str())
};
// 1. 先验证代理配置是否有效(不应用)
http_client::validate_proxy(url_opt)?;
// 2. 验证成功后保存到数据库
state
.db
.set_global_proxy_url(url_opt)
.map_err(|e| e.to_string())?;
// 3. DB 写入成功后再应用到运行态
http_client::apply_proxy(url_opt)?;
log::info!(
"[GlobalProxy] [GP-009] Configuration updated: {}",
url_opt
.map(http_client::mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 代理测试结果
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTestResult {
/// 是否连接成功
pub success: bool,
/// 延迟(毫秒)
pub latency_ms: u64,
/// 错误信息
pub error: Option<String>,
}
/// 测试代理连接
///
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
/// 使用多个测试目标,任一成功即认为代理可用。
#[tauri::command]
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
if url.trim().is_empty() {
return Err("Proxy URL is empty".to_string());
}
let start = Instant::now();
// 构建带代理的临时客户端
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build client: {e}"))?;
// 使用多个测试目标,提高兼容性
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
let test_urls = [
"https://httpbin.org/get",
"https://www.google.com",
"https://api.anthropic.com",
];
let mut last_error = None;
for test_url in test_urls {
match client.head(test_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis() as u64;
log::debug!(
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
http_client::mask_url(&url),
test_url,
resp.status(),
latency
);
return Ok(ProxyTestResult {
success: true,
latency_ms: latency,
error: None,
});
}
Err(e) => {
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
last_error = Some(e);
}
}
}
// 所有测试目标都失败
let latency = start.elapsed().as_millis() as u64;
let error_msg = last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "All test targets failed".to_string());
log::debug!(
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
http_client::mask_url(&url),
error_msg,
latency
);
Ok(ProxyTestResult {
success: false,
latency_ms: latency,
error: Some(error_msg),
})
}
/// 获取当前出站代理状态
///
/// 返回当前是否启用了出站代理以及代理 URL。
#[tauri::command]
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
let url = http_client::get_current_proxy_url();
UpstreamProxyStatus {
enabled: url.is_some(),
proxy_url: url,
}
}
/// 出站代理状态信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpstreamProxyStatus {
/// 是否启用代理
pub enabled: bool,
/// 代理 URL
pub proxy_url: Option<String>,
}
/// 检测到的代理信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedProxy {
/// 代理 URL
pub url: String,
/// 代理类型 (http/socks5)
pub proxy_type: String,
/// 端口
pub port: u16,
}
/// 常见代理端口配置
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
/// 对于 mixed 端口,会同时返回两种协议供用户选择
const PROXY_PORTS: &[(u16, &str, bool)] = &[
(7890, "http", true), // Clash (mixed mode)
(7891, "socks5", false), // Clash SOCKS only
(1080, "socks5", false), // 通用 SOCKS5
(8080, "http", false), // 通用 HTTP
(8888, "http", false), // Charles/Fiddler
(3128, "http", false), // Squid
(10808, "socks5", false), // V2Ray SOCKS
(10809, "http", false), // V2Ray HTTP
];
/// 扫描本地代理
///
/// 检测常见端口是否有代理服务在运行。
/// 使用异步任务避免阻塞 UI 线程。
#[tauri::command]
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
// 使用 spawn_blocking 避免阻塞主线程
tokio::task::spawn_blocking(|| {
let mut found = Vec::new();
for &(port, primary_type, is_mixed) in PROXY_PORTS {
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
// 添加主要类型
found.push(DetectedProxy {
url: format!("{primary_type}://127.0.0.1:{port}"),
proxy_type: primary_type.to_string(),
port,
});
// 对于 mixed 端口,同时添加另一种协议
if is_mixed {
let alt_type = if primary_type == "http" {
"socks5"
} else {
"http"
};
found.push(DetectedProxy {
url: format!("{alt_type}://127.0.0.1:{port}"),
proxy_type: alt_type.to_string(),
port,
});
}
}
}
found
})
.await
.unwrap_or_default()
}
+426 -12
View File
@@ -1,7 +1,14 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
use std::str::FromStr;
use tauri::AppHandle; use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -85,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"]; let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new(); let mut results = Vec::new();
// 用于获取远程版本的 client // 使用全局 HTTP 客户端(已包含代理配置)
let client = reqwest::Client::builder() let client = crate::proxy::http_client::get();
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
for tool in tools { for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径 // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = { let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
try_get_version_wsl(tool, &distro)
} else {
// 先尝试直接执行 // 先尝试直接执行
let direct_result = try_get_version(tool); let direct_result = try_get_version(tool);
@@ -142,11 +148,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
} }
} }
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
/// 从版本输出中提取纯版本号 /// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String { fn extract_version(raw: &str) -> String {
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx VERSION_RE
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap(); .find(raw)
re.find(raw)
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string()) .unwrap_or_else(|| raw.to_string())
} }
@@ -178,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
if out.status.success() { if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout }; let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() { if raw.is_empty() {
(None, Some("未安装或无法执行".to_string())) (None, Some("not installed or not executable".to_string()))
} else { } else {
(Some(extract_version(raw)), None) (Some(extract_version(raw)), None)
} }
@@ -187,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
( (
None, None,
Some(if err.is_empty() { Some(if err.is_empty() {
"未安装或无法执行".to_string() "not installed or not executable".to_string()
} else { } else {
err err
}), }),
@@ -198,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
} }
} }
/// 校验 WSL 发行版名称是否合法
/// WSL 发行版名称只允许字母、数字、连字符和下划线
#[cfg(target_os = "windows")]
fn is_valid_wsl_distro_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini"].contains(&tool),
"unexpected tool name: {tool}"
);
// 校验 distro 名称,防止命令注入
if !is_valid_wsl_distro_name(distro) {
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
}
let output = Command::new("wsl.exe")
.args([
"-d",
distro,
"--",
"sh",
"-lc",
&format!("{tool} --version"),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(
None,
Some(format!("[WSL:{distro}] not installed or not executable")),
)
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(format!(
"[WSL:{distro}] {}",
if err.is_empty() {
"not installed or not executable".to_string()
} else {
err
}
)),
)
}
}
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
}
}
/// 非 Windows 平台的 WSL 版本检测存根
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
(
None,
Some("WSL check not supported on this platform".to_string()),
)
}
/// 扫描常见路径查找 CLI /// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) { fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command; use std::process::Command;
@@ -293,5 +384,328 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
} }
} }
(None, Some("未安装或无法执行".to_string())) (None, Some("not installed or not executable".to_string()))
}
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
_ => None,
}?;
wsl_distro_from_path(&override_dir)
}
/// 从 UNC 路径中提取 WSL 发行版名称
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
#[cfg(target_os = "windows")]
fn wsl_distro_from_path(path: &Path) -> Option<String> {
use std::path::{Component, Prefix};
let Some(Component::Prefix(prefix)) = path.components().next() else {
return None;
};
match prefix.kind() {
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
let server_name = server.to_string_lossy();
if server_name.eq_ignore_ascii_case("wsl$")
|| server_name.eq_ignore_ascii_case("wsl.localhost")
{
let distro = share.to_string_lossy().to_string();
if !distro.is_empty() {
return Some(distro);
}
}
None
}
_ => None,
}
}
/// 非 Windows 平台不支持 WSL 路径解析
#[cfg(not(target_os = "windows"))]
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
None
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
let provider = providers
.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();
let Some(obj) = config.as_object() else {
return env_vars;
};
// 处理 env 字段(Claude/Gemini 通用)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
if let Some(key) = base_url_key {
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
env_vars.push((key.to_string(), url_str.to_string()));
}
}
}
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
if *app_type == AppType::Codex {
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
}
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
if *app_type == AppType::Gemini {
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
}
}
env_vars
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
"claude_{}_{}.json",
provider_id,
std::process::id()
));
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
// 转义配置文件路径用于 shell
let config_path_escaped = escape_shell_path(&config_file);
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
/// 写入 claude 配置文件
fn write_claude_config(
config_file: &std::path::Path,
env_vars: &[(String, String)],
) -> Result<(), String> {
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();
for (key, value) in env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
let config_json =
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// 转义 shell 路径
fn escape_shell_path(path: &std::path::Path) -> String {
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace(' ', "\\ ")
}
/// 生成 bash 包装脚本,用于清理临时文件
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
format!(
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
)
}
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
let script = format!(
r#"tell application "Terminal"
activate
do script "{}"
end tell"#,
shell_script.replace('\"', "\\\"")
);
Command::new("osascript")
.arg("-e")
.arg(&script)
.spawn()
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
Ok(())
}
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let terminals = [
"gnome-terminal",
"konsole",
"xfce4-terminal",
"mate-terminal",
"lxterminal",
"alacritty",
"kitty",
];
let config_path_for_bash = config_file.to_string_lossy();
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
let mut last_error = String::from("未找到可用的终端");
for terminal in terminals {
// 检查终端是否存在
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
let result = match terminal {
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
.arg("--")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
_ => Command::new(terminal)
.arg("-e")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
};
match result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("启动 {} 失败: {}", terminal, e);
}
}
}
}
// 清理配置文件
let _ = std::fs::remove_file(config_file);
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let content = format!(
"@echo off
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
if errorlevel 1 (
echo.
echo Press any key to close...
pause >nul
)",
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
Ok(())
} }
+4
View File
@@ -4,6 +4,7 @@ mod config;
mod deeplink; mod deeplink;
mod env; mod env;
mod failover; mod failover;
mod global_proxy;
mod import_export; mod import_export;
mod mcp; mod mcp;
mod misc; mod misc;
@@ -14,12 +15,14 @@ mod proxy;
mod settings; mod settings;
pub mod skill; pub mod skill;
mod stream_check; mod stream_check;
mod template;
mod usage; mod usage;
pub use config::*; pub use config::*;
pub use deeplink::*; pub use deeplink::*;
pub use env::*; pub use env::*;
pub use failover::*; pub use failover::*;
pub use global_proxy::*;
pub use import_export::*; pub use import_export::*;
pub use mcp::*; pub use mcp::*;
pub use misc::*; pub use misc::*;
@@ -30,4 +33,5 @@ pub use proxy::*;
pub use settings::*; pub use settings::*;
pub use skill::*; pub use skill::*;
pub use stream_check::*; pub use stream_check::*;
pub use template::*;
pub use usage::*; pub use usage::*;
+21
View File
@@ -59,3 +59,24 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
pub async fn get_auto_launch_status() -> Result<bool, String> { pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}")) crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
} }
/// 获取整流器配置
#[tauri::command]
pub async fn get_rectifier_config(
state: tauri::State<'_, crate::AppState>,
) -> Result<crate::proxy::types::RectifierConfig, String> {
state.db.get_rectifier_config().map_err(|e| e.to_string())
}
/// 设置整流器配置
#[tauri::command]
pub async fn set_rectifier_config(
state: tauri::State<'_, crate::AppState>,
config: crate::proxy::types::RectifierConfig,
) -> Result<bool, String> {
state
.db
.set_rectifier_config(&config)
.map_err(|e| e.to_string())?;
Ok(true)
}
+278
View File
@@ -0,0 +1,278 @@
use tauri::State;
use crate::database::lock_conn;
use crate::error::AppError;
use crate::services::{
BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent,
TemplateRepo, TemplateService,
};
use crate::store::AppState;
/// 刷新模板索引
#[tauri::command]
pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> {
let service = TemplateService::new().map_err(|e| e.to_string())?;
let db = state.db.clone();
// 使用 spawn_blocking 在后台线程中执行数据库操作
tokio::task::spawn_blocking(move || {
let conn = lock_conn!(db.conn);
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
service
.refresh_index(&conn)
.await
.map_err(|e| e.to_string())
})
})
.await
.map_err(|e| format!("任务执行失败: {e}"))??;
Ok(())
}
/// 获取模板组件列表
#[tauri::command]
pub fn list_template_components(
state: State<'_, AppState>,
component_type: Option<String>,
category: Option<String>,
search: Option<String>,
page: u32,
page_size: u32,
app_type: Option<String>,
) -> Result<PaginatedResult<TemplateComponent>, AppError> {
let (mut components, total) = state.db.list_components(
component_type.as_deref(),
category.as_deref(),
search.as_deref(),
page,
page_size,
)?;
// 填充 installed 字段
if let Some(app) = &app_type {
let installed_ids = state.db.get_installed_component_ids(app)?;
for component in &mut components {
if let Some(id) = component.id {
component.installed = installed_ids.contains(&id);
}
}
}
Ok(PaginatedResult {
items: components,
total: total as i64,
page,
page_size,
})
}
/// 获取组件详情
#[tauri::command]
pub async fn get_template_component(
state: State<'_, AppState>,
id: i64,
) -> Result<ComponentDetail, String> {
let service = TemplateService::new().map_err(|e| e.to_string())?;
let db = state.db.clone();
let detail = tokio::task::spawn_blocking(move || {
let conn = lock_conn!(db.conn);
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
service
.get_component(&conn, id)
.await
.map_err(|e| e.to_string())
})
})
.await
.map_err(|e| format!("任务执行失败: {e}"))??;
Ok(detail)
}
/// 安装组件
#[tauri::command]
pub async fn install_template_component(
state: State<'_, AppState>,
id: i64,
app_type: String,
) -> Result<(), String> {
let service = TemplateService::new().map_err(|e| e.to_string())?;
let db = state.db.clone();
tokio::task::spawn_blocking(move || {
let conn = lock_conn!(db.conn);
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
service
.install_component(&conn, id, &app_type)
.await
.map_err(|e| e.to_string())
})
})
.await
.map_err(|e| format!("任务执行失败: {e}"))??;
Ok(())
}
/// 卸载组件
#[tauri::command]
pub fn uninstall_template_component(
state: State<'_, AppState>,
id: i64,
app_type: String,
) -> Result<(), AppError> {
let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?;
let conn = lock_conn!(state.db.conn);
service
.uninstall_component(&conn, id, &app_type)
.map_err(|e| AppError::Config(e.to_string()))?;
Ok(())
}
/// 批量安装组件
#[tauri::command]
pub async fn batch_install_template_components(
state: State<'_, AppState>,
ids: Vec<i64>,
app_type: String,
) -> Result<BatchInstallResult, String> {
let service = TemplateService::new().map_err(|e| e.to_string())?;
let db = state.db.clone();
let result = tokio::task::spawn_blocking(move || {
let conn = lock_conn!(db.conn);
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
service
.batch_install(&conn, ids, &app_type)
.await
.map_err(|e| e.to_string())
})
})
.await
.map_err(|e| format!("任务执行失败: {e}"))??;
Ok(result)
}
/// 获取模板仓库列表
#[tauri::command]
pub fn list_template_repos(state: State<'_, AppState>) -> Result<Vec<TemplateRepo>, AppError> {
state.db.list_repos()
}
/// 添加模板仓库
#[tauri::command]
pub fn add_template_repo(
state: State<'_, AppState>,
owner: String,
name: String,
branch: String,
) -> Result<i64, AppError> {
let repo = TemplateRepo::new(owner, name, branch);
state.db.insert_repo(&repo)
}
/// 删除模板仓库
#[tauri::command]
pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
state.db.delete_repo(id)
}
/// 切换仓库启用状态
#[tauri::command]
pub fn toggle_template_repo(
state: State<'_, AppState>,
id: i64,
enabled: bool,
) -> Result<(), AppError> {
state.db.toggle_repo_enabled(id, enabled)
}
/// 获取组件分类列表
#[tauri::command]
pub fn list_template_categories(
state: State<'_, AppState>,
component_type: Option<String>,
) -> Result<Vec<String>, AppError> {
let conn = lock_conn!(state.db.conn);
// 构建查询语句
let sql = if let Some(ct) = component_type {
format!(
"SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category"
)
} else {
"SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string()
};
let mut stmt = conn.prepare(&sql)?;
let categories = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<String>, _>>()?;
Ok(categories)
}
/// 获取已安装组件列表
#[tauri::command]
pub fn list_installed_components(
state: State<'_, AppState>,
app_type: Option<String>,
component_type: Option<String>,
) -> Result<Vec<InstalledComponent>, AppError> {
state
.db
.list_installed_components(app_type.as_deref(), component_type.as_deref())
}
/// 预览组件内容
#[tauri::command]
pub async fn preview_component_content(
state: State<'_, AppState>,
id: i64,
) -> Result<String, String> {
let service = TemplateService::new().map_err(|e| e.to_string())?;
let db = state.db.clone();
tokio::task::spawn_blocking(move || {
let conn = lock_conn!(db.conn);
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
service
.preview_content(&conn, id)
.await
.map_err(|e| e.to_string())
})
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
/// 获取市场组合列表
#[tauri::command]
pub async fn list_marketplace_bundles(
state: State<'_, AppState>,
) -> Result<Vec<crate::services::MarketplaceBundle>, String> {
let service = TemplateService::new().map_err(|e| e.to_string())?;
let db = state.db.clone();
tokio::task::spawn_blocking(move || {
let conn = lock_conn!(db.conn);
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
service
.fetch_marketplace_bundles(&conn)
.await
.map_err(|e| e.to_string())
})
})
.await
.map_err(|e| format!("任务执行失败: {e}"))?
}
+10 -6
View File
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError; use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Claude Code 配置目录路径 /// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf { pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() { if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom; return custom;
} }
dirs::home_dir() get_home_dir().join(".claude")
.expect("无法获取用户主目录")
.join(".claude")
} }
/// 默认 Claude MCP 配置文件路径 (~/.claude.json) /// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf { pub fn get_default_claude_mcp_path() -> PathBuf {
dirs::home_dir() get_home_dir().join(".claude.json")
.expect("无法获取用户主目录")
.join(".claude.json")
} }
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> { fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
+5 -2
View File
@@ -73,11 +73,14 @@ impl Database {
params![ params![
server.id, server.id,
server.name, server.name,
serde_json::to_string(&server.server).unwrap(), serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
"Failed to serialize server config: {e}"
)))?,
server.description, server.description,
server.homepage, server.homepage,
server.docs, server.docs,
serde_json::to_string(&server.tags).unwrap(), serde_json::to_string(&server.tags)
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
server.apps.claude, server.apps.claude,
server.apps.codex, server.apps.codex,
server.apps.gemini, server.apps.gemini,
+1
View File
@@ -10,6 +10,7 @@ pub mod proxy;
pub mod settings; pub mod settings;
pub mod skills; pub mod skills;
pub mod stream_check; pub mod stream_check;
pub mod template;
pub mod universal_providers; pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 所有 DAO 方法都通过 Database impl 提供,无需单独导出
+13 -5
View File
@@ -220,7 +220,9 @@ impl Database {
WHERE id = ?13 AND app_type = ?14", WHERE id = ?13 AND app_type = ?14",
params![ params![
provider.name, provider.name,
serde_json::to_string(&provider.settings_config).unwrap(), serde_json::to_string(&provider.settings_config).map_err(|e| {
AppError::Database(format!("Failed to serialize settings_config: {e}"))
})?,
provider.website_url, provider.website_url,
provider.category, provider.category,
provider.created_at, provider.created_at,
@@ -228,7 +230,9 @@ impl Database {
provider.notes, provider.notes,
provider.icon, provider.icon,
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
"Failed to serialize meta: {e}"
)))?,
is_current, is_current,
in_failover_queue, in_failover_queue,
provider.id, provider.id,
@@ -247,7 +251,8 @@ impl Database {
provider.id, provider.id,
app_type, app_type,
provider.name, provider.name,
serde_json::to_string(&provider.settings_config).unwrap(), serde_json::to_string(&provider.settings_config)
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
provider.website_url, provider.website_url,
provider.category, provider.category,
provider.created_at, provider.created_at,
@@ -255,7 +260,8 @@ impl Database {
provider.notes, provider.notes,
provider.icon, provider.icon,
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone)
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
is_current, is_current,
in_failover_queue, in_failover_queue,
], ],
@@ -324,7 +330,9 @@ impl Database {
conn.execute( conn.execute(
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
params![ params![
serde_json::to_string(settings_config).unwrap(), serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
"Failed to serialize settings_config: {e}"
)))?,
provider_id, provider_id,
app_type app_type
], ],
+58
View File
@@ -63,6 +63,41 @@ impl Database {
} }
} }
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
/// 获取全局出站代理 URL
///
/// 返回 None 表示未配置或已清除代理(直连)
/// 返回 Some(url) 表示已配置代理
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
}
/// 设置全局出站代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串或 None:清除代理设置(直连)
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
match url {
Some(u) if !u.trim().is_empty() => {
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
}
_ => {
// 清除代理设置
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![Self::GLOBAL_PROXY_URL_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)--- // --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态 /// 获取指定应用的代理接管状态
@@ -128,4 +163,27 @@ impl Database {
log::info!("已清除所有代理接管状态"); log::info!("已清除所有代理接管状态");
Ok(()) Ok(())
} }
// --- 整流器配置 ---
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
None => Ok(crate::proxy::types::RectifierConfig::default()),
}
}
/// 更新整流器配置
pub fn set_rectifier_config(
&self,
config: &crate::proxy::types::RectifierConfig,
) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
self.set_setting("rectifier_config", &json)
}
} }
+595
View File
@@ -0,0 +1,595 @@
//! Template 数据访问对象
//!
//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::template::{
ComponentType, InstalledComponent, TemplateComponent, TemplateRepo,
};
use chrono::{DateTime, Utc};
use rusqlite::{params, OptionalExtension};
impl Database {
// ==================== TemplateRepo 相关 ====================
/// 插入模板仓库
pub fn insert_repo(&self, repo: &TemplateRepo) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![repo.owner, repo.name, repo.branch, repo.enabled, now, now],
)
.map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?;
Ok(conn.last_insert_rowid())
}
/// 获取单个模板仓库
pub fn get_repo(&self, id: i64) -> Result<Option<TemplateRepo>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, owner, name, branch, enabled, created_at, updated_at
FROM template_repos
WHERE id = ?1",
)
.map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?;
let repo = stmt
.query_row(params![id], |row| {
Ok(TemplateRepo {
id: Some(row.get(0)?),
owner: row.get(1)?,
name: row.get(2)?,
branch: row.get(3)?,
enabled: row.get(4)?,
created_at: row
.get::<_, String>(5)
.ok()
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
updated_at: row
.get::<_, String>(6)
.ok()
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
})
})
.optional()
.map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?;
Ok(repo)
}
/// 获取所有模板仓库
pub fn list_repos(&self) -> Result<Vec<TemplateRepo>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, owner, name, branch, enabled, created_at, updated_at
FROM template_repos
ORDER BY created_at DESC",
)
.map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?;
let repo_iter = stmt
.query_map([], |row| {
Ok(TemplateRepo {
id: Some(row.get(0)?),
owner: row.get(1)?,
name: row.get(2)?,
branch: row.get(3)?,
enabled: row.get(4)?,
created_at: row
.get::<_, String>(5)
.ok()
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
updated_at: row
.get::<_, String>(6)
.ok()
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
})
})
.map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?;
let mut repos = Vec::new();
for repo_res in repo_iter {
repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?);
}
Ok(repos)
}
/// 更新模板仓库
pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> {
let repo_id = repo
.id
.ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?;
let conn = lock_conn!(self.conn);
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE template_repos
SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5
WHERE id = ?6",
params![
repo.owner,
repo.name,
repo.branch,
repo.enabled,
now,
repo_id
],
)
.map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?;
Ok(())
}
/// 删除模板仓库
pub fn delete_repo(&self, id: i64) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?;
Ok(())
}
/// 切换仓库启用状态
pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
params![enabled, now, id],
)
.map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?;
Ok(())
}
// ==================== TemplateComponent 相关 ====================
/// 插入模板组件
pub fn insert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO template_components
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
component.repo_id,
component.component_type.as_str(),
component.category,
component.name,
component.path,
component.description,
component.content_hash,
now,
now
],
)
.map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?;
Ok(conn.last_insert_rowid())
}
/// 获取单个模板组件
pub fn get_component(&self, id: i64) -> Result<Option<TemplateComponent>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
FROM template_components
WHERE id = ?1",
)
.map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?;
let component = stmt
.query_row(params![id], |row| {
let component_type_str: String = row.get(2)?;
let component_type = ComponentType::from_str(&component_type_str)
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
Ok(TemplateComponent {
id: Some(row.get(0)?),
repo_id: row.get(1)?,
component_type,
category: row.get(3)?,
name: row.get(4)?,
path: row.get(5)?,
description: row.get(6)?,
content_hash: row.get(7)?,
installed: false, // 需要单独查询
})
})
.optional()
.map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?;
Ok(component)
}
/// 获取组件列表(支持过滤和分页)
pub fn list_components(
&self,
component_type: Option<&str>,
category: Option<&str>,
search: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<TemplateComponent>, u32), AppError> {
let conn = lock_conn!(self.conn);
// 构建 WHERE 子句
let mut where_clauses = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ct) = component_type {
where_clauses.push("component_type = ?");
params_vec.push(Box::new(ct.to_string()));
}
if let Some(cat) = category {
where_clauses.push("category = ?");
params_vec.push(Box::new(cat.to_string()));
}
if let Some(s) = search {
where_clauses.push("(name LIKE ? OR description LIKE ?)");
let pattern = format!("%{s}%");
params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", where_clauses.join(" AND "))
};
// 查询总数
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
let total: u32 = {
let mut stmt = conn
.prepare(&count_sql)
.map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?;
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
stmt.query_row(&params_refs[..], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))?
};
// 查询数据
let offset = (page.saturating_sub(1)) * page_size;
let query_sql = format!(
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
FROM template_components
{where_sql}
ORDER BY name ASC
LIMIT ? OFFSET ?"
);
let mut stmt = conn
.prepare(&query_sql)
.map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?;
params_vec.push(Box::new(page_size));
params_vec.push(Box::new(offset));
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let component_iter = stmt
.query_map(&params_refs[..], |row| {
let component_type_str: String = row.get(2)?;
let component_type = ComponentType::from_str(&component_type_str)
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
Ok(TemplateComponent {
id: Some(row.get(0)?),
repo_id: row.get(1)?,
component_type,
category: row.get(3)?,
name: row.get(4)?,
path: row.get(5)?,
description: row.get(6)?,
content_hash: row.get(7)?,
installed: false, // 需要单独查询
})
})
.map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?;
let mut components = Vec::new();
for component_res in component_iter {
components
.push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?);
}
Ok((components, total))
}
/// 删除仓库的所有组件
pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM template_components WHERE repo_id = ?1",
params![repo_id],
)
.map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?;
Ok(())
}
/// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在)
pub fn upsert_component(&self, component: &TemplateComponent) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO template_components
(repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(repo_id, component_type, path) DO UPDATE SET
category = excluded.category,
name = excluded.name,
description = excluded.description,
content_hash = excluded.content_hash,
updated_at = excluded.updated_at",
params![
component.repo_id,
component.component_type.as_str(),
component.category,
component.name,
component.path,
component.description,
component.content_hash,
now,
now
],
)
.map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?;
Ok(conn.last_insert_rowid())
}
// ==================== InstalledComponent 相关 ====================
/// 插入已安装组件
pub fn insert_installed(&self, installed: &InstalledComponent) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
let installed_at = installed.installed_at.to_rfc3339();
conn.execute(
"INSERT INTO installed_components
(component_id, component_type, name, path, app_type, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
installed.component_id,
installed.component_type.as_str(),
installed.name,
installed.path,
installed.app_type,
installed_at
],
)
.map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?;
Ok(conn.last_insert_rowid())
}
/// 删除已安装组件
pub fn delete_installed(
&self,
component_type: &str,
path: &str,
app_type: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM installed_components
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
params![component_type, path, app_type],
)
.map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?;
Ok(())
}
/// 获取已安装组件列表
pub fn list_installed(
&self,
app_type: Option<&str>,
) -> Result<Vec<InstalledComponent>, AppError> {
let conn = lock_conn!(self.conn);
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type
{
(
"SELECT id, component_id, component_type, name, path, app_type, installed_at
FROM installed_components
WHERE app_type = ?
ORDER BY installed_at DESC"
.to_string(),
vec![Box::new(at.to_string())],
)
} else {
(
"SELECT id, component_id, component_type, name, path, app_type, installed_at
FROM installed_components
ORDER BY installed_at DESC"
.to_string(),
vec![],
)
};
let mut stmt = conn
.prepare(&sql)
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let installed_iter = stmt
.query_map(&params_refs[..], |row| {
let component_type_str: String = row.get(2)?;
let component_type = ComponentType::from_str(&component_type_str)
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
let installed_at_str: String = row.get(6)?;
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|_| rusqlite::Error::InvalidQuery)?;
Ok(InstalledComponent {
id: Some(row.get(0)?),
component_id: row.get(1)?,
component_type,
name: row.get(3)?,
path: row.get(4)?,
app_type: row.get(5)?,
installed_at,
})
})
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
let mut installed = Vec::new();
for installed_res in installed_iter {
installed.push(
installed_res
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
);
}
Ok(installed)
}
/// 获取已安装组件列表(支持 app_type 和 component_type 过滤)
pub fn list_installed_components(
&self,
app_type: Option<&str>,
component_type: Option<&str>,
) -> Result<Vec<InstalledComponent>, AppError> {
let conn = lock_conn!(self.conn);
// 构建 WHERE 子句
let mut where_clauses = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(at) = app_type {
where_clauses.push("app_type = ?");
params_vec.push(Box::new(at.to_string()));
}
if let Some(ct) = component_type {
where_clauses.push("component_type = ?");
params_vec.push(Box::new(ct.to_string()));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", where_clauses.join(" AND "))
};
let sql = format!(
"SELECT id, component_id, component_type, name, path, app_type, installed_at
FROM installed_components
{where_sql}
ORDER BY installed_at DESC"
);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let installed_iter = stmt
.query_map(&params_refs[..], |row| {
let component_type_str: String = row.get(2)?;
let component_type = ComponentType::from_str(&component_type_str)
.ok_or_else(|| rusqlite::Error::InvalidQuery)?;
let installed_at_str: String = row.get(6)?;
let installed_at = DateTime::parse_from_rfc3339(&installed_at_str)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|_| rusqlite::Error::InvalidQuery)?;
Ok(InstalledComponent {
id: Some(row.get(0)?),
component_id: row.get(1)?,
component_type,
name: row.get(3)?,
path: row.get(4)?,
app_type: row.get(5)?,
installed_at,
})
})
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?;
let mut installed = Vec::new();
for installed_res in installed_iter {
installed.push(
installed_res
.map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?,
);
}
Ok(installed)
}
/// 检查组件是否已安装
pub fn is_installed(
&self,
component_type: &str,
path: &str,
app_type: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM installed_components
WHERE component_type = ?1 AND path = ?2 AND app_type = ?3",
params![component_type, path, app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?;
Ok(count > 0)
}
/// 获取指定应用已安装的组件 ID 列表
pub fn get_installed_component_ids(&self, app_type: &str) -> Result<Vec<i64>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL",
)
.map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?;
let ids: Vec<i64> = stmt
.query_map(params![app_type], |row| row.get(0))
.map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?
.filter_map(|r| r.ok())
.collect();
Ok(ids)
}
}
+181
View File
@@ -302,6 +302,90 @@ impl Database {
[], [],
); );
// 15. Template Repos 表 (模板仓库)
conn.execute(
"CREATE TABLE IF NOT EXISTS template_repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 插入默认模板仓库
conn.execute(
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 16. Template Components 表 (模板组件)
conn.execute(
"CREATE TABLE IF NOT EXISTS template_components (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL,
component_type TEXT NOT NULL,
category TEXT,
name TEXT NOT NULL,
path TEXT NOT NULL,
description TEXT,
content_hash TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
UNIQUE(repo_id, component_type, path)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 为 template_components 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_components_type
ON template_components(component_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_components_category
ON template_components(category)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 17. Installed Components 表 (已安装组件)
conn.execute(
"CREATE TABLE IF NOT EXISTS installed_components (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component_id INTEGER,
component_type TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
app_type TEXT NOT NULL,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
UNIQUE(component_type, path, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 为 installed_components 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
ON installed_components(app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -346,6 +430,12 @@ impl Database {
Self::migrate_v2_to_v3(conn)?; Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?; Self::set_user_version(conn, 3)?;
} }
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
// 3 => {
// log::info!("迁移数据库从 v3 到 v4Claude Code Templates 市场功能)");
// Self::migrate_v3_to_v4(conn)?;
// Self::set_user_version(conn, 4)?;
// }
_ => { _ => {
return Err(AppError::Database(format!( return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -786,6 +876,97 @@ impl Database {
Ok(()) Ok(())
} }
/// v3 -> v4 迁移:添加 Claude Code Templates 功能相关表
#[allow(dead_code)]
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
// 1. template_repos 表 - 存储模板仓库信息
conn.execute(
"CREATE TABLE IF NOT EXISTS template_repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name)
)",
[],
)
.map_err(|e| AppError::Database(format!("创建 template_repos 表失败: {e}")))?;
// 2. template_components 表 - 存储从仓库中发现的模板组件
conn.execute(
"CREATE TABLE IF NOT EXISTS template_components (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL,
component_type TEXT NOT NULL,
category TEXT,
name TEXT NOT NULL,
path TEXT NOT NULL,
description TEXT,
content_hash TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE,
UNIQUE(repo_id, component_type, path)
)",
[],
)
.map_err(|e| AppError::Database(format!("创建 template_components 表失败: {e}")))?;
// 为 template_components 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_components_type
ON template_components(component_type)",
[],
)
.map_err(|e| AppError::Database(format!("创建 template_components 类型索引失败: {e}")))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_components_category
ON template_components(category)",
[],
)
.map_err(|e| AppError::Database(format!("创建 template_components 分类索引失败: {e}")))?;
// 3. installed_components 表 - 存储已安装的组件
conn.execute(
"CREATE TABLE IF NOT EXISTS installed_components (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component_id INTEGER,
component_type TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
app_type TEXT NOT NULL,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL,
UNIQUE(component_type, path, app_type)
)",
[],
)
.map_err(|e| AppError::Database(format!("创建 installed_components 表失败: {e}")))?;
// 为 installed_components 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_installed_components_app
ON installed_components(app_type)",
[],
)
.map_err(|e| AppError::Database(format!("创建 installed_components 应用索引失败: {e}")))?;
// 4. 插入默认模板仓库
conn.execute(
"INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled)
VALUES ('yovinchen', 'claude-code-templates', 'main', 1)",
[],
)
.map_err(|e| AppError::Database(format!("插入默认模板仓库失败: {e}")))?;
log::info!("已创建 Claude Code Templates 相关表并插入默认仓库");
Ok(())
}
/// v2 -> v3 迁移:Skills 统一管理架构 /// v2 -> v3 迁移:Skills 统一管理架构
/// ///
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构, /// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
+88 -8
View File
@@ -518,8 +518,7 @@ fn model_pricing_is_seeded_on_init() {
assert!( assert!(
count > 0, count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {}", "模型定价数据应该在初始化时自动填充,实际数量: {count}"
count
); );
// 验证包含 Claude 模型 // 验证包含 Claude 模型
@@ -532,8 +531,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check claude"); .expect("check claude");
assert!( assert!(
claude_count > 0, claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {}", "应该包含 Claude 模型定价,实际数量: {claude_count}"
claude_count
); );
// 验证包含 GPT 模型 // 验证包含 GPT 模型
@@ -546,8 +544,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check gpt"); .expect("check gpt");
assert!( assert!(
gpt_count > 0, gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {}", "应该包含 GPT 模型定价,实际数量: {gpt_count}"
gpt_count
); );
// 验证包含 Gemini 模型 // 验证包含 Gemini 模型
@@ -560,7 +557,90 @@ fn model_pricing_is_seeded_on_init() {
.expect("check gemini"); .expect("check gemini");
assert!( assert!(
gemini_count > 0, gemini_count > 0,
"应该包含 Gemini 模型定价,实际数量: {}", "应该包含 Gemini 模型定价,实际数量: {gemini_count}"
gemini_count
); );
} }
#[test]
fn test_v2_to_v3_migration_creates_template_tables() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建 v2 schema(即当前完整的 schema
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, 2).expect("set v2 version");
// 应用迁移到 v3
Database::apply_schema_migrations_on_conn(&conn).expect("migrate to v3");
// 验证版本号已更新
assert_eq!(
Database::get_user_version(&conn).expect("read version"),
3,
"版本应该更新为 3"
);
// 验证 template_repos 表存在且包含默认仓库
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM template_repos", [], |row| row.get(0))
.expect("count template_repos");
assert_eq!(count, 1, "应该有 1 个默认模板仓库");
let (owner, name, branch): (String, String, String) = conn
.query_row(
"SELECT owner, name, branch FROM template_repos WHERE id = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.expect("read default repo");
assert_eq!(owner, "yovinchen", "默认仓库 owner 应该是 yovinchen");
assert_eq!(
name, "claude-code-templates",
"默认仓库 name 应该是 claude-code-templates"
);
assert_eq!(branch, "main", "默认仓库 branch 应该是 main");
// 验证 template_components 表存在
assert!(
Database::table_exists(&conn, "template_components").expect("check table"),
"template_components 表应该存在"
);
// 验证 installed_components 表存在
assert!(
Database::table_exists(&conn, "installed_components").expect("check table"),
"installed_components 表应该存在"
);
// 验证索引存在
let index_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND (
name = 'idx_template_components_type' OR
name = 'idx_template_components_category' OR
name = 'idx_installed_components_app'
)",
[],
|row| row.get(0),
)
.expect("count indexes");
assert_eq!(index_count, 3, "应该创建 3 个索引");
// 验证外键约束
let fk_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_foreign_key_list('template_components')",
[],
|row| row.get(0),
)
.expect("count fk");
assert_eq!(fk_count, 1, "template_components 应该有 1 个外键约束");
let fk_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_foreign_key_list('installed_components')",
[],
|row| row.get(0),
)
.expect("count fk");
assert_eq!(fk_count, 1, "installed_components 应该有 1 个外键约束");
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
/// Provider homepage URL /// Provider homepage URL
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>, pub homepage: Option<String>,
/// API endpoint/base URL /// API endpoint/base URL (supports comma-separated multiple URLs)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>, pub endpoint: Option<String>,
/// API key /// API key
+6 -2
View File
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
validate_url(hp, "homepage")?; validate_url(hp, "homepage")?;
} }
} }
// Validate each endpoint (supports comma-separated multiple URLs)
if let Some(ref ep) = endpoint { if let Some(ref ep) = endpoint {
if !ep.is_empty() { for (i, url) in ep.split(',').enumerate() {
validate_url(ep, "endpoint")?; let trimmed = url.trim();
if !trimmed.is_empty() {
validate_url(trimmed, &format!("endpoint[{i}]"))?;
}
} }
} }
+91 -48
View File
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
} }
// Step 1: Merge config file if provided (v3.8+) // Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?; let mut merged_request = parse_and_merge_config(&request)?;
// Extract required fields (now as Option) // Extract required fields (now as Option)
let app_str = merged_request let app_str = merged_request
.app .app
.as_ref() .clone()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?; .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
let api_key = merged_request.api_key.as_ref().ok_or_else(|| { let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
)); ));
} }
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| { // Get endpoint: supports comma-separated multiple URLs (first is primary)
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string()) AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
})?; })?;
if endpoint.is_empty() { // Parse endpoints: split by comma, first is primary
return Err(AppError::InvalidInput( let all_endpoints: Vec<String> = endpoint_str
"Endpoint cannot be empty".to_string(), .split(',')
)); .map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.collect();
let primary_endpoint = all_endpoints
.first()
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
// Auto-infer homepage from endpoint if not provided
if merged_request
.homepage
.as_ref()
.is_none_or(|s| s.is_empty())
{
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
} }
let homepage = merged_request.homepage.as_ref().ok_or_else(|| { let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
let name = merged_request let name = merged_request
.name .name
.as_ref() .clone()
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?; .ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
// Parse app type // Parse app type
let app_type = AppType::from_str(app_str) let app_type = AppType::from_str(&app_str)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?; .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
// Build provider configuration based on app type // Build provider configuration based on app type
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
// Use ProviderService to add the provider // Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider)?; ProviderService::add(state, app_type.clone(), provider)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
let normalized = ep.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() {
if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
normalized.clone(),
) {
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
}
}
}
// If enabled=true, set as current provider // If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) { if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?; ProviderService::switch(state, app_type.clone(), &provider_id)?;
@@ -138,6 +168,16 @@ pub(crate) fn build_provider_from_request(
Ok(provider) Ok(provider)
} }
/// Get primary endpoint from request (first one if comma-separated)
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
request
.endpoint
.as_ref()
.and_then(|ep| ep.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Build provider meta with usage script configuration /// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> { fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided // Check if any usage script fields are provided
@@ -165,6 +205,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults // Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript { let usage_script = UsageScript {
enabled, enabled,
language: "javascript".to_string(), language: "javascript".to_string(),
@@ -174,10 +215,14 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
.usage_api_key .usage_api_key
.clone() .clone()
.or_else(|| request.api_key.clone()), .or_else(|| request.api_key.clone()),
base_url: request base_url: request.usage_base_url.clone().or_else(|| {
.usage_base_url let primary = get_primary_endpoint(request);
.clone() if primary.is_empty() {
.or_else(|| request.endpoint.clone()), None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(), access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(), user_id: request.usage_user_id.clone(),
auto_query_interval: request.usage_auto_interval, auto_query_interval: request.usage_auto_interval,
@@ -198,7 +243,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
); );
env.insert( env.insert(
"ANTHROPIC_BASE_URL".to_string(), "ANTHROPIC_BASE_URL".to_string(),
json!(request.endpoint.clone().unwrap_or_default()), json!(get_primary_endpoint(request)),
); );
// Add default model if provided // Add default model if provided
@@ -271,11 +316,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.unwrap_or("gpt-5-codex") .unwrap_or("gpt-5-codex")
.to_string(); .to_string();
// Endpoint: normalize trailing slashes // Endpoint: normalize trailing slashes (use primary endpoint only)
let endpoint = request let endpoint = get_primary_endpoint(request)
.endpoint
.as_deref()
.unwrap_or("")
.trim() .trim()
.trim_end_matches('/') .trim_end_matches('/')
.to_string(); .to_string();
@@ -309,7 +351,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key)); env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert( env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(), "GOOGLE_GEMINI_BASE_URL".to_string(),
json!(request.endpoint), json!(get_primary_endpoint(request)),
); );
// Add model if provided // Add model if provided
@@ -409,27 +451,26 @@ fn merge_claude_config(
})?; })?;
// Auto-fill API key if not provided in URL // Auto-fill API key if not provided in URL
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = Some(token.to_string()); request.api_key = Some(token.to_string());
} }
} }
// Auto-fill endpoint if not provided in URL // Auto-fill endpoint if not provided in URL
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string()); request.endpoint = Some(base_url.to_string());
} }
} }
// Auto-fill homepage from endpoint if not provided // Auto-fill homepage from endpoint if not provided
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
&& request.endpoint.is_some() if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
&& !request.endpoint.as_ref().unwrap().is_empty() request.homepage = infer_homepage_from_endpoint(endpoint);
{ if request.homepage.is_none() {
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); request.homepage = Some("https://anthropic.com".to_string());
if request.homepage.is_none() { }
request.homepage = Some("https://anthropic.com".to_string());
} }
} }
@@ -468,7 +509,7 @@ fn merge_codex_config(
config: &serde_json::Value, config: &serde_json::Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY // Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config if let Some(api_key) = config
.get("auth") .get("auth")
.and_then(|v| v.get("OPENAI_API_KEY")) .and_then(|v| v.get("OPENAI_API_KEY"))
@@ -483,7 +524,7 @@ fn merge_codex_config(
// Parse TOML config string to extract base_url and model // Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) { if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section // Extract base_url from model_providers section
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = extract_codex_base_url(&toml_value) { if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = Some(base_url); request.endpoint = Some(base_url);
} }
@@ -499,13 +540,12 @@ fn merge_codex_config(
} }
// Auto-fill homepage from endpoint // Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
&& request.endpoint.is_some() if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
&& !request.endpoint.as_ref().unwrap().is_empty() request.homepage = infer_homepage_from_endpoint(endpoint);
{ if request.homepage.is_none() {
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); request.homepage = Some("https://openai.com".to_string());
if request.homepage.is_none() { }
request.homepage = Some("https://openai.com".to_string());
} }
} }
@@ -518,14 +558,18 @@ fn merge_gemini_config(
config: &serde_json::Value, config: &serde_json::Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// Gemini uses flat env structure // Gemini uses flat env structure
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = Some(api_key.to_string()); request.api_key = Some(api_key.to_string());
} }
} }
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) { if let Some(base_url) = config
.get("GOOGLE_GEMINI_BASE_URL")
.or_else(|| config.get("GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string()); request.endpoint = Some(base_url.to_string());
} }
} }
@@ -538,13 +582,12 @@ fn merge_gemini_config(
} }
// Auto-fill homepage from endpoint // Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
&& request.endpoint.is_some() if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
&& !request.endpoint.as_ref().unwrap().is_empty() request.homepage = infer_homepage_from_endpoint(endpoint);
{ if request.homepage.is_none() {
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); request.homepage = Some("https://ai.google.dev".to_string());
if request.homepage.is_none() { }
request.homepage = Some("https://ai.google.dev".to_string());
} }
} }
+56 -4
View File
@@ -365,8 +365,7 @@ fn test_parse_prompt_deeplink() {
let content = "Hello World"; let content = "Hello World";
let content_b64 = BASE64_STANDARD.encode(content); let content_b64 = BASE64_STANDARD.encode(content);
let url = format!( let url = format!(
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true", "ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true"
content_b64
); );
let request = parse_deeplink_url(&url).unwrap(); let request = parse_deeplink_url(&url).unwrap();
@@ -383,8 +382,7 @@ fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#; let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config); let config_b64 = BASE64_STANDARD.encode(config);
let url = format!( let url = format!(
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true", "ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true"
config_b64
); );
let request = parse_deeplink_url(&url).unwrap(); let request = parse_deeplink_url(&url).unwrap();
@@ -404,3 +402,57 @@ fn test_parse_skill_deeplink() {
assert_eq!(request.directory.unwrap(), "skills"); assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev"); assert_eq!(request.branch.unwrap(), "dev");
} }
// =============================================================================
// Multiple Endpoints Tests
// =============================================================================
#[test]
fn test_parse_multiple_endpoints_comma_separated() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert!(request.endpoint.is_some());
let endpoint = request.endpoint.unwrap();
// Should contain all endpoints comma-separated
assert!(endpoint.contains("https://api1.example.com"));
assert!(endpoint.contains("https://api2.example.com"));
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
}
#[test]
fn test_parse_endpoints_with_spaces_trimmed() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
// Validation should pass (spaces are trimmed during validation)
assert!(request.endpoint.is_some());
}
#[test]
fn test_infer_homepage_from_endpoint_without_homepage() {
// Test that homepage is auto-inferred from endpoint when not provided
assert_eq!(
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
Some("https://cubence.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://cubence.com"),
Some("https://cubence.com".to_string())
);
}
+9 -3
View File
@@ -5,15 +5,21 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖) /// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf { pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() { if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom; return custom;
} }
dirs::home_dir() get_home_dir().join(".gemini")
.expect("无法获取用户主目录")
.join(".gemini")
} }
/// 获取 Gemini .env 文件路径 /// 获取 Gemini .env 文件路径
+27
View File
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage"); obj.remove("homepage");
obj.remove("docs"); obj.remove("docs");
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
// Gemini CLI 只支持 timeout(单位 ms
// 默认值:startup=10s, tool=60s
const DEFAULT_STARTUP_MS: u64 = 10_000;
const DEFAULT_TOOL_MS: u64 = 60_000;
let extract_timeout =
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
obj.remove(key).and_then(|val| {
val.as_u64()
.map(|n| n * multiplier)
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
})
};
// 分别收集 startup 和 tool timeout,未设置时使用默认值
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
.unwrap_or(DEFAULT_STARTUP_MS);
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
.unwrap_or(DEFAULT_TOOL_MS);
// 取最大值作为 Gemini timeout
let final_timeout = startup_ms.max(tool_ms);
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
out.insert(id.clone(), Value::Object(obj)); out.insert(id.clone(), Value::Object(obj));
} }
+58
View File
@@ -27,6 +27,7 @@ mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::open_provider_terminal;
pub use commands::*; pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database; pub use database::Database;
@@ -643,6 +644,37 @@ pub fn run() {
let skill_service = SkillService::new(); let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
let proxy_url = db.get_global_proxy_url().ok().flatten();
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
log::error!(
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
);
// 清除无效的代理配置
if proxy_url.is_some() {
log::warn!(
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
);
if let Err(clear_err) = db.set_global_proxy_url(None) {
log::error!(
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
);
}
}
// 使用直连模式重新初始化
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
log::error!(
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
);
}
}
}
// 异常退出恢复 + 代理状态自动恢复 // 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
@@ -702,6 +734,8 @@ pub fn run() {
commands::read_live_provider_settings, commands::read_live_provider_settings,
commands::get_settings, commands::get_settings,
commands::save_settings, commands::save_settings,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::restart_app, commands::restart_app,
commands::check_for_updates, commands::check_for_updates,
commands::is_portable_mode, commands::is_portable_mode,
@@ -832,12 +866,36 @@ pub fn run() {
commands::get_stream_check_config, commands::get_stream_check_config,
commands::save_stream_check_config, commands::save_stream_check_config,
commands::get_tool_versions, commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management // Universal Provider management
commands::get_universal_providers, commands::get_universal_providers,
commands::get_universal_provider, commands::get_universal_provider,
commands::upsert_universal_provider, commands::upsert_universal_provider,
commands::delete_universal_provider, commands::delete_universal_provider,
commands::sync_universal_provider, commands::sync_universal_provider,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// Template management
commands::refresh_template_index,
commands::list_template_components,
commands::get_template_component,
commands::install_template_component,
commands::uninstall_template_component,
commands::batch_install_template_components,
commands::list_template_repos,
commands::add_template_repo,
commands::remove_template_repo,
commands::toggle_template_repo,
commands::list_template_categories,
commands::list_installed_components,
commands::preview_component_content,
commands::list_marketplace_bundles,
]); ]);
let app = builder let app = builder
+3
View File
@@ -147,6 +147,9 @@ pub struct ProviderMeta {
/// 用量查询脚本配置 /// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>, pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致) /// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")] #[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>, pub is_partner: Option<bool>,
+29 -53
View File
@@ -2,6 +2,7 @@
//! //!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求 //! 实现熔断器模式,用于防止向不健康的供应商发送请求
use super::log_codes::cb as log_cb;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -106,7 +107,6 @@ impl CircuitBreaker {
/// 更新熔断器配置(热更新,不重置状态) /// 更新熔断器配置(热更新,不重置状态)
pub async fn update_config(&self, new_config: CircuitBreakerConfig) { pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write().await = new_config; *self.config.write().await = new_config;
log::debug!("Circuit breaker config updated");
} }
/// 判断当前 Provider 是否“可被纳入候选链路” /// 判断当前 Provider 是否“可被纳入候选链路”
@@ -128,7 +128,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds { if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
log::info!( log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)" "[{}] 熔断器 Open HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
); );
self.transition_to_half_open().await; self.transition_to_half_open().await;
return true; return true;
@@ -155,7 +156,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds { if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
log::info!( log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)" "[{}] 熔断器 Open HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
); );
self.transition_to_half_open().await; self.transition_to_half_open().await;
@@ -197,25 +199,17 @@ impl CircuitBreaker {
self.consecutive_failures.store(0, Ordering::SeqCst); self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst);
match state { if state == CircuitState::HalfOpen {
CircuitState::HalfOpen => { let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
successes,
config.success_threshold
);
if successes >= config.success_threshold { if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)"); log::info!(
self.transition_to_closed().await; "[{}] 熔断器 HalfOpen → Closed (恢复正常)",
} log_cb::HALF_OPEN_TO_CLOSED
);
self.transition_to_closed().await;
} }
CircuitState::Closed => {
log::debug!("Circuit breaker Closed: request succeeded");
}
_ => {}
} }
} }
@@ -236,18 +230,14 @@ impl CircuitBreaker {
// 重置成功计数 // 重置成功计数
self.consecutive_successes.store(0, Ordering::SeqCst); self.consecutive_successes.store(0, Ordering::SeqCst);
log::debug!(
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
state,
failures,
config.failure_threshold
);
// 检查是否应该打开熔断器 // 检查是否应该打开熔断器
match state { match state {
CircuitState::HalfOpen => { CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open // HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open"); log::warn!(
"[{}] 熔断器 HalfOpen 探测失败 → Open",
log_cb::HALF_OPEN_PROBE_FAILED
);
drop(config); drop(config);
self.transition_to_open().await; self.transition_to_open().await;
} }
@@ -255,9 +245,8 @@ impl CircuitBreaker {
// 检查连续失败次数 // 检查连续失败次数
if failures >= config.failure_threshold { if failures >= config.failure_threshold {
log::warn!( log::warn!(
"Circuit breaker opening due to {} consecutive failures (threshold: {})", "[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
failures, log_cb::TRIGGERED_FAILURES
config.failure_threshold
); );
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
self.transition_to_open().await; self.transition_to_open().await;
@@ -268,18 +257,12 @@ impl CircuitBreaker {
if total >= config.min_requests { if total >= config.min_requests {
let error_rate = failed as f64 / total as f64; let error_rate = failed as f64 / total as f64;
log::debug!(
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
error_rate * 100.0,
failed,
total
);
if error_rate >= config.error_rate_threshold { if error_rate >= config.error_rate_threshold {
log::warn!( log::warn!(
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)", "[{}] 熔断器触发: 错误率 {:.1}% → Open",
error_rate * 100.0, log_cb::TRIGGERED_ERROR_RATE,
config.error_rate_threshold * 100.0 error_rate * 100.0
); );
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
self.transition_to_open().await; self.transition_to_open().await;
@@ -312,22 +295,16 @@ impl CircuitBreaker {
/// 重置熔断器(手动恢复) /// 重置熔断器(手动恢复)
#[allow(dead_code)] #[allow(dead_code)]
pub async fn reset(&self) { pub async fn reset(&self) {
log::info!("Circuit breaker manually reset to Closed state"); log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
self.transition_to_closed().await; self.transition_to_closed().await;
} }
fn allow_half_open_probe(&self) -> AllowResult { fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测 // 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32; let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst); let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests { if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult { AllowResult {
allowed: true, allowed: true,
used_half_open_permit: true, used_half_open_permit: true,
@@ -335,9 +312,6 @@ impl CircuitBreaker {
} else { } else {
// 超过限额,回退计数,拒绝请求 // 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst); self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult { AllowResult {
allowed: false, allowed: false,
used_half_open_permit: false, used_half_open_permit: false,
@@ -345,12 +319,14 @@ impl CircuitBreaker {
} }
} }
fn release_half_open_permit(&self) { /// 仅释放 HalfOpen permit,不影响健康统计
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst); let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop { loop {
if current == 0 { if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return; return;
} }
+12
View File
@@ -17,6 +17,12 @@ pub enum ProxyError {
#[error("地址绑定失败: {0}")] #[error("地址绑定失败: {0}")]
BindFailed(String), BindFailed(String),
#[error("停止超时")]
StopTimeout,
#[error("停止失败: {0}")]
StopFailed(String),
#[error("请求转发失败: {0}")] #[error("请求转发失败: {0}")]
ForwardFailed(String), ForwardFailed(String),
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
ProxyError::BindFailed(_) => { ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
} }
ProxyError::StopTimeout => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()), ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => { ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string()) (StatusCode::SERVICE_UNAVAILABLE, self.to_string())
+5 -7
View File
@@ -86,17 +86,17 @@ impl FailoverSwitchManager {
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await { let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.enabled, Ok(config) => config.enabled,
Err(e) => { Err(e) => {
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换"); log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
return Ok(false); return Ok(false);
} }
}; };
if !app_enabled { if !app_enabled {
log::info!("[Failover] {app_type} 未被代理接管(enabled=false,跳过切换"); log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
return Ok(false); return Ok(false);
} }
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})"); log::info!("[FO-001] 切换: {app_type} {provider_name}");
// 1. 更新数据库 is_current // 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?; self.db.set_current_provider(app_type, provider_id)?;
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
.update_live_backup_from_provider(app_type, &provider) .update_live_backup_from_provider(app_type, &provider)
.await .await
{ {
log::warn!("[Failover] 更新 Live 备份失败: {e}"); log::warn!("[FO-003] Live 备份更新失败: {e}");
} }
} }
@@ -138,12 +138,10 @@ impl FailoverSwitchManager {
"source": "failover" // 标识来源是故障转移 "source": "failover" // 标识来源是故障转移
}); });
if let Err(e) = app.emit("provider-switched", event_data) { if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("[Failover] 发射供应商切换事件失败: {e}"); log::error!("[Failover] 发射事件失败: {e}");
} }
} }
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
Ok(true) Ok(true)
} }
} }
+241 -53
View File
@@ -7,15 +7,15 @@ use super::{
error::*, error::*,
failover_switch::FailoverSwitchManager, failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter, provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter}, providers::{get_adapter, ProviderAdapter, ProviderType},
types::ProxyStatus, thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
types::{ProxyStatus, RectifierConfig},
ProxyError, ProxyError,
}; };
use crate::{app_config::AppType, provider::Provider}; use crate::{app_config::AppType, provider::Provider};
use reqwest::{Client, Response}; use reqwest::Response;
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers /// Headers 黑名单 - 不透传到上游的 Headers
@@ -81,8 +81,6 @@ pub struct ForwardError {
} }
pub struct RequestForwarder { pub struct RequestForwarder {
client: Option<Client>,
client_init_error: Option<String>,
/// 共享的 ProviderRouter(持有熔断器状态) /// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
@@ -93,6 +91,10 @@ pub struct RequestForwarder {
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String, current_provider_id_at_start: String,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
} }
impl RequestForwarder { impl RequestForwarder {
@@ -107,52 +109,17 @@ impl RequestForwarder {
current_provider_id_at_start: String, current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64, _streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64, _streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
) -> Self { ) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let timeout_secs = if non_streaming_timeout > 0 {
non_streaming_timeout
} else {
GLOBAL_TIMEOUT_SECS
};
// 注意:这里不能用 expect/unwrap。
// release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。
// 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。
let (client, client_init_error) = match Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
{
Ok(client) => (Some(client), None),
Err(e) => {
// 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃
match Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.no_proxy()
.build()
{
Ok(client) => (Some(client), Some(e.to_string())),
Err(fallback_err) => (
None,
Some(format!(
"Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}"
)),
),
}
}
};
Self { Self {
client,
client_init_error,
router, router,
status, status,
current_providers, current_providers,
failover_manager, failover_manager,
app_handle, app_handle,
current_provider_id_at_start, current_provider_id_at_start,
rectifier_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
} }
} }
@@ -168,7 +135,7 @@ impl RequestForwarder {
&self, &self,
app_type: &AppType, app_type: &AppType,
endpoint: &str, endpoint: &str,
body: Value, mut body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
providers: Vec<Provider>, providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> { ) -> Result<ForwardResult, ForwardError> {
@@ -187,6 +154,9 @@ impl RequestForwarder {
let mut last_provider = None; let mut last_provider = None;
let mut attempted_providers = 0usize; let mut attempted_providers = 0usize;
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时) // 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1; let bypass_circuit_breaker = providers.len() == 1;
@@ -281,6 +251,205 @@ impl RequestForwarder {
}); });
} }
Err(e) => { Err(e) => {
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
let is_anthropic_provider = matches!(
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_signature(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if rectifier_retried {
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:直接返回错误(不可重试客户端错误)
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
}
ProxyError::UpstreamError { status, .. } => *status >= 500,
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
// 失败:记录失败并更新熔断器 // 失败:记录失败并更新熔断器
let _ = self let _ = self
.router .router
@@ -305,6 +474,14 @@ impl RequestForwarder {
Some(format!("Provider {} 失败: {}", provider.name, e)); Some(format!("Provider {} 失败: {}", provider.name, e));
} }
log::warn!(
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
app_type_str,
provider.name,
attempted_providers,
providers.len()
);
last_error = Some(e); last_error = Some(e);
last_provider = Some(provider.clone()); last_provider = Some(provider.clone());
// 继续尝试下一个供应商 // 继续尝试下一个供应商
@@ -360,6 +537,8 @@ impl RequestForwarder {
} }
} }
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
Err(ForwardError { Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded), error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider, provider: last_provider,
@@ -406,16 +585,17 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段 // 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]); let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 构建请求 // 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
let client = self.client.as_ref().ok_or_else(|| { let client = super::http_client::get();
ProxyError::ForwardFailed(
self.client_init_error
.clone()
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
)
})?;
let mut request = client.post(&url); let mut request = client.post(&url);
// 只有当 timeout > 0 时才设置请求超时
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
// 过滤黑名单 Headers,保护隐私并避免冲突 // 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers { for (key, value) in headers {
if HEADER_BLACKLIST if HEADER_BLACKLIST
@@ -531,3 +711,11 @@ impl RequestForwarder {
} }
} }
} }
/// 从 ProxyError 中提取错误消息
fn extract_error_message(error: &ProxyError) -> Option<String> {
match error {
ProxyError::UpstreamError { body, .. } => body.clone(),
_ => Some(error.to_string()),
}
}
+13 -4
View File
@@ -5,7 +5,10 @@
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::{ use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, extract_session_id,
forwarder::RequestForwarder,
server::ProxyState,
types::{AppProxyConfig, RectifierConfig},
ProxyError, ProxyError,
}; };
use axum::http::HeaderMap; use axum::http::HeaderMap;
@@ -54,6 +57,8 @@ pub struct RequestContext {
pub app_type: AppType, pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成) /// Session ID(从客户端请求提取或新生成)
pub session_id: String, pub session_id: String,
/// 整流器配置
pub rectifier_config: RectifierConfig,
} }
impl RequestContext { impl RequestContext {
@@ -86,6 +91,9 @@ impl RequestContext {
.await .await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?; .map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 从数据库读取整流器配置
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
let current_provider_id = let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default(); crate::settings::get_current_provider(&app_type).unwrap_or_default();
@@ -127,7 +135,7 @@ impl RequestContext {
.cloned() .cloned()
.ok_or(ProxyError::NoAvailableProvider)?; .ok_or(ProxyError::NoAvailableProvider)?;
log::info!( log::debug!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}", "[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag, tag,
provider.name, provider.name,
@@ -147,6 +155,7 @@ impl RequestContext {
app_type_str, app_type_str,
app_type, app_type,
session_id, session_id,
rectifier_config,
}) })
} }
@@ -168,7 +177,6 @@ impl RequestContext {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
self self
} }
@@ -190,7 +198,7 @@ impl RequestContext {
) )
} else { } else {
// 故障转移关闭:不启用超时配置 // 故障转移关闭:不启用超时配置
log::info!( log::debug!(
"[{}] Failover disabled, timeout configs are bypassed", "[{}] Failover disabled, timeout configs are bypassed",
self.tag self.tag
); );
@@ -207,6 +215,7 @@ impl RequestContext {
self.current_provider_id.clone(), self.current_provider_id.clone(),
first_byte_timeout, first_byte_timeout,
idle_timeout, idle_timeout,
self.rectifier_config.clone(),
) )
} }
+7 -54
View File
@@ -98,16 +98,6 @@ pub async fn handle_messages(
let adapter = get_adapter(&AppType::Claude); let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider); let needs_transform = adapter.needs_transform(&ctx.provider);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
let status = response.status();
log::info!("[Claude] 上游响应状态: {status}");
// Claude 特有:格式转换处理 // Claude 特有:格式转换处理
if needs_transform { if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
@@ -131,8 +121,6 @@ async fn handle_claude_transform(
if is_stream { if is_stream {
// 流式响应转换 (OpenAI SSE → Anthropic SSE) // 流式响应转换 (OpenAI SSE → Anthropic SSE)
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
let stream = response.bytes_stream(); let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream); let sse_stream = create_anthropic_sse_stream(stream);
@@ -196,13 +184,10 @@ async fn handle_claude_transform(
); );
let body = axum::body::Body::from_stream(logged_stream); let body = axum::body::Body::from_stream(logged_stream);
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
return Ok((headers, body).into_response()); return Ok((headers, body).into_response());
} }
// 非流式响应转换 (OpenAI → Anthropic) // 非流式响应转换 (OpenAI → Anthropic)
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
let response_headers = response.headers().clone(); let response_headers = response.headers().clone();
let body_bytes = response.bytes().await.map_err(|e| { let body_bytes = response.bytes().await.map_err(|e| {
@@ -211,31 +196,17 @@ async fn handle_claude_transform(
})?; })?;
let body_str = String::from_utf8_lossy(&body_bytes); let body_str = String::from_utf8_lossy(&body_bytes);
log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len());
log::debug!("[Claude] OpenAI 原始响应: {body_str}");
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
})?; })?;
log::info!("[Claude] 解析 OpenAI 响应成功");
log::info!(
"[Claude] <<< OpenAI 响应 JSON:\n{}",
serde_json::to_string_pretty(&openai_response).unwrap_or_default()
);
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| { let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}"); log::error!("[Claude] 转换响应失败: {e}");
e e
})?; })?;
log::info!("[Claude] 转换响应成功");
log::info!(
"[Claude] <<< Anthropic 响应 JSON:\n{}",
serde_json::to_string_pretty(&anthropic_response).unwrap_or_default()
);
// 记录使用量 // 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
let model = anthropic_response let model = anthropic_response
@@ -265,8 +236,6 @@ async fn handle_claude_transform(
}); });
} }
log::info!("[Claude] ====== 请求结束 ======");
// 构建响应 // 构建响应
let mut builder = axum::response::Response::builder().status(status); let mut builder = axum::response::Response::builder().status(status);
@@ -285,11 +254,6 @@ async fn handle_claude_transform(
ProxyError::TransformError(format!("Failed to serialize response: {e}")) ProxyError::TransformError(format!("Failed to serialize response: {e}"))
})?; })?;
log::info!(
"[Claude] 返回转换后的响应, 长度: {} bytes",
response_body.len()
);
let body = axum::body::Body::from(response_body); let body = axum::body::Body::from(response_body);
builder.body(body).map_err(|e| { builder.body(body).map_err(|e| {
log::error!("[Claude] 构建响应失败: {e}"); log::error!("[Claude] 构建响应失败: {e}");
@@ -307,8 +271,6 @@ pub async fn handle_chat_completions(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
@@ -317,12 +279,6 @@ pub async fn handle_chat_completions(
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
log::info!(
"[Codex] 请求模型: {}, 流式: {}",
ctx.request_model,
is_stream
);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
@@ -347,8 +303,6 @@ pub async fn handle_chat_completions(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
} }
@@ -390,8 +344,6 @@ pub async fn handle_responses(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
} }
@@ -417,8 +369,6 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str()) .map(|pq| pq.as_str())
.unwrap_or(uri.path()); .unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
@@ -448,8 +398,6 @@ pub async fn handle_gemini(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
log::info!("[Gemini] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
} }
@@ -508,7 +456,12 @@ async fn log_usage(
Ok(Some(p)) => { Ok(Some(p)) => {
if let Some(meta) = p.meta { if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier { if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else { } else {
Decimal::from(1) Decimal::from(1)
} }
@@ -535,6 +488,6 @@ async fn log_usage(
None, // provider_type None, // provider_type
is_streaming, is_streaming,
) { ) {
log::warn!("记录使用量失败: {e}"); log::warn!("[USG-001] 记录使用量失败: {e}");
} }
} }
+301
View File
@@ -0,0 +1,301 @@
//! 全局 HTTP 客户端模块
//!
//! 提供支持全局代理配置的 HTTP 客户端。
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
use once_cell::sync::OnceCell;
use reqwest::Client;
use std::sync::RwLock;
use std::time::Duration;
/// 全局 HTTP 客户端实例
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
/// 当前代理 URL(用于日志和状态查询)
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
/// 初始化全局 HTTP 客户端
///
/// 应在应用启动时调用一次。
///
/// # Arguments
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
/// 传入 None 或空字符串表示直连
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let client = build_client(effective_url)?;
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
log::warn!(
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
// 已初始化,改用 apply_proxy 更新
return apply_proxy(proxy_url);
}
// 初始化代理 URL 记录
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
log::info!(
"[GlobalProxy] Initialized: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 验证代理配置(不应用)
///
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
/// 用于在持久化之前验证配置的有效性。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
///
/// # Returns
/// 验证成功返回 Ok(()),失败返回错误信息
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
// 只调用 build_client 来验证,但不应用
build_client(effective_url)?;
Ok(())
}
/// 应用代理配置(假设已验证)
///
/// 直接应用代理配置到全局客户端,不做额外验证。
/// 应在 validate_proxy 成功后调用。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Applied: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 更新代理配置(热更新)
///
/// 可在运行时调用以更改代理设置,无需重启应用。
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
/// 请使用 validate_proxy + apply_proxy 组合。
///
/// # Arguments
/// * `proxy_url` - 新的代理 URLNone 或空字符串表示直连
#[allow(dead_code)]
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Updated: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 获取全局 HTTP 客户端
///
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
pub fn get() -> Client {
GLOBAL_CLIENT
.get()
.and_then(|lock| lock.read().ok())
.map(|c| c.clone())
.unwrap_or_else(|| {
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.no_proxy()
.build()
.unwrap_or_default()
})
}
/// 获取当前代理 URL
///
/// 返回当前配置的代理 URL,None 表示直连。
pub fn get_current_proxy_url() -> Option<String> {
CURRENT_PROXY_URL
.get()
.and_then(|lock| lock.read().ok())
.and_then(|url| url.clone())
}
/// 检查是否正在使用代理
#[allow(dead_code)]
pub fn is_proxy_enabled() -> bool {
get_current_proxy_url().is_some()
}
/// 构建 HTTP 客户端
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60));
// 有代理地址则使用代理,否则直连
if let Some(url) = proxy_url {
// 先验证 URL 格式和 scheme
let parsed = url::Url::parse(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
let scheme = parsed.scheme();
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
return Err(format!(
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
scheme,
mask_url(url)
));
}
let proxy = reqwest::Proxy::all(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
builder = builder.proxy(proxy);
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
} else {
builder = builder.no_proxy();
log::debug!("[GlobalProxy] Direct connection (no proxy)");
}
builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
/// 隐藏 URL 中的敏感信息(用于日志)
pub fn mask_url(url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
// 隐藏用户名和密码,保留 scheme、host 和端口
let host = parsed.host_str().unwrap_or("?");
match parsed.port() {
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
None => format!("{}://{}", parsed.scheme(), host),
}
} else {
// URL 解析失败,返回部分内容
if url.len() > 20 {
format!("{}...", &url[..20])
} else {
url.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_url() {
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
assert_eq!(
mask_url("http://user:pass@127.0.0.1:7890"),
"http://127.0.0.1:7890"
);
assert_eq!(
mask_url("socks5://admin:secret@proxy.example.com:1080"),
"socks5://proxy.example.com:1080"
);
// 无端口的 URL 不应显示 ":?"
assert_eq!(
mask_url("http://proxy.example.com"),
"http://proxy.example.com"
);
assert_eq!(
mask_url("https://user:pass@proxy.example.com"),
"https://proxy.example.com"
);
}
#[test]
fn test_build_client_direct() {
let result = build_client(None);
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_http_proxy() {
let result = build_client(Some("http://127.0.0.1:7890"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_socks5_proxy() {
let result = build_client(Some("socks5://127.0.0.1:1080"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_invalid_url() {
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
// 使用明确无效的 scheme 来触发错误
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
assert!(result.is_err(), "Should reject invalid proxy scheme");
}
}
+59
View File
@@ -0,0 +1,59 @@
//! 代理模块日志错误码定义
//!
//! 格式: [模块-编号] 消息
//! - CB: Circuit Breaker (熔断器)
//! - SRV: Server (服务器)
//! - FWD: Forwarder (转发器)
//! - FO: Failover (故障转移)
//! - RSP: Response (响应处理)
//! - USG: Usage (使用量)
#![allow(dead_code)]
/// 熔断器日志码
pub mod cb {
pub const OPEN_TO_HALF_OPEN: &str = "CB-001";
pub const HALF_OPEN_TO_CLOSED: &str = "CB-002";
pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003";
pub const TRIGGERED_FAILURES: &str = "CB-004";
pub const TRIGGERED_ERROR_RATE: &str = "CB-005";
pub const MANUAL_RESET: &str = "CB-006";
}
/// 服务器日志码
pub mod srv {
pub const STARTED: &str = "SRV-001";
pub const STOPPED: &str = "SRV-002";
pub const STOP_TIMEOUT: &str = "SRV-003";
pub const TASK_ERROR: &str = "SRV-004";
}
/// 转发器日志码
pub mod fwd {
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
}
/// 故障转移日志码
pub mod fo {
pub const SWITCH_SUCCESS: &str = "FO-001";
pub const CONFIG_READ_ERROR: &str = "FO-002";
pub const LIVE_BACKUP_ERROR: &str = "FO-003";
pub const ALL_CIRCUIT_OPEN: &str = "FO-004";
pub const NO_PROVIDERS: &str = "FO-005";
}
/// 响应处理日志码
pub mod rsp {
pub const BUILD_STREAM_ERROR: &str = "RSP-001";
pub const READ_BODY_ERROR: &str = "RSP-002";
pub const BUILD_RESPONSE_ERROR: &str = "RSP-003";
pub const STREAM_TIMEOUT: &str = "RSP-004";
pub const STREAM_ERROR: &str = "RSP-005";
}
/// 使用量日志码
pub mod usg {
pub const LOG_FAILED: &str = "USG-001";
pub const PRICING_NOT_FOUND: &str = "USG-002";
}
+3
View File
@@ -12,6 +12,8 @@ pub mod handler_config;
pub mod handler_context; pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod http_client;
pub mod log_codes;
pub mod model_mapper; pub mod model_mapper;
pub mod provider_router; pub mod provider_router;
pub mod providers; pub mod providers;
@@ -19,6 +21,7 @@ pub mod response_handler;
pub mod response_processor; pub mod response_processor;
pub(crate) mod server; pub(crate) mod server;
pub mod session; pub mod session;
pub mod thinking_rectifier;
pub(crate) mod types; pub(crate) mod types;
pub mod usage; pub mod usage;
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn apply_model_mapping(
let mapped = mapping.map_model(original, has_thinking); let mapped = mapping.map_model(original, has_thinking);
if mapped != *original { if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}"); log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped); body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped)); return (body, Some(original.clone()), Some(mapped));
} }
+83 -103
View File
@@ -39,15 +39,9 @@ impl ProviderRouter {
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => { Ok(config) => config.auto_failover_enabled,
let enabled = config.auto_failover_enabled;
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
enabled
}
Err(e) => { Err(e) => {
log::error!( log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false false
} }
}; };
@@ -56,85 +50,37 @@ impl ProviderRouter {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 // 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?; let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len(); total_providers = failover_providers.len();
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
log::info!(
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
);
for provider in failover_providers { for provider in failover_providers {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id); let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
let state = breaker.get_state().await;
if breaker.is_available().await { if breaker.is_available().await {
log::debug!(
"[{}] Queue provider available: {} ({}) (state: {:?})",
app_type,
provider.name,
provider.id,
state
);
log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type,
provider.name,
provider.id,
provider.sort_index
);
result.push(provider); result.push(provider);
} else { } else {
circuit_open_count += 1; circuit_open_count += 1;
log::debug!(
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
app_type,
provider.name,
state
);
} }
} }
} else { } else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查 // 故障转移关闭:仅使用当前供应商,跳过熔断器检查
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
if let Some(current_id) = self.db.get_current_provider(app_type)? { if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? { if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
log::info!(
"[{}] Current provider: {} ({})",
app_type,
current.name,
current.id
);
total_providers = 1; total_providers = 1;
result.push(current); result.push(current);
} else {
log::debug!(
"[{app_type}] Current provider id {current_id} not found in database"
);
} }
} else {
log::debug!("[{app_type}] No current provider configured");
} }
} }
if result.is_empty() { if result.is_empty() {
// 区分两种情况:全部熔断 vs 未配置供应商
if total_providers > 0 && circuit_open_count == total_providers { if total_providers > 0 && circuit_open_count == total_providers {
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道"); log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
return Err(AppError::AllProvidersCircuitOpen); return Err(AppError::AllProvidersCircuitOpen);
} else { } else {
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空"); log::warn!("[{app_type}] [FO-005] 未配置供应商");
return Err(AppError::NoProvidersConfigured); return Err(AppError::NoProvidersConfigured);
} }
} }
log::info!(
"[{}] Provider chain: {} provider(s) available",
app_type,
result.len()
);
Ok(result) Ok(result)
} }
@@ -161,15 +107,10 @@ impl ProviderRouter {
success: bool, success: bool,
error_msg: Option<String>, error_msg: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用) // 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold, Ok(app_config) => app_config.circuit_failure_threshold,
Err(e) => { Err(_) => 5, // 默认值
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
}; };
// 2. 更新熔断器状态 // 2. 更新熔断器状态
@@ -178,14 +119,8 @@ impl ProviderRouter {
if success { if success {
breaker.record_success(used_half_open_permit).await; breaker.record_success(used_half_open_permit).await;
log::debug!("Provider {provider_id} request succeeded");
} else { } else {
breaker.record_failure(used_half_open_permit).await; breaker.record_failure(used_half_open_permit).await;
log::warn!(
"Provider {} request failed: {}",
provider_id,
error_msg.as_deref().unwrap_or("Unknown error")
);
} }
// 3. 更新数据库健康状态(使用配置的阈值) // 3. 更新数据库健康状态(使用配置的阈值)
@@ -206,7 +141,6 @@ impl ProviderRouter {
pub async fn reset_circuit_breaker(&self, circuit_key: &str) { pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
let breakers = self.circuit_breakers.read().await; let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(circuit_key) { if let Some(breaker) = breakers.get(circuit_key) {
log::info!("Manually resetting circuit breaker for {circuit_key}");
breaker.reset().await; breaker.reset().await;
} }
} }
@@ -217,19 +151,30 @@ impl ProviderRouter {
self.reset_circuit_breaker(&circuit_key).await; self.reset_circuit_breaker(&circuit_key).await;
} }
/// 更新所有熔断器的配置(热更新 /// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口
/// ///
/// 当用户在 UI 中修改熔断器配置后调用此方法 /// 用于整流器等场景:请求结果不应计入 Provider 健康度
/// 所有现有的熔断器会立即使用新配置 /// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub async fn release_permit_neutral(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if !used_half_open_permit {
return;
}
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.release_half_open_permit();
}
/// 更新所有熔断器的配置(热更新)
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) { pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await; let breakers = self.circuit_breakers.read().await;
let count = breakers.len();
for breaker in breakers.values() { for breaker in breakers.values() {
breaker.update_config(config.clone()).await; breaker.update_config(config.clone()).await;
} }
log::info!("已更新 {count} 个熔断器的配置");
} }
/// 获取熔断器状态 /// 获取熔断器状态
@@ -272,32 +217,16 @@ impl ProviderRouter {
// 按应用独立读取熔断器配置 // 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await { let config = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => { Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
log::debug!( failure_threshold: app_config.circuit_failure_threshold,
"Loading circuit breaker config for {key} (app={app_type}): \ success_threshold: app_config.circuit_success_threshold,
failure_threshold={}, success_threshold={}, timeout={}s", timeout_seconds: app_config.circuit_timeout_seconds as u64,
app_config.circuit_failure_threshold, error_rate_threshold: app_config.circuit_error_rate_threshold,
app_config.circuit_success_threshold, min_requests: app_config.circuit_min_requests,
app_config.circuit_timeout_seconds },
); Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
}; };
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
let breaker = Arc::new(CircuitBreaker::new(config)); let breaker = Arc::new(CircuitBreaker::new(config));
breakers.insert(key.to_string(), breaker.clone()); breakers.insert(key.to_string(), breaker.clone());
@@ -414,4 +343,55 @@ mod tests {
assert!(router.allow_provider_request("b", "claude").await.allowed); assert!(router.allow_provider_request("b", "claude").await.allowed);
} }
#[tokio::test]
async fn test_release_permit_neutral_frees_half_open_slot() {
let db = Arc::new(Database::memory().unwrap());
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
// 触发熔断:1 次失败
router
.record_result("a", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
// 第一次请求:获取 HalfOpen 探测名额
let first = router.allow_provider_request("a", "claude").await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
// 第二次请求应被拒绝(名额已被占用)
let second = router.allow_provider_request("a", "claude").await;
assert!(!second.allowed);
// 使用 release_permit_neutral 释放名额(不影响健康统计)
router
.release_permit_neutral("a", "claude", first.used_half_open_permit)
.await;
// 第三次请求应被允许(名额已释放)
let third = router.allow_provider_request("a", "claude").await;
assert!(third.allowed);
assert!(third.used_half_open_permit);
}
} }
+13 -2
View File
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
let normalized = value.trim().to_lowercase(); let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1" normalized == "true" || normalized == "1"
} }
_ => true, // OpenRouter now supports Claude Code compatible API, default to passthrough
_ => false,
} }
} }
@@ -465,12 +466,22 @@ mod tests {
})); }));
assert!(!adapter.needs_transform(&anthropic_provider)); assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({ let openrouter_provider = create_provider(json!({
"env": { "env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api" "ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
} }
})); }));
assert!(adapter.needs_transform(&openrouter_provider)); assert!(!adapter.needs_transform(&openrouter_provider));
// OpenRouter provider with explicit compat mode enabled should transform
let openrouter_enabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({ let openrouter_disabled = create_provider(json!({
"env": { "env": {
+4 -13
View File
@@ -75,8 +75,6 @@ pub fn create_anthropic_sse_stream(
let mut current_block_type: Option<String> = None; let mut current_block_type: Option<String> = None;
let mut tool_call_id = None; let mut tool_call_id = None;
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
tokio::pin!(stream); tokio::pin!(stream);
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
@@ -96,25 +94,18 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() { for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") { if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" { if data.trim() == "[DONE]" {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]"); log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"}); let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n", let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default()); serde_json::to_string(&event).unwrap_or_default());
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop"); log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data)); yield Ok(Bytes::from(sse_data));
continue; continue;
} }
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) { if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 记录原始 OpenAI 事件(格式化显示) // 仅在 DEBUG 级别简短记录 SSE 事件
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) { log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
log::info!(
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
}
if message_id.is_none() { if message_id.is_none() {
message_id = Some(chunk.id.clone()); message_id = Some(chunk.id.clone());
+13 -20
View File
@@ -46,8 +46,6 @@ pub async fn handle_streaming(
state: &ProxyState, state: &ProxyState,
parser_config: &UsageParserConfig, parser_config: &UsageParserConfig,
) -> Response { ) -> Response {
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
let status = response.status(); let status = response.status();
let mut builder = axum::response::Response::builder().status(status); let mut builder = axum::response::Response::builder().status(status);
@@ -99,12 +97,6 @@ pub async fn handle_non_streaming(
// 解析并记录使用量 // 解析并记录使用量
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) { if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
log::info!(
"[{}] <<< 响应 JSON:\n{}",
ctx.tag,
serde_json::to_string_pretty(&json_value).unwrap_or_default()
);
// 解析使用量 // 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) { if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
@@ -137,7 +129,7 @@ pub async fn handle_non_streaming(
); );
} }
} else { } else {
log::info!( log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes", "[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag, ctx.tag,
body_bytes.len() body_bytes.len()
@@ -152,8 +144,6 @@ pub async fn handle_non_streaming(
); );
} }
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
// 构建响应 // 构建响应
let mut builder = axum::response::Response::builder().status(status); let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() { for (key, value) in response_headers.iter() {
@@ -382,7 +372,12 @@ async fn log_usage_internal(
Ok(Some(p)) => { Ok(Some(p)) => {
if let Some(meta) = p.meta { if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier { if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else { } else {
Decimal::from(1) Decimal::from(1)
} }
@@ -418,7 +413,7 @@ async fn log_usage_internal(
None, // provider_type None, // provider_type
is_streaming, is_streaming,
) { ) {
log::warn!("记录使用量失败: {e}"); log::warn!("[USG-001] 记录使用量失败: {e}");
} }
} }
@@ -493,16 +488,16 @@ pub fn create_logged_passthrough_stream(
if let Some(c) = &collector { if let Some(c) = &collector {
c.push(json_value.clone()).await; c.push(json_value.clone()).await;
} }
log::info!( log::debug!(
"[{}] <<< SSE 事件:\n{}", "[{}] <<< SSE 事件: {}",
tag, tag,
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) data.chars().take(100).collect::<String>()
); );
} else { } else {
log::info!("[{tag}] <<< SSE 数据: {data}"); log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
} }
} else { } else {
log::info!("[{tag}] <<< SSE: [DONE]"); log::debug!("[{tag}] <<< SSE: [DONE]");
} }
} }
} }
@@ -523,8 +518,6 @@ pub fn create_logged_passthrough_stream(
} }
} }
log::info!("[{}] ====== 流结束 ======", tag);
if let Some(c) = collector.take() { if let Some(c) = collector.take() {
c.finish().await; c.finish().await;
} }
+20 -8
View File
@@ -3,8 +3,8 @@
//! 基于Axum的HTTP服务器,处理代理请求 //! 基于Axum的HTTP服务器,处理代理请求
use super::{ use super::{
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*, failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
ProxyError, provider_router::ProviderRouter, types::*, ProxyError,
}; };
use crate::database::Database; use crate::database::Database;
use axum::{ use axum::{
@@ -95,7 +95,7 @@ impl ProxyServer {
.await .await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?; .map_err(|e| ProxyError::BindFailed(e.to_string()))?;
log::info!("代理服务器启动于 {addr}"); log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 保存关闭句柄 // 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx); *self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -146,13 +146,25 @@ impl ProxyServer {
// 2. 等待服务器任务结束(带 5 秒超时保护) // 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() { if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await { match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
Ok(Ok(())) => log::info!("代理服务器已完全停止"), Ok(Ok(())) => {
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"), log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"), Ok(())
}
Ok(Err(e)) => {
log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
Err(ProxyError::StopFailed(e.to_string()))
}
Err(_) => {
log::warn!(
"[{}] 代理服务器停止超时(5秒),强制继续",
log_srv::STOP_TIMEOUT
);
Err(ProxyError::StopTimeout)
}
} }
} else {
Ok(())
} }
Ok(())
} }
pub async fn get_status(&self) -> ProxyStatus { pub async fn get_status(&self) -> ProxyStatus {
+421
View File
@@ -0,0 +1,421 @@
//! Thinking Signature 整流器
//!
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
use super::types::RectifierConfig;
use serde_json::Value;
/// 整流结果
#[derive(Debug, Clone, Default)]
pub struct RectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 移除的 thinking block 数量
pub removed_thinking_blocks: usize,
/// 移除的 redacted_thinking block 数量
pub removed_redacted_thinking_blocks: usize,
/// 移除的 signature 字段数量
pub removed_signature_fields: usize,
}
/// 检测是否需要触发 thinking 签名整流器
///
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
/// 会检查配置开关。
pub fn should_rectify_thinking_signature(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_signature {
return false;
}
// 检测错误类型
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 场景1: thinking block 中的签名无效
// 错误示例: "Invalid 'signature' in 'thinking' block"
if lower.contains("invalid")
&& lower.contains("signature")
&& lower.contains("thinking")
&& lower.contains("block")
{
return true;
}
// 场景2: assistant 消息必须以 thinking block 开头
// 错误示例: "must start with a thinking block"
if lower.contains("must start with a thinking block") {
return true;
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
{
return true;
}
// 场景4: signature 字段必需但缺失
// 错误示例: "signature: Field required"
if lower.contains("signature") && lower.contains("field required") {
return true;
}
false
}
/// 对 Anthropic 请求体做最小侵入整流
///
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
/// - 移除非 thinking block 上遗留的 signature 字段
/// - 特定条件下删除顶层 thinking 字段
///
/// 注意:该函数会原地修改 body 对象
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
let mut result = RectifyResult::default();
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
Some(m) => m,
None => return result,
};
// 遍历所有消息
for msg in messages.iter_mut() {
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
Some(c) => c,
None => continue,
};
let mut new_content = Vec::with_capacity(content.len());
let mut content_modified = false;
for block in content.iter() {
let block_type = block.get("type").and_then(|t| t.as_str());
match block_type {
Some("thinking") => {
result.removed_thinking_blocks += 1;
content_modified = true;
continue;
}
Some("redacted_thinking") => {
result.removed_redacted_thinking_blocks += 1;
content_modified = true;
continue;
}
_ => {}
}
// 移除非 thinking block 上的 signature 字段
if block.get("signature").is_some() {
let mut block_clone = block.clone();
if let Some(obj) = block_clone.as_object_mut() {
obj.remove("signature");
result.removed_signature_fields += 1;
content_modified = true;
new_content.push(Value::Object(obj.clone()));
continue;
}
}
new_content.push(block.clone());
}
if content_modified {
result.applied = true;
*content = new_content;
}
}
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
let messages_snapshot: Vec<Value> = body
.get("messages")
.and_then(|m| m.as_array())
.map(|a| a.to_vec())
.unwrap_or_default();
if should_remove_top_level_thinking(body, &messages_snapshot) {
if let Some(obj) = body.as_object_mut() {
obj.remove("thinking");
result.applied = true;
}
}
result
}
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_enabled = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if !thinking_enabled {
return false;
}
// 找到最后一条 assistant 消息
let last_assistant = messages
.iter()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
let last_assistant_content = match last_assistant
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
Some(c) if !c.is_empty() => c,
_ => return false,
};
// 检查首块是否为 thinking/redacted_thinking
let first_block_type = last_assistant_content
.first()
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str());
let missing_thinking_prefix =
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
if !missing_thinking_prefix {
return false;
}
// 检查是否存在 tool_use
last_assistant_content
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
}
}
fn disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
}
}
// ==================== should_rectify_thinking_signature 测试 ====================
#[test]
fn test_detect_invalid_signature() {
assert!(should_rectify_thinking_signature(
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_no_backticks() {
assert!(should_rectify_thinking_signature(
Some("Messages.1.Content.0: invalid signature in thinking block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_nested_json() {
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_expected() {
assert!(should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
Some("a final `assistant` message must start with a thinking block"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_signature(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(
Some("Connection refused"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
}
#[test]
fn test_detect_signature_field_required() {
// 场景4: signature 字段缺失
assert!(should_rectify_thinking_signature(
Some("***.***.***.***.***.signature: Field required"),
&enabled_config()
));
// 嵌套 JSON 格式
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_disabled_config() {
// 即使错误匹配,配置关闭时也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&disabled_config()
));
}
#[test]
fn test_master_disabled() {
// 总开关关闭时,即使子开关开启也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&master_disabled_config()
));
}
// ==================== rectify_anthropic_request 测试 ====================
#[test]
fn test_rectify_removes_thinking_blocks() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig" },
{ "type": "text", "text": "hello", "signature": "sig_text" },
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
assert_eq!(result.removed_redacted_thinking_blocks, 1);
assert_eq!(result.removed_signature_fields, 2);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(content[1]["type"], "tool_use");
assert!(content[1].get("signature").is_none());
}
#[test]
fn test_rectify_removes_top_level_thinking() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert!(body.get("thinking").is_none());
}
#[test]
fn test_rectify_no_change_when_no_issues() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(result.removed_thinking_blocks, 0);
}
#[test]
fn test_rectify_no_messages() {
let mut body = json!({ "model": "claude-test" });
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
}
#[test]
fn test_rectify_preserves_thinking_when_prefix_exists() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "some thought" },
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
]
}]
});
let result = rectify_anthropic_request(&mut body);
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
}
+64
View File
@@ -191,3 +191,67 @@ pub struct AppProxyConfig {
/// 计算错误率的最小请求数 /// 计算错误率的最小请求数
pub circuit_min_requests: u32, pub circuit_min_requests: u32,
} }
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器
#[serde(default = "default_true")]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default = "default_true")]
pub request_thinking_signature: bool,
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
}
}
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全启用状态
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
let config = RectifierConfig::default();
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用 default_true
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
}
#[test]
fn test_rectifier_config_serde_explicit_false() {
// 验证显式设置 false 时正确反序列化
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
}
}
+1 -1
View File
@@ -214,7 +214,7 @@ impl<'a> UsageLogger<'a> {
let pricing = self.get_model_pricing(&model)?; let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() { if pricing.is_none() {
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0"); log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
} }
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier); let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
+7
View File
@@ -8,6 +8,7 @@ pub mod proxy;
pub mod skill; pub mod skill;
pub mod speedtest; pub mod speedtest;
pub mod stream_check; pub mod stream_check;
pub mod template;
pub mod usage_stats; pub mod usage_stats;
pub use config::ConfigService; pub use config::ConfigService;
@@ -19,6 +20,12 @@ pub use proxy::ProxyService;
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService}; pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use template::{
BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent,
MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo,
TemplateService,
};
#[allow(unused_imports)]
pub use usage_stats::{ pub use usage_stats::{
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
RequestLogDetail, UsageSummary, RequestLogDetail, UsageSummary,
+1 -1
View File
@@ -85,7 +85,7 @@ impl PromptService {
if !content_exists { if !content_exists {
let timestamp = std::time::SystemTime::now() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap_or_default()
.as_secs() as i64; .as_secs() as i64;
let backup_id = format!("backup-{timestamp}"); let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt { let backup_prompt = Prompt {
+9
View File
@@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// MCP sync // MCP sync
McpService::sync_all_enabled(state)?; McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
}
}
Ok(()) Ok(())
} }
+4 -12
View File
@@ -7,7 +7,6 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
@@ -143,9 +142,7 @@ pub struct SkillMetadata {
// ========== SkillService ========== // ========== SkillService ==========
pub struct SkillService { pub struct SkillService;
http_client: Client,
}
impl Default for SkillService { impl Default for SkillService {
fn default() -> Self { fn default() -> Self {
@@ -155,13 +152,7 @@ impl Default for SkillService {
impl SkillService { impl SkillService {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self
http_client: Client::builder()
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client"),
}
} }
// ========== 路径管理 ========== // ========== 路径管理 ==========
@@ -863,7 +854,8 @@ impl SkillService {
/// 下载并解压 ZIP /// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> { async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
let response = self.http_client.get(url).send().await?; let client = crate::proxy::http_client::get();
let response = client.get(url).send().await?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status().as_u16().to_string(); let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error( return Err(anyhow::anyhow!(format_skill_error(
+13 -17
View File
@@ -1,7 +1,7 @@
use futures::future::join_all; use futures::future::join_all;
use reqwest::{Client, Url}; use reqwest::{Client, Url};
use serde::Serialize; use serde::Serialize;
use std::time::{Duration, Instant}; use std::time::Instant;
use crate::error::AppError; use crate::error::AppError;
@@ -65,17 +65,21 @@ impl SpeedtestService {
} }
let timeout = Self::sanitize_timeout(timeout_secs); let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?; let (client, request_timeout) = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| { let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone(); let client = client.clone();
async move { async move {
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await; let _ = client
.get(parsed_url.clone())
.timeout(request_timeout)
.send()
.await;
// 第二次请求开始计时,并将其作为结果返回。 // 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now(); let start = Instant::now();
let latency = match client.get(parsed_url).send().await { let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
Ok(resp) => EndpointLatency { Ok(resp) => EndpointLatency {
url: trimmed, url: trimmed,
latency: Some(start.elapsed().as_millis()), latency: Some(start.elapsed().as_millis()),
@@ -112,19 +116,11 @@ impl SpeedtestService {
Ok(results.into_iter().flatten().collect::<Vec<_>>()) Ok(results.into_iter().flatten().collect::<Vec<_>>())
} }
fn build_client(timeout_secs: u64) -> Result<Client, AppError> { fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
Client::builder() // 使用全局 HTTP 客户端(已包含代理配置)
.timeout(Duration::from_secs(timeout_secs)) // 返回 timeout Duration 供请求级别使用
.redirect(reqwest::redirect::Policy::limited(5)) let timeout = std::time::Duration::from_secs(timeout_secs);
.user_agent("cc-switch-speedtest/1.0") Ok((crate::proxy::http_client::get(), timeout))
.build()
.map_err(|e| {
AppError::localized(
"speedtest.client_create_failed",
format!("创建 HTTP 客户端失败: {e}"),
format!("Failed to create HTTP client: {e}"),
)
})
} }
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 { fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
+177 -46
View File
@@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::time::{Duration, Instant}; use std::time::Instant;
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::error::AppError; use crate::error::AppError;
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
pub codex_model: String, pub codex_model: String,
/// Gemini 测试模型 /// Gemini 测试模型
pub gemini_model: String, pub gemini_model: String,
/// 检查提示词
#[serde(default = "default_test_prompt")]
pub test_prompt: String,
}
fn default_test_prompt() -> String {
"Who are you?".to_string()
} }
impl Default for StreamCheckConfig { impl Default for StreamCheckConfig {
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
claude_model: "claude-haiku-4-5-20251001".to_string(), claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(), codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(), gemini_model: "gemini-3-pro-preview".to_string(),
test_prompt: default_test_prompt(),
} }
} }
} }
@@ -110,7 +118,7 @@ impl StreamCheckService {
Ok(last_result.unwrap_or_else(|| StreamCheckResult { Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed, status: HealthStatus::Failed,
success: false, success: false,
message: "检查失败".to_string(), message: "Check failed".to_string(),
response_time_ms: None, response_time_ms: None,
http_status: None, http_status: None,
model_used: String::new(), model_used: String::new(),
@@ -130,29 +138,52 @@ impl StreamCheckService {
let base_url = adapter let base_url = adapter
.extract_base_url(provider) .extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?; .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
let auth = adapter let auth = adapter
.extract_auth(provider) .extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; .ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
let client = Client::builder() // 使用全局 HTTP 客户端(已包含代理配置)
.timeout(Duration::from_secs(config.timeout_secs)) let client = crate::proxy::http_client::get();
.user_agent("cc-switch/1.0") let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
let model_to_test = Self::resolve_test_model(app_type, provider, config); let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type { let result = match app_type {
AppType::Claude => { AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await Self::check_claude_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
AppType::Codex => { AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await Self::check_codex_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
AppType::Gemini => { AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await Self::check_gemini_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
}; };
@@ -166,7 +197,7 @@ impl StreamCheckService {
Ok(StreamCheckResult { Ok(StreamCheckResult {
status: health_status, status: health_status,
success: true, success: true,
message: "检查成功".to_string(), message: "Check succeeded".to_string(),
response_time_ms: Some(response_time), response_time_ms: Some(response_time),
http_status: Some(status_code), http_status: Some(status_code),
model_used: model, model_used: model,
@@ -188,31 +219,69 @@ impl StreamCheckService {
} }
/// Claude 流式检查 /// Claude 流式检查
///
/// 严格按照 Claude CLI 真实请求格式构建请求
async fn check_claude_stream( async fn check_claude_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") { let url = if base.ends_with("/v1") {
format!("{base}/messages") format!("{base}/messages?beta=true")
} else { } else {
format!("{base}/v1/messages") format!("{base}/v1/messages?beta=true")
}; };
let body = json!({ let body = json!({
"model": model, "model": model,
"max_tokens": 1, "max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }], "messages": [{ "role": "user", "content": test_prompt }],
"stream": true "stream": true
}); });
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 严格按照 Claude CLI 请求格式设置 headers
let response = client let response = client
.post(&url) .post(&url)
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01") .header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json") .header(
"anthropic-beta",
"claude-code-20250219,interleaved-thinking-2025-05-14",
)
.header("anthropic-dangerous-direct-browser-access", "true")
// 内容类型 headers
.header("content-type", "application/json")
.header("accept", "application/json")
.header("accept-encoding", "identity")
.header("accept-language", "*")
// 客户端标识 headers
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
.header("x-app", "cli")
// x-stainless SDK headers(动态获取本地系统信息)
.header("x-stainless-lang", "js")
.header("x-stainless-package-version", "0.70.0")
.header("x-stainless-os", os_name)
.header("x-stainless-arch", arch_name)
.header("x-stainless-runtime", "node")
.header("x-stainless-runtime-version", "v22.20.0")
.header("x-stainless-retry-count", "0")
.header("x-stainless-timeout", "600")
// 其他 headers
.header("sec-fetch-mode", "cors")
.header("connection", "keep-alive")
.timeout(timeout)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -230,51 +299,64 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
} }
} else { } else {
Err(AppError::Message("未收到响应数据".to_string())) Err(AppError::Message("No response data received".to_string()))
} }
} }
/// Codex 流式检查 /// Codex 流式检查
///
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
async fn check_codex_stream( async fn check_codex_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") { let url = if base.ends_with("/v1") {
format!("{base}/chat/completions") format!("{base}/responses")
} else { } else {
format!("{base}/v1/chat/completions") format!("{base}/v1/responses")
}; };
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) // 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// Responses API 请求体格式 (input 必须是数组)
let mut body = json!({ let mut body = json!({
"model": actual_model, "model": actual_model,
"messages": [ "input": [{ "role": "user", "content": test_prompt }],
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"stream": true "stream": true
}); });
// 如果是推理模型,添加 reasoning_effort // 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort { if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort); body["reasoning"] = json!({ "effort": effort });
} }
// 严格按照 Codex CLI 请求格式设置 headers
let response = client let response = client
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key)) .header("authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json") .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) .json(&body)
.send() .send()
.await .await
@@ -291,10 +373,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
} }
} else { } else {
Err(AppError::Message("未收到响应数据".to_string())) Err(AppError::Message("No response data received".to_string()))
} }
} }
@@ -304,13 +386,15 @@ impl StreamCheckService {
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions"); let url = format!("{base}/v1/chat/completions");
let body = json!({ let body = json!({
"model": model, "model": model,
"messages": [{ "role": "user", "content": "hi" }], "messages": [{ "role": "user", "content": test_prompt }],
"max_tokens": 1, "max_tokens": 1,
"temperature": 0, "temperature": 0,
"stream": true "stream": true
@@ -320,6 +404,7 @@ impl StreamCheckService {
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key)) .header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.timeout(timeout)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -336,10 +421,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
} }
} else { } else {
Err(AppError::Message("未收到响应数据".to_string())) Err(AppError::Message("No response data received".to_string()))
} }
} }
@@ -354,7 +439,6 @@ impl StreamCheckService {
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>) /// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) { fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) { if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string(); let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string(); let effort = model[pos + 1..].to_string();
@@ -367,17 +451,14 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool { fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase(); let lower = msg.to_lowercase();
lower.contains("timeout") lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
} }
fn map_request_error(e: reqwest::Error) -> AppError { fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() { if e.is_timeout() {
AppError::Message("请求超时".to_string()) AppError::Message("Request timeout".to_string())
} else if e.is_connect() { } else if e.is_connect() {
AppError::Message(format!("连接失败: {e}")) AppError::Message(format!("Connection failed: {e}"))
} else { } else {
AppError::Message(e.to_string()) AppError::Message(e.to_string())
} }
@@ -424,6 +505,26 @@ impl StreamCheckService {
.map(|m| m.as_str().trim().to_string()) .map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
} }
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
fn get_os_name() -> &'static str {
match std::env::consts::OS {
"macos" => "MacOS",
"linux" => "Linux",
"windows" => "Windows",
other => other,
}
}
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
fn get_arch_name() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x86_64",
"x86" => "x86",
other => other,
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -448,9 +549,10 @@ mod tests {
#[test] #[test]
fn test_should_retry() { fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时")); assert!(StreamCheckService::should_retry("Request timeout"));
assert!(StreamCheckService::should_retry("request timeout")); assert!(StreamCheckService::should_retry("request timed out"));
assert!(!StreamCheckService::should_retry("API Key 无效")); assert!(StreamCheckService::should_retry("connection abort"));
assert!(!StreamCheckService::should_retry("API Key invalid"));
} }
#[test] #[test]
@@ -478,4 +580,33 @@ mod tests {
assert_eq!(model, "gpt-4o-mini"); assert_eq!(model, "gpt-4o-mini");
assert_eq!(effort, None); assert_eq!(effort, None);
} }
#[test]
fn test_get_os_name() {
let os_name = StreamCheckService::get_os_name();
// 确保返回非空字符串
assert!(!os_name.is_empty());
// 在 macOS 上应该返回 "MacOS"
#[cfg(target_os = "macos")]
assert_eq!(os_name, "MacOS");
// 在 Linux 上应该返回 "Linux"
#[cfg(target_os = "linux")]
assert_eq!(os_name, "Linux");
// 在 Windows 上应该返回 "Windows"
#[cfg(target_os = "windows")]
assert_eq!(os_name, "Windows");
}
#[test]
fn test_get_arch_name() {
let arch_name = StreamCheckService::get_arch_name();
// 确保返回非空字符串
assert!(!arch_name.is_empty());
// 在 ARM64 上应该返回 "arm64"
#[cfg(target_arch = "aarch64")]
assert_eq!(arch_name, "arm64");
// 在 x86_64 上应该返回 "x86_64"
#[cfg(target_arch = "x86_64")]
assert_eq!(arch_name, "x86_64");
}
} }
@@ -0,0 +1,267 @@
//! Claude 应用适配器
//!
//! 完整支持所有组件类型:
//! - Agent → `~/.claude/agents/{name}.md`
//! - Command → `~/.claude/commands/{name}.md`
//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段
//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段
//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段
use anyhow::{Context, Result};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use super::AppAdapter;
use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path};
/// Claude 应用适配器
pub struct ClaudeAdapter {
config_dir: PathBuf,
}
impl ClaudeAdapter {
/// 创建新的 Claude 适配器实例
pub fn new() -> Self {
Self {
config_dir: get_claude_config_dir(),
}
}
/// 读取 JSON 配置文件
fn read_json_file(path: &PathBuf) -> Result<Value> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path)
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
let value: Value = serde_json::from_str(&content)
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
Ok(value)
}
/// 写入 JSON 配置文件(原子写入)
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
atomic_write(path, json.as_bytes())
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
Ok(())
}
/// 合并两个 JSON 对象(深度合并)
fn merge_json(base: &mut Value, overlay: &Value) {
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
for (key, value) in overlay_obj {
if let Some(base_value) = base_obj.get_mut(key) {
// 如果两边都是对象,递归合并
if base_value.is_object() && value.is_object() {
Self::merge_json(base_value, value);
} else {
// 否则直接覆盖
*base_value = value.clone();
}
} else {
// 键不存在,直接插入
base_obj.insert(key.clone(), value.clone());
}
}
}
}
/// 安装 Markdown 文件(通用)
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
let dir = self.config_dir.join(subdir);
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
let filename = if name.ends_with(".md") {
name.to_string()
} else {
format!("{name}.md")
};
let file_path = dir.join(&filename);
atomic_write(&file_path, content.as_bytes())
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
log::info!("已安装 Claude {}: {}", subdir, file_path.display());
Ok(file_path)
}
/// 获取 Claude settings.json 路径
fn get_settings_path(&self) -> PathBuf {
crate::config::get_claude_settings_path()
}
}
impl AppAdapter for ClaudeAdapter {
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
self.install_markdown_file(content, "agents", name)
}
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
self.install_markdown_file(content, "commands", name)
}
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
let mcp_path = get_claude_mcp_path();
// 读取现有 MCP 配置
let mut current = Self::read_json_file(&mcp_path)?;
// 确保 mcpServers 字段存在
if !current.is_object() {
current = serde_json::json!({});
}
if current.get("mcpServers").is_none() {
current["mcpServers"] = serde_json::json!({});
}
// 合并新的 MCP 服务器配置
if let Some(mcp_servers) = current.get_mut("mcpServers") {
Self::merge_json(mcp_servers, mcp_config);
}
// 写回配置文件
Self::write_json_file(&mcp_path, &current)?;
log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display());
Ok(())
}
fn install_setting(&self, setting_config: &Value) -> Result<()> {
let settings_path = self.get_settings_path();
// 读取现有配置
let mut current = Self::read_json_file(&settings_path)?;
// 确保 permissions 字段存在
if !current.is_object() {
current = serde_json::json!({});
}
if current.get("permissions").is_none() {
current["permissions"] = serde_json::json!({});
}
// 合并新的 permissions 配置
if let Some(permissions) = current.get_mut("permissions") {
Self::merge_json(permissions, setting_config);
}
// 写回配置文件
Self::write_json_file(&settings_path, &current)?;
log::info!("已安装 Claude Setting 配置到: {}", settings_path.display());
Ok(())
}
fn install_hook(&self, hook_config: &Value) -> Result<()> {
let settings_path = self.get_settings_path();
// 读取现有配置
let mut current = Self::read_json_file(&settings_path)?;
// 确保 hooks 字段存在
if !current.is_object() {
current = serde_json::json!({});
}
if current.get("hooks").is_none() {
current["hooks"] = serde_json::json!({});
}
// 合并新的 hooks 配置
if let Some(hooks) = current.get_mut("hooks") {
Self::merge_json(hooks, hook_config);
}
// 写回配置文件
Self::write_json_file(&settings_path, &current)?;
log::info!("已安装 Claude Hook 配置到: {}", settings_path.display());
Ok(())
}
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
match component_type.to_lowercase().as_str() {
"agent" => {
let path = self.config_dir.join("agents").join(format!("{name}.md"));
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
log::info!("已卸载 Claude Agent: {}", path.display());
}
}
"command" => {
let path = self.config_dir.join("commands").join(format!("{name}.md"));
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
log::info!("已卸载 Claude Command: {}", path.display());
}
}
"mcp" => {
let mcp_path = get_claude_mcp_path();
let mut current = Self::read_json_file(&mcp_path)?;
if let Some(mcp_servers) = current
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
mcp_servers.remove(name);
Self::write_json_file(&mcp_path, &current)?;
log::info!("已卸载 Claude MCP 服务器: {name}");
}
}
"setting" => {
let settings_path = self.get_settings_path();
let mut current = Self::read_json_file(&settings_path)?;
if let Some(permissions) = current
.get_mut("permissions")
.and_then(|v| v.as_object_mut())
{
permissions.remove(name);
Self::write_json_file(&settings_path, &current)?;
log::info!("已卸载 Claude Setting: {name}");
}
}
"hook" => {
let settings_path = self.get_settings_path();
let mut current = Self::read_json_file(&settings_path)?;
if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) {
hooks.remove(name);
Self::write_json_file(&settings_path, &current)?;
log::info!("已卸载 Claude Hook: {name}");
}
}
_ => anyhow::bail!("不支持的组件类型: {component_type}"),
}
Ok(())
}
fn config_dir(&self) -> PathBuf {
self.config_dir.clone()
}
fn supports_component_type(&self, component_type: &str) -> bool {
matches!(
component_type.to_lowercase().as_str(),
"agent" | "command" | "mcp" | "setting" | "hook"
)
}
}
impl Default for ClaudeAdapter {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,299 @@
//! Codex 应用适配器
//!
//! 部分支持:
//! - Agent → `~/.codex/agents/{name}.md`
//! - Command → `~/.codex/commands/{name}.md`
//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表
//! - Setting/Hook → 不支持(Codex 不支持这些功能)
use anyhow::{bail, Context, Result};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use super::AppAdapter;
use crate::codex_config::get_codex_config_dir;
use crate::config::{atomic_write, write_text_file};
/// Codex 应用适配器
pub struct CodexAdapter {
config_dir: PathBuf,
}
impl CodexAdapter {
/// 创建新的 Codex 适配器实例
pub fn new() -> Self {
Self {
config_dir: get_codex_config_dir(),
}
}
/// 安装 Markdown 文件(通用)
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
let dir = self.config_dir.join(subdir);
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
let filename = if name.ends_with(".md") {
name.to_string()
} else {
format!("{name}.md")
};
let file_path = dir.join(&filename);
atomic_write(&file_path, content.as_bytes())
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
log::info!("已安装 Codex {}: {}", subdir, file_path.display());
Ok(file_path)
}
/// 获取 Codex config.toml 路径
fn get_config_toml_path(&self) -> PathBuf {
crate::codex_config::get_codex_config_path()
}
/// 读取 TOML 配置文件
fn read_toml_file(path: &PathBuf) -> Result<toml::Table> {
if !path.exists() {
return Ok(toml::Table::new());
}
let content = fs::read_to_string(path)
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
let table: toml::Table = toml::from_str(&content)
.with_context(|| format!("解析 TOML 失败: {}", path.display()))?;
Ok(table)
}
/// 写入 TOML 配置文件(原子写入)
fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?;
write_text_file(path, &toml_string)
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
Ok(())
}
/// 将 JSON MCP 配置转换为 TOML 格式
fn json_mcp_to_toml(json_config: &Value) -> Result<toml::Table> {
let mut mcp_servers = toml::Table::new();
if let Some(obj) = json_config.as_object() {
for (server_id, server_spec) in obj {
let mut server_table = toml::Table::new();
if let Some(spec_obj) = server_spec.as_object() {
// type 字段(默认 stdio
let server_type = spec_obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
server_table.insert(
"type".to_string(),
toml::Value::String(server_type.to_string()),
);
match server_type {
"stdio" => {
// command 字段(必需)
if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) {
server_table.insert(
"command".to_string(),
toml::Value::String(cmd.to_string()),
);
}
// args 字段(可选)
if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) {
let toml_args: Vec<toml::Value> = args
.iter()
.filter_map(|v| v.as_str())
.map(|s| toml::Value::String(s.to_string()))
.collect();
if !toml_args.is_empty() {
server_table
.insert("args".to_string(), toml::Value::Array(toml_args));
}
}
// env 字段(可选)
if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) {
let mut env_table = toml::Table::new();
for (key, value) in env {
if let Some(val_str) = value.as_str() {
env_table.insert(
key.clone(),
toml::Value::String(val_str.to_string()),
);
}
}
if !env_table.is_empty() {
server_table
.insert("env".to_string(), toml::Value::Table(env_table));
}
}
// cwd 字段(可选)
if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) {
server_table.insert(
"cwd".to_string(),
toml::Value::String(cwd.to_string()),
);
}
}
"http" | "sse" => {
// url 字段(必需)
if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) {
server_table.insert(
"url".to_string(),
toml::Value::String(url.to_string()),
);
}
// http_headers 字段(可选)
if let Some(headers) =
spec_obj.get("http_headers").and_then(|v| v.as_object())
{
let mut headers_table = toml::Table::new();
for (key, value) in headers {
if let Some(val_str) = value.as_str() {
headers_table.insert(
key.clone(),
toml::Value::String(val_str.to_string()),
);
}
}
if !headers_table.is_empty() {
server_table.insert(
"http_headers".to_string(),
toml::Value::Table(headers_table),
);
}
}
}
_ => {}
}
}
mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table));
}
}
Ok(mcp_servers)
}
}
impl AppAdapter for CodexAdapter {
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
self.install_markdown_file(content, "agents", name)
}
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
self.install_markdown_file(content, "commands", name)
}
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
let config_path = self.get_config_toml_path();
// 读取现有 TOML 配置
let mut current = Self::read_toml_file(&config_path)?;
// 确保 mcp_servers 表存在
if !current.contains_key("mcp_servers") {
current.insert(
"mcp_servers".to_string(),
toml::Value::Table(toml::Table::new()),
);
}
// 转换 JSON MCP 配置到 TOML
let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?;
// 合并 MCP 服务器配置
if let Some(mcp_servers) = current
.get_mut("mcp_servers")
.and_then(|v| v.as_table_mut())
{
for (server_id, server_config) in new_mcp_servers {
mcp_servers.insert(server_id, server_config);
}
}
// 写回配置文件
Self::write_toml_file(&config_path, &current)?;
log::info!("已安装 Codex MCP 配置到: {}", config_path.display());
Ok(())
}
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
bail!("Codex 不支持 Setting 配置")
}
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
bail!("Codex 不支持 Hook 配置")
}
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
match component_type.to_lowercase().as_str() {
"agent" => {
let path = self.config_dir.join("agents").join(format!("{name}.md"));
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
log::info!("已卸载 Codex Agent: {}", path.display());
}
}
"command" => {
let path = self.config_dir.join("commands").join(format!("{name}.md"));
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
log::info!("已卸载 Codex Command: {}", path.display());
}
}
"mcp" => {
let config_path = self.get_config_toml_path();
let mut current = Self::read_toml_file(&config_path)?;
if let Some(mcp_servers) = current
.get_mut("mcp_servers")
.and_then(|v| v.as_table_mut())
{
mcp_servers.remove(name);
Self::write_toml_file(&config_path, &current)?;
log::info!("已卸载 Codex MCP 服务器: {name}");
}
}
"setting" | "hook" => {
bail!("Codex 不支持 {component_type} 组件类型")
}
_ => bail!("不支持的组件类型: {component_type}"),
}
Ok(())
}
fn config_dir(&self) -> PathBuf {
self.config_dir.clone()
}
fn supports_component_type(&self, component_type: &str) -> bool {
matches!(
component_type.to_lowercase().as_str(),
"agent" | "command" | "mcp"
)
}
}
impl Default for CodexAdapter {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,239 @@
//! Gemini 应用适配器
//!
//! 部分支持:
//! - Agent → `~/.gemini/agents/{name}.md`
//! - Command → `~/.gemini/commands/{name}.md`
//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段
//! - Setting/Hook → 不支持(Gemini 不支持这些功能)
use anyhow::{bail, Context, Result};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use super::AppAdapter;
use crate::config::atomic_write;
use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path};
/// Gemini 应用适配器
pub struct GeminiAdapter {
config_dir: PathBuf,
}
impl GeminiAdapter {
/// 创建新的 Gemini 适配器实例
pub fn new() -> Self {
Self {
config_dir: get_gemini_dir(),
}
}
/// 读取 JSON 配置文件
fn read_json_file(path: &PathBuf) -> Result<Value> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path)
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
let value: Value = serde_json::from_str(&content)
.with_context(|| format!("解析 JSON 失败: {}", path.display()))?;
Ok(value)
}
/// 写入 JSON 配置文件(原子写入)
fn write_json_file(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?;
atomic_write(path, json.as_bytes())
.with_context(|| format!("写入配置文件失败: {}", path.display()))?;
Ok(())
}
/// 合并两个 JSON 对象(深度合并)
fn merge_json(base: &mut Value, overlay: &Value) {
if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) {
for (key, value) in overlay_obj {
if let Some(base_value) = base_obj.get_mut(key) {
// 如果两边都是对象,递归合并
if base_value.is_object() && value.is_object() {
Self::merge_json(base_value, value);
} else {
// 否则直接覆盖
*base_value = value.clone();
}
} else {
// 键不存在,直接插入
base_obj.insert(key.clone(), value.clone());
}
}
}
}
/// 安装 Markdown 文件(通用)
fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result<PathBuf> {
let dir = self.config_dir.join(subdir);
fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?;
let filename = if name.ends_with(".md") {
name.to_string()
} else {
format!("{name}.md")
};
let file_path = dir.join(&filename);
atomic_write(&file_path, content.as_bytes())
.with_context(|| format!("写入文件失败: {}", file_path.display()))?;
log::info!("已安装 Gemini {}: {}", subdir, file_path.display());
Ok(file_path)
}
/// 获取 Gemini settings.json 路径
fn get_settings_path(&self) -> PathBuf {
get_gemini_settings_path()
}
/// 转换 MCP 配置为 Gemini 格式
///
/// Gemini 使用特殊格式:
/// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"`
/// - SSE/stdio 类型:保持标准格式
fn transform_mcp_to_gemini(mcp_config: &Value) -> Result<Value> {
let mut transformed = mcp_config.clone();
if let Some(obj) = transformed.as_object_mut() {
for (_server_id, server_spec) in obj.iter_mut() {
if let Some(spec_obj) = server_spec.as_object_mut() {
// 检查是否为 HTTP 类型
let is_http = spec_obj
.get("type")
.and_then(|v| v.as_str())
.map(|t| t == "http")
.unwrap_or(false);
if is_http {
// 将 url 字段转换为 httpUrl
if let Some(url) = spec_obj.remove("url") {
spec_obj.insert("httpUrl".to_string(), url);
}
// 移除 type 字段(Gemini 不需要显式指定 type
spec_obj.remove("type");
}
}
}
}
Ok(transformed)
}
}
impl AppAdapter for GeminiAdapter {
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf> {
self.install_markdown_file(content, "agents", name)
}
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
self.install_markdown_file(content, "commands", name)
}
fn install_mcp(&self, mcp_config: &Value) -> Result<()> {
let settings_path = self.get_settings_path();
// 读取现有配置
let mut current = Self::read_json_file(&settings_path)?;
// 确保 mcpServers 字段存在
if !current.is_object() {
current = serde_json::json!({});
}
if current.get("mcpServers").is_none() {
current["mcpServers"] = serde_json::json!({});
}
// 转换 MCP 配置为 Gemini 格式
let transformed = Self::transform_mcp_to_gemini(mcp_config)?;
// 合并新的 MCP 服务器配置
if let Some(mcp_servers) = current.get_mut("mcpServers") {
Self::merge_json(mcp_servers, &transformed);
}
// 写回配置文件
Self::write_json_file(&settings_path, &current)?;
log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display());
Ok(())
}
fn install_setting(&self, _setting_config: &Value) -> Result<()> {
bail!("Gemini 不支持 Setting 配置")
}
fn install_hook(&self, _hook_config: &Value) -> Result<()> {
bail!("Gemini 不支持 Hook 配置")
}
fn uninstall(&self, component_type: &str, name: &str) -> Result<()> {
match component_type.to_lowercase().as_str() {
"agent" => {
let path = self.config_dir.join("agents").join(format!("{name}.md"));
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?;
log::info!("已卸载 Gemini Agent: {}", path.display());
}
}
"command" => {
let path = self.config_dir.join("commands").join(format!("{name}.md"));
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("删除 Command 文件失败: {}", path.display()))?;
log::info!("已卸载 Gemini Command: {}", path.display());
}
}
"mcp" => {
let settings_path = self.get_settings_path();
let mut current = Self::read_json_file(&settings_path)?;
if let Some(mcp_servers) = current
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
mcp_servers.remove(name);
Self::write_json_file(&settings_path, &current)?;
log::info!("已卸载 Gemini MCP 服务器: {name}");
}
}
"setting" | "hook" => {
bail!("Gemini 不支持 {component_type} 组件类型")
}
_ => bail!("不支持的组件类型: {component_type}"),
}
Ok(())
}
fn config_dir(&self) -> PathBuf {
self.config_dir.clone()
}
fn supports_component_type(&self, component_type: &str) -> bool {
matches!(
component_type.to_lowercase().as_str(),
"agent" | "command" | "mcp"
)
}
}
impl Default for GeminiAdapter {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,107 @@
//! 应用适配器模块
//!
//! 负责将 Template 组件安装到不同应用的配置目录中。
//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。
mod claude;
mod codex;
mod gemini;
use anyhow::Result;
use std::path::PathBuf;
pub use claude::ClaudeAdapter;
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
use crate::app_config::AppType;
/// 应用适配器 trait
///
/// 定义了将 Template 组件安装到应用配置目录的统一接口。
/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。
#[allow(dead_code)]
pub trait AppAdapter: Send + Sync {
/// 安装 Agent 到应用配置目录
///
/// # 参数
/// - `content`: Agent 内容(Markdown 格式)
/// - `name`: Agent 名称(用作文件名)
///
/// # 返回
/// 安装后的文件路径
fn install_agent(&self, content: &str, name: &str) -> Result<PathBuf>;
/// 安装 Command 到应用配置目录
///
/// # 参数
/// - `content`: Command 内容(Markdown 格式)
/// - `name`: Command 名称(用作文件名)
///
/// # 返回
/// 安装后的文件路径
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
/// 安装 MCP 服务器配置
///
/// # 参数
/// - `mcp_config`: MCP 服务器配置(JSON 对象)
///
/// # 说明
/// 配置会合并到应用的 MCP 配置文件中,保留现有配置。
fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>;
/// 安装 Setting (permissions)
///
/// # 参数
/// - `setting_config`: Setting 配置(JSON 对象)
///
/// # 说明
/// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。
fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>;
/// 安装 Hook
///
/// # 参数
/// - `hook_config`: Hook 配置(JSON 对象)
///
/// # 说明
/// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。
fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>;
/// 卸载组件
///
/// # 参数
/// - `component_type`: 组件类型(agent/command/mcp/setting/hook
/// - `name`: 组件名称或 ID
fn uninstall(&self, component_type: &str, name: &str) -> Result<()>;
/// 获取配置目录路径
fn config_dir(&self) -> PathBuf;
/// 检查组件类型是否支持
///
/// # 参数
/// - `component_type`: 组件类型字符串
///
/// # 返回
/// 如果应用支持该组件类型返回 true,否则返回 false
#[allow(dead_code)]
fn supports_component_type(&self, component_type: &str) -> bool;
}
/// 创建应用适配器工厂函数
///
/// # 参数
/// - `app_type`: 应用类型
///
/// # 返回
/// 对应应用的适配器实例
#[allow(dead_code)]
pub fn create_adapter(app_type: &AppType) -> Box<dyn AppAdapter> {
match app_type {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
}
}
+746
View File
@@ -0,0 +1,746 @@
use anyhow::{anyhow, Context, Result};
use rusqlite::{params, Connection};
use std::fs;
use std::path::{Path, PathBuf};
use tokio::time::timeout;
use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService};
impl TemplateService {
/// 刷新所有启用仓库的组件索引
pub async fn refresh_index(&self, conn: &Connection) -> Result<()> {
// 获取所有启用的仓库
let repos = self.list_enabled_repos(conn)?;
if repos.is_empty() {
log::info!("没有启用的模板仓库");
return Ok(());
}
log::info!("开始刷新 {} 个模板仓库", repos.len());
// 并行扫描所有仓库
let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo));
let results: Vec<Result<Vec<TemplateComponent>>> =
futures::future::join_all(scan_tasks).await;
// 处理扫描结果
let mut total_components = 0;
for (repo, result) in repos.iter().zip(results.into_iter()) {
match result {
Ok(components) => {
log::info!(
"仓库 {}/{} 扫描到 {} 个组件",
repo.owner,
repo.name,
components.len()
);
// 保存到数据库
if let Err(e) = self.save_components(conn, &components) {
log::error!("保存组件到数据库失败: {e}");
} else {
total_components += components.len();
}
}
Err(e) => {
log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e);
}
}
}
log::info!("刷新完成,共索引 {total_components} 个组件");
Ok(())
}
/// 扫描单个仓库
pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result<Vec<TemplateComponent>> {
log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name);
// 下载仓库(增加超时控制)
let temp_dir = timeout(
std::time::Duration::from_secs(120),
self.download_repo(repo),
)
.await
.map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??;
let mut components = Vec::new();
// 扫描不同类型的组件
self.scan_agents(&temp_dir, repo, &mut components)?;
self.scan_commands(&temp_dir, repo, &mut components)?;
self.scan_mcps(&temp_dir, repo, &mut components)?;
self.scan_settings(&temp_dir, repo, &mut components)?;
self.scan_hooks(&temp_dir, repo, &mut components)?;
self.scan_skills(&temp_dir, repo, &mut components)?;
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
log::info!(
"仓库 {}/{} 扫描完成,找到 {} 个组件",
repo.owner,
repo.name,
components.len()
);
Ok(components)
}
/// 下载仓库 ZIP
async fn download_repo(&self, repo: &TemplateRepo) -> Result<PathBuf> {
let temp_dir = tempfile::tempdir().context("创建临时目录失败")?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep();
// 尝试多个分支
let branches = if repo.branch.is_empty() {
vec!["main", "master"]
} else {
vec![repo.branch.as_str(), "main", "master"]
};
let mut last_error = None;
for branch in branches {
let url = format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
repo.owner, repo.name, branch
);
log::debug!("尝试下载: {url}");
match self.download_and_extract(&url, &temp_path).await {
Ok(_) => {
log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch);
return Ok(temp_path);
}
Err(e) => {
log::debug!("下载分支 {branch} 失败: {e}");
last_error = Some(e);
continue;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败")))
}
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
// 下载 ZIP
let response = self.client().get(url).send().await?;
if !response.status().is_success() {
anyhow::bail!("下载失败: HTTP {}", response.status());
}
let bytes = response.bytes().await?;
// 解压
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
// 获取根目录名称
let root_name = if !archive.is_empty() {
let first_file = archive.by_index(0)?;
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow!("空的压缩包"));
};
// 解压所有文件
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name();
// 跳过根目录,直接提取内容
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
stripped
} else {
continue;
};
if relative_path.is_empty() {
continue;
}
let outpath = dest.join(relative_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(())
}
/// 扫描 Agents
fn scan_agents(
&self,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
// 尝试多个可能的路径
let paths = [
base_dir.join("cli-tool").join("components").join("agents"),
base_dir.join("src").join("agents"),
base_dir.join("components").join("agents"),
];
for agents_dir in paths {
if agents_dir.exists() {
self.scan_markdown_components(
&agents_dir,
base_dir,
ComponentType::Agent,
repo,
components,
)?;
return Ok(());
}
}
Ok(())
}
/// 扫描 Commands
fn scan_commands(
&self,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
let paths = [
base_dir
.join("cli-tool")
.join("components")
.join("commands"),
base_dir.join("src").join("commands"),
base_dir.join("components").join("commands"),
];
for commands_dir in paths {
if commands_dir.exists() {
self.scan_markdown_components(
&commands_dir,
base_dir,
ComponentType::Command,
repo,
components,
)?;
return Ok(());
}
}
Ok(())
}
/// 扫描 MCPs
fn scan_mcps(
&self,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
let paths = [
base_dir.join("cli-tool").join("components").join("mcps"),
base_dir.join("src").join("mcp"),
base_dir.join("components").join("mcps"),
];
for mcps_dir in paths {
if mcps_dir.exists() {
self.scan_json_components(
&mcps_dir,
base_dir,
ComponentType::Mcp,
repo,
components,
)?;
return Ok(());
}
}
Ok(())
}
/// 扫描 Settings
fn scan_settings(
&self,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
let paths = [
base_dir
.join("cli-tool")
.join("components")
.join("settings"),
base_dir.join("src").join("settings"),
base_dir.join("components").join("settings"),
];
for settings_dir in paths {
if settings_dir.exists() {
self.scan_json_components(
&settings_dir,
base_dir,
ComponentType::Setting,
repo,
components,
)?;
return Ok(());
}
}
Ok(())
}
/// 扫描 Hooks
fn scan_hooks(
&self,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
let paths = [
base_dir.join("cli-tool").join("components").join("hooks"),
base_dir.join("src").join("hooks"),
base_dir.join("components").join("hooks"),
];
for hooks_dir in paths {
if hooks_dir.exists() {
self.scan_json_components(
&hooks_dir,
base_dir,
ComponentType::Hook,
repo,
components,
)?;
return Ok(());
}
}
Ok(())
}
/// 扫描 Skills
fn scan_skills(
&self,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
let paths = [
base_dir.join("cli-tool").join("components").join("skills"),
base_dir.join("src").join("skills"),
base_dir.join("components").join("skills"),
];
for skills_dir in paths {
if skills_dir.exists() {
self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?;
return Ok(());
}
}
Ok(())
}
/// 扫描 Markdown 组件(Agent/Command
fn scan_markdown_components(
&self,
dir: &Path,
base_dir: &Path,
component_type: ComponentType,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
if let Ok(component) =
self.parse_markdown_component(&path, base_dir, component_type.clone(), repo)
{
components.push(component);
}
} else if path.is_dir() {
// 递归扫描子目录(用于分类)
self.scan_markdown_components(
&path,
base_dir,
component_type.clone(),
repo,
components,
)?;
}
}
Ok(())
}
/// 扫描 JSON 组件(MCP/Setting/Hook
fn scan_json_components(
&self,
dir: &Path,
base_dir: &Path,
component_type: ComponentType,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(component) =
self.parse_json_component(&path, base_dir, component_type.clone(), repo)
{
components.push(component);
}
} else if path.is_dir() {
// 递归扫描子目录(用于分类)
self.scan_json_components(
&path,
base_dir,
component_type.clone(),
repo,
components,
)?;
}
}
Ok(())
}
/// 递归扫描技能目录
fn scan_skills_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
repo: &TemplateRepo,
components: &mut Vec<TemplateComponent>,
) -> Result<()> {
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能
if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) {
components.push(component);
}
return Ok(());
}
// 继续递归扫描子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
self.scan_skills_recursive(&path, base_dir, repo, components)?;
}
}
Ok(())
}
/// 解析 Markdown 组件元数据
fn parse_markdown_component(
&self,
path: &Path,
base_dir: &Path,
component_type: ComponentType,
repo: &TemplateRepo,
) -> Result<TemplateComponent> {
let content = fs::read_to_string(path)?;
let meta = self.parse_component_metadata(&content)?;
let file_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
// 提取分类(从目录结构)
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
// 计算相对于仓库根目录的路径
let relative_path = path
.strip_prefix(base_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
Ok(TemplateComponent {
id: None,
repo_id: repo.id.unwrap_or(0),
component_type,
category,
name: meta.name.unwrap_or_else(|| file_name.to_string()),
path: relative_path,
description: meta.description,
content_hash: Some(Self::calculate_hash(&content)),
installed: false,
})
}
/// 解析 JSON 组件元数据
fn parse_json_component(
&self,
path: &Path,
base_dir: &Path,
component_type: ComponentType,
repo: &TemplateRepo,
) -> Result<TemplateComponent> {
let content = fs::read_to_string(path)?;
let json: serde_json::Value = serde_json::from_str(&content)?;
let file_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let name = json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(file_name)
.to_string();
let description = json
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
let category = self.extract_category(path, &format!("src/{}", component_type.as_str()));
// 计算相对于仓库根目录的路径
let relative_path = path
.strip_prefix(base_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
Ok(TemplateComponent {
id: None,
repo_id: repo.id.unwrap_or(0),
component_type,
category,
name,
path: relative_path,
description,
content_hash: Some(Self::calculate_hash(&content)),
installed: false,
})
}
/// 解析技能组件
fn parse_skill_component(
&self,
skill_md: &Path,
base_dir: &Path,
repo: &TemplateRepo,
) -> Result<TemplateComponent> {
let content = fs::read_to_string(skill_md)?;
let meta = self.parse_component_metadata(&content)?;
let skill_dir = skill_md.parent().unwrap();
let directory = skill_dir
.strip_prefix(base_dir)
.unwrap_or(skill_dir)
.to_string_lossy()
.to_string();
Ok(TemplateComponent {
id: None,
repo_id: repo.id.unwrap_or(0),
component_type: ComponentType::Skill,
category: None,
name: meta.name.unwrap_or_else(|| directory.clone()),
path: directory,
description: meta.description,
content_hash: Some(Self::calculate_hash(&content)),
installed: false,
})
}
/// 解析组件元数据(从 front matter
pub fn parse_component_metadata(&self, content: &str) -> Result<ComponentMetadata> {
// 移除 BOM
let content = content.trim_start_matches('\u{feff}');
// 提取 YAML front matter
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Ok(ComponentMetadata {
name: None,
description: None,
tools: None,
model: None,
});
}
let front_matter = parts[1].trim();
let meta: ComponentMetadata =
serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata {
name: None,
description: None,
tools: None,
model: None,
});
Ok(meta)
}
/// 提取分类(从路径)
fn extract_category(&self, path: &Path, base: &str) -> Option<String> {
let path_str = path.to_string_lossy();
if let Some(pos) = path_str.find(base) {
let after_base = &path_str[pos + base.len()..];
let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() > 1 {
return Some(parts[0].to_string());
}
}
None
}
/// 计算内容哈希
fn calculate_hash(content: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
/// 保存组件到数据库
fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> {
for component in components {
// 检查是否已存在(通过 repo_id + component_type + path
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM template_components
WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3",
params![
component.repo_id,
component.component_type.as_str(),
&component.path
],
|row| row.get(0),
)
.ok();
if let Some(id) = existing {
// 更新现有组件
conn.execute(
"UPDATE template_components
SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP
WHERE id = ?5",
params![
&component.category,
&component.name,
&component.description,
&component.content_hash,
id
],
)?;
} else {
// 插入新组件
conn.execute(
"INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
component.repo_id,
component.component_type.as_str(),
&component.category,
&component.name,
&component.path,
&component.description,
&component.content_hash
],
)?;
}
}
Ok(())
}
/// 列出组件(支持过滤和分页)
#[allow(dead_code)]
pub fn list_components(
&self,
conn: &Connection,
component_type: Option<ComponentType>,
category: Option<String>,
search: Option<String>,
page: u32,
page_size: u32,
) -> Result<super::PaginatedResult<TemplateComponent>> {
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ct) = &component_type {
where_clauses.push("component_type = ?");
params.push(Box::new(ct.as_str().to_string()));
}
if let Some(cat) = &category {
where_clauses.push("category = ?");
params.push(Box::new(cat.clone()));
}
if let Some(s) = &search {
where_clauses.push("(name LIKE ? OR description LIKE ?)");
let search_pattern = format!("%{s}%");
params.push(Box::new(search_pattern.clone()));
params.push(Box::new(search_pattern));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", where_clauses.join(" AND "))
};
// 获取总数
let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}");
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
// 获取分页数据
let offset = (page - 1) * page_size;
let query_sql = format!(
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
FROM template_components
{where_sql}
ORDER BY name
LIMIT ? OFFSET ?"
);
params.push(Box::new(page_size as i64));
params.push(Box::new(offset as i64));
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&query_sql)?;
let components = stmt
.query_map(param_refs.as_slice(), |row| {
Ok(TemplateComponent {
id: Some(row.get(0)?),
repo_id: row.get(1)?,
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
.unwrap_or(ComponentType::Agent),
category: row.get(3)?,
name: row.get(4)?,
path: row.get(5)?,
description: row.get(6)?,
content_hash: row.get(7)?,
installed: false,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(super::PaginatedResult {
items: components,
total,
page,
page_size,
})
}
}
@@ -0,0 +1,525 @@
use anyhow::Result;
use rusqlite::{params, Connection};
use std::fs;
use super::{
BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent,
TemplateService,
};
impl TemplateService {
/// 获取组件详情(含完整内容)
pub async fn get_component(&self, conn: &Connection, id: i64) -> Result<ComponentDetail> {
// 查询组件基本信息
let component: TemplateComponent = conn.query_row(
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
FROM template_components
WHERE id = ?1",
params![id],
|row| {
Ok(TemplateComponent {
id: Some(row.get(0)?),
repo_id: row.get(1)?,
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
.unwrap_or(ComponentType::Agent),
category: row.get(3)?,
name: row.get(4)?,
path: row.get(5)?,
description: row.get(6)?,
content_hash: row.get(7)?,
installed: false,
})
},
)?;
// 查询仓库信息
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
params![component.repo_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
// 构建 README URL
let readme_url = format!(
"https://github.com/{}/{}/tree/{}/{}",
repo_owner, repo_name, branch, component.path
);
// 下载并读取组件内容
let content = self
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
.await?;
Ok(ComponentDetail {
component,
content,
repo_owner,
repo_name,
repo_branch: branch,
readme_url,
})
}
/// 下载组件内容
async fn download_component_content(
&self,
owner: &str,
name: &str,
branch: &str,
path: &str,
) -> Result<String> {
let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}");
let response = self.client().get(&url).send().await?;
if !response.status().is_success() {
anyhow::bail!("下载组件内容失败: HTTP {}", response.status());
}
let content = response.text().await?;
Ok(content)
}
/// 安装组件到指定应用
pub async fn install_component(
&self,
conn: &Connection,
id: i64,
app_type: &str,
) -> Result<()> {
// 获取组件详情
let detail = self.get_component(conn, id).await?;
// 根据组件类型执行不同的安装逻辑
match detail.component.component_type {
ComponentType::Agent => {
self.install_agent(&detail, app_type).await?;
}
ComponentType::Command => {
self.install_command(&detail, app_type).await?;
}
ComponentType::Mcp => {
self.install_mcp(&detail, app_type).await?;
}
ComponentType::Setting => {
self.install_setting(&detail, app_type).await?;
}
ComponentType::Hook => {
self.install_hook(&detail, app_type).await?;
}
ComponentType::Skill => {
self.install_skill(&detail, app_type).await?;
}
}
// 记录安装状态
self.record_installation(conn, &detail.component, app_type)?;
Ok(())
}
/// 安装 Agent
async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
let config_dir = Self::get_app_config_dir(app_type)?;
let agents_dir = config_dir.join("agents");
fs::create_dir_all(&agents_dir)?;
let file_name = format!("{}.md", detail.component.name);
let dest_path = agents_dir.join(&file_name);
fs::write(&dest_path, &detail.content)?;
log::info!("Agent 已安装: {}", dest_path.display());
Ok(())
}
/// 安装 Command
async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
let config_dir = Self::get_app_config_dir(app_type)?;
let commands_dir = config_dir.join("commands");
fs::create_dir_all(&commands_dir)?;
let file_name = format!("{}.md", detail.component.name);
let dest_path = commands_dir.join(&file_name);
fs::write(&dest_path, &detail.content)?;
log::info!("Command 已安装: {}", dest_path.display());
Ok(())
}
/// 安装 MCP 服务器
/// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json
async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
let config_dir = Self::get_app_config_dir(app_type)?;
let mcps_dir = config_dir.join("mcps");
fs::create_dir_all(&mcps_dir)?;
// 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构)
let file_name = format!("{}.json", detail.component.name);
let dest_path = mcps_dir.join(&file_name);
fs::write(&dest_path, &detail.content)?;
log::info!(
"MCP 配置已保存: {} (可手动合并到 .mcp.json)",
dest_path.display()
);
Ok(())
}
/// 安装 Setting
/// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json
/// 原始格式包含 permissions 等配置,可手动合并
async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
let config_dir = Self::get_app_config_dir(app_type)?;
let settings_dir = config_dir.join("settings");
fs::create_dir_all(&settings_dir)?;
// 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构)
let file_name = format!("{}.json", detail.component.name);
let dest_path = settings_dir.join(&file_name);
fs::write(&dest_path, &detail.content)?;
log::info!(
"Setting 配置已保存: {} (可手动合并到 settings.json)",
dest_path.display()
);
Ok(())
}
/// 安装 Hook
/// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json
/// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并
async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
let config_dir = Self::get_app_config_dir(app_type)?;
let hooks_dir = config_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
// 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构)
let file_name = format!("{}.json", detail.component.name);
let dest_path = hooks_dir.join(&file_name);
fs::write(&dest_path, &detail.content)?;
log::info!(
"Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)",
dest_path.display()
);
Ok(())
}
/// 安装 Skill
/// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/
/// 使用 GitHub API 递归下载整个目录
async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> {
let config_dir = Self::get_app_config_dir(app_type)?;
let skills_dir = config_dir.join("skills");
fs::create_dir_all(&skills_dir)?;
let skill_dir = skills_dir.join(&detail.component.name);
fs::create_dir_all(&skill_dir)?;
// 首先保存 SKILL.md(已下载的内容)
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, &detail.content)?;
// 尝试下载整个 skill 目录的其他文件
// 构建 GitHub API URL 来获取目录内容
let api_url = format!(
"https://api.github.com/repos/{}/{}/contents/{}",
detail.repo_owner,
detail.repo_name,
detail.component.path.trim_end_matches("/SKILL.md")
);
// 递归下载目录内容
if let Err(e) = self
.download_skill_directory(&api_url, &skill_dir, &detail.repo_branch)
.await
{
log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md");
}
log::info!("Skill 已安装: {}", skill_dir.display());
Ok(())
}
/// 递归下载 Skill 目录内容
async fn download_skill_directory(
&self,
api_url: &str,
target_dir: &std::path::Path,
branch: &str,
) -> Result<()> {
let response = self
.client()
.get(api_url)
.header("Accept", "application/vnd.github.v3+json")
.header("User-Agent", "cc-switch")
.query(&[("ref", branch)])
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("GitHub API 请求失败: {}", response.status());
}
let contents: Vec<serde_json::Value> = response.json().await?;
for item in contents {
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
// 跳过 SKILL.md(已经下载)
if item_name == "SKILL.md" {
continue;
}
if item_type == "file" {
// 下载文件
if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) {
let file_response = self.client().get(download_url).send().await?;
if file_response.status().is_success() {
let content = file_response.text().await?;
let file_path = target_dir.join(item_name);
fs::write(&file_path, &content)?;
log::debug!("下载文件: {}", file_path.display());
}
}
} else if item_type == "dir" {
// 递归下载子目录
if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) {
let sub_dir = target_dir.join(item_name);
fs::create_dir_all(&sub_dir)?;
// 递归调用,使用 Box::pin 处理异步递归
Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?;
}
}
}
Ok(())
}
/// 记录安装状态
fn record_installation(
&self,
conn: &Connection,
component: &TemplateComponent,
app_type: &str,
) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
component.id,
component.component_type.as_str(),
&component.name,
&component.path,
app_type
],
)?;
Ok(())
}
/// 卸载组件
pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> {
// 查询组件信息
let component: TemplateComponent = conn.query_row(
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
FROM template_components
WHERE id = ?1",
params![id],
|row| {
Ok(TemplateComponent {
id: Some(row.get(0)?),
repo_id: row.get(1)?,
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
.unwrap_or(ComponentType::Agent),
category: row.get(3)?,
name: row.get(4)?,
path: row.get(5)?,
description: row.get(6)?,
content_hash: row.get(7)?,
installed: false,
})
},
)?;
// 删除文件
match component.component_type {
ComponentType::Agent => {
let config_dir = Self::get_app_config_dir(app_type)?;
let file_path = config_dir
.join("agents")
.join(format!("{}.md", component.name));
if file_path.exists() {
fs::remove_file(&file_path)?;
}
}
ComponentType::Command => {
let config_dir = Self::get_app_config_dir(app_type)?;
let file_path = config_dir
.join("commands")
.join(format!("{}.md", component.name));
if file_path.exists() {
fs::remove_file(&file_path)?;
}
}
ComponentType::Skill => {
let config_dir = Self::get_app_config_dir(app_type)?;
let skill_dir = config_dir.join("skills").join(&component.name);
if skill_dir.exists() {
fs::remove_dir_all(&skill_dir)?;
}
}
ComponentType::Mcp => {
let config_dir = Self::get_app_config_dir(app_type)?;
let file_path = config_dir
.join("mcps")
.join(format!("{}.json", component.name));
if file_path.exists() {
fs::remove_file(&file_path)?;
}
}
ComponentType::Setting => {
let config_dir = Self::get_app_config_dir(app_type)?;
let file_path = config_dir
.join("settings")
.join(format!("{}.json", component.name));
if file_path.exists() {
fs::remove_file(&file_path)?;
}
}
ComponentType::Hook => {
let config_dir = Self::get_app_config_dir(app_type)?;
let file_path = config_dir
.join("hooks")
.join(format!("{}.json", component.name));
if file_path.exists() {
fs::remove_file(&file_path)?;
}
}
}
// 删除安装记录
conn.execute(
"DELETE FROM installed_components
WHERE component_id = ?1 AND app_type = ?2",
params![id, app_type],
)?;
log::info!("组件已卸载: {}", component.name);
Ok(())
}
/// 批量安装组件
pub async fn batch_install(
&self,
conn: &Connection,
ids: Vec<i64>,
app_type: &str,
) -> Result<BatchInstallResult> {
let mut success = Vec::new();
let mut failed = Vec::new();
for id in ids {
match self.install_component(conn, id, app_type).await {
Ok(_) => success.push(id),
Err(e) => failed.push((id, e.to_string())),
}
}
Ok(BatchInstallResult { success, failed })
}
/// 列出已安装的组件
#[allow(dead_code)]
pub fn list_installed(
&self,
conn: &Connection,
app_type: Option<&str>,
) -> Result<Vec<InstalledComponent>> {
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(at) = app_type {
(
"SELECT id, component_id, component_type, name, path, app_type, installed_at
FROM installed_components
WHERE app_type = ?
ORDER BY installed_at DESC"
.to_string(),
vec![Box::new(at.to_string())],
)
} else {
(
"SELECT id, component_id, component_type, name, path, app_type, installed_at
FROM installed_components
ORDER BY installed_at DESC"
.to_string(),
vec![],
)
};
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let components = stmt
.query_map(param_refs.as_slice(), |row| {
Ok(InstalledComponent {
id: Some(row.get(0)?),
component_id: row.get(1)?,
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
.unwrap_or(ComponentType::Agent),
name: row.get(3)?,
path: row.get(4)?,
app_type: row.get(5)?,
installed_at: row
.get::<_, String>(6)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(components)
}
/// 预览组件内容(仅获取内容,不进行安装)
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
// 查询组件基本信息
let component: TemplateComponent = conn.query_row(
"SELECT id, repo_id, component_type, category, name, path, description, content_hash
FROM template_components
WHERE id = ?1",
params![id],
|row| {
Ok(TemplateComponent {
id: Some(row.get(0)?),
repo_id: row.get(1)?,
component_type: ComponentType::from_str(&row.get::<_, String>(2)?)
.unwrap_or(ComponentType::Agent),
category: row.get(3)?,
name: row.get(4)?,
path: row.get(5)?,
description: row.get(6)?,
content_hash: row.get(7)?,
installed: false,
})
},
)?;
// 查询仓库信息
let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row(
"SELECT owner, name, branch FROM template_repos WHERE id = ?1",
params![component.repo_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
// 下载并读取组件内容
let content = self
.download_component_content(&repo_owner, &repo_name, &branch, &component.path)
.await?;
Ok(content)
}
}
+357
View File
@@ -0,0 +1,357 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub mod adapters;
pub mod index;
pub mod installer;
pub mod repo;
#[allow(unused_imports)]
pub use adapters::{create_adapter, AppAdapter};
/// 组件类型枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ComponentType {
Agent,
Command,
Mcp,
Setting,
Hook,
Skill,
}
impl ComponentType {
pub fn as_str(&self) -> &str {
match self {
ComponentType::Agent => "agent",
ComponentType::Command => "command",
ComponentType::Mcp => "mcp",
ComponentType::Setting => "setting",
ComponentType::Hook => "hook",
ComponentType::Skill => "skill",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"agent" => Some(ComponentType::Agent),
"command" => Some(ComponentType::Command),
"mcp" => Some(ComponentType::Mcp),
"setting" => Some(ComponentType::Setting),
"hook" => Some(ComponentType::Hook),
"skill" => Some(ComponentType::Skill),
_ => None,
}
}
}
/// 模板仓库
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateRepo {
pub id: Option<i64>,
pub owner: String,
pub name: String,
pub branch: String,
pub enabled: bool,
#[serde(rename = "createdAt")]
pub created_at: Option<DateTime<Utc>>,
#[serde(rename = "updatedAt")]
pub updated_at: Option<DateTime<Utc>>,
}
impl TemplateRepo {
pub fn new(owner: String, name: String, branch: String) -> Self {
Self {
id: None,
owner,
name,
branch,
enabled: true,
created_at: None,
updated_at: None,
}
}
}
/// 模板组件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateComponent {
pub id: Option<i64>,
#[serde(rename = "repoId")]
pub repo_id: i64,
#[serde(rename = "componentType")]
pub component_type: ComponentType,
pub category: Option<String>,
pub name: String,
pub path: String,
pub description: Option<String>,
#[serde(rename = "contentHash")]
pub content_hash: Option<String>,
/// 是否已安装(前端展示用,需要在查询时填充)
pub installed: bool,
}
/// 组件详情(含完整内容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentDetail {
#[serde(flatten)]
pub component: TemplateComponent,
/// 完整文件内容
pub content: String,
/// 仓库所有者
#[serde(rename = "repoOwner")]
pub repo_owner: String,
/// 仓库名称
#[serde(rename = "repoName")]
pub repo_name: String,
/// 仓库分支
#[serde(rename = "repoBranch")]
pub repo_branch: String,
/// GitHub README URL
#[serde(rename = "readmeUrl")]
pub readme_url: String,
}
/// 组件元数据(从文件 front matter 解析)
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct ComponentMetadata {
pub name: Option<String>,
pub description: Option<String>,
/// Agent 专用 - 工具列表
pub tools: Option<String>,
/// Agent 专用 - 模型名称
pub model: Option<String>,
}
/// 分页结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedResult<T> {
pub items: Vec<T>,
pub total: i64,
pub page: u32,
#[serde(rename = "pageSize")]
pub page_size: u32,
}
/// 批量安装结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchInstallResult {
pub success: Vec<i64>,
pub failed: Vec<(i64, String)>,
}
/// 已安装组件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledComponent {
pub id: Option<i64>,
#[serde(rename = "componentId")]
pub component_id: Option<i64>,
#[serde(rename = "componentType")]
pub component_type: ComponentType,
pub name: String,
pub path: String,
#[serde(rename = "appType")]
pub app_type: String,
#[serde(rename = "installedAt")]
pub installed_at: DateTime<Utc>,
}
/// 市场组合项(plugin 中的单个组件)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceBundleItem {
pub name: String,
pub path: String,
#[serde(rename = "componentType")]
pub component_type: String,
}
/// 市场组合
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceBundle {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub components: Vec<MarketplaceBundleItem>,
}
/// Template 服务
pub struct TemplateService {
http_client: Client,
}
impl TemplateService {
pub fn new() -> Result<Self> {
Ok(Self {
http_client: Client::builder()
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(30))
.build()
.context("创建 HTTP 客户端失败")?,
})
}
/// 获取 HTTP 客户端
pub fn client(&self) -> &Client {
&self.http_client
}
/// 获取应用配置目录
pub fn get_app_config_dir(app_type: &str) -> Result<PathBuf> {
let home = dirs::home_dir().context("无法获取用户主目录")?;
let dir = match app_type.to_lowercase().as_str() {
"claude" => {
// 检查是否有自定义 Claude 配置目录
if let Some(custom) = crate::settings::get_claude_override_dir() {
custom
} else {
home.join(".claude")
}
}
"codex" => {
// 检查是否有自定义 Codex 配置目录
if let Some(custom) = crate::settings::get_codex_override_dir() {
custom
} else {
home.join(".codex")
}
}
"gemini" => {
// 检查是否有自定义 Gemini 配置目录
if let Some(custom) = crate::settings::get_gemini_override_dir() {
custom
} else {
home.join(".gemini")
}
}
_ => anyhow::bail!("不支持的应用类型: {app_type}"),
};
Ok(dir)
}
/// 从 components.json 获取市场组合
pub async fn fetch_marketplace_bundles(
&self,
conn: &rusqlite::Connection,
) -> Result<Vec<MarketplaceBundle>> {
// 获取启用的仓库
let repos = self.list_enabled_repos(conn)?;
if repos.is_empty() {
return Ok(vec![]);
}
let mut bundles = Vec::new();
for repo in repos {
// 尝试多个可能的路径
let urls = [
format!(
"https://raw.githubusercontent.com/{}/{}/{}/components.json",
repo.owner, repo.name, repo.branch
),
format!(
"https://raw.githubusercontent.com/{}/{}/{}/docs/components.json",
repo.owner, repo.name, repo.branch
),
];
for url in urls {
match self.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
// 解析 marketplace.plugins(完整插件包)
if let Some(marketplace) = json.get("marketplace") {
if let Some(plugins) = marketplace.get("plugins") {
if let Some(arr) = plugins.as_array() {
for plugin in arr {
let name = plugin
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let description = plugin
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
// 提取各类型组件路径
let mut components = Vec::new();
let component_types = [
"agents", "commands", "mcps", "settings", "hooks",
"skills",
];
for comp_type in component_types {
if let Some(paths) =
plugin.get(comp_type).and_then(|v| v.as_array())
{
// 单数形式的类型名
let singular_type = match comp_type {
"agents" => "agent",
"commands" => "command",
"mcps" => "mcp",
"settings" => "setting",
"hooks" => "hook",
"skills" => "skill",
_ => comp_type,
};
for path_val in paths {
if let Some(path) = path_val.as_str() {
// 从路径提取组件名(文件名不含扩展名)
let comp_name =
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
components.push(
MarketplaceBundleItem {
name: comp_name,
path: path.to_string(),
component_type: singular_type
.to_string(),
},
);
}
}
}
}
if !components.is_empty() {
bundles.push(MarketplaceBundle {
id: format!("{}-plugin-{}", repo.name, name),
name: name.to_string(),
description: description.to_string(),
category: "plugin".to_string(),
components,
});
}
}
}
}
}
}
break; // 成功获取后跳出 URL 循环
}
_ => continue,
}
}
}
Ok(bundles)
}
}
impl Default for TemplateService {
fn default() -> Self {
Self::new().expect("创建 TemplateService 失败")
}
}
+239
View File
@@ -0,0 +1,239 @@
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use super::{TemplateRepo, TemplateService};
#[allow(dead_code)]
impl TemplateService {
/// 列出所有模板仓库
pub fn list_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
let mut stmt = conn
.prepare(
"SELECT id, owner, name, branch, enabled, created_at, updated_at
FROM template_repos
ORDER BY created_at DESC",
)
.context("准备查询模板仓库语句失败")?;
let repos = stmt
.query_map([], |row| {
Ok(TemplateRepo {
id: Some(row.get(0)?),
owner: row.get(1)?,
name: row.get(2)?,
branch: row.get(3)?,
enabled: row.get::<_, i64>(4)? != 0,
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
})
})
.context("查询模板仓库失败")?
.collect::<Result<Vec<_>, _>>()
.context("收集模板仓库结果失败")?;
Ok(repos)
}
/// 添加模板仓库
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
// 检查是否已存在
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2",
params![&repo.owner, &repo.name],
|row| row.get(0),
)
.ok();
if let Some(id) = existing {
// 更新已存在的仓库
conn.execute(
"UPDATE template_repos
SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP
WHERE id = ?3",
params![&repo.branch, repo.enabled as i64, id],
)
.context("更新模板仓库失败")?;
Ok(id)
} else {
// 插入新仓库
conn.execute(
"INSERT INTO template_repos (owner, name, branch, enabled)
VALUES (?1, ?2, ?3, ?4)",
params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64],
)
.context("插入模板仓库失败")?;
Ok(conn.last_insert_rowid())
}
}
/// 删除模板仓库
pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> {
let rows = conn
.execute("DELETE FROM template_repos WHERE id = ?1", params![id])
.context("删除模板仓库失败")?;
if rows == 0 {
anyhow::bail!("模板仓库不存在: id={id}");
}
Ok(())
}
/// 切换仓库启用状态
pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result<bool> {
// 获取当前状态
let enabled: i64 = conn
.query_row(
"SELECT enabled FROM template_repos WHERE id = ?1",
params![id],
|row| row.get(0),
)
.context("查询仓库状态失败")?;
let new_enabled = enabled == 0;
// 更新状态
conn.execute(
"UPDATE template_repos
SET enabled = ?1, updated_at = CURRENT_TIMESTAMP
WHERE id = ?2",
params![new_enabled as i64, id],
)
.context("更新仓库状态失败")?;
Ok(new_enabled)
}
/// 获取单个仓库
pub fn get_repo(&self, conn: &Connection, id: i64) -> Result<TemplateRepo> {
conn.query_row(
"SELECT id, owner, name, branch, enabled, created_at, updated_at
FROM template_repos
WHERE id = ?1",
params![id],
|row| {
Ok(TemplateRepo {
id: Some(row.get(0)?),
owner: row.get(1)?,
name: row.get(2)?,
branch: row.get(3)?,
enabled: row.get::<_, i64>(4)? != 0,
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
})
},
)
.context(format!("查询模板仓库失败: id={id}"))
}
/// 获取启用的仓库列表
pub fn list_enabled_repos(&self, conn: &Connection) -> Result<Vec<TemplateRepo>> {
let mut stmt = conn
.prepare(
"SELECT id, owner, name, branch, enabled, created_at, updated_at
FROM template_repos
WHERE enabled = 1
ORDER BY created_at DESC",
)
.context("准备查询启用仓库语句失败")?;
let repos = stmt
.query_map([], |row| {
Ok(TemplateRepo {
id: Some(row.get(0)?),
owner: row.get(1)?,
name: row.get(2)?,
branch: row.get(3)?,
enabled: row.get::<_, i64>(4)? != 0,
created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()),
updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()),
})
})
.context("查询启用仓库失败")?
.collect::<Result<Vec<_>, _>>()
.context("收集启用仓库结果失败")?;
Ok(repos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS template_repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name)
)",
[],
)
.unwrap();
conn
}
#[test]
fn test_add_and_list_repos() {
let conn = setup_db();
let service = TemplateService::new().unwrap();
// 添加仓库
let repo = TemplateRepo::new(
"yovinchen".to_string(),
"claude-code-templates".to_string(),
"main".to_string(),
);
let id = service.add_repo(&conn, repo).unwrap();
assert!(id > 0);
// 列出仓库
let repos = service.list_repos(&conn).unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].owner, "yovinchen");
assert_eq!(repos[0].name, "claude-code-templates");
}
#[test]
fn test_toggle_repo_enabled() {
let conn = setup_db();
let service = TemplateService::new().unwrap();
// 添加仓库
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
let id = service.add_repo(&conn, repo).unwrap();
// 切换状态
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
assert!(!enabled);
let enabled = service.toggle_repo_enabled(&conn, id).unwrap();
assert!(enabled);
}
#[test]
fn test_remove_repo() {
let conn = setup_db();
let service = TemplateService::new().unwrap();
// 添加仓库
let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string());
let id = service.add_repo(&conn, repo).unwrap();
// 删除仓库
service.remove_repo(&conn, id).unwrap();
// 验证已删除
let repos = service.list_repos(&conn).unwrap();
assert_eq!(repos.len(), 0);
}
}
+16 -9
View File
@@ -802,25 +802,25 @@ pub(crate) fn find_model_pricing_row(
conn: &Connection, conn: &Connection,
model_id: &str, model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> { ) -> Result<Option<(String, String, String, String)>, AppError> {
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905 // 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
let without_prefix = model_id // 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
let cleaned = model_id
.rsplit_once('/') .rsplit_once('/')
.map(|(_, rest)| rest) .map_or(model_id, |(_, r)| r)
.unwrap_or(model_id);
let cleaned = without_prefix
.split(':') .split(':')
.next() .next()
.map(str::trim) .unwrap_or(model_id)
.unwrap_or(without_prefix); .trim()
.replace('@', "-");
// 2) 精确匹配清洗后的名称 // 精确匹配清洗后的名称
let exact = conn let exact = conn
.query_row( .query_row(
"SELECT input_cost_per_million, output_cost_per_million, "SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing FROM model_pricing
WHERE model_id = ?1", WHERE model_id = ?1",
[cleaned], [&cleaned],
|row| { |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
@@ -952,6 +952,13 @@ mod tests {
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905" "带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
); );
// 清洗:@ 替换为 -seed_model_pricing 已预置 gpt-5.2-codex-low
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
assert!(
result.is_some(),
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
);
// 测试不存在的模型 // 测试不存在的模型
let result = find_model_pricing_row(&conn, "unknown-model-123")?; let result = find_model_pricing_row(&conn, "unknown-model-123")?;
assert!(result.is_none(), "不应该匹配不存在的模型"); assert!(result.is_none(), "不应该匹配不存在的模型");
+23 -10
View File
@@ -92,12 +92,9 @@ impl Default for AppSettings {
} }
impl AppSettings { impl AppSettings {
fn settings_path() -> PathBuf { fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景 // settings.json 保留用于旧版本迁移和无数据库场景
dirs::home_dir() dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
.expect("无法获取用户主目录")
.join(".cc-switch")
.join("settings.json")
} }
fn normalize_paths(&mut self) { fn normalize_paths(&mut self) {
@@ -131,7 +128,9 @@ impl AppSettings {
} }
fn load_from_file() -> Self { fn load_from_file() -> Self {
let path = Self::settings_path(); let Some(path) = Self::settings_path() else {
return Self::default();
};
if let Ok(content) = fs::read_to_string(&path) { if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) { match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => { Ok(mut settings) => {
@@ -156,7 +155,9 @@ impl AppSettings {
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone(); let mut normalized = settings.clone();
normalized.normalize_paths(); normalized.normalize_paths();
let path = AppSettings::settings_path(); let Some(path) = AppSettings::settings_path() else {
return Err(AppError::Config("无法获取用户主目录".to_string()));
};
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -193,14 +194,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
} }
pub fn get_settings() -> AppSettings { pub fn get_settings() -> AppSettings {
settings_store().read().expect("读取设置锁失败").clone() settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.clone()
} }
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths(); new_settings.normalize_paths();
save_settings_file(&new_settings)?; save_settings_file(&new_settings)?;
let mut guard = settings_store().write().expect("写入设置锁失败"); let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
*guard = new_settings; *guard = new_settings;
Ok(()) Ok(())
} }
@@ -209,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
/// 用于导入配置等场景,确保内存缓存与文件同步 /// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> { pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file(); let fresh_settings = AppSettings::load_from_file();
let mut guard = settings_store().write().expect("写入设置锁失败"); let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
*guard = fresh_settings; *guard = fresh_settings;
Ok(()) Ok(())
} }
+8 -18
View File
@@ -1,8 +1,6 @@
use reqwest::Client;
use rquickjs::{Context, Function, Runtime}; use rquickjs::{Context, Function, Runtime};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
use url::{Host, Url}; use url::{Host, Url};
use crate::error::AppError; use crate::error::AppError;
@@ -215,18 +213,10 @@ struct RequestConfig {
/// 发送 HTTP 请求 /// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> { async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 约束超时范围,防止异常配置导致长时间阻塞 // 使用全局 HTTP 客户端(已包含代理配置)
let timeout = timeout_secs.clamp(2, 30); let client = crate::proxy::http_client::get();
let client = Client::builder() // 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
.timeout(Duration::from_secs(timeout)) let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
.build()
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
// 严格校验 HTTP 方法,非法值不回退为 GET // 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| { let method: reqwest::Method = config.method.parse().map_err(|_| {
@@ -237,7 +227,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
) )
})?; })?;
let mut req = client.request(method.clone(), &config.url); let mut req = client
.request(method.clone(), &config.url)
.timeout(request_timeout);
// 添加请求头 // 添加请求头
for (k, v) in &config.headers { for (k, v) in &config.headers {
@@ -864,9 +856,7 @@ mod tests {
} else { } else {
assert!( assert!(
result.is_err(), result.is_err(),
"应该不匹配的URL被允许: base_url={}, request_url={}", "应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}"
base_url,
request_url
); );
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.9.0", "version": "3.9.1",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+1
View File
@@ -4,6 +4,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"title": "CC Switch",
"titleBarStyle": "Visible", "titleBarStyle": "Visible",
"minWidth": 900, "minWidth": 900,
"minHeight": 600 "minHeight": 600
+48 -5
View File
@@ -13,6 +13,7 @@ import {
Wrench, Wrench,
Server, Server,
RefreshCw, RefreshCw,
Package,
Search, Search,
Download, Download,
} from "lucide-react"; } from "lucide-react";
@@ -31,6 +32,7 @@ import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue"; import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isWindows, isLinux } from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher"; import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList"; import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -47,6 +49,7 @@ import { SkillsPage } from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { TemplatesPage } from "@/components/templates/TemplatesPage";
import { UniversalProviderPanel } from "@/components/universal"; import { UniversalProviderPanel } from "@/components/universal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -57,10 +60,12 @@ type View =
| "skills" | "skills"
| "skillsDiscovery" | "skillsDiscovery"
| "mcp" | "mcp"
| "templates"
| "agents" | "agents"
| "universal"; | "universal";
const DRAG_BAR_HEIGHT = 28; // px // 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 HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
@@ -380,6 +385,26 @@ function App() {
await addProvider(duplicatedProvider); await addProvider(duplicatedProvider);
}; };
// 打开提供商终端
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
}),
);
} catch (error) {
console.error("[App] Failed to open terminal", error);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.terminalOpenFailed", {
defaultValue: "打开终端失败",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
}
};
// 导入配置成功后刷新 // 导入配置成功后刷新
const handleImportSuccess = async () => { const handleImportSuccess = async () => {
try { try {
@@ -450,6 +475,8 @@ function App() {
<UniversalProviderPanel /> <UniversalProviderPanel />
</div> </div>
); );
case "templates":
return <TemplatesPage activeApp={activeApp} />;
default: default:
return ( return (
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden"> <div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
@@ -480,6 +507,9 @@ function App() {
onDuplicate={handleDuplicateProvider} onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider} onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite} onOpenWebsite={handleOpenWebsite}
onOpenTerminal={
activeApp === "claude" ? handleOpenTerminal : undefined
}
onCreate={() => setIsAddOpen(true)} onCreate={() => setIsAddOpen(true)}
/> />
</motion.div> </motion.div>
@@ -587,6 +617,7 @@ function App() {
{currentView === "skillsDiscovery" && t("skills.title")} {currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")} {currentView === "agents" && t("agents.title")}
{currentView === "templates" && t("templates.title")}
{currentView === "universal" && {currentView === "universal" &&
t("universalProvider.title", { t("universalProvider.title", {
defaultValue: "统一供应商", defaultValue: "统一供应商",
@@ -622,10 +653,12 @@ function App() {
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
</Button> </Button>
</div> </div>
<UpdateBadge onClick={() => { <UpdateBadge
setSettingsDefaultTab("about"); onClick={() => {
setCurrentView("settings"); setSettingsDefaultTab("about");
}} /> setCurrentView("settings");
}}
/>
</> </>
)} )}
</div> </div>
@@ -763,6 +796,15 @@ function App() {
> >
<Server className="w-4 h-4" /> <Server className="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("templates")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("templates.title")}
>
<Package className="h-4 w-4" />
</Button>
</div> </div>
<Button <Button
@@ -804,6 +846,7 @@ function App() {
{effectiveUsageProvider && ( {effectiveUsageProvider && (
<UsageScriptModal <UsageScriptModal
key={effectiveUsageProvider.id}
provider={effectiveUsageProvider} provider={effectiveUsageProvider}
appId={activeApp} appId={activeApp}
isOpen={Boolean(usageProvider)} isOpen={Boolean(usageProvider)}
+19 -4
View File
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
</div> </div>
{/* API Endpoint */} {/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4"> <div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground"> <div className="font-medium text-sm text-muted-foreground pt-0.5">
{t("deeplink.endpoint")} {t("deeplink.endpoint")}
</div> </div>
<div className="col-span-2 text-sm break-all"> <div className="col-span-2 text-sm break-all space-y-1">
{request.endpoint} {request.endpoint?.split(",").map((ep, idx) => (
<div
key={idx}
className={
idx === 0 ? "font-medium" : "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
</div>
))}
</div> </div>
</div> </div>
+2 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isWindows, isLinux } from "@/lib/platform";
interface FullScreenPanelProps { interface FullScreenPanelProps {
isOpen: boolean; isOpen: boolean;
@@ -12,7 +13,7 @@ interface FullScreenPanelProps {
footer?: React.ReactNode; footer?: React.ReactNode;
} }
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx const HEADER_HEIGHT = 64; // px - match App.tsx
/** /**
@@ -6,6 +6,7 @@ import {
Loader2, Loader2,
Play, Play,
Plus, Plus,
Terminal,
TestTube2, TestTube2,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
onTest?: () => void; onTest?: () => void;
onConfigureUsage: () => void; onConfigureUsage: () => void;
onDelete: () => void; onDelete: () => void;
onOpenTerminal?: () => void;
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled?: boolean; isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean; isInFailoverQueue?: boolean;
@@ -39,6 +41,7 @@ export function ProviderActions({
onTest, onTest,
onConfigureUsage, onConfigureUsage,
onDelete, onDelete,
onOpenTerminal,
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled = false, isAutoFailoverEnabled = false,
isInFailoverQueue = false, isInFailoverQueue = false,
@@ -171,6 +174,21 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
</Button> </Button>
{onOpenTerminal && (
<Button
size="icon"
variant="ghost"
onClick={onOpenTerminal}
title={t("provider.openTerminal", "打开终端")}
className={cn(
iconButtonClass,
"hover:text-emerald-600 dark:hover:text-emerald-400",
)}
>
<Terminal className="h-4 w-4" />
</Button>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
+39 -6
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect, useRef } from "react";
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { import type {
@@ -33,6 +33,7 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void; onTest?: (provider: Provider) => void;
onOpenTerminal?: (provider: Provider) => void;
isTesting?: boolean; isTesting?: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
@@ -91,6 +92,7 @@ export function ProviderCard({
onOpenWebsite, onOpenWebsite,
onDuplicate, onDuplicate,
onTest, onTest,
onOpenTerminal,
isTesting, isTesting,
isProxyRunning, isProxyRunning,
isProxyTakeover = false, isProxyTakeover = false,
@@ -147,6 +149,10 @@ export function ProviderCard({
// 多套餐默认展开 // 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开 // 当检测到多套餐时自动展开
useEffect(() => { useEffect(() => {
if (hasMultiplePlans) { if (hasMultiplePlans) {
@@ -154,6 +160,20 @@ export function ProviderCard({
} }
}, [hasMultiplePlans]); }, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
const width = actionsRef.current?.offsetWidth || 0;
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
const handleOpenWebsite = () => { const handleOpenWebsite = () => {
if (!isClickableUrl) { if (!isClickableUrl) {
return; return;
@@ -279,10 +299,17 @@ export function ProviderCard({
</div> </div>
</div> </div>
<div className="relative flex items-center ml-auto min-w-0"> <div
className="relative flex items-center ml-auto min-w-0 gap-3"
style={
{
"--actions-width": `${actionsWidth || 320}px`,
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */} {/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]"> <div className="ml-auto">
<div className="flex items-center gap-1"> <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 ? ( {hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
@@ -327,8 +354,11 @@ export function ProviderCard({
</div> </div>
</div> </div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */} {/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 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"> <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"
>
<ProviderActions <ProviderActions
isCurrent={isCurrent} isCurrent={isCurrent}
isTesting={isTesting} isTesting={isTesting}
@@ -339,6 +369,9 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined} onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)} onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)} onDelete={() => onDelete(provider)}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关 // 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled} isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue} isInFailoverQueue={isInFailoverQueue}
@@ -41,6 +41,7 @@ interface ProviderListProps {
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onCreate?: () => void; onCreate?: () => void;
isLoading?: boolean; isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态 isProxyRunning?: boolean; // 代理服务运行状态
@@ -58,6 +59,7 @@ export function ProviderList({
onDuplicate, onDuplicate,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
onOpenTerminal,
onCreate, onCreate,
isLoading = false, isLoading = false,
isProxyRunning = false, isProxyRunning = false,
@@ -203,6 +205,7 @@ export function ProviderList({
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage} onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest} onTest={handleTest}
isTesting={isChecking(provider.id)} isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
@@ -311,6 +314,7 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest: (provider: Provider) => void; onTest: (provider: Provider) => void;
isTesting: boolean; isTesting: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
@@ -333,6 +337,7 @@ function SortableProviderCard({
onDuplicate, onDuplicate,
onConfigureUsage, onConfigureUsage,
onOpenWebsite, onOpenWebsite,
onOpenTerminal,
onTest, onTest,
isTesting, isTesting,
isProxyRunning, isProxyRunning,
@@ -371,6 +376,7 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
} }
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={onTest} onTest={onTest}
isTesting={isTesting} isTesting={isTesting}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Selector // Model Selector
shouldShowModelSelector: boolean; shouldShowModelSelector: boolean;
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelSelector, shouldShowModelSelector,
claudeModel, claudeModel,
reasoningModel, reasoningModel,
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
initialEndpoints={speedTestEndpoints} initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen} visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)} onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange} onCustomEndpointsChange={onCustomEndpointsChange}
/> />
)} )}
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Name // Model Name
shouldShowModelField?: boolean; shouldShowModelField?: boolean;
@@ -50,6 +52,8 @@ export function CodexFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField = true, shouldShowModelField = true,
modelName = "", modelName = "",
onModelNameChange, onModelNameChange,
@@ -130,6 +134,8 @@ export function CodexFormFields({
initialEndpoints={speedTestEndpoints} initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen} visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)} onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange} onCustomEndpointsChange={onCustomEndpointsChange}
/> />
)} )}
@@ -30,6 +30,8 @@ interface EndpointSpeedTestProps {
initialEndpoints: EndpointCandidate[]; initialEndpoints: EndpointCandidate[];
visible?: boolean; visible?: boolean;
onClose: () => void; onClose: () => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目) // 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端 // 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void; onCustomEndpointsChange?: (urls: string[]) => void;
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
initialEndpoints, initialEndpoints,
visible = true, visible = true,
onClose, onClose,
autoSelect,
onAutoSelectChange,
onCustomEndpointsChange, onCustomEndpointsChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -93,7 +97,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
); );
const [customUrl, setCustomUrl] = useState(""); const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null); const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null); const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<input <input
type="checkbox" type="checkbox"
checked={autoSelect} checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)} onChange={(event) => {
onAutoSelectChange(event.target.checked);
}}
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20" className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
/> />
{t("endpointTest.autoSelect")} {t("endpointTest.autoSelect")}
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void; onCustomEndpointsChange: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model // Model
shouldShowModelField: boolean; shouldShowModelField: boolean;
@@ -55,6 +57,8 @@ export function GeminiFormFields({
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField, shouldShowModelField,
model, model,
onModelChange, onModelChange,
@@ -142,6 +146,8 @@ export function GeminiFormFields({
initialEndpoints={speedTestEndpoints} initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen} visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)} onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange} onCustomEndpointsChange={onCustomEndpointsChange}
/> />
)} )}
@@ -124,6 +124,9 @@ export function ProviderForm({
return []; return [];
}, },
); );
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
// 使用 category hook // 使用 category hook
const { category } = useProviderCategory({ const { category } = useProviderCategory({
@@ -141,6 +144,7 @@ export function ProviderForm({
if (!initialData) { if (!initialData) {
setDraftCustomEndpoints([]); setDraftCustomEndpoints([]);
} }
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
}, [appId, initialData]); }, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo( const defaultValues: ProviderFormData = useMemo(
@@ -236,7 +240,7 @@ export function ProviderForm({
} catch { } catch {
// ignore // ignore
} }
return true; return false; // OpenRouter now supports Claude Code compatible API, no need for transform
}, [isOpenRouterProvider, settingsConfigValue]); }, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback( const handleOpenRouterCompatChange = useCallback(
@@ -647,6 +651,13 @@ export function ProviderForm({
} }
} }
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
};
onSubmit(payload); onSubmit(payload);
}; };
@@ -856,6 +867,8 @@ export function ProviderForm({
onCustomEndpointsChange={ onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints isEditMode ? undefined : setDraftCustomEndpoints
} }
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelSelector={category !== "official"} shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel} claudeModel={claudeModel}
reasoningModel={reasoningModel} reasoningModel={reasoningModel}
@@ -864,7 +877,7 @@ export function ProviderForm({
defaultOpusModel={defaultOpusModel} defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange} onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints} speedTestEndpoints={speedTestEndpoints}
showOpenRouterCompatToggle={isOpenRouterProvider} showOpenRouterCompatToggle={false}
openRouterCompatEnabled={openRouterCompatEnabled} openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange} onOpenRouterCompatChange={handleOpenRouterCompatChange}
/> />
@@ -889,6 +902,8 @@ export function ProviderForm({
onCustomEndpointsChange={ onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints isEditMode ? undefined : setDraftCustomEndpoints
} }
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={category !== "official"} shouldShowModelField={category !== "official"}
modelName={codexModelName} modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange} onModelNameChange={handleCodexModelNameChange}
@@ -917,6 +932,8 @@ export function ProviderForm({
isEndpointModalOpen={isEndpointModalOpen} isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen} onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints} onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true} shouldShowModelField={true}
model={geminiModel} model={geminiModel}
onModelChange={handleGeminiModelChange} onModelChange={handleGeminiModelChange}
@@ -43,11 +43,7 @@ export function useApiKeyState({
return; return;
} }
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key // 从配置中提取 API Key(如果不存在则返回空字符串
if (!hasApiKeyField(initialConfig, appType)) {
return;
}
const extracted = getApiKeyFromConfig(initialConfig, appType); const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) { if (extracted !== apiKey) {
setApiKey(extracted); setApiKey(extracted);
@@ -41,8 +41,9 @@ export function useBaseUrlState({
try { try {
const config = JSON.parse(settingsConfig || "{}"); const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL; const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) { const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
setBaseUrl(envUrl.trim()); if (nextUrl !== baseUrl) {
setBaseUrl(nextUrl);
} }
} catch { } catch {
// ignore // ignore
+180 -85
View File
@@ -21,52 +21,157 @@ export function AutoFailoverConfigPanel({
const { data: config, isLoading, error } = useAppProxyConfig(appType); const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateAppProxyConfig(); const updateConfig = useUpdateAppProxyConfig();
// 使用字符串状态以支持完全清空数字输入框
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
autoFailoverEnabled: false, autoFailoverEnabled: false,
maxRetries: 3, maxRetries: "3",
streamingFirstByteTimeout: 30, streamingFirstByteTimeout: "30",
streamingIdleTimeout: 60, streamingIdleTimeout: "60",
nonStreamingTimeout: 300, nonStreamingTimeout: "300",
circuitFailureThreshold: 5, circuitFailureThreshold: "5",
circuitSuccessThreshold: 2, circuitSuccessThreshold: "2",
circuitTimeoutSeconds: 60, circuitTimeoutSeconds: "60",
circuitErrorRateThreshold: 0.5, circuitErrorRateThreshold: "50", // 存储百分比值
circuitMinRequests: 10, circuitMinRequests: "10",
}); });
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setFormData({ setFormData({
autoFailoverEnabled: config.autoFailoverEnabled, autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries, maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: config.streamingFirstByteTimeout, streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: config.streamingIdleTimeout, streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: config.nonStreamingTimeout, nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: config.circuitFailureThreshold, circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: config.circuitSuccessThreshold, circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: config.circuitTimeoutSeconds, circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: config.circuitErrorRateThreshold, circuitErrorRateThreshold: String(
circuitMinRequests: config.circuitMinRequests, Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
}); });
} }
}, [config]); }, [config]);
const handleSave = async () => { const handleSave = async () => {
if (!config) return; if (!config) return;
// 解析数字,返回 NaN 表示无效输入
const parseNum = (val: string) => {
const trimmed = val.trim();
// 必须是纯数字
if (!/^-?\d+$/.test(trimmed)) return NaN;
return parseInt(trimmed);
};
// 定义各字段的有效范围
const ranges = {
maxRetries: { min: 0, max: 10 },
streamingFirstByteTimeout: { min: 0, max: 180 },
streamingIdleTimeout: { min: 0, max: 600 },
nonStreamingTimeout: { min: 0, max: 1800 },
circuitFailureThreshold: { min: 1, max: 20 },
circuitSuccessThreshold: { min: 1, max: 10 },
circuitTimeoutSeconds: { min: 0, max: 300 },
circuitErrorRateThreshold: { min: 0, max: 100 },
circuitMinRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
maxRetries: parseNum(formData.maxRetries),
streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
circuitMinRequests: parseNum(formData.circuitMinRequests),
};
// 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
checkRange(
raw.maxRetries,
ranges.maxRetries,
t("proxy.autoFailover.maxRetries", "最大重试次数"),
);
checkRange(
raw.streamingFirstByteTimeout,
ranges.streamingFirstByteTimeout,
t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
);
checkRange(
raw.streamingIdleTimeout,
ranges.streamingIdleTimeout,
t("proxy.autoFailover.streamingIdle", "流式静默超时"),
);
checkRange(
raw.nonStreamingTimeout,
ranges.nonStreamingTimeout,
t("proxy.autoFailover.nonStreaming", "非流式超时"),
);
checkRange(
raw.circuitFailureThreshold,
ranges.circuitFailureThreshold,
t("proxy.autoFailover.failureThreshold", "失败阈值"),
);
checkRange(
raw.circuitSuccessThreshold,
ranges.circuitSuccessThreshold,
t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
);
checkRange(
raw.circuitTimeoutSeconds,
ranges.circuitTimeoutSeconds,
t("proxy.autoFailover.timeout", "恢复等待时间"),
);
checkRange(
raw.circuitErrorRateThreshold,
ranges.circuitErrorRateThreshold,
t("proxy.autoFailover.errorRate", "错误率阈值"),
);
checkRange(
raw.circuitMinRequests,
ranges.circuitMinRequests,
t("proxy.autoFailover.minRequests", "最小请求数"),
);
if (errors.length > 0) {
toast.error(
t("proxy.autoFailover.validationFailed", {
fields: errors.join("; "),
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
}),
);
return;
}
try { try {
await updateConfig.mutateAsync({ await updateConfig.mutateAsync({
appType, appType,
enabled: config.enabled, enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled, autoFailoverEnabled: formData.autoFailoverEnabled,
maxRetries: formData.maxRetries, maxRetries: raw.maxRetries,
streamingFirstByteTimeout: formData.streamingFirstByteTimeout, streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
streamingIdleTimeout: formData.streamingIdleTimeout, streamingIdleTimeout: raw.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout, nonStreamingTimeout: raw.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold, circuitFailureThreshold: raw.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold, circuitSuccessThreshold: raw.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds, circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold, circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
circuitMinRequests: formData.circuitMinRequests, circuitMinRequests: raw.circuitMinRequests,
}); });
toast.success( toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -83,15 +188,17 @@ export function AutoFailoverConfigPanel({
if (config) { if (config) {
setFormData({ setFormData({
autoFailoverEnabled: config.autoFailoverEnabled, autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries, maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: config.streamingFirstByteTimeout, streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: config.streamingIdleTimeout, streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: config.nonStreamingTimeout, nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: config.circuitFailureThreshold, circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: config.circuitSuccessThreshold, circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: config.circuitTimeoutSeconds, circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: config.circuitErrorRateThreshold, circuitErrorRateThreshold: String(
circuitMinRequests: config.circuitMinRequests, Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
}); });
} }
}; };
@@ -142,13 +249,9 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="10" max="10"
value={formData.maxRetries} value={formData.maxRetries}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value); setFormData({ ...formData, maxRetries: e.target.value })
setFormData({ }
...formData,
maxRetries: isNaN(val) ? 0 : val,
});
}}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -169,13 +272,12 @@ export function AutoFailoverConfigPanel({
min="1" min="1"
max="20" max="20"
value={formData.circuitFailureThreshold} value={formData.circuitFailureThreshold}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val), circuitFailureThreshold: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -208,13 +310,12 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="180" max="180"
value={formData.streamingFirstByteTimeout} value={formData.streamingFirstByteTimeout}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
streamingFirstByteTimeout: isNaN(val) ? 0 : val, streamingFirstByteTimeout: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -235,13 +336,12 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="600" max="600"
value={formData.streamingIdleTimeout} value={formData.streamingIdleTimeout}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
streamingIdleTimeout: isNaN(val) ? 0 : val, streamingIdleTimeout: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -262,13 +362,12 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="1800" max="1800"
value={formData.nonStreamingTimeout} value={formData.nonStreamingTimeout}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
nonStreamingTimeout: isNaN(val) ? 0 : val, nonStreamingTimeout: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -298,13 +397,12 @@ export function AutoFailoverConfigPanel({
min="1" min="1"
max="10" max="10"
value={formData.circuitSuccessThreshold} value={formData.circuitSuccessThreshold}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val), circuitSuccessThreshold: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -322,16 +420,15 @@ export function AutoFailoverConfigPanel({
<Input <Input
id={`timeoutSeconds-${appType}`} id={`timeoutSeconds-${appType}`}
type="number" type="number"
min="10" min="0"
max="300" max="300"
value={formData.circuitTimeoutSeconds} value={formData.circuitTimeoutSeconds}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val), circuitTimeoutSeconds: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -352,14 +449,13 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="100" max="100"
step="5" step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)} value={formData.circuitErrorRateThreshold}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100, circuitErrorRateThreshold: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -380,13 +476,12 @@ export function AutoFailoverConfigPanel({
min="5" min="5"
max="100" max="100"
value={formData.circuitMinRequests} value={formData.circuitMinRequests}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val), circuitMinRequests: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -7,42 +7,141 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next";
/** /**
* *
* *
*/ */
export function CircuitBreakerConfigPanel() { export function CircuitBreakerConfigPanel() {
const { t } = useTranslation();
const { data: config, isLoading } = useCircuitBreakerConfig(); const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig(); const updateConfig = useUpdateCircuitBreakerConfig();
// 使用字符串状态以支持完全清空输入框
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
failureThreshold: 5, failureThreshold: "5",
successThreshold: 2, successThreshold: "2",
timeoutSeconds: 60, timeoutSeconds: "60",
errorRateThreshold: 0.5, errorRateThreshold: "50", // 存储百分比值
minRequests: 10, minRequests: "10",
}); });
// 当配置加载完成时更新表单数据 // 当配置加载完成时更新表单数据
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setFormData(config); setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
} }
}, [config]); }, [config]);
const handleSave = async () => { const handleSave = async () => {
// 解析数字,返回 NaN 表示无效输入
const parseNum = (val: string) => {
const trimmed = val.trim();
// 必须是纯数字
if (!/^-?\d+$/.test(trimmed)) return NaN;
return parseInt(trimmed);
};
// 定义各字段的有效范围
const ranges = {
failureThreshold: { min: 1, max: 20 },
successThreshold: { min: 1, max: 10 },
timeoutSeconds: { min: 0, max: 300 },
errorRateThreshold: { min: 0, max: 100 },
minRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
failureThreshold: parseNum(formData.failureThreshold),
successThreshold: parseNum(formData.successThreshold),
timeoutSeconds: parseNum(formData.timeoutSeconds),
errorRateThreshold: parseNum(formData.errorRateThreshold),
minRequests: parseNum(formData.minRequests),
};
// 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
checkRange(
raw.failureThreshold,
ranges.failureThreshold,
t("circuitBreaker.failureThreshold", "失败阈值"),
);
checkRange(
raw.successThreshold,
ranges.successThreshold,
t("circuitBreaker.successThreshold", "成功阈值"),
);
checkRange(
raw.timeoutSeconds,
ranges.timeoutSeconds,
t("circuitBreaker.timeoutSeconds", "超时时间"),
);
checkRange(
raw.errorRateThreshold,
ranges.errorRateThreshold,
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
);
checkRange(
raw.minRequests,
ranges.minRequests,
t("circuitBreaker.minRequests", "最小请求数"),
);
if (errors.length > 0) {
toast.error(
t("circuitBreaker.validationFailed", {
fields: errors.join("; "),
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
}),
);
return;
}
try { try {
await updateConfig.mutateAsync(formData); await updateConfig.mutateAsync({
toast.success("熔断器配置已保存", { closeButton: true }); failureThreshold: raw.failureThreshold,
successThreshold: raw.successThreshold,
timeoutSeconds: raw.timeoutSeconds,
errorRateThreshold: raw.errorRateThreshold / 100,
minRequests: raw.minRequests,
});
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
closeButton: true,
});
} catch (error) { } catch (error) {
toast.error("保存失败: " + String(error)); toast.error(
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
);
} }
}; };
const handleReset = () => { const handleReset = () => {
if (config) { if (config) {
setFormData(config); setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
} }
}; };
@@ -72,10 +171,7 @@ export function CircuitBreakerConfigPanel() {
max="20" max="20"
value={formData.failureThreshold} value={formData.failureThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, failureThreshold: e.target.value })
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -89,14 +185,11 @@ export function CircuitBreakerConfigPanel() {
<Input <Input
id="timeoutSeconds" id="timeoutSeconds"
type="number" type="number"
min="10" min="0"
max="300" max="300"
value={formData.timeoutSeconds} value={formData.timeoutSeconds}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, timeoutSeconds: e.target.value })
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -114,10 +207,7 @@ export function CircuitBreakerConfigPanel() {
max="10" max="10"
value={formData.successThreshold} value={formData.successThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, successThreshold: e.target.value })
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -134,12 +224,9 @@ export function CircuitBreakerConfigPanel() {
min="0" min="0"
max="100" max="100"
step="5" step="5"
value={Math.round(formData.errorRateThreshold * 100)} value={formData.errorRateThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, errorRateThreshold: e.target.value })
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -157,10 +244,7 @@ export function CircuitBreakerConfigPanel() {
max="100" max="100"
value={formData.minRequests} value={formData.minRequests}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, minRequests: e.target.value })
...formData,
minRequests: parseInt(e.target.value) || 10,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
+68 -16
View File
@@ -38,15 +38,15 @@ export function ProxyPanel() {
const { data: globalConfig } = useGlobalProxyConfig(); const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig(); const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态 // 监听地址/端口的本地状态(端口用字符串以支持完全清空)
const [listenAddress, setListenAddress] = useState("127.0.0.1"); const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(15721); const [listenPort, setListenPort] = useState("15721");
// 同步全局配置到本地状态 // 同步全局配置到本地状态
useEffect(() => { useEffect(() => {
if (globalConfig) { if (globalConfig) {
setListenAddress(globalConfig.listenAddress); setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort); setListenPort(String(globalConfig.listenPort));
} }
}, [globalConfig]); }, [globalConfig]);
@@ -102,11 +102,52 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => { const handleSaveBasicConfig = async () => {
if (!globalConfig) return; if (!globalConfig) return;
// 校验地址格式(简单的 IP 地址或 localhost 校验)
const addressTrimmed = listenAddress.trim();
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
const isValidAddress =
addressTrimmed === "localhost" ||
addressTrimmed === "0.0.0.0" ||
(ipv4Regex.test(addressTrimmed) &&
addressTrimmed.split(".").every((n) => {
const num = parseInt(n);
return num >= 0 && num <= 255;
}));
if (!isValidAddress) {
toast.error(
t("proxy.settings.invalidAddress", {
defaultValue:
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
}),
);
return;
}
// 严格校验端口:必须是纯数字
const portTrimmed = listenPort.trim();
if (!/^\d+$/.test(portTrimmed)) {
toast.error(
t("proxy.settings.invalidPort", {
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
}),
);
return;
}
const port = parseInt(portTrimmed);
if (isNaN(port) || port < 1024 || port > 65535) {
toast.error(
t("proxy.settings.invalidPort", {
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
}),
);
return;
}
try { try {
await updateGlobalConfig.mutateAsync({ await updateGlobalConfig.mutateAsync({
...globalConfig, ...globalConfig,
listenAddress, listenAddress: addressTrimmed,
listenPort, listenPort: port,
}); });
toast.success( toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
@@ -133,6 +174,13 @@ export function ProxyPanel() {
} }
}; };
// 格式化地址用于 URL(IPv6 需要方括号)
const formatAddressForUrl = (address: string, port: number): string => {
const isIPv6 = address.includes(":");
const host = isIPv6 ? `[${address}]` : address;
return `http://${host}:${port}`;
};
return ( return (
<> <>
<section className="space-y-6"> <section className="space-y-6">
@@ -147,14 +195,14 @@ export function ProxyPanel() {
</p> </p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60"> <code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port} {formatAddressForUrl(status.address, status.port)}
</code> </code>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`http://${status.address}:${status.port}`, formatAddressForUrl(status.address, status.port),
); );
toast.success( toast.success(
t("proxy.panel.addressCopied", { t("proxy.panel.addressCopied", {
@@ -389,9 +437,12 @@ export function ProxyPanel() {
id="listen-address" id="listen-address"
value={listenAddress} value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)} onChange={(e) => setListenAddress(e.target.value)}
placeholder={t("proxy.settings.fields.listenAddress.placeholder", { placeholder={t(
defaultValue: "127.0.0.1", "proxy.settings.fields.listenAddress.placeholder",
})} {
defaultValue: "127.0.0.1",
},
)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", { {t("proxy.settings.fields.listenAddress.description", {
@@ -411,12 +462,13 @@ export function ProxyPanel() {
id="listen-port" id="listen-port"
type="number" type="number"
value={listenPort} value={listenPort}
onChange={(e) => onChange={(e) => setListenPort(e.target.value)}
setListenPort(parseInt(e.target.value) || 15721) placeholder={t(
} "proxy.settings.fields.listenPort.placeholder",
placeholder={t("proxy.settings.fields.listenPort.placeholder", { {
defaultValue: "15721", defaultValue: "15721",
})} },
)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", { {t("proxy.settings.fields.listenPort.description", {
@@ -0,0 +1,275 @@
/**
*
*
*
*/
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react";
import {
useGlobalProxyUrl,
useSetGlobalProxyUrl,
useTestProxy,
useScanProxies,
type DetectedProxy,
} from "@/hooks/useGlobalProxy";
/** 从完整 URL 提取认证信息 */
function extractAuth(url: string): {
baseUrl: string;
username: string;
password: string;
} {
if (!url.trim()) return { baseUrl: "", username: "", password: "" };
try {
const parsed = new URL(url);
const username = decodeURIComponent(parsed.username || "");
const password = decodeURIComponent(parsed.password || "");
// 移除认证信息,获取基础 URL
parsed.username = "";
parsed.password = "";
return { baseUrl: parsed.toString(), username, password };
} catch {
return { baseUrl: url, username: "", password: "" };
}
}
/** 将认证信息合并到 URL */
function mergeAuth(
baseUrl: string,
username: string,
password: string,
): string {
if (!baseUrl.trim()) return "";
if (!username.trim()) return baseUrl;
try {
const parsed = new URL(baseUrl);
// URL 对象的 username/password setter 会自动进行 percent-encoding
// 不要使用 encodeURIComponent,否则会导致双重编码
parsed.username = username.trim();
if (password) {
parsed.password = password;
}
return parsed.toString();
} catch {
// URL 解析失败,尝试手动插入(此时需要手动编码)
const match = baseUrl.match(/^(\w+:\/\/)(.+)$/);
if (match) {
const auth = password
? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`
: `${encodeURIComponent(username.trim())}@`;
return `${match[1]}${auth}${match[2]}`;
}
return baseUrl;
}
}
export function GlobalProxySettings() {
const { t } = useTranslation();
const { data: savedUrl, isLoading } = useGlobalProxyUrl();
const setMutation = useSetGlobalProxyUrl();
const testMutation = useTestProxy();
const scanMutation = useScanProxies();
const [url, setUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [dirty, setDirty] = useState(false);
const [detected, setDetected] = useState<DetectedProxy[]>([]);
// 计算完整 URL(含认证信息)
const fullUrl = useMemo(
() => mergeAuth(url, username, password),
[url, username, password],
);
// 同步远程配置
useEffect(() => {
if (savedUrl !== undefined) {
const { baseUrl, username: u, password: p } = extractAuth(savedUrl || "");
setUrl(baseUrl);
setUsername(u);
setPassword(p);
setDirty(false);
}
}, [savedUrl]);
const handleSave = async () => {
await setMutation.mutateAsync(fullUrl);
setDirty(false);
};
const handleTest = async () => {
if (fullUrl) {
await testMutation.mutateAsync(fullUrl);
}
};
const handleScan = async () => {
const result = await scanMutation.mutateAsync();
setDetected(result);
};
const handleSelect = (proxyUrl: string) => {
const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);
setUrl(baseUrl);
setUsername(u);
setPassword(p);
setDirty(true);
setDetected([]);
};
const handleClear = () => {
setUrl("");
setUsername("");
setPassword("");
setDirty(true);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && dirty && !setMutation.isPending) {
handleSave();
}
};
// 只在首次加载且无数据时显示加载状态
if (isLoading && savedUrl === undefined) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* 描述 */}
<p className="text-sm text-muted-foreground">
{t("settings.globalProxy.hint")}
</p>
{/* 代理地址输入框和按钮 */}
<div className="flex gap-2">
<Input
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<Button
variant="outline"
size="icon"
disabled={scanMutation.isPending}
onClick={handleScan}
title={t("settings.globalProxy.scan")}
>
{scanMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!fullUrl || testMutation.isPending}
onClick={handleTest}
title={t("settings.globalProxy.test")}
>
{testMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!url && !username && !password}
onClick={handleClear}
title={t("settings.globalProxy.clear")}
>
<X className="h-4 w-4" />
</Button>
<Button
onClick={handleSave}
disabled={!dirty || setMutation.isPending}
size="sm"
>
{setMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
{/* 认证信息:用户名 + 密码(可选) */}
<div className="flex gap-2">
<Input
placeholder={t("settings.globalProxy.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<div className="relative flex-1">
<Input
type={showPassword ? "text" : "password"}
placeholder={t("settings.globalProxy.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
{/* 扫描结果 */}
{detected.length > 0 && (
<div className="flex flex-wrap gap-2">
{detected.map((p) => (
<Button
key={p.url}
variant="secondary"
size="sm"
onClick={() => handleSelect(p.url)}
className="font-mono text-xs"
>
{p.url}
</Button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
export function RectifierConfigPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RectifierConfig>({
enabled: true,
requestThinkingSignature: true,
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
settingsApi
.getRectifierConfig()
.then(setConfig)
.catch((e) => console.error("Failed to load rectifier config:", e))
.finally(() => setIsLoading(false));
}, []);
const handleChange = async (updates: Partial<RectifierConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
try {
await settingsApi.setRectifierConfig(newConfig);
} catch (e) {
console.error("Failed to save rectifier config:", e);
toast.error(String(e));
setConfig(config);
}
};
if (isLoading) return null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.enabledDescription")}
</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(checked) => handleChange({ enabled: checked })}
/>
</div>
<div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("settings.advanced.rectifier.requestGroup")}
</h4>
<div className="flex items-center justify-between pl-4">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
</p>
</div>
<Switch
checked={config.requestThinkingSignature}
disabled={!config.enabled}
onCheckedChange={(checked) =>
handleChange({ requestThinkingSignature: checked })
}
/>
</div>
</div>
</div>
);
}
+48
View File
@@ -9,6 +9,8 @@ import {
Database, Database,
Server, Server,
ChevronDown, ChevronDown,
Zap,
Globe,
} from "lucide-react"; } from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion"; import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -34,12 +36,14 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection"; import { AboutSection } from "@/components/settings/AboutSection";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
import { ProxyPanel } from "@/components/proxy"; import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel"; import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport"; import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -495,6 +499,28 @@ export function SettingsPage({
</AccordionContent> </AccordionContent>
</AccordionItem> </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>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
<AccordionItem <AccordionItem
value="data" value="data"
className="rounded-xl glass-card overflow-hidden" className="rounded-xl glass-card overflow-hidden"
@@ -526,6 +552,28 @@ export function SettingsPage({
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </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>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion> </Accordion>
<div className="pt-4"> <div className="pt-4">
+163
View File
@@ -0,0 +1,163 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Download, Loader2, Trash2 } from "lucide-react";
import type { MarketplaceBundle } from "@/types/template";
import type { AppType } from "@/lib/api/config";
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
agent: "🤖",
command: "⚡",
mcp: "🔌",
setting: "⚙️",
hook: "🪝",
skill: "💡",
};
interface BundleInstallStatus {
installed: boolean;
installedIds: number[];
totalCount: number;
installedCount: number;
}
interface BundleDetailProps {
bundle: MarketplaceBundle;
status?: BundleInstallStatus;
selectedApp: AppType;
onClose: () => void;
onInstall: () => void;
onUninstall: () => void;
installing: boolean;
uninstalling: boolean;
}
export function BundleDetail({
bundle,
status,
onClose,
onInstall,
onUninstall,
installing,
uninstalling,
}: BundleDetailProps) {
const { t } = useTranslation();
// 按类型分组组件
const componentsByType = bundle.components.reduce(
(acc, comp) => {
const type = comp.componentType;
if (!acc[type]) acc[type] = [];
acc[type].push(comp);
return acc;
},
{} as Record<string, typeof bundle.components>,
);
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-3xl">📦</span>
{status?.installed && (
<Badge
variant="default"
className="bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
<DialogTitle className="text-2xl">{bundle.name}</DialogTitle>
<DialogDescription className="text-sm mt-2">
{bundle.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* 组件列表 */}
<div className="flex-1 overflow-y-auto space-y-6 py-4 px-1">
{Object.entries(componentsByType).map(([type, components]) => (
<div key={type}>
<div className="flex items-center justify-center gap-2 mb-3">
<span className="text-xl">
{componentTypeIcons[type] || "📦"}
</span>
<h3 className="font-medium text-foreground">
{t(`templates.type.${type}`, { defaultValue: type })}
</h3>
<Badge variant="secondary" className="text-xs">
{components.length}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 px-2">
{components.map((comp, idx) => (
<div
key={`${comp.name}-${idx}`}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30"
>
<span className="text-lg">
{componentTypeIcons[type] || "📦"}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{comp.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{comp.path}
</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
<Button variant="outline" onClick={onClose}>
{t("common.close", { defaultValue: "关闭" })}
</Button>
{status && status.installedCount > 0 && (
<Button
variant="destructive"
onClick={onUninstall}
disabled={uninstalling}
>
{uninstalling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
</Button>
)}
{!status?.installed && (
<Button onClick={onInstall} disabled={installing}>
{installing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{t("templates.bundle.install", { defaultValue: "安装组合" })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+399
View File
@@ -0,0 +1,399 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Download, Loader2, Package, Trash2, FileText } from "lucide-react";
import { toast } from "sonner";
import {
useMarketplaceBundles,
useBatchInstallComponents,
} from "@/lib/query/template";
import { templateApi } from "@/lib/api/template";
import { BundleDetail } from "./BundleDetail";
import type { MarketplaceBundle, ComponentType } from "@/types/template";
import type { AppType } from "@/lib/api/config";
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
agent: "🤖",
command: "⚡",
mcp: "🔌",
setting: "⚙️",
hook: "🪝",
skill: "💡",
};
interface BundleInstallStatus {
installed: boolean;
installedIds: number[];
totalCount: number;
installedCount: number;
}
interface BundleListProps {
selectedApp: AppType;
}
// 统计组件类型数量
function getComponentTypeCounts(components: MarketplaceBundle["components"]) {
const counts: Record<string, number> = {};
for (const comp of components) {
const type = comp.componentType;
counts[type] = (counts[type] || 0) + 1;
}
return counts;
}
export function BundleList({ selectedApp }: BundleListProps) {
const { t } = useTranslation();
const [installingBundle, setInstallingBundle] = useState<string | null>(null);
const [uninstallingBundle, setUninstallingBundle] = useState<string | null>(
null,
);
const [bundleStatuses, setBundleStatuses] = useState<
Record<string, BundleInstallStatus>
>({});
const [detailBundle, setDetailBundle] = useState<MarketplaceBundle | null>(
null,
);
const { data: bundles = [], isLoading } = useMarketplaceBundles();
const batchInstallMutation = useBatchInstallComponents();
// 检查组合安装状态
const checkBundleStatus = useCallback(
async (bundle: MarketplaceBundle): Promise<BundleInstallStatus> => {
const componentsByType = bundle.components.reduce(
(acc, comp) => {
const type = comp.componentType;
if (!acc[type]) acc[type] = [];
acc[type].push(comp.name.toLowerCase());
return acc;
},
{} as Record<string, string[]>,
);
const installedIds: number[] = [];
let totalMatched = 0;
for (const [componentType, names] of Object.entries(componentsByType)) {
const componentsData = await templateApi.listTemplateComponents({
componentType: componentType as ComponentType,
pageSize: 1000,
appType: selectedApp,
});
for (const comp of componentsData.items) {
if (names.includes(comp.name.toLowerCase())) {
totalMatched++;
if (comp.installed && comp.id !== null) {
installedIds.push(comp.id);
}
}
}
}
return {
installed:
installedIds.length > 0 && installedIds.length === totalMatched,
installedIds,
totalCount: totalMatched,
installedCount: installedIds.length,
};
},
[selectedApp],
);
// 加载所有组合的安装状态
useEffect(() => {
const loadStatuses = async () => {
const statuses: Record<string, BundleInstallStatus> = {};
for (const bundle of bundles) {
statuses[bundle.id] = await checkBundleStatus(bundle);
}
setBundleStatuses(statuses);
};
if (bundles.length > 0) {
loadStatuses();
}
}, [bundles, checkBundleStatus]);
const handleInstallBundle = async (bundle: MarketplaceBundle) => {
setInstallingBundle(bundle.id);
try {
// 按组件类型分组
const componentsByType = bundle.components.reduce(
(acc, comp) => {
const type = comp.componentType;
if (!acc[type]) acc[type] = [];
acc[type].push(comp.name.toLowerCase());
return acc;
},
{} as Record<string, string[]>,
);
// 收集所有匹配的组件 ID
const matchedIds: number[] = [];
for (const [componentType, names] of Object.entries(componentsByType)) {
const componentsData = await templateApi.listTemplateComponents({
componentType: componentType as ComponentType,
pageSize: 1000,
});
const ids = componentsData.items
.filter((c) => names.includes(c.name.toLowerCase()))
.map((c) => c.id)
.filter((id): id is number => id !== null);
matchedIds.push(...ids);
}
if (matchedIds.length === 0) {
toast.warning(
t("templates.bundle.noMatch", {
defaultValue: "未找到匹配的组件",
}),
);
return;
}
const result = await batchInstallMutation.mutateAsync({
ids: matchedIds,
appType: selectedApp,
});
toast.success(
t("templates.bundle.installSuccess", {
count: result.success.length,
defaultValue: `已安装 ${result.success.length} 个组件`,
}),
);
if (result.failed.length > 0) {
toast.warning(
t("templates.bundle.partialFail", {
count: result.failed.length,
defaultValue: `${result.failed.length} 个组件安装失败`,
}),
);
}
// 刷新安装状态
const newStatus = await checkBundleStatus(bundle);
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("templates.bundle.installFailed", { defaultValue: "安装组合失败" }),
{ description: errorMessage, duration: 8000 },
);
} finally {
setInstallingBundle(null);
}
};
const handleUninstallBundle = async (bundle: MarketplaceBundle) => {
const status = bundleStatuses[bundle.id];
if (!status || status.installedIds.length === 0) return;
setUninstallingBundle(bundle.id);
try {
let successCount = 0;
let failCount = 0;
for (const id of status.installedIds) {
try {
await templateApi.uninstallTemplateComponent(id, selectedApp);
successCount++;
} catch {
failCount++;
}
}
if (successCount > 0) {
toast.success(
t("templates.bundle.uninstallSuccess", {
count: successCount,
defaultValue: `已卸载 ${successCount} 个组件`,
}),
);
}
if (failCount > 0) {
toast.warning(
t("templates.bundle.uninstallPartialFail", {
count: failCount,
defaultValue: `${failCount} 个组件卸载失败`,
}),
);
}
// 刷新安装状态
const newStatus = await checkBundleStatus(bundle);
setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("templates.bundle.uninstallFailed", { defaultValue: "卸载组合失败" }),
{ description: errorMessage, duration: 8000 },
);
} finally {
setUninstallingBundle(null);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (bundles.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-foreground">
{t("templates.bundle.empty", { defaultValue: "暂无组合" })}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("templates.bundle.emptyDescription", {
defaultValue: "请添加包含 components.json 的模板仓库",
})}
</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{bundles.map((bundle) => {
const typeCounts = getComponentTypeCounts(bundle.components);
const status = bundleStatuses[bundle.id];
return (
<div
key={bundle.id}
className="glass-card rounded-xl p-4 flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer"
onClick={() => setDetailBundle(bundle)}
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
{/* 头部 */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-2xl">📦</span>
</div>
<h3 className="font-semibold text-foreground truncate">
{bundle.name}
</h3>
</div>
{status?.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
{/* 描述 */}
<p className="text-sm text-muted-foreground/90 line-clamp-2 leading-relaxed mb-3 flex-1">
{bundle.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</p>
{/* 组件类型统计 */}
<div className="flex flex-wrap gap-1.5 mb-3">
{Object.entries(typeCounts).map(([type, count]) => (
<Badge key={type} variant="secondary" className="text-xs">
<span className="mr-1">
{componentTypeIcons[type] || "📦"}
</span>
{type} {count}
</Badge>
))}
</div>
{/* 底部操作栏 */}
<div className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDetailBundle(bundle);
}}
className="flex-1"
>
<FileText className="h-3.5 w-3.5 mr-1.5" />
{t("templates.viewDetail", { defaultValue: "查看详情" })}
</Button>
{status && status.installedCount > 0 && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
handleUninstallBundle(bundle);
}}
disabled={uninstallingBundle === bundle.id}
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
>
{uninstallingBundle === bundle.id ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
</Button>
)}
{!status?.installed && (
<Button
variant="mcp"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInstallBundle(bundle);
}}
disabled={installingBundle === bundle.id}
className="flex-1"
>
{installingBundle === bundle.id ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
{t("templates.bundle.install", { defaultValue: "安装" })}
</Button>
)}
</div>
</div>
);
})}
</div>
{/* 详情弹窗 */}
{detailBundle && (
<BundleDetail
bundle={detailBundle}
status={bundleStatuses[detailBundle.id]}
selectedApp={selectedApp}
onClose={() => setDetailBundle(null)}
onInstall={() => handleInstallBundle(detailBundle)}
onUninstall={() => handleUninstallBundle(detailBundle)}
installing={installingBundle === detailBundle.id}
uninstalling={uninstallingBundle === detailBundle.id}
/>
)}
</>
);
}
@@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
interface CategoryFilterProps {
categories: string[];
selectedCategory?: string;
onSelectCategory: (category: string | undefined) => void;
}
export function CategoryFilter({
categories,
selectedCategory,
onSelectCategory,
}: CategoryFilterProps) {
const { t } = useTranslation();
return (
<div className="glass-card rounded-xl p-4 sticky top-0">
<h3 className="text-sm font-semibold text-foreground mb-3">
{t("templates.category.title", { defaultValue: "分类" })}
</h3>
<div className="h-[calc(100vh-16rem)] overflow-y-auto">
<div className="space-y-1 pr-2">
{/* 全部选项 */}
<Button
variant={selectedCategory === undefined ? "secondary" : "ghost"}
size="sm"
onClick={() => onSelectCategory(undefined)}
className="w-full justify-start text-sm h-9"
>
{t("templates.category.all", { defaultValue: "全部" })}
{selectedCategory === undefined && (
<Badge variant="secondary" className="ml-auto text-xs">
</Badge>
)}
</Button>
{/* 分类列表 */}
{categories.length > 0 && (
<>
<div className="h-px bg-border my-2" />
{categories.map((category) => (
<Button
key={category}
variant={
selectedCategory === category ? "secondary" : "ghost"
}
size="sm"
onClick={() => onSelectCategory(category)}
className="w-full justify-start text-sm h-9"
>
<span className="truncate">{category}</span>
{selectedCategory === category && (
<Badge variant="secondary" className="ml-auto text-xs">
</Badge>
)}
</Button>
))}
</>
)}
{/* 无分类提示 */}
{categories.length === 0 && selectedCategory === undefined && (
<p className="text-xs text-muted-foreground text-center py-4">
{t("templates.category.empty", { defaultValue: "暂无分类" })}
</p>
)}
</div>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Download, Trash2, Loader2, FileText } from "lucide-react";
import type { TemplateComponent } from "@/types/template";
interface ComponentCardProps {
component: TemplateComponent;
onInstall: () => Promise<void>;
onUninstall: () => Promise<void>;
onViewDetail: () => void;
}
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
agent: "🤖",
command: "⚡",
mcp: "🔌",
setting: "⚙️",
hook: "🪝",
skill: "💡",
};
export function ComponentCard({
component,
onInstall,
onUninstall,
onViewDetail,
}: ComponentCardProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleInstall = async () => {
setLoading(true);
try {
await onInstall();
} finally {
setLoading(false);
}
};
const handleUninstall = async () => {
setLoading(true);
try {
await onUninstall();
} finally {
setLoading(false);
}
};
const typeIcon = componentTypeIcons[component.componentType] || "📦";
return (
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<div onClick={onViewDetail}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-2xl">{typeIcon}</span>
</div>
<CardTitle className="text-base font-semibold truncate">
{component.name}
</CardTitle>
{component.category && (
<CardDescription className="text-xs mt-1">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 border-border-default"
>
{component.category}
</Badge>
</CardDescription>
)}
</div>
{component.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
<p className="text-sm text-muted-foreground/90 line-clamp-3 leading-relaxed">
{component.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</p>
</CardContent>
</div>
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onViewDetail();
}}
disabled={loading}
className="flex-1"
>
<FileText className="h-3.5 w-3.5 mr-1.5" />
{t("templates.viewDetail", { defaultValue: "查看详情" })}
</Button>
{component.installed ? (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleUninstall();
}}
disabled={loading}
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{loading
? t("templates.uninstalling", { defaultValue: "卸载中..." })
: t("templates.uninstall", { defaultValue: "卸载" })}
</Button>
) : (
<Button
variant="mcp"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInstall();
}}
disabled={loading}
className="flex-1"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
{loading
? t("templates.installing", { defaultValue: "安装中..." })
: t("templates.install", { defaultValue: "安装" })}
</Button>
)}
</CardFooter>
</Card>
);
}
@@ -0,0 +1,314 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Download,
Trash2,
Loader2,
ExternalLink,
FileCode,
FolderGit2,
Tag,
Clock,
} from "lucide-react";
import { settingsApi } from "@/lib/api";
import {
useTemplateComponent,
useComponentPreview,
} from "@/lib/query/template";
import type { AppType } from "@/lib/api/config";
interface ComponentDetailProps {
componentId: number;
selectedApp: AppType;
onClose: () => void;
onInstall: (id: number, name: string) => Promise<void>;
onUninstall: (id: number, name: string) => Promise<void>;
}
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
agent: "🤖",
command: "⚡",
mcp: "🔌",
setting: "⚙️",
hook: "🪝",
skill: "💡",
};
export function ComponentDetail({
componentId,
onClose,
onInstall,
onUninstall,
}: ComponentDetailProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { data: component, isLoading: componentLoading } =
useTemplateComponent(componentId);
const { data: preview, isLoading: previewLoading } =
useComponentPreview(componentId);
const handleInstall = async () => {
if (!component || component.id === null) return;
setLoading(true);
try {
await onInstall(component.id, component.name);
} finally {
setLoading(false);
}
};
const handleUninstall = async () => {
if (!component || component.id === null) return;
setLoading(true);
try {
await onUninstall(component.id, component.name);
} finally {
setLoading(false);
}
};
const handleOpenGithub = async () => {
if (component?.readmeUrl) {
try {
await settingsApi.openExternal(component.readmeUrl);
} catch (error) {
console.error("Failed to open URL:", error);
}
}
};
if (componentLoading || !component) {
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</DialogContent>
</Dialog>
);
}
const typeIcon = componentTypeIcons[component.componentType] || "📦";
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-3xl">{typeIcon}</span>
<Badge variant="outline" className="text-xs">
{t(`templates.type.${component.componentType}`, {
defaultValue: component.componentType,
})}
</Badge>
{component.category && (
<Badge variant="secondary" className="text-xs">
{component.category}
</Badge>
)}
</div>
<DialogTitle className="text-2xl">{component.name}</DialogTitle>
<DialogDescription className="text-sm mt-2">
{component.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</DialogDescription>
</div>
{component.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="info" className="h-full flex flex-col">
<TabsList>
<TabsTrigger value="info">
{t("templates.detail.info", { defaultValue: "信息" })}
</TabsTrigger>
<TabsTrigger value="preview">
<FileCode className="h-3.5 w-3.5 mr-1.5" />
{t("templates.detail.preview", { defaultValue: "预览" })}
</TabsTrigger>
</TabsList>
<TabsContent value="info" className="flex-1 overflow-y-auto mt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* 类型 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Tag className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.type", { defaultValue: "类型" })}
</p>
<p className="text-sm font-medium truncate">
{t(`templates.type.${component.componentType}`, {
defaultValue: component.componentType,
})}
</p>
</div>
</div>
{/* 分类 */}
{component.category && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Tag className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.category", {
defaultValue: "分类",
})}
</p>
<p className="text-sm font-medium truncate">
{component.category}
</p>
</div>
</div>
)}
{/* 仓库 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<FolderGit2 className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.repository", {
defaultValue: "仓库",
})}
</p>
<div className="flex items-center gap-1">
<p className="text-sm font-medium truncate">
{component.repoOwner}/{component.repoName}
{component.repoBranch &&
component.repoBranch !== "main" &&
` (${component.repoBranch})`}
</p>
{component.readmeUrl && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenGithub}
className="h-6 w-6 p-0"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
{/* 更新时间 */}
{component.updatedAt && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Clock className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.updatedAt", {
defaultValue: "更新时间",
})}
</p>
<p className="text-sm font-medium truncate">
{new Date(component.updatedAt).toLocaleString()}
</p>
</div>
</div>
)}
</div>
{/* 路径 */}
<div className="mt-3 p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1">
{t("templates.detail.path", { defaultValue: "路径" })}
</p>
<p className="text-sm font-mono text-muted-foreground break-all">
{component.path}
</p>
</div>
</TabsContent>
<TabsContent
value="preview"
className="flex-1 overflow-y-auto mt-4"
>
{previewLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : preview ? (
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-auto font-mono whitespace-pre-wrap break-words">
{preview}
</pre>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
{t("templates.detail.noPreview", {
defaultValue: "无法预览内容",
})}
</p>
)}
</TabsContent>
</Tabs>
</div>
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
<Button variant="outline" onClick={onClose}>
{t("common.close", { defaultValue: "关闭" })}
</Button>
{component.installed ? (
<Button
variant="destructive"
onClick={handleUninstall}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{loading
? t("templates.uninstalling", { defaultValue: "卸载中..." })
: t("templates.uninstall", { defaultValue: "卸载" })}
</Button>
) : (
<Button onClick={handleInstall} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{loading
? t("templates.installing", { defaultValue: "安装中..." })
: t("templates.install", { defaultValue: "安装" })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+294
View File
@@ -0,0 +1,294 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Trash2, ExternalLink, Plus, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { settingsApi } from "@/lib/api";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import {
useTemplateRepos,
useAddTemplateRepo,
useRemoveTemplateRepo,
useToggleTemplateRepo,
} from "@/lib/query/template";
interface RepoManagerProps {
onClose: () => void;
}
export function RepoManager({ onClose }: RepoManagerProps) {
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [error, setError] = useState("");
const { data: repos = [], isLoading } = useTemplateRepos();
const addRepoMutation = useAddTemplateRepo();
const removeRepoMutation = useRemoveTemplateRepo();
const toggleRepoMutation = useToggleTemplateRepo();
const parseRepoUrl = (
url: string,
): { owner: string; name: string } | null => {
let cleaned = url.trim();
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
cleaned = cleaned.replace(/\.git$/, "");
const parts = cleaned.split("/");
if (parts.length === 2 && parts[0] && parts[1]) {
return { owner: parts[0], name: parts[1] };
}
return null;
};
const handleAdd = async () => {
setError("");
const parsed = parseRepoUrl(repoUrl);
if (!parsed) {
setError(
t("templates.repo.invalidUrl", {
defaultValue: "仓库地址格式不正确",
}),
);
return;
}
try {
await addRepoMutation.mutateAsync({
owner: parsed.owner,
name: parsed.name,
branch: branch || "main",
});
toast.success(
t("templates.repo.addSuccess", {
owner: parsed.owner,
name: parsed.name,
defaultValue: `已添加仓库 ${parsed.owner}/${parsed.name}`,
}),
);
setRepoUrl("");
setBranch("");
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(
t("templates.repo.addFailed", {
defaultValue: "添加仓库失败",
}),
);
toast.error(
t("templates.repo.addFailed", { defaultValue: "添加仓库失败" }),
{
description: errorMessage,
duration: 8000,
},
);
}
};
const handleRemove = async (id: number, owner: string, name: string) => {
try {
await removeRepoMutation.mutateAsync(id);
toast.success(
t("templates.repo.removeSuccess", {
owner,
name,
defaultValue: `已移除仓库 ${owner}/${name}`,
}),
);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
toast.error(
t("templates.repo.removeFailed", { defaultValue: "移除仓库失败" }),
{
description: errorMessage,
duration: 8000,
},
);
}
};
const handleToggle = async (id: number, enabled: boolean) => {
try {
await toggleRepoMutation.mutateAsync({ id, enabled });
toast.success(
enabled
? t("templates.repo.enableSuccess", { defaultValue: "已启用仓库" })
: t("templates.repo.disableSuccess", { defaultValue: "已禁用仓库" }),
);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
toast.error(
t("templates.repo.toggleFailed", {
defaultValue: "切换仓库状态失败",
}),
{
description: errorMessage,
duration: 8000,
},
);
}
};
const handleOpenRepo = async (owner: string, name: string) => {
try {
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
} catch (error) {
console.error("Failed to open URL:", error);
}
};
return (
<FullScreenPanel
isOpen={true}
title={t("templates.repo.title", { defaultValue: "模板仓库管理" })}
onClose={onClose}
>
{/* 添加仓库表单 */}
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
{t("templates.repo.addTitle", { defaultValue: "添加模板仓库" })}
</h3>
<div className="space-y-4">
<div>
<Label htmlFor="repo-url" className="text-foreground">
{t("templates.repo.url", { defaultValue: "仓库地址" })}
</Label>
<Input
id="repo-url"
placeholder={t("templates.repo.urlPlaceholder", {
defaultValue: "owner/repo 或 https://github.com/owner/repo",
})}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="branch" className="text-foreground">
{t("templates.repo.branch", { defaultValue: "分支" })}
</Label>
<Input
id="branch"
placeholder={t("templates.repo.branchPlaceholder", {
defaultValue: "main",
})}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<Button
onClick={handleAdd}
disabled={addRepoMutation.isPending}
className="bg-primary text-primary-foreground hover:bg-primary/90"
type="button"
>
{addRepoMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{t("templates.repo.add", { defaultValue: "添加仓库" })}
</Button>
</div>
</div>
{/* 仓库列表 */}
<div className="space-y-4">
<h3 className="text-base font-semibold text-foreground">
{t("templates.repo.list", { defaultValue: "仓库列表" })}
</h3>
{isLoading ? (
<div className="flex items-center justify-center py-12 glass-card rounded-xl">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : repos.length === 0 ? (
<div className="text-center py-12 glass-card rounded-xl">
<p className="text-sm text-muted-foreground">
{t("templates.repo.empty", { defaultValue: "暂无仓库" })}
</p>
</div>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div
key={repo.id ?? `${repo.owner}-${repo.name}`}
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
>
<div className="flex items-center gap-4 flex-1 min-w-0">
<Switch
checked={repo.enabled}
onCheckedChange={(checked) =>
repo.id !== null && handleToggle(repo.id, checked)
}
disabled={toggleRepoMutation.isPending || repo.id === null}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{t("templates.repo.branch", { defaultValue: "分支" })}:{" "}
{repo.branch}
</span>
<Badge
variant={repo.enabled ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0 h-4"
>
{repo.enabled
? t("templates.repo.enabled", {
defaultValue: "已启用",
})
: t("templates.repo.disabled", {
defaultValue: "已禁用",
})}
</Badge>
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleOpenRepo(repo.owner, repo.name)}
title={t("common.view", { defaultValue: "查看" })}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() =>
repo.id !== null &&
handleRemove(repo.id, repo.owner, repo.name)
}
disabled={removeRepoMutation.isPending || repo.id === null}
title={t("common.delete", { defaultValue: "删除" })}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</FullScreenPanel>
);
}
+334
View File
@@ -0,0 +1,334 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RefreshCw, Search, Settings, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { ComponentCard } from "./ComponentCard";
import { ComponentDetail } from "./ComponentDetail";
import { CategoryFilter } from "./CategoryFilter";
import { RepoManager } from "./RepoManager";
import { BundleList } from "./BundleList";
import {
useTemplateComponents,
useComponentCategories,
useInstallTemplateComponent,
useUninstallTemplateComponent,
useRefreshTemplateIndex,
} from "@/lib/query/template";
import type { ComponentType, TemplateComponent } from "@/types/template";
import type { AppType } from "@/lib/api/config";
interface TemplatesPageProps {
activeApp: AppType;
}
export function TemplatesPage({ activeApp }: TemplatesPageProps) {
const { t } = useTranslation();
const [selectedType, setSelectedType] = useState<ComponentType | "bundle">(
"bundle",
);
const [selectedCategory, setSelectedCategory] = useState<string | undefined>(
undefined,
);
const [searchQuery, setSearchQuery] = useState("");
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [detailComponent, setDetailComponent] = useState<
TemplateComponent | undefined
>(undefined);
// Queries
const {
data: componentsData,
isLoading: componentsLoading,
refetch: refetchComponents,
} = useTemplateComponents({
componentType: selectedType === "bundle" ? undefined : selectedType,
category: selectedCategory,
search: searchQuery || undefined,
appType: activeApp,
});
const { data: categories = [] } = useComponentCategories(
selectedType === "bundle" ? undefined : selectedType,
);
// Mutations
const installMutation = useInstallTemplateComponent();
const uninstallMutation = useUninstallTemplateComponent();
const refreshMutation = useRefreshTemplateIndex();
const handleInstall = async (id: number, name: string) => {
try {
await installMutation.mutateAsync({ id, appType: activeApp });
toast.success(
t("templates.installSuccess", { name, defaultValue: `已安装 ${name}` }),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("templates.installFailed", {
name,
defaultValue: `安装 ${name} 失败`,
}),
{
description: errorMessage,
duration: 8000,
},
);
console.error("Install component failed:", error);
}
};
const handleUninstall = async (id: number, name: string) => {
try {
await uninstallMutation.mutateAsync({ id, appType: activeApp });
toast.success(
t("templates.uninstallSuccess", {
name,
defaultValue: `已卸载 ${name}`,
}),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("templates.uninstallFailed", {
name,
defaultValue: `卸载 ${name} 失败`,
}),
{
description: errorMessage,
duration: 8000,
},
);
console.error("Uninstall component failed:", error);
}
};
const handleRefresh = async () => {
try {
await refreshMutation.mutateAsync();
await refetchComponents();
toast.success(
t("templates.refreshSuccess", { defaultValue: "刷新成功" }),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("templates.refreshFailed", { defaultValue: "刷新失败" }), {
description: errorMessage,
duration: 8000,
});
console.error("Refresh index failed:", error);
}
};
const components = componentsData?.items || [];
// 过滤组件
const filteredComponents = useMemo(() => {
if (!searchQuery.trim()) return components;
const query = searchQuery.toLowerCase();
return components.filter((component) => {
const name = component.name?.toLowerCase() || "";
const description = component.description?.toLowerCase() || "";
const category = component.category?.toLowerCase() || "";
return (
name.includes(query) ||
description.includes(query) ||
category.includes(query)
);
});
}, [components, searchQuery]);
const componentTypeOptions: {
value: ComponentType | "bundle";
icon: string;
}[] = [
{ value: "bundle", icon: "📦" },
{ value: "agent", icon: "🤖" },
{ value: "command", icon: "⚡" },
{ value: "mcp", icon: "🔌" },
{ value: "setting", icon: "⚙️" },
{ value: "hook", icon: "🪝" },
{ value: "skill", icon: "💡" },
];
return (
<div className="mx-auto max-w-[80rem] px-6 flex h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 左侧分类过滤器 */}
<div className="w-48 shrink-0 mr-6 overflow-y-auto">
<CategoryFilter
categories={categories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
</div>
{/* 右侧主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部工具栏 */}
<div className="mb-6 space-y-4">
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-4">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshMutation.isPending}
>
{refreshMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
{t("templates.refresh", { defaultValue: "刷新索引" })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRepoManagerOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
{t("templates.manageRepos", { defaultValue: "管理仓库" })}
</Button>
</div>
</div>
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("templates.searchPlaceholder", {
defaultValue: "搜索组件...",
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-3"
/>
</div>
</div>
{/* 类型标签页 */}
<Tabs
value={selectedType}
onValueChange={(value) => {
setSelectedType(value as ComponentType | "bundle");
setSelectedCategory(undefined);
}}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="w-full justify-start mb-4">
{componentTypeOptions.map((option) => (
<TabsTrigger key={option.value} value={option.value}>
<span className="mr-1.5">{option.icon}</span>
{t(`templates.type.${option.value}`, {
defaultValue: option.value,
})}
</TabsTrigger>
))}
</TabsList>
{/* 组合标签页 */}
<TabsContent value="bundle" className="flex-1 overflow-y-auto mt-0">
<div className="py-4">
<BundleList selectedApp={activeApp} />
</div>
</TabsContent>
{/* 组件类型标签页 */}
{componentTypeOptions
.filter((opt) => opt.value !== "bundle")
.map((option) => (
<TabsContent
key={option.value}
value={option.value}
className="flex-1 overflow-y-auto mt-0"
>
<div className="py-4">
{componentsLoading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredComponents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("templates.empty", { defaultValue: "暂无组件" })}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("templates.emptyDescription", {
defaultValue: "请添加模板仓库来获取组件",
})}
</p>
</div>
) : (
<>
{searchQuery && (
<p className="mb-4 text-sm text-muted-foreground">
{t("templates.count", {
count: filteredComponents.length,
defaultValue: `找到 ${filteredComponents.length} 个组件`,
})}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredComponents.map((component) => (
<ComponentCard
key={
component.id ??
`${component.repoId}-${component.path}`
}
component={component}
onInstall={async () => {
if (component.id !== null) {
await handleInstall(
component.id,
component.name,
);
}
}}
onUninstall={async () => {
if (component.id !== null) {
await handleUninstall(
component.id,
component.name,
);
}
}}
onViewDetail={() => setDetailComponent(component)}
/>
))}
</div>
</>
)}
</div>
</TabsContent>
))}
</Tabs>
</div>
{/* 仓库管理弹窗 */}
{repoManagerOpen && (
<RepoManager onClose={() => setRepoManagerOpen(false)} />
)}
{/* 组件详情弹窗 */}
{detailComponent && detailComponent.id !== null && (
<ComponentDetail
componentId={detailComponent.id}
selectedApp={activeApp}
onClose={() => setDetailComponent(undefined)}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { TemplatesPage } from "./TemplatesPage";
export { ComponentCard } from "./ComponentCard";
export { ComponentDetail } from "./ComponentDetail";
export { CategoryFilter } from "./CategoryFilter";
export { RepoManager } from "./RepoManager";
+1 -1
View File
@@ -13,7 +13,7 @@ const buttonVariants = cva(
"bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700", "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
// 危险按钮:红底白字(对应旧版 danger) // 危险按钮:红底白字(对应旧版 danger)
destructive: destructive:
"bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700", "bg-red-500 text-white hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600",
// 轮廓按钮 // 轮廓按钮
outline: outline:
"border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800", "border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800",
+49 -18
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react"; import { Save, Loader2 } from "lucide-react";
@@ -17,13 +18,15 @@ export function ModelTestConfigPanel() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<StreamCheckConfig>({ // 使用字符串状态以支持完全清空数字输入框
timeoutSecs: 45, const [config, setConfig] = useState({
maxRetries: 2, timeoutSecs: "45",
degradedThresholdMs: 6000, maxRetries: "2",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001", claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low", codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview", geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
}); });
useEffect(() => { useEffect(() => {
@@ -35,7 +38,15 @@ export function ModelTestConfigPanel() {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const data = await getStreamCheckConfig(); const data = await getStreamCheckConfig();
setConfig(data); setConfig({
timeoutSecs: String(data.timeoutSecs),
maxRetries: String(data.maxRetries),
degradedThresholdMs: String(data.degradedThresholdMs),
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
});
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } finally {
@@ -44,9 +55,23 @@ export function ModelTestConfigPanel() {
} }
async function handleSave() { async function handleSave() {
// 解析数字,空值使用默认值,0 是有效值
const parseNum = (val: string, defaultVal: number) => {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
try { try {
setIsSaving(true); setIsSaving(true);
await saveStreamCheckConfig(config); const parsed: StreamCheckConfig = {
timeoutSecs: parseNum(config.timeoutSecs, 45),
maxRetries: parseNum(config.maxRetries, 2),
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), { toast.success(t("streamCheck.configSaved"), {
closeButton: true, closeButton: true,
}); });
@@ -132,10 +157,7 @@ export function ModelTestConfigPanel() {
max={120} max={120}
value={config.timeoutSecs} value={config.timeoutSecs}
onChange={(e) => onChange={(e) =>
setConfig({ setConfig({ ...config, timeoutSecs: e.target.value })
...config,
timeoutSecs: parseInt(e.target.value) || 45,
})
} }
/> />
</div> </div>
@@ -149,10 +171,7 @@ export function ModelTestConfigPanel() {
max={5} max={5}
value={config.maxRetries} value={config.maxRetries}
onChange={(e) => onChange={(e) =>
setConfig({ setConfig({ ...config, maxRetries: e.target.value })
...config,
maxRetries: parseInt(e.target.value) || 2,
})
} }
/> />
</div> </div>
@@ -169,14 +188,26 @@ export function ModelTestConfigPanel() {
step={1000} step={1000}
value={config.degradedThresholdMs} value={config.degradedThresholdMs}
onChange={(e) => onChange={(e) =>
setConfig({ setConfig({ ...config, degradedThresholdMs: e.target.value })
...config,
degradedThresholdMs: parseInt(e.target.value) || 6000,
})
} }
/> />
</div> </div>
</div> </div>
{/* 检查提示词配置 */}
<div className="space-y-2">
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
<Textarea
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="Who are you?"
rows={2}
className="min-h-[60px]"
/>
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -202,6 +202,7 @@ export function PricingConfigPanel() {
{editingModel && ( {editingModel && (
<PricingEditModal <PricingEditModal
open={!!editingModel}
model={editingModel} model={editingModel}
isNew={isAddingNew} isNew={isAddingNew}
onClose={() => { onClose={() => {

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