1 Commits

Author SHA1 Message Date
YoVinchen
81a6c08673 fix(skills): resolve third-party skills installation failure
- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories
2025-11-21 12:33:12 +08:00
147 changed files with 2962 additions and 8816 deletions

View File

@@ -5,31 +5,6 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.7.1] - 2025-11-22
### Fixed
- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)
- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers
- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)
### Added
- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings
- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`
### Improved
- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions
- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives
- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards
### Reverted
- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization
---
## [3.7.0] - 2025-11-19
### Major Features

View File

@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.7.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![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/)
@@ -12,9 +12,7 @@
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
**From Provider Switcher to All-in-One AI CLI Management Platform**
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
</div>
@@ -35,12 +33,6 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr>
</table>
## Screenshots
@@ -51,49 +43,12 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
**v3.7.0 Major Update (2025-11-19)**
**Six Core Features, 18,000+ Lines of New Code**
- **Gemini CLI Integration**
- Third supported AI CLI (Claude Code / Codex / Gemini)
- Dual-file configuration support (`.env` + `settings.json`)
- Complete MCP server management
- Presets: Google Official (OAuth) / PackyCode / Custom
- **Claude Skills Management System**
- Auto-scan skills from GitHub repositories (3 pre-configured curated repos)
- One-click install/uninstall to `~/.claude/skills/`
- Custom repository support + subdirectory scanning
- Complete lifecycle management (discover/install/update)
- **Prompts Management System**
- Multi-preset system prompt management (unlimited presets, quick switching)
- Cross-app support (Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
- Markdown editor (CodeMirror 6 + real-time preview)
- Smart backfill protection, preserves manual modifications
- **MCP v3.7.0 Unified Architecture**
- Single panel manages MCP servers across three applications
- New SSE (Server-Sent Events) transport type
- Smart JSON parser + Codex TOML format auto-correction
- Unified import/export + bidirectional sync
- **Deep Link Protocol**
- `ccswitch://` protocol registration (all platforms)
- One-click import provider configs via shared links
- Security validation + lifecycle integration
- **Environment Variable Conflict Detection**
- Auto-detect cross-app configuration conflicts (Claude/Codex/Gemini/MCP)
- Visual conflict indicators + resolution suggestions
- Override warnings + backup before changes
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md)
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
@@ -106,6 +61,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
- WSL environment support with auto-sync on directory change
- 100% hooks test coverage & complete architecture refactoring
- New presets: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
**System Features**
@@ -173,36 +129,9 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
### MCP Management
- **Location**: Click "MCP" button in top-right corner
- **Add Server**:
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
- Support stdio / http / sse transport types
- Configure independent MCP servers for different apps
- **Add Server**: Use built-in templates (mcp-fetch, mcp-filesystem) or custom config
- **Enable/Disable**: Toggle switches to control which servers sync to live config
- **Sync**: Enabled servers auto-sync to each app's live files
- **Import/Export**: Import existing MCP servers from Claude/Codex/Gemini config files
### Skills Management (v3.7.0 New)
- **Location**: Click "Skills" button in top-right corner
- **Discover Skills**:
- Auto-scan pre-configured GitHub repositories (Anthropic official, ComposioHQ, community, etc.)
- Add custom repositories (supports subdirectory scanning)
- **Install Skills**: Click "Install" to one-click install to `~/.claude/skills/`
- **Uninstall Skills**: Click "Uninstall" to safely remove and clean up state
- **Manage Repositories**: Add/remove custom GitHub repositories
### Prompts Management (v3.7.0 New)
- **Location**: Click "Prompts" button in top-right corner
- **Create Presets**:
- Create unlimited system prompt presets
- Use Markdown editor to write prompts (syntax highlighting + real-time preview)
- **Switch Presets**: Select preset → Click "Activate" to apply immediately
- **Sync Mechanism**:
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Protection Mechanism**: Auto-save current prompt content before switching, preserves manual modifications
- **Sync**: Enabled servers auto-sync to `~/.claude.json` (Claude) or `~/.codex/config.toml` (Codex)
### Configuration Files
@@ -220,15 +149,13 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
**Gemini**
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
- API key field: `GEMINI_API_KEY` or `GOOGLE_GEMINI_API_KEY` in `.env`
- Environment variables: Support `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- MCP servers: `~/.gemini/settings.json``mcpServers`
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
- API key field: `GEMINI_API_KEY` inside `.env`
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
**CC Switch Storage**
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
- Main config (SSOT): `~/.cc-switch/config.json`
- Settings: `~/.cc-switch/settings.json`
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)

View File

@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.7.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.7.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![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/)
@@ -10,11 +10,9 @@
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
</div>
@@ -35,12 +33,6 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目PackyCode 是一家稳定、高效的API中转服务商提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码可以享受9折优惠。</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
</table>
## 界面预览
@@ -53,47 +45,10 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
### 当前版本v3.7.0 | [完整更新日志](CHANGELOG.md)
**v3.7.0 重大更新2025-11-19**
**六大核心功能18,000+ 行新增代码**
- **Gemini CLI 集成**
- 第三个支持的 AI CLIClaude Code / Codex / Gemini
- 双文件配置支持(`.env` + `settings.json`
- 完整 MCP 服务器管理
- 预设Google Official (OAuth) / PackyCode / 自定义
- **Claude Skills 管理系统**
- 从 GitHub 仓库自动扫描技能(预配置 3 个精选仓库)
- 一键安装/卸载到 `~/.claude/skills/`
- 自定义仓库支持 + 子目录扫描
- 完整生命周期管理(发现/安装/更新)
- **Prompts 管理系统**
- 多预设系统提示词管理(无限数量,快速切换)
- 跨应用支持Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`
- Markdown 编辑器CodeMirror 6 + 实时预览)
- 智能回填保护,保留手动修改
- **MCP v3.7.0 统一架构**
- 单一面板管理三个应用的 MCP 服务器
- 新增 SSE (Server-Sent Events) 传输类型
- 智能 JSON 解析器 + Codex TOML 格式自动修正
- 统一导入/导出 + 双向同步
- **深度链接协议**
- `ccswitch://` 协议注册(全平台)
- 通过共享链接一键导入供应商配置
- 安全验证 + 生命周期集成
- **环境变量冲突检测**
- 自动检测跨应用配置冲突Claude/Codex/Gemini/MCP
- 可视化冲突指示器 + 解决建议
- 覆盖警告 + 更改前备份
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**完整的中英文本地化UI、错误、托盘
@@ -106,6 +61,7 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
- 细粒度模型配置四层Haiku/Sonnet/Opus/自定义)
- WSL 环境支持,配置目录切换自动同步
- 100% hooks 测试覆盖 & 完整架构重构
- 新增预设DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
**系统功能**
@@ -173,36 +129,9 @@ paru -S cc-switch-bin
### MCP 管理
- **位置**:点击右上角"MCP"按钮
- **添加服务器**
- 使用内置模板mcp-fetch、mcp-filesystem 等)
- 支持 stdio / http / sse 三种传输类型
- 为不同应用配置独立的 MCP 服务器
- **添加服务器**使用内置模板mcp-fetch、mcp-filesystem或自定义配置
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
- **同步**:启用的服务器自动同步到各应用的 live 文件
- **导入/导出**:支持从 Claude/Codex/Gemini 配置文件导入现有 MCP 服务器
### Skills 管理v3.7.0 新增)
- **位置**:点击右上角"Skills"按钮
- **发现技能**
- 自动扫描预配置的 GitHub 仓库Anthropic 官方、ComposioHQ、社区等
- 添加自定义仓库(支持子目录扫描)
- **安装技能**:点击"安装"一键安装到 `~/.claude/skills/`
- **卸载技能**:点击"卸载"安全移除并清理状态
- **管理仓库**:添加/删除自定义 GitHub 仓库
### Prompts 管理v3.7.0 新增)
- **位置**:点击右上角"Prompts"按钮
- **创建预设**
- 创建无限数量的系统提示词预设
- 使用 Markdown 编辑器编写提示词(语法高亮 + 实时预览)
- **切换预设**:选择预设 → 点击"激活"立即应用
- **同步机制**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
- **同步**:启用的服务器自动同步到 `~/.claude.json`Claude`~/.codex/config.toml`Codex
### 配置文件
@@ -220,15 +149,13 @@ paru -S cc-switch-bin
**Gemini**
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式)
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY``GOOGLE_GEMINI_API_KEY`
- 环境变量:支持 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等自定义变量
- MCP 服务器:`~/.gemini/settings.json``mcpServers`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`Gemini CLI 无需额外操作即可使用新配置
**CC Switch 存储**
- 主配置SSOT`~/.cc-switch/config.json`包含供应商、MCP、Prompts 预设等)
- 主配置SSOT`~/.cc-switch/config.json`
- 设置:`~/.cc-switch/settings.json`
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,481 +0,0 @@
# CC Switch v3.7.1
> Stability Enhancements and User Experience Improvements
**[中文更新说明 Chinese Documentation →](release-note-v3.7.1-zh.md)**
---
## v3.7.1 Updates
**Release Date**: 2025-11-22
**Code Changes**: 17 files, +524 / -81 lines
### Bug Fixes
- **Fix Third-Party Skills Installation Failure** (#268)
Fixed installation issues for skills repositories with custom subdirectories, now supports repos like `ComposioHQ/awesome-claude-skills` with subdirectories
- **Fix Gemini Configuration Persistence Issue**
Resolved the issue where settings.json edits in Gemini form were lost when switching providers
- **Prevent Dialogs from Closing on Overlay Click**
Added protection against clicking overlay/backdrop, preventing accidental form data loss across all 11 dialog components
### New Features
- **Gemini Configuration Directory Support** (#255)
Added Gemini configuration directory option in settings, supports customizing `~/.gemini/` path
- **ArchLinux Installation Support** (#259)
Added AUR installation method: `paru -S cc-switch-bin`
### Improvements
- **Skills Error Message i18n Enhancement**
Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions, extended download timeout from 15s to 60s
- **Code Formatting**
Applied unified Rust and TypeScript code formatting standards
### Download
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the latest version
---
## v3.7.0 Complete Release Notes
> From Provider Switcher to All-in-One AI CLI Management Platform
**Release Date**: 2025-11-19
**Commits**: 85 from v3.6.0
**Code Changes**: 152 files, +18,104 / -3,732 lines
---
## New Features
### Gemini CLI Integration
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
**Core Capabilities**:
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **Full MCP support** - Complete MCP server management for Gemini
- **Deep link integration** - Import via `ccswitch://` protocol
- **System tray** - Quick-switch from tray menu
**Provider Presets**:
- **Google Official** - OAuth authentication support
- **PackyCode** - Partner integration
- **Custom** - Full customization support
**Technical Implementation**:
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
- Form synchronization with environment editor
- Dual-file atomic writes
---
### MCP v3.7.0 Unified Architecture
Complete refactoring of MCP management system for cross-application unification.
**Architecture Improvements**:
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport** - New Server-Sent Events support
- **Smart parser** - Fault-tolerant JSON parsing
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
- **Extended fields** - Preserve custom TOML fields
**User Experience**:
- Default app selection in forms
- JSON formatter for validation
- Improved visual hierarchy
- Better error messages
**Import/Export**:
- Unified import from all three apps
- Bidirectional synchronization
- State preservation
---
### Claude Skills Management System
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
**GitHub Integration**:
- Auto-scan skills from GitHub repositories
- Pre-configured repos:
- `ComposioHQ/awesome-claude-skills` - Curated collection
- `anthropics/skills` - Official Anthropic skills
- `cexll/myclaude` - Community contributions
- Add custom repositories
- Subdirectory scanning support (`skillsPath`)
**Lifecycle Management**:
- **Discover** - Auto-detect `SKILL.md` files
- **Install** - One-click to `~/.claude/skills/`
- **Uninstall** - Safe removal with tracking
- **Update** - Check for updates (infrastructure ready)
**Technical Architecture**:
- **Backend**: `SkillService` (526 lines) with GitHub API integration
- **Frontend**: SkillsPage, SkillCard, RepoManager
- **UI Components**: Badge, Card, Table (shadcn/ui)
- **State**: Persistent storage in `config.json`
- **i18n**: 47+ translation keys
---
### Prompts Management System
**Approximately 1,300 lines of code** - Complete system prompt management.
**Multi-Preset Management**:
- Create unlimited prompt presets
- Quick switch between presets
- One active prompt at a time
- Delete protection for active prompts
**Cross-App Support**:
- **Claude**: `~/.claude/CLAUDE.md`
- **Codex**: `~/.codex/AGENTS.md`
- **Gemini**: `~/.gemini/GEMINI.md`
**Markdown Editor**:
- Full-featured CodeMirror 6 integration
- Syntax highlighting
- Dark theme (One Dark)
- Real-time preview
**Smart Synchronization**:
- **Auto-write** - Immediately write to live files
- **Backfill protection** - Save current content before switching
- **Auto-import** - Import from live files on first launch
- **Modification protection** - Preserve manual modifications
**Technical Implementation**:
- **Backend**: `PromptService` (213 lines)
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
- **Hooks**: usePromptActions (152 lines)
- **i18n**: 41+ translation keys
---
### Deep Link Protocol (ccswitch://)
One-click provider configuration import via URL scheme.
**Features**:
- Protocol registration on all platforms
- Import from shared links
- Lifecycle integration
- Security validation
---
### Environment Variable Conflict Detection
Intelligent detection and management of configuration conflicts.
**Detection Scope**:
- **Claude & Codex** - Cross-app conflicts
- **Gemini** - Auto-discovery
- **MCP** - Server configuration conflicts
**Management Features**:
- Visual conflict indicators
- Resolution suggestions
- Override warnings
- Backup before changes
---
## Improvements
### Provider Management
**New Presets**:
- **DouBaoSeed** - ByteDance's DouBao
- **Kimi For Coding** - Moonshot AI
- **BaiLing** - BaiLing AI
- **Removed AnyRouter** - To avoid confusion
**Enhancements**:
- Model name configuration for Codex and Gemini
- Provider notes field for organization
- Enhanced preset metadata
### Configuration Management
- **Common config migration** - From localStorage to `config.json`
- **Unified persistence** - Shared across all apps
- **Auto-import** - First launch configuration import
- **Backfill priority** - Correct handling of live files
### UI/UX Improvements
**Design System**:
- **macOS native** - System-aligned color scheme
- **Window centering** - Default centered position
- **Visual polish** - Improved spacing and hierarchy
**Interactions**:
- **Password input** - Fixed Edge/IE reveal buttons
- **URL overflow** - Fixed card overflow
- **Error copying** - Copy-to-clipboard errors
- **Tray sync** - Real-time drag-and-drop sync
---
## Bug Fixes
### Critical Fixes
- **Usage script validation** - Boundary checks
- **Gemini validation** - Relaxed constraints
- **TOML parsing** - CJK quote handling
- **MCP fields** - Custom field preservation
- **White screen** - FormLabel crash fix
### Stability
- **Tray safety** - Pattern matching instead of unwrap
- **Error isolation** - Tray failures don't block operations
- **Import classification** - Correct category assignment
### UI Fixes
- **Model placeholders** - Removed misleading hints
- **Base URL** - Auto-fill for third-party providers
- **Drag sort** - Tray menu synchronization
---
## Technical Improvements
### Architecture
**MCP v3.7.0**:
- Removed legacy code (~1,000 lines)
- Unified initialization structure
- Backward compatibility maintained
- Comprehensive code formatting
**Platform Compatibility**:
- Windows winreg API fix (v0.52)
- Safe pattern matching (no `unwrap()`)
- Cross-platform tray handling
### Configuration
**Synchronization**:
- MCP sync across all apps
- Gemini form-editor sync
- Dual-file reading (.env + settings.json)
**Validation**:
- Input boundary checks
- TOML quote normalization (CJK)
- Custom field preservation
- Enhanced error messages
### Code Quality
**Type Safety**:
- Complete TypeScript coverage
- Rust type refinements
- API contract validation
**Testing**:
- Simplified assertions
- Better test coverage
- Integration test updates
**Dependencies**:
- Tauri 2.8.x
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
- Frontend: CodeMirror 6 packages
- winreg 0.52 (Windows)
---
## Technical Statistics
```
Total Changes:
- Commits: 85
- Files: 152 changed
- Additions: +18,104 lines
- Deletions: -3,732 lines
New Modules:
- Skills Management: 2,034 lines (21 files)
- Prompts Management: 1,302 lines (20 files)
- Gemini Integration: ~1,000 lines
- MCP Refactor: ~3,000 lines refactored
Code Distribution:
- Backend (Rust): ~4,500 lines new
- Frontend (React): ~3,000 lines new
- Configuration: ~1,500 lines refactored
- Tests: ~500 lines
```
---
## Strategic Positioning
### From Tool to Platform
v3.7.0 represents a shift in CC Switch's positioning:
| Aspect | v3.6 | v3.7.0 |
| ----------------- | ------------------------ | ---------------------------- |
| **Identity** | Provider Switcher | AI CLI Management Platform |
| **Scope** | Configuration Management | Ecosystem Management |
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
| **Customization** | Manual editing | Visual management (Prompts) |
| **Integration** | Isolated apps | Unified management (MCP) |
### Six Pillars of AI CLI Management
1. **Configuration Management** - Provider switching and management
2. **Capability Extension** - Skills installation and lifecycle
3. **Behavior Customization** - System prompt presets
4. **Ecosystem Integration** - Deep links and sharing
5. **Multi-AI Support** - Claude/Codex/Gemini
6. **Intelligent Detection** - Conflict prevention
---
## Download & Installation
### System Requirements
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-Linux.AppImage` or `.deb`
- **ArchLinux**: `paru -S cc-switch-bin`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
---
## Migration Notes
### From v3.6.x
**Automatic migration** - No action required, configs are fully compatible
### From v3.1.x or Earlier
**Two-step migration required**:
1. First upgrade to v3.2.x (performs one-time migration)
2. Then upgrade to v3.7.0
### New Features
- **Skills**: No migration needed, start fresh
- **Prompts**: Auto-import from live files on first launch
- **Gemini**: Install Gemini CLI separately if needed
- **MCP v3.7.0**: Backward compatible with previous configs
---
## Acknowledgments
### Contributors
Thanks to all contributors who made this release possible:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
- Community members for testing and feedback
### Sponsors
**Z.ai** - GLM CODING PLAN sponsor
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API relay service partner
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
**ShanDianShuo** - Local-first AI voice input
[Free download](https://shandianshuo.cn) for Mac/Win
---
## Feedback & Support
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **Documentation**: [README](../README.md)
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
---
## What's Next
**v3.8.0 Preview** (Tentative):
- Local proxy functionality
Stay tuned for more updates!
---
**Happy Coding!**

View File

@@ -1,481 +0,0 @@
# CC Switch v3.7.1
> 稳定性增强与用户体验改进
**[English Version →](release-note-v3.7.1-en.md)**
---
## v3.7.1 更新内容
**发布日期**2025-11-22
**代码变更**17 个文件,+524 / -81 行
### Bug 修复
- **修复 Skills 第三方仓库安装失败** (#268)
修复使用自定义子目录的 skills 仓库无法安装的问题,支持类似 `ComposioHQ/awesome-claude-skills` 这样带子目录的仓库
- **修复 Gemini 配置持久化问题**
解决在 Gemini 表单中编辑 settings.json 后,切换供应商时修改丢失的问题
- **防止对话框意外关闭**
添加点击遮罩时的保护,避免误操作导致表单数据丢失,影响所有 11 个对话框组件
### 新增功能
- **Gemini 配置目录支持** (#255)
在设置中添加 Gemini 配置目录选项,支持自定义 `~/.gemini/` 路径
- **ArchLinux 安装支持** (#259)
添加 AUR 安装方式:`paru -S cc-switch-bin`
### 改进
- **Skills 错误消息国际化增强**
新增 28+ 条详细错误消息(中英文),提供具体的解决建议,下载超时从 15 秒延长到 60 秒
- **代码格式化**
应用统一的 Rust 和 TypeScript 代码格式化标准
### 下载
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载最新版本
---
## v3.7.0 完整更新说明
> 从供应商切换器到 AI CLI 一体化管理平台
**发布日期**2025-11-19
**提交数量**:从 v3.6.0 开始 85 个提交
**代码变更**152 个文件,+18,104 / -3,732 行
---
## 新增功能
### Gemini CLI 集成
完整支持 Google Gemini CLI成为第三个支持的应用Claude Code、Codex、Gemini
**核心能力**
- **双文件配置** - 同时支持 `.env``settings.json` 格式
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等环境变量
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
- **系统托盘** - 从托盘菜单快速切换
**供应商预设**
- **Google Official** - 支持 OAuth 认证
- **PackyCode** - 合作伙伴集成
- **自定义** - 完全自定义支持
**技术实现**
- 新增后端模块:`gemini_config.rs`20KB`gemini_mcp.rs`
- 表单与环境编辑器同步
- 双文件原子写入
---
### MCP v3.7.0 统一架构
MCP 管理系统完整重构,实现跨应用统一管理。
**架构改进**
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
- **SSE 传输类型** - 新增 Server-Sent Events 支持
- **智能解析器** - 容错性 JSON 解析
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
- **扩展字段** - 保留自定义 TOML 字段
**用户体验**
- 表单中的默认应用选择
- JSON 格式化器用于验证
- 改进的视觉层次
- 更好的错误消息
**导入/导出**
- 统一从三个应用导入
- 双向同步
- 状态保持
---
### Claude Skills 管理系统
**约 2,000 行代码** - 完整的技能生态平台。
**GitHub 集成**
- 从 GitHub 仓库自动扫描技能
- 预配置仓库:
- `ComposioHQ/awesome-claude-skills` - 精选集合
- `anthropics/skills` - Anthropic 官方技能
- `cexll/myclaude` - 社区贡献
- 添加自定义仓库
- 子目录扫描支持(`skillsPath`
**生命周期管理**
- **发现** - 自动检测 `SKILL.md` 文件
- **安装** - 一键安装到 `~/.claude/skills/`
- **卸载** - 安全移除并跟踪状态
- **更新** - 检查更新(基础设施已就绪)
**技术架构**
- **后端**`SkillService`526 行)集成 GitHub API
- **前端**SkillsPage、SkillCard、RepoManager
- **UI 组件**Badge、Card、Tableshadcn/ui
- **状态**:持久化存储在 `config.json`
- **国际化**47+ 个翻译键
---
### Prompts 管理系统
**约 1,300 行代码** - 完整的系统提示词管理。
**多预设管理**
- 创建无限数量的提示词预设
- 快速在预设间切换
- 同时只能激活一个提示词
- 活动提示词删除保护
**跨应用支持**
- **Claude**`~/.claude/CLAUDE.md`
- **Codex**`~/.codex/AGENTS.md`
- **Gemini**`~/.gemini/GEMINI.md`
**Markdown 编辑器**
- 完整的 CodeMirror 6 集成
- 语法高亮
- 暗色主题One Dark
- 实时预览
**智能同步**
- **自动写入** - 立即写入 live 文件
- **回填保护** - 切换前保存当前内容
- **自动导入** - 首次启动从 live 文件导入
- **修改保护** - 保留手动修改
**技术实现**
- **后端**`PromptService`213 行)
- **前端**PromptPanel177、PromptFormModal160、MarkdownEditor159
- **Hooks**usePromptActions152 行)
- **国际化**41+ 个翻译键
---
### 深度链接协议ccswitch://
通过 URL 方案一键导入供应商配置。
**功能特性**
- 所有平台的协议注册
- 从共享链接导入
- 生命周期集成
- 安全验证
---
### 环境变量冲突检测
智能检测和管理配置冲突。
**检测范围**
- **Claude & Codex** - 跨应用冲突
- **Gemini** - 自动发现
- **MCP** - 服务器配置冲突
**管理功能**
- 可视化冲突指示器
- 解决建议
- 覆盖警告
- 更改前备份
---
## 改进优化
### 供应商管理
**新增预设**
- **DouBaoSeed** - 字节跳动的豆包
- **Kimi For Coding** - 月之暗面
- **BaiLing** - 百灵 AI
- **移除 AnyRouter** - 避免误导
**增强功能**
- Codex 和 Gemini 的模型名称配置
- 供应商备注字段用于组织
- 增强的预设元数据
### 配置管理
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
- **统一持久化** - 跨所有应用共享
- **自动导入** - 首次启动配置导入
- **回填优先级** - 正确处理 live 文件
### UI/UX 改进
**设计系统**
- **macOS 原生** - 与系统对齐的配色方案
- **窗口居中** - 默认居中位置
- **视觉优化** - 改进的间距和层次
**交互优化**
- **密码输入** - 修复 Edge/IE 显示按钮
- **URL 溢出** - 修复卡片溢出
- **错误复制** - 可复制到剪贴板的错误
- **托盘同步** - 实时拖放同步
---
## Bug 修复
### 关键修复
- **用量脚本验证** - 边界检查
- **Gemini 验证** - 放宽约束
- **TOML 解析** - CJK 引号处理
- **MCP 字段** - 自定义字段保留
- **白屏** - FormLabel 崩溃修复
### 稳定性
- **托盘安全** - 模式匹配替代 unwrap
- **错误隔离** - 托盘失败不阻塞操作
- **导入分类** - 正确的类别分配
### UI 修复
- **模型占位符** - 移除误导性提示
- **Base URL** - 第三方供应商自动填充
- **拖拽排序** - 托盘菜单同步
---
## 技术改进
### 架构
**MCP v3.7.0**
- 移除遗留代码(约 1,000 行)
- 统一初始化结构
- 保持向后兼容性
- 全面的代码格式化
**平台兼容性**
- Windows winreg API 修复v0.52
- 安全模式匹配(无 `unwrap()`
- 跨平台托盘处理
### 配置
**同步机制**
- 跨所有应用的 MCP 同步
- Gemini 表单-编辑器同步
- 双文件读取(.env + settings.json
**验证增强**
- 输入边界检查
- TOML 引号规范化CJK
- 自定义字段保留
- 增强的错误消息
### 代码质量
**类型安全**
- 完整的 TypeScript 覆盖
- Rust 类型改进
- API 契约验证
**测试**
- 简化的断言
- 更好的测试覆盖
- 集成测试更新
**依赖项**
- Tauri 2.8.x
- Rust`anyhow``zip``serde_yaml``tempfile`
- 前端CodeMirror 6 包
- winreg 0.52Windows
---
## 技术统计
```
总体变更:
- 提交数85
- 文件数152 个文件变更
- 新增:+18,104 行
- 删除:-3,732 行
新增模块:
- Skills 管理2,034 行21 个文件)
- Prompts 管理1,302 行20 个文件)
- Gemini 集成:约 1,000 行
- MCP 重构:约 3,000 行重构
代码分布:
- 后端Rust约 4,500 行新增
- 前端React约 3,000 行新增
- 配置:约 1,500 行重构
- 测试:约 500 行
```
---
## 战略定位
### 从工具到平台
v3.7.0 代表了 CC Switch 定位的转变:
| 方面 | v3.6 | v3.7.0 |
| -------- | -------------- | ----------------------- |
| **身份** | 供应商切换器 | AI CLI 管理平台 |
| **范围** | 配置管理 | 生态系统管理 |
| **应用** | Claude + Codex | Claude + Codex + Gemini |
| **能力** | 切换配置 | 扩展能力Skills |
| **定制** | 手动编辑 | 可视化管理Prompts |
| **集成** | 孤立应用 | 统一管理MCP |
### AI CLI 管理六大支柱
1. **配置管理** - 供应商切换和管理
2. **能力扩展** - Skills 安装和生命周期
3. **行为定制** - 系统提示词预设
4. **生态集成** - 深度链接和共享
5. **多 AI 支持** - Claude/Codex/Gemini
6. **智能检测** - 冲突预防
---
## 下载与安装
### 系统要求
- **Windows**Windows 10+
- **macOS**macOS 10.15Catalina+
- **Linux**Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
### 下载链接
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
- **Windows**`CC-Switch-Windows.msi``-Portable.zip`
- **macOS**`CC-Switch-macOS.tar.gz``.zip`
- **Linux**`CC-Switch-Linux.AppImage``.deb`
- **ArchLinux**`paru -S cc-switch-bin`
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
---
## 迁移说明
### 从 v3.6.x 升级
**自动迁移** - 无需任何操作,配置完全兼容
### 从 v3.1.x 或更早版本升级
**需要两步迁移**
1. 首先升级到 v3.2.x执行一次性迁移
2. 然后升级到 v3.7.0
### 新功能
- **Skills**:无需迁移,全新开始
- **Prompts**:首次启动时从 live 文件自动导入
- **Gemini**:需要单独安装 Gemini CLI
- **MCP v3.7.0**:与之前的配置向后兼容
---
## 致谢
### 贡献者
感谢所有让这个版本成为可能的贡献者:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini 集成实现
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
- 社区成员的测试和反馈
### 赞助商
**智谱AI** - GLM CODING PLAN 赞助商
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
**PackyCode** - API 中转服务合作伙伴
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
**闪电说** - 本地优先的 AI 语音输入法
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
---
## 反馈与支持
- **问题反馈**[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **讨论**[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **文档**[README](../README_ZH.md)
- **更新日志**[CHANGELOG.md](../CHANGELOG.md)
---
## 未来展望
**v3.8.0 预览**(暂定):
- 本地代理功能
敬请期待更多更新!
---
**Happy Coding!**

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.7.1",
"version": "3.7.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
@@ -46,7 +46,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",

8
pnpm-lock.yaml generated
View File

@@ -41,9 +41,6 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
'@lobehub/icons-static-svg':
specifier: ^1.73.0
version: 1.73.0
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -612,9 +609,6 @@ packages:
'@lezer/markdown@1.6.0':
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
'@lobehub/icons-static-svg@1.73.0':
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -2845,8 +2839,6 @@ snapshots:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lobehub/icons-static-svg@1.73.0': {}
'@marijn/find-cluster-break@1.0.2': {}
'@mswjs/interceptors@0.40.0':

View File

@@ -1,208 +0,0 @@
const fs = require('fs');
const path = require('path');
// 要提取的图标列表(按分类组织)
const ICONS_TO_EXTRACT = {
// AI 服务商(必需)
aiProviders: [
'openai', 'anthropic', 'claude', 'google', 'gemini',
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
'cohere', 'perplexity', 'mistral', 'huggingface'
],
// 云平台
cloudPlatforms: [
'aws', 'azure', 'huawei', 'cloudflare'
],
// 开发工具
devTools: [
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
],
// 其他
others: [
'settings', 'folder', 'file', 'link'
]
};
// 合并所有图标
const ALL_ICONS = [
...ICONS_TO_EXTRACT.aiProviders,
...ICONS_TO_EXTRACT.cloudPlatforms,
...ICONS_TO_EXTRACT.devTools,
...ICONS_TO_EXTRACT.others
];
// 提取逻辑
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('🎨 CC-Switch Icon Extractor\n');
console.log('========================================');
console.log('📦 Extracting icons...\n');
let extracted = 0;
let notFound = [];
// 提取图标
ALL_ICONS.forEach(iconName => {
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
if (fs.existsSync(sourceFile)) {
fs.copyFileSync(sourceFile, targetFile);
console.log(`${iconName}.svg`);
extracted++;
} else {
console.log(`${iconName}.svg (not found)`);
notFound.push(iconName);
}
});
// 生成索引文件
console.log('\n📝 Generating index file...\n');
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${ALL_ICONS.filter(name => !notFound.includes(name))
.map(name => {
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
})
.join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
console.log('✓ Generated: src/icons/extracted/index.ts');
// 生成图标元数据
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
// AI Providers
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
// Cloud Platforms
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
// Dev Tools
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
// Others
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
console.log('✓ Generated: src/icons/extracted/metadata.ts');
// 生成 README
const readmeContent = `# Extracted Icons
This directory contains extracted icons from @lobehub/icons-static-svg.
## Statistics
- Total extracted: ${extracted} icons
- Not found: ${notFound.length} icons
## Extracted Icons
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
## Usage
\`\`\`typescript
import { getIcon, hasIcon, iconList } from './extracted';
// Get icon SVG
const svg = getIcon('openai');
// Check if icon exists
if (hasIcon('openai')) {
// ...
}
// Get all available icons
console.log(iconList);
\`\`\`
---
Last updated: ${new Date().toISOString()}
Generated by: scripts/extract-icons.js
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
console.log('✓ Generated: src/icons/extracted/README.md');
console.log('\n========================================');
console.log('✅ Extraction complete!\n');
console.log(` ✓ Extracted: ${extracted} icons`);
console.log(` ✗ Not found: ${notFound.length} icons`);
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
console.log('========================================\n');

View File

@@ -1,95 +0,0 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
// List of "Famous" icons to keep
// Based on common AI providers and tools
const KEEP_LIST = [
// AI Providers
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
'microsoft', 'azure', 'copilot', 'meta', 'llama',
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
'perplexity', 'huggingface', 'midjourney', 'stability',
'xai', 'grok', 'yi', 'zeroone', 'ollama',
// Cloud/Tools
'aws', 'googlecloud', 'huawei', 'cloudflare',
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
];
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Scanning ${files.length} files...`);
let keptCount = 0;
let deletedCount = 0;
let renamedCount = 0;
// First pass: Identify files to keep and prefer color versions
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
files.forEach(file => {
const isColor = file.endsWith('-color.svg');
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
if (!fileMap[baseName]) {
fileMap[baseName] = { hasColor: false, hasMono: false };
}
if (isColor) {
fileMap[baseName].hasColor = true;
} else {
fileMap[baseName].hasMono = true;
}
});
// Second pass: Process files
Object.keys(fileMap).forEach(baseName => {
const info = fileMap[baseName];
const shouldKeep = KEEP_LIST.includes(baseName);
if (!shouldKeep) {
// Delete both versions if not in keep list
if (info.hasColor) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
deletedCount++;
}
if (info.hasMono) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
deletedCount++;
}
return;
}
// If keeping, prefer color
if (info.hasColor) {
// Rename color version to base version (overwrite mono if exists)
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
try {
// If mono exists, it will be overwritten/replaced
fs.renameSync(colorPath, targetPath);
renamedCount++;
keptCount++;
} catch (e) {
console.error(`Error renaming ${baseName}:`, e);
}
} else if (info.hasMono) {
// Keep mono if no color version
keptCount++;
}
});
console.log(`\nCleanup complete:`);
console.log(`- Kept: ${keptCount}`);
console.log(`- Deleted: ${deletedCount}`);
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
// Regenerate index and metadata
require('./generate-icon-index.js');

