Compare commits

..

58 Commits

Author SHA1 Message Date
Jason 5816ca8ae3 fix(test): align Bedrock API Key test assertions with preset implementation
The API Key preset was refactored to use standard UI input (apiKey: "")
instead of template variables, but the tests were not updated accordingly.
2026-02-24 20:42:51 +08:00
root f6cee96394 refactor: address PR review - split Bedrock into two presets, restore SVG icon
Based on maintainer review feedback on PR #1047:

1. Split merged "AWS Bedrock" back into two separate presets:
   - "AWS Bedrock (AKSK)": uses AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
   - "AWS Bedrock (API Key)": uses top-level apiKey field via standard UI input

2. Restore aws.svg to pure vector SVG (was PNG-in-SVG)

3. Remove all Bedrock-specific logic from shared components:
   - Remove isBedrock prop from ClaudeFormFields
   - Remove Bedrock validation/cleanup blocks from ProviderForm
   - Remove optional/isSecret from TemplateValueConfig
   - Remove optional skip from useTemplateValues

4. Add cloud_provider category handling:
   - Skip API Key/Base URL required validation
   - Hide Speed Test and Base URL for cloud_provider
   - Hide API format selector for cloud_provider (always Anthropic)
   - Show API Key input only when config has apiKey field

5. Fix providerConfigUtils to support top-level apiKey:
   - getApiKeyFromConfig: check config.apiKey before env fields
   - setApiKeyInConfig: write to config.apiKey when present
   - hasApiKeyField: detect top-level apiKey property

6. Add OpenClaw Bedrock preset (bedrock-converse-stream protocol)

7. Update model IDs:
   - Sonnet: global.anthropic.claude-sonnet-4-6
   - Opus: global.anthropic.claude-opus-4-6-v1
   - Haiku: global.anthropic.claude-haiku-4-5-20251001-v1:0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:51 +08:00
