mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c17ed867 | |||
| 1399cbbf06 | |||
| 602c5717b2 | |||
| 4685e5b597 | |||
| ca6a187745 | |||
| bfdac2a22a | |||
| 8d38f0ee4f | |||
| 159279bf44 | |||
| 2513687184 | |||
| bcc14bd07d | |||
| b31bf43e15 | |||
| 98230c3970 | |||
| 0200fe79ae | |||
| b30f3c27ad | |||
| d9c0e4c452 | |||
| fe525891d4 | |||
| 84998aa217 | |||
| f200feebe4 | |||
| 5017002938 | |||
| de49f6fbbe | |||
| e4fe2763cd |
@@ -35,7 +35,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Cache Cargo registry and build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
|
||||
@@ -100,10 +100,13 @@ CC Switch displays an icon in the system tray, providing quick access to operati
|
||||
| Menu Item | Function |
|
||||
|-----------|----------|
|
||||
| Open Main Window | Show and focus the main window |
|
||||
| App Groups | Providers grouped by Claude/Codex/Gemini/OpenCode/OpenClaw |
|
||||
| Provider List | Click to switch; currently active one shows a checkmark |
|
||||
| App Submenus | Collapsible submenus grouped by Claude/Codex/Gemini (e.g., "Claude · PackyCode") |
|
||||
| Provider List | Inside each submenu, click to switch; currently active shows a checkmark |
|
||||
| Lightweight Mode | Toggle checkbox to enter/exit tray-only mode |
|
||||
| Quit | Fully exit the application |
|
||||
|
||||
> **Note**: Each app submenu title shows the current provider name (e.g., "Claude · PackyCode"). Apps with no configured providers show a disabled "(no providers)" entry. App visibility is controlled by the App Visibility setting.
|
||||
|
||||
### Multi-language Support
|
||||
|
||||
The tray menu supports three languages, automatically switching based on settings:
|
||||
@@ -114,6 +117,17 @@ The tray menu supports three languages, automatically switching based on setting
|
||||
| English | Open main window | Quit |
|
||||
| Japanese | Open main window | Quit |
|
||||
|
||||
### Lightweight Mode
|
||||
|
||||
The tray menu includes a **Lightweight Mode** toggle (checkbox). When enabled:
|
||||
|
||||
- The main window is closed to free up resources
|
||||
- The app continues running in the system tray only
|
||||
- You can still switch providers via the tray submenus
|
||||
- On macOS, the Dock icon is also hidden
|
||||
|
||||
To exit Lightweight Mode, uncheck the toggle or click "Open main window" — the main window will be rebuilt and shown.
|
||||
|
||||
### Use Cases
|
||||
|
||||
Switching providers via the tray menu doesn't require opening the main window, suitable for:
|
||||
@@ -121,6 +135,7 @@ Switching providers via the tray menu doesn't require opening the main window, s
|
||||
- Frequently switching providers
|
||||
- Quick operations when the main window is minimized
|
||||
- Managing configurations while running in the background
|
||||
- Running in Lightweight Mode for minimal resource usage
|
||||
|
||||
## Settings Page
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ After switching providers, each CLI tool activates differently:
|
||||
| Claude Code | Instant effect (supports hot reload) |
|
||||
| Codex | Requires closing and reopening the terminal |
|
||||
| Gemini | Instant effect (re-reads config on each request) |
|
||||
| OpenCode | Requires closing and reopening the terminal |
|
||||
| OpenClaw | Requires closing and reopening the terminal |
|
||||
|
||||
### Claude Code First Launch Prompt
|
||||
|
||||
@@ -64,6 +66,14 @@ codex
|
||||
# Gemini - enter a test question after launching
|
||||
gemini
|
||||
> Hello, please briefly introduce yourself
|
||||
|
||||
# OpenCode - enter a test question after launching
|
||||
opencode
|
||||
> Hello, please briefly introduce yourself
|
||||
|
||||
# OpenClaw - enter a test question after launching
|
||||
openclaw
|
||||
> Hello, please briefly introduce yourself
|
||||
```
|
||||
|
||||
If the AI responds normally, the configuration is successful.
|
||||
|
||||
@@ -167,37 +167,74 @@ Import/export configuration backups. See the "Data Management" section above for
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
Manage automatic backups:
|
||||
The Backup Management panel provides full control over database backups.
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Backup Interval | Time interval for automatic backups (hours) |
|
||||
| Retention Count | Number of backups to retain |
|
||||
#### Auto-Backup Settings
|
||||
|
||||
Supports viewing the backup list and restoring from backups.
|
||||
| Setting | Options | Default |
|
||||
|---------|---------|---------|
|
||||
| Backup Interval | Disabled, 6h, 12h, 24h, 48h, 7d | 24 hours |
|
||||
| Retention Count | 3, 5, 10, 15, 20, 30, 50 | 10 backups |
|
||||
|
||||
When an interval is set, CC Switch automatically backs up the database on schedule. Older backups beyond the retention count are automatically removed.
|
||||
|
||||
#### Backup List
|
||||
|
||||
The panel displays all existing backups with:
|
||||
- **Display name** (auto-generated from timestamp, e.g., `db_backup_20260315_143000`)
|
||||
- **Creation time**
|
||||
- **File size** (e.g., "1.5 MB")
|
||||
|
||||
#### Backup Operations
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| **Backup Now** | Create a backup immediately |
|
||||
| **Restore** | Restore the database from a selected backup. A safety backup of the current database is created automatically before restoring |
|
||||
| **Rename** | Change the backup's display name |
|
||||
| **Delete** | Permanently remove a backup (with confirmation) |
|
||||
|
||||
> **Important**: Restoring a backup overwrites the current database. A safety backup is always created before the restore operation, so you can recover if needed.
|
||||
|
||||
### Cloud Sync (WebDAV)
|
||||
|
||||
Sync configurations across multiple devices via the WebDAV protocol.
|
||||
Sync configurations across multiple devices via the WebDAV protocol. Uses **v2 protocol** with dual-layer versioning for improved reliability.
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Service Preset | Jianguoyun / Nextcloud / Synology / Custom |
|
||||
| Server URL | WebDAV server URL |
|
||||
| Username | Login username |
|
||||
| Password | Login password (app-specific password) |
|
||||
| Password | Login password (app-specific password; saved credentials are preserved if left unchanged) |
|
||||
| Remote Directory | Remote storage path (default: `cc-switch-sync`) |
|
||||
| Profile Name | Device profile name (default: `default`) |
|
||||
| Auto Sync | Automatically upload changes when enabled |
|
||||
| Auto Sync | Enable automatic synchronization on a configurable interval |
|
||||
|
||||
Operations:
|
||||
#### Operations
|
||||
|
||||
- **Test Connection**: Verify WebDAV configuration is correct
|
||||
- **Save**: Save configuration and auto-test
|
||||
- **Upload**: Upload local data to the remote server
|
||||
- **Download**: Download data from the remote server to local
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| **Test Connection** | Verify WebDAV URL, username, and password are valid |
|
||||
| **Upload** | Upload local database to remote. Shows progress spinner |
|
||||
| **Download** | Download remote database. Shows remote snapshot info (protocol version, DB version, timestamp, size) before confirming. A safety backup of the local database is created automatically before overwriting |
|
||||
|
||||
> **Note**: Upload will overwrite remote data, and download will overwrite local data. Please confirm before proceeding.
|
||||
#### Auto-Sync
|
||||
|
||||
When **Auto Sync** is enabled:
|
||||
- A confirmation dialog is shown on first activation
|
||||
- CC Switch automatically syncs the database to WebDAV at the configured interval
|
||||
- Sync status is displayed in the panel
|
||||
|
||||
#### Remote Snapshot Info
|
||||
|
||||
Before downloading, CC Switch displays details about the remote snapshot:
|
||||
- Protocol version (v2)
|
||||
- Database compatibility version
|
||||
- Timestamp of the remote backup
|
||||
- File size
|
||||
- Compatibility status (warning if incompatible)
|
||||
|
||||
> **Note**: Upload overwrites remote data, and download overwrites local data. A safety backup is always created before downloading.
|
||||
|
||||
### Log Configuration
|
||||
|
||||
|
||||
@@ -152,6 +152,22 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| AWS Bedrock | AWS Bedrock service |
|
||||
| OpenAI Compatible | OpenAI-compatible interface |
|
||||
|
||||
## Auto-Fetch Models
|
||||
|
||||
When adding or editing a provider, you can auto-fetch available models from the provider's endpoint:
|
||||
|
||||
1. Ensure the **API Key** and **Endpoint URL** are filled in
|
||||
2. Click the **Fetch Models** button (download icon) next to the model input field
|
||||
3. CC Switch calls the provider's `/v1/models` endpoint to retrieve the model list
|
||||
4. Select a model from the dropdown, grouped by vendor
|
||||
|
||||
This feature works for any provider that supports the OpenAI-compatible `/v1/models` API. It is available for Claude, Codex, Gemini, OpenCode, and OpenClaw providers.
|
||||
|
||||
**Common errors:**
|
||||
- **Authentication failed (401/403)**: Check your API Key
|
||||
- **Endpoint not supported (404/405)**: The provider does not expose a `/v1/models` endpoint
|
||||
- **Timeout**: The endpoint is slow to respond; try again later
|
||||
|
||||
## Custom Configuration
|
||||
|
||||
After selecting the "Custom" preset, you need to manually edit the JSON configuration.
|
||||
@@ -316,6 +332,45 @@ Batch import from SQL backup files:
|
||||
|
||||
## Advanced Options
|
||||
|
||||
### API Format (Claude Only)
|
||||
|
||||
When adding a Claude provider that uses a third-party API, you may need to select the correct **API Format** in the Advanced Options section:
|
||||
|
||||
| Format | Description | When to Use |
|
||||
|--------|-------------|-------------|
|
||||
| **Anthropic Messages** | Native Anthropic API format (default) | Direct Anthropic API or compatible proxies |
|
||||
| **OpenAI Chat Completions** | OpenAI Chat API format, auto-converted by proxy | Provider only supports OpenAI Chat format |
|
||||
| **OpenAI Responses API** | OpenAI Responses API format, auto-converted by proxy | Provider only supports OpenAI Responses format |
|
||||
|
||||
> **Note**: API format conversion is handled by the proxy service. When using non-Anthropic formats, the proxy must be running with takeover enabled for correct request/response conversion. See [4.1 Proxy Service](../4-proxy/4.1-service.md) for details.
|
||||
|
||||
The Advanced Options section auto-expands when a non-default API format is configured.
|
||||
|
||||
### Claude Common Config Toggles
|
||||
|
||||
When editing Claude providers, a set of **quick toggles** is available above the JSON editor:
|
||||
|
||||
| Toggle | Effect | Config Change |
|
||||
|--------|--------|---------------|
|
||||
| **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` |
|
||||
| **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **High Effort** | Sets effort level to high | Sets `effortLevel = "high"` |
|
||||
| **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time.
|
||||
|
||||
Additionally, the **Write Common Config** checkbox enables merging a global config snippet into the provider. Click **Edit Common Config** to customize the shared snippet.
|
||||
|
||||
### Codex 1M Context Window
|
||||
|
||||
When adding a Codex provider, an **Enable 1M Context Window** toggle is available:
|
||||
|
||||
- **When enabled**: Sets `model_context_window = 1000000` and auto-fills `model_auto_compact_token_limit = 900000` in config.toml
|
||||
- **When disabled**: Removes both fields
|
||||
|
||||
The auto-compact limit can be customized in the text field that appears when the toggle is on.
|
||||
|
||||
### Custom Icon
|
||||
|
||||
Click the icon area to the left of the name to:
|
||||
|
||||
@@ -26,10 +26,12 @@ Quickly switch providers via the system tray without opening the main interface.
|
||||
### Steps
|
||||
|
||||
1. Right-click the CC Switch icon in the system tray
|
||||
2. Find the corresponding app (Claude/Codex/Gemini/OpenCode) in the menu
|
||||
2. Hover over the corresponding app submenu (e.g., "Claude · CurrentProvider")
|
||||
3. Click the provider name you want to switch to
|
||||
4. Switching completes with a brief tray notification
|
||||
|
||||
> Providers are organized into collapsible submenus by app type (Claude/Codex/Gemini). The submenu title shows the currently active provider name.
|
||||
|
||||
### Tray Menu Structure
|
||||
|
||||