View File

@@ -1,113 +0,0 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
// Known metadata from previous configuration
const KNOWN_METADATA = {
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Found ${files.length} SVG files.`);
// Generate index.ts
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${files.map(file => {
const name = path.basename(file, '.svg');
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
}).join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(INDEX_FILE, indexContent);
console.log(`Generated ${INDEX_FILE}`);
// Generate metadata.ts
const metadataEntries = files.map(file => {
const name = path.basename(file, '.svg').toLowerCase();
const known = KNOWN_METADATA[name];
if (known) {
return ` ${name}: ${JSON.stringify(known)},`;
}
// Default metadata for unknown icons
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
});
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
${metadataEntries.join('\n')}
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(METADATA_FILE, metadataContent);
console.log(`Generated ${METADATA_FILE}`);

45
src-tauri/Cargo.lock generated
View File

@@ -291,17 +291,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -606,18 +595,15 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.7.1"
version = "3.7.0"
dependencies = [
"anyhow",
"auto-launch",
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"once_cell",
"regex",
"reqwest",
"rquickjs",
@@ -996,15 +982,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
@@ -1023,17 +1000,6 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
@@ -6431,15 +6397,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.7.1"
version = "3.7.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -48,9 +48,6 @@ zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

View File

@@ -10,8 +10,7 @@
"opener:default",
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
"process:allow-restart",
"dialog:default"
]
}
}

View File

@@ -1,40 +0,0 @@
use crate::error::AppError;
use auto_launch::AutoLaunch;
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch";
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
Ok(auto_launch)
}
/// 启用开机自启
pub fn enable_auto_launch() -> Result<(), AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.enable()
.map_err(|e| AppError::Message(format!("启用开机自启失败: {e}")))?;
log::info!("已启用开机自启");
Ok(())
}
/// 禁用开机自启
pub fn disable_auto_launch() -> Result<(), AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.disable()
.map_err(|e| AppError::Message(format!("禁用开机自启失败: {e}")))?;
log::info!("已禁用开机自启");
Ok(())
}
/// 检查是否已启用开机自启
pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
}

View File

@@ -9,16 +9,6 @@ pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
parse_deeplink_url(&url).map_err(|e| e.to_string())
}
/// Merge configuration from Base64/URL into a deep link request
/// This is used by the frontend to show the complete configuration in the confirmation dialog
#[tauri::command]
pub fn merge_deeplink_config(
request: DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, String> {
log::info!("Merging config for deep link request: {}", request.name);
crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())
}
/// Import a provider from a deep link request (after user confirmation)
#[tauri::command]
pub fn import_from_deeplink(

View File

@@ -37,20 +37,3 @@ pub async fn set_app_config_dir_override(
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
Ok(true)
}
/// 设置开机自启
#[tauri::command]
pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
if enabled {
crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?;
} else {
crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?;
}
Ok(true)
}
/// 获取开机自启状态
#[tauri::command]
pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
}

View File

@@ -1,4 +1,3 @@
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
@@ -46,30 +45,18 @@ pub async fn install_skill(
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
&[("directory", &directory)],
Some("checkRepoUrl"),
)
})?;
.ok_or_else(|| "技能不存在".to_string())?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
owner: skill
.repo_owner
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
name: skill
.repo_name
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
branch: skill
.repo_branch
.clone()

View File

@@ -37,24 +37,6 @@ pub struct DeepLinkImportRequest {
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// Optional Haiku model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub haiku_model: Option<String>,
/// Optional Sonnet model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub sonnet_model: Option<String>,
/// Optional Opus model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub opus_model: Option<String>,
/// Optional Base64 encoded config content (v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<String>,
/// Optional config format (json/toml, v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_format: Option<String>,
/// Optional remote config URL (v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>,
}
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
@@ -128,33 +110,29 @@ pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppErr
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
.clone();
// Make these optional for config file auto-fill (v3.8+)
let homepage = params.get("homepage").cloned().unwrap_or_default();
let endpoint = params.get("endpoint").cloned().unwrap_or_default();
let api_key = params.get("apiKey").cloned().unwrap_or_default();
let homepage = params
.get("homepage")
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
.clone();
// Validate URLs only if provided
if !homepage.is_empty() {
validate_url(&homepage, "homepage")?;
}
if !endpoint.is_empty() {
validate_url(&endpoint, "endpoint")?;
}
let endpoint = params
.get("endpoint")
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
.clone();
let api_key = params
.get("apiKey")
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
.clone();
// Validate URLs
validate_url(&homepage, "homepage")?;
validate_url(&endpoint, "endpoint")?;
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
// Extract Claude-specific optional model fields (v3.7.1+)
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
let opus_model = params.get("opusModel").cloned();
// Extract optional config fields (v3.8+)
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
Ok(DeepLinkImportRequest {
version,
resource,
@@ -165,12 +143,6 @@ pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppErr
api_key,
model,
notes,
haiku_model,
sonnet_model,
opus_model,
config,
config_format,
config_url,
})
}
@@ -193,44 +165,23 @@ fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
///
/// This function:
/// 1. Validates the request
/// 2. Merges config file if provided (v3.8+)
/// 3. Converts it to a Provider structure
/// 4. Delegates to ProviderService for actual import
/// 2. Converts it to a Provider structure
/// 3. Delegates to ProviderService for actual import
pub fn import_provider_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?;
// Step 2: Validate required fields after merge
if merged_request.api_key.is_empty() {
return Err(AppError::InvalidInput(
"API key is required (either in URL or config file)".to_string(),
));
}
if merged_request.endpoint.is_empty() {
return Err(AppError::InvalidInput(
"Endpoint is required (either in URL or config file)".to_string(),
));
}
if merged_request.homepage.is_empty() {
return Err(AppError::InvalidInput(
"Homepage is required (either in URL or config file)".to_string(),
));
}
// Parse app type
let app_type = AppType::from_str(&merged_request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?;
let app_type = AppType::from_str(&request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
// Build provider configuration based on app type
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
let mut provider = build_provider_from_request(&app_type, &request)?;
// Generate a unique ID for the provider using timestamp + sanitized name
// This is similar to how frontend generates IDs
let timestamp = chrono::Utc::now().timestamp_millis();
let sanitized_name = merged_request
let sanitized_name = request
.name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
@@ -260,31 +211,11 @@ fn build_provider_from_request(
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
// Add default model if provided
// Add model if provided (use as default model)
if let Some(model) = &request.model {
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
}
// Add Claude-specific model fields (v3.7.1+)
if let Some(haiku_model) = &request.haiku_model {
env.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
json!(haiku_model),
);
}
if let Some(sonnet_model) = &request.sonnet_model {
env.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
json!(sonnet_model),
);
}
if let Some(opus_model) = &request.opus_model {
env.insert(
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
json!(opus_model),
);
}
json!({ "env": env })
}
AppType::Codex => {
@@ -388,254 +319,11 @@ requires_openai_auth = true
sort_index: None,
notes: request.notes.clone(),
meta: None,
icon: None,
icon_color: None,
};
Ok(provider)
}
/// Parse and merge configuration from Base64 encoded config or remote URL
///
/// Priority: URL params > inline config > remote config
pub fn parse_and_merge_config(
request: &DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, AppError> {
use base64::prelude::*;
// If no config provided, return original request
if request.config.is_none() && request.config_url.is_none() {
return Ok(request.clone());
}
// Step 1: Get config content
let config_content = if let Some(config_b64) = &request.config {
// Decode Base64 inline config
let decoded = BASE64_STANDARD
.decode(config_b64)
.map_err(|e| AppError::InvalidInput(format!("Invalid Base64 encoding: {e}")))?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
} else if let Some(_config_url) = &request.config_url {
// Fetch remote config (TODO: implement remote fetching in next phase)
return Err(AppError::InvalidInput(
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
));
} else {
return Ok(request.clone());
};
// Step 2: Parse config based on format
let format = request.config_format.as_deref().unwrap_or("json");
let config_value: serde_json::Value = match format {
"json" => serde_json::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
"toml" => {
let toml_value: toml::Value = toml::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
// Convert TOML to JSON for uniform processing
serde_json::to_value(toml_value)
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
}
_ => {
return Err(AppError::InvalidInput(format!(
"Unsupported config format: {format}"
)))
}
};
// Step 3: Extract values from config based on app type and merge with URL params
let mut merged = request.clone();
match request.app.as_str() {
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
_ => {
return Err(AppError::InvalidInput(format!(
"Invalid app type: {}",
request.app
)))
}
}
Ok(merged)
}
/// Merge Claude configuration from config file
///
/// Priority: URL params override config file values
fn merge_claude_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let env = config
.get("env")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::InvalidInput("Claude config must have 'env' object".to_string())
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_empty() {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = token.to_string();
}
}
// Auto-fill endpoint if not provided in URL
if request.endpoint.is_empty() {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = base_url.to_string();
}
}
// Auto-fill homepage from endpoint if not provided
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://anthropic.com".to_string());
}
// Auto-fill model fields (URL params take priority)
if request.model.is_none() {
request.model = env
.get("ANTHROPIC_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.haiku_model.is_none() {
request.haiku_model = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.sonnet_model.is_none() {
request.sonnet_model = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.opus_model.is_none() {
request.opus_model = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
Ok(())
}
/// Merge Codex configuration from config file
fn merge_codex_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_empty() {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
{
request.api_key = api_key.to_string();
}
}
// Auto-fill endpoint and model from config string
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_empty() {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = base_url;
}
}
// Extract model
if request.model.is_none() {
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
request.model = Some(model.to_string());
}
}
}
}
// Auto-fill homepage from endpoint
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://openai.com".to_string());
}
Ok(())
}
/// Merge Gemini configuration from config file
fn merge_gemini_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_empty() {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = api_key.to_string();
}
}
if request.endpoint.is_empty() {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = base_url.to_string();
}
}
if request.model.is_none() {
request.model = config
.get("GEMINI_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
// Auto-fill homepage from endpoint
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://ai.google.dev".to_string());
}
Ok(())
}
/// Extract base_url from Codex TOML config
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
// Try to find base_url in model_providers section
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
for (_key, provider) in providers.iter() {
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
return Some(base_url.to_string());
}
}
}
None
}
/// Infer homepage URL from API endpoint
///
/// Examples:
/// - https://api.anthropic.com/v1 → https://anthropic.com
/// - https://api.openai.com/v1 → https://openai.com
/// - https://api-test.company.com/v1 → https://company.com
fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {
let url = Url::parse(endpoint).ok()?;
let host = url.host_str()?;
// Remove common API prefixes
let clean_host = host
.strip_prefix("api.")
.or_else(|| host.strip_prefix("api-"))
.unwrap_or(host);
Some(format!("https://{clean_host}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -687,15 +375,14 @@ mod tests {
#[test]
fn test_parse_missing_required_field() {
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
let url = "ccswitch://v1/import?resource=provider&app=claude";
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing 'name' parameter"));
.contains("Missing 'homepage' parameter"));
}
#[test]
@@ -726,12 +413,6 @@ mod tests {
api_key: "test-api-key".to_string(),
model: Some("gemini-2.0-flash".to_string()),
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -762,12 +443,6 @@ mod tests {
api_key: "test-api-key".to_string(),
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -779,88 +454,4 @@ mod tests {
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
#[test]
fn test_infer_homepage() {
assert_eq!(
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
Some("https://anthropic.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
Some("https://test.company.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://example.com"),
Some("https://example.com".to_string())
);
}
#[test]
fn test_parse_and_merge_config_claude() {
use base64::prelude::*;
// Prepare Base64 encoded Claude config
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "claude".to_string(),
name: "Test".to_string(),
homepage: String::new(),
endpoint: String::new(),
api_key: String::new(),
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// Should auto-fill from config
assert_eq!(merged.api_key, "sk-ant-xxx");
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
assert_eq!(merged.homepage, "https://anthropic.com");
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
}
#[test]
fn test_parse_and_merge_config_url_override() {
use base64::prelude::*;
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "claude".to_string(),
name: "Test".to_string(),
homepage: String::new(),
endpoint: String::new(),
api_key: "sk-new".to_string(), // URL param should override
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// URL param should take priority
assert_eq!(merged.api_key, "sk-new");
// Config file value should be used
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
}
}

View File

@@ -94,28 +94,3 @@ impl From<AppError> for String {
err.to_string()
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
context: &[(&str, &str)],
suggestion: Option<&str>,
) -> String {
use serde_json::json;
let mut ctx_map = serde_json::Map::new();
for (key, value) in context {
ctx_map.insert(key.to_string(), json!(value));
}
let error_obj = json!({
"code": code,
"context": ctx_map,
"suggestion": suggestion,
});
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
// 如果 JSON 序列化失败,返回简单格式
format!("ERROR:{code}")
})
}

View File

@@ -96,20 +96,7 @@ pub fn set_mcp_servers_map(
obj = server_obj;
}
// Gemini CLI 格式转换:
// - Gemini 不使用 "type" 字段(从字段名推断传输类型)
// - HTTP 使用 "httpUrl" 字段SSE 使用 "url" 字段
let transport_type = obj.get("type").and_then(|v| v.as_str());
if transport_type == Some("http") {
// HTTP streaming: 将 "url" 重命名为 "httpUrl"
if let Some(url_value) = obj.remove("url") {
obj.insert("httpUrl".to_string(), url_value);
}
}
// SSE 保持 "url" 字段不变
// 移除 UI 辅助字段和 type 字段Gemini 不需要)
obj.remove("type");
// 移除 UI 辅助字段
obj.remove("enabled");
obj.remove("source");
obj.remove("id");

View File

@@ -1,6 +1,5 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
@@ -15,7 +14,6 @@ mod mcp;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod services;
mod settings;
mod store;
@@ -706,7 +704,6 @@ pub fn run() {
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
commands::merge_deeplink_config,
commands::import_from_deeplink,
update_tray_menu,
// Environment variable management
@@ -720,9 +717,6 @@ pub fn run() {
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
]);
let app = builder

View File

@@ -449,7 +449,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
// 核心字段(需要手动处理的字段)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "http_headers"],
"http" | "sse" => vec!["type", "url", "headers"],
_ => vec!["type"],
};
@@ -490,13 +490,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
spec.insert("url".into(), json!(url));
}
// Read from http_headers (correct Codex format) or headers (legacy) with priority to http_headers
let headers_tbl = entry_tbl
.get("http_headers")
.and_then(|v| v.as_table())
.or_else(|| entry_tbl.get("headers").and_then(|v| v.as_table()));
if let Some(headers_tbl) = headers_tbl {
if let Some(headers_tbl) = entry_tbl.get("headers").and_then(|v| v.as_table()) {
let mut headers_json = serde_json::Map::new();
for (k, v) in headers_tbl.iter() {
if let Some(sv) = v.as_str() {
@@ -916,7 +910,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
// 定义核心字段(已在下方处理,跳过通用转换)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "http_headers"],
"http" | "sse" => vec!["type", "url", "headers"],
_ => vec!["type"],
};
@@ -994,7 +988,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
}
}
if !h_tbl.is_empty() {
t["http_headers"] = Item::Table(h_tbl);
t["headers"] = Item::Table(h_tbl);
}
}
}

View File

@@ -28,13 +28,6 @@ pub struct Provider {
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 图标名称(如 "openai", "anthropic"
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色Hex 格式,如 "#00A67E"
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
}
impl Provider {
@@ -55,8 +48,6 @@ impl Provider {
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
}
}
}

View File

@@ -1,238 +0,0 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
/// 供应商图标信息
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProviderIcon {
pub name: &'static str,
pub color: &'static str,
}
/// 供应商名称到图标的默认映射
#[allow(dead_code)]
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
let mut m = HashMap::new();
// AI 服务商
m.insert(
"openai",
ProviderIcon {
name: "openai",
color: "#00A67E",
},
);
m.insert(
"anthropic",
ProviderIcon {
name: "anthropic",
color: "#D4915D",
},
);
m.insert(
"claude",
ProviderIcon {
name: "claude",
color: "#D4915D",
},
);
m.insert(
"google",
ProviderIcon {
name: "google",
color: "#4285F4",
},
);
m.insert(
"gemini",
ProviderIcon {
name: "gemini",
color: "#4285F4",
},
);
m.insert(
"deepseek",
ProviderIcon {
name: "deepseek",
color: "#1E88E5",
},
);
m.insert(
"kimi",
ProviderIcon {
name: "kimi",
color: "#6366F1",
},
);
m.insert(
"moonshot",
ProviderIcon {
name: "moonshot",
color: "#6366F1",
},
);
m.insert(
"zhipu",
ProviderIcon {
name: "zhipu",
color: "#0F62FE",
},
);
m.insert(
"minimax",
ProviderIcon {
name: "minimax",
color: "#FF6B6B",
},
);
m.insert(
"baidu",
ProviderIcon {
name: "baidu",
color: "#2932E1",
},
);
m.insert(
"alibaba",
ProviderIcon {
name: "alibaba",
color: "#FF6A00",
},
);
m.insert(
"tencent",
ProviderIcon {
name: "tencent",
color: "#00A4FF",
},
);
m.insert(
"meta",
ProviderIcon {
name: "meta",
color: "#0081FB",
},
);
m.insert(
"microsoft",
ProviderIcon {
name: "microsoft",
color: "#00A4EF",
},
);
m.insert(
"cohere",
ProviderIcon {
name: "cohere",
color: "#39594D",
},
);
m.insert(
"perplexity",
ProviderIcon {
name: "perplexity",
color: "#20808D",
},
);
m.insert(
"mistral",
ProviderIcon {
name: "mistral",
color: "#FF7000",
},
);
m.insert(
"huggingface",
ProviderIcon {
name: "huggingface",
color: "#FFD21E",
},
);
// 云平台
m.insert(
"aws",
ProviderIcon {
name: "aws",
color: "#FF9900",
},
);
m.insert(
"azure",
ProviderIcon {
name: "azure",
color: "#0078D4",
},
);
m.insert(
"huawei",
ProviderIcon {
name: "huawei",
color: "#FF0000",
},
);
m.insert(
"cloudflare",
ProviderIcon {
name: "cloudflare",
color: "#F38020",
},
);
m
});
/// 根据供应商名称智能推断图标
#[allow(dead_code)]
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
let name_lower = provider_name.to_lowercase();
// 精确匹配
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
return Some(icon.clone());
}
// 模糊匹配(包含关键词)
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
if name_lower.contains(key) {
return Some(icon.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let icon = infer_provider_icon("openai");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
assert_eq!(icon.color, "#00A67E");
}
#[test]
fn test_fuzzy_match() {
let icon = infer_provider_icon("OpenAI Official");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
}
#[test]
fn test_case_insensitive() {
let icon = infer_provider_icon("ANTHROPIC");
assert!(icon.is_some());
assert_eq!(icon.unwrap().name, "anthropic");
}
#[test]
fn test_no_match() {
let icon = infer_provider_icon("unknown provider");
assert!(icon.is_none());
}
}

View File

@@ -7,8 +7,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use tokio::time::timeout;
use crate::error::format_skill_error;
/// 技能对象
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
@@ -135,11 +133,7 @@ impl SkillService {
}
fn get_install_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
let home = dirs::home_dir().context("无法获取用户主目录")?;
Ok(home.join(".claude").join("skills"))
}
}
@@ -179,19 +173,9 @@ impl SkillService {
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
@@ -395,17 +379,7 @@ impl SkillService {
// 下载 ZIP
let response = self.http_client.get(url).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
"DOWNLOAD_FAILED",
&[("status", &status)],
match status.as_str() {
"403" => Some("http403"),
"404" => Some("http404"),
"429" => Some("http429"),
_ => Some("checkNetwork"),
},
)));
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
}
let bytes = response.bytes().await?;
@@ -420,11 +394,7 @@ impl SkillService {
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow::anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkRepoUrl"),
)));
return Err(anyhow::anyhow!("空的压缩包"));
};
// 解压所有文件
@@ -471,28 +441,16 @@ impl SkillService {
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
let temp_dir = timeout(
std::time::Duration::from_secs(60),
std::time::Duration::from_secs(15),
self.download_repo(&repo),
)
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
temp_dir.join(skills_path.trim_matches('/')).join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
@@ -500,11 +458,10 @@ impl SkillService {
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow::anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
return Err(anyhow::anyhow!(
"技能目录不存在: {}",
source.display()
));
}
// 删除旧版本

View File

@@ -49,9 +49,6 @@ pub struct AppSettings {
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
@@ -80,7 +77,6 @@ impl Default for AppSettings {
codex_config_dir: None,
gemini_config_dir: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.7.1",
"version": "3.7.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -14,7 +14,6 @@
{
"label": "main",
"title": "",
"titleBarStyle": "Overlay",
"width": 1000,
"height": 650,
"minWidth": 900,

View File

@@ -1,16 +1,7 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Plus,
Settings,
ArrowLeft,
Bot,
Book,
Wrench,
Server,
RefreshCw,
} from "lucide-react";
import { Plus, Settings, Edit3 } from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery } from "@/lib/query";
@@ -28,7 +19,7 @@ import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import UsageScriptModal from "@/components/UsageScriptModal";
@@ -36,34 +27,34 @@ import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { Button } from "@/components/ui/button";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
function App() {
const { t } = useTranslation();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers");
const [isEditMode, setIsEditMode] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAddOpen, setIsAddOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -107,10 +98,7 @@ function App() {
if (flatConflicts.length > 0) {
setEnvConflicts(flatConflicts);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
setShowEnvBanner(true);
}
} catch (error) {
console.error(
@@ -140,10 +128,7 @@ function App() {
);
return [...prev, ...newConflicts];
});
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
setShowEnvBanner(true);
}
} catch (error) {
console.error(
@@ -244,81 +229,13 @@ function App() {
}
};
const renderContent = () => {
switch (currentView) {
case "settings":
return (
<SettingsPage
open={true}
onOpenChange={() => setCurrentView("providers")}
onImportSuccess={handleImportSuccess}
/>
);
case "prompts":
return (
<PromptPanel
ref={promptPanelRef}
open={true}
onOpenChange={() => setCurrentView("providers")}
appId={activeApp}
/>
);
case "skills":
return (
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
/>
);
case "mcp":
return (
<UnifiedMcpPanel
ref={mcpPanelRef}
onOpenChange={() => setCurrentView("providers")}
/>
);
case "agents":
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
default:
return (
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
);
}
};
return (
<div
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden" }}
>
{/* 全局拖拽区域(顶部 4px避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 h-4 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
/>
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => {
setShowEnvBanner(false);
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDismiss={() => setShowEnvBanner(false)}
onDeleted={async () => {
// 删除后重新检测
try {
@@ -338,182 +255,92 @@ function App() {
/>
)}
<header
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView !== "providers" ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")}
{currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && "Agents"}
</h1>
</div>
) : (
<>
<div className="flex items-center gap-2">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentView("settings")}
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<UpdateBadge onClick={() => setCurrentView("settings")} />
</>
)}
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
title={t("common.settings")}
className="ml-2"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditMode(!isEditMode)}
title={t(
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
)}
className={
isEditMode
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
: ""
}
>
<Edit3 className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
</div>
<div
className="flex items-center gap-2"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("prompts.add")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === "mcp" && (
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="h-5 w-5" />
</Button>
</>
)}
<div className="flex flex-wrap items-center gap-2">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<Button
variant="mcp"
onClick={() => setIsPromptOpen(true)}
className="min-w-[80px]"
>
{t("prompts.manage")}
</Button>
<Button
variant="mcp"
onClick={() => setIsMcpOpen(true)}
className="min-w-[80px]"
>
MCP
</Button>
<Button
variant="mcp"
onClick={() => setIsSkillsOpen(true)}
className="min-w-[80px]"
>
{t("skills.manage")}
</Button>
<Button onClick={() => setIsAddOpen(true)}>
<Plus className="h-4 w-4" />
{t("header.addProvider")}
</Button>
</div>
</div>
</header>
<main
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
<main className="flex-1 overflow-y-scroll">
<div className="mx-auto max-w-4xl px-6 py-6">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isEditMode={isEditMode}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
</main>
<AddProviderDialog
@@ -561,6 +388,30 @@ function App() {
onCancel={() => setConfirmDelete(null)}
/>
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
<PromptPanel
open={isPromptOpen}
onOpenChange={setIsPromptOpen}
appId={activeApp}
/>
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
<DialogHeader className="sr-only">
<VisuallyHidden>
<DialogTitle>{t("skills.title")}</DialogTitle>
</VisuallyHidden>
</DialogHeader>
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
</DialogContent>
</Dialog>
<DeepLinkImportDialog />
</div>
);

View File

@@ -13,13 +13,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1">
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent ">
<button
type="button"
onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
@@ -27,8 +27,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
size={16}
className={
activeApp === "claude"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
}
/>
<span>Claude</span>
@@ -39,18 +39,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
<CodexIcon
size={16}
className={
activeApp === "codex"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<CodexIcon size={16} />
<span>Codex</span>
</button>
@@ -59,7 +52,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
onClick={() => handleSwitch("gemini")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`}
>
@@ -67,8 +60,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
size={16}
className={
activeApp === "gemini"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200"
}
/>
<span>Gemini</span>

