Compare commits

...

45 Commits

Author SHA1 Message Date
Jason db4a1ea907 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:52:37 +08:00
Jason 58b7dc32b3 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.
2026-02-23 21:52:37 +08:00
Jason 65ccabd5ad 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.
2026-02-23 21:52:37 +08:00
Jason a3a81806f8 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)
2026-02-23 21:52:37 +08:00
Jason 5104045ffb 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.
2026-02-23 21:52:37 +08:00
Jason 7898096de3 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)
2026-02-23 21:52:37 +08:00
Jason cc235c6c63 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
2026-02-23 21:52:37 +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
SaladDay 9514d08ef6 fix(settings): normalize SQL import/export dark-mode card (#1067) 2026-02-17 22:52:00 +08:00
Jason adaef3522d fix(ui): add vertical spacing between directory settings sections
Replace React Fragment with a div using space-y-6 to add proper
vertical spacing between the app config directory and directory
override sections in Settings > Advanced > Directory Settings.
2026-02-17 21:55:10 +08:00
Jim Northrup 4c8334c6fd fix: Don't add ?beta=true to OpenAI Chat Completions endpoints (#1052)
Fixes Nvidia provider and other providers using apiFormat="openai_chat".

The ClaudeAdapter::build_url() method was incorrectly adding ?beta=true
to both /v1/messages and /v1/chat/completions endpoints. This caused
the Nvidia provider to fail because:

1. Nvidia uses apiFormat="openai_chat"
2. Requests are transformed to OpenAI format and sent to /v1/chat/completions
3. The URL gets ?beta=true appended (Anthropic-specific parameter)
4. Nvidia's API rejects requests with this parameter

Fix:
- Only add ?beta=true to /v1/messages endpoint
- Exclude /v1/chat/completions from getting this parameter

Tested:
- Anthropic /v1/messages still gets ?beta=true ✓
- OpenAI Chat Completions /v1/chat/completions does NOT get ?beta=true ✓
- All 13 Claude adapter tests pass ✓

Co-authored-by: jnorthrup <jnorthrup@example.com>
2026-02-16 22:53:08 +08:00
Jason 6caf843843 docs: sync SSSAiCode sponsor update across all README languages 2026-02-16 21:34:43 +08:00
Jason 6c38a8fd24 docs: add SSSAiCode sponsor across all README languages 2026-02-16 00:10:38 +08:00
Jason 977813f725 docs: sync Crazyrouter sponsor update across all README languages 2026-02-15 23:47:40 +08:00
JIA-ss 11f1ef33e4 feat(ui): add quick config toggles to Claude config editor (#1012)
Add 3 quick-toggle checkboxes above the JSON editor in Claude provider settings:
- Hide AI Attribution (sets attribution.commit/pr to empty string)
- Extended Thinking (sets alwaysThinkingEnabled to true)
- Teammates Mode (sets env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS to "1")

Uses local state to keep toggles and JsonEditor in sync without
triggering ProviderForm re-renders. Includes i18n for zh/en/ja.

Co-authored-by: Jason <farion1231@gmail.com>
2026-02-15 22:10:40 +08:00
SaladDay 20f62bf4f8 feat(webdav): follow-up 补齐自动同步与大文件防护 (#1043)
* feat(webdav): add robust auto sync with failure feedback

(cherry picked from commit bb6760124a62a964b36902c004e173534910728f)

* fix(webdav): enforce bounded download and extraction size

(cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79)

* fix(webdav): only show auto-sync callout for auto-source errors

* refactor(webdav): remove services->commands auto-sync dependency
2026-02-15 20:58:17 +08:00
Dex Miller 508aa6070c Fix/skill zip symlink resolution (#1040)
* fix(skill): resolve symlinks in ZIP extraction for GitHub repos (#1001)

- detect symlink entries via is_symlink() during ZIP extraction and collect target paths

- add resolve_symlinks_in_dir() to copy symlink target content into link location

- canonicalize base_dir to fix macOS /tmp → /private/tmp path comparison issue

- add path traversal safety check to block symlinks pointing outside repo boundary

- apply symlink resolution to both download_and_extract and extract_local_zip paths

Closes https://github.com/farion1231/cc-switch/issues/1001

* fix(skill): change search to match name and repo instead of description

* feat(skill): support importing skills from ~/.agents/skills/ directory

- Scan ~/.agents/skills/ in scan_unmanaged() for skill discovery
- Parse ~/.agents/.skill-lock.json to extract repo owner/name metadata
- Auto-add discovered repos to skill_repos management on import
- Add path field to UnmanagedSkill to show discovered location in UI

Closes #980

* fix(skill): use metadata name or ZIP filename for root-level SKILL.md imports (#1000)

When a ZIP contains SKILL.md at the root without a wrapper directory,
the install name was derived from the temp directory name (e.g. .tmpDZKGpF).
Now falls back to SKILL.md frontmatter name, then ZIP filename stem.

* feat(skill): scan ~/.cc-switch/skills/ for unmanaged skill discovery and import

* refactor(skill): unify scan/import logic with lock file skillPath and repo saving

- Deduplicate scan_unmanaged and import_from_apps using shared source list
- Replace hand-written AppType match with as_str() and AppType::all()
- Extract read_skill_name_desc, build_repo_info_from_lock, save_repos_from_lock helpers
- Add SkillApps::from_labels for building enable state from source labels
- Parse skillPath from .skill-lock.json for correct readme URLs
- Save skill repos to skill_repos table in both import and migration paths

* fix(skill): resolve symlink and path traversal issues in ZIP skill import

* fix(skill): separate source path validation and add canonicalization for symlink safety
2026-02-15 20:57:14 +08:00
121 changed files with 8073 additions and 5269 deletions
+7 -2
View File
@@ -61,8 +61,13 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, GPT, and more. All through a single OpenAI-compatible endpoint with zero code changes. Features include auto-failover, smart routing, unlimited concurrency, and global low-latency access. <a href="https://crazyrouter.com/register?ref=cc-switch">Register here</a> to get started.</td>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
</tr>
</table>
+7 -2
View File
@@ -61,8 +61,13 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI、GPT など 300 以上のモデルにアクセス可能。OpenAI 互換エンドポイントでコード変更不要。自動フェイルオーバー、スマートルーティング、無制限同時接続、グローバル低遅延アクセスに対応。<a href="https://crazyrouter.com/register?ref=cc-switch">こちらから登録</a>してすぐにご利用いただけます</td>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます</td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
</tr>
</table>
+7 -2
View File
@@ -62,8 +62,13 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI、GPT 等。通过单一 OpenAI 兼容端点实现零代码改动接入。支持自动故障转移、智能路由无限并发和全球低延迟访问。<a href="https://crazyrouter.com/register?ref=cc-switch">点击这里注册</a>即可开始使用</td>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%支持自动故障转移、智能路由无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong></td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
</tr>
</table>
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

+1 -1
View File
@@ -57,7 +57,7 @@ url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
+16 -60
View File
@@ -129,6 +129,20 @@ impl SkillApps {
apps.set_enabled_for(app, true);
apps
}
/// 从来源标签列表构建启用状态
///
/// 标签与 AppType::as_str() 一致时启用对应应用,
/// 其他标签(如 "agents", "cc-switch")忽略。
pub fn from_labels(labels: &[String]) -> Self {
let mut apps = Self::default();
for label in labels {
if let Ok(app) = label.parse::<AppType>() {
apps.set_enabled_for(&app, true);
}
}
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
@@ -175,6 +189,8 @@ pub struct UnmanagedSkill {
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
/// 发现路径(首个匹配的完整路径)
pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
@@ -336,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 {
@@ -396,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 {
@@ -423,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,
}
}
}
@@ -518,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>,
+15 -15
View File
@@ -1,8 +1,6 @@
#![allow(non_snake_case)]
use serde_json::{Value, json};
use std::future::Future;
use std::sync::OnceLock;
use serde_json::{json, Value};
use tauri::State;
use crate::commands::sync_support::{
@@ -13,8 +11,9 @@ use crate::services::webdav_sync as webdav_sync_service;
use crate::settings::{self, WebDavSyncSettings};
use crate::store::AppState;
fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError, source: &str) {
settings.status.last_error = Some(error.to_string());
settings.status.last_error_source = Some(source.to_string());
let _ = settings::update_webdav_sync_status(settings.status.clone());
}
@@ -57,20 +56,16 @@ fn resolve_password_for_request(
incoming
}
#[cfg(test)]
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
webdav_sync_service::sync_mutex()
}
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
Fut: std::future::Future<Output = Result<T, AppError>>,
{
let result = {
let _guard = webdav_sync_mutex().lock().await;
operation.await
};
result
webdav_sync_service::run_with_sync_lock(operation).await
}
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
@@ -112,7 +107,9 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, Str
let mut settings = require_enabled_webdav_settings()?;
let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await;
map_sync_result(result, |error| persist_sync_error(&mut settings, error))
map_sync_result(result, |error| {
persist_sync_error(&mut settings, error, "manual")
})
}
#[tauri::command]
@@ -120,10 +117,11 @@ pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, S
let db = state.db.clone();
let db_for_sync = db.clone();
let mut settings = require_enabled_webdav_settings()?;
let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
let mut result = map_sync_result(sync_result, |error| {
persist_sync_error(&mut settings, error)
persist_sync_error(&mut settings, error, "manual")
})?;
// Post-download sync is best-effort: snapshot restore has already succeeded.
@@ -179,8 +177,8 @@ mod tests {
use crate::error::AppError;
use crate::settings::{AppSettings, WebDavSyncSettings};
use serial_test::serial;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test]
@@ -287,6 +285,7 @@ mod tests {
persist_sync_error(
&mut current,
&crate::error::AppError::Config("boom".to_string()),
"manual",
);
let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings");
@@ -304,6 +303,7 @@ mod tests {
.contains("boom"),
"status error should be updated"
);
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
}
#[test]
+143
View File
@@ -1,3 +1,6 @@
use regex::Regex;
use std::sync::LazyLock;
use crate::config::write_text_file;
use crate::openclaw_config::get_openclaw_dir;
@@ -24,6 +27,146 @@ 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}"))
}
/// 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]
+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(())
}
}
+31 -5
View File
@@ -23,7 +23,7 @@
//! └── settings.rs
//! ```
mod backup;
pub(crate) mod backup;
mod dao;
mod migration;
mod schema;
@@ -37,15 +37,12 @@ pub use dao::OmoGlobalConfig;
use crate::config::get_app_config_dir;
use crate::error::AppError;
use rusqlite::Connection;
use rusqlite::{hooks::Action, Connection};
use serde::Serialize;
use std::sync::Mutex;
// DAO 方法通过 impl Database 提供,无需额外导出
/// 数据库备份保留数量
const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 5;
@@ -76,6 +73,17 @@ pub struct Database {
pub(crate) conn: Mutex<Connection>,
}
fn register_db_change_hook(conn: &Connection) {
conn.update_hook(Some(
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
crate::services::webdav_auto_sync::notify_db_changed(table);
}
_ => {}
},
));
}
impl Database {
/// 初始化数据库连接并创建表
///
@@ -93,11 +101,28 @@ impl Database {
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
register_db_change_hook(&conn);
let db = Self {
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()?;
@@ -111,6 +136,7 @@ impl Database {
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
register_db_change_hook(&conn);
let db = Self {
conn: Mutex::new(conn),
-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
+68 -51
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) {
@@ -702,6 +686,10 @@ pub fn run() {
}
let _tray = tray_builder.build(app)?;
crate::services::webdav_auto_sync::start_worker(
app_state.db.clone(),
app.handle().clone(),
);
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
@@ -780,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 初始化失败导致白屏
@@ -838,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,
@@ -908,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,
@@ -1031,9 +1039,18 @@ 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,
]);
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 {
+15 -3
View File
@@ -263,10 +263,13 @@ impl ProviderAdapter for ClaudeAdapter {
base = base.replace("/v1/v1", "/v1");
}
// 为 Claude 相关端点添加 ?beta=true 参数
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
// 注openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
&& !endpoint.contains('?')
{
format!("{base}?beta=true")
@@ -513,6 +516,15 @@ mod tests {
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_build_url_no_beta_for_openai_chat_completions() {
let adapter = ClaudeAdapter::new();
// OpenAI Chat Completions 端点不添加 ?beta=true
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
}
#[test]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
-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(())
}
}
+2 -1
View File
@@ -11,13 +11,14 @@ pub mod speedtest;
pub mod stream_check;
pub mod usage_stats;
pub mod webdav;
pub mod webdav_auto_sync;
pub mod webdav_sync;
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 => {
+2 -2
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;
@@ -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)
+543 -159
View File
@@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use tokio::time::timeout;
@@ -159,6 +159,154 @@ pub struct SkillMetadata {
pub description: Option<String>,
}
// ========== ~/.agents/ lock 文件解析 ==========
/// `~/.agents/.skill-lock.json` 文件结构
#[derive(Deserialize)]
struct AgentsLockFile {
skills: HashMap<String, AgentsLockSkill>,
}
/// lock 文件中单个 skill 的信息
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentsLockSkill {
source: Option<String>,
source_type: Option<String>,
source_url: Option<String>,
skill_path: Option<String>,
branch: Option<String>,
source_branch: Option<String>,
}
#[derive(Debug, Clone)]
struct LockRepoInfo {
owner: String,
repo: String,
skill_path: Option<String>,
branch: Option<String>,
}
fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
branch.and_then(|b| {
let trimmed = b.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
let source_url = source_url?;
let source_url = source_url.trim();
if source_url.is_empty() {
return None;
}
// 支持 https://github.com/owner/repo/tree/<branch>/...
if let Some((_, after_tree)) = source_url.split_once("/tree/") {
let branch = after_tree
.split('/')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())?;
return Some(branch.to_string());
}
// 支持 URL fragment: ...git#branch
if let Some((_, fragment)) = source_url.split_once('#') {
let branch = fragment
.split('&')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())?;
return Some(branch.to_string());
}
// 支持 query: ...?branch=xxx / ?ref=xxx
if let Some((_, query)) = source_url.split_once('?') {
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
if matches!(key, "branch" | "ref") {
let branch = value.trim();
if !branch.is_empty() {
return Some(branch.to_string());
}
}
}
}
None
}
/// 获取 `~/.agents/skills/` 目录(存在时返回)
fn get_agents_skills_dir() -> Option<PathBuf> {
dirs::home_dir()
.map(|h| h.join(".agents").join("skills"))
.filter(|p| p.exists())
}
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
let path = match dirs::home_dir() {
Some(h) => h.join(".agents").join(".skill-lock.json"),
None => {
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
return HashMap::new();
}
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
log::debug!("未找到 agents lock 文件: {}", path.display());
} else {
log::warn!("读取 agents lock 文件失败 ({}): {}", path.display(), e);
}
return HashMap::new();
}
};
let lock: AgentsLockFile = match serde_json::from_str(&content) {
Ok(l) => l,
Err(e) => {
log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e);
return HashMap::new();
}
};
let parsed: HashMap<String, LockRepoInfo> = lock
.skills
.into_iter()
.filter_map(|(name, skill)| {
let source = skill.source?;
if skill.source_type.as_deref() != Some("github") {
return None;
}
let (owner, repo) = source.split_once('/')?;
let branch = normalize_optional_branch(skill.branch)
.or_else(|| normalize_optional_branch(skill.source_branch))
.or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
Some((
name,
LockRepoInfo {
owner: owner.to_string(),
repo: repo.to_string(),
skill_path: skill.skill_path,
branch,
},
))
})
.collect();
log::info!(
"agents lock 文件解析完成,共识别 {} 个 github skill",
parsed.len()
);
parsed
}
// ========== SkillService ==========
pub struct SkillService;
@@ -275,11 +423,25 @@ impl SkillService {
) -> Result<InstalledSkill> {
let ssot_dir = Self::get_ssot_dir()?;
// 使用目录最后一段作为安装名
let install_name = Path::new(&skill.directory)
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {
anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
))
})?;
// 安装目录名始终使用最后一段,避免在 SSOT 中创建多级目录。
let install_name = source_rel
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
.ok_or_else(|| {
anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
))
})?;
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
let existing_skills = db.get_all_installed_skills()?;
@@ -358,7 +520,7 @@ impl SkillService {
repo_branch = used_branch;
// 复制到 SSOT
let source = temp_dir.join(&skill.directory);
let source = temp_dir.join(&source_rel);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
@@ -368,7 +530,24 @@ impl SkillService {
)));
}
Self::copy_dir_recursive(&source, &dest)?;
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
let canonical_source = source.canonicalize().map_err(|_| {
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
))
})?;
if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
)));
}
Self::copy_dir_recursive(&canonical_source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir);
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
@@ -449,12 +628,7 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in AppType::all() {
let _ = Self::remove_from_app(&skill.directory, &app);
}
@@ -511,74 +685,49 @@ impl SkillService {
.map(|s| s.directory.clone())
.collect();
// 收集所有待扫描的目录及其来源标签
let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
for app in AppType::all() {
if let Ok(d) = Self::get_app_skills_dir(&app) {
scan_sources.push((d, app.as_str().to_string()));
}
}
if let Some(agents_dir) = get_agents_skills_dir() {
scan_sources.push((agents_dir, "agents".to_string()));
}
if let Ok(ssot_dir) = Self::get_ssot_dir() {
scan_sources.push((ssot_dir, "cc-switch".to_string()));
}
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(d) => d,
for (scan_dir, label) in &scan_sources {
let entries = match fs::read_dir(scan_dir) {
Ok(e) => e,
Err(_) => continue,
};
if !app_dir.exists() {
continue;
}
for entry in fs::read_dir(&app_dir)? {
let entry = entry?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
// 跳过隐藏目录(以 . 开头,如 .system)
if dir_name.starts_with('.') {
if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
continue;
}
// 跳过已管理的
if managed_dirs.contains(&dir_name) {
continue;
}
// 检查是否有 SKILL.md
let skill_md = path.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| dir_name.clone()),
meta.description,
),
Err(_) => (dir_name.clone(), None),
}
} else {
(dir_name.clone(), None)
};
// 添加或更新
let app_str = match app {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
};
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
unmanaged
.entry(dir_name.clone())
.and_modify(|s| s.found_in.push(app_str.to_string()))
.and_modify(|s| s.found_in.push(label.clone()))
.or_insert(UnmanagedSkill {
directory: dir_name,
name,
description,
found_in: vec![app_str.to_string()],
found_in: vec![label.clone()],
path: path.display().to_string(),
});
}
}
@@ -594,34 +743,36 @@ impl SkillService {
directories: Vec<String>,
) -> Result<Vec<InstalledSkill>> {
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut imported = Vec::new();
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
// 收集所有候选搜索目录
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
for app in AppType::all() {
if let Ok(d) = Self::get_app_skills_dir(&app) {
search_sources.push((d, app.as_str().to_string()));
}
}
if let Some(agents_dir) = get_agents_skills_dir() {
search_sources.push((agents_dir, "agents".to_string()));
}
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
for dir_name in directories {
// 找到源目录(从任一应用目录复制)
// 在所有候选目录中查找
let mut source_path: Option<PathBuf> = None;
let mut found_in: Vec<String> = Vec::new();
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
let skill_path = app_dir.join(&dir_name);
if skill_path.exists() {
if source_path.is_none() {
source_path = Some(skill_path);
}
let app_str = match app {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
};
found_in.push(app_str.to_string());
for (base, label) in &search_sources {
let skill_path = base.join(&dir_name);
if skill_path.exists() {
if source_path.is_none() {
source_path = Some(skill_path);
}
found_in.push(label.clone());
}
}
@@ -638,40 +789,25 @@ impl SkillService {
// 解析元数据
let skill_md = dest.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| dir_name.clone()),
meta.description,
),
Err(_) => (dir_name.clone(), None),
}
} else {
(dir_name.clone(), None)
};
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
// 构建启用状态
let mut apps = SkillApps::default();
for app_str in &found_in {
match app_str.as_str() {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
_ => {}
}
}
let apps = SkillApps::from_labels(&found_in);
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &dir_name);
// 创建记录
let skill = InstalledSkill {
id: format!("local:{dir_name}"),
id,
name,
description,
directory: dir_name,
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
repo_owner,
repo_name,
repo_branch,
readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
@@ -1055,6 +1191,79 @@ impl SkillService {
Ok(meta)
}
/// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {
if skill_md.exists() {
match Self::parse_skill_metadata_static(skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| fallback_name.to_string()),
meta.description,
),
Err(_) => (fallback_name.to_string(), None),
}
} else {
(fallback_name.to_string(), None)
}
}
/// 校验并规范化技能源路径(允许多级目录),拒绝路径穿越和绝对路径
fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut normalized = PathBuf::new();
let mut has_component = false;
for component in Path::new(trimmed).components() {
match component {
Component::Normal(name) => {
let segment = name.to_string_lossy().trim().to_string();
if segment.is_empty() || segment == "." || segment == ".." {
return None;
}
normalized.push(segment);
has_component = true;
}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return None;
}
}
}
has_component.then_some(normalized)
}
/// 校验并规范化安装目录名(最终落盘目录名,仅单段)
fn sanitize_install_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let path = Path::new(trimmed);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(name)), None) => {
let normalized = name.to_string_lossy().trim().to_string();
if normalized.is_empty()
|| normalized == "."
|| normalized == ".."
|| normalized.starts_with('.')
{
None
} else {
Some(normalized)
}
}
_ => None,
}
}
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
@@ -1078,7 +1287,7 @@ impl SkillService {
let _ = temp_dir.keep();
let mut branches = Vec::new();
if !repo.branch.is_empty() {
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
@@ -1143,9 +1352,12 @@ impl SkillService {
)));
};
// 第一遍:解压普通文件和目录,收集 symlink 条目
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name();
let file_path = file.name().to_string();
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
@@ -1160,7 +1372,12 @@ impl SkillService {
let outpath = dest.join(relative_path);
if file.is_dir() {
if file.is_symlink() {
// 读取 symlink 目标路径
let mut target = String::new();
std::io::Read::read_to_string(&mut file, &mut target)?;
symlinks.push((outpath, target.trim().to_string()));
} else if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
@@ -1171,6 +1388,9 @@ impl SkillService {
}
}
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
Self::resolve_symlinks_in_dir(dest, &symlinks)?;
Ok(())
}
@@ -1193,6 +1413,58 @@ impl SkillService {
Ok(())
}
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
///
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
/// 以确保跨平台兼容且 skill 内容自包含。
fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {
// 规范化 base_dirmacOS 上 /tmp → /private/tmp,需保持一致)
let canonical_base = base_dir
.canonicalize()
.unwrap_or_else(|_| base_dir.to_path_buf());
for (link_path, target) in symlinks {
// 计算 symlink 的父目录,然后拼接目标的相对路径
let parent = link_path.parent().unwrap_or(base_dir);
let resolved = parent.join(target);
// 规范化路径(解析 .. 等)
let resolved = match resolved.canonicalize() {
Ok(p) => p,
Err(_) => {
log::warn!(
"Symlink 目标不存在,跳过: {} -> {}",
link_path.display(),
target
);
continue;
}
};
// 安全检查:确保目标在 base_dir 内(防止路径穿越)
if !resolved.starts_with(&canonical_base) {
log::warn!(
"Symlink 目标超出仓库范围,跳过: {} -> {}",
link_path.display(),
resolved.display()
);
continue;
}
// 复制目标内容到 symlink 位置
if resolved.is_dir() {
Self::copy_dir_recursive(&resolved, link_path)?;
} else if resolved.is_file() {
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&resolved, link_path)?;
}
}
Ok(())
}
// ========== 从 ZIP 文件安装 ==========
/// 从本地 ZIP 文件安装 Skills
@@ -1225,13 +1497,56 @@ impl SkillService {
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
let existing_skills = db.get_all_installed_skills()?;
let zip_stem = zip_path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
for skill_dir in skill_dirs {
// 解析元数据(提前解析,用于确定安装名)
let skill_md = skill_dir.join("SKILL.md");
let meta = if skill_md.exists() {
Self::parse_skill_metadata_static(&skill_md).ok()
} else {
None
};
// 获取目录名称作为安装名
let install_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
let install_name = {
let dir_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') {
// SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名
meta.as_ref()
.and_then(|m| m.name.as_deref())
.and_then(Self::sanitize_install_name)
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
} else {
Self::sanitize_install_name(&dir_name)
.or_else(|| {
meta.as_ref()
.and_then(|m| m.name.as_deref())
.and_then(Self::sanitize_install_name)
})
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
}
};
let install_name = match install_name {
Some(name) => name,
None => {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("zip", &zip_path.display().to_string())],
Some("checkZipContent"),
)));
}
};
// 检查是否已有同名 directory 的 skill
let conflict = existing_skills
@@ -1247,18 +1562,12 @@ impl SkillService {
continue;
}
// 解析元数据
let skill_md = skill_dir.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| install_name.clone()),
meta.description,
),
Err(_) => (install_name.clone(), None),
}
} else {
(install_name.clone(), None)
let (name, description) = match meta {
Some(m) => (
m.name.unwrap_or_else(|| install_name.clone()),
m.description,
),
None => (install_name.clone(), None),
};
// 复制到 SSOT
@@ -1322,6 +1631,8 @@ impl SkillService {
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = match file.enclosed_name() {
@@ -1331,7 +1642,11 @@ impl SkillService {
let outpath = temp_path.join(&file_path);
if file.is_dir() {
if file.is_symlink() {
let mut target = String::new();
std::io::Read::read_to_string(&mut file, &mut target)?;
symlinks.push((outpath, target.trim().to_string()));
} else if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
@@ -1342,6 +1657,9 @@ impl SkillService {
}
}
// 解析 symlink
Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
Ok(temp_path)
}
@@ -1414,38 +1732,109 @@ impl SkillService {
// ========== 迁移支持 ==========
/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
///
/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
fn build_repo_info_from_lock(
lock: &HashMap<String, LockRepoInfo>,
dir_name: &str,
) -> (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
match lock.get(dir_name) {
Some(info) => {
let branch = info.branch.clone();
let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
// 优先使用 lock 文件中的 skillPath,否则回退到 dir_name/SKILL.md
let fallback = format!("{dir_name}/SKILL.md");
let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
let url = Some(SkillService::build_skill_doc_url(
&info.owner,
&info.repo,
&url_branch,
doc_path,
));
(
format!("{}/{}:{dir_name}", info.owner, info.repo),
Some(info.owner.clone()),
Some(info.repo.clone()),
branch,
url,
)
}
None => (format!("local:{dir_name}"), None, None, None, None),
}
}
/// 将 lock 文件中发现的仓库保存到 skill_repos(去重)
fn save_repos_from_lock(
db: &Arc<Database>,
lock: &HashMap<String, LockRepoInfo>,
directories: impl Iterator<Item = impl AsRef<str>>,
) {
let existing_repos: HashSet<(String, String)> = db
.get_skill_repos()
.unwrap_or_default()
.into_iter()
.map(|r| (r.owner, r.name))
.collect();
let mut added = HashSet::new();
for dir_name in directories {
if let Some(info) = lock.get(dir_name.as_ref()) {
let key = (info.owner.clone(), info.repo.clone());
if !existing_repos.contains(&key) && added.insert(key) {
let skill_repo = SkillRepo {
owner: info.owner.clone(),
name: info.repo.clone(),
// 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。
branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
enabled: true,
};
if let Err(e) = db.save_skill_repo(&skill_repo) {
log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e);
} else {
log::info!(
"从 agents lock 文件发现并添加仓库: {}/{} ({})",
info.owner,
info.repo,
skill_repo.branch
);
}
}
}
}
}
/// 首次启动迁移:扫描应用目录,重建数据库
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
let ssot_dir = SkillService::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
// 扫描各应用目录
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in AppType::all() {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
};
if !app_dir.exists() {
continue;
}
let entries = match fs::read_dir(&app_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in fs::read_dir(&app_dir)? {
let entry = entry?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
// 跳过隐藏目录(以 . 开头,如 .system)
if dir_name.starts_with('.') {
continue;
}
@@ -1456,7 +1845,6 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
SkillService::copy_dir_recursive(&path, &ssot_path)?;
}
// 记录启用状态
discovered
.entry(dir_name)
.or_default()
@@ -1467,32 +1855,28 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
// 重建数据库
db.clear_skills()?;
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(db, &agents_lock, discovered.keys());
let mut count = 0;
for (directory, apps) in discovered {
let ssot_path = ssot_dir.join(&directory);
let skill_md = ssot_path.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match SkillService::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| directory.clone()),
meta.description,
),
Err(_) => (directory.clone(), None),
}
} else {
(directory.clone(), None)
};
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &directory);
let skill = InstalledSkill {
id: format!("local:{directory}"),
id,
name,
description,
directory,
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
repo_owner,
repo_name,
repo_branch,
readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
+85 -40
View File
@@ -8,6 +8,7 @@ use std::time::Duration;
use crate::error::AppError;
use crate::proxy::http_client;
use futures::StreamExt;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip).
@@ -237,15 +238,7 @@ pub async fn put_bytes(
)
.send()
.await
.map_err(|e| {
webdav_transport_error(
"webdav.put_failed",
"PUT 请求",
"PUT request",
url,
&e,
)
})?;
.map_err(|e| webdav_transport_error("webdav.put_failed", "PUT 请求", "PUT request", url, &e))?;
if resp.status().is_success() {
return Ok(());
@@ -259,6 +252,7 @@ pub async fn put_bytes(
pub async fn get_bytes(
url: &str,
auth: &WebDavAuth,
max_bytes: usize,
) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {
let client = http_client::get();
let resp = apply_auth(
@@ -269,15 +263,7 @@ pub async fn get_bytes(
)
.send()
.await
.map_err(|e| {
webdav_transport_error(
"webdav.get_failed",
"GET 请求",
"GET request",
url,
&e,
)
})?;
.map_err(|e| webdav_transport_error("webdav.get_failed", "GET 请求", "GET request", url, &e))?;
if resp.status() == StatusCode::NOT_FOUND {
return Ok(None);
@@ -285,22 +271,29 @@ pub async fn get_bytes(
if !resp.status().is_success() {
return Err(webdav_status_error("GET", resp.status(), url));
}
ensure_content_length_within_limit(resp.headers(), max_bytes, url)?;
let etag = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = resp
.bytes()
.await
.map_err(|e| {
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
AppError::localized(
"webdav.response_read_failed",
format!("读取 WebDAV 响应失败: {e}"),
format!("Failed to read WebDAV response: {e}"),
)
})?;
Ok(Some((bytes.to_vec(), etag)))
if bytes.len().saturating_add(chunk.len()) > max_bytes {
return Err(response_too_large_error(url, max_bytes));
}
bytes.extend_from_slice(&chunk);
}
Ok(Some((bytes, etag)))
}
/// HEAD request to retrieve the ETag. Returns `None` on 404.
@@ -315,13 +308,7 @@ pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result<Option<String>, A
.send()
.await
.map_err(|e| {
webdav_transport_error(
"webdav.head_failed",
"HEAD 请求",
"HEAD request",
url,
&e,
)
webdav_transport_error("webdav.head_failed", "HEAD 请求", "HEAD request", url, &e)
})?;
if resp.status() == StatusCode::NOT_FOUND {
@@ -386,9 +373,7 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
if jgy {
zh.push_str(
"。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。",
);
zh.push_str("。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。");
en.push_str(
". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.",
);
@@ -401,9 +386,7 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError
en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory.");
} else if op == "MKCOL" && status == StatusCode::CONFLICT {
if jgy {
zh.push_str(
"。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。",
);
zh.push_str("。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。");
en.push_str(
". Jianguoyun does not allow creating top-level folders automatically; create it manually first.",
);
@@ -446,9 +429,47 @@ fn redact_url(raw: &str) -> String {
}
}
fn response_too_large_error(url: &str, max_bytes: usize) -> AppError {
let max_mb = max_bytes / 1024 / 1024;
AppError::localized(
"webdav.response_too_large",
format!(
"WebDAV 响应体超过上限({} MB: {}",
max_mb,
redact_url(url)
),
format!(
"WebDAV response body exceeds limit ({} MB): {}",
max_mb,
redact_url(url)
),
)
}
fn ensure_content_length_within_limit(
headers: &reqwest::header::HeaderMap,
max_bytes: usize,
url: &str,
) -> Result<(), AppError> {
let Some(content_length) = headers.get(reqwest::header::CONTENT_LENGTH) else {
return Ok(());
};
let Ok(raw) = content_length.to_str() else {
return Ok(());
};
let Ok(value) = raw.parse::<u64>() else {
return Ok(());
};
if value > max_bytes as u64 {
return Err(response_too_large_error(url, max_bytes));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};
#[test]
fn build_remote_url_encodes_path_segments() {
@@ -498,10 +519,34 @@ mod tests {
#[test]
fn redact_url_hides_credentials_and_query_values() {
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
assert_eq!(
redacted,
"https://example.com:8443/dav?[keys:foo,token]"
);
assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]");
assert!(!redacted.contains("secret"));
}
#[test]
fn ensure_content_length_within_limit_accepts_missing_or_small_values() {
let empty = HeaderMap::new();
assert!(
ensure_content_length_within_limit(&empty, 1024, "https://dav.example.com").is_ok()
);
let mut small = HeaderMap::new();
small.insert(CONTENT_LENGTH, HeaderValue::from_static("1024"));
assert!(
ensure_content_length_within_limit(&small, 1024, "https://dav.example.com").is_ok()
);
}
#[test]
fn ensure_content_length_within_limit_rejects_oversized_values() {
let mut large = HeaderMap::new();
large.insert(CONTENT_LENGTH, HeaderValue::from_static("2048"));
let err = ensure_content_length_within_limit(&large, 1024, "https://dav.example.com")
.expect_err("oversized response should be rejected");
assert!(
err.to_string().contains("too large") || err.to_string().contains("超过"),
"unexpected error: {err}"
);
}
}
+277
View File
@@ -0,0 +1,277 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use serde_json::json;
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::{channel, Receiver, Sender};
use crate::error::AppError;
use crate::services::webdav_sync as webdav_sync_service;
use crate::settings::{self, WebDavSyncSettings};
const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;
pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;
static DB_CHANGE_TX: OnceLock<Sender<String>> = OnceLock::new();
static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);
pub(crate) struct AutoSyncSuppressionGuard;
impl AutoSyncSuppressionGuard {
pub fn new() -> Self {
AUTO_SYNC_SUPPRESS_DEPTH.fetch_add(1, Ordering::SeqCst);
Self
}
}
impl Drop for AutoSyncSuppressionGuard {
fn drop(&mut self) {
let _ =
AUTO_SYNC_SUPPRESS_DEPTH.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| {
Some(value.saturating_sub(1))
});
}
}
pub(crate) fn is_auto_sync_suppressed() -> bool {
AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0
}
pub fn should_trigger_for_table(table: &str) -> bool {
let normalized = table.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"providers"
| "provider_endpoints"
| "mcp_servers"
| "prompts"
| "skills"
| "skill_repos"
| "settings"
| "proxy_config"
)
}
pub(crate) fn enqueue_change_signal(tx: &Sender<String>, table: &str) -> bool {
match tx.try_send(table.to_string()) {
Ok(()) => true,
Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,
}
}
pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option<Duration> {
let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);
let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);
let elapsed = now.saturating_duration_since(started_at);
if elapsed >= max_wait {
return None;
}
Some(debounce.min(max_wait - elapsed))
}
fn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool {
let Some(sync) = settings else {
return false;
};
sync.enabled && sync.auto_sync
}
fn persist_auto_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
settings.status.last_error = Some(error.to_string());
settings.status.last_error_source = Some("auto".to_string());
let _ = settings::update_webdav_sync_status(settings.status.clone());
}
fn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) {
let payload = match error {
Some(message) => json!({
"source": "auto",
"status": status,
"error": message,
}),
None => json!({
"source": "auto",
"status": status,
}),
};
if let Err(err) = app.emit("webdav-sync-status-updated", payload) {
log::debug!("[WebDAV] failed to emit sync status update event: {err}");
}
}
async fn run_auto_sync_upload(
db: &crate::database::Database,
app: &AppHandle,
) -> Result<(), AppError> {
let mut settings = settings::get_webdav_sync_settings();
if !should_run_auto_sync(settings.as_ref()) {
return Ok(());
}
let mut sync_settings = match settings.take() {
Some(value) => value,
None => return Ok(()),
};
let result = webdav_sync_service::run_with_sync_lock(webdav_sync_service::upload(
db,
&mut sync_settings,
))
.await;
match result {
Ok(_) => {
emit_auto_sync_status_updated(app, "success", None);
Ok(())
}
Err(err) => {
persist_auto_sync_error(&mut sync_settings, &err);
emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));
Err(err)
}
}
}
pub fn notify_db_changed(table: &str) {
if is_auto_sync_suppressed() {
return;
}
if !should_trigger_for_table(table) {
return;
}
let Some(tx) = DB_CHANGE_TX.get() else {
return;
};
let _ = enqueue_change_signal(tx, table);
}
pub fn start_worker(db: Arc<crate::database::Database>, app: tauri::AppHandle) {
if DB_CHANGE_TX.get().is_some() {
return;
}
// Buffer size 1 is enough: we only need "dirty" signals, not every event.
let (tx, rx) = channel::<String>(1);
if DB_CHANGE_TX.set(tx).is_err() {
return;
}
tauri::async_runtime::spawn(async move {
run_worker_loop(db, rx, app).await;
});
}
async fn run_worker_loop(
db: Arc<crate::database::Database>,
mut rx: Receiver<String>,
app: tauri::AppHandle,
) {
while let Some(first_table) = rx.recv().await {
let started_at = Instant::now();
let mut merged_count = 1usize;
loop {
let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else {
break;
};
let timeout = tokio::time::timeout(wait_for, rx.recv()).await;
match timeout {
Ok(Some(_)) => merged_count += 1,
Ok(None) => return,
Err(_) => break,
}
}
log::debug!(
"[WebDAV][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}"
);
if let Err(err) = run_auto_sync_upload(&db, &app).await {
log::warn!("[WebDAV][AutoSync] Upload failed: {err}");
}
}
}
#[cfg(test)]
mod tests {
use super::{
auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,
should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,
MAX_AUTO_SYNC_WAIT_MS,
};
use crate::settings::WebDavSyncSettings;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::channel;
#[test]
fn should_trigger_sync_for_config_tables_only() {
assert!(should_trigger_for_table("providers"));
assert!(should_trigger_for_table("settings"));
assert!(!should_trigger_for_table("proxy_request_logs"));
assert!(!should_trigger_for_table("provider_health"));
}
#[test]
fn suppression_guard_enables_and_restores_state() {
assert!(!is_auto_sync_suppressed());
{
let _guard = AutoSyncSuppressionGuard::new();
assert!(is_auto_sync_suppressed());
}
assert!(!is_auto_sync_suppressed());
}
#[test]
fn max_wait_caps_flush_latency_for_continuous_events() {
let started = Instant::now();
let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);
assert!(auto_sync_wait_duration(started, later).is_none());
}
#[tokio::test]
async fn enqueue_change_signal_drops_when_channel_is_full() {
let (tx, _rx) = channel::<String>(1);
assert!(enqueue_change_signal(&tx, "providers"));
assert!(!enqueue_change_signal(&tx, "providers"));
}
#[test]
fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() {
assert!(!should_run_auto_sync(None));
let disabled = WebDavSyncSettings {
enabled: false,
auto_sync: true,
..WebDavSyncSettings::default()
};
assert!(!should_run_auto_sync(Some(&disabled)));
let auto_sync_off = WebDavSyncSettings {
enabled: true,
auto_sync: false,
..WebDavSyncSettings::default()
};
assert!(!should_run_auto_sync(Some(&auto_sync_off)));
let enabled = WebDavSyncSettings {
enabled: true,
auto_sync: true,
..WebDavSyncSettings::default()
};
assert!(should_run_auto_sync(Some(&enabled)));
}
#[test]
fn service_layer_does_not_depend_on_commands_layer() {
let source = include_str!("webdav_auto_sync.rs");
let needle = ["crate", "commands", ""].join("::");
assert!(
!source.contains(&needle),
"services layer should not depend on commands layer"
);
}
}
+87 -32
View File
@@ -5,7 +5,9 @@
use std::collections::BTreeMap;
use std::fs;
use std::future::Future;
use std::process::Command;
use std::sync::OnceLock;
use chrono::Utc;
use serde::{Deserialize, Serialize};
@@ -33,6 +35,21 @@ const REMOTE_DB_SQL: &str = "db.sql";
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
const REMOTE_MANIFEST: &str = "manifest.json";
const MAX_DEVICE_NAME_LEN: usize = 64;
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
pub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
pub async fn run_with_sync_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
let _guard = sync_mutex().lock().await;
operation.await
}
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
AppError::localized(key, zh, en)
@@ -145,13 +162,15 @@ pub async fn download(
let auth = auth_for(settings);
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth).await?.ok_or_else(|| {
localized(
"webdav.sync.remote_empty",
"远端没有可下载的同步数据",
"No downloadable sync data found on the remote.",
)
})?;
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
.await?
.ok_or_else(|| {
localized(
"webdav.sync.remote_empty",
"远端没有可下载的同步数据",
"No downloadable sync data found on the remote.",
)
})?;
let manifest: SyncManifest =
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
@@ -181,7 +200,7 @@ pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<V
let auth = auth_for(settings);
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
let Some((bytes, _)) = get_bytes(&manifest_url, &auth).await? else {
let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else {
return Ok(None);
};
@@ -214,6 +233,7 @@ fn persist_sync_success(
let status = WebDavSyncStatus {
last_sync_at: Some(Utc::now().timestamp()),
last_error: None,
last_error_source: None,
last_local_manifest_hash: Some(manifest_hash.clone()),
last_remote_manifest_hash: Some(manifest_hash),
last_remote_etag: etag,
@@ -319,14 +339,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
}
fn detect_system_device_name() -> Option<String> {
let env_name = [
"CC_SWITCH_DEVICE_NAME",
"COMPUTERNAME",
"HOSTNAME",
]
.iter()
.filter_map(|key| std::env::var(key).ok())
.find_map(|value| normalize_device_name(&value));
let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"]
.iter()
.filter_map(|key| std::env::var(key).ok())
.find_map(|value| normalize_device_name(&value));
if env_name.is_some() {
return env_name;
@@ -341,21 +357,26 @@ fn detect_system_device_name() -> Option<String> {
}
fn normalize_device_name(raw: &str) -> Option<String> {
let compact = raw.chars().fold(String::with_capacity(raw.len()), |mut acc, ch| {
if ch.is_whitespace() {
acc.push(' ');
} else if !ch.is_control() {
acc.push(ch);
}
acc
});
let compact = raw
.chars()
.fold(String::with_capacity(raw.len()), |mut acc, ch| {
if ch.is_whitespace() {
acc.push(' ');
} else if !ch.is_control() {
acc.push(ch);
}
acc
});
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
if trimmed.is_empty() {
return None;
}
let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::<String>();
let limited = trimmed
.chars()
.take(MAX_DEVICE_NAME_LEN)
.collect::<String>();
if limited.is_empty() {
None
} else {
@@ -405,14 +426,18 @@ async fn download_and_verify(
format!("Manifest missing artifact: {artifact_name}"),
)
})?;
validate_artifact_size_limit(artifact_name, meta.size)?;
let url = remote_file_url(settings, artifact_name)?;
let (bytes, _) = get_bytes(&url, auth).await?.ok_or_else(|| {
localized(
"webdav.sync.remote_missing_artifact",
format!("远端缺少 artifact 文件: {artifact_name}"),
format!("Remote artifact file missing: {artifact_name}"),
)
})?;
let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
.await?
.ok_or_else(|| {
localized(
"webdav.sync.remote_missing_artifact",
format!("远端缺少 artifact 文件: {artifact_name}"),
format!("Remote artifact file missing: {artifact_name}"),
)
})?;
// Quick size check before expensive hash
if bytes.len() as u64 != meta.size {
@@ -503,6 +528,21 @@ fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
auth_from_credentials(&settings.username, &settings.password)
}
fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> {
if size > MAX_SYNC_ARTIFACT_BYTES {
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
return Err(localized(
"webdav.sync.artifact_too_large",
format!("artifact {artifact_name} 超过下载上限({} MB", max_mb),
format!(
"Artifact {artifact_name} exceeds download limit ({} MB)",
max_mb
),
));
}
Ok(())
}
// ─── Tests ───────────────────────────────────────────────────
#[cfg(test)]
@@ -646,4 +686,19 @@ mod tests {
"manifest should not contain deviceId"
);
}
#[test]
fn validate_artifact_size_limit_rejects_oversized_artifacts() {
let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1)
.expect_err("artifact larger than limit should be rejected");
assert!(
err.to_string().contains("too large") || err.to_string().contains("超过"),
"unexpected error: {err}"
);
}
#[test]
fn validate_artifact_size_limit_accepts_limit_boundary() {
assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok());
}
}
+79 -15
View File
@@ -10,10 +10,8 @@ use zip::DateTime;
use crate::error::AppError;
use crate::services::skill::SkillService;
use super::{io_context_localized, localized, REMOTE_SKILLS_ZIP};
use super::{io_context_localized, localized, MAX_SYNC_ARTIFACT_BYTES, REMOTE_SKILLS_ZIP};
/// Maximum total bytes allowed during zip extraction (512 MB).
const MAX_EXTRACT_BYTES: u64 = 512 * 1024 * 1024;
/// Maximum number of entries allowed in a zip archive.
const MAX_EXTRACT_ENTRIES: usize = 10_000;
@@ -92,8 +90,14 @@ pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
if archive.len() > MAX_EXTRACT_ENTRIES {
return Err(localized(
"webdav.sync.skills_zip_too_many_entries",
format!("skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}", archive.len()),
format!("skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}", archive.len()),
format!(
"skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}",
archive.len()
),
format!(
"skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}",
archive.len()
),
));
}
@@ -118,15 +122,13 @@ pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?;
let written = std::io::copy(&mut entry, &mut out).map_err(|e| AppError::io(&out_path, e))?;
total_bytes += written;
if total_bytes > MAX_EXTRACT_BYTES {
return Err(localized(
"webdav.sync.skills_zip_too_large",
format!("skills.zip 解压后体积超过上限({} MB", MAX_EXTRACT_BYTES / 1024 / 1024),
format!("skills.zip extracted size exceeds limit ({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
));
}
let _written = copy_entry_with_total_limit(
&mut entry,
&mut out,
&mut total_bytes,
MAX_SYNC_ARTIFACT_BYTES,
&out_path,
)?;
}
let ssot = SkillService::get_ssot_dir().map_err(|e| {
@@ -327,10 +329,47 @@ fn mark_visited_dir(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<bool,
Ok(visited.insert(canonical))
}
fn copy_entry_with_total_limit<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
total_bytes: &mut u64,
max_total_bytes: u64,
out_path: &Path,
) -> Result<u64, AppError> {
let mut buffer = [0u8; 16 * 1024];
let mut written = 0u64;
loop {
let n = reader
.read(&mut buffer)
.map_err(|e| AppError::io(out_path, e))?;
if n == 0 {
break;
}
if total_bytes.saturating_add(n as u64) > max_total_bytes {
let max_mb = max_total_bytes / 1024 / 1024;
return Err(localized(
"webdav.sync.skills_zip_too_large",
format!("skills.zip 解压后体积超过上限({} MB", max_mb),
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
));
}
writer
.write_all(&buffer[..n])
.map_err(|e| AppError::io(out_path, e))?;
*total_bytes += n as u64;
written += n as u64;
}
Ok(written)
}
#[cfg(test)]
mod tests {
use super::mark_visited_dir;
use super::{copy_entry_with_total_limit, mark_visited_dir};
use std::collections::HashSet;
use std::io::Cursor;
use std::path::Path;
use tempfile::tempdir;
#[test]
@@ -343,4 +382,29 @@ mod tests {
assert!(mark_visited_dir(&dir, &mut visited).expect("first visit"));
assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit"));
}
#[test]
fn copy_entry_with_total_limit_rejects_oversized_stream_before_write() {
let mut reader = Cursor::new(vec![1u8; 16]);
let mut writer = Vec::new();
let mut total_bytes = 0u64;
let err = copy_entry_with_total_limit(
&mut reader,
&mut writer,
&mut total_bytes,
8,
Path::new("skills-extracted/file.bin"),
)
.expect_err("stream larger than limit should be rejected");
assert!(
err.to_string().contains("too large") || err.to_string().contains("超过"),
"unexpected error: {err}"
);
assert_eq!(
writer.len(),
0,
"should not write when the first chunk exceeds limit"
);
}
}
+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);
}
}
}
+54
View File
@@ -72,6 +72,8 @@ pub struct WebDavSyncStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_local_manifest_hash: Option<String>,
@@ -93,6 +95,8 @@ pub struct WebDavSyncSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_sync: bool,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub username: String,
@@ -110,6 +114,7 @@ impl Default for WebDavSyncSettings {
fn default() -> Self {
Self {
enabled: false,
auto_sync: false,
base_url: String::new(),
username: String::new(),
password: String::new(),
@@ -182,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>,
@@ -231,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"
@@ -257,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,
@@ -272,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,
}
}
@@ -606,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"
);
}
+325 -213
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import {
Plus,
@@ -34,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";
@@ -60,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";
@@ -81,6 +86,12 @@ type View =
| "openclawTools"
| "openclawAgents";
interface WebDavSyncStatusUpdatedPayload {
source?: string;
status?: string;
error?: string;
}
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
@@ -169,7 +180,10 @@ function App() {
if (
currentView === "sessions" &&
activeApp !== "claude" &&
activeApp !== "codex"
activeApp !== "codex" &&
activeApp !== "opencode" &&
activeApp !== "openclaw" &&
activeApp !== "gemini"
) {
setCurrentView("providers");
}
@@ -187,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);
@@ -213,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,
@@ -241,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;
@@ -292,6 +331,50 @@ function App() {
};
}, [queryClient]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let active = true;
const setupListener = async () => {
try {
const off = await listen(
"webdav-sync-status-updated",
async (event) => {
const payload = (event.payload ??
{}) as WebDavSyncStatusUpdatedPayload;
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (payload.source !== "auto" || payload.status !== "error") {
return;
}
toast.error(
t("settings.webdavSync.autoSyncFailedToast", {
error: payload.error || t("common.unknown"),
}),
);
},
);
if (!active) {
off();
return;
}
unsubscribe = off;
} catch (error) {
console.error(
"[App] Failed to subscribe webdav-sync-status-updated event",
error,
);
}
};
void setupListener();
return () => {
active = false;
unsubscribe?.();
};
}, [queryClient, t]);
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
@@ -647,7 +730,7 @@ function App() {
);
case "sessions":
return <SessionManagerPage />;
return <SessionManagerPage key={activeApp} appId={activeApp} />;
case "workspace":
return <WorkspaceFilesPanel />;
case "openclawEnv":
@@ -695,6 +778,11 @@ function App() {
onDisableOmo={
activeApp === "opencode" ? handleDisableOmo : undefined
}
onDisableOmoSlim={
activeApp === "opencode"
? handleDisableOmoSlim
: undefined
}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
@@ -877,228 +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} />
<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();
@@ -219,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}
@@ -1,174 +0,0 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, 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();
}, []);
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>
)}
<JsonEditor
value={value}
onChange={onChange}
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,
};
}
-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>
+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;
};
+192 -11
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,8 +465,64 @@ 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">
@@ -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>
);
}
@@ -38,7 +38,7 @@ export function DirectorySettings({
const { t } = useTranslation();
return (
<>
<div className="space-y-6">
{/* CC Switch 配置目录 - 独立区块 */}
<section className="space-y-4">
<header className="space-y-1">
@@ -131,7 +131,7 @@ export function DirectorySettings({
onReset={() => onResetDirectory("opencode")}
/>
</section>
</>
</div>
);
}
@@ -53,7 +53,7 @@ export function ImportExportSection({
</p>
</header>
<div className="space-y-4 rounded-xl glass-card p-6 border border-white/10">
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
{/* Import and Export Buttons Side by Side */}
<div className="grid grid-cols-2 gap-4 items-stretch">
{/* Import Button */}
+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>
@@ -16,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -162,6 +163,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
password: config?.password ?? "",
remoteRoot: config?.remoteRoot ?? "cc-switch-sync",
profile: config?.profile ?? "default",
autoSync: config?.autoSync ?? false,
}));
// Preset selector — derived from initial URL, updated on user selection
@@ -196,6 +198,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
password: config.password ?? "",
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
profile: config.profile ?? "default",
autoSync: config.autoSync ?? false,
});
setPasswordTouched(false);
setPresetId(detectPreset(config.baseUrl ?? ""));
@@ -237,6 +240,16 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
}
}, [form.baseUrl, presetId]);
const handleAutoSyncChange = useCallback((checked: boolean) => {
setForm((prev) => ({ ...prev, autoSync: checked }));
setDirty(true);
setJustSaved(false);
if (justSavedTimerRef.current) {
clearTimeout(justSavedTimerRef.current);
justSavedTimerRef.current = null;
}
}, []);
const buildSettings = useCallback((): WebDavSyncSettings | null => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) return null;
@@ -247,6 +260,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
password: form.password,
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
profile: form.profile.trim() || "default",
autoSync: form.autoSync,
};
}, [form]);
@@ -433,6 +447,9 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
const lastSyncDisplay = lastSyncAt
? new Date(lastSyncAt * 1000).toLocaleString()
: null;
const lastError = config?.status?.lastError?.trim();
const showAutoSyncError =
!!lastError && config?.status?.lastErrorSource === "auto";
// ─── Render ─────────────────────────────────────────────
@@ -559,6 +576,23 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
disabled={isLoading}
/>
</div>
<div className="flex items-start gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.autoSync")}
<span className="block text-[10px] font-normal text-muted-foreground">
{t("settings.webdavSync.autoSyncHint")}
</span>
</label>
<div className="pt-1">
<Switch
checked={form.autoSync}
onCheckedChange={handleAutoSyncChange}
aria-label={t("settings.webdavSync.autoSync")}
disabled={isLoading}
/>
</div>
</div>
</div>
{/* Last sync time */}
@@ -567,6 +601,17 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
</p>
)}
{showAutoSyncError && (
<div className="rounded-lg border border-red-300/70 bg-red-50/80 px-3 py-2 text-xs text-red-900 dark:border-red-500/50 dark:bg-red-950/30 dark:text-red-200">
<p className="font-medium">
{t("settings.webdavSync.autoSyncLastErrorTitle")}
</p>
<p className="mt-1 break-all whitespace-pre-wrap">{lastError}</p>
<p className="mt-1 text-[11px] text-red-700/90 dark:text-red-300/80">
{t("settings.webdavSync.autoSyncLastErrorHint")}
</p>
</div>
)}
{/* Config buttons + save status */}
<div className="flex flex-wrap items-center gap-3 pt-2">
+5 -7
View File
@@ -213,14 +213,12 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const query = searchQuery.toLowerCase();
return byStatus.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
const description = skill.description?.toLowerCase() || "";
const directory = skill.directory?.toLowerCase() || "";
const repo =
skill.repoOwner && skill.repoName
? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
: "";
return (
name.includes(query) ||
description.includes(query) ||
directory.includes(query)
);
return name.includes(query) || repo.includes(query);
});
}, [skills, searchQuery, filterRepo, filterStatus]);
+10 -4
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">
@@ -306,6 +308,7 @@ interface ImportSkillsDialogProps {
name: string;
description?: string;
foundIn: string[];
path: string;
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
@@ -362,8 +365,11 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
{skill.description}
</div>
)}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
<div
className="text-xs text-muted-foreground/50 mt-1 truncate"
title={skill.path}
>
{skill.path}
</div>
</div>
</label>
+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,298 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Calendar, Trash2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { workspaceApi, type DailyMemoryFileInfo } 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);
// 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();
}, []);
// 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();
} catch (err) {
console.error("Failed to delete daily memory file:", err);
toast.error(t("workspace.dailyMemory.deleteFailed"));
setDeletingFile(null);
}
}, [deletingFile, editingFile, loadFiles, t]);
// Back from edit mode to list mode
const handleBackToList = useCallback(() => {
setEditingFile(null);
setContent("");
void loadFiles();
}, [loadFiles]);
// Close panel entirely
const handleClose = useCallback(() => {
setEditingFile(null);
setContent("");
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 and create button */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
~/.openclaw/workspace/memory/
</p>
<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>
{/* 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,13 @@ import {
Power,
CheckCircle2,
Circle,
Calendar,
ChevronRight,
} 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 +54,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> = {};
@@ -115,6 +119,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 +147,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",
+1 -3
View File
@@ -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: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
+13
View File
@@ -1129,4 +1129,17 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
iconColor: "#8B5CF6",
isCustomTemplate: true,
},
{
name: "Oh My OpenCode Slim",
websiteUrl: "https://github.com/alvinunreal/oh-my-opencode-slim",
settingsConfig: {
npm: "",
options: {},
models: {},
},
category: "omo-slim" as ProviderCategory,
icon: "opencode",
iconColor: "#6366F1",
isCustomTemplate: true,
},
];
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState, type RefObject } from "react";
/**
* Detects whether the container's children overflow the available width
* and returns a `compact` flag for the AppSwitcher.
*
* Uses ResizeObserver on a flex-constrained container. The container
* must have `flex-1 min-w-0 overflow-hidden` so its width is determined
* by the parent layout, not its own content avoiding the oscillation
* problem when toggling compact mode.
*/
export function useAutoCompact(
containerRef: RefObject<HTMLDivElement | null>,
): boolean {
const [compact, setCompact] = useState(false);
const normalWidthRef = useRef(0);
const lockUntilRef = useRef(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
// During expand animation, ignore resize events to prevent flicker
if (Date.now() < lockUntilRef.current) return;
if (!compact) {
// Cache the total content width in normal mode
normalWidthRef.current = el.scrollWidth;
// Overflow detected → switch to compact
if (el.scrollWidth > el.clientWidth + 1) {
setCompact(true);
}
} else if (normalWidthRef.current > 0) {
// In compact mode: only recover to normal if
// available space >= what normal mode needed
if (el.clientWidth >= normalWidthRef.current) {
// Lock out resize events during the expand animation (200ms + 50ms margin)
lockUntilRef.current = Date.now() + 250;
setCompact(false);
}
}
});
ro.observe(el);
return () => ro.disconnect();
}, [compact]);
return compact;
}
+45
View File
@@ -0,0 +1,45 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { backupsApi } from "@/lib/api";
export function useBackupManager() {
const queryClient = useQueryClient();
const {
data: backups = [],
isLoading,
refetch,
} = useQuery({
queryKey: ["db-backups"],
queryFn: () => backupsApi.listDbBackups(),
});
const restoreMutation = useMutation({
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
onSuccess: async () => {
// Invalidate all queries to refresh data from restored database
await queryClient.invalidateQueries();
// Refetch backup list
await refetch();
},
});
const renameMutation = useMutation({
mutationFn: ({
oldFilename,
newName,
}: {
oldFilename: string;
newName: string;
}) => backupsApi.renameDbBackup(oldFilename, newName),
onSuccess: () => refetch(),
});
return {
backups,
isLoading,
restore: restoreMutation.mutateAsync,
isRestoring: restoreMutation.isPending,
rename: renameMutation.mutateAsync,
isRenaming: renameMutation.isPending,
};
}

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