root ecd5927769 chore: remove planning documents
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 1bac3922fb fix: address code review findings
- Fix AWS icon: use SVG with embedded <image> instead of raw <img> tag
- Hide duplicate ApiKeySection for Bedrock (auth via template fields only)
- Guard settingsConfig cleanup against unresolved template placeholders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 920b675ec2 style: replace AWS icon with new PNG image
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root b5ace10066 style: replace AWS icon with updated logo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 6dcc3803b4 style: replace AWS icon with updated logo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 158a679b8b fix: mask Bedrock API Key as secret and support GovCloud regions
- Add isSecret: true to BEDROCK_API_KEY template value
- Update region regex to support multi-segment regions (us-gov-west-1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root c303cc8acf feat: extend TemplateValueConfig and merge Bedrock presets
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 71dc459eb2 feat: add Bedrock validation, cleanup, and isBedrock prop in ProviderForm
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 7fee0d8fa6 feat: support isSecret template fields and hide base URL for Bedrock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:24 +08:00
root 657b7c29c2 feat: skip optional template values in validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 750e1060e8 Add AWS Bedrock UI merge implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 3d25350cbf Add AWS Bedrock UI merge design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 860d6ec8dd docs: add AWS Bedrock support to README (EN/ZH/JA)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 8371e9e683 chore: remove internal planning documents
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root acbeb03917 docs: add AWS Bedrock provider feature summary for PR
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 7cee0de784 feat: add AWS Bedrock OpenCode provider preset with @ai-sdk/amazon-bedrock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 39ec987396 feat: add AWS Bedrock (API Key) Claude Code provider preset with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 9b71a280ea feat: add AWS Bedrock (AKSK) Claude Code provider preset with tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root c5668165a8 feat: add cloud_provider category to ProviderCategory type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root d9a7c78138 Update implementation plan: add OpenCode Bedrock support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root c8a6aab5b2 Add AWS Bedrock provider implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
root 49de0896b3 Add AWS Bedrock provider integration design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:41:35 +08:00
Jason e1e2bdef2a fix(about): show update badge instead of green check when newer version available 2026-02-24 10:23:18 +08:00
Jason c380528a27 feat(workspace): make directory paths clickable and rename "Today's Note" to "Add Memory"
Add open_workspace_directory Tauri command to open workspace/memory dirs
in the system file manager. Rename dailyMemory.createToday across all locales.
2026-02-24 09:45:10 +08:00
Jason a8dbea1398 feat(workspace): add full-text search for daily memory files
Add backend search command that performs case-insensitive matching
across all daily memory files, supporting both date and content queries.
Frontend includes animated search bar (⌘F), debounced input, snippet
display with match count badge, and search state preservation across
edits.
2026-02-24 09:03:30 +08:00
Jason 7138aca310 feat(preset): update domestic model providers to latest versions
- MiniMax: M2.1 → M2.5
- Zhipu GLM: glm-4.7 → glm-5
- BaiLing: Ling-1T → Ling-2.5-1T
- DouBaoSeed: doubao-seed-code-preview-latest → doubao-seed-2-0-code-preview-latest
- Qwen (Bailian): qwen3-max → qwen3.5-plus
2026-02-23 22:39:42 +08:00
Jason Young 992dda5c5c refactor(provider): switch from full config overwrite to partial key-field merging (#1098)
* refactor(provider): switch from full config overwrite to partial key-field merging

Replace the provider switching mechanism for Claude/Codex/Gemini from
full settings_config overwrite to partial key-field replacement, preserving
user's non-provider settings (plugins, MCP, permissions, etc.) across switches.

- Add write_live_partial() with per-app implementations for Claude (JSON env
  merge), Codex (auth replace + TOML partial merge), and Gemini (env merge)
- Add backfill_key_fields() to extract only provider-specific fields when
  saving live config back to provider entries
- Update switch_normal, sync_current_to_live, add, update to use partial merge
- Remove common config snippet feature for Claude/Codex/Gemini (no longer
  needed with partial merging); preserve OMO common config
- Delete 6 frontend files (3 components + 3 hooks), clean up 11 modified files
- Remove backend extract_common_config_* methods, 3 Tauri commands,
  CommonConfigSnippets struct, and related migration code
- Update integration tests to validate key-field-only backfill behavior

* refactor(cleanup): remove dead code and redundant MCP sync after partial-merge refactor

- Remove ConfigService legacy full-overwrite sync methods (~150 lines)
- Remove redundant McpService::sync_all_enabled from switch_normal
- Switch proxy fallback recovery from write_live_snapshot to write_live_partial
- Remove dead ProviderService::write_gemini_live wrapper
- Update tests to reflect partial-merge behavior (MCP preserved, not re-synced)

* feat(claude): add Quick Toggles for common Claude Code preferences

Add checkbox toggles for hideAttribution, alwaysThinking, and
enableTeammates that write directly to the live settings file via
RFC 7396 JSON Merge Patch. Mirror changes to the form editor using
form.watch for reactive updates.

* fix(provider): add missing key fields to partial-merge constants

Add provider-specific fields verified against official docs to prevent
key residue or loss during provider switching:

- Claude: CLAUDE_CODE_SUBAGENT_MODEL (env), model (top-level)
- Codex: review_model, plan_mode_reasoning_effort
- Gemini: GOOGLE_API_KEY (official alternative to GEMINI_API_KEY)

* fix(provider): expand partial-merge key fields for Bedrock, Vertex, Foundry and behavior settings

Add missing env/top-level fields to CLAUDE_KEY_ENV_FIELDS and
CLAUDE_KEY_TOP_LEVEL so that provider switching correctly replaces
(and clears) credentials and flags for AWS Bedrock, Google Vertex AI,
Microsoft Foundry, and provider behavior overrides like max output
tokens and prompt caching.

* feat(provider): add auth field selector for Claude providers (AUTH_TOKEN / API_KEY)

Allow users to choose between ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY
when creating or editing custom Claude providers, persisted in meta.apiKeyField.

* refactor(preset): remove AiHubMix hardcoded API_KEY in favor of generic auth selector

AiHubMix was the only preset that hardcoded ANTHROPIC_API_KEY before the
generic auth field selector was introduced. Now that users can freely
choose between AUTH_TOKEN and API_KEY via the UI, remove the special-case
and default AiHubMix to the standard ANTHROPIC_AUTH_TOKEN.
2026-02-23 21:57:04 +08:00
Jason 1b20b7ff88 feat(backup): add hourly periodic backup timer during runtime
Previously periodic backup only checked on startup. Now spawns a
tokio interval task that checks every hour while the app is running.
2026-02-23 11:27:28 +08:00
Jason f8820aa22c feat(backup): add independent backup panel, configurable policy, and rename support
Extract backup & restore into a standalone AccordionItem in Advanced settings.
Add configurable auto-backup interval (disabled/6h/12h/24h/48h/7d) and retention
count (3-50) via settings. Add per-backup rename with inline editing UI.
2026-02-23 11:27:28 +08:00
Jason 3afec8a10f feat(backup): add pre-migration backup, periodic backup, backfill warning, and backup management UI
Four improvements to the database backup mechanism:

1. Auto backup before schema migration - creates a snapshot when
   upgrading from an older database version, providing a safety net
   beyond the existing SAVEPOINT rollback mechanism.

2. Periodic startup backup - checks on app launch whether the latest
   backup is older than 24 hours and creates a new one if needed,
   ensuring all users have recent backups regardless of usage patterns.

3. Backfill failure notification - switch now returns SwitchResult with
   warnings instead of silently ignoring backfill errors, so users are
   informed when their manual config changes may not have been saved.

4. Backup management UI - new BackupListSection in Settings > Data
   Management showing all backup snapshots with restore capability,
   including a confirmation dialog and automatic safety backup before
   restore.
2026-02-23 11:27:28 +08:00
Jason 5ebc879f09 feat(settings): add first-run confirmation dialogs for proxy and usage features
Prevent accidental activation of advanced features by showing a one-time
info dialog. Once confirmed, the flag is persisted in settings.json and
the dialog never appears again.

- Proxy: confirmation when toggling proxy server ON for the first time
- Usage: confirmation when enabling usage query inside UsageScriptModal
- Enhanced ConfirmDialog with "info" variant (blue icon + default button)
- Added i18n translations for zh, en, ja
2026-02-23 11:27:28 +08:00
Jason 6b4ba64bbd feat(toolbar): add smooth transition animation for AppSwitcher compact toggle
Replace conditional rendering with always-rendered span driven by CSS
max-width + opacity animation. Add time-lock in useAutoCompact to prevent
ResizeObserver flicker during expand animation.
2026-02-23 11:27:28 +08:00
Jason 5763b9094b fix(toolbar): remove height constraint that clipped AppSwitcher
The toolbar wrapper's fixed h-[32px] was smaller than AppSwitcher's
natural 40px height (32px buttons + 8px padding), causing visual
clipping. Removed the constraint and let flex layout handle sizing
within the 64px header.
2026-02-23 11:27:28 +08:00
Jason ce92f37ef0 feat(toolbar): auto-compact AppSwitcher based on available width
Replace hardcoded app count threshold with ResizeObserver-based
detection. Uses a two-layer layout (overflow-hidden outer + shrink-0
inner) to avoid the compact/normal oscillation problem.
2026-02-23 11:27:28 +08:00
Jason 2e676e5f53 feat(sessions): add session browsing for Gemini CLI 2026-02-23 11:27:28 +08:00
Jason c3f29a62d1 feat(sessions): default filter to current app when entering session page
Navigate directly to the active app's sessions instead of showing all.
2026-02-23 11:27:28 +08:00
Jason 165af5eec4 feat(sessions): add session browsing for OpenCode and OpenClaw
Add two new session providers following the existing convention-based
pattern. OpenCode reads three-layer JSON storage, OpenClaw parses JSONL
event streams. Wire up backend dispatch, frontend filter dropdown, icon
mappings, toolbar button for OpenClaw, and i18n subtitle updates.
2026-02-23 11:27:28 +08:00
Jason 8b9c09d994 feat(toolbar): add fade transition when switching between OpenClaw and other apps 2026-02-23 11:27:28 +08:00
Jason a8d391bd74 refactor(settings): split Data accordion into Import/Export and Cloud Sync 2026-02-23 11:27:28 +08:00
Jason 7d9b20721e refactor(settings): split Advanced tab into Proxy tab and move Pricing to Usage
Extract proxy-related accordion items (Local Proxy, Failover, Rectifier,
Global Outbound Proxy) into a dedicated Proxy tab via ProxyTabContent
component. Move Pricing config panel to UsageDashboard as a collapsible
accordion. This reduces SettingsPage from ~716 to ~426 lines and improves
settings discoverability with a 5-tab layout: General | Proxy | Advanced |
Usage | About.
2026-02-23 11:27:28 +08:00
Kelvin Chiu d11df17b5d feat: more granular local environment checks (#870)
* feat: more granular local environment checks

* refactor: improve PR #870 with i18n, shadcn Select, and testable helpers

- Extract is_valid_shell, is_valid_shell_flag, default_flag_for_shell
  to module-level #[cfg(windows)] functions for testability
- Add unit tests for extracted helper functions
- Replace native <select> with shadcn/ui Select components
- Extract env badge ternary to ENV_BADGE_CONFIG Record lookup
- Add i18n keys for env badges and WSL selectors (zh/en/ja)
- Unify initial useEffect load path with loadAllToolVersions()

* fix: prevent useEffect re-firing on wslShellByTool changes

The useEffect that loads initial tool versions depended on
loadAllToolVersions, which in turn depended on wslShellByTool.
This caused a full re-fetch of all 4 tools every time the user
changed a WSL shell or flag, racing with the single-tool refresh.

Fix: use empty deps [] since this is a mount-only effect. The
refresh button and shell/flag handlers cover subsequent updates.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-02-23 11:26:23 +08:00
Jason 4c88174cb0 refactor(provider): replace startup auto-import with manual import button
Remove the startup loop in lib.rs that auto-imported default providers
for Claude/Codex/Gemini. Move config snippet extraction logic into the
import_default_config command so it works when triggered manually.

Add an "Import Current Config" button to ProviderEmptyState, wired via
useMutation in ProviderList (shown only for standard apps). Update i18n
keys (zh/en/ja) with new button labels and revised empty state text.
2026-02-20 22:01:18 +08:00
Jason 5a72888852 fix(query): remove auto-import side effect from useProvidersQuery queryFn
Users now trigger provider import manually via the empty state button,
keeping queryFn pure and preventing unintended imports on mount/refocus.
2026-02-20 21:57:45 +08:00
Jason 54f1bfb5d0 chore(tray): hide Auto Failover shortcut from tray menu
The Auto (Failover) toggle in the system tray is no longer shown.
The feature remains fully functional via the Settings page.
All related backend code (handle_auto_click, AUTO_SUFFIX, etc.)
is preserved for easy re-enablement.
2026-02-20 20:08:54 +08:00
Jason 4efab15174 refactor(openclaw): make primary model read-only in Agents panel
The primary model field in the Agents defaults panel now displays as
read-only, eliminating the duplicate edit entry with the "Set as Default
Model" button on provider cards. Fallback models and runtime parameters
remain editable.
2026-02-20 16:46:02 +08:00
Jason 357d32c17e fix(i18n): clarify OpenClaw default model button labels
Rename "Enable/Default" to "Set Default/Current Default" across
zh/en/ja locales and widen button to fit longer text.
2026-02-20 11:11:10 +08:00
Jason 7532308519 fix(workspace): sort daily memory by date and inline into file grid
- Sort daily memory list by filename (YYYY-MM-DD.md) instead of mtime
  so editing an older file no longer bumps it to the top
- Move daily memory card inline with workspace MD file buttons
- Shorten card description across all locales
- Defer file creation until user actually saves (no empty file on create)
2026-02-20 10:40:58 +08:00
Jason 4440a14082 chore(ui): exclude OpenClaw from MCP & Skills app toggle lists 2026-02-20 10:40:48 +08:00
Jason d1bb4480db feat(workspace): add daily memory file management for OpenClaw
Add browse, edit, create and delete support for daily memory files
(~/.openclaw/workspace/memory/YYYY-MM-DD.md) in the Workspace panel.
2026-02-20 08:54:53 +08:00
Jason d04c279890 chore(ui): hide proxy/pricing config for OpenCode/OpenClaw providers 2026-02-19 23:34:20 +08:00
Jason 3125b5419c chore(ui): hide stream check / model test config panels
The stream check feature is unreliable due to diverse provider request
formats. Comment out the model test config UI in settings page, provider
advanced config, and the test button in provider actions. Backend code
and i18n keys are preserved for future restoration.
2026-02-19 23:19:44 +08:00
Jason 0fa6b33b5e feat(settings): add enableLocalProxy toggle to gate main page proxy UI
New users often accidentally trigger ProxyToggle/FailoverToggle on the
main page. Add a settings toggle (default off) so the proxy controls
only appear when explicitly enabled. The proxy service start/stop in
settings remains independent of this visibility flag.
2026-02-19 23:06:22 +08:00
Jason 1b71dc721c refactor(omo): deduplicate OMO/OMO Slim via OmoVariant parameterization
Introduce OmoVariant struct with STANDARD/SLIM constants to eliminate
~250 lines of copy-pasted code across DAO, service, commands, and
frontend layers. Adding a new OMO variant now requires only a single
const declaration instead of duplicating ~400 lines.
2026-02-19 21:11:58 +08:00
Jason 8e219b5eb1 feat(omo): add OMO Slim (oh-my-opencode-slim) support
Implement full OMO Slim profile management to align with ai-toolbox:
- Backend: Slim service methods, DAO, Tauri commands, plugin conflict handling
- Frontend: types, API, query hooks, form integration with isSlim parameterization
- Slim variant: 6 agents (no categories), separate config file and plugin name
- Mutual exclusion: standard OMO and Slim cannot coexist as plugins
- i18n: zh/en/ja translations for all Slim agent descriptions
2026-02-19 20:47:55 +08:00
Jason 51476953ae style: apply prettier formatting to App, SettingsPage, useSettings 2026-02-18 23:40:31 +08:00
Jason 0f4ce74916 refactor(forms): extract OpenCode/OMO/OpenClaw state from ProviderForm into dedicated hooks
Split ProviderForm.tsx (2227 → 1526 lines) by extracting:
- opencodeFormUtils.ts: pure functions and default config constants
- useOmoModelSource: OMO model source collection from OpenCode providers
- useOpencodeFormState: OpenCode provider form state and handlers
- useOmoDraftState: OMO profile draft editing state
- useOpenclawFormState: OpenClaw provider form state and handlers
2026-02-18 23:36:36 +08:00
125 changed files with 7786 additions and 6460 deletions
+1
View File
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **AWS Bedrock Support**: Built-in AWS Bedrock provider presets with AKSK and API Key authentication, cross-region inference support (global/us/eu/apac), covering Claude Code and OpenCode
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
+1
View File
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
**コア機能**
- **プロバイダ管理**Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
- **AWS Bedrock 対応**AWS Bedrock プロバイダプリセットを内蔵、AKSK および API Key 認証に対応、クロスリージョン推論(global/us/eu/apac)をサポート、Claude Code と OpenCode に対応
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
+1
View File
@@ -157,6 +157,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **AWS Bedrock 支持**:内置 AWS Bedrock 供应商预设,支持 AKSK 和 API Key 两种认证方式,支持跨区域推理(global/us/eu/apac),覆盖 Claude Code 和 OpenCode
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
-60
View File
@@ -352,49 +352,6 @@ impl FromStr for AppType {
}
}
/// 通用配置片段(按应用分治)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
}
impl CommonConfigSnippets {
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
}
}
/// 设置指定应用的通用配置片段
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
}
}
}
/// 多应用配置结构(向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiAppConfig {
@@ -412,12 +369,6 @@ pub struct MultiAppConfig {
/// Claude Skills 配置
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_common_config_snippet: Option<String>,
}
fn default_version() -> u32 {
@@ -439,8 +390,6 @@ impl Default for MultiAppConfig {
mcp: McpRoot::default(),
prompts: PromptRoot::default(),
skills: SkillStore::default(),
common_config_snippets: CommonConfigSnippets::default(),
claude_common_config_snippet: None,
}
}
}
@@ -534,15 +483,6 @@ impl MultiAppConfig {
updated = true;
}
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
log::info!(
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
);
config.common_config_snippets.claude = Some(old_claude_snippet);
updated = true;
}
if updated {
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
config.save()?;
+20 -59
View File
@@ -164,38 +164,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
Ok(true)
}
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
state
.db
.get_config_snippet("claude")
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
state
.db
.set_config_snippet("claude", value)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
@@ -215,7 +183,7 @@ pub async fn set_common_config_snippet(
) -> Result<(), String> {
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" | "omo" => {
"claude" | "gemini" | "omo" | "omo-slim" => {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
@@ -238,35 +206,28 @@ pub async fn set_common_config_snippet(
if app_type == "omo"
&& state
.db
.get_current_omo_provider("opencode")
.get_current_omo_provider("opencode", "omo")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(state.inner())
.map_err(|e| e.to_string())?;
crate::services::OmoService::write_config_to_file(
state.inner(),
&crate::services::omo::STANDARD,
)
.map_err(|e| e.to_string())?;
}
if app_type == "omo-slim"
&& state
.db
.get_current_omo_provider("opencode", "omo-slim")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(
state.inner(),
&crate::services::omo::SLIM,
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
settingsConfig: Option<String>,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<String, String> {
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
let settings: serde_json::Value =
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app,
&settings,
)
.map_err(|e| e.to_string());
}
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
.map_err(|e| e.to_string())
}
+32
View File
@@ -8,6 +8,8 @@ use tauri_plugin_dialog::DialogExt;
use crate::commands::sync_support::{
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
};
use crate::database::backup::BackupEntry;
use crate::database::Database;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::store::AppState;
@@ -118,3 +120,33 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
Ok(result.map(|p| p.to_string()))
}
// ─── Database backup management ─────────────────────────────
/// List all database backup files
#[tauri::command]
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
Database::list_backups().map_err(|e| e.to_string())
}
/// Restore database from a backup file
#[tauri::command]
pub async fn restore_db_backup(
state: State<'_, AppState>,
filename: String,
) -> Result<String, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
.await
.map_err(|e| format!("Restore failed: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// Rename a database backup file
#[tauri::command]
pub fn rename_db_backup(
#[allow(non_snake_case)] oldFilename: String,
#[allow(non_snake_case)] newName: String,
) -> Result<String, String> {
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
}
+239 -42
View File
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use tauri::AppHandle;
@@ -85,50 +86,122 @@ pub struct ToolVersion {
version: Option<String>,
latest_version: Option<String>, // 新增字段:最新版本
error: Option<String>,
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
env_type: String,
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WslShellPreferenceInput {
#[serde(default)]
pub wsl_shell: Option<String>,
#[serde(default)]
pub wsl_shell_flag: Option<String>,
}
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
#[cfg(target_os = "windows")]
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
if let Some(distro) = wsl_distro_for_tool(tool) {
("wsl".to_string(), Some(distro))
} else {
("windows".to_string(), None)
}
}
#[cfg(target_os = "macos")]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("macos".to_string(), None)
}
#[cfg(target_os = "linux")]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("linux".to_string(), None)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("unknown".to_string(), None)
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini", "opencode"];
pub async fn get_tool_versions(
tools: Option<Vec<String>>,
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
) -> Result<Vec<ToolVersion>, String> {
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
VALID_TOOLS
.iter()
.copied()
.filter(|t| set.contains(t))
.collect()
} else {
VALID_TOOLS.to_vec()
};
let mut results = Vec::new();
for tool in requested {
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
}
Ok(results)
}
/// 获取单个工具的版本信息(内部实现)
async fn get_single_tool_version_impl(
tool: &str,
wsl_shell: Option<&str>,
wsl_shell_flag: Option<&str>,
) -> ToolVersion {
debug_assert!(
VALID_TOOLS.contains(&tool),
"unexpected tool name in get_single_tool_version_impl: {tool}"
);
// 判断该工具的运行环境 & WSL distro(如有)
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
try_get_version_wsl(tool, &distro)
// 1. 获取本地版本
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
} else {
let direct_result = try_get_version(tool);
if direct_result.0.is_some() {
direct_result
} else {
// 先尝试直接执行
let direct_result = try_get_version(tool);
scan_cli_version(tool)
}
};
if direct_result.0.is_some() {
direct_result
} else {
// 扫描常见的 npm 全局安装路径
scan_cli_version(tool)
}
};
// 2. 获取远程最新版本
let latest_version = match tool {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
_ => None,
};
// 2. 获取远程最新版本
let latest_version = match tool {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
_ => None,
};
results.push(ToolVersion {
name: tool.to_string(),
version: local_version,
latest_version,
error: local_error,
});
ToolVersion {
name: tool.to_string(),
version: local_version,
latest_version,
error: local_error,
env_type,
wsl_distro,
}
Ok(results)
}
/// Helper function to fetch latest version from npm registry
@@ -242,8 +315,38 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
/// Validate that the given shell name is one of the allowed shells.
#[cfg(target_os = "windows")]
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
fn is_valid_shell(shell: &str) -> bool {
matches!(
shell.rsplit('/').next().unwrap_or(shell),
"sh" | "bash" | "zsh" | "fish" | "dash"
)
}
/// Validate that the given shell flag is one of the allowed flags.
#[cfg(target_os = "windows")]
fn is_valid_shell_flag(flag: &str) -> bool {
matches!(flag, "-c" | "-lc" | "-lic")
}
/// Return the default invocation flag for the given shell.
#[cfg(target_os = "windows")]
fn default_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
"fish" => "-lc",
_ => "-lic",
}
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(
tool: &str,
distro: &str,
force_shell: Option<&str>,
force_shell_flag: Option<&str>,
) -> (Option<String>, Option<String>) {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
@@ -257,15 +360,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
}
// 构建 Shell 脚本检测逻辑
let (shell, flag, cmd) = if let Some(shell) = force_shell {
// Defensive validation: never allow an arbitrary executable name here.
if !is_valid_shell(shell) {
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
}
let shell = shell.rsplit('/').next().unwrap_or(shell);
let flag = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return (
None,
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
);
}
flag
} else {
default_flag_for_shell(shell)
};
(shell.to_string(), flag, format!("{tool} --version"))
} else {
let cmd = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return (
None,
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
);
}
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
} else {
// 兜底:自动尝试 -lic, -lc, -c
format!(
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
)
};
("sh".to_string(), "-c", cmd)
};
let output = Command::new("wsl.exe")
.args([
"-d",
distro,
"--",
"sh",
"-lc",
&format!("{tool} --version"),
])
.args(["-d", distro, "--", &shell, flag, &cmd])
.creation_flags(CREATE_NO_WINDOW)
.output();
@@ -306,7 +441,12 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
fn try_get_version_wsl(
_tool: &str,
_distro: &str,
_force_shell: Option<&str>,
_force_shell_flag: Option<&str>,
) -> (Option<String>, Option<String>) {
(
None,
Some("WSL check not supported on this platform".to_string()),
@@ -1061,6 +1201,63 @@ mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_extract_version() {
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
assert_eq!(extract_version("no version here"), "no version here");
}
#[cfg(target_os = "windows")]
mod wsl_helpers {
use super::super::*;
#[test]
fn test_is_valid_shell() {
assert!(is_valid_shell("bash"));
assert!(is_valid_shell("zsh"));
assert!(is_valid_shell("sh"));
assert!(is_valid_shell("fish"));
assert!(is_valid_shell("dash"));
assert!(is_valid_shell("/usr/bin/bash"));
assert!(is_valid_shell("/bin/zsh"));
assert!(!is_valid_shell("powershell"));
assert!(!is_valid_shell("cmd"));
assert!(!is_valid_shell(""));
}
#[test]
fn test_is_valid_shell_flag() {
assert!(is_valid_shell_flag("-c"));
assert!(is_valid_shell_flag("-lc"));
assert!(is_valid_shell_flag("-lic"));
assert!(!is_valid_shell_flag("-x"));
assert!(!is_valid_shell_flag(""));
assert!(!is_valid_shell_flag("--login"));
}
#[test]
fn test_default_flag_for_shell() {
assert_eq!(default_flag_for_shell("sh"), "-c");
assert_eq!(default_flag_for_shell("dash"), "-c");
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
assert_eq!(default_flag_for_shell("fish"), "-lc");
assert_eq!(default_flag_for_shell("bash"), "-lic");
assert_eq!(default_flag_for_shell("zsh"), "-lic");
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
}
#[test]
fn test_is_valid_wsl_distro_name() {
assert!(is_valid_wsl_distro_name("Ubuntu"));
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
assert!(is_valid_wsl_distro_name("my_distro"));
assert!(!is_valid_wsl_distro_name(""));
assert!(!is_valid_wsl_distro_name("distro with spaces"));
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
}
}
#[test]
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
let home = PathBuf::from("/home/tester");
+54 -5
View File
@@ -1,19 +1,19 @@
use tauri::State;
use crate::services::omo::OmoLocalFileData;
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
use crate::services::OmoService;
use crate::store::AppState;
#[tauri::command]
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
OmoService::read_local_file().map_err(|e| e.to_string())
OmoService::read_local_file(&STANDARD).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
let provider = state
.db
.get_current_omo_provider("opencode")
.get_current_omo_provider("opencode", "omo")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
@@ -28,11 +28,11 @@ pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), Strin
if p.category.as_deref() == Some("omo") {
state
.db
.clear_omo_provider_current("opencode", id)
.clear_omo_provider_current("opencode", id, "omo")
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file().map_err(|e| e.to_string())?;
OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;
Ok(())
}
@@ -48,3 +48,52 @@ pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize,
.count();
Ok(count)
}
// ── OMO Slim commands ───────────────────────────────────────
#[tauri::command]
pub async fn read_omo_slim_local_file() -> Result<OmoLocalFileData, String> {
OmoService::read_local_file(&SLIM).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_slim_provider_id(
state: State<'_, AppState>,
) -> Result<String, String> {
let provider = state
.db
.get_current_omo_provider("opencode", "omo-slim")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
#[tauri::command]
pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
for (id, p) in &providers {
if p.category.as_deref() == Some("omo-slim") {
state
.db
.clear_omo_provider_current("opencode", id, "omo-slim")
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_slim_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo-slim"))
.count();
Ok(count)
}
+20 -8
View File
@@ -4,7 +4,9 @@ use tauri::State;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
use crate::store::AppState;
use std::str::FromStr;
@@ -67,7 +69,11 @@ pub fn remove_provider_from_live_config(
.map_err(|e| e.to_string())
}
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
fn switch_provider_internal(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
ProviderService::switch(state, app_type, id)
}
@@ -76,7 +82,7 @@ pub fn switch_provider_test_hook(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
) -> Result<SwitchResult, AppError> {
switch_provider_internal(state, app_type, id)
}
@@ -85,15 +91,15 @@ pub fn switch_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
) -> Result<SwitchResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
switch_provider_internal(&state, app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
ProviderService::import_default_config(state, app_type)
let imported = ProviderService::import_default_config(state, app_type)?;
Ok(imported)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
@@ -161,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
-40
View File
@@ -407,43 +407,3 @@ pub async fn get_circuit_breaker_stats(
let _ = (state, provider_id, app_type);
Ok(None)
}
// ==================== URL 预览相关命令 ====================
use crate::app_config::AppType;
use crate::proxy::url_builder::{self, UrlPreview};
/// 构建 URL 预览
///
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
/// 这个命令与后端代理的 URL 构建逻辑保持一致。
#[tauri::command]
pub fn build_url_preview(
app_type: String,
base_url: String,
api_format: Option<String>,
) -> Result<UrlPreview, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
Ok(url_builder::build_url_preview(
&app,
&base_url,
api_format.as_deref(),
))
}
/// 检查是否需要代理
///
/// 返回需要代理的原因(openai_chat_format, full_url, url_mismatch),
/// 或 null 表示不需要代理。
#[tauri::command]
pub fn check_proxy_requirement(
app_type: String,
base_url: String,
api_format: Option<String>,
) -> Result<Option<String>, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
Ok(
url_builder::check_proxy_requirement(&app, &base_url, api_format.as_deref())
.map(|s| s.to_string()),
)
}
+302
View File
@@ -1,3 +1,8 @@
use regex::Regex;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
use crate::config::write_text_file;
use crate::openclaw_config::get_openclaw_dir;
@@ -24,6 +29,281 @@ fn validate_filename(filename: &str) -> Result<(), String> {
Ok(())
}
// --- Daily memory files (memory/YYYY-MM-DD.md) ---
static DAILY_MEMORY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap());
fn validate_daily_memory_filename(filename: &str) -> Result<(), String> {
if !DAILY_MEMORY_RE.is_match(filename) {
return Err(format!(
"Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md"
));
}
Ok(())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyMemoryFileInfo {
pub filename: String,
pub date: String,
pub size_bytes: u64,
pub modified_at: u64,
pub preview: String,
}
// --- Daily memory commands ---
/// List all daily memory files under `workspace/memory/`.
#[tauri::command]
pub async fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
if !memory_dir.exists() {
return Ok(Vec::new());
}
let mut files: Vec<DailyMemoryFileInfo> = Vec::new();
let entries = std::fs::read_dir(&memory_dir)
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".md") {
continue;
}
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
let date = name.trim_end_matches(".md").to_string();
let size_bytes = meta.len();
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let preview = std::fs::read_to_string(entry.path())
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
files.push(DailyMemoryFileInfo {
filename: name,
date,
size_bytes,
modified_at,
preview,
});
}
// Sort by filename descending (newest date first, YYYY-MM-DD.md)
files.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(files)
}
/// Read a daily memory file.
#[tauri::command]
pub async fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {
validate_daily_memory_filename(&filename)?;
let path = get_openclaw_dir()
.join("workspace")
.join("memory")
.join(&filename);
if !path.exists() {
return Ok(None);
}
std::fs::read_to_string(&path)
.map(Some)
.map_err(|e| format!("Failed to read daily memory file {filename}: {e}"))
}
/// Write a daily memory file (atomic write).
#[tauri::command]
pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {
validate_daily_memory_filename(&filename)?;
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
std::fs::create_dir_all(&memory_dir)
.map_err(|e| format!("Failed to create memory directory: {e}"))?;
let path = memory_dir.join(&filename);
write_text_file(&path, &content)
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
}
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Search result for daily memory full-text search.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyMemorySearchResult {
pub filename: String,
pub date: String,
pub size_bytes: u64,
pub modified_at: u64,
pub snippet: String,
pub match_count: usize,
}
/// Full-text search across all daily memory files.
///
/// Performs case-insensitive search on both the date field and file content.
/// Returns results sorted by filename descending (newest first), each with a
/// snippet showing ~120 characters of context around the first match.
#[tauri::command]
pub async fn search_daily_memory_files(
query: String,
) -> Result<Vec<DailyMemorySearchResult>, String> {
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
if !memory_dir.exists() || query.is_empty() {
return Ok(Vec::new());
}
let query_lower = query.to_lowercase();
let mut results: Vec<DailyMemorySearchResult> = Vec::new();
let entries = std::fs::read_dir(&memory_dir)
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".md") {
continue;
}
let meta = match entry.metadata() {
Ok(m) if m.is_file() => m,
_ => continue,
};
let date = name.trim_end_matches(".md").to_string();
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
let content_lower = content.to_lowercase();
// Count matches in content
let content_matches: Vec<usize> = content_lower
.match_indices(&query_lower)
.map(|(i, _)| i)
.collect();
// Also check date field
let date_matches = date.to_lowercase().contains(&query_lower);
if content_matches.is_empty() && !date_matches {
continue;
}
// Build snippet around first content match (~120 chars of context)
let snippet = if let Some(&first_pos) = content_matches.first() {
let start = if first_pos > 50 {
floor_char_boundary(&content, first_pos - 50)
} else {
0
};
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
let mut s = String::new();
if start > 0 {
s.push_str("...");
}
s.push_str(&content[start..end]);
if end < content.len() {
s.push_str("...");
}
s
} else {
// Date-only match — use beginning of file as preview
let end = ceil_char_boundary(&content, 120.min(content.len()));
let mut s = content[..end].to_string();
if end < content.len() {
s.push_str("...");
}
s
};
let size_bytes = meta.len();
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
results.push(DailyMemorySearchResult {
filename: name,
date,
size_bytes,
modified_at,
snippet,
match_count: content_matches.len(),
});
}
// Sort by filename descending (newest date first)
results.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(results)
}
/// Delete a daily memory file (idempotent).
#[tauri::command]
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
validate_daily_memory_filename(&filename)?;
let path = get_openclaw_dir()
.join("workspace")
.join("memory")
.join(&filename);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?;
}
Ok(())
}
// --- Workspace file commands ---
/// Read an OpenClaw workspace file content.
/// Returns None if the file does not exist.
#[tauri::command]
@@ -58,3 +338,25 @@ pub async fn write_workspace_file(filename: String, content: String) -> Result<(
write_text_file(&path, &content)
.map_err(|e| format!("Failed to write workspace file {filename}: {e}"))
}
/// Open the workspace or memory directory in the system file manager.
/// `subdir`: "workspace" opens `~/.openclaw/workspace/`,
/// "memory" opens `~/.openclaw/workspace/memory/`.
#[tauri::command]
pub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result<bool, String> {
let dir = match subdir.as_str() {
"memory" => get_openclaw_dir().join("workspace").join("memory"),
_ => get_openclaw_dir().join("workspace"),
};
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {e}"))?;
}
handle
.opener()
.open_path(dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("Failed to open directory: {e}"))?;
Ok(true)
}
+202 -4
View File
@@ -2,7 +2,7 @@
//!
//! 提供 SQL 导出/导入和二进制快照备份功能。
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
use super::{lock_conn, Database};
use crate::config::get_app_config_dir;
use crate::error::AppError;
use chrono::Utc;
@@ -15,6 +15,15 @@ use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
/// A database backup entry for the UI
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupEntry {
pub filename: String,
pub size_bytes: u64,
pub created_at: String, // ISO 8601
}
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
pub fn export_sql_string(&self) -> Result<String, AppError> {
@@ -120,8 +129,47 @@ impl Database {
))
}
/// Periodic backup: create a new backup if the latest one is older than the configured interval
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
let interval_hours = crate::settings::effective_backup_interval_hours();
if interval_hours == 0 {
return Ok(()); // Auto-backup disabled
}
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
self.backup_database_file()?;
return Ok(());
}
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
.max()
});
let interval_secs = u64::from(interval_hours) * 3600;
let needs_backup = match latest {
None => true,
Some(last_modified) => {
last_modified.elapsed().unwrap_or_default()
> std::time::Duration::from_secs(interval_secs)
}
};
if needs_backup {
log::info!(
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
);
self.backup_database_file()?;
}
Ok(())
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
if !db_path.exists() {
return Ok(None);
@@ -161,6 +209,7 @@ impl Database {
/// 清理旧的数据库备份,保留最新的 N 个
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
let retain = crate::settings::effective_backup_retain_count();
let entries = match fs::read_dir(dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
@@ -175,11 +224,11 @@ impl Database {
Err(_) => return Ok(()),
};
if entries.len() <= DB_BACKUP_RETAIN {
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
let remove_count = entries.len().saturating_sub(retain);
let mut sorted = entries;
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
@@ -333,4 +382,153 @@ impl Database {
}
}
}
/// List all database backup files, sorted by creation time (newest first)
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
.map_err(|e| AppError::io(&backup_dir, e))?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
.filter_map(|e| {
let metadata = e.metadata().ok()?;
let filename = e.file_name().to_string_lossy().to_string();
let size_bytes = metadata.len();
let created_at = metadata
.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<Utc> = t.into();
dt.to_rfc3339()
})
.unwrap_or_default();
Some(BackupEntry {
filename,
size_bytes,
created_at,
})
})
.collect();
// Sort by created_at descending (newest first)
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
/// Restore database from a backup file. Returns the safety backup ID.
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
// Security: validate filename to prevent path traversal
if filename.contains("..")
|| filename.contains('/')
|| filename.contains('\\')
|| !filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
let backup_dir = get_app_config_dir().join("backups");
let backup_path = backup_dir.join(filename);
if !backup_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {filename}"
)));
}
// Step 1: Create safety backup of current database
let safety_backup = self.backup_database_file()?;
let safety_id = safety_backup
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_default();
// Step 2: Open the backup file and restore it to the main database
let source_conn =
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
{
let mut main_conn = lock_conn!(self.conn);
let backup = Backup::new(&source_conn, &mut main_conn)
.map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// Step 3: Run schema migrations (backup may be from an older version)
self.create_tables()?;
self.apply_schema_migrations()?;
self.ensure_model_pricing_seeded()?;
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
Ok(safety_id)
}
/// Rename a backup file. Returns the new filename.
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
// Validate old filename (path traversal + .db suffix)
if old_filename.contains("..")
|| old_filename.contains('/')
|| old_filename.contains('\\')
|| !old_filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
// Clean new name
let trimmed = new_name.trim();
if trimmed.is_empty() {
return Err(AppError::InvalidInput(
"New name cannot be empty".to_string(),
));
}
// Length limit (without .db suffix)
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
if name_part.len() > 100 {
return Err(AppError::InvalidInput(
"Name too long (max 100 characters)".to_string(),
));
}
// Prevent path traversal in new name
if name_part.contains("..")
|| name_part.contains('/')
|| name_part.contains('\\')
|| name_part.contains('\0')
{
return Err(AppError::InvalidInput(
"Invalid characters in new name".to_string(),
));
}
let new_filename = format!("{name_part}.db");
let backup_dir = get_app_config_dir().join("backups");
let old_path = backup_dir.join(old_filename);
let new_path = backup_dir.join(&new_filename);
if !old_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {old_filename}"
)));
}
if new_path.exists() {
return Err(AppError::InvalidInput(format!(
"A backup named '{new_filename}' already exists"
)));
}
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
log::info!("Renamed backup: {old_filename} -> {new_filename}");
Ok(new_filename)
}
}
+9 -5
View File
@@ -55,19 +55,23 @@ impl Default for OmoGlobalConfig {
}
impl Database {
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting("common_config_omo")?;
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting(key)?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
None => Ok(OmoGlobalConfig::default()),
}
}
pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> {
pub fn save_omo_global_config(
&self,
key: &str,
config: &OmoGlobalConfig,
) -> Result<(), AppError> {
let json_str = serde_json::to_string(config)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
self.set_setting("common_config_omo", &json_str)?;
self.set_setting(key, &json_str)?;
Ok(())
}
}
+24 -17
View File
@@ -364,25 +364,26 @@ impl Database {
&self,
app_type: &str,
provider_id: &str,
category: &str,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
params![app_type],
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
params![app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let updated = tx
.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
)
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
params![provider_id, app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
if updated != 1 {
return Err(AppError::Database(format!(
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
"Failed to set {category} provider current: provider '{provider_id}' not found in app '{app_type}'"
)));
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
@@ -393,12 +394,13 @@ impl Database {
&self,
app_type: &str,
provider_id: &str,
category: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
match conn.query_row(
"SELECT is_current FROM providers
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
params![provider_id, app_type, category],
|row| row.get(0),
) {
Ok(is_current) => Ok(is_current),
@@ -411,25 +413,30 @@ impl Database {
&self,
app_type: &str,
provider_id: &str,
category: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET is_current = 0
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
params![provider_id, app_type],
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
params![provider_id, app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
pub fn get_current_omo_provider(
&self,
app_type: &str,
category: &str,
) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
FROM providers
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
WHERE app_type = ?1 AND category = ?2 AND is_current = 1
LIMIT 1",
params![app_type],
params![app_type, category],
|row| {
Ok((
row.get(0)?,
@@ -444,7 +451,7 @@ impl Database {
},
);
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =
match row_data {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
@@ -453,7 +460,7 @@ impl Database {
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
"Failed to parse {category} provider settings_config (provider_id={id}): {e}"
))
})?;
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
@@ -461,7 +468,7 @@ impl Database {
} else {
serde_json::from_str(&meta_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse OMO provider meta (provider_id={id}): {e}"
"Failed to parse {category} provider meta (provider_id={id}): {e}"
))
})?
};
@@ -471,7 +478,7 @@ impl Database {
name,
settings_config,
website_url: None,
category,
category: Some(category.to_string()),
created_at,
sort_index,
notes,
-33
View File
@@ -58,9 +58,6 @@ impl Database {
// 4. 迁移 Skills
Self::migrate_skills(tx, config)?;
// 5. 迁移 Common Config
Self::migrate_common_config(tx, config)?;
Ok(())
}
@@ -212,34 +209,4 @@ impl Database {
Ok(())
}
/// 迁移通用配置片段
fn migrate_common_config(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
if let Some(snippet) = &config.common_config_snippets.claude {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_claude", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.codex {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_codex", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.gemini {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_gemini", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
Ok(())
}
}
+17 -4
View File
@@ -23,7 +23,7 @@
//! └── settings.rs
//! ```
mod backup;
pub(crate) mod backup;
mod dao;
mod migration;
mod schema;
@@ -43,9 +43,6 @@ use std::sync::Mutex;
// DAO 方法通过 impl Database 提供,无需额外导出
/// 数据库备份保留数量
const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 5;
@@ -110,6 +107,22 @@ impl Database {
conn: Mutex::new(conn),
};
db.create_tables()?;
// Pre-migration backup: only when upgrading from an existing database
{
let conn = lock_conn!(db.conn);
let version = Self::get_user_version(&conn)?;
drop(conn);
if version > 0 && version < SCHEMA_VERSION {
log::info!(
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
);
if let Err(e) = db.backup_database_file() {
log::warn!("Pre-migration backup failed, continuing migration: {e}");
}
}
}
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
-4
View File
@@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() {
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should succeed without any file I/O errors
@@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() {
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should validate the full migration path
+66 -54
View File
@@ -448,52 +448,7 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
if app_state
.db
.get_config_snippet(app.as_str())
.ok()
.flatten()
.is_none()
{
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
} else {
log::info!("✓ Extracted common config snippet for {}", app.as_str());
}
}
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
}
}
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
// 2. OpenCode 供应商导入(累加式模式,需特殊处理
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
// 需要遍历 provider 字段下的每个供应商并导入
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
@@ -512,7 +467,7 @@ pub fn run() {
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
.unwrap_or(false);
if !has_omo {
match crate::services::OmoService::import_from_local(&app_state) {
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::STANDARD) {
Ok(provider) => {
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
}
@@ -526,7 +481,36 @@ pub fn run() {
}
}
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
// 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)
{
let has_omo_slim = app_state
.db
.get_all_providers("opencode")
.map(|providers| {
providers
.values()
.any(|p| p.category.as_deref() == Some("omo-slim"))
})
.unwrap_or(false);
if !has_omo_slim {
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {
Ok(provider) => {
log::info!(
"✓ Imported OMO Slim config from local as provider '{}'",
provider.name
);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO Slim config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO Slim config from local: {e}");
}
}
}
}
// 2.4 OpenClaw 供应商导入(累加式模式,需特殊处理)
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
// 需要遍历 models.providers 字段下的每个供应商并导入
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
@@ -784,6 +768,25 @@ pub fn run() {
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
// Periodic backup check (on startup)
if let Err(e) = state.db.periodic_backup_if_needed() {
log::warn!("Periodic backup failed on startup: {e}");
}
// Periodic backup timer: check every hour while the app is running
let db_for_timer = state.db.clone();
tauri::async_runtime::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(3600));
interval.tick().await; // skip immediate first tick (already checked above)
loop {
interval.tick().await;
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
log::warn!("Periodic backup timer failed: {e}");
}
}
});
});
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
@@ -842,12 +845,10 @@ pub fn run() {
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::patch_claude_live_settings,
commands::get_settings,
commands::save_settings,
commands::get_rectifier_config,
@@ -912,6 +913,9 @@ pub fn run() {
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_file_dialog,
commands::list_db_backups,
commands::restore_db_backup,
commands::rename_db_backup,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
@@ -971,9 +975,6 @@ pub fn run() {
commands::get_circuit_breaker_config,
commands::update_circuit_breaker_config,
commands::get_circuit_breaker_stats,
// URL preview (for endpoint field)
commands::build_url_preview,
commands::check_proxy_requirement,
// Failover queue management
commands::get_failover_queue,
commands::get_available_providers_for_failover,
@@ -1038,9 +1039,20 @@ pub fn run() {
commands::get_current_omo_provider_id,
commands::get_omo_provider_count,
commands::disable_current_omo,
commands::read_omo_slim_local_file,
commands::get_current_omo_slim_provider_id,
commands::get_omo_slim_provider_count,
commands::disable_current_omo_slim,
// Workspace files (OpenClaw)
commands::read_workspace_file,
commands::write_workspace_file,
// Daily memory files (OpenClaw workspace)
commands::list_daily_memory_files,
commands::read_daily_memory_file,
commands::write_daily_memory_file,
commands::delete_daily_memory_file,
commands::search_daily_memory_files,
commands::open_workspace_directory,
]);
let app = builder
+11
View File
@@ -145,14 +145,25 @@ pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
match plugins {
Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
if plugin_name.starts_with("oh-my-opencode")
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
// Adding standard OMO -> remove all Slim variants
arr.retain(|v| {
v.as_str()
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
} else if plugin_name.starts_with("oh-my-opencode-slim") {
// Adding Slim -> remove all standard OMO variants (but keep slim)
arr.retain(|v| {
v.as_str()
.map(|s| {
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
})
.unwrap_or(true)
});
}
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
+5
View File
@@ -235,6 +235,11 @@ pub struct ProviderMeta {
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Claude 认证字段名(仅 Claude 供应商使用)
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
}
impl ProviderManager {
+7 -63
View File
@@ -749,17 +749,15 @@ impl RequestForwarder {
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let effective_endpoint = if needs_transform
&& adapter.name() == "Claude"
&& endpoint.starts_with("/v1/messages")
{
transform_claude_messages_endpoint(endpoint)
} else {
endpoint.to_string()
};
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, &effective_endpoint);
let url = adapter.build_url(&base_url, effective_endpoint);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
@@ -929,57 +927,3 @@ fn extract_error_message(error: &ProxyError) -> Option<String> {
_ => Some(error.to_string()),
}
}
fn transform_claude_messages_endpoint(endpoint: &str) -> String {
let transformed = endpoint.replacen("/v1/messages", "/v1/chat/completions", 1);
let Some((path, query)) = transformed.split_once('?') else {
return transformed;
};
// 转换到 chat/completions 时,显式移除 beta=true,避免把旧接口参数带到新接口。
let filtered_params: Vec<&str> = query
.split('&')
.filter(|segment| !segment.is_empty())
.filter(|segment| !is_beta_true_query_pair(segment))
.collect();
if filtered_params.is_empty() {
path.to_string()
} else {
format!("{path}?{}", filtered_params.join("&"))
}
}
fn is_beta_true_query_pair(segment: &str) -> bool {
let Some((key, value)) = segment.split_once('=') else {
return false;
};
key.eq_ignore_ascii_case("beta") && value.eq_ignore_ascii_case("true")
}
#[cfg(test)]
mod tests {
use super::transform_claude_messages_endpoint;
#[test]
fn transform_claude_messages_endpoint_strips_beta_true_only() {
let endpoint = "/v1/messages?beta=true&foo=1";
let transformed = transform_claude_messages_endpoint(endpoint);
assert_eq!(transformed, "/v1/chat/completions?foo=1");
}
#[test]
fn transform_claude_messages_endpoint_drops_empty_query_after_strip() {
let endpoint = "/v1/messages?beta=true";
let transformed = transform_claude_messages_endpoint(endpoint);
assert_eq!(transformed, "/v1/chat/completions");
}
#[test]
fn transform_claude_messages_endpoint_keeps_other_query_values() {
let endpoint = "/v1/messages?beta=false&foo=1";
let transformed = transform_claude_messages_endpoint(endpoint);
assert_eq!(transformed, "/v1/chat/completions?beta=false&foo=1");
}
}
+33
View File
@@ -42,6 +42,22 @@ fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
request_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从事件中直接提取
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式)
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
@@ -91,6 +107,14 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
app_type_str: "claude",
};
/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions
pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_openai_stream_events,
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
app_type_str: "codex",
};
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式)
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
@@ -136,6 +160,15 @@ pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
parser_config: &CLAUDE_PARSER_CONFIG,
};
/// Codex Chat Completions Handler 配置
#[allow(dead_code)]
pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Codex,
tag: "Codex",
app_type_str: "codex",
parser_config: &OPENAI_PARSER_CONFIG,
};
/// Codex Responses Handler 配置
#[allow(dead_code)]
pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
+45 -9
View File
@@ -9,7 +9,9 @@
use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG},
handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
@@ -54,7 +56,6 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
@@ -66,18 +67,12 @@ pub async fn handle_messages(
.and_then(|s| s.as_bool())
.unwrap_or(false);
// 透传客户端 query 参数(例如 ?beta=true),路径统一为 /v1/messages
let endpoint = uri
.query()
.map(|q| format!("/v1/messages?{q}"))
.unwrap_or_else(|| "/v1/messages".to_string());
// 转发请求
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
&endpoint,
"/v1/messages",
body.clone(),
headers,
ctx.get_providers(),
@@ -271,6 +266,47 @@ async fn handle_claude_transform(
// Codex API 处理器
// ============================================================================
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI
pub async fn handle_chat_completions(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/chat/completions",
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(result) => result,
Err(mut err) => {
if let Some(provider) = err.provider.take() {
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
}
};
ctx.provider = result.provider;
let response = result.response;
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
}
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
pub async fn handle_responses(
State(state): State<ProxyState>,
-2
View File
@@ -24,8 +24,6 @@ pub mod session;
pub mod thinking_budget_rectifier;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod url_builder;
pub(crate) mod url_utils;
pub mod usage;
// 公开导出给外部使用(commands, services等模块需要)
+13 -81
View File
@@ -14,7 +14,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
use reqwest::RequestBuilder;
/// Claude 适配器
@@ -253,34 +252,16 @@ impl ProviderAdapter for ClaudeAdapter {
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
// 支持的 API 路径模式:/v1/messages, /messages, /v1/chat/completions, /chat/completions
let api_path_patterns = [
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
];
let base_ends_with_api_path = api_path_patterns
.iter()
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
let base = if base_ends_with_api_path {
base_trimmed.to_string()
} else {
format!("{base_trimmed}/{endpoint_trimmed}")
};
let mut base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
);
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
let base = dedup_v1_v1_boundary_safe(base);
let url = format!("{base}{suffix}");
while base.contains("/v1/v1") {
base = base.replace("/v1/v1", "/v1");
}
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
@@ -290,11 +271,10 @@ impl ProviderAdapter for ClaudeAdapter {
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
&& !endpoint.contains('?')
&& !url.contains('?')
{
format!("{url}?beta=true")
format!("{base}?beta=true")
} else {
url
base
}
}
@@ -507,6 +487,7 @@ mod tests {
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
}
@@ -514,6 +495,7 @@ mod tests {
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
}
@@ -521,7 +503,7 @@ mod tests {
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点保持原样
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
@@ -534,56 +516,6 @@ mod tests {
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_build_url_full_path_messages() {
let adapter = ClaudeAdapter::new();
// base_url 已包含完整路径 /v1/messages,不再追加
let url = adapter.build_url("https://example.com/api/v1/messages", "/v1/messages");
assert_eq!(url, "https://example.com/api/v1/messages?beta=true");
}
#[test]
fn test_build_url_full_path_chat_completions() {
let adapter = ClaudeAdapter::new();
// base_url 已包含完整路径 /v1/chat/completions,不再追加
let url = adapter.build_url(
"https://opencode.ai/zen/v1/chat/completions",
"/v1/chat/completions",
);
assert_eq!(url, "https://opencode.ai/zen/v1/chat/completions");
}
#[test]
fn test_build_url_full_path_short_suffix() {
let adapter = ClaudeAdapter::new();
// base_url 以 /messages 结尾(无 /v1 前缀)
let url = adapter.build_url("https://example.com/api/messages", "/v1/messages");
assert_eq!(url, "https://example.com/api/messages?beta=true");
// base_url 以 /chat/completions 结尾(无 /v1 前缀)
let url2 = adapter.build_url(
"https://example.com/api/chat/completions",
"/v1/chat/completions",
);
assert_eq!(url2, "https://example.com/api/chat/completions");
}
#[test]
fn test_build_url_v1_base_dedup() {
let adapter = ClaudeAdapter::new();
// base_url 以 /v1 结尾,endpoint 也以 /v1 开头,应该去重
// 场景:https://integrate.api.nvidia.com/v1 + /v1/chat/completions
let url = adapter.build_url(
"https://integrate.api.nvidia.com/v1",
"/v1/chat/completions",
);
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
// 另一个场景:/v1 + /v1/messages
let url2 = adapter.build_url("https://api.example.com/v1", "/v1/messages");
assert_eq!(url2, "https://api.example.com/v1/messages?beta=true");
}
#[test]
fn test_build_url_no_beta_for_openai_chat_completions() {
let adapter = ClaudeAdapter::new();
+6 -43
View File
@@ -8,7 +8,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock;
@@ -139,23 +138,9 @@ impl ProviderAdapter for CodexAdapter {
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
// 仅支持 Responses API 路径模式:/v1/responses, /responses
let api_path_patterns = ["/v1/responses", "/responses"];
let base_ends_with_api_path = api_path_patterns
.iter()
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
if base_ends_with_api_path {
return format!("{base_trimmed}{suffix}");
}
// OpenAI/Codex 的 base_url 可能是:
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
@@ -182,8 +167,11 @@ impl ProviderAdapter for CodexAdapter {
};
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
url = dedup_v1_v1_boundary_safe(url);
format!("{url}{suffix}")
while url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
@@ -280,31 +268,6 @@ mod tests {
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
#[test]
fn test_build_url_full_path_responses() {
let adapter = CodexAdapter::new();
// base_url 已包含完整路径 /v1/responses,不再追加
let url = adapter.build_url("https://example.com/v1/responses", "/responses");
assert_eq!(url, "https://example.com/v1/responses");
}
#[test]
fn test_build_url_full_path_short_suffix() {
let adapter = CodexAdapter::new();
// base_url 以 /responses 结尾(无 /v1 前缀)
let url = adapter.build_url("https://example.com/api/responses", "/responses");
assert_eq!(url, "https://example.com/api/responses");
}
#[test]
fn test_build_url_v1_base_dedup() {
let adapter = CodexAdapter::new();
// base_url 以 /v1 结尾,endpoint 也以 /v1 开头,应该去重
// 场景:https://integrate.api.nvidia.com/v1 + /v1/responses
let url = adapter.build_url("https://integrate.api.nvidia.com/v1", "/v1/responses");
assert_eq!(url, "https://integrate.api.nvidia.com/v1/responses");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
+2 -4
View File
@@ -9,7 +9,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use crate::proxy::url_utils::split_url_suffix;
use reqwest::RequestBuilder;
/// Gemini 适配器
@@ -201,8 +200,7 @@ impl ProviderAdapter for GeminiAdapter {
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
@@ -216,7 +214,7 @@ impl ProviderAdapter for GeminiAdapter {
}
}
format!("{url}{suffix}")
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
+14
View File
@@ -218,6 +218,20 @@ impl ProxyServer {
// Claude API (支持带前缀和不带前缀两种格式)
.route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages))
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route(
"/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/v1/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/codex/v1/chat/completions",
post(handlers::handle_chat_completions),
)
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/responses", post(handlers::handle_responses))
.route("/v1/responses", post(handlers::handle_responses))
-437
View File
@@ -1,437 +0,0 @@
//! URL 构建工具模块
//!
//! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。
use crate::app_config::AppType;
use crate::proxy::providers::{ClaudeAdapter, CodexAdapter, ProviderAdapter};
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
use serde::{Deserialize, Serialize};
/// URL 预览结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlPreview {
/// 直连模式请求地址
pub direct_url: String,
/// 代理模式请求地址
pub proxy_url: String,
/// 是否为全链接(base_url 已包含 API 路径)
pub is_full_url: bool,
}
/// API 路径模式
struct ApiPathPatterns {
/// 直连模式默认端点
direct_endpoint: &'static str,
/// 代理模式端点(根据 api_format 可能不同)
proxy_endpoint: &'static str,
/// 识别为全链接的路径后缀
full_url_patterns: &'static [&'static str],
}
impl ApiPathPatterns {
fn for_claude(api_format: Option<&str>) -> Self {
// 根据 API 格式决定端点和全链接检测模式
if api_format == Some("openai_chat") {
Self {
direct_endpoint: "/v1/messages",
proxy_endpoint: "/v1/chat/completions",
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
full_url_patterns: &[
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
],
}
} else {
Self {
direct_endpoint: "/v1/messages",
proxy_endpoint: "/v1/messages",
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
full_url_patterns: &[
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
],
}
}
}
fn for_codex(api_format: Option<&str>) -> Self {
let _ = api_format;
Self {
direct_endpoint: "/responses",
proxy_endpoint: "/responses",
full_url_patterns: &["/v1/responses", "/responses"],
}
}
fn for_gemini() -> Self {
Self {
direct_endpoint: "/v1beta/models",
proxy_endpoint: "/v1beta/models",
full_url_patterns: &["/v1beta/models"],
}
}
}
/// 检测 URL 是否以指定的 API 路径结尾
fn url_ends_with_api_path(url: &str, patterns: &[&str]) -> bool {
let (base, _) = split_url_suffix(url);
let path_part = base.trim_end_matches('/').to_lowercase();
patterns
.iter()
.any(|pattern| path_part.ends_with(&pattern.to_lowercase()))
}
/// 硬拼接 URL(用于直连地址)
///
/// 始终将 endpoint 拼接到 base_url 后面,不做任何智能检测或去重。
fn build_direct_url(base_url: &str, endpoint: &str) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 直接拼接,不做任何去重
format!("{base_trimmed}/{endpoint_trimmed}{suffix}")
}
/// 智能构建 URL(用于代理地址)
///
/// 如果 base_url 已经以 API 路径结尾,直接返回;否则追加 endpoint。
pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str]) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检测 base_url 是否已经以 API 路径结尾
if url_ends_with_api_path(base_trimmed, full_url_patterns) {
return format!("{base_trimmed}{suffix}");
}
// 拼接 URL
let url = format!("{base_trimmed}/{endpoint_trimmed}");
let url = dedup_v1_v1_boundary_safe(url);
format!("{url}{suffix}")
}
fn build_runtime_like_url(
app_type: &AppType,
base_url: &str,
endpoint: &str,
is_proxy: bool,
) -> String {
match app_type {
// Claude 代理预览需要展示与运行时一致的 URL 归一化结果
AppType::Claude if is_proxy => ClaudeAdapter::new().build_url(base_url, endpoint),
// Codex/OpenCode 预览复用运行时 /v1 归一化规则
AppType::Codex | AppType::OpenCode | AppType::OpenClaw => {
CodexAdapter::new().build_url(base_url, endpoint)
}
_ => build_direct_url(base_url, endpoint),
}
}
/// 构建 URL 预览
///
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
/// - 直连地址:始终硬拼接默认后缀
/// - 代理地址:智能检测,如果已包含 API 路径则不重复拼接
pub fn build_url_preview(
app_type: &AppType,
base_url: &str,
api_format: Option<&str>,
) -> UrlPreview {
let patterns = match app_type {
AppType::Claude => ApiPathPatterns::for_claude(api_format),
AppType::Codex => ApiPathPatterns::for_codex(api_format),
AppType::Gemini => ApiPathPatterns::for_gemini(),
AppType::OpenCode => ApiPathPatterns::for_codex(api_format), // OpenCode 使用 Codex 逻辑
AppType::OpenClaw => ApiPathPatterns::for_codex(api_format), // OpenClaw 使用 Codex 逻辑
};
let is_full_url = url_ends_with_api_path(base_url, patterns.full_url_patterns);
// 直连地址:默认硬拼接;Codex/OpenCode 复用运行时规则(含 origin-only /v1 归一化)
let direct_url = build_runtime_like_url(app_type, base_url, patterns.direct_endpoint, false);
// 代理地址:Claude/Codex/OpenCode 复用运行时规则;Gemini 继续使用通用智能拼接
let proxy_url = match app_type {
AppType::Claude | AppType::Codex | AppType::OpenCode | AppType::OpenClaw => {
build_runtime_like_url(app_type, base_url, patterns.proxy_endpoint, true)
}
_ => build_smart_url(
base_url,
patterns.proxy_endpoint,
patterns.full_url_patterns,
),
};
UrlPreview {
direct_url,
proxy_url,
is_full_url,
}
}
/// 检查是否需要代理
///
/// 返回需要代理的原因,None 表示不需要代理
pub fn check_proxy_requirement(
app_type: &AppType,
base_url: &str,
api_format: Option<&str>,
) -> Option<&'static str> {
// Claude OpenAI Chat 格式必须开启代理(需要格式转换)
if matches!(app_type, AppType::Claude) && api_format == Some("openai_chat") {
return Some("openai_chat_format");
}
// base_url 缺失时无法做 full_url / url_mismatch 判断,避免误判
if base_url.trim().is_empty() {
return None;
}
let preview = build_url_preview(app_type, base_url, api_format);
// 如果是全链接且以直连后缀结尾,需要代理
if preview.is_full_url {
// 检查是否以直连后缀结尾
let direct_suffixes: &[&str] = match app_type {
AppType::Claude => &[
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
],
AppType::Codex | AppType::OpenCode | AppType::OpenClaw => &["/v1/responses", "/responses"],
_ => return None,
};
if url_ends_with_api_path(base_url, direct_suffixes) {
return Some("full_url");
}
}
// 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异)
let (direct_base, _) = split_url_suffix(&preview.direct_url);
let (proxy_base, _) = split_url_suffix(&preview.proxy_url);
if direct_base != proxy_base {
return Some("url_mismatch");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_url_preview_claude_anthropic() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com",
Some("anthropic"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_claude_openai_chat() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com",
Some("openai_chat"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/chat/completions"
);
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_claude_full_url() {
// 全链接时:直连会硬拼接后缀,代理保持原地址
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("anthropic"),
);
assert_eq!(
preview.direct_url,
"https://api.example.com/v1/messages/v1/messages"
);
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
assert!(preview.is_full_url);
}
#[test]
fn test_build_url_preview_codex_responses() {
let preview = build_url_preview(
&AppType::Codex,
"https://api.openai.com/v1",
Some("responses"),
);
assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses");
assert_eq!(preview.proxy_url, "https://api.openai.com/v1/responses");
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_codex_origin_normalizes_v1() {
let preview =
build_url_preview(&AppType::Codex, "https://api.openai.com", Some("responses"));
assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses");
assert_eq!(preview.proxy_url, "https://api.openai.com/v1/responses");
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_codex_full_url() {
// 全链接时:直连/代理均保持原地址(运行时适配器规则)
let preview = build_url_preview(
&AppType::Codex,
"https://api.example.com/v1/responses",
Some("responses"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/responses");
assert_eq!(preview.proxy_url, "https://api.example.com/v1/responses");
assert!(preview.is_full_url);
}
#[test]
fn test_check_proxy_requirement_claude_openai_chat() {
let result = check_proxy_requirement(
&AppType::Claude,
"https://api.example.com",
Some("openai_chat"),
);
assert_eq!(result, Some("openai_chat_format"));
}
#[test]
fn test_check_proxy_requirement_claude_full_url() {
let result = check_proxy_requirement(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("anthropic"),
);
assert_eq!(result, Some("full_url"));
}
#[test]
fn test_check_proxy_requirement_codex_full_url() {
let result = check_proxy_requirement(
&AppType::Codex,
"https://api.example.com/v1/responses",
Some("responses"),
);
assert_eq!(result, Some("full_url"));
}
#[test]
fn test_check_proxy_requirement_codex_origin_none() {
let result =
check_proxy_requirement(&AppType::Codex, "https://api.openai.com", Some("responses"));
assert_eq!(result, None);
}
#[test]
fn test_check_proxy_requirement_none() {
let result = check_proxy_requirement(
&AppType::Claude,
"https://api.example.com",
Some("anthropic"),
);
assert_eq!(result, None);
}
#[test]
fn test_v1_dedup() {
// 代理地址使用 build_smart_url,会去重 /v1/v1
let url = build_smart_url("https://api.example.com/v1", "/v1/messages", &[]);
assert_eq!(url, "https://api.example.com/v1/messages");
}
#[test]
fn test_direct_url_no_dedup() {
// 直连地址硬拼接,不做任何去重
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1",
Some("anthropic"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/v1/messages");
// 代理地址按运行时规则构建,会进行路径去重
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
}
#[test]
fn test_query_suffix_preserved_in_preview() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com?beta=true",
Some("anthropic"),
);
assert_eq!(
preview.direct_url,
"https://api.example.com/v1/messages?beta=true"
);
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages?beta=true"
);
assert!(!preview.is_full_url);
}
#[test]
fn test_fragment_suffix_preserved_and_full_url_detected() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages#frag",
Some("anthropic"),
);
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages#frag"
);
assert!(preview.is_full_url);
}
#[test]
fn test_claude_full_url_detection_is_api_format_agnostic() {
// 与运行时 ClaudeAdapter 一致:/messages 与 /chat/completions 都视为全链接
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("anthropic"),
);
assert!(preview.is_full_url);
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/chat/completions",
Some("anthropic"),
);
assert!(preview.is_full_url);
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/chat/completions",
Some("openai_chat"),
);
assert!(preview.is_full_url);
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("openai_chat"),
);
assert!(preview.is_full_url);
}
}
-96
View File
@@ -1,96 +0,0 @@
//! URL utilities shared across proxy modules.
//!
//! This module intentionally avoids full URL parsing to keep behavior aligned with
//! existing "stringly-typed" base_url configurations while fixing common edge cases
//! (query/fragment handling and safe path de-duplication).
/// Split a URL-like string into `(base, suffix)` where suffix starts with `?` or `#`.
///
/// Example:
/// - `https://x/v1?token=1` => (`https://x/v1`, `?token=1`)
/// - `https://x/v1#frag` => (`https://x/v1`, `#frag`)
pub(crate) fn split_url_suffix(input: &str) -> (&str, &str) {
match input.find(['?', '#']) {
Some(idx) => (&input[..idx], &input[idx..]),
None => (input, ""),
}
}
/// De-duplicate repeated `/v1/v1` only when it occurs on a segment boundary.
///
/// This avoids corrupting valid paths such as `/v1/v1beta/...`.
pub(crate) fn dedup_v1_v1_boundary_safe(mut url: String) -> String {
const NEEDLE: &str = "/v1/v1";
let mut search_start = 0usize;
loop {
let Some(rel_pos) = url[search_start..].find(NEEDLE) else {
break;
};
let pos = search_start + rel_pos;
let after = pos + NEEDLE.len();
let boundary_ok = after == url.len()
|| matches!(
url.as_bytes().get(after),
Some(b'/') | Some(b'?') | Some(b'#')
);
if boundary_ok {
url.replace_range(pos..after, "/v1");
// Continue searching from the same position in case we created a new boundary match.
search_start = pos;
} else {
// Skip forward to find later occurrences that might be valid boundary matches.
search_start = pos + 1;
}
}
url
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_url_suffix_handles_query() {
let (base, suffix) = split_url_suffix("https://example.com/v1?token=1");
assert_eq!(base, "https://example.com/v1");
assert_eq!(suffix, "?token=1");
}
#[test]
fn split_url_suffix_handles_fragment() {
let (base, suffix) = split_url_suffix("https://example.com/v1#frag");
assert_eq!(base, "https://example.com/v1");
assert_eq!(suffix, "#frag");
}
#[test]
fn dedup_v1_v1_only_on_boundary() {
let url = "https://example.com/v1/v1/messages".to_string();
assert_eq!(
dedup_v1_v1_boundary_safe(url),
"https://example.com/v1/messages"
);
}
#[test]
fn dedup_v1_v1_does_not_corrupt_v1beta() {
let url = "https://example.com/v1/v1beta/models".to_string();
assert_eq!(
dedup_v1_v1_boundary_safe(url.clone()),
"https://example.com/v1/v1beta/models"
);
}
#[test]
fn dedup_v1_v1_skips_v1beta_but_dedups_later_occurrence() {
let url = "https://example.com/v1/v1beta/v1/v1/messages".to_string();
assert_eq!(
dedup_v1_v1_boundary_safe(url),
"https://example.com/v1/v1beta/v1/messages"
);
}
}
-150
View File
@@ -1,9 +1,5 @@
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
use chrono::Utc;
use serde_json::Value;
use std::fs;
use std::path::Path;
@@ -82,150 +78,4 @@ impl ConfigService {
Ok(())
}
/// 同步当前供应商到对应的 live 配置。
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Ok(())
}
fn sync_current_provider_for_app(
config: &mut MultiAppConfig,
app_type: &AppType,
) -> Result<(), AppError> {
let (current_id, provider) = {
let manager = match config.get_manager(app_type) {
Some(manager) => manager,
None => return Ok(()),
};
if manager.current.is_empty() {
return Ok(());
}
let current_id = manager.current.clone();
let provider = match manager.providers.get(&current_id) {
Some(provider) => provider.clone(),
None => {
log::warn!(
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
);
return Ok(());
}
};
(current_id, provider)
};
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
}
AppType::OpenClaw => {
// OpenClaw uses additive mode, no live sync needed
// OpenClaw providers are managed directly in the config file
}
}
Ok(())
}
fn sync_codex_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
})?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
if let Some(target) = manager.providers.get_mut(provider_id) {
if let Some(obj) = target.settings_config.as_object_mut() {
obj.insert(
"config".to_string(),
serde_json::Value::String(cfg_text_after),
);
}
}
}
Ok(())
}
fn sync_claude_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::config::{read_json_file, write_json_file};
let settings_path = crate::config::get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&settings_path, &settings)?;
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
fn sync_gemini_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::gemini_config::{env_to_json, read_gemini_env};
ProviderService::write_gemini_live(provider)?;
// 读回实际写入的内容并更新到配置中(包含 settings.json
let live_after_env = read_gemini_env()?;
let settings_path = crate::gemini_config::get_gemini_settings_path();
let live_after_config = if settings_path.exists() {
crate::config::read_json_file(&settings_path)?
} else {
serde_json::json!({})
};
let mut live_after = env_to_json(&live_after_env);
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), live_after_config);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub use config::ConfigService;
pub use mcp::McpService;
pub use omo::OmoService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};
pub use proxy::ProxyService;
#[allow(unused_imports)]
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
+190 -74
View File
@@ -20,15 +20,83 @@ pub struct OmoLocalFileData {
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
// ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant {
pub filename: &'static str,
pub category: &'static str,
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
pub plugin_prefix: &'static str,
pub known_keys: &'static [&'static str],
pub has_categories: bool,
pub config_key: &'static str,
pub label: &'static str,
pub import_label: &'static str,
}
pub const STANDARD: OmoVariant = OmoVariant {
filename: "oh-my-opencode.jsonc",
category: "omo",
provider_prefix: "omo-",
plugin_name: "oh-my-opencode@latest",
plugin_prefix: "oh-my-opencode",
known_keys: &[
"$schema",
"agents",
"categories",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"disabled_skills",
"lsp",
"experimental",
"background_task",
"browser_automation_engine",
"claude_code",
],
has_categories: true,
config_key: "common_config_omo",
label: "OMO",
import_label: "Imported",
};
pub const SLIM: OmoVariant = OmoVariant {
filename: "oh-my-opencode-slim.jsonc",
category: "omo-slim",
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
plugin_prefix: "oh-my-opencode-slim",
known_keys: &[
"$schema",
"agents",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"lsp",
"experimental",
],
has_categories: false,
config_key: "common_config_omo_slim",
label: "OMO Slim",
import_label: "Imported Slim",
};
// ── Service ────────────────────────────────────────────────────
pub struct OmoService;
impl OmoService {
fn config_path() -> PathBuf {
get_opencode_dir().join("oh-my-opencode.jsonc")
// ── Path helpers ────────────────────────────────────────
fn config_path(v: &OmoVariant) -> PathBuf {
get_opencode_dir().join(v.filename)
}
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
let config_path = Self::config_path();
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
return Ok(config_path);
}
@@ -52,26 +120,15 @@ impl OmoService {
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
}
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
const KNOWN_KEYS: [&str; 13] = [
"$schema",
"agents",
"categories",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"disabled_skills",
"lsp",
"experimental",
"background_task",
"browser_automation_engine",
"claude_code",
];
// ── Field extraction ───────────────────────────────────
fn extract_other_fields_with_keys(
obj: &Map<String, Value>,
known: &[&str],
) -> Map<String, Value> {
let mut other = Map::new();
for (k, v) in obj {
if !KNOWN_KEYS.contains(&k.as_str()) {
if !known.contains(&k.as_str()) {
other.insert(k.clone(), v.clone());
}
}
@@ -119,6 +176,8 @@ impl OmoService {
}
}
// ── Merge helpers ──────────────────────────────────────
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
if let Some(v) = value {
result.insert(key.to_string(), v.clone());
@@ -142,34 +201,40 @@ impl OmoService {
}
}
pub fn delete_config_file() -> Result<(), AppError> {
let config_path = Self::config_path();
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("OMO config file deleted: {config_path:?}");
log::info!("{} config file deleted: {config_path:?}", v.label);
}
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
Ok(())
}
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
let global = state.db.get_omo_global_config()?;
let current_omo = state.db.get_current_omo_provider("opencode")?;
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
let global = state.db.get_omo_global_config(v.config_key)?;
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").cloned();
let categories = p.settings_config.get("categories").cloned();
let categories = if v.has_categories {
p.settings_config.get("categories").cloned()
} else {
None
};
let other_fields = p.settings_config.get("otherFields").cloned();
let use_common_config = p
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.and_then(|val| val.as_bool())
.unwrap_or(true);
(agents, categories, other_fields, use_common_config)
});
let merged = Self::merge_config(&global, profile_data.as_ref());
let config_path = Self::config_path();
let merged = Self::merge_config(v, &global, profile_data.as_ref());
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -177,15 +242,19 @@ impl OmoService {
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
crate::opencode_config::add_plugin(v.plugin_name)?;
log::info!("OMO config written to {config_path:?}");
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
fn merge_config(
v: &OmoVariant,
global: &OmoGlobalConfig,
profile_data: Option<&OmoProfileData>,
) -> Value {
let mut result = Map::new();
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
let use_common_config = profile_data.map(|(_, _, _, uc)| *uc).unwrap_or(true);
if use_common_config {
if let Some(url) = &global.schema_url {
@@ -196,61 +265,69 @@ impl OmoService {
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
if v.has_categories {
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
Self::insert_opt_value(
&mut result,
"browser_automation_engine",
&global.browser_automation_engine,
);
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
}
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
Self::insert_opt_value(
&mut result,
"browser_automation_engine",
&global.browser_automation_engine,
);
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
}
if let Some((agents, categories, other_fields, _)) = profile_data {
Self::insert_opt_value(&mut result, "agents", agents);
Self::insert_opt_value(&mut result, "categories", categories);
if v.has_categories {
Self::insert_opt_value(&mut result, "categories", categories);
}
Self::insert_object_entries(&mut result, other_fields.as_ref());
}
Value::Object(result)
}
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
let actual_path = Self::resolve_local_config_path()?;
Self::import_from_path(state, &actual_path)
}
fn import_from_path(
pub fn import_from_local(
state: &AppState,
path: &std::path::Path,
v: &OmoVariant,
) -> Result<crate::provider::Provider, AppError> {
let obj = Self::read_jsonc_object(path)?;
let actual_path = Self::resolve_local_config_path(v)?;
let obj = Self::read_jsonc_object(&actual_path)?;
let mut settings = Map::new();
if let Some(agents) = obj.get("agents") {
settings.insert("agents".to_string(), agents.clone());
}
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
if v.has_categories {
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
}
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields(&obj);
let other = Self::extract_other_fields_with_keys(&obj, v.known_keys);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let mut global = state.db.get_omo_global_config()?;
let mut global = state.db.get_omo_global_config(v.config_key)?;
Self::merge_global_from_obj(&obj, &mut global);
global.updated_at = chrono::Utc::now().to_rfc3339();
state.db.save_omo_global_config(&global)?;
state.db.save_omo_global_config(v.config_key, &global)?;
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
let name = format!(
"{} {}",
v.import_label,
chrono::Local::now().format("%Y-%m-%d %H:%M")
);
let settings_config =
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
@@ -259,7 +336,7 @@ impl OmoService {
name,
settings_config,
website_url: None,
category: Some("omo".to_string()),
category: Some(v.category.to_string()),
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
@@ -272,13 +349,13 @@ impl OmoService {
state.db.save_provider("opencode", &provider)?;
state
.db
.set_omo_provider_current("opencode", &provider.id)?;
Self::write_config_to_file(state)?;
.set_omo_provider_current("opencode", &provider.id, v.category)?;
Self::write_config_to_file(state, v)?;
Ok(provider)
}
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path()?;
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path(v)?;
let metadata = std::fs::metadata(&actual_path).ok();
let last_modified = metadata
.and_then(|m| m.modified().ok())
@@ -286,22 +363,28 @@ impl OmoService {
let obj = Self::read_jsonc_object(&actual_path)?;
Ok(Self::build_local_file_data_from_obj(
Ok(Self::build_local_file_data(
v,
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data_from_obj(
fn build_local_file_data(
v: &OmoVariant,
obj: &Map<String, Value>,
file_path: String,
last_modified: Option<String>,
) -> OmoLocalFileData {
let agents = obj.get("agents").cloned();
let categories = obj.get("categories").cloned();
let categories = if v.has_categories {
obj.get("categories").cloned()
} else {
None
};
let other = Self::extract_other_fields(obj);
let other = Self::extract_other_fields_with_keys(obj, v.known_keys);
let other_fields = if other.is_empty() {
None
} else {
@@ -401,7 +484,7 @@ mod tests {
#[test]
fn test_merge_config_empty() {
let global = OmoGlobalConfig::default();
let merged = OmoService::merge_config(&global, None);
let merged = OmoService::merge_config(&STANDARD, &global, None);
assert!(merged.is_object());
}
@@ -418,7 +501,7 @@ mod tests {
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert_eq!(obj["$schema"], "https://example.com/schema.json");
@@ -440,7 +523,7 @@ mod tests {
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, false);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("$schema"));
@@ -465,7 +548,8 @@ mod tests {
});
let obj_map = obj.as_object().unwrap().clone();
let data = OmoService::build_local_file_data_from_obj(
let data = OmoService::build_local_file_data(
&STANDARD,
&obj_map,
"/tmp/oh-my-opencode.jsonc".to_string(),
None,
@@ -497,11 +581,43 @@ mod tests {
let other_fields = Some(serde_json::json!("profile_non_object"));
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("0"));
assert!(!obj.contains_key("global_non_object"));
assert!(!obj.contains_key("profile_non_object"));
}
#[test]
fn test_merge_config_slim_excludes_categories_and_extra_fields() {
let global = OmoGlobalConfig {
schema_url: Some("https://slim.schema".to_string()),
disabled_agents: vec!["oracle".to_string()],
disabled_skills: vec!["playwright".to_string()],
background_task: Some(serde_json::json!({"key": "val"})),
browser_automation_engine: Some(serde_json::json!({"provider": "pw"})),
claude_code: Some(serde_json::json!({"mcp": true})),
..Default::default()
};
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
let other_fields = None;
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&SLIM, &global, Some(&profile_data));
let obj = merged.as_object().unwrap();
// Slim should NOT include these
assert!(!obj.contains_key("disabled_skills"));
assert!(!obj.contains_key("background_task"));
assert!(!obj.contains_key("browser_automation_engine"));
assert!(!obj.contains_key("claude_code"));
assert!(!obj.contains_key("categories"));
// Slim SHOULD include these
assert_eq!(obj["$schema"], "https://slim.schema");
assert!(obj.contains_key("agents"));
assert!(obj.contains_key("disabled_agents"));
}
}
+434 -1
View File
@@ -237,6 +237,439 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
Ok(())
}
// ============================================================================
// Key fields definitions for partial merge
// ============================================================================
/// Claude env-level key fields that belong to the provider.
/// When adding a new field here, also update backfill_claude_key_fields().
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
// --- API auth & endpoint ---
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
// --- Model selection ---
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
// --- AWS Bedrock ---
"CLAUDE_CODE_USE_BEDROCK",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_REGION",
"AWS_PROFILE",
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
// --- Google Vertex AI ---
"CLAUDE_CODE_USE_VERTEX",
"ANTHROPIC_VERTEX_PROJECT_ID",
"CLOUD_ML_REGION",
// --- Microsoft Foundry ---
"CLAUDE_CODE_USE_FOUNDRY",
// --- Provider behavior ---
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
"API_TIMEOUT_MS",
"DISABLE_PROMPT_CACHING",
];
/// Claude top-level key fields (legacy + modern format).
/// When adding a new field here, also update backfill_claude_key_fields().
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
"apiBaseUrl", // legacy
"primaryModel", // legacy
"smallFastModel", // legacy
"model", // modern
"apiKey", // Bedrock API Key auth
];
/// Codex TOML key fields.
/// When adding a new field here, also update backfill_codex_key_fields().
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
"model_provider",
"model",
"model_reasoning_effort",
"review_model",
"plan_mode_reasoning_effort",
];
/// Gemini env-level key fields.
/// When adding a new field here, also update backfill_gemini_key_fields().
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_MODEL",
"GOOGLE_API_KEY",
];
// ============================================================================
// Partial merge: write only key fields to live config
// ============================================================================
/// Write only provider-specific key fields to live configuration,
/// preserving all other user settings in the live file.
///
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
/// - `switch_normal()` — switching providers
/// - `sync_current_to_live()` — startup sync
/// - `add()` / `update()` when the provider is current
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => write_claude_live_partial(provider),
AppType::Codex => write_codex_live_partial(provider),
AppType::Gemini => write_gemini_live_partial(provider),
// Additive mode apps still use full snapshot
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
}
}
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
/// Used for user-level preferences (attribution, thinking, etc.) that are
/// independent of the active provider.
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
let path = get_claude_settings_path();
let mut live = if path.exists() {
read_json_file(&path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
json_merge_patch(&mut live, &patch);
let settings = sanitize_claude_settings_for_live(&live);
write_json_file(&path, &settings)?;
Ok(())
}
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
fn json_merge_patch(target: &mut Value, patch: &Value) {
if let Some(patch_obj) = patch.as_object() {
if !target.is_object() {
*target = json!({});
}
let target_obj = target.as_object_mut().unwrap();
for (key, value) in patch_obj {
if value.is_null() {
target_obj.remove(key);
} else if value.is_object() {
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
json_merge_patch(entry, value);
// Clean up empty container objects
if entry.as_object().map_or(false, |o| o.is_empty()) {
target_obj.remove(key);
}
} else {
target_obj.insert(key.clone(), value.clone());
}
}
}
}
/// Claude: merge only key env and top-level fields into live settings.json
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
let path = get_claude_settings_path();
// 1. Read existing live config (start from empty if file doesn't exist)
let mut live = if path.exists() {
read_json_file(&path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// 2. Ensure live.env exists as an object
if !live.get("env").is_some_and(|v| v.is_object()) {
live.as_object_mut()
.unwrap()
.insert("env".into(), json!({}));
}
// 3. Clear key env fields from live, then write from provider
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
for key in CLAUDE_KEY_ENV_FIELDS {
live_env.remove(*key);
}
if let Some(provider_env) = provider
.settings_config
.get("env")
.and_then(|v| v.as_object())
{
for key in CLAUDE_KEY_ENV_FIELDS {
if let Some(value) = provider_env.get(*key) {
live_env.insert(key.to_string(), value.clone());
}
}
}
// 4. Handle top-level legacy key fields
let live_obj = live.as_object_mut().unwrap();
for key in CLAUDE_KEY_TOP_LEVEL {
live_obj.remove(*key);
}
if let Some(provider_obj) = provider.settings_config.as_object() {
for key in CLAUDE_KEY_TOP_LEVEL {
if let Some(value) = provider_obj.get(*key) {
live_obj.insert(key.to_string(), value.clone());
}
}
}
// 5. Sanitize and write
let settings = sanitize_claude_settings_for_live(&live);
write_json_file(&path, &settings)?;
Ok(())
}
/// Codex: replace auth.json entirely, partially merge config.toml key fields
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
// auth.json is entirely provider-specific, replace it wholesale
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
// Read existing config.toml (or start from empty)
let config_path = get_codex_config_path();
let existing_toml = if config_path.exists() {
std::fs::read_to_string(&config_path).unwrap_or_default()
} else {
String::new()
};
// Parse both existing and provider TOML
let mut live_doc = existing_toml
.parse::<toml_edit::DocumentMut>()
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
// Remove key fields from live doc
let live_root = live_doc.as_table_mut();
for key in CODEX_KEY_TOP_LEVEL {
live_root.remove(key);
}
live_root.remove("model_providers");
// Parse provider TOML and extract key fields
if !provider_config_str.is_empty() {
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
let provider_root = provider_doc.as_table();
// Copy key top-level fields from provider
for key in CODEX_KEY_TOP_LEVEL {
if let Some(item) = provider_root.get(key) {
live_root.insert(key, item.clone());
}
}
// Copy model_providers table from provider
if let Some(mp) = provider_root.get("model_providers") {
live_root.insert("model_providers", mp.clone());
}
}
}
// Write using atomic write
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
Ok(())
}
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
let auth_type = detect_gemini_auth_type(provider);
// 1. Read existing env from live .env file
let mut env_map = if get_gemini_env_path().exists() {
read_gemini_env().unwrap_or_default()
} else {
HashMap::new()
};
// 2. Remove key fields from existing env
for key in GEMINI_KEY_ENV_FIELDS {
env_map.remove(*key);
}
// 3. Extract key fields from provider and merge
if let Some(provider_env) = provider
.settings_config
.get("env")
.and_then(|v| v.as_object())
{
for key in GEMINI_KEY_ENV_FIELDS {
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
if !value.is_empty() {
env_map.insert(key.to_string(), value.to_string());
}
}
}
}
// 4. Handle auth type specific behavior
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google official uses OAuth, clear all env
env_map.clear();
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
// Validate and write env
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
}
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
use crate::gemini_config::get_gemini_settings_path;
let settings_path = get_gemini_settings_path();
if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_object() {
let mut merged = if settings_path.exists() {
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
if let (Some(merged_obj), Some(config_obj)) =
(merged.as_object_mut(), config_value.as_object())
{
for (k, v) in config_obj {
merged_obj.insert(k.clone(), v.clone());
}
}
write_json_file(&settings_path, &merged)?;
} else if !config_value.is_null() {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
}
// 6. Set security flag based on auth type
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
}
Ok(())
}
// ============================================================================
// Backfill: extract only key fields from live config
// ============================================================================
/// Extract only provider-specific key fields from a live config value.
///
/// Used during backfill to ensure the provider's `settings_config` converges
/// to containing only key fields over time.
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
match app_type {
AppType::Claude => backfill_claude_key_fields(live_config),
AppType::Codex => backfill_codex_key_fields(live_config),
AppType::Gemini => backfill_gemini_key_fields(live_config),
// Additive mode: return full config (no backfill needed)
_ => live_config.clone(),
}
}
fn backfill_claude_key_fields(live: &Value) -> Value {
let mut result = json!({});
let result_obj = result.as_object_mut().unwrap();
// Extract key env fields
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
let mut env_obj = serde_json::Map::new();
for key in CLAUDE_KEY_ENV_FIELDS {
if let Some(value) = live_env.get(*key) {
env_obj.insert(key.to_string(), value.clone());
}
}
if !env_obj.is_empty() {
result_obj.insert("env".to_string(), Value::Object(env_obj));
}
}
// Extract key top-level fields
if let Some(live_obj) = live.as_object() {
for key in CLAUDE_KEY_TOP_LEVEL {
if let Some(value) = live_obj.get(*key) {
result_obj.insert(key.to_string(), value.clone());
}
}
}
result
}
fn backfill_codex_key_fields(live: &Value) -> Value {
let mut result = json!({});
let result_obj = result.as_object_mut().unwrap();
// auth is entirely provider-specific — keep it as-is
if let Some(auth) = live.get("auth") {
result_obj.insert("auth".to_string(), auth.clone());
}
// Extract key TOML fields from config string
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
let mut new_doc = toml_edit::DocumentMut::new();
let new_root = new_doc.as_table_mut();
// Copy key top-level fields
for key in CODEX_KEY_TOP_LEVEL {
if let Some(item) = doc.as_table().get(key) {
new_root.insert(key, item.clone());
}
}
// Copy model_providers table
if let Some(mp) = doc.as_table().get("model_providers") {
new_root.insert("model_providers", mp.clone());
}
let toml_str = new_doc.to_string();
if !toml_str.trim().is_empty() {
result_obj.insert("config".to_string(), Value::String(toml_str));
}
}
}
result
}
fn backfill_gemini_key_fields(live: &Value) -> Value {
let mut result = json!({});
let result_obj = result.as_object_mut().unwrap();
// Extract key env fields
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
let mut env_obj = serde_json::Map::new();
for key in GEMINI_KEY_ENV_FIELDS {
if let Some(value) = live_env.get(*key) {
env_obj.insert(key.to_string(), value.clone());
}
}
if !env_obj.is_empty() {
result_obj.insert("env".to_string(), Value::Object(env_obj));
}
}
result
}
/// Sync all providers to live configuration (for additive mode apps)
///
/// Writes all providers from the database to the live configuration file.
@@ -286,7 +719,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
write_live_partial(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
+158 -305
View File
@@ -27,17 +27,25 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::write_live_snapshot;
pub(crate) use live::write_live_partial;
// Internal re-exports
use live::{
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
write_live_snapshot,
};
use usage::validate_usage_script;
/// Provider business logic service
pub struct ProviderService;
/// Result of a provider switch operation, including any non-fatal warnings
#[derive(Debug, serde::Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SwitchResult {
pub warnings: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -77,47 +85,6 @@ mod tests {
assert_eq!(api_key, "token");
assert_eq!(base_url, "https://claude.example");
}
#[test]
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
let config_toml = r#"model_provider = "azure"
model = "gpt-4"
disable_response_storage = true
[model_providers.azure]
name = "Azure OpenAI"
base_url = "https://azure.example/v1"
wire_api = "responses"
[mcp_servers.my_server]
base_url = "http://localhost:8080"
"#;
let settings = json!({ "config": config_toml });
let extracted = ProviderService::extract_codex_common_config(&settings)
.expect("extract_codex_common_config should succeed");
assert!(
!extracted
.lines()
.any(|line| line.trim_start().starts_with("model_provider")),
"should remove top-level model_provider"
);
assert!(
!extracted
.lines()
.any(|line| line.trim_start().starts_with("model =")),
"should remove top-level model"
);
assert!(
!extracted.contains("[model_providers"),
"should remove entire model_providers table"
);
assert!(
extracted.contains("http://localhost:8080"),
"should keep mcp_servers.* base_url"
);
}
}
impl ProviderService {
@@ -167,8 +134,7 @@ impl ProviderService {
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
if app_type.is_additive_mode() {
// OMO providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo")
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
{
// Do not auto-enable newly added OMO providers.
// Users must explicitly switch/apply an OMO provider to activate it.
@@ -185,7 +151,7 @@ impl ProviderService {
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_snapshot(&app_type, &provider)?;
write_live_partial(&app_type, &provider)?;
}
Ok(true)
@@ -207,14 +173,33 @@ impl ProviderService {
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
if app_type.is_additive_mode() {
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo")
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
{
let is_omo_current = state
.db
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
let is_omo_current =
state
.db
.is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?;
if is_omo_current {
crate::services::OmoService::write_config_to_file(state)?;
crate::services::OmoService::write_config_to_file(
state,
&crate::services::omo::STANDARD,
)?;
}
return Ok(true);
}
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo-slim")
{
let is_current = state.db.is_omo_provider_current(
app_type.as_str(),
&provider.id,
"omo-slim",
)?;
if is_current {
crate::services::OmoService::write_config_to_file(
state,
&crate::services::omo::SLIM,
)?;
}
return Ok(true);
}
@@ -247,7 +232,7 @@ impl ProviderService {
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_snapshot(&app_type, &provider)?;
write_live_partial(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
@@ -264,15 +249,16 @@ impl ProviderService {
// Additive mode apps - no current provider concept
if app_type.is_additive_mode() {
if matches!(app_type, AppType::OpenCode) {
let is_omo = state
let provider_category = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
.and_then(|p| p.category);
if is_omo {
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
if provider_category.as_deref() == Some("omo") {
let was_current =
state
.db
.is_omo_provider_current(app_type.as_str(), id, "omo")?;
let omo_count = state
.db
.get_all_providers(app_type.as_str())?
@@ -288,7 +274,36 @@ impl ProviderService {
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
crate::services::OmoService::delete_config_file()?;
crate::services::OmoService::delete_config_file(
&crate::services::omo::STANDARD,
)?;
}
return Ok(());
}
if provider_category.as_deref() == Some("omo-slim") {
let was_current =
state
.db
.is_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
let slim_count = state
.db
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo-slim"))
.count();
if slim_count <= 1 && was_current {
return Err(AppError::Message(
"无法删除当前启用的最后一个 OMO Slim 配置,请先停用".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
crate::services::OmoService::delete_config_file(
&crate::services::omo::SLIM,
)?;
}
return Ok(());
}
@@ -329,21 +344,46 @@ impl ProviderService {
) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
let is_omo = state
let provider_category = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
.and_then(|p| p.category);
if is_omo {
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
let still_has_current =
state.db.get_current_omo_provider("opencode")?.is_some();
if provider_category.as_deref() == Some("omo") {
state
.db
.clear_omo_provider_current(app_type.as_str(), id, "omo")?;
let still_has_current = state
.db
.get_current_omo_provider("opencode", "omo")?
.is_some();
if still_has_current {
crate::services::OmoService::write_config_to_file(state)?;
crate::services::OmoService::write_config_to_file(
state,
&crate::services::omo::STANDARD,
)?;
} else {
crate::services::OmoService::delete_config_file()?;
crate::services::OmoService::delete_config_file(
&crate::services::omo::STANDARD,
)?;
}
} else if provider_category.as_deref() == Some("omo-slim") {
state
.db
.clear_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
let still_has_current = state
.db
.get_current_omo_provider("opencode", "omo-slim")?
.is_some();
if still_has_current {
crate::services::OmoService::write_config_to_file(
state,
&crate::services::omo::SLIM,
)?;
} else {
crate::services::OmoService::delete_config_file(
&crate::services::omo::SLIM,
)?;
}
} else {
remove_opencode_provider_from_live(id)?;
@@ -374,7 +414,7 @@ impl ProviderService {
/// c. Update database is_current (as default for new devices)
/// d. Write target provider config to live files
/// e. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
let _provider = providers
@@ -386,6 +426,13 @@ impl ProviderService {
return Self::switch_normal(state, app_type, id, &providers);
}
// OMO Slim providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode)
&& _provider.category.as_deref() == Some("omo-slim")
{
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
@@ -439,7 +486,7 @@ impl ProviderService {
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
return Ok(());
return Ok(SwitchResult::default());
}
// Normal mode: full switch with Live config write
@@ -452,17 +499,33 @@ impl ProviderService {
app_type: AppType,
id: &str,
providers: &indexmap::IndexMap<String, Provider>,
) -> Result<(), AppError> {
) -> Result<SwitchResult, AppError> {
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
state.db.set_omo_provider_current(app_type.as_str(), id)?;
crate::services::OmoService::write_config_to_file(state)?;
return Ok(());
state
.db
.set_omo_provider_current(app_type.as_str(), id, "omo")?;
crate::services::OmoService::write_config_to_file(
state,
&crate::services::omo::STANDARD,
)?;
return Ok(SwitchResult::default());
}
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
{
state
.db
.set_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;
return Ok(SwitchResult::default());
}
let mut result = SwitchResult::default();
// Backfill: Backfill current live config to current provider
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
@@ -475,9 +538,17 @@ impl ProviderService {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
// Only extract key fields from live config for backfill
current_provider.settings_config =
backfill_key_fields(&app_type, &live_config);
if let Err(e) =
state.db.save_provider(app_type.as_str(), &current_provider)
{
log::warn!("Backfill failed: {e}");
result
.warnings
.push(format!("backfill_failed:{current_id}"));
}
}
}
}
@@ -493,13 +564,10 @@ impl ProviderService {
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
// Sync to live (partial merge: only key fields, preserving user settings)
write_live_partial(&app_type, provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(())
Ok(result)
}
/// Sync current provider to live configuration (re-export)
@@ -507,222 +575,6 @@ impl ProviderService {
sync_current_to_live(state)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
pub fn extract_common_config_snippet(
state: &AppState,
app_type: AppType,
) -> Result<String, AppError> {
// Get current provider
let current_id = Self::current(state, app_type.clone())?;
if current_id.is_empty() {
return Err(AppError::Message("No current provider".to_string()));
}
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&current_id)
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
}
}
/// Extract common config snippet from a config value (e.g. editor content).
pub fn extract_common_config_snippet_from_settings(
app_type: AppType,
settings_config: &Value,
) -> Result<String, AppError> {
match app_type {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
}
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
// Fields to exclude from common config
const ENV_EXCLUDES: &[&str] = &[
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models (5 fields)
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
// Endpoint
"ANTHROPIC_BASE_URL",
];
const TOP_LEVEL_EXCLUDES: &[&str] = &[
"apiBaseUrl",
// Legacy model fields
"primaryModel",
"smallFastModel",
];
// Remove env fields
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
for key in ENV_EXCLUDES {
env.remove(*key);
}
// If env is empty after removal, remove the env object itself
if env.is_empty() {
config.as_object_mut().map(|obj| obj.remove("env"));
}
}
// Remove top-level fields
if let Some(obj) = config.as_object_mut() {
for key in TOP_LEVEL_EXCLUDES {
obj.remove(*key);
}
}
// Check if result is empty
if config.as_object().is_none_or(|obj| obj.is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for Codex (TOML format)
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
// Codex config is stored as { "auth": {...}, "config": "toml string" }
let config_toml = settings
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
if config_toml.is_empty() {
return Ok(String::new());
}
let mut doc = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
// Remove provider-specific fields.
let root = doc.as_table_mut();
root.remove("model");
root.remove("model_provider");
// Legacy/alt formats might use a top-level base_url.
root.remove("base_url");
// Remove entire model_providers table (provider-specific configuration)
root.remove("model_providers");
// Clean up multiple empty lines (keep at most one blank line).
let mut cleaned = String::new();
let mut blank_run = 0usize;
for line in doc.to_string().lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run <= 1 {
cleaned.push('\n');
}
continue;
}
blank_run = 0;
cleaned.push_str(line);
cleaned.push('\n');
}
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (JSON format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut snippet = serde_json::Map::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
continue;
}
let Value::String(v) = value else {
continue;
};
let trimmed = v.trim();
if !trimmed.is_empty() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
// Keep npm and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenClaw (JSON format)
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
obj.remove("apiKey");
obj.remove("baseUrl");
// Keep api and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -735,6 +587,11 @@ impl ProviderService {
read_live_settings(app_type)
}
/// Patch Claude live settings directly (user-level preferences)
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
live::patch_claude_live(patch)
}
/// Get custom endpoints list (re-export)
pub fn get_custom_endpoints(
state: &AppState,
@@ -830,10 +687,6 @@ impl ProviderService {
.await
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
write_gemini_live(provider)
}
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
+3 -3
View File
@@ -8,7 +8,7 @@ use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
use crate::proxy::types::*;
use crate::services::provider::write_live_snapshot;
use crate::services::provider::write_live_partial;
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
@@ -830,7 +830,7 @@ impl ProxyService {
///
/// 代理服务器的路由已经根据 API 端点自动区分应用类型:
/// - `/v1/messages` → Claude
/// - `/v1/responses` → Codex
/// - `/v1/chat/completions`, `/v1/responses` → Codex
/// - `/v1beta/*` → Gemini
///
/// 因此不需要在 URL 中添加应用前缀。
@@ -1266,7 +1266,7 @@ impl ProxyService {
return Ok(false);
};
write_live_snapshot(app_type, provider)
write_live_partial(app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
Ok(true)
+3 -3
View File
@@ -293,11 +293,11 @@ impl StreamCheckService {
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// 健康检查不强制附加查询参数,保持与默认 Claude 路径一致
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") {
format!("{base}/messages")
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages")
format!("{base}/v1/messages?beta=true")
};
let body = json!({
+7 -1
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::Serialize;
use std::path::Path;
use providers::{claude, codex};
use providers::{claude, codex, gemini, openclaw, opencode};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -40,6 +40,9 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
let mut sessions = Vec::new();
sessions.extend(codex::scan_sessions());
sessions.extend(claude::scan_sessions());
sessions.extend(opencode::scan_sessions());
sessions.extend(openclaw::scan_sessions());
sessions.extend(gemini::scan_sessions());
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -55,6 +58,9 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
match provider_id {
"codex" => codex::load_messages(path),
"claude" => claude::load_messages(path),
"opencode" => opencode::load_messages(path),
"openclaw" => openclaw::load_messages(path),
"gemini" => gemini::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -0,0 +1,117 @@
use std::path::Path;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{parse_timestamp_to_ms, truncate_summary};
const PROVIDER_ID: &str = "gemini";
pub fn scan_sessions() -> Vec<SessionMeta> {
let gemini_dir = crate::gemini_config::get_gemini_dir();
let tmp_dir = gemini_dir.join("tmp");
if !tmp_dir.exists() {
return Vec::new();
}
let mut sessions = Vec::new();
// Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json
let project_dirs = match std::fs::read_dir(&tmp_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
for entry in project_dirs.flatten() {
let chats_dir = entry.path().join("chats");
if !chats_dir.is_dir() {
continue;
}
let chat_files = match std::fs::read_dir(&chats_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for file_entry in chat_files.flatten() {
let path = file_entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(meta) = parse_session(&path) {
sessions.push(meta);
}
}
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let data = std::fs::read_to_string(path).map_err(|e| format!("Failed to read session: {e}"))?;
let value: Value =
serde_json::from_str(&data).map_err(|e| format!("Failed to parse session JSON: {e}"))?;
let messages = value
.get("messages")
.and_then(Value::as_array)
.ok_or_else(|| "No messages array found".to_string())?;
let mut result = Vec::new();
for msg in messages {
let content = match msg.get("content").and_then(Value::as_str) {
Some(c) if !c.trim().is_empty() => c.to_string(),
_ => continue,
};
let role = match msg.get("type").and_then(Value::as_str) {
Some("gemini") => "assistant".to_string(),
Some("user") => "user".to_string(),
Some(other) => other.to_string(),
None => continue,
};
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
result.push(SessionMessage { role, content, ts });
}
Ok(result)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let data = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&data).ok()?;
let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string();
let created_at = value.get("startTime").and_then(parse_timestamp_to_ms);
let last_active_at = value.get("lastUpdated").and_then(parse_timestamp_to_ms);
// Derive title from first user message
let title = value
.get("messages")
.and_then(Value::as_array)
.and_then(|msgs| {
msgs.iter()
.find(|m| m.get("type").and_then(Value::as_str) == Some("user"))
.and_then(|m| m.get("content").and_then(Value::as_str))
.filter(|s| !s.trim().is_empty())
.map(|s| truncate_summary(s, 160))
});
let source_path = path.to_string_lossy().to_string();
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title: title.clone(),
summary: title,
project_dir: None, // project hash is not reversible
created_at,
last_active_at: last_active_at.or(created_at),
source_path: Some(source_path),
resume_command: Some(format!("gemini --resume {session_id}")),
})
}
@@ -1,3 +1,6 @@
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod openclaw;
pub mod opencode;
mod utils;
@@ -0,0 +1,211 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use serde_json::Value;
use crate::openclaw_config::get_openclaw_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "openclaw";
pub fn scan_sessions() -> Vec<SessionMeta> {
let agents_dir = get_openclaw_dir().join("agents");
if !agents_dir.exists() {
return Vec::new();
}
let mut sessions = Vec::new();
// Traverse each agent directory
let agent_entries = match std::fs::read_dir(&agents_dir) {
Ok(entries) => entries,
Err(_) => return sessions,
};
for agent_entry in agent_entries.flatten() {
let agent_path = agent_entry.path();
if !agent_path.is_dir() {
continue;
}
let sessions_dir = agent_path.join("sessions");
if !sessions_dir.is_dir() {
continue;
}
let session_entries = match std::fs::read_dir(&sessions_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in session_entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
continue;
}
// Skip sessions.json index file
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "sessions.json")
.unwrap_or(false)
{
continue;
}
if let Some(meta) = parse_session(&path) {
sessions.push(meta);
}
}
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if value.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let message = match value.get("message") {
Some(msg) => msg,
None => continue,
};
let raw_role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown");
// Map OpenClaw roles to our standard roles
let role = match raw_role {
"toolResult" => "tool".to_string(),
other => other.to_string(),
};
let content = message.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts });
}
Ok(messages)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let mut session_id: Option<String> = None;
let mut cwd: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
if created_at.is_none() {
created_at = Some(ts);
}
last_active_at = Some(ts);
}
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
if event_type == "session" {
if session_id.is_none() {
session_id = value
.get("id")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if cwd.is_none() {
cwd = value
.get("cwd")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
created_at.get_or_insert(ts);
}
continue;
}
if event_type != "message" {
continue;
}
// Extract first message content for summary
if summary.is_some() {
continue;
}
let message = match value.get("message") {
Some(msg) => msg,
None => continue,
};
let text = message.get("content").map(extract_text).unwrap_or_default();
if text.trim().is_empty() {
continue;
}
summary = Some(text);
}
// Fall back to filename as session ID
let session_id = session_id.or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
});
let session_id = session_id?;
let title = cwd
.as_deref()
.and_then(path_basename)
.map(|s| s.to_string());
let summary = summary.map(|text| truncate_summary(&text, 160));
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title,
summary,
project_dir: cwd,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
})
}
@@ -0,0 +1,271 @@
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "opencode";
/// Return the OpenCode data directory.
///
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
/// `~/.local/share/opencode/storage/`.
fn get_opencode_data_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("opencode").join("storage");
}
}
dirs::home_dir()
.map(|h| h.join(".local/share/opencode/storage"))
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let storage = get_opencode_data_dir();
let session_dir = storage.join("session");
if !session_dir.exists() {
return Vec::new();
}
let mut json_files = Vec::new();
collect_json_files(&session_dir, &mut json_files);
let mut sessions = Vec::new();
for path in json_files {
if let Some(meta) = parse_session(&storage, &path) {
sessions.push(meta);
}
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
// `path` is the message directory: storage/message/{sessionID}/
if !path.is_dir() {
return Err(format!("Message directory not found: {}", path.display()));
}
let storage = path
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| "Cannot determine storage root from message path".to_string())?;
let mut msg_files = Vec::new();
collect_json_files(path, &mut msg_files);
// Parse all messages and collect (created_ts, message_id, role, parts_text)
let mut entries: Vec<(i64, String, String, String)> = Vec::new();
for msg_path in &msg_files {
let data = match std::fs::read_to_string(msg_path) {
Ok(d) => d,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
let msg_id = match value.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => continue,
};
let role = value
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let created_ts = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(parse_timestamp_to_ms)
.unwrap_or(0);
// Collect text parts from storage/part/{messageID}/
let part_dir = storage.join("part").join(&msg_id);
let text = collect_parts_text(&part_dir);
if text.trim().is_empty() {
continue;
}
entries.push((created_ts, msg_id, role, text));
}
// Sort by created timestamp
entries.sort_by_key(|(ts, _, _, _)| *ts);
let messages = entries
.into_iter()
.map(|(ts, _, role, content)| SessionMessage {
role,
content,
ts: if ts > 0 { Some(ts) } else { None },
})
.collect();
Ok(messages)
}
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
let data = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&data).ok()?;
let session_id = value.get("id").and_then(Value::as_str)?.to_string();
let title = value
.get("title")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let directory = value
.get("directory")
.and_then(Value::as_str)
.map(|s| s.to_string());
let created_at = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(parse_timestamp_to_ms);
let updated_at = value
.get("time")
.and_then(|t| t.get("updated"))
.and_then(parse_timestamp_to_ms);
// Derive title from directory basename if no explicit title
let display_title = title.or_else(|| {
directory
.as_deref()
.and_then(path_basename)
.map(|s| s.to_string())
});
// Build source_path = message directory for this session
let msg_dir = storage.join("message").join(&session_id);
let source_path = msg_dir.to_string_lossy().to_string();
// Get summary from first user message
let summary = get_first_user_summary(storage, &session_id);
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title: display_title,
summary,
project_dir: directory,
created_at,
last_active_at: updated_at.or(created_at),
source_path: Some(source_path),
resume_command: Some(format!("opencode session resume {session_id}")),
})
}
/// Read the first user message's first text part to use as summary.
fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
let msg_dir = storage.join("message").join(session_id);
if !msg_dir.is_dir() {
return None;
}
let mut msg_files = Vec::new();
collect_json_files(&msg_dir, &mut msg_files);
// Collect user messages with timestamps for ordering
let mut user_msgs: Vec<(i64, String)> = Vec::new();
for msg_path in &msg_files {
let data = match std::fs::read_to_string(msg_path) {
Ok(d) => d,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
if value.get("role").and_then(Value::as_str) != Some("user") {
continue;
}
let msg_id = match value.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => continue,
};
let ts = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(parse_timestamp_to_ms)
.unwrap_or(0);
user_msgs.push((ts, msg_id));
}
user_msgs.sort_by_key(|(ts, _)| *ts);
// Take first user message and get its parts
let (_, first_id) = user_msgs.first()?;
let part_dir = storage.join("part").join(first_id);
let text = collect_parts_text(&part_dir);
if text.trim().is_empty() {
return None;
}
Some(truncate_summary(&text, 160))
}
/// Collect text content from all parts in a part directory.
fn collect_parts_text(part_dir: &Path) -> String {
if !part_dir.is_dir() {
return String::new();
}
let mut parts = Vec::new();
collect_json_files(part_dir, &mut parts);
let mut texts = Vec::new();
for part_path in &parts {
let data = match std::fs::read_to_string(part_path) {
Ok(d) => d,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
// Only include text-type parts
if value.get("type").and_then(Value::as_str) != Some("text") {
continue;
}
if let Some(text) = value.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
texts.push(text.to_string());
}
}
}
texts.join("\n")
}
fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
if !root.exists() {
return;
}
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_json_files(&path, files);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
files.push(path);
}
}
}
+49
View File
@@ -187,6 +187,15 @@ pub struct AppSettings {
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
#[serde(default)]
pub silent_startup: bool,
/// 是否在主页面启用本地代理功能(默认关闭)
#[serde(default)]
pub enable_local_proxy: bool,
/// User has confirmed the local proxy first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_confirmed: Option<bool>,
/// User has confirmed the usage query first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_confirmed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
@@ -236,6 +245,14 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_backup: Option<serde_json::Value>,
// ===== 备份策略设置 =====
/// Auto-backup interval in hours (default 24, 0 = disabled)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup_interval_hours: Option<u32>,
/// Maximum number of backup files to retain (default 10)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup_retain_count: Option<u32>,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
@@ -262,6 +279,9 @@ impl Default for AppSettings {
skip_claude_onboarding: false,
launch_on_startup: false,
silent_startup: false,
enable_local_proxy: false,
proxy_confirmed: None,
usage_confirmed: None,
language: None,
visible_apps: None,
claude_config_dir: None,
@@ -277,6 +297,8 @@ impl Default for AppSettings {
skill_sync_method: SyncMethod::default(),
webdav_sync: None,
webdav_backup: None,
backup_interval_hours: None,
backup_retain_count: None,
preferred_terminal: None,
}
}
@@ -611,6 +633,33 @@ pub fn get_skill_sync_method() -> SyncMethod {
.skill_sync_method
}
// ===== 备份策略管理函数 =====
/// Get the effective auto-backup interval in hours (default 24)
pub fn effective_backup_interval_hours() -> u32 {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.backup_interval_hours
.unwrap_or(24)
}
/// Get the effective backup retain count (default 10, minimum 1)
pub fn effective_backup_retain_count() -> usize {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.backup_retain_count
.map(|n| (n as usize).max(1))
.unwrap_or(10)
}
// ===== 终端设置管理函数 =====
/// 获取首选终端应用
+9 -23
View File
@@ -15,7 +15,7 @@ pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub quit: &'static str,
pub auto_label: &'static str,
pub _auto_label: &'static str,
}
impl TrayTexts {
@@ -25,20 +25,20 @@ impl TrayTexts {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
auto_label: "Auto (Failover)",
_auto_label: "Auto (Failover)",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
auto_label: "自動 (フェイルオーバー)",
_auto_label: "自動 (フェイルオーバー)",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
auto_label: "自动 (故障转移)",
_auto_label: "自动 (故障转移)",
},
}
}
@@ -91,7 +91,7 @@ fn append_provider_section<'a>(
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
app_state: &AppState,
_app_state: &AppState,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
@@ -119,22 +119,9 @@ fn append_provider_section<'a>(
return Ok(menu_builder.item(&empty_hint));
}
// 获取 proxy 状态,决定 Auto 是否选中
let (proxy_enabled, auto_failover) =
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
let auto_mode = proxy_enabled && auto_failover;
// 添加 Auto 菜单项(始终显示在供应商列表前)
let auto_item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, AUTO_SUFFIX),
tray_texts.auto_label,
true,
auto_mode,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&auto_item);
// 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)| {
@@ -156,8 +143,7 @@ fn append_provider_section<'a>(
});
for (id, provider) in sorted_providers {
// Auto 模式下所有供应商都不选中
let is_current = !auto_mode && manager.current == *id;
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
+1 -271
View File
@@ -2,10 +2,7 @@ use serde_json::json;
use std::fs;
use std::path::PathBuf;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
Provider, ProviderMeta,
};
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
#[path = "support.rs"]
mod support;
@@ -13,132 +10,6 @@ use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
#[test]
fn sync_claude_provider_writes_live_settings() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
let provider_config = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_BASE_URL": "https://api.test"
},
"ui": {
"displayName": "Test Provider"
}
});
let provider = Provider::with_id(
"prov-1".to_string(),
"Test Claude".to_string(),
provider_config.clone(),
None,
);
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.providers.insert("prov-1".to_string(), provider);
manager.current = "prov-1".to_string();
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
let settings_path = get_claude_settings_path();
assert!(
settings_path.exists(),
"live settings should be written to {}",
settings_path.display()
);
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
assert_eq!(live_value, provider_config);
// 确认 SSOT 中的供应商也同步了最新内容
let updated = config
.get_manager(&AppType::Claude)
.and_then(|m| m.providers.get("prov-1"))
.expect("provider in config");
assert_eq!(updated.settings_config, provider_config);
// 额外确认写入位置位于测试 HOME 下
assert!(
settings_path.starts_with(home),
"settings path {settings_path:?} should reside under test HOME {home:?}"
);
}
#[test]
fn sync_codex_provider_writes_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
let provider_config = json!({
"auth": {
"OPENAI_API_KEY": "codex-key"
},
"config": r#"base_url = "https://codex.test""#
});
let provider = Provider::with_id(
"codex-1".to_string(),
"Codex Test".to_string(),
provider_config.clone(),
None,
);
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert("codex-1".to_string(), provider);
manager.current = "codex-1".to_string();
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
let auth_path = cc_switch_lib::get_codex_auth_path();
let config_path = cc_switch_lib::get_codex_config_path();
assert!(
auth_path.exists(),
"auth.json should exist at {}",
auth_path.display()
);
assert!(
config_path.exists(),
"config.toml should exist at {}",
config_path.display()
);
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
assert_eq!(
auth_value,
provider_config.get("auth").cloned().expect("auth object")
);
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!(
toml_text.contains("base_url"),
"config.toml should contain base_url from provider config"
);
// 当前供应商应同步最新 config 文本
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
let synced = manager.providers.get("codex-1").expect("codex provider");
let synced_cfg = synced
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert_eq!(synced_cfg, toml_text);
}
#[test]
fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -338,46 +209,6 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
}
}
#[test]
fn sync_codex_provider_missing_auth_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
let provider = Provider::with_id(
"codex-missing-auth".to_string(),
"No Auth".to_string(),
json!({
"config": "model = \"test\""
}),
None,
);
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(provider.id.clone(), provider);
manager.current = "codex-missing-auth".to_string();
let err = ConfigService::sync_current_providers_to_live(&mut config)
.expect_err("sync should fail when auth missing");
match err {
cc_switch_lib::AppError::Config(msg) => {
assert!(msg.contains("auth"), "error message should mention auth");
}
other => panic!("unexpected error variant: {other:?}"),
}
// 确认未产生任何 live 配置文件
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"auth.json should not be created on failure"
);
assert!(
!cc_switch_lib::get_codex_config_path().exists(),
"config.toml should not be created on failure"
);
}
#[test]
fn write_codex_live_atomic_persists_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -816,107 +647,6 @@ fn create_backup_retains_only_latest_entries() {
);
}
#[test]
fn sync_gemini_packycode_sets_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-1".to_string();
manager.providers.insert(
"packy-1".to_string(),
Provider::with_id(
"packy-1".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
);
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
);
}
#[test]
fn sync_gemini_google_official_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should record oauth-personal for Google Official"
);
}
#[test]
fn export_sql_writes_to_target_path() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+23 -12
View File
@@ -100,9 +100,12 @@ command = "say"
);
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
// With partial merge, only key fields (model, provider, model_providers) are
// merged into config.toml. The existing MCP section should be preserved.
// MCP sync from DB is handled separately (at startup or explicit sync).
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
config_text.contains("mcp_servers.legacy"),
"config.toml should preserve existing MCP servers after partial merge"
);
let current_id = app_state
@@ -126,12 +129,9 @@ command = "say"
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
// 供应商配置应该包含在 live 文件中
// 注意:live 文件还会包含 MCP 同步后的内容
assert!(
config_text.contains("mcp_servers.latest"),
"live file should contain provider's original config"
);
// With partial merge, only key fields (model_provider, model, model_providers)
// are written to the live file. MCP servers are synced separately.
// The provider's stored config should still contain mcp_servers.latest.
assert!(
new_config_text.contains("mcp_servers.latest"),
"provider snapshot should contain provider's original config"
@@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() {
let legacy_provider = providers
.get("old-provider")
.expect("legacy provider still exists");
// 回填机制:切换前会将 live 配置回填到当前供应商
// 这保护了用户在 live 文件中的手动修改
// Backfill mechanism: before switching, the live config's key fields are
// backfilled to the current provider. With partial merge, only key fields
// (auth, model, endpoint) are extracted — non-key fields like workspace
// are NOT included in the backfill.
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should be backfilled with live config"
legacy_provider
.settings_config
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("legacy-key"),
"previous provider should be backfilled with live auth key"
);
assert!(
legacy_provider.settings_config.get("workspace").is_none(),
"backfill should NOT include non-key fields like workspace"
);
let new_provider = providers.get("new-provider").expect("new provider exists");
+17 -9
View File
@@ -112,9 +112,12 @@ command = "say"
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
// With partial merge, only key fields (model, provider, model_providers) are
// merged into config.toml. The existing MCP section should be preserved.
// MCP sync from DB is handled separately (at startup or explicit sync).
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
config_text.contains("mcp_servers.legacy"),
"config.toml should preserve existing MCP servers after partial merge"
);
let current_id = state
@@ -143,11 +146,6 @@ command = "say"
new_config_text.contains("mcp_servers.latest"),
"provider config should contain original MCP servers"
);
// live 文件额外包含同步的 MCP 服务器
assert!(
config_text.contains("mcp_servers.echo-server"),
"live config should include synced MCP servers"
);
let legacy = providers
.get("old-provider")
@@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
let legacy_provider = providers
.get("old-provider")
.expect("legacy provider still exists");
// With partial merge backfill, only key fields are extracted from live config
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should receive backfilled live config"
legacy_provider
.settings_config
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("legacy-key"),
"previous provider should receive backfilled auth key"
);
assert!(
legacy_provider.settings_config.get("workspace").is_none(),
"backfill should NOT include non-key fields like workspace"
);
}
+277 -221
View File
@@ -35,6 +35,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { openclawKeys } from "@/hooks/useOpenClaw";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
@@ -61,7 +62,10 @@ import { UniversalProviderPanel } from "@/components/universal";
import { McpIcon } from "@/components/BrandIcons";
import { Button } from "@/components/ui/button";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { useDisableCurrentOmo } from "@/lib/query/omo";
import {
useDisableCurrentOmo,
useDisableCurrentOmoSlim,
} from "@/lib/query/omo";
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
import EnvPanel from "@/components/openclaw/EnvPanel";
import ToolsPanel from "@/components/openclaw/ToolsPanel";
@@ -176,7 +180,10 @@ function App() {
if (
currentView === "sessions" &&
activeApp !== "claude" &&
activeApp !== "codex"
activeApp !== "codex" &&
activeApp !== "opencode" &&
activeApp !== "openclaw" &&
activeApp !== "gemini"
) {
setCurrentView("providers");
}
@@ -194,6 +201,9 @@ function App() {
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
const toolbarRef = useRef<HTMLDivElement>(null);
const isToolbarCompact = useAutoCompact(toolbarRef);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
@@ -220,7 +230,12 @@ function App() {
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const hasSkillsSupport = true;
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
const hasSessionSupport =
activeApp === "claude" ||
activeApp === "codex" ||
activeApp === "opencode" ||
activeApp === "openclaw" ||
activeApp === "gemini";
const {
addProvider,
@@ -229,10 +244,7 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp, {
isProxyRunning,
isTakeoverActive: isCurrentAppTakeoverActive,
});
} = useProviderActions(activeApp);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -251,6 +263,23 @@ function App() {
});
};
const disableOmoSlimMutation = useDisableCurrentOmoSlim();
const handleDisableOmoSlim = () => {
disableOmoSlimMutation.mutate(undefined, {
onSuccess: () => {
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
},
onError: (error: Error) => {
toast.error(
t("omo.disableFailed", {
defaultValue: "停用 OMO 失败: {{error}}",
error: extractErrorMessage(error),
}),
);
},
});
};
useEffect(() => {
let unsubscribe: (() => void) | undefined;
@@ -311,7 +340,8 @@ function App() {
const off = await listen(
"webdav-sync-status-updated",
async (event) => {
const payload = (event.payload ?? {}) as WebDavSyncStatusUpdatedPayload;
const payload = (event.payload ??
{}) as WebDavSyncStatusUpdatedPayload;
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (payload.source !== "auto" || payload.status !== "error") {
@@ -700,7 +730,7 @@ function App() {
);
case "sessions":
return <SessionManagerPage />;
return <SessionManagerPage key={activeApp} appId={activeApp} />;
case "workspace":
return <WorkspaceFilesPanel />;
case "openclawEnv":
@@ -748,6 +778,11 @@ function App() {
onDisableOmo={
activeApp === "opencode" ? handleDisableOmo : undefined
}
onDisableOmoSlim={
activeApp === "opencode"
? handleDisableOmoSlim
: undefined
}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
@@ -930,231 +965,252 @@ function App() {
</div>
<div
className="flex items-center gap-1.5 h-[32px]"
style={{ WebkitAppRegion: "no-drag" } as any}
ref={toolbarRef}
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
>
{currentView === "prompts" && (
<Button
variant="ghost"
size="sm"
onClick={() => promptPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button>
)}
{currentView === "mcp" && (
<>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
onClick={() => promptPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
{t("prompts.add")}
</Button>
</>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
{activeApp !== "opencode" && activeApp !== "openclaw" && (
<>
<ProxyToggle
activeApp={activeApp}
currentProvider={providers[currentProviderId] || null}
/>
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none",
)}
>
<FailoverToggle activeApp={activeApp} />
</div>
</>
)}
)}
{currentView === "mcp" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
</Button>
</>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
{activeApp !== "opencode" &&
activeApp !== "openclaw" &&
settingsData?.enableLocalProxy && (
<>
<ProxyToggle activeApp={activeApp} />
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none",
)}
>
<FailoverToggle activeApp={activeApp} />
</div>
</>
)}
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={
isCurrentAppTakeoverActive &&
Object.values(visibleApps).filter(Boolean).length >= 4
}
/>
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={isToolbarCompact}
/>
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
{activeApp === "openclaw" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
<AnimatePresence mode="wait">
<motion.div
key={activeApp === "openclaw" ? "openclaw" : "default"}
className="flex items-center gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
{activeApp === "openclaw" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="w-5 h-5" />
</Button>
</>
)}
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="w-5 h-5" />
</Button>
</>
)}
</div>
</div>
</div>
</header>
+15 -4
View File
@@ -1,6 +1,7 @@
import type { AppId } from "@/lib/api";
import type { VisibleApps } from "@/types";
import { ProviderIcon } from "@/components/ProviderIcon";
import { cn } from "@/lib/utils";
interface AppSwitcherProps {
activeApp: AppId;
@@ -52,18 +53,28 @@ export function AppSwitcher({
key={app}
type="button"
onClick={() => handleSwitch(app)}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
className={cn(
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
activeApp === app
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<ProviderIcon
icon={appIconName[app]}
name={appDisplayName[app]}
size={iconSize}
/>
{!compact && <span>{appDisplayName[app]}</span>}
<span
className={cn(
"transition-all duration-200 whitespace-nowrap overflow-hidden",
compact
? "max-w-0 opacity-0 ml-0"
: "max-w-[80px] opacity-100 ml-2",
)}
>
{appDisplayName[app]}
</span>
</button>
))}
</div>
+12 -3
View File
@@ -7,7 +7,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ConfirmDialogProps {
@@ -16,6 +16,7 @@ interface ConfirmDialogProps {
message: string;
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "info";
onConfirm: () => void;
onCancel: () => void;
}
@@ -26,11 +27,16 @@ export function ConfirmDialog({
message,
confirmText,
cancelText,
variant = "destructive",
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation();
const IconComponent = variant === "info" ? Info : AlertTriangle;
const iconClass =
variant === "info" ? "h-5 w-5 text-blue-500" : "h-5 w-5 text-destructive";
return (
<Dialog
open={isOpen}
@@ -43,7 +49,7 @@ export function ConfirmDialog({
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
<IconComponent className={iconClass} />
{title}
</DialogTitle>
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
@@ -54,7 +60,10 @@ export function ConfirmDialog({
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
<Button variant="destructive" onClick={onConfirm}>
<Button
variant={variant === "info" ? "default" : "destructive"}
onClick={onConfirm}
>
{confirmText || t("common.confirm")}
</Button>
</DialogFooter>
+37 -4
View File
@@ -4,7 +4,8 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import { usageApi, settingsApi, type AppId } from "@/lib/api";
import { useSettingsQuery } from "@/lib/query";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
@@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { cn } from "@/lib/utils";
interface UsageScriptModalProps {
@@ -112,6 +114,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
@@ -247,6 +251,27 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [showApiKey, setShowApiKey] = useState(false);
const [showAccessToken, setShowAccessToken] = useState(false);
const handleEnableToggle = (checked: boolean) => {
if (checked && !settingsData?.usageConfirmed) {
setShowUsageConfirm(true);
} else {
setScript({ ...script, enabled: checked });
}
};
const handleUsageConfirm = async () => {
setShowUsageConfirm(false);
try {
if (settingsData) {
await settingsApi.save({ ...settingsData, usageConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save usage confirmed:", error);
}
setScript({ ...script, enabled: true });
};
const handleSave = () => {
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
@@ -436,9 +461,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</p>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
setScript({ ...script, enabled: checked })
}
onCheckedChange={handleEnableToggle}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
@@ -844,6 +867,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
)}
<ConfirmDialog
isOpen={showUsageConfirm}
variant="info"
title={t("confirm.usage.title")}
message={t("confirm.usage.message")}
confirmText={t("confirm.usage.confirm")}
onConfirm={() => void handleUsageConfirm()}
onCancel={() => setShowUsageConfirm(false)}
/>
</FullScreenPanel>
);
};
+3 -1
View File
@@ -6,11 +6,13 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
interface AppCountBarProps {
totalLabel: string;
counts: Record<AppId, number>;
appIds?: AppId[];
}
export const AppCountBar: React.FC<AppCountBarProps> = ({
totalLabel,
counts,
appIds = APP_IDS,
}) => {
return (
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
@@ -18,7 +20,7 @@ export const AppCountBar: React.FC<AppCountBarProps> = ({
{totalLabel}
</Badge>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
{APP_IDS.map((app) => (
{appIds.map((app) => (
<Badge
key={app}
variant="secondary"
+3 -1
View File
@@ -10,15 +10,17 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
interface AppToggleGroupProps {
apps: Record<AppId, boolean>;
onToggle: (app: AppId, enabled: boolean) => void;
appIds?: AppId[];
}
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
apps,
onToggle,
appIds = APP_IDS,
}) => {
return (
<div className="flex items-center gap-1.5 flex-shrink-0">
{APP_IDS.map((app) => {
{appIds.map((app) => {
const { label, icon, activeClass } = APP_ICON_MAP[app];
const enabled = apps[app];
return (
+4 -2
View File
@@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets";
import { toast } from "sonner";
import { APP_IDS } from "@/config/appConfig";
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
import { AppCountBar } from "@/components/common/AppCountBar";
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
import { ListItemRow } from "@/components/common/ListItemRow";
@@ -58,7 +58,7 @@ const UnifiedMcpPanel = React.forwardRef<
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
serverEntries.forEach(([_, server]) => {
for (const app of APP_IDS) {
for (const app of MCP_SKILLS_APP_IDS) {
if (server.apps[app]) counts[app]++;
}
});
@@ -136,6 +136,7 @@ const UnifiedMcpPanel = React.forwardRef<
<AppCountBar
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
counts={enabledCounts}
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
@@ -277,6 +278,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<AppToggleGroup
apps={server.apps}
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
+13 -11
View File
@@ -17,7 +17,6 @@ const AgentsDefaultsPanel: React.FC = () => {
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
const [primaryModel, setPrimaryModel] = useState("");
const [fallbacks, setFallbacks] = useState("");
// Extra known fields from agents.defaults
@@ -26,13 +25,15 @@ const AgentsDefaultsPanel: React.FC = () => {
const [contextTokens, setContextTokens] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("");
// Primary model is read-only — set via the "Set as default model" button on provider cards
const primaryModel = agentsData?.model?.primary ?? "";
useEffect(() => {
// agentsData is undefined while loading, null when config section is absent
if (agentsData === undefined) return;
setDefaults(agentsData);
if (agentsData) {
setPrimaryModel(agentsData.model?.primary ?? "");
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
// Extract known extra fields
@@ -48,17 +49,21 @@ const AgentsDefaultsPanel: React.FC = () => {
// Preserve all unknown fields from original data
const updated: OpenClawAgentsDefaults = { ...defaults };
// Model configuration
// Model configuration — primary is read-only, preserve original value
const fallbackList = fallbacks
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (primaryModel.trim()) {
const origPrimary = defaults?.model?.primary;
if (origPrimary) {
updated.model = {
primary: primaryModel.trim(),
primary: origPrimary,
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
};
} else if (fallbackList.length > 0) {
// No primary set but user provided fallbacks — keep fallbacks only
updated.model = { primary: "", fallbacks: fallbackList };
}
// Optional fields
@@ -122,12 +127,9 @@ const AgentsDefaultsPanel: React.FC = () => {
<Label className="mb-1.5 block">
{t("openclaw.agents.primaryModel")}
</Label>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(e.target.value)}
placeholder="provider/model-id"
className="font-mono text-xs"
/>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted/50 font-mono text-xs text-muted-foreground">
{primaryModel || t("openclaw.agents.notSet")}
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.primaryModelHint")}
</p>
+9 -7
View File
@@ -3,12 +3,12 @@ import {
Check,
Copy,
Edit,
Loader2,
// Loader2, // Hidden: stream check feature disabled
Minus,
Play,
Plus,
Terminal,
TestTube2,
// TestTube2, // Hidden: stream check feature disabled
Trash2,
Zap,
} from "lucide-react";
@@ -46,14 +46,14 @@ export function ProviderActions({
appId,
isCurrent,
isInConfig = false,
isTesting,
isTesting: _isTesting, // Hidden: stream check feature disabled
isProxyTakeover = false,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDuplicate,
onTest,
onTest: _onTest, // Hidden: stream check feature disabled
onConfigureUsage,
onDelete,
onRemoveFromConfig,
@@ -207,7 +207,7 @@ export function ProviderActions({
onClick={isDefaultModel ? undefined : onSetAsDefault}
disabled={isDefaultModel}
className={cn(
"w-[4.5rem] px-2.5",
"w-fit px-2.5",
isDefaultModel
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
@@ -215,8 +215,8 @@ export function ProviderActions({
>
<Zap className="h-4 w-4" />
{isDefaultModel
? t("provider.isDefault", { defaultValue: "默认" })
: t("provider.setAsDefault", { defaultValue: "启用" })}
? t("provider.isDefault", { defaultValue: "当前默认" })
: t("provider.setAsDefault", { defaultValue: "设为默认" })}
</Button>
)}
@@ -252,6 +252,7 @@ export function ProviderActions({
<Copy className="h-4 w-4" />
</Button>
{/* Hidden: stream check feature disabled
{onTest && (
<Button
size="icon"
@@ -268,6 +269,7 @@ export function ProviderActions({
)}
</Button>
)}
*/}
<Button
size="icon"
+25 -8
View File
@@ -29,11 +29,14 @@ interface ProviderCardProps {
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
isOmo?: boolean;
isLastOmo?: boolean;
isOmoSlim?: boolean;
isLastOmoSlim?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDisableOmoSlim?: () => void;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
@@ -92,11 +95,14 @@ export function ProviderCard({
isInConfig = true,
isOmo = false,
isLastOmo = false,
isOmoSlim = false,
isLastOmoSlim = false,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDisableOmoSlim,
onConfigureUsage,
onOpenWebsite,
onDuplicate,
@@ -117,6 +123,11 @@ export function ProviderCard({
}: ProviderCardProps) {
const { t } = useTranslation();
// OMO and OMO Slim share the same card behavior
const isAnyOmo = isOmo || isOmoSlim;
const isLastAnyOmo = isOmo ? isLastOmo : isLastOmoSlim;
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
const { data: health } = useProviderHealth(provider.id, appId);
const fallbackUrlText = t("provider.notConfigured", {
@@ -186,11 +197,11 @@ export function ProviderCard({
};
// 判断是否是"当前使用中"的供应商
// - OMO 供应商:使用 isCurrent
// - OMO/OMO Slim 供应商:使用 isCurrent
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 普通模式:isCurrent
const isActiveProvider = isOmo
const isActiveProvider = isAnyOmo
? isCurrent
: appId === "opencode" || appId === "openclaw"
? false
@@ -198,10 +209,10 @@ export function ProviderCard({
? activeProviderId === provider.id
: isCurrent;
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
const shouldUseBlue =
(isOmo && isActiveProvider) ||
(!isOmo && !isProxyTakeover && isActiveProvider);
(isAnyOmo && isActiveProvider) ||
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
return (
<div
@@ -265,6 +276,12 @@ export function ProviderCard({
</span>
)}
{isOmoSlim && (
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
Slim
</span>
)}
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
@@ -372,8 +389,8 @@ export function ProviderCard({
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOmo={isOmo}
isLastOmo={isLastOmo}
isOmo={isAnyOmo}
isLastOmo={isLastAnyOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
@@ -385,7 +402,7 @@ export function ProviderCard({
? () => onRemoveFromConfig(provider)
: undefined
}
onDisableOmo={onDisableOmo}
onDisableOmo={handleDisableAnyOmo}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
@@ -1,12 +1,16 @@
import { Users } from "lucide-react";
import { Download, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface ProviderEmptyStateProps {
onCreate?: () => void;
onImport?: () => void;
}
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
export function ProviderEmptyState({
onCreate,
onImport,
}: ProviderEmptyStateProps) {
const { t } = useTranslation();
return (
@@ -18,11 +22,19 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{t("provider.noProvidersDescription")}
</p>
{onCreate && (
<Button className="mt-6" onClick={onCreate}>
{t("provider.addProvider")}
</Button>
)}
<div className="mt-6 flex flex-col gap-2">
{onImport && (
<Button onClick={onImport}>
<Download className="mr-2 h-4 w-4" />
{t("provider.importCurrent")}
</Button>
)}
{onCreate && (
<Button variant={onImport ? "outline" : "default"} onClick={onCreate}>
{t("provider.addProvider")}
</Button>
)}
</div>
</div>
);
}
+63 -4
View File
@@ -15,7 +15,8 @@ import {
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
@@ -33,7 +34,12 @@ import {
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
import {
useCurrentOmoProviderId,
useOmoProviderCount,
useCurrentOmoSlimProviderId,
useOmoSlimProviderCount,
} from "@/lib/query/omo";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -47,6 +53,7 @@ interface ProviderListProps {
onDelete: (provider: Provider) => void;
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDisableOmoSlim?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -68,6 +75,7 @@ export function ProviderList({
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDisableOmoSlim,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -135,6 +143,8 @@ export function ProviderList({
const isOpenCode = appId === "opencode";
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
const { data: omoSlimProviderCount } = useOmoSlimProviderCount(isOpenCode);
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
@@ -170,6 +180,23 @@ export function ProviderList({
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Import current live config as default provider
const queryClient = useQueryClient();
const importMutation = useMutation({
mutationFn: () => providersApi.importDefault(appId),
onSuccess: (imported) => {
if (imported) {
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
toast.success(t("provider.importCurrentDescription"));
} else {
toast.info(t("provider.noProviders"));
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
@@ -222,8 +249,17 @@ export function ProviderList({
);
}
// Only show import button for standard apps (not additive-mode apps like OpenCode/OpenClaw)
const showImportButton =
appId === "claude" || appId === "codex" || appId === "gemini";
if (sortedProviders.length === 0) {
return <ProviderEmptyState onCreate={onCreate} />;
return (
<ProviderEmptyState
onCreate={onCreate}
onImport={showImportButton ? () => importMutation.mutate() : undefined}
/>
);
}
const renderProviderList = () => (
@@ -239,13 +275,20 @@ export function ProviderList({
<div className="space-y-3">
{filteredProviders.map((provider) => {
const isOmo = provider.category === "omo";
const isOmoSlim = provider.category === "omo-slim";
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
const isOmoSlimCurrent =
isOmoSlim && provider.id === (currentOmoSlimId || "");
return (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={
isOmo ? isOmoCurrent : provider.id === currentProviderId
isOmo
? isOmoCurrent
: isOmoSlim
? isOmoSlimCurrent
: provider.id === currentProviderId
}
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
@@ -253,11 +296,18 @@ export function ProviderList({
isLastOmo={
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
}
isOmoSlim={isOmoSlim}
isLastOmoSlim={
isOmoSlim &&
(omoSlimProviderCount ?? 0) <= 1 &&
isOmoSlimCurrent
}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDisableOmoSlim={onDisableOmoSlim}
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
@@ -371,11 +421,14 @@ interface SortableProviderCardProps {
isInConfig: boolean;
isOmo: boolean;
isLastOmo: boolean;
isOmoSlim: boolean;
isLastOmoSlim: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onRemoveFromConfig?: (provider: Provider) => void;
onDisableOmo?: () => void;
onDisableOmoSlim?: () => void;
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
@@ -401,11 +454,14 @@ function SortableProviderCard({
isInConfig,
isOmo,
isLastOmo,
isOmoSlim,
isLastOmoSlim,
onSwitch,
onEdit,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onDisableOmoSlim,
onDuplicate,
onConfigureUsage,
onOpenWebsite,
@@ -445,11 +501,14 @@ function SortableProviderCard({
isInConfig={isInConfig}
isOmo={isOmo}
isLastOmo={isLastOmo}
isOmoSlim={isOmoSlim}
isLastOmoSlim={isLastOmoSlim}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onRemoveFromConfig={onRemoveFromConfig}
onDisableOmo={onDisableOmo}
onDisableOmoSlim={onDisableOmoSlim}
onDuplicate={onDuplicate}
onConfigureUsage={
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
@@ -10,7 +10,11 @@ import {
} from "@/components/ui/select";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
import type {
ProviderCategory,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
interface EndpointCandidate {
@@ -68,6 +72,10 @@ interface ClaudeFormFieldsProps {
// API Format (for third-party providers that use OpenAI Chat Completions format)
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
apiKeyField: ClaudeApiKeyField;
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
}
export function ClaudeFormFields({
@@ -102,6 +110,8 @@ export function ClaudeFormFields({
speedTestEndpoints,
apiFormat,
onApiFormatChange,
apiKeyField,
onApiKeyFieldChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -169,8 +179,6 @@ export function ClaudeFormFields({
: t("providerForm.apiHint")
}
onManageClick={() => onEndpointModalToggle(true)}
appType="claude"
apiFormat={apiFormat}
/>
)}
@@ -190,8 +198,8 @@ export function ClaudeFormFields({
/>
)}
{/* API 格式选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
{/* API 格式选择(仅非官方、非云服务商显示) */}
{shouldShowModelSelector && category !== "cloud_provider" && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
@@ -221,6 +229,41 @@ export function ClaudeFormFields({
</div>
)}
{/* 认证字段选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel htmlFor="apiKeyField">
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Select
value={apiKeyField}
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
>
<SelectTrigger id="apiKeyField" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("providerForm.authFieldAuthToken", {
defaultValue: "Auth Token (默认)",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("providerForm.authFieldApiKey", {
defaultValue: "API Key",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.authFieldHint", {
defaultValue:
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
})}
</p>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { Checkbox } from "@/components/ui/checkbox";
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
interface ClaudeQuickTogglesProps {
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
onPatchApplied?: (patch: Record<string, unknown>) => void;
}
const defaultStates: Record<ToggleKey, boolean> = {
hideAttribution: false,
alwaysThinking: false,
enableTeammates: false,
};
function deriveStates(
cfg: Record<string, unknown>,
): Record<ToggleKey, boolean> {
const env = cfg?.env as Record<string, unknown> | undefined;
const attr = cfg?.attribution as Record<string, unknown> | undefined;
return {
hideAttribution: attr?.commit === "" && attr?.pr === "",
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
};
}
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
function jsonMergePatch(
target: Record<string, unknown>,
patch: Record<string, unknown>,
) {
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === undefined) {
delete target[key];
} else if (typeof value === "object" && !Array.isArray(value)) {
if (
typeof target[key] !== "object" ||
target[key] === null ||
Array.isArray(target[key])
) {
target[key] = {};
}
jsonMergePatch(
target[key] as Record<string, unknown>,
value as Record<string, unknown>,
);
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
delete target[key];
}
} else {
target[key] = value;
}
}
}
export { jsonMergePatch };
export function ClaudeQuickToggles({
onPatchApplied,
}: ClaudeQuickTogglesProps) {
const { t } = useTranslation();
const [states, setStates] = useState(defaultStates);
const readLive = useCallback(async () => {
try {
const cfg = await invoke<Record<string, unknown>>(
"read_live_provider_settings",
{ app: "claude" },
);
setStates(deriveStates(cfg));
} catch {
// Live file missing or unreadable — show all unchecked
}
}, []);
useEffect(() => {
readLive();
}, [readLive]);
const toggle = useCallback(
async (key: ToggleKey) => {
let patch: Record<string, unknown>;
if (key === "hideAttribution") {
patch = states.hideAttribution
? { attribution: null }
: { attribution: { commit: "", pr: "" } };
} else if (key === "alwaysThinking") {
patch = states.alwaysThinking
? { alwaysThinkingEnabled: null }
: { alwaysThinkingEnabled: true };
} else {
patch = states.enableTeammates
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
}
// Optimistic update
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
try {
await invoke("patch_claude_live_settings", { patch });
onPatchApplied?.(patch);
} catch {
// Revert on failure
readLive();
}
},
[states, readLive, onPatchApplied],
);
return (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{(
[
["hideAttribution", "claudeConfig.hideAttribution"],
["alwaysThinking", "claudeConfig.alwaysThinking"],
["enableTeammates", "claudeConfig.enableTeammates"],
] as const
).map(([key, i18nKey]) => (
<label
key={key}
className="flex items-center gap-1.5 text-sm cursor-pointer"
>
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
{t(i18nKey)}
</label>
))}
</div>
);
}
@@ -1,107 +0,0 @@
import React, { useEffect, useState } from "react";
import { Save, Download, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
interface CodexCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
}
/**
* CodexCommonConfigModal - Common Codex configuration editor modal
* Allows editing of common TOML configuration shared across providers
*/
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
isOpen,
onClose,
value,
onChange,
error,
onExtract,
isExtracting,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("codexConfig.editCommonConfigTitle")}
onClose={onClose}
footer={
<>
{onExtract && (
<Button
type="button"
variant="outline"
onClick={onExtract}
disabled={isExtracting}
className="gap-2"
>
{isExtracting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t("codexConfig.extractFromCurrent", {
defaultValue: "从编辑内容提取",
})}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("codexConfig.commonConfigHint")}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
darkMode={isDarkMode}
rows={16}
showValidation={false}
language="javascript"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
);
};
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import React from "react";
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
interface CodexConfigEditorProps {
authValue: string;
@@ -13,23 +12,9 @@ interface CodexConfigEditorProps {
onAuthBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
authError: string;
configError: string; // config.toml 错误提示
onExtract?: () => void;
isExtracting?: boolean;
configError: string;
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
@@ -38,25 +23,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onAuthChange,
onConfigChange,
onAuthBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
authError,
configError,
onExtract,
isExtracting,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// Auto-open common config modal if there's an error
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
return (
<div className="space-y-6">
{/* Auth JSON Section */}
@@ -71,23 +40,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<CodexConfigSection
value={configValue}
onChange={onConfigChange}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
configError={configError}
/>
{/* Common Config Modal */}
<CodexCommonConfigModal
isOpen={isCommonConfigModalOpen}
onClose={() => setIsCommonConfigModalOpen(false)}
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
error={commonConfigError}
onExtract={onExtract}
isExtracting={isExtracting}
/>
</div>
);
};
@@ -78,10 +78,6 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
interface CodexConfigSectionProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
configError?: string;
}
@@ -91,10 +87,6 @@ interface CodexConfigSectionProps {
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
configError,
}) => {
const { t } = useTranslation();
@@ -117,40 +109,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.configToml")}
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("codexConfig.writeCommonConfig")}
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("codexConfig.editCommonConfig")}
</button>
</div>
{commonConfigError && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.configToml")}
</label>
<JsonEditor
value={value}
@@ -94,8 +94,6 @@ export function CodexFormFields({
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
hint={t("providerForm.codexApiHint")}
onManageClick={() => onEndpointModalToggle(true)}
appType="codex"
apiFormat="responses"
/>
)}
@@ -1,280 +0,0 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState, useCallback, useMemo } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, Download, Loader2 } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
interface CommonConfigEditorProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
onEditClick: () => void;
isModalOpen: boolean;
onModalClose: () => void;
onExtract?: () => void;
isExtracting?: boolean;
}
export function CommonConfigEditor({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
onEditClick,
isModalOpen,
onModalClose,
onExtract,
isExtracting,
}: CommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync
// (parent uses form.getValues which doesn't trigger re-renders)
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleLocalChange = useCallback(
(newValue: string) => {
setLocalValue(newValue);
onChange(newValue);
},
[onChange],
);
const toggleStates = useMemo(() => {
try {
const config = JSON.parse(localValue);
return {
hideAttribution:
config?.attribution?.commit === "" && config?.attribution?.pr === "",
alwaysThinking: config?.alwaysThinkingEnabled === true,
teammates:
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
};
} catch {
return {
hideAttribution: false,
alwaysThinking: false,
teammates: false,
};
}
}, [localValue]);
const handleToggle = useCallback(
(toggleKey: string, checked: boolean) => {
try {
const config = JSON.parse(localValue || "{}");
switch (toggleKey) {
case "hideAttribution":
if (checked) {
config.attribution = { commit: "", pr: "" };
} else {
delete config.attribution;
}
break;
case "alwaysThinking":
if (checked) {
config.alwaysThinkingEnabled = true;
} else {
delete config.alwaysThinkingEnabled;
}
break;
case "teammates":
if (!config.env) config.env = {};
if (checked) {
config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
} else {
delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
}
handleLocalChange(JSON.stringify(config, null, 2));
} catch {
// Don't modify if JSON is invalid
}
},
[localValue, handleLocalChange],
);
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
id="useCommonConfig"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("claudeConfig.writeCommonConfig", {
defaultValue: "写入通用配置",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("claudeConfig.editCommonConfig", {
defaultValue: "编辑通用配置",
})}
</button>
</div>
{commonConfigError && !isModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.hideAttribution}
onChange={(e) =>
handleToggle("hideAttribution", e.target.checked)
}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.hideAttribution")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.alwaysThinking}
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.alwaysThinking")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.teammates}
onChange={(e) => handleToggle("teammates", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.enableTeammates")}</span>
</label>
</div>
<JsonEditor
value={localValue}
onChange={handleLocalChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
darkMode={isDarkMode}
rows={14}
showValidation={true}
language="json"
/>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
onClose={onModalClose}
footer={
<>
{onExtract && (
<Button
type="button"
variant="outline"
onClick={onExtract}
disabled={isExtracting}
className="gap-2"
>
{isExtracting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t("claudeConfig.extractFromCurrent", {
defaultValue: "从编辑内容提取",
})}
</Button>
)}
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onModalClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
}
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</FullScreenPanel>
</>
);
}
@@ -1,106 +0,0 @@
import React, { useEffect, useState } from "react";
import { Save, Download, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
interface GeminiCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
}
/**
* GeminiCommonConfigModal - Common Gemini configuration editor modal
* Allows editing of common env snippet shared across Gemini providers
*/
export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
onClose={onClose}
footer={
<>
{onExtract && (
<Button
type="button"
variant="outline"
onClick={onExtract}
disabled={isExtracting}
className="gap-2"
>
{isExtracting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t("geminiConfig.extractFromCurrent", {
defaultValue: "从编辑内容提取",
})}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY",
})}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"GEMINI_MODEL": "gemini-3-pro-preview"
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
);
};
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import React from "react";
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
interface GeminiConfigEditorProps {
envValue: string;
@@ -8,15 +7,8 @@ interface GeminiConfigEditorProps {
onEnvChange: (value: string) => void;
onConfigChange: (value: string) => void;
onEnvBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
envError: string;
configError: string;
onExtract?: () => void;
isExtracting?: boolean;
}
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
@@ -25,25 +17,9 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onEnvChange,
onConfigChange,
onEnvBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
envError,
configError,
onExtract,
isExtracting,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// Auto-open common config modal if there's an error
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
return (
<div className="space-y-6">
{/* Env Section */}
@@ -52,10 +28,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onChange={onEnvChange}
onBlur={onEnvBlur}
error={envError}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
/>
{/* Config JSON Section */}
@@ -64,17 +36,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onChange={onConfigChange}
configError={configError}
/>
{/* Common Config Modal */}
<GeminiCommonConfigModal
isOpen={isCommonConfigModalOpen}
onClose={() => setIsCommonConfigModalOpen(false)}
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
error={commonConfigError}
onExtract={onExtract}
isExtracting={isExtracting}
/>
</div>
);
};
@@ -7,10 +7,6 @@ interface GeminiEnvSectionProps {
onChange: (value: string) => void;
onBlur?: () => void;
error?: string;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
}
/**
@@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
onChange,
onBlur,
error,
useCommonConfig,
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -53,44 +45,12 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="geminiEnv"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("geminiConfig.writeCommonConfig", {
defaultValue: "写入通用配置",
})}
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("geminiConfig.editCommonConfig", {
defaultValue: "编辑通用配置",
})}
</button>
</div>
{commonConfigError && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<label
htmlFor="geminiEnv"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<JsonEditor
value={value}
@@ -23,6 +23,7 @@ interface OmoCommonConfigEditorProps {
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
fieldsKey: number;
isSlim?: boolean;
}
export function OmoCommonConfigEditor({
@@ -37,6 +38,7 @@ export function OmoCommonConfigEditor({
onGlobalConfigStateChange,
globalConfigRef,
fieldsKey,
isSlim = false,
}: OmoCommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -153,6 +155,7 @@ export function OmoCommonConfigEditor({
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
onStateChange={onGlobalConfigStateChange}
hideSaveButtons
isSlim={isSlim}
/>
</div>
</FullScreenPanel>
@@ -41,10 +41,11 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useReadOmoLocalFile } from "@/lib/query/omo";
import { useReadOmoLocalFile, useReadOmoSlimLocalFile } from "@/lib/query/omo";
import {
OMO_BUILTIN_AGENTS,
OMO_BUILTIN_CATEGORIES,
OMO_SLIM_BUILTIN_AGENTS,
type OmoAgentDef,
type OmoCategoryDef,
} from "@/types/omo";
@@ -69,12 +70,13 @@ interface OmoFormFieldsProps {
>;
agents: Record<string, Record<string, unknown>>;
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
categories: Record<string, Record<string, unknown>>;
onCategoriesChange: (
categories?: Record<string, Record<string, unknown>>;
onCategoriesChange?: (
categories: Record<string, Record<string, unknown>>,
) => void;
otherFieldsStr: string;
onOtherFieldsStrChange: (value: string) => void;
isSlim?: boolean;
}
export type CustomModelItem = {
@@ -121,6 +123,9 @@ function DeferredKeyInput({
}
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
const BUILTIN_AGENT_KEYS_SLIM = new Set(
OMO_SLIM_BUILTIN_AGENTS.map((a) => a.key),
);
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
@@ -303,13 +308,21 @@ export function OmoFormFields({
presetMetaMap: _presetMetaMap = {},
agents,
onAgentsChange,
categories,
categories = {},
onCategoriesChange,
otherFieldsStr,
onOtherFieldsStrChange,
isSlim = false,
}: OmoFormFieldsProps) {
const { t } = useTranslation();
const builtinAgentDefs = isSlim
? OMO_SLIM_BUILTIN_AGENTS
: OMO_BUILTIN_AGENTS;
const builtinAgentKeys = isSlim
? BUILTIN_AGENT_KEYS_SLIM
: BUILTIN_AGENT_KEYS;
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
const [categoriesOpen, setCategoriesOpen] = useState(true);
@@ -329,7 +342,7 @@ export function OmoFormFields({
>({});
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
collectCustomModels(agents, BUILTIN_AGENT_KEYS),
collectCustomModels(agents, builtinAgentKeys),
);
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
@@ -337,7 +350,7 @@ export function OmoFormFields({
);
useEffect(() => {
setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS));
setCustomAgents(collectCustomModels(agents, builtinAgentKeys));
}, [agents]);
useEffect(() => {
@@ -349,17 +362,18 @@ export function OmoFormFields({
onAgentsChange(
mergeCustomModelsIntoStore(
agents,
BUILTIN_AGENT_KEYS,
builtinAgentKeys,
customs,
modelVariantsMap,
),
);
},
[agents, onAgentsChange, modelVariantsMap],
[agents, onAgentsChange, modelVariantsMap, builtinAgentKeys],
);
const syncCustomCategories = useCallback(
(customs: CustomModelItem[]) => {
if (!onCategoriesChange) return;
onCategoriesChange(
mergeCustomModelsIntoStore(
categories,
@@ -709,7 +723,7 @@ export function OmoFormFields({
}
const updatedAgents = { ...agents };
for (const agentDef of OMO_BUILTIN_AGENTS) {
for (const agentDef of builtinAgentDefs) {
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
updatedAgents[agentDef.key] = {
@@ -720,30 +734,35 @@ export function OmoFormFields({
}
onAgentsChange(updatedAgents);
const updatedCategories = { ...categories };
for (const catDef of OMO_BUILTIN_CATEGORIES) {
const recommendedValue = resolveRecommendedModel(catDef.recommended);
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
updatedCategories[catDef.key] = {
...updatedCategories[catDef.key],
model: recommendedValue,
};
if (!isSlim && onCategoriesChange) {
const updatedCategories = { ...categories };
for (const catDef of OMO_BUILTIN_CATEGORIES) {
const recommendedValue = resolveRecommendedModel(catDef.recommended);
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
updatedCategories[catDef.key] = {
...updatedCategories[catDef.key],
model: recommendedValue,
};
}
}
onCategoriesChange(updatedCategories);
}
onCategoriesChange(updatedCategories);
};
const configuredAgentCount = Object.keys(agents).length;
const configuredCategoryCount = Object.keys(categories).length;
const mainAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "main");
const subAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "sub");
const configuredCategoryCount = isSlim ? 0 : Object.keys(categories).length;
const mainAgents = builtinAgentDefs.filter((a) => a.group === "main");
const subAgents = builtinAgentDefs.filter((a) => a.group === "sub");
const readLocalFile = useReadOmoLocalFile();
const readSlimLocalFile = useReadOmoSlimLocalFile();
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
const handleImportFromLocal = useCallback(async () => {
try {
const data = await readLocalFile.mutateAsync();
const data = isSlim
? await readSlimLocalFile.mutateAsync()
: await readLocalFile.mutateAsync();
const importedAgents =
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
{};
@@ -753,16 +772,20 @@ export function OmoFormFields({
| undefined) || {};
onAgentsChange(importedAgents);
onCategoriesChange(importedCategories);
if (!isSlim && onCategoriesChange) {
onCategoriesChange(importedCategories);
}
onOtherFieldsStrChange(
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
);
setAgentAdvancedDrafts({});
setCategoryAdvancedDrafts({});
setCustomAgents(collectCustomModels(importedAgents, BUILTIN_AGENT_KEYS));
setCustomCategories(
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
);
setCustomAgents(collectCustomModels(importedAgents, builtinAgentKeys));
if (!isSlim) {
setCustomCategories(
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
);
}
setLocalFilePath(data.filePath);
toast.success(
t("omo.importLocalReplaceSuccess", {
@@ -792,7 +815,7 @@ export function OmoFormFields({
) => {
const isAgent = scope === "agent";
const store = isAgent ? agents : categories;
const setter = isAgent ? onAgentsChange : onCategoriesChange;
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
const expanded = isAgent ? expandedAgents : expandedCategories;
@@ -866,7 +889,7 @@ export function OmoFormFields({
) => {
const isAgent = scope === "agent";
const store = isAgent ? agents : categories;
const setter = isAgent ? onAgentsChange : onCategoriesChange;
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
const expanded = isAgent ? expandedAgents : expandedCategories;
const customs = isAgent ? customAgents : customCategories;
@@ -1153,30 +1176,31 @@ export function OmoFormFields({
),
})}
{renderModelSection({
title: t("omo.categories", { defaultValue: "Categories" }),
isOpen: categoriesOpen,
onToggle: () => setCategoriesOpen(!categoriesOpen),
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
action: renderCustomAddButton(() => addCustomModel("category")),
children: (
<>
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
{customCategories.length > 0 && (
<>
{renderCustomDivider(
t("omo.customCategories", {
defaultValue: "Custom Categories",
}),
)}
{customCategories.map((c, i) =>
renderCustomModelRow("category", c, i),
)}
</>
)}
</>
),
})}
{!isSlim &&
renderModelSection({
title: t("omo.categories", { defaultValue: "Categories" }),
isOpen: categoriesOpen,
onToggle: () => setCategoriesOpen(!categoriesOpen),
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
action: renderCustomAddButton(() => addCustomModel("category")),
children: (
<>
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
{customCategories.length > 0 && (
<>
{renderCustomDivider(
t("omo.customCategories", {
defaultValue: "Custom Categories",
}),
)}
{customCategories.map((c, i) =>
renderCustomModelRow("category", c, i),
)}
</>
)}
</>
),
})}
{renderModelSection({
title: t("omo.otherFieldsJson", {
@@ -40,11 +40,18 @@ import {
OMO_BACKGROUND_TASK_PLACEHOLDER,
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
OMO_CLAUDE_CODE_PLACEHOLDER,
OMO_SLIM_DISABLEABLE_AGENTS,
OMO_SLIM_DISABLEABLE_MCPS,
OMO_SLIM_DISABLEABLE_HOOKS,
OMO_SLIM_DEFAULT_SCHEMA_URL,
} from "@/types/omo";
import {
useOmoGlobalConfig,
useSaveOmoGlobalConfig,
useReadOmoLocalFile,
useOmoSlimGlobalConfig,
useSaveOmoSlimGlobalConfig,
useReadOmoSlimLocalFile,
} from "@/lib/query/omo";
interface PresetOption {
@@ -61,6 +68,7 @@ export interface OmoGlobalConfigFieldsRef {
interface OmoGlobalConfigFieldsProps {
onStateChange?: (config: OmoGlobalConfig) => void;
hideSaveButtons?: boolean;
isSlim?: boolean;
}
type OmoAdvancedFieldKey =
@@ -114,6 +122,11 @@ const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
},
];
const OMO_SLIM_ADVANCED_KEYS: ReadonlySet<OmoAdvancedFieldKey> = new Set([
"lspStr",
"experimentalStr",
]);
function TagListEditor({
label,
values,
@@ -310,12 +323,25 @@ function JsonTextareaField({
export const OmoGlobalConfigFields = forwardRef<
OmoGlobalConfigFieldsRef,
OmoGlobalConfigFieldsProps
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
>(function OmoGlobalConfigFields(
{ onStateChange, hideSaveButtons, isSlim = false },
ref,
) {
const { t } = useTranslation();
const { data: config } = useOmoGlobalConfig();
const saveMutation = useSaveOmoGlobalConfig();
const { data: standardConfig } = useOmoGlobalConfig(!isSlim);
const { data: slimConfig } = useOmoSlimGlobalConfig(isSlim);
const config = isSlim ? slimConfig : standardConfig;
const standardSaveMutation = useSaveOmoGlobalConfig();
const slimSaveMutation = useSaveOmoSlimGlobalConfig();
const saveMutation = isSlim ? slimSaveMutation : standardSaveMutation;
const standardReadLocal = useReadOmoLocalFile();
const slimReadLocal = useReadOmoSlimLocalFile();
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
const defaultSchemaUrl = isSlim
? OMO_SLIM_DEFAULT_SCHEMA_URL
: OMO_DEFAULT_SCHEMA_URL;
const [schemaUrl, setSchemaUrl] = useState(defaultSchemaUrl);
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
@@ -330,7 +356,7 @@ export const OmoGlobalConfigFields = forwardRef<
const [loaded, setLoaded] = useState(false);
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
setSchemaUrl(global.schemaUrl || defaultSchemaUrl);
setSisyphusAgentStr(
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
);
@@ -545,7 +571,7 @@ export const OmoGlobalConfigFields = forwardRef<
placeholder: t("omo.disabledAgentsPlaceholder", {
defaultValue: "Disabled Agents",
}),
presets: OMO_DISABLEABLE_AGENTS,
presets: isSlim ? OMO_SLIM_DISABLEABLE_AGENTS : OMO_DISABLEABLE_AGENTS,
},
{
key: "mcps",
@@ -555,7 +581,7 @@ export const OmoGlobalConfigFields = forwardRef<
placeholder: t("omo.disabledMcpsPlaceholder", {
defaultValue: "Disabled MCPs",
}),
presets: OMO_DISABLEABLE_MCPS,
presets: isSlim ? OMO_SLIM_DISABLEABLE_MCPS : OMO_DISABLEABLE_MCPS,
},
{
key: "hooks",
@@ -565,21 +591,25 @@ export const OmoGlobalConfigFields = forwardRef<
placeholder: t("omo.disabledHooksPlaceholder", {
defaultValue: "Disabled Hooks",
}),
presets: OMO_DISABLEABLE_HOOKS,
presets: isSlim ? OMO_SLIM_DISABLEABLE_HOOKS : OMO_DISABLEABLE_HOOKS,
},
{
key: "skills",
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
values: disabledSkills,
onChange: setDisabledSkills,
placeholder: t("omo.disabledSkillsPlaceholder", {
defaultValue: "Disabled Skills",
}),
presets: OMO_DISABLEABLE_SKILLS,
},
] as const;
...(!isSlim
? [
{
key: "skills" as const,
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
values: disabledSkills,
onChange: setDisabledSkills,
placeholder: t("omo.disabledSkillsPlaceholder", {
defaultValue: "Disabled Skills",
}),
presets: OMO_DISABLEABLE_SKILLS,
},
]
: []),
];
const readLocalFile = useReadOmoLocalFile();
const readLocalFile = isSlim ? slimReadLocal : standardReadLocal;
const handleImportGlobalFromLocal = useCallback(async () => {
try {
@@ -668,25 +698,27 @@ export const OmoGlobalConfigFields = forwardRef<
<Input
value={schemaUrl}
onChange={(e) => setSchemaUrl(e.target.value)}
placeholder={OMO_DEFAULT_SCHEMA_URL}
placeholder={defaultSchemaUrl}
className="text-sm h-8"
/>
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
})}
</Label>
<Textarea
value={sisyphusAgentStr}
onChange={(e) => setSisyphusAgentStr(e.target.value)}
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
className="font-mono text-sm"
style={{ minHeight: "140px" }}
/>
</div>
{!isSlim && (
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
})}
</Label>
<Textarea
value={sisyphusAgentStr}
onChange={(e) => setSisyphusAgentStr(e.target.value)}
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
className="font-mono text-sm"
style={{ minHeight: "140px" }}
/>
</div>
)}
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
<div className="flex items-center gap-2">
@@ -715,7 +747,9 @@ export const OmoGlobalConfigFields = forwardRef<
<Label className="text-sm font-semibold">
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
</Label>
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
{OMO_ADVANCED_JSON_FIELDS.filter(
(field) => !isSlim || OMO_SLIM_ADVANCED_KEYS.has(field.key),
).map((field) => (
<JsonTextareaField
key={field.key}
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
@@ -3,7 +3,7 @@ import { useState, useEffect } from "react";
import {
ChevronDown,
ChevronRight,
FlaskConical,
// FlaskConical, // Hidden: stream check feature disabled
Globe,
Coins,
Eye,
@@ -87,15 +87,16 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
}
export function ProviderAdvancedConfig({
testConfig,
testConfig: _testConfig, // Hidden: stream check feature disabled
proxyConfig,
pricingConfig,
onTestConfigChange,
onTestConfigChange: _onTestConfigChange, // Hidden: stream check feature disabled
onProxyConfigChange,
onPricingConfigChange,
}: ProviderAdvancedConfigProps) {
const { t } = useTranslation();
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
// Hidden: stream check feature disabled
// const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
proxyConfig.enabled,
);
@@ -110,10 +111,10 @@ export function ProviderAdvancedConfig({
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
const [isUserTyping, setIsUserTyping] = useState(false);
// 同步外部 testConfig.enabled 变化到展开状态
useEffect(() => {
setIsTestConfigOpen(testConfig.enabled);
}, [testConfig.enabled]);
// Hidden: stream check feature disabled
// useEffect(() => {
// setIsTestConfigOpen(testConfig.enabled);
// }, [testConfig.enabled]);
// 同步外部 proxyConfig.enabled 变化到展开状态
useEffect(() => {
@@ -167,7 +168,7 @@ export function ProviderAdvancedConfig({
return (
<div className="space-y-4">
{/* 模型测试配置 */}
{/* Hidden: stream check feature disabled model test config panel
<div className="rounded-lg border border-border/50 bg-muted/20">
<button
type="button"
@@ -343,6 +344,7 @@ export function ProviderAdvancedConfig({
</div>
</div>
</div>
*/}
{/* 代理配置 */}
<div className="rounded-lg border border-border/50 bg-muted/20">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
import type { OmoGlobalConfig } from "@/types/omo";
import type { PricingModelSourceOption } from "../ProviderAdvancedConfig";
// ── Default configs ──────────────────────────────────────────────────
export const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
export const CODEX_DEFAULT_CONFIG = JSON.stringify(
{ auth: {}, config: "" },
null,
2,
);
export const GEMINI_DEFAULT_CONFIG = JSON.stringify(
{
env: {
GOOGLE_GEMINI_BASE_URL: "",
GEMINI_API_KEY: "",
GEMINI_MODEL: "gemini-3-pro-preview",
},
},
null,
2,
);
export const OPENCODE_DEFAULT_NPM = "@ai-sdk/openai-compatible";
export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
{
npm: OPENCODE_DEFAULT_NPM,
options: {
baseURL: "",
apiKey: "",
},
models: {},
},
null,
2,
);
export const OPENCODE_KNOWN_OPTION_KEYS = [
"baseURL",
"apiKey",
"headers",
] as const;
export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
{
baseUrl: "",
apiKey: "",
api: "openai-completions",
models: [],
},
null,
2,
);
export const EMPTY_OMO_GLOBAL_CONFIG: OmoGlobalConfig = {
id: "global",
disabledAgents: [],
disabledMcps: [],
disabledHooks: [],
disabledSkills: [],
updatedAt: "",
};
// ── Pure functions ───────────────────────────────────────────────────
export function isKnownOpencodeOptionKey(key: string): boolean {
return OPENCODE_KNOWN_OPTION_KEYS.includes(
key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number],
);
}
export function parseOpencodeConfig(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const normalize = (
parsed: Partial<OpenCodeProviderConfig>,
): OpenCodeProviderConfig => ({
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
});
try {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return normalize(parsed);
} catch {
return {
npm: OPENCODE_DEFAULT_NPM,
options: {},
models: {},
};
}
}
export function parseOpencodeConfigStrict(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
}
export function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record<string, string> {
const extra: Record<string, string> = {};
for (const [k, v] of Object.entries(options || {})) {
if (!isKnownOpencodeOptionKey(k)) {
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
}
}
return extra;
}
export { buildOmoProfilePreview } from "@/types/omo";
export const normalizePricingSource = (
value?: string,
): PricingModelSourceOption =>
value === "request" || value === "response" ? value : "inherit";
@@ -6,9 +6,10 @@ export { useCodexConfigState } from "./useCodexConfigState";
export { useApiKeyLink } from "./useApiKeyLink";
export { useCustomEndpoints } from "./useCustomEndpoints";
export { useTemplateValues } from "./useTemplateValues";
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
export { useCodexCommonConfig } from "./useCodexCommonConfig";
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
export { useCodexTomlValidation } from "./useCodexTomlValidation";
export { useGeminiConfigState } from "./useGeminiConfigState";
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
export { useOmoModelSource } from "./useOmoModelSource";
export { useOpencodeFormState } from "./useOpencodeFormState";
export { useOmoDraftState } from "./useOmoDraftState";
export { useOpenclawFormState } from "./useOpenclawFormState";
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
selectedPresetId: string | null;
category?: ProviderCategory;
appType?: string;
apiKeyField?: string;
}
/**
@@ -24,6 +25,7 @@ export function useApiKeyState({
selectedPresetId,
category,
appType,
apiKeyField,
}: UseApiKeyStateProps) {
const [apiKey, setApiKey] = useState(() => {
if (initialConfig) {
@@ -58,7 +60,7 @@ export function useApiKeyState({
initialConfig || "{}",
key.trim(),
{
// 最佳实践:仅在新增模式”且“非官方类别时补齐缺失字段
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
// - 新增模式:selectedPresetId !== null
// - 非官方类别:category !== undefined && category !== "official"
// - 官方类别:不创建字段(UI 也会禁用输入框)
@@ -68,12 +70,20 @@ export function useApiKeyState({
category !== undefined &&
category !== "official",
appType,
apiKeyField,
},
);
onConfigChange(configString);
},
[initialConfig, selectedPresetId, category, appType, onConfigChange],
[
initialConfig,
selectedPresetId,
category,
appType,
apiKeyField,
onConfigChange,
],
);
const showApiKey = useCallback(
@@ -1,308 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
} from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
interface UseCodexCommonConfigProps {
codexConfig: string;
onConfigChange: (config: string) => void;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
}
/**
* Codex (TOML )
* config.json localStorage
*/
export function useCodexCommonConfig({
codexConfig,
onConfigChange,
initialData,
selectedPresetId,
}: UseCodexCommonConfigProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("codex");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
// 迁移到 config.json
await configApi.setCommonConfigSnippet("codex", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载 Codex 通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, []);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig && !isLoading) {
const config =
typeof initialData.settingsConfig.config === "string"
? initialData.settingsConfig.config
: "";
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
setUseCommonConfig(hasCommon);
}
}, [initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
const lines = commonConfigSnippet.split("\n");
const hasContent = lines.some((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
if (hasContent) {
setUseCommonConfig(true);
// 合并通用配置到当前配置
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (!error) {
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}
}
}, [
initialData,
commonConfigSnippet,
isLoading,
codexConfig,
onConfigChange,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
checked,
);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[codexConfig, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
console.error("保存 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const { updatedConfig } = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
setCommonConfigError("");
// 保存到 config.json
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
console.error("保存 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return;
}
const addResult = updateTomlCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [codexConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("codex", {
settingsConfig: JSON.stringify({
config: codexConfig ?? "",
}),
});
if (!extracted || !extracted.trim()) {
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("codex", extracted);
} catch (error) {
console.error("提取 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [codexConfig, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -1,331 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
updateCommonConfigSnippet,
hasCommonConfigSnippet,
validateJsonConfig,
} from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
interface UseCommonConfigSnippetProps {
settingsConfig: string;
onConfigChange: (config: string) => void;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
/** When false, the hook skips all logic and returns disabled state. Default: true */
enabled?: boolean;
}
/**
* Claude
* config.json localStorage
*/
export function useCommonConfigSnippet({
settingsConfig,
onConfigChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigSnippetProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
}, [selectedPresetId, enabled]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("claude");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
// 迁移到 config.json
await configApi.setCommonConfigSnippet("claude", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [enabled]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}
}, [enabled, initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
if (!enabled) return;
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
// 检查片段是否有实质内容
try {
const snippetObj = JSON.parse(commonConfigSnippet);
const hasContent = Object.keys(snippetObj).length > 0;
if (hasContent) {
setUseCommonConfig(true);
// 合并通用配置到当前配置
const { updatedConfig, error } = updateCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
true,
);
if (!error) {
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}
} catch {
// ignore parse error
}
}
}, [
enabled,
initialData,
commonConfigSnippet,
isLoading,
settingsConfig,
onConfigChange,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
checked,
);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[settingsConfig, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
console.error("保存通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const { updatedConfig } = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// 验证JSON格式
const validationError = validateJsonConfig(value, "通用配置片段");
if (validationError) {
setCommonConfigError(validationError);
} else {
setCommonConfigError("");
// 保存到 config.json
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
console.error("保存通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.saveFailed", { error: String(error) }),
);
});
}
// 若当前启用通用配置且格式正确,需要替换为最新片段
if (useCommonConfig && !validationError) {
const removeResult = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return;
}
const addResult = updateCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (!enabled) return;
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const hasCommon = hasCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("claude", {
settingsConfig,
});
if (!extracted || extracted === "{}") {
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
return;
}
// 验证 JSON 格式
const validationError = validateJsonConfig(extracted, "提取的配置");
if (validationError) {
setCommonConfigError(validationError);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("claude", extracted);
} catch (error) {
console.error("提取通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [settingsConfig, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -1,465 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
] as const;
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
interface UseGeminiCommonConfigProps {
envValue: string;
onEnvChange: (env: string) => void;
envStringToObj: (envString: string) => Record<string, string>;
envObjToString: (envObj: Record<string, unknown>) => string;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* Gemini (JSON )
* Gemini .env
* - GOOGLE_GEMINI_BASE_URL
* - GEMINI_API_KEY
*/
export function useGeminiCommonConfig({
envValue,
onEnvChange,
envStringToObj,
envObjToString,
initialData,
selectedPresetId,
}: UseGeminiCommonConfigProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
const parseSnippetEnv = useCallback(
(
snippetString: string,
): { env: Record<string, string>; error?: string } => {
const trimmed = snippetString.trim();
if (!trimmed) {
return { env: {} };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
if (!isPlainObject(parsed)) {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
if (forbiddenKeys.length > 0) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", {
keys: forbiddenKeys.join(", "),
}),
};
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== "string") {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidValues"),
};
}
const normalized = value.trim();
if (!normalized) continue;
env[key] = normalized;
}
return { env };
},
[t],
);
const hasEnvCommonConfigSnippet = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const entries = Object.entries(snippetEnv);
if (entries.length === 0) return false;
return entries.every(([key, value]) => envObj[key] === value);
},
[],
);
const applySnippetToEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string") {
updated[key] = value;
}
}
return updated;
},
[],
);
const removeSnippetFromEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string" && updated[key] === value) {
delete updated[key];
}
}
return updated;
},
[],
);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("gemini");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
const parsed = parseSnippetEnv(legacySnippet);
if (parsed.error) {
console.warn(
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
);
return;
}
// 迁移到 config.json
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载 Gemini 通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [parseSnippetEnv]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig && !isLoading) {
try {
const env =
isPlainObject(initialData.settingsConfig.env) &&
Object.keys(initialData.settingsConfig.env).length > 0
? (initialData.settingsConfig.env as Record<string, string>)
: {};
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasCommon = hasEnvCommonConfigSnippet(
env,
parsed.env as Record<string, string>,
);
setUseCommonConfig(hasCommon);
} catch {
// ignore parse error
}
}
}, [
commonConfigSnippet,
hasEnvCommonConfigSnippet,
initialData,
isLoading,
parseSnippetEnv,
]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasContent = Object.keys(parsed.env).length > 0;
if (!hasContent) return;
setUseCommonConfig(true);
const currentEnv = envStringToObj(envValue);
const merged = applySnippetToEnv(currentEnv, parsed.env);
const nextEnvString = envObjToString(merged);
isUpdatingFromCommonConfig.current = true;
onEnvChange(nextEnvString);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}, [
initialData,
isLoading,
commonConfigSnippet,
envValue,
envStringToObj,
envObjToString,
applySnippetToEnv,
onEnvChange,
parseSnippetEnv,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) {
setCommonConfigError(parsed.error);
setUseCommonConfig(false);
return;
}
if (Object.keys(parsed.env).length === 0) {
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
setUseCommonConfig(false);
return;
}
const currentEnv = envStringToObj(envValue);
const updatedEnvObj = checked
? applySnippetToEnv(currentEnv, parsed.env)
: removeSnippetFromEnv(currentEnv, parsed.env);
setCommonConfigError("");
setUseCommonConfig(checked);
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(updatedEnvObj));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const parsed = parseSnippetEnv(previousSnippet);
if (!parsed.error && Object.keys(parsed.env).length > 0) {
const currentEnv = envStringToObj(envValue);
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
onEnvChange(envObjToString(updatedEnv));
}
setUseCommonConfig(false);
}
return;
}
// 校验 JSON 格式
const parsed = parseSnippetEnv(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
return;
}
setCommonConfigError("");
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const prevParsed = parseSnippetEnv(previousSnippet);
const prevEnv = prevParsed.error ? {} : prevParsed.env;
const nextEnv = parsed.env;
const currentEnv = envStringToObj(envValue);
const withoutOld =
Object.keys(prevEnv).length > 0
? removeSnippetFromEnv(currentEnv, prevEnv)
: currentEnv;
const withNew =
Object.keys(nextEnv).length > 0
? applySnippetToEnv(withoutOld, nextEnv)
: withoutOld;
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(withNew));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
useCommonConfig,
],
);
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const envObj = envStringToObj(envValue);
setUseCommonConfig(
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
);
}, [
envValue,
commonConfigSnippet,
envStringToObj,
hasEnvCommonConfigSnippet,
isLoading,
parseSnippetEnv,
]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
settingsConfig: JSON.stringify({
env: envStringToObj(envValue),
}),
});
if (!extracted || extracted === "{}") {
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
return;
}
// 验证 JSON 格式
const parsed = parseSnippetEnv(extracted);
if (parsed.error) {
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("gemini", extracted);
} catch (error) {
console.error("提取 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [envStringToObj, envValue, parseSnippetEnv, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -0,0 +1,220 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { OmoGlobalConfig } from "@/types/omo";
import {
mergeOmoConfigPreview,
mergeOmoSlimConfigPreview,
buildOmoSlimProfilePreview,
} from "@/types/omo";
import { type OmoGlobalConfigFieldsRef } from "../OmoGlobalConfigFields";
import * as configApi from "@/lib/api/config";
import {
EMPTY_OMO_GLOBAL_CONFIG,
buildOmoProfilePreview,
} from "../helpers/opencodeFormUtils";
interface UseOmoDraftStateParams {
initialOmoSettings: Record<string, unknown> | undefined;
queriedOmoGlobalConfig: OmoGlobalConfig | undefined;
isEditMode: boolean;
appId: string;
category?: string;
}
export interface OmoDraftState {
omoAgents: Record<string, Record<string, unknown>>;
setOmoAgents: React.Dispatch<
React.SetStateAction<Record<string, Record<string, unknown>>>
>;
omoCategories: Record<string, Record<string, unknown>>;
setOmoCategories: React.Dispatch<
React.SetStateAction<Record<string, Record<string, unknown>>>
>;
omoOtherFieldsStr: string;
setOmoOtherFieldsStr: React.Dispatch<React.SetStateAction<string>>;
useOmoCommonConfig: boolean;
setUseOmoCommonConfig: React.Dispatch<React.SetStateAction<boolean>>;
isOmoConfigModalOpen: boolean;
setIsOmoConfigModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
isOmoSaving: boolean;
omoGlobalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
omoFieldsKey: number;
effectiveOmoGlobalConfig: OmoGlobalConfig;
mergedOmoJsonPreview: string;
handleOmoGlobalConfigSave: () => Promise<void>;
handleOmoEditClick: () => void;
resetOmoDraftState: (useCommonConfig?: boolean) => void;
setOmoGlobalState: React.Dispatch<
React.SetStateAction<OmoGlobalConfig | null>
>;
}
export function useOmoDraftState({
initialOmoSettings,
queriedOmoGlobalConfig,
isEditMode,
appId,
category,
}: UseOmoDraftStateParams): OmoDraftState {
const { t } = useTranslation();
const isSlim = category === "omo-slim";
const commonConfigKey = isSlim ? "omo_slim" : "omo";
const [omoAgents, setOmoAgents] = useState<
Record<string, Record<string, unknown>>
>(
() =>
(initialOmoSettings?.agents as Record<string, Record<string, unknown>>) ||
{},
);
const [omoCategories, setOmoCategories] = useState<
Record<string, Record<string, unknown>>
>(
() =>
(initialOmoSettings?.categories as Record<
string,
Record<string, unknown>
>) || {},
);
const [omoOtherFieldsStr, setOmoOtherFieldsStr] = useState(() => {
const otherFields = initialOmoSettings?.otherFields;
return otherFields ? JSON.stringify(otherFields, null, 2) : "";
});
const [omoGlobalState, setOmoGlobalState] = useState<OmoGlobalConfig | null>(
null,
);
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => {
const raw = initialOmoSettings?.useCommonConfig;
return typeof raw === "boolean" ? raw : true;
});
const [isOmoSaving, setIsOmoSaving] = useState(false);
const omoGlobalConfigRef = useRef<OmoGlobalConfigFieldsRef>(null);
const [omoFieldsKey, setOmoFieldsKey] = useState(0);
const effectiveOmoGlobalConfig =
omoGlobalState ?? queriedOmoGlobalConfig ?? EMPTY_OMO_GLOBAL_CONFIG;
const mergedOmoJsonPreview = useMemo(() => {
if (useOmoCommonConfig) {
if (isSlim) {
const merged = mergeOmoSlimConfigPreview(
effectiveOmoGlobalConfig,
omoAgents,
omoOtherFieldsStr,
);
return JSON.stringify(merged, null, 2);
}
const merged = mergeOmoConfigPreview(
effectiveOmoGlobalConfig,
omoAgents,
omoCategories,
omoOtherFieldsStr,
);
return JSON.stringify(merged, null, 2);
} else {
if (isSlim) {
return JSON.stringify(
buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),
null,
2,
);
}
return JSON.stringify(
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
null,
2,
);
}
}, [
useOmoCommonConfig,
effectiveOmoGlobalConfig,
omoAgents,
omoCategories,
omoOtherFieldsStr,
isSlim,
]);
// Auto-detect whether common config has content for new OMO/OMO Slim profiles
useEffect(() => {
if (
appId !== "opencode" ||
(category !== "omo" && category !== "omo-slim") ||
isEditMode
)
return;
let active = true;
(async () => {
let next = false;
try {
const raw = await configApi.getCommonConfigSnippet(commonConfigKey);
if (raw) {
const parsed = JSON.parse(raw) as Record<string, unknown>;
next = Object.keys(parsed).some(
(k) => k !== "id" && k !== "updatedAt",
);
}
} catch {}
if (active) setUseOmoCommonConfig(next);
})();
return () => {
active = false;
};
}, [appId, category, isEditMode, commonConfigKey]);
const handleOmoGlobalConfigSave = useCallback(async () => {
if (!omoGlobalConfigRef.current) return;
setIsOmoSaving(true);
try {
const config = omoGlobalConfigRef.current.buildCurrentConfigStrict();
await configApi.setCommonConfigSnippet(
commonConfigKey,
JSON.stringify(config),
);
setIsOmoConfigModalOpen(false);
toast.success(
t("omo.globalConfigSaved", { defaultValue: "Global config saved" }),
);
} catch (err) {
toast.error(String(err));
} finally {
setIsOmoSaving(false);
}
}, [t, commonConfigKey]);
const handleOmoEditClick = useCallback(() => {
setOmoFieldsKey((k) => k + 1);
setIsOmoConfigModalOpen(true);
}, []);
const resetOmoDraftState = useCallback((useCommonConfig = true) => {
setOmoAgents({});
setOmoCategories({});
setOmoOtherFieldsStr("");
setUseOmoCommonConfig(useCommonConfig);
}, []);
return {
omoAgents,
setOmoAgents,
omoCategories,
setOmoCategories,
omoOtherFieldsStr,
setOmoOtherFieldsStr,
useOmoCommonConfig,
setUseOmoCommonConfig,
isOmoConfigModalOpen,
setIsOmoConfigModalOpen,
isOmoSaving,
omoGlobalConfigRef,
omoFieldsKey,
effectiveOmoGlobalConfig,
mergedOmoJsonPreview,
handleOmoGlobalConfigSave,
handleOmoEditClick,
resetOmoDraftState,
setOmoGlobalState,
};
}
@@ -0,0 +1,280 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { providersApi } from "@/lib/api";
import { useProvidersQuery } from "@/lib/query/queries";
import type { OpenCodeProviderConfig } from "@/types";
import { OPENCODE_PRESET_MODEL_VARIANTS } from "@/config/opencodeProviderPresets";
import { parseOpencodeConfigStrict } from "../helpers/opencodeFormUtils";
interface UseOmoModelSourceParams {
isOmoCategory: boolean;
providerId?: string;
}
interface OmoModelBuild {
options: Array<{ value: string; label: string }>;
variantsMap: Record<string, string[]>;
presetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>;
parseFailedProviders: string[];
usedFallbackSource: boolean;
}
export interface OmoModelSourceResult {
omoModelOptions: Array<{ value: string; label: string }>;
omoModelVariantsMap: Record<string, string[]>;
omoPresetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>;
existingOpencodeKeys: string[];
}
export function useOmoModelSource({
isOmoCategory,
providerId,
}: UseOmoModelSourceParams): OmoModelSourceResult {
const { t } = useTranslation();
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
const existingOpencodeKeys = useMemo(() => {
if (!opencodeProvidersData?.providers) return [];
return Object.keys(opencodeProvidersData.providers).filter(
(k) => k !== providerId,
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
string[] | null
>(null);
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
const lastOmoModelSourceWarningRef = useRef<string>("");
useEffect(() => {
let active = true;
if (!isOmoCategory) {
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
return () => {
active = false;
};
}
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
if (active) {
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
console.warn(
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
error,
);
if (active) {
setOmoLiveIdsLoadFailed(true);
setEnabledOpencodeProviderIds(null);
}
}
})();
return () => {
active = false;
};
}, [isOmoCategory]);
const omoModelBuild = useMemo<OmoModelBuild>(() => {
const empty: OmoModelBuild = {
options: [],
variantsMap: {},
presetMetaMap: {},
parseFailedProviders: [],
usedFallbackSource: false,
};
if (!isOmoCategory) {
return empty;
}
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return empty;
}
const shouldFilterByLive = !omoLiveIdsLoadFailed;
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
return empty;
}
const liveSet =
shouldFilterByLive && enabledOpencodeProviderIds
? new Set(enabledOpencodeProviderIds)
: null;
const dedupedOptions = new Map<string, string>();
const variantsMap: Record<string, string[]> = {};
const presetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
> = {};
const parseFailedProviders: string[] = [];
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || provider.category === "omo-slim") {
continue;
}
if (liveSet && !liveSet.has(providerKey)) {
continue;
}
let parsedConfig: OpenCodeProviderConfig;
try {
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
} catch (error) {
parseFailedProviders.push(providerKey);
console.warn(
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
{
providerKey,
error,
},
);
continue;
}
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
const modelName =
typeof model.name === "string" && model.name.trim()
? model.name
: modelId;
const providerDisplayName =
typeof provider.name === "string" && provider.name.trim()
? provider.name
: providerKey;
const value = `${providerKey}/${modelId}`;
const label = `${providerDisplayName} / ${modelName} (${modelId})`;
if (!dedupedOptions.has(value)) {
dedupedOptions.set(value, label);
}
const rawVariants = model.variants;
if (
rawVariants &&
typeof rawVariants === "object" &&
!Array.isArray(rawVariants)
) {
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length > 0) {
variantsMap[value] = variantKeys;
}
}
}
// Preset fallback: for models without config-defined variants,
// check if the npm package has preset variant definitions.
// Also collect preset metadata (options, limit) for enrichment.
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
if (presetModels) {
for (const modelId of Object.keys(parsedConfig.models || {})) {
const fullKey = `${providerKey}/${modelId}`;
const preset = presetModels.find((p) => p.id === modelId);
if (!preset) continue;
// Variant fallback
if (!variantsMap[fullKey] && preset.variants) {
const presetKeys = Object.keys(preset.variants).filter(Boolean);
if (presetKeys.length > 0) {
variantsMap[fullKey] = presetKeys;
}
}
// Collect preset metadata for model enrichment
const meta: (typeof presetMetaMap)[string] = {};
if (preset.options) meta.options = preset.options;
if (preset.contextLimit || preset.outputLimit) {
meta.limit = {};
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
}
if (Object.keys(meta).length > 0) {
presetMetaMap[fullKey] = meta;
}
}
}
}
return {
options: Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
variantsMap,
presetMetaMap,
parseFailedProviders,
usedFallbackSource: omoLiveIdsLoadFailed,
};
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
omoLiveIdsLoadFailed,
]);
// Warning toast for parse failures / fallback
useEffect(() => {
if (!isOmoCategory) return;
const failed = omoModelBuild.parseFailedProviders;
const fallback = omoModelBuild.usedFallbackSource;
if (failed.length === 0 && !fallback) return;
const signature = `${fallback ? "fallback:" : ""}${failed
.slice()
.sort()
.join(",")}`;
if (lastOmoModelSourceWarningRef.current === signature) return;
lastOmoModelSourceWarningRef.current = signature;
if (failed.length > 0) {
toast.warning(
t("omo.modelSourcePartialWarning", {
count: failed.length,
defaultValue:
"Some provider model configs are invalid and were skipped.",
}),
);
}
if (fallback) {
toast.warning(
t("omo.modelSourceFallbackWarning", {
defaultValue:
"Failed to load live provider state. Falling back to configured providers.",
}),
);
}
}, [
isOmoCategory,
omoModelBuild.parseFailedProviders,
omoModelBuild.usedFallbackSource,
t,
]);
return {
omoModelOptions: omoModelBuild.options,
omoModelVariantsMap: omoModelBuild.variantsMap,
omoPresetMetaMap: omoModelBuild.presetMetaMap,
existingOpencodeKeys,
};
}
@@ -0,0 +1,180 @@
import { useState, useCallback, useMemo } from "react";
import type { OpenClawModel } from "@/types";
import type { AppId } from "@/lib/api";
import { useProvidersQuery } from "@/lib/query/queries";
import { OPENCLAW_DEFAULT_CONFIG } from "../helpers/opencodeFormUtils";
interface UseOpenclawFormStateParams {
initialData?: {
settingsConfig?: Record<string, unknown>;
};
appId: AppId;
providerId?: string;
onSettingsConfigChange: (config: string) => void;
getSettingsConfig: () => string;
}
export interface OpenclawFormState {
openclawProviderKey: string;
setOpenclawProviderKey: (key: string) => void;
openclawBaseUrl: string;
openclawApiKey: string;
openclawApi: string;
openclawModels: OpenClawModel[];
existingOpenclawKeys: string[];
handleOpenclawBaseUrlChange: (baseUrl: string) => void;
handleOpenclawApiKeyChange: (apiKey: string) => void;
handleOpenclawApiChange: (api: string) => void;
handleOpenclawModelsChange: (models: OpenClawModel[]) => void;
resetOpenclawState: (config?: {
baseUrl?: string;
apiKey?: string;
api?: string;
models?: OpenClawModel[];
}) => void;
}
function parseOpenclawField<T>(
initialData: UseOpenclawFormStateParams["initialData"],
field: string,
fallback: T,
): T {
try {
const config = JSON.parse(
initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig)
: OPENCLAW_DEFAULT_CONFIG,
);
return (config[field] as T) || fallback;
} catch {
return fallback;
}
}
export function useOpenclawFormState({
initialData,
appId,
providerId,
onSettingsConfigChange,
getSettingsConfig,
}: UseOpenclawFormStateParams): OpenclawFormState {
// Query existing providers for duplicate key checking
const { data: openclawProvidersData } = useProvidersQuery("openclaw");
const existingOpenclawKeys = useMemo(() => {
if (!openclawProvidersData?.providers) return [];
return Object.keys(openclawProvidersData.providers).filter(
(k) => k !== providerId,
);
}, [openclawProvidersData?.providers, providerId]);
const [openclawProviderKey, setOpenclawProviderKey] = useState<string>(() => {
if (appId !== "openclaw") return "";
return providerId || "";
});
const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {
if (appId !== "openclaw") return "";
return parseOpenclawField(initialData, "baseUrl", "");
});
const [openclawApiKey, setOpenclawApiKey] = useState<string>(() => {
if (appId !== "openclaw") return "";
return parseOpenclawField(initialData, "apiKey", "");
});
const [openclawApi, setOpenclawApi] = useState<string>(() => {
if (appId !== "openclaw") return "openai-completions";
return parseOpenclawField(initialData, "api", "openai-completions");
});
const [openclawModels, setOpenclawModels] = useState<OpenClawModel[]>(() => {
if (appId !== "openclaw") return [];
return parseOpenclawField<OpenClawModel[]>(initialData, "models", []);
});
const updateOpenclawConfig = useCallback(
(updater: (config: Record<string, any>) => void) => {
try {
const config = JSON.parse(
getSettingsConfig() || OPENCLAW_DEFAULT_CONFIG,
);
updater(config);
onSettingsConfigChange(JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[getSettingsConfig, onSettingsConfigChange],
);
const handleOpenclawBaseUrlChange = useCallback(
(baseUrl: string) => {
setOpenclawBaseUrl(baseUrl);
updateOpenclawConfig((config) => {
config.baseUrl = baseUrl.trim().replace(/\/+$/, "");
});
},
[updateOpenclawConfig],
);
const handleOpenclawApiKeyChange = useCallback(
(apiKey: string) => {
setOpenclawApiKey(apiKey);
updateOpenclawConfig((config) => {
config.apiKey = apiKey;
});
},
[updateOpenclawConfig],
);
const handleOpenclawApiChange = useCallback(
(api: string) => {
setOpenclawApi(api);
updateOpenclawConfig((config) => {
config.api = api;
});
},
[updateOpenclawConfig],
);
const handleOpenclawModelsChange = useCallback(
(models: OpenClawModel[]) => {
setOpenclawModels(models);
updateOpenclawConfig((config) => {
config.models = models;
});
},
[updateOpenclawConfig],
);
const resetOpenclawState = useCallback(
(config?: {
baseUrl?: string;
apiKey?: string;
api?: string;
models?: OpenClawModel[];
}) => {
setOpenclawProviderKey("");
setOpenclawBaseUrl(config?.baseUrl || "");
setOpenclawApiKey(config?.apiKey || "");
setOpenclawApi(config?.api || "openai-completions");
setOpenclawModels(config?.models || []);
},
[],
);
return {
openclawProviderKey,
setOpenclawProviderKey,
openclawBaseUrl,
openclawApiKey,
openclawApi,
openclawModels,
existingOpenclawKeys,
handleOpenclawBaseUrlChange,
handleOpenclawApiKeyChange,
handleOpenclawApiChange,
handleOpenclawModelsChange,
resetOpenclawState,
};
}
@@ -0,0 +1,192 @@
import { useState, useCallback } from "react";
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
import {
OPENCODE_DEFAULT_NPM,
OPENCODE_DEFAULT_CONFIG,
isKnownOpencodeOptionKey,
parseOpencodeConfig,
toOpencodeExtraOptions,
} from "../helpers/opencodeFormUtils";
interface UseOpencodeFormStateParams {
initialData?: {
settingsConfig?: Record<string, unknown>;
};
appId: string;
providerId?: string;
onSettingsConfigChange: (config: string) => void;
getSettingsConfig: () => string;
}
export interface OpencodeFormState {
opencodeProviderKey: string;
setOpencodeProviderKey: (key: string) => void;
opencodeNpm: string;
opencodeApiKey: string;
opencodeBaseUrl: string;
opencodeModels: Record<string, OpenCodeModel>;
opencodeExtraOptions: Record<string, string>;
handleOpencodeNpmChange: (npm: string) => void;
handleOpencodeApiKeyChange: (apiKey: string) => void;
handleOpencodeBaseUrlChange: (baseUrl: string) => void;
handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;
handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;
resetOpencodeState: (config?: OpenCodeProviderConfig) => void;
}
export function useOpencodeFormState({
initialData,
appId,
providerId,
onSettingsConfigChange,
getSettingsConfig,
}: UseOpencodeFormStateParams): OpencodeFormState {
const initialOpencodeConfig =
appId === "opencode"
? parseOpencodeConfig(initialData?.settingsConfig)
: null;
const initialOpencodeOptions = initialOpencodeConfig?.options || {};
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
if (appId !== "opencode") return "";
return providerId || "";
});
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
});
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
if (appId !== "opencode") return "";
const value = initialOpencodeOptions.apiKey;
return typeof value === "string" ? value : "";
});
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
if (appId !== "opencode") return "";
const value = initialOpencodeOptions.baseURL;
return typeof value === "string" ? value : "";
});
const [opencodeModels, setOpencodeModels] = useState<
Record<string, OpenCodeModel>
>(() => {
if (appId !== "opencode") return {};
return initialOpencodeConfig?.models || {};
});
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<
Record<string, string>
>(() => {
if (appId !== "opencode") return {};
return toOpencodeExtraOptions(initialOpencodeOptions);
});
const updateOpencodeSettings = useCallback(
(updater: (config: Record<string, any>) => void) => {
try {
const config = JSON.parse(
getSettingsConfig() || OPENCODE_DEFAULT_CONFIG,
) as Record<string, any>;
updater(config);
onSettingsConfigChange(JSON.stringify(config, null, 2));
} catch {}
},
[getSettingsConfig, onSettingsConfigChange],
);
const handleOpencodeNpmChange = useCallback(
(npm: string) => {
setOpencodeNpm(npm);
updateOpencodeSettings((config) => {
config.npm = npm;
});
},
[updateOpencodeSettings],
);
const handleOpencodeApiKeyChange = useCallback(
(apiKey: string) => {
setOpencodeApiKey(apiKey);
updateOpencodeSettings((config) => {
if (!config.options) config.options = {};
config.options.apiKey = apiKey;
});
},
[updateOpencodeSettings],
);
const handleOpencodeBaseUrlChange = useCallback(
(baseUrl: string) => {
setOpencodeBaseUrl(baseUrl);
updateOpencodeSettings((config) => {
if (!config.options) config.options = {};
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
});
},
[updateOpencodeSettings],
);
const handleOpencodeModelsChange = useCallback(
(models: Record<string, OpenCodeModel>) => {
setOpencodeModels(models);
updateOpencodeSettings((config) => {
config.models = models;
});
},
[updateOpencodeSettings],
);
const handleOpencodeExtraOptionsChange = useCallback(
(options: Record<string, string>) => {
setOpencodeExtraOptions(options);
updateOpencodeSettings((config) => {
if (!config.options) config.options = {};
for (const k of Object.keys(config.options)) {
if (!isKnownOpencodeOptionKey(k)) {
delete config.options[k];
}
}
for (const [k, v] of Object.entries(options)) {
const trimmedKey = k.trim();
if (trimmedKey && !trimmedKey.startsWith("option-")) {
try {
config.options[trimmedKey] = JSON.parse(v);
} catch {
config.options[trimmedKey] = v;
}
}
}
});
},
[updateOpencodeSettings],
);
const resetOpencodeState = useCallback((config?: OpenCodeProviderConfig) => {
setOpencodeProviderKey("");
setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);
setOpencodeBaseUrl(config?.options?.baseURL || "");
setOpencodeApiKey(config?.options?.apiKey || "");
setOpencodeModels(config?.models || {});
setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));
}, []);
return {
opencodeProviderKey,
setOpencodeProviderKey,
opencodeNpm,
opencodeApiKey,
opencodeBaseUrl,
opencodeModels,
opencodeExtraOptions,
handleOpencodeNpmChange,
handleOpencodeApiKeyChange,
handleOpencodeBaseUrlChange,
handleOpencodeModelsChange,
handleOpencodeExtraOptionsChange,
resetOpencodeState,
};
}
@@ -1,11 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Zap, AlertTriangle, Server, Unplug } from "lucide-react";
import { proxyApi, type UrlPreview } from "@/lib/api/proxy";
type AppType = "claude" | "codex" | "gemini";
import { Zap } from "lucide-react";
interface EndpointFieldProps {
id: string;
@@ -17,11 +13,6 @@ interface EndpointFieldProps {
showManageButton?: boolean;
onManageClick?: () => void;
manageButtonLabel?: string;
// 应用类型和 API 格式
appType?: AppType;
apiFormat?: string;
// 是否显示请求地址预览
showUrlPreview?: boolean;
}
export function EndpointField({
@@ -34,48 +25,13 @@ export function EndpointField({
showManageButton = true,
onManageClick,
manageButtonLabel,
appType,
apiFormat,
showUrlPreview = true,
}: EndpointFieldProps) {
const { t } = useTranslation();
const [urlPreview, setUrlPreview] = useState<UrlPreview | null>(null);
const lastRequestIdRef = useRef(0);
const defaultManageLabel = t("providerForm.manageAndTest", {
defaultValue: "管理和测速",
});
// 调用后端 API 获取 URL 预览
useEffect(() => {
if (!value || !appType || !showUrlPreview) {
// 标记当前所有已发出的请求为过期,避免旧请求回写
lastRequestIdRef.current += 1;
setUrlPreview(null);
return;
}
// 防抖:延迟 300ms 后请求
const timer = setTimeout(async () => {
const requestId = ++lastRequestIdRef.current;
try {
const preview = await proxyApi.buildUrlPreview(
appType,
value,
apiFormat,
);
if (requestId !== lastRequestIdRef.current) return;
setUrlPreview(preview);
} catch (error) {
console.error("Failed to build URL preview:", error);
if (requestId !== lastRequestIdRef.current) return;
setUrlPreview(null);
}
}, 300);
return () => clearTimeout(timer);
}, [value, appType, apiFormat, showUrlPreview]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -99,68 +55,6 @@ export function EndpointField({
placeholder={placeholder}
autoComplete="off"
/>
{/* 请求地址预览 */}
{showUrlPreview && urlPreview && (
<div className="p-2 bg-muted/50 border border-border rounded-md space-y-2">
{/* CLI 直连请求地址 */}
<div>
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
<Unplug className="h-3 w-3" />
{t("providerForm.directRequestUrl", {
defaultValue: "CLI 直连请求地址:",
})}
</p>
<p className="text-xs font-mono text-foreground break-all pl-4">
{urlPreview.direct_url}
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
{t("providerForm.directRequestUrlDesc", {
defaultValue: "CLI 直连模式下的实际请求地址",
})}
</p>
</div>
{/* CCS 代理请求地址 */}
<div className="pt-1.5 border-t border-border/50">
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
<Server className="h-3 w-3" />
{t("providerForm.proxyRequestUrl", {
defaultValue: "CCS 代理请求地址:",
})}
</p>
<p className="text-xs font-mono text-foreground break-all pl-4">
{urlPreview.proxy_url}
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
{t("providerForm.proxyRequestUrlDesc", {
defaultValue: "CCS 智能拼接后转发到上游的地址",
})}
</p>
</div>
</div>
)}
{/* 全链接警告 */}
{urlPreview?.is_full_url && (
<div className="flex items-start gap-2 p-2 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-md">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-orange-600 dark:text-orange-400 font-medium">
{t("providerForm.fullUrlWarningTitle", {
defaultValue: "检测到完整 API 路径",
})}
</p>
<p className="text-xs text-orange-600/80 dark:text-orange-400/80 mt-0.5">
{t("providerForm.fullUrlWarning", {
defaultValue:
"填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。",
})}
</p>
</div>
</div>
)}
{hint ? (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
-3
View File
@@ -408,9 +408,6 @@ export function ProxyPanel() {
</div>
) : (
<div className="space-y-6">
{/* 空白区域避免冲突 */}
<div className="h-4"></div>
{/* 基础设置 - 监听地址/端口 */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
+3 -67
View File
@@ -6,91 +6,27 @@
*/
import { Radio, Loader2 } from "lucide-react";
import { useState } from "react";
import { Switch } from "@/components/ui/switch";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { AppId } from "@/lib/api";
import { proxyApi } from "@/lib/api/proxy";
import type { Provider } from "@/types";
import { extractProviderBaseUrl } from "@/utils/providerBaseUrl";
interface ProxyToggleProps {
className?: string;
activeApp: AppId;
currentProvider?: Provider | null;
}
export function ProxyToggle({
className,
activeApp,
currentProvider,
}: ProxyToggleProps) {
export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
const { t } = useTranslation();
const [isCheckingRequirement, setIsCheckingRequirement] = useState(false);
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
useProxyStatus();
const handleToggle = async (checked: boolean) => {
// 关闭代理时,检查当前供应商是否是全链接配置
if (
!checked &&
currentProvider &&
currentProvider.category !== "official"
) {
const baseUrl = extractProviderBaseUrl(currentProvider, activeApp);
const apiFormat = currentProvider.meta?.apiFormat;
setIsCheckingRequirement(true);
try {
let proxyRequirement: string | null = null;
if (baseUrl || apiFormat) {
proxyRequirement = await proxyApi.checkProxyRequirement(
activeApp,
baseUrl || "",
apiFormat,
);
}
if (proxyRequirement) {
const warningKey =
proxyRequirement === "openai_chat_format"
? "notifications.openAIChatFormatWarningOnDisable"
: proxyRequirement === "url_mismatch"
? "notifications.urlMismatchWarningOnDisable"
: "notifications.fullUrlWarningOnDisable";
toast.warning(
t(warningKey, {
defaultValue:
"当前供应商配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
}),
{ duration: 6000, closeButton: true },
);
}
} catch (error) {
console.error("Failed to check proxy requirement:", error);
} finally {
setIsCheckingRequirement(false);
}
}
try {
await setTakeoverForApp({ appType: activeApp, enabled: checked });
} catch (error) {
console.error("[ProxyToggle] Toggle takeover failed:", error);
toast.error(
t("proxy.takeover.toggleFailed", {
defaultValue: "切换接管状态失败",
}),
{
description: t("proxy.takeover.toggleFailedDesc", {
defaultValue: "请检查代理服务状态与权限,然后重试。",
}),
closeButton: true,
},
);
}
};
@@ -125,7 +61,7 @@ export function ProxyToggle({
)}
title={tooltipText}
>
{isPending || isCheckingRequirement ? (
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
@@ -140,7 +76,7 @@ export function ProxyToggle({
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending || isCheckingRequirement}
disabled={isPending}
/>
</div>
);
+42 -6
View File
@@ -46,9 +46,15 @@ import {
getSessionKey,
} from "./utils";
type ProviderFilter = "all" | "codex" | "claude";
type ProviderFilter =
| "all"
| "codex"
| "claude"
| "opencode"
| "openclaw"
| "gemini";
export function SessionManagerPage() {
export function SessionManagerPage({ appId }: { appId: string }) {
const { t } = useTranslation();
const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? [];
@@ -63,7 +69,9 @@ export function SessionManagerPage() {
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = useState("");
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
const [providerFilter, setProviderFilter] = useState<ProviderFilter>(
appId as ProviderFilter,
);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
// 使用 FlexSearch 全文搜索
@@ -265,9 +273,7 @@ export function SessionManagerPage() {
icon={
providerFilter === "all"
? "apps"
: providerFilter === "codex"
? "openai"
: "claude"
: getProviderIconName(providerFilter)
}
name={providerFilter}
size={14}
@@ -309,6 +315,36 @@ export function SessionManagerPage() {
<span>Claude Code</span>
</div>
</SelectItem>
<SelectItem value="opencode">
<div className="flex items-center gap-2">
<ProviderIcon
icon="opencode"
name="opencode"
size={14}
/>
<span>OpenCode</span>
</div>
</SelectItem>
<SelectItem value="openclaw">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openclaw"
name="openclaw"
size={14}
/>
<span>OpenClaw</span>
</div>
</SelectItem>
<SelectItem value="gemini">
<div className="flex items-center gap-2">
<ProviderIcon
icon="gemini"
name="gemini"
size={14}
/>
<span>Gemini CLI</span>
</div>
</SelectItem>
</SelectContent>
</Select>
+2
View File
@@ -48,6 +48,8 @@ export const getProviderLabel = (
export const getProviderIconName = (providerId: string) => {
if (providerId === "codex") return "openai";
if (providerId === "claude") return "claude";
if (providerId === "opencode") return "opencode";
if (providerId === "openclaw") return "openclaw";
return providerId;
};
+199 -19
View File
@@ -11,6 +11,13 @@ import {
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getVersion } from "@tauri-apps/api/app";
@@ -30,8 +37,48 @@ interface ToolVersion {
version: string | null;
latest_version: string | null;
error: string | null;
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
wsl_distro: string | null;
}
const TOOL_NAMES = ["claude", "codex", "gemini", "opencode"] as const;
type ToolName = (typeof TOOL_NAMES)[number];
type WslShellPreference = {
wslShell?: string | null;
wslShellFlag?: string | null;
};
const WSL_SHELL_OPTIONS = ["sh", "bash", "zsh", "fish", "dash"] as const;
// UI-friendly order: login shell first.
const WSL_SHELL_FLAG_OPTIONS = ["-lic", "-lc", "-c"] as const;
const ENV_BADGE_CONFIG: Record<
string,
{ labelKey: string; className: string }
> = {
wsl: {
labelKey: "settings.envBadge.wsl",
className:
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
},
windows: {
labelKey: "settings.envBadge.windows",
className:
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
},
macos: {
labelKey: "settings.envBadge.macos",
className:
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20",
},
linux: {
labelKey: "settings.envBadge.linux",
className:
"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
},
};
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
curl -fsSL https://claude.ai/install.sh | bash
# Codex
@@ -59,30 +106,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
isChecking,
} = useUpdate();
const loadToolVersions = useCallback(async () => {
const [wslShellByTool, setWslShellByTool] = useState<
Record<string, WslShellPreference>
>({});
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
const refreshToolVersions = useCallback(
async (
toolNames: ToolName[],
wslOverrides?: Record<string, WslShellPreference>,
) => {
if (toolNames.length === 0) return;
// 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。
setLoadingTools((prev) => {
const next = { ...prev };
for (const name of toolNames) next[name] = true;
return next;
});
try {
const updated = await settingsApi.getToolVersions(
toolNames,
wslOverrides,
);
setToolVersions((prev) => {
if (prev.length === 0) return updated;
const byName = new Map(updated.map((t) => [t.name, t]));
const merged = prev.map((t) => byName.get(t.name) ?? t);
const existing = new Set(prev.map((t) => t.name));
for (const u of updated) {
if (!existing.has(u.name)) merged.push(u);
}
return merged;
});
} catch (error) {
console.error("[AboutSection] Failed to refresh tools", error);
} finally {
setLoadingTools((prev) => {
const next = { ...prev };
for (const name of toolNames) next[name] = false;
return next;
});
}
},
[],
);
const loadAllToolVersions = useCallback(async () => {
setIsLoadingTools(true);
try {
const tools = await settingsApi.getToolVersions();
setToolVersions(tools);
// Respect current UI overrides (shell / flag) when doing a full refresh.
const versions = await settingsApi.getToolVersions(
[...TOOL_NAMES],
wslShellByTool,
);
setToolVersions(versions);
} catch (error) {
console.error("[AboutSection] Failed to load tool versions", error);
} finally {
setIsLoadingTools(false);
}
}, []);
}, [wslShellByTool]);
const handleToolShellChange = async (toolName: ToolName, value: string) => {
const wslShell = value === "auto" ? null : value;
const nextPref: WslShellPreference = {
...(wslShellByTool[toolName] ?? {}),
wslShell,
};
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
await refreshToolVersions([toolName], { [toolName]: nextPref });
};
const handleToolShellFlagChange = async (
toolName: ToolName,
value: string,
) => {
const wslShellFlag = value === "auto" ? null : value;
const nextPref: WslShellPreference = {
...(wslShellByTool[toolName] ?? {}),
wslShellFlag,
};
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
await refreshToolVersions([toolName], { [toolName]: nextPref });
};
useEffect(() => {
let active = true;
const load = async () => {
try {
const [appVersion, tools] = await Promise.all([
const [appVersion] = await Promise.all([
getVersion(),
settingsApi.getToolVersions(),
loadAllToolVersions(),
]);
if (active) {
setVersion(appVersion);
setToolVersions(tools);
}
} catch (error) {
console.error("[AboutSection] Failed to load info", error);
@@ -92,7 +213,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
} finally {
if (active) {
setIsLoadingVersion(false);
setIsLoadingTools(false);
}
}
};
@@ -101,6 +221,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
return () => {
active = false;
};
// Mount-only: loadAllToolVersions is intentionally excluded to avoid
// re-fetching all tools whenever wslShellByTool changes. Single-tool
// refreshes are handled by refreshToolVersions in the shell/flag handlers.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
@@ -306,7 +430,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
onClick={loadToolVersions}
onClick={() => loadAllToolVersions()}
disabled={isLoadingTools}
>
<RefreshCw
@@ -317,8 +441,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
{TOOL_NAMES.map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
// Special case for OpenCode (capital C), others use capitalize
const displayName =
@@ -340,19 +465,74 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{displayName}</span>
{/* Environment Badge */}
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}
>
{t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}
</span>
)}
{/* WSL Shell Selector */}
{tool?.env_type === "wsl" && (
<Select
value={wslShellByTool[toolName]?.wslShell || "auto"}
onValueChange={(v) =>
handleToolShellChange(toolName, v)
}
disabled={isLoadingTools || loadingTools[toolName]}
>
<SelectTrigger className="h-6 w-[70px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t("common.auto")}
</SelectItem>
{WSL_SHELL_OPTIONS.map((shell) => (
<SelectItem key={shell} value={shell}>
{shell}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* WSL Shell Flag Selector */}
{tool?.env_type === "wsl" && (
<Select
value={wslShellByTool[toolName]?.wslShellFlag || "auto"}
onValueChange={(v) =>
handleToolShellFlagChange(toolName, v)
}
disabled={isLoadingTools || loadingTools[toolName]}
>
<SelectTrigger className="h-6 w-[70px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t("common.auto")}
</SelectItem>
{WSL_SHELL_FLAG_OPTIONS.map((flag) => (
<SelectItem key={flag} value={flag}>
{flag}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isLoadingTools ? (
{isLoadingTools || loadingTools[toolName] ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : tool?.version ? (
<div className="flex items-center gap-1.5">
{tool.latest_version &&
tool.version !== tool.latest_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
{tool.latest_version}
</span>
)}
tool.latest_version &&
tool.version !== tool.latest_version ? (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
{tool.latest_version}
</span>
) : (
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
)
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
@@ -0,0 +1,373 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Pencil, RotateCcw, Check, X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useBackupManager } from "@/hooks/useBackupManager";
import { extractErrorMessage } from "@/utils/errorUtils";
interface BackupListSectionProps {
backupIntervalHours?: number;
backupRetainCount?: number;
onSettingsChange: (updates: {
backupIntervalHours?: number;
backupRetainCount?: number;
}) => void;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
/** Parse display name from backup filename */
function getDisplayName(filename: string): string {
// Try to parse db_backup_YYYYMMDD_HHMMSS format
const match = filename.match(
/^db_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_\d+)?\.db$/,
);
if (match) {
const [, y, m, d, hh, mm, ss] = match;
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
// Otherwise show filename without .db suffix
return filename.replace(/\.db$/, "");
}
export function BackupListSection({
backupIntervalHours,
backupRetainCount,
onSettingsChange,
}: BackupListSectionProps) {
const { t } = useTranslation();
const { backups, isLoading, restore, isRestoring, rename, isRenaming } =
useBackupManager();
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const handleRestore = async () => {
if (!confirmFilename) return;
try {
const safetyId = await restore(confirmFilename);
setConfirmFilename(null);
toast.success(
t("settings.backupManager.restoreSuccess", {
defaultValue: "Restore successful! Safety backup created",
}),
{
description: safetyId
? `${t("settings.backupManager.safetyBackupId", { defaultValue: "Safety Backup ID" })}: ${safetyId}`
: undefined,
duration: 6000,
closeButton: true,
},
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.restoreFailed", {
defaultValue: "Restore failed",
});
toast.error(detail);
}
};
const handleStartRename = (filename: string) => {
setEditingFilename(filename);
setEditValue(getDisplayName(filename));
};
const handleCancelRename = () => {
setEditingFilename(null);
setEditValue("");
};
const handleConfirmRename = async () => {
if (!editingFilename || !editValue.trim()) return;
try {
await rename({ oldFilename: editingFilename, newName: editValue.trim() });
setEditingFilename(null);
setEditValue("");
toast.success(
t("settings.backupManager.renameSuccess", {
defaultValue: "Backup renamed",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.renameFailed", {
defaultValue: "Rename failed",
});
toast.error(detail);
}
};
const intervalValue = String(backupIntervalHours ?? 24);
const retainValue = String(backupRetainCount ?? 10);
return (
<div className="space-y-4">
{/* Backup policy settings */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">
{t("settings.backupManager.intervalLabel", {
defaultValue: "Auto-backup Interval",
})}
</Label>
<Select
value={intervalValue}
onValueChange={(v) =>
onSettingsChange({ backupIntervalHours: Number(v) })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
{t("settings.backupManager.intervalDisabled", {
defaultValue: "Disabled",
})}
</SelectItem>
<SelectItem value="6">
{t("settings.backupManager.intervalHours", {
hours: 6,
defaultValue: "6 hours",
})}
</SelectItem>
<SelectItem value="12">
{t("settings.backupManager.intervalHours", {
hours: 12,
defaultValue: "12 hours",
})}
</SelectItem>
<SelectItem value="24">
{t("settings.backupManager.intervalHours", {
hours: 24,
defaultValue: "24 hours",
})}
</SelectItem>
<SelectItem value="48">
{t("settings.backupManager.intervalHours", {
hours: 48,
defaultValue: "48 hours",
})}
</SelectItem>
<SelectItem value="168">
{t("settings.backupManager.intervalDays", {
days: 7,
defaultValue: "7 days",
})}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">
{t("settings.backupManager.retainLabel", {
defaultValue: "Backup Retention",
})}
</Label>
<Select
value={retainValue}
onValueChange={(v) =>
onSettingsChange({ backupRetainCount: Number(v) })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[3, 5, 10, 15, 20, 30, 50].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Backup list */}
<div>
<h4 className="text-sm font-medium mb-2">
{t("settings.backupManager.title", {
defaultValue: "Database Backups",
})}
</h4>
{isLoading ? (
<div className="text-sm text-muted-foreground py-2">Loading...</div>
) : backups.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">
{t("settings.backupManager.empty", {
defaultValue: "No backups yet",
})}
</div>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{backups.map((backup) => (
<div
key={backup.filename}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
>
<div className="flex-1 min-w-0">
{editingFilename === backup.filename ? (
<div className="flex items-center gap-1.5">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleConfirmRename();
if (e.key === "Escape") handleCancelRename();
}}
className="h-7 text-xs"
placeholder={t(
"settings.backupManager.namePlaceholder",
{ defaultValue: "Enter new name" },
)}
autoFocus
disabled={isRenaming}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={handleConfirmRename}
disabled={isRenaming || !editValue.trim()}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={handleCancelRename}
disabled={isRenaming}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<>
<div className="font-mono text-xs truncate">
{getDisplayName(backup.filename)}
</div>
<div className="text-xs text-muted-foreground">
{formatBackupDate(backup.createdAt)} &middot;{" "}
{formatBytes(backup.sizeBytes)}
</div>
</>
)}
</div>
{editingFilename !== backup.filename && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleStartRename(backup.filename)}
disabled={isRestoring || isRenaming}
title={t("settings.backupManager.rename", {
defaultValue: "Rename",
})}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={isRestoring}
onClick={() => setConfirmFilename(backup.filename)}
>
<RotateCcw className="h-3 w-3 mr-1" />
{isRestoring
? t("settings.backupManager.restoring", {
defaultValue: "Restoring...",
})
: t("settings.backupManager.restore", {
defaultValue: "Restore",
})}
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Confirmation Dialog */}
<Dialog
open={!!confirmFilename}
onOpenChange={(open) => !open && setConfirmFilename(null)}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>
{t("settings.backupManager.confirmTitle", {
defaultValue: "Confirm Restore",
})}
</DialogTitle>
<DialogDescription>
{t("settings.backupManager.confirmMessage", {
defaultValue:
"Restoring this backup will overwrite the current database. A safety backup will be created first.",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmFilename(null)}
disabled={isRestoring}
>
{t("common.cancel", { defaultValue: "Cancel" })}
</Button>
<Button onClick={handleRestore} disabled={isRestoring}>
{isRestoring
? t("settings.backupManager.restoring", {
defaultValue: "Restoring...",
})
: t("settings.backupManager.restore", {
defaultValue: "Restore",
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
import { useState } from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { Server, Activity, ChevronDown, Zap, Globe } from "lucide-react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import { ToggleRow } from "@/components/ui/toggle-row";
import { Badge } from "@/components/ui/badge";
import { ProxyPanel } from "@/components/proxy";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import type { SettingsFormState } from "@/hooks/useSettings";
interface ProxyTabContentProps {
settings: SettingsFormState;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;
}
export function ProxyTabContent({
settings,
onAutoSave,
}: ProxyTabContentProps) {
const { t } = useTranslation();
const [showProxyConfirm, setShowProxyConfirm] = useState(false);
const {
isRunning,
startProxyServer,
stopWithRestore,
isPending: isProxyPending,
} = useProxyStatus();
const handleToggleProxy = async (checked: boolean) => {
try {
if (!checked) {
await stopWithRestore();
} else if (!settings?.proxyConfirmed) {
setShowProxyConfirm(true);
} else {
await startProxyServer();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
const handleProxyConfirm = async () => {
setShowProxyConfirm(false);
try {
await onAutoSave({ proxyConfirmed: true });
await startProxyServer();
} catch (error) {
console.error("Proxy confirm failed:", error);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
{/* Local Proxy */}
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
>
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p>
</div>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-4 pl-4">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ToggleRow
icon={<Zap className="h-4 w-4 text-green-500" />}
title={t("settings.advanced.proxy.enableFeature")}
description={t(
"settings.advanced.proxy.enableFeatureDescription",
)}
checked={settings?.enableLocalProxy ?? false}
onCheckedChange={(checked) =>
onAutoSave({ enableLocalProxy: checked })
}
/>
<div className="mt-4">
<ProxyPanel />
</div>
</AccordionContent>
</AccordionItem>
{/* Auto Failover */}
<AccordionItem
value="failover"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.failover.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.failover.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6">
{!isRunning && (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.failover.proxyRequired", {
defaultValue: "需要先启动代理服务才能配置故障转移",
})}
</p>
</div>
)}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4 space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent value="codex" className="mt-4 space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent value="gemini" className="mt-4 space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div>
</AccordionContent>
</AccordionItem>
{/* Rectifier */}
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
{/* Global Outbound Proxy */}
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
</Accordion>
<ConfirmDialog
isOpen={showProxyConfirm}
variant="info"
title={t("confirm.proxy.title")}
message={t("confirm.proxy.message")}
confirmText={t("confirm.proxy.confirm")}
onConfirm={() => void handleProxyConfirm()}
onCancel={() => setShowProxyConfirm(false)}
/>
</motion.div>
);
}
+71 -291
View File
@@ -4,16 +4,11 @@ import {
Loader2,
Save,
FolderSearch,
Activity,
Coins,
Database,
Server,
ChevronDown,
Zap,
Globe,
Cloud,
ScrollText,
HardDriveDownload,
} from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner";
import {
Dialog,
@@ -39,24 +34,18 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { BackupListSection } from "@/components/settings/BackupListSection";
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
// Hidden: stream check feature disabled
// import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useProxyStatus } from "@/hooks/useProxyStatus";
interface SettingsDialogProps {
open: boolean;
@@ -188,25 +177,6 @@ export function SettingsPage({
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
const {
isRunning,
startProxyServer,
stopWithRestore,
isPending: isProxyPending,
} = useProxyStatus();
const handleToggleProxy = async (checked: boolean) => {
try {
if (!checked) {
await stopWithRestore();
} else {
await startProxyServer();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
return (
<div className="flex flex-col h-full overflow-hidden px-6">
{isBusy ? (
@@ -219,10 +189,11 @@ export function SettingsPage({
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-4 mb-6 glass rounded-lg">
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
@@ -269,6 +240,15 @@ export function SettingsPage({
) : null}
</TabsContent>
<TabsContent value="proxy" className="space-y-6 mt-0 pb-4">
{settings ? (
<ProxyTabContent
settings={settings}
onAutoSave={handleAutoSave}
/>
) : null}
</TabsContent>
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
{settings ? (
<motion.div
@@ -317,256 +297,6 @@ export function SettingsPage({
</AccordionContent>
</AccordionItem>
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
>
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p>
</div>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-4 pl-4">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
<ProxyPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="failover"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.failover.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.failover.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6">
{/* 代理未运行时的提示 */}
{!isRunning && (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.failover.proxyRequired", {
defaultValue:
"需要先启动代理服务才能配置故障转移",
})}
</p>
</div>
)}
{/* 故障转移设置 - 按应用分组 */}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent
value="claude"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="test"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.modelTest.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.modelTest.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ModelTestConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
@@ -596,11 +326,61 @@ export function SettingsPage({
onExport={exportConfig}
onClear={clearSelection}
/>
<div className="pt-6">
<WebdavSyncSection
config={settings?.webdavSync}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="backup"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<HardDriveDownload className="h-5 w-5 text-amber-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.backup.title", {
defaultValue: "Backup & Restore",
})}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.backup.description", {
defaultValue:
"Manage automatic backups, view and restore database snapshots",
})}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<BackupListSection
backupIntervalHours={settings.backupIntervalHours}
backupRetainCount={settings.backupRetainCount}
onSettingsChange={(updates) =>
handleAutoSave(updates)
}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="cloudSync"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Cloud className="h-5 w-5 text-blue-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.cloudSync.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.cloudSync.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<WebdavSyncSection config={settings?.webdavSync} />
</AccordionContent>
</AccordionItem>
+4 -2
View File
@@ -16,7 +16,7 @@ import type { AppId } from "@/lib/api/types";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi, skillsApi } from "@/lib/api";
import { toast } from "sonner";
import { APP_IDS } from "@/config/appConfig";
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
import { AppCountBar } from "@/components/common/AppCountBar";
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
import { ListItemRow } from "@/components/common/ListItemRow";
@@ -56,7 +56,7 @@ const UnifiedSkillsPanel = React.forwardRef<
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
if (!skills) return counts;
skills.forEach((skill) => {
for (const app of APP_IDS) {
for (const app of MCP_SKILLS_APP_IDS) {
if (skill.apps[app]) counts[app]++;
}
});
@@ -159,6 +159,7 @@ const UnifiedSkillsPanel = React.forwardRef<
<AppCountBar
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
counts={enabledCounts}
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
@@ -282,6 +283,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<AppToggleGroup
apps={skill.apps}
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
appIds={MCP_SKILLS_APP_IDS}
/>
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
+39 -1
View File
@@ -8,10 +8,23 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion";
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
import {
BarChart3,
ListFilter,
Activity,
RefreshCw,
Coins,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
export function UsageDashboard() {
const { t } = useTranslation();
@@ -129,6 +142,31 @@ export function UsageDashboard() {
</motion.div>
</Tabs>
</div>
{/* Pricing Configuration */}
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion>
</motion.div>
);
}
@@ -0,0 +1,552 @@
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Calendar, Trash2, Plus, Search, X, FolderOpen } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import {
workspaceApi,
type DailyMemoryFileInfo,
type DailyMemorySearchResult,
} from "@/lib/api/workspace";
interface DailyMemoryPanelProps {
isOpen: boolean;
onClose: () => void;
}
function getTodayFilename(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}.md`;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
isOpen,
onClose,
}) => {
const { t } = useTranslation();
// List state
const [files, setFiles] = useState<DailyMemoryFileInfo[]>([]);
const [loadingList, setLoadingList] = useState(false);
// Edit state
const [editingFile, setEditingFile] = useState<string | null>(null);
const [content, setContent] = useState("");
const [loadingContent, setLoadingContent] = useState(false);
const [saving, setSaving] = useState(false);
// Delete state
const [deletingFile, setDeletingFile] = useState<string | null>(null);
// Search state
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchResults, setSearchResults] = useState<DailyMemorySearchResult[]>(
[],
);
const [searching, setSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Dark mode
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// Whether we are in active search mode (search open with a non-empty term)
const isActiveSearch = useMemo(
() => isSearchOpen && searchTerm.trim().length > 0,
[isSearchOpen, searchTerm],
);
// Debounced search execution
const executeSearch = useCallback(
async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
setSearching(false);
return;
}
setSearching(true);
try {
const results = await workspaceApi.searchDailyMemoryFiles(query.trim());
setSearchResults(results);
} catch (err) {
console.error("Failed to search daily memory files:", err);
toast.error(t("workspace.dailyMemory.searchFailed"));
} finally {
setSearching(false);
}
},
[t],
);
// Handle search input change with debounce
const handleSearchChange = useCallback(
(value: string) => {
setSearchTerm(value);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
void executeSearch(value);
}, 300);
},
[executeSearch],
);
// Open search bar
const openSearch = useCallback(() => {
setIsSearchOpen(true);
// Focus input on next frame
requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
}, []);
// Close search bar and clear state
const closeSearch = useCallback(() => {
setIsSearchOpen(false);
setSearchTerm("");
setSearchResults([]);
setSearching(false);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
}, []);
// Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close
useEffect(() => {
if (!isOpen || editingFile) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "f") {
e.preventDefault();
if (!isSearchOpen) {
openSearch();
} else {
searchInputRef.current?.focus();
}
}
if (e.key === "Escape" && isSearchOpen) {
e.preventDefault();
closeSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]);
// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Load file list
const loadFiles = useCallback(async () => {
setLoadingList(true);
try {
const list = await workspaceApi.listDailyMemoryFiles();
setFiles(list);
} catch (err) {
console.error("Failed to load daily memory files:", err);
toast.error(t("workspace.dailyMemory.loadFailed"));
} finally {
setLoadingList(false);
}
}, [t]);
useEffect(() => {
if (isOpen) {
void loadFiles();
}
}, [isOpen, loadFiles]);
// Open file for editing
const openFile = useCallback(
async (filename: string) => {
setLoadingContent(true);
setEditingFile(filename);
try {
const data = await workspaceApi.readDailyMemoryFile(filename);
setContent(data ?? "");
} catch (err) {
console.error("Failed to read daily memory file:", err);
toast.error(t("workspace.dailyMemory.loadFailed"));
setEditingFile(null);
} finally {
setLoadingContent(false);
}
},
[t],
);
// Create today's note (deferred — file is only persisted on save)
const handleCreateToday = useCallback(async () => {
const filename = getTodayFilename();
// Check if already exists in the list
const existing = files.find((f) => f.filename === filename);
if (existing) {
// Just open it
await openFile(filename);
return;
}
// Open editor with empty content — no file created until user saves
setEditingFile(filename);
setContent("");
}, [files, openFile]);
// Save current file
const handleSave = useCallback(async () => {
if (!editingFile) return;
setSaving(true);
try {
await workspaceApi.writeDailyMemoryFile(editingFile, content);
toast.success(t("workspace.saveSuccess"));
} catch (err) {
console.error("Failed to save daily memory file:", err);
toast.error(t("workspace.saveFailed"));
} finally {
setSaving(false);
}
}, [editingFile, content, t]);
// Delete file
const handleDelete = useCallback(async () => {
if (!deletingFile) return;
try {
await workspaceApi.deleteDailyMemoryFile(deletingFile);
toast.success(t("workspace.dailyMemory.deleteSuccess"));
setDeletingFile(null);
// If we were editing this file, go back to list
if (editingFile === deletingFile) {
setEditingFile(null);
}
await loadFiles();
// Re-trigger search if active
if (isSearchOpen && searchTerm.trim()) {
void executeSearch(searchTerm);
}
} catch (err) {
console.error("Failed to delete daily memory file:", err);
toast.error(t("workspace.dailyMemory.deleteFailed"));
setDeletingFile(null);
}
}, [
deletingFile,
editingFile,
loadFiles,
t,
isSearchOpen,
searchTerm,
executeSearch,
]);
// Back from edit mode to list mode — preserve search state
const handleBackToList = useCallback(() => {
setEditingFile(null);
setContent("");
void loadFiles();
// Re-trigger search if active (file content may have changed)
if (isSearchOpen && searchTerm.trim()) {
void executeSearch(searchTerm);
}
}, [loadFiles, isSearchOpen, searchTerm, executeSearch]);
// Close panel entirely — clear search state
const handleClose = useCallback(() => {
setEditingFile(null);
setContent("");
setIsSearchOpen(false);
setSearchTerm("");
setSearchResults([]);
setSearching(false);
onClose();
}, [onClose]);
// --- Edit mode ---
if (editingFile) {
return (
<>
<FullScreenPanel
isOpen={isOpen}
title={t("workspace.editing", { filename: editingFile })}
onClose={handleBackToList}
footer={
<Button onClick={handleSave} disabled={saving || loadingContent}>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
{loadingContent ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${editingFile}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
)}
</FullScreenPanel>
<ConfirmDialog
isOpen={!!deletingFile}
title={t("workspace.dailyMemory.confirmDeleteTitle")}
message={t("workspace.dailyMemory.confirmDeleteMessage", {
date: deletingFile?.replace(".md", "") ?? "",
})}
onConfirm={handleDelete}
onCancel={() => setDeletingFile(null)}
/>
</>
);
}
// --- List mode ---
return (
<>
<FullScreenPanel
isOpen={isOpen}
title={t("workspace.dailyMemory.title")}
onClose={handleClose}
>
<div className="space-y-4">
{/* Header with path, search, and create button */}
<div className="flex items-center justify-between gap-2">
<p
className="text-sm text-muted-foreground shrink-0 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1"
onClick={() => workspaceApi.openDirectory("memory")}
title={t("workspace.openDirectory")}
>
~/.openclaw/workspace/memory/
<FolderOpen className="w-3.5 h-3.5" />
</p>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={isSearchOpen ? closeSearch : openSearch}
title={t("workspace.dailyMemory.searchScopeHint")}
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCreateToday}
className="gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
{t("workspace.dailyMemory.createToday")}
</Button>
</div>
</div>
{/* Search bar */}
<AnimatePresence>
{isSearchOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={t("workspace.dailyMemory.searchPlaceholder")}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchTerm && (
<button
onClick={() => handleSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={closeSearch}
className="text-xs text-muted-foreground h-8 px-2 shrink-0"
>
{t("workspace.dailyMemory.searchCloseHint")}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Content: search results or normal file list */}
{isActiveSearch ? (
// --- Search results ---
searching ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("workspace.dailyMemory.searching")}
</div>
) : searchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3 border-2 border-dashed border-border rounded-xl">
<Search className="w-10 h-10 opacity-40" />
<p className="text-sm">
{t("workspace.dailyMemory.noSearchResults")}
</p>
</div>
) : (
<div className="space-y-2">
{searchResults.map((result) => (
<button
key={result.filename}
onClick={() => openFile(result.filename)}
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Calendar className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">
{result.date}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(result.sizeBytes)}
</span>
{result.matchCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{t("workspace.dailyMemory.matchCount", {
count: result.matchCount,
})}
</span>
)}
</div>
{result.snippet && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 whitespace-pre-line">
{result.snippet}
</p>
)}
</div>
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
setDeletingFile(result.filename);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
</div>
</button>
))}
</div>
)
) : // --- Normal file list ---
loadingList ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("prompts.loading")}
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3">
<Calendar className="w-10 h-10 opacity-40" />
<p className="text-sm">{t("workspace.dailyMemory.empty")}</p>
</div>
) : (
<div className="space-y-2">
{files.map((file) => (
<button
key={file.filename}
onClick={() => openFile(file.filename)}
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Calendar className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">
{file.date}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.sizeBytes)}
</span>
</div>
{file.preview && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{file.preview}
</p>
)}
</div>
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
setDeletingFile(file.filename);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
</div>
</button>
))}
</div>
)}
</div>
</FullScreenPanel>
<ConfirmDialog
isOpen={!!deletingFile}
title={t("workspace.dailyMemory.confirmDeleteTitle")}
message={t("workspace.dailyMemory.confirmDeleteMessage", {
date: deletingFile?.replace(".md", "") ?? "",
})}
onConfirm={handleDelete}
onCancel={() => setDeletingFile(null)}
/>
</>
);
};
export default DailyMemoryPanel;
@@ -12,10 +12,14 @@ import {
Power,
CheckCircle2,
Circle,
Calendar,
ChevronRight,
FolderOpen,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { workspaceApi } from "@/lib/api/workspace";
import WorkspaceFileEditor from "./WorkspaceFileEditor";
import DailyMemoryPanel from "./DailyMemoryPanel";
interface WorkspaceFile {
filename: string;
@@ -51,6 +55,7 @@ const WorkspaceFilesPanel: React.FC = () => {
const { t } = useTranslation();
const [editingFile, setEditingFile] = useState<string | null>(null);
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
const [showDailyMemory, setShowDailyMemory] = useState(false);
const checkFileExistence = async () => {
const results: Record<string, boolean> = {};
@@ -79,8 +84,13 @@ const WorkspaceFilesPanel: React.FC = () => {
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
<p
className="text-sm text-muted-foreground mb-6 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1"
onClick={() => workspaceApi.openDirectory("workspace")}
title={t("workspace.openDirectory")}
>
~/.openclaw/workspace/
<FolderOpen className="w-3.5 h-3.5" />
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -115,6 +125,27 @@ const WorkspaceFilesPanel: React.FC = () => {
</button>
);
})}
{/* Daily Memory — inline with workspace files */}
<button
onClick={() => setShowDailyMemory(true)}
className="flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Calendar className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<span className="font-medium text-sm text-foreground">
{t("workspace.dailyMemory.cardTitle")}
</span>
<p className="text-xs text-muted-foreground mt-0.5">
{t("workspace.dailyMemory.cardDescription")}
</p>
</div>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<ChevronRight className="w-4 h-4" />
</div>
</button>
</div>
<WorkspaceFileEditor
@@ -122,6 +153,11 @@ const WorkspaceFilesPanel: React.FC = () => {
isOpen={!!editingFile}
onClose={handleEditorClose}
/>
<DailyMemoryPanel
isOpen={showDailyMemory}
onClose={() => setShowDailyMemory(false)}
/>
</div>
);
};
+8
View File
@@ -23,6 +23,14 @@ export const APP_IDS: AppId[] = [
"openclaw",
];
/** App IDs shown in MCP & Skills panels (excludes OpenClaw) */
export const MCP_SKILLS_APP_IDS: AppId[] = [
"claude",
"codex",
"gemini",
"opencode",
];
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
claude: {
label: "Claude",
+106 -39
View File
@@ -92,10 +92,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
ANTHROPIC_MODEL: "glm-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
},
},
category: "cn_official",
@@ -110,10 +110,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
ANTHROPIC_MODEL: "glm-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
},
},
category: "cn_official",
@@ -170,10 +170,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_MODEL: "ZhipuAI/GLM-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-5",
},
},
category: "aggregator",
@@ -236,10 +236,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
ANTHROPIC_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
},
},
category: "cn_official",
@@ -262,10 +262,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
ANTHROPIC_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
},
},
category: "cn_official",
@@ -287,10 +287,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
ANTHROPIC_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_MODEL: "doubao-seed-2-0-code-preview-latest",
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-0-code-preview-latest",
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-0-code-preview-latest",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-0-code-preview-latest",
},
},
category: "cn_official",
@@ -304,10 +304,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.tbox.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "Ling-1T",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-1T",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-1T",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-1T",
ANTHROPIC_MODEL: "Ling-2.5-1T",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-2.5-1T",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-2.5-1T",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-2.5-1T",
},
},
category: "cn_official",
@@ -316,12 +316,10 @@ export const providerPresets: ProviderPreset[] = [
name: "AiHubMix",
websiteUrl: "https://aihubmix.com",
apiKeyUrl: "https://aihubmix.com",
// 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN
apiKeyField: "ANTHROPIC_API_KEY",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://aihubmix.com",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
@@ -338,10 +336,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
},
},
category: "aggregator",
@@ -356,10 +354,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.5",
},
},
category: "aggregator",
@@ -533,4 +531,73 @@ export const providerPresets: ProviderPreset[] = [
icon: "xiaomimimo",
iconColor: "#000000",
},
{
name: "AWS Bedrock (AKSK)",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL:
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}",
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}",
AWS_REGION: "${AWS_REGION}",
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-6-v1",
ANTHROPIC_DEFAULT_HAIKU_MODEL:
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
ANTHROPIC_DEFAULT_SONNET_MODEL:
"global.anthropic.claude-sonnet-4-6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-6-v1",
CLAUDE_CODE_USE_BEDROCK: "1",
},
},
category: "cloud_provider",
templateValues: {
AWS_REGION: {
label: "AWS Region",
placeholder: "us-west-2",
editorValue: "us-west-2",
},
AWS_ACCESS_KEY_ID: {
label: "Access Key ID",
placeholder: "AKIA...",
editorValue: "",
},
AWS_SECRET_ACCESS_KEY: {
label: "Secret Access Key",
placeholder: "your-secret-key",
editorValue: "",
},
},
icon: "aws",
iconColor: "#FF9900",
},
{
name: "AWS Bedrock (API Key)",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
apiKey: "",
env: {
ANTHROPIC_BASE_URL:
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
AWS_REGION: "${AWS_REGION}",
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-6-v1",
ANTHROPIC_DEFAULT_HAIKU_MODEL:
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
ANTHROPIC_DEFAULT_SONNET_MODEL:
"global.anthropic.claude-sonnet-4-6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-6-v1",
CLAUDE_CODE_USE_BEDROCK: "1",
},
},
category: "cloud_provider",
templateValues: {
AWS_REGION: {
label: "AWS Region",
placeholder: "us-west-2",
editorValue: "us-west-2",
},
},
icon: "aws",
iconColor: "#FF9900",
},
];
+74 -39
View File
@@ -112,8 +112,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "glm-4.7",
name: "GLM-4.7",
id: "glm-5",
name: "GLM-5",
contextWindow: 128000,
cost: { input: 0.001, output: 0.001 },
},
@@ -138,8 +138,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "zhipu/glm-4.7" },
modelCatalog: { "zhipu/glm-4.7": { alias: "GLM" } },
model: { primary: "zhipu/glm-5" },
modelCatalog: { "zhipu/glm-5": { alias: "GLM" } },
},
},
{
@@ -152,8 +152,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "glm-4.7",
name: "GLM-4.7",
id: "glm-5",
name: "GLM-5",
contextWindow: 128000,
cost: { input: 0.001, output: 0.001 },
},
@@ -178,8 +178,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "zhipu-en/glm-4.7" },
modelCatalog: { "zhipu-en/glm-4.7": { alias: "GLM" } },
model: { primary: "zhipu-en/glm-5" },
modelCatalog: { "zhipu-en/glm-5": { alias: "GLM" } },
},
},
{
@@ -192,8 +192,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "qwen3-max",
name: "Qwen3 Max",
id: "qwen3.5-plus",
name: "Qwen3.5 Plus",
contextWindow: 32000,
cost: { input: 0.002, output: 0.006 },
},
@@ -216,8 +216,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "qwen/qwen3-max" },
modelCatalog: { "qwen/qwen3-max": { alias: "Qwen" } },
model: { primary: "qwen/qwen3.5-plus" },
modelCatalog: { "qwen/qwen3.5-plus": { alias: "Qwen" } },
},
},
{
@@ -306,8 +306,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -330,8 +330,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "minimax/MiniMax-M2.1" },
modelCatalog: { "minimax/MiniMax-M2.1": { alias: "MiniMax" } },
model: { primary: "minimax/MiniMax-M2.5" },
modelCatalog: { "minimax/MiniMax-M2.5": { alias: "MiniMax" } },
},
},
{
@@ -344,8 +344,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -368,8 +368,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "minimax-en/MiniMax-M2.1" },
modelCatalog: { "minimax-en/MiniMax-M2.1": { alias: "MiniMax" } },
model: { primary: "minimax-en/MiniMax-M2.5" },
modelCatalog: { "minimax-en/MiniMax-M2.5": { alias: "MiniMax" } },
},
},
{
@@ -465,7 +465,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "doubao-seed-code-preview-latest",
id: "doubao-seed-2-0-code-preview-latest",
name: "DouBao Seed Code Preview",
contextWindow: 128000,
cost: { input: 0.002, output: 0.006 },
@@ -483,9 +483,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "doubaoseed/doubao-seed-code-preview-latest" },
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
modelCatalog: {
"doubaoseed/doubao-seed-code-preview-latest": { alias: "DouBao" },
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
},
},
},
@@ -498,8 +498,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "Ling-1T",
name: "Ling 1T",
id: "Ling-2.5-1T",
name: "Ling 2.5 1T",
contextWindow: 128000,
cost: { input: 0.001, output: 0.004 },
},
@@ -514,8 +514,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "bailing/Ling-1T" },
modelCatalog: { "bailing/Ling-1T": { alias: "BaiLing" } },
model: { primary: "bailing/Ling-2.5-1T" },
modelCatalog: { "bailing/Ling-2.5-1T": { alias: "BaiLing" } },
},
},
{
@@ -694,8 +694,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "ZhipuAI/GLM-4.7",
name: "GLM-4.7",
id: "ZhipuAI/GLM-5",
name: "GLM-5",
contextWindow: 128000,
cost: { input: 0.001, output: 0.001 },
},
@@ -718,8 +718,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "modelscope/ZhipuAI/GLM-4.7" },
modelCatalog: { "modelscope/ZhipuAI/GLM-4.7": { alias: "GLM" } },
model: { primary: "modelscope/ZhipuAI/GLM-5" },
modelCatalog: { "modelscope/ZhipuAI/GLM-5": { alias: "GLM" } },
},
},
{
@@ -732,8 +732,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "Pro/MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
id: "Pro/MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -750,9 +750,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.1" },
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.5" },
modelCatalog: {
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
},
},
},
@@ -766,8 +766,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -784,9 +784,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.1" },
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.5" },
modelCatalog: {
"siliconflow-en/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
"siliconflow-en/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
},
},
},
@@ -1054,6 +1054,41 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
// ========== Cloud Providers ==========
{
name: "AWS Bedrock",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
// 请将 us-west-2 替换为你的 AWS Region
baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com",
apiKey: "",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-opus-4-6-20250514-v1:0",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
},
{
id: "anthropic.claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
{
id: "anthropic.claude-haiku-4-5-20251022-v1:0",
name: "Claude Haiku 4.5",
contextWindow: 200000,
cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
},
],
},
category: "cloud_provider",
icon: "aws",
iconColor: "#FF9900",
},
// ========== Custom Template ==========
{
name: "OpenAI Compatible",

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