File diff suppressed because one or more lines are too long

View File

@@ -1,76 +0,0 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface ColorPickerProps {
value?: string;
onValueChange: (color: string) => void;
label?: string;
presets?: string[];
}
const DEFAULT_PRESETS = [
"#00A67E",
"#D4915D",
"#4285F4",
"#FF6A00",
"#00A4FF",
"#FF9900",
"#0078D4",
"#FF0000",
"#1E88E5",
"#6366F1",
"#0F62FE",
"#2932E1",
];
export const ColorPicker: React.FC<ColorPickerProps> = ({
value = "#4285F4",
onValueChange,
label = "图标颜色",
presets = DEFAULT_PRESETS,
}) => {
return (
<div className="space-y-3">
<Label>{label}</Label>
{/* 颜色预设 */}
<div className="grid grid-cols-6 gap-2">
{presets.map((color) => (
<button
key={color}
type="button"
onClick={() => onValueChange(color)}
className={cn(
"w-full aspect-square rounded-lg border-2 transition-all",
"hover:scale-110 hover:shadow-lg",
value === color
? "border-primary ring-2 ring-primary/20"
: "border-border",
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* 自定义颜色输入 */}
<div className="flex items-center gap-2">
<Input
type="color"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="#4285F4"
className="flex-1 font-mono"
/>
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import {
@@ -30,30 +30,9 @@ export function DeepLinkImportDialog() {
// Listen for deep link import events
const unlistenImport = listen<DeepLinkImportRequest>(
"deeplink-import",
async (event) => {
(event) => {
console.log("Deep link import event received:", event.payload);
// If config is present, merge it to get the complete configuration
if (event.payload.config || event.payload.configUrl) {
try {
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
event.payload,
);
console.log("Config merged successfully:", mergedRequest);
setRequest(mergedRequest);
} catch (error) {
console.error("Failed to merge config:", error);
toast.error(t("deeplink.configMergeError"), {
description:
error instanceof Error ? error.message : String(error),
});
// Fall back to original request
setRequest(event.payload);
}
} else {
setRequest(event.payload);
}
setRequest(event.payload);
setIsOpen(true);
},
);
@@ -92,6 +71,7 @@ export function DeepLinkImportDialog() {
});
setIsOpen(false);
setRequest(null);
} catch (error) {
console.error("Failed to import provider from deep link:", error);
toast.error(t("deeplink.importError"), {
@@ -104,326 +84,120 @@ export function DeepLinkImportDialog() {
const handleCancel = () => {
setIsOpen(false);
setRequest(null);
};
if (!request) return null;
// Mask API key for display (show first 4 chars + ***)
const maskedApiKey =
request?.apiKey && request.apiKey.length > 4
request.apiKey.length > 4
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
: "****";
// Check if config file is present
const hasConfigFile = !!(request?.config || request?.configUrl);
const configSource = request?.config
? "base64"
: request?.configUrl
? "url"
: null;
// Parse config file content for display
interface ParsedConfig {
type: "claude" | "codex" | "gemini";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
raw: Record<string, unknown>;
}
// Helper to decode base64 with UTF-8 support
const b64ToUtf8 = (str: string): string => {
try {
const binString = atob(str);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
return new TextDecoder().decode(bytes);
} catch (e) {
console.error("Failed to decode base64:", e);
return atob(str);
}
};
const parsedConfig = useMemo((): ParsedConfig | null => {
if (!request?.config) return null;
try {
const decoded = b64ToUtf8(request.config);
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
raw: parsed,
};
} else if (request.app === "codex") {
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: (parsed.config as string) || "",
raw: parsed,
};
} else if (request.app === "gemini") {
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
return {
type: "gemini",
env: parsed as Record<string, string>,
raw: parsed,
};
}
return null;
} catch (e) {
console.error("Failed to parse config:", e);
return null;
}
}, [request?.config, request?.app]);
// Helper to mask sensitive values
const maskValue = (key: string, value: string): string => {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) =>
key.toUpperCase().includes(k),
);
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
};
return (
<Dialog open={isOpen && !!request} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[500px]" zIndex="top">
{request && (
<>
{/* 标题显式左对齐,避免默认居中样式影响 */}
<DialogHeader className="text-left sm:text-left">
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
<DialogDescription>
{t("deeplink.confirmImportDescription")}
</DialogDescription>
</DialogHeader>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[500px]">
{/* 标题显式左对齐,避免默认居中样式影响 */}
<DialogHeader className="text-left sm:text-left">
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
<DialogDescription>
{t("deeplink.confirmImportDescription")}
</DialogDescription>
</DialogHeader>
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
<div className="space-y-4 px-8 py-4 max-h-[60vh] overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:block [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-200 dark:[&::-webkit-scrollbar-thumb]:bg-gray-700">
{/* App Type */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.app")}
</div>
<div className="col-span-2 text-sm font-medium capitalize">
{request.app}
</div>
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
<div className="space-y-4 px-8 py-4">
{/* App Type */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.app")}
</div>
<div className="col-span-2 text-sm font-medium capitalize">
{request.app}
</div>
</div>
{/* Provider Name */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.providerName")}
</div>
<div className="col-span-2 text-sm font-medium">{request.name}</div>
</div>
{/* Homepage */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.homepage")}
</div>
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
{request.homepage}
</div>
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all">
{request.endpoint}
</div>
</div>
{/* API Key (masked) */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.apiKey")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{maskedApiKey}
</div>
</div>
{/* Model (if present) */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
</div>
{/* Provider Name */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.providerName")}
</div>
<div className="col-span-2 text-sm font-medium">
{request.name}
</div>
</div>
{/* Homepage */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.homepage")}
</div>
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
{request.homepage}
</div>
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all">
{request.endpoint}
</div>
</div>
{/* API Key (masked) */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.apiKey")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{maskedApiKey}
</div>
</div>
{/* Model (if present) */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
)}
{/* Notes (if present) */}
{request.notes && (
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.notes")}
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{request.notes}
</div>
</div>
)}
{/* Config File Details (v3.8+) */}
{hasConfigFile && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.configSource")}
</div>
<div className="col-span-2 text-sm">
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
{configSource === "base64"
? t("deeplink.configEmbedded")
: t("deeplink.configRemote")}
</span>
{request.configFormat && (
<span className="ml-2 text-xs text-muted-foreground uppercase">
{request.configFormat}
</span>
)}
</div>
</div>
{/* Parsed Config Details */}
{parsedConfig && (
<div className="rounded-lg bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t("deeplink.configDetails")}
</div>
{/* Claude config */}
{parsedConfig.type === "claude" && parsedConfig.env && (
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
{/* Codex config */}
{parsedConfig.type === "codex" && (
<div className="space-y-2">
{parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
Auth:
</div>
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs pl-2"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
{parsedConfig.tomlConfig && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
TOML Config:
</div>
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
{parsedConfig.tomlConfig.substring(0, 300)}
{parsedConfig.tomlConfig.length > 300 && "..."}
</pre>
</div>
)}
</div>
)}
{/* Gemini config */}
{parsedConfig.type === "gemini" && parsedConfig.env && (
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
</div>
)}
{/* Config URL (if remote) */}
{request.configUrl && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.configUrl")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground break-all">
{request.configUrl}
</div>
</div>
)}
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isImporting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
</Button>
</DialogFooter>
</>
)}
{/* Notes (if present) */}
{request.notes && (
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.notes")}
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{request.notes}
</div>
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isImporting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -1,85 +0,0 @@
import React, { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProviderIcon } from "./ProviderIcon";
import { iconList } from "@/icons/extracted";
import { searchIcons, getIconMetadata } from "@/icons/extracted/metadata";
import { cn } from "@/lib/utils";
interface IconPickerProps {
value?: string; // 当前选中的图标
onValueChange: (icon: string) => void; // 选择回调
color?: string; // 预览颜色
}
export const IconPicker: React.FC<IconPickerProps> = ({
value,
onValueChange,
}) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
// 过滤图标列表
const filteredIcons = useMemo(() => {
if (!searchQuery) return iconList;
return searchIcons(searchQuery);
}, [searchQuery]);
return (
<div className="space-y-4">
<div>
<Label htmlFor="icon-search">
{t("iconPicker.search", { defaultValue: "搜索图标" })}
</Label>
<Input
id="icon-search"
type="text"
placeholder={t("iconPicker.searchPlaceholder", {
defaultValue: "输入图标名称...",
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mt-2"
/>
</div>
<div className="max-h-[65vh] overflow-y-auto pr-1">
<div className="grid grid-cols-6 sm:grid-cols-8 lg:grid-cols-10 gap-2">
{filteredIcons.map((iconName) => {
const meta = getIconMetadata(iconName);
const isSelected = value === iconName;
return (
<button
key={iconName}
type="button"
onClick={() => onValueChange(iconName)}
className={cn(
"flex flex-col items-center gap-1 p-3 rounded-lg",
"border-2 transition-all duration-200",
"hover:bg-accent hover:border-primary/50",
isSelected
? "border-primary bg-primary/10"
: "border-transparent",
)}
title={meta?.displayName || iconName}
>
<ProviderIcon icon={iconName} name={iconName} size={32} />
<span className="text-xs text-muted-foreground truncate w-full text-center">
{meta?.displayName || iconName}
</span>
</button>
);
})}
</div>
</div>
{filteredIcons.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
{t("iconPicker.noResults", { defaultValue: "未找到匹配的图标" })}
</div>
)}
</div>
);
};

View File

@@ -12,7 +12,6 @@ import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface JsonEditorProps {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
@@ -20,8 +19,7 @@ interface JsonEditorProps {
rows?: number;
showValidation?: boolean;
language?: "json" | "javascript";
height?: string | number;
showMinimap?: boolean; // 添加此属性以防未来使用
height?: string;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -86,47 +84,19 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
// 使用 baseTheme 定义基础样式,优先级低于 oneDark但可以正确响应主题
const baseTheme = EditorView.baseTheme({
".cm-editor": {
"&light .cm-editor, &dark .cm-editor": {
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
background: "transparent",
},
".cm-editor.cm-focused": {
"&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": {
outline: "none",
borderColor: "hsl(var(--primary))",
},
".cm-scroller": {
background: "transparent",
},
".cm-gutters": {
background: "transparent",
borderRight: "1px solid hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
},
".cm-selectionBackground, .cm-content ::selection": {
background: "hsl(var(--primary) / 0.18)",
},
".cm-selectionMatch": {
background: "hsl(var(--primary) / 0.12)",
},
".cm-activeLine": {
background: "hsl(var(--primary) / 0.08)",
},
".cm-activeLineGutter": {
background: "hsl(var(--primary) / 0.08)",
},
});
// 使用 theme 定义尺寸和字体样式
const heightValue = height
? typeof height === "number"
? `${height}px`
: height
: undefined;
const sizingTheme = EditorView.theme({
"&": heightValue
? { height: heightValue }
: { minHeight: `${minHeightPx}px` },
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
@@ -159,32 +129,11 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
".cm-editor": {
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
background: "transparent",
},
".cm-editor.cm-focused": {
outline: "none",
borderColor: "hsl(var(--primary))",
},
".cm-scroller": {
background: "transparent",
},
".cm-gutters": {
background: "transparent",
borderRight: "1px solid hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
},
".cm-selectionBackground, .cm-content ::selection": {
background: "hsl(var(--primary) / 0.18)",
},
".cm-selectionMatch": {
background: "hsl(var(--primary) / 0.12)",
},
".cm-activeLine": {
background: "hsl(var(--primary) / 0.08)",
},
".cm-activeLineGutter": {
background: "hsl(var(--primary) / 0.08)",
},
}),
);
}
@@ -247,23 +196,14 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
}
};
const isFullHeight = height === "100%";
return (
<div
style={{ width: "100%", height: isFullHeight ? "100%" : "auto" }}
className={isFullHeight ? "flex flex-col" : ""}
>
<div
ref={editorRef}
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
className={isFullHeight ? "flex-1 min-h-0" : ""}
/>
<div style={{ width: "100%" }}>
<div ref={editorRef} style={{ width: "100%" }} />
{language === "json" && (
<button
type="button"
onClick={handleFormat}
className={`${isFullHeight ? "mt-2 flex-shrink-0" : "mt-2"} inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors`}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}

View File

@@ -1,81 +0,0 @@
import React, { useMemo } from "react";
import { getIcon, hasIcon } from "@/icons/extracted";
import { cn } from "@/lib/utils";
interface ProviderIconProps {
icon?: string; // 图标名称
name: string; // 供应商名称(用于 fallback
color?: string; // 自定义颜色 (Deprecated, kept for compatibility but ignored for SVG)
size?: number | string; // 尺寸
className?: string;
showFallback?: boolean; // 是否显示 fallback
}
export const ProviderIcon: React.FC<ProviderIconProps> = ({
icon,
name,
size = 32,
className,
showFallback = true,
}) => {
// 获取图标 SVG
const iconSvg = useMemo(() => {
if (icon && hasIcon(icon)) {
return getIcon(icon);
}
return "";
}, [icon]);
// 计算尺寸样式
const sizeStyle = useMemo(() => {
const sizeValue = typeof size === "number" ? `${size}px` : size;
return {
width: sizeValue,
height: sizeValue,
};
}, [size]);
// 如果有图标,显示图标
if (iconSvg) {
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0",
className,
)}
style={sizeStyle}
dangerouslySetInnerHTML={{ __html: iconSvg }}
/>
);
}
// Fallback显示首字母
if (showFallback) {
const initials = name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0 rounded-lg",
"bg-muted text-muted-foreground font-semibold",
className,
)}
style={sizeStyle}
>
<span
style={{
fontSize: `${typeof size === "number" ? size * 0.4 : 14}px`,
}}
>
{initials}
</span>
</span>
);
}
return null;
};

View File

@@ -60,7 +60,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
if (!usage.success) {
if (inline) {
return (
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
<AlertCircle size={12} />
<span>{t("usage.queryFailed")}</span>
@@ -68,7 +68,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -78,7 +78,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
}
return (
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="mt-3 pt-3 border-t border-border-default ">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
<AlertCircle size={14} />
@@ -110,32 +110,29 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
const isExpired = firstUsage.isValid === false;
return (
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
{/* 第一行:新时间刷新按钮 */}
<div className="flex flex-col gap-1 text-xs flex-shrink-0">
{/* 第一行:新时间 + 刷新按钮 */}
<div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */}
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t)
: t("usage.never", { defaultValue: "从未更新" })}
</span>
{lastQueriedAt && (
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)}
</span>
)}
{/* 刷新按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
refetch();
}}
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{/* 第二行:用量和剩余 */}
{/* 第二行:已用 + 剩余 + 单位 */}
<div className="flex items-center gap-2">
{/* 已用 */}
{firstUsage.used !== undefined && (
@@ -156,13 +153,14 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{t("usage.remaining")}
</span>
<span
className={`font-semibold tabular-nums ${isExpired
className={`font-semibold tabular-nums ${
isExpired
? "text-red-500 dark:text-red-400"
: firstUsage.remaining <
(firstUsage.total || firstUsage.remaining) * 0.1
(firstUsage.total || firstUsage.remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
}`}
>
{firstUsage.remaining.toFixed(2)}
</span>
@@ -181,7 +179,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
}
return (
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="mt-3 pt-3 border-t border-border-default ">
{/* 标题行:包含刷新按钮和自动查询时间 */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
@@ -198,7 +196,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -310,12 +308,13 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
{t("usage.remaining")}
</span>
<span
className={`font-semibold tabular-nums ${isExpired
? "text-red-500 dark:text-red-400"
: remaining < (total || remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
className={`font-semibold tabular-nums ${
isExpired
? "text-red-500 dark:text-red-400"
: remaining < (total || remaining) * 0.1
? "text-orange-500 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}`}
>
{remaining.toFixed(2)}
</span>

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { Play, Wand2, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "@/types";
@@ -8,12 +8,17 @@ import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
import * as pluginEstree from "prettier/plugins/estree";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { cn } from "@/lib/utils";
interface UsageScriptModalProps {
provider: Provider;
@@ -126,53 +131,88 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [testing, setTesting] = useState(false);
// 🔧 输入时的格式化(宽松)- 只清理格式,不约束范围
const sanitizeNumberInput = (value: string): string => {
// 移除所有非数字字符
let cleaned = value.replace(/[^\d]/g, "");
// 移除前导零(除非输入的就是 "0"
if (cleaned.length > 1 && cleaned.startsWith("0")) {
cleaned = cleaned.replace(/^0+/, "");
}
return cleaned;
};
// 🔧 失焦时的验证(严格)- 仅确保有效整数
const validateTimeout = (value: string): number => {
// 转换为数字
const num = Number(value);
// 检查是否为有效数字
if (isNaN(num) || value.trim() === "") {
return 10;
return 10; // 默认值
}
// 检查是否为整数
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
);
}
// 检查负数
if (num < 0) {
toast.error(
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
);
return 10;
}
return Math.floor(num);
};
// 🔧 失焦时的验证(严格)- 自动查询间隔
const validateAndClampInterval = (value: string): number => {
// 转换为数字
const num = Number(value);
// 检查是否为有效数字
if (isNaN(num) || value.trim() === "") {
return 0;
return 0; // 禁用自动查询
}
// 检查是否为整数
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
);
}
// 检查负数
if (num < 0) {
toast.error(
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
);
return 0;
}
// 约束到 [0, 1440] 范围最大24小时
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
// 如果值被调整,显示提示
if (clamped !== num && num > 0) {
toast.info(
t("usageScript.intervalAdjusted", { value: clamped }) ||
`自动查询间隔已调整为 ${clamped} 分钟`,
);
}
return clamped;
};
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
// 初始化:如果已有 accessToken 或 userId说明是 NewAPI 模板
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
@@ -183,18 +223,23 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
},
);
// 控制 API Key 的显示/隐藏
const [showApiKey, setShowApiKey] = useState(false);
const [showAccessToken, setShowAccessToken] = useState(false);
const handleSave = () => {
// 验证脚本格式
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
return;
}
// 基本的 JS 语法检查(检查是否包含 return 语句)
if (script.enabled && !script.code.includes("return")) {
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
onSave(script);
onClose();
};
@@ -202,6 +247,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// 使用当前编辑器中的脚本内容进行测试
const result = await usageApi.testScript(
provider.id,
appId,
@@ -213,6 +259,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
script.userId,
);
if (result.success && result.data && result.data.length > 0) {
// 显示所有套餐数据
const summary = result.data
.map((plan) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
@@ -267,7 +314,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
// 根据模板类型清空不同的字段
if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 自定义:清空所有凭证字段
setScript({
...script,
code: preset,
@@ -277,6 +326,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
// 通用:保留 apiKey 和 baseUrl清空 NewAPI 字段
setScript({
...script,
code: preset,
@@ -284,131 +334,84 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
// NewAPI清空 apiKeyNewAPI 不使用通用的 apiKey
setScript({
...script,
code: preset,
apiKey: undefined,
});
}
setSelectedTemplate(presetName);
setSelectedTemplate(presetName); // 记录选择的模板
}
};
// 判断是否应该显示凭证配置区域
const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
const footer = (
<>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} className="mr-1" />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title={t("usageScript.format")}
>
<Wand2 size={14} className="mr-1" />
{t("usageScript.format")}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onClose}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Save size={16} className="mr-2" />
{t("usageScript.saveConfig")}
</Button>
</div>
</>
);
return (
<FullScreenPanel
isOpen={isOpen}
title={`${t("usageScript.title")} - ${provider.name}`}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
setScript({ ...script, enabled: checked })
}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{t("usageScript.title")} - {provider.name}
</DialogTitle>
</DialogHeader>
{script.enabled && (
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<span className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</span>
</div>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
return (
<Button
key={name}
type="button"
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"rounded-lg border",
isSelected
? "shadow-sm"
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => handleUsePreset(name)}
>
{t(TEMPLATE_NAME_KEYS[name])}
</Button>
);
})}
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 启用开关 */}
<div className="flex items-center justify-between gap-4 rounded-lg border border-border-default p-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{t("usageScript.enableUsageQuery")}
</p>
</div>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
setScript({ ...script, enabled: checked })
}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
{script.enabled && (
<>
{/* 预设模板选择 */}
<div>
<Label className="mb-2">
{t("usageScript.presetTemplate")}
</Label>
<div className="flex gap-2">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
return (
<button
key={name}
onClick={() => handleUsePreset(name)}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
isSelected
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t(TEMPLATE_NAME_KEYS[name])}
</button>
);
})}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* 凭证配置区域:通用和 NewAPI 模板显示 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("usageScript.credentialsConfig")}
</h4>
{/* 通用模板:显示 apiKey + baseUrl */}
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
<>
<div className="space-y-2">
@@ -423,13 +426,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="sk-xxxxx"
autoComplete="off"
className="border-white/10"
/>
{script.apiKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
@@ -457,12 +459,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="https://api.example.com"
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
{/* NewAPI 模板:显示 baseUrl + accessToken + userId */}
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<div className="space-y-2">
@@ -476,7 +478,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="https://api.newapi.com"
autoComplete="off"
className="border-white/10"
/>
</div>
@@ -499,7 +500,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
"usageScript.accessTokenPlaceholder",
)}
autoComplete="off"
className="border-white/10"
/>
{script.accessToken && (
<button
@@ -507,7 +507,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onClick={() =>
setShowAccessToken(!showAccessToken)
}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
aria-label={
showAccessToken
? t("apiKeyInput.hide")
@@ -537,70 +537,32 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder={t("usageScript.userIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
</div>
</div>
)}
</div>
)}
{/* 脚本配置 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<h4 className="text-base font-medium text-foreground">
{t("usageScript.scriptConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</p>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="usage-request-url">
{t("usageScript.requestUrl")}
</Label>
<Input
id="usage-request-url"
type="text"
value={script.request?.url || ""}
onChange={(e) => {
setScript({
...script,
request: { ...script.request, url: e.target.value },
});
}}
placeholder={t("usageScript.requestUrlPlaceholder")}
className="border-white/10"
{/* 脚本编辑器 */}
<div>
<Label className="mb-2">{t("usageScript.queryScript")}</Label>
<JsonEditor
value={script.code}
onChange={(code) => setScript({ ...script, code })}
height="300px"
language="javascript"
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t("usageScript.variablesHint", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usage-method">
{t("usageScript.method")}
</Label>
<Input
id="usage-method"
type="text"
value={script.request?.method || "GET"}
onChange={(e) => {
setScript({
...script,
request: {
...script.request,
method: e.target.value.toUpperCase(),
},
});
}}
placeholder="GET / POST"
className="border-white/10"
/>
</div>
{/* 配置选项 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
@@ -608,150 +570,83 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
value={script.timeout ?? ""}
onChange={(e) => {
// 输入时:只清理格式,允许临时为空,避免强制回填默认值
const cleaned = sanitizeNumberInput(e.target.value);
setScript((prev) => ({
...prev,
timeout:
cleaned === "" ? undefined : parseInt(cleaned, 10),
}));
}}
onBlur={(e) => {
// 失焦时:严格验证并约束范围
const validated = validateTimeout(e.target.value);
setScript({ ...script, timeout: validated });
}}
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.timeoutHint") || "范围: 2-30 秒"}
</p>
</div>
{/* 🆕 自动查询间隔 */}
<div className="space-y-2">
<Label htmlFor="usage-auto-interval">
{t("usageScript.autoQueryInterval")}
</Label>
<Input
id="usage-auto-interval"
type="number"
min={0}
max={1440}
step={1}
value={script.autoQueryInterval ?? ""}
onChange={(e) => {
// 输入时:只清理格式,允许临时为空
const cleaned = sanitizeNumberInput(e.target.value);
setScript((prev) => ({
...prev,
autoQueryInterval:
cleaned === "" ? undefined : parseInt(cleaned, 10),
}));
}}
onBlur={(e) => {
// 失焦时:严格验证并约束范围
const validated = validateAndClampInterval(
e.target.value,
);
setScript({ ...script, autoQueryInterval: validated });
}}
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-headers">
{t("usageScript.headers")}
</Label>
<JsonEditor
id="usage-headers"
value={
script.request?.headers
? JSON.stringify(script.request.headers, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed = JSON.parse(value || "{}");
setScript({
...script,
request: { ...script.request, headers: parsed },
});
} catch (error) {
console.error("Invalid headers JSON", error);
}
}}
height={180}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
<JsonEditor
id="usage-body"
value={
script.request?.body
? JSON.stringify(script.request.body, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed =
value?.trim() === "" ? undefined : JSON.parse(value);
setScript({
...script,
request: { ...script.request, body: parsed },
});
} catch (error) {
toast.error(
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
);
}
}}
height={220}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")}
</Label>
<Input
id="usage-interval"
type="number"
min={0}
max={1440}
value={script.autoIntervalMinutes ?? 0}
onChange={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
}
onBlur={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
</div>
{/* 提取器代码 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
{t("usageScript.extractorCode")}
</Label>
<div className="text-xs text-muted-foreground">
{t("usageScript.extractorHint")}
</div>
</div>
<JsonEditor
id="usage-code"
value={script.code || ""}
onChange={(value) => setScript({ ...script, code: value })}
height={480}
language="javascript"
showMinimap={false}
/>
</div>
{/* 帮助信息 */}
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
{`({
{/* 脚本说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<h4 className="font-medium mb-2">
{t("usageScript.scriptHelp")}
</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
{`({
request: {
url: "{{baseUrl}}/api/usage",
method: "POST",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")}
},
extractor: function(response) {
// ${t("usageScript.commentResponseIsJson")}
return {
isValid: !response.error,
remaining: response.balance,
@@ -759,41 +654,79 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
}
})`}
</pre>
</div>
</pre>
</div>
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div className="text-muted-foreground">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
<div className="text-gray-600 dark:text-gray-400">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)}
</FullScreenPanel>
{/* Footer */}
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
{/* Left side - Test and Format buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title={t("usageScript.format")}
>
<Wand2 size={14} />
{t("usageScript.format")}
</Button>
</div>
{/* Right side - Cancel and Save buttons */}
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="default" size="sm" onClick={handleSave}>
{t("usageScript.saveConfig")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,22 +0,0 @@
import { Bot } from "lucide-react";
interface AgentsPanelProps {
onOpenChange: (open: boolean) => void;
}
export function AgentsPanel({}: AgentsPanelProps) {
return (
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
<Bot className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold">Coming Soon</h3>
<p className="text-muted-foreground max-w-md">
The Agents management feature is currently under development. Stay
tuned for powerful autonomous capabilities.
</p>
</div>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import React from "react";
import { createPortal } from "react-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FullScreenPanelProps {
isOpen: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
}
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
* Uses solid theme colors without transparency
*/
export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
isOpen,
title,
onClose,
children,
footer,
}) => {
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
)}
</div>,
document.body,
);
};

View File

@@ -110,7 +110,7 @@ export function EnvWarningBanner({
return (
<>
<div className="fixed top-0 left-0 right-0 z-[100] bg-yellow-50 dark:bg-yellow-950 border-b border-yellow-200 dark:border-yellow-900 shadow-lg animate-slide-down">
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/50">
<div className="container mx-auto px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
@@ -241,7 +241,7 @@ export function EnvWarningBanner({
</div>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="max-w-md" zIndex="top">
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />

View File

@@ -1,11 +1,25 @@
import React, { useMemo, useState, useEffect } from "react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import {
Save,
Plus,
AlertCircle,
ChevronDown,
ChevronUp,
Wand2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import JsonEditor from "@/components/JsonEditor";
import { Textarea } from "@/components/ui/textarea";
import type { AppId } from "@/lib/api/types";
import { McpServer, McpServerSpec } from "@/types";
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
@@ -20,21 +34,25 @@ import {
mcpServerToToml,
} from "@/utils/tomlUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
import { parseSmartMcpJson } from "@/utils/formatters";
import { formatJSON, parseSmartMcpJson } from "@/utils/formatters";
import { useMcpValidation } from "./useMcpValidation";
import { useUpsertMcpServer } from "@/hooks/useMcp";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
interface McpFormModalProps {
editingId?: string;
initialData?: McpServer;
onSave: () => Promise<void>;
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
onClose: () => void;
existingIds?: string[];
defaultFormat?: "json" | "toml";
defaultEnabledApps?: AppId[];
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为全部应用)
}
/**
* MCP 表单模态框组件v3.7.0 完整重构版)
* - 支持 JSON 和 TOML 两种格式
* - 统一管理,通过复选框选择启用到哪些应用
*/
const McpFormModal: React.FC<McpFormModalProps> = ({
editingId,
initialData,
@@ -61,6 +79,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 启用状态:编辑模式使用现有值,新增模式使用默认值
const [enabledApps, setEnabledApps] = useState<{
claude: boolean;
codex: boolean;
@@ -69,6 +88,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (initialData?.apps) {
return { ...initialData.apps };
}
// 新增模式:根据 defaultEnabledApps 设置初始值
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
@@ -76,8 +96,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
});
// 编辑模式下禁止修改 ID
const isEditing = !!editingId;
// 判断是否在编辑模式下有附加信息
const hasAdditionalInfo = !!(
initialData?.description ||
initialData?.tags?.length ||
@@ -85,17 +107,21 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
initialData?.docs
);
// 附加信息展开状态(编辑模式下有值时默认展开)
const [showMetadata, setShowMetadata] = useState(
isEditing ? hasAdditionalInfo : false,
);
// 配置格式:优先使用 defaultFormat编辑模式下可从现有数据推断
const useTomlFormat = useMemo(() => {
if (initialData?.server) {
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON
return defaultFormat === "toml";
}
return defaultFormat === "toml";
}, [defaultFormat, initialData]);
// 根据格式决定初始配置
const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server;
if (!spec) return "";
@@ -109,23 +135,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
const useToml = useTomlFormat;
const wizardInitialSpec = useMemo(() => {
@@ -153,6 +164,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}, [formConfig, initialData, useToml]);
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
);
@@ -174,6 +186,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return `${candidate}-${i}`;
};
// 应用预设(写入表单但不落库)
const applyPreset = (index: number) => {
if (index < 0 || index >= mcpPresets.length) return;
const preset = mcpPresets[index];
@@ -187,6 +200,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setFormDocs(presetWithDesc.docs || "");
setFormTags(presetWithDesc.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
@@ -199,8 +213,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSelectedPreset(index);
};
// 切回自定义
const applyCustom = () => {
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
@@ -212,16 +228,19 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
const handleConfigChange = (value: string) => {
// 若为 TOML 模式,先做引号归一化,避免中文输入法导致的格式错误
const nextValue = useToml ? normalizeTomlText(value) : value;
setFormConfig(nextValue);
if (useToml) {
// TOML validation (use hook's complete validation)
const err = validateTomlConfig(nextValue);
if (err) {
setConfigError(err);
return;
}
// Try to extract ID (if user hasn't filled it yet)
if (nextValue.trim() && !formId.trim()) {
const extractedId = extractIdFromToml(nextValue);
if (extractedId) {
@@ -229,8 +248,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON validation with smart parsing
try {
const result = parseSmartMcpJson(value);
// 验证解析后的配置对象
const configJson = JSON.stringify(result.config);
const validationErr = validateJsonConfig(configJson);
@@ -239,15 +261,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return;
}
// 自动填充提取的 id仅当表单 id 为空且不在编辑模式时)
if (result.id && !formId.trim() && !isEditing) {
const uniqueId = ensureUniqueId(result.id);
setFormId(uniqueId);
// 如果 name 也为空,同时填充 name
if (!formName.trim()) {
setFormName(result.id);
}
}
// 不在输入时自动格式化,保持用户输入的原样
// 格式清理将在提交时进行
setConfigError("");
} catch (err: any) {
const errorMessage = err?.message || String(err);
@@ -256,11 +283,30 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
};
const handleFormatJson = () => {
if (!formConfig.trim()) return;
try {
const formatted = formatJSON(formConfig);
setFormConfig(formatted);
toast.success(t("common.formatSuccess"));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
error: errorMessage,
}),
);
}
};
const handleWizardApply = (title: string, json: string) => {
setFormId(title);
if (!formName.trim()) {
setFormName(title);
}
// Wizard returns JSON, convert based on format if needed
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
@@ -283,14 +329,17 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return;
}
// 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists"));
return;
}
// Validate configuration format
let serverSpec: McpServerSpec;
if (useToml) {
// TOML mode
const tomlError = validateTomlConfig(formConfig);
setConfigError(tomlError);
if (tomlError) {
@@ -299,6 +348,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -315,7 +365,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON mode
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -323,6 +375,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
} else {
try {
// 使用智能解析器,支持带外层键的格式
const result = parseSmartMcpJson(formConfig);
serverSpec = result.config as McpServerSpec;
} catch (e: any) {
@@ -334,6 +387,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
// 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return;
@@ -348,6 +402,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSaving(true);
try {
// 先处理 name 字段(必填)
const nameTrimmed = (formName || trimmedId).trim();
const finalName = nameTrimmed || trimmedId;
@@ -356,6 +411,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
id: trimmedId,
name: finalName,
server: serverSpec,
// 使用表单中的启用状态v3.7.0 完整重构)
apps: enabledApps,
};
@@ -390,9 +446,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
delete entry.tags;
}
// 保存到统一配置
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
await onSave();
await onSave(); // 通知父组件关闭表单
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -409,33 +466,18 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return (
<>
<FullScreenPanel
isOpen={true}
title={getFormTitle()}
onClose={onClose}
footer={
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
}
>
<div className="flex flex-col h-full gap-6">
{/* 上半部分:表单字段 */}
<div className="glass rounded-xl p-6 border border-white/10 space-y-6 flex-shrink-0">
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{getFormTitle()}</DialogTitle>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 预设选择(仅新增时展示) */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-foreground mb-3">
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("mcp.presets.title")}
</label>
<div className="flex flex-wrap gap-2">
@@ -445,7 +487,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-accent text-muted-foreground hover:bg-accent/80"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t("presetSelector.custom")}
@@ -460,7 +502,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === idx
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-accent text-muted-foreground hover:bg-accent/80"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
title={t(descriptionKey)}
>
@@ -471,11 +513,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</div>
)}
{/* ID (标题) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
{!isEditing && idError && (
@@ -495,7 +536,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* Name */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.name")}
</label>
<Input
@@ -506,9 +547,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* 启用到哪些应用 */}
{/* 启用到哪些应用v3.7.0 新增) */}
<div>
<label className="block text-sm font-medium text-foreground mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t("mcp.form.enabledApps")}
</label>
<div className="flex flex-wrap gap-4">
@@ -522,7 +563,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
<label
htmlFor="enable-claude"
className="text-sm text-foreground cursor-pointer select-none"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
@@ -538,7 +579,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
<label
htmlFor="enable-codex"
className="text-sm text-foreground cursor-pointer select-none"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
@@ -554,7 +595,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
<label
htmlFor="enable-gemini"
className="text-sm text-foreground cursor-pointer select-none"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
@@ -567,7 +608,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
<button
type="button"
onClick={() => setShowMetadata(!showMetadata)}
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
{showMetadata ? (
<ChevronUp size={16} />
@@ -581,8 +622,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* 附加信息区域(可折叠) */}
{showMetadata && (
<>
{/* Description (描述) */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.description")}
</label>
<Input
@@ -593,8 +635,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.tags")}
</label>
<Input
@@ -605,8 +648,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Homepage */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.homepage")}
</label>
<Input
@@ -617,8 +661,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Docs */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("mcp.form.docs")}
</label>
<Input
@@ -630,51 +675,79 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</>
)}
</div>
{/* 下半部分JSON 配置编辑器 - 自适应剩余高度 */}
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<label className="text-sm font-medium text-foreground">
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0">
<JsonEditor
value={formConfig}
onChange={handleConfigChange}
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
darkMode={isDarkMode}
rows={12}
showValidation={!useToml}
language={useToml ? "javascript" : "json"}
height="100%"
/>
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{useToml
? t("mcp.form.tomlConfig")
: t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<Textarea
className="h-48 resize-none font-mono text-xs"
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
value={formConfig}
onChange={(e) => handleConfigChange(e.target.value)}
/>
{/* 格式化按钮(仅 JSON 模式) */}
{!useToml && (
<div className="flex items-center justify-between mt-2">
<button
type="button"
onClick={handleFormatJson}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format")}
</button>
</div>
)}
{configError && (
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm flex-shrink-0">
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
<AlertCircle size={16} />
<span>{configError}</span>
</div>
)}
</div>
</div>
</div>
</FullScreenPanel>
{/* Footer */}
<DialogFooter className="flex justify-end gap-3 pt-4">
{/* 操作按钮 */}
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
variant="mcp"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Wizard Modal */}
<McpWizardModal

View File

@@ -1,7 +1,14 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Plus, Server, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
import type { McpServer } from "@/types";
@@ -15,6 +22,7 @@ import { mcpPresets } from "@/config/mcpPresets";
import { toast } from "sonner";
interface UnifiedMcpPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
@@ -22,14 +30,10 @@ interface UnifiedMcpPanelProps {
* 统一 MCP 管理面板
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
*/
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
}
const UnifiedMcpPanel = React.forwardRef<
UnifiedMcpPanelHandle,
UnifiedMcpPanelProps
>(({ onOpenChange: _onOpenChange }, ref) => {
const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
open,
onOpenChange,
}) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -86,10 +90,6 @@ const UnifiedMcpPanel = React.forwardRef<
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
@@ -115,50 +115,78 @@ const UnifiedMcpPanel = React.forwardRef<
};
return (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server size={24} className="text-gray-400 dark:text-gray-500" />
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle>{t("mcp.unifiedPanel.title")}</DialogTitle>
<Button type="button" variant="mcp" onClick={handleAdd}>
<Plus size={16} />
{t("mcp.unifiedPanel.addServer")}
</Button>
</div>
</DialogHeader>
{/* Info Section */}
<div className="flex-shrink-0 px-6 py-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 pb-4">
{isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Form Modal */}
{isFormOpen && (
@@ -187,11 +215,9 @@ const UnifiedMcpPanel = React.forwardRef<
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
</>
);
});
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
};
/**
* 统一 MCP 列表项组件
@@ -233,111 +259,113 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
};
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
<div className="min-h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
<div className="flex items-center gap-4">
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
<Switch
id={`${id}-claude`}
checked={server.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "claude", checked)
}
/>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
<Switch
id={`${id}-claude`}
checked={server.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "claude", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
<Switch
id={`${id}-codex`}
checked={server.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
<Switch
id={`${id}-gemini`}
checked={server.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "gemini", checked)
}
/>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
<Switch
id={`${id}-codex`}
checked={server.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "codex", checked)
}
/>
</div>
<Edit3 size={16} />
</Button>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
<Switch
id={`${id}-gemini`}
checked={server.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "gemini", checked)
}
/>
<Trash2 size={16} />
</Button>
</div>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);
};

View File

@@ -1,153 +0,0 @@
import React, { useState, useEffect } 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 MarkdownEditor from "@/components/MarkdownEditor";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Prompt, AppId } from "@/lib/api";
interface PromptFormPanelProps {
appId: AppId;
editingId?: string;
initialData?: Prompt;
onSave: (id: string, prompt: Prompt) => Promise<void>;
onClose: () => void;
}
const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (initialData) {
setName(initialData.name);
setDescription(initialData.description || "");
setContent(initialData.content);
}
}, [initialData]);
const handleSave = async () => {
if (!name.trim() || !content.trim()) {
return;
}
setSaving(true);
try {
const id = editingId || `prompt-${Date.now()}`;
const timestamp = Math.floor(Date.now() / 1000);
const prompt: Prompt = {
id,
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
enabled: initialData?.enabled || false,
createdAt: initialData?.createdAt || timestamp,
updatedAt: timestamp,
};
await onSave(id, prompt);
onClose();
} catch (error) {
// Error handled by hook
} finally {
setSaving(false);
}
};
const title = editingId
? t("prompts.editTitle", { appName })
: t("prompts.addTitle", { appName });
return (
<FullScreenPanel
isOpen={true}
title={title}
onClose={onClose}
footer={
<Button
type="button"
onClick={handleSave}
disabled={!name.trim() || !content.trim() || saving}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
<div className="glass rounded-xl p-6 border border-white/10 space-y-6">
<div>
<Label htmlFor="name" className="text-foreground">
{t("prompts.name")}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("prompts.namePlaceholder")}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="description" className="text-foreground">
{t("prompts.description")}
</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("prompts.descriptionPlaceholder")}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="content" className="block mb-2 text-foreground">
{t("prompts.content")}
</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={t("prompts.contentPlaceholder", { filename })}
darkMode={isDarkMode}
minHeight="167px"
/>
</div>
</div>
</FullScreenPanel>
);
};
export default PromptFormPanel;

View File

@@ -25,7 +25,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
const enabled = prompt.enabled === true;
return (
<div className="group relative h-16 rounded-xl border border-border-default bg-muted/50 p-4 transition-all duration-300 hover:bg-muted hover:border-border-default/80 hover:shadow-sm">
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
<div className="flex items-center gap-4 h-full">
{/* Toggle 开关 */}
<div className="flex-shrink-0">

View File

@@ -1,10 +1,18 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText } from "lucide-react";
import { Plus, FileText, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { type AppId } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import PromptListItem from "./PromptListItem";
import PromptFormPanel from "./PromptFormPanel";
import PromptFormModal from "./PromptFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
interface PromptPanelProps {
@@ -13,143 +21,157 @@ interface PromptPanelProps {
appId: AppId;
}
export interface PromptPanelHandle {
openAdd: () => void;
}
const PromptPanel: React.FC<PromptPanelProps> = ({
open,
onOpenChange,
appId,
}) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
titleKey: string;
messageKey: string;
messageParams?: Record<string, unknown>;
onConfirm: () => void;
} | null>(null);
const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
({ open, appId }, ref) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
titleKey: string;
messageKey: string;
messageParams?: Record<string, unknown>;
onConfirm: () => void;
} | null>(null);
const { prompts, loading, reload, savePrompt, deletePrompt, toggleEnabled } =
usePromptActions(appId);
const {
prompts,
loading,
reload,
savePrompt,
deletePrompt,
toggleEnabled,
} = usePromptActions(appId);
useEffect(() => {
if (open) reload();
}, [open, reload]);
useEffect(() => {
if (open) reload();
}, [open, reload]);
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleDelete = (id: string) => {
const prompt = prompts[id];
setConfirmDialog({
isOpen: true,
titleKey: "prompts.confirm.deleteTitle",
messageKey: "prompts.confirm.deleteMessage",
messageParams: { name: prompt?.name },
onConfirm: async () => {
try {
await deletePrompt(id);
setConfirmDialog(null);
} catch (e) {
// Error handled by hook
}
},
});
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
const handleDelete = (id: string) => {
const prompt = prompts[id];
setConfirmDialog({
isOpen: true,
titleKey: "prompts.confirm.deleteTitle",
messageKey: "prompts.confirm.deleteMessage",
messageParams: { name: prompt?.name },
onConfirm: async () => {
try {
await deletePrompt(id);
setConfirmDialog(null);
} catch (e) {
// Error handled by hook
}
},
});
};
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
const appName = t(`apps.${appId}`);
const panelTitle = t("prompts.title", { appName });
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle>{panelTitle}</DialogTitle>
<Button type="button" variant="mcp" onClick={handleAdd}>
<Plus size={16} />
{t("prompts.add")}
</Button>
</div>
</DialogHeader>
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
{enabledPrompt
? t("prompts.enabledName", { name: enabledPrompt[1].name })
: t("prompts.noneEnabled")}
<div className="flex-shrink-0 px-6 py-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
{enabledPrompt
? t("prompts.enabledName", { name: enabledPrompt[1].name })
: t("prompts.noneEnabled")}
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto pb-16">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")}
</div>
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-gray-400 dark:text-gray-500"
/>
<div className="flex-1 overflow-y-auto px-6 pb-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{promptEntries.map(([id, prompt]) => (
<PromptListItem
key={id}
id={id}
prompt={prompt}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{promptEntries.map(([id, prompt]) => (
<PromptListItem
key={id}
id={id}
prompt={prompt}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{isFormOpen && (
<PromptFormPanel
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? prompts[editingId] : undefined}
onSave={savePrompt}
onClose={() => setIsFormOpen(false)}
/>
)}
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={t(confirmDialog.titleKey)}
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
},
);
{isFormOpen && (
<PromptFormModal
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? prompts[editingId] : undefined}
onSave={savePrompt}
onClose={() => setIsFormOpen(false)}
/>
)}
PromptPanel.displayName = "PromptPanel";
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={t(confirmDialog.titleKey)}
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</>
);
};
export default PromptPanel;

View File

@@ -1,8 +1,15 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Provider, CustomEndpoint } from "@/types";
import type { AppId } from "@/lib/api";
import {
@@ -41,8 +48,6 @@ export function AddProviderDialog({
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
icon: values.icon?.trim() || undefined,
iconColor: values.iconColor?.trim() || undefined,
...(values.presetCategory ? { category: values.presetCategory } : {}),
...(values.meta ? { meta: values.meta } : {}),
};
@@ -53,6 +58,8 @@ export function AddProviderDialog({
if (!hasCustomEndpoints) {
// 收集端点候选(仅在缺少自定义端点时兜底)
// 1. 从预设配置中获取 endpointCandidates
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => {
@@ -163,40 +170,34 @@ export function AddProviderDialog({
? t("provider.addCodexProvider")
: t("provider.addGeminiProvider");
const footer = (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("common.add")}
</Button>
</>
);
return (
<FullScreenPanel
isOpen={open}
title={submitLabel}
onClose={() => onOpenChange(false)}
footer={footer}
>
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</FullScreenPanel>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<DialogTitle>{submitLabel}</DialogTitle>
<DialogDescription>{t("provider.addProviderHint")}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" form="provider-form">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Provider } from "@/types";
import {
ProviderForm,
@@ -27,7 +34,7 @@ export function EditProviderDialog({
}: EditProviderDialogProps) {
const { t } = useTranslation();
// 默认使用传入的 provider.settingsConfig若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
// 默认使用传入的 provider.settingsConfig若当前编辑对象是当前生效供应商,则尝试读取实时配置替换初始值
const [liveSettings, setLiveSettings] = useState<Record<
string,
unknown
@@ -89,8 +96,6 @@ export function EditProviderDialog({
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
icon: values.icon?.trim() || undefined,
iconColor: values.iconColor?.trim() || undefined,
...(values.presetCategory ? { category: values.presetCategory } : {}),
// 保留或更新 meta 字段
...(values.meta ? { meta: values.meta } : {}),
@@ -107,40 +112,45 @@ export function EditProviderDialog({
}
return (
<FullScreenPanel
isOpen={open}
title={t("provider.editProvider")}
onClose={() => onOpenChange(false)}
footer={
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Save className="h-4 w-4 mr-2" />
{t("common.save")}
</Button>
}
>
<ProviderForm
appId={appId}
providerId={provider.id}
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
}}
showButtons={false}
/>
</FullScreenPanel>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<DialogTitle>{t("provider.editProvider")}</DialogTitle>
<DialogDescription>
{t("provider.editProviderHint")}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<ProviderForm
appId={appId}
providerId={provider.id}
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
}}
showButtons={false}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" form="provider-form">
<Save className="h-4 w-4" />
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,4 @@
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
import { BarChart3, Check, Edit, Play, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -7,7 +7,6 @@ interface ProviderActionsProps {
isCurrent: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
}
@@ -16,22 +15,20 @@ export function ProviderActions({
isCurrent,
onSwitch,
onEdit,
onDuplicate,
onConfigureUsage,
onDelete,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
return (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
<Button
size="sm"
variant={isCurrent ? "secondary" : "default"}
onClick={onSwitch}
disabled={isCurrent}
className={cn(
"w-[4.5rem] px-2.5",
"w-20",
isCurrent &&
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
)}
@@ -55,27 +52,15 @@ export function ProviderActions({
variant="ghost"
onClick={onEdit}
title={t("common.edit")}
className={iconButtonClass}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={onDuplicate}
title={t("provider.duplicate")}
className={iconButtonClass}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage}
title={t("provider.configureUsage")}
className={iconButtonClass}
>
<BarChart3 className="h-4 w-4" />
</Button>
@@ -86,7 +71,6 @@ export function ProviderActions({
onClick={isCurrent ? undefined : onDelete}
title={t("common.delete")}
className={cn(
iconButtonClass,
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
)}

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { GripVertical } from "lucide-react";
import { MoveVertical, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
DraggableAttributes,
@@ -8,8 +8,8 @@ import type {
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
interface DragHandleProps {
@@ -22,6 +22,7 @@ interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isEditMode?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -70,6 +71,7 @@ export function ProviderCard({
provider,
isCurrent,
appId,
isEditMode = false,
onSwitch,
onEdit,
onDelete,
@@ -114,40 +116,53 @@ export function ProviderCard({
return (
<div
className={cn(
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
"rounded-lg bg-card p-4 shadow-sm",
"transition-[border-color,background-color,box-shadow,ring] duration-200",
isCurrent
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
: "hover:scale-[1.01]",
? "border border-border-default bg-primary/5 ring-2 ring-blue-500/30 dark:ring-blue-400/30"
: "border border-border-default hover:border-border-hover",
dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
"cursor-grabbing border-active border-border-dragging shadow-lg",
)}
>
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
<button
type="button"
<div
className={cn(
"-ml-1.5 flex-shrink-0 cursor-grab active:cursor-grabbing p-1.5",
"text-muted-foreground/50 hover:text-muted-foreground transition-colors",
dragHandleProps?.isDragging && "cursor-grabbing",
"flex items-center gap-1 overflow-hidden",
"transition-[max-width,opacity] duration-200 ease-in-out",
isEditMode ? "max-w-20 opacity-100" : "max-w-0 opacity-0",
)}
aria-label={t("provider.dragHandle")}
{...(dragHandleProps?.attributes ?? {})}
{...(dragHandleProps?.listeners ?? {})}
aria-hidden={!isEditMode}
>
<GripVertical className="h-4 w-4" />
</button>
<Button
type="button"
size="icon"
variant="ghost"
className={cn(
"flex-shrink-0 cursor-grab active:cursor-grabbing",
dragHandleProps?.isDragging && "cursor-grabbing",
)}
aria-label={t("provider.dragHandle")}
disabled={!isEditMode}
{...(dragHandleProps?.attributes ?? {})}
{...(dragHandleProps?.listeners ?? {})}
>
<MoveVertical className="h-4 w-4" />
</Button>
{/* 供应商图标 */}
<div className="h-9 w-9 rounded-lg bg-white/5 flex items-center justify-center border border-gray-200 dark:border-white/10 group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
name={provider.name}
color={provider.iconColor}
size={26}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="flex-shrink-0"
onClick={() => onDuplicate(provider)}
disabled={!isEditMode}
aria-label={t("provider.duplicate")}
title={t("provider.duplicate")}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
@@ -195,28 +210,23 @@ export function ProviderCard({
</div>
</div>
<div className="relative flex items-center ml-auto">
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[12.25rem] group-focus-within:-translate-x-[12.25rem] sm:group-hover:-translate-x-[14.25rem] sm:group-focus-within:-translate-x-[14.25rem]">
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={true}
/>
</div>
<div className="flex items-center gap-3">
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={true}
/>
<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">
<ProviderActions
isCurrent={isCurrent}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
/>
</div>
<ProviderActions
isCurrent={isCurrent}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
/>
</div>
</div>
</div>

View File

@@ -16,6 +16,7 @@ interface ProviderListProps {
providers: Record<string, Provider>;
currentProviderId: string;
appId: AppId;
isEditMode?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -30,6 +31,7 @@ export function ProviderList({
providers,
currentProviderId,
appId,
isEditMode = false,
onSwitch,
onEdit,
onDelete,
@@ -71,16 +73,14 @@ export function ProviderList({
items={sortedProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy}
>
<div
className="space-y-3 animate-slide-up"
style={{ animationDelay: "0.1s" }}
>
<div className="space-y-3">
{sortedProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isEditMode={isEditMode}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
@@ -99,6 +99,7 @@ interface SortableProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isEditMode: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -111,6 +112,7 @@ function SortableProviderCard({
provider,
isCurrent,
appId,
isEditMode,
onSwitch,
onEdit,
onDelete,
@@ -138,6 +140,7 @@ function SortableProviderCard({
provider={provider}
isCurrent={isCurrent}
appId={appId}
isEditMode={isEditMode}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}

View File

@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import {
FormControl,
FormField,
@@ -8,17 +7,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { ProviderIcon } from "@/components/ProviderIcon";
import { IconPicker } from "@/components/IconPicker";
import { getIconMetadata } from "@/icons/extracted/metadata";
import type { UseFormReturn } from "react-hook-form";
import type { ProviderFormData } from "@/lib/schemas/provider";
@@ -28,115 +16,22 @@ interface BasicFormFieldsProps {
export function BasicFormFields({ form }: BasicFormFieldsProps) {
const { t } = useTranslation();
const [iconDialogOpen, setIconDialogOpen] = useState(false);
const currentIcon = form.watch("icon");
const currentIconColor = form.watch("iconColor");
const providerName = form.watch("name") || "Provider";
const effectiveIconColor =
currentIconColor ||
(currentIcon ? getIconMetadata(currentIcon)?.defaultColor : undefined);
const handleIconSelect = (icon: string) => {
const meta = getIconMetadata(icon);
form.setValue("icon", icon);
form.setValue("iconColor", meta?.defaultColor ?? "");
};
return (
<>
{/* 图标选择区域 - 顶部居中,可选 */}
<div className="flex justify-center mb-6">
<Dialog open={iconDialogOpen} onOpenChange={setIconDialogOpen}>
<DialogTrigger asChild>
<button
type="button"
className="w-20 h-20 p-3 rounded-xl border-2 border-gray-300 dark:border-gray-600 hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center"
title={currentIcon ? "点击更换图标" : "点击选择图标"}
>
<ProviderIcon
icon={currentIcon}
name={providerName}
color={effectiveIconColor}
size={48}
/>
</button>
</DialogTrigger>
<DialogContent
variant="fullscreen"
zIndex="top"
overlayClassName="bg-[hsl(var(--background))] backdrop-blur-0"
className="p-0 sm:rounded-none"
>
<div className="flex h-full flex-col">
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<DialogClose asChild>
<Button type="button" variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</DialogClose>
<p className="text-lg font-semibold leading-tight">
{t("providerIcon.selectIcon", {
defaultValue: "选择图标",
})}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-6 mx-auto max-w-[56rem] px-6 py-6 w-full">
<IconPicker
value={currentIcon}
onValueChange={handleIconSelect}
color={effectiveIconColor}
/>
<div className="flex justify-end gap-2">
<DialogClose asChild>
<Button type="button" variant="outline">
{t("common.done", { defaultValue: "完成" })}
</Button>
</DialogClose>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* 基础信息 - 网格布局 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.name")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.namePlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.notes")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("provider.notesPlaceholder")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.name")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.namePlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
@@ -151,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.notes")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View File

@@ -1,9 +1,14 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
interface CodexCommonConfigModalProps {
isOpen: boolean;
@@ -25,30 +30,47 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("codexConfig.editCommonConfigTitle")}
onClose={onClose}
footer={
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t("codexConfig.editCommonConfigTitle")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("codexConfig.commonConfigHint")}
</p>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
@@ -56,30 +78,8 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("codexConfig.commonConfigHint")}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
darkMode={isDarkMode}
rows={16}
showValidation={false}
language="javascript"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import JsonEditor from "@/components/JsonEditor";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface CodexAuthSectionProps {
value: string;
@@ -19,27 +21,23 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const handleFormat = () => {
if (!value.trim()) return;
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleChange = (newValue: string) => {
onChange(newValue);
if (onBlur) {
onBlur();
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
@@ -52,19 +50,39 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
{t("codexConfig.authJson")}
</label>
<JsonEditor
<textarea
id="codexAuth"
value={value}
onChange={handleChange}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={t("codexConfig.authJsonPlaceholder")}
darkMode={isDarkMode}
rows={6}
showValidation={true}
language="json"
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -98,22 +116,6 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<div className="space-y-2">
@@ -152,14 +154,22 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</p>
)}
<JsonEditor
<textarea
id="codexConfig"
value={value}
onChange={onChange}
onChange={(e) => onChange(e.target.value)}
placeholder=""
darkMode={isDarkMode}
rows={8}
showValidation={false}
language="javascript"
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{configError && (

View File

@@ -1,10 +1,16 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { Save, Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface CommonConfigEditorProps {
value: string;
@@ -32,22 +38,44 @@ export function CommonConfigEditor({
onModalClose,
}: CommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const handleFormatMain = () => {
if (!value.trim()) return;
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const handleFormatModal = () => {
if (!commonConfigSnippet.trim()) return;
return () => observer.disconnect();
}, []);
try {
const formatted = formatJSON(commonConfigSnippet);
onCommonConfigSnippetChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<>
@@ -87,30 +115,90 @@ export function CommonConfigEditor({
{commonConfigError}
</p>
)}
<JsonEditor
<textarea
id="settingsConfig"
value={value}
onChange={onChange}
onChange={(e) => onChange(e.target.value)}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
darkMode={isDarkMode}
rows={14}
showValidation={true}
language="json"
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[16rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormatMain}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
</div>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
onClose={onModalClose}
footer={
<>
<Dialog
open={isModalOpen}
onOpenChange={(open) => !open && onModalClose()}
>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>
{t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<textarea
value={commonConfigSnippet}
onChange={(e) => onCommonConfigSnippetChange(e.target.value)}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[14rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormatModal}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
@@ -118,35 +206,9 @@ export function CommonConfigEditor({
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
}
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</FullScreenPanel>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -5,7 +5,13 @@ import type { AppId } from "@/lib/api";
import { vscodeApi } from "@/lib/api/vscode";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒)
@@ -425,218 +431,211 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
onClose();
}, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);
if (!visible) return null;
const footer = (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={(event) => {
event.preventDefault();
onClose();
}}
disabled={isSaving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t("common.saving")}
</>
) : (
<>
<Save className="w-4 h-4" />
{t("common.save")}
</>
)}
</Button>
</div>
);
return (
<FullScreenPanel
isOpen={visible}
title={t("endpointTest.title")}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col gap-6">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{entries.length} {t("endpointTest.endpoints")}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(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"
/>
{t("endpointTest.autoSelect")}
</label>
<Button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
size="sm"
className="h-7 w-24 gap-1.5 text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</Button>
</div>
</div>
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[80vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t("endpointTest.title")}</DialogTitle>
</DialogHeader>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1"
/>
<Button
type="button"
onClick={handleAddEndpoint}
variant="outline"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{entries.length} {t("endpointTest.endpoints")}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-border-default "
/>
{t("endpointTest.autoSelect")}
</label>
<Button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
size="sm"
className="h-7 w-20 gap-1.5 text-xs"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</Button>
</div>
</div>
{addError && (
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1"
/>
<Button
type="button"
onClick={handleAddEndpoint}
variant="outline"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-border-default bg-white hover:border-border-default hover:bg-gray-50 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-800"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-green-600 dark:text-green-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border-default bg-gray-50 py-8 text-center text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
{t("endpointTest.noEndpoints")}
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
{lastError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-primary/70 bg-primary/5 shadow-sm"
: "border-border-default bg-background hover:bg-muted"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-emerald-600 dark:text-emerald-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">
{entry.status
? t("endpointTest.status", { code: entry.status })
: t("endpointTest.notTested")}
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border-default bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{t("endpointTest.empty")}
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</div>
</FullScreenPanel>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSaving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t("common.saving")}
</>
) : (
<>
<Save className="w-4 h-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,9 +1,16 @@
import React, { useEffect, useState } from "react";
import { Save } from "lucide-react";
import React from "react";
import { Save, Wand2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
import { formatJSON } from "@/utils/formatters";
interface GeminiCommonConfigModalProps {
isOpen: boolean;
@@ -21,32 +28,86 @@ export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
> = ({ isOpen, onClose, value, onChange, error }) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const handleFormat = () => {
if (!value.trim()) return;
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<FullScreenPanel
isOpen={isOpen}
title={t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
onClose={onClose}
footer={
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>
{t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
})}
</p>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`{
"timeout": 30000,
"maxRetries": 3,
"customField": "value"
}`}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
@@ -54,35 +115,8 @@ export const GeminiCommonConfigModal: React.FC<
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
})}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"timeout": 30000,
"maxRetries": 3,
"customField": "value"
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import JsonEditor from "@/components/JsonEditor";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface GeminiEnvSectionProps {
value: string;
@@ -19,27 +21,27 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const handleFormat = () => {
if (!value.trim()) return;
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleChange = (newValue: string) => {
onChange(newValue);
if (onBlur) {
onBlur();
try {
// 重新格式化 .env 内容
const formatted = value
.split("\n")
.filter((line) => line.trim())
.join("\n");
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
@@ -52,21 +54,41 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<JsonEditor
<textarea
id="geminiEnv"
value={value}
onChange={handleChange}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-3-pro-preview`}
darkMode={isDarkMode}
rows={6}
showValidation={false}
language="javascript"
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -102,22 +124,25 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const handleFormat = () => {
if (!value.trim()) return;
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<div className="space-y-2">
@@ -162,22 +187,43 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
</p>
)}
<JsonEditor
<textarea
id="geminiConfig"
value={value}
onChange={onChange}
onChange={(e) => onChange(e.target.value)}
placeholder={`{
"timeout": 30000,
"maxRetries": 3
}`}
darkMode={isDarkMode}
rows={8}
showValidation={true}
language="json"
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
)}
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">
{configError}
</p>
)}
</div>
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">

View File

@@ -78,8 +78,6 @@ interface ProviderFormProps {
settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
icon?: string;
iconColor?: string;
};
showButtons?: boolean;
}
@@ -149,8 +147,6 @@ export function ProviderForm({
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
[initialData, appId],
);
@@ -655,7 +651,7 @@ export function ProviderForm({
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6 glass rounded-xl p-6 border border-white/10"
className="space-y-6"
>
{/* 预设供应商选择(仅新增模式显示) */}
{!initialData && (

View File

@@ -99,7 +99,7 @@ export function ProviderPresetSelector({
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
}
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
};
// 获取预设按钮的内联样式(用于自定义背景色)
@@ -128,7 +128,7 @@ export function ProviderPresetSelector({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPresetId === "custom"
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-accent text-muted-foreground hover:bg-accent/80"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t("providerPreset.custom")}

View File

@@ -44,73 +44,66 @@ export function ImportExportSection({
return (
<section className="space-y-4">
<header className="space-y-2">
<h3 className="text-base font-semibold text-foreground">
{t("settings.importExport")}
</h3>
<p className="text-sm text-muted-foreground">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.importExport")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.importExportHint")}
</p>
</header>
<div className="space-y-4 rounded-xl glass-card p-6 border border-white/10">
{/* Import and Export Buttons Side by Side */}
<div className="grid grid-cols-2 gap-4 items-stretch">
{/* Import Button */}
<div className="relative">
<div className="space-y-3 rounded-lg border border-border-default p-4">
<Button
type="button"
className="w-full"
variant="secondary"
onClick={onExport}
>
<Save className="mr-2 h-4 w-4" />
{t("settings.exportConfig")}
</Button>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
className={`w-full h-auto py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white ${selectedFile && !isImporting ? "flex-col items-start" : "items-center"}`}
onClick={!selectedFile ? onSelectFile : onImport}
disabled={isImporting}
variant="outline"
className="flex-1 min-w-[180px]"
onClick={onSelectFile}
>
<div className="flex items-center gap-2 w-full justify-center">
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
) : selectedFile ? (
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
) : (
<FolderOpen className="h-4 w-4 flex-shrink-0" />
)}
<span className="font-medium">
{isImporting
? t("settings.importing")
: selectedFile
? t("settings.import")
: t("settings.selectConfigFile")}
<FolderOpen className="mr-2 h-4 w-4" />
{t("settings.selectConfigFile")}
</Button>
<Button
type="button"
disabled={!selectedFile || isImporting}
onClick={onImport}
>
{isImporting ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.importing")}
</span>
</div>
{selectedFile && !isImporting && (
<div className="mt-2 w-full text-left">
<p className="text-xs font-mono text-white/80 truncate">
📄 {selectedFileName}
</p>
</div>
) : (
t("settings.import")
)}
</Button>
{selectedFile && (
<button
type="button"
onClick={onClear}
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
aria-label="Clear selection"
>
<XCircle className="h-4 w-4" />
</button>
)}
{selectedFile ? (
<Button type="button" variant="ghost" onClick={onClear}>
<XCircle className="mr-2 h-4 w-4" />
{t("common.clear")}
</Button>
) : null}
</div>
{/* Export Button */}
<div>
<Button
type="button"
className="w-full h-full py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white items-center"
onClick={onExport}
>
<Save className="mr-2 h-4 w-4" />
{t("settings.exportConfig")}
</Button>
</div>
{selectedFile ? (
<p className="truncate rounded-md bg-muted/40 px-3 py-2 text-xs font-mono text-muted-foreground">
{selectedFileName}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.noFileSelected")}
</p>
)}
</div>
<ImportStatusMessage
@@ -141,19 +134,15 @@ function ImportStatusMessage({
}
const baseClass =
"flex items-start gap-3 rounded-xl border p-4 text-sm leading-relaxed backdrop-blur-sm";
"flex items-start gap-2 rounded-md border px-3 py-2 text-xs leading-relaxed";
if (status === "importing") {
return (
<div
className={`${baseClass} border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400`}
>
<Loader2 className="mt-0.5 h-5 w-5 flex-shrink-0 animate-spin" />
<div className={`${baseClass} border-border-default bg-muted/40`}>
<Loader2 className="mt-0.5 h-4 w-4 animate-spin text-muted-foreground" />
<div>
<p className="font-semibold">{t("settings.importing")}</p>
<p className="text-blue-600/80 dark:text-blue-400/80">
{t("common.loading")}
</p>
<p className="font-medium">{t("settings.importing")}</p>
<p className="text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
@@ -162,19 +151,17 @@ function ImportStatusMessage({
if (status === "success") {
return (
<div
className={`${baseClass} border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400`}
className={`${baseClass} border-green-200 bg-green-100/70 text-green-700`}
>
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importSuccess")}</p>
<CheckCircle2 className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importSuccess")}</p>
{backupId ? (
<p className="text-xs text-green-600/80 dark:text-green-400/80">
<p className="text-xs">
{t("settings.backupId")}: {backupId}
</p>
) : null}
<p className="text-green-600/80 dark:text-green-400/80">
{t("settings.autoReload")}
</p>
<p>{t("settings.autoReload")}</p>
</div>
</div>
);
@@ -183,14 +170,12 @@ function ImportStatusMessage({
if (status === "partial-success") {
return (
<div
className={`${baseClass} border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400`}
className={`${baseClass} border-yellow-200 bg-yellow-100/70 text-yellow-700`}
>
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importPartialSuccess")}</p>
<p className="text-yellow-600/80 dark:text-yellow-400/80">
{t("settings.importPartialHint")}
</p>
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importPartialSuccess")}</p>
<p>{t("settings.importPartialHint")}</p>
</div>
</div>
);
@@ -199,13 +184,11 @@ function ImportStatusMessage({
const message = errorMessage || t("settings.importFailed");
return (
<div
className={`${baseClass} border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400`}
>
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importFailed")}</p>
<p className="text-red-600/80 dark:text-red-400/80">{message}</p>
<div className={`${baseClass} border-red-200 bg-red-100/70 text-red-600`}>
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importFailed")}</p>
<p>{message}</p>
</div>
</div>
);

View File

@@ -0,0 +1,295 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImportSuccess?: () => void | Promise<void>;
}
export function SettingsDialog({
open,
onOpenChange,
onImportSuccess,
}: SettingsDialogProps) {
const { t } = useTranslation();
const {
settings,
isLoading,
isSaving,
isPortable,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
saveSettings,
resetSettings,
requiresRestart,
acknowledgeRestart,
} = useSettings();
const {
selectedFile,
status: importStatus,
errorMessage,
backupId,
isImporting,
selectImportFile,
importConfig,
exportConfig,
clearSelection,
resetStatus,
} = useImportExport({ onImportSuccess });
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
useEffect(() => {
if (open) {
setActiveTab("general");
resetStatus();
}
}, [open, resetStatus]);
useEffect(() => {
if (requiresRestart) {
setShowRestartPrompt(true);
}
}, [requiresRestart]);
const closeDialog = useCallback(() => {
// 取消/直接关闭:恢复到初始设置(包括语言回滚)
resetSettings();
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [
acknowledgeRestart,
clearSelection,
onOpenChange,
resetSettings,
resetStatus,
]);
const closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
const handleDialogChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
closeDialog();
} else {
onOpenChange(true);
}
},
[closeDialog, onOpenChange],
);
const handleCancel = useCallback(() => {
closeDialog();
}, [closeDialog]);
const handleSave = useCallback(async () => {
try {
const result = await saveSettings();
if (!result) return;
if (result.requiresRestart) {
setShowRestartPrompt(true);
return;
}
closeAfterSave();
} catch (error) {
console.error("[SettingsDialog] Failed to save settings", error);
}
}, [closeDialog, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeAfterSave();
}, [closeAfterSave]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
closeAfterSave();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsDialog] Failed to restart app", error);
toast.error(t("settings.restartFailed"));
} finally {
closeAfterSave();
}
}, [closeAfterSave, t]);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
{isBusy ? (
<div className="flex min-h-[320px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 overflow-y-auto px-6 py-4">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<TabsContent
value="general"
className="space-y-6 mt-6 min-h-[400px]"
>
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => updateSettings({ language: lang })}
/>
<ThemeSettings />
<WindowSettings
settings={settings}
onChange={updateSettings}
/>
</>
) : null}
</TabsContent>
<TabsContent
value="advanced"
className="space-y-6 mt-6 min-h-[400px]"
>
{settings ? (
<>
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
</>
) : null}
</TabsContent>
<TabsContent value="about" className="mt-6 min-h-[400px]">
<AboutSection isPortable={isPortable} />
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={isSaving || isBusy}>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
<Dialog
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent zIndex="alert" className="max-w-md">
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
<div className="px-6">
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage")}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleRestartLater}>
{t("settings.restartLater")}
</Button>
<Button onClick={handleRestartNow}>
{t("settings.restartNow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
);
}

View File

@@ -1,284 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImportSuccess?: () => void | Promise<void>;
}
export function SettingsPage({
open,
onOpenChange,
onImportSuccess,
}: SettingsDialogProps) {
const { t } = useTranslation();
const {
settings,
isLoading,
isSaving,
isPortable,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
saveSettings,
autoSaveSettings,
requiresRestart,
acknowledgeRestart,
} = useSettings();
const {
selectedFile,
status: importStatus,
errorMessage,
backupId,
isImporting,
selectImportFile,
importConfig,
exportConfig,
clearSelection,
resetStatus,
} = useImportExport({ onImportSuccess });
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
useEffect(() => {
if (open) {
setActiveTab("general");
resetStatus();
}
}, [open, resetStatus]);
useEffect(() => {
if (requiresRestart) {
setShowRestartPrompt(true);
}
}, [requiresRestart]);
const closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
const handleSave = useCallback(async () => {
try {
const result = await saveSettings(undefined, { silent: false });
if (!result) return;
if (result.requiresRestart) {
setShowRestartPrompt(true);
return;
}
closeAfterSave();
} catch (error) {
console.error("[SettingsPage] Failed to save settings", error);
}
}, [closeAfterSave, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeAfterSave();
}, [closeAfterSave]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
closeAfterSave();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsPage] Failed to restart app", error);
toast.error(t("settings.restartFailed"));
} finally {
closeAfterSave();
}
}, [closeAfterSave, t]);
// 通用设置即时保存(无需手动点击)
// 使用 autoSaveSettings 避免误触发系统 API开机自启、Claude 插件等)
const handleAutoSave = useCallback(
async (updates: Partial<SettingsFormState>) => {
if (!settings) return;
updateSettings(updates);
try {
await autoSaveSettings(updates);
} catch (error) {
console.error("[SettingsPage] Failed to autosave settings", error);
toast.error(
t("settings.saveFailedGeneric", {
defaultValue: "保存失败,请重试",
}),
);
}
},
[autoSaveSettings, settings, t, updateSettings],
);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3 mb-6 glass rounded-xl">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto pr-2">
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })}
/>
<ThemeSettings />
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
</>
) : null}
</TabsContent>
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? (
<>
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
<div className="pt-6 border-t border-gray-200 dark:border-white/10">
<Button
onClick={handleSave}
className="w-full"
disabled={isSaving}
>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</div>
</>
) : null}
</TabsContent>
<TabsContent value="about" className="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
</div>
</Tabs>
)}
<Dialog
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent
zIndex="alert"
className="max-w-md glass border-white/10"
>
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
<div className="px-6">
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage")}
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={handleRestartLater}
className="hover:bg-white/5"
>
{t("settings.restartLater")}
</Button>
<Button
onClick={handleRestartNow}
className="bg-primary hover:bg-primary/90"
>
{t("settings.restartNow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -19,13 +19,6 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
</p>
</header>
<ToggleRow
title={t("settings.launchOnStartup")}
description={t("settings.launchOnStartupDescription")}
checked={!!settings.launchOnStartup}
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow
title={t("settings.minimizeToTray")}
description={t("settings.minimizeToTrayDescription")}

View File

@@ -1,219 +0,0 @@
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 { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Skill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps {
repos: SkillRepo[];
skills: Skill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
onClose: () => void;
}
export function RepoManagerPanel({
repos,
skills,
onAdd,
onRemove,
onClose,
}: RepoManagerPanelProps) {
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
skills.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
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("skills.repo.invalidUrl"));
return;
}
try {
await onAdd({
owner: parsed.owner,
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined,
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
};
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("skills.repo.title")}
onClose={onClose}
>
{/* 添加仓库表单 */}
<div className="space-y-4 glass rounded-xl p-6 border border-white/10">
<h3 className="text-base font-semibold text-foreground">
</h3>
<div className="space-y-4">
<div>
<Label htmlFor="repo-url" className="text-foreground">
{t("skills.repo.url")}
</Label>
<Input
id="repo-url"
placeholder={t("skills.repo.urlPlaceholder")}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
className="mt-2"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="skills-path" className="text-foreground">
{t("skills.repo.path")}
</Label>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="mt-2"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<Button
onClick={handleAdd}
className="bg-primary text-primary-foreground hover:bg-primary/90"
type="button"
>
<Plus className="h-4 w-4 mr-2" />
{t("skills.repo.add")}
</Button>
</div>
</div>
{/* 仓库列表 */}
<div className="space-y-4">
<h3 className="text-base font-semibold text-foreground">
{t("skills.repo.list")}
</h3>
{repos.length === 0 ? (
<div className="text-center py-12 glass rounded-xl border border-white/10">
<p className="text-sm text-muted-foreground">
{t("skills.repo.empty")}
</p>
</div>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div
key={`${repo.owner}/${repo.name}`}
className="flex items-center justify-between rounded-xl border border-white/10 bg-gray-900/40 px-4 py-3"
>
<div>
<div className="text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
})}
</span>
</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={() => onRemove(repo.owner, repo.name)}
title={t("common.delete")}
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>
);
}

View File

@@ -57,8 +57,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="glass flex flex-col h-full border border-white/10 bg-gray-900/40 transition-all duration-300 hover:bg-gray-900/60 hover:border-white/20 hover:shadow-lg group relative overflow-hidden">
<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" />
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
@@ -96,7 +95,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
{skill.description || t("skills.noDescription")}
</p>
</CardContent>
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
<CardFooter className="flex gap-2 pt-3 border-t border-border-default">
{skill.readmeUrl && (
<Button
variant="ghost"

View File

@@ -1,222 +1,190 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import { RefreshCw, Settings } from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import { RepoManager } from "./RepoManager";
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
onClose?: () => void;
}
export interface SkillsPageHandle {
refresh: () => void;
openRepoManager: () => void;
}
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose }, ref) => {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll();
setSkills(data);
if (afterLoad) {
afterLoad(data);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 传入 "skills.loadFailed" 作为标题
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.loadFailed",
);
toast.error(title, {
description,
duration: 8000,
});
console.error("Load skills failed:", error);
} finally {
setLoading(false);
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll();
setSkills(data);
if (afterLoad) {
afterLoad(data);
}
};
} catch (error) {
toast.error(t("skills.loadFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
} finally {
setLoading(false);
}
};
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
}, []);
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
}, []);
useImperativeHandle(ref, () => ({
refresh: () => loadSkills(),
openRepoManager: () => setRepoManagerOpen(true),
}));
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory);
toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills();
} catch (error) {
toast.error(t("skills.installFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
}
};
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory);
toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory);
toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills();
} catch (error) {
toast.error(t("skills.uninstallFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
}
};
// 使用错误解析器格式化错误,传入 "skills.installFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.installFailed",
);
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
toast.error(title, {
description,
duration: 10000, // 延长显示时间让用户看清
});
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
console.error("Install skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
);
};
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory);
toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }));
await Promise.all([loadRepos(), loadSkills()]);
};
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.uninstallFailed",
);
toast.error(title, {
description,
duration: 10000,
});
console.error("Uninstall skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }));
await Promise.all([loadRepos(), loadSkills()]);
};
return (
<div className="flex flex-col h-full min-h-0 bg-background/50">
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto animate-fade-in">
<div className="mx-auto max-w-[56rem] px-6 py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.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("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)}
return (
<div className="flex flex-col h-full min-h-0 bg-background">
{/* 顶部操作栏(固定区域) */}
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between pr-8">
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
{t("skills.title")}
</h1>
<div className="flex gap-2">
<Button
variant="mcp"
size="sm"
onClick={() => loadSkills()}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
{loading ? t("skills.refreshing") : t("skills.refresh")}
</Button>
<Button
variant="mcp"
size="sm"
onClick={() => setRepoManagerOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
</div>
</div>
{/* 仓库管理面板 */}
{repoManagerOpen && (
<RepoManagerPanel
repos={repos}
skills={skills}
onAdd={handleAddRepo}
onRemove={handleRemoveRepo}
onClose={() => setRepoManagerOpen(false)}
/>
{/* 描述 */}
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
{t("skills.description")}
</p>
</div>
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.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("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)}
</div>
);
},
);
SkillsPage.displayName = "SkillsPage";
{/* 仓库管理对话框 */}
<RepoManager
open={repoManagerOpen}
onOpenChange={setRepoManagerOpen}
repos={repos}
skills={skills}
onAdd={handleAddRepo}
onRemove={handleRemoveRepo}
/>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
@@ -13,14 +14,13 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
zIndex?: "base" | "nested" | "alert" | "top";
zIndex?: "base" | "nested" | "alert";
}
>(({ className, zIndex = "base", ...props }, ref) => {
const zIndexMap = {
base: "z-40",
nested: "z-50",
alert: "z-[60]",
top: "z-[110]",
};
return (
@@ -40,54 +40,40 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
zIndex?: "base" | "nested" | "alert" | "top";
variant?: "default" | "fullscreen";
overlayClassName?: string;
zIndex?: "base" | "nested" | "alert";
}
>(
(
{
className,
children,
zIndex = "base",
variant = "default",
overlayClassName,
...props
},
ref,
) => {
const zIndexMap = {
base: "z-40",
nested: "z-50",
alert: "z-[60]",
top: "z-[110]",
};
>(({ className, children, zIndex = "base", ...props }, ref) => {
const zIndexMap = {
base: "z-40",
nested: "z-50",
alert: "z-[60]",
};
const variantClass = {
default:
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-background text-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
fullscreen:
"fixed inset-0 flex flex-col w-screen h-screen translate-x-0 translate-y-0 bg-background text-foreground p-0 sm:rounded-none shadow-none",
}[variant];
return (
<DialogPortal>
<DialogOverlay zIndex={zIndex} className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(variantClass, zIndexMap[zIndex], className)}
onInteractOutside={(e) => {
// 防止点击遮罩层关闭对话框
e.preventDefault();
}}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
);
},
);
return (
<DialogPortal>
<DialogOverlay zIndex={zIndex} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-white dark:bg-gray-900 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
zIndexMap[zIndex],
className,
)}
onInteractOutside={(e) => {
// 防止点击遮罩层关闭对话框
e.preventDefault();
}}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({

View File

@@ -40,9 +40,6 @@ export interface ProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const providerPresets: ProviderPreset[] = [
@@ -59,8 +56,6 @@ export const providerPresets: ProviderPreset[] = [
backgroundColor: "#D97757",
textColor: "#FFFFFF",
},
icon: "anthropic",
iconColor: "#D4915D",
},
{
name: "DeepSeek",

View File

@@ -1,73 +0,0 @@
/**
* 根据供应商名称智能推断图标配置
*/
const iconMappings = {
// AI 服务商
claude: { icon: "claude", iconColor: "#D4915D" },
anthropic: { icon: "anthropic", iconColor: "#D4915D" },
deepseek: { icon: "deepseek", iconColor: "#1E88E5" },
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
glm: { icon: "zhipu", iconColor: "#0F62FE" },
qwen: { icon: "qwen", iconColor: "#FF6A00" },
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
kimi: { icon: "kimi", iconColor: "#6366F1" },
moonshot: { icon: "moonshot", iconColor: "#6366F1" },
baidu: { icon: "baidu", iconColor: "#2932E1" },
tencent: { icon: "tencent", iconColor: "#00A4FF" },
hunyuan: { icon: "hunyuan", iconColor: "#00A4FF" },
minimax: { icon: "minimax", iconColor: "#FF6B6B" },
google: { icon: "google", iconColor: "#4285F4" },
meta: { icon: "meta", iconColor: "#0081FB" },
mistral: { icon: "mistral", iconColor: "#FF7000" },
cohere: { icon: "cohere", iconColor: "#39594D" },
perplexity: { icon: "perplexity", iconColor: "#20808D" },
huggingface: { icon: "huggingface", iconColor: "#FFD21E" },
// 云平台
aws: { icon: "aws", iconColor: "#FF9900" },
azure: { icon: "azure", iconColor: "#0078D4" },
huawei: { icon: "huawei", iconColor: "#FF0000" },
cloudflare: { icon: "cloudflare", iconColor: "#F38020" },
};
/**
* 根据预设名称推断图标
*/
export function inferIconForPreset(presetName: string): {
icon?: string;
iconColor?: string;
} {
const nameLower = presetName.toLowerCase();
// 精确匹配或模糊匹配
for (const [key, config] of Object.entries(iconMappings)) {
if (nameLower.includes(key)) {
return config;
}
}
return {};
}
/**
* 批量为预设添加图标配置
*/
export function addIconsToPresets<
T extends { name: string; icon?: string; iconColor?: string },
>(presets: T[]): T[] {
return presets.map((preset) => {
// 如果已经配置了图标,则保留原配置
if (preset.icon) {
return preset;
}
// 否则根据名称推断
const inferred = inferIconForPreset(preset.name);
return {
...preset,
...inferred,
};
});
}

View File

@@ -33,13 +33,7 @@ export interface UseSettingsResult {
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
saveSettings: (
overrides?: Partial<SettingsFormState>,
options?: { silent?: boolean },
) => Promise<SaveResult | null>;
autoSaveSettings: (
updates: Partial<SettingsFormState>,
) => Promise<SaveResult | null>;
saveSettings: () => Promise<SaveResult | null>;
resetSettings: () => void;
acknowledgeRestart: () => void;
}
@@ -120,220 +114,97 @@ export function useSettings(): UseSettingsResult {
setRequiresRestart,
]);
// 即时保存设置(用于 General 标签页的实时更新)
// 保存基础配置 + 独立的系统 API 调用(开机自启)
const autoSaveSettings = useCallback(
async (updates: Partial<SettingsFormState>): Promise<SaveResult | null> => {
const mergedSettings = settings ? { ...settings, ...updates } : null;
if (!mergedSettings) return null;
// 保存设置
const saveSettings = useCallback(async (): Promise<SaveResult | null> => {
if (!settings) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(settings.geminiConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const payload: Settings = {
...settings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: settings.language,
};
await saveMutation.mutateAsync(payload);
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
try {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: mergedSettings.language,
};
// 保存到配置文件
await saveMutation.mutateAsync(payload);
// 如果开机自启状态改变,调用系统 API
if (
payload.launchOnStartup !== undefined &&
payload.launchOnStartup !== data?.launchOnStartup
) {
try {
await settingsApi.setAutoLaunch(payload.launchOnStartup);
} catch (error) {
console.error("Failed to update auto-launch:", error);
toast.error(
t("settings.autoLaunchFailed", {
defaultValue: "设置开机自启失败",
}),
);
}
if (payload.enableClaudePluginIntegration) {
await settingsApi.applyClaudePluginConfig({ official: false });
} else {
await settingsApi.applyClaudePluginConfig({ official: true });
}
// 持久化语言偏好
try {
if (typeof window !== "undefined" && updates.language) {
window.localStorage.setItem("language", updates.language);
}
} catch (error) {
console.warn(
"[useSettings] Failed to persist language preference",
error,
);
}
// 更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
return { requiresRestart: false };
} catch (error) {
console.error("[useSettings] Failed to auto-save settings", error);
console.warn(
"[useSettings] Failed to sync Claude plugin config",
error,
);
toast.error(
t("notifications.settingsSaveFailed", {
defaultValue: "保存设置失败: {{error}}",
error: (error as Error)?.message ?? String(error),
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
}),
);
throw error;
}
},
[data, saveMutation, settings, t],
);
// 完整保存设置(用于 Advanced 标签页的手动保存)
// 包含所有系统 API 调用和完整的验证流程
const saveSettings = useCallback(
async (
overrides?: Partial<SettingsFormState>,
options?: { silent?: boolean },
): Promise<SaveResult | null> => {
const mergedSettings = settings ? { ...settings, ...overrides } : null;
if (!mergedSettings) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: mergedSettings.language,
};
await saveMutation.mutateAsync(payload);
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
// 只在开机自启状态真正改变时调用系统 API
if (
payload.launchOnStartup !== undefined &&
payload.launchOnStartup !== data?.launchOnStartup
) {
try {
await settingsApi.setAutoLaunch(payload.launchOnStartup);
} catch (error) {
console.error("Failed to update auto-launch:", error);
toast.error(
t("settings.autoLaunchFailed", {
defaultValue: "设置开机自启失败",
}),
);
}
if (typeof window !== "undefined") {
window.localStorage.setItem("language", payload.language as Language);
}
// 只在 Claude 插件集成状态真正改变时调用系统 API
if (
payload.enableClaudePluginIntegration !== undefined &&
payload.enableClaudePluginIntegration !==
data?.enableClaudePluginIntegration
) {
try {
if (payload.enableClaudePluginIntegration) {
await settingsApi.applyClaudePluginConfig({ official: false });
} else {
await settingsApi.applyClaudePluginConfig({ official: true });
}
} catch (error) {
console.warn(
"[useSettings] Failed to sync Claude plugin config",
error,
);
toast.error(
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
}),
);
}
}
try {
if (typeof window !== "undefined") {
window.localStorage.setItem(
"language",
payload.language as Language,
);
}
} catch (error) {
console.warn(
"[useSettings] Failed to persist language preference",
error,
);
}
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
console.warn(
"[useSettings] Failed to sync current providers after directory change",
syncResult.error,
);
}
}
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
setRequiresRestart(appDirChanged);
if (!options?.silent) {
toast.success(
t("notifications.settingsSaved", {
defaultValue: "设置已保存",
}),
);
}
return { requiresRestart: appDirChanged };
} catch (error) {
console.error("[useSettings] Failed to save settings", error);
toast.error(
t("notifications.settingsSaveFailed", {
defaultValue: "保存设置失败: {{error}}",
error: (error as Error)?.message ?? String(error),
}),
console.warn(
"[useSettings] Failed to persist language preference",
error,
);
throw error;
}
},
[
appConfigDir,
data,
initialAppConfigDir,
saveMutation,
settings,
setRequiresRestart,
t,
],
);
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
console.warn(
"[useSettings] Failed to sync current providers after directory change",
syncResult.error,
);
}
}
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
setRequiresRestart(appDirChanged);
return { requiresRestart: appDirChanged };
} catch (error) {
console.error("[useSettings] Failed to save settings", error);
throw error;
}
}, [
appConfigDir,
data,
initialAppConfigDir,
saveMutation,
settings,
setRequiresRestart,
t,
]);
const isLoading = useMemo(
() => isFormLoading || isDirectoryLoading || isMetadataLoading,
@@ -356,7 +227,6 @@ export function useSettings(): UseSettingsResult {
resetDirectory,
resetAppConfigDir,
saveSettings,
autoSaveSettings,
resetSettings,
acknowledgeRestart,
};

View File

@@ -27,8 +27,7 @@
"formatSuccess": "Formatted successfully",
"formatError": "Format failed: {{error}}",
"copy": "Copy",
"view": "View",
"back": "Back"
"view": "View"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -167,9 +166,6 @@
"languageOptionEnglish": "English",
"windowBehavior": "Window Behavior",
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup",
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
"autoLaunchFailed": "Failed to set auto-launch",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"enableClaudePluginIntegration": "Apply to Claude Code extension",
@@ -318,8 +314,7 @@
"pleaseAddEndpoint": "Please add an endpoint first",
"testUnavailable": "Speed test unavailable",
"noResult": "No result returned",
"testFailed": "Speed test failed: {{error}}",
"status": "Status: {{code}}"
"testFailed": "Speed test failed: {{error}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -366,9 +361,6 @@
"title": "Configure Usage Query",
"enableUsageQuery": "Enable usage query",
"presetTemplate": "Preset template",
"requestUrl": "Request URL",
"requestUrlPlaceholder": "e.g. https://api.example.com",
"method": "HTTP method",
"templateCustom": "Custom",
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
@@ -381,14 +373,11 @@
"queryFailedMessage": "Query failed",
"queryScript": "Query script (JavaScript)",
"timeoutSeconds": "Timeout (seconds)",
"headers": "Headers",
"body": "Body",
"timeoutHint": "Range: 2-30 seconds",
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
"timeoutCannotBeNegative": "Timeout cannot be negative",
"autoIntervalMinutes": "Auto query interval (minutes)",
"autoQueryInterval": "Auto Query Interval (minutes)",
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
"autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes",
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
"intervalCannotBeNegative": "Interval cannot be negative",
"intervalAdjusted": "Interval adjusted to {{value}} minutes",
@@ -409,9 +398,6 @@
"formatSuccess": "Format successful",
"formatFailed": "Format failed",
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
"scriptConfig": "Request configuration",
"extractorCode": "Extractor code",
"extractorHint": "Return object should include remaining quota fields",
"fieldIsValid": "• isValid: Boolean, whether plan is valid",
"fieldInvalidMessage": "• invalidMessage: String, reason for expiration (shown when isValid is false)",
"fieldRemaining": "• remaining: Number, remaining quota",
@@ -689,34 +675,6 @@
"installFailed": "Failed to install",
"uninstallSuccess": "Skill {{name}} uninstalled",
"uninstallFailed": "Failed to uninstall",
"error": {
"skillNotFound": "Skill not found: {{directory}}",
"missingRepoInfo": "Missing repository info (owner or name)",
"downloadTimeout": "Download repository {{owner}}/{{name}} timeout ({{timeout}}s)",
"downloadTimeoutHint": "Please check network connection or retry later",
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
"skillDirNotFound": "Skill directory not found: {{path}}",
"emptyArchive": "Downloaded archive is empty",
"downloadFailed": "Download failed: HTTP {{status}}",
"allBranchesFailed": "All branches failed, tried: {{branches}}",
"httpError": "HTTP error {{status}}",
"http403": "GitHub access restricted, possibly rate limited",
"http404": "Repository or branch not found, please check URL",
"http429": "Too many requests, please wait and retry",
"parseMetadataFailed": "Failed to parse skill metadata",
"getHomeDirFailed": "Unable to get user home directory",
"networkError": "Network error",
"fsError": "File system error",
"unknownError": "Unknown error",
"suggestion": {
"checkNetwork": "Please check network connection",
"checkProxy": "Consider configuring HTTP proxy",
"retryLater": "Please retry later",
"checkRepoUrl": "Please check repository URL and branch name",
"checkDiskSpace": "Please check disk space",
"checkPermission": "Please check directory permissions"
}
},
"repo": {
"title": "Manage Skill Repositories",
"description": "Add or remove GitHub skill repository sources",
@@ -753,29 +711,6 @@
"parseError": "Failed to parse deep link",
"importSuccess": "Import successful",
"importSuccessDescription": "Provider \"{{name}}\" has been successfully imported",
"importError": "Failed to import",
"configSource": "Config Source",
"configEmbedded": "Embedded Config",
"configRemote": "Remote Config",
"configDetails": "Config Details",
"configUrl": "Config File URL",
"configMergeError": "Failed to merge configuration file"
},
"iconPicker": {
"search": "Search Icons",
"searchPlaceholder": "Enter icon name...",
"noResults": "No matching icons found",
"category": {
"aiProvider": "AI Providers",
"cloud": "Cloud Platforms",
"tool": "Dev Tools",
"other": "Other"
}
},
"providerIcon": {
"label": "Icon",
"colorLabel": "Icon Color",
"selectIcon": "Select Icon",
"preview": "Preview"
"importError": "Failed to import"
}
}