|
||||
|
||||
@@ -52,6 +52,20 @@ When editing the currently active provider, a special "backfill" mechanism appli
|
||||
|
||||
This ensures CC Switch and CLI tool configurations stay in sync.
|
||||
|
||||
## Auto-Fetch Models
|
||||
|
||||
When editing a provider, you can auto-fetch the available model list from the provider's endpoint:
|
||||
|
||||
1. Ensure the API Key and endpoint URL are filled in
|
||||
2. Click the **Fetch Models** button (download icon) next to the model input field
|
||||
3. Select a model from the grouped dropdown
|
||||
|
||||
See [2.1 Add Provider — Auto-Fetch Models](./2.1-add.md#auto-fetch-models) for full details.
|
||||
|
||||
## Common Config Toggles (Claude)
|
||||
|
||||
When editing a Claude provider, quick toggle switches are available above the JSON editor for common settings like Tool Search, Disable Auto Upgrade, Teammates, and High Effort. See [2.1 Add Provider — Claude Common Config Toggles](./2.1-add.md#claude-common-config-toggles) for details.
|
||||
|
||||
## Modify API Key
|
||||
|
||||
When editing a provider, you can modify the key directly in the **API Key** input field:
|
||||
|
||||
@@ -118,8 +118,27 @@ Installation copies the skill folder to your local machine:
|
||||
|
||||
### Uninstall Effect
|
||||
|
||||
- Deletes the local skill folder
|
||||
- Updates installation status
|
||||
- **Automatic backup**: Before deletion, the skill is backed up to `~/.cc-switch/skill-backups/`
|
||||
- Removes the skill from all app directories (Claude, Codex, Gemini, OpenCode)
|
||||
- Removes the skill from the SSOT directory (`~/.cc-switch/skills/`)
|
||||
- Deletes the skill record from the database
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
If you need to restore a previously uninstalled skill:
|
||||
|
||||
1. Open the Skills page
|
||||
2. Click the **Restore from Backup** button
|
||||
3. Select the backup you want to restore from the list (shows skill name and backup date)
|
||||
4. The skill is restored and enabled for the current app
|
||||
|
||||
### Delete Backups
|
||||
|
||||
To remove old skill backups:
|
||||
|
||||
1. In the restore dialog, find the backup you want to remove
|
||||
2. Click the **Delete** button next to the backup entry
|
||||
3. Confirm deletion — this cannot be undone
|
||||
|
||||
## Repository Management
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 3.4 Session Manager
|
||||
|
||||
The Session Manager lets you browse, search, and manage conversation sessions from all supported CLI tools in one place.
|
||||
|
||||
## Supported Applications
|
||||
|
||||
| Application | Session Storage Location |
|
||||
|-------------|--------------------------|
|
||||
| Claude Code | `~/.cache/claude/projects/*.jsonl` |
|
||||
| Codex | Codex config sessions directory |
|
||||
| OpenCode | `~/.local/share/opencode/` (JSON or SQLite) |
|
||||
| OpenClaw | `~/.openclaw/agents/<agent>/sessions/*.jsonl` |
|
||||
| Gemini CLI | `~/.cache/gemini/tmp/<project_hash>/chats/` |
|
||||
|
||||
## Opening the Session Manager
|
||||
|
||||
Click the **Sessions** button in the main navigation bar toolbar.
|
||||
|
||||
> **Note**: The Sessions button is visible for all five supported applications.
|
||||
|
||||
## Interface Layout
|
||||
|
||||
The Session Manager uses a **two-column layout**:
|
||||
|
||||
- **Left panel**: Session list with search and filter toolbar
|
||||
- **Right panel**: Selected session details with conversation history
|
||||
|
||||
### Session List (Left Panel)
|
||||
|
||||
Each session entry displays:
|
||||
- Provider icon
|
||||
- Session title
|
||||
- Last active time (relative format, e.g., "5 min ago")
|
||||
|
||||
### Session Details (Right Panel)
|
||||
|
||||
When a session is selected, the right panel shows:
|
||||
- **Title**: Derived from session title, project directory name, or session ID
|
||||
- **Last active date/time**: Full timestamp
|
||||
- **Project directory**: Clickable to copy full path (shows basename with tooltip for full path)
|
||||
- **Resume command**: Displayed in monospace style when available
|
||||
- **Conversation history**: Full message transcript
|
||||
|
||||
## Search & Filtering
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
Use the search box at the top of the left panel to search across:
|
||||
- Session ID
|
||||
- Title
|
||||
- Summary
|
||||
- Project directory
|
||||
- Source file path
|
||||
|
||||
The search supports prefix matching and filters results in real-time. Press **Esc** to clear the search.
|
||||
|
||||
### Provider Filtering
|
||||
|
||||
Click the provider filter dropdown (top-right of left panel) to filter by application:
|
||||
- **All** — Show sessions from all providers
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
- **OpenClaw**
|
||||
- **Gemini CLI**
|
||||
|
||||
The filter can be combined with search.
|
||||
|
||||
### Refresh
|
||||
|
||||
Click the refresh button (circular arrow icon) to re-scan all provider directories for new or deleted sessions.
|
||||
|
||||
## Session Actions
|
||||
|
||||
### Resume Session
|
||||
|
||||
Click the **Resume** button (play icon) on a selected session to continue the conversation.
|
||||
|
||||
**On macOS:**
|
||||
- CC Switch launches your preferred terminal with the resume command
|
||||
- The terminal opens in the session's project directory
|
||||
- If terminal launch fails, the command is copied to your clipboard instead
|
||||
|
||||
**Supported terminals (macOS):** Terminal.app, iTerm2, Ghostty, Kitty, WezTerm, Alacritty
|
||||
|
||||
**On other platforms:**
|
||||
- The resume command is copied to your clipboard
|
||||
- Paste it into your terminal to resume
|
||||
|
||||
> The Resume button is disabled if the session has no resume command available.
|
||||
|
||||
### Delete Session
|
||||
|
||||
Click the **Delete** button (trash icon) to permanently remove a session file. A confirmation dialog is shown before deletion.
|
||||
|
||||
> Sessions without a local source path (e.g., immutable sessions) cannot be deleted.
|
||||
|
||||
### Batch Operations
|
||||
|
||||
For managing multiple sessions at once:
|
||||
|
||||
1. Click the **Batch Mode** button (checkbox icon) in the left panel toolbar
|
||||
2. Select sessions using the checkboxes that appear
|
||||
3. Use **Select All** to select all filtered results, or **Clear** to deselect
|
||||
4. Click **Batch Delete** (red trash icon) to delete all selected sessions
|
||||
|
||||
A confirmation dialog shows the count before deletion. Results report the number of successful deletions and any failures.
|
||||
|
||||
## Conversation History
|
||||
|
||||
### Message Display
|
||||
|
||||
Messages are color-coded by role:
|
||||
- **User** messages: Green, left-aligned
|
||||
- **AI** (Assistant) messages: Blue, right-aligned
|
||||
- **System** messages: Amber
|
||||
- **Tool** messages: Purple
|
||||
|
||||
### Table of Contents
|
||||
|
||||
For longer conversations, a Table of Contents is available:
|
||||
- **Desktop (XL+ screens)**: Sidebar on the right showing user message previews
|
||||
- **Smaller screens**: Floating button (list icon) at bottom-right that opens a dialog
|
||||
|
||||
Click any entry to scroll to that message, which is briefly highlighted.
|
||||
|
||||
## Tips
|
||||
|
||||
- Sessions are sorted by last activity time (newest first)
|
||||
- The session count badge updates as you search and filter
|
||||
- OpenCode sessions may come from both JSON files and SQLite database — duplicates are automatically deduplicated
|
||||
@@ -0,0 +1,85 @@
|
||||
# 3.5 Workspace Files & Daily Memory
|
||||
|
||||
## Overview
|
||||
|
||||
The Workspace panel provides file management and daily memory features for **OpenClaw**. It allows you to edit workspace configuration files and maintain a daily memory journal.
|
||||
|
||||
> This feature is specific to OpenClaw. The Workspace button appears in the navigation bar when OpenClaw is the selected application.
|
||||
|
||||
## Workspace Files
|
||||
|
||||
### File Location
|
||||
|
||||
All workspace files are stored in `~/.openclaw/workspace/`.
|
||||
|
||||
Click the directory path at the top of the panel to open it in your file manager.
|
||||
|
||||
### Available Files
|
||||
|
||||
CC Switch manages 9 workspace files, each serving a specific purpose:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| **AGENTS.md** | Agents definition and configuration |
|
||||
| **SOUL.md** | System soul/personality settings |
|
||||
| **USER.md** | User profile information |
|
||||
| **IDENTITY.md** | Identity and role definition |
|
||||
| **TOOLS.md** | Available tools configuration |
|
||||
| **MEMORY.md** | System memory |
|
||||
| **HEARTBEAT.md** | Heartbeat configuration |
|
||||
| **BOOTSTRAP.md** | Bootstrap sequence |
|
||||
| **BOOT.md** | Boot configuration |
|
||||
|
||||
### File Status
|
||||
|
||||
Each file shows a status indicator:
|
||||
- **Green checkmark**: File exists on disk
|
||||
- **Empty circle**: File does not exist yet (will be created on first save)
|
||||
|
||||
### Editing Files
|
||||
|
||||
1. Click any file card to open the Markdown editor
|
||||
2. Edit the content
|
||||
3. Click **Save** to write changes to disk
|
||||
|
||||
If the file doesn't exist yet, it will be created on first save.
|
||||
|
||||
## Daily Memory
|
||||
|
||||
The Daily Memory feature provides a date-organized journal system stored in `~/.openclaw/workspace/memory/`.
|
||||
|
||||
### Accessing Daily Memory
|
||||
|
||||
Click the **Daily Memory** card in the Workspace Files grid to open the memory panel.
|
||||
|
||||
### File List
|
||||
|
||||
The panel displays all daily memory files sorted by date (newest first). Each entry shows:
|
||||
- **Date** (formatted from filename, e.g., `2026-04-01.md`)
|
||||
- **File size**
|
||||
- **Preview** (first 2 lines of content)
|
||||
|
||||
### Create Today's Note
|
||||
|
||||
Click the **Create Today** button to:
|
||||
- Open a new note with today's date (`YYYY-MM-DD.md`)
|
||||
- If today's note already exists, it opens for editing
|
||||
- The file is persisted only after you click Save
|
||||
|
||||
### Search
|
||||
|
||||
Search across all daily memory files:
|
||||
|
||||
1. Press **Cmd/Ctrl+F** or click the search icon
|
||||
2. Enter your search term
|
||||
3. Results show matching files with:
|
||||
- Match count per file
|
||||
- Snippet preview from the matching line
|
||||
- File date and size
|
||||
|
||||
Press **Esc** to close the search.
|
||||
|
||||
### Edit & Delete
|
||||
|
||||
- **Edit**: Click a file entry to open it in the Markdown editor
|
||||
- **Delete**: Hover over a file entry and click the delete icon. A confirmation dialog is shown — deletion cannot be undone.
|
||||
@@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1"
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
## API Format Conversion
|
||||
|
||||
The proxy supports automatic API format conversion for providers configured with non-Anthropic formats. This allows you to use providers that only support OpenAI-compatible APIs with Claude Code.
|
||||
|
||||
| Provider API Format | Proxy Behavior |
|
||||
|---------------------|----------------|
|
||||
| **Anthropic Messages** | Pass-through (no conversion) |
|
||||
| **OpenAI Chat Completions** | Converts Anthropic requests to OpenAI Chat format and responses back |
|
||||
| **OpenAI Responses API** | Converts Anthropic requests to OpenAI Responses format and responses back |
|
||||
|
||||
The API format is configured per-provider in the [Advanced Options](../2-providers/2.1-add.md#api-format-claude-only) when adding or editing a Claude provider.
|
||||
|
||||
> **Note**: Format conversion requires the proxy to be running with app takeover enabled. The conversion handles both streaming and non-streaming requests.
|
||||
|
||||
## Stop the Proxy
|
||||
|
||||
### Option 1: Main Interface Toggle
|
||||
|
||||
@@ -12,12 +12,11 @@ Customizable location in settings (for cloud sync).
|
||||
|
||||
```
|
||||
~/.cc-switch/
|
||||
├── cc-switch.db # SQLite database
|
||||
├── cc-switch.db # SQLite database (SSOT)
|
||||
├── settings.json # Device-level settings
|
||||
└── backups/ # Automatic backups
|
||||
├── backup-20251230-120000.json
|
||||
├── backup-20251229-180000.json
|
||||
└── ...
|
||||
├── skills/ # Skill SSOT directory
|
||||
├── skill-backups/ # Skill backups (created on uninstall)
|
||||
└── db_backup_*.db # Database backups
|
||||
```
|
||||
|
||||
### Database Contents
|
||||
|
||||
@@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage
|
||||
2. Manually download and install the latest version
|
||||
3. If using Homebrew: `brew upgrade --cask cc-switch`
|
||||
|
||||
## Lightweight Mode
|
||||
|
||||
### How to Enter Lightweight Mode?
|
||||
|
||||
Toggle "Lightweight Mode" from the system tray menu. The main window closes, and CC Switch runs as a tray-only app. Toggle again or click "Open main window" to exit.
|
||||
|
||||
### App Uses Less Memory in Lightweight Mode?
|
||||
|
||||
Yes. Lightweight Mode destroys the main window and its web view, reducing memory usage significantly while keeping tray menu functionality available.
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Submit an Issue
|
||||
|
||||
@@ -24,7 +24,9 @@ CC Switch User Manual
|
||||
├── 3. Extensions
|
||||
│ ├── 3.1 MCP Server Management
|
||||
│ ├── 3.2 Prompts Management
|
||||
│ └── 3.3 Skills Management
|
||||
│ ├── 3.3 Skills Management
|
||||
│ ├── 3.4 Session Manager
|
||||
│ └── 3.5 Workspace & Memory
|
||||
│
|
||||
├── 4. Proxy & High Availability
|
||||
│ ├── 4.1 Proxy Service
|
||||
@@ -69,6 +71,8 @@ CC Switch User Manual
|
||||
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP protocol, add servers, app binding |
|
||||
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | Create presets, activate/switch, smart backfill |
|
||||
| [3.3-skills.md](./3-extensions/3.3-skills.md) | Discover skills, install/uninstall, repository management |
|
||||
| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | Session Manager: browse, search, resume, delete sessions |
|
||||
| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | Workspace files and daily memory (OpenClaw) |
|
||||
|
||||
### 4. Proxy & High Availability
|
||||
|
||||
@@ -99,9 +103,9 @@ CC Switch User Manual
|
||||
|
||||
## Version Information
|
||||
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Applicable to CC Switch v3.12.0+
|
||||
- Documentation version: v3.12.3
|
||||
- Last updated: 2026-04-04
|
||||
- Applicable to CC Switch v3.12.3+
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
| メニュー項目 | 機能 |
|
||||
|--------|------|
|
||||
| メインウィンドウを開く | メインウィンドウを表示してフォーカス |
|
||||
| アプリグループ | Claude/Codex/Gemini/OpenCode/OpenClaw ごとにプロバイダーを表示 |
|
||||
| プロバイダーリスト | クリックで切り替え、現在有効なものにはチェックマークを表示 |
|
||||
| アプリサブメニュー | Claude/Codex/Gemini/OpenCode/OpenClaw ごとの折りたたみサブメニュー(例:「Claude · PackyCode」) |
|
||||
| プロバイダーリスト | 各サブメニュー内でクリックして切り替え、現在有効なものにはチェックマークを表示 |
|
||||
| ライトウェイトモード | トグルチェックボックスでトレイ専用モードの開始/終了 |
|
||||
| 終了 | アプリを完全に終了 |
|
||||
|
||||
> **注意**:各アプリのサブメニュータイトルには現在のプロバイダー名が表示されます(例:「Claude · PackyCode」)。プロバイダーが設定されていないアプリでは、無効化された「(プロバイダーなし)」エントリが表示されます。アプリの表示はアプリの表示設定で制御されます。
|
||||
|
||||
### 多言語対応
|
||||
|
||||
トレイメニューは 3 つの言語に対応し、設定に応じて自動的に切り替わります:
|
||||
@@ -114,6 +117,17 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
### ライトウェイトモード
|
||||
|
||||
トレイメニューには **ライトウェイトモード** のトグル(チェックボックス)があります。有効にすると:
|
||||
|
||||
- メインウィンドウが閉じられ、リソースが解放される
|
||||
- アプリはシステムトレイのみで動作を継続
|
||||
- トレイのサブメニューからプロバイダーの切り替えが可能
|
||||
- macOS では Dock アイコンも非表示になる
|
||||
|
||||
ライトウェイトモードを終了するには、トグルのチェックを外すか「メインウィンドウを開く」をクリックします。メインウィンドウが再構築されて表示されます。
|
||||
|
||||
### 使用シーン
|
||||
|
||||
トレイからのプロバイダー切り替えはメイン画面を開く必要がなく、以下の場面に適しています:
|
||||
@@ -121,6 +135,7 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
- 頻繁にプロバイダーを切り替える場合
|
||||
- メインウィンドウが最小化されているときの素早い操作
|
||||
- バックグラウンド実行中の設定管理
|
||||
- ライトウェイトモードでリソース使用量を最小化
|
||||
|
||||
## 設定ページ
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
| アプリ | 反映方法 |
|
||||
|------|----------|
|
||||
| Claude Code | 即時反映(ホットリロード対応) |
|
||||
| Codex | ターミナルを閉じて再度開く必要あり |
|
||||
| Codex | ターミナルの再起動が必要 |
|
||||
| Gemini | 即時反映(リクエストごとに設定を再読み込み) |
|
||||
| OpenCode | ターミナルの再起動が必要 |
|
||||
| OpenClaw | ターミナルの再起動が必要 |
|
||||
|
||||
### Claude Code の初回インストール時の注意
|
||||
|
||||
@@ -64,6 +66,14 @@ codex
|
||||
# Gemini - 起動後にテスト質問を入力
|
||||
gemini
|
||||
> こんにちは、簡単に自己紹介してください
|
||||
|
||||
# OpenCode - 起動後にテスト質問を入力
|
||||
opencode
|
||||
> こんにちは、簡単に自己紹介してください
|
||||
|
||||
# OpenClaw - 起動後にテスト質問を入力
|
||||
openclaw
|
||||
> こんにちは、簡単に自己紹介してください
|
||||
```
|
||||
|
||||
AI が正常に回答すれば、設定は成功です。
|
||||
|
||||
@@ -167,37 +167,74 @@ CC Switch の送信 HTTP/HTTPS プロキシを設定します。外部 API に
|
||||
|
||||
### バックアップと復元
|
||||
|
||||
自動バックアップの管理:
|
||||
バックアップ管理パネルでは、データベースバックアップを完全に管理できます。
|
||||
|
||||
| 設定 | 説明 |
|
||||
| -------- | -------------------------- |
|
||||
| バックアップ間隔 | 自動バックアップの時間間隔(時間) |
|
||||
| 保持数量 | 保持するバックアップの数 |
|
||||
#### 自動バックアップ設定
|
||||
|
||||
バックアップリストの表示とバックアップからの復元をサポートします。
|
||||
| 設定 | オプション | デフォルト |
|
||||
|------|---------|---------|
|
||||
| バックアップ間隔 | 無効、6時間、12時間、24時間、48時間、7日 | 24時間 |
|
||||
| 保持数 | 3、5、10、15、20、30、50 | 10件 |
|
||||
|
||||
間隔を設定すると、CC Switch はスケジュールに従って自動的にデータベースをバックアップします。保持数を超えた古いバックアップは自動的に削除されます。
|
||||
|
||||
#### バックアップリスト
|
||||
|
||||
パネルには既存のすべてのバックアップが以下の情報とともに表示されます:
|
||||
- **表示名**(タイムスタンプから自動生成、例:`db_backup_20260315_143000`)
|
||||
- **作成日時**
|
||||
- **ファイルサイズ**(例:「1.5 MB」)
|
||||
|
||||
#### バックアップ操作
|
||||
|
||||
| 操作 | 説明 |
|
||||
|------|------|
|
||||
| **今すぐバックアップ** | 即座にバックアップを作成 |
|
||||
| **復元** | 選択したバックアップからデータベースを復元。復元前に現在のデータベースの安全バックアップが自動的に作成される |
|
||||
| **名前変更** | バックアップの表示名を変更 |
|
||||
| **削除** | バックアップを完全に削除(確認あり) |
|
||||
|
||||
> **重要**:バックアップの復元は現在のデータベースを上書きします。復元前に安全バックアップが自動作成されるため、必要に応じて元に戻すことができます。
|
||||
|
||||
### クラウド同期(WebDAV)
|
||||
|
||||
WebDAV プロトコルを使用して複数のデバイス間で設定を同期します。
|
||||
WebDAV プロトコルを使用して複数のデバイス間で設定を同期します。信頼性の向上のため、デュアルレイヤーバージョニングを備えた **v2 プロトコル** を使用しています。
|
||||
|
||||
| 設定項目 | 説明 |
|
||||
| -------- | ------------------------------------- |
|
||||
| サービスプリセット | 坚果云 / Nextcloud / Synology / カスタム |
|
||||
| サーバー URL | WebDAV サーバー URL |
|
||||
| ユーザー名 | ログインユーザー名 |
|
||||
| パスワード | ログインパスワード(アプリ専用パスワード) |
|
||||
| パスワード | ログインパスワード(アプリ専用パスワード、保存済みの場合は未変更で保持) |
|
||||
| リモートディレクトリ | リモート保存パス(デフォルト `cc-switch-sync`) |
|
||||
| プロファイル名 | デバイスプロファイル名(デフォルト `default`) |
|
||||
| 自動同期 | 有効にすると変更を自動アップロード |
|
||||
| 自動同期 | 設定可能な間隔で自動同期を有効化 |
|
||||
|
||||
操作:
|
||||
#### 操作
|
||||
|
||||
- **接続テスト**:WebDAV 設定が正しいか確認
|
||||
- **保存**:設定を保存して自動テスト
|
||||
- **アップロード**:ローカルデータをリモートにアップロード
|
||||
- **ダウンロード**:リモートからローカルにデータをダウンロード
|
||||
| 操作 | 説明 |
|
||||
|------|------|
|
||||
| **接続テスト** | WebDAV の URL、ユーザー名、パスワードが有効か確認 |
|
||||
| **アップロード** | ローカルデータベースをリモートにアップロード。進捗スピナーを表示 |
|
||||
| **ダウンロード** | リモートデータベースをダウンロード。確認前にリモートスナップショット情報(プロトコルバージョン、DB バージョン、タイムスタンプ、サイズ)を表示。上書き前にローカルデータベースの安全バックアップを自動作成 |
|
||||
|
||||
> **注意**:アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。操作前にご確認ください。
|
||||
#### 自動同期
|
||||
|
||||
**自動同期** を有効にすると:
|
||||
- 初回有効化時に確認ダイアログが表示される
|
||||
- CC Switch は設定された間隔で自動的にデータベースを WebDAV に同期
|
||||
- 同期ステータスがパネルに表示される
|
||||
|
||||
#### リモートスナップショット情報
|
||||
|
||||
ダウンロード前に、CC Switch はリモートスナップショットの詳細を表示します:
|
||||
- プロトコルバージョン(v2)
|
||||
- データベース互換バージョン
|
||||
- リモートバックアップのタイムスタンプ
|
||||
- ファイルサイズ
|
||||
- 互換性ステータス(非互換の場合は警告)
|
||||
|
||||
> **注意**:アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。ダウンロード前に安全バックアップが自動作成されます。
|
||||
|
||||
### ログ設定
|
||||
|
||||
|
||||
@@ -152,6 +152,22 @@
|
||||
| AWS Bedrock | AWS Bedrock サービス |
|
||||
| OpenAI Compatible | OpenAI 互換インターフェース |
|
||||
|
||||
## モデル自動取得
|
||||
|
||||
プロバイダーの追加や編集時に、プロバイダーのエンドポイントから利用可能なモデルを自動取得できます:
|
||||
|
||||
1. **API Key** と **エンドポイントアドレス** が入力されていることを確認
|
||||
2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック
|
||||
3. CC Switch がプロバイダーの `/v1/models` エンドポイントを呼び出してモデルリストを取得
|
||||
4. ベンダー別にグループ化されたドロップダウンからモデルを選択
|
||||
|
||||
この機能は、OpenAI 互換の `/v1/models` API をサポートするすべてのプロバイダーで動作します。Claude、Codex、Gemini、OpenCode、OpenClaw のプロバイダーで利用可能です。
|
||||
|
||||
**よくあるエラー:**
|
||||
- **認証失敗(401/403)**:API Key を確認してください
|
||||
- **エンドポイント未対応(404/405)**:プロバイダーが `/v1/models` エンドポイントを公開していません
|
||||
- **タイムアウト**:エンドポイントの応答が遅いです。後ほど再試行してください
|
||||
|
||||
## カスタム設定
|
||||
|
||||
「カスタム」プリセットを選択した場合、JSON 設定を手動で編集する必要があります。
|
||||
@@ -316,6 +332,45 @@ SQL バックアップファイルから一括インポート:
|
||||
|
||||
## 高度なオプション
|
||||
|
||||
### API フォーマット(Claude のみ)
|
||||
|
||||
サードパーティ API を使用する Claude プロバイダーを追加する際、高度なオプションセクションで正しい **API フォーマット** を選択する必要がある場合があります:
|
||||
|
||||
| フォーマット | 説明 | 使用場面 |
|
||||
|------|------|------|
|
||||
| **Anthropic Messages** | ネイティブ Anthropic API フォーマット(デフォルト) | Anthropic API に直接接続、または互換プロキシ |
|
||||
| **OpenAI Chat Completions** | OpenAI Chat API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Chat フォーマットのみ対応 |
|
||||
| **OpenAI Responses API** | OpenAI Responses API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Responses フォーマットのみ対応 |
|
||||
|
||||
> **注意**:API フォーマットの変換はプロキシサービスが担当します。Anthropic 以外のフォーマットを使用する場合、リクエスト/レスポンスの正しい変換のためにプロキシが接管有効の状態で稼働している必要があります。詳しくは [4.1 プロキシサービス](../4-proxy/4.1-service.md) をご覧ください。
|
||||
|
||||
デフォルト以外の API フォーマットが設定されている場合、高度なオプションセクションが自動展開されます。
|
||||
|
||||
### Claude 共通設定クイックトグル
|
||||
|
||||
Claude プロバイダーの編集時、JSON エディタの上部に **クイックトグル** が利用できます:
|
||||
|
||||
| トグル | 効果 | 設定変更 |
|
||||
|------|------|------|
|
||||
| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 |
|
||||
| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 |
|
||||
| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 |
|
||||
| **高強度** | エフォートレベルを高に設定 | `effortLevel = "high"` を設定 |
|
||||
| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 |
|
||||
|
||||
トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。
|
||||
|
||||
また、**共通設定を書き込み** チェックボックスを有効にすると、グローバル設定スニペットをプロバイダーにマージできます。**共通設定を編集** をクリックして共有スニペットをカスタマイズできます。
|
||||
|
||||
### Codex 1M コンテキストウィンドウ
|
||||
|
||||
Codex プロバイダーの追加時、**1M コンテキストウィンドウを有効化** トグルが利用できます:
|
||||
|
||||
- **有効時**:config.toml に `model_context_window = 1000000` を設定し、`model_auto_compact_token_limit = 900000` を自動入力
|
||||
- **無効時**:両方のフィールドを削除
|
||||
|
||||
トグルがオンの場合に表示されるテキストフィールドで、自動コンパクト制限をカスタマイズできます。
|
||||
|
||||
### カスタムアイコン
|
||||
|
||||
名前の左側にあるアイコンエリアをクリックすると:
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
### 操作手順
|
||||
|
||||
1. システムトレイの CC Switch アイコンを右クリック
|
||||
2. メニューで対応するアプリ(Claude/Codex/Gemini/OpenCode)を見つける
|
||||
2. 対応するアプリのサブメニュー(例:「Claude · 現在のプロバイダー」)にマウスを合わせる
|
||||
3. 切り替えたいプロバイダー名をクリック
|
||||
4. 切り替え完了、トレイに短い通知が表示
|
||||
|
||||
> プロバイダーはアプリタイプ(Claude/Codex/Gemini)ごとに折りたたみサブメニューに整理されています。サブメニューのタイトルには現在有効なプロバイダー名が表示されます。
|
||||
|
||||
### トレイメニュー構造
|
||||
|
||||

|
||||
|
||||
@@ -52,6 +52,20 @@ JSON 形式の設定内容(以下を含む):
|
||||
|
||||
これにより、CC Switch と CLI ツールの設定が常に同期されます。
|
||||
|
||||
## モデル自動取得
|
||||
|
||||
プロバイダーの編集時に、プロバイダーのエンドポイントから利用可能なモデルリストを自動取得できます:
|
||||
|
||||
1. API Key とエンドポイントアドレスが入力されていることを確認
|
||||
2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック
|
||||
3. グループ化されたドロップダウンからモデルを選択
|
||||
|
||||
詳しくは [2.1 プロバイダーの追加 - モデル自動取得](./2.1-add.md#モデル自動取得) をご覧ください。
|
||||
|
||||
## 共通設定クイックトグル(Claude)
|
||||
|
||||
Claude プロバイダーの編集時、JSON エディタの上部にツール検索、自動アップグレード無効化、チームメイト、高強度などの共通設定のクイックトグルスイッチが利用できます。詳しくは [2.1 プロバイダーの追加 - Claude 共通設定クイックトグル](./2.1-add.md#claude-共通設定クイックトグル) をご覧ください。
|
||||
|
||||
## API Key の変更
|
||||
|
||||
プロバイダーの編集時に、**API Key** 入力ボックスから直接変更できます:
|
||||
|
||||
@@ -118,8 +118,27 @@ CC Switch は強力な検索とフィルタリング機能を提供していま
|
||||
|
||||
### アンインストールの効果
|
||||
|
||||
- ローカルのスキルフォルダを削除
|
||||
- インストール状態を更新
|
||||
- **自動バックアップ**:削除前にスキルが `~/.cc-switch/skill-backups/` にバックアップされる
|
||||
- すべてのアプリディレクトリ(Claude、Codex、Gemini、OpenCode)からスキルを削除
|
||||
- SSOT ディレクトリ(`~/.cc-switch/skills/`)からスキルを削除
|
||||
- データベースからスキルレコードを削除
|
||||
|
||||
### バックアップから復元
|
||||
|
||||
以前アンインストールしたスキルを復元する場合:
|
||||
|
||||
1. Skills ページを開く
|
||||
2. **バックアップから復元** ボタンをクリック
|
||||
3. リスト(スキル名とバックアップ日が表示)から復元したいバックアップを選択
|
||||
4. スキルが復元され、現在のアプリで有効化される
|
||||
|
||||
### バックアップの削除
|
||||
|
||||
古いスキルバックアップを削除するには:
|
||||
|
||||
1. 復元ダイアログで削除したいバックアップを見つける
|
||||
2. バックアップエントリの横にある **削除** ボタンをクリック
|
||||
3. 削除を確認(この操作は取り消せません)
|
||||
|
||||
## リポジトリ管理
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 3.4 セッションマネージャー
|
||||
|
||||
セッションマネージャーでは、対応するすべての CLI ツールの会話セッションを一か所で閲覧、検索、管理できます。
|
||||
|
||||
## 対応アプリ
|
||||
|
||||
| アプリ | セッション保存場所 |
|
||||
|------|----------|
|
||||
| Claude Code | `~/.cache/claude/projects/*.jsonl` |
|
||||
| Codex | Codex 設定のセッションディレクトリ |
|
||||
| OpenCode | `~/.local/share/opencode/`(JSON または SQLite) |
|
||||
| OpenClaw | `~/.openclaw/agents/<agent>/sessions/*.jsonl` |
|
||||
| Gemini CLI | `~/.cache/gemini/tmp/<project_hash>/chats/` |
|
||||
|
||||
## セッションマネージャーを開く
|
||||
|
||||
メインナビゲーションバーの **セッション** ボタンをクリックします。
|
||||
|
||||
> **注意**:セッションボタンは対応する 5 つのアプリすべてで表示されます。
|
||||
|
||||
## インターフェースのレイアウト
|
||||
|
||||
セッションマネージャーは **2 カラムレイアウト** を採用しています:
|
||||
|
||||
- **左パネル**:検索とフィルターツールバー付きのセッションリスト
|
||||
- **右パネル**:選択したセッションの詳細と会話履歴
|
||||
|
||||
### セッションリスト(左パネル)
|
||||
|
||||
各セッションエントリには以下が表示されます:
|
||||
- プロバイダーアイコン
|
||||
- セッションタイトル
|
||||
- 最終アクティブ時間(相対形式、例:「5分前」)
|
||||
|
||||
### セッション詳細(右パネル)
|
||||
|
||||
セッションを選択すると、右パネルに以下が表示されます:
|
||||
- **タイトル**:セッションタイトル、プロジェクトディレクトリ名、またはセッション ID から取得
|
||||
- **最終アクティブ日時**:完全なタイムスタンプ
|
||||
- **プロジェクトディレクトリ**:クリックでフルパスをコピー(ベース名とツールチップでフルパスを表示)
|
||||
- **再開コマンド**:利用可能な場合、モノスペースフォントで表示
|
||||
- **会話履歴**:メッセージの全文記録
|
||||
|
||||
## 検索とフィルタリング
|
||||
|
||||
### 全文検索
|
||||
|
||||
左パネル上部の検索ボックスを使用して、以下の項目を横断的に検索できます:
|
||||
- セッション ID
|
||||
- タイトル
|
||||
- サマリー
|
||||
- プロジェクトディレクトリ
|
||||
- ソースファイルパス
|
||||
|
||||
前方一致検索に対応し、リアルタイムで結果をフィルタリングします。**Esc** で検索をクリアできます。
|
||||
|
||||
### プロバイダーフィルター
|
||||
|
||||
左パネル右上のプロバイダーフィルタードロップダウンをクリックして、アプリ別にフィルタリングします:
|
||||
- **すべて** — すべてのプロバイダーのセッションを表示
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
- **OpenClaw**
|
||||
- **Gemini CLI**
|
||||
|
||||
フィルターは検索と組み合わせて使用できます。
|
||||
|
||||
### 更新
|
||||
|
||||
更新ボタン(循環矢印アイコン)をクリックすると、すべてのプロバイダーディレクトリを再スキャンして新しいセッションや削除済みセッションを検出します。
|
||||
|
||||
## セッション操作
|
||||
|
||||
### セッションの再開
|
||||
|
||||
選択したセッションの **再開** ボタン(再生アイコン)をクリックして、会話を続行します。
|
||||
|
||||
**macOS の場合:**
|
||||
- CC Switch は設定済みのターミナルで再開コマンドを起動します
|
||||
- ターミナルはセッションのプロジェクトディレクトリで開きます
|
||||
- ターミナルの起動に失敗した場合、コマンドがクリップボードにコピーされます
|
||||
|
||||
**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty
|
||||
|
||||
**その他のプラットフォーム:**
|
||||
- 再開コマンドがクリップボードにコピーされます
|
||||
- ターミナルに貼り付けてセッションを再開してください
|
||||
|
||||
> 再開コマンドが利用できないセッションでは、再開ボタンは無効になります。
|
||||
|
||||
### セッションの削除
|
||||
|
||||
**削除** ボタン(ゴミ箱アイコン)をクリックすると、セッションファイルが完全に削除されます。削除前に確認ダイアログが表示されます。
|
||||
|
||||
> ローカルソースパスのないセッション(不変のセッションなど)は削除できません。
|
||||
|
||||
### 一括操作
|
||||
|
||||
複数のセッションを一度に管理するには:
|
||||
|
||||
1. 左パネルツールバーの **一括モード** ボタン(チェックボックスアイコン)をクリック
|
||||
2. 表示されるチェックボックスでセッションを選択
|
||||
3. **すべて選択** でフィルタリング結果をすべて選択、または **クリア** で選択解除
|
||||
4. **一括削除**(赤いゴミ箱アイコン)をクリックして選択したすべてのセッションを削除
|
||||
|
||||
削除前に件数を表示する確認ダイアログが表示されます。結果には成功した削除件数と失敗件数が報告されます。
|
||||
|
||||
## 会話履歴
|
||||
|
||||
### メッセージの表示
|
||||
|
||||
メッセージは役割ごとに色分けされます:
|
||||
- **ユーザー** メッセージ:緑、左寄せ
|
||||
- **AI**(アシスタント)メッセージ:青、右寄せ
|
||||
- **システム** メッセージ:アンバー
|
||||
- **ツール** メッセージ:パープル
|
||||
|
||||
### 目次
|
||||
|
||||
長い会話の場合、目次機能が利用できます:
|
||||
- **デスクトップ(XL+ 画面)**:右側のサイドバーにユーザーメッセージのプレビューを表示
|
||||
- **小さい画面**:右下のフローティングボタン(リストアイコン)をクリックするとダイアログが開く
|
||||
|
||||
エントリをクリックすると該当メッセージにスクロールし、一時的にハイライト表示されます。
|
||||
|
||||
## ヒント
|
||||
|
||||
- セッションは最終アクティブ時間の新しい順にソートされます
|
||||
- セッション数バッジは検索やフィルタリングに応じて更新されます
|
||||
- OpenCode のセッションは JSON ファイルと SQLite データベースの両方から取得される場合があります(重複は自動的に除去されます)
|
||||
@@ -0,0 +1,85 @@
|
||||
# 3.5 ワークスペースファイルとデイリーメモリー
|
||||
|
||||
## 概要
|
||||
|
||||
ワークスペースパネルは、**OpenClaw** 向けのファイル管理とデイリーメモリー機能を提供します。ワークスペース設定ファイルの編集やデイリーメモリージャーナルの管理が可能です。
|
||||
|
||||
> この機能は OpenClaw 専用です。ワークスペースボタンは、OpenClaw が選択されている場合にナビゲーションバーに表示されます。
|
||||
|
||||
## ワークスペースファイル
|
||||
|
||||
### ファイルの保存場所
|
||||
|
||||
すべてのワークスペースファイルは `~/.openclaw/workspace/` に保存されます。
|
||||
|
||||
パネル上部のディレクトリパスをクリックすると、ファイルマネージャーで開きます。
|
||||
|
||||
### 利用可能なファイル
|
||||
|
||||
CC Switch は 9 つのワークスペースファイルを管理しており、それぞれ特定の役割を持っています:
|
||||
|
||||
| ファイル | 説明 |
|
||||
|------|------|
|
||||
| **AGENTS.md** | エージェントの定義と設定 |
|
||||
| **SOUL.md** | システムのソウル/パーソナリティ設定 |
|
||||
| **USER.md** | ユーザープロファイル情報 |
|
||||
| **IDENTITY.md** | アイデンティティとロールの定義 |
|
||||
| **TOOLS.md** | 利用可能なツールの設定 |
|
||||
| **MEMORY.md** | システムメモリー |
|
||||
| **HEARTBEAT.md** | ハートビート設定 |
|
||||
| **BOOTSTRAP.md** | ブートストラップシーケンス |
|
||||
| **BOOT.md** | ブート設定 |
|
||||
|
||||
### ファイルステータス
|
||||
|
||||
各ファイルにはステータスインジケーターが表示されます:
|
||||
- **緑のチェックマーク**:ファイルがディスク上に存在
|
||||
- **空の円**:ファイルがまだ存在しない(初回保存時に作成)
|
||||
|
||||
### ファイルの編集
|
||||
|
||||
1. ファイルカードをクリックして Markdown エディタを開く
|
||||
2. コンテンツを編集
|
||||
3. **保存** をクリックしてディスクに書き込み
|
||||
|
||||
ファイルがまだ存在しない場合は、初回保存時に作成されます。
|
||||
|
||||
## デイリーメモリー
|
||||
|
||||
デイリーメモリー機能は、`~/.openclaw/workspace/memory/` に保存される日付別のジャーナルシステムを提供します。
|
||||
|
||||
### デイリーメモリーへのアクセス
|
||||
|
||||
ワークスペースファイルグリッドの **デイリーメモリー** カードをクリックして、メモリーパネルを開きます。
|
||||
|
||||
### ファイルリスト
|
||||
|
||||
パネルには日付の新しい順にすべてのデイリーメモリーファイルが表示されます。各エントリには以下が表示されます:
|
||||
- **日付**(ファイル名から変換、例:`2026-04-01.md`)
|
||||
- **ファイルサイズ**
|
||||
- **プレビュー**(コンテンツの最初の 2 行)
|
||||
|
||||
### 今日のノートを作成
|
||||
|
||||
**今日のノートを作成** ボタンをクリックすると:
|
||||
- 今日の日付(`YYYY-MM-DD.md`)で新しいノートを開く
|
||||
- 今日のノートがすでに存在する場合は、編集用に開く
|
||||
- ファイルは保存をクリックした後に保存される
|
||||
|
||||
### 検索
|
||||
|
||||
すべてのデイリーメモリーファイルを横断検索できます:
|
||||
|
||||
1. **Cmd/Ctrl+F** を押すか、検索アイコンをクリック
|
||||
2. 検索語を入力
|
||||
3. 結果には以下が表示されます:
|
||||
- ファイルごとのマッチ数
|
||||
- マッチした行のスニペットプレビュー
|
||||
- ファイルの日付とサイズ
|
||||
|
||||
**Esc** で検索を閉じます。
|
||||
|
||||
### 編集と削除
|
||||
|
||||
- **編集**:ファイルエントリをクリックして Markdown エディタで開く
|
||||
- **削除**:ファイルエントリにマウスをホバーして削除アイコンをクリック。確認ダイアログが表示されます(削除は取り消せません)。
|
||||
@@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1"
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
## API フォーマット変換
|
||||
|
||||
プロキシは、Anthropic 以外のフォーマットが設定されたプロバイダーに対して、API フォーマットの自動変換をサポートします。これにより、OpenAI 互換 API のみをサポートするプロバイダーを Claude Code で使用できます。
|
||||
|
||||
| プロバイダー API フォーマット | プロキシの動作 |
|
||||
|------|------|
|
||||
| **Anthropic Messages** | パススルー(変換なし) |
|
||||
| **OpenAI Chat Completions** | Anthropic リクエストを OpenAI Chat フォーマットに変換し、レスポンスを逆変換 |
|
||||
| **OpenAI Responses API** | Anthropic リクエストを OpenAI Responses フォーマットに変換し、レスポンスを逆変換 |
|
||||
|
||||
API フォーマットはプロバイダーごとに、Claude プロバイダーの追加・編集時の[高度なオプション](../2-providers/2.1-add.md#api-フォーマットclaude-のみ)で設定します。
|
||||
|
||||
> **注意**:フォーマット変換にはプロキシがアプリ接管有効の状態で稼働している必要があります。変換はストリーミングと非ストリーミングの両方のリクエストに対応しています。
|
||||
|
||||
## プロキシの停止
|
||||
|
||||
### 方法 1:メイン画面のスイッチ
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
|
||||
```
|
||||
~/.cc-switch/
|
||||
├── cc-switch.db # SQLite データベース
|
||||
├── cc-switch.db # SQLite データベース(SSOT)
|
||||
├── settings.json # デバイスレベルの設定
|
||||
└── backups/ # 自動バックアップ
|
||||
├── backup-20251230-120000.json
|
||||
├── backup-20251229-180000.json
|
||||
└── ...
|
||||
├── skills/ # スキル SSOT ディレクトリ
|
||||
├── skill-backups/ # スキルバックアップ(アンインストール時に作成)
|
||||
└── db_backup_*.db # データベースバックアップ
|
||||
```
|
||||
|
||||
### データベースの内容
|
||||
|
||||
@@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage
|
||||
2. 最新版を手動でダウンロードしてインストール
|
||||
3. Homebrew を使用する場合:`brew upgrade --cask cc-switch`
|
||||
|
||||
## ライトウェイトモード
|
||||
|
||||
### ライトウェイトモードに入るには?
|
||||
|
||||
システムトレイメニューから「ライトウェイトモード」をトグルします。メインウィンドウが閉じ、CC Switch はトレイ専用アプリとして動作します。再度トグルするか「メインウィンドウを開く」をクリックすると終了します。
|
||||
|
||||
### ライトウェイトモードではメモリ使用量が少なくなる?
|
||||
|
||||
はい。ライトウェイトモードではメインウィンドウとその Web ビューを破棄するため、トレイメニュー機能を維持しながらメモリ使用量を大幅に削減します。
|
||||
|
||||
## ヘルプの入手
|
||||
|
||||
### Issue の提出
|
||||
|
||||
@@ -24,7 +24,9 @@ CC Switch ユーザーマニュアル
|
||||
├── 3. 拡張機能
|
||||
│ ├── 3.1 MCP サーバー管理
|
||||
│ ├── 3.2 Prompts プロンプト管理
|
||||
│ └── 3.3 Skills スキル管理
|
||||
│ ├── 3.3 Skills スキル管理
|
||||
│ ├── 3.4 セッションマネージャー
|
||||
│ └── 3.5 ワークスペースとメモリー
|
||||
│
|
||||
├── 4. プロキシと高可用性
|
||||
│ ├── 4.1 プロキシサービス
|
||||
@@ -69,6 +71,8 @@ CC Switch ユーザーマニュアル
|
||||
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP プロトコル、サーバーの追加、アプリバインド |
|
||||
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | プリセットの作成、有効化の切り替え、スマートバックフィル |
|
||||
| [3.3-skills.md](./3-extensions/3.3-skills.md) | スキルの発見、インストール・アンインストール、リポジトリ管理 |
|
||||
| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | セッションマネージャー:閲覧、検索、再開、削除 |
|
||||
| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | ワークスペースファイルとデイリーメモリー(OpenClaw) |
|
||||
|
||||
### 4. プロキシと高可用性
|
||||
|
||||
@@ -99,9 +103,9 @@ CC Switch ユーザーマニュアル
|
||||
|
||||
## バージョン情報
|
||||
|
||||
- ドキュメントバージョン:v3.12.0
|
||||
- 最終更新:2026-03-09
|
||||
- CC Switch v3.12.0+ 対応
|
||||
- ドキュメントバージョン:v3.12.3
|
||||
- 最終更新:2026-04-04
|
||||
- CC Switch v3.12.3+ 対応
|
||||
|
||||
## コントリビュート
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
| 菜单项 | 功能 |
|
||||
|--------|------|
|
||||
| 打开主界面 | 显示主窗口并聚焦 |
|
||||
| 应用分组 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组显示供应商 |
|
||||
| 供应商列表 | 点击切换,当前启用的显示勾选标记 |
|
||||
| 应用子菜单 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组的折叠子菜单(如 "Claude · PackyCode") |
|
||||
| 供应商列表 | 在每个子菜单内,点击切换,当前启用的显示勾选标记 |
|
||||
| 轻量模式 | 勾选框切换,进入/退出仅托盘运行模式 |
|
||||
| 退出 | 完全退出应用 |
|
||||
|
||||
> **注意**:每个应用子菜单的标题会显示当前供应商名称(如 "Claude · PackyCode")。没有配置供应商的应用会显示禁用的"(无供应商)"条目。应用可见性由设置中的"应用可见性"选项控制。
|
||||
|
||||
### 多语言支持
|
||||
|
||||
托盘菜单支持三种语言,根据设置自动切换:
|
||||
@@ -114,6 +117,17 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
### 轻量模式
|
||||
|
||||
托盘菜单包含 **轻量模式** 切换开关(勾选框)。启用后:
|
||||
|
||||
- 主窗口关闭以释放资源
|
||||
- 应用仅在系统托盘中运行
|
||||
- 你仍可通过托盘子菜单切换供应商
|
||||
- 在 macOS 上,Dock 图标也会隐藏
|
||||
|
||||
要退出轻量模式,取消勾选该选项或点击"打开主界面" — 主窗口将被重建并显示。
|
||||
|
||||
### 使用场景
|
||||
|
||||
托盘切换供应商无需打开主界面,适合:
|
||||
@@ -121,6 +135,7 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
- 频繁切换供应商
|
||||
- 主窗口最小化时快速操作
|
||||
- 后台运行时管理配置
|
||||
- 使用轻量模式以最小化资源占用
|
||||
|
||||
## 设置页面
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
| 应用 | 生效方式 |
|
||||
|------|----------|
|
||||
| Claude Code | ✅ 即时生效(支持热重载) |
|
||||
| Codex | 需关闭并重新打开终端 |
|
||||
| Codex | 需要关闭并重新打开终端 |
|
||||
| Gemini | ✅ 即时生效(每次请求重新读取配置) |
|
||||
| OpenCode | 需要关闭并重新打开终端 |
|
||||
| OpenClaw | 需要关闭并重新打开终端 |
|
||||
|
||||
### Claude Code 首次安装提示
|
||||
|
||||
@@ -64,6 +66,14 @@ codex
|
||||
# Gemini - 启动后输入测试问题
|
||||
gemini
|
||||
> 你好,请简单介绍一下自己
|
||||
|
||||
# OpenCode - 启动后输入测试问题
|
||||
opencode
|
||||
> 你好,请简单介绍一下自己
|
||||
|
||||
# OpenClaw - 启动后输入测试问题
|
||||
openclaw
|
||||
> 你好,请简单介绍一下自己
|
||||
```
|
||||
|
||||
如果 AI 能正常回复,说明配置成功。
|
||||
|
||||
@@ -167,37 +167,74 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
|
||||
|
||||
### 备份与恢复
|
||||
|
||||
管理自动备份:
|
||||
备份管理面板提供对数据库备份的全面控制。
|
||||
|
||||
| 配置 | 说明 |
|
||||
| -------- | -------------------------- |
|
||||
| 备份间隔 | 自动备份的时间间隔(小时) |
|
||||
| 保留数量 | 保留的备份份数 |
|
||||
#### 自动备份设置
|
||||
|
||||
支持查看备份列表和从备份恢复。
|
||||
| 配置 | 选项 | 默认值 |
|
||||
| -------- | ---------------------------------- | --------- |
|
||||
| 备份间隔 | 禁用、6h、12h、24h、48h、7d | 24 小时 |
|
||||
| 保留数量 | 3、5、10、15、20、30、50 | 10 个备份 |
|
||||
|
||||
设置间隔后,CC Switch 会按计划自动备份数据库。超出保留数量的旧备份会自动删除。
|
||||
|
||||
#### 备份列表
|
||||
|
||||
面板显示所有现有备份,包含:
|
||||
- **显示名称**(根据时间戳自动生成,如 `db_backup_20260315_143000`)
|
||||
- **创建时间**
|
||||
- **文件大小**(如 "1.5 MB")
|
||||
|
||||
#### 备份操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------ |
|
||||
| **立即备份** | 立即创建一个备份 |
|
||||
| **恢复** | 从选定的备份恢复数据库。恢复前会自动创建当前数据库的安全备份 |
|
||||
| **重命名** | 修改备份的显示名称 |
|
||||
| **删除** | 永久删除备份(需确认) |
|
||||
|
||||
> ⚠️ **重要**:恢复备份会覆盖当前数据库。恢复操作前始终会自动创建安全备份,以便在需要时恢复。
|
||||
|
||||
### 云同步(WebDAV)
|
||||
|
||||
通过 WebDAV 协议在多台设备间同步配置。
|
||||
通过 WebDAV 协议在多台设备间同步配置。使用 **v2 协议**,支持双层版本控制,提高可靠性。
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| -------- | ------------------------------------- |
|
||||
| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义 |
|
||||
| 服务地址 | WebDAV 服务器 URL |
|
||||
| 用户名 | 登录用户名 |
|
||||
| 密码 | 登录密码(应用专用密码) |
|
||||
| 远程目录 | 远程存储路径(默认 `cc-switch-sync`) |
|
||||
| 配置名称 | 设备配置文件名(默认 `default`) |
|
||||
| 自动同步 | 开启后自动上传变更 |
|
||||
| 配置项 | 说明 |
|
||||
| -------- | ---------------------------------------------------------------------------- |
|
||||
| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义 |
|
||||
| 服务地址 | WebDAV 服务器 URL |
|
||||
| 用户名 | 登录用户名 |
|
||||
| 密码 | 登录密码(应用专用密码;已保存的凭据在未修改时会被保留) |
|
||||
| 远程目录 | 远程存储路径(默认 `cc-switch-sync`) |
|
||||
| 配置名称 | 设备配置文件名(默认 `default`) |
|
||||
| 自动同步 | 开启后按配置的间隔自动同步 |
|
||||
|
||||
操作:
|
||||
#### 操作
|
||||
|
||||
- **测试连接**:验证 WebDAV 配置是否正确
|
||||
- **保存**:保存配置并自动测试
|
||||
- **上传**:将本地数据上传到远程
|
||||
- **下载**:从远程下载数据到本地
|
||||
| 操作 | 说明 |
|
||||
| ------------ | -------------------------------------------------------------------------------------------- |
|
||||
| **测试连接** | 验证 WebDAV URL、用户名和密码是否有效 |
|
||||
| **上传** | 将本地数据库上传到远程,显示进度指示器 |
|
||||
| **下载** | 下载远程数据库。下载前显示远程快照信息(协议版本、数据库版本、时间戳、大小)供确认。覆盖本地数据库前会自动创建安全备份 |
|
||||
|
||||
> ⚠️ **注意**:上传会覆盖远程数据,下载会覆盖本地数据。操作前请确认。
|
||||
#### 自动同步
|
||||
|
||||
启用 **自动同步** 后:
|
||||
- 首次激活时会显示确认对话框
|
||||
- CC Switch 按配置的间隔自动将数据库同步到 WebDAV
|
||||
- 面板中显示同步状态
|
||||
|
||||
#### 远程快照信息
|
||||
|
||||
下载前,CC Switch 会显示远程快照的详细信息:
|
||||
- 协议版本(v2)
|
||||
- 数据库兼容版本
|
||||
- 远程备份的时间戳
|
||||
- 文件大小
|
||||
- 兼容性状态(不兼容时显示警告)
|
||||
|
||||
> ⚠️ **注意**:上传会覆盖远程数据,下载会覆盖本地数据。下载前始终会自动创建安全备份。
|
||||
|
||||
### 日志配置
|
||||
|
||||
|
||||
@@ -152,6 +152,22 @@
|
||||
| AWS Bedrock | AWS Bedrock 服务 |
|
||||
| OpenAI Compatible | OpenAI 兼容接口 |
|
||||
|
||||
## 自动获取模型
|
||||
|
||||
添加或编辑供应商时,可以自动从供应商端点获取可用模型列表:
|
||||
|
||||
1. 确保已填写 **API Key** 和 **端点地址**
|
||||
2. 点击模型输入框旁的 **获取模型** 按钮(下载图标)
|
||||
3. CC Switch 调用供应商的 `/v1/models` 端点获取模型列表
|
||||
4. 从按供应商分组的下拉菜单中选择模型
|
||||
|
||||
此功能适用于所有支持 OpenAI 兼容 `/v1/models` API 的供应商。Claude、Codex、Gemini、OpenCode 和 OpenClaw 供应商均可使用。
|
||||
|
||||
**常见错误**:
|
||||
- **认证失败(401/403)**:检查你的 API Key
|
||||
- **端点不支持(404/405)**:该供应商未提供 `/v1/models` 端点
|
||||
- **超时**:端点响应缓慢,请稍后重试
|
||||
|
||||
## 自定义配置
|
||||
|
||||
选择「自定义」预设后,需要手动编辑 JSON 配置。
|
||||
@@ -316,6 +332,45 @@ CC Switch 支持两种方式导入供应商配置:
|
||||
|
||||
## 高级选项
|
||||
|
||||
### API 格式(仅 Claude)
|
||||
|
||||
添加使用第三方 API 的 Claude 供应商时,可能需要在高级选项中选择正确的 **API 格式**:
|
||||
|
||||
| 格式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **Anthropic Messages** | 原生 Anthropic API 格式(默认) | 直接 Anthropic API 或兼容代理 |
|
||||
| **OpenAI Chat Completions** | OpenAI Chat API 格式,由代理自动转换 | 供应商仅支持 OpenAI Chat 格式 |
|
||||
| **OpenAI Responses API** | OpenAI Responses API 格式,由代理自动转换 | 供应商仅支持 OpenAI Responses 格式 |
|
||||
|
||||
> **注意**:API 格式转换由代理服务处理。使用非 Anthropic 格式时,需要开启代理并启用应用接管才能正确转换请求/响应。详见 [4.1 代理服务](../4-proxy/4.1-service.md)。
|
||||
|
||||
当配置了非默认 API 格式时,高级选项区域会自动展开。
|
||||
|
||||
### Claude 通用配置快捷开关
|
||||
|
||||
编辑 Claude 供应商时,JSON 编辑器上方提供一组 **快捷开关**:
|
||||
|
||||
| 开关 | 效果 | 配置变更 |
|
||||
|------|------|----------|
|
||||
| **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` |
|
||||
| **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **高效能模式** | 将 effort 级别设为 high | 设置 `effortLevel = "high"` |
|
||||
| **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。
|
||||
|
||||
此外,**写入通用配置** 复选框可将全局配置片段合并到供应商中。点击 **编辑通用配置** 可自定义共享的配置片段。
|
||||
|
||||
### Codex 1M 上下文窗口
|
||||
|
||||
添加 Codex 供应商时,提供 **启用 1M 上下文窗口** 开关:
|
||||
|
||||
- **启用时**:在 config.toml 中设置 `model_context_window = 1000000` 并自动填充 `model_auto_compact_token_limit = 900000`
|
||||
- **禁用时**:移除这两个字段
|
||||
|
||||
开关开启后显示的文本框可自定义自动压缩限制值。
|
||||
|
||||
### 自定义图标
|
||||
|
||||
点击名称左侧的图标区域,可以:
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
### 操作步骤
|
||||
|
||||
1. 右键点击系统托盘的 CC Switch 图标
|
||||
2. 在菜单中找到对应应用(Claude/Codex/Gemini/OpenCode)
|
||||
3. 点击要切换的供应商名称
|
||||
2. 将鼠标悬停在对应应用的子菜单上(如 "Claude · 当前供应商")
|
||||
3. 点击要切换到的供应商名称
|
||||
4. 切换完成,托盘会短暂提示
|
||||
|
||||
> 供应商按应用类型(Claude/Codex/Gemini)组织到折叠子菜单中。子菜单标题显示当前激活的供应商名称。
|
||||
|
||||
### 托盘菜单结构
|
||||
|
||||

|
||||
|
||||
@@ -52,6 +52,20 @@ JSON 格式的配置内容,包括:
|
||||
|
||||
这确保了 CC Switch 和 CLI 工具的配置始终同步。
|
||||
|
||||
## 自动获取模型
|
||||
|
||||
编辑供应商时,可以自动从供应商端点获取可用模型列表:
|
||||
|
||||
1. 确保已填写 API Key 和端点地址
|
||||
2. 点击模型输入框旁的 **获取模型** 按钮(下载图标)
|
||||
3. 从分组下拉菜单中选择模型
|
||||
|
||||
详细说明请参阅 [2.1 添加供应商 — 自动获取模型](./2.1-add.md#自动获取模型)。
|
||||
|
||||
## 通用配置快捷开关(Claude)
|
||||
|
||||
编辑 Claude 供应商时,JSON 编辑器上方提供常用设置的快捷开关,包括工具搜索、禁用自动更新、Teammates、高效能模式等。详见 [2.1 添加供应商 — Claude 通用配置快捷开关](./2.1-add.md#claude-通用配置快捷开关)。
|
||||
|
||||
## 修改 API Key
|
||||
|
||||
编辑供应商时,可以直接在 **API Key** 输入框中修改:
|
||||
|
||||
@@ -118,8 +118,27 @@ CC Switch 提供强大的搜索和过滤功能:
|
||||
|
||||
### 卸载效果
|
||||
|
||||
- 删除本地技能文件夹
|
||||
- 更新安装状态
|
||||
- **自动备份**:删除前,技能会被备份到 `~/.cc-switch/skill-backups/`
|
||||
- 从所有应用目录(Claude、Codex、Gemini、OpenCode)移除技能
|
||||
- 从 SSOT 目录(`~/.cc-switch/skills/`)移除技能
|
||||
- 从数据库删除技能记录
|
||||
|
||||
### 从备份恢复
|
||||
|
||||
如需恢复之前卸载的技能:
|
||||
|
||||
1. 打开 Skills 页面
|
||||
2. 点击 **从备份恢复** 按钮
|
||||
3. 从列表中选择要恢复的备份(显示技能名称和备份日期)
|
||||
4. 技能将被恢复并为当前应用启用
|
||||
|
||||
### 删除备份
|
||||
|
||||
如需删除旧的技能备份:
|
||||
|
||||
1. 在恢复对话框中,找到要删除的备份
|
||||
2. 点击备份条目旁的 **删除** 按钮
|
||||
3. 确认删除 — 此操作不可撤销
|
||||
|
||||
## 仓库管理
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 3.4 会话管理器
|
||||
|
||||
会话管理器可让你在一处浏览、搜索和管理所有支持的 CLI 工具的对话会话。
|
||||
|
||||
## 支持的应用
|
||||
|
||||
| 应用 | 会话存储位置 |
|
||||
|------|-------------|
|
||||
| Claude Code | `~/.cache/claude/projects/*.jsonl` |
|
||||
| Codex | Codex 配置会话目录 |
|
||||
| OpenCode | `~/.local/share/opencode/`(JSON 或 SQLite) |
|
||||
| OpenClaw | `~/.openclaw/agents/<agent>/sessions/*.jsonl` |
|
||||
| Gemini CLI | `~/.cache/gemini/tmp/<project_hash>/chats/` |
|
||||
|
||||
## 打开会话管理器
|
||||
|
||||
点击主导航栏中的 **会话** 按钮。
|
||||
|
||||
> **注意**:会话按钮在所有五种应用模式下均可见。
|
||||
|
||||
## 界面布局
|
||||
|
||||
会话管理器采用 **双栏布局**:
|
||||
|
||||
- **左侧面板**:会话列表,包含搜索和过滤工具栏
|
||||
- **右侧面板**:选中会话的详情和对话历史
|
||||
|
||||
### 会话列表(左侧面板)
|
||||
|
||||
每个会话条目显示:
|
||||
- 供应商图标
|
||||
- 会话标题
|
||||
- 最后活跃时间(相对格式,如"5 分钟前")
|
||||
|
||||
### 会话详情(右侧面板)
|
||||
|
||||
选中会话后,右侧面板显示:
|
||||
- **标题**:来自会话标题、项目目录名称或会话 ID
|
||||
- **最后活跃日期/时间**:完整时间戳
|
||||
- **项目目录**:可点击复制完整路径(显示目录名,完整路径以提示框显示)
|
||||
- **恢复命令**:以等宽字体显示(如可用)
|
||||
- **对话历史**:完整消息记录
|
||||
|
||||
## 搜索与过滤
|
||||
|
||||
### 全文搜索
|
||||
|
||||
使用左侧面板顶部的搜索框,可搜索以下内容:
|
||||
- 会话 ID
|
||||
- 标题
|
||||
- 摘要
|
||||
- 项目目录
|
||||
- 源文件路径
|
||||
|
||||
搜索支持前缀匹配,实时过滤结果。按 **Esc** 清除搜索。
|
||||
|
||||
### 供应商过滤
|
||||
|
||||
点击左侧面板右上角的供应商过滤下拉菜单,按应用过滤:
|
||||
- **全部** — 显示所有供应商的会话
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
- **OpenClaw**
|
||||
- **Gemini CLI**
|
||||
|
||||
过滤可与搜索组合使用。
|
||||
|
||||
### 刷新
|
||||
|
||||
点击刷新按钮(圆形箭头图标),重新扫描所有供应商目录以发现新增或已删除的会话。
|
||||
|
||||
## 会话操作
|
||||
|
||||
### 恢复会话
|
||||
|
||||
点击选中会话上的 **恢复** 按钮(播放图标)继续对话。
|
||||
|
||||
**macOS 上**:
|
||||
- CC Switch 使用你偏好的终端启动恢复命令
|
||||
- 终端会在会话的项目目录中打开
|
||||
- 如果终端启动失败,命令会被复制到剪贴板
|
||||
|
||||
**支持的终端(macOS)**:Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty
|
||||
|
||||
**其他平台**:
|
||||
- 恢复命令会被复制到剪贴板
|
||||
- 在终端中粘贴即可恢复会话
|
||||
|
||||
> 如果会话没有可用的恢复命令,恢复按钮将被禁用。
|
||||
|
||||
### 删除会话
|
||||
|
||||
点击 **删除** 按钮(垃圾桶图标)永久删除会话文件。删除前会显示确认对话框。
|
||||
|
||||
> 没有本地源路径的会话(如不可变会话)无法删除。
|
||||
|
||||
### 批量操作
|
||||
|
||||
批量管理多个会话:
|
||||
|
||||
1. 点击左侧面板工具栏中的 **批量模式** 按钮(复选框图标)
|
||||
2. 使用出现的复选框选择会话
|
||||
3. 使用 **全选** 选择所有过滤结果,或使用 **清除** 取消选择
|
||||
4. 点击 **批量删除**(红色垃圾桶图标)删除所有选中的会话
|
||||
|
||||
删除前会显示确认对话框并展示数量。结果会报告成功删除的数量和失败的数量。
|
||||
|
||||
## 对话历史
|
||||
|
||||
### 消息显示
|
||||
|
||||
消息按角色以颜色区分:
|
||||
- **用户** 消息:绿色,左对齐
|
||||
- **AI**(助手)消息:蓝色,右对齐
|
||||
- **系统** 消息:琥珀色
|
||||
- **工具** 消息:紫色
|
||||
|
||||
### 目录导航
|
||||
|
||||
对于较长的对话,提供目录导航功能:
|
||||
- **桌面端(XL+ 屏幕)**:右侧边栏显示用户消息预览
|
||||
- **较小屏幕**:右下角浮动按钮(列表图标),点击打开对话框
|
||||
|
||||
点击任意条目可滚动到对应消息,消息会短暂高亮显示。
|
||||
|
||||
## 提示
|
||||
|
||||
- 会话按最后活跃时间排序(最新在前)
|
||||
- 会话数量徽章会随搜索和过滤实时更新
|
||||
- OpenCode 会话可能来自 JSON 文件和 SQLite 数据库 — 重复项会自动去重
|
||||
@@ -0,0 +1,85 @@
|
||||
# 3.5 工作区文件与每日记忆
|
||||
|
||||
## 概述
|
||||
|
||||
工作区面板为 **OpenClaw** 提供文件管理和每日记忆功能。你可以编辑工作区配置文件并维护每日记忆日志。
|
||||
|
||||
> 此功能为 OpenClaw 专属。当 OpenClaw 为当前选中的应用时,导航栏中会显示工作区按钮。
|
||||
|
||||
## 工作区文件
|
||||
|
||||
### 文件位置
|
||||
|
||||
所有工作区文件存储在 `~/.openclaw/workspace/`。
|
||||
|
||||
点击面板顶部的目录路径可在文件管理器中打开。
|
||||
|
||||
### 可用文件
|
||||
|
||||
CC Switch 管理 9 个工作区文件,每个文件有特定用途:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| **AGENTS.md** | Agent 定义与配置 |
|
||||
| **SOUL.md** | 系统灵魂/性格设置 |
|
||||
| **USER.md** | 用户资料信息 |
|
||||
| **IDENTITY.md** | 身份与角色定义 |
|
||||
| **TOOLS.md** | 可用工具配置 |
|
||||
| **MEMORY.md** | 系统记忆 |
|
||||
| **HEARTBEAT.md** | 心跳配置 |
|
||||
| **BOOTSTRAP.md** | 引导序列 |
|
||||
| **BOOT.md** | 启动配置 |
|
||||
|
||||
### 文件状态
|
||||
|
||||
每个文件显示状态指示器:
|
||||
- **绿色对勾**:文件已存在
|
||||
- **空心圆圈**:文件尚未创建(首次保存时创建)
|
||||
|
||||
### 编辑文件
|
||||
|
||||
1. 点击任意文件卡片打开 Markdown 编辑器
|
||||
2. 编辑内容
|
||||
3. 点击 **保存** 将更改写入磁盘
|
||||
|
||||
如果文件尚不存在,将在首次保存时创建。
|
||||
|
||||
## 每日记忆
|
||||
|
||||
每日记忆功能提供按日期组织的日志系统,存储在 `~/.openclaw/workspace/memory/`。
|
||||
|
||||
### 访问每日记忆
|
||||
|
||||
点击工作区文件网格中的 **每日记忆** 卡片,打开记忆面板。
|
||||
|
||||
### 文件列表
|
||||
|
||||
面板显示所有每日记忆文件,按日期排序(最新在前)。每个条目显示:
|
||||
- **日期**(从文件名格式化,如 `2026-04-01.md`)
|
||||
- **文件大小**
|
||||
- **预览**(内容的前 2 行)
|
||||
|
||||
### 创建今日笔记
|
||||
|
||||
点击 **创建今日** 按钮:
|
||||
- 打开以今日日期命名的新笔记(`YYYY-MM-DD.md`)
|
||||
- 如果今日笔记已存在,则打开进行编辑
|
||||
- 文件仅在点击保存后才会持久化
|
||||
|
||||
### 搜索
|
||||
|
||||
搜索所有每日记忆文件:
|
||||
|
||||
1. 按 **Cmd/Ctrl+F** 或点击搜索图标
|
||||
2. 输入搜索词
|
||||
3. 结果显示匹配的文件,包含:
|
||||
- 每个文件的匹配数量
|
||||
- 匹配行的片段预览
|
||||
- 文件日期和大小
|
||||
|
||||
按 **Esc** 关闭搜索。
|
||||
|
||||
### 编辑与删除
|
||||
|
||||
- **编辑**:点击文件条目在 Markdown 编辑器中打开
|
||||
- **删除**:将鼠标悬停在文件条目上,点击删除图标。会显示确认对话框 — 删除操作不可撤销。
|
||||
@@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1"
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
## API 格式转换
|
||||
|
||||
代理支持为配置了非 Anthropic 格式的供应商自动进行 API 格式转换。这使你可以将仅支持 OpenAI 兼容 API 的供应商用于 Claude Code。
|
||||
|
||||
| 供应商 API 格式 | 代理行为 |
|
||||
|-----------------|----------|
|
||||
| **Anthropic Messages** | 透传(不转换) |
|
||||
| **OpenAI Chat Completions** | 将 Anthropic 请求转换为 OpenAI Chat 格式,响应反向转换 |
|
||||
| **OpenAI Responses API** | 将 Anthropic 请求转换为 OpenAI Responses 格式,响应反向转换 |
|
||||
|
||||
API 格式在添加或编辑 Claude 供应商时于[高级选项](../2-providers/2.1-add.md#api-格式仅-claude)中按供应商配置。
|
||||
|
||||
> **注意**:格式转换需要代理运行并启用应用接管。转换同时支持流式和非流式请求。
|
||||
|
||||
## 停止代理
|
||||
|
||||
### 方式一:主界面开关
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
|
||||
```
|
||||
~/.cc-switch/
|
||||
├── cc-switch.db # SQLite 数据库
|
||||
├── cc-switch.db # SQLite 数据库(SSOT)
|
||||
├── settings.json # 设备级设置
|
||||
└── backups/ # 自动备份
|
||||
├── backup-20251230-120000.json
|
||||
├── backup-20251229-180000.json
|
||||
└── ...
|
||||
├── skills/ # 技能 SSOT 目录
|
||||
├── skill-backups/ # 技能备份(卸载时创建)
|
||||
└── db_backup_*.db # 数据库备份
|
||||
```
|
||||
|
||||
### 数据库内容
|
||||
|
||||
@@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage
|
||||
2. 手动下载最新版本安装
|
||||
3. 如使用 Homebrew:`brew upgrade --cask cc-switch`
|
||||
|
||||
## 轻量模式
|
||||
|
||||
### 如何进入轻量模式?
|
||||
|
||||
从系统托盘菜单切换"轻量模式"。主窗口关闭,CC Switch 仅作为托盘应用运行。再次切换或点击"打开主界面"即可退出。
|
||||
|
||||
### 轻量模式下应用占用更少内存?
|
||||
|
||||
是的。轻量模式会销毁主窗口及其 Web 视图,显著减少内存占用,同时保留托盘菜单功能。
|
||||
|
||||
## 获取帮助
|
||||
|
||||
### 提交 Issue
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
├── 3. 扩展功能
|
||||
│ ├── 3.1 MCP 服务器管理
|
||||
│ ├── 3.2 Prompts 提示词管理
|
||||
│ └── 3.3 Skills 技能管理
|
||||
│ ├── 3.3 Skills 技能管理
|
||||
│ ├── 3.4 会话管理器
|
||||
│ └── 3.5 工作区文件与每日记忆
|
||||
│
|
||||
├── 4. 代理与高可用
|
||||
│ ├── 4.1 代理服务
|
||||
@@ -69,6 +71,8 @@
|
||||
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 |
|
||||
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 |
|
||||
| [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 |
|
||||
| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | 会话浏览、搜索过滤、恢复与删除 |
|
||||
| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | OpenClaw 工作区文件、每日记忆 |
|
||||
|
||||
### 4. 代理与高可用
|
||||
|
||||
@@ -99,9 +103,9 @@
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 文档版本:v3.12.0
|
||||
- 最后更新:2026-03-09
|
||||
- 适用于 CC Switch v3.12.0+
|
||||
- 文档版本:v3.12.3
|
||||
- 最后更新:2026-04-04
|
||||
- 适用于 CC Switch v3.12.3+
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
Generated
+137
@@ -107,6 +107,26 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -718,6 +738,7 @@ name = "cc-switch"
|
||||
version = "3.12.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
"auto-launch",
|
||||
"axum",
|
||||
@@ -843,6 +864,15 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
@@ -1445,6 +1475,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1484,6 +1520,26 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
|
||||
dependencies = [
|
||||
"fax_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fax_derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1849,6 +1905,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@@ -2067,6 +2133,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -2457,6 +2534,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3088,6 +3166,7 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
@@ -3894,6 +3973,12 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -5766,6 +5851,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -6660,6 +6759,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -7397,6 +7502,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -7682,6 +7804,21 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
|
||||
@@ -39,6 +39,7 @@ dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
arboard = "3.6"
|
||||
flate2 = "1"
|
||||
brotli = "7"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
||||
}
|
||||
@@ -34,6 +34,22 @@ pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String>
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(text: String) -> Result<bool, String> {
|
||||
// Use spawn_blocking to avoid blocking the async runtime
|
||||
// Clipboard access can block on some platforms and may have thread/loop constraints
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("访问系统剪贴板失败: {e}"))?;
|
||||
clipboard
|
||||
.set_text(text)
|
||||
.map_err(|e| format!("写入系统剪贴板失败: {e}"))?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("剪贴板任务执行失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 检查更新
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod auth;
|
||||
mod coding_plan;
|
||||
mod config;
|
||||
mod copilot;
|
||||
mod deeplink;
|
||||
@@ -10,6 +11,7 @@ mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod model_fetch;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
@@ -20,6 +22,7 @@ mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod subscription;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
@@ -28,6 +31,7 @@ mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use auth::*;
|
||||
pub use coding_plan::*;
|
||||
pub use config::*;
|
||||
pub use copilot::*;
|
||||
pub use deeplink::*;
|
||||
@@ -37,6 +41,7 @@ pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use model_fetch::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
@@ -47,6 +52,7 @@ pub use session_manager::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 模型列表获取命令
|
||||
//!
|
||||
//! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。
|
||||
|
||||
use crate::services::model_fetch::{self, FetchedModel};
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn fetch_models_for_config(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use std::str::FromStr;
|
||||
|
||||
// 常量定义
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -158,29 +159,25 @@ pub async fn queryProviderUsage(
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查是否为 GitHub Copilot 模板类型,并解析绑定账号
|
||||
let (is_copilot_template, copilot_account_id) = {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
// 从数据库读取供应商信息,检查特殊模板类型
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
let provider = providers.get(&providerId);
|
||||
let usage_script = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref());
|
||||
let template_type = usage_script
|
||||
.and_then(|s| s.template_type.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
let provider = providers.get(&providerId);
|
||||
let is_copilot = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.and_then(|s| s.template_type.as_ref())
|
||||
.map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)
|
||||
.unwrap_or(false);
|
||||
let account_id = provider
|
||||
// ── GitHub Copilot 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_GITHUB_COPILOT {
|
||||
let copilot_account_id = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
|
||||
|
||||
(is_copilot, account_id)
|
||||
};
|
||||
|
||||
if is_copilot_template {
|
||||
// 使用 Copilot 专用 API
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let usage = match copilot_account_id.as_deref() {
|
||||
Some(account_id) => auth_manager
|
||||
@@ -211,6 +208,67 @@ pub async fn queryProviderUsage(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Coding Plan 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
|
||||
// 从供应商配置中提取 API Key 和 Base URL
|
||||
let settings_config = provider
|
||||
.map(|p| &p.settings_config)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let env = settings_config.get("env");
|
||||
let base_url = env
|
||||
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let api_key = env
|
||||
.and_then(|e| {
|
||||
e.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
// 将 SubscriptionQuota 转换为 UsageResult
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: quota.error,
|
||||
});
|
||||
}
|
||||
|
||||
let data: Vec<crate::provider::UsageData> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.map(|tier| {
|
||||
let total = 100.0;
|
||||
let used = tier.utilization;
|
||||
let remaining = total - used;
|
||||
crate::provider::UsageData {
|
||||
plan_name: Some(tier.name.clone()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total),
|
||||
used: Some(used),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: tier.resets_at.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 通用 JS 脚本路径 ──
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -247,6 +247,30 @@ pub async fn set_optimizer_config(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取 Copilot 优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_copilot_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::CopilotOptimizerConfig, String> {
|
||||
state
|
||||
.db
|
||||
.get_copilot_optimizer_config()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置 Copilot 优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_copilot_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::CopilotOptimizerConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_copilot_optimizer_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取日志配置
|
||||
#[tauri::command]
|
||||
pub async fn get_log_config(
|
||||
|
||||
@@ -26,6 +26,7 @@ pub async fn stream_check_provider(
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
provider,
|
||||
@@ -39,6 +40,7 @@ pub async fn stream_check_provider(
|
||||
provider,
|
||||
&config,
|
||||
auth_override,
|
||||
base_url_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await?;
|
||||
@@ -87,6 +89,8 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let base_url_override =
|
||||
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
&provider,
|
||||
@@ -108,6 +112,7 @@ pub async fn stream_check_all_providers(
|
||||
&provider,
|
||||
&config,
|
||||
auth_override,
|
||||
base_url_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
@@ -151,17 +156,7 @@ async fn resolve_copilot_auth_override(
|
||||
provider: &crate::provider::Provider,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|url| url.contains("githubcopilot.com"))
|
||||
.unwrap_or(false);
|
||||
let is_copilot = is_copilot_provider(provider);
|
||||
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
@@ -171,7 +166,7 @@ async fn resolve_copilot_auth_override(
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.github_account_id.clone());
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let token = match account_id.as_deref() {
|
||||
Some(id) => auth_manager
|
||||
@@ -190,6 +185,49 @@ async fn resolve_copilot_auth_override(
|
||||
)))
|
||||
}
|
||||
|
||||
async fn resolve_copilot_base_url_override(
|
||||
provider: &crate::provider::Provider,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
let is_copilot = is_copilot_provider(provider);
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_copilot || is_full_url {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let endpoint = match account_id.as_deref() {
|
||||
Some(id) => auth_manager.get_api_endpoint(id).await,
|
||||
None => auth_manager.get_default_api_endpoint().await,
|
||||
};
|
||||
|
||||
Ok(Some(endpoint))
|
||||
}
|
||||
|
||||
fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|url| url.contains("githubcopilot.com"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format_override(
|
||||
app_type: &AppType,
|
||||
provider: &crate::provider::Provider,
|
||||
@@ -237,3 +275,80 @@ async fn resolve_claude_api_format_override(
|
||||
|
||||
Ok(Some(api_format.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_copilot_provider;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn copilot_provider_detection_accepts_provider_type_or_base_url() {
|
||||
let typed_provider = Provider {
|
||||
id: "p1".to_string(),
|
||||
name: "typed".to_string(),
|
||||
settings_config: json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
assert!(is_copilot_provider(&typed_provider));
|
||||
|
||||
let url_provider = Provider {
|
||||
id: "p2".to_string(),
|
||||
name: "url".to_string(),
|
||||
settings_config: json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
assert!(is_copilot_provider(&url_provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copilot_full_url_metadata_is_available_for_override_guard() {
|
||||
let provider = Provider {
|
||||
id: "p3".to_string(),
|
||||
name: "relay".to_string(),
|
||||
settings_config: json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
is_full_url: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
assert!(is_copilot_provider(&provider));
|
||||
assert_eq!(
|
||||
provider.meta.as_ref().and_then(|meta| meta.is_full_url),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
|
||||
/// 查询官方订阅额度
|
||||
///
|
||||
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
|
||||
/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。
|
||||
#[tauri::command]
|
||||
pub async fn get_subscription_quota(tool: String) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::subscription::get_subscription_quota(&tool).await
|
||||
}
|
||||
@@ -270,6 +270,31 @@ impl Database {
|
||||
self.set_setting("optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- Copilot 优化器配置 ---
|
||||
|
||||
/// 获取 Copilot 优化器配置
|
||||
///
|
||||
/// 返回配置,如果不存在则返回默认值(默认开启)
|
||||
pub fn get_copilot_optimizer_config(
|
||||
&self,
|
||||
) -> Result<crate::proxy::types::CopilotOptimizerConfig, AppError> {
|
||||
match self.get_setting("copilot_optimizer_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析 Copilot 优化器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::CopilotOptimizerConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 Copilot 优化器配置
|
||||
pub fn set_copilot_optimizer_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::CopilotOptimizerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化 Copilot 优化器配置失败: {e}")))?;
|
||||
self.set_setting("copilot_optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- 日志配置 ---
|
||||
|
||||
/// 获取日志配置
|
||||
|
||||
@@ -229,6 +229,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
coding_plan_provider: None,
|
||||
};
|
||||
|
||||
Ok(Some(ProviderMeta {
|
||||
|
||||
@@ -867,11 +867,14 @@ pub fn run() {
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
commands::set_optimizer_config,
|
||||
commands::get_copilot_optimizer_config,
|
||||
commands::set_copilot_optimizer_config,
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::get_claude_plugin_status,
|
||||
commands::read_claude_plugin_config,
|
||||
commands::apply_claude_plugin_config,
|
||||
@@ -887,6 +890,9 @@ pub fn run() {
|
||||
// usage query
|
||||
commands::queryProviderUsage,
|
||||
commands::testUsageScript,
|
||||
// subscription quota
|
||||
commands::get_subscription_quota,
|
||||
commands::get_coding_plan_quota,
|
||||
// New MCP via config.json (SSOT)
|
||||
commands::get_mcp_config,
|
||||
commands::upsert_mcp_server_in_config,
|
||||
@@ -905,6 +911,8 @@ pub fn run() {
|
||||
commands::enable_prompt,
|
||||
commands::import_prompt_from_file,
|
||||
commands::get_current_prompt_file_content,
|
||||
// model list fetch (OpenAI-compatible /v1/models)
|
||||
commands::fetch_models_for_config,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
|
||||
@@ -106,6 +106,10 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
pub auto_query_interval: Option<u64>,
|
||||
/// Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "codingPlanProvider")]
|
||||
pub coding_plan_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
//! Copilot 请求优化器
|
||||
//!
|
||||
//! 解决 GitHub Copilot 代理消耗量异常问题(Issue #1813)。
|
||||
//!
|
||||
//! Copilot 使用 `x-initiator` 请求头区分「用户发起」和「agent 续写」:
|
||||
//! - `user`:计为一次 premium interaction(扣额度)
|
||||
//! - `agent`:视为上一次交互的延续(不额外扣费)
|
||||
//!
|
||||
//! 参考实现: https://github.com/caozhiyuan/copilot-api
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 请求分类结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CopilotClassification {
|
||||
/// "user" 或 "agent" — 映射到 x-initiator 请求头
|
||||
pub initiator: &'static str,
|
||||
/// 是否为 warmup/探针请求(可降级到小模型)
|
||||
pub is_warmup: bool,
|
||||
/// 是否为上下文压缩请求
|
||||
pub is_compact: bool,
|
||||
}
|
||||
|
||||
/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。
|
||||
///
|
||||
/// 分类算法(只检查最后一条消息,与参考实现 caozhiyuan/copilot-api 对齐):
|
||||
/// 1. 无消息 → "user"(安全默认,首次请求)
|
||||
/// 2. 最后消息 role=user:
|
||||
/// - content 中存在非 tool_result 类型 block → "user"
|
||||
/// - content 全部是 tool_result → "agent"
|
||||
/// - 匹配 compact 模式 → "agent"
|
||||
/// 3. 最后消息 role 非 user → "user"(安全默认)
|
||||
///
|
||||
/// Warmup 检测(与参考实现对齐):
|
||||
/// - 请求头中有 `anthropic-beta` + 无 tools + 非 compact → warmup
|
||||
///
|
||||
/// `compact_detection`:是否启用 compact 检测。为 false 时跳过,
|
||||
/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。
|
||||
pub fn classify_request(
|
||||
body: &Value,
|
||||
has_anthropic_beta: bool,
|
||||
compact_detection: bool,
|
||||
) -> CopilotClassification {
|
||||
let is_compact = compact_detection && is_compact_request(body);
|
||||
|
||||
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(msgs) if !msgs.is_empty() => msgs,
|
||||
_ => {
|
||||
return CopilotClassification {
|
||||
initiator: "user",
|
||||
is_warmup: is_warmup_request(body, has_anthropic_beta, false),
|
||||
is_compact: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let last_msg = &messages[messages.len() - 1];
|
||||
let role = last_msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
|
||||
// 只有 role=user 的消息需要细分
|
||||
if role != "user" {
|
||||
return CopilotClassification {
|
||||
initiator: "user",
|
||||
is_warmup: false,
|
||||
is_compact,
|
||||
};
|
||||
}
|
||||
|
||||
// 参考实现的判定逻辑(Messages API 路径):
|
||||
// 如果 content 是数组,检查是否有非 tool_result 的 block
|
||||
// 有 → "user",全是 tool_result → "agent"
|
||||
// 如果 content 是字符串 → "user"
|
||||
let is_user_initiated = match last_msg.get("content") {
|
||||
Some(content) if content.is_array() => {
|
||||
let blocks = content.as_array().unwrap();
|
||||
// 存在非 tool_result block → 用户发起
|
||||
blocks
|
||||
.iter()
|
||||
.any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
|
||||
}
|
||||
Some(content) if content.is_string() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let initiator = if !is_user_initiated || is_compact {
|
||||
"agent"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
|
||||
CopilotClassification {
|
||||
initiator,
|
||||
is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact),
|
||||
is_compact,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测是否为 warmup/探针请求(适合降级到小模型)。
|
||||
///
|
||||
/// 与参考实现对齐,三个条件同时满足:
|
||||
/// 1. 请求头有 `anthropic-beta`(Claude Code warmup 探针的标志)
|
||||
/// 2. 无 tools 定义
|
||||
/// 3. 非 compact 请求
|
||||
fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -> bool {
|
||||
if !has_anthropic_beta || is_compact {
|
||||
return false;
|
||||
}
|
||||
// 无工具定义
|
||||
!matches!(body.get("tools"), Some(tools) if tools.is_array() && !tools.as_array().unwrap().is_empty())
|
||||
}
|
||||
|
||||
/// 检测是否为 Claude Code 上下文压缩/compact 请求。
|
||||
///
|
||||
/// 只匹配 Claude Code **内部生成**的机器特征,不匹配用户可能手动输入的通用短语,
|
||||
/// 避免将真实用户请求误标为 agent。
|
||||
///
|
||||
/// 强特征来源:
|
||||
/// 1. system prompt — Claude Code compact 模式会设置专用 system prompt,用户无法手动设置
|
||||
/// 2. "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools." — 机器指令
|
||||
/// 3. 同时包含 "Pending Tasks:" 和 "Current Work:" — Claude Code compact 的结构标记
|
||||
fn is_compact_request(body: &Value) -> bool {
|
||||
// 信号 1: system prompt 以 Claude Code compact 专用前缀开头
|
||||
// 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号
|
||||
if let Some(system) = body.get("system") {
|
||||
let system_text = if let Some(s) = system.as_str() {
|
||||
s.to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if system_text
|
||||
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 信号 2 & 3: 检查最后一条用户消息中的机器生成特征
|
||||
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(msgs) => msgs,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if let Some(last_msg) = messages.last() {
|
||||
if last_msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = extract_text_from_message(last_msg);
|
||||
|
||||
// 信号 2: Claude Code compact 的机器指令(大小写敏感,精确匹配)
|
||||
if text.contains("CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 信号 3: Claude Code compact 的结构标记(两个同时出现才算)
|
||||
if text.contains("Pending Tasks:") && text.contains("Current Work:") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 合并用户消息中的 tool_result 和 text block。
|
||||
///
|
||||
/// 与参考实现 `mergeToolResultForClaude` 对齐:
|
||||
///
|
||||
/// **消息内部合并**(核心):在单条 user 消息内,将 text block 吸收进 tool_result block,
|
||||
/// 使整条消息只剩 tool_result 类型 block。这样 Copilot 不会将其视为用户发起的交互。
|
||||
///
|
||||
/// 场景:Claude Code 在 skill 调用、edit hook、plan 提醒等场景下,会发送混合了
|
||||
/// tool_result + text 的用户消息。text block 的存在让 Copilot 将其计为 premium request。
|
||||
///
|
||||
/// **跨消息合并**(补充):连续的 tool_result-only 用户消息合并为一条。
|
||||
pub fn merge_tool_results(mut body: Value) -> Value {
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(msgs) if !msgs.is_empty() => msgs,
|
||||
_ => return body,
|
||||
};
|
||||
|
||||
// Phase 1: 消息内部合并 — 将 text block 吸收进 tool_result block
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let content = match msg.get("content").and_then(|c| c.as_array()) {
|
||||
Some(blocks) => blocks,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// 分离 tool_result 和 text block
|
||||
let mut tool_results: Vec<Value> = Vec::new();
|
||||
let mut text_blocks: Vec<Value> = Vec::new();
|
||||
let mut valid = true;
|
||||
|
||||
for block in content {
|
||||
match block.get("type").and_then(|t| t.as_str()) {
|
||||
Some("tool_result") => tool_results.push(block.clone()),
|
||||
Some("text") => text_blocks.push(block.clone()),
|
||||
_ => {
|
||||
// 存在其他类型 block → 跳过此消息
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 必须同时有 tool_result 和 text 才需要合并
|
||||
if !valid || tool_results.is_empty() || text_blocks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 合并策略(与参考实现对齐)
|
||||
let merged = merge_blocks_into_tool_results(tool_results, text_blocks);
|
||||
msg["content"] = Value::Array(merged);
|
||||
}
|
||||
|
||||
// Phase 2: 跨消息合并 — 连续的 tool_result-only 用户消息合并
|
||||
let messages = body["messages"].as_array().unwrap().clone();
|
||||
if messages.len() <= 1 {
|
||||
return body;
|
||||
}
|
||||
|
||||
let mut merged_msgs: Vec<Value> = Vec::with_capacity(messages.len());
|
||||
let mut i = 0;
|
||||
|
||||
while i < messages.len() {
|
||||
if is_tool_result_only_message(&messages[i]) {
|
||||
let mut combined_content: Vec<Value> = Vec::new();
|
||||
while i < messages.len() && is_tool_result_only_message(&messages[i]) {
|
||||
if let Some(content) = messages[i].get("content").and_then(|c| c.as_array()) {
|
||||
combined_content.extend(content.iter().cloned());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !combined_content.is_empty() {
|
||||
merged_msgs.push(serde_json::json!({
|
||||
"role": "user",
|
||||
"content": combined_content
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
merged_msgs.push(messages[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
body["messages"] = Value::Array(merged_msgs);
|
||||
body
|
||||
}
|
||||
|
||||
/// 基于最后一条用户消息内容生成确定性 Request ID。
|
||||
///
|
||||
/// 与参考实现对齐:
|
||||
/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control)
|
||||
/// - 找不到用户内容时退化为随机 UUID
|
||||
/// - 使用 UUID v4 格式
|
||||
pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
|
||||
let last_user_content = find_last_user_content(body);
|
||||
|
||||
match last_user_content {
|
||||
Some(content) => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(session_id.as_bytes());
|
||||
hasher.update(content.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes.copy_from_slice(&result[..16]);
|
||||
// UUID v4 版本位和变体位(与参考实现一致)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||
|
||||
Uuid::from_bytes(bytes).to_string()
|
||||
}
|
||||
None => Uuid::new_v4().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 内部辅助 ─────────────────────────────────
|
||||
|
||||
/// 查找最后一条 user 消息的非 tool_result 文本内容。
|
||||
///
|
||||
/// 与参考实现的 `findLastUserContent` 对齐:
|
||||
/// - 从后往前遍历消息
|
||||
/// - 排除 tool_result block
|
||||
/// - 排除 cache_control 字段
|
||||
fn find_last_user_content(body: &Value) -> Option<String> {
|
||||
let messages = body.get("messages").and_then(|m| m.as_array())?;
|
||||
|
||||
for msg in messages.iter().rev() {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let content = msg.get("content")?;
|
||||
|
||||
if let Some(s) = content.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
|
||||
if let Some(blocks) = content.as_array() {
|
||||
// 过滤 tool_result,保留其他 block(去掉 cache_control)
|
||||
let filtered: Vec<Value> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
|
||||
.map(|b| {
|
||||
let mut b = b.clone();
|
||||
if let Some(obj) = b.as_object_mut() {
|
||||
obj.remove("cache_control");
|
||||
}
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !filtered.is_empty() {
|
||||
return Some(serde_json::to_string(&filtered).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 将 text block 合并进 tool_result block。
|
||||
///
|
||||
/// 两种合并策略(与参考实现对齐):
|
||||
/// - 数量相等:一一对应,text 追加到对应 tool_result 的 content 中
|
||||
/// - 数量不等:所有 text 追加到最后一个 tool_result 的 content 中
|
||||
fn merge_blocks_into_tool_results(
|
||||
mut tool_results: Vec<Value>,
|
||||
text_blocks: Vec<Value>,
|
||||
) -> Vec<Value> {
|
||||
if tool_results.len() == text_blocks.len() {
|
||||
// 一一对应合并
|
||||
for (tr, tb) in tool_results.iter_mut().zip(text_blocks.iter()) {
|
||||
append_text_to_tool_result(tr, tb);
|
||||
}
|
||||
} else {
|
||||
// 所有 text 追加到最后一个 tool_result
|
||||
if let Some(last_tr) = tool_results.last_mut() {
|
||||
for tb in &text_blocks {
|
||||
append_text_to_tool_result(last_tr, tb);
|
||||
}
|
||||
}
|
||||
}
|
||||
tool_results
|
||||
}
|
||||
|
||||
/// 将 text block 的内容追加到 tool_result 的 content 中
|
||||
fn append_text_to_tool_result(tool_result: &mut Value, text_block: &Value) {
|
||||
let text = text_block
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// tool_result 的 content 可以是字符串或数组
|
||||
match tool_result.get("content") {
|
||||
Some(c) if c.is_string() => {
|
||||
let existing = c.as_str().unwrap_or("");
|
||||
tool_result["content"] = Value::String(format!("{existing}\n{text}"));
|
||||
}
|
||||
Some(c) if c.is_array() => {
|
||||
let arr = tool_result["content"].as_array_mut().unwrap();
|
||||
arr.push(serde_json::json!({"type": "text", "text": text}));
|
||||
}
|
||||
_ => {
|
||||
// content 缺失或 null — 直接设置
|
||||
tool_result["content"] = Value::String(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从消息中提取文本内容
|
||||
fn extract_text_from_message(msg: &Value) -> String {
|
||||
match msg.get("content") {
|
||||
Some(content) if content.is_string() => content.as_str().unwrap_or("").to_string(),
|
||||
Some(content) if content.is_array() => {
|
||||
let blocks = content.as_array().unwrap();
|
||||
blocks
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
block.get("text").and_then(|t| t.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断消息是否为 tool_result-only 的用户消息
|
||||
fn is_tool_result_only_message(msg: &Value) -> bool {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
match msg.get("content").and_then(|c| c.as_array()) {
|
||||
Some(blocks) if !blocks.is_empty() => blocks
|
||||
.iter()
|
||||
.all(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 测试 ─────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// === classify_request 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_classify_user_text_message() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, please help me write some code"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_user_text_array_message() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "Please explain this code"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tool_result_only() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read the file"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "I'll read that file."},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Read", "input": {"path": "/tmp/test.rs"}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents here"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tool_result_with_text_block() {
|
||||
// 参考实现的关键场景:tool_result + text block
|
||||
// 有非 tool_result block → 仍然是 "user"
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents"},
|
||||
{"type": "text", "text": "Now please refactor this code"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_empty_messages() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": []
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_no_messages() {
|
||||
let body = json!({"model": "claude-sonnet-4-20250514"});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_compact_request_system_prompt() {
|
||||
// compact 通过 system prompt 强特征检测
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"system": "You are a helpful AI assistant tasked with summarizing conversations. Please create a summary.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Here is the conversation history to summarize..."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_compact_request_critical_marker() {
|
||||
// compact 通过 CRITICAL 机器指令检测
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize the conversation."}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_compact_disabled_by_config() {
|
||||
// compact_detection=false 时,即使内容匹配也不标记为 compact
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"system": "You are a helpful AI assistant tasked with summarizing conversations.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Summarize"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, false); // compact_detection=false
|
||||
assert_eq!(result.initiator, "user"); // 不被标记为 agent
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_false_positive_on_user_summarize_request() {
|
||||
// P1 修复验证:用户手动输入 "summarize the conversation" 不应被误判为 compact
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Please summarize the conversation so far into a concise summary."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
// 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
|
||||
// === warmup 测试(与参考实现对齐) ===
|
||||
|
||||
#[test]
|
||||
fn test_warmup_with_anthropic_beta_no_tools() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=true, 无 tools → warmup
|
||||
let result = classify_request(&body, true, true);
|
||||
assert!(result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_warmup_without_anthropic_beta() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=false → 不是 warmup
|
||||
let result = classify_request(&body, false, true);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_warmup_with_tools() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
// 有 tools → 不是 warmup(即使有 anthropic-beta)
|
||||
let result = classify_request(&body, true, true);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_warmup_when_agent() {
|
||||
// tool_result → agent → 不判定 warmup
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "ok"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
// === merge_tool_results 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_message_tool_result_text() {
|
||||
// 核心场景:消息内部 tool_result + text → text 被吸收进 tool_result
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "file contents"},
|
||||
{"type": "text", "text": "skill output here"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
// 应只剩 1 个 tool_result block(text 被吸收)
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "tool_result");
|
||||
// tool_result 的 content 应包含原始内容 + 吸收的 text
|
||||
let tr_content = content[0]["content"].as_str().unwrap();
|
||||
assert!(tr_content.contains("file contents"));
|
||||
assert!(tr_content.contains("skill output here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_message_equal_count() {
|
||||
// 数量相等:一一对应合并
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "result1"},
|
||||
{"type": "text", "text": "text1"},
|
||||
{"type": "tool_result", "tool_use_id": "t2", "content": "result2"},
|
||||
{"type": "text", "text": "text2"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert!(content[0]["content"].as_str().unwrap().contains("text1"));
|
||||
assert!(content[1]["content"].as_str().unwrap().contains("text2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_message_empty_text_ignored() {
|
||||
// 空 text block 不追加内容
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "result"},
|
||||
{"type": "text", "text": ""}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 1);
|
||||
// 空 text 不改变原始 content
|
||||
assert_eq!(content[0]["content"], "result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_skips_other_block_types() {
|
||||
// 有非 tool_result/text 的 block → 跳过整条消息
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "result"},
|
||||
{"type": "image", "source": {"data": "..."}},
|
||||
{"type": "text", "text": "caption"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
// 未合并,保持原样 3 个 block
|
||||
assert_eq!(content.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_cross_message_consecutive() {
|
||||
// 跨消息合并:连续 tool_result-only 用户消息
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read files"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
|
||||
{"type": "tool_use", "id": "t2", "name": "Read", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "file1"}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t2", "content": "file2"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 3);
|
||||
let merged_content = messages[2]["content"].as_array().unwrap();
|
||||
assert_eq!(merged_content.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_does_not_affect_normal_messages() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "How are you?"}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body.clone());
|
||||
assert_eq!(result["messages"], body["messages"]);
|
||||
}
|
||||
|
||||
// === deterministic_request_id 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_stable() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body, "session1");
|
||||
let id2 = deterministic_request_id(&body, "session1");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_varies_by_content() {
|
||||
let body1 = json!({
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let body2 = json!({
|
||||
"messages": [{"role": "user", "content": "Goodbye"}]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body1, "session1");
|
||||
let id2 = deterministic_request_id(&body2, "session1");
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_varies_by_session() {
|
||||
let body = json!({
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body, "session1");
|
||||
let id2 = deterministic_request_id(&body, "session2");
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_ignores_tool_result() {
|
||||
// tool_result 内容不同,但 user text 相同 → 相同 ID
|
||||
let body1 = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "version_A"}
|
||||
]},
|
||||
{"role": "user", "content": "do something"}
|
||||
]
|
||||
});
|
||||
let body2 = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "version_B"}
|
||||
]},
|
||||
{"role": "user", "content": "do something"}
|
||||
]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body1, "s");
|
||||
let id2 = deterministic_request_id(&body2, "s");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_fallback_when_no_user_content() {
|
||||
// 无用户消息 → 退化为随机 UUID(每次不同)
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "Hi"}
|
||||
]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body, "s");
|
||||
let id2 = deterministic_request_id(&body, "s");
|
||||
// 随机 UUID,每次应不同
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_is_valid_uuid() {
|
||||
let body = json!({
|
||||
"messages": [{"role": "user", "content": "test"}]
|
||||
});
|
||||
let id = deterministic_request_id(&body, "session");
|
||||
assert!(Uuid::parse_str(&id).is_ok());
|
||||
}
|
||||
|
||||
// === compact 检测增强测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_system_prompt() {
|
||||
let body = json!({
|
||||
"system": "You are a helpful AI assistant tasked with summarizing conversations. Please provide a concise summary.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Here is the conversation to summarize..."}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_critical_keyword() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize this conversation."}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_structural_markers() {
|
||||
// Claude Code compact 特有的结构标记
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Summary of conversation:\n\nPending Tasks:\n- Fix bug\n\nCurrent Work:\n- Implementing feature"}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_no_false_positive_on_generic_summary() {
|
||||
// 通用短语不应触发 compact 检测
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Your task is to create a detailed summary of the conversation so far."}
|
||||
]
|
||||
});
|
||||
assert!(!is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_negative() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the weather today?"}
|
||||
]
|
||||
});
|
||||
assert!(!is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_system_array() {
|
||||
let body = json!({
|
||||
"system": [
|
||||
{"type": "text", "text": "You are a helpful AI assistant tasked with summarizing conversations."}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Summarize"}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use super::{
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::CopilotAuthState;
|
||||
@@ -52,6 +52,8 @@ pub struct RequestForwarder {
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
@@ -70,6 +72,7 @@ impl RequestForwarder {
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
@@ -80,6 +83,7 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
@@ -747,7 +751,7 @@ impl RequestForwarder {
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
let mut base_url = adapter.extract_base_url(provider)?;
|
||||
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
@@ -760,7 +764,7 @@ impl RequestForwarder {
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
@@ -770,6 +774,92 @@ impl RequestForwarder {
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) ---
|
||||
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
|
||||
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
|
||||
// 1. Tool result 合并 — 必须在分类之前执行
|
||||
// 合并将 [tool_result, text] 变为 [tool_result(含text)],
|
||||
// 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block)
|
||||
if self.copilot_optimizer_config.tool_result_merging {
|
||||
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
|
||||
}
|
||||
|
||||
// 2. 在合并后的 body 上进行分类
|
||||
let has_anthropic_beta = headers.contains_key("anthropic-beta");
|
||||
let classification = super::copilot_optimizer::classify_request(
|
||||
&mapped_body,
|
||||
has_anthropic_beta,
|
||||
self.copilot_optimizer_config.compact_detection,
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}",
|
||||
classification.initiator,
|
||||
classification.is_warmup,
|
||||
classification.is_compact
|
||||
);
|
||||
|
||||
// 3. Warmup 小模型降级
|
||||
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
|
||||
log::info!(
|
||||
"[Copilot] Warmup 请求降级到模型: {}",
|
||||
self.copilot_optimizer_config.warmup_model
|
||||
);
|
||||
mapped_body["model"] =
|
||||
serde_json::json!(&self.copilot_optimizer_config.warmup_model);
|
||||
}
|
||||
|
||||
// 预计算确定性 Request ID(在 body 被 move 之前)
|
||||
// 使用 session_id 从 body.metadata.user_id 或请求头提取
|
||||
let session_id = body
|
||||
.pointer("/metadata/user_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok()))
|
||||
.unwrap_or("");
|
||||
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
|
||||
Some(super::copilot_optimizer::deterministic_request_id(
|
||||
&mapped_body,
|
||||
session_id,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some((classification, det_request_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// GitHub Copilot 动态 endpoint 路由
|
||||
// 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint)
|
||||
if is_copilot && !is_full_url {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth = copilot_state.0.read().await;
|
||||
|
||||
// 从 provider.meta 获取关联的 GitHub 账号 ID
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||
|
||||
let dynamic_endpoint = match &account_id {
|
||||
Some(id) => copilot_auth.get_api_endpoint(id).await,
|
||||
None => copilot_auth.get_default_api_endpoint().await,
|
||||
};
|
||||
|
||||
// 只在动态 endpoint 与当前 base_url 不同时替换
|
||||
if dynamic_endpoint != base_url {
|
||||
log::debug!(
|
||||
"[Copilot] 使用动态 API endpoint: {} (原: {})",
|
||||
dynamic_endpoint,
|
||||
base_url
|
||||
);
|
||||
base_url = dynamic_endpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resolved_claude_api_format = if adapter.name() == "Claude" {
|
||||
Some(
|
||||
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
|
||||
@@ -828,7 +918,7 @@ impl RequestForwarder {
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
if auth.strategy == AuthStrategy::GitHubCopilot {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
@@ -884,6 +974,25 @@ impl RequestForwarder {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
|
||||
for (name, value) in auth_headers.iter_mut() {
|
||||
match name.as_str() {
|
||||
"x-initiator" if self.copilot_optimizer_config.request_classification => {
|
||||
*value = http::HeaderValue::from_static(classification.initiator);
|
||||
}
|
||||
"x-request-id" | "x-agent-task-id" => {
|
||||
if let Some(ref det_id) = det_request_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
|
||||
*value = hv;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
|
||||
let copilot_fingerprint_headers: &[&str] = if is_copilot {
|
||||
&[
|
||||
@@ -893,6 +1002,12 @@ impl RequestForwarder {
|
||||
"copilot-integration-id",
|
||||
"x-github-api-version",
|
||||
"openai-intent",
|
||||
// 新增 headers
|
||||
"x-initiator",
|
||||
"x-interaction-type",
|
||||
"x-vscode-user-agent-library-version",
|
||||
"x-request-id",
|
||||
"x-agent-task-id",
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
@@ -1663,4 +1778,107 @@ mod tests {
|
||||
&headers
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Copilot 动态 endpoint 路由相关测试 ====================
|
||||
|
||||
/// 验证 is_copilot 检测逻辑:通过 provider_type 判断
|
||||
#[test]
|
||||
fn copilot_detection_via_provider_type() {
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
|
||||
let provider = Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Copilot".to_string(),
|
||||
settings_config: serde_json::json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot");
|
||||
|
||||
assert!(is_copilot, "应该通过 provider_type 检测为 Copilot");
|
||||
}
|
||||
|
||||
/// 验证 is_copilot 检测逻辑:通过 base_url 判断
|
||||
#[test]
|
||||
fn copilot_detection_via_base_url() {
|
||||
let base_url = "https://api.githubcopilot.com";
|
||||
let is_copilot = base_url.contains("githubcopilot.com");
|
||||
assert!(is_copilot, "应该通过 base_url 检测为 Copilot");
|
||||
|
||||
let non_copilot_url = "https://api.anthropic.com";
|
||||
let is_not_copilot = non_copilot_url.contains("githubcopilot.com");
|
||||
assert!(!is_not_copilot, "非 Copilot URL 不应被检测为 Copilot");
|
||||
}
|
||||
|
||||
/// 验证企业版 endpoint(不包含 githubcopilot.com)场景下 is_copilot 仍然正确
|
||||
#[test]
|
||||
fn copilot_detection_for_enterprise_endpoint() {
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
|
||||
// 企业版场景:provider_type 是 github_copilot,但 base_url 可能是企业内部域名
|
||||
let provider = Provider {
|
||||
id: "enterprise".to_string(),
|
||||
name: "Enterprise Copilot".to_string(),
|
||||
settings_config: serde_json::json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
let enterprise_base_url = "https://copilot-api.corp.example.com";
|
||||
|
||||
// is_copilot 应该通过 provider_type 检测成功,即使 base_url 不包含 githubcopilot.com
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| enterprise_base_url.contains("githubcopilot.com");
|
||||
|
||||
assert!(
|
||||
is_copilot,
|
||||
"企业版 Copilot 应该通过 provider_type 被正确检测"
|
||||
);
|
||||
}
|
||||
|
||||
/// 验证动态 endpoint 替换条件
|
||||
#[test]
|
||||
fn dynamic_endpoint_replacement_conditions() {
|
||||
// 条件:is_copilot && !is_full_url
|
||||
let test_cases = [
|
||||
(true, false, true, "Copilot + 非 full_url 应该替换"),
|
||||
(true, true, false, "Copilot + full_url 不应替换"),
|
||||
(false, false, false, "非 Copilot 不应替换"),
|
||||
(false, true, false, "非 Copilot + full_url 不应替换"),
|
||||
];
|
||||
|
||||
for (is_copilot, is_full_url, should_replace, desc) in test_cases {
|
||||
let will_replace = is_copilot && !is_full_url;
|
||||
assert_eq!(will_replace, should_replace, "{desc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -61,6 +61,8 @@ pub struct RequestContext {
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
pub copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -96,6 +98,7 @@ impl RequestContext {
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
let copilot_optimizer_config = state.db.get_copilot_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -160,6 +163,7 @@ impl RequestContext {
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +225,7 @@ impl RequestContext {
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
self.copilot_optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -359,6 +359,10 @@ async fn send_raw_request(
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
tls_stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Flush failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(tls_stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
@@ -368,6 +372,10 @@ async fn send_raw_request(
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Flush failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
@@ -441,6 +449,10 @@ async fn connect_via_proxy(
|
||||
.write_all(connect_req.as_bytes())
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT flush failed: {e}")))?;
|
||||
|
||||
// Read the proxy's response status line
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod copilot_optimizer;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
|
||||
@@ -348,6 +348,8 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
)]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
@@ -373,9 +375,30 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-github-api-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||||
),
|
||||
// 26-04-01新增的copilot关键 headers
|
||||
(
|
||||
HeaderName::from_static("openai-intent"),
|
||||
HeaderValue::from_static("conversation-panel"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-initiator"),
|
||||
HeaderValue::from_static("user"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-interaction-type"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-request-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-agent-task-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,15 +46,18 @@ const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;
|
||||
const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
|
||||
|
||||
/// Copilot API Header 常量
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
|
||||
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
|
||||
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-04-01";
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.110.1";
|
||||
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.38.2";
|
||||
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.38.2";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-10-01";
|
||||
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
|
||||
|
||||
/// Copilot 使用量 API URL
|
||||
const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user";
|
||||
|
||||
/// 默认 Copilot API 端点
|
||||
const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
|
||||
|
||||
/// Copilot 使用量响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotUsageResponse {
|
||||
@@ -64,6 +67,19 @@ pub struct CopilotUsageResponse {
|
||||
pub quota_reset_date: String,
|
||||
/// 配额快照
|
||||
pub quota_snapshots: QuotaSnapshots,
|
||||
/// API 端点信息 (用于动态获取 API URL)
|
||||
#[serde(default)]
|
||||
pub endpoints: Option<CopilotEndpoints>,
|
||||
}
|
||||
|
||||
/// Copilot API 端点信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotEndpoints {
|
||||
/// API 端点 URL
|
||||
pub api: String,
|
||||
/// Telemetry 端点 URL
|
||||
#[serde(default)]
|
||||
pub telemetry: Option<String>,
|
||||
}
|
||||
|
||||
/// 配额快照
|
||||
@@ -312,6 +328,10 @@ pub struct CopilotAuthManager {
|
||||
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
||||
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
|
||||
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
|
||||
/// Copilot API 端点缓存(key = GitHub user ID,从 /copilot_internal/user 获取)
|
||||
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
||||
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
@@ -333,6 +353,8 @@ impl CopilotAuthManager {
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
||||
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
@@ -386,6 +408,15 @@ impl CopilotAuthManager {
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.remove(account_id);
|
||||
}
|
||||
// 清理 API 端点缓存
|
||||
{
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut endpoint_locks = self.endpoint_locks.write().await;
|
||||
endpoint_locks.remove(account_id);
|
||||
}
|
||||
|
||||
{
|
||||
let accounts = self.accounts.read().await;
|
||||
@@ -775,6 +806,14 @@ impl CopilotAuthManager {
|
||||
.await
|
||||
.map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;
|
||||
|
||||
// 存储动态 API 端点(如果有)
|
||||
if let Some(ref endpoints) = usage.endpoints {
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.insert(account_id.to_string(), endpoints.api.clone());
|
||||
// 使用 debug 级别避免在日志中暴露企业内部域名
|
||||
log::debug!("[CopilotAuth] 账号 {account_id} 已保存动态 API 端点");
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[CopilotAuth] 获取使用量成功,计划: {}, 重置日期: {}",
|
||||
usage.copilot_plan,
|
||||
@@ -794,6 +833,118 @@ impl CopilotAuthManager {
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
/// 获取指定账号的 API 端点(缓存命中直接返回,未命中则从 API 惰性拉取)
|
||||
pub async fn get_api_endpoint(&self, account_id: &str) -> String {
|
||||
let _ = self.ensure_migration_complete().await;
|
||||
|
||||
{
|
||||
let endpoints = self.api_endpoints.read().await;
|
||||
if let Some(endpoint) = endpoints.get(account_id) {
|
||||
return endpoint.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 用锁串行化同一账号的并发拉取,避免对 GitHub API 的重复请求
|
||||
let lock = self.get_endpoint_lock(account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
// 持锁后二次检查:可能已由其他请求填充
|
||||
{
|
||||
let endpoints = self.api_endpoints.read().await;
|
||||
if let Some(endpoint) = endpoints.get(account_id) {
|
||||
return endpoint.clone();
|
||||
}
|
||||
}
|
||||
|
||||
match self.fetch_and_cache_endpoint(account_id).await {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"[CopilotAuth] 获取账号 {account_id} 动态 API 端点失败: {e},使用默认值"
|
||||
);
|
||||
DEFAULT_COPILOT_API_ENDPOINT.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取默认账号的 API 端点
|
||||
pub async fn get_default_api_endpoint(&self) -> String {
|
||||
let _ = self.ensure_migration_complete().await;
|
||||
|
||||
match self.resolve_default_account_id().await {
|
||||
Some(id) => self.get_api_endpoint(&id).await,
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_cache_endpoint(&self, account_id: &str) -> Result<String, CopilotAuthError> {
|
||||
let github_token = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
};
|
||||
|
||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", COPILOT_PLUGIN_VERSION)
|
||||
.header("user-agent", COPILOT_USER_AGENT)
|
||||
.header("x-github-api-version", COPILOT_API_VERSION)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(CopilotAuthError::GitHubTokenInvalid);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取 API 端点失败: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let usage: CopilotUsageResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;
|
||||
|
||||
let endpoint = match usage.endpoints {
|
||||
Some(endpoints) => endpoints.api.clone(),
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
};
|
||||
|
||||
// 缓存端点(包括默认值),避免重复请求
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.insert(account_id.to_string(), endpoint.clone());
|
||||
log::debug!("[CopilotAuth] 账号 {account_id} 已缓存 API 端点");
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
async fn get_endpoint_lock(&self, account_id: &str) -> Arc<Mutex<()>> {
|
||||
{
|
||||
let locks = self.endpoint_locks.read().await;
|
||||
if let Some(lock) = locks.get(account_id) {
|
||||
return Arc::clone(lock);
|
||||
}
|
||||
}
|
||||
|
||||
let mut locks = self.endpoint_locks.write().await;
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(account_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取认证状态(支持多账号)
|
||||
pub async fn get_status(&self) -> CopilotAuthStatus {
|
||||
// 确保迁移完成
|
||||
@@ -838,6 +989,7 @@ impl CopilotAuthManager {
|
||||
pub async fn clear_auth(&self) -> Result<(), CopilotAuthError> {
|
||||
log::info!("[CopilotAuth] 清除所有认证");
|
||||
|
||||
// 先清理内存状态,确保即使文件删除失败用户也能看到已登出
|
||||
{
|
||||
let mut accounts = self.accounts.write().await;
|
||||
accounts.clear();
|
||||
@@ -851,12 +1003,25 @@ impl CopilotAuthManager {
|
||||
let mut tokens = self.copilot_tokens.write().await;
|
||||
tokens.clear();
|
||||
}
|
||||
{
|
||||
let mut models = self.copilot_models.write().await;
|
||||
models.clear();
|
||||
}
|
||||
{
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.clear();
|
||||
}
|
||||
// 清理 API 端点缓存
|
||||
{
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.clear();
|
||||
}
|
||||
{
|
||||
let mut endpoint_locks = self.endpoint_locks.write().await;
|
||||
endpoint_locks.clear();
|
||||
}
|
||||
|
||||
// 删除存储文件
|
||||
// 最后删除存储文件
|
||||
if self.storage_path.exists() {
|
||||
std::fs::remove_file(&self.storage_path)?;
|
||||
}
|
||||
@@ -1414,4 +1579,242 @@ mod tests {
|
||||
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
|
||||
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_cached_value() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 手动设置 api_endpoints 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, "https://copilot-api.enterprise.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_default_when_not_cached() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("99999").await;
|
||||
assert_eq!(endpoint, "https://api.githubcopilot.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_default_api_endpoint_uses_default_account() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 设置默认账号
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
// 添加账号数据
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
// 设置 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.corp.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let endpoint = manager.get_default_api_endpoint().await;
|
||||
assert_eq!(endpoint, "https://copilot-api.corp.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_account_clears_api_endpoint_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 添加账号数据
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
// 设置 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 确认缓存存在
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.contains_key("12345"));
|
||||
}
|
||||
|
||||
// 移除账号
|
||||
manager.remove_account("12345").await.unwrap();
|
||||
|
||||
// 确认缓存已清理
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(!api_endpoints.contains_key("12345"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_auth_clears_all_api_endpoint_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 添加多个账号的 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise1.example.com".to_string(),
|
||||
);
|
||||
api_endpoints.insert(
|
||||
"67890".to_string(),
|
||||
"https://copilot-api.enterprise2.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 确认缓存存在
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert_eq!(api_endpoints.len(), 2);
|
||||
}
|
||||
|
||||
// 清除所有认证
|
||||
manager.clear_auth().await.unwrap();
|
||||
|
||||
// 确认缓存已清空
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_auth_cleans_memory_even_when_file_removal_fails() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// Create a directory at storage_path so remove_file fails
|
||||
std::fs::create_dir_all(&manager.storage_path).unwrap();
|
||||
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let result = manager.clear_auth().await;
|
||||
// Should still return an error for the file deletion failure
|
||||
assert!(result.is_err());
|
||||
|
||||
// But memory state should already be cleaned
|
||||
let accounts = manager.accounts.read().await;
|
||||
assert!(accounts.is_empty());
|
||||
drop(accounts);
|
||||
|
||||
let default_account_id = manager.default_account_id.read().await;
|
||||
assert!(default_account_id.is_none());
|
||||
drop(default_account_id);
|
||||
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_cache_hit_skips_fetch() {
|
||||
// 缓存命中时应直接返回,不发起网络请求
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let enterprise_endpoint = "https://copilot-api.enterprise.example.com".to_string();
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert("12345".to_string(), enterprise_endpoint.clone());
|
||||
}
|
||||
|
||||
// 即使没有账号数据,缓存命中也应直接返回
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, enterprise_endpoint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_default_for_unknown_account() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, DEFAULT_COPILOT_API_ENDPOINT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_and_cache_endpoint_requires_account() {
|
||||
// 账号不存在时 fetch_and_cache_endpoint 应返回 AccountNotFound 错误
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let result = manager.fetch_and_cache_endpoint("nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CopilotAuthError::AccountNotFound(id) => assert_eq!(id, "nonexistent"),
|
||||
other => panic!("期望 AccountNotFound 错误,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
}
|
||||
}
|
||||
|
||||
normalize_openai_system_messages(&mut messages);
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
@@ -182,6 +183,73 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
let system_count = messages
|
||||
.iter()
|
||||
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
|
||||
.count();
|
||||
|
||||
if system_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if system_count == 1 {
|
||||
if let Some(index) = messages.iter().position(|message| {
|
||||
message.get("role").and_then(|value| value.as_str()) == Some("system")
|
||||
}) {
|
||||
if index > 0 {
|
||||
let message = messages.remove(index);
|
||||
messages.insert(0, message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut inherited_cache_control: Option<Value> = None;
|
||||
let mut cache_control_conflict = false;
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match message.get("content") {
|
||||
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
|
||||
Some(Value::Array(content_parts)) => {
|
||||
let text = content_parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !text.is_empty() {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(cache_control) = message.get("cache_control") {
|
||||
match &inherited_cache_control {
|
||||
None => inherited_cache_control = Some(cache_control.clone()),
|
||||
Some(existing) if existing == cache_control => {}
|
||||
Some(_) => cache_control_conflict = true,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
let mut merged = json!({"role": "system", "content": parts.join("\n")});
|
||||
if !cache_control_conflict {
|
||||
if let Some(cache_control) = inherited_cache_control {
|
||||
merged["cache_control"] = cache_control;
|
||||
}
|
||||
}
|
||||
messages.insert(0, merged);
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
@@ -560,6 +628,50 @@ mod tests {
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "system", "content": "Follow repo conventions."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise.\nFollow repo conventions."
|
||||
);
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(result["messages"][0]["content"], "You are Claude Code.\nBe concise.");
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
|
||||
@@ -269,6 +269,54 @@ impl Default for OptimizerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copilot 优化器配置
|
||||
///
|
||||
/// 存储在 settings 表中,key = "copilot_optimizer_config"
|
||||
/// 解决 Copilot 代理消耗量异常问题(Issue #1813)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotOptimizerConfig {
|
||||
/// 总开关(默认开启 — 对 Copilot 用户至关重要)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// x-initiator 请求分类(默认开启,P0 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub request_classification: bool,
|
||||
/// Tool result 消息合并(默认开启,P1 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub tool_result_merging: bool,
|
||||
/// Compact 请求识别(默认开启,P2 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub compact_detection: bool,
|
||||
/// 确定性 Request ID(默认开启,P3 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub deterministic_request_id: bool,
|
||||
/// Warmup 小模型降级(默认关闭,P4 优先级,opt-in)
|
||||
#[serde(default)]
|
||||
pub warmup_downgrade: bool,
|
||||
/// Warmup 降级使用的模型(默认 "gpt-4o-mini")
|
||||
#[serde(default = "default_warmup_model")]
|
||||
pub warmup_model: String,
|
||||
}
|
||||
|
||||
fn default_warmup_model() -> String {
|
||||
"gpt-4o-mini".to_string()
|
||||
}
|
||||
|
||||
impl Default for CopilotOptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_classification: true,
|
||||
tool_result_merging: true,
|
||||
compact_detection: true,
|
||||
deterministic_request_id: true,
|
||||
warmup_downgrade: false,
|
||||
warmup_model: "gpt-4o-mini".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! 国产 Token Plan 额度查询服务
|
||||
//!
|
||||
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。
|
||||
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
|
||||
|
||||
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── 供应商检测 ──────────────────────────────────────────────
|
||||
|
||||
enum CodingPlanProvider {
|
||||
Kimi,
|
||||
ZhipuCn,
|
||||
ZhipuEn,
|
||||
MiniMaxCn,
|
||||
MiniMaxEn,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
let url = base_url.to_lowercase();
|
||||
if url.contains("api.kimi.com/coding") {
|
||||
Some(CodingPlanProvider::Kimi)
|
||||
} else if url.contains("open.bigmodel.cn") || url.contains("bigmodel.cn") {
|
||||
Some(CodingPlanProvider::ZhipuCn)
|
||||
} else if url.contains("api.z.ai") {
|
||||
Some(CodingPlanProvider::ZhipuEn)
|
||||
} else if url.contains("api.minimaxi.com") {
|
||||
Some(CodingPlanProvider::MiniMaxCn)
|
||||
} else if url.contains("api.minimax.io") {
|
||||
Some(CodingPlanProvider::MiniMaxEn)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn millis_to_iso8601(ms: i64) -> Option<String> {
|
||||
let secs = ms / 1000;
|
||||
let nsecs = ((ms % 1000) * 1_000_000) as u32;
|
||||
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
/// 从 JSON 值提取重置时间,兼容字符串和数字格式
|
||||
/// - 字符串:直接返回(ISO 8601)
|
||||
/// - 数字:自动判断秒/毫秒并转为 ISO 8601
|
||||
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(n) = value.as_i64() {
|
||||
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
|
||||
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
|
||||
return millis_to_iso8601(ms);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`)
|
||||
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
|
||||
}
|
||||
|
||||
fn make_error(msg: String) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(msg),
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kimi For Coding ─────────────────────────────────────────
|
||||
|
||||
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://api.kimi.com/coding/v1/usages")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
// 5 小时窗口限额(优先显示)
|
||||
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
if let Some(detail) = limit_item.get("detail") {
|
||||
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 总体用量(周限额)
|
||||
if let Some(usage) = body.get("usage") {
|
||||
let limit = usage.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = usage.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = usage.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tiers.push(QuotaTier {
|
||||
name: "weekly_limit".to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
});
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
|
||||
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
// 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制)
|
||||
let resp = client
|
||||
.get("https://api.z.ai/api/monitor/usage/quota/limit")
|
||||
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Language", "en-US,en")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
|
||||
let msg = body
|
||||
.get("msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error: {msg}"));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
let limit_type = limit_item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let percentage = limit_item
|
||||
.get("percentage")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let next_reset = limit_item
|
||||
.get("nextResetTime")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(millis_to_iso8601);
|
||||
|
||||
if limit_type != "TOKENS_LIMIT" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: percentage,
|
||||
resets_at: next_reset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 套餐等级存入 credential_message
|
||||
let level = data
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: level,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── MiniMax ─────────────────────────────────────────────────
|
||||
|
||||
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let api_domain = if is_cn {
|
||||
"api.minimaxi.com"
|
||||
} else {
|
||||
"api.minimax.io"
|
||||
};
|
||||
let url = format!("https://{api_domain}/v1/api/openplatform/coding_plan/remains");
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
if let Some(base_resp) = body.get("base_resp") {
|
||||
let status_code = base_resp
|
||||
.get("status_code")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
if status_code != 0 {
|
||||
let msg = base_resp
|
||||
.get("status_msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error (code {status_code}): {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) {
|
||||
// 只取第一个模型(MiniMax-M*,主力编程模型)
|
||||
if let Some(item) = model_remains.first() {
|
||||
// usage_count 是剩余量(满额=total,用完=0),需反转为已用百分比
|
||||
let interval_total = item
|
||||
.get("current_interval_total_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let interval_remaining = item
|
||||
.get("current_interval_usage_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let end_time = item.get("end_time").and_then(|v| v.as_i64());
|
||||
|
||||
if interval_total > 0.0 {
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: ((interval_total - interval_remaining) / interval_total) * 100.0,
|
||||
resets_at: end_time.and_then(millis_to_iso8601),
|
||||
});
|
||||
}
|
||||
|
||||
// 周额度
|
||||
let weekly_total = item
|
||||
.get("current_weekly_total_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let weekly_remaining = item
|
||||
.get("current_weekly_usage_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64());
|
||||
|
||||
if weekly_total > 0.0 {
|
||||
tiers.push(QuotaTier {
|
||||
name: "weekly_limit".to_string(),
|
||||
utilization: ((weekly_total - weekly_remaining) / weekly_total) * 100.0,
|
||||
resets_at: weekly_end.and_then(millis_to_iso8601),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
|
||||
let provider = match detect_provider(base_url) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let quota = match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await,
|
||||
CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await,
|
||||
CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await,
|
||||
};
|
||||
|
||||
Ok(quota)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod coding_plan;
|
||||
pub mod config;
|
||||
pub mod env_checker;
|
||||
pub mod env_manager;
|
||||
pub mod mcp;
|
||||
pub mod model_fetch;
|
||||
pub mod omo;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
@@ -9,6 +11,7 @@ pub mod proxy;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_auto_sync;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 模型列表获取服务
|
||||
//!
|
||||
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
|
||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 获取到的模型信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FetchedModel {
|
||||
pub id: String,
|
||||
pub owned_by: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 兼容的 /v1/models 响应格式
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Option<Vec<ModelEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
owned_by: Option<String>,
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
pub async fn fetch_models(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
is_full_url: bool,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
if api_key.is_empty() {
|
||||
return Err("API Key is required to fetch models".to_string());
|
||||
}
|
||||
|
||||
let models_url = build_models_url(base_url, is_full_url)?;
|
||||
let client = crate::proxy::http_client::get_for_provider(None);
|
||||
|
||||
let response = client
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
let resp: ModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
let mut models: Vec<FetchedModel> = resp
|
||||
.data
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| FetchedModel {
|
||||
id: m.id,
|
||||
owned_by: m.owned_by,
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 构造 /v1/models 的完整 URL
|
||||
fn build_models_url(base_url: &str, is_full_url: bool) -> Result<String, String> {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err("Base URL is empty".to_string());
|
||||
}
|
||||
|
||||
if is_full_url {
|
||||
// 尝试从完整端点 URL 推导 API 根路径
|
||||
// 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models
|
||||
if let Some(idx) = trimmed.find("/v1/") {
|
||||
return Ok(format!("{}/v1/models", &trimmed[..idx]));
|
||||
}
|
||||
// 如果没有 /v1/ 路径,直接去掉最后一段路径
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
let root = &trimmed[..idx];
|
||||
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
|
||||
return Ok(format!("{root}/v1/models"));
|
||||
}
|
||||
}
|
||||
return Err("Cannot derive models endpoint from full URL".to_string());
|
||||
}
|
||||
|
||||
// 常规情况: base_url 是 API 根路径
|
||||
// 如果已经包含 /v1 路径,直接追加 /models
|
||||
if trimmed.ends_with("/v1") {
|
||||
return Ok(format!("{trimmed}/models"));
|
||||
}
|
||||
|
||||
Ok(format!("{trimmed}/v1/models"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_basic() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.siliconflow.cn", false).unwrap(),
|
||||
"https://api.siliconflow.cn/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_trailing_slash() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_with_v1() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/v1", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_full_url() {
|
||||
assert_eq!(
|
||||
build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(),
|
||||
"https://proxy.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_empty() {
|
||||
assert!(build_models_url("", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let json = r#"{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"openai"},{"id":"claude-3-sonnet","object":"model","owned_by":"anthropic"}]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.len(), 2);
|
||||
assert_eq!(data[0].id, "gpt-4");
|
||||
assert_eq!(data[0].owned_by.as_deref(), Some("openai"));
|
||||
assert_eq!(data[1].id, "claude-3-sonnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_no_owned_by() {
|
||||
let json = r#"{"object":"list","data":[{"id":"my-model","object":"model"}]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data[0].id, "my-model");
|
||||
assert!(data[0].owned_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_empty_data() {
|
||||
let json = r#"{"object":"list","data":[]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.data.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
base_url_override: Option<String>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 合并供应商单独配置和全局配置
|
||||
@@ -100,6 +101,7 @@ impl StreamCheckService {
|
||||
provider,
|
||||
&effective_config,
|
||||
auth_override.clone(),
|
||||
base_url_override.clone(),
|
||||
claude_api_format_override.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -191,14 +193,18 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
base_url_override: Option<String>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
let start = Instant::now();
|
||||
let adapter = get_adapter(app_type);
|
||||
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||
let base_url = match base_url_override {
|
||||
Some(base_url) => base_url,
|
||||
None => adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?,
|
||||
};
|
||||
|
||||
let auth = auth_override
|
||||
.or_else(|| adapter.extract_auth(provider))
|
||||
@@ -364,6 +370,8 @@ impl StreamCheckService {
|
||||
let mut request_builder = client.post(&url);
|
||||
|
||||
if is_github_copilot {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
@@ -380,7 +388,13 @@ impl StreamCheckService {
|
||||
copilot_auth::COPILOT_INTEGRATION_ID,
|
||||
)
|
||||
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel");
|
||||
// 260401 新增copilot 的关键 headers
|
||||
.header("openai-intent", "conversation-agent")
|
||||
.header("x-initiator", "user")
|
||||
.header("x-interaction-type", "conversation-agent")
|
||||
.header("x-vscode-user-agent-library-version", "electron-fetch")
|
||||
.header("x-request-id", &request_id)
|
||||
.header("x-agent-task-id", &request_id);
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
// OpenAI-compatible targets: Bearer auth + SSE headers only
|
||||
request_builder = request_builder
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+52
-84
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
@@ -13,7 +13,7 @@ use crate::store::AppState;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub no_providers_label: &'static str,
|
||||
pub lightweight_mode: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
@@ -24,22 +24,21 @@ impl TrayTexts {
|
||||
match language {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
no_providers_label: "(no providers)",
|
||||
lightweight_mode: "Lightweight Mode",
|
||||
quit: "Quit",
|
||||
_auto_label: "Auto (Failover)",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
no_providers_label: "(プロバイダーなし)",
|
||||
lightweight_mode: "軽量モード",
|
||||
quit: "終了",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
no_providers_label: "(无供应商)",
|
||||
lightweight_mode: "轻量模式",
|
||||
quit: "退出",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
@@ -52,7 +51,6 @@ impl TrayTexts {
|
||||
pub struct TrayAppSection {
|
||||
pub app_type: AppType,
|
||||
pub prefix: &'static str,
|
||||
pub header_id: &'static str,
|
||||
pub empty_id: &'static str,
|
||||
pub header_label: &'static str,
|
||||
pub log_name: &'static str,
|
||||
@@ -65,7 +63,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "Claude",
|
||||
log_name: "Claude",
|
||||
@@ -73,7 +70,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Codex,
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "Codex",
|
||||
log_name: "Codex",
|
||||
@@ -81,54 +77,18 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Gemini,
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "Gemini",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
/// 添加供应商分区到菜单
|
||||
fn append_provider_section<'a>(
|
||||
app: &'a tauri::AppHandle,
|
||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
_app_state: &AppState,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
};
|
||||
|
||||
let header = MenuItem::with_id(
|
||||
app,
|
||||
section.header_id,
|
||||
section.header_label,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&header);
|
||||
|
||||
if manager.providers.is_empty() {
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
section.empty_id,
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
// Auto (Failover) menu item is hidden from tray; the feature is still
|
||||
// accessible from the Settings page. Keep the surrounding code intact so
|
||||
// it can be re-enabled easily in the future.
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
/// 对供应商列表排序:sort_index → created_at → name
|
||||
fn sort_providers(
|
||||
providers: &indexmap::IndexMap<String, crate::provider::Provider>,
|
||||
) -> Vec<(&String, &crate::provider::Provider)> {
|
||||
let mut sorted: Vec<_> = providers.iter().collect();
|
||||
sorted.sort_by(|(_, a), (_, b)| {
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
@@ -145,22 +105,7 @@ fn append_provider_section<'a>(
|
||||
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
Ok(menu_builder)
|
||||
sorted
|
||||
}
|
||||
|
||||
/// 处理供应商托盘事件
|
||||
@@ -352,10 +297,8 @@ pub fn create_tray_menu(
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
// Only add visible app sections
|
||||
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
// Skip hidden apps
|
||||
if !visible_apps.is_visible(§ion.app_type) {
|
||||
continue;
|
||||
}
|
||||
@@ -363,26 +306,51 @@ pub fn create_tray_menu(
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||
|
||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||
let current_id =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let manager = crate::provider::ProviderManager {
|
||||
providers,
|
||||
current: current_id,
|
||||
};
|
||||
if providers.is_empty() {
|
||||
// 空供应商:显示禁用的菜单项
|
||||
let label = format!("{} {}", section.header_label, tray_texts.no_providers_label);
|
||||
let empty_item = MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>)
|
||||
.map_err(|e| {
|
||||
AppError::Message(format!("创建{}空提示失败: {e}", section.log_name))
|
||||
})?;
|
||||
menu_builder = menu_builder.item(&empty_item);
|
||||
} else {
|
||||
// 有供应商:构建子菜单
|
||||
let current_name = providers.get(¤t_id).map(|p| p.name.as_str());
|
||||
let submenu_label = match current_name {
|
||||
Some(name) => format!("{} · {}", section.header_label, name),
|
||||
None => section.header_label.to_string(),
|
||||
};
|
||||
let submenu_id = format!("submenu_{}", app_type_str);
|
||||
|
||||
menu_builder = append_provider_section(
|
||||
app,
|
||||
menu_builder,
|
||||
Some(&manager),
|
||||
section,
|
||||
&tray_texts,
|
||||
app_state,
|
||||
)?;
|
||||
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
|
||||
|
||||
for (id, provider) in sort_providers(&providers) {
|
||||
let is_current = current_id == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name))
|
||||
})?;
|
||||
submenu_builder = submenu_builder.item(&item);
|
||||
}
|
||||
|
||||
let submenu = submenu_builder.build().map_err(|e| {
|
||||
AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name))
|
||||
})?;
|
||||
menu_builder = menu_builder.item(&submenu);
|
||||
}
|
||||
|
||||
// 在每个 section 后添加分隔符
|
||||
menu_builder = menu_builder.separator();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import React from "react";
|
||||
import { RefreshCw, AlertCircle, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useSubscriptionQuota } from "@/lib/query/subscription";
|
||||
import type { QuotaTier } from "@/types/subscription";
|
||||
|
||||
interface SubscriptionQuotaFooterProps {
|
||||
appId: AppId;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */
|
||||
export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
five_hour: "subscription.fiveHour",
|
||||
seven_day: "subscription.sevenDay",
|
||||
seven_day_opus: "subscription.sevenDayOpus",
|
||||
seven_day_sonnet: "subscription.sevenDaySonnet",
|
||||
// Gemini 模型分类
|
||||
gemini_pro: "subscription.geminiPro",
|
||||
gemini_flash: "subscription.geminiFlash",
|
||||
gemini_flash_lite: "subscription.geminiFlashLite",
|
||||
// Token Plan(five_hour 已在上方官方映射中)
|
||||
weekly_limit: "subscription.weeklyLimit",
|
||||
};
|
||||
|
||||
/** 根据使用百分比返回颜色 class */
|
||||
export function utilizationColor(utilization: number): string {
|
||||
if (utilization >= 90) return "text-red-500 dark:text-red-400";
|
||||
if (utilization >= 70) return "text-orange-500 dark:text-orange-400";
|
||||
return "text-green-600 dark:text-green-400";
|
||||
}
|
||||
|
||||
/** 计算倒计时的纯时间字符串,如 "2h30m"、"3d12h" */
|
||||
export function countdownStr(resetsAt: string | null): string | null {
|
||||
if (!resetsAt) return null;
|
||||
const diffMs = new Date(resetsAt).getTime() - Date.now();
|
||||
if (diffMs <= 0) return null;
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) return `${hours}h${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/** 格式化重置时间为倒计时文本(带 i18n 模板) */
|
||||
function formatResetTime(
|
||||
resetsAt: string | null,
|
||||
t: (key: string, options?: Record<string, string>) => string,
|
||||
): string | null {
|
||||
const time = countdownStr(resetsAt);
|
||||
if (!time) return null;
|
||||
return t("subscription.resetsIn", { time });
|
||||
}
|
||||
|
||||
/** 不需要在 inline 模式显示的 tier */
|
||||
const HIDDEN_INLINE_TIERS = new Set(["seven_day_sonnet"]);
|
||||
|
||||
/** 格式化相对时间(与 UsageFooter 一致) */
|
||||
function formatRelativeTime(
|
||||
timestamp: number,
|
||||
now: number,
|
||||
t: (key: string, options?: { count?: number }) => string,
|
||||
): string {
|
||||
const diff = Math.floor((now - timestamp) / 1000);
|
||||
if (diff < 60) return t("usage.justNow");
|
||||
if (diff < 3600)
|
||||
return t("usage.minutesAgo", { count: Math.floor(diff / 60) });
|
||||
if (diff < 86400)
|
||||
return t("usage.hoursAgo", { count: Math.floor(diff / 3600) });
|
||||
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
appId,
|
||||
inline = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useSubscriptionQuota(appId, true);
|
||||
|
||||
// 定期更新相对时间显示
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
React.useEffect(() => {
|
||||
if (!quota?.queriedAt) return;
|
||||
const interval = setInterval(() => setNow(Date.now()), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [quota?.queriedAt]);
|
||||
|
||||
// 无凭据 → 不显示
|
||||
if (!quota || quota.credentialStatus === "not_found") return null;
|
||||
|
||||
// 凭据解析错误 → 不显示(静默)
|
||||
if (quota.credentialStatus === "parse_error") return null;
|
||||
|
||||
// 凭据过期
|
||||
if (quota.credentialStatus === "expired" && !quota.success) {
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle size={12} />
|
||||
<span>{t("subscription.expired")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
title={t("subscription.refresh")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle size={14} />
|
||||
<div>
|
||||
<span className="font-medium">{t("subscription.expired")}</span>
|
||||
<span className="ml-2 text-amber-500/70 dark:text-amber-400/70">
|
||||
{t("subscription.expiredHint", { tool: appId })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-amber-100 dark:hover:bg-amber-800/30 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
title={t("subscription.refresh")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// API 调用失败
|
||||
if (!quota.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-1.5 text-red-500 dark:text-red-400">
|
||||
<AlertCircle size={12} />
|
||||
<span>{t("subscription.queryFailed")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
title={t("subscription.refresh")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
|
||||
<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} />
|
||||
<span>{quota.error || t("subscription.queryFailed")}</span>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
title={t("subscription.refresh")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 成功获取数据
|
||||
const tiers = quota.tiers || [];
|
||||
if (tiers.length === 0) return null;
|
||||
|
||||
// ── inline 模式:紧凑两行显示 ──
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
|
||||
{/* 第一行:查询时间 + 刷新 */}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{quota.queriedAt
|
||||
? formatRelativeTime(quota.queriedAt, now, t)
|
||||
: t("usage.never", { defaultValue: "从未更新" })}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refetch();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
|
||||
title={t("subscription.refresh")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:各 tier 使用百分比 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{tiers
|
||||
.filter((tier) => !HIDDEN_INLINE_TIERS.has(tier.name))
|
||||
.map((tier) => (
|
||||
<TierBadge key={tier.name} tier={tier} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 展开模式:详细信息 ──
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{t("subscription.title", { defaultValue: "Subscription Quota" })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{quota.queriedAt && (
|
||||
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatRelativeTime(quota.queriedAt, now, t)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
|
||||
title={t("subscription.refresh")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{tiers.map((tier) => (
|
||||
<TierBar key={tier.name} tier={tier} t={t} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 超额使用 */}
|
||||
{quota.extraUsage?.isEnabled && (
|
||||
<div className="mt-2 pt-2 border-t border-border-default text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">{t("subscription.extraUsage")}: </span>
|
||||
<span className="tabular-nums">
|
||||
{quota.extraUsage.currency === "USD" ? "$" : ""}
|
||||
{(quota.extraUsage.usedCredits ?? 0).toFixed(2)}
|
||||
{quota.extraUsage.monthlyLimit != null && (
|
||||
<>
|
||||
{" "}
|
||||
/ {quota.extraUsage.currency === "USD" ? "$" : ""}
|
||||
{quota.extraUsage.monthlyLimit.toFixed(2)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** inline 模式下的单个 tier 显示 */
|
||||
export const TierBadge: React.FC<{
|
||||
tier: QuotaTier;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}> = ({ tier, t }) => {
|
||||
const label = TIER_I18N_KEYS[tier.name]
|
||||
? t(TIER_I18N_KEYS[tier.name])
|
||||
: tier.name;
|
||||
const countdown = countdownStr(tier.resetsAt);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-gray-500 dark:text-gray-400">{label}:</span>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
|
||||
>
|
||||
{t("subscription.utilization", { value: Math.round(tier.utilization) })}
|
||||
</span>
|
||||
{countdown && (
|
||||
<span className="text-muted-foreground/60 ml-0.5 flex items-center gap-px">
|
||||
<Clock size={10} />
|
||||
{countdown}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 展开模式下的单个 tier 进度条 */
|
||||
const TierBar: React.FC<{
|
||||
tier: QuotaTier;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}> = ({ tier, t }) => {
|
||||
const label = TIER_I18N_KEYS[tier.name]
|
||||
? t(TIER_I18N_KEYS[tier.name])
|
||||
: tier.name;
|
||||
const resetText = formatResetTime(tier.resetsAt, t);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className="text-gray-500 dark:text-gray-400 min-w-0 font-medium"
|
||||
style={{ width: "25%" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
tier.utilization >= 90
|
||||
? "bg-red-500"
|
||||
: tier.utilization >= 70
|
||||
? "bg-orange-500"
|
||||
: "bg-green-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(tier.utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 flex-shrink-0"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
|
||||
>
|
||||
{Math.round(tier.utilization)}%
|
||||
</span>
|
||||
{resetText && (
|
||||
<span
|
||||
className="text-[10px] text-muted-foreground/70 truncate"
|
||||
title={resetText}
|
||||
>
|
||||
{resetText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionQuotaFooter;
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { type AppId } from "@/lib/api";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
import { UsageData, Provider } from "@/types";
|
||||
import { TierBadge } from "@/components/SubscriptionQuotaFooter";
|
||||
import type { QuotaTier } from "@/types/subscription";
|
||||
|
||||
interface UsageFooterProps {
|
||||
provider: Provider;
|
||||
@@ -15,6 +17,15 @@ interface UsageFooterProps {
|
||||
inline?: boolean; // 是否内联显示(在按钮左侧)
|
||||
}
|
||||
|
||||
/** UsageData → QuotaTier 转换(Token Plan 使用) */
|
||||
function toQuotaTier(data: UsageData): QuotaTier {
|
||||
return {
|
||||
name: data.planName || "",
|
||||
utilization: data.used || 0,
|
||||
resetsAt: data.extra || null,
|
||||
};
|
||||
}
|
||||
|
||||
const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
provider,
|
||||
providerId,
|
||||
@@ -25,6 +36,8 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
inline = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isTokenPlan =
|
||||
provider.meta?.usage_script?.templateType === "token_plan";
|
||||
|
||||
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
|
||||
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
|
||||
@@ -108,7 +121,41 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
// 无数据时不显示
|
||||
if (usageDataList.length === 0) return null;
|
||||
|
||||
// 内联模式:仅显示第一个套餐的核心数据(分上下两行)
|
||||
// ── Token Plan:订阅风格内联渲染(百分比徽章 + 倒计时) ──
|
||||
if (isTokenPlan && inline) {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
|
||||
{/* 第一行:查询时间 + 刷新 */}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{lastQueriedAt
|
||||
? formatRelativeTime(lastQueriedAt, now, t)
|
||||
: t("usage.never", { defaultValue: "从未更新" })}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refetch();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
|
||||
title={t("usage.refreshUsage")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
{/* 第二行:tier 徽章(复用官方订阅的 TierBadge) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{usageDataList.map((data, index) => (
|
||||
<TierBadge key={index} tier={toQuotaTier(data)} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 通用用量:内联模式(原有逻辑) ──
|
||||
if (inline) {
|
||||
const firstUsage = usageDataList[0];
|
||||
const isExpired = firstUsage.isValid === false;
|
||||
@@ -231,6 +278,8 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ── 通用用量组件 ────────────────────────────────────────────
|
||||
|
||||
// 单个套餐数据展示组件
|
||||
const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -95,6 +95,9 @@ const generatePresetTemplates = (
|
||||
|
||||
// GitHub Copilot 模板不需要脚本,使用专用 API
|
||||
[TEMPLATE_TYPES.GITHUB_COPILOT]: "",
|
||||
|
||||
// Coding Plan 模板不需要脚本,使用专用 Rust 查询
|
||||
[TEMPLATE_TYPES.TOKEN_PLAN]: "",
|
||||
});
|
||||
|
||||
// 模板名称国际化键映射
|
||||
@@ -103,8 +106,33 @@ const TEMPLATE_NAME_KEYS: Record<string, string> = {
|
||||
[TEMPLATE_TYPES.GENERAL]: "usageScript.templateGeneral",
|
||||
[TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI",
|
||||
[TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot",
|
||||
[TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan",
|
||||
};
|
||||
|
||||
/** Coding Plan 供应商选项 */
|
||||
const TOKEN_PLAN_PROVIDERS = [
|
||||
{ id: "kimi", label: "Kimi For Coding", pattern: /api\.kimi\.com\/coding/i },
|
||||
{
|
||||
id: "zhipu",
|
||||
label: "Zhipu GLM (智谱)",
|
||||
pattern: /bigmodel\.cn|api\.z\.ai/i,
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
pattern: /api\.minimaxi?\.com|api\.minimax\.io/i,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 根据 Base URL 自动检测 Coding Plan 供应商 */
|
||||
function detectTokenPlanProvider(baseUrl: string | undefined): string | null {
|
||||
if (!baseUrl) return null;
|
||||
for (const cp of TOKEN_PLAN_PROVIDERS) {
|
||||
if (cp.pattern.test(baseUrl)) return cp.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
provider,
|
||||
appId,
|
||||
@@ -164,18 +192,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
const [script, setScript] = useState<UsageScript>(() => {
|
||||
const savedScript = provider.meta?.usage_script;
|
||||
const defaultScript = {
|
||||
if (savedScript) {
|
||||
// 已有配置:如果是 coding_plan 但没有 codingPlanProvider,自动检测填充
|
||||
if (
|
||||
savedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN &&
|
||||
!savedScript.codingPlanProvider
|
||||
) {
|
||||
return {
|
||||
...savedScript,
|
||||
codingPlanProvider:
|
||||
detectTokenPlanProvider(providerCredentials.baseUrl) || "kimi",
|
||||
};
|
||||
}
|
||||
return savedScript;
|
||||
}
|
||||
|
||||
// 新配置:如果 URL 匹配 Coding Plan,自动初始化
|
||||
const autoDetected = detectTokenPlanProvider(providerCredentials.baseUrl);
|
||||
if (autoDetected) {
|
||||
return {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
code: "",
|
||||
timeout: 10,
|
||||
codingPlanProvider: autoDetected,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
|
||||
timeout: 10,
|
||||
};
|
||||
|
||||
if (!savedScript) {
|
||||
return defaultScript;
|
||||
}
|
||||
|
||||
return savedScript;
|
||||
});
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -236,7 +285,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}
|
||||
// 优先使用保存的 templateType
|
||||
if (existingScript?.templateType) {
|
||||
return existingScript.templateType;
|
||||
return existingScript.templateType as string;
|
||||
}
|
||||
// 向后兼容:根据字段推断模板类型
|
||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||
@@ -247,7 +296,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
if (existingScript?.apiKey || existingScript?.baseUrl) {
|
||||
return TEMPLATE_TYPES.GENERAL;
|
||||
}
|
||||
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
|
||||
// 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板
|
||||
if (detectTokenPlanProvider(providerCredentials.baseUrl)) {
|
||||
return TEMPLATE_TYPES.TOKEN_PLAN;
|
||||
}
|
||||
// 默认使用 GENERAL(与默认代码模板一致)
|
||||
return TEMPLATE_TYPES.GENERAL;
|
||||
},
|
||||
);
|
||||
@@ -278,8 +331,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Copilot 模板不需要脚本验证
|
||||
if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) {
|
||||
// Copilot 和 Coding Plan 模板不需要脚本验证
|
||||
if (
|
||||
selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN
|
||||
) {
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
return;
|
||||
@@ -297,6 +353,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
| "general"
|
||||
| "newapi"
|
||||
| "github_copilot"
|
||||
| "token_plan"
|
||||
| undefined,
|
||||
};
|
||||
onSave(scriptWithTemplate);
|
||||
@@ -306,6 +363,45 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// Coding Plan 模板使用专用 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
|
||||
const config = provider.settingsConfig as Record<string, any>;
|
||||
const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? "";
|
||||
const apiKey: string =
|
||||
config?.env?.ANTHROPIC_AUTH_TOKEN ??
|
||||
config?.env?.ANTHROPIC_API_KEY ??
|
||||
"";
|
||||
const { subscriptionApi } = await import("@/lib/api/subscription");
|
||||
const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey);
|
||||
if (quota.success && quota.tiers.length > 0) {
|
||||
const summary = quota.tiers
|
||||
.map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`)
|
||||
.join(", ");
|
||||
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
||||
duration: 3000,
|
||||
closeButton: true,
|
||||
});
|
||||
// 将结果转换为 UsageResult 格式更新缓存
|
||||
const usageData = quota.tiers.map((tier) => ({
|
||||
planName: tier.name,
|
||||
remaining: 100 - tier.utilization,
|
||||
total: 100,
|
||||
used: tier.utilization,
|
||||
unit: "%",
|
||||
}));
|
||||
queryClient.setQueryData(["usage", provider.id, appId], {
|
||||
success: true,
|
||||
data: usageData,
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("usageScript.testFailed")}: ${quota.error || t("endpointTest.noResult")}`,
|
||||
{ duration: 5000 },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Copilot 模板使用专用 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT) {
|
||||
const accountId = resolveManagedAccountId(
|
||||
@@ -410,7 +506,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
const handleUsePreset = (presetName: string) => {
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset) {
|
||||
if (preset !== undefined) {
|
||||
if (presetName === TEMPLATE_TYPES.CUSTOM) {
|
||||
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||
// 这样可以避免同源检查导致的问题
|
||||
@@ -447,6 +543,21 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) {
|
||||
// Coding Plan 模板不需要脚本,使用 Rust 原生查询
|
||||
const autoDetected = detectTokenPlanProvider(
|
||||
providerCredentials.baseUrl,
|
||||
);
|
||||
setScript({
|
||||
...script,
|
||||
code: "",
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
codingPlanProvider:
|
||||
script.codingPlanProvider || autoDetected || "kimi",
|
||||
});
|
||||
}
|
||||
setSelectedTemplate(presetName);
|
||||
}
|
||||
@@ -529,10 +640,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
.filter((name) => {
|
||||
const isCopilotProvider =
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
// Copilot 供应商只显示 copilot 模板,其他供应商不显示 copilot 模板
|
||||
// Copilot 供应商只显示 copilot 模板
|
||||
if (isCopilotProvider) {
|
||||
return name === TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
}
|
||||
// 非 Copilot 供应商不显示 copilot 模板
|
||||
return name !== TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
})
|
||||
.map((name) => {
|
||||
@@ -634,6 +746,43 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coding Plan 模式:供应商选择 */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="space-y-3 border-t border-white/10 pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usageScript.tokenPlanHint")}
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TOKEN_PLAN_PROVIDERS.map((cp) => (
|
||||
<Button
|
||||
key={cp.id}
|
||||
type="button"
|
||||
variant={
|
||||
script.codingPlanProvider === cp.id
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border",
|
||||
script.codingPlanProvider === cp.id
|
||||
? "shadow-sm"
|
||||
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
setScript({
|
||||
...script,
|
||||
codingPlanProvider: cp.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{cp.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 凭证配置 */}
|
||||
{shouldShowCredentialsConfig && (
|
||||
<div className="space-y-4">
|
||||
@@ -860,40 +1009,42 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 提取器代码 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
|
||||
<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")}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<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((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) =>
|
||||
setScript((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 帮助信息 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
|
||||
<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">
|
||||
{`({
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<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">
|
||||
{`({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/usage",
|
||||
method: "POST",
|
||||
@@ -910,39 +1061,39 @@ 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-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ interface ProviderActionsProps {
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onConfigureUsage?: () => void;
|
||||
onDelete: () => void;
|
||||
onRemoveFromConfig?: () => void;
|
||||
onDisableOmo?: () => void;
|
||||
@@ -263,15 +263,17 @@ export function ProviderActions({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onConfigureUsage}
|
||||
title={t("provider.configureUsage")}
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
{onConfigureUsage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onConfigureUsage}
|
||||
title={t("provider.configureUsage")}
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onOpenTerminal && (
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
|
||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
@@ -55,6 +56,30 @@ interface ProviderCardProps {
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */
|
||||
function isOfficialProvider(provider: Provider, appId: AppId): boolean {
|
||||
const config = provider.settingsConfig as Record<string, any>;
|
||||
if (appId === "claude") {
|
||||
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "");
|
||||
}
|
||||
if (appId === "codex") {
|
||||
// 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方)
|
||||
const apiKey = config?.auth?.OPENAI_API_KEY;
|
||||
return !apiKey || (typeof apiKey === "string" && apiKey.trim() === "");
|
||||
}
|
||||
if (appId === "gemini") {
|
||||
// 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式
|
||||
const apiKey = config?.env?.GEMINI_API_KEY;
|
||||
const baseUrl = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||
return (
|
||||
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) &&
|
||||
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
if (provider.notes?.trim()) {
|
||||
return provider.notes.trim();
|
||||
@@ -146,6 +171,7 @@ export function ProviderCard({
|
||||
}, [provider.notes, displayUrl, fallbackUrlText]);
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
const isOfficial = isOfficialProvider(provider, appId);
|
||||
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
|
||||
@@ -160,32 +186,19 @@ export function ProviderCard({
|
||||
autoQueryInterval,
|
||||
});
|
||||
|
||||
const isTokenPlan =
|
||||
provider.meta?.usage_script?.templateType === "token_plan";
|
||||
const hasMultiplePlans =
|
||||
usage?.success && usage.data && usage.data.length > 1;
|
||||
usage?.success && usage.data && usage.data.length > 1 && !isTokenPlan;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMultiplePlans) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasMultiplePlans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionsRef.current) {
|
||||
const updateWidth = () => {
|
||||
const width = actionsRef.current?.offsetWidth || 0;
|
||||
setActionsWidth(width);
|
||||
};
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => window.removeEventListener("resize", updateWidth);
|
||||
}
|
||||
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
|
||||
|
||||
const handleOpenWebsite = () => {
|
||||
if (!isClickableUrl) {
|
||||
return;
|
||||
@@ -332,17 +345,12 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||
style={
|
||||
{
|
||||
"--actions-width": `${actionsWidth || 320}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-center ml-auto min-w-0 gap-3">
|
||||
<div className="ml-auto">
|
||||
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||
{hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{isOfficial ? (
|
||||
<SubscriptionQuotaFooter appId={appId} inline={true} />
|
||||
) : hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">
|
||||
{t("usage.multiplePlans", {
|
||||
@@ -385,10 +393,7 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={actionsRef}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 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-opacity duration-200">
|
||||
<ProviderActions
|
||||
appId={appId}
|
||||
isCurrent={isCurrent}
|
||||
@@ -399,8 +404,12 @@ export function ProviderCard({
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onTest={
|
||||
onTest && !isOfficial ? () => onTest(provider) : undefined
|
||||
}
|
||||
onConfigureUsage={
|
||||
isOfficial ? undefined : () => onConfigureUsage(provider)
|
||||
}
|
||||
onDelete={() => onDelete(provider)}
|
||||
onRemoveFromConfig={
|
||||
onRemoveFromConfig
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -24,15 +24,20 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
||||
import { CopilotAuthSection } from "./CopilotAuthSection";
|
||||
import {
|
||||
copilotGetModels,
|
||||
copilotGetModelsForAccount,
|
||||
} from "@/lib/api/copilot";
|
||||
import type { CopilotModel } from "@/lib/api/copilot";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
@@ -179,6 +184,37 @@ export function ClaudeFormFields({
|
||||
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
|
||||
// 通用模型获取(非 Copilot 供应商)
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
hasApiKey: !!apiKey,
|
||||
hasBaseUrl: !!baseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(baseUrl, apiKey, isFullUrl)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, isFullUrl, t]);
|
||||
|
||||
// 当 Copilot 预设且已认证时,加载可用模型
|
||||
useEffect(() => {
|
||||
// 如果不是 Copilot 预设或未认证,清空模型列表
|
||||
@@ -298,14 +334,15 @@ export function ClaudeFormFields({
|
||||
);
|
||||
}
|
||||
|
||||
// 非 Copilot 供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁)
|
||||
return (
|
||||
<Input
|
||||
<ModelInputWithFetch
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
onChange={(v) => onModelChange(field, v)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
fetchedModels={fetchedModels}
|
||||
isLoading={isFetchingModels}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -502,7 +539,26 @@ export function ClaudeFormFields({
|
||||
|
||||
{/* 模型映射 */}
|
||||
<div className="space-y-1 pt-2 border-t">
|
||||
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
|
||||
{!isCopilotPreset && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.modelMappingHint")}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -65,6 +74,36 @@ export function CodexFormFields({
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!codexBaseUrl || !codexApiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
hasApiKey: !!codexApiKey,
|
||||
hasBaseUrl: !!codexBaseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Codex API Key 输入框 */}
|
||||
@@ -107,21 +146,38 @@ export function CodexFormFields({
|
||||
{/* Codex Model Name 输入框 */}
|
||||
{shouldShowModelField && onModelNameChange && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexModelName"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
||||
</label>
|
||||
<input
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexModelName"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
</div>
|
||||
<ModelInputWithFetch
|
||||
id="codexModelName"
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
onChange={(v) => onModelNameChange!(v)}
|
||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||
defaultValue: "例如: gpt-5.4",
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
|
||||
fetchedModels={fetchedModels}
|
||||
isLoading={isFetchingModels}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{modelName.trim()
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCopilotAuth } from "./hooks/useCopilotAuth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import type { GitHubAccount } from "@/lib/api";
|
||||
|
||||
interface CopilotAuthSectionProps {
|
||||
@@ -67,7 +68,7 @@ export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
// 复制用户码
|
||||
const copyUserCode = async () => {
|
||||
if (deviceCode?.user_code) {
|
||||
await navigator.clipboard.writeText(deviceCode.user_code);
|
||||
await copyText(deviceCode.user_code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Info } from "lucide-react";
|
||||
import { Download, Info, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -66,6 +73,36 @@ export function GeminiFormFields({
|
||||
}: GeminiFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
hasApiKey: !!apiKey,
|
||||
hasBaseUrl: !!baseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(baseUrl, apiKey)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, t]);
|
||||
|
||||
// 检测是否为 Google 官方(使用 OAuth)
|
||||
const isGoogleOfficial =
|
||||
partnerPromotionKey?.toLowerCase() === "google-official";
|
||||
@@ -123,15 +160,34 @@ export function GeminiFormFields({
|
||||
|
||||
{/* Model 输入框 */}
|
||||
{shouldShowModelField && (
|
||||
<div>
|
||||
<FormLabel htmlFor="gemini-model">
|
||||
{t("provider.form.gemini.model", { defaultValue: "模型" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel htmlFor="gemini-model">
|
||||
{t("provider.form.gemini.model", { defaultValue: "模型" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
</div>
|
||||
<ModelInputWithFetch
|
||||
id="gemini-model"
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
onChange={onModelChange}
|
||||
placeholder="gemini-3-pro-preview"
|
||||
fetchedModels={fetchedModels}
|
||||
isLoading={isFetchingModels}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,9 +16,30 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Download,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
|
||||
import type { ProviderCategory, OpenClawModel } from "@/types";
|
||||
|
||||
@@ -70,6 +91,8 @@ export function OpenClawFormFields({
|
||||
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
// Stable key tracking for models list
|
||||
const modelKeysRef = useRef<string[]>([]);
|
||||
@@ -107,6 +130,34 @@ export function OpenClawFormFields({
|
||||
]);
|
||||
};
|
||||
|
||||
// Fetch models from API
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
hasApiKey: !!apiKey,
|
||||
hasBaseUrl: !!baseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(baseUrl, apiKey)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, t]);
|
||||
|
||||
// Remove a model entry
|
||||
const handleRemoveModel = (index: number) => {
|
||||
modelKeysRef.current.splice(index, 1);
|
||||
@@ -234,16 +285,33 @@ export function OpenClawFormFields({
|
||||
<FormLabel>
|
||||
{t("openclaw.models", { defaultValue: "模型列表" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("openclaw.addModel", { defaultValue: "添加模型" })}
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("openclaw.addModel", { defaultValue: "添加模型" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
@@ -283,15 +351,66 @@ export function OpenClawFormFields({
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
|
||||
</label>
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "id", e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelIdPlaceholder", {
|
||||
defaultValue: "claude-3-sonnet",
|
||||
})}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "id", e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelIdPlaceholder", {
|
||||
defaultValue: "claude-3-sonnet",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{Object.entries(
|
||||
fetchedModels.reduce(
|
||||
(acc, m) => {
|
||||
const v = m.ownedBy || "Other";
|
||||
if (!acc[v]) acc[v] = [];
|
||||
acc[v].push(m);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FetchedModel[]>,
|
||||
),
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([vendor, vModels], vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>
|
||||
{vendor}
|
||||
</DropdownMenuLabel>
|
||||
{vModels.map((m) => (
|
||||
<DropdownMenuItem
|
||||
key={m.id}
|
||||
onSelect={() =>
|
||||
handleModelChange(index, "id", m.id)
|
||||
}
|
||||
>
|
||||
{m.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -10,8 +10,29 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -136,6 +157,49 @@ function ModelOptionKeyInput({
|
||||
);
|
||||
}
|
||||
|
||||
/** Dropdown button to select from fetched models */
|
||||
function ModelDropdown({
|
||||
models,
|
||||
onSelect,
|
||||
}: {
|
||||
models: FetchedModel[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const grouped: Record<string, FetchedModel[]> = {};
|
||||
for (const model of models) {
|
||||
const vendor = model.ownedBy || "Other";
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(model);
|
||||
}
|
||||
const vendors = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{vendors.map((vendor, vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
|
||||
{grouped[vendor].map((m) => (
|
||||
<DropdownMenuItem key={m.id} onSelect={() => onSelect(m.id)}>
|
||||
{m.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenCodeFormFieldsProps {
|
||||
// NPM Package
|
||||
npm: string;
|
||||
@@ -182,6 +246,36 @@ export function OpenCodeFormFields({
|
||||
}: OpenCodeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
hasApiKey: !!apiKey,
|
||||
hasBaseUrl: !!baseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(baseUrl, apiKey)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, t]);
|
||||
|
||||
// Track which models have expanded options panel
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -552,16 +646,33 @@ export function OpenCodeFormFields({
|
||||
<FormLabel>
|
||||
{t("opencode.models", { defaultValue: "Models" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addModel", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addModel", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(models).length === 0 ? (
|
||||
@@ -600,13 +711,21 @@ export function OpenCodeFormFields({
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<ModelIdInput
|
||||
modelId={key}
|
||||
onChange={(newId) => handleModelIdChange(key, newId)}
|
||||
placeholder={t("opencode.modelId", {
|
||||
defaultValue: "Model ID",
|
||||
})}
|
||||
/>
|
||||
<div className="flex gap-1 flex-1">
|
||||
<ModelIdInput
|
||||
modelId={key}
|
||||
onChange={(newId) => handleModelIdChange(key, newId)}
|
||||
placeholder={t("opencode.modelId", {
|
||||
defaultValue: "Model ID",
|
||||
})}
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) => handleModelIdChange(key, id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(e) => handleModelNameChange(key, e.target.value)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { authApi, settingsApi } from "@/lib/api";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import type {
|
||||
ManagedAuthProvider,
|
||||
ManagedAuthStatus,
|
||||
@@ -58,7 +59,7 @@ export function useManagedAuth(authProvider: ManagedAuthProvider) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(response.user_code);
|
||||
await copyText(response.user_code);
|
||||
} catch (e) {
|
||||
console.debug("[ManagedAuth] Failed to copy user code:", e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, Download, Loader2 } from "lucide-react";
|
||||
import type { FetchedModel } from "@/lib/api/model-fetch";
|
||||
|
||||
interface ModelInputWithFetchProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
fetchedModels: FetchedModel[];
|
||||
isLoading: boolean;
|
||||
/** 传入时显示获取按钮;不传时只在有数据后显示下拉 */
|
||||
onFetch?: () => void;
|
||||
}
|
||||
|
||||
export function ModelInputWithFetch({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
fetchedModels,
|
||||
isLoading,
|
||||
onFetch,
|
||||
}: ModelInputWithFetchProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 有模型数据: Input + DropdownMenu
|
||||
if (fetchedModels.length > 0) {
|
||||
const grouped: Record<string, FetchedModel[]> = {};
|
||||
for (const model of fetchedModels) {
|
||||
const vendor = model.ownedBy || "Other";
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(model);
|
||||
}
|
||||
const vendors = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{vendors.map((vendor, vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
|
||||
{grouped[vendor].map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onSelect={() => onChange(model.id)}
|
||||
>
|
||||
{model.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 加载中: Input + Spinner
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon" className="shrink-0" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 有 onFetch: Input + 获取按钮
|
||||
if (onFetch) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
type="button"
|
||||
onClick={onFetch}
|
||||
title={t("providerForm.fetchModels")}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 无 onFetch: 纯 Input
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ApiKeySection } from "./ApiKeySection";
|
||||
export { EndpointField } from "./EndpointField";
|
||||
export { ModelInputWithFetch } from "./ModelInputWithFetch";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ScrollText,
|
||||
HardDriveDownload,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
||||
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -189,16 +191,14 @@ export function SettingsPage({
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
|
||||
<TabsList className="grid w-full grid-cols-6 mb-6 glass rounded-lg">
|
||||
<TabsTrigger value="general">
|
||||
{t("settings.tabGeneral")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
|
||||
{/* HIDDEN: Copilot auth tab temporarily disabled - 用户反馈消耗过快
|
||||
<TabsTrigger value="auth">
|
||||
{t("settings.tabAuth", { defaultValue: "认证" })}
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
@@ -254,7 +254,6 @@ export function SettingsPage({
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* HIDDEN: Copilot auth tab temporarily disabled - 用户反馈消耗过快
|
||||
<TabsContent value="auth" className="space-y-6 mt-0 pb-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -282,7 +281,6 @@ export function SettingsPage({
|
||||
<AuthCenterPanel />
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||
{settings ? (
|
||||
|
||||
@@ -707,7 +707,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
requiresOAuth: true,
|
||||
icon: "github",
|
||||
iconColor: "#000000",
|
||||
hidden: true, // 临时隐藏:用户反馈消耗速度过快
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const TEMPLATE_TYPES = {
|
||||
GENERAL: "general",
|
||||
NEW_API: "newapi",
|
||||
GITHUB_COPILOT: "github_copilot",
|
||||
TOKEN_PLAN: "token_plan",
|
||||
} as const;
|
||||
|
||||
export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
|
||||
|
||||
@@ -183,7 +183,6 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
"此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -203,13 +202,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
|
||||
// 根据供应商类型显示不同的成功提示
|
||||
if (
|
||||
!proxyRequiredReason &&
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
(isCopilotProvider ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses")
|
||||
) {
|
||||
// OpenAI format provider: show proxy hint
|
||||
// OpenAI format provider: show proxy hint (skip if warning already shown)
|
||||
toast.info(
|
||||
isCopilotProvider
|
||||
? t("notifications.copilotProxyHint")
|
||||
|
||||
@@ -790,7 +790,18 @@
|
||||
"categoryOfficial": "Official",
|
||||
"categoryCnOfficial": "Opensource Official",
|
||||
"categoryAggregation": "Aggregation",
|
||||
"categoryThirdParty": "Third Party"
|
||||
"categoryThirdParty": "Third Party",
|
||||
"fetchModels": "Fetch Models",
|
||||
"fetchingModels": "Fetching...",
|
||||
"fetchModelsSuccess": "Found {{count}} models",
|
||||
"fetchModelsFailed": "Failed to fetch models",
|
||||
"fetchModelsEmpty": "No models found",
|
||||
"fetchModelsNeedApiKey": "Please fill in API Key first",
|
||||
"fetchModelsNeedEndpoint": "Please fill in API endpoint first",
|
||||
"fetchModelsNeedConfig": "Please fill in API endpoint and API Key first",
|
||||
"fetchModelsAuthFailed": "API Key is invalid or lacks permission",
|
||||
"fetchModelsNotSupported": "This provider does not support fetching model list",
|
||||
"fetchModelsTimeout": "Request timed out, please check network connection"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot Authentication",
|
||||
@@ -1079,7 +1090,9 @@
|
||||
"templateGeneral": "General",
|
||||
"templateNewAPI": "NewAPI",
|
||||
"templateCopilot": "GitHub Copilot",
|
||||
"templateTokenPlan": "Token Plan",
|
||||
"copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed",
|
||||
"tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota",
|
||||
"resetDate": "Reset date",
|
||||
"premiumRequests": "Premium Requests",
|
||||
"credentialsConfig": "Credentials",
|
||||
@@ -2265,5 +2278,23 @@
|
||||
},
|
||||
"suggestedDefaults": "Apply Suggested Defaults",
|
||||
"suggestedDefaultsHint": "Use this preset's recommended default model configuration"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subscription Quota",
|
||||
"fiveHour": "5-Hour",
|
||||
"sevenDay": "7-Day",
|
||||
"sevenDayOpus": "7-Day (Opus)",
|
||||
"sevenDaySonnet": "7-Day (Sonnet)",
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "Weekly",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
"extraUsage": "Extra Usage",
|
||||
"expired": "Session expired",
|
||||
"expiredHint": "Run the {{tool}} command to refresh your login",
|
||||
"queryFailed": "Query failed",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +790,18 @@
|
||||
"categoryOfficial": "公式",
|
||||
"categoryCnOfficial": "オープンソース公式",
|
||||
"categoryAggregation": "アグリゲーター",
|
||||
"categoryThirdParty": "サードパーティ"
|
||||
"categoryThirdParty": "サードパーティ",
|
||||
"fetchModels": "モデル一覧を取得",
|
||||
"fetchingModels": "取得中...",
|
||||
"fetchModelsSuccess": "{{count}}件のモデルを取得",
|
||||
"fetchModelsFailed": "モデル一覧の取得に失敗しました",
|
||||
"fetchModelsEmpty": "モデルが見つかりません",
|
||||
"fetchModelsNeedApiKey": "先に API Key を入力してください",
|
||||
"fetchModelsNeedEndpoint": "先に API エンドポイントを入力してください",
|
||||
"fetchModelsNeedConfig": "先に API エンドポイントと API Key を入力してください",
|
||||
"fetchModelsAuthFailed": "API Key が無効か、権限がありません",
|
||||
"fetchModelsNotSupported": "このプロバイダーはモデル一覧の取得に対応していません",
|
||||
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot 認証",
|
||||
@@ -1079,7 +1090,9 @@
|
||||
"templateGeneral": "General",
|
||||
"templateNewAPI": "NewAPI",
|
||||
"templateCopilot": "GitHub Copilot",
|
||||
"templateTokenPlan": "Token Plan",
|
||||
"copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要",
|
||||
"tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ",
|
||||
"resetDate": "リセット日",
|
||||
"premiumRequests": "Premium リクエスト",
|
||||
"credentialsConfig": "認証情報",
|
||||
@@ -2265,5 +2278,23 @@
|
||||
},
|
||||
"suggestedDefaults": "推奨デフォルト設定を適用",
|
||||
"suggestedDefaultsHint": "このプリセットの推奨デフォルトモデル設定を使用します"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "サブスクリプションクォータ",
|
||||
"fiveHour": "5時間",
|
||||
"sevenDay": "7日間",
|
||||
"sevenDayOpus": "7日間(Opus)",
|
||||
"sevenDaySonnet": "7日間(Sonnet)",
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "週間",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}後にリセット",
|
||||
"extraUsage": "超過使用量",
|
||||
"expired": "セッション期限切れ",
|
||||
"expiredHint": "{{tool}}コマンドを実行してログインを更新してください",
|
||||
"queryFailed": "クエリに失敗しました",
|
||||
"refresh": "更新"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +790,18 @@
|
||||
"categoryOfficial": "官方",
|
||||
"categoryCnOfficial": "开源官方",
|
||||
"categoryAggregation": "聚合服务",
|
||||
"categoryThirdParty": "第三方"
|
||||
"categoryThirdParty": "第三方",
|
||||
"fetchModels": "获取模型列表",
|
||||
"fetchingModels": "正在获取...",
|
||||
"fetchModelsSuccess": "获取到 {{count}} 个模型",
|
||||
"fetchModelsFailed": "获取模型列表失败",
|
||||
"fetchModelsEmpty": "未找到可用模型",
|
||||
"fetchModelsNeedApiKey": "请先填写 API Key",
|
||||
"fetchModelsNeedEndpoint": "请先填写 API 端点",
|
||||
"fetchModelsNeedConfig": "请先填写 API 端点和 API Key",
|
||||
"fetchModelsAuthFailed": "API Key 无效或无权限",
|
||||
"fetchModelsNotSupported": "该供应商不支持获取模型列表",
|
||||
"fetchModelsTimeout": "请求超时,请检查网络连接"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot 认证",
|
||||
@@ -1079,7 +1090,9 @@
|
||||
"templateGeneral": "通用模板",
|
||||
"templateNewAPI": "NewAPI",
|
||||
"templateCopilot": "GitHub Copilot",
|
||||
"templateTokenPlan": "Token Plan",
|
||||
"copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证",
|
||||
"tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度",
|
||||
"resetDate": "重置日期",
|
||||
"premiumRequests": "Premium 请求",
|
||||
"credentialsConfig": "凭证配置",
|
||||
@@ -2265,5 +2278,23 @@
|
||||
},
|
||||
"suggestedDefaults": "应用建议默认配置",
|
||||
"suggestedDefaultsHint": "使用此预设推荐的默认模型配置"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "订阅额度",
|
||||
"fiveHour": "5小时",
|
||||
"sevenDay": "7天",
|
||||
"sevenDayOpus": "7天(Opus)",
|
||||
"sevenDaySonnet": "7天(Sonnet)",
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每周",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}后重置",
|
||||
"extraUsage": "超额用量",
|
||||
"expired": "会话已过期",
|
||||
"expiredHint": "请运行 {{tool}} 命令刷新登录",
|
||||
"queryFailed": "查询失败",
|
||||
"refresh": "刷新"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export { mcpApi } from "./mcp";
|
||||
export { promptsApi } from "./prompts";
|
||||
export { skillsApi } from "./skills";
|
||||
export { usageApi } from "./usage";
|
||||
export { subscriptionApi } from "./subscription";
|
||||
export { vscodeApi } from "./vscode";
|
||||
export { proxyApi } from "./proxy";
|
||||
export { openclawApi } from "./openclaw";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface FetchedModel {
|
||||
id: string;
|
||||
ownedBy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从供应商获取可用模型列表
|
||||
*
|
||||
* 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
* 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
*/
|
||||
export async function fetchModelsForConfig(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
isFullUrl?: boolean,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型显示对应的 toast 提示
|
||||
*/
|
||||
export function showFetchModelsError(
|
||||
err: unknown,
|
||||
t: TFunction,
|
||||
opts?: { hasApiKey: boolean; hasBaseUrl: boolean },
|
||||
): void {
|
||||
// 前端预检:缺少必填字段
|
||||
if (opts && !opts.hasBaseUrl && !opts.hasApiKey) {
|
||||
toast.error(t("providerForm.fetchModelsNeedConfig"));
|
||||
return;
|
||||
}
|
||||
if (opts && !opts.hasApiKey) {
|
||||
toast.error(t("providerForm.fetchModelsNeedApiKey"));
|
||||
return;
|
||||
}
|
||||
if (opts && !opts.hasBaseUrl) {
|
||||
toast.error(t("providerForm.fetchModelsNeedEndpoint"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析后端错误字符串
|
||||
const msg = String(err);
|
||||
|
||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
||||
toast.error(t("providerForm.fetchModelsAuthFailed"));
|
||||
return;
|
||||
}
|
||||
if (msg.includes("HTTP 404") || msg.includes("HTTP 405")) {
|
||||
toast.error(t("providerForm.fetchModelsNotSupported"));
|
||||
return;
|
||||
}
|
||||
if (msg.includes("timeout") || msg.includes("timed out")) {
|
||||
toast.error(t("providerForm.fetchModelsTimeout"));
|
||||
return;
|
||||
}
|
||||
if (msg.includes("Failed to parse")) {
|
||||
toast.error(t("providerForm.fetchModelsNotSupported"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 通用兜底
|
||||
toast.error(t("providerForm.fetchModelsFailed"));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SubscriptionQuota } from "@/types/subscription";
|
||||
|
||||
export const subscriptionApi = {
|
||||
getQuota: (tool: string): Promise<SubscriptionQuota> =>
|
||||
invoke("get_subscription_quota", { tool }),
|
||||
getCodingPlanQuota: (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<SubscriptionQuota> =>
|
||||
invoke("get_coding_plan_quota", { baseUrl, apiKey }),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export async function copyText(text: string): Promise<void> {
|
||||
try {
|
||||
await invoke("copy_text_to_clipboard", { text });
|
||||
return;
|
||||
} catch (nativeError) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch (webError) {
|
||||
throw webError instanceof Error
|
||||
? webError
|
||||
: nativeError instanceof Error
|
||||
? nativeError
|
||||
: new Error(String(webError || nativeError));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from "./queryClient";
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./proxy";
|
||||
export * from "./subscription";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { subscriptionApi } from "@/lib/api/subscription";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export function useSubscriptionQuota(appId: AppId, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["subscription", "quota", appId],
|
||||
queryFn: () => subscriptionApi.getQuota(appId),
|
||||
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
|
||||
refetchInterval: REFETCH_INTERVAL,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: REFETCH_INTERVAL,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface UsageScript {
|
||||
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
|
||||
accessToken?: string; // 访问令牌(NewAPI 模板使用)
|
||||
userId?: string; // 用户ID(NewAPI 模板使用)
|
||||
codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax")
|
||||
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
|
||||
autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段
|
||||
request?: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export type CredentialStatus =
|
||||
| "valid"
|
||||
| "expired"
|
||||
| "not_found"
|
||||
| "parse_error";
|
||||
|
||||
export interface QuotaTier {
|
||||
name: string;
|
||||
utilization: number; // 0-100
|
||||
resetsAt: string | null;
|
||||
}
|
||||
|
||||
export interface ExtraUsage {
|
||||
isEnabled: boolean;
|
||||
monthlyLimit: number | null;
|
||||
usedCredits: number | null;
|
||||
utilization: number | null;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionQuota {
|
||||
tool: string;
|
||||
credentialStatus: CredentialStatus;
|
||||
credentialMessage: string | null;
|
||||
success: boolean;
|
||||
tiers: QuotaTier[];
|
||||
extraUsage: ExtraUsage | null;
|
||||
error: string | null;
|
||||
queriedAt: number | null;
|
||||
}
|
||||
@@ -194,7 +194,8 @@ describe("useProviderActions", () => {
|
||||
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks switching providers that require proxy when proxy is not running", async () => {
|
||||
it("warns but still switches providers that require proxy when proxy is not running", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
category: "custom",
|
||||
@@ -211,12 +212,12 @@ describe("useProviderActions", () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
||||
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
||||
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it("blocks switching Codex full URL providers when proxy is not running", async () => {
|
||||
it("warns but still switches Codex full URL providers when proxy is not running", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
category: "custom",
|
||||
@@ -233,9 +234,8 @@ describe("useProviderActions", () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
||||
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
||||
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
|
||||
|
||||
Reference in New Issue
Block a user