From 97b6f82e767c9135373ef8c0fd8a4a93025bfb93 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 14:40:16 +0800 Subject: [PATCH 01/29] feat(settings): add advanced settings navigation tabs --- src/i18n/ja.ts | 5 +++- src/i18n/resources.ts | 10 +++++-- src/i18n/traditional.ts | 1 + src/pages/ConfigPanel.tsx | 55 +++++++++++++++++++++++++++++++++++---- src/styles.css | 20 ++++++++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 13ea72c..e47bd5f 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -11,7 +11,7 @@ export const jaOverrides = { 'app.nav.home': 'ホーム', 'app.nav.versions': 'バージョン管理', 'app.nav.kernel': 'コア', - 'app.nav.config': '設定', + 'app.nav.config': '詳細設定', 'app.nav.thinkingAliases': 'モデルエイリアス', 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API 接続', @@ -241,6 +241,9 @@ export const jaOverrides = { 'aliases.delete': '{alias} を削除', 'config.routing.roundRobin': 'ラウンドロビン', 'config.routing.fillFirst': '優先充填', + 'config.tabs.label': '詳細設定ページ', + 'config.tabs.general': '基本設定', + 'config.tabs.network': 'ネットワークとルーティング', 'config.error.emptyKey': '認証キーを入力してください', 'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます', 'config.error.duplicateKey': '同じ認証キーがすでに存在します', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 68817ec..1070744 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -14,7 +14,7 @@ export const zhCN = { 'app.nav.home': '首页', 'app.nav.versions': '版本管理', 'app.nav.kernel': '内核', - 'app.nav.config': '配置', + 'app.nav.config': '高级设置', 'app.nav.thinkingAliases': '模型别名', 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API 接入', @@ -244,6 +244,9 @@ export const zhCN = { 'aliases.delete': '删除 {alias}', 'config.routing.roundRobin': '轮询', 'config.routing.fillFirst': '优先填充', + 'config.tabs.label': '高级设置子页面', + 'config.tabs.general': '基础设置', + 'config.tabs.network': '网络与路由', 'config.error.emptyKey': '鉴权密钥不能为空', 'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格', 'config.error.duplicateKey': '该鉴权密钥已经存在', @@ -861,7 +864,7 @@ export const en: Record = { 'app.nav.home': 'Home', 'app.nav.versions': 'Version Management', 'app.nav.kernel': 'Core', - 'app.nav.config': 'Settings', + 'app.nav.config': 'Advanced Settings', 'app.nav.thinkingAliases': 'Model Aliases', 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API Access', @@ -1091,6 +1094,9 @@ export const en: Record = { 'aliases.delete': 'Delete {alias}', 'config.routing.roundRobin': 'Round Robin', 'config.routing.fillFirst': 'Fill First', + 'config.tabs.label': 'Advanced settings sections', + 'config.tabs.general': 'General Settings', + 'config.tabs.network': 'Network & Routing', 'config.error.emptyKey': 'The authentication key cannot be empty', 'config.error.invalidKey': 'Use visible ASCII characters only, without spaces', 'config.error.duplicateKey': 'This authentication key already exists', diff --git a/src/i18n/traditional.ts b/src/i18n/traditional.ts index 3bad967..413383f 100644 --- a/src/i18n/traditional.ts +++ b/src/i18n/traditional.ts @@ -1,4 +1,5 @@ const phraseReplacements: ReadonlyArray = [ + ['高级设置', '進階設定'], ['繁体中文', '繁體中文'], ['简体中文', '簡體中文'], ['智能体', '智慧代理'], diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index fb19da1..46fa45a 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -48,6 +48,7 @@ type ConfigAction = | 'network' | null; type NoticeTone = 'success' | 'error'; +type ConfigSubpage = 'general' | 'network'; const ROUTING_OPTIONS = [ { value: 'round-robin', labelKey: 'config.routing.roundRobin' }, @@ -70,6 +71,7 @@ export function ConfigPanelPage() { const [formError, setFormError] = useState(''); const [copiedIndex, setCopiedIndex] = useState(null); const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null); + const [activeSubpage, setActiveSubpage] = useState('general'); const [portDraft, setPortDraft] = useState('8317'); const [allowLanDraft, setAllowLanDraft] = useState(false); const [proxyUrlDraft, setProxyUrlDraft] = useState(''); @@ -351,7 +353,41 @@ export function ConfigPanelPage() { return (
-
+
+ + +
+ + {activeSubpage === 'general' ? ( +
+
@@ -454,7 +490,7 @@ export function ConfigPanelPage() {
-
+
@@ -485,7 +521,16 @@ export function ConfigPanelPage() {
- +
+
+
+ ) : ( +
@@ -516,8 +561,6 @@ export function ConfigPanelPage() { ))}
-
-
@@ -658,6 +701,8 @@ export function ConfigPanelPage() {
+
+ )} {addDialogOpen ? (
{ diff --git a/src/styles.css b/src/styles.css index bf2fc06..6676124 100644 --- a/src/styles.css +++ b/src/styles.css @@ -861,6 +861,16 @@ input:focus-visible { height: 350px; } +.config-subpage-panel { + display: grid; + min-width: 0; + gap: 18px; +} + +.config-network-subpage > .config-setting-panel { + width: min(420px, 100%); +} + .control-panel { grid-column: auto; } @@ -1503,6 +1513,12 @@ input:focus-visible { height: 350px; } +.config-side-stack.single { + grid-template-rows: 166px; + align-content: start; + height: 166px; +} + .config-setting-panel { grid-template-rows: 42px minmax(0, 1fr); gap: 16px; @@ -6725,6 +6741,10 @@ input:focus-visible { height: 166px; } + .config-side-stack.single { + grid-template-columns: 1fr; + } + .control-panel, .version-panel { grid-column: auto; From 473c19a5ae35271d4b285f2ddd35db1b285e5130 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 14:47:58 +0800 Subject: [PATCH 02/29] refactor(settings): move model aliases into advanced settings --- src/App.tsx | 8 -------- src/pages/ConfigPanel.tsx | 26 ++++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 60fb4c3..af9163b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import { ExternalLink, FileKey, Gauge, - GitFork, History, House, Languages, @@ -29,7 +28,6 @@ import { KernelPage } from './pages/Kernel'; import { OAuthLoginPage } from './pages/ManagementPages'; import { QuotaPage } from './pages/QuotaPage'; import { AgentsPage } from './pages/AgentsPage'; -import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; import { languageOptions, useI18n } from './i18n'; import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate'; @@ -58,12 +56,6 @@ const pages = [ icon: Settings, component: ConfigPanelPage, }, - { - id: 'thinking-aliases', - labelKey: 'app.nav.thinkingAliases', - icon: GitFork, - component: ThinkingAliasesPage, - }, { id: 'oauth', labelKey: 'app.nav.oauth', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 46fa45a..ee9e027 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react'; import { useCoreRuntime, type CoreStatus } from '../coreRuntime'; import { useI18n } from '../i18n'; +import { ThinkingAliasesPage } from './ThinkingAliasesPage'; type CoreConfigSettings = { apiKeys: CoreApiKey[]; @@ -48,7 +49,7 @@ type ConfigAction = | 'network' | null; type NoticeTone = 'success' | 'error'; -type ConfigSubpage = 'general' | 'network'; +type ConfigSubpage = 'general' | 'network' | 'aliases'; const ROUTING_OPTIONS = [ { value: 'round-robin', labelKey: 'config.routing.roundRobin' }, @@ -378,6 +379,18 @@ export function ConfigPanelPage() { > {t('config.tabs.network')} +
{activeSubpage === 'general' ? ( @@ -524,7 +537,7 @@ export function ConfigPanelPage() { - ) : ( + ) : activeSubpage === 'network' ? (
+ ) : ( +
+ +
)} {addDialogOpen ? ( From 0baff99d6e631348f95d2f56e746344c14d127f7 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 14:53:36 +0800 Subject: [PATCH 03/29] update core version to 7.2.109 --- core-version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-version.txt b/core-version.txt index 3d296eb..32bf1fc 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.101 +7.2.109 From aafe0c85330c6b3753fcff7c095d78e9972ea590 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 30 Jul 2026 23:38:47 +0800 Subject: [PATCH 04/29] refactor(config): reorganize network settings and remove plugin controls --- README.ja.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- src/i18n/ja.ts | 6 +- src/i18n/resources.ts | 14 +- src/pages/ConfigPanel.tsx | 327 +++++++++++++++++++------------------- src/styles.css | 108 +++++-------- 7 files changed, 207 insertions(+), 254 deletions(-) diff --git a/README.ja.md b/README.ja.md index c2869d2..7c6a381 100644 --- a/README.ja.md +++ b/README.ja.md @@ -90,7 +90,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で ## その他の機能 -- コア設定、API Key、リモート管理用認証情報、プラグイン、ルーティング戦略の管理。 +- コア設定、API Key、リモート管理用認証情報、ルーティング戦略の管理。 - クライアント向けモデルエイリアスを作成し、プロバイダーモデルや推論レベルへマッピング。 - 認証ファイルのアップロード、ダウンロード、確認、管理。 - プロバイダーのクォータとアカウント利用状態を確認。 diff --git a/README.md b/README.md index 534205e..6c46ac3 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ previous configuration, and launch an available desktop or CLI entry point. ## Additional Capabilities -- Manage core settings, API keys, remote management credentials, plugins, and routing strategy. +- Manage core settings, API keys, remote management credentials, and routing strategy. - Create client-visible model aliases and map them to provider models and reasoning levels. - Upload, download, inspect, and manage authentication files. - Review provider quotas and account availability. diff --git a/README.zh-CN.md b/README.zh-CN.md index af10392..1513819 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,7 +86,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 ## 其他功能 -- 管理内核配置、API Key、远程管理凭证、插件和路由策略。 +- 管理内核配置、API Key、远程管理凭证和路由策略。 - 创建客户端可见的模型别名,并映射到 Provider 模型和推理等级。 - 上传、下载、检查和管理认证文件。 - 查看 Provider 配额和账号可用状态。 diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index e47bd5f..c10851d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -256,8 +256,6 @@ export const jaOverrides = { 'config.notice.keyDeleted': '認証キーを削除しました', 'config.notice.keyCopied': '認証キーをコピーしました', 'config.notice.keyCopyFailed': '認証キーをコピーできませんでした', - 'config.notice.pluginsEnabled': 'プラグインシステムを有効にしました', - 'config.notice.pluginsDisabled': 'プラグインシステムを無効にしました', 'config.notice.routingUpdated': 'ルーティング方式を更新しました', 'config.notice.networkUpdated': 'ネットワークとセッションルーティング設定を保存しました', 'config.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました', @@ -279,14 +277,14 @@ export const jaOverrides = { 'config.keys.delete': '認証キーを削除', 'config.keys.deleteNth': '{number} 番目の認証キーを削除', 'config.keys.empty': '認証キーがありません', - 'config.plugins.title': 'プラグインシステム', 'common.loading': '読み込み中', 'common.unavailable': '利用不可', 'common.enabled': '有効', 'common.disabled': '無効', - 'config.plugins.enable': 'プラグインシステムを有効にする', 'config.routing.title': 'ルーティング方式', 'config.network.title': 'ネットワークとセッションルーティング', + 'config.network.networkSection': 'ネットワーク設定', + 'config.network.routingSection': 'セッションルーティング', 'config.network.saving': '保存中', 'config.network.saved': '保存済み', 'config.network.unsaved': '未保存の変更あり', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 1070744..9cf319a 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -259,8 +259,6 @@ export const zhCN = { 'config.notice.keyDeleted': '鉴权密钥已删除', 'config.notice.keyCopied': '鉴权密钥已复制', 'config.notice.keyCopyFailed': '复制鉴权密钥失败', - 'config.notice.pluginsEnabled': '插件系统已启用', - 'config.notice.pluginsDisabled': '插件系统已停用', 'config.notice.routingUpdated': '路由策略已更新', 'config.notice.networkUpdated': '网络与会话路由设置已保存', 'config.notice.networkRestarted': '网络设置已保存,内核已重启', @@ -282,14 +280,14 @@ export const zhCN = { 'config.keys.delete': '删除鉴权密钥', 'config.keys.deleteNth': '删除第 {number} 个鉴权密钥', 'config.keys.empty': '暂无鉴权密钥', - 'config.plugins.title': '插件系统', 'common.loading': '读取中', 'common.unavailable': '不可用', 'common.enabled': '已启用', 'common.disabled': '已停用', - 'config.plugins.enable': '启用插件系统', 'config.routing.title': '路由策略', 'config.network.title': '网络与会话路由', + 'config.network.networkSection': '网络设置', + 'config.network.routingSection': '会话路由', 'config.network.saving': '正在保存', 'config.network.saved': '已保存', 'config.network.unsaved': '有未保存修改', @@ -297,7 +295,7 @@ export const zhCN = { 'config.network.port': '端口', 'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启', 'config.network.allowLan': '允许局域网', - 'config.network.allowLanHint': '允许其他局域网设备访问本机代理', + 'config.network.allowLanHint': '启用后将', 'config.network.proxyUrl': '代理 URL', 'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890', 'config.network.proxyHint': '全局上游代理;留空表示不使用', @@ -1109,8 +1107,6 @@ export const en: Record = { 'config.notice.keyDeleted': 'Authentication key deleted', 'config.notice.keyCopied': 'Authentication key copied', 'config.notice.keyCopyFailed': 'Failed to copy the authentication key', - 'config.notice.pluginsEnabled': 'Plugin system enabled', - 'config.notice.pluginsDisabled': 'Plugin system disabled', 'config.notice.routingUpdated': 'Routing strategy updated', 'config.notice.networkUpdated': 'Network and session routing settings saved', 'config.notice.networkRestarted': 'Network settings saved and the core restarted', @@ -1132,14 +1128,14 @@ export const en: Record = { 'config.keys.delete': 'Delete authentication key', 'config.keys.deleteNth': 'Delete authentication key {number}', 'config.keys.empty': 'No authentication keys', - 'config.plugins.title': 'Plugin System', 'common.loading': 'Loading', 'common.unavailable': 'Unavailable', 'common.enabled': 'Enabled', 'common.disabled': 'Disabled', - 'config.plugins.enable': 'Enable plugin system', 'config.routing.title': 'Routing Strategy', 'config.network.title': 'Network & Session Routing', + 'config.network.networkSection': 'Network Settings', + 'config.network.routingSection': 'Session Routing', 'config.network.saving': 'Saving', 'config.network.saved': 'Saved', 'config.network.unsaved': 'Unsaved changes', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index ee9e027..9a8b46a 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -13,7 +13,6 @@ import { Network, Pencil, Plus, - Plug, RefreshCw, Route, Sparkles, @@ -28,7 +27,6 @@ type CoreConfigSettings = { apiKeys: CoreApiKey[]; port: number; allowLan: boolean; - pluginsEnabled: boolean; routingStrategy: string; proxyUrl: string; routingSessionAffinity: boolean; @@ -44,7 +42,6 @@ type ConfigAction = | 'add-key' | 'update-key' | 'delete-key' - | 'plugins' | 'routing' | 'network' | null; @@ -79,6 +76,7 @@ export function ConfigPanelPage() { const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false); const [sessionTtlDraft, setSessionTtlDraft] = useState(''); const [portError, setPortError] = useState(''); + const [savedStatusVisible, setSavedStatusVisible] = useState(false); const noticeTimerRef = useRef(null); const copyTimerRef = useRef(null); @@ -268,25 +266,18 @@ export function ConfigPanelPage() { } }; - const togglePlugins = async (enabled: boolean) => { - await runMutation( - 'plugins', - 'set_core_plugins_enabled', - { enabled }, - enabled ? t('config.notice.pluginsEnabled') : t('config.notice.pluginsDisabled'), - ); - }; - const changeRoutingStrategy = async (strategy: string) => { if (strategy === settings?.routingStrategy) { return; } - await runMutation( + setSavedStatusVisible(false); + const saved = await runMutation( 'routing', 'set_core_routing_strategy', { strategy }, t('config.notice.routingUpdated'), ); + if (saved) setSavedStatusVisible(true); }; const saveNetworkRoutingSettings = async () => { @@ -302,6 +293,7 @@ export function ConfigPanelPage() { const routingSessionAffinityTtl = sessionTtlDraft.trim(); const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan; setPortError(''); + setSavedStatusVisible(false); setBusyAction('network'); try { const result = await invoke('save_network_routing_settings', { @@ -315,6 +307,7 @@ export function ConfigPanelPage() { }); applySettings(result); setLoadError(''); + setSavedStatusVisible(true); if (networkChanged && coreStatus?.running) { try { @@ -347,6 +340,22 @@ export function ConfigPanelPage() { || sessionAffinityDraft !== settings?.routingSessionAffinity || sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl ); + const networkStatusLabel = loading + ? t('common.loading') + : settings === null + ? t('common.unavailable') + : busyAction === 'network' || busyAction === 'routing' + ? t('config.network.saving') + : networkRoutingDirty + ? t('config.network.unsaved') + : savedStatusVisible + ? t('config.network.saved') + : ''; + const networkStatusIsSaved = !loading + && settings !== null + && busyAction === null + && !networkRoutingDirty + && savedStatusVisible; const selectedDeleteKey = deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || ''; const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1; @@ -400,7 +409,6 @@ export function ConfigPanelPage() { role="tabpanel" aria-labelledby="config-subpage-tab-general" > -
@@ -502,40 +510,6 @@ export function ConfigPanelPage() { )}
- -
-
-
-
-
- - {loading - ? t('common.loading') - : settings === null - ? t('common.unavailable') - : settings.pluginsEnabled - ? t('common.enabled') - : t('common.disabled')} - -
-
- {t('config.plugins.enable')} - -
-
-
-
) : activeSubpage === 'network' ? (
-
-
-
-
- - {loading - ? t('common.loading') - : settings === null - ? t('common.unavailable') - : routingStrategyLabel(settings.routingStrategy, t)} - -
-
- {ROUTING_OPTIONS.map((option) => ( - - ))} -
-
-
@@ -582,17 +525,11 @@ export function ConfigPanelPage() {

{t('config.network.title')}

- - {loading - ? t('common.loading') - : settings === null - ? t('common.unavailable') - : busyAction === 'network' - ? t('config.network.saving') - : networkRoutingDirty - ? t('config.network.unsaved') - : t('config.network.saved')} - + {networkStatusLabel ? ( + + {networkStatusLabel} + + ) : null}
-
-
diff --git a/src/styles.css b/src/styles.css index 6676124..77072ee 100644 --- a/src/styles.css +++ b/src/styles.css @@ -853,24 +853,12 @@ input:focus-visible { gap: 18px; } -.config-workspace-grid { - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr); - align-items: stretch; - gap: 18px; - height: 350px; -} - .config-subpage-panel { display: grid; min-width: 0; gap: 18px; } -.config-network-subpage > .config-setting-panel { - width: min(420px, 100%); -} - .control-panel { grid-column: auto; } @@ -1494,8 +1482,7 @@ input:focus-visible { transform: translateX(20px); } -.config-keys-panel, -.config-setting-panel { +.config-keys-panel { display: grid; min-height: 0; } @@ -1506,30 +1493,46 @@ input:focus-visible { height: 350px; } -.config-side-stack { - display: grid; - grid-template-rows: repeat(2, 166px); - gap: 18px; - height: 350px; -} - -.config-side-stack.single { - grid-template-rows: 166px; - align-content: start; - height: 166px; -} - -.config-setting-panel { - grid-template-rows: 42px minmax(0, 1fr); - gap: 16px; - height: 166px; -} - .config-network-panel { display: grid; gap: 18px; } +.config-network-sections { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 18px; +} + +.config-network-section { + display: grid; + min-width: 0; + gap: 14px; + padding: 16px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 8px; + background: var(--theme-fffdf8); +} + +.config-network-section-heading { + display: flex; + align-items: center; + gap: 8px; + color: var(--theme-39352f); +} + +.config-network-section-heading svg { + flex: none; + color: var(--theme-777067); +} + +.config-network-section-heading h3 { + margin: 0; + font-size: 14px; + line-height: 1.4; +} + .config-network-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1867,24 +1870,6 @@ input:focus-visible { font-size: 13px; } -.config-single-control { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 0 4px; -} - -.config-single-control > span { - overflow: hidden; - color: var(--theme-2d2a26); - font-size: 14px; - font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; -} - .routing-segmented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -6695,11 +6680,6 @@ input:focus-visible { grid-template-columns: 1fr; } - .config-workspace-grid { - grid-template-columns: 1fr; - height: auto; - } - .provider-workbench { grid-template-columns: 1fr; } @@ -6735,16 +6715,6 @@ input:focus-visible { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .config-side-stack { - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-template-rows: 166px; - height: 166px; - } - - .config-side-stack.single { - grid-template-columns: 1fr; - } - .control-panel, .version-panel { grid-column: auto; @@ -7002,12 +6972,6 @@ input:focus-visible { .agent-modification-buttons.codex { grid-template-columns: 1fr; } - - .config-side-stack { - grid-template-columns: 1fr; - grid-template-rows: repeat(2, 166px); - height: 350px; - } } @media (max-width: 520px) { From d499edabbce3100c401b6e14ff1ae67287e6b318 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 31 Jul 2026 00:36:37 +0800 Subject: [PATCH 05/29] update ui --- index.html | 2 +- src-tauri/src/main.rs | 8 +- src-tauri/tauri.conf.json | 4 +- src/styles.css | 613 +++++++++++++++++++++++--------------- src/theme.ts | 4 +- 5 files changed, 388 insertions(+), 243 deletions(-) diff --git a/index.html b/index.html index 0030da6..3a8c0ca 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,7 @@ : matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; - document.documentElement.style.backgroundColor = theme === 'dark' ? '#101419' : '#faf9f5'; + document.documentElement.style.backgroundColor = theme === 'dark' ? '#0b1220' : '#f4f7fb'; } catch { // The app applies the theme again during initialization. } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e275986..d421762 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -74,8 +74,8 @@ const LEGACY_GUI_CONFIG_FILE: &str = "cpa-gui.yaml"; const MIN_MAIN_WINDOW_WIDTH: u32 = 640; const MIN_MAIN_WINDOW_HEIGHT: u32 = 600; const MAX_SAVED_WINDOW_DIMENSION: u32 = 16_384; -const DEFAULT_MAIN_WINDOW_WIDTH: u32 = 1551; -const DEFAULT_MAIN_WINDOW_HEIGHT: u32 = 893; +const DEFAULT_MAIN_WINDOW_WIDTH: u32 = 1531; +const DEFAULT_MAIN_WINDOW_HEIGHT: u32 = 891; const OAUTH_DIR_NAME: &str = "oauth"; const DEFAULT_AUTH_DIR: &str = "../oauth"; const DEFAULT_API_KEY: &str = "123456"; @@ -13935,8 +13935,8 @@ mod tests { assert!(content.contains("port = 8317")); assert!(content.contains("allow-lan = false")); assert!(content.contains("run-on-startup = false")); - assert!(content.contains("window-width = 1551")); - assert!(content.contains("window-height = 893")); + assert!(content.contains("window-width = 1531")); + assert!(content.contains("window-height = 891")); assert!(content.contains("auth-dir = \"../oauth\"")); assert!(content.contains("[[api-keys]]")); assert!(content.contains("key = \"123456\"")); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dc0436b..e02005b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,8 +14,8 @@ { "label": "main", "title": "EasyCLIProxyAPI", - "width": 1551, - "height": 893, + "width": 1531, + "height": 891, "minWidth": 640, "minHeight": 600 } diff --git a/src/styles.css b/src/styles.css index 77072ee..b0735bf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -226,12 +226,146 @@ --theme-ffffff: #1c2129; } +/* + * Readability-first palette. + * + * The original tokens are retained because they are used throughout the app, + * while these overrides move the interface from low-contrast warm grays to a + * clearer blue-gray system. New semantic tokens are used by the shell and + * primary interactions below. + */ +:root { + --ui-accent: #3568d4; + --ui-accent-strong: #234fad; + --ui-accent-soft: #e7efff; + --ui-focus-ring: rgba(53, 104, 212, 0.28); + --ui-panel-shadow: 0 8px 24px rgba(31, 48, 76, 0.06); + --theme-2d2a26: #182235; + --theme-342f2a: #202c40; + --theme-39352f: #29364a; + --theme-433f39: #324057; + --theme-4f4a43: #425168; + --theme-5f5a53: #526176; + --theme-655f57: #5d6b7e; + --theme-6d6760: #647286; + --theme-6f6961: #687589; + --theme-716b61: #667488; + --theme-777067: #6b788b; + --theme-7a746c: #627086; + --theme-817b73: #748195; + --theme-8a847c: #7a8798; + --theme-8d8377: #7d899a; + --theme-918a81: #8490a0; + --theme-9a938a: #8d98a8; + --theme-9a958d: #8a96a7; + --theme-9b958c: #8c97a8; + --theme-aaa49b: #9da7b5; + --theme-bdb5a9: #b0bac7; + --theme-c7c2b8: #bac6d4; + --theme-c9c4ba: #bec9d5; + --theme-d5d2cb: #cbd7e5; + --theme-d8d2c8: #d3dce8; + --theme-d8d4cc: #cfdbe8; + --theme-d9d4c9: #d4dde9; + --theme-ddd8ce: #dbe3ed; + --theme-dfdcd4: #dfe9f5; + --theme-e2ddd3: #d9e3ef; + --theme-e3e1db: #e0e8f1; + --theme-e5e1d9: #e3eaf2; + --theme-e8e4dc: #e7edf5; + --theme-e8e5dc: #e5edf6; + --theme-ece8df: #edf2f7; + --theme-eeeae2: #ebf0f6; + --theme-eeece6: #edf2f7; + --theme-f0ede6: #eef3f8; + --theme-f0eee8: #edf3fa; + --theme-f2efe8: #f1f5f9; + --theme-f2f0ea: #f1f5f9; + --theme-f4f1ea: #f5f7fa; + --theme-f4f2ec: #f4f7fb; + --theme-f6f2ec: #f6f8fb; + --theme-f8f5ee: #f8fafc; + --theme-f8f5ef: #f8fafc; + --theme-f8f6f1: #f8fafc; + --theme-faf8f2: #fafbfd; + --theme-faf8f4: #fafbfd; + --theme-faf9f5: #f4f7fb; + --theme-fff: #ffffff; + --theme-fffdf8: #ffffff; + --theme-ffffff: #ffffff; +} + +:root[data-theme='dark'] { + --ui-accent: #7da2ff; + --ui-accent-strong: #a9c1ff; + --ui-accent-soft: rgba(86, 128, 224, 0.2); + --ui-focus-ring: rgba(125, 162, 255, 0.38); + --ui-panel-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); + --theme-2d2a26: #edf3fb; + --theme-342f2a: #e3eaf4; + --theme-39352f: #d8e1ed; + --theme-433f39: #d4deea; + --theme-4f4a43: #cbd5e2; + --theme-5f5a53: #bec9d8; + --theme-655f57: #b7c3d2; + --theme-6d6760: #afbbcc; + --theme-6f6961: #aebacc; + --theme-716b61: #aab6c8; + --theme-777067: #a8b4c6; + --theme-7a746c: #9eabbd; + --theme-817b73: #96a3b5; + --theme-8a847c: #8996a8; + --theme-8d8377: #8c98a9; + --theme-918a81: #8491a3; + --theme-9a938a: #7d899b; + --theme-9a958d: #758195; + --theme-9b958c: #778397; + --theme-aaa49b: #6d7a8d; + --theme-bdb5a9: #53627a; + --theme-c7c2b8: #455972; + --theme-c9c4ba: #41546c; + --theme-d5d2cb: #34465e; + --theme-d8d2c8: #303f54; + --theme-d8d4cc: #35475f; + --theme-d9d4c9: #2f3f54; + --theme-ddd8ce: #2b3a4e; + --theme-dfdcd4: #22334b; + --theme-e2ddd3: #304158; + --theme-e3e1db: #293a50; + --theme-e5e1d9: #29384c; + --theme-e8e4dc: #223047; + --theme-e8e5dc: #1c2a3e; + --theme-ece8df: #202d40; + --theme-eeeae2: #253449; + --theme-eeece6: #202e41; + --theme-f0ede6: #1c293c; + --theme-f0eee8: #101a2a; + --theme-f2efe8: #1b283a; + --theme-f2f0ea: #1c293b; + --theme-f4f1ea: #182437; + --theme-f4f2ec: #172336; + --theme-f6f2ec: #142033; + --theme-f8f5ee: #121e30; + --theme-f8f5ef: #121e30; + --theme-f8f6f1: #121e30; + --theme-faf8f2: #111d2e; + --theme-faf8f4: #111d2e; + --theme-faf9f5: #0b1220; + --theme-fff: #121d2e; + --theme-fffdf8: #111c2c; + --theme-ffffff: #121d2e; +} + :root { font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; + 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', + -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', Arial, sans-serif; + font-size: 16px; + line-height: 1.5; color: var(--theme-2d2a26); background: var(--theme-faf9f5); font-synthesis: none; + letter-spacing: 0.005em; text-rendering: optimizeLegibility; } @@ -266,7 +400,9 @@ body { } button, -input { +input, +select, +textarea { font: inherit; } @@ -275,14 +411,16 @@ button { } button:focus-visible, -input:focus-visible { - outline: 3px solid rgba(45, 42, 38, 0.18); +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 3px solid var(--ui-focus-ring); outline-offset: 2px; } .app-shell { display: grid; - grid-template-columns: 236px minmax(0, 1fr); + grid-template-columns: 264px minmax(0, 1fr); min-height: 100vh; background: var(--theme-faf9f5); } @@ -293,7 +431,8 @@ input:focus-visible { display: flex; flex-direction: column; height: 100vh; - padding: 18px; + padding: 20px 16px; + border-right: 1px solid var(--theme-e3e1db); background: var(--theme-f0eee8); color: var(--theme-2d2a26); } @@ -313,11 +452,12 @@ input:focus-visible { height: 42px; place-items: center; border: 1px solid var(--theme-d5d2cb); - border-radius: 8px; + border-radius: 12px; background: var(--theme-2d2a26); color: var(--theme-fffdf8); - font-size: 14px; + font-size: 16px; font-weight: 800; + box-shadow: 0 6px 16px rgba(31, 58, 104, 0.14); } .brand-logo { @@ -334,7 +474,7 @@ input:focus-visible { .sidebar-brand strong { overflow: hidden; color: var(--theme-2d2a26); - font-size: 15px; + font-size: 17px; text-overflow: ellipsis; white-space: nowrap; } @@ -342,7 +482,7 @@ input:focus-visible { .sidebar-brand div > span { margin-top: 4px; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; } .nav-section { @@ -356,13 +496,16 @@ input:focus-visible { align-items: center; gap: 10px; width: 100%; - min-height: 42px; - padding: 0 12px; + min-height: 46px; + padding: 0 13px; border: 1px solid transparent; - border-radius: 8px; + border-radius: 10px; background: transparent; color: var(--theme-2d2a26); + font-weight: 600; text-align: left; + transition: border-color 140ms ease, background 140ms ease, color 140ms ease, + transform 140ms ease; } .nav-section button svg { @@ -401,6 +544,7 @@ input:focus-visible { .nav-section button:hover { background: var(--theme-e8e5dc); color: var(--theme-2d2a26); + transform: translateX(2px); } .nav-section button:disabled, @@ -414,6 +558,7 @@ input:focus-visible { .nav-section button.locked:hover { background: transparent; color: var(--theme-9a958d); + transform: none; } .nav-section button:disabled svg, @@ -422,14 +567,15 @@ input:focus-visible { } .nav-section button.active { - border-color: var(--theme-d5d2cb); - background: var(--theme-dfdcd4); - color: var(--theme-2d2a26); + border-color: color-mix(in srgb, var(--ui-accent) 30%, transparent); + background: var(--ui-accent-soft); + color: var(--ui-accent-strong); font-weight: 700; + box-shadow: 0 4px 12px rgba(35, 79, 173, 0.08); } .nav-section button.active svg { - color: var(--theme-2d2a26); + color: var(--ui-accent); } .sidebar-bottom { @@ -459,7 +605,7 @@ input:focus-visible { border-radius: 5px; background: transparent; color: var(--theme-716b61); - font-size: 12px; + font-size: 14px; font-weight: 700; transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease; white-space: nowrap; @@ -506,7 +652,7 @@ input:focus-visible { .sidebar-language-trigger span { min-width: 0; overflow: hidden; - font-size: 12px; + font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -547,7 +693,7 @@ input:focus-visible { border-radius: 6px; background: transparent; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; font-weight: 650; text-align: left; } @@ -605,7 +751,7 @@ input:focus-visible { .sidebar-contact span { overflow: hidden; - font-size: 12px; + font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -625,7 +771,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-fffdf8); color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; font-weight: 700; white-space: nowrap; } @@ -650,7 +796,7 @@ input:focus-visible { .content { width: 100%; - padding: 24px 40px 42px; + padding: 32px 40px 48px; container-type: inline-size; container-name: workspace; } @@ -676,11 +822,11 @@ input:focus-visible { .panel { width: 100%; min-width: 0; - padding: 20px; + padding: 24px; border: 1px solid var(--theme-e3e1db); - border-radius: 8px; + border-radius: 12px; background: var(--theme-fffdf8); - box-shadow: 0 1px 2px rgba(45, 42, 38, 0.04); + box-shadow: var(--ui-panel-shadow); } .quiet-panel { @@ -703,19 +849,16 @@ input:focus-visible { .panel h2 { margin: 0; color: var(--theme-2d2a26); - font-size: 16px; + font-size: 18px; line-height: 1.25; } .panel-heading p { - min-height: 19px; + min-height: 0; margin: 5px 0 0; - overflow: hidden; color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; line-height: 1.45; - text-overflow: ellipsis; - white-space: nowrap; } .panel-heading p.success { @@ -758,7 +901,7 @@ input:focus-visible { .status-card span { overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -766,7 +909,7 @@ input:focus-visible { .status-card strong { overflow: hidden; color: var(--theme-2d2a26); - font-size: 16px; + font-size: 18px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; @@ -795,13 +938,13 @@ input:focus-visible { border: 0; background: transparent; color: var(--theme-817b73); - font-size: 10px; + font-size: 12px; font-weight: 600; } .software-version-action span { color: inherit; - font-size: 10px; + font-size: 12px; font-weight: 600; } @@ -882,7 +1025,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; font-weight: 400; line-height: 1.2; text-overflow: ellipsis; @@ -926,7 +1069,7 @@ input:focus-visible { border: 0; background: transparent; color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; line-height: 1.45; } @@ -941,7 +1084,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -1032,12 +1175,12 @@ input:focus-visible { .client-api-card-heading strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .client-api-card-heading > div > span { color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; } .client-api-values { @@ -1062,14 +1205,14 @@ input:focus-visible { .client-api-value-row > span:first-child { grid-column: 1 / -1; color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; font-weight: 700; } .client-api-value-row code { min-width: 0; color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; white-space: normal; @@ -1124,7 +1267,7 @@ input:focus-visible { .panel-detail-row dt { color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; } .panel-detail-row dd { @@ -1328,7 +1471,7 @@ input:focus-visible { .info-list dt { color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; } .info-list dd { @@ -1369,11 +1512,11 @@ input:focus-visible { display: inline-flex; align-items: center; justify-content: center; - min-height: 38px; + min-height: 42px; min-width: 0; overflow: hidden; - padding: 0 10px; - border-radius: 6px; + padding: 0 14px; + border-radius: 8px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -1386,13 +1529,15 @@ input:focus-visible { } .primary-button { - border: 1px solid var(--theme-2d2a26); - background: var(--theme-2d2a26); - color: var(--theme-fffdf8); + border: 1px solid var(--ui-accent); + background: var(--ui-accent); + color: #ffffff; + box-shadow: 0 4px 12px rgba(53, 104, 212, 0.18); } .primary-button:hover:not(:disabled) { - background: var(--theme-433f39); + border-color: var(--ui-accent-strong); + background: var(--ui-accent-strong); } .secondary-button { @@ -1529,7 +1674,7 @@ input:focus-visible { .config-network-section-heading h3 { margin: 0; - font-size: 14px; + font-size: 16px; line-height: 1.4; } @@ -1555,13 +1700,13 @@ input:focus-visible { .config-network-field > span, .config-network-toggle > div > span { color: var(--theme-39352f); - font-size: 13px; + font-size: 15px; font-weight: 700; } .config-network-field small { color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; line-height: 1.45; } @@ -1587,7 +1732,7 @@ input:focus-visible { background: var(--theme-fffdf8); color: var(--theme-2d2a26); font: inherit; - font-size: 13px; + font-size: 15px; } .config-network-input:focus { @@ -1679,7 +1824,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-f4f1ea); color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; font-variant-numeric: tabular-nums; font-weight: 700; } @@ -1765,7 +1910,7 @@ input:focus-visible { border-radius: 5px; background: var(--theme-f0eee8); color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; font-variant-numeric: tabular-nums; font-weight: 800; } @@ -1787,7 +1932,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } @@ -1796,7 +1941,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; @@ -1849,14 +1994,14 @@ input:focus-visible { .config-empty-list strong, .config-unavailable strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .config-unavailable > span { width: min(360px, 100%); overflow: hidden; color: var(--theme-9f2d20); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -1867,7 +2012,7 @@ input:focus-visible { min-height: 34px; margin-top: 3px; padding: 0 12px; - font-size: 13px; + font-size: 15px; } .routing-segmented { @@ -1890,7 +2035,7 @@ input:focus-visible { border-radius: 5px; background: transparent; color: var(--theme-6d6760); - font-size: 13px; + font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -1952,7 +2097,7 @@ input:focus-visible { margin: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 17px; + font-size: 19px; text-overflow: ellipsis; white-space: nowrap; } @@ -1962,7 +2107,7 @@ input:focus-visible { grid-template-rows: 20px 42px; gap: 8px; color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; font-weight: 700; } @@ -2013,7 +2158,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: transparent; - font-size: 12px; + font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; @@ -2102,14 +2247,14 @@ input:focus-visible { .close-dialog-heading h2 { margin: 0; color: var(--theme-2d2a26); - font-size: 20px; + font-size: 22px; line-height: 1.25; } .close-dialog > p { margin: 0; color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; line-height: 1.6; } @@ -2119,7 +2264,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff1ee); color: var(--theme-9f2d20); - font-size: 12px; + font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; } @@ -2138,7 +2283,7 @@ input:focus-visible { } .close-choice-button span { - font-size: 13px; + font-size: 15px; } .close-choice-button:focus-visible { @@ -2180,7 +2325,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-f4f1ea); color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; @@ -2196,7 +2341,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff1ee); color: var(--theme-8f3025); - font-size: 12px; + font-size: 14px; line-height: 1.45; } @@ -2238,7 +2383,7 @@ input:focus-visible { .config-toast span { min-width: 0; overflow: hidden; - font-size: 13px; + font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -2262,7 +2407,7 @@ input:focus-visible { margin-bottom: 5px; overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 800; text-overflow: ellipsis; text-transform: uppercase; @@ -2273,7 +2418,7 @@ input:focus-visible { margin: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 24px; + font-size: 28px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; @@ -2310,7 +2455,7 @@ input:focus-visible { margin: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 16px; + font-size: 18px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; @@ -2322,7 +2467,7 @@ input:focus-visible { margin-top: 4px; overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -2344,7 +2489,7 @@ input:focus-visible { .oauth-hint { margin: 0; color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; line-height: 1.5; } @@ -2360,7 +2505,7 @@ input:focus-visible { .oauth-auth-url-label { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 700; } @@ -2368,7 +2513,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; line-height: 1.45; word-break: break-all; } @@ -2387,7 +2532,7 @@ input:focus-visible { .oauth-inline-status { min-width: 0; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; line-height: 1.4; } @@ -2462,7 +2607,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } @@ -2497,13 +2642,13 @@ input:focus-visible { .model-config-heading span { color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; font-weight: 700; } .model-config-heading small { color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; font-weight: 400; } @@ -2535,17 +2680,17 @@ input:focus-visible { .model-config-summary strong { color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; } .model-config-summary span { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .model-picker-error { color: var(--theme-9f2d20); - font-size: 11px; + font-size: 13px; font-weight: 400; } @@ -2594,13 +2739,13 @@ input:focus-visible { .model-discovery-header h2 { margin: 0; color: var(--theme-2d2a26); - font-size: 18px; + font-size: 20px; } .model-discovery-header span, .model-discovery-toolbar > span { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; white-space: nowrap; } @@ -2693,12 +2838,12 @@ input:focus-visible { .model-discovery-row strong { color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; } .model-discovery-row small { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; font-weight: 400; } @@ -2715,13 +2860,13 @@ input:focus-visible { gap: 8px; padding: 20px; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-align: center; } .model-discovery-message strong { color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; } .model-discovery-message.error, @@ -2803,7 +2948,7 @@ input:focus-visible { .provider-category-panel strong { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-align: right; } @@ -2893,19 +3038,19 @@ input:focus-visible { .resource-main strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .resource-main span, .resource-models, .resource-priority { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; } .resource-row code { color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; } .resource-priority { @@ -2930,7 +3075,7 @@ input:focus-visible { .management-empty strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .management-dialog { @@ -2953,7 +3098,7 @@ input:focus-visible { gap: 6px; min-width: 0; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 700; } @@ -2997,14 +3142,14 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } .muted-summary { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; white-space: nowrap; } @@ -3017,7 +3162,7 @@ input:focus-visible { border-radius: 7px; background: var(--theme-fffdf8); color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; } .management-alert.error { @@ -3091,7 +3236,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -3119,7 +3264,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; text-overflow: ellipsis; white-space: nowrap; } @@ -3156,7 +3301,7 @@ input:focus-visible { justify-content: flex-end; gap: 8px; color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; white-space: nowrap; } @@ -3263,12 +3408,12 @@ input:focus-visible { .provider-preset-summary strong { color: var(--theme-344ca1); - font-size: 12px; + font-size: 14px; } .provider-preset-summary span { color: var(--theme-66709a); - font-size: 11px; + font-size: 13px; overflow-wrap: anywhere; } @@ -3290,12 +3435,12 @@ input:focus-visible { .thinking-level-heading strong { color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; } .thinking-level-heading span { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .thinking-level-entry { @@ -3332,7 +3477,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-fffdf8); color: var(--theme-6d6760); - font-size: 11px; + font-size: 13px; font-weight: 700; } @@ -3363,7 +3508,7 @@ input:focus-visible { .thinking-level-empty { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .provider-advanced-settings { @@ -3375,7 +3520,7 @@ input:focus-visible { .provider-advanced-settings > summary { padding: 11px 12px; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; font-weight: 700; cursor: pointer; } @@ -3403,12 +3548,12 @@ input:focus-visible { .provider-advanced-toggle strong { color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; } .provider-advanced-toggle > div > span { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .provider-advanced-toggle .switch-control { @@ -3514,14 +3659,14 @@ input:focus-visible { .thinking-alias-list-heading h2 { margin: 0; color: var(--theme-2d2a26); - font-size: 17px; + font-size: 19px; } .thinking-alias-panel-heading p, .thinking-alias-list-heading span { margin: 0; color: var(--theme-8a847c); - font-size: 14px; + font-size: 16px; line-height: 1.45; } @@ -3538,7 +3683,7 @@ input:focus-visible { .thinking-alias-field > label { color: var(--theme-6f6961); - font-size: 14px; + font-size: 16px; font-weight: 700; } @@ -3551,13 +3696,13 @@ input:focus-visible { .thinking-field-heading strong { color: var(--theme-6f6961); - font-size: 14px; + font-size: 16px; } .thinking-field-heading span { overflow: hidden; color: var(--theme-918a81); - font-size: 13px; + font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } @@ -3588,7 +3733,7 @@ input:focus-visible { outline: 0; background: transparent; color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .thinking-source-kind { @@ -3599,7 +3744,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-f5f1ff); color: var(--theme-654f91); - font-size: 11px; + font-size: 13px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; @@ -3668,12 +3813,12 @@ input:focus-visible { } .thinking-model-option-copy strong { - font-size: 15px; + font-size: 17px; } .thinking-model-option-copy > span:first-child small { color: var(--theme-8a847c); - font-size: 13px; + font-size: 15px; } .thinking-model-source { @@ -3689,14 +3834,14 @@ input:focus-visible { border-radius: 4px; background: var(--theme-eeece6); color: var(--theme-5f5a53); - font-size: 11px; + font-size: 13px; font-style: normal; font-weight: 800; } .thinking-model-source small { color: var(--theme-8a847c); - font-size: 13px; + font-size: 15px; } .thinking-model-list > button > svg { @@ -3710,7 +3855,7 @@ input:focus-visible { justify-content: center; gap: 7px; color: var(--theme-8a847c); - font-size: 13px; + font-size: 15px; } .thinking-model-selection { @@ -3719,7 +3864,7 @@ input:focus-visible { align-items: center; gap: 6px; color: var(--theme-777067); - font-size: 13px; + font-size: 15px; } .thinking-model-selection > span { @@ -3741,7 +3886,7 @@ input:focus-visible { .thinking-model-hint { color: var(--theme-918a81); - font-size: 13px; + font-size: 15px; line-height: 1.5; } @@ -3758,7 +3903,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff); color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; font-weight: 700; } @@ -3795,7 +3940,7 @@ input:focus-visible { outline: 0; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .thinking-effort-custom input:focus { @@ -3811,7 +3956,7 @@ input:focus-visible { .thinking-effort-custom small { grid-column: 1 / -1; color: var(--theme-918a81); - font-size: 12px; + font-size: 14px; line-height: 1.45; } @@ -3824,7 +3969,7 @@ input:focus-visible { outline: 0; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .thinking-alias-input:focus, @@ -3859,7 +4004,7 @@ input:focus-visible { gap: 5px; overflow: hidden; color: var(--theme-3f3550); - font-size: 15px; + font-size: 17px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -3867,7 +4012,7 @@ input:focus-visible { .thinking-alias-preview code { color: var(--theme-8069ad); - font-size: 13px; + font-size: 15px; } .thinking-alias-create { @@ -3905,7 +4050,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-eeece6); color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; } .thinking-alias-list { @@ -3944,7 +4089,7 @@ input:focus-visible { .thinking-alias-route-source > span { color: var(--theme-7a746c); - font-size: 14px; + font-size: 16px; } .thinking-alias-route-source { @@ -3966,19 +4111,19 @@ input:focus-visible { border-radius: 3px; background: var(--theme-eeece6); color: var(--theme-6f6961); - font-size: 10px; + font-size: 12px; font-style: normal; font-weight: 800; } .thinking-alias-route-source > small span { color: var(--theme-9a938a); - font-size: 12px; + font-size: 14px; } .thinking-alias-route strong { color: var(--theme-2d2a26); - font-size: 15px; + font-size: 17px; } .thinking-alias-route svg { @@ -3992,7 +4137,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-f5f1ff); color: var(--theme-654f91); - font-size: 12px; + font-size: 14px; font-weight: 800; text-align: center; text-transform: uppercase; @@ -4065,7 +4210,7 @@ input:focus-visible { display: block; min-width: 0; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; } @@ -4105,14 +4250,14 @@ input:focus-visible { .agent-list-heading strong, .agent-list-items strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .agent-list-heading span, .agent-list-items small { overflow: hidden; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } @@ -4211,7 +4356,7 @@ input:focus-visible { border-radius: 6px; background: transparent; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 700; white-space: nowrap; } @@ -4277,13 +4422,13 @@ input:focus-visible { .codex-session-overview-head strong, .codex-session-list-head strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .codex-session-overview-head span, .codex-session-list-head > div:first-child > span { color: var(--theme-8a847c); - font-size: 12px; + font-size: 14px; line-height: 1.45; } @@ -4294,7 +4439,7 @@ input:focus-visible { min-height: 32px; margin-top: 0; padding: 0 10px; - font-size: 12px; + font-size: 14px; } .codex-session-metrics { @@ -4319,14 +4464,14 @@ input:focus-visible { align-items: center; gap: 5px; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .codex-session-metrics strong { min-width: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 17px; + font-size: 19px; text-overflow: ellipsis; white-space: nowrap; } @@ -4334,7 +4479,7 @@ input:focus-visible { .codex-session-database-metric strong { direction: rtl; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 11px; + font-size: 13px; text-align: left; } @@ -4372,12 +4517,12 @@ input:focus-visible { .codex-session-repair-copy strong { color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; } .codex-session-repair-copy span { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; line-height: 1.45; } @@ -4398,7 +4543,7 @@ input:focus-visible { justify-content: space-between; gap: 12px; color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; } .codex-session-progress-track { @@ -4425,7 +4570,7 @@ input:focus-visible { border-radius: 7px; background: var(--theme-eef7f0); color: var(--theme-2f6b3f); - font-size: 11px; + font-size: 13px; line-height: 1.5; } @@ -4485,7 +4630,7 @@ input:focus-visible { .codex-session-selection-actions > span { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; white-space: nowrap; } @@ -4564,17 +4709,17 @@ input:focus-visible { .codex-session-main strong { color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; } .codex-session-main code { color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; } .codex-session-main span { color: var(--theme-5f5a53); - font-size: 11px; + font-size: 13px; } .codex-session-meta { @@ -4589,7 +4734,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-eef7f0); color: var(--theme-2f6b3f); - font-size: 10px; + font-size: 12px; font-weight: 800; } @@ -4603,7 +4748,7 @@ input:focus-visible { .codex-session-meta time { max-width: 190px; color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; } .codex-session-delete-button { @@ -4621,7 +4766,7 @@ input:focus-visible { border-radius: 7px; background: var(--theme-faf9f5); color: var(--theme-8a847c); - font-size: 12px; + font-size: 14px; text-align: center; } @@ -4640,7 +4785,7 @@ input:focus-visible { .codex-session-pagination span { min-width: 72px; color: var(--theme-5f5a53); - font-size: 11px; + font-size: 13px; font-weight: 700; text-align: center; } @@ -4666,7 +4811,7 @@ input:focus-visible { .codex-session-cleanup-dialog > p { margin: 0; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; } @@ -4683,7 +4828,7 @@ input:focus-visible { align-items: center; gap: 8px; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; font-weight: 700; } @@ -4731,13 +4876,13 @@ input:focus-visible { .codex-session-cleanup-list strong { color: var(--theme-2d2a26); - font-size: 12px; + font-size: 14px; } .codex-session-cleanup-list code, .codex-session-cleanup-list small { color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; } .agent-status-messages { @@ -4785,7 +4930,7 @@ input:focus-visible { border-radius: 5px; background: transparent; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 700; } @@ -4815,14 +4960,14 @@ input:focus-visible { .agent-status-grid span { gap: 5px; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .agent-status-grid strong { min-width: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -4859,13 +5004,13 @@ input:focus-visible { .agent-section-heading strong { color: var(--theme-2d2a26); - font-size: 14px; + font-size: 16px; } .agent-section-heading > div > span, .agent-model-hint { color: var(--theme-8a847c); - font-size: 12px; + font-size: 14px; } .agent-model-hint { @@ -4880,7 +5025,7 @@ input:focus-visible { border-radius: 999px; background: var(--theme-fff8eb); color: var(--theme-805a24); - font-size: 11px; + font-size: 13px; font-weight: 700; } @@ -4932,12 +5077,12 @@ input:focus-visible { .agent-model-trigger strong { color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; } .agent-model-trigger small { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .agent-model-trigger > svg { @@ -4983,7 +5128,7 @@ input:focus-visible { outline: 0; background: transparent; color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; } .agent-model-list { @@ -5035,12 +5180,12 @@ input:focus-visible { } .agent-model-option strong { - font-size: 13px; + font-size: 15px; } .agent-model-option small { color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .agent-model-option > svg { @@ -5055,13 +5200,13 @@ input:focus-visible { gap: 6px; padding: 16px; color: var(--theme-8a847c); - font-size: 12px; + font-size: 14px; text-align: center; } .agent-model-empty strong { color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; } .agent-model-empty.error, @@ -5075,7 +5220,7 @@ input:focus-visible { justify-content: space-between; gap: 8px; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .agent-model-dropdown-footer .error { @@ -5127,7 +5272,7 @@ input:focus-visible { .codex-auto-restore-toggle > strong { min-width: 42px; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; text-align: right; } @@ -5138,7 +5283,7 @@ input:focus-visible { .codex-auto-restore-message { max-width: 100%; color: var(--theme-2f6b3f); - font-size: 11px; + font-size: 13px; line-height: 1.4; overflow-wrap: anywhere; } @@ -5168,7 +5313,7 @@ input:focus-visible { border-radius: 9px; background: var(--theme-faf8f4); color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; font-weight: 600; cursor: pointer; user-select: none; @@ -5236,7 +5381,7 @@ input:focus-visible { .agent-restore-dialog p { margin: 0; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; line-height: 1.65; } @@ -5254,7 +5399,7 @@ input:focus-visible { min-width: 0; gap: 7px; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; } .agent-config-summary > span { @@ -5327,14 +5472,14 @@ input:focus-visible { .usage-collector-state strong { color: var(--theme-2d2a26); - font-size: 11px; + font-size: 13px; } .usage-collector-state span { margin: 0; overflow: visible; color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; font-weight: 400; text-transform: none; white-space: normal; @@ -5363,7 +5508,7 @@ input:focus-visible { border-radius: 6px; background: transparent; color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; font-weight: 700; } @@ -5389,7 +5534,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 11px; + font-size: 13px; } .usage-custom-range { @@ -5399,7 +5544,7 @@ input:focus-visible { align-items: center; gap: 8px; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .usage-initial-loading, @@ -5410,7 +5555,7 @@ input:focus-visible { gap: 8px; min-height: 120px; color: var(--theme-8a847c); - font-size: 11px; + font-size: 13px; } .usage-overview-layout { @@ -5446,13 +5591,13 @@ input:focus-visible { align-items: center; gap: 7px; color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; } .usage-stat-card > strong { overflow: hidden; color: var(--theme-2d2a26); - font-size: 24px; + font-size: 28px; text-overflow: ellipsis; white-space: nowrap; } @@ -5460,7 +5605,7 @@ input:focus-visible { .usage-stat-card > small { overflow: hidden; color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; line-height: 1.45; text-overflow: ellipsis; white-space: normal; @@ -5488,12 +5633,12 @@ input:focus-visible { .usage-section-heading strong { color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; } .usage-section-heading span { color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; } .usage-trend { @@ -5522,12 +5667,12 @@ input:focus-visible { justify-content: space-between; gap: 10px; color: var(--theme-8a847c); - font-size: 9px; + font-size: 12px; } .usage-trend-labels strong { color: var(--theme-5f5a53); - font-size: 10px; + font-size: 12px; } .usage-token-breakdown, @@ -5562,7 +5707,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-5f5a53); - font-size: 11px; + font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } @@ -5571,7 +5716,7 @@ input:focus-visible { .usage-category-list small { flex: none; color: var(--theme-8a847c); - font-size: 9px; + font-size: 12px; } .usage-token-breakdown i, @@ -5622,7 +5767,7 @@ input:focus-visible { justify-content: space-between; gap: 10px; color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; } .usage-table-wrap { @@ -5637,7 +5782,7 @@ input:focus-visible { min-width: 1140px; border-collapse: collapse; table-layout: fixed; - font-size: 10px; + font-size: 12px; } .usage-events-table th, @@ -5753,7 +5898,7 @@ input:focus-visible { .usage-events-table td small { color: var(--theme-8a847c); - font-size: 9px; + font-size: 12px; } .usage-result { @@ -5804,12 +5949,12 @@ input:focus-visible { .usage-pricing-summary strong { color: var(--theme-2d2a26); - font-size: 22px; + font-size: 26px; } .usage-pricing-summary span { color: var(--theme-8a847c); - font-size: 10px; + font-size: 12px; } .usage-pricing-actions { @@ -5827,7 +5972,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 11px; + font-size: 13px; } .usage-pricing-actions button, @@ -5853,7 +5998,7 @@ input:focus-visible { .usage-price-editor label > span { color: var(--theme-6f6961); - font-size: 10px; + font-size: 12px; font-weight: 700; } @@ -5865,7 +6010,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-fff); color: var(--theme-2d2a26); - font-size: 11px; + font-size: 13px; } .usage-price-editor-actions { @@ -5883,7 +6028,7 @@ input:focus-visible { width: 100%; min-width: 980px; border-collapse: collapse; - font-size: 10px; + font-size: 12px; } .usage-pricing-table th, @@ -5961,7 +6106,7 @@ input:focus-visible { min-width: 0; margin-top: -7px; color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; } .auth-file-quota.loading, @@ -6038,7 +6183,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -6072,7 +6217,7 @@ input:focus-visible { background: var(--theme-f4f1ea); color: var(--theme-5f5a53); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -6081,7 +6226,7 @@ input:focus-visible { display: grid; gap: 8px; color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; font-weight: 700; } @@ -6109,7 +6254,7 @@ input:focus-visible { .auth-priority-field small { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 400; line-height: 1.5; } @@ -6117,7 +6262,7 @@ input:focus-visible { .page-footnote { margin: -6px 0 0; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; } .quota-group-list { @@ -6136,7 +6281,7 @@ input:focus-visible { justify-content: space-between; gap: 12px; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; } .quota-group-heading > div { @@ -6148,7 +6293,7 @@ input:focus-visible { .quota-group-heading h2 { margin: 0; color: var(--theme-2d2a26); - font-size: 16px; + font-size: 18px; } .quota-group-heading .provider-logo { @@ -6203,7 +6348,7 @@ input:focus-visible { .real-quota-card-header span { color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; } .quota-row-list { @@ -6220,7 +6365,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-f4f2ec); color: var(--theme-7a746c); - font-size: 11px; + font-size: 13px; } .quota-reset-credit-summary span { @@ -6242,7 +6387,7 @@ input:focus-visible { justify-content: space-between; gap: 10px; color: var(--theme-5f5a53); - font-size: 12px; + font-size: 14px; } .real-quota-row > div:first-child strong { @@ -6266,7 +6411,7 @@ input:focus-visible { .real-quota-row small { min-height: 15px; color: var(--theme-9a958d); - font-size: 11px; + font-size: 13px; } .quota-card-message, @@ -6276,7 +6421,7 @@ input:focus-visible { gap: 8px; min-height: 72px; color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; } .quota-card-error { @@ -6313,7 +6458,7 @@ input:focus-visible { .quota-meter-row strong { color: var(--theme-2d2a26); - font-size: 24px; + font-size: 28px; line-height: 1; } @@ -6321,7 +6466,7 @@ input:focus-visible { min-width: 0; overflow: hidden; color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } @@ -6355,7 +6500,7 @@ input:focus-visible { .quota-meta-grid strong { min-width: 0; overflow: hidden; - font-size: 12px; + font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } @@ -6402,7 +6547,7 @@ input:focus-visible { display: block; margin-bottom: 5px; color: var(--theme-7a746c); - font-size: 12px; + font-size: 14px; font-weight: 700; } @@ -6410,7 +6555,7 @@ input:focus-visible { margin: 0; overflow: hidden; color: var(--theme-2d2a26); - font-size: 20px; + font-size: 22px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; @@ -6429,7 +6574,7 @@ input:focus-visible { .install-dialog-phase span { color: var(--theme-7a746c); - font-size: 13px; + font-size: 15px; } .install-dialog-phase strong { @@ -6482,7 +6627,7 @@ input:focus-visible { gap: 12px; min-width: 0; color: var(--theme-6d6760); - font-size: 13px; + font-size: 15px; } .install-progress-meta strong, @@ -6503,7 +6648,7 @@ input:focus-visible { border-radius: 6px; background: var(--theme-faf9f5); color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; line-height: 1.45; overflow-wrap: anywhere; } @@ -6530,7 +6675,7 @@ input:focus-visible { .app-update-confirm-copy { margin: 0; color: var(--theme-5f5a53); - font-size: 13px; + font-size: 15px; line-height: 1.65; } @@ -6669,7 +6814,7 @@ input:focus-visible { @media (max-width: 1080px) { .app-shell { - grid-template-columns: 204px minmax(0, 1fr); + grid-template-columns: 232px minmax(0, 1fr); } .status-strip { @@ -7299,7 +7444,7 @@ input:focus-visible { border-radius: 8px; background: var(--theme-f8f5ef); color: var(--theme-2d2a26); - font-size: 13px; + font-size: 15px; line-height: 1.45; } diff --git a/src/theme.ts b/src/theme.ts index 1652501..72ba288 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -5,8 +5,8 @@ export type AppTheme = 'light' | 'dark'; const STORAGE_KEY = 'easy-cli-proxy-api.theme'; const WINDOW_BACKGROUND: Record = { - light: '#faf9f5', - dark: '#101419', + light: '#f4f7fb', + dark: '#0b1220', }; async function applyNativeTheme(theme: AppTheme): Promise { From b66afcf2c5c4f4a9da5adc35f0bee3fa9c75dee3 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 31 Jul 2026 01:55:16 +0800 Subject: [PATCH 06/29] update ui --- index.html | 2 +- src/i18n/ja.ts | 18 +- src/i18n/resources.ts | 36 +- src/i18n/traditional.ts | 2 + src/pages/AgentsPage.tsx | 173 ++++---- src/styles.css | 896 ++++++++++++++++++++------------------- src/theme.ts | 4 +- tests/i18n.test.ts | 21 + 8 files changed, 567 insertions(+), 585 deletions(-) diff --git a/index.html b/index.html index 3a8c0ca..b35e535 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,7 @@ : matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; - document.documentElement.style.backgroundColor = theme === 'dark' ? '#0b1220' : '#f4f7fb'; + document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5'; } catch { // The app applies the theme again during initialization. } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index c10851d..5863d85 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -442,7 +442,7 @@ export const jaOverrides = { 'agents.sessions.databaseMissing': 'セッションデータベースが見つかりません', 'agents.sessions.repairTitle': '過去のセッションを復元', 'agents.sessions.repairDescription': '他の Provider のセッションを現在のセッション Provider に同期します。', - 'agents.sessions.autoRepair': 'Codex 起動前に過去のセッションを自動復元', + 'agents.sessions.autoRepair': '過去のセッションを自動復元', 'agents.sessions.settingLoading': '読み込み中', 'agents.sessions.settingOn': 'オン', 'agents.sessions.settingOff': 'オフ', @@ -517,17 +517,11 @@ export const jaOverrides = { 'agents.modify.clear': '設定を消去', 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', 'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。', - 'agents.footer.changed': 'モデル選択が変更されました。設定変更を適用すると新しいモデルが使用されます。', - 'agents.footer.applied': '設定変更は適用済みです。アプリは関連フィールドだけを管理します。', - 'agents.footer.invalidManaged': '設定が無効です。変更を適用して修復するか、デフォルト設定にリセットしてください。', - 'agents.footer.unsupported': '現在のシステムではこのクライアントを設定できません', - 'agents.footer.installFirst': 'クライアントをインストールしてから再検出してください', - 'agents.footer.noCommand': '設定ファイルは検出しましたが、クライアントコマンドが見つかりません', - 'agents.footer.invalidConfig': '元の設定形式が無効なため、安全に変更できません', - 'agents.launchMethods': 'Codex の起動方法', 'agents.launch.enableFirst': '先に設定変更を適用してください', 'agents.launch.applyFirst': '先に選択中のモデルを適用してください', 'agents.launch.starting': '起動中', + 'agents.launch.startCli': 'CLI を起動', + 'agents.launch.startApp': 'App を起動', 'agents.launch.start': '{target} を起動', 'agents.launch.unavailable': '起動できません', 'agents.launch.closeChatgpt': 'ChatGPT App を終了', @@ -791,9 +785,9 @@ export const jaOverrides = { 'kernel.dialog.install': 'コアのインストール', 'kernel.dialog.phase': '現在の段階', 'kernel.dialog.unknownProgress': '進捗不明', - 'kernel.access.openaiDescription': 'OpenAI SDK、OpenCode などの互換クライアント向け', - 'kernel.access.claudeDescription': 'Claude SDK および Claude Code 向け', - 'kernel.access.geminiDescription': 'Gemini ネイティブ互換クライアント向け', + 'kernel.access.openaiDescription': 'OpenAI互換形式', + 'kernel.access.claudeDescription': 'Anthropic互換形式', + 'kernel.access.geminiDescription': 'Gemini互換形式', 'authFiles.unnamed': '無名の認証ファイル', 'management.notConfigured': '未設定', 'management.error.upstream': '上流リクエストに失敗しました', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 9cf319a..40cd90c 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -445,7 +445,7 @@ export const zhCN = { 'agents.sessions.databaseMissing': '未发现会话数据库', 'agents.sessions.repairTitle': '恢复历史会话', 'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话', - 'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话', + 'agents.sessions.autoRepair': '自动恢复历史会话', 'agents.sessions.settingLoading': '读取中', 'agents.sessions.settingOn': '已开启', 'agents.sessions.settingOff': '已关闭', @@ -520,17 +520,11 @@ export const zhCN = { 'agents.modify.clear': '清空配置', 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', 'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。', - 'agents.footer.changed': '模型选择已变化;应用配置修改后将使用新模型。', - 'agents.footer.applied': '配置修改已应用;软件只管理相关字段。', - 'agents.footer.invalidManaged': '配置当前无效,请应用修改进行修复,或重置为默认配置。', - 'agents.footer.unsupported': '当前系统无法配置该客户端', - 'agents.footer.installFirst': '请先安装客户端并重新检测', - 'agents.footer.noCommand': '检测到配置文件,但没有找到客户端命令', - 'agents.footer.invalidConfig': '原配置格式异常,无法安全修改', - 'agents.launchMethods': 'Codex 启动方式', 'agents.launch.enableFirst': '请先应用配置修改', 'agents.launch.applyFirst': '请先应用当前模型选择', 'agents.launch.starting': '启动中', + 'agents.launch.startCli': '启动 CLI', + 'agents.launch.startApp': '启动 App', 'agents.launch.start': '启动 {target}', 'agents.launch.unavailable': '无法启动', 'agents.launch.closeChatgpt': '关闭 ChatGPT App', @@ -794,9 +788,9 @@ export const zhCN = { 'kernel.dialog.install': '内核安装', 'kernel.dialog.phase': '当前阶段', 'kernel.dialog.unknownProgress': '进度未知', - 'kernel.access.openaiDescription': '适用于 OpenAI SDK、OpenCode 等兼容客户端', - 'kernel.access.claudeDescription': '适用于 Claude SDK 与 Claude Code', - 'kernel.access.geminiDescription': '适用于 Gemini 原生兼容客户端', + 'kernel.access.openaiDescription': 'OpenAI兼容格式', + 'kernel.access.claudeDescription': 'Anthropic兼容格式', + 'kernel.access.geminiDescription': 'Gemini兼容格式', 'authFiles.unnamed': '未命名认证文件', 'management.notConfigured': '未配置', 'management.error.upstream': '上游请求失败', @@ -1293,7 +1287,7 @@ export const en: Record = { 'agents.sessions.databaseMissing': 'No session database found', 'agents.sessions.repairTitle': 'Restore Historical Sessions', 'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.', - 'agents.sessions.autoRepair': 'Restore historical sessions before launching Codex', + 'agents.sessions.autoRepair': 'Automatically restore historical sessions', 'agents.sessions.settingLoading': 'Loading', 'agents.sessions.settingOn': 'On', 'agents.sessions.settingOff': 'Off', @@ -1368,17 +1362,11 @@ export const en: Record = { 'agents.modify.clear': 'Clear Configuration', 'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.', 'agents.modify.applied': 'The fields managed by the app are applied; all other user content remains unchanged.', - 'agents.footer.changed': 'The model selection changed. Apply the configuration to use the new model.', - 'agents.footer.applied': 'Configuration changes are applied; only the relevant fields are managed by the app.', - 'agents.footer.invalidManaged': 'The configuration is invalid. Apply changes to repair it or reset it to the default configuration.', - 'agents.footer.unsupported': 'This client cannot be configured on the current system', - 'agents.footer.installFirst': 'Install the client, then detect again', - 'agents.footer.noCommand': 'A configuration file was found, but no client command is available', - 'agents.footer.invalidConfig': 'The original configuration is invalid and cannot be modified safely', - 'agents.launchMethods': 'Codex launch methods', 'agents.launch.enableFirst': 'Apply the configuration changes first', 'agents.launch.applyFirst': 'Apply the selected model first', 'agents.launch.starting': 'Starting', + 'agents.launch.startCli': 'Start CLI', + 'agents.launch.startApp': 'Start App', 'agents.launch.start': 'Start {target}', 'agents.launch.unavailable': 'Cannot Start', 'agents.launch.closeChatgpt': 'Close ChatGPT App', @@ -1642,9 +1630,9 @@ export const en: Record = { 'kernel.dialog.install': 'Core Installation', 'kernel.dialog.phase': 'Current Phase', 'kernel.dialog.unknownProgress': 'Progress Unknown', - 'kernel.access.openaiDescription': 'For OpenAI SDK, OpenCode, and other compatible clients', - 'kernel.access.claudeDescription': 'For Claude SDK and Claude Code', - 'kernel.access.geminiDescription': 'For native Gemini-compatible clients', + 'kernel.access.openaiDescription': 'OpenAI-compatible format', + 'kernel.access.claudeDescription': 'Anthropic-compatible format', + 'kernel.access.geminiDescription': 'Gemini-compatible format', 'authFiles.unnamed': 'Unnamed credential file', 'management.notConfigured': 'Not configured', 'management.error.upstream': 'Upstream request failed', diff --git a/src/i18n/traditional.ts b/src/i18n/traditional.ts index 413383f..13a9cc1 100644 --- a/src/i18n/traditional.ts +++ b/src/i18n/traditional.ts @@ -1,4 +1,6 @@ const phraseReplacements: ReadonlyArray = [ + ['兼容格式', '相容格式'], + ['历史', '歷史'], ['高级设置', '進階設定'], ['繁体中文', '繁體中文'], ['简体中文', '簡體中文'], diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 7c92ba9..504f004 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -12,6 +12,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { AlertTriangle, + AppWindow, BadgeCheck, Bot, Check, @@ -22,6 +23,7 @@ import { RefreshCw, Search, Sparkles, + Terminal, Trash2, X, } from 'lucide-react'; @@ -481,10 +483,11 @@ export function AgentsPage() { const [modelByClient, setModelByClient] = useState>>( readAgentModelSelections, ); - const [launchTargetByClient, setLaunchTargetByClient] = useState>>({}); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); - const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | null>(null); + const [busyAction, setBusyAction] = useState< + 'apply' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | null + >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); const [modelError, setModelError] = useState(''); @@ -598,20 +601,6 @@ export function AgentsPage() { setCloseAppConfirmOpen(false); }, [selected]); - useEffect(() => { - setLaunchTargetByClient((current) => agentDefinitions.reduce>>( - (next, definition) => { - const targets = statuses.find((status) => status.id === definition.id)?.launchTargets ?? []; - const previous = current[definition.id] ?? ''; - next[definition.id] = targets.some((target) => target.id === previous) - ? previous - : targets[0]?.id ?? ''; - return next; - }, - {}, - )); - }, [statuses]); - const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) ?? agentDefinitions[0]; const activeStatus = statuses.find((status) => status.id === selected) ?? null; @@ -622,10 +611,9 @@ export function AgentsPage() { const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; const activeLaunchTargets = activeStatus?.launchTargets ?? []; - const selectedLaunchTargetId = launchTargetByClient[selected] ?? activeLaunchTargets[0]?.id ?? ''; - const selectedLaunchTarget = activeLaunchTargets.find( - (target) => target.id === selectedLaunchTargetId, - ) ?? activeLaunchTargets[0] ?? null; + const defaultLaunchTarget = activeLaunchTargets[0] ?? null; + const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null; + const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null; const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? ''; const modelDraftChanged = Boolean( selectedModel.trim() @@ -641,13 +629,13 @@ export function AgentsPage() { && !modelLoading && selectedModelOption, ); - const canLaunch = Boolean( + const launchEnabled = Boolean( activeStatus?.supportedPlatform && activeStatus.installed - && selectedLaunchTarget && (selected === 'codex' || (activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')), ); + const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target); const modelHint = modelSelectionError || modelError || (modelLoading @@ -662,20 +650,6 @@ export function AgentsPage() { : activeStatus?.modificationState === 'applied' ? t('agents.modify.applied') : ''; - const footerMessage = activeStatus?.modificationState === 'applied' - ? draftChanged - ? t('agents.footer.changed') - : t('agents.footer.applied') - : activeStatus?.modificationState === 'invalid' - ? t('agents.footer.invalidManaged') - : !activeStatus?.supportedPlatform - ? t('agents.footer.unsupported') - : !activeStatus.installed - ? t('agents.footer.installFirst') - : activeStatus.launchTargets.length === 0 - ? t('agents.footer.noCommand') - : ''; - const refreshModels = () => { void loadModels(selected); }; @@ -776,14 +750,18 @@ export function AgentsPage() { } }; - const launchAgent = async () => { - setBusyAction('launch'); + const launchAgent = async (target: AgentLaunchTarget | null) => { + if (!target) return; + const launchAction = selected === 'codex' + ? target.id === 'cli' ? 'launch-cli' : 'launch-app' + : 'launch'; + setBusyAction(launchAction); setLaunchError(''); try { if (selected !== 'codex' && draftChanged) { throw new Error(t('agents.error.applyFirst')); } - await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id }); + await invoke('launch_agent', { client: selected, target: target.id }); } catch (requestError) { setLaunchError(String(requestError)); } finally { @@ -975,23 +953,26 @@ export function AgentsPage() {
{selected === 'codex' ? ( - +
+ + +
) : null}
- ))} -
- ) : null} - + + + ) : ( + + > + {busyAction === 'launch' ? : } + {busyAction === 'launch' ? t('agents.launch.starting') : defaultLaunchTarget ? t('agents.launch.start', { target: defaultLaunchTarget.label }) : t('agents.launch.unavailable')} + + )} {selected === 'codex' ? (
) : null} + + {oauthLoginRequiredOpen ? ( +
+
+
+

{t('agents.oauthLoginRequired.title')}

+
+

{t('agents.oauthLoginRequired.description')}

+
+ +
+
+
+ ) : null} ); } From e01aef44f5c3ea70e45812ce4fbbb2df1b4ee031 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 31 Jul 2026 20:21:17 +0800 Subject: [PATCH 09/29] fix(codex): validate OAuth credentials before enabling configuration --- src-tauri/src/main.rs | 48 +++++++++++++++++++++--- src/i18n/ja.ts | 10 +++-- src/i18n/resources.ts | 20 +++++++--- src/pages/AgentsPage.tsx | 81 ++++++++++++++++++++++++++++++---------- src/styles.css | 5 +++ 5 files changed, 130 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5bb7a18..c1d079d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1608,6 +1608,15 @@ async fn get_agent_models( Ok(fetch_prepared_agent_models(client, &config).await?.models) } +#[tauri::command] +fn check_codex_oauth_login(app: tauri::AppHandle) -> Result<(), String> { + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + validate_codex_oauth_login(&home) +} + #[tauri::command] async fn update_codex_model_catalog( app: tauri::AppHandle, @@ -1902,11 +1911,7 @@ async fn fetch_prepared_agent_models( let api_key = effective_agent_api_key(config); if client == AgentClient::Codex { let runtime_models = fetch_codex_runtime_models(config.port, api_key).await?; - let catalog = codex_catalog::prepare_catalog(&runtime_models)?; - Ok(PreparedAgentModels { - models: catalog.models, - codex_catalog: Some(catalog.json), - }) + prepare_codex_agent_models(&runtime_models) } else { Ok(PreparedAgentModels { models: fetch_agent_models(config.port, api_key).await?, @@ -1915,6 +1920,24 @@ async fn fetch_prepared_agent_models( } } +fn prepare_codex_agent_models( + runtime_models: &[codex_catalog::CodexRuntimeModel], +) -> Result { + match codex_catalog::prepare_catalog(runtime_models) { + Ok(catalog) => Ok(PreparedAgentModels { + models: catalog.models, + codex_catalog: Some(catalog.json), + }), + Err(error) if error.contains("CPA 当前没有可写入 Codex 的模型") => { + Ok(PreparedAgentModels { + models: Vec::new(), + codex_catalog: None, + }) + } + Err(error) => Err(error), + } +} + #[tauri::command] async fn apply_agent_config( app: tauri::AppHandle, @@ -1930,6 +1953,9 @@ async fn apply_agent_config( .map_err(|error| format!("无法获取用户目录: {error}"))?; let config = gui_config_state.snapshot()?; let api_key = effective_agent_api_key(&config); + if client == AgentClient::Codex && oauth_configuration { + validate_codex_oauth_login(&home)?; + } validate_agent_can_enable(client, &home, config.port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; @@ -1965,6 +1991,9 @@ async fn reset_agent_config_to_default( .map_err(|error| format!("无法获取用户目录: {error}"))?; let config = gui_config_state.snapshot()?; let api_key = effective_agent_api_key(&config); + if client == AgentClient::Codex && oauth_configuration { + validate_codex_oauth_login(&home)?; + } validate_agent_can_enable(client, &home, config.port, api_key)?; let prepared = fetch_prepared_agent_models(client, &config).await?; let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?; @@ -13513,6 +13542,7 @@ fn main() { get_agent_config_statuses, refresh_agent_config_statuses, get_agent_models, + check_codex_oauth_login, update_codex_model_catalog, get_thinking_aliases, get_thinking_alias_sources, @@ -14480,6 +14510,14 @@ mod tests { assert!(parse_agent_model_options(&serde_json::json!({"data": null})).is_err()); } + #[test] + fn codex_model_list_is_empty_when_cpa_has_no_writable_models() { + let prepared = prepare_codex_agent_models(&[]).unwrap(); + + assert!(prepared.models.is_empty()); + assert!(prepared.codex_catalog.is_none()); + } + #[test] fn agent_model_validation_only_accepts_models_in_current_list() { let models = vec![AgentModelOption { diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f9fabbf..20126fc 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -402,13 +402,13 @@ export const jaOverrides = { 'agents.model.loading': 'モデルを読み込んでいます', 'agents.model.loadFailed': 'モデルの取得に失敗しました', 'agents.model.select': 'モデルを選択', - 'agents.model.none': '選択可能なモデルなし', + 'agents.model.none': 'モデル一覧は空です', 'agents.model.search': 'モデル名またはエイリアスを検索', 'agents.model.clearSearch': '検索を消去', 'agents.model.refresh': 'モデルを更新', 'agents.model.fetching': 'モデルを取得しています', 'agents.model.noMatch': '一致するモデルがありません', - 'agents.model.unavailable': '現在利用可能なモデルはありません', + 'agents.model.unavailable': 'モデル一覧は空です', 'agents.model.tryKeywords': '別のキーワードをお試しください', 'agents.model.connectFirst': '先に利用可能なモデルを接続してから更新してください', 'agents.model.available': '利用可能なモデル', @@ -513,7 +513,11 @@ export const jaOverrides = { 'agents.modify.title': '設定変更', 'agents.modify.oauthConfiguration': 'OAuth 設定方式', 'agents.oauthLoginRequired.title': 'Codex へのログインが必要です', - 'agents.oauthLoginRequired.description': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。', + 'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に', + 'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去', + 'agents.oauthLoginRequired.enableDescriptionSuffix': 'を使用してください。', + 'agents.oauthLoginRequired.applyDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定を適用できません。Codex で ChatGPT アカウントにログインしてから、設定を適用してください。', + 'agents.oauthLoginRequired.launchDescription': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。', 'agents.oauthLoginRequired.confirm': '了解しました', 'agents.modify.apply': '設定変更を適用', 'agents.modify.default': 'デフォルト設定', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 8da4214..2555da7 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -405,13 +405,13 @@ export const zhCN = { 'agents.model.loading': '正在读取模型', 'agents.model.loadFailed': '获取模型失败', 'agents.model.select': '选择模型', - 'agents.model.none': '无可选模型', + 'agents.model.none': '模型列表为空', 'agents.model.search': '搜索模型名称或别名', 'agents.model.clearSearch': '清空搜索', 'agents.model.refresh': '刷新模型', 'agents.model.fetching': '正在获取模型', 'agents.model.noMatch': '没有匹配的模型', - 'agents.model.unavailable': '暂时没有可用模型', + 'agents.model.unavailable': '模型列表为空', 'agents.model.tryKeywords': '尝试其他关键词', 'agents.model.connectFirst': '请先接入可用模型后刷新', 'agents.model.available': '可用模型', @@ -516,7 +516,11 @@ export const zhCN = { 'agents.modify.title': '配置修改', 'agents.modify.oauthConfiguration': 'Oauth配置方式', 'agents.oauthLoginRequired.title': '需要登录 Codex', - 'agents.oauthLoginRequired.description': '已开启 OAuth 配置模式,但未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再启动。', + 'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先使用', + 'agents.oauthLoginRequired.enableClearConfiguration': '清空配置', + 'agents.oauthLoginRequired.enableDescriptionSuffix': '功能。', + 'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。', + 'agents.oauthLoginRequired.launchDescription': '已开启 OAuth 配置模式,但未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再启动。', 'agents.oauthLoginRequired.confirm': '我知道了', 'agents.modify.apply': '应用配置修改', 'agents.modify.default': '默认配置', @@ -1250,13 +1254,13 @@ export const en: Record = { 'agents.model.loading': 'Loading models', 'agents.model.loadFailed': 'Failed to load models', 'agents.model.select': 'Select a model', - 'agents.model.none': 'No models available', + 'agents.model.none': 'Model list is empty', 'agents.model.search': 'Search model names or aliases', 'agents.model.clearSearch': 'Clear search', 'agents.model.refresh': 'Refresh models', 'agents.model.fetching': 'Fetching models', 'agents.model.noMatch': 'No matching models', - 'agents.model.unavailable': 'No models are currently available', + 'agents.model.unavailable': 'Model list is empty', 'agents.model.tryKeywords': 'Try different keywords', 'agents.model.connectFirst': 'Connect an available model, then refresh', 'agents.model.available': 'Available model', @@ -1361,7 +1365,11 @@ export const en: Record = { 'agents.modify.title': 'Configuration Changes', 'agents.modify.oauthConfiguration': 'OAuth configuration mode', 'agents.oauthLoginRequired.title': 'Codex sign-in required', - 'agents.oauthLoginRequired.description': 'OAuth configuration mode is enabled, but no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex before launching it.', + 'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, use', + 'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration', + 'agents.oauthLoginRequired.enableDescriptionSuffix': 'first.', + 'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.', + 'agents.oauthLoginRequired.launchDescription': 'OAuth configuration mode is enabled, but no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex before launching it.', 'agents.oauthLoginRequired.confirm': 'Got it', 'agents.modify.apply': 'Apply Configuration Changes', 'agents.modify.default': 'Default Configuration', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index f9b846f..f44df33 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -92,6 +92,8 @@ type ChatGptCloseResult = { closedProcesses: number; }; +type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch'; + const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED'; type AgentDefinition = { @@ -488,7 +490,7 @@ export function AgentsPage() { const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | null + 'apply' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -504,7 +506,7 @@ export function AgentsPage() { const [closeAppError, setCloseAppError] = useState(''); const [closeAppNotice, setCloseAppNotice] = useState(''); const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false); - const [oauthLoginRequiredOpen, setOauthLoginRequiredOpen] = useState(false); + const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState(null); const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState(null); const modelRequestRef = useRef(0); @@ -602,7 +604,7 @@ export function AgentsPage() { setCloseAppError(''); setCloseAppNotice(''); setCloseAppConfirmOpen(false); - setOauthLoginRequiredOpen(false); + setOauthLoginRequiredAction(null); }, [selected]); const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) @@ -696,6 +698,34 @@ export function AgentsPage() { return model.name; }; + const handleOAuthLoginError = (requestError: unknown, action: OAuthLoginRequiredAction) => { + const message = String(requestError); + if (message.includes(CODEX_OAUTH_LOGIN_REQUIRED_ERROR)) { + setOauthLoginRequiredAction(action); + return true; + } + return false; + }; + + const changeOauthConfiguration = async (enabled: boolean) => { + if (!enabled) { + setOauthConfigurationDraft(false); + return; + } + + setBusyAction('oauth-check'); + try { + await invoke('check_codex_oauth_login'); + setOauthConfigurationDraft(true); + } catch (requestError) { + if (!handleOAuthLoginError(requestError, 'enable')) { + setConfigurationError(String(requestError)); + } + } finally { + setBusyAction(null); + } + }; + const applyConfigurationChanges = async () => { setConfigurationError(''); const model = requireSelectedModel(); @@ -710,7 +740,9 @@ export function AgentsPage() { await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { - setConfigurationError(String(requestError)); + if (!handleOAuthLoginError(requestError, 'apply')) { + setConfigurationError(String(requestError)); + } } finally { setBusyAction(null); } @@ -731,7 +763,9 @@ export function AgentsPage() { await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { - setDefaultError(String(requestError)); + if (!handleOAuthLoginError(requestError, 'apply')) { + setDefaultError(String(requestError)); + } } finally { setBusyAction(null); } @@ -762,16 +796,12 @@ export function AgentsPage() { setBusyAction(launchAction); setLaunchError(''); try { - if (selected !== 'codex' && draftChanged) { + if (draftChanged) { throw new Error(t('agents.error.applyFirst')); } await invoke('launch_agent', { client: selected, target: target.id }); } catch (requestError) { - if (String(requestError) === CODEX_OAUTH_LOGIN_REQUIRED_ERROR) { - setOauthLoginRequiredOpen(true); - } else { - setLaunchError(String(requestError)); - } + if (!handleOAuthLoginError(requestError, 'launch')) setLaunchError(String(requestError)); } finally { setBusyAction(null); } @@ -827,6 +857,13 @@ export function AgentsPage() { const availableSubpages = agentSubpages.filter( (subpage) => !subpage.clients || subpage.clients.includes(selected), ); + const oauthLoginRequiredDescription = oauthLoginRequiredAction === 'enable' ? ( + <> + {t('agents.oauthLoginRequired.enableDescription')} + {t('agents.oauthLoginRequired.enableClearConfiguration')} + {t('agents.oauthLoginRequired.enableDescriptionSuffix')} + + ) : oauthLoginRequiredAction ? t(`agents.oauthLoginRequired.${oauthLoginRequiredAction}Description`) : ''; return (
@@ -973,7 +1010,7 @@ export function AgentsPage() { type="checkbox" role="switch" checked={oauthConfiguration} - onChange={(event) => setOauthConfigurationDraft(event.currentTarget.checked)} + onChange={(event) => void changeOauthConfiguration(event.currentTarget.checked)} disabled={busy} aria-label={t('agents.modify.oauthConfiguration')} /> @@ -1038,8 +1075,10 @@ export function AgentsPage() { type="button" className="secondary-button agent-launch-button" onClick={() => void launchAgent(cliLaunchTarget)} - disabled={busy || !canLaunchTarget(cliLaunchTarget)} - title={cliLaunchTarget?.detail ?? t('agents.launch.unavailable')} + disabled={busy || !canLaunchTarget(cliLaunchTarget) || draftChanged} + title={draftChanged + ? t('agents.launch.applyFirst') + : cliLaunchTarget?.detail ?? t('agents.launch.unavailable')} > {busyAction === 'launch-cli' ? : } {busyAction === 'launch-cli' ? t('agents.launch.starting') : t('agents.launch.startCli')} @@ -1048,8 +1087,10 @@ export function AgentsPage() { type="button" className="primary-button agent-launch-button" onClick={() => void launchAgent(appLaunchTarget)} - disabled={busy || !canLaunchTarget(appLaunchTarget)} - title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')} + disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged} + title={draftChanged + ? t('agents.launch.applyFirst') + : appLaunchTarget?.detail ?? t('agents.launch.unavailable')} > {busyAction === 'launch-app' ? : } {busyAction === 'launch-app' ? t('agents.launch.starting') : t('agents.launch.startApp')} @@ -1182,15 +1223,15 @@ export function AgentsPage() { ) : null} - {oauthLoginRequiredOpen ? ( + {oauthLoginRequiredAction ? (

{t('agents.oauthLoginRequired.title')}

-

{t('agents.oauthLoginRequired.description')}

-
- +

{oauthLoginRequiredDescription}

+
+
diff --git a/src/styles.css b/src/styles.css index 640d119..d9d9310 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2194,6 +2194,11 @@ textarea:focus-visible { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.config-dialog-actions.single-action { + grid-template-columns: 118px; + justify-content: end; +} + .config-dialog-actions button { gap: 7px; height: 40px; From 6a5eedbb56cd12444eb58acbf72f6e29e27ebcac Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 1 Aug 2026 00:04:25 +0800 Subject: [PATCH 10/29] feat(sessions): group local conversations by project and enable auto-repair by default --- build.ps1 | 15 +- core-version.txt | 2 +- scripts/prepare-portable.mjs | 23 +- src-tauri/src/codex_catalog.rs | 67 ++- src-tauri/src/main.rs | 735 ++++++++++++++++++++++++++++++--- src/i18n/ja.ts | 1 - src/i18n/resources.ts | 2 - src/pages/AgentsPage.tsx | 4 +- src/styles.css | 8 + 9 files changed, 763 insertions(+), 94 deletions(-) diff --git a/build.ps1 b/build.ps1 index 5b2e281..a958702 100644 --- a/build.ps1 +++ b/build.ps1 @@ -58,7 +58,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) { throw "Build finished, but executable not found: $AppBin" } -& bun $PreparePortable --binary $AppBin --output $BinDir --download true +& bun $PreparePortable --binary $AppBin --output $BinDir --download true --preserve-runtime-config true if ($LASTEXITCODE -ne 0) { throw "Portable preparation failed with exit code $LASTEXITCODE." } @@ -68,15 +68,14 @@ if (-not (Test-Path -LiteralPath $BinOut -PathType Leaf)) { } $CoreOut = Join-Path $BinDir 'cpa-core' -$CoreEntries = @(Get-ChildItem -LiteralPath $CoreOut -Force) -if ( - $CoreEntries.Count -ne 1 -or - $CoreEntries[0].PSIsContainer -or - $CoreEntries[0].Name -notmatch '^CLIProxyAPI_.+_windows_.+\.zip$' -) { +$CoreArchives = @( + Get-ChildItem -LiteralPath $CoreOut -File | + Where-Object { $_.Name -match '^CLIProxyAPI_.+_windows_.+\.zip$' } +) +if ($CoreArchives.Count -ne 1) { throw "Portable core output must contain exactly one Windows core archive: $CoreOut" } Write-Host "Built: $AppBin" Write-Host "Copied: $BinOut" -Write-Host "Bundled core archive: $($CoreEntries[0].FullName)" +Write-Host "Bundled core archive: $($CoreArchives[0].FullName)" diff --git a/core-version.txt b/core-version.txt index 32bf1fc..ee4117d 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.109 +7.2.112 diff --git a/scripts/prepare-portable.mjs b/scripts/prepare-portable.mjs index 1615c06..37b7fd8 100644 --- a/scripts/prepare-portable.mjs +++ b/scripts/prepare-portable.mjs @@ -14,6 +14,7 @@ const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform]; const targetArch = args.get('--arch') ?? ({ x64: 'amd64', arm64: 'aarch64' })[process.arch]; const shouldDownload = args.get('--download') === 'true'; +const preserveRuntimeConfig = args.get('--preserve-runtime-config') === 'true'; if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`); if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`); @@ -75,17 +76,25 @@ await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({ }, null, 2)}\n`); const coreOutput = join(output, 'cpa-core'); -await rm(coreOutput, { recursive: true, force: true }); +if (preserveRuntimeConfig) { + await mkdir(coreOutput, { recursive: true }); + const entries = await readdir(coreOutput, { withFileTypes: true }); + await Promise.all(entries + .filter((entry) => entry.name !== 'config.yaml') + .map((entry) => rm(join(coreOutput, entry.name), { recursive: true, force: true }))); +} else { + await rm(coreOutput, { recursive: true, force: true }); +} await mkdir(coreOutput, { recursive: true }); await copyFile(sourceArchive, join(coreOutput, assetName)); const coreEntries = await readdir(coreOutput, { withFileTypes: true }); -if ( - coreEntries.length !== 1 - || !coreEntries[0].isFile() - || coreEntries[0].name !== assetName -) { +const bundledArchive = coreEntries.find((entry) => entry.isFile() && entry.name === assetName); +if (!bundledArchive) { + throw new Error(`Core output must contain ${assetName}`); +} +if (!preserveRuntimeConfig && coreEntries.length !== 1) { throw new Error(`Core output must contain only ${assetName}`); } console.log(`Prepared portable directory: ${output}`); -console.log(`Bundled core: ${basename(sourceArchive)} (archive only)`); +console.log(`Bundled core: ${basename(sourceArchive)}${preserveRuntimeConfig ? ' (preserved cpa-core/config.yaml)' : ' (archive only)'}`); diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 4b1829e..8e3f482 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -99,11 +99,14 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result Result
- ) : ( + ) : activeSubpage === 'aliases' ? (
+ ) : ( +
+ +
)} {addDialogOpen ? ( diff --git a/src/pages/SpeedAliasesPage.tsx b/src/pages/SpeedAliasesPage.tsx new file mode 100644 index 0000000..10f84c0 --- /dev/null +++ b/src/pages/SpeedAliasesPage.tsx @@ -0,0 +1,399 @@ +import { + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + ArrowRight, + Check, + GitFork, + LoaderCircle, + Search, + Trash2, + Zap, +} from 'lucide-react'; +import { useI18n } from '../i18n'; +import { thinkingAliasSourceKindLabel } from './ThinkingAliasesPage'; + +type SpeedAliasEntry = { + sourceModel: string; + alias: string; + serviceTier: string; + provider: string; + kind: string; +}; + +type SpeedAliasSource = { + id: string; + model: string; + displayName: string | null; + provider: string; + kind: string; + protocol: string; +}; + +export function SpeedAliasesPage() { + const { t } = useI18n(); + const [entries, setEntries] = useState([]); + const [sources, setSources] = useState([]); + const [selectedSourceId, setSelectedSourceId] = useState(''); + const [alias, setAlias] = useState(''); + const [search, setSearch] = useState(''); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const [activeSourceIndex, setActiveSourceIndex] = useState(0); + const modelPickerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [busyAlias, setBusyAlias] = useState(''); + const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>(''); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const [nextEntries, nextSources] = await Promise.all([ + invoke('get_speed_aliases'), + invoke('get_speed_alias_sources'), + ]); + setEntries(nextEntries); + setSources(nextSources); + setSelectedSourceId((current) => ( + nextSources.some((source) => source.id === current) ? current : '' + )); + } catch (requestError) { + setError(String(requestError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!modelPickerOpen) return undefined; + const closeOnOutsideClick = (event: PointerEvent) => { + if (!modelPickerRef.current?.contains(event.target as Node)) { + setModelPickerOpen(false); + setSearch(''); + } + }; + document.addEventListener('pointerdown', closeOnOutsideClick); + return () => document.removeEventListener('pointerdown', closeOnOutsideClick); + }, [modelPickerOpen]); + + const selectedSource = useMemo( + () => sources.find((source) => source.id === selectedSourceId) ?? null, + [selectedSourceId, sources], + ); + + const filteredSources = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return sources; + return sources.filter((source) => { + const haystack = [ + source.model, + source.displayName ?? '', + source.provider, + thinkingAliasSourceKindLabel(source.kind), + ].join(' ').toLowerCase(); + return haystack.includes(query); + }); + }, [search, sources]); + + useEffect(() => { + setActiveSourceIndex(0); + }, [search, sources]); + + const chooseSource = (source: SpeedAliasSource) => { + setSelectedSourceId(source.id); + setModelPickerOpen(false); + setSearch(''); + }; + + const handleModelSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setModelPickerOpen(false); + setSearch(''); + event.currentTarget.blur(); + return; + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + if (!modelPickerOpen) { + setModelPickerOpen(true); + return; + } + if (!filteredSources.length) return; + const direction = event.key === 'ArrowDown' ? 1 : -1; + setActiveSourceIndex((current) => ( + (current + direction + filteredSources.length) % filteredSources.length + )); + return; + } + if (event.key === 'Enter' && modelPickerOpen && filteredSources[activeSourceIndex]) { + event.preventDefault(); + chooseSource(filteredSources[activeSourceIndex]); + } + }; + + const createAlias = async () => { + if (!selectedSource) { + setError(t('aliases.error.selectModel')); + return; + } + const normalizedAlias = alias.trim(); + if (!normalizedAlias) { + setError(t('aliases.error.emptyAlias')); + return; + } + setBusyAlias(normalizedAlias); + setBusyAction('create'); + setError(''); + setNotice(''); + try { + const nextEntries = await invoke('create_speed_alias', { + sourceId: selectedSource.id, + alias: normalizedAlias, + }); + setEntries(nextEntries); + setNotice(t('speedAliases.created', { alias: normalizedAlias })); + setAlias(''); + } catch (requestError) { + setError(String(requestError)); + } finally { + setBusyAlias(''); + setBusyAction(''); + } + }; + + const deleteAlias = async (entry: SpeedAliasEntry) => { + if (!window.confirm(t('speedAliases.deleteConfirm', { alias: entry.alias }))) return; + setBusyAlias(entry.alias); + setBusyAction('delete'); + setError(''); + setNotice(''); + try { + const nextEntries = await invoke('delete_speed_alias', { + alias: entry.alias, + }); + setEntries(nextEntries); + setNotice(t('speedAliases.deleted', { alias: entry.alias })); + } catch (requestError) { + setError(String(requestError)); + } finally { + setBusyAlias(''); + setBusyAction(''); + } + }; + + return ( +
+
+ {error ?
{error}
: null} + {!error && notice ?
{notice}
: null} +
+ +
+
+
+ +
+

{t('speedAliases.create.title')}

+

{t('speedAliases.create.description')}

+
+
+ +
+ +
+
+ {loading ? : } + { + setSearch(selectedSource?.model ?? ''); + setModelPickerOpen(true); + event.currentTarget.select(); + }} + onChange={(event) => { + setSearch(event.currentTarget.value); + setModelPickerOpen(true); + }} + onKeyDown={handleModelSearchKeyDown} + placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')} + autoComplete="off" + spellCheck={false} + disabled={loading} + /> + {!modelPickerOpen && selectedSource ? ( + + {thinkingAliasSourceKindLabel(selectedSource.kind)} + + ) : null} +
+ {modelPickerOpen ? ( +
+ {filteredSources.length === 0 ? ( +
+ {sources.length ? t('aliases.noMatch') : t('aliases.noModels')} +
+ ) : filteredSources.map((source, index) => { + const selected = source.id === selectedSourceId; + return ( + + ); + })} +
+ ) : null} +
+ {selectedSource ? ( +
+ {thinkingAliasSourceKindLabel(selectedSource.kind)} + {selectedSource.provider} +
+ ) : ( + {t('aliases.sourceHint')} + )} +
+ +
+ +
+ {t('speedAliases.fast.title')} + service_tier = priority +
+
+ +
+ +
+
+
+

{t('speedAliases.createdList.title')}

+ {t('speedAliases.createdList.description')} +
+ {entries.length} +
+ +
+ {loading ? ( +
{t('aliases.loadingConfig')}
+ ) : entries.length === 0 ? ( +
+ + {t('speedAliases.empty.title')} + {t('speedAliases.empty.description')} +
+ ) : entries.map((entry) => ( +
+
+
+ {entry.sourceModel} + + {thinkingAliasSourceKindLabel(entry.kind)} + {entry.provider} + +
+ + {entry.alias} +
+ {entry.serviceTier} + +
+ ))} +
+
+
+
+ ); +} From 88baeab5125ea556818be54073fa63037f4691b3 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 00:08:16 +0800 Subject: [PATCH 13/29] feat(config): improve agent and WebUI configuration security - add WebUI management key settings - generate strong per-install management secrets - rotate the legacy 123456 management key on upgrade - preserve custom management keys - restore Codex configuration without a state JSON file --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 796 +++++++++++++++++++++++++++++++------- src/i18n/ja.ts | 19 + src/i18n/resources.ts | 50 +++ src/pages/AgentsPage.tsx | 214 ++++++++-- src/pages/ConfigPanel.tsx | 166 ++++++++ src/styles.css | 146 +++++++ 8 files changed, 1226 insertions(+), 167 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index edd4b58..b6ec629 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -588,6 +588,7 @@ dependencies = [ "chrono", "flate2", "futures-util", + "getrandom 0.3.4", "json5", "libc", "notify", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 69d8a1d..d8378c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,6 +13,7 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } flate2 = "1" futures-util = "0.3" +getrandom = "0.3" json5 = "0.4" notify = "7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4083e06..236ba47 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -80,11 +80,14 @@ const OAUTH_DIR_NAME: &str = "oauth"; const DEFAULT_AUTH_DIR: &str = "../oauth"; const DEFAULT_API_KEY: &str = "123456"; const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥"; -const DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; +const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json"; const CODEX_OAUTH_LOGIN_REQUIRED_ERROR: &str = "CODEX_OAUTH_LOGIN_REQUIRED"; const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700"; +const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5"; +const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-4-6"; +const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5"; const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ "codex-api-key", "openai-compatibility", @@ -93,7 +96,7 @@ const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ ]; const LEGACY_AGENT_MODIFICATION_STATE_VERSION: u8 = 1; const AGENT_MODIFICATION_STATE_VERSION: u8 = 2; -const AGENT_APPLIED_STATE_VERSION: u8 = 3; +const AGENT_APPLIED_STATE_VERSION: u8 = 4; const AGENT_PHASE_APPLYING: &str = "applying"; const AGENT_PHASE_ACTIVE: &str = "active"; const AGENT_PHASE_RESTORING: &str = "restoring"; @@ -112,6 +115,9 @@ const APP_USER_AGENT: &str = concat!( ); static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); +static CODEX_APPLIED_STATES: LazyLock< + Mutex>, +> = LazyLock::new(|| Mutex::new(std::collections::HashMap::new())); static CONFIG_WRITE_HASHES: LazyLock>> = LazyLock::new(|| Mutex::new(std::collections::HashMap::new())); @@ -537,9 +543,9 @@ impl Default for GuiConfigFile { auth_dir: DEFAULT_AUTH_DIR.to_string(), api_keys: vec![default_api_key_entry()], api_access_remarks: Vec::new(), - // Keep plaintext here for management API auth. Core hashes the - // value written into config.yaml on startup. - management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(), + // Populated with an OS-generated secret while loading the GUI + // configuration. Core hashes the value written into config.yaml. + management_secret_key: String::new(), usage_statistics_enabled: true, plugins_enabled: false, routing_strategy: "round-robin".to_string(), @@ -747,9 +753,19 @@ struct AgentAppliedState { model: String, #[serde(default, skip_serializing_if = "Option::is_none")] claude_desktop_model_mappings: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + backup_files: Vec, updated_at_unix: u64, } +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentAppliedBackupFile { + path: PathBuf, + backup_path: PathBuf, + existed_before: bool, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AgentClient { ClaudeCode, @@ -2122,6 +2138,9 @@ async fn apply_agent_config( &model, claude_desktop_model_mappings, )?; + if let Some(mappings) = claude_desktop_model_mappings.as_ref() { + ensure_claude_desktop_model_aliases(&config, mappings).await?; + } let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; @@ -2140,6 +2159,23 @@ async fn apply_agent_config( ) } +#[tauri::command] +fn close_agent_config_modification( + app: tauri::AppHandle, + client: String, +) -> Result { + let client = AgentClient::parse(&client)?; + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + restore_agent_session_configuration(client, &home)?; + Ok(action_result("closed", false, None, Vec::new(), Vec::new())) +} + #[tauri::command] async fn reset_agent_config_to_default( app: tauri::AppHandle, @@ -2168,6 +2204,9 @@ async fn reset_agent_config_to_default( &model, claude_desktop_model_mappings, )?; + if let Some(mappings) = claude_desktop_model_mappings.as_ref() { + ensure_claude_desktop_model_aliases(&config, mappings).await?; + } let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; @@ -2226,6 +2265,9 @@ async fn set_agent_config_enabled( &model, claude_desktop_model_mappings, )?; + if let Some(mappings) = claude_desktop_model_mappings.as_ref() { + ensure_claude_desktop_model_aliases(&config, mappings).await?; + } let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; @@ -2275,6 +2317,9 @@ async fn update_agent_config( &model, claude_desktop_model_mappings, )?; + if let Some(mappings) = claude_desktop_model_mappings.as_ref() { + ensure_claude_desktop_model_aliases(&config, mappings).await?; + } let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; @@ -2733,9 +2778,10 @@ fn clear_codex_config_files(home: &Path) -> Result, String> { } } - // The state file belongs to this app. Remove it after the Codex files are - // cleared so the UI does not report the deliberate deletion as an external edit. let state_path = agent_state_path(std::slice::from_ref(&config_path))?; + clear_codex_applied_state(&state_path)?; + // Clean up state files left by older releases. New Codex applications keep + // this short-lived restore metadata in memory instead. remove_codex_config_file(&state_path)?; Ok(deleted) @@ -4275,7 +4321,7 @@ fn build_agent_updates_with_oauth( models, codex_catalog, oauth_configuration, - claude_desktop_model_mappings: _, + claude_desktop_model_mappings, } = options; let paths = agent_config_paths(client, home); let root_base = format!("http://127.0.0.1:{port}"); @@ -4316,10 +4362,16 @@ fn build_agent_updates_with_oauth( &root_base, api_key, model, - models, + claude_desktop_model_mappings, ) .or_else(|_| { - build_claude_desktop_profile(None, &root_base, api_key, model, models) + build_claude_desktop_profile( + None, + &root_base, + api_key, + model, + claude_desktop_model_mappings, + ) })?, }, AgentFileUpdate { @@ -4541,7 +4593,7 @@ fn build_claude_desktop_profile( base_url: &str, api_key: &str, model: &str, - available_models: &[AgentModelOption], + mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; root.insert( @@ -4568,33 +4620,14 @@ fn build_claude_desktop_profile( "inferenceProvider".to_string(), serde_json::json!("gateway"), ); - // Keep every CPA model visible in Claude Desktop. Non-Claude model IDs use - // stable Claude-style gateway aliases, while the original model ID remains - // the visible label. - let mut inference_models = Vec::with_capacity(available_models.len().max(1)); - let mut inference_model_ids = Vec::with_capacity(available_models.len().max(1)); - for available_model in ordered_agent_models(available_models, model) { - inference_models.push(serde_json::json!(available_model.name)); - continue; - let inference_model = claude_desktop_inference_model_id(&available_model.name)?; - if inference_model_ids - .iter() - .any(|existing: &String| existing.eq_ignore_ascii_case(&inference_model)) - { - return Err(format!("Claude Desktop 模型别名冲突: {inference_model}")); - } - inference_model_ids.push(inference_model.clone()); - inference_models.push( - if inference_model.eq_ignore_ascii_case(&available_model.name) { - serde_json::json!(inference_model) - } else { - serde_json::json!({ - "name": inference_model, - "labelOverride": available_model.name, - }) - }, - ); - } + let _mappings = mappings + .cloned() + .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); + let inference_models = vec![ + serde_json::json!(CLAUDE_DESKTOP_OPUS_MODEL_ID), + serde_json::json!(CLAUDE_DESKTOP_SONNET_MODEL_ID), + serde_json::json!(CLAUDE_DESKTOP_HAIKU_MODEL_ID), + ]; root.insert( "inferenceModels".to_string(), serde_json::Value::Array(inference_models), @@ -4602,32 +4635,6 @@ fn build_claude_desktop_profile( render_agent_json(root, "Claude Desktop 网关配置") } -fn claude_desktop_inference_model_id(model: &str) -> Result { - let model = validate_agent_model(model)?; - let normalized = model.to_ascii_lowercase(); - if normalized.starts_with("claude-") - || normalized.starts_with("opus-") - || matches!(normalized.as_str(), "sonnet" | "opus" | "haiku") - { - return Ok(model); - } - let reversed = model - .chars() - .rev() - .map(|character| { - if character.is_ascii_alphanumeric() { - character.to_ascii_lowercase() - } else if matches!(character, '-' | '.') { - character - } else { - '-' - } - }) - .collect::(); - let alias = format!("opus-{reversed}"); - validate_thinking_alias_model_id(&alias, "Claude Desktop 自动别名") -} - fn build_claude_desktop_meta(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?; let entries = ensure_json_array_entry(&mut root, "entries"); @@ -5223,20 +5230,158 @@ fn legacy_agent_backup_paths(state_content: &str) -> Vec { } fn write_agent_applied_state(path: &Path, state: &AgentAppliedState) -> Result<(), String> { + if state.client == AgentClient::Codex.id() { + CODEX_APPLIED_STATES + .lock() + .map_err(|_| "Codex 应用状态内存锁已损坏".to_string())? + .insert(path.to_path_buf(), state.clone()); + if path.is_file() { + fs::remove_file(path).map_err(|error| { + format!( + "清理旧版 Codex 应用状态失败 {}: {error}", + path_to_string(path) + ) + })?; + } + return Ok(()); + } + let mut content = serde_json::to_string_pretty(state) .map_err(|error| format!("生成智能体应用状态失败: {error}"))?; content.push('\n'); write_bytes_directly(path, content.as_bytes()) } +fn clear_codex_applied_state(path: &Path) -> Result<(), String> { + CODEX_APPLIED_STATES + .lock() + .map_err(|_| "Codex 应用状态内存锁已损坏".to_string())? + .remove(path); + Ok(()) +} + +fn is_dated_agent_backup_name(file_name: &str, original_name: &str) -> bool { + let Some(date) = file_name + .strip_prefix(&format!("{original_name}.")) + .and_then(|value| value.strip_suffix(".bak")) + else { + return false; + }; + let bytes = date.as_bytes(); + bytes.len() == 8 + && bytes[0..2].iter().all(u8::is_ascii_digit) + && bytes[2] == b'-' + && bytes[3..5].iter().all(u8::is_ascii_digit) + && bytes[5] == b'-' + && bytes[6..8].iter().all(u8::is_ascii_digit) +} + +fn latest_dated_agent_backup_path(path: &Path) -> Result, String> { + let directory = path.parent().unwrap_or_else(|| Path::new(".")); + if !directory.is_dir() { + return Ok(None); + } + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(path)))?; + let mut candidates = fs::read_dir(directory) + .map_err(|error| { + format!( + "读取智能体备份目录失败 {}: {error}", + path_to_string(directory) + ) + })? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|candidate| { + candidate + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| is_dated_agent_backup_name(value, file_name)) + }) + .collect::>(); + candidates.sort(); + Ok(candidates.pop()) +} + +fn recover_codex_applied_state_from_backups( + paths: &[PathBuf], +) -> Result, String> { + let managed_paths = expected_agent_record_paths(AgentClient::Codex, paths); + let mut discovered = Vec::with_capacity(managed_paths.len()); + let mut found_backup = false; + for path in managed_paths { + let backup = latest_dated_agent_backup_path(&path)?; + found_backup |= backup.is_some(); + discovered.push((path, backup)); + } + if !found_backup { + return Ok(None); + } + + let model = paths + .first() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|content| toml::from_str::(&content).ok()) + .and_then(|root| { + root.get("model") + .and_then(toml::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_default(); + let mut backup_files = Vec::with_capacity(discovered.len()); + for (path, backup) in discovered { + let existed_before = backup.is_some(); + backup_files.push(AgentAppliedBackupFile { + backup_path: match backup { + Some(path) => path, + None => dated_agent_backup_path(&path)?, + }, + path, + existed_before, + }); + } + let state = AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: AgentClient::Codex.id().to_string(), + model, + claude_desktop_model_mappings: None, + backup_files, + updated_at_unix: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + Ok(Some(state)) +} + fn load_agent_applied_state( client: AgentClient, home: &Path, ) -> Result, String> { let paths = agent_config_paths(client, home); let state_path = agent_state_path(&paths)?; + if client == AgentClient::Codex { + if let Some(state) = CODEX_APPLIED_STATES + .lock() + .map_err(|_| "Codex 应用状态内存锁已损坏".to_string())? + .get(&state_path) + .cloned() + { + return Ok(Some(state)); + } + } if !state_path.is_file() { - return Ok(None); + let recovered = if client == AgentClient::Codex { + recover_codex_applied_state_from_backups(&paths)? + } else { + None + }; + if let Some(state) = recovered.as_ref() { + write_agent_applied_state(&state_path, state)?; + } + return Ok(recovered); } let content = fs::read_to_string(&state_path) .map_err(|error| format!("读取智能体应用状态失败: {error}"))?; @@ -5246,12 +5391,15 @@ fn load_agent_applied_state( .get("version") .and_then(serde_json::Value::as_u64) .ok_or_else(|| "智能体应用状态缺少版本号".to_string())? as u8; - if version == AGENT_APPLIED_STATE_VERSION { + if version == AGENT_APPLIED_STATE_VERSION || version == 3 { let state = serde_json::from_value::(value) .map_err(|error| format!("解析智能体应用状态失败: {error}"))?; if state.client != client.id() { return Err("智能体应用状态与客户端不匹配".to_string()); } + if client == AgentClient::Codex { + write_agent_applied_state(&state_path, &state)?; + } return Ok(Some(state)); } if version != LEGACY_AGENT_MODIFICATION_STATE_VERSION @@ -5268,6 +5416,7 @@ fn load_agent_applied_state( client: client.id().to_string(), model: record.model, claude_desktop_model_mappings: None, + backup_files: Vec::new(), updated_at_unix: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -5621,7 +5770,7 @@ fn fresh_agent_contents( name: model.to_string(), alias: None, }]; - fresh_agent_contents_with_oauth(client, port, api_key, model, &models, false) + fresh_agent_contents_with_oauth(client, port, api_key, model, &models, false, None) } fn fresh_agent_contents_with_oauth( @@ -5631,6 +5780,7 @@ fn fresh_agent_contents_with_oauth( model: &str, models: &[AgentModelOption], oauth_configuration: bool, + claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result, String> { let root_base = format!("http://127.0.0.1:{port}"); let openai_base = format!("{root_base}/v1"); @@ -5641,7 +5791,13 @@ fn fresh_agent_contents_with_oauth( AgentClient::ClaudeDesktop => Ok(vec![ build_claude_desktop_deployment_config(None)?, build_claude_desktop_deployment_config(None)?, - build_claude_desktop_profile(None, &root_base, api_key, model, models)?, + build_claude_desktop_profile( + None, + &root_base, + api_key, + model, + claude_desktop_model_mappings, + )?, build_claude_desktop_meta(None)?, ]), AgentClient::Codex => Ok(vec![build_codex_agent_config_with_oauth( @@ -6125,6 +6281,113 @@ fn restore_agent_snapshots_direct(snapshots: &[FileSnapshot]) -> Result<(), Stri } } +fn dated_agent_backup_path(path: &Path) -> Result { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(path)))?; + let date = chrono::Local::now().format("%y-%m-%d"); + Ok(path.with_file_name(format!("{file_name}.{date}.bak"))) +} + +fn prepare_agent_session_backups( + updates: &[AgentFileUpdate], +) -> Result, String> { + let mut backups = Vec::with_capacity(updates.len()); + let mut renamed = Vec::new(); + for update in updates { + let existed_before = update.path.is_file(); + let backup_path = dated_agent_backup_path(&update.path)?; + if existed_before { + if backup_path.exists() { + for (path, backup) in renamed.iter().rev() { + let _ = fs::rename(backup, path); + } + return Err(format!( + "智能体备份文件已存在,无法覆盖: {}", + path_to_string(&backup_path) + )); + } + fs::rename(&update.path, &backup_path).map_err(|error| { + format!( + "重命名原智能体配置为备份失败 {}: {error}", + path_to_string(&update.path) + ) + })?; + renamed.push((update.path.clone(), backup_path.clone())); + } + backups.push(AgentAppliedBackupFile { + path: update.path.clone(), + backup_path, + existed_before, + }); + } + Ok(backups) +} + +fn restore_agent_session_configuration(client: AgentClient, home: &Path) -> Result<(), String> { + let Some(state) = load_agent_applied_state(client, home)? else { + return Ok(()); + }; + if state.backup_files.is_empty() { + return Ok(()); + } + + for file in state.backup_files.iter().rev() { + if file.existed_before { + let original = fs::read(&file.backup_path).map_err(|error| { + format!( + "读取智能体备份失败 {}: {error}", + path_to_string(&file.backup_path) + ) + })?; + write_bytes_directly(&file.path, &original)?; + fs::remove_file(&file.backup_path).map_err(|error| { + format!( + "清理智能体备份失败 {}: {error}", + path_to_string(&file.backup_path) + ) + })?; + } else if file.path.exists() { + fs::remove_file(&file.path).map_err(|error| { + format!( + "删除临时智能体配置失败 {}: {error}", + path_to_string(&file.path) + ) + })?; + } + } + + let state_path = agent_state_path(&agent_config_paths(client, home))?; + if client == AgentClient::Codex { + clear_codex_applied_state(&state_path)?; + } + if state_path.is_file() { + fs::remove_file(&state_path).map_err(|error| { + format!( + "清理智能体应用状态失败 {}: {error}", + path_to_string(&state_path) + ) + })?; + } + Ok(()) +} + +fn restore_all_agent_session_configurations(home: &Path) { + for client in [ + AgentClient::ClaudeCode, + AgentClient::ClaudeDesktop, + AgentClient::Codex, + AgentClient::OpenCode, + AgentClient::OpenClaw, + AgentClient::Hermes, + ] { + if let Err(error) = restore_agent_session_configuration(client, home) { + eprintln!("关闭时恢复 {} 配置失败: {error}", client.name()); + } + } +} + fn commit_agent_configuration( client: AgentClient, home: &Path, @@ -6135,6 +6398,7 @@ fn commit_agent_configuration( ) -> Result { let base_paths = agent_config_paths(client, home); let state_path = agent_state_path(&base_paths)?; + let existing_state = load_agent_applied_state(client, home)?; let previous_state = read_agent_bytes(&state_path)?; let mut legacy_backups = previous_state .as_deref() @@ -6155,6 +6419,16 @@ fn commit_agent_configuration( .collect::, String>>()?; snapshots.push((state_path.clone(), previous_state)); + let created_session_backups = existing_state + .as_ref() + .is_none_or(|state| state.backup_files.is_empty()); + let backup_files = + if let Some(state) = existing_state.filter(|state| !state.backup_files.is_empty()) { + state.backup_files + } else { + prepare_agent_session_backups(updates)? + }; + let transaction = (|| -> Result, String> { let mut changed = Vec::new(); for update in updates { @@ -6172,6 +6446,7 @@ fn commit_agent_configuration( claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop) .then(|| claude_desktop_model_mappings.cloned()) .flatten(), + backup_files: backup_files.clone(), updated_at_unix: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -6196,10 +6471,20 @@ fn commit_agent_configuration( Vec::new(), )) } - Err(error) => match restore_agent_snapshots_direct(&snapshots) { - Ok(()) => Err(error), - Err(rollback_error) => Err(format!("{error};回滚配置失败: {rollback_error}")), - }, + Err(error) => { + let rollback = restore_agent_snapshots_direct(&snapshots); + if created_session_backups { + for backup in &backup_files { + if backup.existed_before && backup.backup_path.is_file() { + let _ = fs::remove_file(&backup.backup_path); + } + } + } + match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!("{error};回滚配置失败: {rollback_error}")), + } + } } } @@ -6285,8 +6570,15 @@ fn reset_agent_configuration_to_default_with_oauth( claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let paths = agent_config_paths(client, home); - let contents = - fresh_agent_contents_with_oauth(client, port, api_key, model, models, oauth_configuration)?; + let contents = fresh_agent_contents_with_oauth( + client, + port, + api_key, + model, + models, + oauth_configuration, + claude_desktop_model_mappings, + )?; if paths.len() != contents.len() { return Err("智能体默认配置文件数量不匹配".to_string()); } @@ -6939,13 +7231,17 @@ fn set_core_management_secret_key( gui_config_state: tauri::State<'_, GuiConfigState>, secret_key: String, ) -> Result { - let secret_key = secret_key.trim().to_string(); - if secret_key.chars().any(char::is_control) { - return Err("管理密钥不能包含控制字符".to_string()); - } - validate_management_secret_key(&secret_key)?; - let config = gui_config_state.set_management_secret_key(secret_key)?; - patch_core_management_secret_key(&config.management_secret_key)?; + let secret_key = normalize_management_secret_key(secret_key)?; + let previous = gui_config_state.snapshot()?; + patch_core_management_secret_key(&secret_key)?; + let config = match gui_config_state.set_management_secret_key(secret_key) { + Ok(config) => config, + Err(error) => { + let rollback_error = + patch_core_management_secret_key(&previous.management_secret_key).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; Ok(CoreConfigView::from(&config)) } @@ -6953,8 +7249,16 @@ fn set_core_management_secret_key( fn clear_core_management_secret_key( gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { - let config = gui_config_state.set_management_secret_key(String::new())?; - patch_core_management_secret_key(&config.management_secret_key)?; + let previous = gui_config_state.snapshot()?; + patch_core_management_secret_key("")?; + let config = match gui_config_state.set_management_secret_key(String::new()) { + Ok(config) => config, + Err(error) => { + let rollback_error = + patch_core_management_secret_key(&previous.management_secret_key).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; Ok(CoreConfigView::from(&config)) } @@ -9860,11 +10164,10 @@ async fn put_management_config_yaml(config: &GuiConfigFile, content: &str) -> Re async fn ensure_claude_desktop_model_aliases( config: &GuiConfigFile, - models: &[AgentModelOption], mappings: &ClaudeDesktopModelMappings, ) -> Result<(), String> { let content = fetch_management_config_yaml(config).await?; - let updated = ensure_claude_desktop_model_aliases_in_yaml(&content, models, mappings)?; + let updated = ensure_claude_desktop_model_aliases_in_yaml(&content, mappings)?; if updated != content { put_management_config_yaml(config, &updated).await?; } @@ -9873,7 +10176,6 @@ async fn ensure_claude_desktop_model_aliases( fn ensure_claude_desktop_model_aliases_in_yaml( content: &str, - models: &[AgentModelOption], mappings: &ClaudeDesktopModelMappings, ) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) @@ -9883,16 +10185,10 @@ fn ensure_claude_desktop_model_aliases_in_yaml( .as_mapping_mut() .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; - for model in models { - let alias = claude_desktop_inference_model_id(&model.name)?; - if !alias.eq_ignore_ascii_case(&model.name) { - ensure_claude_desktop_model_alias(root, &model.name, &alias)?; - } - } for (alias, source_model) in [ - ("opus", mappings.opus.as_str()), - ("sonnet", mappings.sonnet.as_str()), - ("haiku", mappings.haiku.as_str()), + (CLAUDE_DESKTOP_OPUS_MODEL_ID, mappings.opus.as_str()), + (CLAUDE_DESKTOP_SONNET_MODEL_ID, mappings.sonnet.as_str()), + (CLAUDE_DESKTOP_HAIKU_MODEL_ID, mappings.haiku.as_str()), ] { ensure_claude_desktop_model_alias(root, source_model, alias)?; } @@ -11435,6 +11731,49 @@ fn validate_management_secret_key(secret_key: &str) -> Result<(), String> { Ok(()) } +fn validate_strong_management_secret_key(secret_key: &str) -> Result<(), String> { + validate_management_secret_key(secret_key)?; + if secret_key.trim().is_empty() { + return Err("WebUI 密钥不能为空".to_string()); + } + if secret_key.trim() == LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY { + return Err("不能继续使用旧版默认 WebUI 密钥 123456".to_string()); + } + if is_hashed_management_secret_key(secret_key) { + return Err("GUI 配置必须保存可用于管理接口认证的明文 WebUI 密钥".to_string()); + } + Ok(()) +} + +fn generate_management_secret_key() -> Result { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let mut random = [0_u8; 32]; + getrandom::fill(&mut random).map_err(|error| format!("生成 WebUI 安全密钥失败: {error}"))?; + Ok(format!("wui-Aa9_{}", URL_SAFE_NO_PAD.encode(random))) +} + +fn management_secret_requires_rotation(secret_key: &str) -> bool { + let secret_key = secret_key.trim(); + secret_key.is_empty() + || secret_key == LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY + || is_hashed_management_secret_key(secret_key) +} + +fn ensure_strong_management_secret(config: &mut GuiConfigFile) -> Result { + if !management_secret_requires_rotation(&config.management_secret_key) { + return Ok(false); + } + config.management_secret_key = generate_management_secret_key()?; + Ok(true) +} + +fn normalize_management_secret_key(secret_key: String) -> Result { + let secret_key = secret_key.trim().to_string(); + validate_strong_management_secret_key(&secret_key)?; + Ok(secret_key) +} + fn is_example_core_api_key(api_key: &str) -> bool { let value = api_key.trim(); value == "your-api-key" || value.starts_with("your-api-key-") @@ -12047,11 +12386,18 @@ fn load_or_create_gui_config() -> Result { if presence.codex_session_repair_on_launch.is_none() { changed = true; } + let management_secret_rotated = ensure_strong_management_secret(&mut config)?; + changed |= management_secret_rotated; changed |= sanitize_gui_config(&mut config)?; validate_gui_config(&config)?; if changed { write_gui_config(&config)?; } + if management_secret_rotated { + if let Err(error) = patch_core_management_secret_key(&config.management_secret_key) { + eprintln!("更新旧版 CPA WebUI 密钥失败,将在下次启动内核时重试: {error}"); + } + } if !config.auth_dir.trim().is_empty() { let install_dir = core_install_dir()?; let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir); @@ -12458,7 +12804,7 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { } validate_api_key_remark(&entry.remark)?; } - validate_management_secret_key(&config.management_secret_key)?; + validate_strong_management_secret_key(&config.management_secret_key)?; validate_routing_strategy(config.routing_strategy.trim())?; if config.proxy_url.chars().any(char::is_control) { return Err("代理 URL 不能包含控制字符".to_string()); @@ -14526,6 +14872,10 @@ fn main() { Err(error) => { eprintln!("{error}"); let mut config = GuiConfigFile::default(); + if let Err(secret_error) = ensure_strong_management_secret(&mut config) { + eprintln!("初始化 WebUI 安全密钥失败: {secret_error}"); + return; + } if let Err(sanitize_error) = sanitize_gui_config(&mut config) { eprintln!("初始化固定凭证目录失败: {sanitize_error}"); } @@ -14708,6 +15058,7 @@ fn main() { create_speed_alias, delete_speed_alias, apply_agent_config, + close_agent_config_modification, reset_agent_config_to_default, clear_codex_config, set_agent_config_enabled, @@ -14775,6 +15126,10 @@ fn main() { } tauri::RunEvent::Exit => { usage::stop_usage_collector(app_handle); + if let Ok(home) = app_handle.path().home_dir() { + let _guard = AGENT_CONFIG_FILE_LOCK.lock(); + restore_all_agent_session_configurations(&home); + } let process_state = app_handle.state::(); let gui_config_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); @@ -14880,6 +15235,108 @@ mod tests { codex_catalog::prepare_catalog(&runtime).unwrap().json } + #[test] + fn agent_configuration_is_restored_from_the_dated_session_backup_on_exit() { + let home = agent_test_home("session-backup"); + let path = home.join(".config/opencode/opencode.json"); + let original = b"{\"provider\":\"original\"}"; + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, original).unwrap(); + + commit_agent_configuration( + AgentClient::OpenCode, + &home, + "gpt-test", + &[AgentFileUpdate { + path: path.clone(), + after: "{\"provider\":\"managed\"}".to_string(), + }], + "applied", + None, + ) + .unwrap(); + + let backup = dated_agent_backup_path(&path).unwrap(); + assert!(backup.is_file()); + assert_eq!(fs::read(&backup).unwrap(), original); + assert!(fs::read_to_string(&path).unwrap().contains("managed")); + + restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(!backup.exists()); + assert!(!agent_state_path(std::slice::from_ref(&path)) + .unwrap() + .exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn codex_session_restore_does_not_create_a_state_json_file() { + let home = agent_test_home("codex-session-without-state-file"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); + let original = b"approval_policy = \"never\"\n"; + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, original).unwrap(); + + commit_agent_configuration( + AgentClient::Codex, + &home, + "gpt-test", + &[ + AgentFileUpdate { + path: config_path.clone(), + after: "model = \"gpt-test\"\n".to_string(), + }, + AgentFileUpdate { + path: catalog_path.clone(), + after: "{\"models\":[]}".to_string(), + }, + ], + "applied", + None, + ) + .unwrap(); + + let backup = dated_agent_backup_path(&config_path).unwrap(); + assert!(backup.is_file()); + assert!(!state_path.exists()); + assert_eq!( + inspect_agent_application(AgentClient::Codex, &home).state, + "applied" + ); + + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + assert_eq!(fs::read(&config_path).unwrap(), original); + assert!(!catalog_path.exists()); + assert!(!backup.exists()); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn codex_session_restore_recovers_from_a_dated_backup_without_state_json() { + let home = agent_test_home("codex-session-backup-recovery"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); + let backup = dated_agent_backup_path(&config_path).unwrap(); + let original = b"approval_policy = \"on-request\"\n"; + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&backup, original).unwrap(); + fs::write(&config_path, "model = \"gpt-test\"\n").unwrap(); + fs::write(&catalog_path, "{\"models\":[]}").unwrap(); + + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + + assert_eq!(fs::read(&config_path).unwrap(), original); + assert!(!catalog_path.exists()); + assert!(!backup.exists()); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); + } + #[test] fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them() { let home = agent_test_home("direct-apply"); @@ -15200,7 +15657,7 @@ mod tests { assert!(content.contains("[[api-keys]]")); assert!(content.contains("key = \"123456\"")); assert!(content.contains("remark = \"默认密钥\"")); - assert!(content.contains("management-secret-key = \"123456\"")); + assert!(content.contains("management-secret-key = \"\"")); assert!(content.contains("plugins-enabled = false")); assert!(content.contains("routing-strategy = \"round-robin\"")); assert!(content.contains("codex-session-repair-on-launch = false")); @@ -15456,13 +15913,12 @@ mod tests { #[test] fn claude_desktop_config_builds_gateway_profile_and_index() { - let models = test_agent_models(&["claude-sonnet-test", "claude-opus-test"]); let profile = build_claude_desktop_profile( Some(r#"{"keep":true}"#), "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-sonnet-test", - &models, + None, ) .unwrap(); let meta = build_claude_desktop_meta(Some( @@ -15477,9 +15933,14 @@ mod tests { assert_eq!(profile["keep"], true); assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); - assert_eq!(profile["inferenceModels"][0], "claude-sonnet-test"); - assert_eq!(profile["inferenceModels"][1], "claude-opus-test"); - assert_eq!(profile["inferenceModels"].as_array().unwrap().len(), 2); + assert_eq!( + profile["inferenceModels"], + serde_json::json!([ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID + ]) + ); assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); assert_eq!(meta["entries"].as_array().unwrap().len(), 2); let managed_entry = meta["entries"] @@ -15492,28 +15953,31 @@ mod tests { } #[test] - fn claude_desktop_profile_aliases_a_non_claude_model() { + fn claude_desktop_profile_keeps_non_claude_models_internal() { + let mappings = ClaudeDesktopModelMappings { + opus: "gpt-5.6-sol".to_string(), + sonnet: "gpt-5.6".to_string(), + haiku: "gpt-5.6-mini".to_string(), + }; let profile = build_claude_desktop_profile( None, "http://127.0.0.1:8317", DEFAULT_API_KEY, "gpt-5.6-sol", - &test_agent_models(&["gpt-5.6-sol"]), + Some(&mappings), ) .unwrap(); let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); assert_eq!( profile["inferenceModels"], - serde_json::json!([{ - "name": "opus-los-6.5-tpg", - "labelOverride": "gpt-5.6-sol", - }]) - ); - assert_eq!( - claude_desktop_inference_model_id("opus-los-6.5-tpg").unwrap(), - "opus-los-6.5-tpg" + serde_json::json!([ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID + ]) ); + assert!(!profile.to_string().contains("gpt-")); } #[cfg(target_os = "windows")] @@ -15726,17 +16190,14 @@ mod tests { } #[test] - fn claude_desktop_aliases_expose_all_models_and_role_routes() { + fn claude_desktop_aliases_expose_role_routes_only() { let input = "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1\n models:\n - name: gpt-5.6-sol\n"; - let available_models = test_agent_models(&["gpt-5.6-sol"]); let mappings = ClaudeDesktopModelMappings { opus: "gpt-5.6-sol".to_string(), sonnet: "gpt-5.6-sol".to_string(), haiku: "gpt-5.6-sol".to_string(), }; - let rendered = - ensure_claude_desktop_model_aliases_in_yaml(input, &available_models, &mappings) - .unwrap(); + let rendered = ensure_claude_desktop_model_aliases_in_yaml(input, &mappings).unwrap(); let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); let root = value.as_mapping().unwrap(); let providers = yaml_mapping_value(root, "openai-compatibility") @@ -15746,21 +16207,21 @@ mod tests { .and_then(serde_norway::Value::as_sequence) .unwrap(); - assert_eq!(models.len(), 5); + assert_eq!(models.len(), 4); assert_eq!( - configured_model_identity(&models[1]), - Some(( - "gpt-5.6-sol".to_string(), - "opus-los-6.5-tpg".to_string(), - None, - )) + configured_model_identity(&models[1]).unwrap().1, + CLAUDE_DESKTOP_OPUS_MODEL_ID ); - assert_eq!(configured_model_identity(&models[2]).unwrap().1, "opus"); - assert_eq!(configured_model_identity(&models[3]).unwrap().1, "sonnet"); - assert_eq!(configured_model_identity(&models[4]).unwrap().1, "haiku"); assert_eq!( - ensure_claude_desktop_model_aliases_in_yaml(&rendered, &available_models, &mappings) - .unwrap(), + configured_model_identity(&models[2]).unwrap().1, + CLAUDE_DESKTOP_SONNET_MODEL_ID + ); + assert_eq!( + configured_model_identity(&models[3]).unwrap().1, + CLAUDE_DESKTOP_HAIKU_MODEL_ID + ); + assert_eq!( + ensure_claude_desktop_model_aliases_in_yaml(&rendered, &mappings).unwrap(), rendered ); } @@ -16826,11 +17287,68 @@ custom_option = "keep-original" #[test] fn core_config_view_exposes_api_key_metadata_for_the_webview() { - let view = serde_json::to_value(CoreConfigView::from(&GuiConfigFile::default())).unwrap(); + let mut config = GuiConfigFile::default(); + ensure_strong_management_secret(&mut config).unwrap(); + let view = serde_json::to_value(CoreConfigView::from(&config)).unwrap(); assert_eq!(view["apiKeys"][0]["apiKey"], DEFAULT_API_KEY); assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_INITIAL_REMARK); assert!(view["apiKeys"][0].get("builtIn").is_none()); + assert_eq!(view["managementSecretConfigured"], true); + assert!(view.get("managementSecretKey").is_none()); + } + + #[test] + fn webui_management_secret_requires_a_non_empty_plaintext_value() { + assert_eq!( + normalize_management_secret_key(" new-webui-secret ".to_string()).unwrap(), + "new-webui-secret" + ); + assert!(normalize_management_secret_key(" ".to_string()).is_err()); + assert!(normalize_management_secret_key( + "$2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu".to_string() + ) + .is_err()); + assert!(normalize_management_secret_key("bad\nsecret".to_string()).is_err()); + assert!(normalize_management_secret_key("123456".to_string()).is_err()); + } + + #[test] + fn management_secret_rotation_replaces_legacy_values_and_preserves_custom_values() { + let mut fresh = GuiConfigFile::default(); + assert!(ensure_strong_management_secret(&mut fresh).unwrap()); + assert!(fresh.management_secret_key.starts_with("wui-Aa9_")); + assert!(fresh.management_secret_key.len() >= 50); + assert!(!management_secret_requires_rotation( + &fresh.management_secret_key + )); + + let first_generated = fresh.management_secret_key.clone(); + let mut legacy = GuiConfigFile { + management_secret_key: LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY.to_string(), + ..GuiConfigFile::default() + }; + assert!(ensure_strong_management_secret(&mut legacy).unwrap()); + assert_ne!( + legacy.management_secret_key, + LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY + ); + assert_ne!(legacy.management_secret_key, first_generated); + + let mut hashed = GuiConfigFile { + management_secret_key: "$2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + .to_string(), + ..GuiConfigFile::default() + }; + assert!(ensure_strong_management_secret(&mut hashed).unwrap()); + assert!(hashed.management_secret_key.starts_with("wui-Aa9_")); + + let mut custom = GuiConfigFile { + management_secret_key: "user-selected-secret".to_string(), + ..GuiConfigFile::default() + }; + assert!(!ensure_strong_management_secret(&mut custom).unwrap()); + assert_eq!(custom.management_secret_key, "user-selected-secret"); } #[test] @@ -16858,6 +17376,7 @@ custom_option = "keep-original" auth_dir: "/tmp/user-selected-auth".to_string(), ..GuiConfigFile::default() }; + ensure_strong_management_secret(&mut config).unwrap(); assert!(validate_gui_config(&config).is_ok()); assert!(!sanitize_gui_config(&mut config).unwrap()); @@ -17466,7 +17985,9 @@ custom_option = "keep-original" #[test] fn startup_merge_without_current_config_uses_gui_defaults() { let template = "# Template\nhost: \"\"\nport: 9000\napi-keys:\n - template-key\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\ndebug: false\n"; - let merged = merge_core_config_yaml(template, None, &GuiConfigFile::default()).unwrap(); + let mut config = GuiConfigFile::default(); + ensure_strong_management_secret(&mut config).unwrap(); + let merged = merge_core_config_yaml(template, None, &config).unwrap(); let document = serde_norway::from_str::(&merged).unwrap(); assert!(merged.contains("# Template")); @@ -17480,6 +18001,10 @@ custom_option = "keep-original" assert_eq!(document["plugins"]["enabled"], false); assert_eq!(document["routing"]["strategy"], "round-robin"); assert_eq!(document["usage-statistics-enabled"], true); + assert_eq!( + document["remote-management"]["secret-key"], + config.management_secret_key + ); } #[test] @@ -17487,14 +18012,19 @@ custom_option = "keep-original" let template = "host: \"\"\nport: 8317\nremote-management:\n secret-key: \"\"\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - template-one\n - template-two\n - template-three\ndebug: false\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n"; let current = "host: 127.0.0.1\nport: 8317\nremote-management:\n secret-key: hashed\nauth-dir: C:/oauth\napi-keys:\n - '123456'\ndebug: false\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n"; - let merged = - merge_core_config_yaml(template, Some(current), &GuiConfigFile::default()).unwrap(); + let mut config = GuiConfigFile::default(); + ensure_strong_management_secret(&mut config).unwrap(); + let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); let document = serde_norway::from_str::(&merged) .unwrap_or_else(|error| panic!("invalid YAML: {error}\n{merged}")); assert_eq!(document["api-keys"][0], DEFAULT_API_KEY); assert_eq!(document["api-keys"].as_sequence().unwrap().len(), 1); assert_eq!(document["debug"], false); + assert_eq!( + document["remote-management"]["secret-key"], + config.management_secret_key + ); } #[test] diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index a71ed3f..b278fea 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -264,6 +264,24 @@ export const jaOverrides = { 'config.notice.sessionAffinityEnabled': 'セッションアフィニティを有効にしました', 'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました', 'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました', + 'config.webuiKey.title': 'WebUI キー', + 'config.webuiKey.configured': '設定済み', + 'config.webuiKey.unconfigured': '未設定', + 'config.webuiKey.heading': '管理 API のアクセス認証情報', + 'config.webuiKey.description': 'CPA WebUI へのログインと管理 API の呼び出しに使用します。上の API 認証キーとは用途が異なります。', + 'config.webuiKey.securityHint': '現在のキーは表示されません。保存後、他の WebUI では新しいキーで再ログインしてください。', + 'config.webuiKey.newKey': '新しい WebUI キー', + 'config.webuiKey.confirmKey': '新しいキーを確認', + 'config.webuiKey.placeholder': '新しい平文キーを入力', + 'config.webuiKey.confirmPlaceholder': '新しいキーをもう一度入力', + 'config.webuiKey.generate': '安全なキーを生成', + 'config.webuiKey.save': 'キーを保存', + 'config.webuiKey.error.empty': 'WebUI キーを入力してください', + 'config.webuiKey.error.legacyDefault': '旧版の既定キー 123456 は使用できません', + 'config.webuiKey.error.tooLong': 'WebUI キーは 512 文字以内で入力してください', + 'config.webuiKey.error.invalid': 'WebUI キーに制御文字は使用できません', + 'config.webuiKey.error.mismatch': '入力した WebUI キーが一致しません', + 'config.webuiKey.notice.updated': 'WebUI キーを更新しました', 'config.keys.title': '認証キー', 'config.keys.count': 'キー数', 'config.keys.add': '認証キーを追加', @@ -520,6 +538,7 @@ export const jaOverrides = { 'agents.oauthLoginRequired.launchDescription': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。', 'agents.oauthLoginRequired.confirm': '了解しました', 'agents.modify.apply': '設定変更を適用', + 'agents.modify.close': '設定変更を終了', 'agents.modify.default': 'デフォルト設定', 'agents.modify.clear': '設定を消去', 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index d70de47..4142ca4 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -282,6 +282,24 @@ export const zhCN = { 'config.notice.sessionAffinityEnabled': '会话粘性路由已启用', 'config.notice.sessionAffinityDisabled': '会话粘性路由已停用', 'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存', + 'config.webuiKey.title': 'WebUI 密钥', + 'config.webuiKey.configured': '已配置', + 'config.webuiKey.unconfigured': '未配置', + 'config.webuiKey.heading': '管理接口访问凭据', + 'config.webuiKey.description': '用于登录 CPA WebUI 和调用管理接口,与上方 API 鉴权密钥用途不同。', + 'config.webuiKey.securityHint': '现有密钥不会回显;保存后,其他 WebUI 需要使用新密钥重新登录。', + 'config.webuiKey.newKey': '新 WebUI 密钥', + 'config.webuiKey.confirmKey': '确认新密钥', + 'config.webuiKey.placeholder': '输入新的明文密钥', + 'config.webuiKey.confirmPlaceholder': '再次输入新密钥', + 'config.webuiKey.generate': '生成安全密钥', + 'config.webuiKey.save': '保存密钥', + 'config.webuiKey.error.empty': 'WebUI 密钥不能为空', + 'config.webuiKey.error.legacyDefault': '不能继续使用旧版默认密钥 123456', + 'config.webuiKey.error.tooLong': 'WebUI 密钥不能超过 512 个字符', + 'config.webuiKey.error.invalid': 'WebUI 密钥不能包含控制字符', + 'config.webuiKey.error.mismatch': '两次输入的 WebUI 密钥不一致', + 'config.webuiKey.notice.updated': 'WebUI 密钥已更新', 'config.keys.title': '鉴权密钥', 'config.keys.count': '密钥数量', 'config.keys.add': '新增鉴权密钥', @@ -435,6 +453,7 @@ export const zhCN = { 'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试', 'agents.error.noModels': '当前没有可选模型,无法应用配置修改', 'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择', + 'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择', 'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”', 'agents.status.detecting': '检测中', 'agents.status.unsupported': '平台不支持', @@ -525,6 +544,11 @@ export const zhCN = { 'agents.notFetched': '未获取', 'agents.useModel': '使用模型', 'agents.pending': '尚未应用', + 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型', + 'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。', + 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到', + 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到', + 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到', 'agents.model.readingAvailable': '正在读取可用模型', 'agents.model.current': '当前已应用:{model}', 'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项', @@ -538,6 +562,7 @@ export const zhCN = { 'agents.oauthLoginRequired.launchDescription': '已开启 OAuth 配置模式,但未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再启动。', 'agents.oauthLoginRequired.confirm': '我知道了', 'agents.modify.apply': '应用配置修改', + 'agents.modify.close': '关闭配置修改', 'agents.modify.default': '默认配置', 'agents.modify.clear': '清空配置', 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', @@ -1145,6 +1170,24 @@ export const en: Record = { 'config.notice.sessionAffinityEnabled': 'Session affinity enabled', 'config.notice.sessionAffinityDisabled': 'Session affinity disabled', 'config.notice.sessionTtlUpdated': 'Session affinity TTL saved', + 'config.webuiKey.title': 'WebUI Key', + 'config.webuiKey.configured': 'Configured', + 'config.webuiKey.unconfigured': 'Not configured', + 'config.webuiKey.heading': 'Management access credential', + 'config.webuiKey.description': 'Used to sign in to the CPA WebUI and call the Management API. It is different from the API authentication keys above.', + 'config.webuiKey.securityHint': 'The current key is never displayed. Other WebUIs must sign in again with the new key after it is saved.', + 'config.webuiKey.newKey': 'New WebUI key', + 'config.webuiKey.confirmKey': 'Confirm new key', + 'config.webuiKey.placeholder': 'Enter a new plaintext key', + 'config.webuiKey.confirmPlaceholder': 'Enter the new key again', + 'config.webuiKey.generate': 'Generate secure key', + 'config.webuiKey.save': 'Save key', + 'config.webuiKey.error.empty': 'The WebUI key cannot be empty', + 'config.webuiKey.error.legacyDefault': 'The legacy default key 123456 is no longer allowed', + 'config.webuiKey.error.tooLong': 'The WebUI key cannot exceed 512 characters', + 'config.webuiKey.error.invalid': 'The WebUI key cannot contain control characters', + 'config.webuiKey.error.mismatch': 'The WebUI keys do not match', + 'config.webuiKey.notice.updated': 'WebUI key updated', 'config.keys.title': 'Authentication Keys', 'config.keys.count': 'Key count', 'config.keys.add': 'Add Authentication Key', @@ -1298,6 +1341,7 @@ export const en: Record = { 'agents.error.modelsLoading': 'The model list is still loading. Try again shortly.', 'agents.error.noModels': 'No models are available, so configuration changes cannot be applied', 'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.', + 'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.', 'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.', 'agents.status.detecting': 'Detecting', 'agents.status.unsupported': 'Unsupported Platform', @@ -1388,6 +1432,11 @@ export const en: Record = { 'agents.notFetched': 'Not available', 'agents.useModel': 'Model', 'agents.pending': 'Not Applied', + 'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model', + 'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.', + 'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to', + 'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to', + 'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to', 'agents.model.readingAvailable': 'Loading available models', 'agents.model.current': 'Currently applied: {model}', 'agents.model.firstSelection': '{count} models available; the first is selected by default', @@ -1401,6 +1450,7 @@ export const en: Record = { 'agents.oauthLoginRequired.launchDescription': 'OAuth configuration mode is enabled, but no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex before launching it.', 'agents.oauthLoginRequired.confirm': 'Got it', 'agents.modify.apply': 'Apply Configuration Changes', + 'agents.modify.close': 'Close Configuration Changes', 'agents.modify.default': 'Default Configuration', 'agents.modify.clear': 'Clear Configuration', 'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index a6b3c71..922e3e6 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -69,6 +69,7 @@ type AgentConfigStatus = { modificationState: AgentModificationState; backupAvailable: boolean; appliedModel: string | null; + claudeDesktopModelMappings: ClaudeDesktopModelMappings | null; warnings: string[]; error: string | null; }; @@ -94,8 +95,31 @@ type ChatGptCloseResult = { type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch'; +type ClaudeDesktopModelMappings = { + opus: string; + sonnet: string; + haiku: string; +}; + const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED'; +const createClaudeDesktopModelMappings = (model: string): ClaudeDesktopModelMappings => ({ + opus: model, + sonnet: model, + haiku: model, +}); + +const sameClaudeDesktopModelMappings = ( + left: ClaudeDesktopModelMappings, + right: ClaudeDesktopModelMappings, +) => left.opus === right.opus && left.sonnet === right.sonnet && left.haiku === right.haiku; + +const claudeDesktopMappingRoles = [ + { key: 'opus', labelKey: 'agents.claudeDesktopMapping.opus' }, + { key: 'sonnet', labelKey: 'agents.claudeDesktopMapping.sonnet' }, + { key: 'haiku', labelKey: 'agents.claudeDesktopMapping.haiku' }, +] as const; + type AgentDefinition = { id: AgentClientId; name: string; @@ -487,10 +511,13 @@ export function AgentsPage() { const [modelByClient, setModelByClient] = useState>>( readAgentModelSelections, ); + const [claudeDesktopModelMappingsDraft, setClaudeDesktopModelMappingsDraft] = useState( + createClaudeDesktopModelMappings(''), + ); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'apply' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | null + 'apply' | 'close-config' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -509,6 +536,7 @@ export function AgentsPage() { const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState(null); const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState(null); const modelRequestRef = useRef(0); + const claudeDesktopModelMappingsDirtyRef = useRef(false); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -616,24 +644,55 @@ export function AgentsPage() { const savedSelectedModel = modelByClient[selected] ?? ''; const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; + + useEffect(() => { + if (selected !== 'claude-desktop' || !selectedModel) return; + const appliedMappings = activeStatus?.claudeDesktopModelMappings; + setClaudeDesktopModelMappingsDraft((current) => { + const source = claudeDesktopModelMappingsDirtyRef.current + ? current + : appliedMappings ?? current; + const next: ClaudeDesktopModelMappings = { + opus: findAgentModel(models, source.opus)?.name ?? selectedModel, + sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel, + haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel, + }; + return sameClaudeDesktopModelMappings(current, next) ? current : next; + }); + }, [activeStatus?.claudeDesktopModelMappings, models, selected, selectedModel]); + const activeLaunchTargets = activeStatus?.launchTargets ?? []; const defaultLaunchTarget = activeLaunchTargets[0] ?? null; const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null; const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null; const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? ''; - const modelDraftChanged = Boolean( + const modelDraftChanged = selected !== 'claude-desktop' && Boolean( selectedModel.trim() && appliedModel.trim() && selectedModel.trim() !== appliedModel.trim(), ); + const appliedClaudeDesktopModelMappings = activeStatus?.claudeDesktopModelMappings + ?? createClaudeDesktopModelMappings(appliedModel); + const claudeDesktopMappingsReady = selected !== 'claude-desktop' + || claudeDesktopMappingRoles.every((role) => + Boolean(findAgentModel(models, claudeDesktopModelMappingsDraft[role.key])), + ); + const claudeDesktopMappingDraftChanged = selected === 'claude-desktop' + && activeStatus?.modificationState === 'applied' + && !sameClaudeDesktopModelMappings( + claudeDesktopModelMappingsDraft, + appliedClaudeDesktopModelMappings, + ); const oauthConfigurationChanged = selected === 'codex' && oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration); - const draftChanged = modelDraftChanged || oauthConfigurationChanged; + const draftChanged = modelDraftChanged || claudeDesktopMappingDraftChanged || oauthConfigurationChanged; const canEnable = Boolean( activeStatus?.supportedPlatform && activeStatus.installed && !modelLoading - && selectedModelOption, + && (selected === 'claude-desktop' + ? claudeDesktopMappingsReady + : selectedModelOption), ); const launchEnabled = Boolean( activeStatus?.supportedPlatform @@ -678,6 +737,17 @@ export function AgentsPage() { }); }; + const selectClaudeDesktopModelMapping = ( + role: keyof ClaudeDesktopModelMappings, + value: string, + ) => { + const model = findAgentModel(models, value); + if (!model) return; + claudeDesktopModelMappingsDirtyRef.current = true; + setModelSelectionError(''); + setClaudeDesktopModelMappingsDraft((current) => ({ ...current, [role]: model.name })); + }; + const requireSelectedModel = () => { if (modelLoading) { setModelSelectionError(t('agents.error.modelsLoading')); @@ -696,6 +766,20 @@ export function AgentsPage() { return model.name; }; + const requireClaudeDesktopModelMappings = (): ClaudeDesktopModelMappings | null => { + if (selected !== 'claude-desktop') return null; + const resolved = {} as ClaudeDesktopModelMappings; + for (const role of claudeDesktopMappingRoles) { + const model = findAgentModel(models, claudeDesktopModelMappingsDraft[role.key]); + if (!model) { + setModelSelectionError(t('agents.error.mappingSelectionGone')); + return null; + } + resolved[role.key] = model.name; + } + return resolved; + }; + const handleOAuthLoginError = (requestError: unknown, action: OAuthLoginRequiredAction) => { const message = String(requestError); if (message.includes(CODEX_OAUTH_LOGIN_REQUIRED_ERROR)) { @@ -726,7 +810,11 @@ export function AgentsPage() { const applyConfigurationChanges = async () => { setConfigurationError(''); - const model = requireSelectedModel(); + const claudeDesktopModelMappings = requireClaudeDesktopModelMappings(); + if (selected === 'claude-desktop' && !claudeDesktopModelMappings) return; + const model = selected === 'claude-desktop' + ? claudeDesktopModelMappings?.sonnet ?? null + : requireSelectedModel(); if (!model) return; setBusyAction('apply'); try { @@ -734,9 +822,11 @@ export function AgentsPage() { client: selected, model, oauthConfiguration, + claudeDesktopModelMappings, }); await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); + claudeDesktopModelMappingsDirtyRef.current = false; } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setConfigurationError(String(requestError)); @@ -746,8 +836,26 @@ export function AgentsPage() { } }; + const closeConfigurationChanges = async () => { + setConfigurationError(''); + setBusyAction('close-config'); + try { + await invoke('close_agent_config_modification', { client: selected }); + await reloadStatusesAfterAction(); + setOauthConfigurationDraft(null); + } catch (requestError) { + setConfigurationError(String(requestError)); + } finally { + setBusyAction(null); + } + }; + const resetConfigurationToDefault = async () => { - const model = requireSelectedModel(); + const claudeDesktopModelMappings = requireClaudeDesktopModelMappings(); + if (selected === 'claude-desktop' && !claudeDesktopModelMappings) return; + const model = selected === 'claude-desktop' + ? claudeDesktopModelMappings?.sonnet ?? null + : requireSelectedModel(); if (!model) return; setBusyAction('default'); setDefaultError(''); @@ -756,10 +864,12 @@ export function AgentsPage() { client: selected, model, oauthConfiguration, + claudeDesktopModelMappings, }); setDefaultConfirmOpen(false); await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); + claudeDesktopModelMappingsDirtyRef.current = false; } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setDefaultError(String(requestError)); @@ -962,30 +1072,60 @@ export function AgentsPage() { ) : null} -
-
-
{t('agents.useModel')}
- {modelDraftChanged ? {t('agents.pending')} : null} -
- - {modelHint ? ( - - {modelHint} - - ) : null} -
+ {selected !== 'claude-desktop' ? ( +
+
+
{t('agents.useModel')}
+ {modelDraftChanged ? {t('agents.pending')} : null} +
+ + {modelHint ? ( + + {modelHint} + + ) : null} +
+ ) : null} + + {selected === 'claude-desktop' ? ( +
+
+
+ {t('agents.claudeDesktopMapping.title')} + {t('agents.claudeDesktopMapping.description')} +
+ {claudeDesktopMappingDraftChanged ? {t('agents.pending')} : null} +
+
+ {claudeDesktopMappingRoles.map((role) => ( +
+ {t(role.labelKey)} + selectClaudeDesktopModelMapping(role.key, value)} + onRefresh={refreshModels} + /> +
+ ))} +
+
+ ) : null}
@@ -1021,14 +1161,20 @@ export function AgentsPage() {
+
+
+
+
+ {!loading && settings ? ( + + {settings.managementSecretConfigured + ? t('config.webuiKey.configured') + : t('config.webuiKey.unconfigured')} + + ) : null} +
+ +
+
+ {t('config.webuiKey.heading')} +

{t('config.webuiKey.description')}

+ {t('config.webuiKey.securityHint')} +
+ +
void saveManagementSecret(event)} + > + + + + +
+ + {managementSecretError || ' '} + +
+ + +
+
+
+
+
) : activeSubpage === 'network' ? (
.config-dialog-text-input { + background: var(--theme-fffdf8); +} + +.config-management-field > .config-dialog-text-input[aria-invalid='true'] { + border-color: var(--theme-a83f32); +} + +.config-management-form-footer { + display: grid; + grid-column: 1 / -1; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; +} + +.config-management-error { + min-width: 0; + overflow: hidden; + color: transparent; + font-size: var(--font-size-label); + line-height: var(--line-height-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-management-error.visible { + color: var(--theme-9f2d20); +} + +.config-management-actions { + display: flex; + align-items: center; + justify-content: end; + gap: 10px; +} + .config-network-panel { display: grid; gap: 18px; @@ -4988,6 +5087,27 @@ textarea:focus-visible { min-height: 0; } +.agent-claude-desktop-mapping { + grid-column: 1 / -1; +} + +.agent-claude-desktop-mapping-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.agent-claude-desktop-mapping-row { + display: grid; + min-width: 0; + gap: 7px; +} + +.agent-claude-desktop-mapping-row > strong { + color: var(--theme-5f5a53); + font-size: var(--font-size-label); +} + .agent-section-heading { display: flex; align-items: center; @@ -6771,6 +6891,10 @@ textarea:focus-visible { } @container agent-panel (max-width: 640px) { + .agent-claude-desktop-mapping-grid { + grid-template-columns: 1fr; + } + .agent-codex-options { grid-template-columns: 1fr; } @@ -6882,6 +7006,10 @@ textarea:focus-visible { grid-column: auto; } + .config-management-content { + grid-template-columns: 1fr; + } + } @media (max-width: 760px) { @@ -6937,6 +7065,14 @@ textarea:focus-visible { grid-auto-rows: auto; } + .config-management-form { + grid-template-columns: 1fr; + } + + .config-management-form-footer { + grid-column: auto; + } + .client-api-grid { grid-template-columns: 1fr; } @@ -7153,6 +7289,16 @@ textarea:focus-visible { width: 100%; } + .config-management-form-footer { + grid-template-columns: 1fr; + } + + .config-management-actions { + display: grid; + grid-template-columns: 1fr; + width: 100%; + } + .kernel-layout .panel-heading { flex-direction: row; } From 365356969ed8c63c8f97f985ee2e3c9d9cba95c2 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 00:45:45 +0800 Subject: [PATCH 14/29] feat(updater): migrate Windows updates to full release packages - add a v0.2.10 legacy updater bridge - support both legacy and full update archives - stop publishing dedicated update packages after v0.2.10 - preserve user data during full-package upgrades --- .github/workflows/release.yml | 47 +- README.ja.md | 6 + README.md | 6 + README.zh-CN.md | 6 + .../generate-portable-update-manifest.d.mts | 2 + scripts/generate-portable-update-manifest.mjs | 9 +- src-tauri/src/main.rs | 535 ++++++++++++++++-- src/i18n/ja.ts | 21 + tests/portableUpdateManifest.test.ts | 43 +- 9 files changed, 595 insertions(+), 80 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eea2f2e..037d19d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,8 +190,8 @@ jobs: throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`); } NODE - - name: Prepare Windows portable updater payload - if: matrix.os == 'windows' + - name: Prepare v0.2.10 legacy updater bridge + if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10' shell: pwsh run: | $updateDir = "release/update-${{ matrix.arch }}" @@ -201,11 +201,11 @@ jobs: $marker = Get-Content -Raw -LiteralPath "$updateDir/portable-app.json" | ConvertFrom-Json if ($marker.schemaVersion -ne 1 -or $marker.application -ne "EasyCLIProxyAPI" -or - $marker.version -ne "${{ steps.package.outputs.version }}" -or + $marker.version -ne "0.2.10" -or $marker.platform -ne "windows" -or $marker.arch -ne "${{ matrix.arch }}" -or $marker.autoUpdate -ne $true) { - throw "portable-app.json does not match the Windows updater target" + throw "v0.2.10 legacy updater bridge does not match the Windows target" } - name: Build signed macOS DMG if: matrix.os == 'darwin' @@ -341,22 +341,40 @@ jobs: - name: Archive Windows release if: matrix.os == 'windows' shell: pwsh + env: + LEGACY_UPDATE_BRIDGE: ${{ steps.package.outputs.version == '0.2.10' }} run: | Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip" - $updateArchive = "EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip" - Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive)) + $releaseArchive = "${{ steps.package.outputs.stem }}.zip" + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $releaseArchive)) try { $entries = @($zip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }) - if ($entries.Count -ne 2 -or - $entries -notcontains "EasyCLIProxyAPI.exe" -or - $entries -notcontains "portable-app.json") { - throw "Portable updater ZIP contains unexpected entries: $($entries -join ', ')" + $root = "${{ steps.package.outputs.stem }}" + if ($entries -notcontains "$root/EasyCLIProxyAPI.exe" -or + $entries -notcontains "$root/portable-app.json" -or + $entries -notcontains "$root/core-version.txt" -or + -not ($entries | Where-Object { $_ -match "^$([regex]::Escape($root))/cpa-core/CLIProxyAPI_.+_windows_${{ matrix.arch }}\.zip$" })) { + throw "Full Windows release ZIP is incomplete: $($entries -join ', ')" } } finally { $zip.Dispose() } + if ($env:LEGACY_UPDATE_BRIDGE -eq "true") { + $updateArchive = "EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip" + Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive + $updateZip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive)) + try { + $updateEntries = @($updateZip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }) + if ($updateEntries.Count -ne 2 -or + $updateEntries -notcontains "EasyCLIProxyAPI.exe" -or + $updateEntries -notcontains "portable-app.json") { + throw "v0.2.10 legacy updater bridge contains unexpected entries: $($updateEntries -join ', ')" + } + } finally { + $updateZip.Dispose() + } + } - uses: actions/upload-artifact@v7 with: name: ${{ steps.package.outputs.stem }} @@ -364,13 +382,12 @@ jobs: if-no-files-found: error retention-days: 14 - uses: actions/upload-artifact@v7 - if: matrix.os == 'windows' + if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10' with: - name: portable-updater-windows-${{ matrix.arch }} - path: EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip + name: legacy-updater-bridge-windows-${{ matrix.arch }} + path: EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip if-no-files-found: error retention-days: 14 - publish: name: Publish GitHub release if: >- diff --git a/README.ja.md b/README.ja.md index 7c6a381..1c6119f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -106,6 +106,12 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で 4. **コア** 画面を開き、同梱版または最新版の CLIProxyAPI コアをインストールします。 5. コアを起動し、必要なローカル API エンドポイントをコピーするか、OAuth/API プロバイダーを設定します。 +## アップグレード + +v0.2.10 は一度限りの移行用リリースです。完全版 Windows ZIP と旧クライアント互換の `update` ZIP を同時に公開し、v0.2.6~v0.2.9 からアプリ内で v0.2.10 へ更新できます。v0.2.11 以降は完全版 ZIP のみを公開し、v0.2.10 へ移行済みのクライアントは途中のバージョンを個別に導入せず直接アップグレードできます。 + +v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。 + ## 対応プラットフォーム GitHub Actions では、次のリリースパッケージをビルドします。 diff --git a/README.md b/README.md index 6c46ac3..ce89c9e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ previous configuration, and launch an available desktop or CLI entry point. 4. Open the **Core** page and install the bundled or latest CLIProxyAPI core. 5. Start the core, then copy the required local endpoint or configure an OAuth/API provider. +## Upgrading + +v0.2.10 is a one-time bridge release. It publishes both the complete Windows ZIP and the legacy `update` ZIP so v0.2.6–v0.2.9 can upgrade to v0.2.10 in the app. Starting with v0.2.11, only the complete ZIP is published; clients already on v0.2.10 can consume it and jump directly across intermediate releases. + +If you are running v0.2.5 or earlier, perform one manual migration: exit EasyCLIProxyAPI, download the latest complete Windows ZIP for your architecture, then copy the contents of its top-level directory over the existing installation directory. Do not delete the existing directory first; user data such as `config.toml`, `oauth`, and `cpa-core/config.yaml` will remain in place. After launching the new version, later releases can use in-app automatic updates. + ## Supported Platforms GitHub Actions builds the following release packages: diff --git a/README.zh-CN.md b/README.zh-CN.md index 1513819..6f431f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -102,6 +102,12 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 4. 打开 **内核** 页面,安装内置版本或最新版本的 CLIProxyAPI 内核。 5. 启动内核,然后复制所需的本地 API 地址,或配置 OAuth/API Provider。 +## 升级 + +v0.2.10 是一次性桥接版本:它会同时发布完整 Windows ZIP 和兼容旧客户端的 `update` ZIP,使 v0.2.6–v0.2.9 可以通过应用内更新升级到 v0.2.10。v0.2.11 起只发布完整 ZIP;已升级到 v0.2.10 的客户端可以识别完整包并跨过多个中间版本直接升级。 + +如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。 + ## 支持的平台 GitHub Actions 会构建以下发行包: diff --git a/scripts/generate-portable-update-manifest.d.mts b/scripts/generate-portable-update-manifest.d.mts index 88b6dce..2daf8d5 100644 --- a/scripts/generate-portable-update-manifest.d.mts +++ b/scripts/generate-portable-update-manifest.d.mts @@ -4,6 +4,8 @@ export type PortableUpdateManifestAsset = { sizeBytes: number; }; +export const LEGACY_UPDATE_BRIDGE_VERSION: string; + export type PortableUpdateManifest = { schemaVersion: number; version: string; diff --git a/scripts/generate-portable-update-manifest.mjs b/scripts/generate-portable-update-manifest.mjs index 82cfab9..0a62cb5 100644 --- a/scripts/generate-portable-update-manifest.mjs +++ b/scripts/generate-portable-update-manifest.mjs @@ -3,6 +3,8 @@ import { readFile, stat, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +export const LEGACY_UPDATE_BRIDGE_VERSION = '0.2.10'; + export async function generatePortableUpdateManifest({ directory, output, @@ -29,12 +31,15 @@ export async function generatePortableUpdateManifest({ } const assets = {}; + const useLegacyBridgeAssets = version === LEGACY_UPDATE_BRIDGE_VERSION; for (const arch of ['amd64', 'aarch64']) { - const filename = `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`; + const filename = useLegacyBridgeAssets + ? `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip` + : `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`; const path = join(resolvedDirectory, filename); const [contents, metadata] = await Promise.all([readFile(path), stat(path)]); if (!metadata.isFile() || metadata.size === 0) { - throw new Error(`Portable updater asset is empty or not a file: ${filename}`); + throw new Error(`Portable release asset is empty or not a file: ${filename}`); } assets[`windows-${arch}`] = { url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 236ba47..e2f8f26 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -398,6 +398,12 @@ impl Default for AppUpdateTask { } } +#[cfg(windows)] +struct PortablePackagePayload { + manifest: PortableAppManifest, + core_archive_name: Option, +} + #[cfg(windows)] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -409,6 +415,12 @@ struct PortableUpdateDescriptor { staged_manifest: PathBuf, backup_exe: PathBuf, backup_manifest: PathBuf, + current_core_version: PathBuf, + staged_core_version: PathBuf, + backup_core_version: PathBuf, + staged_core_archive: PathBuf, + target_core_archive: PathBuf, + install_core_archive: bool, ack_path: PathBuf, work_dir: PathBuf, target_version: String, @@ -2210,17 +2222,17 @@ async fn reset_agent_config_to_default( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - reset_agent_configuration_to_default_with_oauth( + reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { client, - &home, - config.port, + home: &home, + port: config.port, api_key, - &model, - &prepared.models, - prepared.codex_catalog.as_deref(), + model: &model, + models: &prepared.models, + codex_catalog: prepared.codex_catalog.as_deref(), oauth_configuration, - claude_desktop_model_mappings.as_ref(), - ) + claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(), + }) } #[tauri::command] @@ -2812,7 +2824,7 @@ fn claude_desktop_config_paths(_home: &Path) -> Vec { let local = env::var_os("LOCALAPPDATA") .map(PathBuf::from) .unwrap_or_else(|| _home.join("AppData/Local")); - return claude_desktop_config_paths_from_local_app_data(&local); + claude_desktop_config_paths_from_local_app_data(&local) } #[cfg(target_os = "linux")] let (normal, threep) = { @@ -3230,7 +3242,7 @@ fn find_codex_app_target(home: &Path) -> Option { fn find_claude_desktop_target(home: &Path) -> Option { #[cfg(target_os = "windows")] { - return find_windows_claude_desktop_target(home); + find_windows_claude_desktop_target(home) } #[cfg(not(target_os = "windows"))] find_claude_desktop_executable(home).map(ClaudeDesktopTarget::Application) @@ -3239,9 +3251,9 @@ fn find_claude_desktop_target(home: &Path) -> Option { fn read_claude_desktop_version(home: &Path) -> Option { #[cfg(target_os = "windows")] { - return read_windows_claude_desktop_store_version().or_else(|| { + read_windows_claude_desktop_store_version().or_else(|| { find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) - }); + }) } #[cfg(not(target_os = "windows"))] find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) @@ -6542,33 +6554,48 @@ fn reset_agent_configuration_to_default( model: &str, codex_catalog: Option<&str>, ) -> Result { - reset_agent_configuration_to_default_with_oauth( + reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration { client, home, port, api_key, model, - &[AgentModelOption { + models: &[AgentModelOption { name: model.to_string(), alias: None, }], codex_catalog, - false, - None, - ) + oauth_configuration: false, + claude_desktop_model_mappings: None, + }) +} + +struct AgentDefaultConfiguration<'a> { + client: AgentClient, + home: &'a Path, + port: u16, + api_key: &'a str, + model: &'a str, + models: &'a [AgentModelOption], + codex_catalog: Option<&'a str>, + oauth_configuration: bool, + claude_desktop_model_mappings: Option<&'a ClaudeDesktopModelMappings>, } fn reset_agent_configuration_to_default_with_oauth( - client: AgentClient, - home: &Path, - port: u16, - api_key: &str, - model: &str, - models: &[AgentModelOption], - codex_catalog: Option<&str>, - oauth_configuration: bool, - claude_desktop_model_mappings: Option<&ClaudeDesktopModelMappings>, + request: AgentDefaultConfiguration<'_>, ) -> Result { + let AgentDefaultConfiguration { + client, + home, + port, + api_key, + model, + models, + codex_catalog, + oauth_configuration, + claude_desktop_model_mappings, + } = request; let paths = agent_config_paths(client, home); let contents = fresh_agent_contents_with_oauth( client, @@ -7752,16 +7779,25 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul .get(key) .ok_or_else(|| format!("软件更新清单缺少 {key}"))?; validate_portable_update_asset(asset)?; - let expected_name = format!( + let full_package_name = format!( + "EasyCLIProxyAPI-v{}-Windows-{arch}.zip", + manifest.version.trim().trim_start_matches('v') + ); + let full_package_url = format!( + "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", + manifest.version.trim().trim_start_matches('v'), + full_package_name + ); + let legacy_package_name = format!( "EasyCLIProxyAPI-update-v{}-Windows-{arch}.zip", manifest.version.trim().trim_start_matches('v') ); - let expected_url = format!( + let legacy_package_url = format!( "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", manifest.version.trim().trim_start_matches('v'), - expected_name + legacy_package_name ); - if asset.url != expected_url { + if asset.url != full_package_url && asset.url != legacy_package_url { return Err(format!("软件更新资产名称与 {key} 不匹配")); } } @@ -7913,10 +7949,10 @@ async fn download_and_stage_portable_app_update( task.message = Some("正在准备应用更新".to_string()); }); let staging_dir = work_dir.join("staging"); - let package_manifest = extract_portable_update_archive(&archive_path, &staging_dir)?; - if normalize_version(&package_manifest.version) != normalize_version(&pending.version) - || package_manifest.platform != "windows" - || package_manifest.arch != pending.arch + let package = extract_portable_update_archive(&archive_path, &staging_dir)?; + if normalize_version(&package.manifest.version) != normalize_version(&pending.version) + || package.manifest.platform != "windows" + || package.manifest.arch != pending.arch { return Err("应用更新包版本或架构不匹配".to_string()); } @@ -7927,6 +7963,10 @@ async fn download_and_stage_portable_app_update( .parent() .ok_or_else(|| "当前程序路径没有父目录".to_string())?; preflight_portable_update_directory(app_dir)?; + let core_archive_name = match package.core_archive_name { + Some(name) => name, + None => stage_current_portable_core_payload(app_dir, &staging_dir, &pending.arch)?, + }; let helper_path = work_dir.join("EasyCLIProxyAPI-updater.exe"); fs::copy(¤t_exe, &helper_path) .map_err(|error| format!("准备应用更新助手失败: {error}"))?; @@ -7939,6 +7979,12 @@ async fn download_and_stage_portable_app_update( staged_manifest: staging_dir.join(PORTABLE_APP_MANIFEST_FILE), backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.update-backup"), backup_manifest: app_dir.join(".portable-app.json.update-backup"), + current_core_version: app_dir.join(CORE_VERSION_FILE), + staged_core_version: staging_dir.join(CORE_VERSION_FILE), + backup_core_version: app_dir.join(".core-version.txt.update-backup"), + staged_core_archive: staging_dir.join("cpa-core").join(&core_archive_name), + target_core_archive: app_dir.join("cpa-core").join(&core_archive_name), + install_core_archive: !app_dir.join("cpa-core").join(&core_archive_name).is_file(), ack_path: work_dir.join("update-started.ack"), work_dir: work_dir.clone(), target_version: pending.version.clone(), @@ -8071,23 +8117,33 @@ fn format_byte_count(bytes: u64) -> String { fn extract_portable_update_archive( archive_path: &Path, staging_dir: &Path, -) -> Result { +) -> Result { fs::create_dir_all(staging_dir) .map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?; let file = File::open(archive_path).map_err(|error| format!("打开应用更新包失败: {error}"))?; let mut archive = ZipArchive::new(file).map_err(|error| format!("读取应用更新 ZIP 失败: {error}"))?; - if archive.len() != 2 { - return Err("应用更新包必须只包含程序和便携版标识".to_string()); - } + let archive_names = archive + .file_names() + .map(|name| name.replace('\\', "/")) + .collect::>(); + let legacy_layout = archive_names.len() == 2 + && archive_names.iter().any(|name| name == PORTABLE_APP_BINARY) + && archive_names + .iter() + .any(|name| name == PORTABLE_APP_MANIFEST_FILE); + let mut package_root = None::; let mut seen_binary = false; let mut seen_manifest = false; + let mut seen_core_version = false; + let mut core_archive_name = None::; + let mut regular_file_count = 0_u32; for index in 0..archive.len() { let mut entry = archive .by_index(index) .map_err(|error| format!("读取应用更新 ZIP 条目失败: {error}"))?; let name = entry.name().replace('\\', "/"); - if entry.is_dir() || entry.enclosed_name().is_none() { + if entry.enclosed_name().is_none() { return Err("应用更新包包含不安全路径".to_string()); } if entry @@ -8096,7 +8152,32 @@ fn extract_portable_update_archive( { return Err("应用更新包不能包含符号链接".to_string()); } - let destination = match name.as_str() { + if entry.is_dir() { + continue; + } + regular_file_count = regular_file_count.saturating_add(1); + let relative; + let relative_name; + if legacy_layout { + relative = Vec::new(); + relative_name = name.clone(); + } else { + let mut components = name.split('/'); + let root = components + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "应用更新包缺少顶层目录".to_string())?; + relative = components.collect::>(); + if relative.is_empty() || relative.iter().any(|value| value.is_empty()) { + return Err("应用更新包目录结构无效".to_string()); + } + if package_root.as_deref().is_some_and(|value| value != root) { + return Err("应用更新包必须只包含一个顶层目录".to_string()); + } + package_root.get_or_insert_with(|| root.to_string()); + relative_name = relative.join("/"); + } + let destination = match relative_name.as_str() { PORTABLE_APP_BINARY if !seen_binary => { if entry.size() == 0 || entry.size() > 256 * 1024 * 1024 { return Err("应用更新程序大小异常".to_string()); @@ -8111,6 +8192,32 @@ fn extract_portable_update_archive( seen_manifest = true; staging_dir.join(PORTABLE_APP_MANIFEST_FILE) } + CORE_VERSION_FILE if !seen_core_version => { + if entry.size() == 0 || entry.size() > 1024 { + return Err("应用更新包内核版本文件大小异常".to_string()); + } + seen_core_version = true; + staging_dir.join(CORE_VERSION_FILE) + } + _ if relative.len() == 2 + && relative[0] == "cpa-core" + && core_archive_name.is_none() => + { + let filename = relative[1]; + if !filename.starts_with("CLIProxyAPI_") + || !filename.ends_with(".zip") + || filename.contains("_no-plugin") + || entry.size() == 0 + || entry.size() > 512 * 1024 * 1024 + { + return Err("应用更新包内置内核压缩包无效".to_string()); + } + core_archive_name = Some(filename.to_string()); + let core_staging_dir = staging_dir.join("cpa-core"); + fs::create_dir_all(&core_staging_dir) + .map_err(|error| format!("创建内置内核暂存目录失败: {error}"))?; + core_staging_dir.join(filename) + } _ => return Err(format!("应用更新包包含未知文件: {name}")), }; let mut output = File::create(&destination) @@ -8121,7 +8228,12 @@ fn extract_portable_update_archive( .flush() .map_err(|error| format!("保存应用更新暂存文件失败: {error}"))?; } - if !seen_binary || !seen_manifest { + let required_files_present = if legacy_layout { + regular_file_count == 2 && seen_binary && seen_manifest + } else { + regular_file_count == 4 && seen_binary && seen_manifest && seen_core_version + }; + if !required_files_present { return Err("应用更新包缺少必要文件".to_string()); } let manifest = fs::read_to_string(staging_dir.join(PORTABLE_APP_MANIFEST_FILE)) @@ -8134,11 +8246,79 @@ fn extract_portable_update_archive( { return Err("应用更新标识无效".to_string()); } - Ok(manifest) + if !legacy_layout { + let expected_root = format!( + "EasyCLIProxyAPI-v{}-Windows-{}", + manifest.version.trim().trim_start_matches('v'), + manifest.arch + ); + if package_root.as_deref() != Some(expected_root.as_str()) { + return Err("应用更新包顶层目录与版本或架构不匹配".to_string()); + } + let core_version = fs::read_to_string(staging_dir.join(CORE_VERSION_FILE)) + .map_err(|error| format!("读取内置内核版本失败: {error}"))?; + let core_version = core_version.trim().trim_start_matches('v'); + if core_version.is_empty() + || !core_version.split('.').all(|segment| { + !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit()) + }) + { + return Err("应用更新包内置内核版本无效".to_string()); + } + let expected_core_archive = + format!("CLIProxyAPI_{core_version}_windows_{}.zip", manifest.arch); + if core_archive_name.as_deref() != Some(expected_core_archive.as_str()) { + return Err("应用更新包内置内核版本或架构不匹配".to_string()); + } + } + Ok(PortablePackagePayload { + manifest, + core_archive_name, + }) +} + +#[cfg(windows)] +fn stage_current_portable_core_payload( + app_dir: &Path, + staging_dir: &Path, + arch: &str, +) -> Result { + let current_core_version = app_dir.join(CORE_VERSION_FILE); + let core_version = fs::read_to_string(¤t_core_version) + .map_err(|error| format!("读取当前内置内核版本失败: {error}"))?; + let core_version = core_version.trim().trim_start_matches('v'); + if core_version.is_empty() + || !core_version + .split('.') + .all(|segment| !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err("当前内置内核版本无效,无法兼容旧版更新包".to_string()); + } + let core_archive_name = format!("CLIProxyAPI_{core_version}_windows_{arch}.zip"); + let current_core_archive = app_dir.join("cpa-core").join(&core_archive_name); + if !current_core_archive.is_file() { + return Err(format!( + "当前便携版缺少内置内核压缩包 {core_archive_name},无法兼容旧版更新包" + )); + } + let staged_core_dir = staging_dir.join("cpa-core"); + fs::create_dir_all(&staged_core_dir) + .map_err(|error| format!("创建旧版更新兼容暂存目录失败: {error}"))?; + fs::copy(¤t_core_version, staging_dir.join(CORE_VERSION_FILE)) + .map_err(|error| format!("暂存当前内核版本文件失败: {error}"))?; + fs::copy( + current_core_archive, + staged_core_dir.join(&core_archive_name), + ) + .map_err(|error| format!("暂存当前内置内核压缩包失败: {error}"))?; + Ok(core_archive_name) } #[cfg(windows)] fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> { + if !app_dir.join(CORE_VERSION_FILE).is_file() || !app_dir.join("cpa-core").is_dir() { + return Err("当前便携版目录不完整,请下载最新版完整包覆盖升级".to_string()); + } let probe = app_dir.join(format!( ".easycliproxy-update-write-test-{}", std::process::id() @@ -8146,6 +8326,14 @@ fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> { fs::write(&probe, b"update-write-test") .map_err(|error| format!("应用目录不可写,无法自动升级: {error}"))?; fs::remove_file(&probe).map_err(|error| format!("清理应用更新写入测试失败: {error}"))?; + let core_probe = app_dir.join("cpa-core").join(format!( + ".easycliproxy-update-write-test-{}", + std::process::id() + )); + fs::write(&core_probe, b"update-write-test") + .map_err(|error| format!("内置内核目录不可写,无法自动升级: {error}"))?; + fs::remove_file(&core_probe) + .map_err(|error| format!("清理内置内核更新写入测试失败: {error}"))?; Ok(()) } @@ -14335,9 +14523,24 @@ fn validate_portable_update_descriptor( || descriptor.current_manifest != app_dir.join(PORTABLE_APP_MANIFEST_FILE) || descriptor.backup_exe != app_dir.join(".EasyCLIProxyAPI.exe.update-backup") || descriptor.backup_manifest != app_dir.join(".portable-app.json.update-backup") + || descriptor.current_core_version != app_dir.join(CORE_VERSION_FILE) + || descriptor.backup_core_version != app_dir.join(".core-version.txt.update-backup") { return Err("应用更新目标路径无效".to_string()); } + let core_archive_name = descriptor + .staged_core_archive + .file_name() + .ok_or_else(|| "应用更新内置内核文件名无效".to_string())?; + if descriptor.target_core_archive != app_dir.join("cpa-core").join(core_archive_name) + || !core_archive_name + .to_string_lossy() + .starts_with("CLIProxyAPI_") + || !core_archive_name.to_string_lossy().ends_with(".zip") + || descriptor.install_core_archive == descriptor.target_core_archive.is_file() + { + return Err("应用更新内置内核目标路径无效".to_string()); + } let canonical_work_dir = fs::canonicalize(&descriptor.work_dir) .map_err(|error| format!("读取应用更新临时目录失败: {error}"))?; let canonical_temp_dir = fs::canonicalize(env::temp_dir()) @@ -14361,7 +14564,12 @@ fn validate_portable_update_descriptor( { return Err("应用更新描述路径越界".to_string()); } - for staged in [&descriptor.staged_exe, &descriptor.staged_manifest] { + for staged in [ + &descriptor.staged_exe, + &descriptor.staged_manifest, + &descriptor.staged_core_version, + &descriptor.staged_core_archive, + ] { let canonical = fs::canonicalize(staged) .map_err(|error| format!("读取应用更新暂存文件失败: {error}"))?; if !canonical.starts_with(&canonical_work_dir) { @@ -14378,6 +14586,14 @@ fn validate_portable_update_descriptor( .work_dir .join("staging") .join(PORTABLE_APP_MANIFEST_FILE) + || descriptor.staged_core_version + != descriptor.work_dir.join("staging").join(CORE_VERSION_FILE) + || descriptor.staged_core_archive + != descriptor + .work_dir + .join("staging") + .join("cpa-core") + .join(core_archive_name) { return Err("应用更新暂存路径无效".to_string()); } @@ -14386,7 +14602,10 @@ fn validate_portable_update_descriptor( #[cfg(windows)] fn restore_portable_update_backup(descriptor: &PortableUpdateDescriptor) -> Result<(), String> { - if !descriptor.backup_exe.is_file() || !descriptor.backup_manifest.is_file() { + if !descriptor.backup_exe.is_file() + || !descriptor.backup_manifest.is_file() + || !descriptor.backup_core_version.is_file() + { return Err("应用更新备份不完整,无法回滚".to_string()); } if descriptor.current_exe.exists() { @@ -14401,6 +14620,19 @@ fn restore_portable_update_backup(descriptor: &PortableUpdateDescriptor) -> Resu } fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest) .map_err(|error| format!("恢复旧版便携版标识失败: {error}"))?; + if descriptor.current_core_version.exists() { + fs::remove_file(&descriptor.current_core_version) + .map_err(|error| format!("移除新版内核版本文件失败: {error}"))?; + } + fs::rename( + &descriptor.backup_core_version, + &descriptor.current_core_version, + ) + .map_err(|error| format!("恢复旧版内核版本文件失败: {error}"))?; + if descriptor.install_core_archive && descriptor.target_core_archive.exists() { + fs::remove_file(&descriptor.target_core_archive) + .map_err(|error| format!("移除新版内置内核压缩包失败: {error}"))?; + } Ok(()) } @@ -14412,39 +14644,81 @@ fn replace_portable_update_files(descriptor: &PortableUpdateDescriptor) -> Resul .ok_or_else(|| "应用更新目标路径无效".to_string())?; let replacement_exe = app_dir.join(".EasyCLIProxyAPI.exe.update-new"); let replacement_manifest = app_dir.join(".portable-app.json.update-new"); + let replacement_core_version = app_dir.join(".core-version.txt.update-new"); + let replacement_core_archive = app_dir.join(".cpa-core-archive.update-new"); let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); fs::copy(&descriptor.staged_exe, &replacement_exe) .map_err(|error| format!("准备新版应用程序失败: {error}"))?; if let Err(error) = fs::copy(&descriptor.staged_manifest, &replacement_manifest) { let _ = fs::remove_file(&replacement_exe); return Err(format!("准备新版便携版标识失败: {error}")); } + if let Err(error) = fs::copy(&descriptor.staged_core_version, &replacement_core_version) { + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + return Err(format!("准备新版内核版本文件失败: {error}")); + } + if descriptor.install_core_archive { + if let Err(error) = fs::copy(&descriptor.staged_core_archive, &replacement_core_archive) { + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + return Err(format!("准备新版内置内核压缩包失败: {error}")); + } + } let _ = fs::remove_file(&descriptor.backup_exe); let _ = fs::remove_file(&descriptor.backup_manifest); + let _ = fs::remove_file(&descriptor.backup_core_version); if let Err(error) = fs::rename(&descriptor.current_exe, &descriptor.backup_exe) { let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); return Err(format!("备份旧版应用失败: {error}")); } if let Err(error) = fs::rename(&descriptor.current_manifest, &descriptor.backup_manifest) { let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe); let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); return Err(format!("备份便携版标识失败: {error}")); } + if let Err(error) = fs::rename( + &descriptor.current_core_version, + &descriptor.backup_core_version, + ) { + let _ = fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest); + let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe); + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); + return Err(format!("备份旧版内核版本文件失败: {error}")); + } let replace_result = (|| -> Result<(), String> { fs::rename(&replacement_exe, &descriptor.current_exe) .map_err(|error| format!("替换应用程序失败: {error}"))?; fs::rename(&replacement_manifest, &descriptor.current_manifest) .map_err(|error| format!("替换便携版标识失败: {error}"))?; + fs::rename(&replacement_core_version, &descriptor.current_core_version) + .map_err(|error| format!("替换内核版本文件失败: {error}"))?; + if descriptor.install_core_archive { + fs::rename(&replacement_core_archive, &descriptor.target_core_archive) + .map_err(|error| format!("安装新版内置内核压缩包失败: {error}"))?; + } Ok(()) })(); if let Err(error) = replace_result { let _ = fs::remove_file(&replacement_exe); let _ = fs::remove_file(&replacement_manifest); + let _ = fs::remove_file(&replacement_core_version); + let _ = fs::remove_file(&replacement_core_archive); restore_portable_update_backup(descriptor) .map_err(|rollback| format!("{error};{rollback}"))?; return Err(error); @@ -14452,6 +14726,48 @@ fn replace_portable_update_files(descriptor: &PortableUpdateDescriptor) -> Resul Ok(()) } +#[cfg(windows)] +fn cleanup_superseded_bundled_core_archives(descriptor: &PortableUpdateDescriptor) { + let Some(core_dir) = descriptor.target_core_archive.parent() else { + return; + }; + let Some(current_name) = descriptor + .target_core_archive + .file_name() + .and_then(|value| value.to_str()) + else { + return; + }; + let Ok(entries) = fs::read_dir(core_dir) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if path.is_file() + && name != current_name + && name.starts_with("CLIProxyAPI_") + && name.ends_with(".zip") + && !name.contains("_no-plugin") + { + if let Err(error) = fs::remove_file(&path) { + eprintln!( + "清理旧版内置内核压缩包失败 {}: {error}", + path_to_string(&path) + ); + } + } + } +} + +#[cfg(windows)] +fn cleanup_portable_update_payload(descriptor_path: &Path, descriptor: &PortableUpdateDescriptor) { + let _ = fs::remove_file(descriptor.work_dir.join("update.zip")); + let _ = fs::remove_dir_all(descriptor.work_dir.join("staging")); + let _ = fs::remove_file(&descriptor.ack_path); + let _ = fs::remove_file(descriptor_path); +} + #[cfg(windows)] fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let descriptor = serde_json::from_slice::( @@ -14465,6 +14781,7 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let mut rollback = Command::new(&descriptor.current_exe); configure_background_command(&mut rollback); let _ = rollback.spawn(); + cleanup_portable_update_payload(descriptor_path, &descriptor); return Err(error); } @@ -14484,6 +14801,7 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let mut rollback = Command::new(&descriptor.current_exe); configure_background_command(&mut rollback); let _ = rollback.spawn(); + cleanup_portable_update_payload(descriptor_path, &descriptor); return Err(format!("启动新版应用失败: {error}")); } }; @@ -14508,8 +14826,9 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { if confirmed { let _ = fs::remove_file(&descriptor.backup_exe); let _ = fs::remove_file(&descriptor.backup_manifest); - let _ = fs::remove_file(&descriptor.ack_path); - let _ = fs::remove_file(descriptor_path); + let _ = fs::remove_file(&descriptor.backup_core_version); + cleanup_superseded_bundled_core_archives(&descriptor); + cleanup_portable_update_payload(descriptor_path, &descriptor); return Ok(()); } @@ -14519,6 +14838,7 @@ fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { let mut rollback = Command::new(&descriptor.current_exe); configure_background_command(&mut rollback); let _ = rollback.spawn(); + cleanup_portable_update_payload(descriptor_path, &descriptor); Err(format!( "新版应用 {} 未能在 60 秒内完成启动确认,已回滚", descriptor.target_version @@ -18051,6 +18371,15 @@ custom_option = "keep-original" } fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset { + let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip"); + PortableUpdateAsset { + url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), + sha256: "ab".repeat(32), + size_bytes: 1024, + } + } + + fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdateAsset { let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip"); PortableUpdateAsset { url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), @@ -18087,6 +18416,17 @@ custom_option = "keep-original" let manifest = portable_update_test_manifest("1.2.3"); assert!(validate_portable_update_manifest(&manifest).is_ok()); + let mut legacy_manifest = portable_update_test_manifest("1.2.3"); + legacy_manifest.assets.insert( + "windows-amd64".to_string(), + portable_update_test_legacy_asset("1.2.3", "amd64"), + ); + legacy_manifest.assets.insert( + "windows-aarch64".to_string(), + portable_update_test_legacy_asset("1.2.3", "aarch64"), + ); + assert!(validate_portable_update_manifest(&legacy_manifest).is_ok()); + let mut missing_arch = portable_update_test_manifest("1.2.3"); missing_arch.assets.remove("windows-aarch64"); assert!(validate_portable_update_manifest(&missing_arch).is_err()); @@ -18104,9 +18444,8 @@ custom_option = "keep-original" assert!(validate_portable_update_manifest(&foreign_host).is_err()); let mut mismatched_tag = portable_update_test_manifest("1.2.3"); - mismatched_tag.assets.get_mut("windows-amd64").unwrap().url = format!( - "{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip" - ); + mismatched_tag.assets.get_mut("windows-amd64").unwrap().url = + format!("{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip"); assert!(validate_portable_update_manifest(&mismatched_tag).is_err()); } @@ -18175,27 +18514,65 @@ custom_option = "keep-original" #[cfg(windows)] #[test] - fn portable_update_zip_accepts_only_the_two_expected_regular_files() { + fn portable_update_zip_accepts_the_complete_release_package() { let root = agent_test_home("portable-zip"); let valid_zip = root.join("valid.zip"); let manifest = br#"{"schemaVersion":1,"application":"EasyCLIProxyAPI","version":"1.2.3","platform":"windows","arch":"amd64","autoUpdate":true}"#; write_portable_update_zip( &valid_zip, &[ - (PORTABLE_APP_BINARY, b"new executable", None), - (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/EasyCLIProxyAPI.exe", + b"new executable", + None, + ), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json", + manifest, + None, + ), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/core-version.txt", + b"7.2.109\n", + None, + ), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/cpa-core/CLIProxyAPI_7.2.109_windows_amd64.zip", + b"core archive", + None, + ), ], ); let extracted = extract_portable_update_archive(&valid_zip, &root.join("valid")).unwrap(); - assert_eq!(extracted.version, "1.2.3"); - assert!(extracted.auto_update); + assert_eq!(extracted.manifest.version, "1.2.3"); + assert!(extracted.manifest.auto_update); + assert_eq!( + extracted.core_archive_name, + Some("CLIProxyAPI_7.2.109_windows_amd64.zip".to_string()) + ); + + let legacy_zip = root.join("legacy.zip"); + write_portable_update_zip( + &legacy_zip, + &[ + (PORTABLE_APP_BINARY, b"legacy executable", None), + (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ], + ); + let legacy = extract_portable_update_archive(&legacy_zip, &root.join("legacy")).unwrap(); + assert_eq!(legacy.manifest.version, "1.2.3"); + assert!(legacy.core_archive_name.is_none()); let traversal_zip = root.join("traversal.zip"); write_portable_update_zip( &traversal_zip, &[ ("../EasyCLIProxyAPI.exe", b"malicious", None), - (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json", + manifest, + None, + ), ], ); assert!(extract_portable_update_archive(&traversal_zip, &root.join("traversal")).is_err()); @@ -18208,13 +18585,16 @@ custom_option = "keep-original" let mut archive = zip::ZipWriter::new(file); archive .add_symlink( - PORTABLE_APP_BINARY, + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/EasyCLIProxyAPI.exe", "target.exe", SimpleFileOptions::default(), ) .unwrap(); archive - .start_file(PORTABLE_APP_MANIFEST_FILE, SimpleFileOptions::default()) + .start_file( + "EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json", + SimpleFileOptions::default(), + ) .unwrap(); archive.write_all(manifest).unwrap(); archive.finish().unwrap(); @@ -18232,13 +18612,31 @@ custom_option = "keep-original" let staging = work_dir.join("staging"); fs::create_dir_all(&app_dir).unwrap(); fs::create_dir_all(&staging).unwrap(); + fs::create_dir_all(app_dir.join("cpa-core")).unwrap(); + fs::create_dir_all(staging.join("cpa-core")).unwrap(); fs::write(app_dir.join(PORTABLE_APP_BINARY), b"old exe").unwrap(); fs::write(app_dir.join(PORTABLE_APP_MANIFEST_FILE), b"old manifest").unwrap(); + fs::write(app_dir.join(CORE_VERSION_FILE), b"7.2.100").unwrap(); + fs::write( + app_dir + .join("cpa-core") + .join("CLIProxyAPI_7.2.100_windows_amd64.zip"), + b"old core archive", + ) + .unwrap(); fs::write(app_dir.join(GUI_CONFIG_FILE), b"user config").unwrap(); fs::create_dir_all(app_dir.join(OAUTH_DIR_NAME)).unwrap(); fs::write(app_dir.join(OAUTH_DIR_NAME).join("account.json"), b"oauth").unwrap(); fs::write(staging.join(PORTABLE_APP_BINARY), b"new exe").unwrap(); fs::write(staging.join(PORTABLE_APP_MANIFEST_FILE), b"new manifest").unwrap(); + fs::write(staging.join(CORE_VERSION_FILE), b"7.2.109").unwrap(); + fs::write( + staging + .join("cpa-core") + .join("CLIProxyAPI_7.2.109_windows_amd64.zip"), + b"new core archive", + ) + .unwrap(); let descriptor = PortableUpdateDescriptor { parent_pid: 1, current_exe: app_dir.join(PORTABLE_APP_BINARY), @@ -18247,6 +18645,16 @@ custom_option = "keep-original" staged_manifest: staging.join(PORTABLE_APP_MANIFEST_FILE), backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.update-backup"), backup_manifest: app_dir.join(".portable-app.json.update-backup"), + current_core_version: app_dir.join(CORE_VERSION_FILE), + staged_core_version: staging.join(CORE_VERSION_FILE), + backup_core_version: app_dir.join(".core-version.txt.update-backup"), + staged_core_archive: staging + .join("cpa-core") + .join("CLIProxyAPI_7.2.109_windows_amd64.zip"), + target_core_archive: app_dir + .join("cpa-core") + .join("CLIProxyAPI_7.2.109_windows_amd64.zip"), + install_core_archive: true, ack_path: work_dir.join("update-started.ack"), work_dir, target_version: "1.2.3".to_string(), @@ -18258,6 +18666,14 @@ custom_option = "keep-original" fs::read(&descriptor.current_manifest).unwrap(), b"new manifest" ); + assert_eq!( + fs::read(&descriptor.current_core_version).unwrap(), + b"7.2.109" + ); + assert_eq!( + fs::read(&descriptor.target_core_archive).unwrap(), + b"new core archive" + ); assert_eq!( fs::read(app_dir.join(GUI_CONFIG_FILE)).unwrap(), b"user config" @@ -18273,6 +18689,11 @@ custom_option = "keep-original" fs::read(&descriptor.current_manifest).unwrap(), b"old manifest" ); + assert_eq!( + fs::read(&descriptor.current_core_version).unwrap(), + b"7.2.100" + ); + assert!(!descriptor.target_core_archive.exists()); fs::remove_dir_all(root).unwrap(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index b278fea..9b7c611 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -13,6 +13,7 @@ export const jaOverrides = { 'app.nav.kernel': 'コア', 'app.nav.config': '詳細設定', 'app.nav.thinkingAliases': 'モデルエイリアス', + 'app.nav.speedAliases': '速度エイリアス', 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API 接続', 'app.nav.authFiles': '認証ファイル', @@ -239,6 +240,20 @@ export const jaOverrides = { 'aliases.empty.description': '左側でモデルとレベルを選択して作成できます', 'aliases.unboundEffort': 'レベル未設定', 'aliases.delete': '{alias} を削除', + 'speedAliases.created': '{alias} を作成し、速度階層を Fast に固定しました', + 'speedAliases.deleted': '{alias} を削除しました', + 'speedAliases.deleteConfirm': '速度エイリアス「{alias}」を削除しますか?\n対応する Fast 上書きルールも削除されます。', + 'speedAliases.create.title': '速度エイリアスを作成', + 'speedAliases.create.description': '常に Fast リクエスト階層を使用するエイリアスを作成します', + 'speedAliases.fast.title': 'Fast', + 'speedAliases.aliasName.example': '例:{model}-fast', + 'speedAliases.creating': '作成中', + 'speedAliases.create': 'Fast エイリアスを作成', + 'speedAliases.createdList.title': '作成済み速度エイリアス', + 'speedAliases.createdList.description': '各エイリアスには元のモデル、プロバイダー、固定 Fast 階層が保持されます', + 'speedAliases.empty.title': '速度エイリアスはまだありません', + 'speedAliases.empty.description': '左側でモデルを選択して Fast エイリアスを作成できます', + 'speedAliases.delete': '{alias} を削除', 'config.routing.roundRobin': 'ラウンドロビン', 'config.routing.fillFirst': '優先充填', 'config.tabs.label': '詳細設定ページ', @@ -435,6 +450,7 @@ export const jaOverrides = { 'agents.error.modelsLoading': 'モデル一覧を読み込み中です。しばらくしてから再試行してください', 'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません', 'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください', + 'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください', 'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください', 'agents.status.detecting': '検出中', 'agents.status.unsupported': 'プラットフォーム非対応', @@ -530,6 +546,11 @@ export const jaOverrides = { 'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します', 'agents.modify.title': '設定変更', 'agents.modify.oauthConfiguration': 'OAuth 設定方式', + 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', + 'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。', + 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先', + 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 のマッピング先', + 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 のマッピング先', 'agents.oauthLoginRequired.title': 'Codex へのログインが必要です', 'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に', 'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去', diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index 722d9ce..86fd619 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -3,19 +3,22 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; -import { generatePortableUpdateManifest } from '../scripts/generate-portable-update-manifest.mjs'; +import { + generatePortableUpdateManifest, + LEGACY_UPDATE_BRIDGE_VERSION, +} from '../scripts/generate-portable-update-manifest.mjs'; describe('Windows 便携更新清单', () => { test('URL、大小和哈希与实际上传资产一致', async () => { const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-test-')); try { const payloads = { - amd64: Buffer.from('amd64 portable updater'), - aarch64: Buffer.from('aarch64 portable updater'), + amd64: Buffer.from('amd64 full portable package'), + aarch64: Buffer.from('aarch64 full portable package'), }; for (const [arch, contents] of Object.entries(payloads)) { await writeFile( - join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`), + join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`), contents, ); } @@ -34,7 +37,7 @@ describe('Windows 便携更新清单', () => { for (const arch of ['amd64', 'aarch64'] as const) { const asset = manifest.assets[`windows-${arch}`]; expect(asset.url).toBe( - `https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`, + `https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`, ); expect(asset.sizeBytes).toBe(payloads[arch].byteLength); expect(asset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex')); @@ -48,7 +51,7 @@ describe('Windows 便携更新清单', () => { const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-missing-')); try { await writeFile( - join(root, 'EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip'), + join(root, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), 'amd64', ); await expect(generatePortableUpdateManifest({ @@ -61,4 +64,32 @@ describe('Windows 便携更新清单', () => { await rm(root, { recursive: true, force: true }); } }); + + test('v0.2.10 使用一次性旧版桥接资产,后续版本恢复完整包', async () => { + const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-bridge-')); + try { + for (const arch of ['amd64', 'aarch64']) { + await writeFile( + join(root, `EasyCLIProxyAPI-update-v${LEGACY_UPDATE_BRIDGE_VERSION}-Windows-${arch}.zip`), + `legacy bridge ${arch}`, + ); + } + const manifest = await generatePortableUpdateManifest({ + directory: root, + output: join(root, 'portable-update-windows.json'), + repository: 'router-for-me/EasyCLIProxyAPI', + tag: `v${LEGACY_UPDATE_BRIDGE_VERSION}`, + publishedAt: '2026-08-02T00:00:00.000Z', + }); + + expect(manifest.assets['windows-amd64'].url).toEndWith( + '/EasyCLIProxyAPI-update-v0.2.10-Windows-amd64.zip', + ); + expect(manifest.assets['windows-aarch64'].url).toEndWith( + '/EasyCLIProxyAPI-update-v0.2.10-Windows-aarch64.zip', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); From 85e0764ad99361fb2b5ef5a5a70dbd8e84e3d7e7 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 00:51:19 +0800 Subject: [PATCH 15/29] feat(oauth): consolidate credential and quota management - add tabbed navigation to the OAuth page - move auth files and quota lookup into OAuth - remove their standalone sidebar entries --- src/App.tsx | 20 ++----------- src/i18n/ja.ts | 1 + src/i18n/resources.ts | 2 ++ src/oauthNavigation.ts | 7 +++++ src/pages/ManagementPages.tsx | 48 +++++++++++++++++++++++++++++++ src/styles.css | 7 +++++ tests/navigationAndUpdate.test.ts | 12 ++++++++ 7 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 src/oauthNavigation.ts diff --git a/src/App.tsx b/src/App.tsx index af9163b..504610d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,8 +6,6 @@ import { Check, ChevronUp, ExternalLink, - FileKey, - Gauge, History, House, Languages, @@ -23,10 +21,8 @@ import appLogo from './assets/logo.jpg'; import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; import { ConfigPanelPage } from './pages/ConfigPanel'; import { ApiAccessPage } from './pages/ApiAccessPage'; -import { AuthFileManagementPage } from './pages/AuthFileManagementPage'; import { KernelPage } from './pages/Kernel'; -import { OAuthLoginPage } from './pages/ManagementPages'; -import { QuotaPage } from './pages/QuotaPage'; +import { OAuthManagementPage } from './pages/ManagementPages'; import { AgentsPage } from './pages/AgentsPage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; import { languageOptions, useI18n } from './i18n'; @@ -60,7 +56,7 @@ const pages = [ id: 'oauth', labelKey: 'app.nav.oauth', icon: LogIn, - component: OAuthLoginPage, + component: OAuthManagementPage, }, { id: 'api', @@ -68,18 +64,6 @@ const pages = [ icon: Network, component: ApiAccessPage, }, - { - id: 'auth-files', - labelKey: 'app.nav.authFiles', - icon: FileKey, - component: AuthFileManagementPage, - }, - { - id: 'quota', - labelKey: 'app.nav.quota', - icon: Gauge, - component: QuotaPage, - }, { id: 'usage-records', labelKey: 'app.nav.usageRecords', diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 9b7c611..49987fb 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -60,6 +60,7 @@ export const jaOverrides = { 'quota.confirm.expiry': '最も早い有効期限:{time}', 'quota.confirm.warning': '「確認」を押した場合のみ実行されます。「キャンセル」ではリセット回数を消費しません。', 'oauth.title': 'OAuth ログイン', + 'oauth.tabs.label': 'OAuth 管理ページ', 'oauth.loginSuccess': '{provider} へのログインに成功しました', 'oauth.priorityApplyFailed': 'OAuth ログインは成功しましたが、新しい認証情報の優先度を 0 に設定できませんでした:{error}', 'oauth.authFailed': '認証に失敗しました', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 4142ca4..3658bdc 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -63,6 +63,7 @@ export const zhCN = { 'quota.confirm.expiry': '最早过期:{time}', 'quota.confirm.warning': '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。', 'oauth.title': 'OAuth 登录', + 'oauth.tabs.label': 'OAuth 管理子页面', 'oauth.loginSuccess': '{provider} 登录成功', 'oauth.priorityApplyFailed': 'OAuth 登录成功,但无法将新凭证的优先级设为 0:{error}', 'oauth.authFailed': '认证失败', @@ -951,6 +952,7 @@ export const en: Record = { 'quota.confirm.expiry': 'Earliest expiry: {time}', 'quota.confirm.warning': 'The reset runs only after you confirm. Canceling will not consume a reset credit.', 'oauth.title': 'OAuth Sign-In', + 'oauth.tabs.label': 'OAuth management sections', 'oauth.loginSuccess': '{provider} sign-in succeeded', 'oauth.priorityApplyFailed': 'OAuth sign-in succeeded, but the new credential priority could not be set to 0: {error}', 'oauth.authFailed': 'Authentication failed', diff --git a/src/oauthNavigation.ts b/src/oauthNavigation.ts new file mode 100644 index 0000000..4c8ca9e --- /dev/null +++ b/src/oauthNavigation.ts @@ -0,0 +1,7 @@ +export const oauthSubpages = [ + { id: 'login', labelKey: 'oauth.title' }, + { id: 'authFiles', labelKey: 'authFiles.title' }, + { id: 'quota', labelKey: 'quota.title' }, +] as const; + +export type OAuthSubpage = (typeof oauthSubpages)[number]['id']; diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index 6db0fc2..0b69ea3 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -13,12 +13,15 @@ import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; import { useI18n } from '../i18n'; +import { oauthSubpages, type OAuthSubpage } from '../oauthNavigation'; import { changedOAuthAuthFileNames, snapshotAuthFiles, type AuthFileSnapshot, } from '../services/authFiles'; import { managementApi, responseList } from '../services/managementApi'; +import { AuthFileManagementPage } from './AuthFileManagementPage'; +import { QuotaPage } from './QuotaPage'; type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai'; type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error'; @@ -65,6 +68,51 @@ const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback'; const OAUTH_SUCCESS_RESET_MS = 5000; const OAUTH_POLL_INTERVAL_MS = 3000; +export function OAuthManagementPage() { + const { t } = useI18n(); + const [activeSubpage, setActiveSubpage] = useState('login'); + + return ( +
+
+ {oauthSubpages.map((subpage) => { + const active = activeSubpage === subpage.id; + return ( + + ); + })} +
+ +
+ {activeSubpage === 'login' ? : null} + {activeSubpage === 'authFiles' ? : null} + {activeSubpage === 'quota' ? : null} +
+
+ ); +} + export function OAuthLoginPage() { const { t } = useI18n(); const [states, setStates] = useState>>({}); diff --git a/src/styles.css b/src/styles.css index 133ceb3..3961673 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4498,6 +4498,13 @@ textarea:focus-visible { box-shadow: 0 1px 4px rgba(45, 42, 38, 0.14); } +.oauth-management-page, +.oauth-subpage-panel { + display: grid; + min-width: 0; + gap: 18px; +} + .agent-core-config { display: grid; min-width: 0; diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts index df52750..4c15c10 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { appUpdateIndicatorState } from '../src/appUpdateModel'; import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation'; +import { oauthSubpages } from '../src/oauthNavigation'; describe('首页、配置与版本管理导航', () => { test('内核停止时首页、配置和版本管理始终可进入', () => { @@ -19,6 +20,17 @@ describe('首页、配置与版本管理导航', () => { }); }); +describe('OAuth 子页面导航', () => { + test('认证文件和额度查询收纳在 OAuth 页面内', () => { + expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles', 'quota']); + expect(oauthSubpages.map((page) => page.labelKey)).toEqual([ + 'oauth.title', + 'authFiles.title', + 'quota.title', + ]); + }); +}); + describe('软件更新导航提示点', () => { test('有新版显示橙点,处理中的蓝点优先', () => { expect(appUpdateIndicatorState(true, false)).toBe('available'); From 3f370a1faa6fa7f609688b76cd2daa7669bc8557 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 14:17:37 +0800 Subject: [PATCH 16/29] fix(agents): preserve runtime config changes across managed sessions --- src-tauri/src/main.rs | 2083 ++++++++++++++++++++++++++++++++++++-- src/pages/AgentsPage.tsx | 3 +- 2 files changed, 2014 insertions(+), 72 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e2f8f26..44b297c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2984,14 +2984,53 @@ fn inspect_agent_config( fn inspect_agent_application(client: AgentClient, home: &Path) -> AgentModificationInspection { match load_agent_applied_state(client, home) { - Ok(Some(state)) => AgentModificationInspection { - enabled: true, - state: "applied".to_string(), - backup_available: false, - applied_model: Some(state.model), - claude_desktop_model_mappings: state.claude_desktop_model_mappings, - warnings: Vec::new(), - }, + Ok(Some(state)) => { + let paths = agent_config_paths(client, home); + let backup_available = !state.backup_files.is_empty() + && state + .backup_files + .iter() + .all(|file| !file.existed_before || file.backup_path.is_file()); + if state.backup_files.is_empty() { + match agent_has_managed_marker(client, &paths) { + Ok(false) => { + return AgentModificationInspection { + enabled: false, + state: "unconfigured".to_string(), + backup_available: false, + applied_model: None, + claude_desktop_model_mappings: None, + warnings: Vec::new(), + }; + } + Err(error) => { + return AgentModificationInspection { + enabled: false, + state: "invalid".to_string(), + backup_available: false, + applied_model: None, + claude_desktop_model_mappings: None, + warnings: vec![error], + }; + } + Ok(true) => {} + } + } + let mut warnings = Vec::new(); + if state.backup_files.is_empty() { + warnings.push("检测到旧版应用状态;关闭时将只移除 CPA 管理的配置字段".to_string()); + } else if !backup_available { + warnings.push("原配置会话备份不完整,暂时无法安全恢复".to_string()); + } + AgentModificationInspection { + enabled: true, + state: "applied".to_string(), + backup_available, + applied_model: Some(state.model), + claude_desktop_model_mappings: state.claude_desktop_model_mappings, + warnings, + } + } Ok(None) => AgentModificationInspection { enabled: false, state: "unconfigured".to_string(), @@ -3035,7 +3074,6 @@ fn inspect_agent_managed_config( } } -#[cfg(test)] fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result { match client { AgentClient::ClaudeCode => { @@ -3047,21 +3085,29 @@ fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result { - if paths.len() != 4 || !paths[3].is_file() { + if paths.len() != 4 { return Ok(false); } let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?; + let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop 网关配置")?; Ok(meta.get("appliedId").and_then(serde_json::Value::as_str) - == Some(CLAUDE_DESKTOP_PROFILE_ID)) + == Some(CLAUDE_DESKTOP_PROFILE_ID) + || meta + .get("entries") + .and_then(serde_json::Value::as_array) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry.get("id").and_then(serde_json::Value::as_str) + == Some(CLAUDE_DESKTOP_PROFILE_ID) + }) + }) + || profile + .get("inferenceGatewayBaseUrl") + .and_then(serde_json::Value::as_str) + .is_some_and(is_managed_agent_base_url)) } AgentClient::Codex => { if !paths[0].is_file() { @@ -3080,10 +3126,16 @@ fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result { if !paths[0].is_file() { @@ -3094,11 +3146,20 @@ fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result { if !paths[0].is_file() { @@ -3109,7 +3170,7 @@ fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result Result bool { + let value = value.trim().to_ascii_lowercase(); + value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:") +} + fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec { let mut targets = Vec::new(); if client == AgentClient::ClaudeDesktop { @@ -4678,6 +4750,978 @@ fn build_claude_desktop_meta(existing: Option<&str>) -> Result { render_agent_json(root, "Claude Desktop 配置索引") } +fn update_agent_json_file(path: &Path, label: &str, update: F) -> Result +where + F: FnOnce(&mut serde_json::Map) -> bool, +{ + if !path.is_file() { + return Ok(false); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; + let mut root = parse_agent_json_object(Some(&content), label)?; + if !update(&mut root) { + return Ok(false); + } + if root.is_empty() { + fs::remove_file(path) + .map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?; + } else { + let rendered = render_agent_json(root, label)?; + write_bytes_directly(path, rendered.as_bytes())?; + } + Ok(true) +} + +fn remove_claude_desktop_managed_configuration(paths: &[PathBuf]) -> Result, String> { + if paths.len() != 4 { + return Err("Claude Desktop 当前平台配置路径不可用".to_string()); + } + let mut changed = Vec::new(); + for (path, label) in [ + (&paths[0], "Claude Desktop 主配置"), + (&paths[1], "Claude Desktop 3P 配置"), + ] { + if update_agent_json_file(path, label, |root| { + if root + .get("deploymentMode") + .and_then(serde_json::Value::as_str) + == Some("3p") + { + root.remove("deploymentMode"); + true + } else { + false + } + })? { + changed.push(path_to_string(path)); + } + } + + if update_agent_json_file(&paths[2], "Claude Desktop 网关配置", |root| { + let mut updated = false; + for key in [ + "coworkEgressAllowedHosts", + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + "inferenceModels", + ] { + updated |= root.remove(key).is_some(); + } + updated + })? { + changed.push(path_to_string(&paths[2])); + } + + if update_agent_json_file(&paths[3], "Claude Desktop 配置索引", |root| { + let mut updated = false; + if root.get("appliedId").and_then(serde_json::Value::as_str) + == Some(CLAUDE_DESKTOP_PROFILE_ID) + { + root.remove("appliedId"); + updated = true; + } + let entries_empty = if let Some(entries) = root + .get_mut("entries") + .and_then(serde_json::Value::as_array_mut) + { + let previous_len = entries.len(); + entries.retain(|entry| { + entry.get("id").and_then(serde_json::Value::as_str) + != Some(CLAUDE_DESKTOP_PROFILE_ID) + }); + updated |= entries.len() != previous_len; + entries.is_empty() + } else { + false + }; + if entries_empty { + root.remove("entries"); + } + updated + })? { + changed.push(path_to_string(&paths[3])); + } + Ok(changed) +} + +fn remove_claude_code_managed_configuration(paths: &[PathBuf]) -> Result, String> { + let Some(path) = paths.first() else { + return Err("Claude Code 当前平台配置路径不可用".to_string()); + }; + let updated = update_agent_json_file(path, "Claude Code 配置", |root| { + let managed_model = root + .get("env") + .and_then(serde_json::Value::as_object) + .and_then(|env| env.get("ANTHROPIC_MODEL")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let managed = root + .get("env") + .and_then(serde_json::Value::as_object) + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(serde_json::Value::as_str) + .is_some_and(is_managed_agent_base_url); + if !managed { + return false; + } + if root.get("model").and_then(serde_json::Value::as_str) == managed_model.as_deref() { + root.remove("model"); + } + let env_empty = if let Some(env) = root + .get_mut("env") + .and_then(serde_json::Value::as_object_mut) + { + for key in [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + ] { + env.remove(key); + } + env.is_empty() + } else { + false + }; + if env_empty { + root.remove("env"); + } + true + })?; + Ok(updated.then(|| path_to_string(path)).into_iter().collect()) +} + +fn remove_codex_managed_configuration(paths: &[PathBuf]) -> Result, String> { + use toml_edit::{Document, Item}; + + let Some(path) = paths.first() else { + return Err("Codex 当前平台配置路径不可用".to_string()); + }; + if !path.is_file() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 Codex 配置失败 {}: {error}", path_to_string(path)))?; + let mut document = content + .parse::() + .map_err(|error| format!("解析 Codex 配置失败: {error}"))?; + let managed_selected = + document.get("model_provider").and_then(Item::as_str) == Some(MANAGED_AGENT_PROVIDER_ID); + let managed_catalog = + document.get("model_catalog_json").and_then(Item::as_str) == Some(CODEX_MODEL_CATALOG_FILE); + let managed_provider = document + .get("model_providers") + .and_then(Item::as_table) + .is_some_and(|providers| providers.contains_key(MANAGED_AGENT_PROVIDER_ID)); + if !managed_selected && !managed_catalog && !managed_provider { + return Ok(Vec::new()); + } + if managed_selected { + document.remove("model_provider"); + document.remove("model"); + } + if managed_catalog { + document.remove("model_catalog_json"); + } + if let Some(providers) = document + .get_mut("model_providers") + .and_then(Item::as_table_mut) + { + providers.remove(MANAGED_AGENT_PROVIDER_ID); + } + if document + .get("model_providers") + .and_then(Item::as_table) + .is_some_and(toml_edit::Table::is_empty) + { + document.remove("model_providers"); + } + let rendered = document.to_string(); + toml::from_str::(&rendered) + .map_err(|error| format!("验证恢复后的 Codex 配置失败: {error}"))?; + if rendered.trim().is_empty() { + fs::remove_file(path).map_err(|error| { + format!("删除空的 Codex 配置失败 {}: {error}", path_to_string(path)) + })?; + } else { + write_bytes_directly(path, rendered.as_bytes())?; + } + let mut changed = vec![path_to_string(path)]; + if managed_catalog { + let catalog_path = path.with_file_name(CODEX_MODEL_CATALOG_FILE); + if catalog_path.is_file() { + fs::remove_file(&catalog_path).map_err(|error| { + format!( + "删除 Codex CPA 模型目录失败 {}: {error}", + path_to_string(&catalog_path) + ) + })?; + changed.push(path_to_string(&catalog_path)); + } + } + Ok(changed) +} + +fn remove_opencode_managed_configuration(paths: &[PathBuf]) -> Result, String> { + let Some(path) = paths.first() else { + return Err("OpenCode 当前平台配置路径不可用".to_string()); + }; + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let updated = update_agent_json_file(path, "OpenCode 配置", |root| { + let mut changed = false; + if root + .get("model") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + root.remove("model"); + changed = true; + } + let providers_empty = if let Some(providers) = root + .get_mut("provider") + .and_then(serde_json::Value::as_object_mut) + { + changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); + providers.is_empty() + } else { + false + }; + if providers_empty { + root.remove("provider"); + } + changed + })?; + Ok(updated.then(|| path_to_string(path)).into_iter().collect()) +} + +fn update_agent_json5_file(path: &Path, label: &str, update: F) -> Result +where + F: FnOnce(&mut serde_json::Map) -> bool, +{ + if !path.is_file() { + return Ok(false); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 {label} 失败 {}: {error}", path_to_string(path)))?; + let value = json5::from_str::(&content) + .map_err(|error| format!("解析 {label} 失败: {error}"))?; + let mut root = value + .as_object() + .cloned() + .ok_or_else(|| format!("{label} 根节点必须是对象"))?; + if !update(&mut root) { + return Ok(false); + } + if root.is_empty() { + fs::remove_file(path) + .map_err(|error| format!("删除空的 {label} 失败 {}: {error}", path_to_string(path)))?; + } else { + let rendered = render_agent_json(root, label)?; + let comments = extract_json5_comments(&content); + let rendered = if comments.is_empty() { + rendered + } else { + format!("{}\n{rendered}", comments.join("\n")) + }; + write_bytes_directly(path, rendered.as_bytes())?; + } + Ok(true) +} + +fn remove_openclaw_managed_configuration(paths: &[PathBuf]) -> Result, String> { + let Some(path) = paths.first() else { + return Err("OpenClaw 当前平台配置路径不可用".to_string()); + }; + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let updated = update_agent_json5_file(path, "OpenClaw 配置", |root| { + let mut changed = false; + if let Some(models) = root + .get_mut("models") + .and_then(serde_json::Value::as_object_mut) + { + let providers_empty = if let Some(providers) = models + .get_mut("providers") + .and_then(serde_json::Value::as_object_mut) + { + changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); + providers.is_empty() + } else { + false + }; + if providers_empty { + models.remove("providers"); + } + } + if let Some(defaults) = root + .get_mut("agents") + .and_then(serde_json::Value::as_object_mut) + .and_then(|agents| agents.get_mut("defaults")) + .and_then(serde_json::Value::as_object_mut) + { + let model_empty = if let Some(model) = defaults + .get_mut("model") + .and_then(serde_json::Value::as_object_mut) + { + if model + .get("primary") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + model.remove("primary"); + changed = true; + } + model.is_empty() + } else { + false + }; + if model_empty { + defaults.remove("model"); + } + let catalog_empty = if let Some(catalog) = defaults + .get_mut("models") + .and_then(serde_json::Value::as_object_mut) + { + let previous_len = catalog.len(); + catalog.retain(|name, _| !name.starts_with(&prefix)); + changed |= catalog.len() != previous_len; + catalog.is_empty() + } else { + false + }; + if catalog_empty { + defaults.remove("models"); + } + } + changed + })?; + Ok(updated.then(|| path_to_string(path)).into_iter().collect()) +} + +fn remove_hermes_managed_configuration(paths: &[PathBuf]) -> Result, String> { + let Some(path) = paths.first() else { + return Err("Hermes 当前平台配置路径不可用".to_string()); + }; + if !path.is_file() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 Hermes 配置失败 {}: {error}", path_to_string(path)))?; + let mut document = yaml_serde_edit::YamlValue::parse(&content) + .map_err(|error| format!("解析 Hermes 配置失败: {error}"))?; + let mut updated = document.get().clone(); + let root = updated + .as_mapping_mut() + .ok_or_else(|| "Hermes 配置根节点必须是映射".to_string())?; + let mut changed = false; + let providers_empty = if let Some(providers) = root + .get_mut(yaml_key("custom_providers")) + .and_then(serde_norway::Value::as_sequence_mut) + { + let previous_len = providers.len(); + providers.retain(|provider| { + provider.get("name").and_then(serde_norway::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + }); + changed |= providers.len() != previous_len; + providers.is_empty() + } else { + false + }; + if providers_empty { + root.remove(yaml_key("custom_providers")); + } + let model_empty = if let Some(model) = root + .get_mut(yaml_key("model")) + .and_then(serde_norway::Value::as_mapping_mut) + { + if model + .get(yaml_key("provider")) + .and_then(serde_norway::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + { + model.remove(yaml_key("provider")); + model.remove(yaml_key("default")); + changed = true; + } + model.is_empty() + } else { + false + }; + if model_empty { + root.remove(yaml_key("model")); + } + if !changed { + return Ok(Vec::new()); + } + if root.is_empty() { + fs::remove_file(path).map_err(|error| { + format!("删除空的 Hermes 配置失败 {}: {error}", path_to_string(path)) + })?; + } else { + let rendered = render_updated_core_yaml(&mut document, updated)?; + write_bytes_directly(path, rendered.as_bytes())?; + } + Ok(vec![path_to_string(path)]) +} + +fn remove_agent_managed_configuration( + client: AgentClient, + paths: &[PathBuf], +) -> Result, String> { + match client { + AgentClient::ClaudeCode => remove_claude_code_managed_configuration(paths), + AgentClient::ClaudeDesktop => remove_claude_desktop_managed_configuration(paths), + AgentClient::Codex => remove_codex_managed_configuration(paths), + AgentClient::OpenCode => remove_opencode_managed_configuration(paths), + AgentClient::OpenClaw => remove_openclaw_managed_configuration(paths), + AgentClient::Hermes => remove_hermes_managed_configuration(paths), + } +} + +fn restore_json_key( + current: &mut serde_json::Map, + original: Option<&serde_json::Map>, + key: &str, +) { + if let Some(value) = original.and_then(|root| root.get(key)).cloned() { + current.insert(key.to_string(), value); + } else { + current.remove(key); + } +} + +fn parse_restored_json_object( + content: Option<&str>, + label: &str, +) -> Result>, String> { + content + .map(|content| parse_agent_json_object(Some(content), label)) + .transpose() +} + +fn render_restored_json( + root: serde_json::Map, + original_existed: bool, + label: &str, +) -> Result, String> { + if root.is_empty() && !original_existed { + Ok(None) + } else { + render_agent_json(root, label).map(Some) + } +} + +fn build_restored_claude_code_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut root = parse_agent_json_object(Some(current), "当前 Claude Code 配置")?; + let original_root = parse_restored_json_object(original, "原始 Claude Code 配置")?; + restore_json_key(&mut root, original_root.as_ref(), "model"); + let original_env = original_root + .as_ref() + .and_then(|root| root.get("env")) + .and_then(serde_json::Value::as_object); + if root.get("env").is_some_and(serde_json::Value::is_object) { + let env = root + .get_mut("env") + .and_then(serde_json::Value::as_object_mut) + .expect("Claude Code env was checked as an object"); + for key in [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + ] { + restore_json_key(env, original_env, key); + } + if env.is_empty() && original_env.is_none() { + root.remove("env"); + } + } else { + restore_json_key(&mut root, original_root.as_ref(), "env"); + } + render_restored_json(root, original.is_some(), "恢复后的 Claude Code 配置") +} + +fn build_restored_claude_desktop_config( + index: usize, + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut root = parse_agent_json_object(Some(current), "当前 Claude Desktop 配置")?; + let original_root = parse_restored_json_object(original, "原始 Claude Desktop 配置")?; + match index { + 0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"), + 2 => { + for key in [ + "coworkEgressAllowedHosts", + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + "inferenceModels", + ] { + restore_json_key(&mut root, original_root.as_ref(), key); + } + } + 3 => { + restore_json_key(&mut root, original_root.as_ref(), "appliedId"); + let mut entries = root + .remove("entries") + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + entries.retain(|entry| { + entry.get("id").and_then(serde_json::Value::as_str) + != Some(CLAUDE_DESKTOP_PROFILE_ID) + }); + let original_managed_entries = original_root + .as_ref() + .and_then(|root| root.get("entries")) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter(|entry| { + entry.get("id").and_then(serde_json::Value::as_str) + == Some(CLAUDE_DESKTOP_PROFILE_ID) + }) + .cloned(); + entries.extend(original_managed_entries); + if !entries.is_empty() + || original_root + .as_ref() + .and_then(|root| root.get("entries")) + .is_some() + { + root.insert("entries".to_string(), serde_json::Value::Array(entries)); + } + } + _ => return Err("Claude Desktop 配置文件索引无效".to_string()), + } + render_restored_json(root, original.is_some(), "恢复后的 Claude Desktop 配置") +} + +fn build_restored_opencode_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut root = parse_agent_json_object(Some(current), "当前 OpenCode 配置")?; + let original_root = parse_restored_json_object(original, "原始 OpenCode 配置")?; + for key in ["$schema", "model"] { + restore_json_key(&mut root, original_root.as_ref(), key); + } + let original_provider = original_root + .as_ref() + .and_then(|root| root.get("provider")) + .and_then(serde_json::Value::as_object); + let original_managed = original_provider + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .and_then(serde_json::Value::as_object); + if root + .get("provider") + .is_some_and(serde_json::Value::is_object) + { + let providers = root + .get_mut("provider") + .and_then(serde_json::Value::as_object_mut) + .expect("OpenCode provider was checked as an object"); + if providers + .get(MANAGED_AGENT_PROVIDER_ID) + .is_some_and(serde_json::Value::is_object) + { + let managed = providers + .get_mut(MANAGED_AGENT_PROVIDER_ID) + .and_then(serde_json::Value::as_object_mut) + .expect("OpenCode managed provider was checked as an object"); + for key in ["npm", "name", "models"] { + restore_json_key(managed, original_managed, key); + } + let original_options = original_managed + .and_then(|managed| managed.get("options")) + .and_then(serde_json::Value::as_object); + if managed + .get("options") + .is_some_and(serde_json::Value::is_object) + { + let options = managed + .get_mut("options") + .and_then(serde_json::Value::as_object_mut) + .expect("OpenCode options were checked as an object"); + for key in ["baseURL", "apiKey"] { + restore_json_key(options, original_options, key); + } + if options.is_empty() && original_options.is_none() { + managed.remove("options"); + } + } else { + restore_json_key(managed, original_managed, "options"); + } + if managed.is_empty() && original_managed.is_none() { + providers.remove(MANAGED_AGENT_PROVIDER_ID); + } + } else if let Some(original_managed) = original_provider + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .cloned() + { + providers.insert(MANAGED_AGENT_PROVIDER_ID.to_string(), original_managed); + } + if providers.is_empty() && original_provider.is_none() { + root.remove("provider"); + } + } else { + restore_json_key(&mut root, original_root.as_ref(), "provider"); + } + render_restored_json(root, original.is_some(), "恢复后的 OpenCode 配置") +} + +fn build_restored_openclaw_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + let current_value = json5::from_str::(current) + .map_err(|error| format!("当前 OpenClaw 配置格式无效: {error}"))?; + let mut root = current_value + .as_object() + .cloned() + .ok_or_else(|| "当前 OpenClaw 配置根节点必须是对象".to_string())?; + let original_root = original + .map(|content| { + json5::from_str::(content) + .map_err(|error| format!("原始 OpenClaw 配置格式无效: {error}"))? + .as_object() + .cloned() + .ok_or_else(|| "原始 OpenClaw 配置根节点必须是对象".to_string()) + }) + .transpose()?; + let original_models = original_root + .as_ref() + .and_then(|root| root.get("models")) + .and_then(serde_json::Value::as_object); + if root.get("models").is_some_and(serde_json::Value::is_object) { + let models = root + .get_mut("models") + .and_then(serde_json::Value::as_object_mut) + .expect("OpenClaw models were checked as an object"); + restore_json_key(models, original_models, "mode"); + let original_providers = original_models + .and_then(|models| models.get("providers")) + .and_then(serde_json::Value::as_object); + if models + .get("providers") + .is_some_and(serde_json::Value::is_object) + { + let providers = models + .get_mut("providers") + .and_then(serde_json::Value::as_object_mut) + .expect("OpenClaw providers were checked as an object"); + let original_managed = original_providers + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .and_then(serde_json::Value::as_object); + if providers + .get(MANAGED_AGENT_PROVIDER_ID) + .is_some_and(serde_json::Value::is_object) + { + let managed = providers + .get_mut(MANAGED_AGENT_PROVIDER_ID) + .and_then(serde_json::Value::as_object_mut) + .expect("OpenClaw managed provider was checked as an object"); + for key in ["baseUrl", "apiKey", "api", "models"] { + restore_json_key(managed, original_managed, key); + } + if managed.is_empty() && original_managed.is_none() { + providers.remove(MANAGED_AGENT_PROVIDER_ID); + } + } else if let Some(original_managed) = original_providers + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .cloned() + { + providers.insert(MANAGED_AGENT_PROVIDER_ID.to_string(), original_managed); + } + if providers.is_empty() && original_providers.is_none() { + models.remove("providers"); + } + } else { + restore_json_key(models, original_models, "providers"); + } + if models.is_empty() && original_models.is_none() { + root.remove("models"); + } + } else { + restore_json_key(&mut root, original_root.as_ref(), "models"); + } + + let original_defaults = original_root + .as_ref() + .and_then(|root| root.get("agents")) + .and_then(|agents| agents.get("defaults")) + .and_then(serde_json::Value::as_object); + if let Some(defaults) = root + .get_mut("agents") + .and_then(serde_json::Value::as_object_mut) + .and_then(|agents| agents.get_mut("defaults")) + .and_then(serde_json::Value::as_object_mut) + { + let original_model = original_defaults + .and_then(|defaults| defaults.get("model")) + .and_then(serde_json::Value::as_object); + if defaults + .get("model") + .is_some_and(serde_json::Value::is_object) + { + let model = defaults + .get_mut("model") + .and_then(serde_json::Value::as_object_mut) + .expect("OpenClaw default model was checked as an object"); + restore_json_key(model, original_model, "primary"); + if model.is_empty() && original_model.is_none() { + defaults.remove("model"); + } + } else { + restore_json_key(defaults, original_defaults, "model"); + } + + let original_catalog = original_defaults + .and_then(|defaults| defaults.get("models")) + .and_then(serde_json::Value::as_object); + if defaults + .get("models") + .is_some_and(serde_json::Value::is_object) + { + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let catalog = defaults + .get_mut("models") + .and_then(serde_json::Value::as_object_mut) + .expect("OpenClaw model catalog was checked as an object"); + catalog.retain(|name, _| !name.starts_with(&prefix)); + if let Some(original_catalog) = original_catalog { + catalog.extend( + original_catalog + .iter() + .filter(|(name, _)| name.starts_with(&prefix)) + .map(|(name, value)| (name.clone(), value.clone())), + ); + } + if catalog.is_empty() && original_catalog.is_none() { + defaults.remove("models"); + } + } else { + restore_json_key(defaults, original_defaults, "models"); + } + } + let original_agents = original_root + .as_ref() + .and_then(|root| root.get("agents")) + .and_then(serde_json::Value::as_object); + if let Some(agents) = root + .get_mut("agents") + .and_then(serde_json::Value::as_object_mut) + { + let remove_defaults = agents + .get("defaults") + .and_then(serde_json::Value::as_object) + .is_some_and(serde_json::Map::is_empty) + && original_defaults.is_none(); + if remove_defaults { + agents.remove("defaults"); + } + if agents.is_empty() && original_agents.is_none() { + root.remove("agents"); + } + } + if root.is_empty() && original.is_none() { + return Ok(None); + } + let rendered = render_agent_json(root, "恢复后的 OpenClaw 配置")?; + let comments = extract_json5_comments(current); + Ok(Some(if comments.is_empty() { + rendered + } else { + format!("{}\n{rendered}", comments.join("\n")) + })) +} + +fn restore_yaml_key( + current: &mut serde_norway::Mapping, + original: Option<&serde_norway::Mapping>, + key: &str, +) { + if let Some(value) = original.and_then(|root| root.get(yaml_key(key))).cloned() { + current.insert(yaml_key(key), value); + } else { + current.remove(yaml_key(key)); + } +} + +fn build_restored_hermes_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut document = yaml_serde_edit::YamlValue::parse(current) + .map_err(|error| format!("当前 Hermes 配置格式无效: {error}"))?; + let mut updated = document.get().clone(); + let root = updated + .as_mapping_mut() + .ok_or_else(|| "当前 Hermes 配置根节点必须是映射".to_string())?; + let original_value = original + .map(|content| { + serde_norway::from_str::(content) + .map_err(|error| format!("原始 Hermes 配置格式无效: {error}")) + }) + .transpose()?; + let original_root = original_value + .as_ref() + .and_then(serde_norway::Value::as_mapping); + let original_managed = original_root + .and_then(|root| root.get(yaml_key("custom_providers"))) + .and_then(serde_norway::Value::as_sequence) + .and_then(|providers| { + providers.iter().find(|provider| { + provider.get("name").and_then(serde_norway::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + }); + if let Some(providers) = root + .get_mut(yaml_key("custom_providers")) + .and_then(serde_norway::Value::as_sequence_mut) + { + let current_managed = providers + .iter() + .find(|provider| { + provider.get("name").and_then(serde_norway::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + .and_then(serde_norway::Value::as_mapping) + .cloned(); + providers.retain(|provider| { + provider.get("name").and_then(serde_norway::Value::as_str) + != Some(MANAGED_AGENT_PROVIDER_ID) + }); + let mut managed = current_managed.unwrap_or_default(); + let original_managed = original_managed.and_then(serde_norway::Value::as_mapping); + for key in ["name", "base_url", "api_key", "api_mode", "model", "models"] { + restore_yaml_key(&mut managed, original_managed, key); + } + if original_managed.is_some() { + providers.push(serde_norway::Value::Mapping(managed)); + } else if !managed.is_empty() { + managed.insert( + yaml_key("name"), + serde_norway::Value::String(MANAGED_AGENT_PROVIDER_ID.to_string()), + ); + providers.push(serde_norway::Value::Mapping(managed)); + } + if providers.is_empty() + && original_root + .and_then(|root| root.get(yaml_key("custom_providers"))) + .is_none() + { + root.remove(yaml_key("custom_providers")); + } + } else { + restore_yaml_key(root, original_root, "custom_providers"); + } + let original_model = original_root + .and_then(|root| root.get(yaml_key("model"))) + .and_then(serde_norway::Value::as_mapping); + if let Some(model) = root + .get_mut(yaml_key("model")) + .and_then(serde_norway::Value::as_mapping_mut) + { + for key in ["default", "provider"] { + restore_yaml_key(model, original_model, key); + } + if model.is_empty() && original_model.is_none() { + root.remove(yaml_key("model")); + } + } else { + restore_yaml_key(root, original_root, "model"); + } + if root.is_empty() && original.is_none() { + return Ok(None); + } + render_updated_core_yaml(&mut document, updated).map(Some) +} + +fn agent_config_semantically_equal(client: AgentClient, actual: &str, expected: &str) -> bool { + match client { + AgentClient::Codex => { + toml::from_str::(actual).ok() + == toml::from_str::(expected).ok() + } + AgentClient::OpenClaw => { + json5::from_str::(actual).ok() + == json5::from_str::(expected).ok() + } + AgentClient::Hermes => { + serde_norway::from_str::(actual).ok() + == serde_norway::from_str::(expected).ok() + } + _ => { + serde_json::from_str::(actual).ok() + == serde_json::from_str::(expected).ok() + } + } +} + +fn build_agent_session_restored_bytes( + client: AgentClient, + paths: &[PathBuf], + path: &Path, + current: Option<&[u8]>, + original: Option<&[u8]>, +) -> Result>, String> { + let Some(current) = current else { + return Ok(original.map(ToOwned::to_owned)); + }; + if client == AgentClient::Codex && paths.first().is_none_or(|config| config != path) { + return Ok(original.map(ToOwned::to_owned)); + } + let current = std::str::from_utf8(current) + .map_err(|_| format!("当前智能体配置不是 UTF-8 文本: {}", path_to_string(path)))?; + let original_bytes = original; + let original = original_bytes + .map(|bytes| { + std::str::from_utf8(bytes) + .map_err(|_| format!("原智能体配置不是 UTF-8 文本: {}", path_to_string(path))) + }) + .transpose()?; + let restored = match client { + AgentClient::ClaudeCode => build_restored_claude_code_config(current, original)?, + AgentClient::ClaudeDesktop => { + let index = paths + .iter() + .position(|candidate| candidate == path) + .ok_or_else(|| "Claude Desktop 恢复路径不匹配".to_string())?; + build_restored_claude_desktop_config(index, current, original)? + } + AgentClient::Codex => build_restored_codex_agent_config(Some(current), original)?, + AgentClient::OpenCode => build_restored_opencode_config(current, original)?, + AgentClient::OpenClaw => build_restored_openclaw_config(current, original)?, + AgentClient::Hermes => build_restored_hermes_config(current, original)?, + }; + if let (Some(restored), Some(original), Some(original_bytes)) = + (restored.as_deref(), original, original_bytes) + { + if agent_config_semantically_equal(client, restored, original) { + return Ok(Some(original_bytes.to_vec())); + } + } + Ok(restored.map(String::into_bytes)) +} + #[cfg(test)] fn build_codex_agent_config( existing: Option<&str>, @@ -4750,9 +5794,7 @@ fn build_codex_agent_config_with_oauth( Ok(rendered) } -#[cfg(test)] const CODEX_MANAGED_ROOT_KEYS: [&str; 3] = ["model_provider", "model", "model_catalog_json"]; -#[cfg(test)] const CODEX_MANAGED_PROVIDER_KEYS: [&str; 5] = [ "name", "base_url", @@ -4771,7 +5813,6 @@ fn set_codex_table_item(table: &mut toml_edit::Table, key: &str, mut item: toml_ *table.entry(key).or_insert(toml_edit::Item::None) = item; } -#[cfg(test)] fn restore_codex_table_item( current: &mut toml_edit::Table, original: Option<&toml_edit::Table>, @@ -4794,7 +5835,6 @@ fn restore_codex_table_item( } } -#[cfg(test)] fn parse_codex_document(content: Option<&str>, label: &str) -> Result { use toml_edit::Document; @@ -4806,7 +5846,6 @@ fn parse_codex_document(content: Option<&str>, label: &str) -> Result Option<&toml_edit::Table> { document .as_table() @@ -4816,7 +5855,6 @@ fn codex_provider_table(document: &toml_edit::Document) -> Option<&toml_edit::Ta .and_then(toml_edit::Item::as_table) } -#[cfg(test)] fn ensure_codex_provider_table( document: &mut toml_edit::Document, ) -> Result<&mut toml_edit::Table, String> { @@ -4839,7 +5877,21 @@ fn ensure_codex_provider_table( .ok_or_else(|| "Codex cpa-gui provider 必须是 TOML 表".to_string()) } -#[cfg(test)] +fn merge_missing_codex_table_items(current: &mut toml_edit::Table, original: &toml_edit::Table) { + for (key, original_item) in original.iter() { + if !current.contains_key(key) { + current.insert(key, original_item.clone()); + continue; + } + if let (Some(current_table), Some(original_table)) = ( + current.get_mut(key).and_then(toml_edit::Item::as_table_mut), + original_item.as_table(), + ) { + merge_missing_codex_table_items(current_table, original_table); + } + } +} + fn build_restored_codex_agent_config( current: Option<&str>, original: Option<&str>, @@ -4850,6 +5902,12 @@ fn build_restored_codex_agent_config( let original_document = original .map(|content| parse_codex_document(Some(content), "原始 Codex config.toml")) .transpose()?; + if let Some(original_document) = original_document.as_ref() { + merge_missing_codex_table_items( + current_document.as_table_mut(), + original_document.as_table(), + ); + } for key in CODEX_MANAGED_ROOT_KEYS { restore_codex_table_item( @@ -5368,6 +6426,48 @@ fn recover_codex_applied_state_from_backups( Ok(Some(state)) } +fn validate_agent_applied_state( + client: AgentClient, + paths: &[PathBuf], + state: &AgentAppliedState, +) -> Result<(), String> { + if state.client != client.id() { + return Err("智能体应用状态与客户端不匹配".to_string()); + } + if state.backup_files.is_empty() { + return Ok(()); + } + let expected_paths = expected_agent_record_paths(client, paths); + let state_paths = state + .backup_files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + let valid_paths = state_paths == expected_paths + || (client == AgentClient::Codex && state_paths.as_slice() == paths); + if !valid_paths { + return Err("智能体应用状态文件数量或路径不匹配".to_string()); + } + for file in &state.backup_files { + let legacy_backup = agent_backup_path(&file.path)?; + let original_name = file + .path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(&file.path)))?; + let dated_backup = file.backup_path.parent() == file.path.parent() + && file + .backup_path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| is_dated_agent_backup_name(name, original_name)); + if file.backup_path != legacy_backup && !dated_backup { + return Err("智能体应用状态包含非预期备份路径".to_string()); + } + } + Ok(()) +} + fn load_agent_applied_state( client: AgentClient, home: &Path, @@ -5402,13 +6502,12 @@ fn load_agent_applied_state( let version = value .get("version") .and_then(serde_json::Value::as_u64) - .ok_or_else(|| "智能体应用状态缺少版本号".to_string())? as u8; + .ok_or_else(|| "智能体应用状态缺少版本号".to_string())?; + let version = u8::try_from(version).map_err(|_| "不支持的智能体应用状态版本".to_string())?; if version == AGENT_APPLIED_STATE_VERSION || version == 3 { let state = serde_json::from_value::(value) .map_err(|error| format!("解析智能体应用状态失败: {error}"))?; - if state.client != client.id() { - return Err("智能体应用状态与客户端不匹配".to_string()); - } + validate_agent_applied_state(client, &paths, &state)?; if client == AgentClient::Codex { write_agent_applied_state(&state_path, &state)?; } @@ -5423,23 +6522,27 @@ fn load_agent_applied_state( let record = serde_json::from_value::(value) .map_err(|error| format!("解析旧版智能体状态失败: {error}"))?; validate_agent_record(client, &paths, &record)?; + let backup_files = record + .files + .iter() + .map(|file| AgentAppliedBackupFile { + path: file.path.clone(), + backup_path: file.backup_path.clone(), + existed_before: file.existed_before, + }) + .collect(); let state = AgentAppliedState { version: AGENT_APPLIED_STATE_VERSION, client: client.id().to_string(), model: record.model, claude_desktop_model_mappings: None, - backup_files: Vec::new(), + backup_files, updated_at_unix: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(), }; write_agent_applied_state(&state_path, &state)?; - for backup_path in record.files.into_iter().map(|file| file.backup_path) { - if backup_path.is_file() { - let _ = fs::remove_file(backup_path); - } - } Ok(Some(state)) } @@ -6338,47 +7441,102 @@ fn prepare_agent_session_backups( } fn restore_agent_session_configuration(client: AgentClient, home: &Path) -> Result<(), String> { + let paths = agent_config_paths(client, home); + let state_path = agent_state_path(&paths)?; let Some(state) = load_agent_applied_state(client, home)? else { return Ok(()); }; - if state.backup_files.is_empty() { - return Ok(()); - } + restore_agent_applied_state_configuration(client, &paths, &state_path, &state) +} - for file in state.backup_files.iter().rev() { - if file.existed_before { - let original = fs::read(&file.backup_path).map_err(|error| { - format!( - "读取智能体备份失败 {}: {error}", - path_to_string(&file.backup_path) +fn restore_agent_applied_state_configuration( + client: AgentClient, + paths: &[PathBuf], + state_path: &Path, + state: &AgentAppliedState, +) -> Result<(), String> { + validate_agent_applied_state(client, paths, state)?; + if state.backup_files.is_empty() { + if agent_has_managed_marker(client, paths)? { + remove_agent_managed_configuration(client, paths)?; + } + } else { + let originals = state + .backup_files + .iter() + .map(|file| { + if file.existed_before { + fs::read(&file.backup_path).map(Some).map_err(|error| { + format!( + "读取智能体备份失败 {}: {error}", + path_to_string(&file.backup_path) + ) + }) + } else { + Ok(None) + } + }) + .collect::, String>>()?; + let snapshots = state + .backup_files + .iter() + .map(|file| Ok((file.path.clone(), read_agent_bytes(&file.path)?))) + .collect::, String>>()?; + let replacements = state + .backup_files + .iter() + .zip(&originals) + .zip(&snapshots) + .map(|((file, original), (_, current))| { + build_agent_session_restored_bytes( + client, + paths, + &file.path, + current.as_deref(), + original.as_deref(), ) - })?; - write_bytes_directly(&file.path, &original)?; - fs::remove_file(&file.backup_path).map_err(|error| { - format!( - "清理智能体备份失败 {}: {error}", - path_to_string(&file.backup_path) - ) - })?; - } else if file.path.exists() { - fs::remove_file(&file.path).map_err(|error| { - format!( - "删除临时智能体配置失败 {}: {error}", - path_to_string(&file.path) - ) - })?; + }) + .collect::, String>>()?; + let restore_result = (|| -> Result<(), String> { + for (file, replacement) in state.backup_files.iter().zip(&replacements).rev() { + if let Some(replacement) = replacement { + write_bytes_directly(&file.path, replacement)?; + } else if file.path.exists() { + fs::remove_file(&file.path).map_err(|error| { + format!( + "删除临时智能体配置失败 {}: {error}", + path_to_string(&file.path) + ) + })?; + } + } + Ok(()) + })(); + if let Err(error) = restore_result { + return match restore_agent_snapshots_direct(&snapshots) { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!("{error};回滚恢复操作失败: {rollback_error}")), + }; + } + for file in &state.backup_files { + if file.existed_before && file.backup_path.is_file() { + fs::remove_file(&file.backup_path).map_err(|error| { + format!( + "清理智能体备份失败 {}: {error}", + path_to_string(&file.backup_path) + ) + })?; + } } } - - let state_path = agent_state_path(&agent_config_paths(client, home))?; if client == AgentClient::Codex { - clear_codex_applied_state(&state_path)?; + clear_codex_applied_state(state_path)?; } if state_path.is_file() { - fs::remove_file(&state_path).map_err(|error| { + fs::remove_file(state_path).map_err(|error| { format!( "清理智能体应用状态失败 {}: {error}", - path_to_string(&state_path) + path_to_string(state_path) ) })?; } @@ -15580,6 +16738,9 @@ mod tests { assert!(backup.is_file()); assert_eq!(fs::read(&backup).unwrap(), original); assert!(fs::read_to_string(&path).unwrap().contains("managed")); + let inspection = inspect_agent_application(AgentClient::OpenCode, &home); + assert_eq!(inspection.state, "applied"); + assert!(inspection.backup_available); restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); assert_eq!(fs::read(&path).unwrap(), original); @@ -15590,6 +16751,788 @@ mod tests { fs::remove_dir_all(home).unwrap(); } + #[test] + fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() { + let home = agent_test_home("opencode-runtime-edit-merge"); + let path = home.join(".config/opencode/opencode.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"{"provider":{"other":{"keep":"original"}},"keep":"root"}"#, + ) + .unwrap(); + let models = test_agent_models(&["gpt-one", "gpt-two"]); + apply_agent_configuration( + AgentClient::OpenCode, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-one", + &models, + None, + ) + .unwrap(); + + let mut runtime: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + runtime["agentAdded"] = serde_json::json!({"enabled": true}); + runtime["provider"]["other"]["runtimeAdded"] = serde_json::json!(42); + runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"] = + serde_json::json!("keep-me"); + runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"] = + serde_json::json!("https://agent-overwrite.invalid/v1"); + runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["apiKey"] = + serde_json::json!("agent-overwrite"); + runtime["model"] = serde_json::json!("cpa-gui/agent-overwrite"); + fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap(); + + restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + let closed: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(closed["keep"], "root"); + assert_eq!(closed["agentAdded"]["enabled"], true); + assert_eq!(closed["provider"]["other"]["keep"], "original"); + assert_eq!(closed["provider"]["other"]["runtimeAdded"], 42); + assert_eq!( + closed["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], + "keep-me" + ); + assert!(closed.get("model").is_none()); + assert!(closed.get("$schema").is_none()); + assert!(closed["provider"][MANAGED_AGENT_PROVIDER_ID] + .get("options") + .is_none()); + + apply_agent_configuration( + AgentClient::OpenCode, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-two", + &models, + None, + ) + .unwrap(); + let reapplied: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(reapplied["model"], "cpa-gui/gpt-two"); + assert_eq!( + reapplied["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"], + "http://127.0.0.1:8317/v1" + ); + assert_eq!( + reapplied["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["apiKey"], + DEFAULT_API_KEY + ); + assert_eq!( + reapplied["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], + "keep-me" + ); + assert_eq!(reapplied["agentAdded"]["enabled"], true); + assert_eq!(reapplied["provider"]["other"]["runtimeAdded"], 42); + + restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn session_merge_preserves_runtime_fields_for_other_agent_formats() { + let claude_original = r#"{"env":{"KEEP_ENV":"original"},"keep":"claude"}"#; + let claude_managed = build_claude_agent_config( + Some(claude_original), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + ) + .unwrap(); + let mut claude_current: serde_json::Value = serde_json::from_str(&claude_managed).unwrap(); + claude_current["runtimeAdded"] = serde_json::json!(true); + claude_current["env"]["RUNTIME_ENV"] = serde_json::json!("keep"); + claude_current["env"]["ANTHROPIC_BASE_URL"] = + serde_json::json!("https://agent-overwrite.invalid"); + let claude_restored = build_restored_claude_code_config( + &serde_json::to_string(&claude_current).unwrap(), + Some(claude_original), + ) + .unwrap() + .unwrap(); + let claude: serde_json::Value = serde_json::from_str(&claude_restored).unwrap(); + assert_eq!(claude["runtimeAdded"], true); + assert_eq!(claude["env"]["RUNTIME_ENV"], "keep"); + assert_eq!(claude["env"]["KEEP_ENV"], "original"); + assert!(claude["env"].get("ANTHROPIC_BASE_URL").is_none()); + assert!(claude.get("model").is_none()); + + let desktop_original = r#"{"keep":"desktop-profile"}"#; + let desktop_managed = build_claude_desktop_profile( + Some(desktop_original), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + None, + ) + .unwrap(); + let mut desktop_current: serde_json::Value = + serde_json::from_str(&desktop_managed).unwrap(); + desktop_current["runtimeAdded"] = serde_json::json!({"keep": true}); + desktop_current["inferenceGatewayBaseUrl"] = + serde_json::json!("https://agent-overwrite.invalid"); + let desktop_restored = build_restored_claude_desktop_config( + 2, + &serde_json::to_string(&desktop_current).unwrap(), + Some(desktop_original), + ) + .unwrap() + .unwrap(); + let desktop: serde_json::Value = serde_json::from_str(&desktop_restored).unwrap(); + assert_eq!(desktop["keep"], "desktop-profile"); + assert_eq!(desktop["runtimeAdded"]["keep"], true); + assert!(desktop.get("inferenceGatewayBaseUrl").is_none()); + assert!(desktop.get("inferenceGatewayApiKey").is_none()); + + let models = test_agent_models(&["gpt-test"]); + let openclaw_original = r#"{ + "models": {"providers": {"other": {"keep": true}}}, + "agents": {"defaults": { + "model": {"primary": "other/original", "fallback": "other/fallback"}, + "models": {"other/original": {}} + }}, + "keep": "openclaw" +}"#; + let openclaw_managed = build_openclaw_agent_config( + Some(openclaw_original), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let mut openclaw_current: serde_json::Value = json5::from_str(&openclaw_managed).unwrap(); + openclaw_current["runtimeAdded"] = serde_json::json!(true); + openclaw_current["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"] = + serde_json::json!("keep"); + openclaw_current["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["baseUrl"] = + serde_json::json!("https://agent-overwrite.invalid"); + openclaw_current["agents"]["defaults"]["models"]["other/runtime"] = + serde_json::json!({"keep": true}); + let openclaw_restored = build_restored_openclaw_config( + &serde_json::to_string(&openclaw_current).unwrap(), + Some(openclaw_original), + ) + .unwrap() + .unwrap(); + let openclaw: serde_json::Value = json5::from_str(&openclaw_restored).unwrap(); + assert_eq!(openclaw["runtimeAdded"], true); + assert_eq!( + openclaw["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], + "keep" + ); + assert!(openclaw["models"]["providers"][MANAGED_AGENT_PROVIDER_ID] + .get("baseUrl") + .is_none()); + assert_eq!( + openclaw["agents"]["defaults"]["model"]["primary"], + "other/original" + ); + assert!(openclaw["agents"]["defaults"]["models"] + .get("cpa-gui/gpt-test") + .is_none()); + assert_eq!( + openclaw["agents"]["defaults"]["models"]["other/runtime"]["keep"], + true + ); + let generated_openclaw = build_openclaw_agent_config( + None, + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + assert!(build_restored_openclaw_config(&generated_openclaw, None) + .unwrap() + .is_none()); + + let hermes_original = r#"keep: hermes +custom_providers: + - name: other + keep: true +model: + default: original-model + provider: other + keep: model +"#; + let hermes_managed = build_hermes_agent_config( + Some(hermes_original), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let mut hermes_current: serde_norway::Value = + serde_norway::from_str(&hermes_managed).unwrap(); + let hermes_root = hermes_current.as_mapping_mut().unwrap(); + hermes_root.insert(yaml_key("runtimeAdded"), serde_norway::Value::Bool(true)); + let managed_provider = hermes_root + .get_mut(yaml_key("custom_providers")) + .and_then(serde_norway::Value::as_sequence_mut) + .unwrap() + .iter_mut() + .find(|provider| { + provider.get("name").and_then(serde_norway::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + .and_then(serde_norway::Value::as_mapping_mut) + .unwrap(); + managed_provider.insert( + yaml_key("custom_after_apply"), + serde_norway::Value::String("keep".to_string()), + ); + managed_provider.insert( + yaml_key("base_url"), + serde_norway::Value::String("https://agent-overwrite.invalid".to_string()), + ); + let hermes_restored = build_restored_hermes_config( + &serde_norway::to_string(&hermes_current).unwrap(), + Some(hermes_original), + ) + .unwrap() + .unwrap(); + let hermes: serde_norway::Value = serde_norway::from_str(&hermes_restored).unwrap(); + assert_eq!(hermes["runtimeAdded"], serde_norway::Value::Bool(true)); + assert_eq!(hermes["model"]["default"].as_str(), Some("original-model")); + assert_eq!(hermes["model"]["provider"].as_str(), Some("other")); + let hermes_managed_provider = hermes["custom_providers"] + .as_sequence() + .unwrap() + .iter() + .find(|provider| { + provider.get("name").and_then(serde_norway::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + .unwrap(); + assert_eq!( + hermes_managed_provider["custom_after_apply"].as_str(), + Some("keep") + ); + assert!(hermes_managed_provider.get("base_url").is_none()); + } + + #[test] + fn legacy_agent_state_migration_preserves_backup_for_restore() { + let home = agent_test_home("legacy-state-backup-migration"); + let path = home.join(".config/opencode/opencode.json"); + let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap(); + let backup_path = agent_backup_path(&path).unwrap(); + let original = b"{\"provider\":\"original\"}"; + let managed = b"{\"provider\":\"managed\"}"; + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, managed).unwrap(); + fs::write(&backup_path, original).unwrap(); + write_agent_state( + &state_path, + &AgentModificationRecord { + version: AGENT_MODIFICATION_STATE_VERSION, + client: AgentClient::OpenCode.id().to_string(), + phase: AGENT_PHASE_ACTIVE.to_string(), + model: "gpt-test".to_string(), + files: vec![AgentModificationFile { + path: path.clone(), + backup_path: backup_path.clone(), + existed_before: true, + original_sha256: Some(sha256_bytes(original)), + managed_sha256: sha256_bytes(managed), + }], + }, + ) + .unwrap(); + + let migrated = load_agent_applied_state(AgentClient::OpenCode, &home) + .unwrap() + .unwrap(); + assert_eq!(migrated.version, AGENT_APPLIED_STATE_VERSION); + assert_eq!(migrated.backup_files.len(), 1); + assert_eq!(migrated.backup_files[0].path, path); + assert_eq!(migrated.backup_files[0].backup_path, backup_path); + assert!(migrated.backup_files[0].existed_before); + assert!(backup_path.is_file()); + + restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(!backup_path.exists()); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn legacy_codex_single_file_state_remains_restorable() { + let home = agent_test_home("legacy-codex-single-file-state"); + let path = home.join(".codex/config.toml"); + let paths = vec![path.clone()]; + let state_path = agent_state_path(&paths).unwrap(); + let backup_path = agent_backup_path(&path).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"cpa-gui\"\n").unwrap(); + fs::write(&backup_path, "approval_policy = \"never\"\n").unwrap(); + fs::write(&state_path, "state").unwrap(); + let state = AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: AgentClient::Codex.id().to_string(), + model: "gpt-test".to_string(), + claude_desktop_model_mappings: None, + backup_files: vec![AgentAppliedBackupFile { + path: path.clone(), + backup_path: backup_path.clone(), + existed_before: true, + }], + updated_at_unix: 1, + }; + + restore_agent_applied_state_configuration(AgentClient::Codex, &paths, &state_path, &state) + .unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "approval_policy = \"never\"\n" + ); + assert!(!backup_path.exists()); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn claude_desktop_version_three_state_can_be_closed_safely() { + let home = agent_test_home("claude-desktop-version-three-restore"); + let normal = home.join("Claude"); + let threep = home.join("Claude-3p"); + let paths = claude_desktop_config_paths_from_directories(normal, threep); + let state_path = agent_state_path(&paths).unwrap(); + for path in &paths { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + } + fs::write(&paths[0], r#"{"deploymentMode":"3p","keep":"normal"}"#).unwrap(); + fs::write(&paths[1], r#"{"deploymentMode":"3p","keep":"threep"}"#).unwrap(); + fs::write( + &paths[2], + r#"{ + "coworkEgressAllowedHosts": ["127.0.0.1"], + "disableDeploymentModeChooser": true, + "inferenceGatewayApiKey": "managed-key", + "inferenceGatewayAuthScheme": "bearer", + "inferenceGatewayBaseUrl": "http://127.0.0.1:8317", + "inferenceProvider": "generic-chat-completion-api", + "inferenceModels": ["claude-sonnet-4-5"], + "keep": "profile" +}"#, + ) + .unwrap(); + fs::write( + &paths[3], + format!( + r#"{{ + "appliedId": "{CLAUDE_DESKTOP_PROFILE_ID}", + "entries": [ + {{"id": "{CLAUDE_DESKTOP_PROFILE_ID}", "name": "CPA"}}, + {{"id": "other", "name": "Other"}} + ], + "keep": "meta" +}}"# + ), + ) + .unwrap(); + let state = AgentAppliedState { + version: 3, + client: AgentClient::ClaudeDesktop.id().to_string(), + model: "gpt-test".to_string(), + claude_desktop_model_mappings: None, + backup_files: Vec::new(), + updated_at_unix: 1, + }; + write_agent_applied_state(&state_path, &state).unwrap(); + + restore_agent_applied_state_configuration( + AgentClient::ClaudeDesktop, + &paths, + &state_path, + &state, + ) + .unwrap(); + + assert!(!state_path.exists()); + let normal: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[0]).unwrap()).unwrap(); + let threep: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); + let profile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[2]).unwrap()).unwrap(); + let meta: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[3]).unwrap()).unwrap(); + assert!(normal.get("deploymentMode").is_none()); + assert_eq!(normal["keep"], "normal"); + assert!(threep.get("deploymentMode").is_none()); + assert_eq!(threep["keep"], "threep"); + for key in [ + "coworkEgressAllowedHosts", + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + "inferenceModels", + ] { + assert!(profile.get(key).is_none(), "managed key remains: {key}"); + } + assert_eq!(profile["keep"], "profile"); + assert!(meta.get("appliedId").is_none()); + assert_eq!(meta["keep"], "meta"); + assert_eq!(meta["entries"].as_array().unwrap().len(), 1); + assert_eq!(meta["entries"][0]["id"], "other"); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn opencode_version_three_state_uses_managed_marker_and_closes_safely() { + let home = agent_test_home("opencode-version-three-restore"); + let path = home.join(".config/opencode/opencode.json"); + let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"{ + "$schema": "https://opencode.ai/config.json", + "model": "cpa-gui/gpt-test", + "provider": { + "cpa-gui": { + "options": {"baseURL": "http://127.0.0.1:8317/v1", "apiKey": "secret"} + }, + "other": {"keep": true} + }, + "keep": "root" +}"#, + ) + .unwrap(); + fs::write( + &state_path, + r#"{"version":3,"client":"opencode","model":"gpt-test","updatedAtUnix":1}"#, + ) + .unwrap(); + + let before = inspect_agent_application(AgentClient::OpenCode, &home); + assert_eq!(before.state, "applied"); + assert!(!before.backup_available); + restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + + let restored: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(restored.get("model").is_none()); + assert!(restored["provider"] + .get(MANAGED_AGENT_PROVIDER_ID) + .is_none()); + assert_eq!(restored["provider"]["other"]["keep"], true); + assert_eq!(restored["keep"], "root"); + assert!(!state_path.exists()); + assert_eq!( + inspect_agent_application(AgentClient::OpenCode, &home).state, + "unconfigured" + ); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn stale_version_three_state_without_managed_configuration_is_not_applied() { + let home = agent_test_home("stale-version-three-state"); + let path = home.join(".config/opencode/opencode.json"); + let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = r#"{"provider":{"other":{"keep":true}}}"#; + fs::write(&path, original).unwrap(); + fs::write( + &state_path, + r#"{"version":3,"client":"opencode","model":"gpt-test","updatedAtUnix":1}"#, + ) + .unwrap(); + + assert_eq!( + inspect_agent_application(AgentClient::OpenCode, &home).state, + "unconfigured" + ); + restore_agent_session_configuration(AgentClient::OpenCode, &home).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn applied_state_rejects_backup_paths_outside_the_managed_config_set() { + let home = agent_test_home("invalid-applied-backup-path"); + let path = home.join(".config/opencode/opencode.json"); + let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "{}").unwrap(); + write_agent_applied_state( + &state_path, + &AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: AgentClient::OpenCode.id().to_string(), + model: "gpt-test".to_string(), + claude_desktop_model_mappings: None, + backup_files: vec![AgentAppliedBackupFile { + path: path.clone(), + backup_path: home.join("unexpected-location.bak"), + existed_before: true, + }], + updated_at_unix: 1, + }, + ) + .unwrap(); + + let error = load_agent_applied_state(AgentClient::OpenCode, &home) + .err() + .unwrap(); + assert!(error.contains("非预期备份路径")); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn missing_session_backup_is_detected_before_any_file_is_restored() { + let home = agent_test_home("missing-session-backup-preflight"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = config_path.with_file_name(CODEX_MODEL_CATALOG_FILE); + let paths = vec![config_path.clone()]; + let state_path = agent_state_path(&paths).unwrap(); + let config_backup = dated_agent_backup_path(&config_path).unwrap(); + let catalog_backup = dated_agent_backup_path(&catalog_path).unwrap(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "managed-config").unwrap(); + fs::write(&catalog_path, "managed-catalog").unwrap(); + fs::write(&config_backup, "original-config").unwrap(); + fs::write(&state_path, "state").unwrap(); + let state = AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: AgentClient::Codex.id().to_string(), + model: "gpt-test".to_string(), + claude_desktop_model_mappings: None, + backup_files: vec![ + AgentAppliedBackupFile { + path: config_path.clone(), + backup_path: config_backup, + existed_before: true, + }, + AgentAppliedBackupFile { + path: catalog_path.clone(), + backup_path: catalog_backup, + existed_before: true, + }, + ], + updated_at_unix: 1, + }; + + let error = restore_agent_applied_state_configuration( + AgentClient::Codex, + &paths, + &state_path, + &state, + ) + .err() + .unwrap(); + assert!(error.contains("读取智能体备份失败")); + assert_eq!(fs::read_to_string(&config_path).unwrap(), "managed-config"); + assert_eq!( + fs::read_to_string(&catalog_path).unwrap(), + "managed-catalog" + ); + assert!(state_path.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn version_three_cleanup_covers_every_non_desktop_agent_format() { + let home = agent_test_home("all-agent-version-three-cleanup"); + let state = |client: AgentClient| AgentAppliedState { + version: 3, + client: client.id().to_string(), + model: "gpt-test".to_string(), + claude_desktop_model_mappings: None, + backup_files: Vec::new(), + updated_at_unix: 1, + }; + + let claude_path = home.join("claude/settings.json"); + fs::create_dir_all(claude_path.parent().unwrap()).unwrap(); + fs::write( + &claude_path, + r#"{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_API_KEY": "secret", + "ANTHROPIC_AUTH_TOKEN": "secret", + "ANTHROPIC_MODEL": "gpt-test", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-test", + "KEEP_ENV": "yes" + }, + "model": "gpt-test", + "keep": "claude" +}"#, + ) + .unwrap(); + let claude_state_path = agent_state_path(std::slice::from_ref(&claude_path)).unwrap(); + fs::write(&claude_state_path, "state").unwrap(); + restore_agent_applied_state_configuration( + AgentClient::ClaudeCode, + std::slice::from_ref(&claude_path), + &claude_state_path, + &state(AgentClient::ClaudeCode), + ) + .unwrap(); + let claude: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&claude_path).unwrap()).unwrap(); + assert!(claude.get("model").is_none()); + assert!(claude["env"].get("ANTHROPIC_BASE_URL").is_none()); + assert_eq!(claude["env"]["KEEP_ENV"], "yes"); + assert_eq!(claude["keep"], "claude"); + + let codex_path = home.join("codex/config.toml"); + let codex_catalog = codex_path.with_file_name(CODEX_MODEL_CATALOG_FILE); + fs::create_dir_all(codex_path.parent().unwrap()).unwrap(); + fs::write( + &codex_path, + r#"approval_policy = "never" +model_provider = "cpa-gui" +model = "gpt-test" +model_catalog_json = "cpa-gui-model-catalog.json" + +[model_providers.cpa-gui] +name = "EasyCLIProxyAPI" +base_url = "http://127.0.0.1:8317/v1" + +[model_providers.other] +name = "Other" +"#, + ) + .unwrap(); + fs::write(&codex_catalog, "{}").unwrap(); + let codex_state_path = agent_state_path(std::slice::from_ref(&codex_path)).unwrap(); + fs::write(&codex_state_path, "state").unwrap(); + restore_agent_applied_state_configuration( + AgentClient::Codex, + std::slice::from_ref(&codex_path), + &codex_state_path, + &state(AgentClient::Codex), + ) + .unwrap(); + let codex: toml::Value = toml::from_str(&fs::read_to_string(&codex_path).unwrap()).unwrap(); + assert_eq!(codex["approval_policy"].as_str(), Some("never")); + assert!(codex.get("model_provider").is_none()); + assert!(codex.get("model").is_none()); + assert!(codex["model_providers"] + .get(MANAGED_AGENT_PROVIDER_ID) + .is_none()); + assert_eq!( + codex["model_providers"]["other"]["name"].as_str(), + Some("Other") + ); + assert!(!codex_catalog.exists()); + + let openclaw_path = home.join("openclaw/openclaw.json"); + fs::create_dir_all(openclaw_path.parent().unwrap()).unwrap(); + fs::write( + &openclaw_path, + r#"// keep-comment +{ + models: { + mode: "merge", + providers: { + "cpa-gui": {baseUrl: "http://127.0.0.1:8317/v1"}, + other: {keep: true} + } + }, + agents: {defaults: { + model: {primary: "cpa-gui/gpt-test", fallback: "other/model"}, + models: {"cpa-gui/gpt-test": {}, "other/model": {}} + }}, + keep: "openclaw" +}"#, + ) + .unwrap(); + let openclaw_state_path = agent_state_path(std::slice::from_ref(&openclaw_path)).unwrap(); + fs::write(&openclaw_state_path, "state").unwrap(); + restore_agent_applied_state_configuration( + AgentClient::OpenClaw, + std::slice::from_ref(&openclaw_path), + &openclaw_state_path, + &state(AgentClient::OpenClaw), + ) + .unwrap(); + let openclaw_content = fs::read_to_string(&openclaw_path).unwrap(); + assert!(openclaw_content.contains("// keep-comment")); + let openclaw: serde_json::Value = json5::from_str(&openclaw_content).unwrap(); + assert!(openclaw["models"]["providers"] + .get(MANAGED_AGENT_PROVIDER_ID) + .is_none()); + assert_eq!(openclaw["models"]["providers"]["other"]["keep"], true); + assert!(openclaw["agents"]["defaults"]["model"] + .get("primary") + .is_none()); + assert_eq!( + openclaw["agents"]["defaults"]["model"]["fallback"], + "other/model" + ); + assert!(openclaw["agents"]["defaults"]["models"] + .get("cpa-gui/gpt-test") + .is_none()); + assert!(openclaw["agents"]["defaults"]["models"] + .get("other/model") + .is_some()); + + let hermes_path = home.join("hermes/config.yaml"); + fs::create_dir_all(hermes_path.parent().unwrap()).unwrap(); + fs::write( + &hermes_path, + r#"keep: hermes +custom_providers: + - name: other + keep: true + - name: cpa-gui + base_url: http://127.0.0.1:8317/v1 +model: + default: gpt-test + provider: cpa-gui + keep: model +"#, + ) + .unwrap(); + let hermes_state_path = agent_state_path(std::slice::from_ref(&hermes_path)).unwrap(); + fs::write(&hermes_state_path, "state").unwrap(); + restore_agent_applied_state_configuration( + AgentClient::Hermes, + std::slice::from_ref(&hermes_path), + &hermes_state_path, + &state(AgentClient::Hermes), + ) + .unwrap(); + let hermes: serde_norway::Value = + serde_norway::from_str(&fs::read_to_string(&hermes_path).unwrap()).unwrap(); + assert_eq!(hermes["keep"].as_str(), Some("hermes")); + assert_eq!(hermes["custom_providers"].as_sequence().unwrap().len(), 1); + assert_eq!( + hermes["custom_providers"][0]["name"].as_str(), + Some("other") + ); + assert!(hermes["model"].get("provider").is_none()); + assert!(hermes["model"].get("default").is_none()); + assert_eq!(hermes["model"]["keep"].as_str(), Some("model")); + + for state_path in [ + claude_state_path, + codex_state_path, + openclaw_state_path, + hermes_state_path, + ] { + assert!(!state_path.exists()); + } + fs::remove_dir_all(home).unwrap(); + } + #[test] fn codex_session_restore_does_not_create_a_state_json_file() { let home = agent_test_home("codex-session-without-state-file"); diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 922e3e6..b962c27 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -22,7 +22,6 @@ import { Power, RefreshCw, Search, - Sparkles, Terminal, Trash2, X, @@ -1171,7 +1170,7 @@ export function AgentsPage() { > {busyAction === 'apply' || busyAction === 'close-config' ? - : } + : null} {activeStatus?.modificationState === 'applied' ? t('agents.modify.close') : t('agents.modify.apply')} From 31c0ceaf08659212be2ef4ebcd959acd6f0d3e0d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 14:25:24 +0800 Subject: [PATCH 17/29] fix(agents): require valid version detection for installed status --- src-tauri/src/main.rs | 57 +++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 44b297c..86828e3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2936,21 +2936,19 @@ fn inspect_agent_config( }; let launch_targets = agent_launch_targets(client, home); let executable = find_agent_executable(client, home); - let installed = if client == AgentClient::ClaudeDesktop { - !launch_targets.is_empty() - } else { - !launch_targets.is_empty() || config_exists - }; let version = if client == AgentClient::ClaudeDesktop { read_claude_desktop_version(home) } else { executable.as_deref().and_then(read_agent_version) }; + let installed = version.is_some(); let mut warnings = Vec::new(); if !client.supported_platform() { warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string()); } else if launch_targets.is_empty() && config_exists { warnings.push("只检测到配置文件,未在 PATH 中找到客户端命令".to_string()); + } else if !launch_targets.is_empty() && version.is_none() { + warnings.push("检测到客户端入口,但无法读取有效版本,未标记为已安装".to_string()); } if let Some(message) = error.as_ref() { warnings.push(message.clone()); @@ -3609,9 +3607,7 @@ fn parse_windows_claude_desktop_version_output(output: &str) -> Option { output.lines().find_map(|line| { line.trim() .strip_prefix("VERSION:") - .map(str::trim) - .filter(|version| !version.is_empty()) - .map(str::to_string) + .and_then(normalize_detected_agent_version) }) } @@ -3900,16 +3896,24 @@ fn read_agent_version(path: &Path) -> Option { command.arg("--version"); configure_background_command(&mut command); let output = command.output().ok()?; - let text = if output.stdout.is_empty() { - String::from_utf8_lossy(&output.stderr) - } else { - String::from_utf8_lossy(&output.stdout) - }; - text.lines() - .next() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_string) + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout + .lines() + .chain(stderr.lines()) + .find_map(normalize_detected_agent_version) +} + +fn normalize_detected_agent_version(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() + && value.len() <= 256 + && value.chars().any(|character| character.is_ascii_digit()) + && !value.chars().any(char::is_control)) + .then(|| value.to_string()) } fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> { @@ -18523,6 +18527,22 @@ model: assert!(parse_agent_model_options(&serde_json::json!({"data": null})).is_err()); } + #[test] + fn detected_agent_version_requires_a_real_version_value() { + assert_eq!( + normalize_detected_agent_version(" opencode 1.2.3 ").as_deref(), + Some("opencode 1.2.3") + ); + assert_eq!( + normalize_detected_agent_version("Claude Code v4").as_deref(), + Some("Claude Code v4") + ); + assert!(normalize_detected_agent_version("").is_none()); + assert!(normalize_detected_agent_version("version unknown").is_none()); + assert!(normalize_detected_agent_version("1.2.3\0invalid").is_none()); + assert!(normalize_detected_agent_version(&"1".repeat(257)).is_none()); + } + #[test] fn codex_model_list_is_empty_when_cpa_has_no_writable_models() { let prepared = prepare_codex_agent_models(&[]).unwrap(); @@ -18773,6 +18793,7 @@ model: Some("1.24012.9.0") ); assert!(parse_windows_claude_desktop_version_output("VERSION:\r\n").is_none()); + assert!(parse_windows_claude_desktop_version_output("VERSION:unknown\r\n").is_none()); } #[test] From 5924faaac3b1edc2620a2301c892456534278edb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 2 Aug 2026 14:30:08 +0800 Subject: [PATCH 18/29] feat(usage): format statistics with automatic M and B units --- src/pages/ConfigPanel.tsx | 7 +++---- src/pages/UsageRecordsPage.tsx | 6 ++---- src/services/usageNumber.ts | 16 ++++++++++++++++ tests/usageNumber.test.ts | 17 +++++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 src/services/usageNumber.ts create mode 100644 tests/usageNumber.test.ts diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 59a2fcd..6d3e448 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -670,10 +670,9 @@ export function ConfigPanelPage() { -
{activeSubpage === 'general' ? ( @@ -888,7 +875,7 @@ export function ConfigPanelPage() { - ) : activeSubpage === 'aliases' ? ( + ) : (
- ) : ( -
- -
)} {addDialogOpen ? ( diff --git a/src/pages/SpeedAliasesPage.tsx b/src/pages/SpeedAliasesPage.tsx deleted file mode 100644 index 10f84c0..0000000 --- a/src/pages/SpeedAliasesPage.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { - type KeyboardEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { - ArrowRight, - Check, - GitFork, - LoaderCircle, - Search, - Trash2, - Zap, -} from 'lucide-react'; -import { useI18n } from '../i18n'; -import { thinkingAliasSourceKindLabel } from './ThinkingAliasesPage'; - -type SpeedAliasEntry = { - sourceModel: string; - alias: string; - serviceTier: string; - provider: string; - kind: string; -}; - -type SpeedAliasSource = { - id: string; - model: string; - displayName: string | null; - provider: string; - kind: string; - protocol: string; -}; - -export function SpeedAliasesPage() { - const { t } = useI18n(); - const [entries, setEntries] = useState([]); - const [sources, setSources] = useState([]); - const [selectedSourceId, setSelectedSourceId] = useState(''); - const [alias, setAlias] = useState(''); - const [search, setSearch] = useState(''); - const [modelPickerOpen, setModelPickerOpen] = useState(false); - const [activeSourceIndex, setActiveSourceIndex] = useState(0); - const modelPickerRef = useRef(null); - const [loading, setLoading] = useState(true); - const [busyAlias, setBusyAlias] = useState(''); - const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>(''); - const [error, setError] = useState(''); - const [notice, setNotice] = useState(''); - - const load = useCallback(async () => { - setLoading(true); - setError(''); - try { - const [nextEntries, nextSources] = await Promise.all([ - invoke('get_speed_aliases'), - invoke('get_speed_alias_sources'), - ]); - setEntries(nextEntries); - setSources(nextSources); - setSelectedSourceId((current) => ( - nextSources.some((source) => source.id === current) ? current : '' - )); - } catch (requestError) { - setError(String(requestError)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - if (!modelPickerOpen) return undefined; - const closeOnOutsideClick = (event: PointerEvent) => { - if (!modelPickerRef.current?.contains(event.target as Node)) { - setModelPickerOpen(false); - setSearch(''); - } - }; - document.addEventListener('pointerdown', closeOnOutsideClick); - return () => document.removeEventListener('pointerdown', closeOnOutsideClick); - }, [modelPickerOpen]); - - const selectedSource = useMemo( - () => sources.find((source) => source.id === selectedSourceId) ?? null, - [selectedSourceId, sources], - ); - - const filteredSources = useMemo(() => { - const query = search.trim().toLowerCase(); - if (!query) return sources; - return sources.filter((source) => { - const haystack = [ - source.model, - source.displayName ?? '', - source.provider, - thinkingAliasSourceKindLabel(source.kind), - ].join(' ').toLowerCase(); - return haystack.includes(query); - }); - }, [search, sources]); - - useEffect(() => { - setActiveSourceIndex(0); - }, [search, sources]); - - const chooseSource = (source: SpeedAliasSource) => { - setSelectedSourceId(source.id); - setModelPickerOpen(false); - setSearch(''); - }; - - const handleModelSearchKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setModelPickerOpen(false); - setSearch(''); - event.currentTarget.blur(); - return; - } - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - if (!modelPickerOpen) { - setModelPickerOpen(true); - return; - } - if (!filteredSources.length) return; - const direction = event.key === 'ArrowDown' ? 1 : -1; - setActiveSourceIndex((current) => ( - (current + direction + filteredSources.length) % filteredSources.length - )); - return; - } - if (event.key === 'Enter' && modelPickerOpen && filteredSources[activeSourceIndex]) { - event.preventDefault(); - chooseSource(filteredSources[activeSourceIndex]); - } - }; - - const createAlias = async () => { - if (!selectedSource) { - setError(t('aliases.error.selectModel')); - return; - } - const normalizedAlias = alias.trim(); - if (!normalizedAlias) { - setError(t('aliases.error.emptyAlias')); - return; - } - setBusyAlias(normalizedAlias); - setBusyAction('create'); - setError(''); - setNotice(''); - try { - const nextEntries = await invoke('create_speed_alias', { - sourceId: selectedSource.id, - alias: normalizedAlias, - }); - setEntries(nextEntries); - setNotice(t('speedAliases.created', { alias: normalizedAlias })); - setAlias(''); - } catch (requestError) { - setError(String(requestError)); - } finally { - setBusyAlias(''); - setBusyAction(''); - } - }; - - const deleteAlias = async (entry: SpeedAliasEntry) => { - if (!window.confirm(t('speedAliases.deleteConfirm', { alias: entry.alias }))) return; - setBusyAlias(entry.alias); - setBusyAction('delete'); - setError(''); - setNotice(''); - try { - const nextEntries = await invoke('delete_speed_alias', { - alias: entry.alias, - }); - setEntries(nextEntries); - setNotice(t('speedAliases.deleted', { alias: entry.alias })); - } catch (requestError) { - setError(String(requestError)); - } finally { - setBusyAlias(''); - setBusyAction(''); - } - }; - - return ( -
-
- {error ?
{error}
: null} - {!error && notice ?
{notice}
: null} -
- -
-
-
- -
-

{t('speedAliases.create.title')}

-

{t('speedAliases.create.description')}

-
-
- -
- -
-
- {loading ? : } - { - setSearch(selectedSource?.model ?? ''); - setModelPickerOpen(true); - event.currentTarget.select(); - }} - onChange={(event) => { - setSearch(event.currentTarget.value); - setModelPickerOpen(true); - }} - onKeyDown={handleModelSearchKeyDown} - placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')} - autoComplete="off" - spellCheck={false} - disabled={loading} - /> - {!modelPickerOpen && selectedSource ? ( - - {thinkingAliasSourceKindLabel(selectedSource.kind)} - - ) : null} -
- {modelPickerOpen ? ( -
- {filteredSources.length === 0 ? ( -
- {sources.length ? t('aliases.noMatch') : t('aliases.noModels')} -
- ) : filteredSources.map((source, index) => { - const selected = source.id === selectedSourceId; - return ( - - ); - })} -
- ) : null} -
- {selectedSource ? ( -
- {thinkingAliasSourceKindLabel(selectedSource.kind)} - {selectedSource.provider} -
- ) : ( - {t('aliases.sourceHint')} - )} -
- -
- -
- {t('speedAliases.fast.title')} - service_tier = priority -
-
- -
- -
-
-
-

{t('speedAliases.createdList.title')}

- {t('speedAliases.createdList.description')} -
- {entries.length} -
- -
- {loading ? ( -
{t('aliases.loadingConfig')}
- ) : entries.length === 0 ? ( -
- - {t('speedAliases.empty.title')} - {t('speedAliases.empty.description')} -
- ) : entries.map((entry) => ( -
-
-
- {entry.sourceModel} - - {thinkingAliasSourceKindLabel(entry.kind)} - {entry.provider} - -
- - {entry.alias} -
- {entry.serviceTier} - -
- ))} -
-
-
-
- ); -} diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 79744cb..792abdd 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -16,6 +16,7 @@ import { Search, Trash2, X, + Zap, } from 'lucide-react'; import { getCurrentLocale, translate, useI18n } from '../i18n'; @@ -29,6 +30,20 @@ type ThinkingAliasEntry = { kind: string; }; +type SpeedAliasEntry = { + sourceModel: string; + alias: string; + serviceTier: string; + provider: string; + kind: string; +}; + +type AliasListEntry = + | (ThinkingAliasEntry & { mode: 'reasoning' }) + | (SpeedAliasEntry & { mode: 'fast' }); + +type AliasMode = 'reasoning' | 'fast'; + type ThinkingAliasSource = { id: string; model: string; @@ -46,6 +61,17 @@ const effortOptions = [ { value: 'max', label: 'Max', hintKey: 'aliases.effort.max' }, ] as const satisfies ReadonlyArray<{ value: PresetThinkingEffort; label: string; hintKey: string }>; +export const combineModelAliasEntries = ( + thinkingEntries: ThinkingAliasEntry[], + speedEntries: SpeedAliasEntry[], +): AliasListEntry[] => [ + ...thinkingEntries.map((entry) => ({ ...entry, mode: 'reasoning' as const })), + ...speedEntries.map((entry) => ({ ...entry, mode: 'fast' as const })), +].sort((left, right) => ( + left.provider.localeCompare(right.provider) + || left.alias.localeCompare(right.alias) +)); + export const thinkingAliasSourceKindLabel = (kind: string) => { if (kind === 'codex-oauth') return 'Codex OAuth'; if (kind === 'codex-api') return 'Codex API'; @@ -65,9 +91,12 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => ( export function ThinkingAliasesPage() { const { t } = useI18n(); - const [entries, setEntries] = useState([]); - const [sources, setSources] = useState([]); + const [thinkingEntries, setThinkingEntries] = useState([]); + const [speedEntries, setSpeedEntries] = useState([]); + const [thinkingSources, setThinkingSources] = useState([]); + const [speedSources, setSpeedSources] = useState([]); const [selectedSourceId, setSelectedSourceId] = useState(''); + const [aliasMode, setAliasMode] = useState('reasoning'); const [effort, setEffort] = useState('xhigh'); const [customEffortOpen, setCustomEffortOpen] = useState(false); const [alias, setAlias] = useState(''); @@ -85,14 +114,20 @@ export function ThinkingAliasesPage() { setLoading(true); setError(''); try { - const [nextEntries, nextSources] = await Promise.all([ + const [nextThinkingEntries, nextThinkingSources, nextSpeedEntries, nextSpeedSources] = await Promise.all([ invoke('get_thinking_aliases'), invoke('get_thinking_alias_sources'), + invoke('get_speed_aliases'), + invoke('get_speed_alias_sources'), ]); - setEntries(nextEntries); - setSources(nextSources); + setThinkingEntries(nextThinkingEntries); + setThinkingSources(nextThinkingSources); + setSpeedEntries(nextSpeedEntries); + setSpeedSources(nextSpeedSources); setSelectedSourceId((current) => ( - nextSources.some((source) => source.id === current) ? current : '' + [...nextThinkingSources, ...nextSpeedSources].some((source) => source.id === current) + ? current + : '' )); } catch (requestError) { setError(String(requestError)); @@ -117,6 +152,18 @@ export function ThinkingAliasesPage() { return () => document.removeEventListener('pointerdown', closeOnOutsideClick); }, [modelPickerOpen]); + const sources = aliasMode === 'fast' ? speedSources : thinkingSources; + const entries = useMemo( + () => combineModelAliasEntries(thinkingEntries, speedEntries), + [speedEntries, thinkingEntries], + ); + + useEffect(() => { + setSelectedSourceId((current) => ( + sources.some((source) => source.id === current) ? current : '' + )); + }, [sources]); + const selectedSource = useMemo( () => sources.find((source) => source.id === selectedSourceId) ?? null, [selectedSourceId, sources], @@ -186,7 +233,9 @@ export function ThinkingAliasesPage() { const normalizedEffort = effort.trim().toLowerCase(); const customEffortSelected = Boolean( - normalizedEffort && !effortOptions.some((option) => option.value === normalizedEffort), + aliasMode === 'reasoning' + && normalizedEffort + && !effortOptions.some((option) => option.value === normalizedEffort), ); const createAlias = async () => { @@ -199,7 +248,7 @@ export function ThinkingAliasesPage() { setError(t('aliases.error.emptyAlias')); return; } - if (!normalizedEffort) { + if (aliasMode === 'reasoning' && !normalizedEffort) { setError(t('aliases.error.emptyEffort')); return; } @@ -208,13 +257,22 @@ export function ThinkingAliasesPage() { setError(''); setNotice(''); try { - const nextEntries = await invoke('create_thinking_alias', { - sourceId: selectedSource.id, - alias: normalizedAlias, - effort: normalizedEffort, - }); - setEntries(nextEntries); - setNotice(t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort })); + if (aliasMode === 'fast') { + const nextEntries = await invoke('create_speed_alias', { + sourceId: selectedSource.id, + alias: normalizedAlias, + }); + setSpeedEntries(nextEntries); + setNotice(t('speedAliases.created', { alias: normalizedAlias })); + } else { + const nextEntries = await invoke('create_thinking_alias', { + sourceId: selectedSource.id, + alias: normalizedAlias, + effort: normalizedEffort, + }); + setThinkingEntries(nextEntries); + setNotice(t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort })); + } setAlias(''); } catch (requestError) { setError(String(requestError)); @@ -224,20 +282,30 @@ export function ThinkingAliasesPage() { } }; - const deleteAlias = async (entry: ThinkingAliasEntry) => { + const deleteAlias = async (entry: AliasListEntry) => { if (!window.confirm( - t('aliases.deleteConfirm', { alias: entry.alias }), + t(entry.mode === 'fast' ? 'speedAliases.deleteConfirm' : 'aliases.deleteConfirm', { + alias: entry.alias, + }), )) return; setBusyAlias(entry.alias); setBusyAction('delete'); setError(''); setNotice(''); try { - const nextEntries = await invoke('delete_thinking_alias', { - alias: entry.alias, - }); - setEntries(nextEntries); - setNotice(t('aliases.deleted', { alias: entry.alias })); + if (entry.mode === 'fast') { + const nextEntries = await invoke('delete_speed_alias', { + alias: entry.alias, + }); + setSpeedEntries(nextEntries); + setNotice(t('speedAliases.deleted', { alias: entry.alias })); + } else { + const nextEntries = await invoke('delete_thinking_alias', { + alias: entry.alias, + }); + setThinkingEntries(nextEntries); + setNotice(t('aliases.deleted', { alias: entry.alias })); + } } catch (requestError) { setError(String(requestError)); } finally { @@ -366,9 +434,10 @@ export function ThinkingAliasesPage() { {effortOptions.map((option) => ( ))} + @@ -487,15 +578,19 @@ export function ThinkingAliasesPage() { {entry.alias} - - {entry.effort ?? t('aliases.unboundEffort')} + + {entry.mode === 'fast' + ? t('speedAliases.fast.title') + : entry.effort ?? t('aliases.unboundEffort')} {effortOptions.map((option) => ( ))} - {t('aliases.customHelp')} : null} + {selectedSource && !selectedSource.supportsReasoning ? ( + {t('aliases.effort.unsupported')} + ) : null} + + +
+
+ {t('aliases.fast.title')} + {t('aliases.fast.description')} +
+
- {aliasMode === 'fast' ? : } + {normalizedEffort ? : }
{selectedSource?.model || t('aliases.notSelected')} {alias || t('aliases.enterAlias')} - {aliasMode === 'fast' - ? 'service_tier = priority' - : `${selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = ${normalizedEffort || t('aliases.notSet')}`} + {[ + normalizedEffort + ? `${selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = ${normalizedEffort}` + : '', + fastEnabled ? 'service_tier = priority' : '', + ].filter(Boolean).join(' · ') || t('aliases.notSet')}
@@ -536,11 +598,11 @@ export function ThinkingAliasesPage() { type="button" className="primary-button thinking-alias-create" onClick={() => void createAlias()} - disabled={loading || Boolean(busyAlias) || !selectedSource || (aliasMode === 'reasoning' && !effort.trim()) || !alias.trim()} + disabled={loading || Boolean(busyAlias) || !selectedSource || (!normalizedEffort && !fastEnabled) || !alias.trim()} > {busyAction === 'create' ? - : aliasMode === 'fast' ? : } + : } {busyAction === 'create' ? t('aliases.creating') : t('aliases.create')} @@ -578,19 +640,20 @@ export function ThinkingAliasesPage() { {entry.alias} - - {entry.mode === 'fast' - ? t('speedAliases.fast.title') - : entry.effort ?? t('aliases.unboundEffort')} - +
+ {entry.effort ? ( + {entry.effort} + ) : null} + {entry.serviceTier ? ( + {t('speedAliases.fast.title')} + ) : null} +