View File

@@ -27,8 +27,7 @@
"formatSuccess": "格式化成功",
"formatError": "格式化失败:{{error}}",
"copy": "复制",
"view": "查看",
"back": "返回"
"view": "查看"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -167,9 +166,6 @@
"languageOptionEnglish": "English",
"windowBehavior": "窗口行为",
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
"autoLaunchFailed": "设置开机自启失败",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
@@ -318,8 +314,7 @@
"pleaseAddEndpoint": "请先添加端点",
"testUnavailable": "测速功能不可用",
"noResult": "未返回结果",
"testFailed": "测速失败: {{error}}",
"status": "状态码:{{code}}"
"testFailed": "测速失败: {{error}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -366,9 +361,6 @@
"title": "配置用量查询",
"enableUsageQuery": "启用用量查询",
"presetTemplate": "预设模板",
"requestUrl": "请求地址",
"requestUrlPlaceholder": "例如https://api.example.com",
"method": "HTTP 方法",
"templateCustom": "自定义",
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
@@ -381,14 +373,11 @@
"queryFailedMessage": "查询失败",
"queryScript": "查询脚本JavaScript",
"timeoutSeconds": "超时时间(秒)",
"headers": "请求头",
"body": "请求 Body",
"timeoutHint": "范围: 2-30 秒",
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
"timeoutCannotBeNegative": "超时时间不能为负数",
"autoIntervalMinutes": "自动查询间隔(分钟)",
"autoQueryInterval": "自动查询间隔(分钟)",
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
"autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟",
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
"intervalCannotBeNegative": "自动查询间隔不能为负数",
"intervalAdjusted": "自动查询间隔已调整为 {{value}} 分钟",
@@ -409,9 +398,6 @@
"formatSuccess": "格式化成功",
"formatFailed": "格式化失败",
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
"scriptConfig": "请求配置",
"extractorCode": "提取器代码",
"extractorHint": "返回对象需包含剩余额度等字段",
"fieldIsValid": "• isValid: 布尔值,套餐是否有效",
"fieldInvalidMessage": "• invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)",
"fieldRemaining": "• remaining: 数字,剩余额度",
@@ -689,34 +675,6 @@
"installFailed": "安装失败",
"uninstallSuccess": "技能 {{name}} 已卸载",
"uninstallFailed": "卸载失败",
"error": {
"skillNotFound": "技能不存在:{{directory}}",
"missingRepoInfo": "缺少仓库信息owner 或 name",
"downloadTimeout": "下载仓库 {{owner}}/{{name}} 超时({{timeout}}秒)",
"downloadTimeoutHint": "请检查网络连接或稍后重试",
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
"skillDirNotFound": "技能目录不存在:{{path}}",
"emptyArchive": "下载的压缩包为空",
"downloadFailed": "下载失败HTTP {{status}}",
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
"httpError": "HTTP 错误 {{status}}",
"http403": "GitHub 访问受限,可能是请求频率过高",
"http404": "仓库或分支不存在,请检查地址",
"http429": "请求过于频繁,请等待后重试",
"parseMetadataFailed": "解析技能元数据失败",
"getHomeDirFailed": "无法获取用户主目录",
"networkError": "网络错误",
"fsError": "文件系统错误",
"unknownError": "未知错误",
"suggestion": {
"checkNetwork": "请检查网络连接",
"checkProxy": "建议配置 HTTP 代理",
"retryLater": "请稍后重试",
"checkRepoUrl": "请检查仓库地址和分支名称",
"checkDiskSpace": "请检查磁盘空间",
"checkPermission": "请检查目录权限"
}
},
"repo": {
"title": "管理技能仓库",
"description": "添加或删除 GitHub 技能仓库源",
@@ -753,29 +711,6 @@
"parseError": "深链接解析失败",
"importSuccess": "导入成功",
"importSuccessDescription": "供应商 \"{{name}}\" 已成功导入",
"importError": "导入失败",
"configSource": "配置来源",
"configEmbedded": "内嵌配置",
"configRemote": "远程配置",
"configDetails": "配置详情",
"configUrl": "配置文件 URL",
"configMergeError": "合并配置文件失败"
},
"iconPicker": {
"search": "搜索图标",
"searchPlaceholder": "输入图标名称...",
"noResults": "未找到匹配的图标",
"category": {
"aiProvider": "AI 服务商",
"cloud": "云平台",
"tool": "开发工具",
"other": "其他"
}
},
"providerIcon": {
"label": "图标",
"colorLabel": "图标颜色",
"selectIcon": "选择图标",
"preview": "预览"
"importError": "导入失败"
}
}

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

