From 0ca3c01571f3f2f523b1de14837d10a68dffcdea Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Wed, 17 Jun 2026 23:49:42 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(frontend):=20drop=20Dashboard=20+?= =?UTF-8?q?=20Guide=20=E9=A1=B5(FineTune=20redesign=20=E5=B7=B2=E5=BC=83)?= =?UTF-8?q?=E2=80=94=20Stage=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FineTune 顶部 5 tab(providers/proxy/usage/codex/settings)不含 Dashboard; Guide 引导内容并入文档。移除 2 路由 + 删 stub 页。i18n 死键留待最终 i18n pass 清。 Refs MOC-254 --- frontend/src/pages/DashboardPage.vue | 21 --------------------- frontend/src/pages/GuidePage.vue | 21 --------------------- frontend/src/router/index.ts | 8 +++----- 3 files changed, 3 insertions(+), 47 deletions(-) delete mode 100644 frontend/src/pages/DashboardPage.vue delete mode 100644 frontend/src/pages/GuidePage.vue diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue deleted file mode 100644 index d46998f3..00000000 --- a/frontend/src/pages/DashboardPage.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - - - diff --git a/frontend/src/pages/GuidePage.vue b/frontend/src/pages/GuidePage.vue deleted file mode 100644 index 7e44380f..00000000 --- a/frontend/src/pages/GuidePage.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - - - diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 172fce8b..be731892 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,11 +1,10 @@ import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router' -// 路由表映射旧 SPA 的 9 个页面 + providers/add 子路由 + 隐藏 desktop。 -// 原 #theme(Codex Desktop 皮肤注入)改名 /codex-skin, 与"本应用三主题"区分。 +// FineTune redesign: 顶部 5 tab(providers/proxy/usage/codex/settings)+ 次要页 +// codex-skin(Codex Desktop 皮肤注入)/ desktop(配置状态)。Dashboard/Guide 已 drop +// (FineTune 主页 = providers,引导内容并入文档)。 const routes: RouteRecordRaw[] = [ - // FineTune 风顶部 tab 无 dashboard, 默认进主页 providers(dashboard 路由保留, 仅不在 tab) { path: '/', redirect: '/providers' }, - { path: '/dashboard', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue'), meta: { navKey: 'nav.dashboard', icon: 'gauge', hidden: true } }, { path: '/providers', name: 'providers', component: () => import('@/pages/ProvidersPage.vue'), meta: { navKey: 'nav.providers', icon: 'plug' } }, { path: '/providers/add', name: 'provider-form', component: () => import('@/pages/ProviderFormPage.vue') }, { path: '/proxy', name: 'proxy', component: () => import('@/pages/ProxyPage.vue'), meta: { navKey: 'nav.proxy', icon: 'radio' } }, @@ -13,7 +12,6 @@ const routes: RouteRecordRaw[] = [ { path: '/settings', name: 'settings', component: () => import('@/pages/SettingsPage.vue'), meta: { navKey: 'nav.settings', icon: 'settings' } }, { path: '/codex', name: 'codex', component: () => import('@/pages/CodexPage.vue'), meta: { navKey: 'nav.codex', icon: 'bookmark' } }, { path: '/codex-skin', name: 'codex-skin', component: () => import('@/pages/CodexSkinPage.vue'), meta: { navKey: 'nav.theme', icon: 'palette' } }, - { path: '/guide', name: 'guide', component: () => import('@/pages/GuidePage.vue'), meta: { navKey: 'nav.guide', icon: 'book' } }, { path: '/desktop', name: 'desktop', component: () => import('@/pages/DesktopPage.vue'), meta: { hidden: true } }, { path: '/:pathMatch(.*)*', redirect: '/providers' }, ] From 45e8548a4984e98fff1788e2e785b5e4b944cfa8 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 18 Jun 2026 00:30:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(frontend):=20CodexSkin/Desktop/setting?= =?UTF-8?q?s=20=E5=AD=90=20UI=20=E7=A7=BB=E6=A4=8D=20+=20=E5=88=A0?= =?UTF-8?q?=E6=97=A7=20vanilla=20=E5=89=8D=E7=AB=AF=20=E2=80=94=20Stage=20?= =?UTF-8?q?6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodexSkin 主题注入页(grid + MOC-102 徽章派生 + 删/隐藏 + 1:1 裁剪上传 + 重启对话框) - Desktop 配置页(状态 + 复制环境变量命令 + 还原原配置) - settings 三子 UI:residual 反投毒自检 / snapshot 恢复 / diagnostic 诊断查看器 - api/desktop.ts 收编 theme/desktop/residual/snapshot/trace 全端点;useCodexRestore 共享还原流 - 删旧 frontend/js + css + vendor(Bootstrap)+ gallery.html(83 文件),Vue 重构达成 parity - i18n 补 23 key(zh/en 对称)+ 修 providers.baseUrl off-by-one;Rust 注释 + README 前端栈指针同步 Refs MOC-254 --- README.md | 4 +- frontend/css/base.css | 55 - frontend/css/components/activity.css | 35 - frontend/css/components/badge.css | 99 - frontend/css/components/baseurl-menu.css | 87 - frontend/css/components/button.css | 78 - frontend/css/components/card.css | 161 - .../css/components/codex-conversations.css | 411 - frontend/css/components/codex-mcp.css | 671 -- frontend/css/components/codex-path-picker.css | 466 - frontend/css/components/codex-sidebar.css | 149 - frontend/css/components/desktop-warning.css | 38 - frontend/css/components/feedback.css | 73 - frontend/css/components/form.css | 123 - frontend/css/components/header.css | 186 - frontend/css/components/info-box.css | 35 - frontend/css/components/modal.css | 32 - frontend/css/components/nav.css | 84 - frontend/css/components/page.css | 96 - frontend/css/components/segmented.css | 99 - frontend/css/components/step.css | 56 - frontend/css/components/switch.css | 50 - frontend/css/components/update-badge.css | 52 - frontend/css/components/usage.css | 217 - frontend/css/pages/dashboard.css | 183 - frontend/css/pages/guide.css | 225 - frontend/css/pages/providers.css | 967 -- frontend/css/pages/proxy.css | 165 - frontend/css/pages/settings.css | 386 - frontend/css/responsive.css | 166 - frontend/css/style.css | 59 - frontend/css/tokens.css | 168 - frontend/gallery.html | 388 - frontend/js/api.js | 772 -- frontend/js/app.js | 8971 ----------------- frontend/js/i18n.js | 1600 --- frontend/src/api/desktop.ts | 177 + .../src/components/codex/ThemeCropModal.vue | 208 + .../components/settings/DiagnosticPanel.vue | 105 + .../components/settings/ResidualScanPanel.vue | 169 + .../src/components/settings/SnapshotPanel.vue | 63 + frontend/src/composables/useCodexRestore.ts | 69 + frontend/src/i18n/en.ts | 25 +- frontend/src/i18n/zh.ts | 24 +- frontend/src/pages/CodexSkinPage.vue | 572 +- frontend/src/pages/DesktopPage.vue | 221 +- frontend/src/pages/SettingsPage.vue | 59 + .../bootstrap-icons.css | 2078 ---- .../fonts/bootstrap-icons.woff | Bin 176032 -> 0 bytes .../fonts/bootstrap-icons.woff2 | Bin 130396 -> 0 bytes .../bootstrap@5.3.3/bootstrap.bundle.min.js | 7 - .../vendor/bootstrap@5.3.3/bootstrap.min.css | 6 - frontend/vendor/fira/fira.css | 567 -- .../uU9NCBsR6Z2vfE9aq3bh09SDqFGedCMX.woff2 | Bin 13232 -> 0 bytes .../uU9NCBsR6Z2vfE9aq3bh0NSDqFGedCMX.woff2 | Bin 22056 -> 0 bytes .../uU9NCBsR6Z2vfE9aq3bh0dSDqFGedCMX.woff2 | Bin 8580 -> 0 bytes .../uU9NCBsR6Z2vfE9aq3bh2dSDqFGedCMX.woff2 | Bin 11924 -> 0 bytes .../uU9NCBsR6Z2vfE9aq3bh3dSDqFGedA.woff2 | Bin 36340 -> 0 bytes .../uU9NCBsR6Z2vfE9aq3bh3tSDqFGedCMX.woff2 | Bin 13780 -> 0 bytes .../uU9NCBsR6Z2vfE9aq3bhZ_Wmh3mUfBsu_Q.woff2 | Bin 4208 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnLK3eQhf6Xl7Gl3LX.woff2 | Bin 7148 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnLK3eRRf6Xl7Gl3LX.woff2 | Bin 8960 -> 0 bytes .../va9B4kDNxMZdWfMOD5VnLK3eRhf6Xl7Glw.woff2 | Bin 17744 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnLK3eSBf6Xl7Gl3LX.woff2 | Bin 32900 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnLK3eSRf6Xl7Gl3LX.woff2 | Bin 5236 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnLK3eShf6Xl7Gl3LX.woff2 | Bin 5268 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnLK3eSxf6Xl7Gl3LX.woff2 | Bin 12108 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnMK7eQhf6Xl7Gl3LX.woff2 | Bin 7204 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnMK7eRRf6Xl7Gl3LX.woff2 | Bin 8884 -> 0 bytes .../va9B4kDNxMZdWfMOD5VnMK7eRhf6Xl7Glw.woff2 | Bin 17696 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnMK7eSBf6Xl7Gl3LX.woff2 | Bin 32896 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnMK7eSRf6Xl7Gl3LX.woff2 | Bin 5092 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnMK7eShf6Xl7Gl3LX.woff2 | Bin 5196 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnMK7eSxf6Xl7Gl3LX.woff2 | Bin 12056 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnSKzeQhf6Xl7Gl3LX.woff2 | Bin 7108 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnSKzeRRf6Xl7Gl3LX.woff2 | Bin 8952 -> 0 bytes .../va9B4kDNxMZdWfMOD5VnSKzeRhf6Xl7Glw.woff2 | Bin 17680 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnSKzeSBf6Xl7Gl3LX.woff2 | Bin 32932 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnSKzeSRf6Xl7Gl3LX.woff2 | Bin 5136 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnSKzeShf6Xl7Gl3LX.woff2 | Bin 5388 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnSKzeSxf6Xl7Gl3LX.woff2 | Bin 12116 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnZKveQhf6Xl7Gl3LX.woff2 | Bin 7176 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnZKveRRf6Xl7Gl3LX.woff2 | Bin 8936 -> 0 bytes .../va9B4kDNxMZdWfMOD5VnZKveRhf6Xl7Glw.woff2 | Bin 17820 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnZKveSBf6Xl7Gl3LX.woff2 | Bin 32952 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnZKveSRf6Xl7Gl3LX.woff2 | Bin 5168 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnZKveShf6Xl7Gl3LX.woff2 | Bin 5360 -> 0 bytes ...va9B4kDNxMZdWfMOD5VnZKveSxf6Xl7Gl3LX.woff2 | Bin 12120 -> 0 bytes .../va9E4kDNxMZdWfMOD5Vvk4jLazX3dGTP.woff2 | Bin 7132 -> 0 bytes .../va9E4kDNxMZdWfMOD5Vvl4jLazX3dA.woff2 | Bin 17464 -> 0 bytes .../va9E4kDNxMZdWfMOD5VvlIjLazX3dGTP.woff2 | Bin 8760 -> 0 bytes .../va9E4kDNxMZdWfMOD5Vvm4jLazX3dGTP.woff2 | Bin 5164 -> 0 bytes .../va9E4kDNxMZdWfMOD5VvmIjLazX3dGTP.woff2 | Bin 5100 -> 0 bytes .../va9E4kDNxMZdWfMOD5VvmYjLazX3dGTP.woff2 | Bin 32120 -> 0 bytes .../va9E4kDNxMZdWfMOD5VvmojLazX3dGTP.woff2 | Bin 11900 -> 0 bytes src-tauri/src/admin/mod.rs | 4 +- src-tauri/src/admin/services/mcp_servers.rs | 2 +- src-tauri/src/codex_theme_injector.rs | 2 +- 98 files changed, 1678 insertions(+), 20087 deletions(-) delete mode 100644 frontend/css/base.css delete mode 100644 frontend/css/components/activity.css delete mode 100644 frontend/css/components/badge.css delete mode 100644 frontend/css/components/baseurl-menu.css delete mode 100644 frontend/css/components/button.css delete mode 100644 frontend/css/components/card.css delete mode 100644 frontend/css/components/codex-conversations.css delete mode 100644 frontend/css/components/codex-mcp.css delete mode 100644 frontend/css/components/codex-path-picker.css delete mode 100644 frontend/css/components/codex-sidebar.css delete mode 100644 frontend/css/components/desktop-warning.css delete mode 100644 frontend/css/components/feedback.css delete mode 100644 frontend/css/components/form.css delete mode 100644 frontend/css/components/header.css delete mode 100644 frontend/css/components/info-box.css delete mode 100644 frontend/css/components/modal.css delete mode 100644 frontend/css/components/nav.css delete mode 100644 frontend/css/components/page.css delete mode 100644 frontend/css/components/segmented.css delete mode 100644 frontend/css/components/step.css delete mode 100644 frontend/css/components/switch.css delete mode 100644 frontend/css/components/update-badge.css delete mode 100644 frontend/css/components/usage.css delete mode 100644 frontend/css/pages/dashboard.css delete mode 100644 frontend/css/pages/guide.css delete mode 100644 frontend/css/pages/providers.css delete mode 100644 frontend/css/pages/proxy.css delete mode 100644 frontend/css/pages/settings.css delete mode 100644 frontend/css/responsive.css delete mode 100644 frontend/css/style.css delete mode 100644 frontend/css/tokens.css delete mode 100644 frontend/gallery.html delete mode 100644 frontend/js/api.js delete mode 100644 frontend/js/app.js delete mode 100644 frontend/js/i18n.js create mode 100644 frontend/src/api/desktop.ts create mode 100644 frontend/src/components/codex/ThemeCropModal.vue create mode 100644 frontend/src/components/settings/DiagnosticPanel.vue create mode 100644 frontend/src/components/settings/ResidualScanPanel.vue create mode 100644 frontend/src/components/settings/SnapshotPanel.vue create mode 100644 frontend/src/composables/useCodexRestore.ts delete mode 100644 frontend/vendor/bootstrap-icons@1.11.3/bootstrap-icons.css delete mode 100644 frontend/vendor/bootstrap-icons@1.11.3/fonts/bootstrap-icons.woff delete mode 100644 frontend/vendor/bootstrap-icons@1.11.3/fonts/bootstrap-icons.woff2 delete mode 100644 frontend/vendor/bootstrap@5.3.3/bootstrap.bundle.min.js delete mode 100644 frontend/vendor/bootstrap@5.3.3/bootstrap.min.css delete mode 100644 frontend/vendor/fira/fira.css delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bh09SDqFGedCMX.woff2 delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bh0NSDqFGedCMX.woff2 delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bh0dSDqFGedCMX.woff2 delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bh2dSDqFGedCMX.woff2 delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bh3dSDqFGedA.woff2 delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bh3tSDqFGedCMX.woff2 delete mode 100644 frontend/vendor/fira/fonts/uU9NCBsR6Z2vfE9aq3bhZ_Wmh3mUfBsu_Q.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eQhf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eRRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eRhf6Xl7Glw.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eSBf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eSRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eShf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnLK3eSxf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eQhf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eRRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eRhf6Xl7Glw.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eSBf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eSRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eShf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnMK7eSxf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeQhf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeRRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeRhf6Xl7Glw.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeSBf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeSRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeShf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnSKzeSxf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveQhf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveRRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveRhf6Xl7Glw.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveSBf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveSRf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveShf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9B4kDNxMZdWfMOD5VnZKveSxf6Xl7Gl3LX.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5Vvk4jLazX3dGTP.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5Vvl4jLazX3dA.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5VvlIjLazX3dGTP.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5Vvm4jLazX3dGTP.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5VvmIjLazX3dGTP.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5VvmYjLazX3dGTP.woff2 delete mode 100644 frontend/vendor/fira/fonts/va9E4kDNxMZdWfMOD5VvmojLazX3dGTP.woff2 diff --git a/README.md b/README.md index 77d044d4..19c40756 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ forward 分页详情为调 adapter 准备了几件利器(借鉴 [`liaohch3/claud ### 想改 UI 怎么改 -前端是 **Vue 3 + Vite + TypeScript**(`frontend/`,正从旧 vanilla JS 逐步重构迁移)。源码在 `frontend/src/`(SFC 单文件组件 + Pinia + vue-router)。 +前端是 **Vue 3 + Vite + TypeScript**(`frontend/`,已从旧 vanilla JS + Bootstrap 完全重构)。源码在 `frontend/src/`(SFC 单文件组件 + Pinia + vue-router)。 开发(热更新): @@ -325,7 +325,7 @@ v2.1.12+ 的客户端 **强制** RSA-3072 PKCS#1-v1.5-SHA256 验签 `latest.json - **后端 / 转发**:Rust 1.85+ · axum 0.8 · reqwest 0.12(rustls-tls)· tokio · `wreq` 6.0-rc(浏览器 TLS 指纹伪装,Chrome 120 指纹,curl/wreq/headless 三层声称同一版本避免身份漂移,给 Cloudflare 强保的 `openai.com` / `chatgpt.com` 用,详见 `crates/http/`)· `sys-locale`(读系统语言区域,生成 locale-aware `Accept-Language` 减少 UA 粗筛误拦)· `base64`(Bing `ck/a` 跳转解码)· `chromiumoxide` 0.9(headless Chromium,抓 ①reqwest / ②wreq 都拿不到的 JS 渲染 SPA —— 探测系统 Chrome,否则按需下载 chrome-headless-shell 到 app data,不打包进安装包;目前为 PoC,接入分层 router 待后续 PR,见 `crates/http/src/headless/`)· `crates/http::web_fetch`(统一抓取层,按设置页档位路由 curl/wreq/headless;配套 `GET /api/chrome/detect` + `POST /api/chrome/ensure`;`webFetchBackend != off` 时自动往 `~/.codex/config.toml` 注册 `[mcp_servers.cat-webfetch]`(stdio MCP server,transfer 自身 + `--mcp-serve-webfetch`),让 Codex 模型可调 `web_fetch` / `web_search` 工具) - **协议适配**:`crates/adapters/` — Responses ↔ Chat / Gemini Native / Gemini CLI OAuth / Anthropic Messages / Grok Web 互转(请求 body + 流式响应状态机 + reasoning_content + tool_calls) -- **前端**:HTML + CSS + 原生 JavaScript + Bootstrap 5.3.3(本地化,无 CDN 依赖) +- **前端**:Vue 3 + Vite + TypeScript(SFC + Pinia + vue-router;源码 `frontend/src/`,`frontend/dist` 经 `include_dir!` 编入二进制,prod 走 `cas://localhost/` + 严格 CSP `script-src 'self'`;MacBook 风设计 + 三套主题 白/黑/国风) - **桌面壳**:Tauri 2 + tray-icon 0.23,通过 `cas://` URI scheme 把 frontend/ 与 axum 同进程串起来,无 TCP loopback - **存储**:`~/.codex-app-transfer/config.json`(配置,与 v1.x 互通)、`~/.codex-app-transfer/sessions.db`(L2 sqlite 会话持久化)、`~/.codex-app-transfer/blobs/`(会话内大图按 sha256 去重外置,删 db 不会自动清,需一并删或走 `POST /api/sessions/clear`)、`~/.codex/{config.toml,auth.json,.credentials.json}`(Codex APP 集成)、`~/.codex-app-transfer/mcp-credentials.json`(MCP 凭据镜像,在 `~/.codex` 之外) - **打包**:`cargo tauri build` 单命令出 dmg/AppImage/deb/exe/msi;`xtask release-bundle` 收口出 sha256 + RSA-3072 sig + latest.json + draft GitHub release diff --git a/frontend/css/base.css b/frontend/css/base.css deleted file mode 100644 index 1a9904f4..00000000 --- a/frontend/css/base.css +++ /dev/null @@ -1,55 +0,0 @@ -/* ============================================================ - base.css — Global reset, body, focus, scroll, transitions - layer1 fira import + reset/body/links (line 1, 35-75) - layer2 body (line 1872-1876) - ============================================================ */ - -@import url("../vendor/fira/fira.css"); - -* { - box-sizing: border-box; -} - -body { - display: flex; - flex-direction: column; - height: 100vh; - margin: 0; - overflow: hidden; - background: var(--app-bg); - color: var(--text); - font-family: "Fira Sans", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; - letter-spacing: 0; -} - -a { - text-decoration: none; -} - -button, -a, -select, -input, -.preset-item, -.route-tab { - transition: background-color .2s ease, border-color .2s ease, color .2s ease, box-shadow .2s ease, opacity .2s ease; -} - -button, -a, -.preset-item, -label[for], -.switch-label { - cursor: pointer; -} - -:focus-visible { - outline: 3px solid color-mix(in srgb, var(--primary) 70%, #fff); - outline-offset: 3px; -} - -body { - background: var(--app-bg); - font-family: Inter, "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; - font-size: 14px; -} diff --git a/frontend/css/components/activity.css b/frontend/css/components/activity.css deleted file mode 100644 index 00a23add..00000000 --- a/frontend/css/components/activity.css +++ /dev/null @@ -1,35 +0,0 @@ -/* ============================================================ - components/activity.css — Activity list rows (.activity-list / .activity-row) - layer1: line 454-483 - ============================================================ */ - -.activity-list { - max-height: 340px; - margin-top: 18px; - overflow-y: auto; - border-top: 1px solid var(--line); -} - -.activity-row { - display: grid; - grid-template-columns: 28px 100px 1fr; - gap: 22px; - align-items: center; - min-height: 70px; - border-bottom: 1px solid var(--line); - color: var(--text); - font-size: 19px; -} - -.activity-row::before { - content: ""; - width: 16px; - height: 16px; - margin-left: 10px; - border-radius: 50%; - background: var(--success); -} - -.activity-row time { - color: var(--muted); -} diff --git a/frontend/css/components/badge.css b/frontend/css/components/badge.css deleted file mode 100644 index db079ed0..00000000 --- a/frontend/css/components/badge.css +++ /dev/null @@ -1,99 +0,0 @@ -/* ============================================================ - components/badge.css — Status badges, hero status circles, large dots, status/update badges - layer1: line 316-372 (.hero-status / .circle-check / .badge-icon / .large-dot / .hero-line-icon) - + 777-794 (.status-badge + .active variant) - + 1402-1416 (.update-status text) - ============================================================ */ - -.hero-status, -.circle-check { - display: inline-grid; - place-items: center; - width: 86px; - height: 86px; - border-radius: 50%; - color: #fff; - background: var(--success); - font-size: 48px; -} - -.hero-status.muted { - background: var(--warning); -} - -.hero-line-icon { - position: relative; - display: inline-grid; - place-items: center; - min-height: 92px; - color: var(--muted); - font-size: 80px; -} - -.hero-line-icon.success { - color: var(--success); -} - -.hero-line-icon.muted { - color: var(--muted); -} - -.badge-icon { - position: absolute; - right: -18px; - bottom: 8px; - display: grid; - place-items: center; - width: 48px; - height: 48px; - border-radius: 50%; - color: #fff; - background: var(--success); - border: 5px solid var(--surface); - font-size: 28px; -} - -.large-dot { - display: inline-block; - width: 48px; - height: 48px; - margin-top: 30px; - border-radius: 50%; - background: var(--success); -} - - -.status-badge { - display: inline-flex; - align-items: center; - gap: 8px; - width: fit-content; - padding: 10px 14px; - border-radius: 10px; - color: var(--muted); - background: var(--soft-surface); - border: 1px solid var(--line); - font-weight: 730; -} - -.status-badge.active { - color: var(--success); - background: var(--success-soft); - border-color: color-mix(in srgb, var(--success) 35%, var(--line)); -} - -.update-status { - min-height: 24px; - margin: 12px 0 0; - color: var(--muted); - font-size: 15px; -} - -.update-status:empty { - display: none; -} - -.update-status.available { - color: var(--success); - font-weight: 700; -} diff --git a/frontend/css/components/baseurl-menu.css b/frontend/css/components/baseurl-menu.css deleted file mode 100644 index b49ec1d6..00000000 --- a/frontend/css/components/baseurl-menu.css +++ /dev/null @@ -1,87 +0,0 @@ -/* ============================================================ - components/baseurl-menu.css — Base URL dropdown picker (used by add-provider form) - layer1: line 537-618 (.baseurl-input-wrap / .baseurl-trigger / .baseurl-menu / .baseurl-option) - ============================================================ */ - -.baseurl-input-wrap { - position: relative; -} - -.baseurl-input-wrap .form-control { - padding-right: 64px; -} - -.baseurl-trigger { - position: absolute; - top: 1px; - right: 1px; - bottom: 1px; - width: 54px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 0; - border-radius: 0 10px 10px 0; - background: transparent; - color: var(--text); -} - -.baseurl-trigger i { - font-size: 16px; - transition: transform .18s ease; -} - -.baseurl-input-wrap.open .baseurl-trigger i { - transform: rotate(180deg); -} - -.baseurl-menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 20; - display: grid; - gap: 4px; - padding: 8px; - border: 1px solid var(--line); - border-radius: 14px; - background: color-mix(in srgb, var(--surface) 92%, white); - box-shadow: 0 16px 34px rgba(15, 23, 42, 0.12); -} - -.baseurl-input-wrap:not(.open) .baseurl-menu { - display: none; -} - -.baseurl-option { - display: grid; - gap: 2px; - width: 100%; - padding: 10px 12px; - border: 0; - border-radius: 10px; - background: transparent; - color: var(--text); - text-align: left; -} - -.baseurl-option:hover, -.baseurl-option:focus-visible { - background: color-mix(in srgb, var(--primary-soft) 72%, var(--surface)); - outline: none; -} - -.baseurl-option.selected { - font-weight: 700; -} - -.baseurl-option small { - color: var(--muted); - font-size: 12px; -} - -.baseurl-option i { - justify-self: end; - color: var(--primary); -} diff --git a/frontend/css/components/button.css b/frontend/css/components/button.css deleted file mode 100644 index 26d80a3e..00000000 --- a/frontend/css/components/button.css +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================ - components/button.css — Buttons (.btn / .btn-primary / .btn-outline / .btn-success / .action-button / .btn-lg / .button-row) - layer1: line 380-418 (.action-button / .btn-* / .btn-primary etc) + 631-640 (.button-row) - layer2: line 2964-2982 (compact 38px buttons + .button-row layout) - ============================================================ */ - -.action-button, -.btn-lg { - min-height: 70px; - border-radius: 12px; - font-size: 21px; - font-weight: 750; -} - -.btn-primary { - --bs-btn-color: #fff; - --bs-btn-bg: var(--primary); - --bs-btn-border-color: var(--primary); - --bs-btn-hover-bg: color-mix(in srgb, var(--primary) 86%, #000); - --bs-btn-hover-border-color: color-mix(in srgb, var(--primary) 86%, #000); - --bs-btn-active-bg: var(--primary); - --bs-btn-active-border-color: var(--primary); - --bs-btn-disabled-color: #fff; - --bs-btn-disabled-bg: var(--primary); - --bs-btn-disabled-border-color: var(--primary); -} - -.btn-outline-primary { - --bs-btn-color: var(--primary); - --bs-btn-border-color: var(--primary); - --bs-btn-hover-bg: var(--primary); - --bs-btn-hover-border-color: var(--primary); -} - -.btn-success { - --bs-btn-bg: var(--success); - --bs-btn-border-color: var(--success); -} - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 12px; -} - -.button-row { - display: flex; - flex-wrap: wrap; - gap: 28px; - margin-top: 28px; -} - -.button-row .btn { - min-width: 210px; -} - -/* === Layer 2: compact desktop === */ - -.form-control-lg, -.form-select-lg, -.action-button, -.btn-lg { - min-height: 38px; - padding-inline: 12px; - border-radius: 9px; - font-size: 14px; -} - -.button-row { - justify-content: flex-end; - gap: 10px; - margin-top: 18px; -} - -.button-row .btn { - min-width: 92px; -} diff --git a/frontend/css/components/card.css b/frontend/css/components/card.css deleted file mode 100644 index 3c28eaac..00000000 --- a/frontend/css/components/card.css +++ /dev/null @@ -1,161 +0,0 @@ -/* ============================================================ - components/card.css — Panels, status cards, stat cards (.panel / .status-card / .stat-card / .terminal-panel / .soft-icon) - layer1: line 272-310 + 420-452 + 1185-1225 - layer2: line 2860-2865 + 3036-3052 (compact override) - ============================================================ */ - -.panel, -.status-card, -.terminal-panel { - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--surface); - box-shadow: var(--shadow); -} - -.status-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 28px; -} - -.status-card { - min-height: 278px; - padding: 32px 24px; - text-align: center; -} - -.status-card h2 { - margin: 0 0 26px; - color: var(--muted); - font-size: 25px; - font-weight: 750; -} - -.status-card strong { - display: block; - margin-top: 22px; - color: var(--success); - font-size: 31px; - font-weight: 800; -} - -.status-card:nth-child(3) strong { - color: var(--text); -} - -.status-card strong.muted-text { - color: var(--warning); -} - -.panel { - padding: 28px; -} - -.panel-header, -.title-with-icon { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; -} - -.title-with-icon { - justify-content: flex-start; -} - -.panel h2 { - margin: 0; - color: var(--text); - font-size: 26px; - font-weight: 760; -} - -.soft-icon { - display: grid; - place-items: center; - width: 48px; - height: 48px; - border-radius: 10px; - color: var(--primary); - background: var(--primary-soft); - font-size: 26px; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 24px; - margin-top: 22px; -} - -.stat-card { - display: flex; - align-items: center; - gap: 22px; - min-height: 135px; - padding: 26px; - border: 1px solid var(--line); - border-radius: 16px; - background: var(--surface); - box-shadow: var(--shadow); -} - -.stat-card i { - display: grid; - place-items: center; - width: 70px; - height: 70px; - border-radius: 50%; - color: var(--primary); - background: var(--primary-soft); - font-size: 34px; -} - -.stat-card strong { - display: block; - color: var(--text); - font-size: 36px; -} - -.stat-card span { - color: var(--muted); - font-size: 18px; - font-weight: 700; -} - -/* === Layer 2: compact desktop === */ - -.form-panel, -.panel { - padding: 24px; - border-radius: 14px; - box-shadow: none; -} - -.stats-grid { - grid-template-columns: repeat(4, 1fr); - gap: 12px; -} - -.stat-card { - min-height: 82px; - padding: 14px; - border-radius: 12px; - box-shadow: none; -} - -.stat-card i { - width: 36px; - height: 36px; - font-size: 18px; -} - -/* === Cross-component layer2 拆出 (原 line 3022 + 3058 复合 selector 拆解) === */ -.stat-card strong { - font-size: 20px; -} - -.panel h2 { - font-size: 16px; -} diff --git a/frontend/css/components/codex-conversations.css b/frontend/css/components/codex-conversations.css deleted file mode 100644 index 1c65a40e..00000000 --- a/frontend/css/components/codex-conversations.css +++ /dev/null @@ -1,411 +0,0 @@ -/* components/codex-conversations.css — #271 文档管理页第 5 tab 对话导出 */ - -/* cas-dropdown: 自定义下拉(替代原生 select,menu 锚定在 toggle 正下方) */ -.cas-dropdown { - position: relative; - display: inline-block; -} -.cas-dropdown-toggle { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 6px; - width: 100%; - min-height: 30px; - padding: 4px 10px; - font-size: 13px; - line-height: 1.4; - background: var(--surface, #fff); - border: 1px solid var(--line, #d1d5db); - border-radius: 8px; - cursor: pointer; - color: inherit; - text-align: left; -} -.cas-dropdown-toggle:hover { - border-color: color-mix(in srgb, var(--primary) 50%, var(--line)); -} -.cas-dropdown[aria-expanded="true"] .cas-dropdown-toggle, -.cas-dropdown-toggle[aria-expanded="true"] { - border-color: var(--primary); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent); -} -.cas-dropdown-label { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.cas-dropdown-caret { - font-size: 11px; - color: var(--muted, #6b7280); - transition: transform 0.15s ease; -} -.cas-dropdown-toggle[aria-expanded="true"] .cas-dropdown-caret { - transform: rotate(180deg); -} -.cas-dropdown-menu { - position: absolute; - top: calc(100% + 4px); - left: 0; - min-width: 100%; - max-width: 320px; - max-height: 280px; - overflow-y: auto; - margin: 0; - padding: 4px; - background: var(--surface, #fff); - border: 1px solid var(--line, #d1d5db); - border-radius: 8px; - box-shadow: 0 6px 24px rgba(15, 23, 42, .12); - z-index: 1500; - list-style: none; -} -.cas-dropdown-menu li { - padding: 6px 10px; - border-radius: 6px; - font-size: 13px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.cas-dropdown-menu li:hover { - background: color-mix(in srgb, var(--primary) 10%, transparent); -} -.cas-dropdown-menu li.cas-dropdown-selected { - background: color-mix(in srgb, var(--primary) 14%, transparent); - color: var(--primary); - font-weight: 600; -} - -.codex-conv-filterbar { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 8px; - flex-wrap: wrap; -} -.codex-conv-search { flex: 1 1 200px; min-width: 180px; } -.codex-conv-filter { width: 110px; flex: 0 0 110px; } -.codex-conv-filter-cwd { width: 200px; flex: 0 0 200px; } -.codex-conv-summary { - color: var(--muted, #6b7280); - font-size: 12px; - flex: 0 0 auto; -} - -.codex-conv-toolbar { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 8px; - flex-wrap: wrap; -} -.codex-conv-select-all-label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - cursor: pointer; -} -.codex-conv-format { width: 150px; flex: 0 0 150px; } - -/* 默认导出文件夹:二合一 input(整块可点 → 打开 dir picker) */ -.codex-conv-default-dir { - display: inline-flex; - align-items: center; - gap: 6px; - flex: 1 1 220px; - min-width: 160px; - padding: 4px 8px; - background: var(--surface, #fff); - border: 1px solid var(--line, #d1d5db); - border-radius: 8px; - cursor: pointer; - transition: border-color 0.15s; -} -.codex-conv-default-dir:hover { - border-color: color-mix(in srgb, var(--primary) 50%, var(--line)); -} -.codex-conv-default-dir-icon { - color: var(--muted, #6b7280); - font-size: 13px; - flex: 0 0 auto; -} -.codex-conv-default-dir-input { - flex: 1; - min-width: 0; - border: 0; - outline: 0; - background: transparent; - font-size: 13px; - padding: 0; - cursor: pointer; - text-overflow: ellipsis; - color: inherit; -} -.codex-conv-default-dir-input::placeholder { - color: var(--muted, #9ca3af); -} -.codex-conv-default-dir-clear { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - padding: 0; - border: 0; - background: transparent; - border-radius: 50%; - color: var(--muted, #6b7280); - cursor: pointer; -} -.codex-conv-default-dir-clear:hover { - background: color-mix(in srgb, var(--muted, #6b7280) 18%, transparent); - color: var(--text, #111); -} - -.codex-conv-warning { - background: color-mix(in srgb, var(--primary) 6%, transparent); - border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent); - border-radius: 8px; - padding: 6px 10px; - font-size: 12px; - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 10px; - color: var(--muted, #4b5563); -} - -/* filter bar + toolbar 不 grow,占自己内容高度;body 吃 pane 剩余高度 */ -#codexConversationsTab > .codex-conv-filterbar, -#codexConversationsTab > .codex-conv-toolbar { - flex-shrink: 0; -} -.codex-conv-body { - display: grid; - grid-template-columns: 360px 1fr; - gap: 12px; - flex: 1; - min-height: 0; /* 让内部 overflow 生效 */ - padding-bottom: 12px; /* 与顶部 sticky filter 上方间距对称 */ -} - -.codex-conv-list { - list-style: none; - margin: 0; - padding: 4px; - overflow-y: auto; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface); -} - -.codex-conv-list-item { - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - display: flex; - flex-direction: column; - gap: 2px; - border: 1px solid transparent; -} -.codex-conv-list-item:hover { background: color-mix(in srgb, var(--primary) 5%, transparent); } -.codex-conv-list-item.selected { - background: color-mix(in srgb, var(--primary) 12%, transparent); - border-color: color-mix(in srgb, var(--primary) 30%, transparent); -} -.codex-conv-list-item-row { - display: flex; - align-items: center; - gap: 6px; -} -.codex-conv-list-checkbox { margin: 0; } -.codex-conv-list-title { - font-weight: 600; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -} -.codex-conv-list-kind { - font-size: 10px; - padding: 1px 6px; - border-radius: 999px; - text-transform: uppercase; - font-weight: 600; - letter-spacing: 0.04em; -} -.codex-conv-list-kind.active { - background: color-mix(in srgb, var(--success, #16a34a) 18%, transparent); - color: var(--success, #16a34a); -} -.codex-conv-list-kind.archived { - background: color-mix(in srgb, var(--muted, #6b7280) 18%, transparent); - color: var(--muted, #6b7280); -} -.codex-conv-list-meta { - font-size: 11px; - color: var(--muted, #6b7280); - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.codex-conv-detail { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface); - padding: 12px 14px; - overflow-y: auto; -} -.codex-conv-detail-empty { - color: var(--muted, #6b7280); - text-align: center; - margin-top: 40px; -} -.codex-conv-detail h3 { - margin: 0 0 6px; - font-size: 16px; -} -.codex-conv-detail-meta { - font-size: 12px; - color: var(--muted, #6b7280); - margin-bottom: 12px; -} -.codex-conv-turn { - margin-bottom: 14px; - padding-bottom: 14px; - border-bottom: 1px dashed var(--line); -} -.codex-conv-turn:last-child { border-bottom: 0; } -.codex-conv-turn-header { - font-weight: 600; - font-size: 13px; - margin-bottom: 6px; - color: var(--muted, #6b7280); -} -.codex-conv-item { - margin-bottom: 8px; -} -.codex-conv-item-role { - font-weight: 600; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--primary); - margin-bottom: 2px; -} -.codex-conv-item-text { - white-space: pre-wrap; - font-size: 13px; - line-height: 1.5; - word-break: break-word; -} -/* 渲染后的 markdown preview body */ -.codex-conv-item-text.codex-conv-md { - white-space: normal; -} -.codex-conv-md p { margin: 0 0 8px; } -.codex-conv-md h1, .codex-conv-md h2, .codex-conv-md h3, -.codex-conv-md h4, .codex-conv-md h5, .codex-conv-md h6 { - margin: 12px 0 4px; - font-weight: 600; - line-height: 1.3; -} -.codex-conv-md h1 { font-size: 17px; } -.codex-conv-md h2 { font-size: 15px; } -.codex-conv-md h3 { font-size: 14px; } -.codex-conv-md h4, .codex-conv-md h5, .codex-conv-md h6 { font-size: 13px; } -.codex-conv-md ul, .codex-conv-md ol { - margin: 0 0 8px; - padding-left: 22px; -} -.codex-conv-md li { margin-bottom: 2px; } -.codex-conv-md blockquote { - margin: 0 0 8px; - padding: 4px 10px; - border-left: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); - background: color-mix(in srgb, var(--primary) 4%, transparent); - color: var(--muted, #4b5563); -} -.codex-conv-md code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.92em; - background: color-mix(in srgb, var(--muted, #6b7280) 12%, transparent); - padding: 1px 5px; - border-radius: 4px; -} -.codex-conv-md-code { - background: color-mix(in srgb, var(--muted, #6b7280) 10%, transparent); - padding: 8px 10px; - border-radius: 6px; - overflow: auto; - max-height: 280px; - margin: 0 0 8px; -} -.codex-conv-md-code code { - background: transparent; - padding: 0; - font-size: 12px; -} -.codex-conv-md a { color: var(--primary); } -.codex-conv-md hr { - border: 0; - border-top: 1px dashed var(--line); - margin: 10px 0; -} -.codex-conv-md strong { font-weight: 600; } - -.codex-conv-item-text.codex-conv-tool { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - background: color-mix(in srgb, var(--muted, #6b7280) 8%, transparent); - padding: 6px 8px; - border-radius: 6px; - max-height: 180px; - overflow: auto; -} -.codex-conv-compacted { - background: color-mix(in srgb, var(--primary) 8%, transparent); - border-left: 3px solid var(--primary); - padding: 6px 10px; - font-style: italic; - font-size: 12px; -} - -/* Options dialog 简陋实现:用 dialog 元素 */ -dialog.codex-conv-options-dialog { - border: 1px solid var(--line); - border-radius: 12px; - padding: 16px 20px; - background: var(--surface); - color: inherit; - width: 420px; - max-width: 90vw; -} -dialog.codex-conv-options-dialog::backdrop { - background: rgba(0, 0, 0, .35); - backdrop-filter: blur(2px); -} -dialog.codex-conv-options-dialog h3 { - margin: 0 0 8px; - font-size: 15px; -} -.codex-conv-options-row { - display: flex; - align-items: center; - gap: 8px; - margin: 8px 0; - font-size: 13px; -} -.codex-conv-options-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 14px; -} diff --git a/frontend/css/components/codex-mcp.css b/frontend/css/components/codex-mcp.css deleted file mode 100644 index d83c7909..00000000 --- a/frontend/css/components/codex-mcp.css +++ /dev/null @@ -1,671 +0,0 @@ -/* Codex 文档管理 → MCP tab 专属布局 - 3 sub-tab(Servers form / Plugins / Marketplace),sub-nav + 内容 flex 自适应 */ - -.codex-mcp-tab { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -.codex-mcp-subnav { - display: flex; - gap: 4px; - border-bottom: 1px solid var(--line); - margin-bottom: 10px; -} - -.codex-mcp-subnav-item { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - border: none; - background: transparent; - color: var(--muted); - cursor: pointer; - font-size: 14px; - border-bottom: 2px solid transparent; - border-radius: 6px 6px 0 0; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; -} - -.codex-mcp-subnav-item:hover { - background: var(--surface); - color: var(--text); -} - -.codex-mcp-subnav-item.active { - color: var(--primary); - border-bottom-color: var(--primary); - background: var(--primary-soft); -} - -.codex-mcp-subpane { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ── Servers 2-column split ── */ - -.codex-mcp-split { - display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - gap: 14px; - flex: 1; - min-height: 0; - margin-top: 10px; -} - -.codex-mcp-list-wrap { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; -} - -.codex-mcp-list-col { - border: 1px solid var(--line); - border-radius: 8px; - overflow: auto; - background: var(--surface); - padding: 6px; - flex: 1; - min-height: 0; -} - -.codex-mcp-list-legend { - border: 1px dashed var(--line); - border-radius: 8px; - padding: 10px 12px; - font-size: 11.5px; - line-height: 1.55; - color: var(--muted); - background: var(--surface); -} - -.codex-mcp-list-legend div { - margin-bottom: 4px; -} - -.codex-mcp-list-legend div:last-child { - margin-bottom: 0; -} - -.codex-mcp-list-legend code { - font-size: 11px; - background: var(--bg); - padding: 1px 4px; - border-radius: 3px; -} - -.codex-mcp-form-col { - border: 1px solid var(--line); - border-radius: 8px; - overflow: auto; - background: var(--surface); - padding: 12px; - display: flex; - flex-direction: column; - min-height: 0; -} - -.codex-mcp-json-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 1px solid var(--line); -} - -.codex-mcp-json-name { - font-size: 14px; - font-weight: 700; - color: var(--text); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} - -.codex-mcp-json-delete { - color: var(--muted); -} - -.codex-mcp-json-pre { - flex: 1; - min-height: 200px; - margin: 0; - padding: 10px 12px; - background: var(--bg); - border: 1px solid var(--line); - border-radius: 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12.5px; - line-height: 1.5; - color: var(--text); - overflow: auto; - white-space: pre-wrap; - word-break: break-word; -} - -.codex-mcp-json-area { - flex: 1; - min-height: 240px; - padding: 10px 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12.5px; - line-height: 1.5; - resize: none; -} - -.codex-mcp-json-error { - margin-top: 8px; - padding: 8px 12px; - background: rgba(239, 68, 68, 0.1); - border: 1px solid var(--danger); - border-radius: 6px; - color: var(--danger); - font-size: 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} - -.codex-mcp-list-item { - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - border: 1px solid transparent; - margin-bottom: 4px; - transition: background 120ms ease, border-color 120ms ease; -} - -.codex-mcp-list-item:hover { - background: var(--primary-soft); -} - -.codex-mcp-list-item.active { - background: var(--primary-soft); - border-color: var(--primary); -} - -.codex-mcp-list-item.disabled { - opacity: 0.5; -} - -.codex-mcp-list-item-name { - font-size: 14px; - font-weight: 600; - color: var(--text); - display: flex; - align-items: center; - gap: 6px; - word-break: break-word; -} - -.codex-mcp-list-item-meta { - font-size: 11px; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} - -/* transport chip:HTTP 蓝 / Stdio 橙 */ -.codex-mcp-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 99px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; -} - -.codex-mcp-chip.http { - background: rgba(59, 130, 246, 0.15); - color: #2563eb; -} - -.codex-mcp-chip.stdio { - background: rgba(245, 158, 11, 0.15); - color: #d97706; -} - -.codex-mcp-chip.disabled { - background: rgba(148, 163, 184, 0.2); - color: var(--muted); -} - -/* form 字段密度 */ - -.codex-mcp-form-col .form-group { - margin-bottom: 10px; -} - -.codex-mcp-form-col .form-label { - font-size: 12px; - color: var(--muted); - margin-bottom: 3px; -} - -.codex-mcp-form-col .form-control { - font-size: 13px; - padding: 6px 10px; -} - -.codex-mcp-form-col .codex-mcp-radio-row { - display: flex; - gap: 14px; - align-items: center; -} - -.codex-mcp-form-col .codex-mcp-radio-row label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 13px; - cursor: pointer; -} - -.codex-mcp-form-col .codex-mcp-advanced-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - margin-top: 6px; - font-size: 12px; - color: var(--primary); - cursor: pointer; - user-select: none; -} - -.codex-mcp-form-col .codex-mcp-advanced-pane { - border-top: 1px dashed var(--line); - margin-top: 8px; - padding-top: 10px; -} - -.codex-mcp-empty-form { - text-align: center; - color: var(--muted); - font-size: 13px; - padding: 32px 12px; -} - -/* ── 卡片 + 可视化 form ── */ - -.codex-mcp-card { - border: 1px solid var(--line); - border-radius: 10px; - padding: 14px 16px; - margin-bottom: 12px; - background: var(--bg); -} - -.codex-mcp-card-title { - font-size: 13px; - font-weight: 700; - color: var(--text); - margin-bottom: 10px; - display: flex; - align-items: center; - gap: 6px; -} - -.codex-mcp-required { - color: var(--danger); - font-weight: 700; -} - -.codex-mcp-hint { - display: block; - margin-top: 4px; - color: var(--muted); - font-size: 11px; - line-height: 1.5; -} - -.codex-mcp-hint code { - background: var(--surface); - padding: 1px 4px; - border-radius: 3px; - font-size: 10.5px; -} - -.codex-mcp-transport-option { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: 8px; - cursor: pointer; - background: var(--surface); - transition: border-color 120ms ease, background 120ms ease; -} - -.codex-mcp-transport-option:hover { - border-color: var(--primary); -} - -.codex-mcp-transport-option.selected { - border-color: var(--primary); - background: var(--primary-soft); -} - -.codex-mcp-transport-option input[type="radio"] { - margin-top: 3px; - cursor: pointer; -} - -.codex-mcp-transport-body { - flex: 1; - min-width: 0; -} - -.codex-mcp-transport-name { - font-size: 14px; - font-weight: 600; - color: var(--text); - margin-bottom: 4px; -} - -.codex-mcp-transport-desc { - font-size: 12px; - color: var(--muted); - line-height: 1.5; -} - -.codex-mcp-transport-desc code { - background: var(--bg); - padding: 1px 4px; - border-radius: 3px; - font-size: 11px; -} - -.codex-mcp-checkbox-row { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 6px 0; - cursor: pointer; - font-size: 13px; -} - -.codex-mcp-checkbox-row input[type="checkbox"] { - margin-top: 3px; - cursor: pointer; -} - -.codex-mcp-checkbox-row strong { - font-weight: 600; -} - -.codex-mcp-checkbox-row code { - font-size: 11px; - background: var(--bg); - padding: 1px 4px; - border-radius: 3px; -} - -.codex-mcp-arg-list, -.codex-mcp-kv-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 4px; -} - -.codex-mcp-arg-row, -.codex-mcp-kv-row { - display: flex; - align-items: center; - gap: 6px; -} - -.codex-mcp-arg-row .form-control, -.codex-mcp-kv-key, -.codex-mcp-kv-val { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12.5px; -} - -.codex-mcp-kv-eq { - color: var(--muted); - font-weight: 700; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} - -.btn-icon-only { - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - padding: 4px 6px; - cursor: pointer; - color: var(--muted); - display: inline-flex; - align-items: center; - justify-content: center; -} - -.btn-icon-only:hover { - background: rgba(239, 68, 68, 0.1); - color: var(--danger); - border-color: var(--danger); -} - -.codex-mcp-add-row { - font-size: 12px; - padding: 4px 10px; -} - -.codex-mcp-advanced-details { - margin-top: 4px; -} - -.codex-mcp-advanced-details > summary { - cursor: pointer; - padding: 6px 10px; - font-size: 12px; - color: var(--primary); - background: var(--surface); - border: 1px solid var(--line); - border-radius: 8px; - list-style: none; - user-select: none; -} - -.codex-mcp-advanced-details > summary::-webkit-details-marker { - display: none; -} - -.codex-mcp-advanced-details > summary::before { - content: "▸ "; - color: var(--muted); -} - -.codex-mcp-advanced-details[open] > summary::before { - content: "▾ "; -} - -/* ── Plugins list ── */ - -.codex-mcp-plugin-list { - list-style: none; - padding: 0; - margin: 10px 0 0; - overflow: auto; - flex: 1; - min-height: 0; -} - -.codex-mcp-plugin-item { - border: 1px solid var(--line); - border-radius: 8px; - padding: 12px 14px; - margin-bottom: 8px; - background: var(--surface); -} - -.codex-mcp-plugin-item-head { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.codex-mcp-plugin-name { - font-size: 15px; - font-weight: 600; - color: var(--text); -} - -.codex-mcp-plugin-version { - font-size: 11px; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} - -.codex-mcp-plugin-desc { - font-size: 13px; - color: var(--text); - margin: 6px 0; - word-break: break-word; -} - -.codex-mcp-plugin-caps { - font-size: 11px; - color: var(--muted); - margin: 4px 0; -} - -.codex-mcp-plugin-actions { - display: flex; - gap: 8px; - margin-top: 8px; -} - -/* ── Marketplace ── */ - -.codex-mcp-sources-row { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 6px 0; - border-bottom: 1px solid var(--line); - margin-bottom: 10px; -} - -.codex-mcp-source-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border: 1px solid var(--line); - border-radius: 99px; - background: var(--surface); - font-size: 12px; - cursor: pointer; - user-select: none; -} - -.codex-mcp-source-chip.active { - border-color: var(--primary); - background: var(--primary-soft); - color: var(--primary); -} - -.codex-mcp-source-chip.disabled { - opacity: 0.5; -} - -.codex-mcp-source-remove { - background: none; - border: none; - padding: 0; - margin-left: 2px; - color: var(--danger); - cursor: pointer; - font-size: 12px; -} - -.codex-mcp-marketplace-toolbar { - display: flex; - gap: 8px; - margin-bottom: 12px; - align-items: center; -} - -.codex-mcp-marketplace-toolbar .form-control { - flex: 1; - font-size: 13px; -} - -.codex-mcp-section-title { - font-size: 14px; - font-weight: 700; - margin: 10px 0 6px; - color: var(--text); -} - -.codex-mcp-market-list { - list-style: none; - padding: 0; - margin: 0 0 10px; -} - -.codex-mcp-market-item { - border: 1px solid var(--line); - border-radius: 8px; - padding: 10px 12px; - margin-bottom: 6px; - background: var(--surface); - display: flex; - align-items: flex-start; - gap: 10px; -} - -.codex-mcp-market-item-body { - flex: 1; - min-width: 0; -} - -.codex-mcp-market-item-name { - font-size: 14px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} - -.codex-mcp-market-item-desc { - font-size: 12px; - color: var(--muted); - margin-top: 4px; - word-break: break-word; -} - -.codex-mcp-market-item-action { - flex-shrink: 0; -} - -.codex-mcp-market-source-tag { - font-size: 10px; - color: var(--muted); - background: var(--bg); - border: 1px solid var(--line); - border-radius: 4px; - padding: 1px 6px; -} - -.codex-mcp-market-error { - background: rgba(239, 68, 68, 0.1); - border: 1px solid var(--danger); - border-radius: 6px; - padding: 8px 12px; - font-size: 12px; - color: var(--danger); - margin-bottom: 8px; -} diff --git a/frontend/css/components/codex-path-picker.css b/frontend/css/components/codex-path-picker.css deleted file mode 100644 index c3ccb1a4..00000000 --- a/frontend/css/components/codex-path-picker.css +++ /dev/null @@ -1,466 +0,0 @@ -/* AGENTS.md 路径 picker — custom div-based dropdown,支持 chip-style 标签 - (native - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/js/api.js b/frontend/js/api.js deleted file mode 100644 index 762a3c34..00000000 --- a/frontend/js/api.js +++ /dev/null @@ -1,772 +0,0 @@ -(function () { - 'use strict'; - - const BASE = ''; - - async function api(method, path, body) { - const opts = { method, headers: { 'X-CAS-Request': '1' } }; - if (body !== undefined) { - opts.headers['Content-Type'] = 'application/json'; - opts.body = JSON.stringify(body); - } - const resp = await fetch(BASE + path, opts); - // 非 JSON 响应兜底(MOC-145):反代/网关 502/504 或长阻塞请求中断时 body 常是 HTML/ - // 空,直接 resp.json() 抛裸 SyntaxError("Unexpected token <")误导排查。这里捕获并 - // 抛带 HTTP status 的清晰错误,让上层 toast 有可读信息。 - let data; - try { - data = await resp.json(); - } catch (parseErr) { - const error = new Error( - `Request failed: ${method} ${path} — HTTP ${resp.status} ${resp.statusText || ""} ` + - `(非 JSON 响应,可能是网关错误或服务未就绪)` - ); - error.errors = []; - error.responseData = { status: resp.status, parseError: String(parseErr) }; - throw error; - } - if (!resp.ok || data.success === false) { - // baseMessage 直接用 backend message(可能是 i18n key 如 "models.fetchFailed", - // 上层负责翻译;也可能是 raw string)。**不在这里 inline errors[0]**: - // backend 现在返结构化 errors[] (object 数组,含 code/host/statusCode), - // string 拼接会变 "[object Object]"。让上层(如 formatModelFetchError) - // 按 i18n 翻译每条 error 后再拼。 - const baseMessage = data.message || `Request failed: ${method} ${path}`; - const error = new Error(baseMessage); - error.errors = Array.isArray(data.errors) ? data.errors : []; - error.responseData = data; - throw error; - } - return data; - } - - // ── 工具 ── - const ICON_MAP = { - deepseek: { logo: 'assets/providers/deepseek.ico' }, - kimi: { logo: 'assets/providers/kimi.ico' }, - moonshot: { logo: 'assets/providers/kimi.ico' }, - xiaomi: { logo: 'assets/providers/xiaomi-mimo.png' }, - mimo: { logo: 'assets/providers/xiaomi-mimo.png' }, - qiniu: { logo: 'assets/providers/qiniu.ico' }, - qnaigc: { logo: 'assets/providers/qiniu.ico' }, - zhipu: { logo: 'assets/providers/zhipu.png' }, - bigmodel: { logo: 'assets/providers/zhipu.png' }, - glm: { logo: 'assets/providers/zhipu.png' }, - // [MOC-252] GLM 账号登录(OAuth)preset:zai-login / bigmodel-login。bigmodel-login - // 已被上面 'bigmodel' 子串命中;zai-login 的 id/baseUrl(z.ai)不含 zhipu/bigmodel/glm - // (仅 name "GLM(Z.ai)" 偶然含 glm,脆弱),显式加 'zai-login' + 'z-ai'(baseUrl - // api.z.ai 经 `_`/空格→`-` normalize 后是 api-z.ai,'.' 不变,所以匹配 'z-ai' 不命中; - // 用 'zai-login' id 子串稳)兜底,统一复用智谱 logo。 - 'zai-login': { logo: 'assets/providers/zhipu.png' }, - siliconflow: { icon: 'bi-diagram-3-fill' }, - bailian: { logo: 'assets/providers/aliyun.ico' }, - dashscope: { logo: 'assets/providers/aliyun.ico' }, - aliyun: { logo: 'assets/providers/aliyun.ico' }, - minimax: { logo: 'assets/providers/minimax.ico' }, - minimaxi: { logo: 'assets/providers/minimax.ico' }, - // Gemini CLI(OAuth)provider — 用 Gemini 品牌四角星 spark 图标(brand mark - // 跟 gemini.google.com 一致)。**必须放在 'gemini' 通用规则前**(JS object - // iteration 顺序 = insertion 顺序),computeIcon 子串匹配 'gemini-cli' 命中 - // 这条 才能优先于下面的 'gemini' 走到 google-ai-studio.png。 - // 不加 'cloudcode' 子串(silent-failure-hunter C2 修):太宽会误命中任何含 - // cloudcode 的 provider 名 / baseUrl 包括用户自定义 — 仅 'gemini-cli' id - // stable 匹配 - 'gemini-cli': { logo: 'assets/providers/gemini.svg' }, - // Antigravity OAuth provider — Google Antigravity IDE 同样走 cloudcode-pa - // 但 OAuth client_id / brand 不同,用专属箭头 mark 区分(避免跟 gemini 共用 - // 图标让用户分不清两个 OAuth provider)。`antigravity-oauth` 子串命中 preset - // id;不加更宽的 'antigravity' 防误命中用户自定义 provider 名 - 'antigravity-oauth': { logo: 'assets/providers/antigravity.png' }, - // Google AI Studio:用官方品牌图标(从 aistudio.google.com 抓的 - // ai_studio_favicon_2_128x128.png,圆形黑底带 sparkle/方框 mark)。 - // 子串命中 "google" / "gemini" / "aistudio" / "generativelanguage" 任一都映射到此。 - google: { logo: 'assets/providers/google-ai-studio.png' }, - gemini: { logo: 'assets/providers/google-ai-studio.png' }, - aistudio: { logo: 'assets/providers/google-ai-studio.png' }, - generativelanguage: { logo: 'assets/providers/google-ai-studio.png' }, - // Grok Web 反代 provider — 用 grok.com 官方 favicon SVG(从 - // https://grok.com/images/favicon.svg 抓的,黑色圆角方块 + 白色 grok mark)。 - // `grok-web` 子串命中 preset id / apiFormat;不加更宽的 `grok` 防误命中用户 - // 自定义 provider 名含 "grok" 但实际不是 web 反代场景 - 'grok-web': { logo: 'assets/providers/grok.svg' }, - // AnyRouter 第三方聚合 — 用 anyrouter.top 官方 logo。 - // `anyrouter` 子串命中 preset id / name / baseUrl 任一。 - anyrouter: { logo: 'assets/providers/anyrouter.png' }, - }; - - function buildCustomThirdPartyPreset() { - const i18n = window.CCI18n; - const tr = (k, fallback) => (i18n && i18n.t(k)) || fallback; - return { - id: 'custom-third-party', - name: tr('providersAdd.customThirdPartyName', '自定义第三方'), - baseUrl: '', - apiFormat: 'OpenAI', - authScheme: 'bearer', - models: {}, - modelOptions: {}, - baseUrlOptions: [], - baseUrlHint: tr('providersAdd.customThirdPartyHint', ''), - requestOptionPresets: {}, - extraHeaders: {}, - modelCapabilities: {}, - requestOptions: {}, - icon: 'bi-puzzle', - allowApiFormatSelection: true, - }; - } - - function computeIcon(provider) { - // **包含 apiFormat** 让 user 自加的 OAuth provider(name 自填,id UUID)也 - // 能命中专属图标 — 否则会 fall through 到 baseUrl 的 'google' 子串撞 - // google-ai-studio.png(2026-05-11 修)。 - // **normalize**:把 lookup string 里所有 `_` / 空格 全部转 `-`,这样: - // - apiFormat="antigravity_oauth" 命中 ICON_MAP key 'antigravity-oauth' - // - name="Gemini CLI"(空格) 命中 'gemini-cli'(dash) - // 用单一 dash 形态做规范化(ICON_MAP key 全是 dash 形) - const raw = `${provider.id || ''} ${provider.name || ''} ${provider.baseUrl || ''} ${provider.apiFormat || ''}`.toLowerCase(); - const lookup = raw.replace(/[_\s]+/g, '-'); - for (const [key, val] of Object.entries(ICON_MAP)) { - if (lookup.includes(key)) return val; - } - return { icon: 'bi-plug-fill' }; - } - - function mapProvider(provider, activeId) { - const models = provider.models || {}; - return { - id: provider.id, - name: provider.name, - baseUrl: provider.baseUrl, - apiFormat: ['openai', 'openai_chat'].includes(provider.apiFormat) ? 'openai_chat' : (provider.apiFormat || 'openai_chat'), - authScheme: provider.authScheme || 'bearer', - hasApiKey: !!provider.hasApiKey, - // [MOC-211] 后端 public_provider mask 出 mimoCookie、只暴露 hasMimoCookie;显式挑字段 - // 必须带上,否则编辑页登录后仍显「未登录」(mapper 不列即丢,见 api.js mapper 契约)。 - hasMimoCookie: !!provider.hasMimoCookie, - extraHeaders: provider.extraHeaders || {}, - modelCapabilities: provider.modelCapabilities || {}, - requestOptions: provider.requestOptions || {}, - default: provider.id === activeId, - isBuiltin: !!provider.isBuiltin, - // [MOC-173] auto-review 审查模型槽位 key(gpt_5_X);显式挑字段,不加这行前端拿不到后端返的值。 - reviewModelSlot: provider.reviewModelSlot || '', - mappings: { - default: models.default || '', - gpt_5_5: models.gpt_5_5 || '', - gpt_5_4: models.gpt_5_4 || '', - gpt_5_4_mini: models.gpt_5_4_mini || '', - gpt_5_3_codex: models.gpt_5_3_codex || '', - gpt_5_2: models.gpt_5_2 || '', - }, - ...computeIcon(provider), - }; - } - - function providerBody(payload, includeModels = true) { - const body = { - name: payload.name, - baseUrl: payload.baseUrl, - authScheme: payload.authScheme || 'bearer', - // 未知值 / 缺失 → "openai_chat" fallback(跟后端 normalize_provider_api_format 对齐)。 - // 历史 v1.x 这里 fallback 是 "responses",造成 MiMo / 老配置升级时绕过代理 → 404。 - // **修复历史(2026-05-10)**:旧实现把白名单外任何 apiFormat(包括新加的 - // `gemini_native`)强制改写成 `'openai_chat'` → backend 收到 openai_chat - // 走 /chat/completions 探测 → Gemini native 端点不存在 → 404(用户截图反馈)。 - // 改成 passthrough 已知协议(responses/openai_chat/gemini_native/anthropic_messages - // + 别名),让后端 normalize_provider_api_format 唯一负责协议规范化。 - apiFormat: (() => { - const v = (payload.apiFormat || '').toLowerCase().replace(/-/g, '_'); - if (['responses', 'openai_responses'].includes(v)) return 'responses'; - if (['anthropic_messages', 'anthropic', 'claude', 'messages', 'claude_messages'].includes(v)) return 'anthropic_messages'; - if (['gemini_native', 'google_ai_studio', 'gemini'].includes(v)) return 'gemini_native'; - // Cloud Code Assist OAuth(impersonate gemini-cli)— passthrough, - // 后端 normalize_provider_api_format 识别 + GeminiCliAdapter 路由。 - // 漏 passthrough 会被 fallback 'openai_chat',OAuth provider 退化成 - // 用 api_key+/chat/completions 探测 cloudcode-pa 必 404。2026-05-11 实测 - if (['gemini_cli_oauth', 'gemini_oauth', 'google_oauth_cloud_code'].includes(v)) return 'gemini_cli_oauth'; - // Antigravity OAuth(Google Antigravity IDE,跟 gemini-cli 共用 cloudcode-pa - // 上游但不同 OAuth client_id + 独立 token 文件)— passthrough,后端 - // GeminiCliAdapter 按 apiFormat 别名分流到 antigravity-oauth.json token。 - // 不接受裸 'antigravity' alias —— 怕 legacy 配置 / 用户手填把别的 provider - // (apiFormat 历史漂移值)误归 OAuth 路径(silent-failure I3 修) - if (['antigravity_oauth', 'google_oauth_antigravity'].includes(v)) return 'antigravity_oauth'; - // Grok Web 反代(R1 Plan A):passthrough 让后端 normalize_provider_api_format - // + grok_web adapter 路由。漏这条 → fallback 'openai_chat' → save 后 healing - // 强改 grok_web 但 grokWeb 字段也没 passthrough → 进半残态(2026-05-12 user - // 真机 E2E 报错 "需要 grokWeb.cookies.sso" 的根因) - if (['grok_web', 'grok', 'grok_com'].includes(v)) return 'grok_web'; - return 'openai_chat'; // openai / openai_chat / chat_completions / 空 / 未知 → openai_chat - })(), - extraHeaders: payload.extraHeaders || {}, - modelCapabilities: payload.modelCapabilities || {}, - requestOptions: payload.requestOptions || {}, - }; - if (payload.apiKey) { - body.apiKey = payload.apiKey; - } - if (includeModels) { - body.models = payload.models || {}; - } - // [MOC-173] auto-review 审查模型槽位:带键就下发(含空串 '' → 后端 remove 清除,回退复用主模型)。 - if (payload.reviewModelSlot !== undefined && payload.reviewModelSlot !== null) { - body.reviewModelSlot = payload.reviewModelSlot; - } - // R1 Plan A:grokWeb extra(cookies + statsigId override + UA override)必须 - // passthrough 到 backend payload。**此前漏掉**(2026-05-12 user E2E 真机 - // 反馈):前端 providerPayloadFromForm 拼了 payload.grokWeb,但这个 helper - // 一直没 forward → backend AddProviderInput.grok_web 永远是 None → P2 必填 - // check 命中报错。passthrough 后 backend 正常持久化到 provider.grokWeb - if (payload.grokWeb) { - body.grokWeb = payload.grokWeb; - } - return body; - } - - function mapLog(log) { - return { - at: log.time, - level: log.level.toLowerCase(), - message: log.message, - }; - } - - // ── 公开 API ── - window.CCApi = { - async getStatus() { - const data = await api('GET', '/api/status'); - const active = data.activeProvider; - return { - desktopConfigured: !!data.desktopConfigured, - proxyRunning: !!data.proxyRunning, - proxyPort: data.proxyPort || 18080, - activeProvider: active ? { name: active.name, id: active.id } : { name: '-', id: null }, - activeProviderId: data.activeProviderId, - desktopHealth: data.desktopHealth || { needsApply: false, issues: [] }, - exposeAllProviderModels: !!data.exposeAllProviderModels, - }; - }, - - async getProviders() { - const data = await api('GET', '/api/providers'); - return (data.providers || []).map(p => mapProvider(p, data.activeId)); - }, - - // MOC-32 PR-2b: silently dropped Responses tool types snapshot - // (`{total, by_type: {tool_type: count}}`)。前端 dashboard 在 total>0 时 - // 弹 warning 让 user / maintainer 看见 silent drop(防 MOC-32 类静默 bug - // 再藏 N 月);total=0 时隐藏 — 0 是 healthy 状态不要刷屏。 - async getDroppedTools() { - try { - return await api('GET', '/api/diagnostic/dropped-tools'); - } catch (_) { - return { total: 0, by_type: {} }; - } - }, - - async getProviderSecret(id) { - return api('GET', `/api/providers/${encodeURIComponent(id)}/secret`); - }, - - async getPresets() { - const data = await api('GET', '/api/presets'); - const builtin = (data.presets || []).map(p => ({ - id: p.id, - name: p.name, - baseUrl: p.baseUrl, - // 直接 passthrough 后端原值,让前端 normalizeApiFormat 唯一负责协议规范化。 - // **修复历史(2026-05-10)**:之前这里 hardcode `?'Responses':'OpenAI'`, - // 把任何不在白名单的 apiFormat(包括新加的 `gemini_native`)强制改写成 - // 字面量 `'OpenAI'`,导致 normalizeApiFormat 永远命中 default openai_chat - // 分支,UI 显示协议名错误。passthrough + 让 normalizeApiFormat 处理是 - // 唯一正确做法(它已识别 openai_chat / responses / anthropic_messages / gemini_native - // 各种子值,加新协议只需更新 normalizeApiFormat,不需要改这里)。 - apiFormat: p.apiFormat || 'openai_chat', - authScheme: p.authScheme || 'bearer', - models: p.models || {}, - modelOptions: p.modelOptions || {}, - baseUrlOptions: p.baseUrlOptions || [], - baseUrlHint: p.baseUrlHint || '', - requestOptionPresets: p.requestOptionPresets || {}, - extraHeaders: p.extraHeaders || {}, - modelCapabilities: p.modelCapabilities || {}, - requestOptions: p.requestOptions || {}, - // MOC-91:gray=true 标记 TOS 灰色 / 实验性 preset。getPresets 显式挑字段, - // **必须透传 gray**,否则前端 visiblePresets() 拿不到它 → 隐藏开关失效。 - gray: p.gray === true, - ...computeIcon(p), - })); - return [...builtin, buildCustomThirdPartyPreset()]; - }, - - async addProvider(payload) { - const data = await api('POST', '/api/providers', providerBody(payload)); - return data.provider || data; - }, - - async updateProvider(id, payload) { - const data = await api('PUT', `/api/providers/${encodeURIComponent(id)}`, providerBody(payload)); - return data.provider || data; - }, - - async deleteProvider(id) { - return api('DELETE', `/api/providers/${encodeURIComponent(id)}`); - }, - - async setDefaultProvider(id) { - return api('PUT', `/api/providers/${encodeURIComponent(id)}/default`); - }, - - async saveDraft(id, payload) { - return api('POST', `/api/providers/${encodeURIComponent(id)}/draft`, providerBody(payload, true)); - }, - - async activateProvider(id) { - return api('POST', `/api/providers/${encodeURIComponent(id)}/activate`); - }, - - // [MOC-211] 触发小米账号内嵌 webview 登录,抓取 session cookie 存到该 provider - // (用于 MiMo Token Plan 套餐用量查询)。后端阻塞到登录成功 / 超时 / 关窗。 - async mimoLogin(id) { - return api('POST', `/api/providers/${encodeURIComponent(id)}/mimo-login`); - }, - - async reorderProviders(providerIds) { - return api('PUT', '/api/providers/reorder', { providerIds }); - }, - - async testProvider(id) { - return api('POST', `/api/providers/${encodeURIComponent(id)}/test`); - }, - - async queryProviderUsage(id) { - return api('POST', `/api/providers/${encodeURIComponent(id)}/usage`); - }, - - async getProviderCompatibility() { - return api('GET', '/api/providers/compatibility'); - }, - - async testProviderPayload(payload) { - return api('POST', '/api/providers/test', providerBody(payload, true)); - }, - - async saveModelMappings(id, mappings) { - return api('PUT', `/api/providers/${encodeURIComponent(id)}/models`, { models: mappings }); - }, - - async fetchProviderModels(id) { - return api('GET', `/api/providers/${encodeURIComponent(id)}/models/available`); - }, - - async fetchProviderModelsPayload(payload) { - return api('POST', '/api/providers/models/available', providerBody(payload, false)); - }, - - async autofillProviderModels(id) { - return api('POST', `/api/providers/${encodeURIComponent(id)}/models/autofill`); - }, - - async getDesktopStatus() { - const data = await api('GET', '/api/desktop/status'); - const status = await api('GET', '/api/status'); - const proxyPort = status.proxyPort || 18080; - const registryConfig = data.keys || {}; - return { - configured: !!data.configured, - health: data.health || { needsApply: false, issues: [] }, - config: { - inferenceProvider: registryConfig.inferenceProvider || 'gateway', - inferenceGatewayBaseUrl: registryConfig.inferenceGatewayBaseUrl || `http://127.0.0.1:${proxyPort}`, - inferenceGatewayApiKey: registryConfig.inferenceGatewayApiKey ? '******' : '', - inferenceGatewayAuthScheme: registryConfig.inferenceGatewayAuthScheme || 'bearer', - inferenceModels: registryConfig.inferenceModels || '[]', - }, - }; - }, - - async configureDesktop() { - const result = await api('POST', '/api/desktop/configure'); - return result; - }, - - async clearDesktop() { - return api('POST', '/api/desktop/clear'); - }, - - async getDesktopSnapshots() { - const data = await api('GET', '/api/desktop/snapshots'); - return data.snapshots || []; - }, - - async restoreDesktopSnapshot(snapshotId) { - return api('POST', '/api/desktop/restore', { - snapshotId, - cleanupAll: true, - }); - }, - - // #268 — Codex 原配置完整性自检. - async scanResidualPollution() { - return api('GET', '/api/desktop/scan-residual'); - }, - - async repairResidualPollution({ dryRun = false } = {}) { - return api('POST', '/api/desktop/repair-residual', { dryRun }); - }, - - // MOC-62 — MCP 凭据可移植保险箱:load 时查状态,文件丢失时用户确认恢复 / 忽略. - async getMcpCredentialsStatus() { - return api('GET', '/api/desktop/mcp-credentials/status'); - }, - - async restoreMcpCredentials() { - return api('POST', '/api/desktop/mcp-credentials/restore'); - }, - - async discardMcpCredentialsMirror() { - return api('POST', '/api/desktop/mcp-credentials/discard'); - }, - - // #271 — Codex CLI rollout 对话导出. - async listConversations() { - const data = await api('GET', '/api/conversations/list'); - return data?.sessions || []; - }, - async getConversation(id) { - return api('GET', `/api/conversations/${encodeURIComponent(id)}`); - }, - /** 返回 { blob, filename } — 调用方负责落盘 */ - async exportConversations({ sessionIds, format, options }) { - const resp = await fetch('/api/conversations/export', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionIds, format, options: options || {} }), - }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || `HTTP ${resp.status}`); - } - const cd = resp.headers.get('content-disposition') || ''; - const m = cd.match(/filename="?([^";]+)"?/); - const filename = m ? m[1] : `conversation-${Date.now()}`; - const blob = await resp.blob(); - return { blob, filename }; - }, - - async startProxy(port) { - if (port) { - await this.saveSettings({ proxyPort: Number(port) }); - } - await api('POST', '/api/proxy/start', port ? { port: Number(port) } : undefined); - const status = await api('GET', '/api/status'); - return { - running: !!status.proxyRunning, - port: status.proxyPort || port || 18080, - }; - }, - - async stopProxy() { - await api('POST', '/api/proxy/stop'); - const status = await api('GET', '/api/status'); - return { - running: !!status.proxyRunning, - port: status.proxyPort || 18080, - }; - }, - - async getProxyLogs() { - const data = await api('GET', '/api/proxy/logs'); - return (data.logs || []).map(mapLog); - }, - - async getProxyStatus() { - const data = await api('GET', '/api/proxy/status'); - return { - running: !!data.running, - port: data.port || 18080, - stats: data.stats || { total: 0, success: 0, failed: 0, today: 0 }, - }; - }, - - async clearLogs() { - return api('POST', '/api/proxy/logs/clear'); - }, - - async openLogDir() { - return api('POST', '/api/proxy/logs/open-dir'); - }, - - // [MOC-169] 诊断流量查看器开关 - async traceViewerStart() { - return api('POST', '/api/trace-viewer/start'); - }, - - async traceViewerStop() { - return api('POST', '/api/trace-viewer/stop'); - }, - - // [MOC-185] 查诊断查看器运行态(session 级:renderSettings 据此设开关,避免与持久化 desync) - async traceViewerStatus() { - return api('GET', '/api/trace-viewer/status'); - }, - - async openTraceViewer() { - return api('POST', '/api/trace-viewer/open'); - }, - - async getSettings() { - return api('GET', '/api/settings'); - }, - - async getVersion() { - return api('GET', '/api/version'); - }, - - async saveSettings(settings) { - const data = await api('PUT', '/api/settings', settings); - const out = data.settings || data; - // 顶层警告字段(webFetchSyncWarning)不在 settings 里, 挂回返回对象 —— - // 否则被 `data.settings ||` 这层 mapper 静默丢掉, _commitWebFetch 读不到、 - // toast 成死代码(MOC-145)。 - if (data && data.webFetchSyncWarning && out && typeof out === 'object') { - out.webFetchSyncWarning = data.webFetchSyncWarning; - } - return out; - }, - - // MOC-144 联网抓取后端: headless 档需要 Chromium, 这两个给设置页探测/按需下载用。 - async detectSystemChrome() { - return api('GET', '/api/chrome/detect'); - }, - - async ensureChromeHeadlessShell() { - return api('POST', '/api/chrome/ensure', {}); - }, - - async checkUpdate(updateUrl) { - const params = new URLSearchParams(); - if (updateUrl) params.set('url', updateUrl); - return api('GET', `/api/update/check?${params.toString()}`); - }, - - async installUpdate(updateUrl) { - return api('POST', '/api/update/install', updateUrl ? { url: updateUrl } : {}); - }, - - async createBackup() { - return api('POST', '/api/config/backup'); - }, - - async listBackups() { - const data = await api('GET', '/api/config/backups'); - return data.backups || []; - }, - - async exportConfig() { - return api('GET', '/api/config/export'); - }, - - async importConfig(configData) { - return api('POST', '/api/config/import', configData); - }, - - async getDesktopSnapshotStatus() { - return api('GET', '/api/desktop/snapshot-status'); - }, - - async restartCodexApp() { - return api('POST', '/api/desktop/restart-codex-app'); - }, - - async submitFeedback(payload) { - // 走 JSON 而不是 multipart/form-data —— pywebview 的 WebKit 对 - // fetch+FormData 组合存在 "the string did not match the expected pattern" - // bug,JSON 路径稳定。文件以 base64 嵌入。 - return api('POST', '/api/feedback', payload); - }, - - async getActivities() { - const data = await api('GET', '/api/proxy/logs'); - const logs = data.logs || []; - return logs.slice(-5).reverse().map(log => ({ - time: log.time, - text: log.message, - })); - }, - - // ── Gemini CLI OAuth (P2.2) ────────────────────────────────────────── - // 后端 admin handler 在 src-tauri/src/admin/handlers/gemini_oauth.rs。 - // login 是 long-poll 5min(浏览器登录 callback timeout),前端按钮要 disable - // 直到 promise resolve;status / logout 是即时操作。 - - async getGeminiOauthStatus() { - return api('GET', '/api/gemini-oauth/status'); - }, - - async loginGeminiOauth() { - // **long polling** — fetch 会阻塞最长 5min 等待 OAuth callback - return api('POST', '/api/gemini-oauth/login', {}); - }, - - async logoutGeminiOauth() { - return api('DELETE', '/api/gemini-oauth/logout'); - }, - - // ── Antigravity OAuth ──────────────────────────────────────────────── - // 后端 admin handler 在 src-tauri/src/admin/handlers/antigravity_oauth.rs。 - // 跟 gemini-cli 完全 parallel:独立 cancel slot / done channel / token 文件, - // 用户可同时登录两个 provider。endpoint shape 同 gemini-cli(login long-poll + - // status/logout 即时操作)。 - async getAntigravityOauthStatus() { - return api('GET', '/api/antigravity-oauth/status'); - }, - - async loginAntigravityOauth() { - // **long polling** — fetch 会阻塞最长 5min 等待 OAuth callback - return api('POST', '/api/antigravity-oauth/login', {}); - }, - - async logoutAntigravityOauth() { - return api('DELETE', '/api/antigravity-oauth/logout'); - }, - - /// 拉 antigravity 上游可用 model 列表(`:fetchAvailableModels`),后端 - /// 失败时退到静态种子。响应 OpenAI `/v1/models` shape: - /// `{ object: "list", data: [{id, object, owned_by, ...}], source: "upstream"|"static_seed" }` - /// 跟 gemini-cli 不同 — gemini-cli 没 fetchAvailableModels endpoint,前端那边 - /// 是 hardcoded list;antigravity 真有 endpoint(CLIProxyAPI 实证) - async getAntigravityOauthModels() { - return api('GET', '/api/antigravity-oauth/models'); - }, - - // ── z.ai / bigmodel OAuth(GLM Coding Plan 账号登录,免 API key)────────── - // 后端 admin handler 在 src-tauri/src/admin/handlers/zai_oauth.rs。两个 provider - // (z.ai / bigmodel)共用一套路由,用 `?provider=zai|bigmodel` query 区分,各自独立 - // token 文件。endpoint shape 同 antigravity(login long-poll + status/logout 即时)。 - async getZaiOauthStatus(provider) { - return api('GET', `/api/zai-oauth/status?provider=${encodeURIComponent(provider)}`); - }, - - async loginZaiOauth(provider) { - // **long polling** — fetch 会阻塞最长 5min 等待 OAuth callback - return api('POST', `/api/zai-oauth/login?provider=${encodeURIComponent(provider)}`, {}); - }, - - async logoutZaiOauth(provider) { - return api('DELETE', `/api/zai-oauth/logout?provider=${encodeURIComponent(provider)}`); - }, - }; - -// ── Codex Desktop Plugins 解锁 API ── -// **#264 fix**: pluginUnlock + theme 必须留在 IIFE **内**(line 1 `(function () {`), -// 否则 IIFE close 后 `api()` fn 不可见,调用报 `Can't find variable: api`。 -// 原版 line 514 的 `})()` 提前关 IIFE 是 bug(plugin unlock UI 实际很少触发, -// 没暴露);改成 IIFE 包到文件末尾。 - -window.CCApi = window.CCApi || {}; - -window.CCApi.pluginUnlock = { - /** 查询解锁状态 */ - async status() { - return api('GET', '/api/desktop/plugin-unlock/status'); - }, - - /** 启动解锁服务 */ - async start() { - return api('POST', '/api/desktop/plugin-unlock/start'); - }, - - /** 停止解锁服务 */ - async stop() { - return api('POST', '/api/desktop/plugin-unlock/stop'); - }, - - /** 手动触发重新注入 */ - async reinject() { - return api('POST', '/api/desktop/plugin-unlock/reinject'); - }, -}; - -// MOC-104 真实 ChatGPT 账号 plugin 模式 -window.CCApi.realAccount = { - /** 检测真实 chatgpt 登录态 + 登录流程状态 */ - async status() { - return api('GET', '/api/desktop/real-account/status'); - }, - /** 在 transfer 内调起官方 codex login(非阻塞,弹浏览器做 OAuth) */ - async login() { - return api('POST', '/api/desktop/real-account/login'); - }, - /** 取消进行中的登录 */ - async loginCancel() { - return api('POST', '/api/desktop/real-account/login/cancel'); - }, - /** 从文件导入真实账号(sourcePath = Tauri dialog 选的源文件绝对路径;后端读该路径 - * 文件、记录源路径,reconcile 可从活源跟随刷新) */ - async import(sourcePath) { - return api('POST', '/api/desktop/real-account/import', { source_path: sourcePath }); - }, - /** 钉住当前检测到的真实账号(持久保留) */ - async pinCurrent() { - return api('POST', '/api/desktop/real-account/pin-current'); - }, - /** 忘记导入的真实账号(删持久镜像) */ - async forget() { - return api('POST', '/api/desktop/real-account/forget'); - }, - /** [MOC-178] 开真实账号模式(写持久 flag=true + 把活动写回 chatgpt + apply relay) */ - async enable() { - return api('POST', '/api/desktop/real-account/enable'); - }, -}; - -// MOC-114 系统代理(梯子)连通性 —— relay 真账号/插件/第三方路由都依赖它 -window.CCApi.systemProxy = { - /** 探测系统代理是否挂 + 端口可连(只探代理端口,不碰 chatgpt.com) */ - async status() { - return api('GET', '/api/system-proxy/status'); - }, -}; - -window.CCApi.theme = { - /** 列出内置主题(#264) */ - async list() { - return api('GET', '/api/desktop/theme/list'); - }, - /** 当前注入状态 */ - async status() { - return api('GET', '/api/desktop/theme/status'); - }, - /** 应用指定主题 */ - async apply(themeId) { - return api('POST', '/api/desktop/theme/apply', { theme_id: themeId }); - }, - /** 清除主题(回原生 Codex UI) */ - async clear() { - return api('POST', '/api/desktop/theme/clear'); - }, - /** 刷新 Codex Desktop 当前 page。v1 无前端调用(主题切换 IIFE 即刻生效不需 reload); - * 保留对应后端 endpoint 做开发 / 测试备用 */ - async reload() { - return api('POST', '/api/desktop/theme/reload'); - }, - /** 重启 Codex.app(quit + 启动)— 复用 desktop handler */ - async restartCodex() { - return api('POST', '/api/desktop/restart-codex-app'); - }, - /** 上传 / 替换自定义主题图。流程:前端 `openCropModal` 让 user 拖选 1:1 - * 区域 → canvas 已 crop 成方形 JPEG → 后端再 center-crop(已是方图时 no-op) - * + resize 2048 + JPEG encode 写 `~/.codex-app-transfer/themes/custom/`。 - * `dataUri` 形如 `data:image/jpeg;base64,...` */ - async uploadCustom(dataUri) { - return api('POST', '/api/desktop/theme/custom/upload', { data_uri: dataUri }); - }, - /** 删除自定义主题(rm disk) */ - async deleteCustom() { - return api('DELETE', '/api/desktop/theme/custom'); - }, -}; - -})(); - diff --git a/frontend/js/app.js b/frontend/js/app.js deleted file mode 100644 index 2e2368a7..00000000 --- a/frontend/js/app.js +++ /dev/null @@ -1,8971 +0,0 @@ -(function () { - const routes = ["dashboard", "providers/add", "providers", "desktop", "proxy", "usage", "settings", "codex", "theme", "guide"]; - const providerFormModelSlots = [ - { key: "default", label: "Default", icon: "bi-circle-fill", iconClass: "default", source: "未配置映射时默认使用这一项", required: true }, - { key: "gpt_5_5", label: "gpt-5.5", icon: "bi-circle", iconClass: "default", source: "gpt-5.5" }, - { key: "gpt_5_4", label: "gpt-5.4", icon: "bi-circle", iconClass: "default", source: "gpt-5.4" }, - { key: "gpt_5_4_mini", label: "gpt-5.4-mini", icon: "bi-circle", iconClass: "default", source: "gpt-5.4-mini" }, - { key: "gpt_5_3_codex", label: "gpt-5.3-codex", icon: "bi-circle", iconClass: "default", source: "gpt-5.3-codex" }, - { key: "gpt_5_2", label: "gpt-5.2", icon: "bi-circle", iconClass: "default", source: "gpt-5.2" }, - ]; - const availableThemes = ["default", "green", "orange", "gray", "dark", "white"]; - // **2026-05-10 修复**:加 google_api_key(Gemini native 用 `x-goog-api-key` header)。 - // 旧实现白名单只有 bearer / x-api-key / none,Google AI Studio preset 的 - // authScheme=google_api_key 经 setAuthSchemeValue 校验失败 → fallback 'bearer' - // → backend 用 Authorization: Bearer 调 Gemini /v1beta/models → 401(Google 不接 Bearer) - // → 测速看似绿(401 走 auth_not_verified 路径)但实际从未真正鉴权过 + 列模型失败。 - // [MOC-252] 加 zai_oauth / bigmodel_oauth(GLM 账号登录,免 API key)。两者 apiFormat 都是 - // anthropic_messages(跟非 OAuth 的 Claude 共用),OAuth 性靠 authScheme 判 —— 不加进白名单 - // 的话 setAuthSchemeValue 会把 authScheme 静默落 'bearer',preset 存盘后丢掉 OAuth 标记。 - const providerAuthSchemes = ["bearer", "x-api-key", "google_api_key", "grok_cookie", "zai_oauth", "bigmodel_oauth", "none"]; - const providerFormDefaultRows = ["default", "gpt_5_5", "gpt_5_4", "gpt_5_4_mini", "gpt_5_3_codex", "gpt_5_2"]; - let pendingDeleteId = null; - let selectedPreset = null; - let presetCache = []; - // MOC-91:是否在 preset 选择器里展示「灰色」(TOS-gray / 实验性)preset。默认 false - // (隐藏)。仅过滤**展示**,presetCache 始终保留全量供已配置 provider 反查 preset。 - let showGrayPresets = false; - let formApiFormatValue = "openai_chat"; - let formModelCapabilities = {}; - let formRequestOptions = {}; - let providerFormMappings = {}; - let providerFormRows = [...providerFormDefaultRows]; - let providerAvailableModels = []; - let openProviderModelMenuKey = null; - let baseUrlMenuOpen = false; - let editingProviderId = null; - let deleteModal = null; - let restartReminderModal = null; - let toast = null; - let updateCheckCache = null; - let updateInstallPhase = "idle"; - - function $(selector, root = document) { - return root.querySelector(selector); - } - - function $all(selector, root = document) { - return Array.from(root.querySelectorAll(selector)); - } - - function routeFromHash() { - const hash = window.location.hash.replace(/^#/, ""); - return routes.includes(hash) ? hash : "dashboard"; - } - - function showToast(message) { - $("#toastBody").textContent = message; - toast.show(); - } - - // MOC-62:MCP 凭据文件整个丢失但镜像有备份时弹确认。原生 dialog.ask(yes/no): - // 确认 → 从备份恢复;否 → 忽略(删镜像,停止再弹,接受"凭据已不在")。 - // dialog 不可用时退回 window.confirm。in-flight guard 防重入重复弹。 - let mcpRestorePromptInFlight = false; - async function mcpCredentialsHandleRestorePrompt(count) { - if (mcpRestorePromptInFlight) return; - mcpRestorePromptInFlight = true; - try { - let restore; - try { - const dialog = window.__TAURI__?.dialog; - const body = tFmt("mcp.restorePromptBody", { count }); - if (dialog && typeof dialog.ask === "function") { - restore = await dialog.ask(body, { - title: t("mcp.restorePromptTitle"), - kind: "warning", - }); - } else { - restore = window.confirm(`${t("mcp.restorePromptTitle")}\n\n${body}`); - } - } catch (err) { - console.error("mcp restore prompt:", err); - return; - } - try { - if (restore) { - const r = await CCApi.restoreMcpCredentials(); - showToast(tFmt("mcp.restoreDone", { count: r?.restored ?? count })); - } else { - await CCApi.discardMcpCredentialsMirror(); - showToast(t("mcp.restoreDismissed")); - } - } catch (err) { - showToast(err.message || t("toast.requestFailed")); - } - } finally { - mcpRestorePromptInFlight = false; - } - } - - // MOC-62:load 时查询是否有可恢复的 MCP 凭据备份(整文件丢失 + 镜像有备份),有则弹 - // 确认。轮询比一次性 startup event 可靠(后者可能在 listener 注册前 emit 丢失)。 - async function mcpCredentialsCheckRestoreOnLoad() { - try { - const s = await CCApi.getMcpCredentialsStatus(); - const count = Number(s?.restoreAvailable) || 0; - if (count > 0) await mcpCredentialsHandleRestorePrompt(count); - } catch (err) { - console.error("mcp restore status:", err); - } - } - - function showRestartReminder() { - restartReminderModal?.show(); - } - - function dismissRestartReminderLater() { - restartReminderModal?.hide(); - } - - async function restartCodexAppNow({ - buttonId = "restartReminderNow", - fallbackLabelKey = "restartReminder.now", - hideModal = true, - } = {}) { - const button = $(`#${buttonId}`); - // MOC-20 / PR #281 fix:三种按钮形态都要兼容,不能直接改 button.textContent 否则抹 DOM: - // a) modal 内纯文本按钮(#restartReminderNow):textContent 含 label,直接改安全 - // b) 含 icon + label span 按钮(dashboard quick-actions 形态):必须改 [data-i18n] 子节点 - // c) icon-only 按钮(header `.theme-btn`):textContent = 空白,只能改 disabled + .is-loading class - // 判断:有 [data-i18n] 子 → label swap;无子但 button.textContent.trim() 非空 → 改 button text; - // 都不是 → icon-only,仅 disabled + class 反馈。 - const labelEl = button?.querySelector("[data-i18n]"); - const buttonHasText = button && !labelEl && button.textContent.trim().length > 0; - const swapEl = labelEl || (buttonHasText ? button : null); - const original = swapEl?.textContent; - try { - if (button) { - button.disabled = true; - button.classList.add("is-loading"); - } - if (swapEl) swapEl.textContent = t("restartReminder.restarting") || "重启中…"; - await CCApi.restartCodexApp(); - if (hideModal) restartReminderModal?.hide(); - showToast(t("toast.codexAppRestartRequested")); - } catch (error) { - console.error(error); - showToast(error.message || t("toast.codexAppRestartFailed")); - } finally { - if (button) { - button.disabled = false; - button.classList.remove("is-loading"); - } - if (swapEl) swapEl.textContent = original || t(fallbackLabelKey); - } - } - - function t(key) { - return CCI18n.t(key); - } - - // formatI18n removed (M2 migration) — 使用 tFmt 统一(line ~1131),tFmt 多了 - // missing-key + unsubstituted-placeholder warning,行为更安全。所有原 callsite - // 已迁到 tFmt - - - function iconMarkup(item) { - if (item.logo) return ``; - if (item.iconText) return `${item.iconText}`; - return ``; - } - - function escapeHtml(value) { - return String(value ?? "").replace(/[&<>"']/g, (char) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - }[char])); - } - - function safeHttpUrl(value) { - try { - const parsed = new URL(String(value || ""), window.location.origin); - if (["http:", "https:"].includes(parsed.protocol)) return parsed.href; - } catch (error) { - return "#"; - } - return "#"; - } - - function normalizePresetKey(value) { - return String(value || "").trim().toLowerCase().replace(/\/+$/, ""); - } - - function presetExists(preset, providers) { - // 「自定义第三方」是无限重复添加入口卡片(用户每次填不同 baseUrl + apiKey), - // 永远视为不存在 → 永远在 dashboard available presets 列表显示 - if (preset.id === "custom-third-party") return false; - const presetName = normalizePresetKey(preset.name); - const presetUrl = normalizePresetKey(preset.baseUrl); - const presetApiFormat = String(preset.apiFormat || "").toLowerCase(); - return providers.some((provider) => { - // **多 preset 共享上游场景**:eg gemini-cli-oauth + antigravity-oauth 都 - // 走 cloudcode-pa.googleapis.com 上游但 apiFormat 不同 (前者 - // gemini_cli_oauth 后者 antigravity_oauth) — 加一个另一个不能被 - // baseUrl 去重隐藏。同 apiFormat 才视为同 preset(2026-05-11 修) - if (presetApiFormat && String(provider.apiFormat || "").toLowerCase() !== presetApiFormat) { - return false; - } - return ( - normalizePresetKey(provider.name) === presetName - || normalizePresetKey(provider.baseUrl) === presetUrl - ); - }); - } - - function updatePresetSelection() { - const selectedId = selectedPreset?.id || ""; - $all("#presetList [data-preset]").forEach((button) => { - const active = button.dataset.preset === selectedId; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", active ? "true" : "false"); - const icon = $("i:last-child", button); - if (icon) icon.className = `bi ${active ? "bi-check2" : "bi-chevron-right"}`; - }); - } - - function normalizeApiFormat(apiFormat) { - const v = String(apiFormat || "").toLowerCase().replace(/-/g, "_"); - if (["responses", "openai_responses"].includes(v)) return { key: "responses", canonical: "responses" }; - if (["anthropic_messages", "anthropic", "claude", "messages", "claude_messages"].includes(v)) { - return { key: "anthropic", canonical: "anthropic_messages" }; - } - if (["gemini_native", "google_ai_studio", "gemini"].includes(v)) return { key: "geminiNative", canonical: "gemini_native" }; - if (["gemini_cli_oauth", "gemini_cli", "google_oauth_cloud_code"].includes(v)) return { key: "geminiCliOauth", canonical: "gemini_cli_oauth" }; - if (["grok_web", "grok", "grok_com"].includes(v)) return { key: "grokWeb", canonical: "grok_web" }; - if (["antigravity_oauth", "antigravity", "google_oauth_antigravity"].includes(v)) return { key: "antigravityOauth", canonical: "antigravity_oauth" }; - return { key: "openaiChat", canonical: "openai_chat" }; - } - - /// OAuth 登录类 provider 的 per-config 配置。新增 OAuth provider 时往这里加一条即可, - /// setOauthRowState/refreshOauthStatusUi/handleOauthLogin/handleOauthLogout 都 share - /// 这个 dispatch 表。字段: - /// - i18nPrefix:整套 UI 文案 namespace - /// - api:CCApi 方法三件套(getStatus/login/logout),login 闭包带各 provider 参数 - /// - baseUrl:OAuth 模式下锁定的 baseUrl(setOauthRowState 写死,user 不看 / 不改) - /// - requiresProject:上游是否有 Google Cloud Code project 概念(gemini/antigravity=true: - /// 登录成功还需 provision project,status.projectId 缺失 = partial;zai/bigmodel=false: - /// 纯账号登录无 project,projectId/expiresAt 不参与成功判定) - /// - /// **key 选择**:gemini/antigravity 用 apiFormat canonical 做 key(它们 apiFormat 唯一); - /// zai/bigmodel 的 apiFormat 是 anthropic_messages(非 OAuth,跟 Claude 共用),无法靠 - /// apiFormat 区分,改用 authScheme 值(zai_oauth / bigmodel_oauth)做 key。resolveOauthConfig - /// 先按 apiFormat canonical 查 → miss 再按 authScheme 查,两类 provider 都能命中。 - const OAUTH_PROVIDER_CONFIGS = { - gemini_cli_oauth: { - i18nPrefix: "geminiOauth", - baseUrl: "https://cloudcode-pa.googleapis.com", - requiresProject: true, - api: { - getStatus: () => CCApi.getGeminiOauthStatus(), - login: () => CCApi.loginGeminiOauth(), - logout: () => CCApi.logoutGeminiOauth(), - }, - }, - antigravity_oauth: { - // **行为保持**:旧 setOauthRowState 对 gemini / antigravity 都无条件锁 - // baseUrl=cloudcode-pa.googleapis.com(即便 antigravity-oauth preset 自带 - // daily-cloudcode-pa)。这里继续锁 cloudcode-pa 不改既有 OAuth 流程。 - i18nPrefix: "antigravityOauth", - baseUrl: "https://cloudcode-pa.googleapis.com", - requiresProject: true, - api: { - getStatus: () => CCApi.getAntigravityOauthStatus(), - login: () => CCApi.loginAntigravityOauth(), - logout: () => CCApi.logoutAntigravityOauth(), - }, - }, - // [MOC-252] z.ai / bigmodel(GLM Coding Plan 账号登录)。key = authScheme 值 - // (apiFormat=anthropic_messages 跟 Claude 共用,无法靠 apiFormat 路由)。 - zai_oauth: { - i18nPrefix: "zaiOauth", - baseUrl: "https://api.z.ai/api/anthropic", - requiresProject: false, - api: { - getStatus: () => CCApi.getZaiOauthStatus("zai"), - login: () => CCApi.loginZaiOauth("zai"), - logout: () => CCApi.logoutZaiOauth("zai"), - }, - }, - bigmodel_oauth: { - i18nPrefix: "bigmodelOauth", - baseUrl: "https://open.bigmodel.cn/api/anthropic", - requiresProject: false, - api: { - getStatus: () => CCApi.getZaiOauthStatus("bigmodel"), - login: () => CCApi.loginZaiOauth("bigmodel"), - logout: () => CCApi.logoutZaiOauth("bigmodel"), - }, - }, - }; - - /// [MOC-252] 解析当前表单对应的 OAuth provider config(无 → null)。 - /// **两段查找**:先按 apiFormat canonical 查(gemini_cli_oauth / antigravity_oauth 命中, - /// 它们 apiFormat 唯一)→ miss 再按 authScheme 查(zai_oauth / bigmodel_oauth 命中,它们 - /// apiFormat=anthropic_messages 跟 Claude 共用、只能靠 authScheme 区分)。authScheme 缺省 - /// 从 #providerAuth input 现值读(applyPresetToForm 里 setAuthSchemeValue 在 setOauthRowState - /// 之前调,所以读得到 preset.authScheme)。 - function resolveOauthConfig(apiFormat, authScheme) { - const { canonical } = normalizeApiFormat(apiFormat); - const byFormat = OAUTH_PROVIDER_CONFIGS[canonical]; - if (byFormat) return byFormat; - const scheme = authScheme !== undefined ? authScheme : ($("#providerAuth")?.value || ""); - return OAUTH_PROVIDER_CONFIGS[scheme] || null; - } - - /// 当前 form 已选 apiFormat 对应的 OAuth provider config(none → null)。 - /// setOauthRowState 时缓存,refresh / login / logout 复用避免每次重 lookup - let activeOauthConfig = null; - - /// OAuth 登录路径不需要 apiKey input,改 OAuth login UI block。返 true 表示当前 - /// 表单是 OAuth 模式(gemini_cli_oauth / antigravity_oauth 按 apiFormat,zai_oauth / - /// bigmodel_oauth 按 authScheme),调用方据此切换 form 显示。 - function isOauthApiFormat(apiFormat, authScheme) { - return resolveOauthConfig(apiFormat, authScheme) !== null; - } - - function renderApiFormatDisplay(apiFormat) { - const { key, canonical } = normalizeApiFormat(apiFormat); - formApiFormatValue = canonical; - const nameEl = $("#providerApiFormatName"); - const detailEl = $("#providerApiFormatDetail"); - if (nameEl) { - const nameKey = `apiFormatDisplay.${key}.name`; - nameEl.dataset.i18n = nameKey; - nameEl.textContent = t(nameKey); - } - if (detailEl) { - const detailKey = `apiFormatDisplay.${key}.detail`; - detailEl.dataset.i18n = detailKey; - detailEl.textContent = t(detailKey); - } - } - - function updateApiFormatSelectDetail(value) { - const { key, canonical } = normalizeApiFormat(value); - formApiFormatValue = canonical; - const detailEl = $("#providerApiFormatSelectDetail"); - if (detailEl) { - const detailKey = `apiFormatDisplay.${key}.detail`; - detailEl.dataset.i18n = detailKey; - detailEl.textContent = t(detailKey); - } - // 协议切换 → 重渲 mappings UI 让 default required 状态跟当前协议同步 - // (responses 1:1 透传协议 default 解锁为可空,其他场景仍 required) - setProviderMappings(providerFormMappings); - // OAuth 模式切换:apiFormat=gemini_cli_oauth 时隐藏 apiKey input,显示 OAuth UI - setOauthRowState(canonical); - } - - /// [MOC-211] 该 provider 是否 MiMo Token Plan(套餐用量需小米账号 session)。 - /// 按 baseUrl 判:host 含 xiaomimimo.com 且路径含 token-plan(对齐 token-plan-{cn,sgp,ams})。 - function isMimoTokenPlan(baseUrl) { - const u = baseUrl || ""; - return /xiaomimimo\.com/i.test(u) && /token-plan/i.test(u); - } - - /// [MOC-211] MiMo Token Plan「登录小米账号」row:仅编辑已保存的 MiMo token-plan provider - /// 时显示(登录需 provider.id 落 cookie)。点击开内嵌 webview 登录,抓 session 存后端。 - function setMimoLoginRow(show, hasCookie, providerId) { - const row = $("#providerMimoLoginRow"); - const btn = $("#providerMimoLoginBtn"); - const status = $("#providerMimoLoginStatus"); - if (row) row.hidden = !show; - if (!show) return; - if (status) { - status.textContent = hasCookie - ? t("providersAdd.mimoLogin.statusLoggedIn") - : t("providersAdd.mimoLogin.statusNotLoggedIn"); - } - if (btn) { - btn.disabled = false; - // onclick 赋值(非 addEventListener)避免重复绑定叠加 - btn.onclick = async () => { - if (!providerId) return; - btn.disabled = true; - if (status) status.textContent = t("providersAdd.mimoLogin.statusLoggingIn"); - try { - const res = await CCApi.mimoLogin(providerId); - if (res && res.captured === false) { - // 用户关窗 / 未完成登录 → 中性提示,不当错误 - if (status) status.textContent = t("providersAdd.mimoLogin.statusNotLoggedIn"); - showToast(t("providersAdd.mimoLogin.cancelled")); - } else { - if (status) status.textContent = t("providersAdd.mimoLogin.statusLoggedIn"); - showToast(t("providersAdd.mimoLogin.success")); - } - } catch (error) { - if (status) status.textContent = t("providersAdd.mimoLogin.statusNotLoggedIn"); - showToast(error.message || t("toast.requestFailed")); - } finally { - btn.disabled = false; - } - }; - } - } - - function setApiFormatMode(allowSelect, currentValue) { - const displayEl = $("#providerApiFormatDisplay"); - const selectableEl = $("#providerApiFormatSelectable"); - const selectEl = $("#providerApiFormatSelect"); - if (displayEl) displayEl.hidden = allowSelect; - if (selectableEl) selectableEl.hidden = !allowSelect; - if (allowSelect && selectEl) { - const { canonical } = normalizeApiFormat(currentValue); - selectEl.value = canonical; - updateApiFormatSelectDetail(canonical); - } - } - - function firstHealthMessage(health) { - return health?.issues?.[0]?.message || ""; - } - - function renderDesktopHealthWarning(selector, health) { - const warning = $(selector); - if (!warning) return; - const message = firstHealthMessage(health); - warning.hidden = !message; - const text = $("span", warning); - if (text) text.textContent = message; - } - - function renderUpdateBadge(result) { - const badge = $("#dashboardUpdateBadge"); - const available = !!result?.updateAvailable; - const installButton = $("#settingsInstallUpdate"); - const busy = updateInstallPhase !== "idle"; - if (badge) { - badge.hidden = !(available || busy); - badge.disabled = busy; - badge.title = available && !busy ? t("settings.installUpdate") : ""; - badge.setAttribute("aria-label", available ? t("settings.installUpdate") : t("dashboard.updateAvailable")); - } - if (installButton) { - installButton.hidden = !(available || busy); - installButton.disabled = busy; - } - const badgeIcon = badge ? $("i", badge) : null; - if (badgeIcon) { - badgeIcon.className = busy ? "bi bi-arrow-repeat" : "bi bi-cloud-arrow-down"; - } - const installIcon = installButton ? $("i", installButton) : null; - if (installIcon) { - installIcon.className = busy ? "bi bi-arrow-repeat" : "bi bi-download"; - } - const text = badge ? $("span", badge) : null; - if (text) { - if (updateInstallPhase === "downloading") { - text.textContent = t("settings.downloadingUpdate"); - } else if (updateInstallPhase === "installing") { - text.textContent = t("settings.installingUpdate"); - } else if (available) { - text.textContent = result.latestVersion - ? `${t("dashboard.updateAvailable")} ${result.latestVersion}` - : t("dashboard.updateAvailable"); - } - } - const installText = installButton ? $("span", installButton) : null; - if (installText) { - if (updateInstallPhase === "downloading") { - installText.textContent = t("settings.downloadingUpdate"); - } else if (updateInstallPhase === "installing") { - installText.textContent = t("settings.installingUpdate"); - } else { - installText.textContent = t("settings.installUpdate"); - } - } - } - - function setUpdateInstallPhase(phase = "idle") { - updateInstallPhase = phase; - renderUpdateBadge(updateCheckCache); - } - - async function refreshUpdateBadge(force = false) { - try { - updateCheckCache = await CCApi.checkUpdate(""); - renderUpdateBadge(updateCheckCache); - } catch (error) { - console.warn(error); - updateCheckCache = null; - renderUpdateBadge(null); - } - } - - function emptyMappings() { - return Object.fromEntries(providerFormModelSlots.map((slot) => [slot.key, ""])); - } - - const predefinedSlotKeys = new Set(providerFormModelSlots.map((s) => s.key)); - - function normalizeMappings(mappings = {}) { - const normalized = emptyMappings(); - if (!mappings || typeof mappings !== "object") return normalized; - normalized.default = String(mappings.default || "").trim(); - normalized.gpt_5_5 = String(mappings.gpt_5_5 || "").trim(); - normalized.gpt_5_4 = String(mappings.gpt_5_4 || "").trim(); - normalized.gpt_5_4_mini = String(mappings.gpt_5_4_mini || "").trim(); - normalized.gpt_5_3_codex = String(mappings.gpt_5_3_codex || "").trim(); - normalized.gpt_5_2 = String(mappings.gpt_5_2 || "").trim(); - // preserve custom (non-predefined) keys - for (const [key, value] of Object.entries(mappings)) { - if (!predefinedSlotKeys.has(key)) { - const trimmed = String(value || "").trim(); - if (trimmed) normalized[key] = trimmed; - } - } - return normalized; - } - - // [MOC-241] 保留 supports1m **与** context_window(数值 ≥ 1024)。Gemini 1M 开关把 - // 600000(默认关)/ 1000000(开)写进 modelCapabilities[model].context_window,后端 - // explicit_context_window 直接消费;旧逻辑只留 supports1m,会把写入的窗口值在保存/回填 - // 时静默剥掉(同 api.js mapper「不列即丢」契约)。 - function normalizeCapabilities(capabilities = {}) { - if (!capabilities || typeof capabilities !== "object") return {}; - const out = {}; - for (const [modelId, value] of Object.entries(capabilities)) { - if (!value || typeof value !== "object") continue; - const entry = {}; - if (value.supports1m === true) entry.supports1m = true; - const cw = Number(value.context_window); - if (Number.isFinite(cw) && cw >= 1024) entry.context_window = Math.trunc(cw); - if (Object.keys(entry).length > 0) out[modelId] = entry; - } - return out; - } - - // [MOC-241] Gemini 1M 上下文开关。开=完整 1M,关(默认)=封顶 600K → 后端 auto_compact - // 在 480K 触发,稳在用户实测的 ~500K 指令遵循悬崖以下。仅作用于 Gemini,不碰其它 provider。 - const GEMINI_1M_CONTEXT = 1000000; - const GEMINI_CAPPED_CONTEXT = 600000; - - // 只匹配后端 documented_context_window 判 1M 的 Gemini 文本世代(gemini-1.5 / 2.x / 3.x / - // pro-agent,排除 image),避免给老世代 / 图像模型误写窗口(explicit 优先级最高,会反向抬高)。 - function isGemini1mModelId(modelId) { - const m = String(modelId || "").trim().toLowerCase(); - if (!m || m.includes("image")) return false; - return m.startsWith("gemini-1.5") || m.startsWith("gemini-2") - || m.startsWith("gemini-3") || m.startsWith("gemini-pro-agent"); - } - - const EFFORT_VALUES = ["low", "medium", "high", "xhigh", "max"]; - - function normalizeResponsesBlock(source = {}) { - if (!source || typeof source !== "object") return {}; - const block = {}; - if (["enabled", "disabled"].includes(source.thinking?.type)) { - block.thinking = { type: source.thinking.type }; - } - if (EFFORT_VALUES.includes(source.output_config?.effort)) { - block.output_config = { effort: source.output_config.effort }; - } - return block; - } - - function normalizeChatBlock(source = {}) { - if (!source || typeof source !== "object") return {}; - const block = {}; - // DeepSeek V4:thinking 对象只接受 type;reasoning_effort 在请求体顶层 - const thinkingType = source.thinking?.type; - if (["enabled", "disabled"].includes(thinkingType)) { - block.thinking = { type: thinkingType }; - } - if (EFFORT_VALUES.includes(source.reasoning_effort)) { - block.reasoning_effort = source.reasoning_effort; - } - return block; - } - - function normalizeRequestOptions(options = {}) { - if (!options || typeof options !== "object") return {}; - // 兼容旧配置:anthropic 键重命名为 responses - const responsesSource = options.responses && typeof options.responses === "object" - ? options.responses - : (options.anthropic && typeof options.anthropic === "object" ? options.anthropic : null); - const result = {}; - const responsesBlock = normalizeResponsesBlock(responsesSource || (responsesSource === null ? options : {})); - if (Object.keys(responsesBlock).length) result.responses = responsesBlock; - const chatBlock = normalizeChatBlock(options.chat); - if (Object.keys(chatBlock).length) result.chat = chatBlock; - return result; - } - - function mergeRequestOptions(base = {}, extra = {}) { - const baseNorm = normalizeRequestOptions(base); - const extraNorm = normalizeRequestOptions(extra); - const merged = {}; - const responsesMerged = { ...(baseNorm.responses || {}), ...(extraNorm.responses || {}) }; - if (Object.keys(responsesMerged).length) merged.responses = responsesMerged; - const chatMerged = { ...(baseNorm.chat || {}), ...(extraNorm.chat || {}) }; - if (Object.keys(chatMerged).length) merged.chat = chatMerged; - return normalizeRequestOptions(merged); - } - - function clearRequestOptions(base = {}, option = {}) { - const baseNorm = normalizeRequestOptions(base); - const optionNorm = normalizeRequestOptions(option); - const next = {}; - if (baseNorm.responses) { - const block = { ...baseNorm.responses }; - Object.keys(optionNorm.responses || {}).forEach((key) => { delete block[key]; }); - if (Object.keys(block).length) next.responses = block; - } - if (baseNorm.chat) { - const block = { ...baseNorm.chat }; - Object.keys(optionNorm.chat || {}).forEach((key) => { delete block[key]; }); - if (Object.keys(block).length) next.chat = block; - } - return normalizeRequestOptions(next); - } - - function requestOptionsMatch(left = {}, right = {}) { - return JSON.stringify(normalizeRequestOptions(left)) === JSON.stringify(normalizeRequestOptions(right)); - } - - function capabilitiesMatch(left = {}, right = {}) { - return JSON.stringify(normalizeCapabilities(left)) === JSON.stringify(normalizeCapabilities(right)); - } - - function mergeCapabilities(base = {}, extra = {}) { - return { - ...normalizeCapabilities(base), - ...normalizeCapabilities(extra), - }; - } - - function clearCapabilities(base = {}, option = {}) { - const current = normalizeCapabilities(base); - Object.keys(normalizeCapabilities(option)).forEach((modelId) => { - delete current[modelId]; - }); - return current; - } - - function optionEnabled(option = {}, currentMappings = collectProviderMappings()) { - const hasModels = option.models && typeof option.models === "object"; - const hasRequestOptions = option.requestOptions && typeof option.requestOptions === "object"; - const hasCapabilities = option.modelCapabilities && typeof option.modelCapabilities === "object"; - const modelsOk = !hasModels || modelsMatch(option.models, currentMappings); - const requestOptionsOk = !hasRequestOptions || requestOptionsMatch(option.requestOptions, formRequestOptions); - const optionChangesModels = hasModels && !modelsMatch(option.models, selectedPreset?.models || {}); - const capabilitiesOk = !hasCapabilities || optionChangesModels || capabilitiesMatch(option.modelCapabilities, formModelCapabilities); - if (hasModels || hasRequestOptions || hasCapabilities) { - return modelsOk && requestOptionsOk && capabilitiesOk; - } - return false; - } - - function modelsMatch(left = {}, right = {}) { - const a = normalizeMappings(left); - const b = normalizeMappings(right); - return providerFormModelSlots.every((slot) => (a[slot.key] || "") === (b[slot.key] || "")); - } - - function presetMatchesProvider(preset, provider) { - if (!preset || !provider) return false; - const baseUrlOptions = Array.isArray(preset.baseUrlOptions) ? preset.baseUrlOptions : []; - // **多 preset 共享上游场景**:apiFormat 不同就不算同 preset - // (gemini-cli-oauth vs antigravity-oauth 同 baseUrl 但不同协议;2026-05-11 修) - if (preset.apiFormat && provider.apiFormat - && String(preset.apiFormat).toLowerCase() !== String(provider.apiFormat).toLowerCase()) { - return false; - } - return normalizePresetKey(preset.name) === normalizePresetKey(provider.name) - || normalizePresetKey(preset.baseUrl) === normalizePresetKey(provider.baseUrl) - || baseUrlOptions.some((option) => normalizePresetKey(option?.value) === normalizePresetKey(provider.baseUrl)); - } - - function presetBaseUrlOptions(preset = null) { - return Array.isArray(preset?.baseUrlOptions) ? preset.baseUrlOptions.filter((option) => option?.value) : []; - } - - function closeBaseUrlMenu() { - if (!baseUrlMenuOpen) return; - baseUrlMenuOpen = false; - renderBaseUrlOptions(); - } - - function toggleBaseUrlMenu() { - if (!presetBaseUrlOptions(selectedPreset).length) return; - baseUrlMenuOpen = !baseUrlMenuOpen; - renderBaseUrlOptions(); - } - - function setBaseUrlValue(value) { - const input = $("#providerBaseUrl"); - if (!input) return; - input.value = value; - closeBaseUrlMenu(); - } - - function renderBaseUrlOptions(preset = selectedPreset) { - const input = $("#providerBaseUrl"); - const trigger = $("#providerBaseUrlTrigger"); - const menu = $("#providerBaseUrlMenu"); - const wrap = $("#providerBaseUrlControl"); - const hint = $("#providerBaseUrlHint"); - if (!input || !trigger || !menu || !wrap || !hint) return; - const options = presetBaseUrlOptions(preset); - const helpText = String(preset?.baseUrlHint || "").trim(); - trigger.hidden = !options.length; - trigger.disabled = !options.length; - trigger.setAttribute("aria-expanded", options.length && baseUrlMenuOpen ? "true" : "false"); - wrap.classList.toggle("open", !!options.length && baseUrlMenuOpen); - menu.innerHTML = options.map((option) => { - const selected = input.value.trim() === option.value; - return ` - - `; - }).join(""); - hint.textContent = helpText; - hint.hidden = !helpText; - } - - function capabilitiesForCurrentMappings(mappings = collectProviderMappings()) { - const usedModelIds = new Set(Object.values(mappings).filter(Boolean)); - return Object.fromEntries(Object.entries(normalizeCapabilities(formModelCapabilities)).filter(([modelId]) => ( - usedModelIds.has(modelId) - ))); - } - - // [MOC-241] 当前映射里命中的 1M-世代 Gemini 模型(开关只对它们写窗口)。 - function mappedGemini1mModels(mappings = collectProviderMappings()) { - return [...new Set(Object.values(mappings || {}).filter(Boolean))].filter(isGemini1mModelId); - } - - // [MOC-241] Gemini 1M 开关的 provider-wide 意图(不随换 model 丢失)。表单打开时重置(新建 false)/ - // 回填(编辑按已存 cap 推断);change listener 更新。**不**用 per-model 派生勾选态 —— 否则开启后换 - // Gemini 模型,新 id 在 formModelCapabilities 无 cap 会被误取消、保存写回 600K,丢失用户选择(#490 bot review)。 - let gemini1mOptIn = false; - - // provider 含 1M-世代 Gemini 模型时才显示开关。勾选态取自 gemini1mOptIn(provider-wide 意图),并把意图 - // 同步写进当前映射的 gemini 模型(remap 后新模型也拿到正确 cap)。在 renderPresetOptions(映射变化 / - // 编辑回填 / 新增重置都会调)里调用。 - function updateGemini1mRow(mappings = collectProviderMappings()) { - const row = $("#providerGemini1mRow"); - if (!row) return; - const geminiModels = mappedGemini1mModels(mappings); - row.hidden = geminiModels.length === 0; - const checkbox = $("#providerGemini1m"); - if (!checkbox || row.hidden) return; - checkbox.checked = gemini1mOptIn; - applyGemini1mCapabilities(formModelCapabilities, mappings); - } - - // 按已存 capabilities 推断 1M 意图(编辑回填用):任一 gemini 模型 context_window ≥ 1M → 开。 - function deriveGemini1mOptIn(caps, mappings) { - const c = normalizeCapabilities(caps || {}); - return mappedGemini1mModels(mappings).some( - (id) => Number(c[id]?.context_window) >= GEMINI_1M_CONTEXT, - ); - } - - // 保存时把开关状态写进待存 capabilities:开 → 1M + supports1m;关 → 600K(并清掉 supports1m - // 保持 legacy model_context_window 与 catalog 一致)。仅 Gemini provider(开关可见)生效。 - function applyGemini1mCapabilities(caps, mappings) { - const row = $("#providerGemini1mRow"); - if (!row || row.hidden || !mappings) return; - const on = !!$("#providerGemini1m")?.checked; - for (const modelId of mappedGemini1mModels(mappings)) { - const entry = { ...(caps[modelId] || {}) }; - entry.context_window = on ? GEMINI_1M_CONTEXT : GEMINI_CAPPED_CONTEXT; - if (on) { - entry.supports1m = true; - } else { - delete entry.supports1m; - } - caps[modelId] = entry; - } - } - - function formMappingRowsFromMappings(mappings = {}) { - // [MOC-154] 列表式迁移:把现有映射(default + gpt_5_x + 旧 custom 行)去重去空、 - // 按 default 优先的顺序压成"模型列表",依次填进 gpt_5_5/gpt_5_4/... 前 N 个 slot。 - const slotKeys = providerFormDefaultRows.filter((k) => k !== "default"); // gpt_5_5..gpt_5_2 - const ordered = []; - const seen = new Set(); - const pushVal = (v) => { - const s = String(v || "").trim(); - if (s && !seen.has(s)) { - ordered.push(s); - seen.add(s); - } - }; - pushVal(mappings.default); - slotKeys.forEach((k) => pushVal(mappings[k])); - for (const [k, v] of Object.entries(mappings)) { - if (!predefinedSlotKeys.has(k)) pushVal(v); // 旧 custom 行的值并入列表 - } - const list = ordered.slice(0, slotKeys.length); - providerFormMappings = {}; - const rows = []; - list.forEach((val, i) => { - providerFormMappings[slotKeys[i]] = val; - rows.push(slotKeys[i]); - }); - if (rows.length === 0) rows.push(slotKeys[0]); // 至少留 1 行(gpt_5_5=默认) - return rows; - } - - // [MOC-69] provider 可用 model 列表项可能是 raw id string(gemini-cli / 普通 - // OpenAI provider),也可能是带元数据的 object(Antigravity `/api/antigravity-oauth/ - // models` 的 entry,含 display_name / recommended / tag_title)。下面 helper 统一 - // 抽取,**只影响展示文本 / 排序 / 标记,绝不改提交的 value** —— value 永远是 raw id。 - function modelEntryId(entry) { - // 【硬约束】写进 select / mapping 的 value 永远是 raw id,绝不用 display_name。 - if (typeof entry === "string") return entry; - if (!entry || typeof entry !== "object") return ""; - return entry.id || entry.model || ""; - } - - function modelEntryDisplayLabel(entry) { - // 显示文本优先 display_name(如 "Gemini 3.5 Flash (High)"),fallback name -> id。 - // 其他 provider 的 entry(string / 无 display_name 的 object)自动回退到 id。 - if (typeof entry === "string") return entry; - if (!entry || typeof entry !== "object") return ""; - return entry.display_name || entry.name || entry.id || entry.model || ""; - } - - function modelEntryIsRecommended(entry) { - return !!(entry && typeof entry === "object" && entry.recommended === true); - } - - function modelEntryTagLabel(entry) { - // recommended model 的标记:优先 tag_title(如 "Fast"),没有就 i18n "推荐"。 - if (!modelEntryIsRecommended(entry)) return ""; - const tag = (entry && typeof entry.tag_title === "string") ? entry.tag_title.trim() : ""; - return tag || t("common.recommended"); - } - - // [MOC-69] 按 raw id 在 providerAvailableModels 里反查 entry —— 给映射「选框」显示 - // displayName 用。case-insensitive;找不到返回 null(其他 provider / 未拉取 / - // 自定义 id → 选框 fallback 显 raw id,行为同改前)。 - function modelEntryById(id) { - const target = String(id || "").trim().toLowerCase(); - if (!target) return null; - return providerAvailableModels.find((e) => modelEntryId(e).trim().toLowerCase() === target) || null; - } - - function providerModelOptionsMarkup(currentValue = "") { - // recommended:true 置顶,其余保持原相对顺序(稳定排序);非推荐仍全量保留可见。 - // 其他 provider(全 string entry)recommended 恒 false,排序 no-op,行为同改前。 - const indexed = providerAvailableModels.map((entry, i) => ({ entry, i })); - indexed.sort((a, b) => { - const ra = modelEntryIsRecommended(a.entry) ? 0 : 1; - const rb = modelEntryIsRecommended(b.entry) ? 0 : 1; - if (ra !== rb) return ra - rb; - return a.i - b.i; - }); - return indexed.map(({ entry }) => { - const modelId = modelEntryId(entry); - const label = modelEntryDisplayLabel(entry); - const isRecommended = modelEntryIsRecommended(entry); - const tagLabel = modelEntryTagLabel(entry); - return ` - - `; - }).join(""); - } - - // [MOC-69] 映射「选框」渲染 —— raw id 能反查到带 displayName 的 entry 时,**只读**显示 - // displayName(用户只看 displayName,实际存储/发上游仍是 raw id,从右侧下拉选);否则 - // (未拉取 / 其他 provider / 自定义 id)保持可编辑输入显示 raw id,行为同改前。 - // 只读分支**不带** data-provider-model-input → input 事件不会用 displayName 覆盖存储值; - // title 悬停可看真实 raw id。 - function providerModelValueInputMarkup(rowKey, index, currentProviderModel, isRequired) { - const entry = modelEntryById(currentProviderModel); - const displayName = entry ? modelEntryDisplayLabel(entry) : ""; - if (entry && displayName && displayName !== currentProviderModel) { - return ` - `; - } - return ` - `; - } - - function isDirectResponsesMode() { - // 自定义第三方 + apiFormat=responses → 代理内 1:1 字节透传给原生 Responses 上游 - // (MOC-234:不再 direct 直连,但仍 1:1 不做 alias 翻译)→ default mapping 可空。 - return ( - formApiFormatValue === "responses" && !!selectedPreset?.allowApiFormatSelection - ); - } - - function formMappingMarkup() { - return providerFormRows.map((rowKey, index) => { - // [MOC-154] 列表式:行序自动对应 Codex slot(行0=默认→gpt_5_5+default,行1→gpt_5_4 - // ...),用户只填"要在 Codex 显示的模型",不再手动选槽位、不再有 custom 行。 - const isDefault = index === 0; - // responses 1:1 透传不需要 model alias 映射,默认行也可空;其他场景默认行仍 required - const isRequired = isDefault && !isDirectResponsesMode(); - const currentProviderModel = providerFormMappings[rowKey] || ""; - return ` -
-
- -
-
- -
- ${providerModelValueInputMarkup(rowKey, index, currentProviderModel, isRequired)} - - ${providerAvailableModels.length ? ` -
- ${providerModelOptionsMarkup(currentProviderModel)} -
- ` : ""} -
-
-
- ${isDefault - ? '' - : ``} -
-
- `; - }).join(""); - } - - function renderProviderMappings() { - const stack = $("#providerMappingStack"); - if (!stack) return; - if (openProviderModelMenuKey !== null && !providerFormRows.includes(openProviderModelMenuKey)) { - openProviderModelMenuKey = null; - } - const maxRows = providerFormDefaultRows.filter((k) => k !== "default").length; - stack.innerHTML = ` -
-
- ${formMappingMarkup()} -
- ${providerFormRows.length < maxRows ? ` - - ` : ""} -
- `; - refreshReviewModelSlotOptions(); - } - - // [MOC-173] auto-review 审查模型下拉:选项 = 当前映射非空的 gpt_5_X 槽(有独立 catalog - // entry + openai_id),value = 槽位 key,text = 上游模型名。default 槽不列(列表式 catalog - // 无独立 entry,override 会降级);空选项 = 跟随主模型。刷新时保留仍有效的已选值。 - // 与后端 MODEL_SLOTS(crates/registry/src/model_alias.rs)的非 default 槽 key 一致 —— 后端 - // catalog 生成按此校验,此处是并行硬编码列表;新增 slot 时记得同步(后端为 source of truth)。 - const REVIEW_MODEL_SLOT_KEYS = ["gpt_5_5", "gpt_5_4", "gpt_5_4_mini", "gpt_5_3_codex", "gpt_5_2"]; - function refreshReviewModelSlotOptions() { - const sel = $("#providerReviewModelSlot"); - if (!sel) return; - const prev = sel.value; - const opts = [``]; - for (const key of REVIEW_MODEL_SLOT_KEYS) { - const model = String((providerFormMappings || {})[key] || "").trim(); - if (!model) continue; - opts.push(``); - } - sel.innerHTML = opts.join(""); - sel.value = Array.from(sel.options).some((o) => o.value === prev) ? prev : ""; - } - - function setReviewModelSlotField(value) { - const sel = $("#providerReviewModelSlot"); - if (!sel) return; - const v = value || ""; - sel.value = Array.from(sel.options).some((o) => o.value === v) ? v : ""; - } - - function setProviderMappings(mappings = {}, options = {}) { - providerFormMappings = normalizeMappings(mappings); - providerFormRows = formMappingRowsFromMappings(providerFormMappings); - if (Array.isArray(options.availableModels)) { - providerAvailableModels = options.availableModels.slice(); - } - openProviderModelMenuKey = null; - renderProviderMappings(); - } - - // [MOC-69] 编辑 antigravity provider 时静默拉一次模型列表,让映射选框立即显示 displayName - // (否则要手点「获取模型」才有反查表)。失败 / 离线 → 保持现状(选框显示 raw id), - // 不报错不清空(非破坏性 fallback)。antigravity 上游 list 走 OAuth token,不依赖 apiKey。 - async function autoFetchModelsForDisplay() { - try { - const payload = providerPayloadFromForm(false); - if (editingProviderId && !payload.apiKey) { - try { - const secret = await CCApi.getProviderSecret(editingProviderId); - if (secret.apiKey) payload.apiKey = secret.apiKey; - } catch (e) { /* ignore — antigravity OAuth 不依赖 apiKey */ } - } - const result = await CCApi.fetchProviderModelsPayload(payload); - const models = Array.isArray(result.models) ? result.models.slice() : []; - if (models.length) { - setProviderMappings(providerFormMappings, { availableModels: models }); - } - } catch (e) { - // 非破坏性 fallback:保持选框 raw id 显示,不弹 toast 不打扰用户;但留 devtools - // 面包屑便于诊断(显式「获取模型」按钮才 surface 错误给用户)。 - console.warn("[autoFetchModelsForDisplay] displayName 预取失败,保持 raw id 显示:", e); - } - } - - function collectProviderMappingsWithCustom() { - // [MOC-154] 行序 → Codex slot:行0 的模型同时占 gpt_5_5 + default(Codex 新对话默认 - // gpt-5.5 直接用它);其余行依次填 gpt_5_4/.../gpt_5_2。 - // **输出全部 slot key(空的也给空串)**:删行/清空某行后该 slot="" 才能覆盖后端已存的 - // 旧值 —— update_provider 对 models 是 per-key merge(crud.rs:413,input 无条件覆盖 - // existing、含空串),漏给某 key = 旧值残留磁盘 + Codex catalog(chatgpt-codex-connector - // 发现)。catalog 生成时空 slot 仍会跳过、不显示。 - const slotKeys = providerFormDefaultRows.filter((k) => k !== "default"); // gpt_5_5..gpt_5_2 - const result = {}; - slotKeys.forEach((k) => { - result[k] = String(providerFormMappings[k] || "").trim(); - }); - result.default = result.gpt_5_5 || ""; - return result; - } - - function updateProviderModelInput(slotKey, value) { - providerFormMappings[slotKey] = value.trim(); - // [MOC-173] inline 改 mapping 值后同步审查模型下拉:所选槽位被清空时下拉自动重置为 - // 「跟随主模型」,否则 若按 slot key - // 保留会静默指向另一个 model。先记下审查当前指向的 model 值,repack 后按该值重定位到它 - // 的新槽位(原 model 行被删 → 找不到 → 清回「跟随主模型」)。 - const reviewSel = $("#providerReviewModelSlot"); - const reviewModelVal = - reviewSel && reviewSel.value ? providerFormMappings[reviewSel.value] || "" : ""; - const vals = providerFormRows.map((k) => providerFormMappings[k] || ""); - vals.splice(index, 1); - providerFormMappings = {}; - providerFormRows = vals.map((val, i) => { - providerFormMappings[slotKeys[i]] = val; - return slotKeys[i]; - }); - openProviderModelMenuKey = null; - renderProviderMappings(); - if (reviewModelVal) { - const newSlot = providerFormRows.find( - (k) => (providerFormMappings[k] || "") === reviewModelVal, - ); - setReviewModelSlotField(newSlot || ""); - } - } - - function toggleProviderModelMenu(rowKey) { - openProviderModelMenuKey = openProviderModelMenuKey === rowKey ? null : rowKey; - renderProviderMappings(); - } - - function closeProviderModelMenu() { - if (openProviderModelMenuKey === null) return; - openProviderModelMenuKey = null; - renderProviderMappings(); - } - - function setAuthSchemeValue(value) { - const input = $("#providerAuth"); - if (!input) return; - input.value = providerAuthSchemes.includes(value) ? value : "bearer"; - } - - function renderPresetOptions(preset = null, mappings = null) { - // [MOC-241] Gemini 1M 开关跟映射联动:每次重渲染都按当前映射刷新可见性 + 勾选态。 - updateGemini1mRow(normalizeMappings(mappings || collectProviderMappings())); - const container = $("#providerPresetOptions"); - if (!container) return; - const modelOptions = preset?.modelOptions && typeof preset.modelOptions === "object" - ? Object.entries(preset.modelOptions) - : []; - const requestOptionPresets = preset?.requestOptionPresets && typeof preset.requestOptionPresets === "object" - ? Object.entries(preset.requestOptionPresets) - : []; - const options = [...modelOptions, ...requestOptionPresets]; - const notices = Array.isArray(preset?.notices) ? preset.notices.filter((n) => n && n.text) : []; - - if (!options.length && !notices.length) { - container.hidden = true; - container.innerHTML = ""; - return; - } - - const currentMappings = normalizeMappings(mappings || collectProviderMappings()); - container.hidden = false; - - const noticeIcon = (type) => { - if (type === "warning") return "bi-exclamation-triangle-fill"; - if (type === "success") return "bi-check-circle-fill"; - return "bi-info-circle-fill"; - }; - const noticesHtml = notices.map((n) => ` -
- - ${escapeHtml(n.text)} -
- `).join(""); - - const optionsHtml = options.map(([id, option]) => ` - - `).join(""); - - container.innerHTML = noticesHtml + optionsHtml; - } - - function applyPresetModelOption(optionId, enabled) { - const option = selectedPreset?.modelOptions?.[optionId] || selectedPreset?.requestOptionPresets?.[optionId]; - if (!option) return; - const hasModels = option.models && typeof option.models === "object"; - const hasCapabilities = option.modelCapabilities && typeof option.modelCapabilities === "object"; - const mappings = option.models - ? (enabled ? option.models : selectedPreset.models || emptyMappings()) - : collectProviderMappings(); - if (hasModels) { - setProviderMappings(mappings); - } - if (hasCapabilities) { - formModelCapabilities = enabled - ? mergeCapabilities(formModelCapabilities || selectedPreset.modelCapabilities || {}, option.modelCapabilities) - : clearCapabilities(formModelCapabilities, option.modelCapabilities); - } else if (hasModels) { - formModelCapabilities = normalizeCapabilities(enabled - ? option.modelCapabilities || selectedPreset.modelCapabilities || {} - : selectedPreset.modelCapabilities || {}); - } - if (option.requestOptions) { - formRequestOptions = enabled - ? mergeRequestOptions(selectedPreset.requestOptions || {}, option.requestOptions) - : clearRequestOptions(formRequestOptions, option.requestOptions); - } - renderPresetOptions(selectedPreset, mappings); - showToast(`${option.label || optionId} ${t("providersAdd.optionApplied")}`); - } - - function collectProviderMappings() { - return collectProviderMappingsWithCustom(); - } - - function providerPayloadFromForm(includeModels = true) { - const apiKey = $("#providerApiKey").value.trim(); - const mappings = includeModels ? collectProviderMappings() : null; - const modelCapabilities = mappings - ? capabilitiesForCurrentMappings(mappings) - : normalizeCapabilities(formModelCapabilities); - // [MOC-241] Gemini 1M 开关:把 context_window(开 1M / 关 600K)写进待存 capabilities。 - applyGemini1mCapabilities(modelCapabilities, mappings); - const payload = { - name: $("#providerName").value.trim(), - baseUrl: $("#providerBaseUrl").value.trim(), - authScheme: $("#providerAuth").value, - apiFormat: formApiFormatValue, - extraHeaders: selectedPreset?.extraHeaders || {}, - modelCapabilities, - requestOptions: normalizeRequestOptions(formRequestOptions), - }; - if (apiKey) { - payload.apiKey = apiKey; - } - if (includeModels) { - payload.models = mappings; - } - // R1 PR-7:apiFormat=grok_web 时打包 extra.grokWeb(cookies + statsigId)。 - // Provider 后端 schema 用 `#[serde(flatten)] extra`,任何不在已知字段的 key - // 自动收进 provider.extra,所以前端 payload 顶层加 `grokWeb` 就 work。 - const grokWebPayload = collectGrokWebPayload(); - if (grokWebPayload) { - payload.grokWeb = grokWebPayload; - } - // [MOC-173] auto-review 审查模型槽位:始终带上(含空串)——空串让后端清除、回退复用主模型。 - payload.reviewModelSlot = $("#providerReviewModelSlot")?.value ?? ""; - return payload; - } - - function findDocsUrlForProvider(provider) { - if (!presetCache.length) return null; - const stripSlash = (s) => String(s || "").replace(/\/+$/, ""); - const target = stripSlash(provider.baseUrl); - const providerId = String(provider.id || ""); - for (const preset of presetCache) { - const candidates = new Set([stripSlash(preset.baseUrl)]); - for (const opt of (preset.baseUrlOptions || [])) { - if (opt && opt.value) candidates.add(stripSlash(opt.value)); - } - if (preset.id === providerId || (target && candidates.has(target))) { - return preset.docsUrl || null; - } - } - return null; - } - - function providerCardMarkup(provider) { - const mapping = [ - provider.mappings.default, - provider.mappings.gpt_5_5, - provider.mappings.gpt_5_4, - provider.mappings.gpt_5_4_mini, - provider.mappings.gpt_5_3_codex, - provider.mappings.gpt_5_2, - ] - .filter(Boolean) - .slice(0, 2) - .join(" / "); - const providerId = escapeHtml(provider.id); - const providerName = escapeHtml(provider.name); - const providerUrl = escapeHtml(provider.baseUrl); - const mappingText = escapeHtml(mapping || provider.apiFormat); - const docsUrl = findDocsUrlForProvider(provider); - const baseUrlMarkup = docsUrl - ? `${providerUrl}` - : `${providerUrl}`; - return ` -
- - - - ${providerName} - ${baseUrlMarkup} - - ${mappingText} - - ${provider.default ? `${escapeHtml(t("status.active"))}` : ""} - - - - - - - - - - - - -
- `; - } - - function providerPresetCardMarkup(preset, added = false) { - const presetId = escapeHtml(preset.id); - return ` - - `; - } - - function dashboardPresetSectionMarkup(providers, presets) { - const available = presets.filter((preset) => !presetExists(preset, providers)); - if (!available.length) return ""; - return ` -
-
-
-

${escapeHtml(t("dashboard.availablePresets"))}

-

${escapeHtml(t("dashboard.availablePresetsHint"))}

-
-
-
- ${available.map((preset) => providerPresetCardMarkup(preset)).join("")} -
-
- `; - } - - function getDragAfterElement(container, y) { - const items = [...container.querySelectorAll("[data-provider-id]:not(.dragging)")]; - return items.reduce((closest, child) => { - const box = child.getBoundingClientRect(); - const offset = y - box.top - box.height / 2; - if (offset < 0 && offset > closest.offset) return { offset, element: child }; - return closest; - }, { offset: Number.NEGATIVE_INFINITY, element: null }).element; - } - - function enableProviderReorder(listEl) { - if (!listEl || listEl.dataset.reorderBound === "1") return; - listEl.dataset.reorderBound = "1"; - - listEl.addEventListener("dragstart", (event) => { - const card = event.target.closest("[data-provider-id]"); - if (!card) return; - card.classList.add("dragging"); - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", card.dataset.providerId); - }); - - listEl.addEventListener("dragover", (event) => { - const dragging = listEl.querySelector(".dragging"); - if (!dragging) return; - event.preventDefault(); - const afterElement = getDragAfterElement(listEl, event.clientY); - if (afterElement) { - listEl.insertBefore(dragging, afterElement); - } else { - listEl.appendChild(dragging); - } - }); - - listEl.addEventListener("drop", async (event) => { - const dragging = listEl.querySelector(".dragging"); - if (!dragging) return; - event.preventDefault(); - dragging.classList.remove("dragging"); - const providerIds = $all("[data-provider-id]", listEl).map((item) => item.dataset.providerId); - try { - await CCApi.reorderProviders(providerIds); - showToast(t("toast.providersReordered")); - await renderProviders(); - if (routeFromHash() === "dashboard") await renderDashboard(); - } catch (error) { - console.error(error); - if (routeFromHash() === "dashboard") { - await renderDashboard(); - } else { - await renderProviders(); - } - showToast(error.message || t("toast.requestFailed")); - } - }); - - listEl.addEventListener("dragend", (event) => { - event.target.closest("[data-provider-id]")?.classList.remove("dragging"); - }); - } - - async function renderProviderCards(targetSelector, options = {}) { - const target = $(targetSelector); - if (!target) return; - const providers = await CCApi.getProviders(); - if (!presetCache.length) presetCache = await CCApi.getPresets(); - const providerList = providers.length - ? `
${providers.map(providerCardMarkup).join("")}
` - : ""; - if (!providers.length) { - target.innerHTML = `
${visiblePresets().map((preset) => providerPresetCardMarkup(preset)).join("")}
`; - return; - } - if (options.includePresets) { - target.innerHTML = `${providerList}${dashboardPresetSectionMarkup(providers, visiblePresets())}`; - } else { - target.innerHTML = providerList; - } - enableProviderReorder($("[data-provider-list]", target)); - } - - - // ── Plugin Unlock 状态刷新 ── - async function refreshPluginUnlockStatus() { - try { - const unlock = await CCApi.pluginUnlock.status(); - const icon = $("#pluginUnlockIcon"); - const statusText = $("#pluginUnlockStatus"); - const actions = $("#pluginUnlockActions"); - if (!icon || !statusText) return; - - icon.classList.remove("muted", "success", "warning", "danger"); - - // [MOC-104 relay] daemon 未跑(disconnected)时,若活动是真实 chatgpt 账号, - // plugins 是「原生显示」(relay 模式:Codex 据 auth_mode==chatgpt 解锁,无需 CDP - // 注入)→ 显示成功态而非「未运行」,避免误以为解锁失效。卡片语义是「Plugins 是否 - // 解锁」,relay 下已解锁;真实账号活动正是 daemon 不跑的**预期**原因(无高延迟)。 - if (unlock.status === "disconnected") { - try { - const ra = await CCApi.realAccount.status(); - // [MOC-178 codex P2] relay 实效看 active_is_chatgpt(活动 auth_mode==chatgpt),不能用 - // source==official —— detect 认 token 后 source=Official 对「关了模式、活动 apikey 但 - // tokens 还在」的文件也返回,会误判 plugins 已原生解锁(实际 apikey 没解锁)。 - if (ra && ra.active_is_chatgpt === true) { - icon.innerHTML = ``; - icon.classList.add("success"); - statusText.classList.remove("muted-text"); - statusText.textContent = t("pluginUnlock.nativeRelay") || "已解锁(真实账号)"; - if (actions) actions.style.display = "none"; - return; - } - } catch (_e) { - // [MEDIUM-1] 查真实账号失败 → 退回下方 daemon 态显示(方向安全、可自愈);记日志 - // 便于排障区分「真未运行」vs「查询失败退回」(与本文件其余 catch 一致)。 - console.log("[PluginUnlock] relay 检测 status() 失败,退回 daemon 态:", _e); - } - } - - const statusMap = { - disconnected: { icon: "bi-lock", class: "muted", text: t("pluginUnlock.disconnected") || "未运行" }, - connecting: { icon: "bi-arrow-repeat", class: "warning", text: t("pluginUnlock.connecting") || "连接中..." }, - connected: { icon: "bi-plug", class: "warning", text: t("pluginUnlock.connected") || "已连接" }, - injected: { icon: "bi-unlock", class: "success", text: t("pluginUnlock.injected") || "已解锁" }, - failed: { icon: "bi-exclamation-triangle", class: "danger", text: unlock.message || "失败" }, - }; - const s = statusMap[unlock.status] || statusMap.disconnected; - icon.innerHTML = ``; - icon.classList.add(s.class); - statusText.classList.toggle("muted-text", s.class === "muted"); - statusText.textContent = s.text; - if (actions) actions.style.display = unlock.status === "injected" || unlock.status === "connected" ? "block" : "none"; - - // 同步设置页"运行时状态"提示。dashboard 卡片跟 settings 页用同一份 - // /api/desktop/plugin-unlock/status 数据,文案前缀 "运行时状态:" 标识 - // 这是 daemon 当前态(跟用户配置区分开)。 - } catch (e) { - console.log("[PluginUnlock] status refresh failed:", e); - } - } - - // MOC-104:真实 ChatGPT 账号「账号状态」(获取成功/获取失败/账号已失效 + 状态色)。 - // 失败静默,不影响其它卡片。realAccountExpired 由 relogin-required 事件置真。 - async function refreshRealAccountStatus() { - try { - const resp = await CCApi.realAccount.status(); - const st = resp.status || {}; - const login = resp.login || {}; - // [connector review] 「账号已失效」以后端持久标记 st.relogin_required 为准 —— - // 不再只靠一次性事件(启动时若前端还没注册 listener 会丢)。登录成功后端会清零, - // 下次轮询这里自然恢复。一次性事件仍保留(更快),与此处轮询殊途同归。 - realAccountExpired = st.relogin_required === true; - const el = $("#raAcctStatus"); - if (el) { - let text, color; - if (login.state === "running") { - text = t("realAccount.acctFetching") || "获取中…"; - color = "var(--ra-muted, #999)"; - } else if (realAccountExpired) { - text = t("realAccount.acctExpired") || "账号已失效"; - color = "var(--ra-bad, #d33)"; - } else if (st.logged_in) { - text = t("realAccount.acctOk") || "获取成功"; - color = "var(--ra-ok, #1a8f3c)"; - } else { - text = t("realAccount.acctFail") || "获取失败"; - color = "var(--ra-muted, #999)"; - } - el.textContent = text; - el.style.color = color; - } - // [MOC-104 解耦] 运行状态 = 解锁是否真的生效,而非旧开关原值: - // ① 活动是有效真实 chatgpt(原生显示 plugins,无需 daemon)→ 已开启; - // ② 否则用户显式强制开启(开关=true,跑 CDP 高延迟 daemon)→ 已开启; - // ③ 都不是 → 未开启。这样「账号获取失败 + 没强制开启」如实显示未开启, - // 不再出现「获取失败却显示已开启(默默走高延迟)」。 - // [MOC-104] 派生「自动解锁」开关态 + 运行状态:真实账号活动(relay,source=official、 - // 未失效 → Codex 原生解锁)或持久强制档 → ON。每次刷新都按此派生,实现「首次加载 / - // 刷新按账号状态自动开/关」。relay 的 ON 由 realActive 派生维持,不写持久强制档。 - // 仅有镜像但活动是 apikey(source=imported)→ 此刻未启用,不显示已开启(gate official)。 - // [MOC-178] toggle 派生改看持久 flag(用户意图),不再用「活动是否 chatgpt」——清除切 - // apikey 后退出 restore 会把活动写回 chatgpt,若按活动派生会又显示 ON(关不住)。flag - // 是跨重启的关闭意图真相源。realActive(relay 此刻真生效)= flag 开 + 活动确实 chatgpt。 - realAccountModeEnabled = resp.mode_enabled; - const modeOn = resp.mode_enabled === true; - const realActive = modeOn && resp.active_is_chatgpt === true && !realAccountExpired; - const on = modeOn || forceUnlockPersisted; - const unlockToggle = $("#autoUnlockCodexPlugins"); - if (unlockToggle) unlockToggle.checked = on; - const runEl = $("#raRunStatus"); - if (runEl) { - // [MOC-114 connector review] relay 真账号已解锁但系统代理(梯子)没开 → plugins 网络层 - // 用不了。runtime 如实标注「网络代理未连接」(否则裸显示"已开启"会重现"已登录却转圈" - // 误导态);checkbox 仍 ON —— auth 层确实解锁了(Codex 据 auth_mode 原生显示 plugins)。 - const proxyDown = realActive && !systemProxyGateOk(lastSystemProxyStatus); - if (proxyDown) { - runEl.textContent = t("realAccount.runOnProxyDown") || "已开启(网络代理未连接)"; - runEl.style.color = "var(--ra-warn, #c80)"; - } else { - runEl.textContent = on ? t("realAccount.runOn") || "已开启" : t("realAccount.runOff") || "未开启"; - runEl.style.color = on ? "var(--ra-ok, #1a8f3c)" : "var(--ra-muted, #999)"; - } - } - // [MOC-178] 「清除真实账号」按钮显示 = 真实账号模式开(modeOn,可关)**或**有持久镜像 - // (has_imported,可清凭据)。[codex P2] 不能只看 modeOn —— direct 下 import/auto-pin 会留 - // has_imported=true 但 flag=false,只看 modeOn 会藏掉按钮、用户没法删镜像(唯一清除入口)。 - const forgetBtn = $("#realAccountForgetBtn"); - if (forgetBtn) - forgetBtn.style.display = modeOn || st.has_imported === true ? "" : "none"; - // 新登录开始 → 复位「本次登录已持久」标记,下次成功要重新 pin(替换旧镜像)。 - if (login.state === "running") realAccountLoginPinned = false; - // 自动持久(单账号工具:登录即选择真实账号模式,无需手动「钉住」): - // ① 登录刚成功 → 把刚授权的活动账号写进镜像,**即便已有旧镜像也要替换** - // (connector review:旧镜像可能是已失效账号,不替换会在日后启动被恢复回去 - // 盖掉刚重登的账号);每次登录成功只 pin 一次(realAccountLoginPinned 防重复)。 - // ② 或:活动已是真实账号但还没有镜像(如 app 外登录)→ 首次持久化一次。 - // 两种都要求活动确实是真实 chatgpt(source=official),不拿镜像态去 pin。 - // [MOC-178 codex P2] auto-pin 只在活动**真 chatgpt**(active_is_chatgpt)时触发,不能用 - // source==official —— 认 token 后 apikey+token 文件也 source=Official,会让关了模式的活动 - // (apikey)误触发 auto-pin、把 apikey 态 pin 进镜像。 - const activeReal = resp.active_is_chatgpt === true; - const justLoggedIn = login.state === "succeeded" && !realAccountLoginPinned; - const firstPersist = activeReal && !st.has_imported; - // [MOC-178] flag=false(用户主动关)时禁止**被动** auto-pin 重生镜像 —— 否则关闭后镜像复活、 - // reconcile 又恢复,违反「关闭持久」。flag 跨重启权威,realAccountForgotten 是 session 内双保险。 - // [codex P2] 但 justLoggedIn(显式 codex login 成功)是用户主动新登录,bypass mode_enabled=false - // 抑制(login path 已 reset realAccountForgotten=false)—— 否则 clear/fresh 后显式登录会被挡、 - // 登录态不 persist、toggle 弹回 off。firstPersist(被动检测到活动 chatgpt)仍尊重 flag=false。 - const autoPinModeGate = justLoggedIn || resp.mode_enabled !== false; - if (activeReal && (justLoggedIn || firstPersist) && !realAccountAutoPersisting && !realAccountForgotten && autoPinModeGate) { - realAccountAutoPersisting = true; - if (justLoggedIn) realAccountLoginPinned = true; - CCApi.realAccount.pinCurrent() - .then(async (res) => { - // [codex P2] **只在 pin 真开了 relay**(res.enabled,provider 支持 relay)时清强制 CDP 档 - // (forceUnlockPersisted/autoUnlockCodexPlugins)+ 停 daemon。direct/无 provider 下 pin 只 - // save 镜像、relay 没开(flag false),force CDP 可能是唯一 unlock path,清了会让 plugins 失去 - // 解锁;relay 真开时才清(补 auto-pin 这第三个入口,同 toggle off 第七轮 / 清除按钮第十六轮)。 - if (res && res.enabled === true && forceUnlockPersisted) { - forceUnlockPersisted = false; - try { await saveSettingsFromForm(); } catch (_e) {} - try { await CCApi.pluginUnlock.stop(); } catch (_e) {} - } - showToast(t("realAccount.kept") || "已长期保留"); - setTimeout(refreshRealAccountStatus, 300); - }) - .catch((e) => console.log("[RealAccount] auto-persist failed:", e)) - .finally(() => { realAccountAutoPersisting = false; }); - } - return login.state; - } catch (e) { - console.log("[RealAccount] status refresh failed:", e); - return undefined; - } - } - - // [MOC-104] 「自动解锁 Codex Plugins」开关的智能 change handler: - // - 开启:检测账号 → 有效真实账号则 relay 原生解锁(后端自动,无需 daemon)、提示成功; - // 无有效账号则弹引导窗(登录优先 / 强制兜底),开关回退派生态。 - // - 关闭:relay(有账号)态前端关不掉账号原生显示 → 提示走「清除真实账号」并弹回 ON; - // 强制档则持久化 false + 停 daemon。 - async function onAutoUnlockToggle(e) { - const toggle = e.target; - let st = {}; - let modeEnabled = realAccountModeEnabled; - let statusOk = false; - try { - const ra = await CCApi.realAccount.status(); - st = (ra && ra.status) || {}; - modeEnabled = ra && ra.mode_enabled; - realAccountModeEnabled = modeEnabled; - statusOk = true; - } catch (err) { - console.log("[RealAccount] onAutoUnlockToggle status() 失败:", err); - } - // [HIGH-1] status() 失败 ≠ 无账号(admin server 启动早期 / 抖动)。回退持久 flag 派生态 + - // 提示重试,不伪装无账号误导已登录用户。 - if (!statusOk) { - toggle.checked = (realAccountModeEnabled === true) || forceUnlockPersisted; - showToast(t("realAccount.statusUnavailable") || "暂时无法获取账号状态,请稍后重试"); - return; - } - // [MOC-114] relay 真账号/插件/第三方路由都依赖系统代理(梯子)可达;探测失败 fail-open。 - let proxyConnected = true; - try { - const sp = await CCApi.systemProxy.status(); - proxyConnected = systemProxyGateOk(sp && sp.systemProxy); - } catch (_e) { - proxyConnected = true; - } - if (toggle.checked) { - // [MOC-178] 开真实账号模式:账号有有效 token(新口径 logged_in,哪怕活动当前是 apikey) - // + 梯子连通 → enable(写持久 flag=true + 把活动写回 chatgpt + apply relay)。 - if (st.logged_in && proxyConnected) { - try { - await CCApi.realAccount.enable(); - realAccountForgotten = false; - realAccountModeEnabled = true; - showToast(t("realAccount.modeEnabled") || "已开启真实账号模式"); - setTimeout(refreshRealAccountStatus, 100); - setTimeout(refreshPluginUnlockStatus, 300); - } catch (err) { - toggle.checked = false; - showToast((err && err.message) || "开启真实账号模式失败"); - } - } else { - // 无 token / 缺代理 → 引导弹窗(登录优先 / 强制兜底)。开关回退强制档值。 - toggle.checked = forceUnlockPersisted; - showRealAccountGateModal(!st.logged_in, !proxyConnected); - } - } else { - // [MOC-178] 关真实账号模式:**仅当 flag 真开**(modeEnabled===true)才 forget(切 apikey + 持久 - // flag=false、保留 tokens)。[codex P2] 不能用 st.logged_in —— modeEnabled=false 但有账号 + - // forceUnlockPersisted=true 时 checkbox on 只因 force CDP(on=modeOn||force),toggle off 是想关 - // force,误调 forget 会**删掉真实账号镜像**(数据丢失);那种情况落到下面 else 只关 force 档。 - if (modeEnabled === true) { - try { - const res = await CCApi.realAccount.forget(); - realAccountForgotten = true; - realAccountModeEnabled = false; - // [codex P2] 一并清强制 daemon 档:曾 force-enable(autoUnlockCodexPlugins=true)又有 - // 真实账号的用户,若不清,forget 后 toggle 派生 modeOn||forceUnlockPersisted 仍 true - // (弹回 on)、启动也看 autoUnlockCodexPlugins=true 启 CDP daemon,plugins 仍被 force-unlock。 - // 关真实账号模式 = 把整个解锁开关都关掉。 - if (forceUnlockPersisted) { - forceUnlockPersisted = false; - await saveSettingsFromForm(); - try { await CCApi.pluginUnlock.stop(); } catch (_e) {} - } - // [codex P2] 切 apikey 失败(IO error)→ switchedToApikey:false → warning 暴露(同清除按钮), - // 不报「已关」误导(活动可能仍 chatgpt、plugins 未关)。主操作(删镜像+关 flag)已成功故 - // success:true 不 throw、上面 handling 照常执行。 - if (res && res.switchedToApikey === false) { - showToast(t("realAccount.forgetApplyFailed") || "已清除镜像,但切 apikey 失败 —— Plugins 可能未关,请重试或重启 Codex"); - } else { - showToast(t("realAccount.modeDisabled") || "已关闭真实账号模式(切 apikey,登录态保留)"); - } - setTimeout(refreshRealAccountStatus, 100); - setTimeout(refreshPluginUnlockStatus, 300); - } catch (err) { - toggle.checked = true; - showToast((err && err.message) || "关闭真实账号模式失败"); - } - } else { - // 无真实账号 → 强制 daemon 档关闭:持久化 false + 停 daemon。 - forceUnlockPersisted = false; - await saveSettingsFromForm(); - try { await CCApi.pluginUnlock.stop(); } catch (_e) {} - setTimeout(refreshPluginUnlockStatus, 300); - setTimeout(refreshRealAccountStatus, 300); - } - } - } - - // [MOC-114] 解锁引导窗 —— 按「缺账号 / 缺代理 / 都缺」动态设 title/desc 与登录按钮可见性。 - // 缺代理时登录按钮无意义(梯子没挂,登录也连不上),隐藏之;强制开启按钮始终保留(逃生阀)。 - function showRealAccountGateModal(needAccount, needProxy) { - const m = $("#realAccountNoAccountModal"); - if (!m) return; - const titleEl = m.querySelector("h3"); - const descEl = m.querySelector(".codex-modal-desc"); - const loginBtn = m.querySelector('[data-action="real-account-noacct-login"]'); - let titleKey, descKey; - if (needAccount && needProxy) { - titleKey = "realAccount.gateBothTitle"; - descKey = "realAccount.gateBothDesc"; - } else if (needProxy) { - titleKey = "realAccount.gateProxyTitle"; - descKey = "realAccount.gateProxyDesc"; - } else { - titleKey = "realAccount.noAccountTitle"; - descKey = "realAccount.noAccountDesc"; - } - // [review] 同步 data-i18n 到动态 key —— 否则 i18n.apply() 在语言切换时 re-walk - // [data-i18n] 节点会用旧的 noAccount key 覆盖回静态文案,把「缺代理」误显示成「缺账号」。 - if (titleEl) { titleEl.dataset.i18n = titleKey; titleEl.textContent = t(titleKey) || titleEl.textContent; } - if (descEl) { descEl.dataset.i18n = descKey; descEl.textContent = t(descKey) || descEl.textContent; } - if (loginBtn) loginBtn.style.display = needAccount ? "" : "none"; - m.hidden = false; - } - - let realAccountAutoPersisting = false; - // [MOC-104] 持久「强制 CDP daemon 档」值(= settings.autoUnlockCodexPlugins)。开关 checked - // 是派生态(realActive || forceUnlockPersisted):真实账号活动走 relay 原生解锁、不靠它;它 - // 只在「无账号 + 用户强制开启」时为 true。与派生 checkbox 分离,避免 saveSettings 把 - // realActive 误存进强制档(否则账号失效后会误启 daemon)。 - let forceUnlockPersisted = false; - // [MOC-178] 真实账号模式持久开关缓存(后端 settings.realAccountModeEnabled 三态:true/false/ - // null=未设)。每次 status 刷新更新;status() 失败时回退派生用。 - let realAccountModeEnabled = null; - let realAccountLoginPinned = false; // 本次登录成功是否已 pin(替换镜像);新登录开始时复位 - let realAccountExpired = false; // relogin-required 事件置真 → 账号状态显示「账号已失效」 - let realAccountForgotten = false; // 用户点「清除」后置真 → 抑制本 session auto-persist 重生镜像 - // 轮询登录直到终态(succeeded/failed/cancelled)——固定几次 setTimeout 会在 OAuth - // 慢于最后一次时漏掉 succeeded → auto-persist 不触发(connector P2)。running 期间 - // 持续轮询,终态或超 5min 停。 - let realAccountLoginPollTimer = null; - function pollRealAccountLogin() { - if (realAccountLoginPollTimer) return; // 已在轮询 - const started = Date.now(); - const tick = async () => { - realAccountLoginPollTimer = null; - const state = await refreshRealAccountStatus(); - if (state === "running" && Date.now() - started < 300000) { - realAccountLoginPollTimer = setTimeout(tick, 2500); - } - }; - realAccountLoginPollTimer = setTimeout(tick, 1500); - } - - async function renderDashboard() { - // **#249 fix**:getStatus / getActivities 分别 try-catch,任一失败 - // 仍渲染其余卡片,避免单个 API 崩溃 → 整个 dashboard 白屏。 - let status; - try { - status = await CCApi.getStatus(); - } catch (err) { - console.error("[renderDashboard] getStatus failed:", err); - status = {}; - } - let activities = []; - try { - activities = await CCApi.getActivities(); - } catch (err) { - console.error("[renderDashboard] getActivities failed:", err); - } - const health = status.desktopHealth || {}; - const desktopReady = status.desktopConfigured && !health.needsApply; - try { - await renderProviderCards("#dashboardProviderCards", { includePresets: true }); - } catch (err) { - console.error("[renderDashboard] renderProviderCards failed:", err); - } - const desktopIcon = $("#dashboardDesktopIcon"); - desktopIcon.classList.toggle("muted", !desktopReady); - desktopIcon.innerHTML = ``; - const desktopStatus = $("#dashboardDesktopStatus"); - desktopStatus.classList.toggle("muted-text", !desktopReady); - desktopStatus.textContent = health.needsApply - ? t("status.needsApply") - : status.desktopConfigured ? t("status.configured") : t("status.notConfigured"); - renderDesktopHealthWarning("#dashboardDesktopWarning", health); - // ── proxy 状态卡片:图标颜色 + 文字颜色跟随 running 状态 ── - const proxyIcon = $("#dashboardProxyIcon"); - if (proxyIcon) { - proxyIcon.classList.toggle("success", !!status.proxyRunning); - proxyIcon.classList.toggle("muted", !status.proxyRunning); - proxyIcon.innerHTML = status.proxyRunning - ? '' - : ''; - } - const proxyStatusEl = $("#dashboardProxyStatus"); - proxyStatusEl.textContent = status.proxyRunning ? `${t("status.running")} :${status.proxyPort}` : t("status.stopped"); - proxyStatusEl.classList.toggle("muted-text", !status.proxyRunning); - $("#dashboardProviderName").textContent = status.activeProvider?.name ?? "—"; - // Plugin Unlock 状态刷新 - refreshPluginUnlockStatus(); - // MOC-104 真实账号状态刷新 - refreshRealAccountStatus(); - // MOC-114 网络代理(梯子)连通性刷新(独立异步,含 TCP 探测,不阻塞 dashboard 其余渲染) - refreshSystemProxyStatus(); - // MOC-32 PR-2b: silently dropped Responses tool types - refreshDroppedToolsWarning(); - $("#activityList").innerHTML = activities.map((item) => ( - `
${escapeHtml(item.text)}
` - )).join(""); - try { - await refreshUpdateBadge(); - } catch (err) { - console.error("[renderDashboard] refreshUpdateBadge failed:", err); - } - } - - // MOC-114:最近一次系统代理探测结果(refreshSystemProxyStatus 写),供 refreshRealAccountStatus - // 派生 runtime 状态复用,避免高频轮询里重复 TCP 探测。 - let lastSystemProxyStatus = null; - - // MOC-114:系统代理是否「通过 gate」。语义:**只有「配了代理但端口连不上(梯子没开)」才算 - // 未通过**;没配代理(configured=false,可能 TUN 模式/路由器 VPN/直连,网络本就正常)、PAC - // (无固定端口无法探)、查询失败(null)一律 fail-open —— 这些都无法判定「梯子没开」,武断 - // gate 会误伤网络正常的用户(devin review),且与探测失败乐观放行口径一致。 - function systemProxyGateOk(spData) { - if (!spData) return true; - if (spData.kind === "pac") return true; - if (!spData.configured) return true; - return spData.connected === true; - } - - // MOC-114:刷新「网络代理」卡片 —— 系统代理(梯子)是否挂 + 端口可连。relay 真账号、 - // 插件、第三方路由都依赖它;探测只连代理端口、不碰 chatgpt.com。查询失败时显示「未知」 - // 灰色,**不**伪装成未连接(避免误导已挂梯子的用户)。 - async function refreshSystemProxyStatus() { - const el = $("#dashboardSystemProxyStatus"); - const icon = $("#dashboardSystemProxyIcon"); - if (!el) return; - let sp = null; - try { - const resp = await CCApi.systemProxy.status(); - sp = resp && resp.systemProxy; - } catch (e) { - console.log("[SystemProxy] status failed:", e); - } - lastSystemProxyStatus = sp; // 缓存供 refreshRealAccountStatus 派生 runtime 复用(null=查询失败) - const setIcon = (cls, glyph) => { - if (!icon) return; - icon.classList.remove("success", "warning", "muted"); - icon.classList.add(cls); - icon.innerHTML = ``; - }; - if (!sp) { - el.textContent = t("status.unknown") || "未知"; - el.classList.add("muted-text"); - setIcon("muted", "bi-globe2"); - return; - } - // [review] PAC 自动配置无固定端口、无法 TCP 探测 → 不武断报「未连接」(会误导 + 误 gate - // 一个代理其实正常的 PAC 用户);如实显示「自动配置」,gate 侧亦对 PAC fail-open。 - if (sp.kind === "pac") { - el.textContent = t("systemProxy.pac") || "自动配置(PAC)"; - el.classList.add("muted-text"); - setIcon("muted", "bi-globe2"); - return; - } - // [review devin] 没配系统代理(configured=false)可能是 TUN 模式/路由器 VPN/直连,网络本就 - // 正常 → 显示中性「未配置」、不报「未连接」警告(否则误导,且与 gate fail-open 口径一致)。 - if (sp.configured === false) { - el.textContent = t("systemProxy.notConfigured") || "未配置"; - el.classList.add("muted-text"); - setIcon("muted", "bi-globe2"); - return; - } - const connected = sp.connected === true; - el.textContent = connected - ? (t("systemProxy.connected") || "已连接") - : (t("systemProxy.disconnected") || "未连接"); - el.classList.toggle("muted-text", !connected); - setIcon(connected ? "success" : "warning", connected ? "bi-globe2" : "bi-exclamation-triangle"); - // [connector review] 首次加载时 refreshRealAccountStatus 先跑、彼时 lastSystemProxyStatus - // 还是 null → fail-open 渲染 runtime「已开启」。这里 cache 刚填好,若代理 down(gate 失败) - // 补触发一次 real-account 重新派生,把 runtime 修正成「已开启(网络代理未连接)」,不让误导 - // 态持续到下次导航。单向调用(refreshRealAccountStatus 不回调本函数),无循环;仅代理 down - // 时多一次本地 status 请求。 - if (!connected) refreshRealAccountStatus(); - } - - /// MOC-32 PR-2b: query /api/diagnostic/dropped-tools, total>0 时弹 warning - /// 让 user / maintainer 看到 transfer adapter 静默 drop 的 Responses API - /// 工具类型(防 MOC-32 类静默 bug 再藏 N 月)。total=0 隐藏 warning(0 是 - /// healthy 状态不要刷屏)。 - async function refreshDroppedToolsWarning() { - const warning = $("#dashboardDroppedToolsWarning"); - if (!warning) return; - try { - const data = await CCApi.getDroppedTools(); - const total = Number(data?.total ?? 0); - if (total <= 0) { - warning.hidden = true; - return; - } - const byType = data.by_type || {}; - const types = Object.keys(byType).sort(); - const summary = $("#dashboardDroppedToolsSummary"); - if (summary) { - summary.textContent = ` (${total} ${t("dashboard.droppedToolsCalls") || "次"} / ${types.length} ${t("dashboard.droppedToolsTypes") || "种"})`; - } - const list = $("#dashboardDroppedToolsList"); - if (list) { - list.innerHTML = types - .map((tt) => `
  • ${escapeHtml(tt)} × ${Number(byType[tt])}
  • `) - .join(""); - } - warning.hidden = false; - } catch (_) { - warning.hidden = true; - } - } - - // MOC-91:展示用的 preset 列表 —— `showGrayPresets=false` 时滤掉灰色(`gray:true`)preset。 - // **只过滤展示**,不动 presetCache 本身(它还要供已配置 provider 反查 preset:logo / - // notices / 默认值 —— 见 presetMatchesProvider / showProviderForm),否则已添加的灰色 - // provider 会匹配不到自己的 preset。 - function visiblePresets(list) { - const src = list || presetCache; - return showGrayPresets ? src : src.filter((p) => p && p.gray !== true); - } - - async function renderPresets() { - presetCache = await CCApi.getPresets(); - $("#presetList").innerHTML = visiblePresets().map((preset) => { - const active = selectedPreset?.id === preset.id; - const isCustom = preset.id === "custom-third-party"; - const nameMarkup = isCustom - ? `${escapeHtml(preset.name)}` - : `${escapeHtml(preset.name)}`; - const subText = isCustom - ? `${escapeHtml(preset.baseUrlHint || "")}` - : `${escapeHtml(preset.baseUrl)}`; - return ` - - `; - }).join(""); - } - - function setProviderFormMode(titleKey) { - const title = $("#page-providers-add .page-title h1"); - if (title) title.textContent = t(titleKey); - const submit = $("#providerSaveOnly"); - if (submit) submit.textContent = t("common.saveOnly"); - const result = $("#formSpeedResult"); - if (result) { - result.textContent = ""; - result.className = "speed-result"; - } - const modelResult = $("#providerModelFetchResult"); - if (modelResult) modelResult.textContent = ""; - } - - function setApiKeyInputState(hasSavedKey = false, savedKey = "") { - const input = $("#providerApiKey"); - const label = $("label[for='providerApiKey']"); - if (!input) return; - input.type = "password"; - input.value = savedKey || ""; - input.required = !hasSavedKey && !savedKey; - input.placeholder = (hasSavedKey || savedKey) ? t("providers.keySavedPlaceholder") : t("providers.keyPlaceholder"); - const toggle = $("[data-action='toggle-key']"); - if (toggle) toggle.innerHTML = ''; - if (label) label.classList.toggle("required", input.required); - } - - /// i18n template fill — 替代 ad-hoc `t(key).replace("{var}",val)`。fallback 行为: - /// 1) t() 返 key 字符串(missing key)→ console.warn + return key (silent-failure - /// M1 修)。2) replace 后仍残留 `{var}` 占位 → console.warn 让 i18n 不全暴露 - function tFmt(key, vars = {}) { - const tmpl = t(key); - if (tmpl === key) { - console.warn(`[i18n] missing key: ${key}`); - } - let out = tmpl; - for (const [k, v] of Object.entries(vars)) { - out = out.split(`{${k}}`).join(String(v ?? "")); - } - if (/\{[a-zA-Z_]+\}/.test(out)) { - console.warn(`[i18n] unsubstituted placeholder in "${key}": ${out}`); - } - return out; - } - - /// Cloud Code Assist OAuth row 切换:apiFormat 是 OAuth 类(gemini_cli_oauth / - /// antigravity_oauth)时隐藏 apiKey input,显示 OAuth login button + status - /// widget;其他 apiFormat 隐藏 OAuth row。调用时机:form 加载 / apiFormat - /// select 切换。 - /// 内部 update activeOauthConfig 让后续 refresh/login/logout 走对的 provider - /// R1 PR-7:apiFormat=grok_web 时显示 grok web cookie 输入 row,隐藏 apiKey - /// 输入(grok_web 不用 apiKey,用 extra.grokWeb.{cookies, statsigId})。 - /// 与 setOauthRowState 互斥 — 调用方应先确保 apiFormat 解析后路由到对的一个。 - function setGrokWebRowState(apiFormat) { - const row = $("#providerGrokWebRow"); - const apiKeyRow = $("#providerApiKeyRow"); - const apiKeyInput = $("#providerApiKey"); - const { canonical } = normalizeApiFormat(apiFormat); - const isGrokWeb = canonical === "grok_web"; - if (row) row.hidden = !isGrokWeb; - if (apiKeyRow) { - // grok_web 隐藏 apiKey input;非 grok_web 显示(避免与 OAuth 状态冲突 - // —— setOauthRowState 自己控制 isOauth case 的 apiKey 可见性,所以 - // 我们**只在切换到/离开 grok_web 时操作**,其它情况让 OAuth/默认逻辑接管) - if (isGrokWeb) { - apiKeyRow.hidden = true; - } - } - if (apiKeyInput && isGrokWeb) { - // required 兜底:grok_web 不需要 apiKey 必填,否则浏览器 form validation 会 - // 卡住 submit(即使 input 被 hidden div 包着,required 仍校验)。 - // - // **chatgpt-codex P1 修(2026-05-12)**:**不**在非 grok_web 时无条件设 - // `required = true` —— OAuth modes(gemini_cli_oauth / antigravity_oauth) - // 由 setOauthRowState 自己管理 required(它走 dataset.origRequired - // 保存/恢复机制,1308-1313 行),无条件覆盖会让 OAuth 模式 hidden apiKey - // 仍 required → form submit 静默被浏览器拒。chain 调用顺序是 - // setOauthRowState → setGrokWebRowState,我们离开 grok_web 时**不动**, - // 让上游 setter 的决定生效。 - apiKeyInput.required = false; - } - if (isGrokWeb) { - const authEl = $("#providerAuth"); - if (authEl) authEl.value = "grok_cookie"; - } - } - - /// 从 grok_web form input 收集 grokWeb extra payload(用于 POST 时打包到 - /// provider.extra.grokWeb)。 - /// - /// Plan A:仅 sso 必填;sso-rw / cf_clearance / statsigId / userAgent 都 optional - /// (后端 auth.rs 缺失时分别复用 sso / 跳过 segment / 动态生成 / 用默认 UA)。 - /// - /// 返回 null 表示不是 grok_web 模式或 input 全空(编辑模式留空 = 保留现值)。 - function collectGrokWebPayload() { - const row = $("#providerGrokWebRow"); - if (!row || row.hidden) return null; - const sso = $("#grokWebSso")?.value.trim() || ""; - const ssoRw = $("#grokWebSsoRw")?.value.trim() || ""; - const cf = $("#grokWebCfClearance")?.value.trim() || ""; - const cookieString = $("#grokWebCookieString")?.value.trim() || ""; - const statsigId = $("#grokWebStatsigId")?.value.trim() || ""; - const userAgent = $("#grokWebUserAgent")?.value.trim() || ""; - if (!sso && !ssoRw && !cf && !cookieString && !statsigId && !userAgent) - return null; - const cookies = { sso }; - if (ssoRw) cookies["sso-rw"] = ssoRw; - if (cf) cookies.cf_clearance = cf; - if (cookieString) cookies.cookieString = cookieString; - const payload = { cookies }; - if (statsigId) payload.statsigId = statsigId; - if (userAgent) payload.userAgent = userAgent; - return payload; - } - - /// 编辑现有 provider 时初始化 grok_web form。 - /// - /// 后端 public_provider 已把 grokWeb 字段 mask 出去,只保留 `hasGrokWeb: bool` - /// (cookies + statsigId 是高敏感凭证,跟 apiKey 一样不回传前端)。所以这里: - /// - 清空 input 值 - /// - hasGrokWeb=true 时给 placeholder 提示"已保存凭证,留空则保持不变" - /// - 用户若真要替换才填新值,save 时 collectGrokWebPayload 返回新对象; - /// 若空白 save → payload 不带 grokWeb → 后端 update_provider 不动现值 - function fillGrokWebFormFromProvider(provider) { - const hasGrokWeb = !!provider?.hasGrokWeb; - const ids = [ - "grokWebSso", - "grokWebSsoRw", - "grokWebCfClearance", - "grokWebCookieString", - "grokWebStatsigId", - "grokWebUserAgent", - ]; - const savedPlaceholder = t("grokWeb.savedPlaceholder") || "已保存,留空则保持"; - for (const id of ids) { - const el = $(`#${id}`); - if (!el) continue; - el.value = ""; - el.placeholder = hasGrokWeb ? savedPlaceholder : ""; - } - } - - /// apiFormat = 当前 form 选的协议;authScheme(可选)= 当前 form 的 authScheme, - /// 不传则 resolveOauthConfig 从 #providerAuth 现值读(zai/bigmodel 靠它命中)。 - function setOauthRowState(apiFormat, authScheme) { - const oauthRow = $("#providerOauthRow"); - const apiKeyRow = $("#providerApiKeyRow"); - const apiKeyInput = $("#providerApiKey"); - const baseUrlRow = $("#providerBaseUrlRow"); - const baseUrlInput = $("#providerBaseUrl"); - const config = resolveOauthConfig(apiFormat, authScheme); - activeOauthConfig = config; - const isOauth = !!config; - if (oauthRow) oauthRow.hidden = !isOauth; - if (apiKeyRow) apiKeyRow.hidden = isOauth; - // OAuth 模式 baseUrl 由 preset 写死(per-config baseUrl), - // user 不需要看 / 改;切回非 OAuth 显示 - if (baseUrlRow) baseUrlRow.hidden = isOauth; - // **silent-failure-hunter H3 修**:原 `required = !isOauth && req` 单调毁掉 - // required 字段(切 OAuth 后 required=false,切回 openai_chat 仍 false)。改用 - // dataset.origRequired 缓存,switch 回非 OAuth 时恢复 - if (apiKeyInput) { - if (apiKeyInput.dataset.origRequired === undefined) { - apiKeyInput.dataset.origRequired = apiKeyInput.required ? "1" : "0"; - } - if (isOauth) { - apiKeyInput.required = false; - } else { - apiKeyInput.required = apiKeyInput.dataset.origRequired === "1"; - } - } - // baseUrl 同样的 required cache 处理 — OAuth 时解 required 防表单提交卡住 - if (baseUrlInput) { - if (baseUrlInput.dataset.origRequired === undefined) { - baseUrlInput.dataset.origRequired = baseUrlInput.required ? "1" : "0"; - } - baseUrlInput.required = isOauth ? false : baseUrlInput.dataset.origRequired === "1"; - // **silent-failure-hunter H2 修**:hide 行不等于清 value。OAuth 模式下 - // baseUrl 由 per-config 写死(gemini/antigravity=cloudcode-pa,zai=api.z.ai, - // bigmodel=open.bigmodel),user 切换 preset 时残留的旧 value 可能跟着 form - // submit 上去让 backend 用错 endpoint。强制锁定 value 防止 hidden field 数据漂移。 - if (isOauth && config.baseUrl) { - baseUrlInput.value = config.baseUrl; - } - } - if (isOauth && config) { - // 切换 provider 时,把 OAuth row 内**全部**静态 i18n 节点的 data-i18n key - // 重写到当前 provider namespace。语言切换时 i18n.applyTranslations 会读 - // dataset.i18n 拿对的本地化 — 缺写就 stale 显错 provider 文案。 - // refreshOauthStatusUi 异步,resolve 前 user 切语言会撞旧 key,所以这里 - // 4 个节点全部同步重写一次(label / loginBtn / logoutBtn / statusText) - const k = (suffix) => `${config.i18nPrefix}.${suffix}`; - const label = $("#providerOauthRow > label.form-label"); - if (label) { - label.dataset.i18n = k("title"); - label.textContent = tFmt(k("title")); - } - const loginBtn = $("#oauthLoginBtn"); - if (loginBtn) { - loginBtn.dataset.i18n = k("loginBtn"); - loginBtn.textContent = tFmt(k("loginBtn")); - } - const logoutBtn = $("#oauthLogoutBtn"); - if (logoutBtn) { - logoutBtn.dataset.i18n = k("logoutBtn"); - logoutBtn.textContent = tFmt(k("logoutBtn")); - } - const statusEl = $("#oauthStatusText"); - if (statusEl) { - statusEl.dataset.i18n = k("statusLoading"); - statusEl.textContent = tFmt(k("statusLoading")); - statusEl.classList.remove("text-warning"); - } - refreshOauthStatusUi().catch((e) => { - console.error("refresh oauth status failed:", e); - }); - } - } - - /// 调 GET /api/-oauth/status 同步 UI 状态(已登录 / 未登录 / partial)。 - /// 错误路径完整:清旧 i18n key + 复位 button visibility + structured error message - /// (silent-failure-hunter C1 修)。i18n key 前缀按 activeOauthConfig 切换, - /// 让 gemini-cli vs antigravity 用各自文案 namespace。 - /// - /// **race 安全**(2026-05-11 codex-connector P2):入口 snapshot `activeOauthConfig`, - /// await 后**identity check** 才动 DOM。否则 user 在 status fetch 飞行中切 provider - /// (eg gemini-cli ↔ antigravity 互切),旧 provider 的延迟 response 会覆盖 - /// 新 provider UI,显错登录状态。同 handleOauthLogin/Logout 的 race fix 模式 - async function refreshOauthStatusUi() { - const statusEl = $("#oauthStatusText"); - const loginBtn = $("#oauthLoginBtn"); - const logoutBtn = $("#oauthLogoutBtn"); - if (!statusEl) return; - const config = activeOauthConfig; - if (!config) return; // OAuth row 隐藏中,不刷新 - const k = (suffix) => `${config.i18nPrefix}.${suffix}`; - try { - const status = await config.api.getStatus(); - // **post-await identity check**:status 拿回时 user 可能已切到别 provider / - // 关 OAuth row,这条 response 是过时数据,不该污染新 UI 上下文 - if (activeOauthConfig !== config) { - return; - } - if (!status.loggedIn) { - statusEl.dataset.i18n = k("statusNotLoggedIn"); - statusEl.textContent = tFmt(k("statusNotLoggedIn")); - statusEl.classList.remove("text-warning"); - if (logoutBtn) logoutBtn.hidden = true; - if (loginBtn) { - loginBtn.hidden = false; - loginBtn.dataset.i18n = k("loginBtn"); - loginBtn.textContent = tFmt(k("loginBtn")); - } - } else if (!config.requiresProject) { - // [MOC-252] 无 project 概念的 OAuth(zai / bigmodel):纯账号登录,status 只有 - // email(+ obtainedAt),projectId/expiresAt 不参与判定。logged in = 成功态, - // 不显 partial / 不加 warning。 - statusEl.dataset.i18n = k("statusLoggedIn"); - statusEl.textContent = tFmt(k("statusLoggedIn"), { - email: status.email || "?", - }); - statusEl.classList.remove("text-warning"); - if (logoutBtn) logoutBtn.hidden = false; - if (loginBtn) { - loginBtn.hidden = false; - // 已登录时改 button 文案为"切换账号" - loginBtn.dataset.i18n = k("switchAccountBtn"); - loginBtn.textContent = tFmt(k("switchAccountBtn")); - } - } else { - const expiresStr = status.expiresAt - ? new Date(status.expiresAt).toLocaleString() - : "?"; - const tmplKey = status.projectId - ? k("statusLoggedIn") - : k("statusLoggedInNoProject"); - statusEl.dataset.i18n = tmplKey; - statusEl.textContent = tFmt(tmplKey, { - email: status.email || "?", - projectId: status.projectId || "?", - expiresAt: expiresStr, - }); - // partial state(无 projectId)加 visual cue(M3 修) - statusEl.classList.toggle("text-warning", !status.projectId); - if (logoutBtn) logoutBtn.hidden = false; - if (loginBtn) { - loginBtn.hidden = false; - // 已登录时改 button 文案为"切换账号"(reviewer #2 修) - loginBtn.dataset.i18n = k("switchAccountBtn"); - loginBtn.textContent = tFmt(k("switchAccountBtn")); - } - } - } catch (e) { - // **catch 路径同 identity check**:fetch reject 后 user 可能也已切 provider - if (activeOauthConfig !== config) { - return; - } - const msg = e?.message || String(e); - statusEl.dataset.i18n = k("statusFetchFailed"); - statusEl.textContent = tFmt(k("statusFetchFailed"), { error: msg }); - statusEl.classList.add("text-warning"); - // catch 路径**复位** button visibility 防 stale(C1 修) - if (loginBtn) { - loginBtn.hidden = false; - loginBtn.dataset.i18n = k("loginBtn"); - loginBtn.textContent = tFmt(k("loginBtn")); - } - if (logoutBtn) logoutBtn.hidden = true; - } - } - - /// OAuth login click handler — long-poll 等浏览器授权 + bootstrap project。 - /// **silent-failure-hunter C2 修**:fetch 自带 timeout 风险(浏览器 ~ 90-300s vs - /// 后端 5min),browser timeout 后端继续成功 → frontend 看 fail 但 status 看 success - /// 矛盾 UX。修法:catch 不显 "failed" toast,而是显"timeout 等 status 刷新"+ refresh。 - /// 走 activeOauthConfig 拿对应 provider(gemini-cli vs antigravity)的 API + i18n - async function handleOauthLogin() { - const config = activeOauthConfig; - if (!config) { - // OAuth row 在隐藏状态下被点(button stale / e2e replay / 罕见 race)。 - // 不能 silent return —— user 看 click 没反应会怀疑 app hang。toast 给出提示 - console.warn("handleOauthLogin called with no active oauth config"); - showToast("OAuth login skipped: no active OAuth provider in form (switch apiFormat first)"); - return; - } - const k = (suffix) => `${config.i18nPrefix}.${suffix}`; - const loginBtn = $("#oauthLoginBtn"); - const logoutBtn = $("#oauthLogoutBtn"); - if (loginBtn) { - loginBtn.disabled = true; - loginBtn.textContent = tFmt(k("loginBtnInProgress")); - } - if (logoutBtn) logoutBtn.disabled = true; - try { - const result = await config.api.login(); - if (result.cancelled) { - // 用户在浏览器授权窗口取消 / 关窗(后端返 {loggedIn:false, cancelled:true})。 - // 中性提示,不当错误。zai/bigmodel 后端显式带 cancelled 字段。 - showToast(tFmt(k("loginCancelled"))); - } else if (!config.requiresProject && result.loggedIn) { - // [MOC-252] 无 project 概念的 OAuth(zai / bigmodel):loggedIn 即成功, - // 不看 projectId。 - showToast(tFmt(k("loginSuccess"), { - email: result.email || "?", - })); - } else if (result.loggedIn && result.projectId) { - showToast(tFmt(k("loginSuccess"), { - email: result.email || "?", - projectId: result.projectId, - })); - } else if (result.loggedIn && !result.projectId) { - // partial state — token 不该在此分支被持久化(后端 commit C C2 修),但 UI 防御 - showToast(tFmt(k("loginPartial"))); - } else if (result.error) { - showToast(tFmt(k("loginFailed"), { error: result.error })); - } else { - // 未知 shape — 把整个 response 序列化进 toast 给 user 看到(silent H1 修) - const dump = JSON.stringify(result).slice(0, 200); - console.error("OAuth login unknown response shape:", result); - showToast(tFmt(k("loginFailed"), { error: `unknown response: ${dump}` })); - } - } catch (e) { - // C2:浏览器 fetch timeout 而后端可能仍成功 — 不显 "failed",改提示"refreshing" - const msg = e?.message || String(e); - console.warn("OAuth login fetch error (backend may have succeeded):", msg); - showToast(`Login fetch interrupted (${msg}); refreshing status...`); - } finally { - if (loginBtn) loginBtn.disabled = false; - if (logoutBtn) logoutBtn.disabled = false; - // **silent-failure C1+C2 修**:long-poll 期间 user 可能切到非 OAuth / - // 另一 OAuth provider,activeOauthConfig 已变。这种情况 refresh 会画错 - // provider 的状态进 row(toast 已给确认)— 跳过 refresh。回到原 provider - // 时 setOauthRowState 会重新 fetch 一次 status,UI 收敛 - if (activeOauthConfig === config) { - await refreshOauthStatusUi(); - } - } - } - - /// Logout click handler。**silent-failure-hunter H2 修**:logout 失败时显手动删 - /// 提示而不是 blindly refresh status (那会让 user 看 logged in 没意识 token 还在)。 - /// 走 activeOauthConfig 拿对应 provider 的 API + i18n - async function handleOauthLogout() { - const config = activeOauthConfig; - if (!config) { - console.warn("handleOauthLogout called with no active oauth config"); - showToast("OAuth logout skipped: no active OAuth provider in form"); - return; - } - const k = (suffix) => `${config.i18nPrefix}.${suffix}`; - let failed = false; - try { - await config.api.logout(); - showToast(tFmt(k("logoutConfirmed"))); - } catch (e) { - failed = true; - const msg = e?.message || String(e); - showToast(tFmt(k("logoutFailedManual"), { error: msg })); - const statusEl = $("#oauthStatusText"); - if (statusEl) { - statusEl.dataset.i18n = k("logoutFailedManual"); - statusEl.textContent = tFmt(k("logoutFailedManual"), { error: msg }); - statusEl.classList.add("text-warning"); - } - } finally { - // 失败时不刷新 status — 防覆盖 manual-delete 警告。成功时刷新让 UI 转 "未登录"。 - // **silent-failure C1+C2 修**:logout 长 poll 罕见但 race 同样适用 — - // 切到别 provider 时不画旧 provider 的状态进 row - if (!failed && activeOauthConfig === config) { - await refreshOauthStatusUi(); - } - } - } - - function resetProviderForm() { - editingProviderId = null; - selectedPreset = null; - providerAvailableModels = []; - baseUrlMenuOpen = false; - gemini1mOptIn = false; // [MOC-241] 新建态:Gemini 1M 开关意图复位为关 - renderPresetOptions(null); - updatePresetSelection(); - formModelCapabilities = {}; - formRequestOptions = {}; - setProviderFormMode("providersAdd.title"); - $("#providerName").value = ""; - $("#providerName").placeholder = ""; - $("#providerBaseUrl").value = ""; - $("#providerBaseUrl").placeholder = ""; - $("#providerBaseUrl").disabled = false; - const trigger = $("#providerBaseUrlTrigger"); - if (trigger) trigger.hidden = true; - renderBaseUrlOptions(null); - setApiKeyInputState(false); - $("#providerAuth").value = "bearer"; - renderApiFormatDisplay("openai_chat"); - setApiFormatMode(false, "openai_chat"); - setOauthRowState("openai_chat"); // P2.2 reset OAuth row 隐藏 - setGrokWebRowState("openai_chat"); // R1 PR-7 reset grok_web row 隐藏 - fillGrokWebFormFromProvider(null); - setMimoLoginRow(false, false, null); // 新建态无 provider.id,隐藏登录 row - setProviderMappings(emptyMappings()); - setReviewModelSlotField(""); // 新建/重置 → 审查模型回到「跟随主模型」 - } - - function applyPresetToForm(preset, notify = true) { - // 自定义第三方:不预填 name/baseUrl(用户必须自己填),用 placeholder 提示 - // builtin preset:直接预填 name + baseUrl,用户保存即可 - const isCustom = preset.id === "custom-third-party"; - if (isCustom) { - $("#providerName").value = ""; - $("#providerName").placeholder = preset.name; - $("#providerBaseUrl").value = ""; - $("#providerBaseUrl").placeholder = "https://api.example.com/v1"; - } else { - $("#providerName").value = preset.name; - $("#providerName").placeholder = ""; - $("#providerBaseUrl").value = preset.baseUrl; - $("#providerBaseUrl").placeholder = ""; - } - $("#providerBaseUrl").disabled = false; - const trigger = $("#providerBaseUrlTrigger"); - if (trigger) trigger.hidden = true; - baseUrlMenuOpen = false; - renderBaseUrlOptions(preset); - setAuthSchemeValue(preset.authScheme); - setApiKeyInputState(false); - selectedPreset = preset; - renderApiFormatDisplay(preset.apiFormat); - setApiFormatMode(!!preset.allowApiFormatSelection, preset.apiFormat); - setOauthRowState(preset.apiFormat); // P2.2 OAuth UI 切换 - setGrokWebRowState(preset.apiFormat); // R1 PR-7 grok_web UI 切换 - formModelCapabilities = normalizeCapabilities(preset.modelCapabilities || {}); - // [MOC-241] 预设自带的 Gemini context_window(=1M)不当作用户选择 —— 否则选 Google AI Studio / - // Gemini CLI / Antigravity 预设直接保存就持久化 1M、违反「Gemini 1M 开关默认关」(#490 bot review)。 - // 清掉 → 开关默认关(updateGemini1mRow 读不到 ≥1M);用户显式开启经 change listener 写回 1M。 - for (const id of Object.keys(formModelCapabilities)) { - const cap = formModelCapabilities[id]; - if (isGemini1mModelId(id) && cap && typeof cap === "object") { - delete cap.context_window; - if (Object.keys(cap).length === 0) delete formModelCapabilities[id]; - } - } - gemini1mOptIn = false; // [MOC-241] 新建/预设:Gemini 1M 开关默认关 - formRequestOptions = normalizeRequestOptions(preset.requestOptions || {}); - setMimoLoginRow(false, false, null); // 选 preset(新建态)无 provider.id,隐藏登录 row - providerAvailableModels = []; - setProviderMappings(preset.models || emptyMappings()); - setReviewModelSlotField(""); // preset 不带审查模型 → 回到「跟随主模型」 - renderPresetOptions(preset, preset.models || emptyMappings()); - updatePresetSelection(); - if (notify) showToast(`${preset.name} ${t("toast.presetFilled")}`); - } - - async function fillProviderForEdit(providerId) { - const providers = await CCApi.getProviders(); - const provider = providers.find((item) => item.id === providerId); - if (!provider) return; - editingProviderId = provider.id; - const matchedPreset = presetCache.find((preset) => presetMatchesProvider(preset, provider)); - selectedPreset = matchedPreset - ? { ...matchedPreset, extraHeaders: provider.extraHeaders || matchedPreset.extraHeaders || {} } - : { - models: provider.mappings, - extraHeaders: provider.extraHeaders || {}, - modelCapabilities: provider.modelCapabilities || {}, - requestOptions: provider.requestOptions || {}, - }; - formModelCapabilities = normalizeCapabilities(provider.modelCapabilities || selectedPreset.modelCapabilities || {}); - // [MOC-241] 编辑已存 provider:按已保存的 gemini context_window 推断 1M 开关意图(≥1M → 开)。 - gemini1mOptIn = deriveGemini1mOptIn(formModelCapabilities, provider.mappings || {}); - formRequestOptions = normalizeRequestOptions(provider.requestOptions || selectedPreset.requestOptions || {}); - setProviderFormMode("providersAdd.editTitle"); - $("#providerName").value = provider.name; - $("#providerName").placeholder = ""; - $("#providerBaseUrl").value = provider.baseUrl; - $("#providerBaseUrl").placeholder = ""; - // 内置 provider 不允许修改 baseUrl - $("#providerBaseUrl").disabled = !!provider.isBuiltin; - const baseUrlTrigger = $("#providerBaseUrlTrigger"); - if (baseUrlTrigger) baseUrlTrigger.hidden = !!provider.isBuiltin; - baseUrlMenuOpen = false; - renderBaseUrlOptions(selectedPreset); - setApiKeyInputState(provider.hasApiKey); - if (provider.hasApiKey) { - try { - const secret = await CCApi.getProviderSecret(provider.id); - setApiKeyInputState(true, secret.apiKey || ""); - } catch (error) { - console.error(error); - showToast(error.message || t("toast.requestFailed")); - } - } - setAuthSchemeValue(provider.authScheme); - const effectiveFormat = (matchedPreset && matchedPreset.apiFormat) || provider.apiFormat; - renderApiFormatDisplay(effectiveFormat); - setApiFormatMode(false, effectiveFormat); - setOauthRowState(effectiveFormat); // OAuth UI 切换(P2.2) - setGrokWebRowState(effectiveFormat); // R1 PR-7 grok_web UI 切换 - fillGrokWebFormFromProvider(provider); - // [MOC-211] 编辑已保存的 MiMo Token Plan provider → 显示「登录小米账号」row(有 provider.id 可落 cookie) - setMimoLoginRow(isMimoTokenPlan(provider.baseUrl), !!provider.hasMimoCookie, provider.id); - providerAvailableModels = []; - setProviderMappings(provider.mappings || emptyMappings()); - setReviewModelSlotField(provider.reviewModelSlot || ""); // 回填已配置的审查模型槽位 - renderPresetOptions(selectedPreset, provider.mappings || emptyMappings()); - updatePresetSelection(); - // [MOC-69] antigravity 自动拉模型列表,让映射选框立即显示 displayName(不必手点「获取模型」); - // 失败/离线静默保持 raw id 显示。只对 antigravity(唯一带 displayName 的 provider)生效。 - if ((effectiveFormat || provider.apiFormat) === "antigravity_oauth") { - await autoFetchModelsForDisplay(); - } - } - - async function renderProviderForm() { - await renderPresets(); - if (editingProviderId) { - await fillProviderForEdit(editingProviderId); - return; - } - if (selectedPreset) { - setProviderFormMode("providersAdd.title"); - applyPresetToForm(selectedPreset, false); - return; - } - resetProviderForm(); - } - - async function renderProviders() { - await renderModelMenuModePanel(); - await renderProviderCards("#providerRows"); - } - - function renderModelMenuModeState(settings = {}) { - const enabled = !!settings.exposeAllProviderModels; - const button = $("#modelMenuModeToggle"); - const hint = $("#modelMenuModeHint"); - if (button) { - button.classList.toggle("btn-primary", enabled); - button.classList.toggle("btn-outline-primary", !enabled); - const span = $("span", button); - if (span) span.textContent = enabled ? t("providers.showSingleModel") : t("providers.showAllModels"); - button.setAttribute("aria-pressed", enabled ? "true" : "false"); - } - if (hint) { - hint.textContent = enabled ? t("providers.modelMenuAllHint") : t("providers.modelMenuSingleHint"); - } - const settingToggle = $("#exposeAllProviderModels"); - if (settingToggle) settingToggle.checked = enabled; - } - - async function renderModelMenuModePanel() { - const settings = await CCApi.getSettings(); - renderModelMenuModeState(settings); - } - - async function renderModelSelectors() { - const providers = await CCApi.getProviders(); - const select = $("#modelProvider"); - select.innerHTML = providers.map((provider) => ``).join(""); - const active = providers.find((provider) => provider.default) || providers[0]; - if (active) select.value = active.id; - renderMappingCards(); - } - - async function renderMappingCards() { - const providers = await CCApi.getProviders(); - const provider = providers.find((item) => item.id === $("#modelProvider").value) || providers[0]; - if (!provider) return; - const defaultSelect = $("#defaultModel"); - if (defaultSelect) { - const defaultValue = provider.mappings.default || provider.mappings.gpt_5_5 || ""; - const defaultKey = providerFormModelSlots.find((slot) => provider.mappings[slot.key] === defaultValue)?.key || "gpt_5_5"; - defaultSelect.value = defaultKey; - } - const result = $("#modelFetchResult"); - if (result) result.textContent = ""; - $("#mappingStack").innerHTML = providerFormModelSlots.slice(1).map((slot) => ` -
    -
    - - ${slot.label} - ${slot.label} -
    - - ${slot.source} -
    - `).join(""); - } - - async function renderDesktop() { - const desktop = await CCApi.getDesktopStatus(); - const entries = Object.entries(desktop.config || {}); - const health = desktop.health || {}; - const desktopReady = desktop.configured && !health.needsApply; - const statusText = $("#desktopConfiguredText"); - statusText.textContent = health.needsApply - ? t("status.needsApply") - : desktop.configured ? t("status.configured") : t("status.notConfigured"); - statusText.classList.toggle("muted-text", !desktopReady); - $(".desktop-card .circle-check")?.classList.toggle("warning", !desktopReady); - renderDesktopHealthWarning("#desktopPageWarning", health); - $("#desktopConfigList").innerHTML = entries.map(([key, value]) => ` -
    ${escapeHtml(key)}:${escapeHtml(Array.isArray(value) ? JSON.stringify(value) : value)}
    - `).join(""); - // Show env config commands instead of raw JSON - const cmdBlock = desktop.commands?.temporary || JSON.stringify(desktop.config, null, 2); - $("#desktopJson").textContent = cmdBlock; - } - - let proxyLogTimer = null; - let proxyLogInflight = false; - let proxyLogAtBottom = true; - const PROXY_LOG_BOTTOM_TOLERANCE = 8; - - function isProxyLogAtBottom(el) { - return el.scrollTop + el.clientHeight >= el.scrollHeight - PROXY_LOG_BOTTOM_TOLERANCE; - } - - function bindProxyLogScroll() { - const logEl = $("#proxyLog"); - if (!logEl || logEl.dataset.scrollBound === "1") return; - logEl.dataset.scrollBound = "1"; - logEl.addEventListener("scroll", () => { - proxyLogAtBottom = isProxyLogAtBottom(logEl); - }, { passive: true }); - } - - async function refreshProxyLog() { - if (proxyLogInflight) return; - const logEl = $("#proxyLog"); - if (!logEl) return; - proxyLogInflight = true; - try { - const [proxyStatus, logs] = await Promise.all([ - CCApi.getProxyStatus(), - CCApi.getProxyLogs(), - ]); - const wasAtBottom = proxyLogAtBottom; - const prevScrollTop = logEl.scrollTop; - logEl.innerHTML = logs.map((line) => ` -
    ${escapeHtml(line.at)}${escapeHtml(line.level.toUpperCase())}${escapeHtml(line.message)}
    - `).join(""); - const userToggleOn = $("#autoScroll")?.checked !== false; - if (userToggleOn && wasAtBottom) { - logEl.scrollTop = logEl.scrollHeight; - proxyLogAtBottom = true; - } else { - logEl.scrollTop = prevScrollTop; - proxyLogAtBottom = isProxyLogAtBottom(logEl); - } - const statsEl = $("#proxyStats"); - if (statsEl) { - const stats = [ - { label: t("proxy.stats.total"), value: proxyStatus.stats.total, icon: "bi-list-ul" }, - { label: t("proxy.stats.success"), value: proxyStatus.stats.success, icon: "bi-check-circle" }, - { label: t("proxy.stats.failed"), value: proxyStatus.stats.failed, icon: "bi-x-circle", danger: true }, - { label: t("proxy.stats.today"), value: proxyStatus.stats.today, icon: "bi-calendar3" }, - ]; - statsEl.innerHTML = stats.map((stat) => ` -
    ${stat.label}${stat.value}
    - `).join(""); - } - } catch (err) { - // 静默吞掉单次轮询失败,避免在控制台刷错误 - } finally { - proxyLogInflight = false; - } - } - - function stopProxyLogAutoRefresh() { - if (proxyLogTimer !== null) { - clearInterval(proxyLogTimer); - proxyLogTimer = null; - } - proxyLogAtBottom = true; - } - - function startProxyLogAutoRefresh() { - stopProxyLogAutoRefresh(); - proxyLogTimer = setInterval(() => { - if (document.visibilityState === "hidden") return; - refreshProxyLog(); - }, 2000); - } - - async function renderProxy() { - const status = await CCApi.getStatus(); - $("#proxyPort").value = status.proxyPort; - $("#settingsProxyPort").value = status.proxyPort; - $("#proxyStateText").textContent = status.proxyRunning ? t("status.running") : t("status.stopped"); - // ── 停止态视觉反馈:pulse-dot 灰色 + 状态文字灰色 ── - const proxyRunningEl = document.querySelector(".proxy-running"); - if (proxyRunningEl) proxyRunningEl.classList.toggle("stopped", !status.proxyRunning); - // ── toggle 按钮:running → Stop(danger),stopped → Start(success) ── - const toggleBtn = $("#proxyToggleBtn"); - if (toggleBtn) { - if (status.proxyRunning) { - toggleBtn.className = "btn btn-danger btn-lg"; - toggleBtn.innerHTML = `${t("proxy.stop")}`; - } else { - toggleBtn.className = "btn btn-success btn-lg"; - toggleBtn.innerHTML = `${t("proxy.start")}`; - } - } - bindProxyLogScroll(); - proxyLogAtBottom = true; - await refreshProxyLog(); - startProxyLogAutoRefresh(); - } - - // MOC-145 发现性徽章 seen 标记(localStorage, 纯 UI; try/catch 同 default-dir 范式)。 - // 默认关 + 一次性提示: 用户与控件交互过即视为已发现, 永久隐藏「NEW」徽章。 - // **必须放 IIFE 顶层**(非 bindEvents 内): renderSettings 与 bindEvents 是同级函数, - // 声明在 bindEvents 内则 renderSettings 看不到 → ReferenceError 整页崩(codex-connector P1)。 - function webFetchHintSeen() { - try { - return localStorage.getItem("cas:webfetch-hint-seen") === "1"; - } catch (e) { - return false; - } - } - function markWebFetchHintSeen() { - try { - localStorage.setItem("cas:webfetch-hint-seen", "1"); - } catch (e) { - /* localStorage 不可用: 徽章下次仍显示, 无害 */ - } - const b = $("#webFetchNewBadge"); - if (b) b.hidden = true; - } - - async function renderSettings() { - const settings = await CCApi.getSettings(); - applyTheme(settings.theme || "default"); - $("#settingsProxyPort").value = settings.proxyPort; - $("#settingsAdminPort").value = settings.adminPort; - $("#autoApplyOnStart").checked = settings.autoApplyOnStart !== false; - // [MOC-104] 持久强制档初值;refreshRealAccountStatus 随后按 realActive 派生修正 - // (有账号→ON、无账号→强制档值)。migrate 后默认 false → 无账号时初始 OFF。 - forceUnlockPersisted = settings.autoUnlockCodexPlugins === true; - $("#autoUnlockCodexPlugins").checked = forceUnlockPersisted; - $("#autoWakeCodexPet").checked = settings.autoWakeCodexPet !== false; - $("#codexQuotaEnabled").checked = settings.codexQuotaEnabled === true; - $("#exposeAllProviderModels").checked = !!settings.exposeAllProviderModels; - showGrayPresets = settings.showGrayProviders === true; - $("#showGrayProviders").checked = showGrayPresets; - $("#restoreCodexOnExit").checked = settings.restoreCodexOnExit !== false; - $("#mcpCredentialsPortableStore").checked = settings.mcpCredentialsPortableStore !== false; - $("#codexNetworkAccess").checked = settings.codexNetworkAccess === true; - // [MOC-185] 诊断模式 = session 级一次性:不读持久化,改查查看器**真实运行态** - // (退出 transfer 即关、启动不自启;CAS_DIAG_TRACE env 例外)。切页面回来时 - // checkbox 反映本 session 是否还开着,避免与运行态 desync。 - if ($("#traceViewerEnabled")) { - let _tv = false; - try { - _tv = (await CCApi.traceViewerStatus())?.running === true; - } catch (_) { - /* status 查询失败(后端未就绪等):保守置关 */ - } - $("#traceViewerEnabled").checked = _tv; - if ($("#openTraceViewerBtn")) $("#openTraceViewerBtn").hidden = !_tv; - } - if ($("#webFetchBackend")) { - const _wfb = settings.webFetchBackend || "auto"; // MOC-215: 默认 auto(与后端 schema 默认一致) - // segmented 按钮组: 高亮当前档 + 记下"已保存值"供切换失败/取消时回退。 - $("#webFetchBackend").dataset.saved = _wfb; - $all("#webFetchBackend .btn").forEach((b) => - b.classList.toggle("active", b.dataset.webfetch === _wfb) - ); - // MOC-145 发现性徽章: 默认关 + 一次性提示。用户与控件交互过(localStorage seen)就不再显示。 - const _wfBadge = $("#webFetchNewBadge"); - if (_wfBadge) _wfBadge.hidden = webFetchHintSeen(); - } - $("#settingsUpdateUrl").value = settings.updateUrl || ""; - renderModelMenuModeState(settings); - // 设置页的 Plugins 解锁运行时状态 + 真实账号区(MOC-104)随设置页一起刷新, - // 不依赖用户先访问 dashboard。 - refreshPluginUnlockStatus(); - refreshRealAccountStatus(); - await refreshAppVersion(); - await refreshBackupList(); - await refreshCodexSnapshotStatus(); - await refreshResidualScanStatus(); - } - - // #268 — Codex 原配置完整性自检渲染. - async function refreshResidualScanStatus() { - const statusEl = $("#residualScanStatus"); - const repairBtn = $("#repairResidualBtn"); - const previewEl = $("#residualScanPreview"); - if (!statusEl) return; - statusEl.classList.remove("residual-clean", "residual-dirty"); - statusEl.textContent = t("settings.residualScanStatusUnknown"); - if (repairBtn) repairBtn.hidden = true; - if (previewEl) { - previewEl.hidden = true; - previewEl.textContent = ""; - } - let report; - try { - report = await CCApi.scanResidualPollution(); - } catch (error) { - statusEl.textContent = tFmt("settings.residualScanStatusError", { - error: error?.message || String(error), - }); - return null; - } - const count = (report?.polluted || []).length; - if (count === 0) { - statusEl.classList.add("residual-clean"); - statusEl.textContent = report?.transferCurrentlyApplied - ? t("settings.residualScanStatusCleanWhileApplied") - : t("settings.residualScanStatusClean"); - return report; - } - statusEl.classList.add("residual-dirty"); - statusEl.textContent = tFmt("settings.residualScanStatusDirty", { count }); - if (repairBtn) repairBtn.hidden = false; - return report; - } - - function formatResidualPreview(polluted) { - const lines = []; - for (const file of polluted) { - const kindLabel = (() => { - switch (file.kind) { - case "liveConfig": - return "~/.codex/config.toml"; - case "activeSnapshot": - return "active snapshot"; - case "recoverySnapshot": - return "recovery snapshot"; - default: - return file.kind; - } - })(); - lines.push(`[${kindLabel}] ${file.path}`); - for (const key of file.fieldsToStrip || []) { - lines.push(` - ${key}`); - } - } - return lines.join("\n"); - } - - async function handleRepairResidual() { - const previewEl = $("#residualScanPreview"); - let scan; - try { - scan = await CCApi.scanResidualPollution(); - } catch (error) { - showToast(tFmt("settings.residualScanStatusError", { - error: error?.message || String(error), - })); - return; - } - if (!scan?.polluted?.length) { - await refreshResidualScanStatus(); - return; - } - const preview = formatResidualPreview(scan.polluted); - if (previewEl) { - previewEl.textContent = `${t("settings.residualScanPreviewTitle")}\n\n${preview}`; - previewEl.hidden = false; - } - if (!window.confirm(tFmt("settings.residualScanConfirm", { preview }))) { - return; - } - try { - const result = await CCApi.repairResidualPollution({ dryRun: false }); - const cleaned = (result?.repair?.repaired || []).length; - showToast(tFmt("settings.residualScanToastCleaned", { count: cleaned })); - } catch (error) { - showToast(tFmt("settings.residualScanStatusError", { - error: error?.message || String(error), - })); - } finally { - await refreshResidualScanStatus(); - } - } - - // 只读查看:复用 scan(dry,GET)结果把每个污染文件的残留字段列进 previewEl, - // 不弹 confirm、不写盘(对比 handleRepairResidual 会清除)。 - async function handleShowResidualFields() { - const previewEl = $("#residualScanPreview"); - const report = await refreshResidualScanStatus(); - if (!report) return; // scan 出错 / 无 statusEl,状态文本已反映 - if (!report.polluted?.length) { - if (previewEl) { - previewEl.textContent = t("settings.residualScanShowFieldsClean"); - previewEl.hidden = false; - } - return; - } - const preview = formatResidualPreview(report.polluted); - if (previewEl) { - previewEl.textContent = `${t("settings.residualScanPreviewTitle")}\n\n${preview}`; - previewEl.hidden = false; - } - } - - async function refreshAppVersion() { - const target = $("#appVersion"); - if (!target) return; - try { - const payload = await CCApi.getVersion(); - if (payload && payload.version) { - target.textContent = payload.version; - } - } catch (error) { - console.warn("Failed to load app version", error); - } - } - - async function refreshCodexSnapshotStatus() { - const target = $("#codexSnapshotStatus"); - if (!target) return; - try { - const status = await CCApi.getDesktopSnapshotStatus(); - if (status && status.hasSnapshot) { - target.textContent = tFmt("settings.codexSnapshotStatusActive", { - time: status.snapshotAt || "", - }); - } else if (status && status.restorableCount > 0) { - target.textContent = tFmt("settings.codexSnapshotStatusRecovery", { - count: status.restorableCount, - }); - } else { - target.textContent = t("settings.codexSnapshotStatusEmpty"); - } - } catch (error) { - target.textContent = t("settings.codexSnapshotStatusEmpty"); - } - } - - function formatCodexSnapshotChoice(snapshot, index) { - const kind = t(`settings.codexSnapshotKind.${snapshot.kind || "unknown"}`); - const provider = snapshot.providerName || t("settings.codexSnapshotProviderUnknown"); - const time = snapshot.snapshotAt || t("settings.codexSnapshotTimeUnknown"); - const version = snapshot.appVersion || t("settings.codexSnapshotVersionUnknown"); - const files = [ - snapshot.configExisted ? "config.toml" : null, - snapshot.authExisted ? "auth.json" : null, - ].filter(Boolean).join(" + ") || t("settings.codexSnapshotFilesNone"); - return `${index + 1}. ${time} | ${kind} | ${provider} | ${version} | ${files}`; - } - - async function chooseCodexRestoreTarget() { - const snapshots = await CCApi.getDesktopSnapshots(); - if (!snapshots.length) { - return window.confirm(t("confirm.desktopClearFallback")) ? { fallback: true } : null; - } - if (snapshots.length === 1) { - const summary = formatCodexSnapshotChoice(snapshots[0], 0); - return window.confirm(tFmt("confirm.desktopSnapshotRestoreSingle", { summary })) - ? { snapshotId: snapshots[0].id } - : null; - } - const list = snapshots.map(formatCodexSnapshotChoice).join("\n"); - const input = window.prompt(tFmt("confirm.desktopSnapshotSelect", { list })); - if (input === null) return null; - const selectedIndex = Number.parseInt(String(input).trim(), 10) - 1; - if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= snapshots.length) { - showToast(t("toast.desktopSnapshotInvalid")); - return null; - } - const summary = formatCodexSnapshotChoice(snapshots[selectedIndex], selectedIndex); - if (!window.confirm(tFmt("confirm.desktopSnapshotRestoreSelected", { summary }))) return null; - return { snapshotId: snapshots[selectedIndex].id }; - } - - async function renderRoute(route) { - $all(".page").forEach((page) => page.classList.toggle("active", page.dataset.page === route)); - $all(".route-tab").forEach((tab) => { - const key = route.startsWith("providers") ? "providers" : route; - tab.classList.toggle("active", tab.dataset.nav === key); - }); - if (route !== "proxy") stopProxyLogAutoRefresh(); - // **#249 fix**:每个 render 函数单独 try-catch,防止单页 API 失败 - // 级联阻断其他页面渲染 / 首屏白屏。路由表保持与原 if 链一致(含 usage / theme)。 - const renders = { - dashboard: renderDashboard, - "providers/add": renderProviderForm, - providers: renderProviders, - desktop: renderDesktop, - proxy: renderProxy, - usage: renderUsage, - settings: renderSettings, - codex: renderCodexAssets, - theme: renderTheme, - }; - const fn = renders[route]; - if (fn) { - try { - await fn(); - } catch (err) { - console.error(`[renderRoute] ${route} failed:`, err); - showToast(err.message || t("toast.requestFailed")); - } - } - } - - // ── Usage 页 (#279) — token 统计 ──────────────────────────────────────────── - // 数据流: GET /api/usage/summary → 后端 codex-app-transfer-usage-tracker - // 扫 ~/.codex/sessions/ rollout JSONL,解析层 vendor 自 ryoppippi/ccusage(MIT)。 - let usageCache = null; - let usageActiveView = "conversation"; // conversation | daily | model - - function fmtNum(n) { - if (n === null || n === undefined) return "—"; - return Number(n).toLocaleString(); - } - - function fmtLastActivity(s) { - if (!s) return "—"; - // [MOC-19 ④] 后端已把 last_activity 按用户 tz format 成 `YYYY-MM-DD HH:MM`(不再是 - // raw UTC RFC3339,见 usage_tracker::format_last_activity_tz)。这里只做防御性裁切: - // 命中正则取前 16 字符(兼容后端 format 串 + 万一旧 cache 的 RFC3339,T/空格都认)。 - const m = s.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/); - return m ? `${m[1]} ${m[2]}` : s; - } - - function renderUsageKpis(report) { - const el = $("#usageKpis"); - if (!el) return; - const kpis = [ - { label: t("usage.kpi.totalInput"), value: fmtNum(report.totalInputTokens), icon: "bi-arrow-down-circle" }, - { label: t("usage.kpi.totalOutput"), value: fmtNum(report.totalOutputTokens), icon: "bi-arrow-up-circle" }, - { label: t("usage.kpi.totalTokens"), value: fmtNum(report.totalTokens), icon: "bi-stack" }, - { label: t("usage.kpi.conversations"), value: fmtNum(report.totalConversations), icon: "bi-chat-square-text" }, - ]; - el.innerHTML = kpis.map((kpi) => ` -
    ${escapeHtml(kpi.label)}${escapeHtml(kpi.value)}
    - `).join(""); - } - - // 缓存命中率(#304):整体 hit% = cachedInput / input;input=0 → null(显示 —) - function cacheHitPct(row) { - const input = row.inputTokens || 0; - if (input <= 0) return null; - return Math.round(((row.cachedInputTokens || 0) / input) * 100); - } - - // 按对话视图把命中率做成可点击(打开逐轮分布弹窗);其余视图纯数字。 - function cacheHitCell(row, view) { - const pct = cacheHitPct(row); - const txt = pct == null ? "—" : `${pct}%`; - if (view === "conversation" && pct != null && row.group) { - return ``; - } - return `${escapeHtml(txt)}`; - } - - // 「按对话」首列:显示 Codex 对话名(session_index thread_name)前 5 字,全名 + - // rollout 路径放 hover;无名时回退日期(MM/DD)。其余视图原样(日期 / 模型)。 - function firstColCell(row, view) { - if (view !== "conversation") return `${escapeHtml(row.group || "—")}`; - const name = (row.displayName || "").trim(); - let label; - if (name) { - label = name.length > 5 ? `${name.slice(0, 5)}…` : name; - } else { - const m = (row.group || "").match(/^\d{4}\/(\d{2})\/(\d{2})\//); - label = m ? `${m[1]}/${m[2]}` : "—"; - } - const full = name ? `${name}\n${row.group || ""}` : (row.group || ""); - return `${escapeHtml(label)}`; - } - - async function openCacheHitModal(session) { - const modal = $("#usageCacheModal"); - const chart = $("#usageCacheChart"); - const summary = $("#usageCacheModalSummary"); - if (!modal || !chart) return; - if (summary) summary.textContent = session || ""; - chart.innerHTML = `
    ${escapeHtml(t("usage.cacheModal.loading"))}
    `; - modal.hidden = false; - try { - const res = await fetch(`/api/usage/conversation/cache-series?session=${encodeURIComponent(session)}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - renderCacheChart(chart, summary, await res.json(), session); - } catch (e) { - console.warn("cas: load cache series failed", e); - chart.innerHTML = `
    ${escapeHtml(t("usage.loadError"))}: ${escapeHtml(e?.message || String(e))}
    `; - } - } - - // ≤10 桶后端已分好;每柱高度 = 该桶 token 加权命中率(cached/input)。 - function renderCacheChart(chart, summary, buckets, session) { - if (!Array.isArray(buckets) || buckets.length === 0) { - chart.innerHTML = `
    ${escapeHtml(t("usage.cacheModal.empty"))}
    `; - if (summary) summary.textContent = session || ""; - return; - } - let totCached = 0; - let totInput = 0; - let totOutput = 0; - let maxInput = 0; - buckets.forEach((b) => { - totCached += b.cachedInputTokens || 0; - totInput += b.inputTokens || 0; - totOutput += b.outputTokens || 0; - maxInput = Math.max(maxInput, b.inputTokens || 0); - }); - const overall = totInput > 0 ? Math.round((100 * totCached) / totInput) : 0; - if (summary) { - summary.textContent = - `${t("usage.cacheModal.overall")}: ${overall}% · ${fmtNum(totCached)} / ${fmtNum(totInput)}` + - ` · ${t("usage.cacheModal.output")} ${fmtNum(totOutput)}`; - } - const totalTurns = buckets[buckets.length - 1].turnEnd || 1; - const lblHit = t("usage.cacheModal.hitInput"); - const lblTotal = t("usage.cacheModal.totalInput"); - const lblOut = t("usage.cacheModal.output"); - const bars = buckets.map((b) => { - const input = b.inputTokens || 0; - const cached = b.cachedInputTokens || 0; - const output = b.outputTokens || 0; - const pct = input > 0 ? Math.round((100 * cached) / input) : 0; - // 柱高 = 该桶总输入相对全局最大输入(体现 token 量);柱内命中部分(底部、 - // 不同色)= cached/input —— 命中包含在总计里。 - const barH = maxInput > 0 ? Math.round((100 * input) / maxInput) : 0; - const posPct = Math.round((100 * b.turnEnd) / totalTurns); - const title = `${lblHit}: ${fmtNum(cached)}\n${lblTotal}: ${fmtNum(input)}\n${lblOut}: ${fmtNum(output)}`; - return `
    -
    -
    -
    -
    -
    -
    ${pct}%
    -
    ${posPct}%
    -
    `; - }).join(""); - chart.innerHTML = `
    ${bars}
    `; - } - - function renderUsageTable(report, view) { - const head = $("#usageTableHead"); - const body = $("#usageTableBody"); - const empty = $("#usageEmpty"); - if (!head || !body || !empty) return; - - let rows; - let firstColKey; - if (view === "daily") { - rows = report.daily || []; - firstColKey = "usage.col.date"; - } else if (view === "model") { - rows = report.byModel || []; - firstColKey = "usage.col.model"; - } else { - rows = report.byConversation || []; - firstColKey = "usage.col.conversation"; - } - - if (!rows.length) { - head.innerHTML = ""; - body.innerHTML = ""; - empty.hidden = false; - return; - } - empty.hidden = true; - - // By Model 视图下 first column = model name,第二个 models 列内容必然跟 first - // 列重复(每个 group 只有自己一个 model)— Devin Review #280 BUG_..._0001 fix: - // model view 跳过第二列;daily / conversation view 保留(本日 / 本会话用到的 - // 模型清单是补充信息有意义)。 - const showModelsCol = view !== "model"; - const modelHeader = showModelsCol ? `${escapeHtml(t("usage.col.model"))}` : ""; - - head.innerHTML = ` - - ${escapeHtml(t(firstColKey))} - ${modelHeader} - ${escapeHtml(t("usage.col.cacheHit"))} - ${escapeHtml(t("usage.col.input"))} - ${escapeHtml(t("usage.col.output"))} - ${escapeHtml(t("usage.col.reasoning"))} - ${escapeHtml(t("usage.col.total"))} - ${escapeHtml(t("usage.col.turns"))} - ${escapeHtml(t("usage.col.lastActivity"))} - - `; - - // ccusage daily 视图按日期降序;model / conversation 按 total tokens 降序 - const sorted = rows.slice().sort((a, b) => { - if (view === "daily") return (b.group || "").localeCompare(a.group || ""); - return (b.totalTokens || 0) - (a.totalTokens || 0); - }); - - body.innerHTML = sorted.map((row) => { - // 按对话视图优先显示真实上游模型(proxy 本地记录);无则回退 rollout 客户端模型名。 - const modelText = - view === "conversation" && row.upstreamModel - ? row.upstreamModel - : (row.models || []).join(", ") || "—"; - const modelCell = showModelsCol - ? `${escapeHtml(modelText)}` - : ""; - return ` - - ${firstColCell(row, view)} - ${modelCell} - ${cacheHitCell(row, view)} - ${escapeHtml(fmtNum(row.inputTokens))} - ${escapeHtml(fmtNum(row.outputTokens))} - ${escapeHtml(fmtNum(row.reasoningOutputTokens))} - ${escapeHtml(fmtNum(row.totalTokens))} - ${escapeHtml(fmtNum(row.turnCount))} - ${escapeHtml(fmtLastActivity(row.lastActivity))} - - `; - }).join(""); - } - - async function fetchUsageReport(forceRefresh = false) { - // 浏览器 tz:Intl.DateTimeFormat().resolvedOptions().timeZone - const tz = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone || ""); - // [MOC-19 ③] 后端有 60s TTL cache;用户主动点 Refresh(forceRefresh)时带 nocache=1 - // 绕过缓存、强制冷扫拿最新数据(refresh 的语义就是要最新)。常规切 view / 多 tab 自动 - // 加载不带 → 命中后端 cache,避免冗余全扫 1.2GB。 - const nocache = forceRefresh ? "&nocache=1" : ""; - const res = await fetch(`/api/usage/summary?tz=${tz}${nocache}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return await res.json(); - } - - function renderUsageError(message) { - // silent-failure-hunter PR #279 修:fetch 失败不再写空 cache 让 UI 误显示 - // "0 用量",而是显示带 retry 的 error banner 让用户知道是 backend 错。 - const head = $("#usageTableHead"); - const body = $("#usageTableBody"); - const empty = $("#usageEmpty"); - const kpis = $("#usageKpis"); - if (head) head.innerHTML = ""; - if (body) body.innerHTML = ""; - if (empty) empty.hidden = true; - if (kpis) { - kpis.innerHTML = ` -
    - -
    - ${escapeHtml(t("usage.kpi.totalTokens"))} - ${escapeHtml(message)} -
    -
    - `; - } - } - - async function renderUsage(forceRefresh = false) { - const loading = $("#usageLoading"); - if (!usageCache || forceRefresh) { - if (loading) loading.hidden = false; - try { - usageCache = await fetchUsageReport(forceRefresh); - } catch (e) { - console.warn("cas: load usage failed", e); - if (loading) loading.hidden = true; - renderUsageError(`${t("usage.loadError")}: ${e?.message || e}`); - return; - } finally { - if (loading) loading.hidden = true; - } - } - renderUsageKpis(usageCache); - renderUsageTable(usageCache, usageActiveView); - if (usageCache.unknownTimestampEvents && usageCache.unknownTimestampEvents > 0) { - // 后端 Phase 1 加的字段:>0 说明 ts 解析失败,可能 Codex CLI 改 format - console.warn(`cas: ${usageCache.unknownTimestampEvents} events have unparseable timestamps`); - } - } - - // delegate Usage 页交互 - document.addEventListener("click", (e) => { - const viewBtn = e.target.closest(".usage-view-btn"); - if (viewBtn) { - const view = viewBtn.dataset.usageView; - if (!view || view === usageActiveView) return; - usageActiveView = view; - $all(".usage-view-btn").forEach((b) => b.classList.toggle("active", b.dataset.usageView === view)); - renderUsageTable(usageCache || { daily: [], byModel: [], byConversation: [] }, view); - return; - } - const hitBtn = e.target.closest(".usage-cache-hit"); - if (hitBtn) { - openCacheHitModal(hitBtn.dataset.session); - return; - } - if (e.target.closest('[data-action="usage-cache-modal-close"]') || e.target.id === "usageCacheModal") { - const m = $("#usageCacheModal"); - if (m) m.hidden = true; - return; - } - if (e.target.closest("#usageRefreshBtn")) { - usageCache = null; - renderUsage(true); - } - }); - - let currentTheme = "default"; - - function normalizeTheme(theme) { - if (!theme || theme === "light" || theme === "auto") return "default"; - return availableThemes.includes(theme) ? theme : "default"; - } - - function applyTheme(theme) { - if (theme === "toggle") { - theme = currentTheme === "dark" ? "default" : "dark"; - } - const normalized = normalizeTheme(theme); - currentTheme = normalized; - document.documentElement.setAttribute("data-bs-theme", normalized === "dark" ? "dark" : "light"); - document.documentElement.setAttribute("data-theme-palette", normalized); - $all(".theme-segment .btn").forEach((button) => { - const active = button.dataset.themeAction === normalized; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", active ? "true" : "false"); - }); - const icon = $("[data-theme-action='toggle'] i"); - if (icon) icon.className = normalized === "dark" ? "bi bi-sun-fill" : "bi bi-moon-stars-fill"; - return normalized; - } - - async function saveSettingsFromForm() { - const settings = { - theme: currentTheme, - proxyPort: Number($("#settingsProxyPort").value), - adminPort: Number($("#settingsAdminPort").value), - autoApplyOnStart: $("#autoApplyOnStart")?.checked !== false, - autoUnlockCodexPlugins: forceUnlockPersisted, - autoWakeCodexPet: $("#autoWakeCodexPet")?.checked !== false, - codexQuotaEnabled: $("#codexQuotaEnabled")?.checked === true, - exposeAllProviderModels: $("#exposeAllProviderModels")?.checked || false, - showGrayProviders: $("#showGrayProviders")?.checked || false, - restoreCodexOnExit: $("#restoreCodexOnExit")?.checked !== false, - mcpCredentialsPortableStore: $("#mcpCredentialsPortableStore")?.checked !== false, - codexNetworkAccess: $("#codexNetworkAccess")?.checked === true, - // [MOC-185] traceViewerEnabled 不再持久化(诊断改 session 级,见 toggle handler)。 - webFetchBackend: $("#webFetchBackend")?.querySelector(".btn.active")?.dataset.webfetch || "auto", - updateUrl: $("#settingsUpdateUrl").value.trim(), - }; - const resp = await CCApi.saveSettings(settings); - $("#proxyPort").value = settings.proxyPort; - renderModelMenuModeState(settings); - // 返回后端响应:含 webFetchSyncWarning 时由 _commitWebFetch 提示(MOC-145)。 - return resp; - } - - function formatUsageItems(result) { - if (result.supported === false) return result.message; - if (!result.items || !result.items.length) return result.message || t("providers.usageUnavailable"); - return result.items.map((item) => { - const unit = item.unit ? ` ${item.unit}` : ""; - if (item.remaining !== null && item.remaining !== undefined) { - return `${item.label}: ${item.remaining}${unit}`; - } - if (item.used !== null && item.used !== undefined) { - return `${item.label}: ${item.used}${unit}`; - } - return item.label; - }).join(" · "); - } - - function formatProviderTestResult(result) { - if (result?.message) return result.message; - if (Number.isFinite(result?.latencyMs)) return `${Math.round(result.latencyMs)} ms`; - return t("providers.testDone"); - } - - // 测速结果是否要 UI 标黄(.bad class)。**白名单语义**(silent-failure-hunter - // review H2):后端将来加新 authStatus 枚举(`tls_warn` / `rate_limited` / - // `cert_expired` 等)/ 或返 `success: false` 不带 ok 字段,helper 默认标黄不漏判。 - // - // **修复历史(2026-05-10)**:`auth_required_or_invalid`(401/403)以前被 - // 当 bad 标黄,但 backend `test.rs:312-318` 注释明确"401/403 = baseUrl 连接性 - // OK + 鉴权未验证,应绿色"(测连接性本来不需要 key,鉴权层跟连接层解耦)。 - // 显式 allow-list 这个 authStatus 走绿色,其他未来新增 authStatus 默认仍标黄。 - function isProviderTestResultBad(result) { - if (!result) return true; - if (result.success === false) return true; - if (result.ok === false) return true; - if (result.authStatus && result.authStatus !== "ok" - && result.authStatus !== "auth_required_or_invalid") { - return true; - } - return false; - } - - // 把 backend errors[] (object 数组,含 code/host/statusCode)按当前 locale i18n 翻译。 - // 历史兼容:string 元素直接显示。未识别的 code → 走 unknown / unknown_with_status - // fallback,把 statusCode 拼进文案("上游返回错误 (HTTP 502)" / "Upstream error (HTTP 502)")。 - function translateUpstreamError(err) { - if (typeof err === "string") return err; - if (!err || typeof err !== "object") return t("models.upstreamError.unknown"); - const code = err.code || "unknown"; - let translated = t(`models.upstreamError.${code}`); - // 没命中(返了 key 自身)→ fallback 通用文案 - if (translated === `models.upstreamError.${code}`) { - translated = t("models.upstreamError.unknown"); - } - if (err.statusCode) { - // 动态 key (`models.upstreamError.${code}`) 已 t() 完,这里只对模板字符串 - // 替换 `{status}` 占位 — split/join 而非 String.replace 防 statusCode 含 - // `$` / 正则元字符被 replace 误解析。tFmt 不适用于"已 t 完的字符串" - translated = translated.split("{status}").join(String(err.statusCode)); - } - return err.host ? `[${err.host}] ${translated}` : translated; - } - - function formatModelFetchError(error) { - const errs = (error && error.errors) || []; - // 优先用第一个结构化 error(最相关) — 已 i18n;退化路径用 error.message(网络层异常) - const detail = errs.length > 0 ? translateUpstreamError(errs[0]) : (error && error.message); - const reason = detail || t("toast.requestFailed"); - return `${t("models.fetchFailedManual")}: ${reason}`; - } - - function downloadJson(filename, data) { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); - } - - async function refreshBackupList() { - const target = $("#backupList"); - if (!target) return; - try { - const backups = await CCApi.listBackups(); - target.innerHTML = backups.length - ? backups.slice(0, 5).map((item) => `${escapeHtml(item.name)}`).join("") - : `${t("settings.noBackups")}`; - } catch (error) { - target.innerHTML = `${t("settings.backupLoadFailed")}`; - } - } - - async function importConfigFile(file) { - if (!file) return; - if (!window.confirm(t("confirm.configImport"))) return; - try { - const text = await file.text(); - const configData = JSON.parse(text); - await CCApi.importConfig(configData); - await renderRoute(routeFromHash()); - showToast(t("toast.configImported")); - } catch (error) { - console.error(error); - showToast(error.message || t("toast.configImportFailed")); - } finally { - const input = $("#configImportFile"); - if (input) input.value = ""; - } - } - - function renderProviderCompatibilityList(result) { - const target = $("#providerCompatibilityList"); - if (!target) return; - const providers = result?.providers || []; - if (!providers.length) { - target.innerHTML = `

    ${escapeHtml(t("settings.compatibilityEmpty"))}

    `; - return; - } - target.innerHTML = providers.map((provider) => ` -
    -
    - ${escapeHtml(provider.name)} - ${escapeHtml(provider.message)} -
    - ${escapeHtml(provider.apiFormat)} -
    - `).join(""); - } - - async function saveProviderFromForm() { - const payload = providerPayloadFromForm(true); - // Responses 透传协议必须填齐 baseUrl + apiKey:[MOC-234] responses 现统一经 - // 本地代理做 1:1 字节透传(不再 direct 直连),baseUrl = 代理转发的上游地址、 - // apiKey = 代理按 provider 注入的上游凭据,缺任一上游都无法转发。前端拦下让 - // 用户立即看到错误,而不是保存一个转发必失败的 provider。 - if (payload.apiFormat === "responses" || payload.apiFormat === "openai_responses") { - if (!payload.baseUrl) { - throw new Error(t("toast.directModeBaseUrlRequired")); - } - if (!editingProviderId && !payload.apiKey) { - throw new Error(t("toast.directModeApiKeyRequired")); - } - } - // **code-reviewer #3 修**:OAuth provider 必须先登录才能 save。否则 backend - // 存了 provider 但 extra.cloud_code_project_id 缺失,任何 chat 请求都返 - // BadRequest "cloud_code_project_id required" — 前端拦下让 user 立即知道要 - // 先登录,不是 save 后才发现 silently broken provider。两个 OAuth provider - // 各自独立检查,错的 provider 不能 save - { - // [MOC-252] 按 apiFormat + authScheme 解析(zai/bigmodel 的 apiFormat=anthropic_messages - // 跟 Claude 共用,只能靠 authScheme 命中)。无 project 的 OAuth(requiresProject=false) - // 只校验 loggedIn,不要求 projectId。 - const config = resolveOauthConfig(payload.apiFormat, payload.authScheme); - if (config) { - const loginRequiredMsg = t(`${config.i18nPrefix}.loginRequired`); - try { - const status = await config.api.getStatus(); - const notReady = config.requiresProject - ? (!status.loggedIn || !status.projectId) - : !status.loggedIn; - if (notReady) { - throw new Error(loginRequiredMsg); - } - } catch (e) { - // 网络错也阻止 save — 不能让 unknown state 持久化 - if (e.message && e.message.includes(loginRequiredMsg)) throw e; - throw new Error(`OAuth status check failed: ${e.message || e}`); - } - } - } - if (editingProviderId) { - await CCApi.saveDraft(editingProviderId, payload); - return { id: editingProviderId, ...payload }; - } - const provider = await CCApi.addProvider(payload); - editingProviderId = provider.id; - await CCApi.saveDraft(provider.id, payload); - return provider; - } - - async function applyProviderToDesktop(actionEl) { - // 表单页"启用"按钮 = 保存表单 + 走 set-default 同一条后端链路 - // (`switch_provider_and_sync` 写 activeProvider 并同步到 ~/.codex)。 - // 与 dashboard 的「启用」按钮(action="set-default")在用户感知上等价, - // 唯一差异是这里要先把表单字段保存为 provider。**不弹 window.confirm**: - // Tauri webview 在某些环境会静默忽略原生 confirm,导致用户看不到任何反馈 - // 误以为按钮失灵(2026-05-06 现场实测)。 - // - // 响应延迟优化(2026-05-06): - // 旧版按顺序 await 10+ RPC(saveProvider → setDefault → startProxy → - // renderProviderCards × 1 → renderProviders 内 × 2 → renderDashboard 内 - // × 3,getProviders 重复 3 次),链路 1.5-3s 才解锁按钮。 - // 现在: - // 1. 关键路径只 await `saveProviderFromForm` + `setDefaultProvider`(2-3 - // RPC),拿到结果立刻 hash 跳页 / toast / 重启提示。 - // 2. hash → "dashboard" 由路由器自动触发 `renderDashboard`,不再手动调, - // 避免与路由器重复 fetch 同一份 providers/status。 - // 3. `startProxy`(若 desktopSync.requiresProxy)放后台,不阻塞 UI。 - // 4. providers 页 / model 选择器等"用户没在看"的渲染留给下次进入时再 - // 跑,避免冗余 RPC。 - const form = $("#providerForm"); - if (form && !form.reportValidity()) return; - - actionEl.disabled = true; - try { - const provider = await saveProviderFromForm(); - const result = await CCApi.setDefaultProvider(provider.id); - const desktopSync = result?.desktopSync || {}; - - editingProviderId = null; - selectedPreset = null; - window.location.hash = "dashboard"; - if (desktopSync.attempted && desktopSync.success === false) { - showToast(t("toast.defaultUpdatedDesktopFailed")); - } else { - showToast(t("toast.defaultUpdatedDesktop")); - } - // MOC-20:启用解耦 — 不再强制弹 restart-reminder modal,toast 文案已提示 - // 用户去首页 quick-actions『重启 Codex』按钮手动重启(避免误点丢上下文)。 - - if (desktopSync.requiresProxy) { - CCApi.startProxy().catch((error) => { - console.error("applyProviderToDesktop background startProxy failed:", error); - }); - } - } finally { - actionEl.disabled = false; - } - } - - async function handleAction(target) { - const action = target.closest("[data-action]")?.dataset.action; - if (!action) return; - const actionEl = target.closest("[data-action]"); - - if (action === "toggle-key") { - const input = $("#providerApiKey"); - input.type = input.type === "password" ? "text" : "password"; - actionEl.innerHTML = ``; - } - - try { - if (action === "set-default") { - const result = await CCApi.setDefaultProvider(actionEl.dataset.id); - if (result.desktopSync?.requiresProxy) { - await CCApi.startProxy(); - } - await renderProviderCards("#dashboardProviderCards", { includePresets: true }); - await renderProviders(); - await renderDashboard(); - const desktopSync = result.desktopSync || {}; - // MOC-20:启用解耦 — 不再强制弹 restart-reminder modal,改用 toast + - // 让用户去首页『重启 Codex』按钮手动重启。 - if (desktopSync.attempted && desktopSync.success) { - showToast(t("toast.defaultUpdatedDesktop")); - } else if (desktopSync.attempted && desktopSync.success === false) { - showToast(t("toast.defaultUpdatedDesktopFailed")); - } else { - showToast(t("toast.defaultUpdated")); - } - } - - if (action === "restart-codex-dashboard") { - // MOC-20:首页 quick-actions『重启 Codex』按钮 — 复用 restartCodexAppNow, - // 传 dashboard 按钮 id,hideModal=false(无 modal 可隐),按 dashboard 文案 fallback。 - await restartCodexAppNow({ - buttonId: "dashboardRestartCodexBtn", - fallbackLabelKey: "dashboard.restartCodex", - hideModal: false, - }); - } - - if (action === "new-from-preset") { - const presets = await CCApi.getPresets(); - selectedPreset = presets.find((item) => item.id === actionEl.dataset.preset) || null; - editingProviderId = null; - window.location.hash = "providers/add"; - } - - if (action === "edit-provider") { - editingProviderId = actionEl.dataset.id; - selectedPreset = null; - window.location.hash = "providers/add"; - } - - if (action === "copy-url") { - await navigator.clipboard.writeText(actionEl.dataset.url || ""); - showToast(t("toast.copied")); - } - - if (action === "open-docs") { - event.preventDefault(); - const url = actionEl.dataset.docsUrl; - const name = actionEl.dataset.providerName || ""; - if (!url) return; - const message = tFmt("confirm.openDocs", { provider: name }); - if (window.confirm(message)) { - window.open(url, "_blank", "noopener,noreferrer"); - } - } - - if (action === "test-provider") { - const resultEl = $(`[data-speed-for="${actionEl.dataset.id}"]`); - actionEl.disabled = true; - if (resultEl) { - resultEl.textContent = t("providers.testing"); - resultEl.classList.remove("bad"); - } - try { - const result = await CCApi.testProvider(actionEl.dataset.id); - const message = formatProviderTestResult(result); - if (resultEl) { - resultEl.textContent = message; - resultEl.classList.toggle("bad", isProviderTestResultBad(result)); - } - showToast(message); - } catch (error) { - const message = error?.message || t("toast.requestFailed"); - if (resultEl) { - resultEl.textContent = message; - resultEl.classList.add("bad"); - } - showToast(message); - } finally { - actionEl.disabled = false; - } - } - - if (action === "query-usage") { - const resultEl = $(`[data-usage-for="${actionEl.dataset.id}"]`) || $(`[data-speed-for="${actionEl.dataset.id}"]`); - actionEl.disabled = true; - if (resultEl) { - resultEl.textContent = t("providers.usageQuerying"); - resultEl.classList.remove("bad"); - } - try { - const result = await CCApi.queryProviderUsage(actionEl.dataset.id); - const message = formatUsageItems(result); - if (resultEl) { - resultEl.textContent = message; - resultEl.classList.toggle("bad", result.ok === false || result.supported === false); - } - showToast(message); - } catch (error) { - const message = error?.message || t("toast.requestFailed"); - if (resultEl) { - resultEl.textContent = message; - resultEl.classList.add("bad"); - } - showToast(message); - } finally { - actionEl.disabled = false; - } - } - - if (action === "test-provider-form") { - const resultEl = $("#formSpeedResult"); - actionEl.disabled = true; - resultEl.textContent = t("providers.testing"); - resultEl.classList.remove("bad"); - try { - const payload = providerPayloadFromForm(true); - if (editingProviderId && !payload.apiKey) { - try { - const secret = await CCApi.getProviderSecret(editingProviderId); - if (secret.apiKey) payload.apiKey = secret.apiKey; - } catch (e) { /* ignore */ } - } - if (editingProviderId) { - await CCApi.saveDraft(editingProviderId, payload); - } - const result = await CCApi.testProviderPayload(payload); - const message = formatProviderTestResult(result); - resultEl.textContent = message; - resultEl.classList.toggle("bad", isProviderTestResultBad(result)); - showToast(message); - } catch (error) { - const message = error?.message || t("toast.requestFailed"); - resultEl.textContent = message; - resultEl.classList.add("bad"); - showToast(message); - } finally { - actionEl.disabled = false; - } - } - - if (action === "fetch-form-models") { - const resultEl = $("#providerModelFetchResult"); - actionEl.disabled = true; - if (resultEl) resultEl.textContent = t("models.fetching"); - try { - const payload = providerPayloadFromForm(false); - if (editingProviderId && !payload.apiKey) { - try { - const secret = await CCApi.getProviderSecret(editingProviderId); - if (secret.apiKey) payload.apiKey = secret.apiKey; - } catch (e) { /* ignore */ } - } - if (editingProviderId) { - await CCApi.saveDraft(editingProviderId, payload); - } - const result = await CCApi.fetchProviderModelsPayload(payload); - providerAvailableModels = Array.isArray(result.models) ? result.models.slice() : []; - // **不覆盖 user 已有 mappings,只刷新下拉选项**:获取模型只更新下拉可选项, - // 不清 user 已列的模型。[MOC-154] 列表式下"默认"= 列表第 1 个 = gpt_5_5 槽 - // (default 不再是独立 slot key,在 providerFormMappings 里永远 undefined), - // 故 guard 改判 gpt_5_5 —— 仅当用户还没配第 1 个模型(gpt_5_5 空)时才用后端 - // suggestedDefault 填进去,否则不动用户列表(原 `!providerFormMappings.default` - // 列表式下永真 → 无条件覆盖 + 重排用户列表,devin review 发现)。 - const suggestedDefault = (result.suggested && result.suggested.default) || ""; - if (suggestedDefault && !providerFormMappings.gpt_5_5) { - providerFormMappings.gpt_5_5 = suggestedDefault; - } - setProviderMappings(providerFormMappings, { availableModels: providerAvailableModels }); - if (resultEl) resultEl.textContent = t("models.fetchSuccess"); - showToast(t("toast.modelsAutofilled")); - } catch (error) { - providerAvailableModels = []; - renderProviderMappings(); - const message = formatModelFetchError(error); - if (resultEl) resultEl.textContent = message; - showToast(message); - } finally { - actionEl.disabled = false; - } - } - - if (action === "add-provider-model-row") { - addProviderMappingRow(); - } - - if (action === "remove-provider-model-row") { - removeProviderMappingRow(Number(actionEl.dataset.rowIndex)); - } - - if (action === "toggle-baseurl-menu") { - toggleBaseUrlMenu(); - } - - if (action === "select-baseurl-option") { - setBaseUrlValue(actionEl.dataset.baseurlValue || ""); - } - - if (action === "toggle-provider-model-menu") { - toggleProviderModelMenu(actionEl.dataset.rowKey); - } - - if (action === "select-provider-model-option") { - const rowKey = openProviderModelMenuKey; - if (rowKey) { - updateProviderModelInput(rowKey, actionEl.dataset.modelValue || ""); - closeProviderModelMenu(); - renderPresetOptions(selectedPreset, collectProviderMappings()); - } - } - - if (action === "delete-provider") { - pendingDeleteId = actionEl.dataset.id; - deleteModal.show(); - } - - if (action === "save-models") { - const mappings = {}; - $all("[data-model-input]").forEach((input) => { - mappings[input.dataset.modelInput] = input.value.trim(); - }); - const defaultKey = $("#defaultModel")?.value || "gpt_5_5"; - mappings.default = mappings[defaultKey] || mappings.gpt_5_5 || mappings.gpt_5_4 || mappings.gpt_5_4_mini || mappings.gpt_5_3_codex || mappings.gpt_5_2 || ""; - await CCApi.saveModelMappings($("#modelProvider").value, mappings); - showToast(t("toast.modelsSaved")); - } - - if (action === "fetch-models") { - const providerId = $("#modelProvider").value; - const resultEl = $("#modelFetchResult"); - actionEl.disabled = true; - if (resultEl) resultEl.textContent = t("models.fetching"); - try { - const result = await CCApi.autofillProviderModels(providerId); - await renderMappingCards(); - if (resultEl) { - resultEl.textContent = `${t("models.fetched")} ${result.models.length}`; - } - showToast(t("toast.modelsAutofilled")); - } catch (error) { - const message = formatModelFetchError(error); - if (resultEl) resultEl.textContent = message; - showToast(message); - } finally { - actionEl.disabled = false; - } - } - - if (action === "reset-models") { - await renderMappingCards(); - showToast(t("toast.modelsReset")); - } - - if (action === "apply-desktop") { - const result = await CCApi.configureDesktop(); - if (result && result.commands && result.commands.temporary) { - await navigator.clipboard.writeText(result.commands.temporary); - showToast(t("toast.desktopApplied")); - } else { - showToast(t("toast.desktopApplied")); - } - await renderDesktop(); - } - - if (action === "clear-desktop") { - const target = await chooseCodexRestoreTarget(); - if (!target) return; - const result = target.snapshotId - ? await CCApi.restoreDesktopSnapshot(target.snapshotId) - : await CCApi.clearDesktop(); - const route = routeFromHash(); - if (route === "dashboard") { - await renderDashboard(); - } else if (route === "desktop") { - await renderDesktop(); - } else if (route === "settings") { - await refreshCodexSnapshotStatus(); - } - const fellBackToLegacy = result && result.restored === false; - showToast(t(fellBackToLegacy ? "toast.desktopClearedLegacy" : "toast.desktopCleared")); - } - - if (action === "rescan-residual") { - await refreshResidualScanStatus(); - return; - } - - if (action === "show-residual-fields") { - await handleShowResidualFields(); - return; - } - - if (action === "codex-conv-refresh") { - await codexConversationsLoadAndRender(); - return; - } - if (action === "codex-conv-export-selected") { - await codexConversationsExportSelected(); - return; - } - if (action === "codex-conv-delete-selected") { - await codexConversationsDeleteSelected(); - return; - } - if (action === "codex-conv-default-dir-pick") { - await codexConvPickDefaultDir(); - return; - } - if (action === "codex-conv-default-dir-clear") { - codexConvClearDefaultDir(); - return; - } - if (action === "codex-conv-options") { - codexConversationsOpenOptionsDialog(); - return; - } - - if (action === "repair-residual") { - await handleRepairResidual(); - return; - } - - if (action === "proxy-start") { - await CCApi.startProxy($("#proxyPort") ? $("#proxyPort").value : 18080); - await renderProxy(); - await renderDashboard(); - showToast(t("toast.proxyStarted")); - } - - if (action === "proxy-stop") { - await CCApi.stopProxy(); - await renderProxy(); - await renderDashboard(); - showToast(t("toast.proxyStopped")); - } - - if (action === "proxy-toggle") { - const currentStatus = await CCApi.getProxyStatus(); - if (currentStatus.running) { - await CCApi.stopProxy(); - showToast(t("toast.proxyStopped")); - } else { - await CCApi.startProxy($("#proxyPort") ? $("#proxyPort").value : 18080); - showToast(t("toast.proxyStarted")); - } - await renderProxy(); - await renderDashboard(); - } - - if (action === "clear-logs") { - await CCApi.clearLogs(); - await renderProxy(); - showToast(t("toast.logsCleared")); - } - - if (action === "open-log-dir") { - try { - await CCApi.openLogDir(); - showToast(t("toast.logDirOpened")); - } catch (err) { - showToast(t("toast.logDirOpenFailed")); - } - } - - // [MOC-169] 在系统浏览器打开诊断流量查看器(未运行则后端先 start) - if (action === "open-trace-viewer") { - try { - const r = await CCApi.openTraceViewer(); - if (r && r.success === false) showToast("打开诊断查看器失败"); - } catch (_) { - showToast("打开诊断查看器失败"); - } - } - - if (action === "view-logs") { - window.location.hash = "proxy"; - } - - if (action === "open-feedback") { - openFeedbackModal(); - } - - if (action === "toggle-model-menu-mode") { - const settings = await CCApi.getSettings(); - const next = !settings.exposeAllProviderModels; - const saved = await CCApi.saveSettings({ exposeAllProviderModels: next }); - renderModelMenuModeState(saved); - showToast(next ? t("toast.allModelsEnabled") : t("toast.singleModelEnabled")); - } - - if (action === "check-provider-compatibility") { - actionEl.disabled = true; - try { - const result = await CCApi.getProviderCompatibility(); - renderProviderCompatibilityList(result); - showToast(t("toast.compatibilityChecked")); - } finally { - actionEl.disabled = false; - } - } - - if (action === "check-update") { - const result = await CCApi.checkUpdate($("#settingsUpdateUrl").value.trim()); - updateCheckCache = result; - renderUpdateBadge(result); - const message = result.updateAvailable - ? `${t("toast.updateAvailable")} ${result.latestVersion}` - : `${t("toast.noUpdate")} ${result.currentVersion}`; - const status = $("#updateStatus"); - if (status) { - status.textContent = message; - status.classList.toggle("available", !!result.updateAvailable); - } - showToast(message); - } - - if (action === "install-update") { - if (!updateCheckCache?.updateAvailable) { - updateCheckCache = await CCApi.checkUpdate($("#settingsUpdateUrl")?.value.trim() || ""); - renderUpdateBadge(updateCheckCache); - } - if (!updateCheckCache?.updateAvailable) { - const message = `${t("toast.noUpdate")} ${updateCheckCache?.currentVersion || ""}`.trim(); - const status = $("#updateStatus"); - if (status) { - status.textContent = message; - status.classList.remove("available"); - } - showToast(message); - return; - } - if (!window.confirm(t("confirm.installUpdate"))) return; - let keepBusyState = false; - const status = $("#updateStatus"); - setUpdateInstallPhase("downloading"); - if (status) { - status.textContent = t("toast.updateDownloading"); - status.classList.add("available"); - } - try { - const result = await CCApi.installUpdate($("#settingsUpdateUrl")?.value.trim() || ""); - updateCheckCache = result; - keepBusyState = !!result.quitRequested; - setUpdateInstallPhase(keepBusyState ? "installing" : "idle"); - renderUpdateBadge(result); - const message = result.message || t("toast.updateInstallerStarted"); - if (status) { - status.textContent = message; - status.classList.toggle("available", !!result.updateAvailable); - } - showToast(message); - } catch (error) { - setUpdateInstallPhase("idle"); - throw error; - } finally { - if (!keepBusyState) setUpdateInstallPhase("idle"); - } - } - - if (action === "backup-config") { - await CCApi.createBackup(); - await refreshBackupList(); - showToast(t("toast.configBackedUp")); - } - - if (action === "export-config") { - const data = await CCApi.exportConfig(); - const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); - downloadJson(`codex-app-transfer-config-${stamp}.json`, data); - showToast(t("toast.configExported")); - } - - if (action === "choose-import-config") { - $("#configImportFile").click(); - } - - if (action === "apply-provider-desktop") { - await applyProviderToDesktop(actionEl); - } - - // ── Codex 资产管理 (#24 / #25, Agents + MCP + Skills tab) ── - if (action === "codex-block-preview") { - await codexBlockPreview(currentCodexBlockType()); - } - if (action === "codex-block-apply") { - await codexBlockApply(currentCodexBlockType()); - } - if (action === "codex-block-history-toggle") { - await codexBlockToggleHistory(currentCodexBlockType()); - } - if (action === "codex-block-clear") { - if (window.confirm(t("codex.confirmClear"))) { - await codexBlockClear(currentCodexBlockType()); - } - } - if (action === "codex-block-rollback") { - const idx = Number(actionEl.dataset.idx); - const type = actionEl.dataset.type || currentCodexBlockType(); - if (!Number.isFinite(idx)) return; - if (window.confirm(tFmt("codex.confirmRollback", { type, idx }))) { - await codexBlockRollback(type, idx); - } - } - if (action === "codex-agents-path-add") { - codexAgentsOnPathAdd("agents"); - } - if (action === "codex-agents-path-remove") { - await codexAgentsOnPathRemove(); - } - if (action === "codex-memories-edit-start") { - await codexMemoriesOnEditStart(); - } - if (action === "codex-memories-apply") { - await codexMemoriesOnApply(); - } - if (action === "codex-memories-cancel") { - codexMemoriesOnCancel(); - } - if (action === "codex-memories-backup") { - await codexMemoriesOnBackup(); - } - if (action === "codex-memories-history-toggle") { - await codexMemoriesToggleHistory(); - } - if (action === "codex-skills-edit-start") { - await codexSkillsOnEditStart(); - } - if (action === "codex-skills-apply") { - await codexSkillsOnApply(); - } - if (action === "codex-skills-cancel") { - codexSkillsOnCancel(); - } - if (action === "codex-skills-backup-md") { - await codexSkillsOnBackup(); - } - if (action === "codex-skills-history-toggle") { - await codexSkillsToggleHistory(); - } - if (action === "codex-skills-reveal") { - await codexSkillsOnReveal(); - } - // ── MCP ── - if (action === "codex-mcp-server-new") { - codexMcpServerNew(); - } - if (action === "codex-mcp-new-cancel") { - codexMcpServerNewCancel(); - } - if (action === "codex-mcp-new-confirm") { - codexMcpServerNewConfirm(); - } - if (action === "codex-mcp-server-edit") { - codexMcpServerEditToggle(); - } - if (action === "codex-mcp-server-delete") { - await codexMcpServerDelete(); - } - if (action === "codex-mcp-servers-backup") { - await codexMcpServersBackup(); - } - if (action === "codex-mcp-servers-history") { - await codexMcpServersOpenHistory(); - } - if (action === "codex-mcp-raw-toggle") { - await codexMcpRawToggle(); - } - if (action === "codex-mcp-raw-apply") { - await codexMcpRawApply(); - } - if (action === "codex-mcp-raw-cancel") { - codexMcpRawCancel(); - } - if (action === "codex-mcp-form-toggle-advanced") { - const pane = $("#codexMcpAdvancedPane"); - if (pane) pane.hidden = !pane.hidden; - } - if (action === "codex-mcp-form-add-arg") { codexMcpAddArgRow(); } - if (action === "codex-mcp-form-remove-arg") { codexMcpRemoveArgRow(actionEl.dataset.idx); } - if (action === "codex-mcp-form-add-env") { codexMcpAddKvRow("env"); } - if (action === "codex-mcp-form-add-hh") { codexMcpAddKvRow("hh"); } - if (action === "codex-mcp-form-add-ehh") { codexMcpAddKvRow("ehh"); } - if (action === "codex-mcp-form-remove-kv") { codexMcpRemoveKvRow(actionEl.dataset.prefix, actionEl.dataset.idx); } - if (action === "codex-mcp-plugin-toggle") { - const key = actionEl.dataset.key; - const enabled = !!actionEl.checked; - await codexMcpPluginToggle(key, enabled); - } - if (action === "codex-mcp-plugin-toggle-btn") { - const key = actionEl.dataset.key; - const wasEnabled = actionEl.dataset.enabled === "true"; - await codexMcpPluginToggle(key, !wasEnabled); - } - if (action === "codex-mcp-plugin-uninstall") { - const key = actionEl.dataset.key; - await codexMcpPluginUninstall(key); - } - if (action === "codex-mcp-source-add-open") { - codexMcpSourceAddOpen(); - } - if (action === "codex-mcp-source-modal-close") { - codexMcpSourceAddClose(); - } - if (action === "codex-mcp-source-modal-confirm") { - await codexMcpSourceAddConfirm(); - } - if (action === "codex-mcp-source-toggle") { - const id = actionEl.dataset.id; - const enabled = actionEl.dataset.enabled === "true"; - await codexMcpSourceToggle(id, enabled); - } - if (action === "codex-mcp-source-remove") { - const id = actionEl.dataset.id; - await codexMcpSourceRemove(id); - } - if (action === "codex-mcp-market-refresh") { - await codexMcpReloadMarketIndex(true); - } - if (action === "codex-mcp-market-install-server") { - await codexMcpMarketInstallServer(actionEl.dataset.id); - } - if (action === "codex-mcp-market-install-plugin") { - await codexMcpMarketInstallPlugin(actionEl.dataset.id, actionEl.dataset.marketplace); - } - if (action === "codex-mcp-deeplink-cancel") { - codexMcpDeeplinkCancel(); - } - if (action === "codex-mcp-deeplink-confirm") { - await codexMcpDeeplinkConfirm(); - } - if (action === "codex-add-path-cancel") { - codexAgentsClosePathModal(); - } - if (action === "codex-add-path-browse") { - await codexAgentsOnBrowse(); - } - if (action === "codex-add-path-confirm") { - await codexAgentsConfirmPathAdd(); - } - if (action === "codex-agents-edit-start") { - await codexAgentsOnEditStart(); - } - if (action === "codex-agents-apply") { - await codexAgentsOnApply(); - } - if (action === "codex-agents-cancel") { - codexAgentsOnCancel(); - } - if (action === "codex-agents-backup") { - await codexAgentsOnBackup(); - } - if (action === "codex-agents-history-toggle") { - await codexAgentsToggleHistory(); - } - if (action === "codex-history-close") { - codexHistoryClose(); - } - if (action === "codex-history-restore") { - await codexHistoryRestore(); - } - } catch (error) { - console.error(error); - showToast(error.message || t("toast.requestFailed")); - } - } - - // ── Codex 文档管理: marker 受管块 (agents + mcp 共享, type ∈ {agents, mcp}) ── - - /** 当前选中的 AGENTS.md 路径 hash(null = 默认全局)*/ - let currentAgentsHash = null; - /** 当前选中的 MEMORY.md 路径 hash */ - let currentMemoriesHash = null; - /** 当前选中的 SKILL.md 路径 hash */ - let currentSkillsHash = null; - /** Add modal / History modal 当前服务的 resource("agents" / "memories" / "skills")*/ - let codexDocActiveResource = "agents"; - - /** resource → API base */ - function codexDocApiBase(resource) { - if (resource === "memories") return "/api/codex/memories-md"; - if (resource === "skills") return "/api/codex/skills-md"; - return "/api/codex/agents-md"; - } - - /** resource → 当前 hash */ - function codexDocCurrentHash(resource) { - if (resource === "memories") return currentMemoriesHash; - if (resource === "skills") return currentSkillsHash; - return currentAgentsHash; - } - function codexDocSetCurrentHash(resource, hash) { - if (resource === "memories") currentMemoriesHash = hash; - else if (resource === "skills") currentSkillsHash = hash; - else currentAgentsHash = hash; - } - - /** URL prefix for managed-block endpoints。agents tab 自动拼 ?hash= */ - function codexBlockUrl(type) { - if (type === "mcp") return "/api/codex/mcp-toml"; - if (type === "memories") return "/api/codex/memories-md"; - return "/api/codex/agents-md"; - } - - /** agents endpoint suffix(hash query)— 仅 type=agents 时拼 */ - function codexAgentsHashSuffix(type) { - if (type !== "agents") return ""; - return currentAgentsHash ? `?hash=${encodeURIComponent(currentAgentsHash)}` : ""; - } - - function currentCodexTab() { - return $("#codexSidebar .codex-sidebar-item.active")?.dataset?.codexTab || "agents"; - } - - function currentCodexBlockType() { - const tab = currentCodexTab(); - if (tab === "mcp") return "mcp"; - if (tab === "memories") return "memories"; - return "agents"; - } - - async function codexBlockFetchStatus(type) { - const r = await fetch(`${codexBlockUrl(type)}/status${codexAgentsHashSuffix(type)}`); - if (!r.ok) throw new Error("status request failed"); - return r.json(); - } - - // ── AGENTS.md 自定义路径 dropdown ── - - /** 路径 chip(s) HTML — 按 category 决定单 chip 或双 chip */ - function codexAgentsChipsHtml(entry) { - if (entry.category === "global") { - return `${escapeHtml(t("codex.agentsPath.global"))}`; - } - if (entry.category === "project-root") { - const name = entry.projectName || "?"; - return `${escapeHtml(name)}`; - } - // subdir → 项目名(绿) + 子目录路径(橙) - const project = entry.projectName || "?"; - const subdir = entry.subdirPath || "?"; - return `${escapeHtml(project)}${escapeHtml(subdir)}`; - } - - /** 缓存当前 picker 显示的 entries(供 toggle 渲染 + change 处理用)*/ - let codexAgentsEntriesCache = []; - - /** 渲染当前选中条目到 toggle button 内 */ - function codexAgentsRenderToggle() { - const cur = $("#codexAgentsPathPicker .codex-path-picker-current"); - if (!cur) return; - const entry = codexAgentsEntriesCache.find((e) => e.hash === currentAgentsHash); - if (!entry) { - cur.innerHTML = `${escapeHtml(t("codex.agentsPathEmpty"))}`; - return; - } - cur.innerHTML = `${codexAgentsChipsHtml(entry)}${escapeHtml(entry.path)}`; - } - - /** 渲染下拉菜单 ul li 列表 */ - function codexAgentsRenderMenu() { - const menu = $("#codexAgentsPathMenu"); - if (!menu) return; - if (codexAgentsEntriesCache.length === 0) { - menu.innerHTML = `
  • ${escapeHtml(t("codex.agentsPathEmpty"))}
  • `; - return; - } - menu.innerHTML = codexAgentsEntriesCache - .map((e) => { - const selected = e.hash === currentAgentsHash ? " selected" : ""; - return `
  • - ${codexAgentsChipsHtml(e)} - ${escapeHtml(e.path)} -
  • `; - }) - .join(""); - } - - /** 调后端 /paths 拉列表 + 刷 picker UI */ - async function codexAgentsReloadPaths() { - try { - const r = await fetch("/api/codex/agents-md/paths"); - if (!r.ok) throw new Error("paths request failed"); - const j = await r.json(); - codexAgentsEntriesCache = j.entries || []; - // 保留当前选中,若不在新 list 则回退到第一条(可能空) - if ( - !currentAgentsHash || - !codexAgentsEntriesCache.some((e) => e.hash === currentAgentsHash) - ) { - currentAgentsHash = codexAgentsEntriesCache[0]?.hash || null; - } - codexAgentsRenderToggle(); - codexAgentsRenderMenu(); - // 删除按钮:仅在非全局选中时显示 - const removeBtn = $("#codexAgentsPathRemoveBtn"); - if (removeBtn) { - const cur = codexAgentsEntriesCache.find((e) => e.hash === currentAgentsHash); - removeBtn.hidden = !cur || cur.category === "global"; - } - return codexAgentsEntriesCache; - } catch (e) { - console.error("codexAgentsReloadPaths:", e); - codexAgentsEntriesCache = []; - codexAgentsRenderToggle(); - codexAgentsRenderMenu(); - return []; - } - } - - /** dropdown item click → 切换当前 hash + 刷新该 path 的 status/content */ - async function codexAgentsSelectHash(hash) { - if (!hash) return; - currentAgentsHash = hash; - codexAgentsRenderToggle(); - codexAgentsRenderMenu(); - const removeBtn = $("#codexAgentsPathRemoveBtn"); - if (removeBtn) { - const cur = codexAgentsEntriesCache.find((e) => e.hash === hash); - removeBtn.hidden = !cur || cur.category === "global"; - } - codexAgentsClosePicker(); - try { - await codexAgentsRawLoadAndRender(); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - /** picker toggle open/close + outside click */ - function codexAgentsOpenPicker() { - const picker = $("#codexAgentsPathPicker"); - const menu = $("#codexAgentsPathMenu"); - if (!picker || !menu) return; - if (codexAgentsEntriesCache.length === 0) return; // 空态 toggle 不开 - picker.classList.add("open"); - menu.hidden = false; - } - function codexAgentsClosePicker() { - const picker = $("#codexAgentsPathPicker"); - const menu = $("#codexAgentsPathMenu"); - if (!picker || !menu) return; - picker.classList.remove("open"); - menu.hidden = true; - } - function codexAgentsTogglePicker() { - const picker = $("#codexAgentsPathPicker"); - if (!picker) return; - if (picker.classList.contains("open")) codexAgentsClosePicker(); - else codexAgentsOpenPicker(); - } - - /** 添加按钮 → inline modal(替代 window.prompt)。resource 默认 agents */ - function codexAgentsOnPathAdd(resource = "agents") { - const modal = $("#codexAddPathModal"); - const input = $("#codexAddPathInput"); - if (!modal || !input) return; - codexDocActiveResource = resource; - input.value = ""; - // 切换 title / desc 文案到对应 resource - const titleEl = $("#codexAddPathModalTitle"); - const descEl = $("#codexAddPathModal .codex-modal-desc"); - const placeholder = resource === "memories" ? "/path/to/project-root" : "/path/to/AGENTS.md"; - if (titleEl) { - titleEl.textContent = t( - resource === "memories" ? "codex.memoriesPathAddTitle" : "codex.agentsPathAddTitle", - ); - } - if (descEl) { - descEl.textContent = t( - resource === "memories" ? "codex.memoriesPathAddPrompt" : "codex.agentsPathAddPrompt", - ); - } - input.placeholder = placeholder; - modal.hidden = false; - setTimeout(() => input.focus(), 50); - } - - function codexAgentsClosePathModal() { - const modal = $("#codexAddPathModal"); - if (modal) modal.hidden = true; - } - - /** 浏览按钮:打开 Tauri file/dir dialog。Memories tab 选**目录**,Agents 选 .md 文件。*/ - async function codexAgentsOnBrowse() { - try { - const dialog = window.__TAURI__?.dialog; - if (!dialog || typeof dialog.open !== "function") { - showToast("Tauri dialog API 不可用 — 请直接粘贴绝对路径"); - return; - } - const input = $("#codexAddPathInput"); - const raw = (input?.value || "").trim(); - const defaultPath = raw && raw.startsWith("/") ? raw : undefined; - const isMemories = codexDocActiveResource === "memories"; - const selected = await dialog.open({ - title: t(isMemories ? "codex.memoriesPathAddTitle" : "codex.agentsPathAddTitle"), - multiple: false, - directory: isMemories, // memories 选目录,agents 选文件 - defaultPath, - filters: isMemories - ? undefined - : [ - { name: "AGENTS.md", extensions: ["md", "MD"] }, - { name: "All files", extensions: ["*"] }, - ], - }); - if (typeof selected === "string" && selected) { - if (input) input.value = selected; - } - } catch (e) { - console.error("dialog open:", e); - showToast(e.message || "dialog open failed"); - } - } - - async function codexAgentsConfirmPathAdd() { - const input = $("#codexAddPathInput"); - const raw = input?.value || ""; - const path = raw.trim(); - if (!path) { - showToast(t("codex.agentsPathAddEmpty")); - return; - } - const resource = codexDocActiveResource; - try { - const r = await fetch(`${codexDocApiBase(resource)}/paths/add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "add path failed"); - } - const j = await r.json(); - codexDocSetCurrentHash(resource, j.entry?.hash || null); - codexAgentsClosePathModal(); - if (resource === "memories") { - await codexMemoriesReloadPaths(); - await codexMemoriesRawLoadAndRender(); - } else { - await codexAgentsReloadPaths(); - await codexAgentsRawLoadAndRender(); - } - showToast(t("codex.agentsPathAddOk")); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - /** 删除按钮 → POST /paths/remove(全局路径按钮自动隐藏)*/ - async function codexAgentsOnPathRemove() { - if (!currentAgentsHash) return; - if (!confirm(t("codex.agentsPathRemoveConfirm"))) return; - try { - const r = await fetch("/api/codex/agents-md/paths/remove", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hash: currentAgentsHash }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "remove path failed"); - } - currentAgentsHash = null; // 回退到全局 - await codexAgentsReloadPaths(); - await codexAgentsRawLoadAndRender(); - showToast(t("codex.agentsPathRemoveOk")); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - // ── Agents raw mode(Preview/Edit/Backup/History)── - - /** UI mode 状态:"preview" / "edit" */ - let codexAgentsMode = "preview"; - /** Preview 模式下缓存的原内容(用于 Edit→Cancel 回退)*/ - let codexAgentsLastFullContent = ""; - - /** 加载 raw 全文 → 写入 preview pre */ - async function codexAgentsRawLoadAndRender() { - const pre = $("#codexAgentsPreview"); - const ta = $("#codexAgentsEdit"); - if (!pre || !ta) return; - // 切换路径或重新加载时强制回 preview 模式 - codexAgentsSwitchMode("preview"); - if (!currentAgentsHash) { - pre.classList.remove("codex-md-rendered"); - pre.textContent = ""; - pre.setAttribute("data-empty-hint", t("codex.agentsPathEmpty")); - ta.value = ""; - codexAgentsLastFullContent = ""; - return; - } - try { - const r = await fetch(`/api/codex/agents-md/raw?hash=${encodeURIComponent(currentAgentsHash)}`); - if (!r.ok) throw new Error("raw fetch failed"); - const j = await r.json(); - codexAgentsLastFullContent = j.content || ""; - pre.classList.add("codex-md-rendered"); - pre.innerHTML = renderMiniMd(codexAgentsLastFullContent); - pre.removeAttribute("data-empty-hint"); - ta.value = codexAgentsLastFullContent; - } catch (e) { - pre.classList.remove("codex-md-rendered"); - pre.textContent = ""; - pre.setAttribute("data-empty-hint", `读取失败: ${e.message || e}`); - } - } - - /** 切换 mode("preview" / "edit"),同步按钮 + pre/textarea 显示 */ - function codexAgentsSwitchMode(mode) { - codexAgentsMode = mode; - const pre = $("#codexAgentsPreview"); - const ta = $("#codexAgentsEdit"); - const editBtn = $("#codexAgentsEditBtn"); - const backupBtn = $("#codexAgentsBackupBtn"); - const applyBtn = $("#codexAgentsApplyBtn"); - const cancelBtn = $("#codexAgentsCancelBtn"); - if (mode === "edit") { - if (pre) pre.hidden = true; - if (ta) { - ta.hidden = false; - ta.value = codexAgentsLastFullContent; - } - if (editBtn) editBtn.hidden = true; - if (backupBtn) backupBtn.hidden = true; - if (applyBtn) applyBtn.hidden = false; - if (cancelBtn) cancelBtn.hidden = false; - } else { - if (pre) pre.hidden = false; - if (ta) ta.hidden = true; - if (editBtn) editBtn.hidden = false; - if (backupBtn) backupBtn.hidden = false; - if (applyBtn) applyBtn.hidden = true; - if (cancelBtn) cancelBtn.hidden = true; - } - } - - async function codexAgentsOnEditStart() { - if (!currentAgentsHash) { - showToast(t("codex.agentsPathEmpty")); - return; - } - codexAgentsSwitchMode("edit"); - setTimeout(() => $("#codexAgentsEdit")?.focus(), 50); - } - - async function codexAgentsOnApply() { - if (!currentAgentsHash) return; - const ta = $("#codexAgentsEdit"); - const content = ta?.value ?? ""; - // 写盘前二次确认,防误改影响 AI 行为的文档(MOC-106)。 - if (!window.confirm(tFmt("codex.docApplyConfirm", { doc: "AGENTS.md" }))) return; - try { - const r = await fetch(`/api/codex/agents-md/raw?hash=${encodeURIComponent(currentAgentsHash)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "write failed"); - } - showToast(t("codex.agentsApplyOk")); - await codexAgentsRawLoadAndRender(); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - function codexAgentsOnCancel() { - codexAgentsSwitchMode("preview"); - } - - async function codexAgentsOnBackup() { - if (!currentAgentsHash) { - showToast(t("codex.agentsPathEmpty")); - return; - } - try { - const r = await fetch(`/api/codex/agents-md/backup?hash=${encodeURIComponent(currentAgentsHash)}`, { - method: "POST", - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "backup failed"); - } - showToast(t("codex.agentsBackupOk")); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - // ── History 大 modal:picker + 应用 + diff preview ── - - /** history entries 缓存(reversed,最新在前)+ 当前选中 index */ - let codexHistoryEntries = []; - let codexHistorySelectedIdx = null; - - /** LCS-based line diff(O(m*n) — 5K 行 OK) - * 返回 [{ type: "ctx"|"add"|"del", text }] - */ - function codexLineDiff(oldText, newText) { - const oldLines = oldText.split("\n"); - const newLines = newText.split("\n"); - const m = oldLines.length; - const n = newLines.length; - // dp[i][j] = LCS length for oldLines[0..i], newLines[0..j] - const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = 0; i < m; i++) { - for (let j = 0; j < n; j++) { - if (oldLines[i] === newLines[j]) dp[i + 1][j + 1] = dp[i][j] + 1; - else dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - const result = []; - let i = m, j = n; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { - result.unshift({ type: "ctx", text: oldLines[i - 1] }); - i--; j--; - } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - result.unshift({ type: "add", text: newLines[j - 1] }); - j--; - } else { - result.unshift({ type: "del", text: oldLines[i - 1] }); - i--; - } - } - return result; - } - - /** entry label:"项目名 / 子目录路径 · YYYY-MM-DD HH:MM:SS" — 项目 / 子目录从当前 active resource 的 path 推断 */ - function codexHistoryEntryLabel(entry) { - const resource = codexDocActiveResource; - const cache = - resource === "memories" ? codexMemoriesEntriesCache : - resource === "skills" ? codexSkillsEntriesCache : codexAgentsEntriesCache; - const hash = - resource === "memories" ? currentMemoriesHash : - resource === "skills" ? currentSkillsHash : currentAgentsHash; - const cur = cache.find((e) => e.hash === hash); - const ts = new Date(entry.timestamp * 1000).toLocaleString(); - let prefix = ""; - if (resource === "mcp") { - prefix = "config.toml"; - } else if (cur) { - if (resource === "skills") { - prefix = cur.name || "?"; - } else if (cur.category === "global") { - prefix = t("codex.agentsPath.global"); - } else if (cur.category === "project-root") { - prefix = cur.projectName || "?"; - } else { - prefix = `${cur.projectName || "?"} / ${cur.subdirPath || "?"}`; - } - } - return prefix ? `${prefix} · ${ts}` : ts; - } - - /** render history picker toggle button(selected entry)*/ - function codexHistoryRenderToggle() { - const cur = $("#codexHistoryPicker .codex-path-picker-current"); - if (!cur) return; - if (codexHistorySelectedIdx == null || !codexHistoryEntries[codexHistorySelectedIdx]) { - cur.innerHTML = `${escapeHtml(t("codex.historyEmpty"))}`; - return; - } - const entry = codexHistoryEntries[codexHistorySelectedIdx]; - cur.innerHTML = `${escapeHtml(codexHistoryEntryLabel(entry))}`; - } - - /** render history picker dropdown menu */ - function codexHistoryRenderMenu() { - const menu = $("#codexHistoryMenu"); - if (!menu) return; - if (codexHistoryEntries.length === 0) { - menu.innerHTML = `
  • ${escapeHtml(t("codex.historyEmpty"))}
  • `; - return; - } - menu.innerHTML = codexHistoryEntries - .map((entry, i) => { - const selected = i === codexHistorySelectedIdx ? " selected" : ""; - return `
  • - ${escapeHtml(codexHistoryEntryLabel(entry))} -
  • `; - }) - .join(""); - } - - /** 渲染当前选中 history 对比 file 当前内容的 diff */ - function codexHistoryRenderDiff() { - const pre = $("#codexHistoryDiff"); - if (!pre) return; - if (codexHistorySelectedIdx == null || !codexHistoryEntries[codexHistorySelectedIdx]) { - pre.innerHTML = ""; - pre.setAttribute("data-empty-hint", t("codex.historyDiffEmpty")); - return; - } - const entry = codexHistoryEntries[codexHistorySelectedIdx]; - const newContent = entry.appliedContent || entry.managedContent || ""; - const oldContent = - codexDocActiveResource === "memories" - ? codexMemoriesLastFullContent || "" - : codexDocActiveResource === "skills" - ? codexSkillsLastFullContent || "" - : codexAgentsLastFullContent || ""; - const diff = codexLineDiff(oldContent, newContent); - pre.removeAttribute("data-empty-hint"); - pre.innerHTML = diff - .map((d) => `${escapeHtml(d.text) || " "}`) - .join(""); - } - - function codexHistoryPickerToggle() { - const picker = $("#codexHistoryPicker"); - const menu = $("#codexHistoryMenu"); - if (!picker || !menu) return; - if (codexHistoryEntries.length === 0) return; - const open = picker.classList.toggle("open"); - menu.hidden = !open; - } - - function codexHistoryPickerClose() { - const picker = $("#codexHistoryPicker"); - const menu = $("#codexHistoryMenu"); - if (!picker || !menu) return; - picker.classList.remove("open"); - menu.hidden = true; - } - - async function codexHistoryOpen() { - const resource = codexDocActiveResource; - const hash = codexDocCurrentHash(resource); - if (!hash) { - showToast( - t( - resource === "memories" - ? "codex.memoriesPathEmpty" - : resource === "skills" - ? "codex.skillsEmpty" - : "codex.agentsPathEmpty", - ), - ); - return; - } - try { - const r = await fetch(`${codexDocApiBase(resource)}/history?hash=${encodeURIComponent(hash)}`); - if (!r.ok) throw new Error("history failed"); - const j = await r.json(); - const entries = j.history || []; - codexHistoryEntries = entries.slice().reverse(); - codexHistorySelectedIdx = codexHistoryEntries.length > 0 ? 0 : null; - codexHistoryRenderToggle(); - codexHistoryRenderMenu(); - codexHistoryRenderDiff(); - const modal = $("#codexHistoryModal"); - if (modal) modal.hidden = false; - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - function codexHistoryClose() { - const modal = $("#codexHistoryModal"); - if (modal) modal.hidden = true; - codexHistoryPickerClose(); - } - - function codexHistorySelect(idx) { - if (idx < 0 || idx >= codexHistoryEntries.length) return; - codexHistorySelectedIdx = idx; - codexHistoryRenderToggle(); - codexHistoryRenderMenu(); - codexHistoryRenderDiff(); - codexHistoryPickerClose(); - } - - async function codexHistoryRestore() { - if (codexHistorySelectedIdx == null || !codexHistoryEntries[codexHistorySelectedIdx]) { - showToast(t("codex.historyEmpty")); - return; - } - const entry = codexHistoryEntries[codexHistorySelectedIdx]; - if (!confirm(t("codex.agentsRestoreConfirm"))) return; - const resource = codexDocActiveResource; - // MCP 走独立 endpoint(无 hash,操作整个 config.toml) - if (resource === "mcp") { - try { - const r = await fetch("/api/codex/mcp/servers/restore", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index: entry.index }), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "restore failed"); } - showToast(t("codex.agentsRestoreOk")); - codexHistoryClose(); - await codexMcpReloadServers(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - return; - } - const hash = codexDocCurrentHash(resource); - if (!hash) return; - try { - const r = await fetch(`${codexDocApiBase(resource)}/restore-raw?hash=${encodeURIComponent(hash)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index: entry.index }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "restore failed"); - } - showToast(t("codex.agentsRestoreOk")); - codexHistoryClose(); - if (resource === "memories") await codexMemoriesRawLoadAndRender(); - else if (resource === "skills") await codexSkillsRawLoadAndRender(); - else if (resource === "mcp") await codexMcpReloadServers(); - else await codexAgentsRawLoadAndRender(); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - // 旧 toggle 函数名保持给 action 用 — 等价于 open - async function codexAgentsToggleHistory() { - codexDocActiveResource = "agents"; - await codexHistoryOpen(); - } - - // ── Memories 完全镜像 Agents,但用 currentMemoriesHash + memories endpoints ── - - let codexMemoriesEntriesCache = []; - let codexMemoriesMode = "preview"; - let codexMemoriesLastFullContent = ""; - - /** Memories 用 2 固定 entry,chip 按文件名分色:MEMORY.md 蓝(主索引)/ summary 绿 */ - function codexMemoriesChipsHtml(entry) { - const filename = entry.path.split("/").pop() || ""; - if (filename === "MEMORY.md") { - return `${escapeHtml(t("codex.memoriesPath.index"))}`; - } - if (filename === "memory_summary.md") { - return `${escapeHtml(t("codex.memoriesPath.summary"))}`; - } - return `${escapeHtml(filename)}`; - } - - function codexMemoriesRenderToggle() { - const cur = $("#codexMemoriesPathPicker .codex-path-picker-current"); - if (!cur) return; - const entry = codexMemoriesEntriesCache.find((e) => e.hash === currentMemoriesHash); - if (!entry) { - cur.innerHTML = `${escapeHtml(t("codex.memoriesPathEmpty"))}`; - return; - } - cur.innerHTML = `${codexMemoriesChipsHtml(entry)}${escapeHtml(entry.path)}`; - } - - function codexMemoriesRenderMenu() { - const menu = $("#codexMemoriesPathMenu"); - if (!menu) return; - if (codexMemoriesEntriesCache.length === 0) { - menu.innerHTML = `
  • ${escapeHtml(t("codex.memoriesPathEmpty"))}
  • `; - return; - } - menu.innerHTML = codexMemoriesEntriesCache - .map((e) => { - const selected = e.hash === currentMemoriesHash ? " selected" : ""; - return `
  • - ${codexMemoriesChipsHtml(e)} - ${escapeHtml(e.path)} -
  • `; - }) - .join(""); - } - - async function codexMemoriesReloadPaths() { - try { - const r = await fetch("/api/codex/memories-md/paths"); - if (!r.ok) throw new Error("memories paths request failed"); - const j = await r.json(); - codexMemoriesEntriesCache = j.entries || []; - if ( - !currentMemoriesHash || - !codexMemoriesEntriesCache.some((e) => e.hash === currentMemoriesHash) - ) { - currentMemoriesHash = codexMemoriesEntriesCache[0]?.hash || null; - } - codexMemoriesRenderToggle(); - codexMemoriesRenderMenu(); - return codexMemoriesEntriesCache; - } catch (e) { - console.error("codexMemoriesReloadPaths:", e); - codexMemoriesEntriesCache = []; - codexMemoriesRenderToggle(); - codexMemoriesRenderMenu(); - return []; - } - } - - async function codexMemoriesSelectHash(hash) { - if (!hash) return; - currentMemoriesHash = hash; - codexMemoriesRenderToggle(); - codexMemoriesRenderMenu(); - codexMemoriesClosePicker(); - try { - await codexMemoriesRawLoadAndRender(); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - function codexMemoriesOpenPicker() { - const picker = $("#codexMemoriesPathPicker"); - const menu = $("#codexMemoriesPathMenu"); - if (!picker || !menu) return; - if (codexMemoriesEntriesCache.length === 0) return; - picker.classList.add("open"); - menu.hidden = false; - } - function codexMemoriesClosePicker() { - const picker = $("#codexMemoriesPathPicker"); - const menu = $("#codexMemoriesPathMenu"); - if (!picker || !menu) return; - picker.classList.remove("open"); - menu.hidden = true; - } - function codexMemoriesTogglePicker() { - const picker = $("#codexMemoriesPathPicker"); - if (!picker) return; - if (picker.classList.contains("open")) codexMemoriesClosePicker(); - else codexMemoriesOpenPicker(); - } - - async function codexMemoriesOnPathRemove() { - if (!currentMemoriesHash) return; - if (!confirm(t("codex.agentsPathRemoveConfirm"))) return; - try { - const r = await fetch("/api/codex/memories-md/paths/remove", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hash: currentMemoriesHash }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "remove path failed"); - } - currentMemoriesHash = null; - await codexMemoriesReloadPaths(); - await codexMemoriesRawLoadAndRender(); - showToast(t("codex.agentsPathRemoveOk")); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - async function codexMemoriesRawLoadAndRender() { - const pre = $("#codexMemoriesPreview"); - const ta = $("#codexMemoriesEdit"); - if (!pre || !ta) return; - codexMemoriesSwitchMode("preview"); - if (!currentMemoriesHash) { - pre.classList.remove("codex-md-rendered"); - pre.textContent = ""; - pre.setAttribute("data-empty-hint", t("codex.memoriesLoading")); - ta.value = ""; - codexMemoriesLastFullContent = ""; - return; - } - try { - const r = await fetch(`/api/codex/memories-md/raw?hash=${encodeURIComponent(currentMemoriesHash)}`); - if (!r.ok) throw new Error("raw fetch failed"); - const j = await r.json(); - codexMemoriesLastFullContent = j.content || ""; - pre.classList.add("codex-md-rendered"); - pre.innerHTML = renderMiniMd(codexMemoriesLastFullContent); - pre.removeAttribute("data-empty-hint"); - ta.value = codexMemoriesLastFullContent; - } catch (e) { - pre.classList.remove("codex-md-rendered"); - pre.textContent = ""; - pre.setAttribute("data-empty-hint", `读取失败: ${e.message || e}`); - } - } - - function codexMemoriesSwitchMode(mode) { - codexMemoriesMode = mode; - const pre = $("#codexMemoriesPreview"); - const ta = $("#codexMemoriesEdit"); - const editBtn = $("#codexMemoriesEditBtn"); - const backupBtn = $("#codexMemoriesBackupBtn"); - const applyBtn = $("#codexMemoriesApplyBtn"); - const cancelBtn = $("#codexMemoriesCancelBtn"); - if (mode === "edit") { - if (pre) pre.hidden = true; - if (ta) { - ta.hidden = false; - ta.value = codexMemoriesLastFullContent; - } - if (editBtn) editBtn.hidden = true; - if (backupBtn) backupBtn.hidden = true; - if (applyBtn) applyBtn.hidden = false; - if (cancelBtn) cancelBtn.hidden = false; - } else { - if (pre) pre.hidden = false; - if (ta) ta.hidden = true; - if (editBtn) editBtn.hidden = false; - if (backupBtn) backupBtn.hidden = false; - if (applyBtn) applyBtn.hidden = true; - if (cancelBtn) cancelBtn.hidden = true; - } - } - - async function codexMemoriesOnEditStart() { - if (!currentMemoriesHash) { - showToast(t("codex.memoriesPathEmpty")); - return; - } - codexMemoriesSwitchMode("edit"); - setTimeout(() => $("#codexMemoriesEdit")?.focus(), 50); - } - - async function codexMemoriesOnApply() { - if (!currentMemoriesHash) return; - const ta = $("#codexMemoriesEdit"); - const content = ta?.value ?? ""; - // 写盘前二次确认,防误改影响 AI 行为的文档(MOC-106)。 - if (!window.confirm(tFmt("codex.docApplyConfirm", { doc: "MEMORY.md" }))) return; - try { - const r = await fetch(`/api/codex/memories-md/raw?hash=${encodeURIComponent(currentMemoriesHash)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "write failed"); - } - showToast(t("codex.agentsApplyOk")); - await codexMemoriesRawLoadAndRender(); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - function codexMemoriesOnCancel() { - codexMemoriesSwitchMode("preview"); - } - - async function codexMemoriesOnBackup() { - if (!currentMemoriesHash) { - showToast(t("codex.memoriesPathEmpty")); - return; - } - try { - const r = await fetch(`/api/codex/memories-md/backup?hash=${encodeURIComponent(currentMemoriesHash)}`, { - method: "POST", - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "backup failed"); - } - showToast(t("codex.agentsBackupOk")); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - async function codexMemoriesToggleHistory() { - codexDocActiveResource = "memories"; - await codexHistoryOpen(); - } - - // ── Skills 镜像 Agents/Memories,扫 ~/.codex/skills//SKILL.md ── - - let codexSkillsEntriesCache = []; - let codexSkillsMode = "preview"; - let codexSkillsLastFullContent = ""; - - /** skill chip:只显 skill 名(绿)*/ - function codexSkillsChipsHtml(entry) { - return `${escapeHtml(entry.name)}`; - } - - function codexSkillsRenderToggle() { - const cur = $("#codexSkillsPathPicker .codex-path-picker-current"); - if (!cur) return; - const entry = codexSkillsEntriesCache.find((e) => e.hash === currentSkillsHash); - if (!entry) { - cur.innerHTML = `${escapeHtml(t("codex.skillsEmpty"))}`; - return; - } - cur.innerHTML = `${codexSkillsChipsHtml(entry)}${escapeHtml(entry.path)}`; - } - - function codexSkillsRenderMenu() { - const menu = $("#codexSkillsPathMenu"); - if (!menu) return; - if (codexSkillsEntriesCache.length === 0) { - menu.innerHTML = `
  • ${escapeHtml(t("codex.skillsEmpty"))}
  • `; - return; - } - menu.innerHTML = codexSkillsEntriesCache - .map((e) => { - const selected = e.hash === currentSkillsHash ? " selected" : ""; - return `
  • - ${codexSkillsChipsHtml(e)} - ${escapeHtml(e.path)} -
  • `; - }) - .join(""); - } - - async function codexSkillsReloadPaths() { - try { - const r = await fetch("/api/codex/skills-md/paths"); - if (!r.ok) throw new Error("skills paths request failed"); - const j = await r.json(); - codexSkillsEntriesCache = j.entries || []; - if ( - !currentSkillsHash || - !codexSkillsEntriesCache.some((e) => e.hash === currentSkillsHash) - ) { - currentSkillsHash = codexSkillsEntriesCache[0]?.hash || null; - } - codexSkillsRenderToggle(); - codexSkillsRenderMenu(); - return codexSkillsEntriesCache; - } catch (e) { - console.error("codexSkillsReloadPaths:", e); - codexSkillsEntriesCache = []; - codexSkillsRenderToggle(); - codexSkillsRenderMenu(); - return []; - } - } - - async function codexSkillsSelectHash(hash) { - if (!hash) return; - currentSkillsHash = hash; - codexSkillsRenderToggle(); - codexSkillsRenderMenu(); - codexSkillsClosePicker(); - try { - await codexSkillsRawLoadAndRender(); - } catch (e) { - showToast(e.message || t("toast.requestFailed")); - } - } - - function codexSkillsOpenPicker() { - const picker = $("#codexSkillsPathPicker"); - const menu = $("#codexSkillsPathMenu"); - if (!picker || !menu) return; - if (codexSkillsEntriesCache.length === 0) return; - picker.classList.add("open"); - menu.hidden = false; - } - function codexSkillsClosePicker() { - const picker = $("#codexSkillsPathPicker"); - const menu = $("#codexSkillsPathMenu"); - if (!picker || !menu) return; - picker.classList.remove("open"); - menu.hidden = true; - } - function codexSkillsTogglePicker() { - const picker = $("#codexSkillsPathPicker"); - if (!picker) return; - if (picker.classList.contains("open")) codexSkillsClosePicker(); - else codexSkillsOpenPicker(); - } - - async function codexSkillsRawLoadAndRender() { - const pre = $("#codexSkillsPreview"); - const ta = $("#codexSkillsEdit"); - if (!pre || !ta) return; - codexSkillsSwitchMode("preview"); - if (!currentSkillsHash) { - pre.classList.remove("codex-md-rendered"); - pre.textContent = ""; - pre.setAttribute("data-empty-hint", t("codex.skillsEmpty")); - ta.value = ""; - codexSkillsLastFullContent = ""; - return; - } - try { - const r = await fetch(`/api/codex/skills-md/raw?hash=${encodeURIComponent(currentSkillsHash)}`); - if (!r.ok) throw new Error("raw fetch failed"); - const j = await r.json(); - codexSkillsLastFullContent = j.content || ""; - pre.classList.add("codex-md-rendered"); - pre.innerHTML = renderMiniMd(codexSkillsLastFullContent); - pre.removeAttribute("data-empty-hint"); - ta.value = codexSkillsLastFullContent; - } catch (e) { - pre.classList.remove("codex-md-rendered"); - pre.textContent = ""; - pre.setAttribute("data-empty-hint", `读取失败: ${e.message || e}`); - } - } - - function codexSkillsSwitchMode(mode) { - codexSkillsMode = mode; - const pre = $("#codexSkillsPreview"); - const ta = $("#codexSkillsEdit"); - const editBtn = $("#codexSkillsEditBtn"); - const backupBtn = $("#codexSkillsBackupBtn"); - const applyBtn = $("#codexSkillsApplyBtn"); - const cancelBtn = $("#codexSkillsCancelBtn"); - if (mode === "edit") { - if (pre) pre.hidden = true; - if (ta) { ta.hidden = false; ta.value = codexSkillsLastFullContent; } - if (editBtn) editBtn.hidden = true; - if (backupBtn) backupBtn.hidden = true; - if (applyBtn) applyBtn.hidden = false; - if (cancelBtn) cancelBtn.hidden = false; - } else { - if (pre) pre.hidden = false; - if (ta) ta.hidden = true; - if (editBtn) editBtn.hidden = false; - if (backupBtn) backupBtn.hidden = false; - if (applyBtn) applyBtn.hidden = true; - if (cancelBtn) cancelBtn.hidden = true; - } - } - - async function codexSkillsOnEditStart() { - if (!currentSkillsHash) { showToast(t("codex.skillsEmpty")); return; } - codexSkillsSwitchMode("edit"); - setTimeout(() => $("#codexSkillsEdit")?.focus(), 50); - } - - async function codexSkillsOnApply() { - if (!currentSkillsHash) return; - const ta = $("#codexSkillsEdit"); - const content = ta?.value ?? ""; - // 写盘前二次确认,防误改影响 AI 行为的文档(MOC-106)。 - if (!window.confirm(tFmt("codex.docApplyConfirm", { doc: "SKILL.md" }))) return; - try { - const r = await fetch(`/api/codex/skills-md/raw?hash=${encodeURIComponent(currentSkillsHash)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || "write failed"); } - showToast(t("codex.agentsApplyOk")); - await codexSkillsRawLoadAndRender(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - function codexSkillsOnCancel() { codexSkillsSwitchMode("preview"); } - - async function codexSkillsOnBackup() { - if (!currentSkillsHash) { showToast(t("codex.skillsEmpty")); return; } - try { - const r = await fetch(`/api/codex/skills-md/backup?hash=${encodeURIComponent(currentSkillsHash)}`, { method: "POST" }); - if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || "backup failed"); } - showToast(t("codex.agentsBackupOk")); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexSkillsToggleHistory() { - codexDocActiveResource = "skills"; - await codexHistoryOpen(); - } - - /** 打开当前 skill 所在目录(macOS:open / Linux:xdg-open / Windows:explorer)*/ - async function codexSkillsOnReveal() { - if (!currentSkillsHash) { showToast(t("codex.skillsEmpty")); return; } - try { - const r = await fetch(`/api/codex/skills-md/reveal?hash=${encodeURIComponent(currentSkillsHash)}`, { method: "POST" }); - if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || "open dir failed"); } - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - // ── MCP tab: Servers form + Plugins + Marketplace + Deeplink ── - - let codexMcpCurrentSubpane = "servers"; - let codexMcpServersCache = []; - let codexMcpCurrentServerName = null; - let codexMcpFormDirty = false; - let codexMcpPluginsCache = []; - let codexMcpSourcesCache = []; - let codexMcpMarketIndex = { servers: [], plugins: [], errors: {} }; - let codexMcpMarketFilter = ""; - let codexMcpRawSnapshot = ""; - let codexMcpPendingDeeplink = null; - - function codexMcpSetSubpaneVisible(sub) { - codexMcpCurrentSubpane = sub; - $all("#codexMcpSubnav .codex-mcp-subnav-item").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.mcpSub === sub); - }); - $all('#codexMcpTab .codex-mcp-subpane').forEach((pane) => { - const match = pane.dataset.mcpSubPane === sub; - pane.hidden = !match; - pane.classList.toggle("active", match); - }); - const rawWrap = $("#codexMcpRawWrap"); - if (rawWrap && sub !== "servers") rawWrap.hidden = true; - } - - async function codexMcpOpenSubpane(sub) { - codexMcpSetSubpaneVisible(sub); - if (sub === "servers") { - await codexMcpReloadServers(); - } else if (sub === "plugins") { - await codexMcpReloadPlugins(); - } else if (sub === "marketplace") { - await codexMcpReloadSources(); - await codexMcpReloadMarketIndex(false); - } - } - - // ── Servers ── - - async function codexMcpReloadServers() { - try { - const r = await fetch("/api/codex/mcp/servers"); - if (!r.ok) throw new Error("list servers failed"); - const j = await r.json(); - codexMcpServersCache = j.servers || []; - if ( - codexMcpCurrentServerName && - !codexMcpServersCache.some((s) => s.name === codexMcpCurrentServerName) - ) { - codexMcpCurrentServerName = null; - } - codexMcpRenderServersList(); - codexMcpRenderForm(); - } catch (e) { - console.error("codexMcpReloadServers:", e); - codexMcpServersCache = []; - codexMcpRenderServersList(); - } - } - - function codexMcpRenderServersList() { - const wrap = $("#codexMcpServersList"); - if (!wrap) return; - if (codexMcpServersCache.length === 0) { - wrap.innerHTML = `
    ${escapeHtml(t("codex.mcp.serversEmpty"))}
    `; - return; - } - wrap.innerHTML = codexMcpServersCache - .map((s) => { - const active = s.name === codexMcpCurrentServerName ? " active" : ""; - const disabled = s.enabled === false ? " disabled" : ""; - const chip = s.transport === "stdio" - ? `本机` - : `远程`; - const offChip = s.enabled === false ? `disabled` : ""; - return `
    -
    ${chip}${offChip}${escapeHtml(s.name)}
    -
    `; - }) - .join(""); - } - - function codexMcpEmptyServerSpec() { - return { - name: "", - transport: "stdio", - command: "", - args: [], - env: {}, - cwd: null, - url: null, - bearerTokenEnvVar: null, - httpHeaders: {}, - envHttpHeaders: {}, - enabled: true, - required: false, - supportsParallelToolCalls: false, - experimentalEnvironment: null, - startupTimeoutSec: null, - toolTimeoutSec: null, - defaultToolsApprovalMode: null, - enabledTools: null, - disabledTools: null, - _isNew: true, - }; - } - - /** JSON 编辑模式 — 当前选中 server / 新增的 JSON read-only pre + 编辑 textarea 切换 */ - let codexMcpJsonEditMode = false; - let codexMcpJsonDraft = ""; - - function codexMcpServerSpecToJsonText(spec) { - // 清掉内部字段 _isNew + null/undefined,然后 JSON.stringify pretty - const out = {}; - const skipKeys = new Set(["_isNew", "name", "disabledReason", "transport"]); - // 保留 transport 字段在输出 - if (spec.transport) out.transport = spec.transport; - for (const [k, v] of Object.entries(spec)) { - if (skipKeys.has(k)) continue; - if (v == null) continue; - if (Array.isArray(v) && v.length === 0) continue; - if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) continue; - out[k] = v; - } - return JSON.stringify(out, null, 2); - } - - function codexMcpRenderForm() { - const wrap = $("#codexMcpServerForm"); - const editBtnText = $("#codexMcpEditBtnText"); - const editBtn = $("#codexMcpEditBtn"); - if (!wrap) return; - let spec = null; - let isNew = false; - if (codexMcpCurrentServerName === "__new__") { - spec = codexMcpEmptyServerSpec(); - isNew = true; - } else if (codexMcpCurrentServerName) { - spec = codexMcpServersCache.find((s) => s.name === codexMcpCurrentServerName); - } - if (!spec) { - wrap.innerHTML = `
    从左侧列表选一个 server,或点底部「新增」
    `; - if (editBtn) editBtn.disabled = true; - if (editBtnText) editBtnText.textContent = "编辑"; - codexMcpJsonEditMode = false; - return; - } - if (editBtn) editBtn.disabled = false; - const jsonText = codexMcpJsonEditMode && codexMcpJsonDraft - ? codexMcpJsonDraft - : codexMcpServerSpecToJsonText(spec); - const title = `
    - ${escapeHtml(spec.name || "(新)")} - ${isNew ? "" : ``} -
    `; - wrap.innerHTML = ` - ${title} - ${codexMcpJsonEditMode - ? ` - ` - : `
    ${escapeHtml(jsonText)}
    ` - } - `; - if (editBtnText) editBtnText.textContent = codexMcpJsonEditMode ? (isNew ? "确认创建" : "保存") : "编辑"; - if (editBtn) { - const icon = editBtn.querySelector("i"); - if (icon) icon.className = codexMcpJsonEditMode ? "bi bi-check2-circle" : "bi bi-pencil"; - } - } - - /** args 列表 — 每个 1 个 input row,带删除按钮(legacy,JSON 模式不用) */ - function codexMcpRenderArgRows(args) { - const list = args || []; - if (list.length === 0) { - return `
    `; - } - return `
    ${list - .map( - (a, i) => `
    - - -
    `, - ) - .join("")}
    `; - } - - /** env / headers — KEY=VALUE row pair list */ - function codexMcpRenderKvRows(prefix, map) { - const entries = map ? Object.entries(map) : []; - if (entries.length === 0) { - return `
    `; - } - return `
    ${entries - .map( - ([k, v], i) => `
    - - = - - -
    `, - ) - .join("")}
    `; - } - - /** add row button helpers — 直接 append DOM,不重 render 整个 form */ - function codexMcpAddArgRow() { - const list = $("#codexMcpArgList"); - if (!list) return; - const idx = list.children.length; - const row = document.createElement("div"); - row.className = "codex-mcp-arg-row"; - row.dataset.argIdx = String(idx); - row.innerHTML = ` - `; - list.appendChild(row); - row.querySelector("input")?.focus(); - } - function codexMcpAddKvRow(prefix) { - const list = $(`#codexMcpKvList-${prefix}`); - if (!list) return; - const idx = list.children.length; - const row = document.createElement("div"); - row.className = "codex-mcp-kv-row"; - row.dataset.kvPrefix = prefix; - row.dataset.kvIdx = String(idx); - row.innerHTML = ` - = - - `; - list.appendChild(row); - row.querySelector("input")?.focus(); - } - function codexMcpRemoveArgRow(idx) { - const row = document.querySelector(`#codexMcpArgList .codex-mcp-arg-row[data-arg-idx="${idx}"]`); - if (row) row.remove(); - } - function codexMcpRemoveKvRow(prefix, idx) { - const row = document.querySelector(`#codexMcpKvList-${prefix} .codex-mcp-kv-row[data-kv-idx="${idx}"]`); - if (row) row.remove(); - } - - function codexMcpCollectKvRows(prefix) { - const rows = $all(`#codexMcpKvList-${prefix} .codex-mcp-kv-row`); - const out = {}; - for (const row of rows) { - const k = row.querySelector(".codex-mcp-kv-key")?.value?.trim(); - const v = row.querySelector(".codex-mcp-kv-val")?.value?.trim() ?? ""; - if (k) out[k] = v; - } - return out; - } - - function codexMcpRenderKvLines(map) { - if (!map || Object.keys(map).length === 0) return ""; - return Object.entries(map).map(([k, v]) => `${k}=${v}`).join("\n"); - } - function codexMcpParseKvLines(text) { - const out = {}; - for (const line of (text || "").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const idx = trimmed.indexOf("="); - if (idx <= 0) continue; - const k = trimmed.slice(0, idx).trim(); - const v = trimmed.slice(idx + 1).trim(); - if (k) out[k] = v; - } - return out; - } - function codexMcpParseCsvList(text) { - if (!text) return null; - const arr = text - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - return arr.length === 0 ? null : arr; - } - - function codexMcpCollectFormSpec() { - const transport = (document.querySelector('input[name="codexMcpTransport"]:checked')?.value) || "stdio"; - const name = ($("#codexMcpFormName")?.value || "").trim(); - const spec = { - name, - transport, - enabled: !!$("#codexMcpFormEnabled")?.checked, - required: !!$("#codexMcpFormRequired")?.checked, - supportsParallelToolCalls: !!$("#codexMcpFormParallel")?.checked, - }; - if (transport === "stdio") { - spec.command = ($("#codexMcpFormCommand")?.value || "").trim(); - const argInputs = $all("#codexMcpArgList .codex-mcp-arg-input"); - spec.args = argInputs - .map((el) => (el.value || "").trim()) - .filter((s) => s.length > 0); - const envMap = codexMcpCollectKvRows("env"); - spec.env = Object.keys(envMap).length > 0 ? envMap : null; - const cwd = ($("#codexMcpFormCwd")?.value || "").trim(); - spec.cwd = cwd || null; - } else { - spec.url = ($("#codexMcpFormUrl")?.value || "").trim(); - const bearer = ($("#codexMcpFormBearerEnv")?.value || "").trim(); - spec.bearerTokenEnvVar = bearer || null; - const hh = codexMcpCollectKvRows("hh"); - spec.httpHeaders = Object.keys(hh).length > 0 ? hh : null; - const ehh = codexMcpCollectKvRows("ehh"); - spec.envHttpHeaders = Object.keys(ehh).length > 0 ? ehh : null; - } - const startup = parseInt($("#codexMcpFormStartupTimeout")?.value, 10); - spec.startupTimeoutSec = isFinite(startup) && startup >= 0 ? startup : null; - const toolTo = parseInt($("#codexMcpFormToolTimeout")?.value, 10); - spec.toolTimeoutSec = isFinite(toolTo) && toolTo >= 0 ? toolTo : null; - const mode = ($("#codexMcpFormApprovalMode")?.value || "").trim(); - spec.defaultToolsApprovalMode = mode || null; - spec.enabledTools = codexMcpParseCsvList($("#codexMcpFormEnabledTools")?.value); - spec.disabledTools = codexMcpParseCsvList($("#codexMcpFormDisabledTools")?.value); - const expEnv = ($("#codexMcpFormExperimental")?.value || "").trim(); - spec.experimentalEnvironment = expEnv || null; - return spec; - } - - /** 进入 / 退出编辑模式 — toggle JSON read-only ↔ textarea */ - function codexMcpServerEditToggle() { - if (codexMcpJsonEditMode) { - // 当前编辑模式 → 保存 - codexMcpJsonSave(); - } else { - // 进入编辑模式 — draft 用 nul,让 render 读 current spec - codexMcpJsonDraft = ""; - codexMcpJsonEditMode = true; - codexMcpRenderForm(); - } - } - - function codexMcpJsonErrShow(msg) { - const el = $("#codexMcpJsonError"); - if (!el) { showToast(msg); return; } - el.textContent = msg; - el.hidden = false; - } - - function codexMcpJsonErrClear() { - const el = $("#codexMcpJsonError"); - if (el) el.hidden = true; - } - - // 写 MCP server 进 ~/.codex/config.toml 前的二次确认文案(MOC-106)。 - // 所有新增 / 编辑一律确认,没有免确认白名单 —— stdio 强调"会以你的权限在本机执行" - // (config 即可 spawn 任意命令,黑名单只是 guardrail),http 列出连接地址防误填。 - // stdio 一并展示 cwd / env —— 它们同样 execution-affecting(env 可塞 NODE_OPTIONS / - // LD_PRELOAD / PATH 劫持注入代码、cwd 改工作目录),否则恶意 payload 藏 env 里会绕过确认。 - function codexMcpBuildSaveConfirm(spec) { - const name = spec.name || ""; - if (spec.transport === "stdio") { - const cmdline = [spec.command || "", ...(Array.isArray(spec.args) ? spec.args : [])] - .join(" ") - .trim(); - const extras = []; - if (spec.cwd) extras.push(`cwd: ${spec.cwd}`); - if (spec.env && typeof spec.env === "object") { - const envLines = Object.keys(spec.env).map((k) => ` ${k}=${spec.env[k]}`); - if (envLines.length) extras.push("env:\n" + envLines.join("\n")); - } - const extra = extras.length ? "\n\n" + extras.join("\n") : ""; - return tFmt("codex.mcp.saveConfirmStdio", { name, cmdline, extra }); - } - // http 也展示发往远端的凭据 / header —— 恶意 spec 可把 Authorization / $GITHUB_TOKEN - // 等通过 bearerTokenEnvVar / httpHeaders / envHttpHeaders 发到错误或恶意 URL。 - const httpExtras = []; - if (spec.bearerTokenEnvVar) httpExtras.push(`bearer token env var: ${spec.bearerTokenEnvVar}`); - if (spec.httpHeaders && typeof spec.httpHeaders === "object") { - const lines = Object.keys(spec.httpHeaders).map((k) => ` ${k}: ${spec.httpHeaders[k]}`); - if (lines.length) httpExtras.push("http headers:\n" + lines.join("\n")); - } - if (spec.envHttpHeaders && typeof spec.envHttpHeaders === "object") { - const lines = Object.keys(spec.envHttpHeaders).map((k) => ` ${k} ← $${spec.envHttpHeaders[k]}`); - if (lines.length) httpExtras.push("env http headers:\n" + lines.join("\n")); - } - const httpExtra = httpExtras.length ? "\n\n" + httpExtras.join("\n") : ""; - return tFmt("codex.mcp.saveConfirmHttp", { name, url: spec.url || "", extra: httpExtra }); - } - - async function codexMcpJsonSave() { - const ta = $("#codexMcpJsonTextarea"); - if (!ta) return; - codexMcpJsonErrClear(); - let parsed; - try { - parsed = JSON.parse(ta.value || "{}"); - } catch (e) { - codexMcpJsonErrShow("JSON 解析失败:" + (e.message || e)); - return; - } - if (typeof parsed !== "object" || Array.isArray(parsed) || parsed === null) { - codexMcpJsonErrShow("JSON 必须是一个 object(花括号 {...})"); - return; - } - const isNew = codexMcpCurrentServerName === "__new__"; - const name = isNew ? codexMcpPendingNewName : codexMcpCurrentServerName; - if (!name) { - codexMcpJsonErrShow("server 名缺失"); - return; - } - // 推断 transport:JSON 里有 transport 字段优先;否则按 command/url 启发判断 - let transport = parsed.transport; - if (!transport) { - if (typeof parsed.command === "string" && parsed.command.length > 0) transport = "stdio"; - else if (typeof parsed.url === "string" && parsed.url.length > 0) transport = "streamable_http"; - else transport = "stdio"; - } - if (transport !== "stdio" && transport !== "streamable_http") { - codexMcpJsonErrShow(`transport 仅支持 "stdio" 跟 "streamable_http",收到:${transport}`); - return; - } - const spec = { - name, - transport, - command: parsed.command ?? null, - args: Array.isArray(parsed.args) ? parsed.args : null, - env: parsed.env && typeof parsed.env === "object" ? parsed.env : null, - cwd: parsed.cwd ?? null, - url: parsed.url ?? null, - bearerTokenEnvVar: parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var ?? null, - httpHeaders: parsed.httpHeaders ?? parsed.http_headers ?? null, - envHttpHeaders: parsed.envHttpHeaders ?? parsed.env_http_headers ?? null, - enabled: parsed.enabled !== false, - required: !!parsed.required, - supportsParallelToolCalls: !!(parsed.supportsParallelToolCalls ?? parsed.supports_parallel_tool_calls), - experimentalEnvironment: parsed.experimentalEnvironment ?? parsed.experimental_environment ?? null, - startupTimeoutSec: parsed.startupTimeoutSec ?? parsed.startup_timeout_sec ?? null, - toolTimeoutSec: parsed.toolTimeoutSec ?? parsed.tool_timeout_sec ?? null, - defaultToolsApprovalMode: parsed.defaultToolsApprovalMode ?? parsed.default_tools_approval_mode ?? null, - enabledTools: Array.isArray(parsed.enabledTools ?? parsed.enabled_tools) ? (parsed.enabledTools ?? parsed.enabled_tools) : null, - disabledTools: Array.isArray(parsed.disabledTools ?? parsed.disabled_tools) ? (parsed.disabledTools ?? parsed.disabled_tools) : null, - }; - if (!window.confirm(codexMcpBuildSaveConfirm(spec))) return; - try { - const r = await fetch("/api/codex/mcp/servers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(spec), - }); - if (!r.ok) { - const j = await r.json().catch(() => ({})); - codexMcpJsonErrShow(j.error || "save failed"); - return; - } - showToast(t("codex.mcp.saveOk")); - codexMcpCurrentServerName = name; - codexMcpPendingNewName = null; - codexMcpJsonEditMode = false; - codexMcpJsonDraft = ""; - await codexMcpReloadServers(); - } catch (e) { codexMcpJsonErrShow(e.message || t("toast.requestFailed")); } - } - - async function codexMcpServerDelete() { - if (!codexMcpCurrentServerName || codexMcpCurrentServerName === "__new__") return; - if (!confirm(`确认删除 server "${codexMcpCurrentServerName}"?(会同步删 ~/.codex/config.toml 对应节)`)) return; - try { - const r = await fetch("/api/codex/mcp/servers/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: codexMcpCurrentServerName }), - }); - if (!r.ok) { - const j = await r.json().catch(() => ({})); - throw new Error(j.error || "delete failed"); - } - codexMcpCurrentServerName = null; - await codexMcpReloadServers(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - let codexMcpPendingNewName = null; - - function codexMcpServerNew() { - // 弹 inline modal 收 name - const modal = $("#codexMcpNewServerModal"); - const input = $("#codexMcpNewServerNameInput"); - if (!modal || !input) return; - input.value = ""; - modal.hidden = false; - setTimeout(() => input.focus(), 50); - } - - function codexMcpServerNewCancel() { - const modal = $("#codexMcpNewServerModal"); - if (modal) modal.hidden = true; - codexMcpPendingNewName = null; - } - - function codexMcpServerNewConfirm() { - const input = $("#codexMcpNewServerNameInput"); - const name = ((input?.value) || "").trim(); - if (!name) { showToast("名字不能为空"); return; } - if (!/^[A-Za-z0-9_.\-]+$/.test(name)) { - showToast("名字仅允许字母数字 / 短横 / 下划线 / 点"); - return; - } - if (codexMcpServersCache.some((s) => s.name === name)) { - showToast(`server "${name}" 已存在`); - return; - } - codexMcpPendingNewName = name; - codexMcpCurrentServerName = "__new__"; - codexMcpJsonEditMode = true; - codexMcpJsonDraft = JSON.stringify({ - transport: "stdio", - command: "npx", - args: [], - enabled: true, - }, null, 2); - const modal = $("#codexMcpNewServerModal"); - if (modal) modal.hidden = true; - codexMcpRenderServersList(); - codexMcpRenderForm(); - } - - async function codexMcpServersBackup() { - try { - const r = await fetch("/api/codex/mcp/servers/backup", { method: "POST" }); - if (!r.ok) { - const j = await r.json().catch(() => ({})); - throw new Error(j.error || "backup failed"); - } - showToast(t("codex.agentsBackupOk")); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexMcpServersOpenHistory() { - codexDocActiveResource = "mcp"; - try { - const r = await fetch("/api/codex/mcp/servers/history"); - if (!r.ok) throw new Error("history failed"); - const j = await r.json(); - const entries = j.history || []; - codexHistoryEntries = entries.slice().reverse(); - codexHistorySelectedIdx = codexHistoryEntries.length > 0 ? 0 : null; - codexHistoryRenderToggle(); - codexHistoryRenderMenu(); - codexHistoryRenderDiff(); - const modal = $("#codexHistoryModal"); - if (modal) modal.hidden = false; - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexMcpRawToggle() { - const wrap = $("#codexMcpRawWrap"); - const ta = $("#codexMcpRawTextarea"); - if (!wrap || !ta) return; - if (wrap.hidden) { - try { - const r = await fetch("/api/codex/mcp/config/raw"); - if (!r.ok) throw new Error("raw fetch failed"); - const j = await r.json(); - codexMcpRawSnapshot = j.content || ""; - ta.value = codexMcpRawSnapshot; - wrap.hidden = false; - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } else { - wrap.hidden = true; - } - } - - async function codexMcpRawApply() { - const ta = $("#codexMcpRawTextarea"); - if (!ta) return; - // raw 模式整体覆盖 ~/.codex/config.toml(含 [mcp_servers.*] command),同样需二次确认(MOC-106)。 - if (!window.confirm(t("codex.mcp.rawApplyConfirm"))) return; - try { - const r = await fetch("/api/codex/mcp/config/raw", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: ta.value }), - }); - if (!r.ok) { - const j = await r.json().catch(() => ({})); - throw new Error(j.error || "apply raw failed"); - } - showToast(t("codex.mcp.saveOk")); - $("#codexMcpRawWrap").hidden = true; - await codexMcpReloadServers(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - function codexMcpRawCancel() { - const ta = $("#codexMcpRawTextarea"); - if (ta) ta.value = codexMcpRawSnapshot; - $("#codexMcpRawWrap").hidden = true; - } - - // ── Plugins ── - - async function codexMcpReloadPlugins() { - try { - const r = await fetch("/api/codex/mcp/plugins"); - if (!r.ok) throw new Error("list plugins failed"); - const j = await r.json(); - codexMcpPluginsCache = j.plugins || []; - codexMcpRenderPlugins(); - } catch (e) { - console.error("codexMcpReloadPlugins:", e); - codexMcpPluginsCache = []; - codexMcpRenderPlugins(); - } - } - - function codexMcpRenderPlugins() { - const wrap = $("#codexMcpPluginsList"); - if (!wrap) return; - if (codexMcpPluginsCache.length === 0) { - wrap.innerHTML = `
  • ${escapeHtml(t("codex.mcp.pluginsEmpty"))}
  • `; - return; - } - wrap.innerHTML = codexMcpPluginsCache - .map((p) => { - const enableIcon = p.enabled ? "bi-check2-square" : "bi-square"; - const enableLabel = p.enabled ? "已启用" : "已关闭"; - return `
  • -
    - ${escapeHtml(p.name)} - @${escapeHtml(p.marketplace)} · v${escapeHtml(p.version)} -
    -
    - - -
    -
  • `; - }) - .join(""); - } - - async function codexMcpPluginToggle(key, enabled) { - try { - const r = await fetch("/api/codex/mcp/plugins/toggle", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key, enabled }), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "toggle failed"); } - await codexMcpReloadPlugins(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexMcpPluginUninstall(key) { - if (!confirm(`确认卸载 plugin "${key}"?会同步删除 ~/.codex/plugins/cache/ 下整个目录`)) return; - try { - const r = await fetch("/api/codex/mcp/plugins/uninstall", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "uninstall failed"); } - showToast(t("codex.mcp.uninstallOk")); - await codexMcpReloadPlugins(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - // ── Marketplace ── - - async function codexMcpReloadSources() { - try { - const r = await fetch("/api/codex/mcp/marketplace/sources"); - if (!r.ok) throw new Error("sources failed"); - const j = await r.json(); - codexMcpSourcesCache = j.sources || []; - codexMcpRenderSources(); - } catch (e) { - console.error("codexMcpReloadSources:", e); - codexMcpSourcesCache = []; - codexMcpRenderSources(); - } - } - - function codexMcpRenderSources() { - const wrap = $("#codexMcpSourcesRow"); - if (!wrap) return; - wrap.innerHTML = codexMcpSourcesCache - .map((s) => { - const active = s.enabled ? " active" : ""; - const disabled = s.enabled ? "" : " disabled"; - const removeBtn = s.official - ? "" - : ``; - return ` - - ${escapeHtml(s.name)} - ${removeBtn} - `; - }) - .join(""); - } - - async function codexMcpSourceToggle(id, enabled) { - try { - const r = await fetch("/api/codex/mcp/marketplace/sources/toggle", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id, enabled }), - }); - if (!r.ok) throw new Error("toggle source failed"); - await codexMcpReloadSources(); - await codexMcpReloadMarketIndex(true); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexMcpSourceRemove(id) { - if (!confirm("删除该 marketplace 源?(官方源不可删)")) return; - try { - const r = await fetch("/api/codex/mcp/marketplace/sources/remove", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - if (!r.ok) throw new Error("remove source failed"); - await codexMcpReloadSources(); - await codexMcpReloadMarketIndex(true); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - function codexMcpSourceAddOpen() { - const modal = $("#codexMcpAddSourceModal"); - const nameInp = $("#codexMcpSourceNameInput"); - const urlInp = $("#codexMcpSourceUrlInput"); - if (!modal || !nameInp || !urlInp) return; - nameInp.value = ""; - urlInp.value = ""; - modal.hidden = false; - setTimeout(() => nameInp.focus(), 50); - } - - function codexMcpSourceAddClose() { - const modal = $("#codexMcpAddSourceModal"); - if (modal) modal.hidden = true; - } - - async function codexMcpSourceAddConfirm() { - const name = ($("#codexMcpSourceNameInput")?.value || "").trim(); - const url = ($("#codexMcpSourceUrlInput")?.value || "").trim(); - if (!name || !url) { showToast("name 跟 url 都必填"); return; } - try { - const r = await fetch("/api/codex/mcp/marketplace/sources/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, url }), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "add source failed"); } - codexMcpSourceAddClose(); - await codexMcpReloadSources(); - await codexMcpReloadMarketIndex(true); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexMcpReloadMarketIndex(forceRefresh) { - try { - const r = await fetch(`/api/codex/mcp/marketplace/index${forceRefresh ? "?force_refresh=true" : ""}`); - if (!r.ok) throw new Error("market index failed"); - const j = await r.json(); - codexMcpMarketIndex = j.index || { servers: [], plugins: [], errors: {} }; - codexMcpRenderMarketIndex(); - } catch (e) { - console.error("codexMcpReloadMarketIndex:", e); - codexMcpMarketIndex = { servers: [], plugins: [], errors: {} }; - codexMcpRenderMarketIndex(); - } - } - - function codexMcpRenderMarketIndex() { - const serversWrap = $("#codexMcpMarketServersList"); - const pluginsWrap = $("#codexMcpMarketPluginsList"); - const filter = (codexMcpMarketFilter || "").trim().toLowerCase(); - const matches = (txt) => !filter || (txt || "").toLowerCase().includes(filter); - - const errEntries = Object.entries(codexMcpMarketIndex.errors || {}); - let errHtml = ""; - if (errEntries.length > 0) { - errHtml = errEntries - .map(([id, msg]) => `
    ${escapeHtml(id)} fetch 失败:${escapeHtml(msg)}
    `) - .join(""); - } - - if (serversWrap) { - const filtered = (codexMcpMarketIndex.servers || []).filter( - (s) => matches(s.id) || matches(s.name) || matches(s.description) || matches(s.transport), - ); - const html = filtered - .map((s) => { - const chip = s.transport === "stdio" - ? `Stdio` - : `HTTP`; - return `
  • -
    -
    ${chip}${escapeHtml(s.name || s.id)}${escapeHtml(s.source || "?")}
    - ${s.description ? `
    ${escapeHtml(s.description)}
    ` : ""} -
    -
    - -
    -
  • `; - }) - .join(""); - serversWrap.innerHTML = errHtml + (filtered.length === 0 - ? `
  • ${escapeHtml(t("codex.mcp.marketEmpty"))}
  • ` - : html); - } - if (pluginsWrap) { - const filtered = (codexMcpMarketIndex.plugins || []).filter( - (p) => matches(p.id) || matches(p.description) || matches(p.marketplace), - ); - const html = filtered - .map((p) => { - const caps = p.capabilities ? `mcp:${p.capabilities.mcpServers || 0} skills:${p.capabilities.skills || 0} apps:${p.capabilities.apps || 0}` : ""; - return `
  • -
    -
    ${escapeHtml(p.id)}@${escapeHtml(p.marketplace)} v${escapeHtml(p.version)}${escapeHtml(p.source || "?")}
    - ${p.description ? `
    ${escapeHtml(p.description)}
    ` : ""} - ${caps ? `
    ${caps}
    ` : ""} -
    -
    - -
    -
  • `; - }) - .join(""); - pluginsWrap.innerHTML = filtered.length === 0 - ? `
  • ${escapeHtml(t("codex.mcp.marketEmpty"))}
  • ` - : html; - } - } - - async function codexMcpMarketInstallServer(id) { - const item = (codexMcpMarketIndex.servers || []).find((s) => s.id === id); - if (!item) return; - const spec = { - name: item.id, - transport: item.transport === "stdio" ? "stdio" : "streamable_http", - enabled: true, - required: false, - supportsParallelToolCalls: false, - }; - if (item.transport === "stdio") { - spec.command = item.command || ""; - spec.args = item.args || []; - } else { - spec.url = item.url || ""; - spec.bearerTokenEnvVar = item.bearerTokenEnvVar || null; - } - if (!window.confirm(codexMcpBuildSaveConfirm(spec))) return; - try { - const r = await fetch("/api/codex/mcp/servers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(spec), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "install server failed"); } - showToast(t("codex.mcp.installServerOk")); - codexMcpCurrentServerName = item.id; - codexMcpSetSubpaneVisible("servers"); - await codexMcpReloadServers(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - async function codexMcpMarketInstallPlugin(id, marketplace) { - const item = (codexMcpMarketIndex.plugins || []).find( - (p) => p.id === id && p.marketplace === marketplace, - ); - if (!item) return; - if (!confirm(`下载并安装 plugin "${id}@${marketplace}" v${item.version}?\n\n来源:${item.tarballUrl}\n会解压到 ~/.codex/plugins/cache/${marketplace}/${id}/${item.version}/`)) return; - try { - showToast("正在下载 + 解压…"); - const r = await fetch("/api/codex/mcp/plugins/install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: id, - marketplace, - version: item.version, - tarballUrl: item.tarballUrl, - }), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "install plugin failed"); } - showToast(t("codex.mcp.installPluginOk")); - codexMcpSetSubpaneVisible("plugins"); - await codexMcpReloadPlugins(); - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - // ── Deeplink import ── - // 处理 codex-app-transfer://v1/import?resource=mcp-server|plugin&... - - function codexMcpDeeplinkOpenConfirm(payload) { - codexMcpPendingDeeplink = payload; - const modal = $("#codexMcpDeeplinkModal"); - const pre = $("#codexMcpDeeplinkPreview"); - if (!modal || !pre) return; - pre.textContent = JSON.stringify(payload, null, 2); - modal.hidden = false; - } - - function codexMcpDeeplinkCancel() { - codexMcpPendingDeeplink = null; - const modal = $("#codexMcpDeeplinkModal"); - if (modal) modal.hidden = true; - } - - async function codexMcpDeeplinkConfirm() { - const p = codexMcpPendingDeeplink; - codexMcpDeeplinkCancel(); - if (!p) return; - try { - if (p.resource === "mcp-server") { - const r = await fetch("/api/codex/mcp/servers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(p.spec), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "deeplink install server failed"); } - showToast(t("codex.mcp.deeplinkInstallOk")); - window.location.hash = "codex"; - codexMcpSetSubpaneVisible("servers"); - await codexMcpReloadServers(); - } else if (p.resource === "plugin") { - const r = await fetch("/api/codex/mcp/plugins/install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(p.input), - }); - if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || "deeplink install plugin failed"); } - showToast(t("codex.mcp.deeplinkInstallOk")); - window.location.hash = "codex"; - codexMcpSetSubpaneVisible("plugins"); - await codexMcpReloadPlugins(); - } - } catch (e) { showToast(e.message || t("toast.requestFailed")); } - } - - /** 解析 deeplink URL,弹 confirmation modal。供 Tauri deep-link plugin / 也支持手动 paste 触发。 */ - function codexMcpHandleDeeplink(url) { - try { - const u = new URL(url); - if (u.protocol !== "codex-app-transfer:") return false; - const action = (u.pathname || "").replace(/^\/+/, "") || u.host; - // 形态 1:/v1/import?... 形态 2:host=v1/import - if (!action.includes("import")) return false; - const resource = u.searchParams.get("resource"); - if (resource === "mcp-server") { - const configB64 = u.searchParams.get("config"); - if (!configB64) { showToast("deeplink 缺 config 参数"); return false; } - if (configB64.length > 16 * 1024) { showToast("deeplink config 过大(>16KB)"); return false; } - let raw; - try { raw = atob(configB64); } catch { showToast("deeplink config base64 解码失败"); return false; } - let spec; - try { spec = JSON.parse(raw); } catch { showToast("deeplink config 不是合法 JSON"); return false; } - codexMcpDeeplinkOpenConfirm({ resource: "mcp-server", spec }); - return true; - } - if (resource === "plugin") { - const name = u.searchParams.get("name") || u.searchParams.get("id"); - const marketplace = u.searchParams.get("marketplace") || "official"; - const version = u.searchParams.get("version") || "local"; - const tarballUrl = u.searchParams.get("tarball_url") || u.searchParams.get("url"); - if (!name || !tarballUrl) { showToast("deeplink plugin 缺 name 或 tarball_url"); return false; } - if (!tarballUrl.startsWith("https://")) { showToast("deeplink tarball_url 必须 https"); return false; } - codexMcpDeeplinkOpenConfirm({ - resource: "plugin", - input: { name, marketplace, version, tarballUrl }, - }); - return true; - } - } catch (e) { - console.error("codexMcpHandleDeeplink:", e); - } - return false; - } - window.codexMcpHandleDeeplink = codexMcpHandleDeeplink; - - async function codexBlockLoadAndRender(type) { - const status = await codexBlockFetchStatus(type); - const el = $("#codexBlockStatus"); - if (!el) return; - const lastApply = status.lastApply - ? new Date(status.lastApply * 1000).toLocaleString() - : t("codex.statusNone"); - const stateLabel = status.hasManaged ? t("codex.statusManaged") : t("codex.statusEmpty"); - el.innerHTML = `

    - ${escapeHtml(t("codex.statusBlockState"))}(${escapeHtml(type)}): ${escapeHtml(stateLabel)}
    - ${escapeHtml(t("codex.statusUserBytes"))}: ${status.beforeUserBytes + status.afterUserBytes} ${escapeHtml(t("codex.statusBytesSuffix"))}
    - ${escapeHtml(t("codex.statusHistoryCount"))}: ${status.historyCount} ${escapeHtml(t("codex.statusHistoryCountSuffix"))}
    - ${escapeHtml(t("codex.statusLastApply"))}: ${escapeHtml(lastApply)}
    - ${escapeHtml(t("codex.statusTargetFile"))}: ${escapeHtml(status.targetPath || "")} -

    `; - const ta = $("#codexBlockContent"); - if (ta) { - if (status.outerSignature !== undefined) { - ta.dataset.outerSignature = status.outerSignature; - } - if (status.managedContent !== undefined && !ta.dataset.dirty) { - ta.value = status.managedContent || ""; - } - } - } - - async function codexBlockPreview(type) { - const content = $("#codexBlockContent")?.value ?? ""; - const r = await fetch(`${codexBlockUrl(type)}/preview${codexAgentsHashSuffix(type)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!r.ok) throw new Error("preview failed"); - const j = await r.json(); - const pre = $("#codexBlockPreviewArea"); - if (pre) { - pre.textContent = j.rendered ?? "(empty)"; - pre.hidden = false; - } - } - - async function codexBlockApply(type) { - const ta = $("#codexBlockContent"); - const content = ta?.value ?? ""; - const expectedOuterSignature = ta?.dataset?.outerSignature || null; - const body = { content }; - if (expectedOuterSignature) { - body.expectedOuterSignature = expectedOuterSignature; - } - const r = await fetch(`${codexBlockUrl(type)}/apply${codexAgentsHashSuffix(type)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "apply failed"); - } - if (ta) delete ta.dataset.dirty; - await codexBlockLoadAndRender(type); - showToast(tFmt("codex.toastApplied", { type })); - } - - async function codexBlockClear(type) { - const r = await fetch(`${codexBlockUrl(type)}/clear${codexAgentsHashSuffix(type)}`, { method: "POST" }); - if (!r.ok) throw new Error("clear failed"); - await codexBlockLoadAndRender(type); - showToast(tFmt("codex.toastCleared", { type })); - } - - async function codexBlockRollback(type, idx) { - const r = await fetch(`${codexBlockUrl(type)}/rollback${codexAgentsHashSuffix(type)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index: idx }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "rollback failed"); - } - await codexBlockLoadAndRender(type); - await codexBlockRenderHistory(type); - showToast(tFmt("codex.toastRollbacked", { type, idx })); - } - - async function codexBlockRenderHistory(type) { - const r = await fetch(`${codexBlockUrl(type)}/history${codexAgentsHashSuffix(type)}`); - if (!r.ok) throw new Error("history failed"); - const j = await r.json(); - const list = $("#codexBlockHistoryList"); - if (!list) return; - const items = (j.history || []).map((entry) => { - const ts = new Date(entry.timestamp * 1000).toLocaleString(); - const preview = (entry.managedContent || "").slice(0, 80).replace(/\n/g, " ⏎ "); - return `
  • -
    - [${entry.index}] ${ts} - -
    -
    ${escapeHtml(preview) || "(empty)"}
    -
  • `; - }); - list.innerHTML = - items.join("") || `
  • ${escapeHtml(t("codex.historyEmpty"))}
  • `; - } - - async function codexBlockToggleHistory(type) { - const el = $("#codexBlockHistory"); - if (!el) return; - if (el.hidden) { - await codexBlockRenderHistory(type); - el.hidden = false; - } else { - el.hidden = true; - } - } - - // ── Skills tab (file-snapshot backup / restore, 独立 ManagedBlock 之外) ── - - async function codexSkillsLoadAndRender() { - const [listR, backupsR] = await Promise.all([ - fetch("/api/codex/skills/list"), - fetch("/api/codex/skills/backups"), - ]); - if (!listR.ok || !backupsR.ok) throw new Error("skills load failed"); - const list = await listR.json(); - const backups = await backupsR.json(); - - const statusEl = $("#codexSkillsStatus"); - if (statusEl) { - const countSuffix = t("codex.skillsCountSuffix"); - const backupsSuffix = t("codex.skillsBackupsCountSuffix"); - statusEl.innerHTML = `

    - ${escapeHtml(t("codex.skillsDirLabel"))}: ${escapeHtml(list.skillsDir || "")}
    - ${escapeHtml(t("codex.skillsInstalledLabel"))}: ${list.count}${countSuffix ? " " + escapeHtml(countSuffix) : ""}
    - ${escapeHtml(t("codex.skillsBackupDirLabel"))}: ${escapeHtml(backups.backupDir || "")}
    - ${escapeHtml(t("codex.skillsBackupsLabel"))}: ${backups.count}${backupsSuffix ? " " + escapeHtml(backupsSuffix) : ""} -

    `; - } - - const ul = $("#codexSkillsList"); - if (ul) { - const filesSuffix = t("codex.skillsFilesSuffix"); - const rows = (list.entries || []).map((entry) => { - const md = entry.has_skill_md - ? t("codex.skillsHasSkillMd") - : t("codex.skillsNoSkillMd"); - return `
  • - ${escapeHtml(entry.name)} - ${escapeHtml(md)} · ${entry.files_count} ${escapeHtml(filesSuffix)} -
  • `; - }); - ul.innerHTML = - rows.join("") || `
  • ${escapeHtml(t("codex.skillsListEmpty"))}
  • `; - } - - const backupList = $("#codexSkillsBackupsList"); - if (backupList) { - const rows = (backups.backups || []).map((entry) => { - const ts = new Date(entry.created_unix * 1000).toLocaleString(); - const sizeKb = (entry.size_bytes / 1024).toFixed(1); - return `
  • - ${escapeHtml(entry.filename)} ${sizeKb} KB · ${ts} - -
  • `; - }); - backupList.innerHTML = - rows.join("") || `
  • ${escapeHtml(t("codex.backupsListEmpty"))}
  • `; - } - } - - async function codexSkillsBackup() { - const r = await fetch("/api/codex/skills/backup", { method: "POST" }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "backup failed"); - } - const j = await r.json(); - showToast(tFmt("codex.toastSkillsBackedUp", { name: j.backupPath?.split("/").pop() || "ok" })); - await codexSkillsLoadAndRender(); - } - - async function codexSkillsRestore(filename) { - const r = await fetch("/api/codex/skills/restore", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filename }), - }); - if (!r.ok) { - const err = await r.json().catch(() => ({})); - throw new Error(err.error || "restore failed"); - } - showToast(tFmt("codex.toastSkillsRestored", { filename })); - await codexSkillsLoadAndRender(); - } - - // ── sidebar tab switch + renderCodexAssets entry (#25 sidebar + lazy + 转场) ── - - /** sidebar tab visibility:active class + fade slide pane */ - function codexShowTab(tab) { - const agentsPane = $("#codexAgentsRawTab"); - const memoriesPane = $("#codexMemoriesRawTab"); - const skillsRawPane = $("#codexSkillsRawTab"); - const blockPane = $("#codexBlockTab"); - if (agentsPane) { - agentsPane.hidden = tab !== "agents"; - agentsPane.classList.toggle("active", tab === "agents"); - } - if (memoriesPane) { - memoriesPane.hidden = tab !== "memories"; - memoriesPane.classList.toggle("active", tab === "memories"); - } - if (skillsRawPane) { - skillsRawPane.hidden = tab !== "skills"; - skillsRawPane.classList.toggle("active", tab === "skills"); - } - const mcpPane = $("#codexMcpTab"); - if (mcpPane) { - mcpPane.hidden = tab !== "mcp"; - mcpPane.classList.toggle("active", tab === "mcp"); - } - const convPane = $("#codexConversationsTab"); - if (convPane) { - convPane.hidden = tab !== "conversations"; - convPane.classList.toggle("active", tab === "conversations"); - } - // 切 tab 时把非当前 tab 的 Edit 模式回退 preview - if (tab !== "agents") codexAgentsSwitchMode("preview"); - if (tab !== "memories") codexMemoriesSwitchMode("preview"); - if (tab !== "skills") codexSkillsSwitchMode("preview"); - - // sidebar item active state - $all("#codexSidebar .codex-sidebar-item").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.codexTab === tab); - }); - } - - /** lazy load + 状态 badge 刷新 (sidebar 上各 item 显 ✓ / 数字) */ - async function codexLoadTab(tab) { - if (tab === "skills") { - await codexSkillsReloadPaths(); - await codexSkillsRawLoadAndRender(); - } else if (tab === "agents") { - await codexAgentsReloadPaths(); - await codexAgentsRawLoadAndRender(); - } else if (tab === "memories") { - await codexMemoriesReloadPaths(); - await codexMemoriesRawLoadAndRender(); - } else if (tab === "mcp") { - await codexMcpOpenSubpane(codexMcpCurrentSubpane || "servers"); - } else if (tab === "conversations") { - await codexConversationsLoadAndRender(); - } - await codexRefreshSidebarBadges(); - } - - // ── #271 cas-dropdown 自定义下拉(替代 native - ${escapeHtml(title)} - ${kindCls} - -
    - ${escapeHtml(date)} - · ${escapeHtml(cwdShort)} - · ${s.turnCount} ${t("codex.conv.turns") || "turns"} - ${s.modelProvider ? `· ${escapeHtml(s.modelProvider)}` : ""} -
    - - `; - } - - function codexConvFallbackTitle(s) { - // 没 title 时拿 cwd basename + 短 id 做兜底 - const cwdBase = (s.cwd || "").split("/").pop() || ""; - const shortId = (s.id || "").slice(0, 8); - return cwdBase ? `${cwdBase} (${shortId})` : `Session ${shortId}`; - } - - function codexConvUpdateExportBtn() { - const exportBtn = $("#codexConvExportBtn"); - const deleteBtn = $("#codexConvDeleteBtn"); - const count = conversationsSelected.size; - if (exportBtn) { - exportBtn.disabled = count === 0; - exportBtn.textContent = ""; - const icon = document.createElement("i"); - icon.className = "bi bi-download"; - exportBtn.appendChild(icon); - const lbl = document.createElement("span"); - lbl.textContent = count > 0 - ? tFmt("codex.conv.exportSelectedN", { count }) - : t("codex.conv.exportSelected"); - exportBtn.appendChild(lbl); - } - if (deleteBtn) { - deleteBtn.disabled = count === 0; - deleteBtn.textContent = ""; - const icon = document.createElement("i"); - icon.className = "bi bi-trash"; - deleteBtn.appendChild(icon); - const lbl = document.createElement("span"); - lbl.textContent = count > 0 - ? tFmt("codex.conv.deleteSelectedN", { count }) - : t("codex.conv.deleteSelected"); - deleteBtn.appendChild(lbl); - } - } - - async function codexConversationsOpenDetail(id) { - conversationsActiveId = id; - codexConversationsRenderList(); - const detail = $("#codexConvDetail"); - if (!detail) return; - detail.innerHTML = `

    ${t("codex.conv.loading") || "加载中…"}

    `; - let session; - try { - session = await CCApi.getConversation(id); - } catch (e) { - detail.innerHTML = `

    ${escapeHtml(e.message || String(e))}

    `; - return; - } - detail.innerHTML = codexConversationsDetailHtml(session); - } - - function codexConversationsDetailHtml(session) { - const meta = session.meta || {}; - const headerTitle = meta.title || codexConvFallbackTitle(meta); - let html = `

    ${escapeHtml(headerTitle)}

    -
    -
    ID: ${escapeHtml(meta.id || "")}
    -
    ${escapeHtml(meta.cwd || "")} · ${escapeHtml(meta.originator || "")} · ${escapeHtml(meta.modelProvider || "")}
    -
    `; - for (let i = 0; i < (session.turns || []).length; i += 1) { - const turn = session.turns[i]; - html += `
    Turn ${i + 1}
    `; - for (const item of turn.items || []) { - html += codexConversationsItemDetailHtml(item); - } - html += `
    `; - } - return html; - } - - function codexConversationsItemDetailHtml(item) { - if (!item || !item.type) return ""; - switch (item.type) { - case "User": - case "user": - // 用户输入通常是纯文本,但有的 IDE 会贴 markdown — 都按 md 渲染 - return `
    ${t("codex.conv.roleUser") || "用户"}
    ${renderMiniMd(item.text || "")}
    `; - case "Assistant": - case "assistant": - return `
    ${t("codex.conv.roleAssistant") || "助手"}
    ${renderMiniMd(item.text || "")}
    `; - case "Reasoning": - case "reasoning": - return `
    ${t("codex.conv.reasoning") || "Reasoning"}
    ${renderMiniMd(item.text || "")}
    `; - case "ToolCall": - case "toolCall": - // tool call 是机读 JSON/cmd,保持等宽不渲染 md - return `
    🔧 ${escapeHtml(item.name || "")}
    ${escapeHtml(item.arguments || "")}
    `; - case "ToolOutput": - case "toolOutput": - return `
    ↳ output
    ${escapeHtml(truncateString(item.output || "", 4000))}
    `; - case "Compacted": - case "compacted": - return `
    📦 ${t("codex.conv.compacted") || "Autocompact 切点"}: ${renderMiniMd(item.summary || "")}
    `; - case "System": - case "system": - return `
    [${escapeHtml(item.role || "system")}]
    ${renderMiniMd(item.text || "")}
    `; - default: - return ""; - } - } - - /** - * #271 极简 markdown 渲染(避免外部依赖 + XSS 安全)。 - * - * 支持:fenced code block / inline code / headings (# .. ######) / bold / - * italic / unordered & ordered list / blockquote / link (仅 http(s)) / - * 段落 + 软换行。先 escape HTML,再按 block 状态机渲染,inline 替换在 - * 已 escape 文本上跑。 - */ - function renderMiniMd(input) { - if (!input) return ""; - const src = String(input).replace(/\r\n?/g, "\n"); - // 1. 抽走 fenced code block,placeholder 占位避免 inline rule 污染 - const codeBlocks = []; - let body = src.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g, (_, lang, code) => { - const idx = codeBlocks.push({ lang, code }) - 1; - return `\x00CODEBLOCK${idx}\x00`; - }); - // 2. 行级 + paragraph 渲染 - const lines = body.split("\n"); - const out = []; - let paragraphBuf = []; - let listBuf = []; // {ord: bool, items: []} - const flushParagraph = () => { - if (paragraphBuf.length === 0) return; - const text = paragraphBuf.join("\n"); - out.push(`

    ${applyInlineMd(text)}

    `); - paragraphBuf = []; - }; - const flushList = () => { - if (!listBuf.length) return; - const ord = listBuf._ord; - const tag = ord ? "ol" : "ul"; - out.push(`<${tag}>${listBuf.map((i) => `
  • ${applyInlineMd(i)}
  • `).join("")}`); - listBuf = []; - listBuf._ord = false; - }; - for (const line of lines) { - // placeholder 行 → 直接放 - const phMatch = line.match(/^\x00CODEBLOCK(\d+)\x00$/); - if (phMatch) { - flushParagraph(); - flushList(); - const cb = codeBlocks[Number(phMatch[1])]; - out.push(`
    ${escapeHtml(cb.code)}
    `); - continue; - } - if (/^\s*$/.test(line)) { - flushParagraph(); - flushList(); - continue; - } - // headings (#~######) - const head = line.match(/^(#{1,6})\s+(.*)$/); - if (head) { - flushParagraph(); - flushList(); - const level = head[1].length; - out.push(`${applyInlineMd(head[2])}`); - continue; - } - // unordered / ordered list - const ul = line.match(/^\s*[-*]\s+(.*)$/); - const ol = line.match(/^\s*\d+\.\s+(.*)$/); - if (ul || ol) { - flushParagraph(); - const wantOrd = !!ol; - if (listBuf._ord !== wantOrd && listBuf.length) { - flushList(); - } - listBuf._ord = wantOrd; - listBuf.push((ul || ol)[1]); - continue; - } - // blockquote - const bq = line.match(/^>\s?(.*)$/); - if (bq) { - flushParagraph(); - flushList(); - out.push(`
    ${applyInlineMd(bq[1])}
    `); - continue; - } - // horizontal rule - if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { - flushParagraph(); - flushList(); - out.push("
    "); - continue; - } - // 默认聚合到段落 - flushList(); - paragraphBuf.push(line); - } - flushParagraph(); - flushList(); - return out.join(""); - } - - function applyInlineMd(text) { - // 先 escape HTML,再在 escape 后的文本上跑 inline rule(安全) - let s = escapeHtml(text); - // inline code `code` — 占位防止内部 ** _ 被吃 - const inlineCodes = []; - s = s.replace(/`([^`\n]+)`/g, (_, c) => { - const idx = inlineCodes.push(c) - 1; - return `\x01IC${idx}\x01`; - }); - // links [text](url) — 仅 http(s) - s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, text, url) => { - const safeUrl = url.replace(/"/g, "%22"); - return `${text}`; - }); - // bold **text** (non-greedy 防 `**a** **b**` 折叠成一段) - s = s.replace(/\*\*([^*\n]+?)\*\*/g, "$1"); - // italic *text* / _text_(避开 ** 已处理后剩下的孤立 * + non-greedy - // 让 `*a* and *b*` 渲染成两个 em 而非一段;devin #272 code-reviewer fix) - s = s.replace(/(^|[\s(])\*([^*\n]+?)\*(?=[\s).,!?:;]|$)/g, "$1$2"); - s = s.replace(/(^|[\s(])_([^_\n]+?)_(?=[\s).,!?:;]|$)/g, "$1$2"); - // restore inline code - // **devin #272 review fix**:inlineCodes 里的 content 是从 `escapeHtml(text)` - // 输出的串里捕获的,已经是 escape 后的形态(`<` `&` 等)。再 escape - // 一次会让 `<` 变 `&lt;`,用户看到 literal `<` 而不是 `<`。 - // 直接拼回去即可,不可二次 escape。 - s = s.replace(/\x01IC(\d+)\x01/g, (_, i) => `${inlineCodes[Number(i)]}`); - return s; - } - - function truncateString(s, n) { - if (!s || s.length <= n) return s || ""; - return `${s.slice(0, n)}\n… [前端预览截断,导出文件含完整内容]`; - } - - async function codexConversationsExportSelected() { - if (conversationsSelected.size === 0) return; - const format = casDropdownGetValue($("#codexConvFormat")) || "markdown"; - const ids = Array.from(conversationsSelected); - const isMulti = ids.length > 1; - - // 生成默认文件名 - const tsTag = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - let defaultName; - let extFilter; - if (isMulti) { - defaultName = `codex-conversations-${tsTag}.zip`; - extFilter = { name: "Zip", extensions: ["zip"] }; - } else { - const meta = conversationsCache.find((s) => s.id === ids[0]); - const baseName = meta?.path?.split("/").pop()?.replace(/\.jsonl$/, "") || `session-${ids[0].slice(0, 8)}`; - const ext = format === "markdown" ? "md" : format === "jsonl" ? "jsonl" : "json"; - defaultName = `${baseName}.${ext}`; - extFilter = { name: ext.toUpperCase(), extensions: [ext] }; - } - - // 优先用「默认导出文件夹」(localStorage 持久化的);留空才弹 Tauri dialog.save() - const defaultDir = codexConvLoadDefaultDir(); - let targetPath; - if (defaultDir) { - const sep = defaultDir.endsWith("/") || defaultDir.endsWith("\\") ? "" : "/"; - targetPath = `${defaultDir}${sep}${defaultName}`; - } else { - const dialog = window.__TAURI__?.dialog; - if (!dialog?.save) { - showToast(t("codex.conv.exportFailed") + ": Tauri dialog API 不可用"); - return; - } - try { - targetPath = await dialog.save({ - title: isMulti ? (t("codex.conv.saveDialogMulti") || "保存对话 zip") : (t("codex.conv.saveDialogSingle") || "保存对话文件"), - defaultPath: defaultName, - filters: [extFilter], - }); - } catch (e) { - showToast(t("codex.conv.exportFailed") + ": " + (e.message || e)); - return; - } - if (!targetPath) return; // 用户取消 - } - - try { - const resp = await fetch("/api/conversations/export", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sessionIds: ids, - format, - options: conversationsExportOptions, - targetPath, - }), - }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || `HTTP ${resp.status}`); - } - // devin #272 silent-failure-hunter HIGH-5: 按 Content-Type 分支,backend - // 在传 targetPath 时返 JSON,否则返二进制 body — 之前无脑 .json() 会 - // 把成功的二进制下载误判成"导出失败" - const ct = resp.headers.get("content-type") || ""; - if (ct.includes("application/json")) { - const data = await resp.json(); - showToast(tFmt("codex.conv.toastExportedTo", { count: ids.length, path: data.path })); - } else { - // HTTP body 下载分支(未指定 targetPath)— 浏览器 Content-Disposition 自动落盘 - showToast(tFmt("codex.conv.toastExported", { count: ids.length })); - } - } catch (e) { - showToast(`${t("codex.conv.exportFailed") || "导出失败"}: ${e.message || e}`); - } - } - - // #271 fix #3 — 删除选中(移动到 trash,需要二次确认) - async function codexConversationsDeleteSelected() { - if (conversationsSelected.size === 0) return; - const ids = Array.from(conversationsSelected); - const confirmMsg = tFmt("codex.conv.confirmDelete", { count: ids.length }); - if (!window.confirm(confirmMsg)) return; - try { - const resp = await fetch("/api/conversations/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionIds: ids }), - }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || `HTTP ${resp.status}`); - } - const data = await resp.json(); - const moved = (data.deleted || []).length; - const failedItems = data.failed || []; - const failed = failedItems.length; - conversationsSelected.clear(); - if (failed > 0) { - showToast(tFmt("codex.conv.toastDeletedPartial", { moved, failed })); - // devin #272 silent-failure-hunter MED-7: 暴露失败 reason 给用户而非 - // 只显示计数 — log + 弹 alert 列前 3 条让用户能 actionable - console.warn("cas: delete failures", failedItems); - const sample = failedItems.slice(0, 3) - .map((f) => ` - ${f.sessionId}: ${f.reason}`).join("\n"); - const more = failed > 3 ? `\n ... +${failed - 3} more (see console)` : ""; - window.alert(`${t("codex.conv.deleteFailureDetail") || "部分删除失败"}:\n${sample}${more}`); - } else { - showToast(tFmt("codex.conv.toastDeleted", { count: moved })); - } - await codexConversationsLoadAndRender(); - } catch (e) { - showToast(`${t("codex.conv.deleteFailed") || "删除失败"}: ${e.message || e}`); - } - } - - function codexConversationsOpenOptionsDialog() { - let dialog = $("#codexConvOptionsDialog"); - if (!dialog) { - dialog = document.createElement("dialog"); - dialog.id = "codexConvOptionsDialog"; - dialog.className = "codex-conv-options-dialog"; - document.body.appendChild(dialog); - } - const o = conversationsExportOptions; - dialog.innerHTML = ` -

    ${t("codex.conv.optionsTitle") || "导出选项"}

    - - - - - -
    - - -
    - `; - $("#optCancelBtn").onclick = () => dialog.close(); - $("#optSaveBtn").onclick = () => { - conversationsExportOptions = { - includeReasoning: $("#optInclReasoning").checked, - includeToolCalls: $("#optInclTools").checked, - includeSystemPrompts: $("#optInclSystem").checked, - redactSecrets: $("#optRedact").checked, - toolOutputMaxChars: Math.max(100, Number($("#optToolMax").value) || 2048), - }; - dialog.close(); - showToast(t("codex.conv.optionsSaved") || "选项已保存"); - }; - dialog.showModal(); - } - - // escapeHtml 复用 IIFE 顶部 line 107 的实现 - - /** sidebar badge: 'ON' (managed) / 'OFF' / 数字(skills 数) */ - async function codexRefreshSidebarBadges() { - try { - const [agentsPaths, memPaths, mcpServers, mcpPlugins, skillsPaths, convs] = await Promise.all([ - fetch("/api/codex/agents-md/paths").then((r) => (r.ok ? r.json() : null)), - fetch("/api/codex/memories-md/paths").then((r) => (r.ok ? r.json() : null)), - fetch("/api/codex/mcp/servers").then((r) => (r.ok ? r.json() : null)), - fetch("/api/codex/mcp/plugins").then((r) => (r.ok ? r.json() : null)), - fetch("/api/codex/skills-md/paths").then((r) => (r.ok ? r.json() : null)), - fetch("/api/conversations/list").then((r) => (r.ok ? r.json() : null)), - ]); - const setBadge = (id, text) => { - const el = $(id); - if (el) el.textContent = text; - }; - const agentsCount = agentsPaths?.entries?.length || 0; - const memCount = memPaths?.entries?.length || 0; - const mcpServersCount = mcpServers?.servers?.length || 0; - const mcpPluginsCount = mcpPlugins?.plugins?.length || 0; - const mcpTotal = mcpServersCount + mcpPluginsCount; - const skillsCount = skillsPaths?.entries?.length || 0; - const convCount = convs?.sessions?.length || 0; - setBadge("#codexSidebarBadge-agents", agentsCount > 0 ? String(agentsCount) : "—"); - setBadge("#codexSidebarBadge-memories", memCount > 0 ? String(memCount) : "—"); - setBadge("#codexSidebarBadge-mcp", mcpTotal > 0 ? String(mcpTotal) : "—"); - setBadge("#codexSidebarBadge-skills", skillsCount > 0 ? String(skillsCount) : "—"); - setBadge("#codexSidebarBadge-conversations", convCount > 0 ? String(convCount) : "—"); - } catch (e) { - console.warn("cas: sidebar badges fetch failed", e); - } - } - - // ── #264 Codex Desktop Theme page ───────────────────────────────── - let themeListCache = null; - let selectedThemeId = null; - - /** - * 1:1 crop 弹窗 — user 上传图后用来选 crop 区域。 - * - * UI:全屏暗背景 modal + 中央"舞台"显示原图(等比 fit 进 stage),叠一个 - * 居中的方形 selection box(初始为 stage 短边 × 0.9)。 - * 交互: - * - 拖动:mousedown 在 stage 任意位置即开始(不限 box 内)→ 拖动 box 位置 - * (clamp 到 stage 内) - * - 滚轮缩放:wheel up/down → box 边长 ±5%(min 40px 绝对值 / max stage 短边) - * - 确认:canvas.drawImage 把选区缩到 `min(2048, selectionPixels)` 方形 → - * toDataURL JPEG 92% - * - 取消 / 点遮罩 / 图片 decode 失败:resolve(null) - * - * @param {string} srcDataUri 原图 data:image/...;base64,... - * @returns {Promise} cropped JPEG data URI;null = user 取消 - */ - function openCropModal(srcDataUri) { - return new Promise((resolve) => { - const lang = CCI18n && CCI18n.language === "en" ? "en" : "zh"; - const overlay = document.createElement("div"); - overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.78);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;"; - const panel = document.createElement("div"); - panel.style.cssText = "background:#1a1a1a;border:1px solid #444;border-radius:12px;padding:18px;max-width:90vw;max-height:90vh;display:flex;flex-direction:column;gap:12px;"; - const title = document.createElement("div"); - title.style.cssText = "color:#eee;font-size:15px;font-weight:600;"; - title.textContent = lang === "en" - ? "Crop 1:1 (drag to move, scroll to zoom)" - : "1:1 截取(拖动调整位置,滚轮缩放)"; - const stage = document.createElement("div"); - stage.style.cssText = "position:relative;background:#000;border-radius:6px;overflow:hidden;cursor:move;user-select:none;"; - const img = new Image(); - img.style.cssText = "display:block;max-width:70vw;max-height:65vh;width:auto;height:auto;pointer-events:none;"; - const box = document.createElement("div"); - box.style.cssText = "position:absolute;border:2px solid rgba(255,255,255,0.95);box-shadow:0 0 0 9999px rgba(0,0,0,0.55);box-sizing:border-box;pointer-events:none;"; - stage.appendChild(img); - stage.appendChild(box); - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:10px;"; - const cancelBtn = document.createElement("button"); - cancelBtn.className = "btn btn-outline-secondary btn-sm"; - cancelBtn.type = "button"; - cancelBtn.textContent = lang === "en" ? "Cancel" : "取消"; - const okBtn = document.createElement("button"); - okBtn.className = "btn btn-primary btn-sm"; - okBtn.type = "button"; - okBtn.textContent = lang === "en" ? "Use this crop" : "使用此截取"; - btnRow.appendChild(cancelBtn); - btnRow.appendChild(okBtn); - panel.appendChild(title); - panel.appendChild(stage); - panel.appendChild(btnRow); - overlay.appendChild(panel); - document.body.appendChild(overlay); - - // box 状态(相对 stage 像素 — 显示坐标),img.naturalW/H = 原始像素 - let boxX = 0, boxY = 0, boxSize = 0; - let stageW = 0, stageH = 0; - - function clampBox() { - if (boxSize > Math.min(stageW, stageH)) boxSize = Math.min(stageW, stageH); - if (boxSize < 40) boxSize = 40; - if (boxX < 0) boxX = 0; - if (boxY < 0) boxY = 0; - if (boxX + boxSize > stageW) boxX = stageW - boxSize; - if (boxY + boxSize > stageH) boxY = stageH - boxSize; - } - function applyBox() { - clampBox(); - box.style.left = boxX + "px"; - box.style.top = boxY + "px"; - box.style.width = boxSize + "px"; - box.style.height = boxSize + "px"; - } - - // OK 默认 disabled,等 img.onload 才放行 — 防 0x0 canvas 路径 - okBtn.disabled = true; - okBtn.style.opacity = "0.5"; - img.onload = () => { - stage.style.width = img.offsetWidth + "px"; - stage.style.height = img.offsetHeight + "px"; - stageW = img.offsetWidth; - stageH = img.offsetHeight; - boxSize = Math.min(stageW, stageH) * 0.9; - boxX = (stageW - boxSize) / 2; - boxY = (stageH - boxSize) / 2; - applyBox(); - okBtn.disabled = false; - okBtn.style.opacity = ""; - }; - img.onerror = () => { - showToast(`${t("theme.uploadFailed") || "上传失败"}: ${lang === "en" ? "Image could not be decoded — try a different file" : "图片无法解码,请换一张"}`); - done(null); - }; - img.src = srcDataUri; - - // 拖动 + 滚轮缩放 — listener 显式 remove 在 done() 防 modal 多次打开累积 leak - let dragging = false, dragOX = 0, dragOY = 0; - const onMouseDown = (e) => { - dragging = true; - const r = stage.getBoundingClientRect(); - dragOX = e.clientX - r.left - boxX; - dragOY = e.clientY - r.top - boxY; - e.preventDefault(); - }; - const onMouseMove = (e) => { - if (!dragging) return; - const r = stage.getBoundingClientRect(); - boxX = e.clientX - r.left - dragOX; - boxY = e.clientY - r.top - dragOY; - applyBox(); - }; - const onMouseUp = () => { dragging = false; }; - const onWheel = (e) => { - e.preventDefault(); - const cx = boxX + boxSize / 2; - const cy = boxY + boxSize / 2; - const delta = e.deltaY < 0 ? 1.05 : 0.95; - boxSize = boxSize * delta; - boxX = cx - boxSize / 2; - boxY = cy - boxSize / 2; - applyBox(); - }; - stage.addEventListener("mousedown", onMouseDown); - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - stage.addEventListener("wheel", onWheel, { passive: false }); - - function done(result) { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - overlay.remove(); - resolve(result); - } - cancelBtn.addEventListener("click", () => done(null)); - overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); }); - okBtn.addEventListener("click", () => { - // 显示坐标 → 原图坐标 - const scaleX = img.naturalWidth / stageW; - const scaleY = img.naturalHeight / stageH; - const sx = boxX * scaleX; - const sy = boxY * scaleY; - const ssize = boxSize * scaleX; // 1:1 所以 X/Y scale 相同 - const outSize = Math.min(2048, Math.round(ssize)); // 不放大,只缩(或保持) - const canvas = document.createElement("canvas"); - canvas.width = outSize; - canvas.height = outSize; - const ctx = canvas.getContext("2d"); - ctx.imageSmoothingQuality = "high"; - ctx.drawImage(img, sx, sy, ssize, ssize, 0, 0, outSize, outSize); - const out = canvas.toDataURL("image/jpeg", 0.92); - done(out); - }); - }); - } - - async function renderTheme() { - const container = $("#themeListContainer"); - const toggle = $("#codexUiThemeEnabled"); - const badge = $("#themeStatusBadge"); - if (!container || !toggle) return; - - // 1. 读 settings.codexUiThemeEnabled + codexUiTheme - let settings; - try { - settings = await CCApi.getSettings(); - } catch (e) { - settings = {}; - } - toggle.checked = settings.codexUiThemeEnabled === true; - selectedThemeId = settings.codexUiTheme || null; - const hiddenIds = Array.isArray(settings.themeHiddenIds) ? settings.themeHiddenIds : []; - - // 2. 拉主题列表 — **每次 renderTheme 都重拉**(不缓存):避免 v1 cache-empty - // bug(一旦失败 set 成 [],之后永不重试)+ 主题列表 5-6 项;响应只含 640px - // preview base64(~40KB/张,5-6 张合计 ~250KB),走 webview 本地 IPC 延迟可忽略 - try { - const res = await CCApi.theme.list(); - themeListCache = res.themes || []; - if (themeListCache.length === 0) { - console.warn("[theme] list returned empty:", res); - showToast("主题列表为空 — 检查 backend route 是否注册"); - } - } catch (e) { - themeListCache = []; - console.error("[theme] list failed:", e); - showToast(`${t("theme.loadFailed") || "主题列表加载失败"}: ${e.message}`); - } - const lang = CCI18n && CCI18n.language === "en" ? "en" : "zh"; - - // 3. 渲染主题卡(grid 4 列 + 缩略图)。CSP-compatible data-theme-* 属性, - // 由 bindThemeEvents 中的 #themeListContainer 点击委托处理。 - container.style.display = "grid"; - container.style.gridTemplateColumns = "repeat(4, 1fr)"; - container.style.gap = "14px"; - // 过滤掉已隐藏(themeHiddenIds 内的)— custom 不能被隐藏,只能 delete - const visibleThemes = themeListCache.filter((th) => !hiddenIds.includes(th.id)); - - // 顶部"已隐藏 N" + "恢复"入口(仅 N > 0 显示) - const hiddenBadge = $("#themeHiddenBadge"); - const restoreBtn = $("#themeRestoreHidden"); - const hiddenCount = hiddenIds.length; - if (hiddenBadge && restoreBtn) { - if (hiddenCount > 0) { - hiddenBadge.textContent = lang === "en" - ? `${hiddenCount} hidden` - : `已隐藏 ${hiddenCount} 个`; - hiddenBadge.style.display = ""; - restoreBtn.style.display = ""; - } else { - hiddenBadge.style.display = "none"; - restoreBtn.style.display = "none"; - } - } - const cards = visibleThemes.map((th) => { - const displayName = lang === "en" ? th.displayNameEn : th.displayNameZh; - const checked = th.id === selectedThemeId; - const borderStyle = checked - ? "border:2px solid var(--bs-primary);box-shadow:0 0 0 3px rgba(13,110,253,0.18);" - : "border:1px solid var(--bs-border-color);"; - const checkBadge = checked ? `` : ""; - // data-theme-* 属性上下文:用 escapeHtml(转 & < > " '),旧 inline-onclick 时代的 ' 转义已不适用。 - const idEscaped = escapeHtml(String(th.id)); - const isCustom = th.id === "custom"; - // 右上"替换"小角标 — 仅 custom - const replaceBadge = isCustom - ? `${escapeHtml(lang === "en" ? "Replace" : "替换")}` - : ""; - // 右上 X 删除按钮 — 每张都有。内置 = 隐藏(持久化 themeHiddenIds);custom = 真删 disk。 - const deleteBtn = `×`; - return ` -
    - ${checkBadge} - ${replaceBadge} - ${deleteBtn} - ${escapeHtml(displayName)} -
    -
    ${escapeHtml(displayName)}
    -
    -
    - `; - }); - - // 末尾追加"+ 添加自定义"上传卡(仅当 visible 列表里还没 custom 时显示) - const hasCustom = visibleThemes.some((th) => th.id === "custom"); - if (!hasCustom) { - cards.push(` -
    -
    -
    -
    ${escapeHtml(lang === "en" ? "Add custom" : "添加自定义")}
    -
    -
    - `); - } - container.innerHTML = cards.join(""); - - // 5. 刷新 status badge - // MOC-102:badge 完全由「开关偏好(toggle.checked)+ 后端 status」推导,**永不** - // 把 raw CDP 502 暴露给 user。规则: - // - 开关关:一律"未启用",无视后端 status,不 reapply、不暴露任何失败。 - // - 开关开 + Failed:Codex 当前注入不了(非 transfer 启动 / 调试端口不可用)= - // "待重启生效";不重试(必然又失败)、不暴露 502。每次 render 都如此(持久, - // 不依赖一次性 flag)。 - // - 开关开 + Applied(别的主题)/ Disabled:CDP 可能刚恢复(transfer/Codex 重启) - // → best-effort reapply,成功"已应用",失败降级"待重启生效"(仍不暴露 502)。 - try { - const st = await CCApi.theme.status(); - const sObj = st.status; - // best-effort 重应用:成功→已应用,失败→"待重启生效"(绝不把 raw 502 写进 badge)。 - const reapplyOrPending = async () => { - try { - await CCApi.theme.apply(selectedThemeId); - badge.textContent = `${t("theme.applied") || "已应用"}: ${selectedThemeId}`; - } catch (err) { - console.warn("[theme] auto-re-apply failed (pending restart):", err); - badge.textContent = t("theme.pendingRestart") || "待重启生效"; - } - }; - if (!toggle.checked) { - // 开关关:偏好已禁用 → badge 一律"未启用",不碰运行态、不暴露失败。 - badge.textContent = t("theme.disabled") || "未启用"; - } else if (sObj && typeof sObj === "object") { - if (sObj.Applied) { - badge.textContent = `${t("theme.applied") || "已应用"}: ${sObj.Applied.theme_id}`; - // 选了别的主题但后端报旧 theme_id(切换 race / 重启错位)→ best-effort 重应用。 - if (selectedThemeId && sObj.Applied.theme_id !== selectedThemeId) { - await reapplyOrPending(); - } - } else if (sObj.Failed) { - // 后端上次失败:CDP 可能已恢复(Codex 重启后)→ best-effort 重应用, - // 成功"已应用",失败降级"待重启生效"(绝不暴露 raw 502)。 - if (selectedThemeId) { - await reapplyOrPending(); - } else { - badge.textContent = ""; - } - } else { - badge.textContent = ""; - } - } else if (sObj === "Disabled") { - // 后端 Disabled 但偏好开 + 选了主题:CDP 可能刚恢复 → best-effort 重应用。 - if (selectedThemeId) { - await reapplyOrPending(); - } else { - badge.textContent = t("theme.disabled") || "未启用"; - } - } - } catch (e) { - badge.textContent = ""; - } - } - - // bind toggle + reload/restart + card click(delegation)一次性,避免 renderTheme 反复绑定丢 - let themeEventsBound = false; - // MOC-102:双按钮弹窗(立即重启 / 稍后重启),复用 openCropModal 的 overlay/panel - // 样式。返 Promise<"now" | "later">。**不**自动重启——重启必须是用户在此显式选择 - // 「立即重启」才触发;选「稍后重启」或点遮罩/✕ 关闭则保留偏好、不动 Codex。 - function showThemeRestartDialog() { - return new Promise((resolve) => { - const lang = CCI18n && CCI18n.language === "en" ? "en" : "zh"; - const overlay = document.createElement("div"); - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center;"; - const panel = document.createElement("div"); - panel.style.cssText = - "background:var(--bs-body-bg);border-radius:12px;padding:20px;max-width:440px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,0.4);"; - const title = document.createElement("div"); - title.style.cssText = "font-weight:600;font-size:16px;margin-bottom:10px;"; - title.textContent = t("theme.savedTitle") || (lang === "en" ? "Theme preference saved" : "主题偏好已保存"); - const body = document.createElement("div"); - body.style.cssText = "font-size:13px;color:var(--bs-secondary-color);line-height:1.6;margin-bottom:18px;"; - body.textContent = - t("theme.savedPendingRestart") || - "当前 Codex 未通过本工具启动或调试端口不可用,主题将在 Codex 重启后生效。"; - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:10px;"; - const laterBtn = document.createElement("button"); - laterBtn.className = "btn btn-outline-secondary btn-sm"; - laterBtn.type = "button"; - laterBtn.textContent = t("theme.restartLater") || (lang === "en" ? "Restart later" : "稍后重启"); - const nowBtn = document.createElement("button"); - nowBtn.className = "btn btn-primary btn-sm"; - nowBtn.type = "button"; - nowBtn.textContent = t("theme.restartNow") || (lang === "en" ? "Restart now" : "立即重启"); - btnRow.appendChild(laterBtn); - btnRow.appendChild(nowBtn); - panel.appendChild(title); - panel.appendChild(body); - panel.appendChild(btnRow); - overlay.appendChild(panel); - document.body.appendChild(overlay); - - const done = (choice) => { - overlay.remove(); - resolve(choice); - }; - // 点遮罩空白 / 稍后 = later(默认尊重"稍后",不重启);立即 = now。 - overlay.addEventListener("click", (e) => { - if (e.target === overlay) done("later"); - }); - laterBtn.addEventListener("click", () => done("later")); - nowBtn.addEventListener("click", () => done("now")); - }); - } - - // MOC-102:即时注入失败(CDP 不可达 / Codex 非 transfer 启动)时——偏好已落盘, - // 弹双按钮窗让用户**自己选**立即 / 稍后重启,绝不自动重启。 - // **不**当作开关失败、**不**回退 toggle、**不**报 502 红错。 - async function promptRestartCodexForTheme() { - const choice = await showThemeRestartDialog(); - if (choice !== "now") { - // 稍后重启:偏好已落盘,什么都不动,badge 显示"待重启生效"。 - showToast(t("theme.savedPendingRestartToast") || "主题已保存,Codex 重启后生效"); - return; - } - try { - await CCApi.theme.restartCodex(); - showToast(t("theme.restartToast") || "已请求重启 Codex"); - } catch (e) { - showToast(`${t("theme.restartFailed") || "重启失败"}: ${e.message || e}`); - } - } - - function bindThemeEvents() { - if (themeEventsBound) return; - themeEventsBound = true; - - // toggle = 持久化偏好的「状态标记」,不是即时动作按钮。 - // - // **状态标记语义(MOC-102)**:toggle 表示「下次从 transfer 启动 Codex 时是否 - // 自动注入主题」。两条路径都**先落盘 settings**,再 best-effort 对当前运行中的 - // Codex 即时 apply。开关**不被** Codex 当前运行/注入态反向驱动:CDP 不可达 - // (Codex 非 transfer 启动 / 未带 --remote-debugging-port)时**不**回退 toggle、 - // **不**报失败,落盘后弹「重启 Codex 生效」提示。关闭分支**不**主动跑 CDP clear - // (去掉旧的「clear 先、成功才保存」耦合——那会让 CDP 不可达时连开关状态都存不下、 - // 且属于对运行态的破坏性回滚);已注入的主题保留到 Codex 下次重启自然消失。 - $("#codexUiThemeEnabled")?.addEventListener("change", async (e) => { - if (e.target.checked) { - if (!selectedThemeId) { - showToast(t("theme.pickFirst") || "请先选一个主题再开启"); - e.target.checked = false; - return; - } - // 1) 先落盘偏好(状态标记)—— 不依赖 Codex 运行态。落盘是唯一的"真失败": - // 失败则回退 toggle + 提示,**不**继续注入(避免 toggle/settings desync)。 - let saved = false; - try { - await CCApi.saveSettings({ codexUiThemeEnabled: true, codexUiTheme: selectedThemeId }); - saved = true; - } catch (err) { - e.target.checked = false; - showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`); - } - // 2) best-effort 即时注入:成功即生效;失败不当作开关失败(落盘已成功), - // 提示重启 Codex 生效。 - if (saved) { - try { - await CCApi.theme.apply(selectedThemeId); - showToast(t("theme.appliedToast") || "主题已应用"); - } catch (err) { - console.warn("[theme] enable apply (best-effort) failed:", err); - await promptRestartCodexForTheme(); - } - } - } else { - // 关闭:只落盘 enabled=false(状态标记),不主动对 Codex 跑 CDP clear。 - // 落盘失败 → 回退 toggle + 提示,保持 toggle/settings 一致。 - try { - await CCApi.saveSettings({ codexUiThemeEnabled: false }); - showToast(t("theme.disabledPendingRestart") || "已关闭,主题将在 Codex 重启后移除"); - } catch (err) { - e.target.checked = true; - showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`); - } - } - await renderTheme(); - }); - - - // Restart Codex.app(完全 quit + 重启,走 transfer 已有 endpoint) - $("[data-action=theme-restart-codex]")?.addEventListener("click", async () => { - try { - await CCApi.theme.restartCodex(); - showToast(t("theme.restartToast") || "已请求重启 Codex"); - } catch (err) { - showToast(`${t("theme.restartFailed") || "重启失败"}: ${err.message}`); - } - }); - - // "+ 添加自定义" / "替换" — 全局 fn `window.__themeUploadHandler`。 - // 流程:file picker → FileReader.readAsDataURL → **弹 1:1 crop 弹窗**让 user - // 选 crop 区域 → canvas crop 出方形 JPEG → POST 给后端 → renderTheme + 自动 - // 选中 custom + apply。 - // - // crop 在前端完成(canvas):后端 save_custom_theme 收到已是方形图,只做 - // resize + JPEG encode 不再二次 crop;user 可拖框 + 滚轮 zoom 自由选定。 - window.__themeUploadHandler = async () => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/jpeg,image/png,image/jpg"; - input.style.display = "none"; - document.body.appendChild(input); - input.addEventListener("change", async () => { - const file = input.files && input.files[0]; - input.remove(); - if (!file) return; - if (file.size > 20 * 1024 * 1024) { - showToast(t("theme.uploadTooLarge") || "图片过大(>20MB)"); - return; - } - try { - const srcDataUri = await new Promise((resolve, reject) => { - const r = new FileReader(); - r.onload = () => resolve(r.result); - r.onerror = () => reject(r.error); - r.readAsDataURL(file); - }); - // 弹 1:1 crop modal,user 确认后返 cropped data URI(JPEG) - const croppedDataUri = await openCropModal(srcDataUri); - if (!croppedDataUri) return; // user cancel - await CCApi.theme.uploadCustom(croppedDataUri); - showToast(t("theme.uploadOk") || "自定义主题已保存"); - themeListCache = null; - await renderTheme(); - await window.__themePickHandler("custom"); - } catch (err) { - console.error("[theme] upload failed:", err); - showToast(`${t("theme.uploadFailed") || "上传失败"}: ${err.message || err}`); - } - }); - input.click(); - }; - - // 删除 / 隐藏卡 — 全局 fn `window.__themeDeleteHandler`。内置 = 隐藏(写 - // settings.themeHiddenIds),custom = 真删 disk(API + 切默认主题)。 - window.__themeDeleteHandler = async (themeId, isCustom) => { - const lang2 = CCI18n && CCI18n.language === "en" ? "en" : "zh"; - const confirmMsg = isCustom - ? (lang2 === "en" ? "Delete custom theme image? This cannot be undone." : "确认删除自定义主题图片?此操作不可恢复。") - : (lang2 === "en" ? `Hide theme "${themeId}"? You can restore from the top of the page.` : `隐藏主题"${themeId}"?顶部可"恢复隐藏"。`); - if (!confirm(confirmMsg)) return; - try { - let curSettings; - try { curSettings = await CCApi.getSettings(); } catch { curSettings = {}; } - const curSelected = curSettings.codexUiTheme; - const curEnabled = curSettings.codexUiThemeEnabled === true; - // 共用 fallback 选择器:返 `{ id, unhide }` 二元组。优先找已 visible 的内置; - // 找不到(carton 都被隐藏 + 删 custom)→ 强制选第一个内置 + 把它从 hidden 列表 - // 移除(`unhide=true`),确保 selected card 在 grid 可见。**绝不**返个还在 - // hidden 列表里的 id 给 caller — 那会让 selected 卡在不可见状态。 - const pickFallback = (hiddenList) => { - const visible = themeListCache.find(th => - th.id !== "custom" && th.id !== themeId && !hiddenList.includes(th.id) - ); - if (visible) return { id: visible.id, unhide: null }; - // 全 hidden 的极端 case:挑第一个非 custom 内置,自动 unhide - const anyBuiltin = themeListCache.find(th => th.id !== "custom" && th.id !== themeId); - const id = anyBuiltin ? anyBuiltin.id : "carton"; - return { id, unhide: id }; - }; - if (isCustom) { - await CCApi.theme.deleteCustom(); - if (curSelected === "custom") { - const hidden = Array.isArray(curSettings.themeHiddenIds) ? curSettings.themeHiddenIds.slice() : []; - const fb = pickFallback(hidden); - const patch = { codexUiTheme: fb.id }; - // 极端 case 自动 unhide fallback 保证 selected 在 grid 可见 - if (fb.unhide) { - patch.themeHiddenIds = hidden.filter(id => id !== fb.unhide); - } - await CCApi.saveSettings(patch); - if (curEnabled) { - try { - await CCApi.theme.apply(fb.id); - } catch (e) { - console.error("[theme] post-delete apply failed:", e); - showToast(`${t("theme.applyFailed") || "应用失败"}: ${e.message || e} — 请重启 Codex 看效果`); - } - } - } - } else { - const hidden = Array.isArray(curSettings.themeHiddenIds) ? curSettings.themeHiddenIds.slice() : []; - if (!hidden.includes(themeId)) hidden.push(themeId); - const patch = { themeHiddenIds: hidden }; - if (curSelected === themeId) { - const fb = pickFallback(hidden); - patch.codexUiTheme = fb.id; - if (fb.unhide) { - patch.themeHiddenIds = hidden.filter(id => id !== fb.unhide); - } - if (curEnabled) { - try { - await CCApi.theme.apply(fb.id); - } catch (e) { - console.error("[theme] post-hide apply failed:", e); - showToast(`${t("theme.applyFailed") || "应用失败"}: ${e.message || e} — 请重启 Codex 看效果`); - } - } - } - await CCApi.saveSettings(patch); - } - themeListCache = null; - await renderTheme(); - } catch (err) { - console.error("[theme] delete failed:", err); - showToast(err.message || String(err)); - } - }; - - // 顶部"恢复隐藏" — 清空 themeHiddenIds + 重渲染 - $("#themeRestoreHidden")?.addEventListener("click", async () => { - try { - await CCApi.saveSettings({ themeHiddenIds: [] }); - themeListCache = null; - await renderTheme(); - } catch (err) { - showToast(err.message || String(err)); - } - }); - - // 卡片点击 — CSP-compatible 事件委托:#themeListContainer 上绑一次即可, - // 不需要 inline onclick(后者被 script-src 'self' 拦截)。 - // - // 热更新(#264):toggle 开 + 点卡片 → save settings → apply → 立即切换 - // 主题(IIFE 进来先 remove 旧 style + mascot 再 inject 新的,**不需要** - // reload Codex page;reload 会扰乱当前对话 React state)。 - window.__themePickHandler = async (themeId) => { - console.log("[theme] pick", themeId); - selectedThemeId = themeId; - const enabled = $("#codexUiThemeEnabled")?.checked; - // 状态标记语义(MOC-102):先落盘选择,再 best-effort 即时注入(仅 toggle 已开时)。 - if (enabled) { - let saved = false; - try { - await CCApi.saveSettings({ codexUiThemeEnabled: true, codexUiTheme: themeId }); - saved = true; - } catch (err) { - console.error("[theme] pick save failed:", err); - showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`); - } - if (saved) { - try { - await CCApi.theme.apply(themeId); - showToast(t("theme.appliedToast") || "主题已应用"); - } catch (err) { - console.warn("[theme] pick apply (best-effort) failed:", err); - await promptRestartCodexForTheme(); - } - } - } else { - // toggle 关时,只持久化 user 的选择(不调 apply,toggle 开时再 apply) - try { - await CCApi.saveSettings({ codexUiTheme: themeId }); - } catch (err) { - console.error("[theme] pick save failed:", err); - showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`); - } - } - await renderTheme(); - }; - // MOC-131: CSP-compatible 事件委托,取代 inline onclick。 - const themeContainer = $("#themeListContainer"); - if (themeContainer && !themeContainer.dataset.cspDelegate) { - themeContainer.dataset.cspDelegate = "1"; - themeContainer.addEventListener("click", (evt) => { - const delBtn = evt.target.closest("[data-theme-delete]"); - if (delBtn) { - evt.stopPropagation(); - const tid = delBtn.dataset.themeId; - const isCstm = delBtn.dataset.themeCustom === "true"; - if (tid !== undefined && window.__themeDeleteHandler) { - window.__themeDeleteHandler(tid, isCstm); - } - return; - } - const replaceBtn = evt.target.closest("[data-theme-replace]"); - if (replaceBtn) { - evt.stopPropagation(); - if (window.__themeUploadHandler) window.__themeUploadHandler(); - return; - } - const addCard = evt.target.closest("[data-theme-add]"); - if (addCard) { - evt.stopPropagation(); - if (window.__themeUploadHandler) window.__themeUploadHandler(); - return; - } - const pickCard = evt.target.closest("[data-theme-pick]"); - if (pickCard) { - evt.stopPropagation(); - const tid = pickCard.dataset.themeId; - if (tid && window.__themePickHandler) { - window.__themePickHandler(tid); - } - } - }); - } - } - - async function renderCodexAssets() { - const sidebar = $("#codexSidebar"); - const initialTab = currentCodexTab(); - codexShowTab(initialTab); - await codexLoadTab(initialTab); - - // textarea dirty 标记: user 编辑后 status 重 load 不覆盖 - const ta = $("#codexBlockContent"); - if (ta && !ta.dataset.bound) { - ta.dataset.bound = "1"; - ta.addEventListener("input", () => (ta.dataset.dirty = "1")); - } - - // AGENTS.md 路径 picker:toggle button click + menu item click + outside click - const pathToggle = $("#codexAgentsPathToggle"); - const pathMenu = $("#codexAgentsPathMenu"); - if (pathToggle && !pathToggle.dataset.bound) { - pathToggle.dataset.bound = "1"; - pathToggle.addEventListener("click", (e) => { - e.stopPropagation(); - codexAgentsTogglePicker(); - }); - } - if (pathMenu && !pathMenu.dataset.bound) { - pathMenu.dataset.bound = "1"; - pathMenu.addEventListener("click", (e) => { - const li = e.target.closest(".codex-path-picker-item"); - if (!li || li.getAttribute("aria-disabled") === "true") return; - const hash = li.dataset.hash; - if (hash) codexAgentsSelectHash(hash); - }); - } - if (!document.body.dataset.codexPathPickerOutsideBound) { - document.body.dataset.codexPathPickerOutsideBound = "1"; - document.addEventListener("click", (e) => { - const aPicker = $("#codexAgentsPathPicker"); - if (aPicker && !aPicker.contains(e.target)) codexAgentsClosePicker(); - const mPicker = $("#codexMemoriesPathPicker"); - if (mPicker && !mPicker.contains(e.target)) codexMemoriesClosePicker(); - const sPicker = $("#codexSkillsPathPicker"); - if (sPicker && !sPicker.contains(e.target)) codexSkillsClosePicker(); - }); - } - - // Memories picker - const memToggle = $("#codexMemoriesPathToggle"); - const memMenu = $("#codexMemoriesPathMenu"); - if (memToggle && !memToggle.dataset.bound) { - memToggle.dataset.bound = "1"; - memToggle.addEventListener("click", (e) => { - e.stopPropagation(); - codexMemoriesTogglePicker(); - }); - } - if (memMenu && !memMenu.dataset.bound) { - memMenu.dataset.bound = "1"; - memMenu.addEventListener("click", (e) => { - const li = e.target.closest(".codex-path-picker-item"); - if (!li || li.getAttribute("aria-disabled") === "true") return; - const hash = li.dataset.hash; - if (hash) codexMemoriesSelectHash(hash); - }); - } - - // Skills picker - const skToggle = $("#codexSkillsPathToggle"); - const skMenu = $("#codexSkillsPathMenu"); - if (skToggle && !skToggle.dataset.bound) { - skToggle.dataset.bound = "1"; - skToggle.addEventListener("click", (e) => { - e.stopPropagation(); - codexSkillsTogglePicker(); - }); - } - if (skMenu && !skMenu.dataset.bound) { - skMenu.dataset.bound = "1"; - skMenu.addEventListener("click", (e) => { - const li = e.target.closest(".codex-path-picker-item"); - if (!li || li.getAttribute("aria-disabled") === "true") return; - const hash = li.dataset.hash; - if (hash) codexSkillsSelectHash(hash); - }); - } - - // 添加 modal:Enter 确认,Esc 取消 - const addInput = $("#codexAddPathInput"); - if (addInput && !addInput.dataset.bound) { - addInput.dataset.bound = "1"; - addInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - codexAgentsConfirmPathAdd(); - } else if (e.key === "Escape") { - e.preventDefault(); - codexAgentsClosePathModal(); - } - }); - } - // modal backdrop 点击关闭 - const modalBackdrop = $("#codexAddPathModal"); - if (modalBackdrop && !modalBackdrop.dataset.bound) { - modalBackdrop.dataset.bound = "1"; - modalBackdrop.addEventListener("click", (e) => { - if (e.target === modalBackdrop) codexAgentsClosePathModal(); - }); - } - - // History modal:toggle picker + menu item click + backdrop close + Esc - const histToggle = $("#codexHistoryToggle"); - const histMenu = $("#codexHistoryMenu"); - if (histToggle && !histToggle.dataset.bound) { - histToggle.dataset.bound = "1"; - histToggle.addEventListener("click", (e) => { - e.stopPropagation(); - codexHistoryPickerToggle(); - }); - } - if (histMenu && !histMenu.dataset.bound) { - histMenu.dataset.bound = "1"; - histMenu.addEventListener("click", (e) => { - const li = e.target.closest(".codex-path-picker-item"); - if (!li || li.getAttribute("aria-disabled") === "true") return; - const idx = Number(li.dataset.historyIdx); - if (Number.isFinite(idx)) codexHistorySelect(idx); - }); - } - const histModal = $("#codexHistoryModal"); - if (histModal && !histModal.dataset.bound) { - histModal.dataset.bound = "1"; - histModal.addEventListener("click", (e) => { - if (e.target === histModal) codexHistoryClose(); - }); - } - if (!document.body.dataset.codexHistoryEscBound) { - document.body.dataset.codexHistoryEscBound = "1"; - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - const m = $("#codexHistoryModal"); - if (m && !m.hidden) codexHistoryClose(); - } - }); - } - - // sidebar click → 切 tab + lazy load - if (sidebar && !sidebar.dataset.bound) { - sidebar.dataset.bound = "1"; - sidebar.addEventListener("click", async (evt) => { - const btn = evt.target.closest(".codex-sidebar-item"); - if (!btn) return; - const tab = btn.dataset.codexTab; - if (!tab) return; - if (ta) delete ta.dataset.dirty; - codexShowTab(tab); - await codexLoadTab(tab); - }); - } - - // MCP sub-nav 切换 - const mcpSubnav = $("#codexMcpSubnav"); - if (mcpSubnav && !mcpSubnav.dataset.bound) { - mcpSubnav.dataset.bound = "1"; - mcpSubnav.addEventListener("click", async (evt) => { - const btn = evt.target.closest(".codex-mcp-subnav-item"); - if (!btn) return; - const sub = btn.dataset.mcpSub; - if (!sub) return; - await codexMcpOpenSubpane(sub); - }); - } - - // MCP servers list item click → 选 server - const mcpServersList = $("#codexMcpServersList"); - if (mcpServersList && !mcpServersList.dataset.bound) { - mcpServersList.dataset.bound = "1"; - mcpServersList.addEventListener("click", (evt) => { - const li = evt.target.closest(".codex-mcp-list-item"); - if (!li) return; - const name = li.dataset.server; - if (!name) return; - codexMcpCurrentServerName = name; - codexMcpJsonEditMode = false; - codexMcpJsonDraft = ""; - codexMcpPendingNewName = null; - codexMcpRenderServersList(); - codexMcpRenderForm(); - }); - } - - // MCP marketplace search input - const mcpSearch = $("#codexMcpMarketSearch"); - if (mcpSearch && !mcpSearch.dataset.bound) { - mcpSearch.dataset.bound = "1"; - mcpSearch.addEventListener("input", () => { - codexMcpMarketFilter = mcpSearch.value; - codexMcpRenderMarketIndex(); - }); - } - - // MCP modal backdrop close - const mcpAddSourceModal = $("#codexMcpAddSourceModal"); - if (mcpAddSourceModal && !mcpAddSourceModal.dataset.bound) { - mcpAddSourceModal.dataset.bound = "1"; - mcpAddSourceModal.addEventListener("click", (e) => { - if (e.target === mcpAddSourceModal) codexMcpSourceAddClose(); - }); - } - const mcpDeeplinkModal = $("#codexMcpDeeplinkModal"); - if (mcpDeeplinkModal && !mcpDeeplinkModal.dataset.bound) { - mcpDeeplinkModal.dataset.bound = "1"; - mcpDeeplinkModal.addEventListener("click", (e) => { - if (e.target === mcpDeeplinkModal) codexMcpDeeplinkCancel(); - }); - } - - const mcpNewModal = $("#codexMcpNewServerModal"); - if (mcpNewModal && !mcpNewModal.dataset.bound) { - mcpNewModal.dataset.bound = "1"; - mcpNewModal.addEventListener("click", (e) => { - if (e.target === mcpNewModal) codexMcpServerNewCancel(); - }); - // Enter 直接 confirm - $("#codexMcpNewServerNameInput")?.addEventListener("keydown", (e) => { - if (e.key === "Enter") codexMcpServerNewConfirm(); - if (e.key === "Escape") codexMcpServerNewCancel(); - }); - } - } - - async function fillPreset(presetId) { - if (!presetCache.length) presetCache = await CCApi.getPresets(); - const preset = presetCache.find((item) => item.id === presetId); - if (!preset) return; - editingProviderId = null; - applyPresetToForm(preset); - } - - // ── 用户反馈 modal ─────────────────────────────────────────────── - let feedbackAttachments = []; // [{name, size, file}] - let feedbackBsModal = null; - - function openFeedbackModal() { - const el = $("#feedbackModal"); - if (!el) return; - // 重置表单 - $("#feedbackTitle").value = ""; - $("#feedbackContactEmail").value = ""; - $("#feedbackBody").value = ""; - $("#feedbackIncludeDiagnostics").checked = true; - feedbackAttachments = []; - renderFeedbackAttachments(); - if (!feedbackBsModal) feedbackBsModal = new bootstrap.Modal(el); - feedbackBsModal.show(); - } - - function renderFeedbackAttachments() { - const list = $("#feedbackAttachmentList"); - if (!list) return; - list.innerHTML = feedbackAttachments - .map((a, i) => ``) - .join(""); - list.querySelectorAll("button[data-idx]").forEach((btn) => { - btn.addEventListener("click", () => { - const idx = Number(btn.dataset.idx); - feedbackAttachments.splice(idx, 1); - renderFeedbackAttachments(); - }); - }); - } - - function addFeedbackFiles(files) { - if (!files || !files.length) return; - const max = 5 * 1024 * 1024; - for (const f of files) { - if (f.size > max) { - showToast(tFmt("feedback.tooLargeFile", { name: f.name })); - continue; - } - feedbackAttachments.push({ name: f.name, size: f.size, file: f }); - } - renderFeedbackAttachments(); - } - - function formatBytes(n) { - if (n < 1024) return `${n}B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; - return `${(n / 1024 / 1024).toFixed(2)}MB`; - } - - async function submitFeedback() { - const titleEl = $("#feedbackTitle"); - const contactEmailEl = $("#feedbackContactEmail"); - const bodyEl = $("#feedbackBody"); - const submitBtn = $("#feedbackSubmitBtn"); - if (!bodyEl) return; - - const title = (titleEl?.value || "").trim(); - const contactEmail = (contactEmailEl?.value || "").trim(); - const body = bodyEl.value.trim(); - if (!body) { - showToast(t("feedback.bodyRequired")); - bodyEl.focus(); - return; - } - - submitBtn.disabled = true; - const originalText = submitBtn.textContent; - submitBtn.textContent = t("feedback.submitting"); - - try { - // 把附件转成 base64 嵌进 JSON,避开 pywebview WebKit 对 FormData 的 bug - const attachments = []; - for (const a of feedbackAttachments) { - try { - const b64 = await fileToBase64(a.file); - const isImg = /^image\//.test(a.file.type || ""); - const safeName = String(a.name || `attachment-${Date.now()}.bin`) - .replace(/[\x00-\x1f\\/]/g, "_") - .slice(0, 200); - attachments.push({ - kind: isImg ? "screenshot" : "log", - name: safeName, - content_type: a.file.type || "application/octet-stream", - content_b64: b64, - }); - } catch (innerErr) { - console.warn("[feedback] skipped attachment:", innerErr, a); - } - } - - const payload = { - title, - contact_email: contactEmail, - body, - include_diagnostics: $("#feedbackIncludeDiagnostics").checked, - attachments, - }; - - const result = await CCApi.submitFeedback(payload); - if (feedbackBsModal) feedbackBsModal.hide(); - showToast(tFmt("feedback.successToast", { id: result.id || "" })); - } catch (err) { - console.error("[feedback] submit failed:", err); - let msg = err && err.message ? err.message : String(err); - if (msg.includes("did not match the expected pattern")) { - msg = "请求体构造异常,请重试或去掉附件"; - } - showToast(tFmt("feedback.failToast", { message: msg })); - } finally { - submitBtn.disabled = false; - submitBtn.textContent = originalText; - } - } - - function fileToBase64(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const r = String(reader.result || ""); - const i = r.indexOf(","); - resolve(i >= 0 ? r.slice(i + 1) : r); - }; - reader.onerror = () => reject(reader.error || new Error("FileReader failed")); - reader.readAsDataURL(file); - }); - } - - function bindFeedbackEvents() { - const dropzone = $("#feedbackDropzone"); - const fileInput = $("#feedbackFiles"); - if (dropzone && fileInput) { - dropzone.addEventListener("click", (e) => { - // 不要在点击删除按钮 / 列表项时触发 - if (e.target.closest(".feedback-attachment-item")) return; - fileInput.click(); - }); - fileInput.addEventListener("change", () => { - addFeedbackFiles(Array.from(fileInput.files)); - fileInput.value = ""; - }); - dropzone.addEventListener("dragover", (e) => { - e.preventDefault(); - dropzone.classList.add("dragover"); - }); - dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragover")); - dropzone.addEventListener("drop", (e) => { - e.preventDefault(); - dropzone.classList.remove("dragover"); - addFeedbackFiles(Array.from(e.dataTransfer.files)); - }); - } - document.addEventListener("paste", (e) => { - // 粘贴截图(只有 modal 打开时响应) - const modalEl = $("#feedbackModal"); - if (!modalEl?.classList.contains("show")) return; - const items = e.clipboardData?.items || []; - for (const it of items) { - if (it.kind === "file" && /^image\//.test(it.type)) { - const f = it.getAsFile(); - if (f) addFeedbackFiles([new File([f], f.name || `pasted-${Date.now()}.png`, { type: f.type })]); - } - } - }); - const submitBtn = $("#feedbackSubmitBtn"); - if (submitBtn) submitBtn.addEventListener("click", submitFeedback); - } - - function bindEvents() { - window.addEventListener("hashchange", () => renderRoute(routeFromHash())); - window.addEventListener("cc:i18n", () => renderRoute(routeFromHash())); - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { - if (currentTheme === "dark") applyTheme("dark"); - }); - - document.addEventListener("click", async (event) => { - if (!event.target.closest(".baseurl-input-wrap")) { - closeBaseUrlMenu(); - } - if (!event.target.closest(".provider-model-input-wrap")) { - closeProviderModelMenu(); - } - const langButton = event.target.closest("[data-lang]"); - if (langButton) { - const lang = langButton.dataset.lang; - CCI18n.apply(lang); - // 落盘后端 settings,重启时 getSettings 能读回,避免回退默认语言 (MOC-70) - await CCApi.saveSettings({ language: lang }); - } - const addLink = event.target.closest("a[href='#providers/add']"); - if (addLink) { - editingProviderId = null; - selectedPreset = null; - updatePresetSelection(); - } - const themeButton = event.target.closest("[data-theme-action]"); - if (themeButton) { - const nextTheme = applyTheme(themeButton.dataset.themeAction); - await CCApi.saveSettings({ theme: nextTheme }); - } - const presetButton = event.target.closest("[data-preset]"); - if (presetButton && presetButton.closest("#presetList")) { - event.preventDefault(); - await fillPreset(presetButton.dataset.preset); - return; - } - const presetModelOption = event.target.closest("[data-preset-model-option]"); - if (presetModelOption) { - applyPresetModelOption(presetModelOption.dataset.presetModelOption, presetModelOption.checked); - return; - } - await handleAction(event.target); - }); - - document.addEventListener("change", (event) => { - const mappingInput = event.target.closest("[data-provider-model-input]"); - if (mappingInput) { - updateProviderModelInput(mappingInput.dataset.providerModelInput, mappingInput.value); - renderPresetOptions(selectedPreset, collectProviderMappings()); - } - if (event.target.id === "providerBaseUrl") { - renderBaseUrlOptions(); - } - if (event.target.id === "providerApiFormatSelect") { - updateApiFormatSelectDetail(event.target.value); - formApiFormatValue = event.target.value; - // R1 PR-7:切换 apiFormat 时同步 OAuth / grok_web row 显隐 - setOauthRowState(event.target.value); - setGrokWebRowState(event.target.value); - } - // [MOC-241] Gemini 1M 开关:更新 provider-wide 意图 + 写进 formModelCapabilities(当前映射的 - // gemini 模型)。意图变量保证后续换 model / 重渲染不丢失用户选择。 - if (event.target.id === "providerGemini1m") { - gemini1mOptIn = !!event.target.checked; - applyGemini1mCapabilities(formModelCapabilities, collectProviderMappings()); - } - }); - - // P2.2 OAuth login/logout buttons —— delegate via closest() 防 future 嵌套 - // icon 时 event.target 是 而 .id 为空导致 dead button (silent-failure L1 修) - document.addEventListener("click", (event) => { - if (event.target?.closest?.("#oauthLoginBtn")) { - handleOauthLogin(); - } else if (event.target?.closest?.("#oauthLogoutBtn")) { - handleOauthLogout(); - } - }); - - document.addEventListener("input", (event) => { - if (event.target.id === "providerBaseUrl") { - renderBaseUrlOptions(); - } - const mappingInput = event.target.closest("[data-provider-model-input]"); - if (!mappingInput) return; - updateProviderModelInput(mappingInput.dataset.providerModelInput, mappingInput.value); - }); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - closeBaseUrlMenu(); - closeProviderModelMenu(); - } - }); - - $("#providerForm").addEventListener("submit", async (event) => { - event.preventDefault(); - try { - const wasEditing = !!editingProviderId; - await saveProviderFromForm(); - if (editingProviderId) { - showToast(wasEditing ? t("toast.providerUpdated") : t("toast.providerSaved")); - } else { - showToast(t("toast.providerSaved")); - } - editingProviderId = null; - selectedPreset = null; - window.location.hash = "providers"; - } catch (error) { - console.error(error); - showToast(error.message || t("toast.requestFailed")); - } - }); - - $("#modelProvider")?.addEventListener("change", renderMappingCards); - $("#settingsProxyPort").addEventListener("change", saveSettingsFromForm); - $("#settingsAdminPort").addEventListener("change", saveSettingsFromForm); - $("#settingsUpdateUrl").addEventListener("change", saveSettingsFromForm); - $("#autoApplyOnStart")?.addEventListener("change", saveSettingsFromForm); - $("#autoUnlockCodexPlugins")?.addEventListener("change", onAutoUnlockToggle); - $("#autoWakeCodexPet")?.addEventListener("change", saveSettingsFromForm); - $("#mcpCredentialsPortableStore")?.addEventListener("change", saveSettingsFromForm); - - // Plugin Unlock 按钮事件 - $("[data-action=plugin-unlock-start]")?.addEventListener("click", async () => { - try { - await CCApi.pluginUnlock.start(); - showToast(t("pluginUnlock.started") || "解锁服务已启动"); - setTimeout(refreshPluginUnlockStatus, 1000); - } catch (e) { showToast(e.message); } - }); - $("[data-action=plugin-unlock-stop]")?.addEventListener("click", async () => { - try { - await CCApi.pluginUnlock.stop(); - showToast(t("pluginUnlock.stopped") || "解锁服务已停止"); - setTimeout(refreshPluginUnlockStatus, 500); - } catch (e) { showToast(e.message); } - }); - $("[data-action=plugin-unlock-reinject]")?.addEventListener("click", async () => { - try { - await CCApi.pluginUnlock.reinject(); - showToast(t("pluginUnlock.reinjecting") || "正在重新注入..."); - setTimeout(refreshPluginUnlockStatus, 1500); - } catch (e) { showToast(e.message); } - }); - // MOC-104 真实账号:登录(成功后自动长期保留)/ 导入文件 / 清除 - $("[data-action=real-account-login]")?.addEventListener("click", async () => { - try { - realAccountForgotten = false; // 重新登录 = 重新选择真实账号模式,解除「已清除」抑制 - await CCApi.realAccount.login(); - showToast(t("realAccount.loginStarted") || "已启动登录,请在浏览器完成授权"); - // 轮询到终态;成功后 refreshRealAccountStatus 会自动把账号长期保留。 - pollRealAccountLogin(); - } catch (e) { showToast(e.message); } - }); - // [MOC-104 导入分流] 用 Tauri dialog.open 选文件 —— file input 在 macOS webview 拿不到 - // 绝对路径,而后端要记录"导入源路径"以便 reconcile 从活源跟随刷新,故必须走 dialog。 - // 交互不变(点按钮弹系统文件选择器),只是把"读内容"换成"拿路径传后端、后端读"。 - $("[data-action=real-account-import]")?.addEventListener("click", async () => { - const dialog = window.__TAURI__?.dialog; - if (!dialog || typeof dialog.open !== "function") { - showToast("Tauri dialog API 不可用 — 无法选择导入文件"); - return; - } - try { - const picked = await dialog.open({ - title: t("realAccount.importPickTitle") || "选择 chatgpt 模式 auth.json(导入真实账号)", - multiple: false, - directory: false, - filters: [ - { name: "auth.json", extensions: ["json"] }, - { name: "All files", extensions: ["*"] }, - ], - }); - if (!picked) return; // 用户取消 - const sourcePath = Array.isArray(picked) ? picked[0] : picked; - const resp = await CCApi.realAccount.import(sourcePath); - // 导入**不刷新**;后端按本地 JWT exp 判过期。relogin_required=true → 文件太旧/失效, - // 提示重新导出最新文件或改用登录,而非默默拿过期账号去 401。 - if (resp?.relogin_required === true) { - showToast(t("realAccount.importExpired") || "已导入,但该账号登录态已失效,请重新导出最新文件或改用「登录真实账号」"); - } else { - showToast(t("realAccount.imported") || "已导入并长期保留真实账号"); - } - // 重新导入即视为重新启用该账号,清掉本 session 的「已清除」抑制(review #1)。 - realAccountForgotten = false; - setTimeout(refreshRealAccountStatus, 500); - } catch (e) { showToast(e.message); } - }); - $("[data-action=real-account-forget]")?.addEventListener("click", async () => { - if (!window.confirm(t("realAccount.forgetConfirm") || "清除真实账号?将切回 apikey 模式(Codex 不再显示 Plugins);你的登录态会保留,退出 transfer 时自动恢复。")) return; - try { - const res = await CCApi.realAccount.forget(); - // 抑制本 session 内的 auto-persist 重新生成镜像(review #1):清除后即便 - // login.state 仍是 succeeded、活动仍是 chatgpt,也别把刚删的镜像又 pin 回来。 - realAccountForgotten = true; - realAccountModeEnabled = false; - // [codex P2] 同 toggle off 分支:清除真实账号也清强制 daemon 档(forceUnlockPersisted)+ 停 - // daemon。否则曾 force-enable(autoUnlockCodexPlugins=true)的用户用清除按钮清账号后,checkbox - // 仍 modeOn||force=on、startup 还启 CDP daemon,plugins 仍 force-unlocked(跟确认文案「Codex - // 不再显示 Plugins」矛盾)。 - if (forceUnlockPersisted) { - forceUnlockPersisted = false; - await saveSettingsFromForm(); - try { await CCApi.pluginUnlock.stop(); } catch (_e) {} - } - // [MOC-178] 后端删镜像后 apply 切 apikey;失败(如 proxy 起不来)时如实提示 —— - // 镜像已删但活动仍 chatgpt、toggle 可能没关。500ms 后 refresh 会自纠偏,但先告知。 - if (res && res.switchedToApikey === false) { - showToast(t("realAccount.forgetApplyFailed") || "已清除镜像,但切 apikey 失败 —— Plugins 可能未关,请重试或重启 Codex"); - } else { - showToast(t("realAccount.forgotten") || "已清除真实账号"); - } - setTimeout(refreshRealAccountStatus, 500); - } catch (e) { showToast(e.message); } - }); - // 强制开启:二次确认走 app 自己的 modal(Tauri webview 的 window.confirm 不稳定)。 - $("[data-action=real-account-force-enable]")?.addEventListener("click", () => { - const m = $("#realAccountForceEnableModal"); - if (m) m.hidden = false; - }); - // modal 有两个取消触点(右上角 ✕ + 底部「取消」),都要绑(review #4)。 - $all("[data-action=real-account-force-cancel]").forEach((b) => - b.addEventListener("click", () => { - const m = $("#realAccountForceEnableModal"); - if (m) m.hidden = true; - }) - ); - $("[data-action=real-account-force-confirm]")?.addEventListener("click", async () => { - const m = $("#realAccountForceEnableModal"); - if (m) m.hidden = true; - try { - // 强制档:持久化 autoUnlockCodexPlugins=true + 启 CDP daemon(伪造注入,高延迟)。 - forceUnlockPersisted = true; - await saveSettingsFromForm(); - await CCApi.pluginUnlock.start(); - const toggle = $("#autoUnlockCodexPlugins"); - if (toggle) toggle.checked = true; - showToast(t("realAccount.forceEnabled") || "已强制开启(高延迟)"); - setTimeout(refreshPluginUnlockStatus, 1000); - } catch (e) { showToast(e.message); } - }); - // [MOC-104] 无账号引导弹窗:取消 / 强制开启(转高延迟二次确认) / 登录真实账号。 - $all("[data-action=real-account-noacct-cancel]").forEach((b) => - b.addEventListener("click", () => { - const nm = $("#realAccountNoAccountModal"); - if (nm) nm.hidden = true; - refreshRealAccountStatus(); // 开关派生回 OFF - }) - ); - $("[data-action=real-account-noacct-force]")?.addEventListener("click", () => { - const nm = $("#realAccountNoAccountModal"); - if (nm) nm.hidden = true; - const fm = $("#realAccountForceEnableModal"); // 转高延迟二次确认 - if (fm) fm.hidden = false; - }); - $("[data-action=real-account-noacct-login]")?.addEventListener("click", () => { - const nm = $("#realAccountNoAccountModal"); - if (nm) nm.hidden = true; - $("[data-action=real-account-login]")?.click(); // 复用「登录真实账号」逻辑 - }); - $("#exposeAllProviderModels").addEventListener("change", saveSettingsFromForm); - $("#showGrayProviders")?.addEventListener("change", async () => { - // MOC-91:更新展示过滤缓存 + 持久化。设置页当前不展示 preset,无需即时重渲染; - // 下次进「添加 provider」/ dashboard 时 visiblePresets() 即按新值过滤。 - showGrayPresets = $("#showGrayProviders")?.checked === true; - await saveSettingsFromForm(); - }); - $("#restoreCodexOnExit")?.addEventListener("change", saveSettingsFromForm); - $("#codexNetworkAccess")?.addEventListener("change", saveSettingsFromForm); - // [MOC-204] 额度注入开关:持久化即可,注入 daemon 每 tick 自取。注意: - // daemon 走 CDP,Codex 启动时才决定是否带调试端口(should_attach_debug_port); - // Codex 已无端口运行时开开关不会即时生效,需重启 Codex(hint 已注明)。 - $("#codexQuotaEnabled")?.addEventListener("change", saveSettingsFromForm); - // [MOC-185] 诊断模式开关 = session 级一次性:**纯运行时**起/停查看器服务 + 切按钮可见性, - // **不再 saveSettingsFromForm 持久化开关态**(退出 transfer 即关、启动不自启)。 - // 注意:api() 对后端返回的 `success:false`(含 bind 失败)会 **throw**(api.js:28), - // 所以启动失败走 catch —— 回滚必须放 catch 里(后端 start 失败已同步清运行时 gate)。 - $("#traceViewerEnabled")?.addEventListener("change", async () => { - const on = $("#traceViewerEnabled")?.checked === true; - if ($("#openTraceViewerBtn")) $("#openTraceViewerBtn").hidden = !on; - // 快速 on→off 竞争:若 await(start/stop)期间用户又 toggle 了(当前 checkbox 状态已与本次 - // 捕获的 on 不符),本次是 stale handler → 放弃 start/stop,交给最新那次 change 处理。 - if (($("#traceViewerEnabled")?.checked === true) !== on) return; - try { - if (on) { - const r = await CCApi.traceViewerStart(); - showToast(r?.url ? `诊断查看器已启动 ${r.url}` : "诊断查看器已启动"); - } else { - await CCApi.traceViewerStop(); - showToast("诊断查看器已关闭"); - } - } catch (e) { - if (on) { - // 启动失败(如 18090 被占):回滚开关 + 隐藏按钮,避免 UI 假"on"。 - // 诊断态不持久化(session 级),无需回滚持久化。 - $("#traceViewerEnabled").checked = false; - if ($("#openTraceViewerBtn")) $("#openTraceViewerBtn").hidden = true; - showToast("诊断查看器启动失败" + (e && e.message ? `:${e.message}` : "")); - } else { - showToast("诊断查看器关闭失败"); - } - } - }); - // MOC-144 联网抓取后端: segmented 按钮组(不用原生