Before

Width:  |  Height:  |  Size: 368 B

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ByteDance</title><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z" fill="#00C8D2" fill-rule="nonzero"></path><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z" fill="#3C8CFF" fill-rule="nonzero"></path><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z" fill="#78E6DC" fill-rule="nonzero"></path><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z" fill="#325AB4"></path></svg>

Before

Width:  |  Height:  |  Size: 953 B

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ChatGLM</title><defs><linearGradient id="lobe-icons-chat-glm-fill" x1="-18.756%" x2="70.894%" y1="49.371%" y2="90.944%"><stop offset="0%" stop-color="#504AF4"></stop><stop offset="100%" stop-color="#3485FF"></stop></linearGradient></defs><path d="M9.917 2c4.906 0 10.178 3.947 8.93 10.58-.014.07-.037.14-.057.21l-.003-.277c-.083-3-1.534-8.934-8.87-8.934-3.393 0-8.137 3.054-7.93 8.158-.04 4.778 3.555 8.4 7.95 8.332l.073-.001c1.2-.033 2.763-.429 3.1-1.657.063-.031.26.534.268.598.048.256.112.369.192.34.981-.348 2.286-1.222 1.952-2.38-.176-.61-1.775-.147-1.921-.347.418-.979 2.234-.926 3.153-.716.443.102.657.38 1.012.442.29.052.981-.2.96.242-1.5 3.042-4.893 5.41-8.808 5.41C3.654 22 0 16.574 0 11.737 0 5.947 4.959 2 9.917 2zM9.9 5.3c.484 0 1.125.225 1.38.585 3.669.145 4.313 2.686 4.694 5.444.255 1.838.315 2.3.182 1.387l.083.59c.068.448.554.737.982.516.144-.075.254-.231.328-.47a.2.2 0 01.258-.13l.625.22a.2.2 0 01.124.238 2.172 2.172 0 01-.51.92c-.878.917-2.757.664-3.08-.62-.14-.554-.055-.626-.345-1.242-.292-.621-1.238-.709-1.69-.295-.345.315-.407.805-.406 1.282L12.6 15.9a.9.9 0 01-.9.9h-1.4a.9.9 0 01-.9-.9v-.65a1.15 1.15 0 10-2.3 0v.65a.9.9 0 01-.9.9H4.8a.9.9 0 01-.9-.9l.035-3.239c.012-1.884.356-3.658 2.47-4.134.2-.045.252.13.29.342.025.154.043.252.053.294.701 3.058 1.75 4.299 3.144 3.722l.66-.331.254-.13c.158-.082.25-.131.276-.15.012-.01-.165-.206-.407-.464l-1.012-1.067a8.925 8.925 0 01-.199-.216c-.047-.034-.116.068-.208.306-.074.157-.251.252-.272.326-.013.058.108.298.362.72.164.288.22.508-.31.343-1.04-.8-1.518-2.273-1.684-3.725-.004-.035-.162-1.913-.162-1.913a1.2 1.2 0 011.113-1.281L9.9 5.3zm12.994 8.68c.037.697-.403.704-1.213.591l-1.783-.276c-.265-.053-.385-.099-.313-.147.47-.315 3.268-.93 3.31-.168zm-.915-.083l-.926.042c-.85.077-1.452.24.338.336l.103.003c.815.012 1.264-.359.485-.381zm1.667-3.601h.01c.79.398.067 1.03-.65 1.393-.14.07-.491.176-1.052.315-.241.04-.457.092-.333.16l.01.005c1.952.958-3.123 1.534-2.495 1.285l.38-.148c.68-.266 1.614-.682 1.666-1.337.038-.48 1.253-.442 1.493-.968.048-.106 0-.236-.144-.389-.05-.047-.094-.094-.107-.148-.073-.305.7-.431 1.222-.168zm-2.568-.474c-.135 1.198-2.479 4.192-1.949 2.863l.017-.042c.298-.717.376-2.221 1.337-3.221.25-.26.636.035.595.4zm-7.976-.253c.02-.694 1.002-.968 1.346-.347.01-1.274-1.941-.768-1.346.347z" fill="url(#lobe-icons-chat-glm-fill)" fill-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437" fill="#F38020"></path><path d="M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777" fill="#FCAD32"></path></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>

Before

Width:  |  Height:  |  Size: 769 B

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Copilot</title><path d="M17.533 1.829A2.528 2.528 0 0015.11 0h-.737a2.531 2.531 0 00-2.484 2.087l-1.263 6.937.314-1.08a2.528 2.528 0 012.424-1.833h4.284l1.797.706 1.731-.706h-.505a2.528 2.528 0 01-2.423-1.829l-.715-2.453z" fill="url(#lobe-icons-copilot-fill-0)" transform="translate(0 1)"></path><path d="M6.726 20.16A2.528 2.528 0 009.152 22h1.566c1.37 0 2.49-1.1 2.525-2.48l.17-6.69-.357 1.228a2.528 2.528 0 01-2.423 1.83h-4.32l-1.54-.842-1.667.843h.497c1.124 0 2.113.75 2.426 1.84l.697 2.432z" fill="url(#lobe-icons-copilot-fill-1)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-2)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-3)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-4)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-5)" transform="translate(0 1)"></path><defs><radialGradient cx="85.44%" cy="100.653%" fx="85.44%" fy="100.653%" gradientTransform="scale(-.8553 -1) rotate(50.927 2.041 -1.946)" id="lobe-icons-copilot-fill-0" r="105.116%"><stop offset="9.6%" stop-color="#00AEFF"></stop><stop offset="77.3%" stop-color="#2253CE"></stop><stop offset="100%" stop-color="#0736C4"></stop></radialGradient><radialGradient cx="18.143%" cy="32.928%" fx="18.143%" fy="32.928%" gradientTransform="scale(.8897 1) rotate(52.069 .193 .352)" id="lobe-icons-copilot-fill-1" r="95.612%"><stop offset="0%" stop-color="#FFB657"></stop><stop offset="63.4%" stop-color="#FF5F3D"></stop><stop offset="92.3%" stop-color="#C02B3C"></stop></radialGradient><radialGradient cx="82.987%" cy="-9.792%" fx="82.987%" fy="-9.792%" gradientTransform="scale(-1 -.9441) rotate(-70.872 .142 1.17)" id="lobe-icons-copilot-fill-4" r="140.622%"><stop offset="6.6%" stop-color="#8C48FF"></stop><stop offset="50%" stop-color="#F2598A"></stop><stop offset="89.6%" stop-color="#FFB152"></stop></radialGradient><linearGradient id="lobe-icons-copilot-fill-2" x1="39.465%" x2="46.884%" y1="12.117%" y2="103.774%"><stop offset="15.6%" stop-color="#0D91E1"></stop><stop offset="48.7%" stop-color="#52B471"></stop><stop offset="65.2%" stop-color="#98BD42"></stop><stop offset="93.7%" stop-color="#FFC800"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-3" x1="45.949%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#3DCBFF"></stop><stop offset="24.7%" stop-color="#0588F7" stop-opacity="0"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-5" x1="83.507%" x2="83.453%" y1="-6.106%" y2="21.131%"><stop offset="5.8%" stop-color="#F8ADFA"></stop><stop offset="70.8%" stop-color="#A86EDD" stop-opacity="0"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemma</title><defs><linearGradient id="lobe-icons-gemma-fill" x1="24.419%" x2="75.194%" y1="75.581%" y2="25.194%"><stop offset="0%" stop-color="#446EFF"></stop><stop offset="36.661%" stop-color="#2E96FF"></stop><stop offset="83.221%" stop-color="#B1C5FF"></stop></linearGradient></defs><path d="M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z" fill="url(#lobe-icons-gemma-fill)" fill-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>

Before

Width:  |  Height:  |  Size: 907 B

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z" fill="#4285F4"></path><path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z" fill="#34A853"></path><path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z" fill="#FBBC05"></path><path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z" fill="#EB4335"></path></svg>

Before

Width:  |  Height:  |  Size: 920 B

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GoogleCloud</title><path d="M15.961 7.327l2.086-2.086.14-.879C14.384.905 8.34 1.297 4.913 5.18A9.643 9.643 0 002.88 8.991l.747-.105 4.172-.688.322-.33c1.856-2.038 4.994-2.312 7.137-.578l.703.037z" fill="#EA4335"></path><path d="M21.02 8.93a9.399 9.399 0 00-2.834-4.568L15.258 7.29a5.204 5.204 0 011.91 4.129v.52a2.606 2.606 0 012.607 2.605c0 1.44-1.167 2.577-2.606 2.577h-5.22l-.512.556v3.126l.513.49h5.219c3.743.03 6.802-2.952 6.83-6.695a6.778 6.778 0 00-2.98-5.668z" fill="#4285F4"></path><path d="M6.738 21.293h5.212v-4.172H6.738c-.371 0-.731-.08-1.069-.234l-.74.227-2.1 2.086-.183.71a6.763 6.763 0 004.092 1.383z" fill="#34A853"></path><path d="M6.738 7.759A6.778 6.778 0 002.646 19.91l3.023-3.023a2.606 2.606 0 113.448-3.448l3.023-3.023a6.771 6.771 0 00-5.402-2.657z" fill="#FBBC05"></path></svg>

Before

Width:  |  Height:  |  Size: 925 B

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

Before

Width:  |  Height:  |  Size: 756 B

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Huawei</title><path d="M10.341 17.042s.062-.061 0-.061C7.516 10.902 3.646 6.22 3.646 6.22S1.557 8.168 1.68 10.174c.061 1.52 1.228 2.37 1.228 2.37 1.843 1.763 6.266 4.012 7.31 4.499h.123zm-.737 1.52c0-.061-.123-.061-.123-.061l-7.371.243c.798 1.398 2.15 2.492 3.563 2.188.983-.243 3.194-1.763 3.87-2.25.123-.12.061-.12.061-.12zm.123-.67c.062-.06 0-.12 0-.12C6.471 15.581.206 12.3.206 12.3c-.553 1.763.184 3.161.184 3.161.798 1.702 2.334 2.189 2.334 2.189.676.303 1.413.303 1.413.303h5.529c.061 0 .061-.06.061-.06zm.492-14.831c-.308 0-1.168.243-1.168.243-1.965.486-2.395 2.249-2.395 2.249-.369 1.094 0 2.31 0 2.31.675 2.857 3.87 7.598 4.545 8.57l.062.062c.061 0 .061-.061.061-.061C12.43 5.796 10.22 3.06 10.22 3.06zm2.457 13.373c.061 0 .123-.061.123-.061.737-1.033 3.87-5.714 4.545-8.57 0 0 .369-1.399 0-2.31 0 0-.491-1.764-2.457-2.25 0 0-.553-.121-1.167-.243 0 0-2.211 2.796-1.106 13.312 0 .122.062.122.062.122zm1.72 2.067s-.062 0-.062.06v.122c.738.486 2.826 2.006 3.87 2.249 0 0 1.905.669 3.563-2.188l-7.371-.243zm9.398-6.261s-6.265 3.343-9.521 5.531c0 0-.062.06-.062.122 0 0 0 .06.062.06h5.651s.553 0 1.29-.303c0 0 1.536-.487 2.396-2.25 0-.06.737-1.458.184-3.16zM13.66 17.042s.061.06.122 0c1.045-.547 5.468-2.736 7.31-4.499 0 0 1.168-.911 1.23-2.37.122-2.067-1.967-3.951-1.967-3.951s-3.87 4.559-6.695 10.698c0 0-.062.06 0 .122z" fill="#C7000B"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>HuggingFace</title><path d="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z" fill="#FF9D0B"></path><path d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z" fill="#FFD21E"></path><path d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z" fill="#FF323D"></path><path d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z" fill="#3A3B45"></path><path d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z" fill="#FF9D0B"></path><path d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z" fill="#FFD21E"></path></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>

Before

Width:  |  Height:  |  Size: 711 B

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