diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89b82c8..9b0d4a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,8 @@ jobs: - run: go test -race ./... - run: go vet ./... - run: node --test tests/*.test.cjs + - name: Console sources stay formatted + run: npx --yes prettier@3.7.4 --check internal/bridge/ui/console.js internal/bridge/ui/console.css - name: Native host integration (CPA v8.0.23) env: GH_TOKEN: ${{ github.token }} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..32a2397 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c18b48..5e1a398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- 插件控制台的 `console.js`、`console.css` 改为 Prettier 3.7.4 格式化的可读源码(原为长行手写压缩风格),按原样内嵌;JS 语法树与原文件一致(仅去掉空语句),CSS 仅引号、属性选择器引号和小数前导零写法变化。控制台页面增大约 17 KB,未加入压缩步骤。CI 检查这两个文件的格式;Node 测试改为按唯一标记切片,标记缺失时直接失败。 + ## [0.2.13] - 2026-11-07 ### Added diff --git a/README.md b/README.md index d236164..36dbafd 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ CPAMP 套餐名统一显示 **Go、GOAT、Pro、Max**。月额度与 5h 顶部 原生插件构建需要 **Go ≥1.26、CGO 和目标平台 C 编译器**。发行构建先生成 pinned CPAMP 页面,再内嵌到四个平台的动态库。 +控制台脚本与样式(`internal/bridge/ui/console.js`、`console.css`)以格式化后的源码原样内嵌;修改后运行 `npx prettier@3.7.4 --write internal/bridge/ui/console.js internal/bridge/ui/console.css`(配置见 `.prettierrc.json`),CI 会检查格式。 + GitHub Actions 运行 Go race/vet、前端测试与类型检查、CPA 原生协议及异常恢复联测、CPAMP 页面安装与热更新联测,并校验四平台插件和独立前端包的来源、校验值及内嵌页面一致性。具体步骤与验证边界见以下文档。 | 文档 | 内容 | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 600a6aa..e1f7b8d 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -1,5 +1,14 @@ # 开发验证记录 +## 控制台源码可读化(2026-11-08,未发布源码) + +`internal/bridge/ui/console.js`、`console.css` 原为长行手写压缩风格(JS 266 行、最长 1,709 字符;CSS 16 行、最长 4,024 字符),改为 Prettier 3.7.4 格式化的源码(JS 1,574 行、CSS 1,854 行),配置见 `.prettierrc.json`。 + +- **等价性**:`prettier --debug-check` 通过;另用 acorn 独立比较语法树,除去空语句与属性键引号写法后 JS **完全一致**;postcss 比较 1,428 个 CSS 节点,仅 11 处为引号、属性选择器引号和小数前导零写法差异。 +- **未加压缩步骤**:格式化后两文件共增加 16,842 B,控制台页面约增 17 KB,动态库从 16,232,403 B 增至 16,249,299 B(本机未裁剪符号的构建,约 0.1%)。控制台经 CPA 管理接口在本机或内网提供且不缓存;在四平台构建(含 manylinux 容器)中加入 Node 压缩工具会让发布内容与测试源码不同并增加构建依赖,收益不足以抵消,因此按原样内嵌。 +- **测试**:三个 Node 测试文件改为用 `between()` 按唯一标记切片,标记缺失或重复时直接失败(原先 `indexOf` 返回 -1 时会静默截取错误片段)。Node **31 项**、Go `go test ./internal/... ./cmd/...` 通过;CI 测试任务新增 `prettier --check`。 +- **宿主与浏览器**:新 Windows 动态库在实际 CPA v8.0.23 与 v8.0.59 上 **18/18 通过**(含以 v0.2.12 正式包为前一版本的热升级)。`console_preview.py` 普通场景中登录并切换五个页面,无全局错误,设置页显示首字节/流空闲超时字段;与格式化前的 v0.2.13 构建逐页截图对比(1265×712),设置页 0 像素差异,其余四页差异仅为夹具时间戳和一次登录提示气泡。 + ## v0.2.13 发版记录与真实上游验收(2026-11-08) 用户已确认合并 PR #16 并发版,并提供真实凭证用于验收。下方“未发布源码”一节保留为当轮验证时的状态。本次未部署或访问任何服务器。 diff --git a/internal/bridge/ui/console.css b/internal/bridge/ui/console.css index c2956d3..ff087c5 100644 --- a/internal/bridge/ui/console.css +++ b/internal/bridge/ui/console.css @@ -1,15 +1,1854 @@ /* Studio overview/detail provide the visual direction; see docs/ROUND3_UI_PLAN.md. */ -:root{color-scheme:light;--page:#fff;--surface:#fff;--sidebar:#fafafa;--inset:#f4f4f5;--ink:#09090b;--muted:#6b6b74;--line:#e4e4e7;--control:#71717a;--primary:#18181b;--primary-ink:#fafafa;--accent:#4932ae;--soft:#f1effb;--good:#18794e;--good-soft:#edf7f0;--bad:#b42330;--bad-soft:#fff1f2;--warn:#80570f;--warn-soft:#fff8e8;--sidebar-width:224px;--mono:"Geist Mono","Cascadia Code",Consolas,monospace} -:root[data-theme=dark]{color-scheme:dark;--page:#000;--surface:#09090b;--sidebar:#09090b;--inset:#18181b;--ink:#fafafa;--muted:#a1a1aa;--line:#27272a;--control:#71717a;--primary:#fafafa;--primary-ink:#18181b;--accent:#b4a5ff;--soft:#231d3b;--good:#77c89c;--good-soft:#14251b;--bad:#f4848c;--bad-soft:#2c151a;--warn:#e1b45e;--warn-soft:#2a2213} -*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--ink);font:14px/22px "Geist","Segoe UI Variable Text","Segoe UI","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--control);border-radius:2px;background:var(--surface);color:var(--ink);padding:6px 12px;font-weight:500;min-height:36px}button:hover{background:var(--inset)}button:disabled{opacity:.45;cursor:not-allowed}button.primary{background:var(--primary);color:var(--primary-ink);border-color:var(--primary)}button.primary:hover{opacity:.86}button.quiet{border-color:transparent;background:transparent}button.quiet:hover{background:var(--inset)}button.danger{color:var(--bad)}.error-text,.form-error{color:var(--bad)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}:focus-visible{outline:2px solid var(--accent);outline-offset:3px}input,select,textarea{background:var(--surface);color:var(--ink);border:1px solid var(--control);border-radius:2px;padding:6px 10px;min-width:0;min-height:36px;max-width:100%}input::placeholder,textarea::placeholder{color:var(--muted)}textarea{resize:vertical}input[type=checkbox]{accent-color:var(--accent);width:16px;height:16px;min-height:0;flex-shrink:0}label{display:grid;gap:8px;font-size:14px;margin:16px 0}label.inline{display:flex;align-items:center;margin:0;gap:8px}.block{display:block}.small,.muted{color:var(--muted)}.small{font-size:12px;line-height:18px}.actions,.header-actions,.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}p{margin:0 0 12px}h1{font-size:24px;font-weight:600;letter-spacing:-.6px;line-height:32px;margin:0}h2{font-size:15px;font-weight:600;line-height:22px;margin:0}h3{font-size:14px;font-weight:600;margin:0 0 16px}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:miter;flex-shrink:0}.code{font-family:var(--mono);font-size:12px;line-height:20px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.skip-link{position:fixed;top:-100px;left:16px;z-index:100;background:var(--surface);padding:12px;border:1px solid var(--accent)}.skip-link:focus{top:8px} -.shell{display:grid;grid-template-columns:var(--sidebar-width) minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;display:flex;flex-direction:column;height:100dvh;background:var(--sidebar);border-right:1px solid var(--line);padding:20px 12px 16px;min-width:0}.brand{display:flex;align-items:center;gap:10px;padding:0 8px;color:var(--ink);height:40px}.brand:hover{text-decoration:none}.mark{width:28px;height:28px;display:flex;align-items:center;flex-shrink:0;color:#000}.mark svg{width:100%;height:100%;stroke:none}:root[data-theme=dark] .mark{background:#fff;border-radius:2px}.brand-copy{display:grid;gap:0;white-space:nowrap}.brand strong{font-size:16px;font-weight:600;letter-spacing:-.5px}.brand-copy>span{font-size:12px;color:var(--muted)}.workspace-name{display:flex;align-items:center;gap:8px;margin:24px 0;padding:8px;border:1px solid var(--line);background:var(--surface);font-size:12px;white-space:nowrap}.workspace-avatar{display:grid;place-items:center;width:24px;height:24px;background:var(--inset);font-size:12px;border:1px solid var(--line);flex-shrink:0}nav{display:flex;flex-direction:column;flex:1;min-height:0;gap:4px}nav button{display:flex;align-items:center;gap:12px;width:100%;border:0;background:none;text-align:left;padding:8px 12px;min-height:40px;color:var(--muted);font-weight:400;white-space:nowrap}nav button:hover{color:var(--ink)}nav button.active{background:var(--inset);color:var(--ink);font-weight:500;box-shadow:inset 2px 0 var(--accent)}nav button.active svg{color:var(--accent)}.nav-bottom{margin-top:auto;padding:20px 8px 0;font-size:12px;color:var(--muted)}.nav-bottom a{display:flex;justify-content:space-between;gap:8px;color:var(--muted);margin-bottom:12px}.workspace{min-width:0}.topbar{height:56px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line)}.breadcrumbs{display:flex;align-items:center;gap:12px;min-width:0;white-space:nowrap}.breadcrumb-root,.breadcrumb-separator{color:var(--muted)}button.icon-button{display:grid;place-items:center;padding:8px;width:36px;height:36px;min-height:36px;border:0;background:transparent;color:var(--muted);flex-shrink:0}button.icon-button:hover{color:var(--ink)}.header-actions{flex-wrap:nowrap}main{padding:32px 32px 64px;max-width:1440px;min-width:0;margin-inline:auto;width:100%}.title{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin-bottom:28px}.title .actions{justify-content:flex-end}.page-description{color:var(--muted);margin:8px 0 0;font-size:14px}.sidebar-scrim{display:none}.sidebar-collapsed{--sidebar-width:64px}.sidebar-collapsed .sidebar{padding-inline:8px}.sidebar-collapsed .brand{padding:0 10px}.sidebar-collapsed .brand-copy,.sidebar-collapsed .workspace-name,.sidebar-collapsed nav button>span,.sidebar-collapsed .nav-bottom{display:none}.sidebar-collapsed nav{margin-top:32px}.sidebar-collapsed nav button{padding:10px 14px;justify-content:center} -.panel{background:var(--surface);border:1px solid var(--line);margin:24px 0;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--line);gap:12px;min-height:56px}.panel-body{padding:20px}.panel-footer{display:flex;gap:8px;flex-wrap:wrap;padding:0 20px 20px}.date-range{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.date-range input{max-width:200px}.date-range select{min-width:128px}.summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0 32px;border:1px solid var(--line);background:var(--surface)}.summary>div{padding:20px;border-right:1px solid var(--line);min-width:0}.summary>div:last-child{border:0}.summary>div>span{font-size:12px}.summary b{display:block;font-size:28px;font-weight:500;letter-spacing:-.6px;margin:12px 0 8px;line-height:36px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.summary small{display:block;color:var(--muted);font-size:12px;line-height:18px}.badge{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;font-weight:500;border-radius:2px;padding:3px 7px;background:var(--soft);color:var(--accent);white-space:normal}.badge.good{color:var(--good);background:var(--good-soft)}.badge.bad{color:var(--bad);background:var(--bad-soft)}.badge.warn{color:var(--warn);background:var(--warn-soft)}.badge.neutral{color:var(--muted);background:var(--inset)}.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor;flex-shrink:0}.empty{padding:40px 24px;text-align:center;color:var(--muted)}.empty strong{display:block;color:var(--ink);margin-bottom:8px;font-size:15px;font-weight:500}.empty button{margin-top:16px}table{width:100%;border-collapse:collapse;text-align:left;font-variant-numeric:tabular-nums;font-size:14px}th{color:var(--muted);font-size:12px;font-weight:500;background:var(--sidebar)}th,td{padding:16px;border-bottom:1px solid var(--line);vertical-align:top}tr:last-child td{border-bottom:0}.table-wrap{overflow:auto}#modelStats table{min-width:600px}#modelStats td:not(:first-child),#modelStats th:not(:first-child){text-align:right}td .small{margin-top:4px}tbody tr:hover{background:var(--sidebar)}button.compact{padding:4px 10px;min-height:32px;font-size:12px}.notice{padding:12px 16px;border:1px solid var(--bad);background:var(--bad-soft);color:var(--bad);margin-bottom:20px;overflow-wrap:anywhere}.list-status{padding:12px 20px;margin:0;background:var(--warn-soft);color:var(--warn)}#statisticsPrecision{padding:12px 20px;margin:0;border-bottom:1px solid var(--line)} -.health-layout{display:grid;grid-template-columns:180px minmax(0,1fr)}.health-score{padding:20px;border-right:1px solid var(--line);background:var(--sidebar)}.health-score>span{font-size:12px;color:var(--muted)}.health-ratio{font-size:32px;line-height:40px;font-weight:500;letter-spacing:-1px;margin:12px 0 20px;font-variant-numeric:tabular-nums}.health-ratio small{font-size:16px;color:var(--muted);font-weight:400;letter-spacing:0}.health-bar{height:4px;display:flex;background:var(--line);overflow:hidden;gap:2px}.health-bar span{background:var(--line)}.health-bar .healthy{background:var(--good)}.health-bar .warning,.health-bar .cooldown{background:var(--warn)}.health-bar .invalid{background:var(--bad)}.health-legend{display:flex;flex-wrap:wrap;gap:8px 12px;margin-top:12px;font-size:12px;color:var(--muted)}.health-list{max-height:320px;overflow:auto}.health-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(130px,1fr) auto;align-items:center;gap:16px;padding:16px 20px;border:0;border-bottom:1px solid var(--line);border-radius:0;width:100%;text-align:left;font-weight:400;min-height:88px}.health-row:last-child{border-bottom:0}.health-name{font-size:14px;font-weight:500;overflow-wrap:anywhere}.health-row .small{margin-top:4px}.health-row .badge{justify-self:end}.health-meta{color:var(--muted);font-size:12px} -.diagnostic-panel{margin-bottom:24px}.diagnostic-overview{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 20px}.diagnostic-overview .small{margin-top:6px}.diagnostic-panel>details>summary{padding:12px 20px;border-top:1px solid var(--line);cursor:pointer;color:var(--muted);font-size:12px}.diagnostic-row{padding:16px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.diagnostic-row:last-child{border:0}.diagnostic-row p{margin:8px 0}.diagnostic-row .badge{margin-left:8px}.diagnostic-row .evidence-source{border-left:2px solid var(--line);padding-left:12px;margin-top:8px}.credential-workspace{display:grid;grid-template-columns:180px minmax(0,1fr);gap:24px;align-items:start}.account-selector{display:grid;gap:4px;min-width:0;max-height:480px;overflow:auto}.selector-heading{display:flex;align-items:center;justify-content:space-between;padding:0 8px 12px;font-size:12px;color:var(--muted)}button.account-choice{display:flex;align-items:center;gap:10px;text-align:left;background:transparent;border-color:transparent;min-width:0;padding:12px 8px}button.account-choice:hover,button.account-choice.selected{background:var(--inset)}button.account-choice.selected{box-shadow:inset 2px 0 var(--accent)}.account-choice-copy{display:grid;gap:4px;min-width:0}.account-choice-copy strong{font-size:14px;font-weight:500;overflow-wrap:anywhere}.account-choice-copy small{font-size:12px;color:var(--muted);font-weight:400}.account-choice .status-dot{color:var(--muted)}.account-choice .healthy{color:var(--good)}.account-choice .invalid{color:var(--bad)}.account-choice .warning,.account-choice .cooldown{color:var(--warn)}.account{background:var(--surface);border:1px solid var(--line);min-width:0}.account-top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px;border-bottom:1px solid var(--line)}.account-title{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.account-title strong{font-size:16px;font-weight:500;overflow-wrap:anywhere}.account-top .small{margin-top:8px}.account-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));padding:24px}.balance-cell{grid-column:1/-1;border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:24px;display:flex;align-items:center;justify-content:space-between;gap:24px}.balance{font-size:32px;font-weight:500;letter-spacing:-1px;font-variant-numeric:tabular-nums;line-height:40px;margin:8px 0}.balance small{font-size:12px;color:var(--muted);letter-spacing:0;margin-left:6px}.balance-parts{display:flex;gap:24px}.balance-parts span{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}.balance-parts b{font-size:14px;font-weight:400;font-variant-numeric:tabular-nums}.quota-window{padding:0 16px;min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--line)}.quota-window:nth-child(2){padding-left:0}.quota-window:last-child{border:0;padding-right:0}.meter-label{display:flex;justify-content:space-between;gap:6px;flex-wrap:wrap;font-size:12px;font-weight:500;margin-bottom:12px}.quota-value{font-size:24px;font-weight:500;font-variant-numeric:tabular-nums;margin-bottom:16px;line-height:32px;overflow-wrap:anywhere}.quota-value small{font-size:12px;font-weight:400;color:var(--muted)}.meter{height:4px;background:var(--line);overflow:hidden;margin-top:auto}.meter span{display:block;height:100%;background:var(--accent)}.meter.over span{background:var(--bad)}.quota-note{font-size:12px;color:var(--muted);margin-top:12px;line-height:20px}.account-foot{display:flex;justify-content:space-between;gap:8px 16px;flex-wrap:wrap;font-size:12px;padding:16px 20px;border-top:1px solid var(--line);color:var(--muted)}.account-foot .error-text,.account-foot .usage-warning{width:100%}.usage-warning{color:var(--warn)} -.model-toolbar{display:flex;align-items:end;gap:16px;flex-wrap:wrap;padding:20px;background:var(--surface);border:1px solid var(--line);margin-bottom:16px}.model-toolbar label{margin:0;min-width:160px;flex:1}.model-toolbar .model-search{flex:1.6}#modelRows table{table-layout:fixed}#modelRows th:first-child{width:30%}#modelRows th:nth-child(2){width:13%}#modelRows th:nth-child(3){width:12%}#modelRows th:nth-child(4){width:28%}#modelRows td{overflow-wrap:anywhere}#modelRows td .small{max-width:32em}.model-mobile-list{display:none}.evidence-summary .small{margin-top:6px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px;max-width:780px;margin-bottom:20px}.settings-grid label{margin:0}.setting-note{margin:8px 0 0}.section-actions{margin-top:20px}.advanced-settings{border-block:1px solid var(--line);padding:12px 0;margin:20px 0}.advanced-settings>summary{cursor:pointer}.advanced-settings .settings-grid{margin-top:20px}.status-rows{margin:16px 0 0}.status-rows>div{display:grid;grid-template-columns:140px minmax(0,1fr);gap:16px;padding:12px 0;border-bottom:1px solid var(--line)}.status-rows>div:last-child{border:0}.status-rows dt{color:var(--muted);font-size:12px}.status-rows dd{margin:0;overflow-wrap:anywhere}.path-value{margin-top:8px}.api-panel>summary{cursor:pointer;padding:16px 20px;font-weight:500}.api-example{padding:20px;margin:0;border-top:1px solid var(--line);background:var(--sidebar);font:12px/20px var(--mono)} -.toolbar input{flex:1;min-width:140px}.toolbar{margin:16px 0}.log-toolbar{padding:16px 20px;margin:0;border-bottom:1px solid var(--line)}.log-toolbar input{flex:1}.log-toolbar .small{margin-left:auto}#logs{--log-columns:72px minmax(110px,1.5fr) 80px minmax(100px,1fr) 100px minmax(90px,1fr) 14px}.log-head,.log-main{display:grid;grid-template-columns:var(--log-columns);gap:12px;align-items:center}.log-head{font-size:12px;color:var(--muted);padding:12px 20px;background:var(--sidebar);border-bottom:1px solid var(--line)}.log-main{min-height:96px;padding:16px 20px;font-size:14px;line-height:22px;width:100%;border:0;border-bottom:1px solid var(--line);border-radius:0;text-align:left;font-weight:400}.log-main:hover{background:var(--inset)}.log-main .small{margin-top:4px}.log-cell{min-width:0;overflow-wrap:anywhere}.cell-label{display:none;color:var(--muted);font-size:12px}.log-model{font-weight:500}.log-latency{display:grid;gap:4px}.latency-line{display:flex;justify-content:space-between;gap:6px;font-size:12px;font-variant-numeric:tabular-nums}.latency-line>span{color:var(--muted)}.latency-line strong{font-weight:400;white-space:nowrap}.latency-line.good strong{color:var(--good)}.latency-line.warn strong{color:var(--warn)}.latency-line.bad strong{color:var(--bad)}.chevron{color:var(--muted);font-size:20px}.detail-section{padding:24px 0;border-bottom:1px solid var(--line)}.detail-section:first-child{padding-top:0}.detail-section:last-child{border-bottom:0}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin:0}.detail-grid dt{color:var(--muted);font-size:12px}.detail-grid dd{margin:6px 0 0;font-size:14px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.log-error{margin:16px 0 0;padding:12px;border:1px solid var(--bad);background:var(--bad-soft);color:var(--bad);overflow-wrap:anywhere}.log-error pre{margin:8px 0 0;font:12px/20px var(--mono);max-height:260px;overflow:auto}.pager{display:flex;gap:8px;align-items:center;justify-content:flex-end;padding:16px 20px}.pager>span{margin-right:auto} -dialog{border:1px solid var(--line);border-radius:4px;background:var(--surface);color:var(--ink);padding:24px;width:min(520px,calc(100vw - 32px));max-height:90dvh;overflow:auto;box-shadow:0 16px 70px #0003}dialog::backdrop{background:rgba(0,0,0,.5)}dialog h2{font-size:20px;line-height:28px;font-weight:500;margin-bottom:20px}dialog .actions{justify-content:flex-end;margin-top:24px}dialog label input,dialog label select,dialog textarea{width:100%}dialog label.inline input{width:auto}.dialog-wide{width:min(760px,calc(100vw - 32px))}.catalog-list{max-height:390px;overflow:auto;border:1px solid var(--line);margin-top:16px}.catalog-list label{display:flex;align-items:center;gap:12px;padding:12px 16px;margin:0;border-bottom:1px solid var(--line)}.catalog-list label input{width:auto}.catalog-list label:last-child{border:0}.catalog-list label>span{flex:1;min-width:0}.catalog-list label>span:last-child{flex:0 0 auto}.result{white-space:pre-wrap;max-height:320px;overflow:auto;background:var(--inset);padding:16px;font-size:12px}.toast{position:fixed;bottom:24px;right:24px;max-width:min(420px,calc(100vw - 32px));background:var(--ink);color:var(--page);border-radius:2px;padding:12px 16px;z-index:60;font-size:14px}.form-error{margin-top:12px;white-space:pre-wrap;font-size:14px;min-height:22px}.form-error:empty{margin-top:0;min-height:0}pre{white-space:pre-wrap;word-break:break-word}#confirmText{white-space:pre-wrap}.detail-drawer{position:fixed;inset:0 0 0 auto;margin:0;width:min(480px,100vw);max-width:100vw;height:100dvh;max-height:100dvh;padding:0;border-radius:0;border-width:0 0 0 1px;overflow:hidden}.detail-drawer[open]{display:flex;flex-direction:column}.drawer-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px;border-bottom:1px solid var(--line);flex-shrink:0}.drawer-head>div{min-width:0}.drawer-head h2{overflow-wrap:anywhere;margin:4px 0 0;font-size:20px}.drawer-head p{margin:0}.drawer-body{padding:24px;overflow:auto;overscroll-behavior:contain;flex:1}.drawer-body .actions{justify-content:flex-start}.drawer-body .code{font-size:12px}.evidence-source h3{display:flex;align-items:center;gap:12px;flex-wrap:wrap}[hidden],.view[hidden]{display:none!important} -@media(max-width:1180px){.credential-workspace{grid-template-columns:1fr}.account-selector{display:flex;overflow:auto;gap:8px;padding-bottom:4px}.selector-heading{display:none}button.account-choice{min-width:180px;max-width:260px;flex-shrink:0;border-color:var(--line)}button.account-choice.selected{border-color:var(--accent);background:var(--soft);box-shadow:none}.log-head{display:none}.log-main{grid-template-columns:90px minmax(0,1fr) 95px;gap:16px}.log-cell:nth-child(1){grid-column:1;grid-row:1}.log-cell:nth-child(2){grid-column:2;grid-row:1}.log-cell:nth-child(3){grid-column:3;grid-row:1}.log-cell:nth-child(4){grid-column:1;grid-row:2}.log-cell:nth-child(5){grid-column:2;grid-row:2;max-width:160px}.log-cell:nth-child(6){grid-column:3;grid-row:2}.log-main>.chevron{display:none}.cell-label{display:block;margin-bottom:4px}.summary>div{padding:16px}.summary b{font-size:25px}.topbar{padding-inline:20px}} -@media(max-width:960px){.breadcrumb-root,.breadcrumb-separator{display:none}.title{flex-wrap:wrap;gap:16px}.title .actions{justify-content:flex-start}.summary{grid-template-columns:1fr 1fr}.summary>div:nth-child(2){border-right:0}.summary>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.health-layout{grid-template-columns:1fr}.health-score{border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.health-ratio{margin:8px 0 0;grid-column:1}.health-bar{grid-column:2;grid-row:1;align-self:center}.health-legend{grid-column:2;grid-row:2;align-self:center;margin:0}.health-row{grid-template-columns:minmax(0,1fr) auto}.health-meta{grid-column:1;grid-row:2}.health-row .badge{grid-column:2;grid-row:1/3}.balance-cell{align-items:flex-start;gap:16px}.balance-parts{gap:16px}.settings-grid{grid-template-columns:1fr}#busy{display:none}} -@media(max-width:767px){.shell,.sidebar-collapsed{display:block;--sidebar-width:224px}.sidebar{position:fixed;inset:0 auto 0 0;width:256px;height:100dvh;z-index:50;padding:24px 16px;visibility:hidden;transform:translateX(-100%)}.sidebar-open .sidebar{visibility:visible;transform:none}.sidebar-open .sidebar-scrim{display:block;position:fixed;inset:0;border:0;border-radius:0;background:#0008;z-index:40;padding:0}.sidebar-collapsed .brand-copy,.sidebar-collapsed .workspace-name,.sidebar-collapsed nav button>span,.sidebar-collapsed .nav-bottom{display:revert}.sidebar-collapsed .brand-copy{display:grid}.sidebar-collapsed .workspace-name{display:flex}.sidebar-collapsed nav{margin:0}.sidebar-collapsed nav button{justify-content:flex-start;padding:10px 12px}.topbar{padding:0 12px;height:64px;gap:8px}.breadcrumbs{gap:8px;font-size:12px}.header-actions{gap:4px}.header-actions #authButton{font-size:12px;padding-inline:10px}button,input,select{min-height:44px}button.icon-button{width:44px;height:44px;min-height:44px}button.compact{min-height:44px;font-size:12px}main{padding:24px 16px 40px}.title{margin-bottom:24px}.title .actions{width:100%}.title .actions>button{flex:1;padding-inline:8px}.page-description{font-size:14px}h1{font-size:24px}.panel{margin:20px 0}.panel-head,.panel-body{padding:16px}.panel-head{flex-wrap:wrap}.date-range label{flex:1}.date-range input{width:100%;max-width:none}.summary{margin-block:20px}.summary>div{padding:16px}.summary b{font-size:24px;line-height:32px}.health-row{padding:16px;gap:8px 12px}.health-score{padding:16px}.account-selector{gap:8px}.account-top{padding:16px;flex-wrap:wrap}.account-top>.actions{width:100%;justify-content:flex-start}.account-grid{padding:20px 16px;grid-template-columns:1fr}.balance-cell{flex-wrap:wrap;gap:16px;padding-bottom:20px;margin-bottom:0}.balance-parts{width:100%;justify-content:space-between}.quota-window{padding:20px 0!important;border:0;border-bottom:1px solid var(--line)}.quota-window:last-child{padding-bottom:0!important}.quota-value{font-size:24px;margin-bottom:12px}.meter-label{margin-bottom:8px}.quota-note{margin-top:8px}.account-foot{padding:16px}.diagnostic-overview{padding:16px;align-items:flex-start}.diagnostic-overview>button{flex-shrink:0;font-size:12px;padding-inline:8px}.diagnostic-panel>details>summary{padding:14px 16px;min-height:44px}.panel-footer{padding:0 16px 16px}.model-toolbar{padding:16px;gap:16px;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.model-toolbar label{min-width:0}.model-toolbar label:first-child{grid-column:1/-1}#modelRows table{display:none}.model-mobile-list{display:block}.model-item{padding:16px;border-bottom:1px solid var(--line)}.model-item:last-child{border-bottom:0}.model-item .code{font-size:14px;line-height:22px}.model-item .small{margin-top:6px}.model-item .evidence-summary{margin-top:12px}.model-item>.actions{margin-top:16px}.log-toolbar{padding:16px;gap:8px}.log-toolbar input{flex-basis:100%}.log-toolbar .small{width:100%;margin:4px 0 0}.log-main{padding:16px;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}.log-cell:nth-child(1){grid-column:1;grid-row:1}.log-cell:nth-child(2){grid-column:1/3;grid-row:2}.log-cell:nth-child(3){grid-column:2;grid-row:1;justify-self:end}.log-cell:nth-child(4){grid-column:1;grid-row:3}.log-cell:nth-child(5){grid-column:2;grid-row:3}.log-cell:nth-child(6){grid-column:1/3;grid-row:4}.log-cell:nth-child(2)>.small{display:none}.pager{padding:16px;flex-wrap:wrap}.pager>span{width:100%;margin:0 0 4px}.status-rows>div{grid-template-columns:1fr;gap:6px}.settings-grid{gap:20px}.detail-drawer{width:100vw;border:0}.drawer-head{padding:20px 16px}.drawer-body{padding:20px 16px}.detail-grid{gap:20px 16px}.dialog-wide,dialog{padding:20px 16px;width:calc(100vw - 24px);max-height:calc(100dvh - 24px)}.detail-drawer{width:100vw;max-height:100dvh;padding:0}.catalog-list label{flex-wrap:wrap}.catalog-list label>span:last-child{flex:1 1 100%;padding-left:28px}.toast{right:16px;bottom:16px}.api-example{padding:16px}} -@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +:root { + color-scheme: light; + --page: #fff; + --surface: #fff; + --sidebar: #fafafa; + --inset: #f4f4f5; + --ink: #09090b; + --muted: #6b6b74; + --line: #e4e4e7; + --control: #71717a; + --primary: #18181b; + --primary-ink: #fafafa; + --accent: #4932ae; + --soft: #f1effb; + --good: #18794e; + --good-soft: #edf7f0; + --bad: #b42330; + --bad-soft: #fff1f2; + --warn: #80570f; + --warn-soft: #fff8e8; + --sidebar-width: 224px; + --mono: 'Geist Mono', 'Cascadia Code', Consolas, monospace; +} +:root[data-theme='dark'] { + color-scheme: dark; + --page: #000; + --surface: #09090b; + --sidebar: #09090b; + --inset: #18181b; + --ink: #fafafa; + --muted: #a1a1aa; + --line: #27272a; + --control: #71717a; + --primary: #fafafa; + --primary-ink: #18181b; + --accent: #b4a5ff; + --soft: #231d3b; + --good: #77c89c; + --good-soft: #14251b; + --bad: #f4848c; + --bad-soft: #2c151a; + --warn: #e1b45e; + --warn-soft: #2a2213; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--page); + color: var(--ink); + font: + 14px/22px 'Geist', + 'Segoe UI Variable Text', + 'Segoe UI', + 'Microsoft YaHei', + sans-serif; + -webkit-font-smoothing: antialiased; +} +button, +input, +select, +textarea { + font: inherit; +} +button { + cursor: pointer; + border: 1px solid var(--control); + border-radius: 2px; + background: var(--surface); + color: var(--ink); + padding: 6px 12px; + font-weight: 500; + min-height: 36px; +} +button:hover { + background: var(--inset); +} +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +button.primary { + background: var(--primary); + color: var(--primary-ink); + border-color: var(--primary); +} +button.primary:hover { + opacity: 0.86; +} +button.quiet { + border-color: transparent; + background: transparent; +} +button.quiet:hover { + background: var(--inset); +} +button.danger { + color: var(--bad); +} +.error-text, +.form-error { + color: var(--bad); +} +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} +input, +select, +textarea { + background: var(--surface); + color: var(--ink); + border: 1px solid var(--control); + border-radius: 2px; + padding: 6px 10px; + min-width: 0; + min-height: 36px; + max-width: 100%; +} +input::placeholder, +textarea::placeholder { + color: var(--muted); +} +textarea { + resize: vertical; +} +input[type='checkbox'] { + accent-color: var(--accent); + width: 16px; + height: 16px; + min-height: 0; + flex-shrink: 0; +} +label { + display: grid; + gap: 8px; + font-size: 14px; + margin: 16px 0; +} +label.inline { + display: flex; + align-items: center; + margin: 0; + gap: 8px; +} +.block { + display: block; +} +.small, +.muted { + color: var(--muted); +} +.small { + font-size: 12px; + line-height: 18px; +} +.actions, +.header-actions, +.toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +p { + margin: 0 0 12px; +} +h1 { + font-size: 24px; + font-weight: 600; + letter-spacing: -0.6px; + line-height: 32px; + margin: 0; +} +h2 { + font-size: 15px; + font-weight: 600; + line-height: 22px; + margin: 0; +} +h3 { + font-size: 14px; + font-weight: 600; + margin: 0 0 16px; +} +svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linejoin: miter; + flex-shrink: 0; +} +.code { + font-family: var(--mono); + font-size: 12px; + line-height: 20px; + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} +.skip-link { + position: fixed; + top: -100px; + left: 16px; + z-index: 100; + background: var(--surface); + padding: 12px; + border: 1px solid var(--accent); +} +.skip-link:focus { + top: 8px; +} +.shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 100vh; +} +.sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + height: 100dvh; + background: var(--sidebar); + border-right: 1px solid var(--line); + padding: 20px 12px 16px; + min-width: 0; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px; + color: var(--ink); + height: 40px; +} +.brand:hover { + text-decoration: none; +} +.mark { + width: 28px; + height: 28px; + display: flex; + align-items: center; + flex-shrink: 0; + color: #000; +} +.mark svg { + width: 100%; + height: 100%; + stroke: none; +} +:root[data-theme='dark'] .mark { + background: #fff; + border-radius: 2px; +} +.brand-copy { + display: grid; + gap: 0; + white-space: nowrap; +} +.brand strong { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.5px; +} +.brand-copy > span { + font-size: 12px; + color: var(--muted); +} +.workspace-name { + display: flex; + align-items: center; + gap: 8px; + margin: 24px 0; + padding: 8px; + border: 1px solid var(--line); + background: var(--surface); + font-size: 12px; + white-space: nowrap; +} +.workspace-avatar { + display: grid; + place-items: center; + width: 24px; + height: 24px; + background: var(--inset); + font-size: 12px; + border: 1px solid var(--line); + flex-shrink: 0; +} +nav { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 4px; +} +nav button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + border: 0; + background: none; + text-align: left; + padding: 8px 12px; + min-height: 40px; + color: var(--muted); + font-weight: 400; + white-space: nowrap; +} +nav button:hover { + color: var(--ink); +} +nav button.active { + background: var(--inset); + color: var(--ink); + font-weight: 500; + box-shadow: inset 2px 0 var(--accent); +} +nav button.active svg { + color: var(--accent); +} +.nav-bottom { + margin-top: auto; + padding: 20px 8px 0; + font-size: 12px; + color: var(--muted); +} +.nav-bottom a { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + margin-bottom: 12px; +} +.workspace { + min-width: 0; +} +.topbar { + height: 56px; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line); +} +.breadcrumbs { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + white-space: nowrap; +} +.breadcrumb-root, +.breadcrumb-separator { + color: var(--muted); +} +button.icon-button { + display: grid; + place-items: center; + padding: 8px; + width: 36px; + height: 36px; + min-height: 36px; + border: 0; + background: transparent; + color: var(--muted); + flex-shrink: 0; +} +button.icon-button:hover { + color: var(--ink); +} +.header-actions { + flex-wrap: nowrap; +} +main { + padding: 32px 32px 64px; + max-width: 1440px; + min-width: 0; + margin-inline: auto; + width: 100%; +} +.title { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + margin-bottom: 28px; +} +.title .actions { + justify-content: flex-end; +} +.page-description { + color: var(--muted); + margin: 8px 0 0; + font-size: 14px; +} +.sidebar-scrim { + display: none; +} +.sidebar-collapsed { + --sidebar-width: 64px; +} +.sidebar-collapsed .sidebar { + padding-inline: 8px; +} +.sidebar-collapsed .brand { + padding: 0 10px; +} +.sidebar-collapsed .brand-copy, +.sidebar-collapsed .workspace-name, +.sidebar-collapsed nav button > span, +.sidebar-collapsed .nav-bottom { + display: none; +} +.sidebar-collapsed nav { + margin-top: 32px; +} +.sidebar-collapsed nav button { + padding: 10px 14px; + justify-content: center; +} +.panel { + background: var(--surface); + border: 1px solid var(--line); + margin: 24px 0; + min-width: 0; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--line); + gap: 12px; + min-height: 56px; +} +.panel-body { + padding: 20px; +} +.panel-footer { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding: 0 20px 20px; +} +.date-range { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.date-range input { + max-width: 200px; +} +.date-range select { + min-width: 128px; +} +.summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 24px 0 32px; + border: 1px solid var(--line); + background: var(--surface); +} +.summary > div { + padding: 20px; + border-right: 1px solid var(--line); + min-width: 0; +} +.summary > div:last-child { + border: 0; +} +.summary > div > span { + font-size: 12px; +} +.summary b { + display: block; + font-size: 28px; + font-weight: 500; + letter-spacing: -0.6px; + margin: 12px 0 8px; + line-height: 36px; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.summary small { + display: block; + color: var(--muted); + font-size: 12px; + line-height: 18px; +} +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + line-height: 18px; + font-weight: 500; + border-radius: 2px; + padding: 3px 7px; + background: var(--soft); + color: var(--accent); + white-space: normal; +} +.badge.good { + color: var(--good); + background: var(--good-soft); +} +.badge.bad { + color: var(--bad); + background: var(--bad-soft); +} +.badge.warn { + color: var(--warn); + background: var(--warn-soft); +} +.badge.neutral { + color: var(--muted); + background: var(--inset); +} +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} +.empty { + padding: 40px 24px; + text-align: center; + color: var(--muted); +} +.empty strong { + display: block; + color: var(--ink); + margin-bottom: 8px; + font-size: 15px; + font-weight: 500; +} +.empty button { + margin-top: 16px; +} +table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-variant-numeric: tabular-nums; + font-size: 14px; +} +th { + color: var(--muted); + font-size: 12px; + font-weight: 500; + background: var(--sidebar); +} +th, +td { + padding: 16px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} +tr:last-child td { + border-bottom: 0; +} +.table-wrap { + overflow: auto; +} +#modelStats table { + min-width: 600px; +} +#modelStats td:not(:first-child), +#modelStats th:not(:first-child) { + text-align: right; +} +td .small { + margin-top: 4px; +} +tbody tr:hover { + background: var(--sidebar); +} +button.compact { + padding: 4px 10px; + min-height: 32px; + font-size: 12px; +} +.notice { + padding: 12px 16px; + border: 1px solid var(--bad); + background: var(--bad-soft); + color: var(--bad); + margin-bottom: 20px; + overflow-wrap: anywhere; +} +.list-status { + padding: 12px 20px; + margin: 0; + background: var(--warn-soft); + color: var(--warn); +} +#statisticsPrecision { + padding: 12px 20px; + margin: 0; + border-bottom: 1px solid var(--line); +} +.health-layout { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); +} +.health-score { + padding: 20px; + border-right: 1px solid var(--line); + background: var(--sidebar); +} +.health-score > span { + font-size: 12px; + color: var(--muted); +} +.health-ratio { + font-size: 32px; + line-height: 40px; + font-weight: 500; + letter-spacing: -1px; + margin: 12px 0 20px; + font-variant-numeric: tabular-nums; +} +.health-ratio small { + font-size: 16px; + color: var(--muted); + font-weight: 400; + letter-spacing: 0; +} +.health-bar { + height: 4px; + display: flex; + background: var(--line); + overflow: hidden; + gap: 2px; +} +.health-bar span { + background: var(--line); +} +.health-bar .healthy { + background: var(--good); +} +.health-bar .warning, +.health-bar .cooldown { + background: var(--warn); +} +.health-bar .invalid { + background: var(--bad); +} +.health-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + margin-top: 12px; + font-size: 12px; + color: var(--muted); +} +.health-list { + max-height: 320px; + overflow: auto; +} +.health-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(130px, 1fr) auto; + align-items: center; + gap: 16px; + padding: 16px 20px; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + width: 100%; + text-align: left; + font-weight: 400; + min-height: 88px; +} +.health-row:last-child { + border-bottom: 0; +} +.health-name { + font-size: 14px; + font-weight: 500; + overflow-wrap: anywhere; +} +.health-row .small { + margin-top: 4px; +} +.health-row .badge { + justify-self: end; +} +.health-meta { + color: var(--muted); + font-size: 12px; +} +.diagnostic-panel { + margin-bottom: 24px; +} +.diagnostic-overview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; +} +.diagnostic-overview .small { + margin-top: 6px; +} +.diagnostic-panel > details > summary { + padding: 12px 20px; + border-top: 1px solid var(--line); + cursor: pointer; + color: var(--muted); + font-size: 12px; +} +.diagnostic-row { + padding: 16px 0; + border-bottom: 1px solid var(--line); + overflow-wrap: anywhere; +} +.diagnostic-row:last-child { + border: 0; +} +.diagnostic-row p { + margin: 8px 0; +} +.diagnostic-row .badge { + margin-left: 8px; +} +.diagnostic-row .evidence-source { + border-left: 2px solid var(--line); + padding-left: 12px; + margin-top: 8px; +} +.credential-workspace { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 24px; + align-items: start; +} +.account-selector { + display: grid; + gap: 4px; + min-width: 0; + max-height: 480px; + overflow: auto; +} +.selector-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 12px; + font-size: 12px; + color: var(--muted); +} +button.account-choice { + display: flex; + align-items: center; + gap: 10px; + text-align: left; + background: transparent; + border-color: transparent; + min-width: 0; + padding: 12px 8px; +} +button.account-choice:hover, +button.account-choice.selected { + background: var(--inset); +} +button.account-choice.selected { + box-shadow: inset 2px 0 var(--accent); +} +.account-choice-copy { + display: grid; + gap: 4px; + min-width: 0; +} +.account-choice-copy strong { + font-size: 14px; + font-weight: 500; + overflow-wrap: anywhere; +} +.account-choice-copy small { + font-size: 12px; + color: var(--muted); + font-weight: 400; +} +.account-choice .status-dot { + color: var(--muted); +} +.account-choice .healthy { + color: var(--good); +} +.account-choice .invalid { + color: var(--bad); +} +.account-choice .warning, +.account-choice .cooldown { + color: var(--warn); +} +.account { + background: var(--surface); + border: 1px solid var(--line); + min-width: 0; +} +.account-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px; + border-bottom: 1px solid var(--line); +} +.account-title { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.account-title strong { + font-size: 16px; + font-weight: 500; + overflow-wrap: anywhere; +} +.account-top .small { + margin-top: 8px; +} +.account-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 24px; +} +.balance-cell { + grid-column: 1/-1; + border-bottom: 1px solid var(--line); + padding-bottom: 24px; + margin-bottom: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.balance { + font-size: 32px; + font-weight: 500; + letter-spacing: -1px; + font-variant-numeric: tabular-nums; + line-height: 40px; + margin: 8px 0; +} +.balance small { + font-size: 12px; + color: var(--muted); + letter-spacing: 0; + margin-left: 6px; +} +.balance-parts { + display: flex; + gap: 24px; +} +.balance-parts span { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} +.balance-parts b { + font-size: 14px; + font-weight: 400; + font-variant-numeric: tabular-nums; +} +.quota-window { + padding: 0 16px; + min-width: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); +} +.quota-window:nth-child(2) { + padding-left: 0; +} +.quota-window:last-child { + border: 0; + padding-right: 0; +} +.meter-label { + display: flex; + justify-content: space-between; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + font-weight: 500; + margin-bottom: 12px; +} +.quota-value { + font-size: 24px; + font-weight: 500; + font-variant-numeric: tabular-nums; + margin-bottom: 16px; + line-height: 32px; + overflow-wrap: anywhere; +} +.quota-value small { + font-size: 12px; + font-weight: 400; + color: var(--muted); +} +.meter { + height: 4px; + background: var(--line); + overflow: hidden; + margin-top: auto; +} +.meter span { + display: block; + height: 100%; + background: var(--accent); +} +.meter.over span { + background: var(--bad); +} +.quota-note { + font-size: 12px; + color: var(--muted); + margin-top: 12px; + line-height: 20px; +} +.account-foot { + display: flex; + justify-content: space-between; + gap: 8px 16px; + flex-wrap: wrap; + font-size: 12px; + padding: 16px 20px; + border-top: 1px solid var(--line); + color: var(--muted); +} +.account-foot .error-text, +.account-foot .usage-warning { + width: 100%; +} +.usage-warning { + color: var(--warn); +} +.model-toolbar { + display: flex; + align-items: end; + gap: 16px; + flex-wrap: wrap; + padding: 20px; + background: var(--surface); + border: 1px solid var(--line); + margin-bottom: 16px; +} +.model-toolbar label { + margin: 0; + min-width: 160px; + flex: 1; +} +.model-toolbar .model-search { + flex: 1.6; +} +#modelRows table { + table-layout: fixed; +} +#modelRows th:first-child { + width: 30%; +} +#modelRows th:nth-child(2) { + width: 13%; +} +#modelRows th:nth-child(3) { + width: 12%; +} +#modelRows th:nth-child(4) { + width: 28%; +} +#modelRows td { + overflow-wrap: anywhere; +} +#modelRows td .small { + max-width: 32em; +} +.model-mobile-list { + display: none; +} +.evidence-summary .small { + margin-top: 6px; +} +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px 24px; + max-width: 780px; + margin-bottom: 20px; +} +.settings-grid label { + margin: 0; +} +.setting-note { + margin: 8px 0 0; +} +.section-actions { + margin-top: 20px; +} +.advanced-settings { + border-block: 1px solid var(--line); + padding: 12px 0; + margin: 20px 0; +} +.advanced-settings > summary { + cursor: pointer; +} +.advanced-settings .settings-grid { + margin-top: 20px; +} +.status-rows { + margin: 16px 0 0; +} +.status-rows > div { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} +.status-rows > div:last-child { + border: 0; +} +.status-rows dt { + color: var(--muted); + font-size: 12px; +} +.status-rows dd { + margin: 0; + overflow-wrap: anywhere; +} +.path-value { + margin-top: 8px; +} +.api-panel > summary { + cursor: pointer; + padding: 16px 20px; + font-weight: 500; +} +.api-example { + padding: 20px; + margin: 0; + border-top: 1px solid var(--line); + background: var(--sidebar); + font: 12px/20px var(--mono); +} +.toolbar input { + flex: 1; + min-width: 140px; +} +.toolbar { + margin: 16px 0; +} +.log-toolbar { + padding: 16px 20px; + margin: 0; + border-bottom: 1px solid var(--line); +} +.log-toolbar input { + flex: 1; +} +.log-toolbar .small { + margin-left: auto; +} +#logs { + --log-columns: 72px minmax(110px, 1.5fr) 80px minmax(100px, 1fr) 100px minmax(90px, 1fr) 14px; +} +.log-head, +.log-main { + display: grid; + grid-template-columns: var(--log-columns); + gap: 12px; + align-items: center; +} +.log-head { + font-size: 12px; + color: var(--muted); + padding: 12px 20px; + background: var(--sidebar); + border-bottom: 1px solid var(--line); +} +.log-main { + min-height: 96px; + padding: 16px 20px; + font-size: 14px; + line-height: 22px; + width: 100%; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + text-align: left; + font-weight: 400; +} +.log-main:hover { + background: var(--inset); +} +.log-main .small { + margin-top: 4px; +} +.log-cell { + min-width: 0; + overflow-wrap: anywhere; +} +.cell-label { + display: none; + color: var(--muted); + font-size: 12px; +} +.log-model { + font-weight: 500; +} +.log-latency { + display: grid; + gap: 4px; +} +.latency-line { + display: flex; + justify-content: space-between; + gap: 6px; + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.latency-line > span { + color: var(--muted); +} +.latency-line strong { + font-weight: 400; + white-space: nowrap; +} +.latency-line.good strong { + color: var(--good); +} +.latency-line.warn strong { + color: var(--warn); +} +.latency-line.bad strong { + color: var(--bad); +} +.chevron { + color: var(--muted); + font-size: 20px; +} +.detail-section { + padding: 24px 0; + border-bottom: 1px solid var(--line); +} +.detail-section:first-child { + padding-top: 0; +} +.detail-section:last-child { + border-bottom: 0; +} +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + margin: 0; +} +.detail-grid dt { + color: var(--muted); + font-size: 12px; +} +.detail-grid dd { + margin: 6px 0 0; + font-size: 14px; + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} +.log-error { + margin: 16px 0 0; + padding: 12px; + border: 1px solid var(--bad); + background: var(--bad-soft); + color: var(--bad); + overflow-wrap: anywhere; +} +.log-error pre { + margin: 8px 0 0; + font: 12px/20px var(--mono); + max-height: 260px; + overflow: auto; +} +.pager { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + padding: 16px 20px; +} +.pager > span { + margin-right: auto; +} +dialog { + border: 1px solid var(--line); + border-radius: 4px; + background: var(--surface); + color: var(--ink); + padding: 24px; + width: min(520px, calc(100vw - 32px)); + max-height: 90dvh; + overflow: auto; + box-shadow: 0 16px 70px #0003; +} +dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} +dialog h2 { + font-size: 20px; + line-height: 28px; + font-weight: 500; + margin-bottom: 20px; +} +dialog .actions { + justify-content: flex-end; + margin-top: 24px; +} +dialog label input, +dialog label select, +dialog textarea { + width: 100%; +} +dialog label.inline input { + width: auto; +} +.dialog-wide { + width: min(760px, calc(100vw - 32px)); +} +.catalog-list { + max-height: 390px; + overflow: auto; + border: 1px solid var(--line); + margin-top: 16px; +} +.catalog-list label { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin: 0; + border-bottom: 1px solid var(--line); +} +.catalog-list label input { + width: auto; +} +.catalog-list label:last-child { + border: 0; +} +.catalog-list label > span { + flex: 1; + min-width: 0; +} +.catalog-list label > span:last-child { + flex: 0 0 auto; +} +.result { + white-space: pre-wrap; + max-height: 320px; + overflow: auto; + background: var(--inset); + padding: 16px; + font-size: 12px; +} +.toast { + position: fixed; + bottom: 24px; + right: 24px; + max-width: min(420px, calc(100vw - 32px)); + background: var(--ink); + color: var(--page); + border-radius: 2px; + padding: 12px 16px; + z-index: 60; + font-size: 14px; +} +.form-error { + margin-top: 12px; + white-space: pre-wrap; + font-size: 14px; + min-height: 22px; +} +.form-error:empty { + margin-top: 0; + min-height: 0; +} +pre { + white-space: pre-wrap; + word-break: break-word; +} +#confirmText { + white-space: pre-wrap; +} +.detail-drawer { + position: fixed; + inset: 0 0 0 auto; + margin: 0; + width: min(480px, 100vw); + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + padding: 0; + border-radius: 0; + border-width: 0 0 0 1px; + overflow: hidden; +} +.detail-drawer[open] { + display: flex; + flex-direction: column; +} +.drawer-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 24px; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} +.drawer-head > div { + min-width: 0; +} +.drawer-head h2 { + overflow-wrap: anywhere; + margin: 4px 0 0; + font-size: 20px; +} +.drawer-head p { + margin: 0; +} +.drawer-body { + padding: 24px; + overflow: auto; + overscroll-behavior: contain; + flex: 1; +} +.drawer-body .actions { + justify-content: flex-start; +} +.drawer-body .code { + font-size: 12px; +} +.evidence-source h3 { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +[hidden], +.view[hidden] { + display: none !important; +} +@media (max-width: 1180px) { + .credential-workspace { + grid-template-columns: 1fr; + } + .account-selector { + display: flex; + overflow: auto; + gap: 8px; + padding-bottom: 4px; + } + .selector-heading { + display: none; + } + button.account-choice { + min-width: 180px; + max-width: 260px; + flex-shrink: 0; + border-color: var(--line); + } + button.account-choice.selected { + border-color: var(--accent); + background: var(--soft); + box-shadow: none; + } + .log-head { + display: none; + } + .log-main { + grid-template-columns: 90px minmax(0, 1fr) 95px; + gap: 16px; + } + .log-cell:nth-child(1) { + grid-column: 1; + grid-row: 1; + } + .log-cell:nth-child(2) { + grid-column: 2; + grid-row: 1; + } + .log-cell:nth-child(3) { + grid-column: 3; + grid-row: 1; + } + .log-cell:nth-child(4) { + grid-column: 1; + grid-row: 2; + } + .log-cell:nth-child(5) { + grid-column: 2; + grid-row: 2; + max-width: 160px; + } + .log-cell:nth-child(6) { + grid-column: 3; + grid-row: 2; + } + .log-main > .chevron { + display: none; + } + .cell-label { + display: block; + margin-bottom: 4px; + } + .summary > div { + padding: 16px; + } + .summary b { + font-size: 25px; + } + .topbar { + padding-inline: 20px; + } +} +@media (max-width: 960px) { + .breadcrumb-root, + .breadcrumb-separator { + display: none; + } + .title { + flex-wrap: wrap; + gap: 16px; + } + .title .actions { + justify-content: flex-start; + } + .summary { + grid-template-columns: 1fr 1fr; + } + .summary > div:nth-child(2) { + border-right: 0; + } + .summary > div:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + .health-layout { + grid-template-columns: 1fr; + } + .health-score { + border-right: 0; + border-bottom: 1px solid var(--line); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 20px; + } + .health-ratio { + margin: 8px 0 0; + grid-column: 1; + } + .health-bar { + grid-column: 2; + grid-row: 1; + align-self: center; + } + .health-legend { + grid-column: 2; + grid-row: 2; + align-self: center; + margin: 0; + } + .health-row { + grid-template-columns: minmax(0, 1fr) auto; + } + .health-meta { + grid-column: 1; + grid-row: 2; + } + .health-row .badge { + grid-column: 2; + grid-row: 1/3; + } + .balance-cell { + align-items: flex-start; + gap: 16px; + } + .balance-parts { + gap: 16px; + } + .settings-grid { + grid-template-columns: 1fr; + } + #busy { + display: none; + } +} +@media (max-width: 767px) { + .shell, + .sidebar-collapsed { + display: block; + --sidebar-width: 224px; + } + .sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 256px; + height: 100dvh; + z-index: 50; + padding: 24px 16px; + visibility: hidden; + transform: translateX(-100%); + } + .sidebar-open .sidebar { + visibility: visible; + transform: none; + } + .sidebar-open .sidebar-scrim { + display: block; + position: fixed; + inset: 0; + border: 0; + border-radius: 0; + background: #0008; + z-index: 40; + padding: 0; + } + .sidebar-collapsed .brand-copy, + .sidebar-collapsed .workspace-name, + .sidebar-collapsed nav button > span, + .sidebar-collapsed .nav-bottom { + display: revert; + } + .sidebar-collapsed .brand-copy { + display: grid; + } + .sidebar-collapsed .workspace-name { + display: flex; + } + .sidebar-collapsed nav { + margin: 0; + } + .sidebar-collapsed nav button { + justify-content: flex-start; + padding: 10px 12px; + } + .topbar { + padding: 0 12px; + height: 64px; + gap: 8px; + } + .breadcrumbs { + gap: 8px; + font-size: 12px; + } + .header-actions { + gap: 4px; + } + .header-actions #authButton { + font-size: 12px; + padding-inline: 10px; + } + button, + input, + select { + min-height: 44px; + } + button.icon-button { + width: 44px; + height: 44px; + min-height: 44px; + } + button.compact { + min-height: 44px; + font-size: 12px; + } + main { + padding: 24px 16px 40px; + } + .title { + margin-bottom: 24px; + } + .title .actions { + width: 100%; + } + .title .actions > button { + flex: 1; + padding-inline: 8px; + } + .page-description { + font-size: 14px; + } + h1 { + font-size: 24px; + } + .panel { + margin: 20px 0; + } + .panel-head, + .panel-body { + padding: 16px; + } + .panel-head { + flex-wrap: wrap; + } + .date-range label { + flex: 1; + } + .date-range input { + width: 100%; + max-width: none; + } + .summary { + margin-block: 20px; + } + .summary > div { + padding: 16px; + } + .summary b { + font-size: 24px; + line-height: 32px; + } + .health-row { + padding: 16px; + gap: 8px 12px; + } + .health-score { + padding: 16px; + } + .account-selector { + gap: 8px; + } + .account-top { + padding: 16px; + flex-wrap: wrap; + } + .account-top > .actions { + width: 100%; + justify-content: flex-start; + } + .account-grid { + padding: 20px 16px; + grid-template-columns: 1fr; + } + .balance-cell { + flex-wrap: wrap; + gap: 16px; + padding-bottom: 20px; + margin-bottom: 0; + } + .balance-parts { + width: 100%; + justify-content: space-between; + } + .quota-window { + padding: 20px 0 !important; + border: 0; + border-bottom: 1px solid var(--line); + } + .quota-window:last-child { + padding-bottom: 0 !important; + } + .quota-value { + font-size: 24px; + margin-bottom: 12px; + } + .meter-label { + margin-bottom: 8px; + } + .quota-note { + margin-top: 8px; + } + .account-foot { + padding: 16px; + } + .diagnostic-overview { + padding: 16px; + align-items: flex-start; + } + .diagnostic-overview > button { + flex-shrink: 0; + font-size: 12px; + padding-inline: 8px; + } + .diagnostic-panel > details > summary { + padding: 14px 16px; + min-height: 44px; + } + .panel-footer { + padding: 0 16px 16px; + } + .model-toolbar { + padding: 16px; + gap: 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + .model-toolbar label { + min-width: 0; + } + .model-toolbar label:first-child { + grid-column: 1/-1; + } + #modelRows table { + display: none; + } + .model-mobile-list { + display: block; + } + .model-item { + padding: 16px; + border-bottom: 1px solid var(--line); + } + .model-item:last-child { + border-bottom: 0; + } + .model-item .code { + font-size: 14px; + line-height: 22px; + } + .model-item .small { + margin-top: 6px; + } + .model-item .evidence-summary { + margin-top: 12px; + } + .model-item > .actions { + margin-top: 16px; + } + .log-toolbar { + padding: 16px; + gap: 8px; + } + .log-toolbar input { + flex-basis: 100%; + } + .log-toolbar .small { + width: 100%; + margin: 4px 0 0; + } + .log-main { + padding: 16px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + } + .log-cell:nth-child(1) { + grid-column: 1; + grid-row: 1; + } + .log-cell:nth-child(2) { + grid-column: 1/3; + grid-row: 2; + } + .log-cell:nth-child(3) { + grid-column: 2; + grid-row: 1; + justify-self: end; + } + .log-cell:nth-child(4) { + grid-column: 1; + grid-row: 3; + } + .log-cell:nth-child(5) { + grid-column: 2; + grid-row: 3; + } + .log-cell:nth-child(6) { + grid-column: 1/3; + grid-row: 4; + } + .log-cell:nth-child(2) > .small { + display: none; + } + .pager { + padding: 16px; + flex-wrap: wrap; + } + .pager > span { + width: 100%; + margin: 0 0 4px; + } + .status-rows > div { + grid-template-columns: 1fr; + gap: 6px; + } + .settings-grid { + gap: 20px; + } + .detail-drawer { + width: 100vw; + border: 0; + } + .drawer-head { + padding: 20px 16px; + } + .drawer-body { + padding: 20px 16px; + } + .detail-grid { + gap: 20px 16px; + } + .dialog-wide, + dialog { + padding: 20px 16px; + width: calc(100vw - 24px); + max-height: calc(100dvh - 24px); + } + .detail-drawer { + width: 100vw; + max-height: 100dvh; + padding: 0; + } + .catalog-list label { + flex-wrap: wrap; + } + .catalog-list label > span:last-child { + flex: 1 1 100%; + padding-left: 28px; + } + .toast { + right: 16px; + bottom: 16px; + } + .api-example { + padding: 16px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} diff --git a/internal/bridge/ui/console.js b/internal/bridge/ui/console.js index 7b91f22..392c372 100644 --- a/internal/bridge/ui/console.js +++ b/internal/bridge/ui/console.js @@ -1,265 +1,1574 @@ 'use strict'; -const $=id=>document.getElementById(id), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const state={view:'overview',config:null,credentials:[],activeCredential:null,catalog:[],selected:new Set(),offset:0,total:0,key:'',authType:'bearer',authenticated:false,blocked:false,busy:0}; -const API_BASE=document.querySelector('meta[name="cpa-api-base"]').content; -const compactNumber=v=>v==null?'—':Number(v).toLocaleString('en',{notation:'compact',maximumFractionDigits:1}); -const shortWhen=v=>v?new Date(v).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}):'未知'; -const fmt=(v,d=0)=>v===null||v===undefined?'—':Number(v).toLocaleString('zh-CN',{maximumFractionDigits:d}), when=v=>v?new Date(v).toLocaleString('zh-CN'):'未知', percent=(a,b)=>a!=null&&b>0?Math.max(0,Math.min(100,a/b*100)):null; -function toast(text){$('toast').textContent=text;$('toast').hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').hidden=true,3500)} -function globalError(e){$('globalError').textContent=e.message||String(e);$('globalError').hidden=false} -function dialogError(id,e){$(id).querySelector('.form-error').textContent=e.message||String(e)} -const dialogOrigins=new Map(); -function openDialog(id){const dialog=$(id);dialog.querySelectorAll('.form-error').forEach(x=>x.textContent='');if(!dialog.open){const node=document.activeElement;dialogOrigins.set(id,{node,data:{...node?.dataset},view:state.view});dialog.showModal()}} -function restoreDialogFocus(id){const origin=dialogOrigins.get(id);dialogOrigins.delete(id);if(!origin||document.querySelector('dialog[open]'))return;if(origin.node?.isConnected&&(!origin.node.getClientRects||origin.node.getClientRects().length)&&!origin.node.closest?.('dialog:not([open])')){origin.node.focus();return}for(const key of ['logDetail','modelDetail','modelEdit','modelTest','accountEdit','accountSelect']){if(origin.data[key]!==undefined){const node=[...document.querySelectorAll('button')].find(x=>x.dataset[key]===origin.data[key]&&(!x.getClientRects||x.getClientRects().length)&&!x.closest?.('dialog:not([open])'));if(node){node.focus();return}}}const modelIndex=origin.data.modelEdit??origin.data.modelTest;const modelButton=[...document.querySelectorAll('[data-model-detail]')].find(x=>x.dataset.modelDetail===modelIndex&&x.getClientRects().length);if(modelButton){modelButton.focus();return}if(origin.view===state.view)$('mainContent').focus()} -function showDetail(title,subtitle,html){$('detailTitle').textContent=title;$('detailSubtitle').textContent=subtitle;$('detailContent').innerHTML=html;openDialog('detailDialog');$('detailTitle').focus()} -function statusRows(rows){return '
'+rows.filter(([,value])=>value!==undefined&&value!==null&&value!=='').map(([label,value])=>`
${esc(label)}
${esc(value)}
`).join('')+'
'} -function apiURL(path){return new URL(API_BASE+path,location.origin).href} -async function api(path,options={}){if(state.blocked&&!options.verify)throw new Error('管理访问已暂停,请重新验证登录');const headers={...(window.CommandCodeBridgeAuthHeaders?.()||{}),...options.headers};if(state.key){delete headers.Authorization;delete headers['X-Management-Key'];headers[state.authType==='x-management-key'?'X-Management-Key':'Authorization']=state.authType==='x-management-key'?state.key:'Bearer '+state.key}if(options.body!==undefined)headers['Content-Type']='application/json';state.busy++;$('busy').textContent='正在更新';try{const response=await fetch(apiURL(path),{method:options.method||'GET',headers,body:options.body===undefined?undefined:JSON.stringify(options.body),signal:AbortSignal.timeout(options.timeout||650000)});const text=await response.text();let data;try{data=JSON.parse(text)}catch{data={error:text.slice(0,200)}}if(!response.ok){if((response.status===401||response.status===403)&&path!=='/models/test'){state.authenticated=false;state.blocked=true;resetDiagnosticViews();if(response.status===401){window.CommandCodeBridgeClearAuth?.(headers);state.key=''}openDialog('authDialog')}throw new Error(data.error||`HTTP ${response.status}`)}return data}finally{state.busy--;$('busy').textContent=state.busy?'正在更新':''}} -async function confirmed(title,text){$('confirmTitle').textContent=title;$('confirmText').textContent=text;openDialog('confirmDialog');return new Promise(resolve=>{const dialog=$('confirmDialog');let settled=false;const onclose=()=>end(false);const end=value=>{if(settled)return;settled=true;dialog.removeEventListener('close',onclose);if(dialog.open)dialog.close();resolve(value)};dialog.addEventListener('close',onclose);$('confirmCancel').onclick=()=>end(false);$('confirmYes').onclick=()=>end(true);dialog.oncancel=e=>{e.preventDefault();end(false)}})} -function period(){const range=$('statsRange').value;let from='',to='';if(range==='today'){const date=new Date();date.setHours(0,0,0,0);from=date.toISOString()}else if(range==='24h')from=new Date(Date.now()-86400000).toISOString();else if(range==='custom'){if($('statsFrom').value)from=new Date($('statsFrom').value).toISOString();if($('statsTo').value)to=new Date($('statsTo').value).toISOString()}const q=new URLSearchParams();if(from)q.set('from',from);if(to)q.set('to',to);return q.size?'?'+q.toString():''} -async function loadConfig(){const identity=diagnosticIdentity(),config=await api('/config');if(!state.authenticated||identity!==diagnosticIdentity())return;state.config=config;renderModels();const f=$('settingsForm');for(const k of ['timeout_seconds','first_byte_timeout_seconds','stream_idle_timeout_seconds','log_retention','usage_refresh_seconds','statistics_retention_days'])f.elements[k].value=state.config[k]??0;for(const k of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])f.elements[k].value=state.config[k]||'';f.elements.max_response_mb.value=state.config.max_response_bytes/1048576;f.elements.zdr.checked=state.config.zdr;$('dataDir').textContent=state.config.data_dir;const model=state.config.models[0]?.id||'你的模型名称';$('apiExample').textContent=JSON.stringify({model,messages:[{role:'user',content:'你好'}],stream:true},null,2);if(state.view==='settings')await Promise.all([loadCPAMPPanel(),loadCompatibility(),loadStorage()])} -function showCPAMPPanel(result){ - state.cpampPanel=result; - const served=result.served_page; - const rows=[['检测结果',result.message],['目标目录',result.install_dir||result.default_install_dir],['运行实例',result.pid?'PID '+result.pid:'未检测到'],['配置文件',result.config_path],['配置方式',result.configuration_mode==='environment'?'环境变量(尚无配置文件)':result.configuration_mode],['面板文件',result.panel_path],['当前服务页面',served?.checked?(served.native_quota?'含原生额度组件':'缺少原生额度组件')+(served.matches?',与内置版本一致':''):'尚未完成 HTTP 核验'],['预计变化',(result.planned_changes||[]).join('\n')||'无'],['可选配置',result.candidates?.join('\n')],['备份文件',result.backups?.join('\n')],['生效条件',result.hot_update_ready?'原路径更新,刷新浏览器': '首次启用后重启 Manager Server']]; - if(!result.available)rows.push(['安装不可用','当前构建未包含面板,请使用官方发布包']); - $('cpampPanelStatus').innerHTML=statusRows(rows); - $('installCPAMPPanel').textContent=result.hot_update_ready?'更新原生面板':'安装原生面板';$('installCPAMPPanel').disabled=!result.available||result.installable===false||result.active; -} -async function loadCPAMPPanel(){const identity=diagnosticIdentity(),result=await api('/cpamp/panel');if(state.authenticated&&identity===diagnosticIdentity())showCPAMPPanel(result)} -async function saveCPAMPPaths(){const f=$('settingsForm'),paths={};for(const name of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])paths[name]=f.elements[name].value.trim();if(Object.entries(paths).some(([name,value])=>value!==(state.config?.[name]||'')))state.config=await api('/config',{method:'PUT',body:paths})} -async function loadCredentials(){const identity=diagnosticIdentity(),result=await api('/credentials');if(!state.authenticated||identity!==diagnosticIdentity())return;state.credentials=result.credentials;renderCredentials();renderHealth()} -const diagnosticStatus={confirmed:'登记一致',disabled:'停用已确认',missing:'未登记',duplicate:'重复登记',routing_mismatch:'路由不一致',disabled_mismatch:'停用未同步',pending:'等待宿主确认',unknown:'未确认',ok:'通过',mismatch:'版本不匹配'}; -const diagnosticSources={account_query:'账户查询',plugin_test:'插件直测',cpa_request:'CPA 请求'}; -function diagnosticTone(status){return ['ok','confirmed','disabled','success'].includes(status)?'good':['pending','unknown','partial'].includes(status)?'warn':'bad'} -function diagnosticCounts(report){return {errors:report.credentials.filter(x=>!['confirmed','disabled','unknown','pending'].includes(x.status)).length+(report.unmatched_registrations||0),pending:report.credentials.filter(x=>['unknown','pending'].includes(x.status)).length}} -function renderDiagnostics(report){ - const rows=[...report.credentials].sort((a,b)=>({bad:0,warn:1,good:2})[diagnosticTone(a.status)]-({bad:0,warn:1,good:2})[diagnosticTone(b.status)]).map(row=>`
${esc(row.label||'凭证')}${esc(diagnosticStatus[row.status]||row.status)}${row.cooldown?' 部分模型冷却中':''}

${esc(row.message)}

${esc(row.action)}

${row.evidence.map(item=>`
${esc(diagnosticSources[item.source]||item.source)}:${esc(({unknown:'暂无可确认记录',success:'成功',error:'失败',ok:'正常',partial:'部分成功',disabled:'已停用'})[item.status]||item.status)}${item.checked_at?' · '+esc(when(item.checked_at)):''}
`).join('')}
`).join(''); - return `

检查时间:${esc(when(report.checked_at))} · 映射模型 ${esc(report.models)} 个

${esc(report.host_message)}

${report.models===0?'

尚未配置模型映射,请在模型映射页导入。

':''}${report.unmatched_registrations?`

另有 ${esc(report.unmatched_registrations)} 个宿主登记无法匹配当前凭证,请检查 CPA 加载日志。

`:''}${rows||'

暂无凭证,请先添加凭证。

'}`; -} -function renderCompatibility(report){return `

检查时间:${esc(when(report.checked_at))} · 插件 ${esc(report.version)}

${report.checks.map(check=>`
${esc(({plugin:'插件接口',host_version:'CPA 版本',page:'实际服务页面',page_quota:'额度页面',page_recent_requests:'最近请求页面',page_windows:'周期历史页面',snapshot_backend:'快照后端',browser_cache:'浏览器缓存'})[check.name]||check.name)}${esc(diagnosticStatus[check.status]||({missing:'缺少能力'})[check.status]||check.status)}

${esc(check.message)}

${check.action?`

${esc(check.action)}

`:''}
`).join('')}`} -function diagnosticIdentity(){return JSON.stringify([state.sessionRevision||0,state.key,state.authType,window.CommandCodeBridgeAuthHeaders?.()||{}])} -function resetDiagnosticViews(){ - state.sessionRevision=(state.sessionRevision||0)+1;state.diagnosticRevision=(state.diagnosticRevision||0)+1;state.compatibilityRevision=(state.compatibilityRevision||0)+1;state.modelEvidenceRevision=(state.modelEvidenceRevision||0)+1;state.logRevision=(state.logRevision||0)+1; - state.modelEvidence=null;state.modelCredential='';state.config=null;state.credentials=[];state.logs=[];state.total=0;state.offset=0;state.cpampPanel=null;state.detail=null;state.catalog=[];state.selected?.clear(); - for(const id of ['credentialDialog','modelDialog','catalogDialog','testDialog','confirmDialog'])$(id).close?.(); - for(const id of ['credentialForm','modelForm','testForm'])$(id).reset?.(); - $('testResult').textContent='';$('catalogRows').innerHTML=''; - $('detailDialog').close?.();$('detailContent').innerHTML=''; - for(const id of ['modelCredential','modelRows','credentialRows','credentialHealth','summary','modelStats','logRows','logCount','logPage','cpampPanelStatus','dataDir','apiExample'])$(id).innerHTML=''; - $('authButton').textContent='管理登录';$('modelEvidenceStatus').textContent='登录后重新读取模型证据';$('storageStatus').textContent='登录后重新读取存储状态';$('diagnosticSummary').textContent='登录后重新诊断';$('diagnosticResults').textContent='登录后重新诊断';$('compatibilityResults').textContent='登录后重新检查'; - $('installCPAMPPanel').disabled=true;$('prevLogs').disabled=true;$('nextLogs').disabled=true; -} -async function loadDiagnostics(){ - const revision=state.diagnosticRevision=(state.diagnosticRevision||0)+1,identity=diagnosticIdentity(); - try{const report=await api('/diagnostics',{timeout:15000});if(state.authenticated&&revision===state.diagnosticRevision&&identity===diagnosticIdentity()){$('diagnosticResults').innerHTML=renderDiagnostics(report);const n=diagnosticCounts(report);$('diagnosticSummary').textContent=`${n.errors} 项异常 · ${n.pending} 项待确认 · ${when(report.checked_at)}`}}catch(e){if(revision===state.diagnosticRevision&&identity===diagnosticIdentity()){$('diagnosticSummary').textContent='诊断刷新失败,详情保留上次结果';if(!$('diagnosticResults').innerHTML)$('diagnosticResults').textContent='诊断失败,请重新登录或重试。'};throw e} -} -async function loadCompatibility(){ - const revision=state.compatibilityRevision=(state.compatibilityRevision||0)+1,identity=diagnosticIdentity(); - try{const report=await api('/compatibility',{timeout:15000});if(state.authenticated&&revision===state.compatibilityRevision&&identity===diagnosticIdentity())$('compatibilityResults').innerHTML=renderCompatibility(report)}catch(e){if(revision===state.compatibilityRevision&&identity===diagnosticIdentity())$('compatibilityResults').textContent='兼容检查失败,请重新登录或重试。';throw e} -} -$('runDiagnostics').onclick=()=>loadDiagnostics().catch(globalError); -$('checkCompatibility').onclick=()=>loadCompatibility().catch(globalError); -$('copyDiagnostics').onclick=async()=>{ - const identity=diagnosticIdentity(); - try{const summary=await api('/diagnostics/summary',{timeout:15000});if(!state.authenticated||identity!==diagnosticIdentity())return;const text=JSON.stringify(summary,null,2);try{await navigator.clipboard.writeText(text);toast('脱敏诊断摘要已复制')}catch{const url=URL.createObjectURL(new Blob([text],{type:'application/json'})),link=document.createElement('a');link.href=url;link.download='commandcodebridge-diagnostics.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('脱敏诊断摘要已导出')}}catch(e){globalError(e)} -}; -async function loadStats(){const identity=diagnosticIdentity(),d=await api('/statistics'+period()),t=d.totals;if(!state.authenticated||identity!==diagnosticIdentity())return;const metrics=[['请求数',fmt(t.requests),`${fmt(t.success)} 成功 · ${fmt(t.failed)} 失败`],['输入 / 输出 tokens',compactNumber(t.input_tokens)+' / '+compactNumber(t.output_tokens),`${fmt(t.with_usage)} 次有用量记录`],['缓存命中',d.cache_hit_percent==null?'—':fmt(d.cache_hit_percent,1)+'%',`${fmt(t.cached_tokens)} tokens 读取`],['输出速率',d.output_tokens_per_second==null?'—':fmt(d.output_tokens_per_second,1)+' tok/s',d.average_ttft_ms==null?'首字时间未知':'首字 '+fmt(d.average_ttft_ms/1000,2)+'s']];$('summary').innerHTML=metrics.map(([name,value,hint],i)=>`
${name}${value}${hint}
`).join('');const rows=Object.values(d.models).sort((a,b)=>b.requests-a.requests);$('modelStats').innerHTML=rows.length?`${rows.map(x=>``).join('')}
模型请求成功输入 tokens输出 tokens缓存 tokens
${esc(x.model)}${fmt(x.requests)}${fmt(x.success)}${fmt(x.input_tokens)}${fmt(x.output_tokens)}${fmt(x.cached_tokens)}
`:'
暂无请求发起请求后显示用量
';$('statisticsPrecision').textContent=(d.bucket_seconds>60?d.range_note:'当前范围为分钟级统计')+((d.storage?.recovery_warnings||[]).some(w=>w.file==='statistics.json')?' · 发生过统计恢复,历史可能不完整,请查看设置中的恢复记录':'');renderStorage(d.storage);if(d.storage_error)globalError(new Error(d.storage_error))} -const healthLabels={healthy:['正常','good'],warning:['需关注','warn'],invalid:['鉴权异常','bad'],cooldown:['冷却中','warn'],disabled:['已停用','neutral'],unknown:['待验证','neutral']}; -function healthBadge(h){const [label,tone]=healthLabels[h?.status]||healthLabels.unknown;return `${label}`} -function renderHealth(){ - const rows=state.credentials; - if(!rows.length){$('credentialHealth').innerHTML='
暂无凭据
';return} - const healthy=rows.filter(c=>c.health?.status==='healthy').length,enabled=rows.filter(c=>!c.disabled).length; - const counts=Object.fromEntries(Object.keys(healthLabels).map(k=>[k,rows.filter(c=>(c.health?.status||'unknown')===k).length])); - const bar=Object.entries(counts).filter(([,n])=>n).map(([k,n])=>``).join(''); - const legend=Object.entries(counts).filter(([,n])=>n).map(([k,n])=>`${healthLabels[k][0]} ${n}`).join(''); - $('credentialHealth').innerHTML=`
正常 / 已启用
${healthy} / ${enabled}
${legend}
${rows.map(c=>{ - const h=c.health||{},recent=h.recent_requests||0; - const detail=h.cooldown_until?'重试 '+shortWhen(h.cooldown_until):['invalid','warning'].includes(h.status)?h.reason||'查询异常':h.checked_at?'验证于 '+shortWhen(h.checked_at):h.status==='disabled'?'不参与调度':'尚未验证'; - const request=h.last_request_at?`最近请求 ${h.last_status} · ${shortWhen(h.last_request_at)}`:'暂无近期请求'; - return `` - }).join('')}
`; -} -function windowHTML(w,name){ - const p=percent(w?.used,w?.cap),unknown=p==null; - return `
${name}${unknown?'未知':fmt(p,1)+'%'}
${fmt(w?.used,2)} / ${fmt(w?.cap,2)} credits
${w?'':'窗口未知 · '}重置 ${esc(shortWhen(w?.reset_at))}
`; -} -function monthlyHTML(u){ - const w=(u.windows||[]).find(w=>w.name==='monthly'); - if(w)return windowHTML(w,'月额度窗口'); - return `
月额度窗口剩余
${fmt(u.monthly_credits,3)} credits
已用 ${fmt(u.monthly_used_credits,3)} · 上限未知
重置 ${esc(shortWhen(u.subscription_end))}
`; -} -function renderCredentials(){ - const rows=state.credentials; - if(!rows.length){state.activeCredential=null;$('credentialRows').innerHTML='
暂无凭据
';return} - if(!rows.some(c=>c.id===state.activeCredential))state.activeCredential=(rows.find(c=>!c.disabled)||rows[0]).id; - const c=rows.find(c=>c.id===state.activeCredential),u=c.usage||{},windows=u.windows||[],plan=u.plan||'套餐未知'; - const unavailable=(u.unavailable||[]).map(x=>({credits:'余额',subscription:'订阅',summary:'本期用量'}[x]||x)).join('、'); - const selector=rows.map(c=>``).join(''); - $('credentialRows').innerHTML=`
凭据${rows.length}
${selector}
`; -} -const modelEvidenceLabels={unknown:'套餐权限未知',available:'最近实测成功',not_in_plan:'明确不在套餐',permission_unknown:'权限拒绝,原因未知',temporary_limit:'临时限流',temporary_error:'临时故障',authentication_error:'鉴权失败',expired:'证据已过期',disabled:'凭据已停用'}; -function evidenceTone(status){return status==='available'?'good':['not_in_plan','authentication_error'].includes(status)?'bad':['temporary_limit','temporary_error','permission_unknown','expired'].includes(status)?'warn':'neutral'} -function modelEvidenceHTML(model){ - const row=state.modelEvidence?.items.find(x=>x.model===model),status=row?.status||'unknown'; - const latest=row?.evidence.filter(e=>e.checked_at).sort((a,b)=>new Date(b.checked_at)-new Date(a.checked_at))[0]; - return `
${esc(modelEvidenceLabels[status]||'未知')}
${latest?esc(diagnosticSources[latest.source]||latest.source)+' · '+esc(shortWhen(latest.checked_at)):'暂无可确认记录'}
`; -} -function modelEvidenceDetailHTML(model){ - const row=state.modelEvidence?.items.find(x=>x.model===model); - return `
${modelEvidenceHTML(model)}

仅表示当前凭据与映射下的最近观察,不保证下一次调用成功。

`+(row?.evidence.length?row.evidence.map(e=>`

${esc(diagnosticSources[e.source]||e.source)}${esc(modelEvidenceLabels[e.status]||'未知')}

观察时间
${esc(when(e.checked_at))}
重试时间
${esc(e.retry_at?when(e.retry_at):'未提供')}
客户端协议
${esc(e.client_protocol||'未知')}
上游协议
${esc(e.upstream_protocol||'未知')}
HTTP 状态
${esc(e.http_status||'未提供')}
错误类型
${esc(e.error_kind||'无记录')}
ZDR 请求
${e.zdr_requested?'已请求,保留策略待核实':'未请求'}
${e.status==='expired'?'

此证据已超过 24 小时,当前权限待重新验证。

':''}
`).join(''):'

暂无插件直测或 CPA 请求记录。目录可见不代表套餐有权限。

'); -} -async function loadModelEvidence(){ - if(!state.authenticated)return; - if(state.detail==='model'){$('detailDialog').close();state.detail=null} - const selector=$('modelCredential'),available=state.credentials; - if(!available.some(c=>c.id===state.modelCredential))state.modelCredential=available.find(c=>!c.disabled)?.id||available[0]?.id||''; - selector.innerHTML=available.map(c=>``).join('');selector.value=state.modelCredential; - const revision=state.modelEvidenceRevision=(state.modelEvidenceRevision||0)+1,identity=diagnosticIdentity(),cid=state.modelCredential; - state.modelEvidence=null;renderModels();$('modelEvidenceStatus').textContent=cid?'正在读取证据…':'请先添加凭据'; - if(!cid)return; - try{const report=await api('/models/evidence?credential_id='+encodeURIComponent(cid));if(!state.authenticated||revision!==state.modelEvidenceRevision||identity!==diagnosticIdentity()||cid!==state.modelCredential)return;state.modelEvidence=report;renderModels();$('modelEvidenceStatus').textContent='读取于 '+when(report.checked_at)+' · 来源为保留日志,缺少证据不代表不可用'}catch(e){if(revision===state.modelEvidenceRevision){$('modelEvidenceStatus').textContent='读取失败,请刷新重试';state.modelEvidence=null;renderModels()}throw e} -} -function renderStorage(storage){if(!storage)return;$('storageStatus').innerHTML=statusRows([['统计精度',storage.precision],['最近成功写入',when(storage.last_success_at)],['待写文件',storage.pending_files?.join('、')||'无'],['待写开始',storage.pending_since?when(storage.pending_since):'无'],['统计桶数',fmt(storage.statistics_buckets)]])+(storage.recovery_warnings||[]).map(w=>`
恢复记录

${esc(w.file)}

${esc(w.message)}

${esc(when(w.checked_at))} · 恢复后的历史可能不完整

`).join('')} -async function loadStorage(){const identity=diagnosticIdentity(),report=await api('/status');if(state.authenticated&&identity===diagnosticIdentity())renderStorage(report.storage)} -function filteredModels(models,search,status){ - const text=search.trim().toLocaleLowerCase();return models.map((model,index)=>({model,index})).filter(({model})=>{const evidence=state.modelEvidence?.items.find(x=>x.model===model.id)?.status||'unknown';return (!text||[model.id,model.upstream_id,model.name].join(' ').toLocaleLowerCase().includes(text))&&(status==='all'||status==='available'&&evidence==='available'||status==='unknown'&&['unknown','expired','disabled'].includes(evidence)||status==='attention'&&!['available','unknown','expired','disabled'].includes(evidence))}) -} -function renderModels(){ - const models=state.config?.models||[],rows=filteredModels(models,$('modelSearch').value,$('modelStatus').value); - if(!models.length){$('modelRows').innerHTML='
暂无模型映射从上游目录导入或手动添加模型。
';return} - if(!rows.length){$('modelRows').innerHTML='
没有匹配的模型调整名称或证据筛选,不会发起生成。
';return} - const protocol=m=>m.supported_endpoints.includes('/chat/completions')?'OpenAI':'Anthropic'; - const actions=i=>``; - $('modelRows').innerHTML=`${rows.map(({model:m,index:i})=>``).join('')}
客户端名称 / 上游模型协议思考映射当前凭据证据操作
${esc(m.id)}
${esc(m.upstream_id)}
${protocol(m)}
${m.context_length?fmt(m.context_length)+' 上下文':''}
${m.reasoning_mapping.enabled?'已启用':'透传'}${modelEvidenceHTML(m.id)}${actions(i)}
${rows.map(({model:m,index:i})=>`
${esc(m.id)}
${esc(m.upstream_id)}
${protocol(m)} · 思考${m.reasoning_mapping.enabled?'映射已启用':'透传'}
${modelEvidenceHTML(m.id)}
${actions(i)}
`).join('')}
`; -} -function showModelDetail(index){ - const m=state.config?.models[index];if(!m)return;state.detail='model'; - showDetail(m.id,'模型详情 · '+(state.credentials.find(c=>c.id===state.modelCredential)?.label||'未选择凭据'),`

模型路由

${statusRows([['上游模型',m.upstream_id],['协议',m.supported_endpoints.join('、')],['思考映射',m.reasoning_mapping.enabled?Object.entries(m.reasoning_mapping.rules||{}).map(([a,b])=>a+' → '+b).join(',')||'全部透传':'未启用,原样透传']])}
`+modelEvidenceDetailHTML(m.id)); -} -function latencyHTML(label,ms,local,threshold){ - const tone=ms==null||local?'':ms>=threshold*3?'bad':ms>=threshold?'warn':'good'; - return `
${label}${ms==null||local?'—':fmt(ms/1000,2)+'s'}
`; -} -function logPresentation(x){const local=x.upstream_skipped,success=x.status>=200&&x.status<400;return {local,status:local?'本地退避':x.status===429?'限流':x.status===401?'鉴权失败':x.status===403?'权限拒绝':success?'成功':'失败',tone:local||x.status===429?'warn':success?'good':'bad',provider:local?'未调用上游':!x.provider||x.provider==='unknown'?'上游未报告':x.provider,usage:x.usage_reported&&!local,reasoning:x.reasoning_from===x.reasoning_to?(x.reasoning_from||'unset'):(x.reasoning_from||'unset')+' → '+(x.reasoning_to||'unset'),speed:x.output_tokens_per_second==null||local?'—':fmt(x.output_tokens_per_second,1)+' tok/s'}} -function logHTML(x){ - const {local,status,tone,provider,usage}=logPresentation(x),date=x.time?new Date(x.time):null; - return ``; -} -function logDetailHTML(x){ - const {local,status,tone,provider,usage,reasoning,speed}=logPresentation(x); - const route=[['请求 ID',x.id],['客户端模型',x.model],['上游模型',x.upstream_model],['实际凭据',x.credential||x.credential_id],['实际上游',provider],['客户端 / 上游协议',(x.client_protocol||'未知')+' / '+(x.upstream_protocol||'未知')],['ZDR 请求',x.zdr_requested?'已请求,保留策略待官方核实':'未请求'],['思考映射',reasoning],['响应状态',x.status],['流终态',x.stream_end||'—'],['重试时间',x.retry_at?when(x.retry_at):'—']]; - const timing=[['输入 tokens',usage?fmt(x.prompt_tokens):'—'],['输出 tokens',usage?fmt(x.completion_tokens):'—'],['缓存读取 tokens',usage?fmt(x.cached_tokens):'—'],['缓存写入 tokens',usage?fmt(x.cache_write_tokens):'—'],['思考 tokens',usage?fmt(x.reasoning_tokens):'—'],['首字耗时',x.ttft_ms==null||local?'—':fmt(x.ttft_ms/1000,2)+'s'],['总耗时',local?'—':fmt(x.duration_ms/1000,2)+'s'],['输出速率',speed]]; - const fields=rows=>'
'+rows.map(([k,v])=>`
${k}
${esc(v??'—')}
`).join('')+'
'; - return `
${status}

${esc(when(x.time))}

${local?'

本地冷却,未调用上游。

':''}

路由与协议

${fields(route)}

时延与用量

${fields(timing)}
${x.error?`

错误

${esc(x.error_kind||'请求错误')}
${esc(x.error)}
`:''}`; -} -async function loadLogs(){ - const revision=state.logRevision=(state.logRevision||0)+1,identity=diagnosticIdentity(); - const q=new URLSearchParams({offset:state.offset,limit:50,search:$('logSearch').value.trim(),status:$('logStatus').value}); - $('logRows').setAttribute('aria-busy','true'); - try{ - const d=await api('/logs?'+q);if(!state.authenticated||revision!==state.logRevision||identity!==diagnosticIdentity())return; - const rows=d.items;state.logs=rows;state.total=d.total;$('logLoadStatus').hidden=true; - if(state.offset>=d.total&&state.offset>0){state.offset=Math.max(0,Math.ceil(d.total/50)-1)*50;return loadLogs()} - $('logCount').textContent=`${fmt(d.total)} 条记录`; - $('logPage').textContent=`第 ${Math.floor(state.offset/50)+1} / ${Math.max(1,Math.ceil(d.total/50))} 页`; - $('prevLogs').disabled=state.offset===0;$('nextLogs').disabled=state.offset+50>=d.total; - $('logRows').innerHTML=rows.length?rows.map(logHTML).join(''):(q.get('search')||q.get('status')!=='all'?'
暂无匹配记录调整筛选条件后重试
':'
暂无请求记录发起请求后可在此查看详情。
'); - }catch(e){if(revision===state.logRevision&&identity===diagnosticIdentity()){$('logLoadStatus').hidden=false;$('logLoadStatus').textContent=state.logs?.length?'刷新失败,保留上次结果;请重试。':'日志读取失败,请重试。';throw e}} - finally{if(revision===state.logRevision)$('logRows').removeAttribute('aria-busy')} -} -async function refresh(){ - if(!state.authenticated)return; - try{ - $('globalError').hidden=true; - if(state.view==='overview')await Promise.all([loadStats(),loadCredentials()]); - if(state.view==='credentials')await Promise.all([loadCredentials(),loadDiagnostics()]); - if(state.view==='models'){await Promise.all([loadConfig(),loadCredentials()]);await loadModelEvidence()} - if(state.view==='settings')await loadConfig(); - if(state.view==='logs')await loadLogs(); - }catch(e){globalError(e)} -} -const viewNames={overview:'运行概览',credentials:'凭据与用量',models:'模型映射',logs:'请求日志',settings:'设置'}; -const mobileNav=matchMedia('(max-width: 767px)'); -function syncSidebar(){ - const opened=mobileNav.matches?document.body.classList.contains('sidebar-open'):!document.body.classList.contains('sidebar-collapsed'); - $('sidebarToggle').setAttribute('aria-expanded',String(opened)); - $('sidebarToggle').setAttribute('aria-label',opened?'收起导航':'展开导航'); - $('sidebar').inert=mobileNav.matches&&!opened; - $('sidebarScrim').hidden=!mobileNav.matches||!opened; -} -function closeSidebar(){document.body.classList.remove('sidebar-open');syncSidebar()} -$('sidebarToggle').onclick=()=>{ - document.body.classList.toggle(mobileNav.matches?'sidebar-open':'sidebar-collapsed');syncSidebar(); - if(mobileNav.matches&&document.body.classList.contains('sidebar-open'))$('sidebar').querySelector('button.active').focus(); -}; -$('sidebarScrim').onclick=()=>{closeSidebar();$('sidebarToggle').focus()}; -mobileNav.addEventListener('change',()=>{closeSidebar()}); -document.addEventListener('keydown',e=>{ - const modal=document.activeElement?.closest('dialog[open]')||[...document.querySelectorAll('dialog[open]')].at(-1); - if(modal){ - if(e.key==='Tab'){ - const nodes=[...modal.querySelectorAll('button:not(:disabled),a[href],input:not(:disabled):not([type=hidden]),select:not(:disabled),textarea:not(:disabled),[tabindex="0"]')].filter(x=>x.getClientRects().length),first=nodes[0],last=nodes.at(-1); - if(first&&e.shiftKey&&(document.activeElement===first||!nodes.includes(document.activeElement))){e.preventDefault();last.focus()} - else if(first&&!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()} +const $ = (id) => document.getElementById(id), + esc = (v) => + String(v ?? '').replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ); +const state = { + view: 'overview', + config: null, + credentials: [], + activeCredential: null, + catalog: [], + selected: new Set(), + offset: 0, + total: 0, + key: '', + authType: 'bearer', + authenticated: false, + blocked: false, + busy: 0, +}; +const API_BASE = document.querySelector('meta[name="cpa-api-base"]').content; +const compactNumber = (v) => + v == null + ? '—' + : Number(v).toLocaleString('en', { notation: 'compact', maximumFractionDigits: 1 }); +const shortWhen = (v) => + v + ? new Date(v).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + : '未知'; +const fmt = (v, d = 0) => + v === null || v === undefined + ? '—' + : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: d }), + when = (v) => (v ? new Date(v).toLocaleString('zh-CN') : '未知'), + percent = (a, b) => (a != null && b > 0 ? Math.max(0, Math.min(100, (a / b) * 100)) : null); +function toast(text) { + $('toast').textContent = text; + $('toast').hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => ($('toast').hidden = true), 3500); +} +function globalError(e) { + $('globalError').textContent = e.message || String(e); + $('globalError').hidden = false; +} +function dialogError(id, e) { + $(id).querySelector('.form-error').textContent = e.message || String(e); +} +const dialogOrigins = new Map(); +function openDialog(id) { + const dialog = $(id); + dialog.querySelectorAll('.form-error').forEach((x) => (x.textContent = '')); + if (!dialog.open) { + const node = document.activeElement; + dialogOrigins.set(id, { node, data: { ...node?.dataset }, view: state.view }); + dialog.showModal(); + } +} +function restoreDialogFocus(id) { + const origin = dialogOrigins.get(id); + dialogOrigins.delete(id); + if (!origin || document.querySelector('dialog[open]')) return; + if ( + origin.node?.isConnected && + (!origin.node.getClientRects || origin.node.getClientRects().length) && + !origin.node.closest?.('dialog:not([open])') + ) { + origin.node.focus(); + return; + } + for (const key of [ + 'logDetail', + 'modelDetail', + 'modelEdit', + 'modelTest', + 'accountEdit', + 'accountSelect', + ]) { + if (origin.data[key] !== undefined) { + const node = [...document.querySelectorAll('button')].find( + (x) => + x.dataset[key] === origin.data[key] && + (!x.getClientRects || x.getClientRects().length) && + !x.closest?.('dialog:not([open])') + ); + if (node) { + node.focus(); + return; + } } + } + const modelIndex = origin.data.modelEdit ?? origin.data.modelTest; + const modelButton = [...document.querySelectorAll('[data-model-detail]')].find( + (x) => x.dataset.modelDetail === modelIndex && x.getClientRects().length + ); + if (modelButton) { + modelButton.focus(); return; } - if(!mobileNav.matches||!document.body.classList.contains('sidebar-open'))return; - if(e.key==='Escape'){e.preventDefault();closeSidebar();$('sidebarToggle').focus()} - if(e.key==='Tab'){ - const focusable=[...$('sidebar').querySelectorAll('a,button')],first=focusable[0],last=focusable.at(-1); - if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()} - else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()} + if (origin.view === state.view) $('mainContent').focus(); +} +function showDetail(title, subtitle, html) { + $('detailTitle').textContent = title; + $('detailSubtitle').textContent = subtitle; + $('detailContent').innerHTML = html; + openDialog('detailDialog'); + $('detailTitle').focus(); +} +function statusRows(rows) { + return ( + '
' + + rows + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([label, value]) => `
${esc(label)}
${esc(value)}
`) + .join('') + + '
' + ); +} +function apiURL(path) { + return new URL(API_BASE + path, location.origin).href; +} +async function api(path, options = {}) { + if (state.blocked && !options.verify) throw new Error('管理访问已暂停,请重新验证登录'); + const headers = { ...(window.CommandCodeBridgeAuthHeaders?.() || {}), ...options.headers }; + if (state.key) { + delete headers.Authorization; + delete headers['X-Management-Key']; + headers[state.authType === 'x-management-key' ? 'X-Management-Key' : 'Authorization'] = + state.authType === 'x-management-key' ? state.key : 'Bearer ' + state.key; + } + if (options.body !== undefined) headers['Content-Type'] = 'application/json'; + state.busy++; + $('busy').textContent = '正在更新'; + try { + const response = await fetch(apiURL(path), { + method: options.method || 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: AbortSignal.timeout(options.timeout || 650000), + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = { error: text.slice(0, 200) }; + } + if (!response.ok) { + if ((response.status === 401 || response.status === 403) && path !== '/models/test') { + state.authenticated = false; + state.blocked = true; + resetDiagnosticViews(); + if (response.status === 401) { + window.CommandCodeBridgeClearAuth?.(headers); + state.key = ''; + } + openDialog('authDialog'); + } + throw new Error(data.error || `HTTP ${response.status}`); + } + return data; + } finally { + state.busy--; + $('busy').textContent = state.busy ? '正在更新' : ''; + } +} +async function confirmed(title, text) { + $('confirmTitle').textContent = title; + $('confirmText').textContent = text; + openDialog('confirmDialog'); + return new Promise((resolve) => { + const dialog = $('confirmDialog'); + let settled = false; + const onclose = () => end(false); + const end = (value) => { + if (settled) return; + settled = true; + dialog.removeEventListener('close', onclose); + if (dialog.open) dialog.close(); + resolve(value); + }; + dialog.addEventListener('close', onclose); + $('confirmCancel').onclick = () => end(false); + $('confirmYes').onclick = () => end(true); + dialog.oncancel = (e) => { + e.preventDefault(); + end(false); + }; + }); +} +function period() { + const range = $('statsRange').value; + let from = '', + to = ''; + if (range === 'today') { + const date = new Date(); + date.setHours(0, 0, 0, 0); + from = date.toISOString(); + } else if (range === '24h') from = new Date(Date.now() - 86400000).toISOString(); + else if (range === 'custom') { + if ($('statsFrom').value) from = new Date($('statsFrom').value).toISOString(); + if ($('statsTo').value) to = new Date($('statsTo').value).toISOString(); + } + const q = new URLSearchParams(); + if (from) q.set('from', from); + if (to) q.set('to', to); + return q.size ? '?' + q.toString() : ''; +} +async function loadConfig() { + const identity = diagnosticIdentity(), + config = await api('/config'); + if (!state.authenticated || identity !== diagnosticIdentity()) return; + state.config = config; + renderModels(); + const f = $('settingsForm'); + for (const k of [ + 'timeout_seconds', + 'first_byte_timeout_seconds', + 'stream_idle_timeout_seconds', + 'log_retention', + 'usage_refresh_seconds', + 'statistics_retention_days', + ]) + f.elements[k].value = state.config[k] ?? 0; + for (const k of ['cpamp_install_dir', 'cpamp_config_path', 'cpamp_panel_path']) + f.elements[k].value = state.config[k] || ''; + f.elements.max_response_mb.value = state.config.max_response_bytes / 1048576; + f.elements.zdr.checked = state.config.zdr; + $('dataDir').textContent = state.config.data_dir; + const model = state.config.models[0]?.id || '你的模型名称'; + $('apiExample').textContent = JSON.stringify( + { model, messages: [{ role: 'user', content: '你好' }], stream: true }, + null, + 2 + ); + if (state.view === 'settings') + await Promise.all([loadCPAMPPanel(), loadCompatibility(), loadStorage()]); +} +function showCPAMPPanel(result) { + state.cpampPanel = result; + const served = result.served_page; + const rows = [ + ['检测结果', result.message], + ['目标目录', result.install_dir || result.default_install_dir], + ['运行实例', result.pid ? 'PID ' + result.pid : '未检测到'], + ['配置文件', result.config_path], + [ + '配置方式', + result.configuration_mode === 'environment' + ? '环境变量(尚无配置文件)' + : result.configuration_mode, + ], + ['面板文件', result.panel_path], + [ + '当前服务页面', + served?.checked + ? (served.native_quota ? '含原生额度组件' : '缺少原生额度组件') + + (served.matches ? ',与内置版本一致' : '') + : '尚未完成 HTTP 核验', + ], + ['预计变化', (result.planned_changes || []).join('\n') || '无'], + ['可选配置', result.candidates?.join('\n')], + ['备份文件', result.backups?.join('\n')], + [ + '生效条件', + result.hot_update_ready ? '原路径更新,刷新浏览器' : '首次启用后重启 Manager Server', + ], + ]; + if (!result.available) rows.push(['安装不可用', '当前构建未包含面板,请使用官方发布包']); + $('cpampPanelStatus').innerHTML = statusRows(rows); + $('installCPAMPPanel').textContent = result.hot_update_ready ? '更新原生面板' : '安装原生面板'; + $('installCPAMPPanel').disabled = + !result.available || result.installable === false || result.active; +} +async function loadCPAMPPanel() { + const identity = diagnosticIdentity(), + result = await api('/cpamp/panel'); + if (state.authenticated && identity === diagnosticIdentity()) showCPAMPPanel(result); +} +async function saveCPAMPPaths() { + const f = $('settingsForm'), + paths = {}; + for (const name of ['cpamp_install_dir', 'cpamp_config_path', 'cpamp_panel_path']) + paths[name] = f.elements[name].value.trim(); + if (Object.entries(paths).some(([name, value]) => value !== (state.config?.[name] || ''))) + state.config = await api('/config', { method: 'PUT', body: paths }); +} +async function loadCredentials() { + const identity = diagnosticIdentity(), + result = await api('/credentials'); + if (!state.authenticated || identity !== diagnosticIdentity()) return; + state.credentials = result.credentials; + renderCredentials(); + renderHealth(); +} +const diagnosticStatus = { + confirmed: '登记一致', + disabled: '停用已确认', + missing: '未登记', + duplicate: '重复登记', + routing_mismatch: '路由不一致', + disabled_mismatch: '停用未同步', + pending: '等待宿主确认', + unknown: '未确认', + ok: '通过', + mismatch: '版本不匹配', +}; +const diagnosticSources = { + account_query: '账户查询', + plugin_test: '插件直测', + cpa_request: 'CPA 请求', +}; +function diagnosticTone(status) { + return ['ok', 'confirmed', 'disabled', 'success'].includes(status) + ? 'good' + : ['pending', 'unknown', 'partial'].includes(status) + ? 'warn' + : 'bad'; +} +function diagnosticCounts(report) { + return { + errors: + report.credentials.filter( + (x) => !['confirmed', 'disabled', 'unknown', 'pending'].includes(x.status) + ).length + (report.unmatched_registrations || 0), + pending: report.credentials.filter((x) => ['unknown', 'pending'].includes(x.status)).length, + }; +} +function renderDiagnostics(report) { + const rows = [...report.credentials] + .sort( + (a, b) => + ({ bad: 0, warn: 1, good: 2 })[diagnosticTone(a.status)] - + { bad: 0, warn: 1, good: 2 }[diagnosticTone(b.status)] + ) + .map( + (row) => + `
${esc(row.label || '凭证')}${esc(diagnosticStatus[row.status] || row.status)}${row.cooldown ? ' 部分模型冷却中' : ''}

${esc(row.message)}

${esc(row.action)}

${row.evidence.map((item) => `
${esc(diagnosticSources[item.source] || item.source)}:${esc({ unknown: '暂无可确认记录', success: '成功', error: '失败', ok: '正常', partial: '部分成功', disabled: '已停用' }[item.status] || item.status)}${item.checked_at ? ' · ' + esc(when(item.checked_at)) : ''}
`).join('')}
` + ) + .join(''); + return `

检查时间:${esc(when(report.checked_at))} · 映射模型 ${esc(report.models)} 个

${esc(report.host_message)}

${report.models === 0 ? '

尚未配置模型映射,请在模型映射页导入。

' : ''}${report.unmatched_registrations ? `

另有 ${esc(report.unmatched_registrations)} 个宿主登记无法匹配当前凭证,请检查 CPA 加载日志。

` : ''}${rows || '

暂无凭证,请先添加凭证。

'}`; +} +function renderCompatibility(report) { + return `

检查时间:${esc(when(report.checked_at))} · 插件 ${esc(report.version)}

${report.checks.map((check) => `
${esc({ plugin: '插件接口', host_version: 'CPA 版本', page: '实际服务页面', page_quota: '额度页面', page_recent_requests: '最近请求页面', page_windows: '周期历史页面', snapshot_backend: '快照后端', browser_cache: '浏览器缓存' }[check.name] || check.name)}${esc(diagnosticStatus[check.status] || { missing: '缺少能力' }[check.status] || check.status)}

${esc(check.message)}

${check.action ? `

${esc(check.action)}

` : ''}
`).join('')}`; +} +function diagnosticIdentity() { + return JSON.stringify([ + state.sessionRevision || 0, + state.key, + state.authType, + window.CommandCodeBridgeAuthHeaders?.() || {}, + ]); +} +function resetDiagnosticViews() { + state.sessionRevision = (state.sessionRevision || 0) + 1; + state.diagnosticRevision = (state.diagnosticRevision || 0) + 1; + state.compatibilityRevision = (state.compatibilityRevision || 0) + 1; + state.modelEvidenceRevision = (state.modelEvidenceRevision || 0) + 1; + state.logRevision = (state.logRevision || 0) + 1; + state.modelEvidence = null; + state.modelCredential = ''; + state.config = null; + state.credentials = []; + state.logs = []; + state.total = 0; + state.offset = 0; + state.cpampPanel = null; + state.detail = null; + state.catalog = []; + state.selected?.clear(); + for (const id of [ + 'credentialDialog', + 'modelDialog', + 'catalogDialog', + 'testDialog', + 'confirmDialog', + ]) + $(id).close?.(); + for (const id of ['credentialForm', 'modelForm', 'testForm']) $(id).reset?.(); + $('testResult').textContent = ''; + $('catalogRows').innerHTML = ''; + $('detailDialog').close?.(); + $('detailContent').innerHTML = ''; + for (const id of [ + 'modelCredential', + 'modelRows', + 'credentialRows', + 'credentialHealth', + 'summary', + 'modelStats', + 'logRows', + 'logCount', + 'logPage', + 'cpampPanelStatus', + 'dataDir', + 'apiExample', + ]) + $(id).innerHTML = ''; + $('authButton').textContent = '管理登录'; + $('modelEvidenceStatus').textContent = '登录后重新读取模型证据'; + $('storageStatus').textContent = '登录后重新读取存储状态'; + $('diagnosticSummary').textContent = '登录后重新诊断'; + $('diagnosticResults').textContent = '登录后重新诊断'; + $('compatibilityResults').textContent = '登录后重新检查'; + $('installCPAMPPanel').disabled = true; + $('prevLogs').disabled = true; + $('nextLogs').disabled = true; +} +async function loadDiagnostics() { + const revision = (state.diagnosticRevision = (state.diagnosticRevision || 0) + 1), + identity = diagnosticIdentity(); + try { + const report = await api('/diagnostics', { timeout: 15000 }); + if ( + state.authenticated && + revision === state.diagnosticRevision && + identity === diagnosticIdentity() + ) { + $('diagnosticResults').innerHTML = renderDiagnostics(report); + const n = diagnosticCounts(report); + $('diagnosticSummary').textContent = + `${n.errors} 项异常 · ${n.pending} 项待确认 · ${when(report.checked_at)}`; + } + } catch (e) { + if (revision === state.diagnosticRevision && identity === diagnosticIdentity()) { + $('diagnosticSummary').textContent = '诊断刷新失败,详情保留上次结果'; + if (!$('diagnosticResults').innerHTML) + $('diagnosticResults').textContent = '诊断失败,请重新登录或重试。'; + } + throw e; + } +} +async function loadCompatibility() { + const revision = (state.compatibilityRevision = (state.compatibilityRevision || 0) + 1), + identity = diagnosticIdentity(); + try { + const report = await api('/compatibility', { timeout: 15000 }); + if ( + state.authenticated && + revision === state.compatibilityRevision && + identity === diagnosticIdentity() + ) + $('compatibilityResults').innerHTML = renderCompatibility(report); + } catch (e) { + if (revision === state.compatibilityRevision && identity === diagnosticIdentity()) + $('compatibilityResults').textContent = '兼容检查失败,请重新登录或重试。'; + throw e; } +} +$('runDiagnostics').onclick = () => loadDiagnostics().catch(globalError); +$('checkCompatibility').onclick = () => loadCompatibility().catch(globalError); +$('copyDiagnostics').onclick = async () => { + const identity = diagnosticIdentity(); + try { + const summary = await api('/diagnostics/summary', { timeout: 15000 }); + if (!state.authenticated || identity !== diagnosticIdentity()) return; + const text = JSON.stringify(summary, null, 2); + try { + await navigator.clipboard.writeText(text); + toast('脱敏诊断摘要已复制'); + } catch { + const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })), + link = document.createElement('a'); + link.href = url; + link.download = 'commandcodebridge-diagnostics.json'; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast('脱敏诊断摘要已导出'); + } + } catch (e) { + globalError(e); + } +}; +async function loadStats() { + const identity = diagnosticIdentity(), + d = await api('/statistics' + period()), + t = d.totals; + if (!state.authenticated || identity !== diagnosticIdentity()) return; + const metrics = [ + ['请求数', fmt(t.requests), `${fmt(t.success)} 成功 · ${fmt(t.failed)} 失败`], + [ + '输入 / 输出 tokens', + compactNumber(t.input_tokens) + ' / ' + compactNumber(t.output_tokens), + `${fmt(t.with_usage)} 次有用量记录`, + ], + [ + '缓存命中', + d.cache_hit_percent == null ? '—' : fmt(d.cache_hit_percent, 1) + '%', + `${fmt(t.cached_tokens)} tokens 读取`, + ], + [ + '输出速率', + d.output_tokens_per_second == null ? '—' : fmt(d.output_tokens_per_second, 1) + ' tok/s', + d.average_ttft_ms == null ? '首字时间未知' : '首字 ' + fmt(d.average_ttft_ms / 1000, 2) + 's', + ], + ]; + $('summary').innerHTML = metrics + .map( + ([name, value, hint], i) => + `
${name}${value}${hint}
` + ) + .join(''); + const rows = Object.values(d.models).sort((a, b) => b.requests - a.requests); + $('modelStats').innerHTML = rows.length + ? `${rows.map((x) => ``).join('')}
模型请求成功输入 tokens输出 tokens缓存 tokens
${esc(x.model)}${fmt(x.requests)}${fmt(x.success)}${fmt(x.input_tokens)}${fmt(x.output_tokens)}${fmt(x.cached_tokens)}
` + : '
暂无请求发起请求后显示用量
'; + $('statisticsPrecision').textContent = + (d.bucket_seconds > 60 ? d.range_note : '当前范围为分钟级统计') + + ((d.storage?.recovery_warnings || []).some((w) => w.file === 'statistics.json') + ? ' · 发生过统计恢复,历史可能不完整,请查看设置中的恢复记录' + : ''); + renderStorage(d.storage); + if (d.storage_error) globalError(new Error(d.storage_error)); +} +const healthLabels = { + healthy: ['正常', 'good'], + warning: ['需关注', 'warn'], + invalid: ['鉴权异常', 'bad'], + cooldown: ['冷却中', 'warn'], + disabled: ['已停用', 'neutral'], + unknown: ['待验证', 'neutral'], +}; +function healthBadge(h) { + const [label, tone] = healthLabels[h?.status] || healthLabels.unknown; + return `${label}`; +} +function renderHealth() { + const rows = state.credentials; + if (!rows.length) { + $('credentialHealth').innerHTML = '
暂无凭据
'; + return; + } + const healthy = rows.filter((c) => c.health?.status === 'healthy').length, + enabled = rows.filter((c) => !c.disabled).length; + const counts = Object.fromEntries( + Object.keys(healthLabels).map((k) => [ + k, + rows.filter((c) => (c.health?.status || 'unknown') === k).length, + ]) + ); + const bar = Object.entries(counts) + .filter(([, n]) => n) + .map( + ([k, n]) => + `` + ) + .join(''); + const legend = Object.entries(counts) + .filter(([, n]) => n) + .map(([k, n]) => `${healthLabels[k][0]} ${n}`) + .join(''); + $('credentialHealth').innerHTML = + `
正常 / 已启用
${healthy} / ${enabled}
${legend}
${rows + .map((c) => { + const h = c.health || {}, + recent = h.recent_requests || 0; + const detail = h.cooldown_until + ? '重试 ' + shortWhen(h.cooldown_until) + : ['invalid', 'warning'].includes(h.status) + ? h.reason || '查询异常' + : h.checked_at + ? '验证于 ' + shortWhen(h.checked_at) + : h.status === 'disabled' + ? '不参与调度' + : '尚未验证'; + const request = h.last_request_at + ? `最近请求 ${h.last_status} · ${shortWhen(h.last_request_at)}` + : '暂无近期请求'; + return ``; + }) + .join('')}
`; +} +function windowHTML(w, name) { + const p = percent(w?.used, w?.cap), + unknown = p == null; + return `
${name}${unknown ? '未知' : fmt(p, 1) + '%'}
${fmt(w?.used, 2)} / ${fmt(w?.cap, 2)} credits
${w ? '' : '窗口未知 · '}重置 ${esc(shortWhen(w?.reset_at))}
`; +} +function monthlyHTML(u) { + const w = (u.windows || []).find((w) => w.name === 'monthly'); + if (w) return windowHTML(w, '月额度窗口'); + return `
月额度窗口剩余
${fmt(u.monthly_credits, 3)} credits
已用 ${fmt(u.monthly_used_credits, 3)} · 上限未知
重置 ${esc(shortWhen(u.subscription_end))}
`; +} +function renderCredentials() { + const rows = state.credentials; + if (!rows.length) { + state.activeCredential = null; + $('credentialRows').innerHTML = + '
暂无凭据
'; + return; + } + if (!rows.some((c) => c.id === state.activeCredential)) + state.activeCredential = (rows.find((c) => !c.disabled) || rows[0]).id; + const c = rows.find((c) => c.id === state.activeCredential), + u = c.usage || {}, + windows = u.windows || [], + plan = u.plan || '套餐未知'; + const unavailable = (u.unavailable || []) + .map((x) => ({ credits: '余额', subscription: '订阅', summary: '本期用量' })[x] || x) + .join('、'); + const selector = rows + .map( + (c) => + `` + ) + .join(''); + $('credentialRows').innerHTML = + `
凭据${rows.length}
${selector}
`; +} +const modelEvidenceLabels = { + unknown: '套餐权限未知', + available: '最近实测成功', + not_in_plan: '明确不在套餐', + permission_unknown: '权限拒绝,原因未知', + temporary_limit: '临时限流', + temporary_error: '临时故障', + authentication_error: '鉴权失败', + expired: '证据已过期', + disabled: '凭据已停用', +}; +function evidenceTone(status) { + return status === 'available' + ? 'good' + : ['not_in_plan', 'authentication_error'].includes(status) + ? 'bad' + : ['temporary_limit', 'temporary_error', 'permission_unknown', 'expired'].includes(status) + ? 'warn' + : 'neutral'; +} +function modelEvidenceHTML(model) { + const row = state.modelEvidence?.items.find((x) => x.model === model), + status = row?.status || 'unknown'; + const latest = row?.evidence + .filter((e) => e.checked_at) + .sort((a, b) => new Date(b.checked_at) - new Date(a.checked_at))[0]; + return `
${esc(modelEvidenceLabels[status] || '未知')}
${latest ? esc(diagnosticSources[latest.source] || latest.source) + ' · ' + esc(shortWhen(latest.checked_at)) : '暂无可确认记录'}
`; +} +function modelEvidenceDetailHTML(model) { + const row = state.modelEvidence?.items.find((x) => x.model === model); + return ( + `
${modelEvidenceHTML(model)}

仅表示当前凭据与映射下的最近观察,不保证下一次调用成功。

` + + (row?.evidence.length + ? row.evidence + .map( + (e) => + `

${esc(diagnosticSources[e.source] || e.source)}${esc(modelEvidenceLabels[e.status] || '未知')}

观察时间
${esc(when(e.checked_at))}
重试时间
${esc(e.retry_at ? when(e.retry_at) : '未提供')}
客户端协议
${esc(e.client_protocol || '未知')}
上游协议
${esc(e.upstream_protocol || '未知')}
HTTP 状态
${esc(e.http_status || '未提供')}
错误类型
${esc(e.error_kind || '无记录')}
ZDR 请求
${e.zdr_requested ? '已请求,保留策略待核实' : '未请求'}
${e.status === 'expired' ? '

此证据已超过 24 小时,当前权限待重新验证。

' : ''}
` + ) + .join('') + : '

暂无插件直测或 CPA 请求记录。目录可见不代表套餐有权限。

') + ); +} +async function loadModelEvidence() { + if (!state.authenticated) return; + if (state.detail === 'model') { + $('detailDialog').close(); + state.detail = null; + } + const selector = $('modelCredential'), + available = state.credentials; + if (!available.some((c) => c.id === state.modelCredential)) + state.modelCredential = available.find((c) => !c.disabled)?.id || available[0]?.id || ''; + selector.innerHTML = available + .map( + (c) => + `` + ) + .join(''); + selector.value = state.modelCredential; + const revision = (state.modelEvidenceRevision = (state.modelEvidenceRevision || 0) + 1), + identity = diagnosticIdentity(), + cid = state.modelCredential; + state.modelEvidence = null; + renderModels(); + $('modelEvidenceStatus').textContent = cid ? '正在读取证据…' : '请先添加凭据'; + if (!cid) return; + try { + const report = await api('/models/evidence?credential_id=' + encodeURIComponent(cid)); + if ( + !state.authenticated || + revision !== state.modelEvidenceRevision || + identity !== diagnosticIdentity() || + cid !== state.modelCredential + ) + return; + state.modelEvidence = report; + renderModels(); + $('modelEvidenceStatus').textContent = + '读取于 ' + when(report.checked_at) + ' · 来源为保留日志,缺少证据不代表不可用'; + } catch (e) { + if (revision === state.modelEvidenceRevision) { + $('modelEvidenceStatus').textContent = '读取失败,请刷新重试'; + state.modelEvidence = null; + renderModels(); + } + throw e; + } +} +function renderStorage(storage) { + if (!storage) return; + $('storageStatus').innerHTML = + statusRows([ + ['统计精度', storage.precision], + ['最近成功写入', when(storage.last_success_at)], + ['待写文件', storage.pending_files?.join('、') || '无'], + ['待写开始', storage.pending_since ? when(storage.pending_since) : '无'], + ['统计桶数', fmt(storage.statistics_buckets)], + ]) + + (storage.recovery_warnings || []) + .map( + (w) => + `
恢复记录

${esc(w.file)}

${esc(w.message)}

${esc(when(w.checked_at))} · 恢复后的历史可能不完整

` + ) + .join(''); +} +async function loadStorage() { + const identity = diagnosticIdentity(), + report = await api('/status'); + if (state.authenticated && identity === diagnosticIdentity()) renderStorage(report.storage); +} +function filteredModels(models, search, status) { + const text = search.trim().toLocaleLowerCase(); + return models + .map((model, index) => ({ model, index })) + .filter(({ model }) => { + const evidence = + state.modelEvidence?.items.find((x) => x.model === model.id)?.status || 'unknown'; + return ( + (!text || + [model.id, model.upstream_id, model.name].join(' ').toLocaleLowerCase().includes(text)) && + (status === 'all' || + (status === 'available' && evidence === 'available') || + (status === 'unknown' && ['unknown', 'expired', 'disabled'].includes(evidence)) || + (status === 'attention' && + !['available', 'unknown', 'expired', 'disabled'].includes(evidence))) + ); + }); +} +function renderModels() { + const models = state.config?.models || [], + rows = filteredModels(models, $('modelSearch').value, $('modelStatus').value); + if (!models.length) { + $('modelRows').innerHTML = + '
暂无模型映射从上游目录导入或手动添加模型。
'; + return; + } + if (!rows.length) { + $('modelRows').innerHTML = + '
没有匹配的模型调整名称或证据筛选,不会发起生成。
'; + return; + } + const protocol = (m) => + m.supported_endpoints.includes('/chat/completions') ? 'OpenAI' : 'Anthropic'; + const actions = (i) => + ``; + $('modelRows').innerHTML = + `${rows.map(({ model: m, index: i }) => ``).join('')}
客户端名称 / 上游模型协议思考映射当前凭据证据操作
${esc(m.id)}
${esc(m.upstream_id)}
${protocol(m)}
${m.context_length ? fmt(m.context_length) + ' 上下文' : ''}
${m.reasoning_mapping.enabled ? '已启用' : '透传'}${modelEvidenceHTML(m.id)}${actions(i)}
${rows.map(({ model: m, index: i }) => `
${esc(m.id)}
${esc(m.upstream_id)}
${protocol(m)} · 思考${m.reasoning_mapping.enabled ? '映射已启用' : '透传'}
${modelEvidenceHTML(m.id)}
${actions(i)}
`).join('')}
`; +} +function showModelDetail(index) { + const m = state.config?.models[index]; + if (!m) return; + state.detail = 'model'; + showDetail( + m.id, + '模型详情 · ' + + (state.credentials.find((c) => c.id === state.modelCredential)?.label || '未选择凭据'), + `

模型路由

${statusRows([ + ['上游模型', m.upstream_id], + ['协议', m.supported_endpoints.join('、')], + [ + '思考映射', + m.reasoning_mapping.enabled + ? Object.entries(m.reasoning_mapping.rules || {}) + .map(([a, b]) => a + ' → ' + b) + .join(',') || '全部透传' + : '未启用,原样透传', + ], + ])}
` + + modelEvidenceDetailHTML(m.id) + ); +} +function latencyHTML(label, ms, local, threshold) { + const tone = + ms == null || local ? '' : ms >= threshold * 3 ? 'bad' : ms >= threshold ? 'warn' : 'good'; + return `
${label}${ms == null || local ? '—' : fmt(ms / 1000, 2) + 's'}
`; +} +function logPresentation(x) { + const local = x.upstream_skipped, + success = x.status >= 200 && x.status < 400; + return { + local, + status: local + ? '本地退避' + : x.status === 429 + ? '限流' + : x.status === 401 + ? '鉴权失败' + : x.status === 403 + ? '权限拒绝' + : success + ? '成功' + : '失败', + tone: local || x.status === 429 ? 'warn' : success ? 'good' : 'bad', + provider: local + ? '未调用上游' + : !x.provider || x.provider === 'unknown' + ? '上游未报告' + : x.provider, + usage: x.usage_reported && !local, + reasoning: + x.reasoning_from === x.reasoning_to + ? x.reasoning_from || 'unset' + : (x.reasoning_from || 'unset') + ' → ' + (x.reasoning_to || 'unset'), + speed: + x.output_tokens_per_second == null || local + ? '—' + : fmt(x.output_tokens_per_second, 1) + ' tok/s', + }; +} +function logHTML(x) { + const { local, status, tone, provider, usage } = logPresentation(x), + date = x.time ? new Date(x.time) : null; + return ``; +} +function logDetailHTML(x) { + const { local, status, tone, provider, usage, reasoning, speed } = logPresentation(x); + const route = [ + ['请求 ID', x.id], + ['客户端模型', x.model], + ['上游模型', x.upstream_model], + ['实际凭据', x.credential || x.credential_id], + ['实际上游', provider], + ['客户端 / 上游协议', (x.client_protocol || '未知') + ' / ' + (x.upstream_protocol || '未知')], + ['ZDR 请求', x.zdr_requested ? '已请求,保留策略待官方核实' : '未请求'], + ['思考映射', reasoning], + ['响应状态', x.status], + ['流终态', x.stream_end || '—'], + ['重试时间', x.retry_at ? when(x.retry_at) : '—'], + ]; + const timing = [ + ['输入 tokens', usage ? fmt(x.prompt_tokens) : '—'], + ['输出 tokens', usage ? fmt(x.completion_tokens) : '—'], + ['缓存读取 tokens', usage ? fmt(x.cached_tokens) : '—'], + ['缓存写入 tokens', usage ? fmt(x.cache_write_tokens) : '—'], + ['思考 tokens', usage ? fmt(x.reasoning_tokens) : '—'], + ['首字耗时', x.ttft_ms == null || local ? '—' : fmt(x.ttft_ms / 1000, 2) + 's'], + ['总耗时', local ? '—' : fmt(x.duration_ms / 1000, 2) + 's'], + ['输出速率', speed], + ]; + const fields = (rows) => + '
' + + rows.map(([k, v]) => `
${k}
${esc(v ?? '—')}
`).join('') + + '
'; + return `
${status}

${esc(when(x.time))}

${local ? '

本地冷却,未调用上游。

' : ''}

路由与协议

${fields(route)}

时延与用量

${fields(timing)}
${x.error ? `

错误

${esc(x.error_kind || '请求错误')}
${esc(x.error)}
` : ''}`; +} +async function loadLogs() { + const revision = (state.logRevision = (state.logRevision || 0) + 1), + identity = diagnosticIdentity(); + const q = new URLSearchParams({ + offset: state.offset, + limit: 50, + search: $('logSearch').value.trim(), + status: $('logStatus').value, + }); + $('logRows').setAttribute('aria-busy', 'true'); + try { + const d = await api('/logs?' + q); + if (!state.authenticated || revision !== state.logRevision || identity !== diagnosticIdentity()) + return; + const rows = d.items; + state.logs = rows; + state.total = d.total; + $('logLoadStatus').hidden = true; + if (state.offset >= d.total && state.offset > 0) { + state.offset = Math.max(0, Math.ceil(d.total / 50) - 1) * 50; + return loadLogs(); + } + $('logCount').textContent = `${fmt(d.total)} 条记录`; + $('logPage').textContent = + `第 ${Math.floor(state.offset / 50) + 1} / ${Math.max(1, Math.ceil(d.total / 50))} 页`; + $('prevLogs').disabled = state.offset === 0; + $('nextLogs').disabled = state.offset + 50 >= d.total; + $('logRows').innerHTML = rows.length + ? rows.map(logHTML).join('') + : q.get('search') || q.get('status') !== 'all' + ? '
暂无匹配记录调整筛选条件后重试
' + : '
暂无请求记录发起请求后可在此查看详情。
'; + } catch (e) { + if (revision === state.logRevision && identity === diagnosticIdentity()) { + $('logLoadStatus').hidden = false; + $('logLoadStatus').textContent = state.logs?.length + ? '刷新失败,保留上次结果;请重试。' + : '日志读取失败,请重试。'; + throw e; + } + } finally { + if (revision === state.logRevision) $('logRows').removeAttribute('aria-busy'); + } +} +async function refresh() { + if (!state.authenticated) return; + try { + $('globalError').hidden = true; + if (state.view === 'overview') await Promise.all([loadStats(), loadCredentials()]); + if (state.view === 'credentials') await Promise.all([loadCredentials(), loadDiagnostics()]); + if (state.view === 'models') { + await Promise.all([loadConfig(), loadCredentials()]); + await loadModelEvidence(); + } + if (state.view === 'settings') await loadConfig(); + if (state.view === 'logs') await loadLogs(); + } catch (e) { + globalError(e); + } +} +const viewNames = { + overview: '运行概览', + credentials: '凭据与用量', + models: '模型映射', + logs: '请求日志', + settings: '设置', +}; +const mobileNav = matchMedia('(max-width: 767px)'); +function syncSidebar() { + const opened = mobileNav.matches + ? document.body.classList.contains('sidebar-open') + : !document.body.classList.contains('sidebar-collapsed'); + $('sidebarToggle').setAttribute('aria-expanded', String(opened)); + $('sidebarToggle').setAttribute('aria-label', opened ? '收起导航' : '展开导航'); + $('sidebar').inert = mobileNav.matches && !opened; + $('sidebarScrim').hidden = !mobileNav.matches || !opened; +} +function closeSidebar() { + document.body.classList.remove('sidebar-open'); + syncSidebar(); +} +$('sidebarToggle').onclick = () => { + document.body.classList.toggle(mobileNav.matches ? 'sidebar-open' : 'sidebar-collapsed'); + syncSidebar(); + if (mobileNav.matches && document.body.classList.contains('sidebar-open')) + $('sidebar').querySelector('button.active').focus(); +}; +$('sidebarScrim').onclick = () => { + closeSidebar(); + $('sidebarToggle').focus(); +}; +mobileNav.addEventListener('change', () => { + closeSidebar(); }); -function navigate(view){ - if(!viewNames[view])return; - state.view=view;document.querySelectorAll('.view').forEach(x=>x.hidden=x.id!==view); - document.querySelectorAll('nav button').forEach(x=>{x.classList.toggle('active',x.dataset.view===view);if(x.dataset.view===view)x.setAttribute('aria-current','page');else x.removeAttribute('aria-current')}); - $('currentView').textContent=viewNames[view]; - if(mobileNav.matches&&document.body.classList.contains('sidebar-open')){closeSidebar();$('sidebarToggle').focus()} +document.addEventListener('keydown', (e) => { + const modal = + document.activeElement?.closest('dialog[open]') || + [...document.querySelectorAll('dialog[open]')].at(-1); + if (modal) { + if (e.key === 'Tab') { + const nodes = [ + ...modal.querySelectorAll( + 'button:not(:disabled),a[href],input:not(:disabled):not([type=hidden]),select:not(:disabled),textarea:not(:disabled),[tabindex="0"]' + ), + ].filter((x) => x.getClientRects().length), + first = nodes[0], + last = nodes.at(-1); + if ( + first && + e.shiftKey && + (document.activeElement === first || !nodes.includes(document.activeElement)) + ) { + e.preventDefault(); + last.focus(); + } else if (first && !e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + return; + } + if (!mobileNav.matches || !document.body.classList.contains('sidebar-open')) return; + if (e.key === 'Escape') { + e.preventDefault(); + closeSidebar(); + $('sidebarToggle').focus(); + } + if (e.key === 'Tab') { + const focusable = [...$('sidebar').querySelectorAll('a,button')], + first = focusable[0], + last = focusable.at(-1); + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } +}); +function navigate(view) { + if (!viewNames[view]) return; + state.view = view; + document.querySelectorAll('.view').forEach((x) => (x.hidden = x.id !== view)); + document.querySelectorAll('nav button').forEach((x) => { + x.classList.toggle('active', x.dataset.view === view); + if (x.dataset.view === view) x.setAttribute('aria-current', 'page'); + else x.removeAttribute('aria-current'); + }); + $('currentView').textContent = viewNames[view]; + if (mobileNav.matches && document.body.classList.contains('sidebar-open')) { + closeSidebar(); + $('sidebarToggle').focus(); + } refresh(); } -$('sidebar').querySelector('.brand').onclick=e=>{e.preventDefault();navigate('overview')}; -document.querySelectorAll('nav button').forEach(b=>{b.title=viewNames[b.dataset.view];b.setAttribute('aria-label',viewNames[b.dataset.view])}); +$('sidebar').querySelector('.brand').onclick = (e) => { + e.preventDefault(); + navigate('overview'); +}; +document.querySelectorAll('nav button').forEach((b) => { + b.title = viewNames[b.dataset.view]; + b.setAttribute('aria-label', viewNames[b.dataset.view]); +}); syncSidebar(); -function editAccount(cid){const f=$('credentialForm');f.reset();const c=state.credentials.find(c=>c.id===cid);f.elements.id.value=cid||'';f.elements.api_key.required=!c;if(c){f.elements.label.value=c.label;f.elements.priority.value=c.priority;f.elements.disabled.checked=c.disabled}$('credentialTitle').textContent=c?'编辑凭据':'添加凭据';openDialog('credentialDialog')} -const defaultAlias=upstream=>upstream.trim().split('/').pop(); -let lastSuggestedAlias=''; -$('modelForm').elements.upstream_id.oninput=e=>{const f=$('modelForm');if(f.elements.index.value!==''||f.elements.id.value&&f.elements.id.value!==lastSuggestedAlias)return;lastSuggestedAlias=defaultAlias(e.target.value);f.elements.id.value=lastSuggestedAlias}; -function editModel(index){const f=$('modelForm');f.reset();lastSuggestedAlias='';const m=state.config?.models[index];f.elements.index.value=m?index:'';if(m){f.elements.id.value=m.id;f.elements.upstream_id.value=m.upstream_id;f.elements.name.value=m.name||'';f.elements.endpoint.value=m.supported_endpoints.includes('/chat/completions')?'/chat/completions':'/messages';f.elements.thinking_mode.value=m.thinking_mode||"adaptive";f.elements.reasoning_enabled.checked=m.reasoning_mapping.enabled;f.elements.rules.value=JSON.stringify(m.reasoning_mapping.rules||{},null,2)}$('modelTitle').textContent=m?'编辑模型':'添加模型';openDialog('modelDialog')} -async function saveModels(models){const aliases=new Set();for(const m of models){if(aliases.has(m.id))throw new Error('客户端名称重复:'+m.id+'。请减少所选模型,或手动添加并设置不同名称。');aliases.add(m.id)}await api('/models',{method:'PUT',body:{models}});await loadConfig();await loadModelEvidence()} -async function showCatalog(){const identity=diagnosticIdentity();try{const result=await api('/models/refresh',{method:'POST',body:{},timeout:20000});if(!state.authenticated||identity!==diagnosticIdentity())return;state.catalog=result.models;state.selected.clear();$('catalogSearch').value='';renderCatalog();openDialog('catalogDialog')}catch(e){if(state.authenticated&&identity===diagnosticIdentity())globalError(e)}} -function catalogFiltered(){const text=$('catalogSearch').value.toLowerCase();const existing=new Set((state.config?.models||[]).map(m=>m.upstream_id));return state.catalog.filter(m=>!existing.has(m.upstream_id)&&(!text||(m.name+' '+m.upstream_id).toLowerCase().includes(text)))} -function renderCatalog(){const rows=catalogFiltered();$('catalogRows').innerHTML=rows.length?rows.map(m=>``).join(''):'
没有可导入的模型。
';$('importCatalog').textContent=`导入所选${state.selected.size?'('+state.selected.size+')':''}`} -async function showTest(index){const identity=diagnosticIdentity();await loadCredentials();if(!state.authenticated||identity!==diagnosticIdentity())return;const m=state.config.models[index],f=$('testForm');f.reset();f.elements.model.value=m.id;$('testModelName').textContent=m.id;f.elements.credential_id.innerHTML=state.credentials.filter(c=>!c.disabled).map(c=>``).join('');if(state.modelCredential)f.elements.credential_id.value=state.modelCredential;$('testResult').hidden=true;openDialog('testDialog')} -document.querySelectorAll('dialog').forEach(d=>{d.addEventListener('close',()=>restoreDialogFocus(d.id));d.setAttribute('aria-labelledby',d.querySelector('h2').id||(d.querySelector('h2').id=d.id+'Title'))}); -document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-jump]').forEach(b=>b.onclick=()=>navigate(b.dataset.jump));document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());document.querySelectorAll('[data-action="refresh"]').forEach(b=>b.onclick=refresh); -$('authButton').onclick=()=>{ $('rememberAuth').checked=window.CommandCodeBridgeRememberAuth?.()||false;openDialog('authDialog')};$('clearAuth').onclick=()=>{window.CommandCodeBridgeClearAuth?.();resetDiagnosticViews();state.key='';state.authenticated=false;state.blocked=true;$('managementKey').value='';toast('已退出登录')}; -$('authForm').onsubmit=async e=>{e.preventDefault();resetDiagnosticViews();state.authenticated=false;state.key=$('managementKey').value.trim();state.authType=$('authType').value;try{const status=await api('/status',{verify:true,timeout:15000});window.CommandCodeBridgeSaveAuth?.(state.key,state.authType,$('rememberAuth').checked);state.authenticated=true;$('authButton').textContent='管理身份';state.blocked=false;$('managementKey').value='';$('authDialog').close();$('version').textContent='v'+status.version;await loadConfig();await loadCredentials();await refresh();toast('已登录')}catch(e){dialogError('authDialog',e)}}; -$('credentialForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget,cid=f.elements.id.value,body={label:f.elements.label.value,priority:Number(f.elements.priority.value),disabled:f.elements.disabled.checked};if(f.elements.api_key.value.trim())body.api_key=f.elements.api_key.value.trim();try{const saved=await api('/credentials'+(cid?'?id='+encodeURIComponent(cid):''),{method:cid?'PUT':'POST',body});f.elements.api_key.value='';$('credentialDialog').close();toast('凭据已保存 · '+(saved.registration?.message||'宿主登记待确认'));await Promise.all([loadCredentials(),loadDiagnostics()]).catch(globalError)}catch(e){dialogError('credentialDialog',e)}}; -$('modelForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const rules=JSON.parse(f.elements.rules.value);if(!rules||Array.isArray(rules)||typeof rules!=='object')throw new Error('映射规则必须是 JSON 对象');const index=f.elements.index.value,old=index===''?{}:state.config.models[Number(index)],m={...old,id:f.elements.id.value.trim(),upstream_id:f.elements.upstream_id.value.trim(),name:f.elements.name.value.trim(),supported_endpoints:[f.elements.endpoint.value],thinking_mode:f.elements.endpoint.value==="/messages"?f.elements.thinking_mode.value:"",reasoning_mapping:{enabled:f.elements.reasoning_enabled.checked,rules}};const models=structuredClone(state.config.models);if(index==='')models.push(m);else models[Number(index)]=m;await saveModels(models);$('modelDialog').close();toast('模型已保存')}catch(e){dialogError('modelDialog',e)}}; -$('testForm').onsubmit=async e=>{e.preventDefault();const identity=diagnosticIdentity(),f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;$('testResult').hidden=false;$('testResult').textContent='等待模型回复…';try{const result=await api('/models/test',{method:'POST',body:Object.fromEntries(new FormData(f))});if(!state.authenticated||identity!==diagnosticIdentity())return;$('testResult').textContent=JSON.stringify(result.response,null,2);toast('测试完成')}catch(e){if(state.authenticated&&identity===diagnosticIdentity()){$('testResult').textContent='';dialogError('testDialog',e)}}finally{b.disabled=false;if(state.authenticated&&identity===diagnosticIdentity())await loadModelEvidence().catch(globalError)}}; -$('settingsForm').onsubmit=async e=>{e.preventDefault();const f=e.currentTarget;try{const retention=Number(f.elements.statistics_retention_days.value);if(retention>0&&retention!==state.config.statistics_retention_days&&!await confirmed('设置历史自动过期','超过 '+retention+' 天的统计将被删除,之后延长保留期也无法恢复。继续保存?'))return;await api('/config',{method:'PUT',body:{statistics_retention_days:retention,timeout_seconds:Number(f.elements.timeout_seconds.value),first_byte_timeout_seconds:Number(f.elements.first_byte_timeout_seconds.value),stream_idle_timeout_seconds:Number(f.elements.stream_idle_timeout_seconds.value),log_retention:Number(f.elements.log_retention.value),usage_refresh_seconds:Number(f.elements.usage_refresh_seconds.value),max_response_bytes:Number(f.elements.max_response_mb.value)*1048576,zdr:f.elements.zdr.checked,cpamp_install_dir:f.elements.cpamp_install_dir.value.trim(),cpamp_config_path:f.elements.cpamp_config_path.value.trim(),cpamp_panel_path:f.elements.cpamp_panel_path.value.trim()}});await loadConfig();toast('设置已保存并生效,无需重启')}catch(e){globalError(e)}}; -$('detectCPAMPPanel').onclick=async()=>{try{await saveCPAMPPaths();await Promise.all([loadCPAMPPanel(),loadCompatibility()])}catch(e){globalError(e)}}; -$('installCPAMPPanel').onclick=async()=>{const button=$('installCPAMPPanel');button.disabled=true;try{await saveCPAMPPaths();await loadCPAMPPanel();const plan=state.cpampPanel;if(!plan?.installable)throw new Error(plan?.message||'尚未确认安装目标');if(!await confirmed(plan.hot_update_ready?'更新原生面板':'安装原生面板',[...(plan.planned_changes||[]),'操作完成后将检查实际服务页面。'].join('\n'))){button.disabled=false;return}const result=await api('/cpamp/panel/install',{method:'POST',body:{}});showCPAMPPanel(result);toast(result.message);await loadCompatibility().catch(globalError)}catch(e){globalError(e);button.disabled=false}}; -for(const name of ['cpamp_install_dir','cpamp_config_path','cpamp_panel_path'])$('settingsForm').elements[name].oninput=()=>{$('installCPAMPPanel').disabled=!state.cpampPanel?.available}; -$('addCredential').onclick=()=>editAccount();$('addModel').onclick=()=>editModel();$('fetchModels').onclick=showCatalog;$('catalogSearch').oninput=renderCatalog;$('selectCatalog').onclick=()=>{catalogFiltered().forEach(m=>state.selected.add(m.upstream_id));renderCatalog()};$('catalogRows').onchange=e=>{const id=e.target.dataset.catalogId;if(!id)return;if(e.target.checked)state.selected.add(id);else state.selected.delete(id);$('importCatalog').textContent='导入所选('+state.selected.size+')'}; -$('importCatalog').onclick=async()=>{try{if(!state.selected.size)throw new Error('请先选择模型');await saveModels([...state.config.models,...state.catalog.filter(m=>state.selected.has(m.upstream_id))]);$('catalogDialog').close();toast('模型已导入')}catch(e){dialogError('catalogDialog',e)}}; -$('credentialRows').onclick=async e=>{const b=e.target.closest('button');if(!b)return;if(b.hasAttribute('data-add-account'))return editAccount();if(b.dataset.accountSelect){state.activeCredential=b.dataset.accountSelect;renderCredentials();[...$('credentialRows').querySelectorAll('[data-account-select]')].find(x=>x.dataset.accountSelect===state.activeCredential)?.focus();return}if(b.dataset.accountEdit)return editAccount(b.dataset.accountEdit);try{if(b.dataset.accountRefresh){b.disabled=true;await api('/credentials/usage?id='+encodeURIComponent(b.dataset.accountRefresh)+'&refresh=1',{timeout:20000});await loadCredentials()}if(b.dataset.accountDelete&&await confirmed('删除凭据','此凭据会从 CPA 移除,后续请求将使用其他可用账号。')){await api('/credentials?id='+encodeURIComponent(b.dataset.accountDelete),{method:'DELETE'});await loadCredentials();toast('凭据已删除')}}catch(e){globalError(e)}finally{b.disabled=false}}; -$('modelCredential').onchange=()=>{state.modelCredential=$('modelCredential').value;loadModelEvidence().catch(globalError)}; -async function modelAction(e){const b=e.target.closest('button');if(!b)return;if(b.hasAttribute('data-fetch-catalog'))return showCatalog();if(b.hasAttribute('data-clear-model-filter')){$('modelSearch').value='';$('modelStatus').value='all';renderModels();$('modelSearch').focus();return}if(b.dataset.modelDetail!==undefined)return showModelDetail(Number(b.dataset.modelDetail));if(b.dataset.modelEdit!==undefined)return editModel(Number(b.dataset.modelEdit));try{if(b.dataset.modelTest!==undefined)return await showTest(Number(b.dataset.modelTest));if(b.dataset.modelDelete!==undefined&&await confirmed('移除模型映射','客户端将无法再通过此名称调用模型。')){await saveModels(state.config.models.filter((_,i)=>i!==Number(b.dataset.modelDelete)));toast('模型映射已移除')}}catch(e){globalError(e)}}; -$('modelRows').onclick=modelAction;$('detailContent').onclick=modelAction; -$('modelSearch').oninput=renderModels;$('modelStatus').onchange=renderModels; -$('credentialHealth').onclick=e=>{const b=e.target.closest('[data-health-credential]');if(!b)return;state.activeCredential=b.dataset.healthCredential;navigate('credentials')}; -$('logRows').onclick=e=>{const b=e.target.closest('[data-log-detail]');if(!b)return;const row=state.logs?.find(x=>x.id===b.dataset.logDetail);if(!row)return;state.detail='log';showDetail(row.model,'请求详情',logDetailHTML(row))}; -$('refreshUsage').onclick=async()=>{const b=$('refreshUsage');b.disabled=true;try{for(const c of state.credentials.filter(c=>!c.disabled))await api('/credentials/usage?id='+encodeURIComponent(c.id)+'&refresh=1',{timeout:20000});await loadCredentials();toast('用量已刷新')}catch(e){globalError(e)}finally{b.disabled=false}}; -$('clearAffinity').onclick=async()=>{if(!await confirmed('重新分配会话','清除本插件账号的旧会话绑定。下一条请求按当前优先级重新选账号,正在生成的回复继续完成。'))return;try{await api('/credentials/clear-affinity',{method:'POST',body:{},timeout:30000});toast('会话已重新分配 · 宿主已确认');await loadDiagnostics().catch(globalError)}catch(e){globalError(e)}}; -$('resetStats').onclick=async()=>{if(!await confirmed('清空统计','统计将从零开始,请求日志保留。插件会保存原统计的恢复副本。'))return;try{await api('/statistics/reset',{method:'POST',body:{}});await loadStats();toast('统计已清空')}catch(e){globalError(e)}}; -$('logStatus').onchange=()=>{state.offset=0;loadLogs().catch(globalError)}; -$('searchLogs').onclick=()=>{state.offset=0;loadLogs().catch(globalError)};$('logSearch').onkeydown=e=>{if(e.key==='Enter')$('searchLogs').click()};$('prevLogs').onclick=()=>{state.offset=Math.max(0,state.offset-50);loadLogs().catch(globalError)};$('nextLogs').onclick=()=>{state.offset+=50;loadLogs().catch(globalError)};$('applyStats').onclick=()=>loadStats().catch(globalError);$('statsRange').onchange=()=>{$('statsFrom').hidden=$('statsTo').hidden=$('statsRange').value!=='custom'}; -function setTheme(theme,save=false){document.documentElement.dataset.theme=theme;if(save)localStorage.setItem('commandcodebridge:theme',theme)}try{let theme=localStorage.getItem('commandcodebridge:theme');if(!theme&&parent!==window&&parent.location.origin===location.origin){const root=parent.document.documentElement;theme=root.classList.contains('dark')||root.dataset.theme==='dark'?'dark':'light'}setTheme(theme||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'))}catch{setTheme('light')}$('themeButton').onclick=()=>setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark',true); -setInterval(()=>{if(state.authenticated&&!state.blocked&&!state.busy&&!document.hidden&&!document.querySelector('dialog[open]')&&!document.activeElement?.closest('#credentialRows,#credentialHealth')&&['credentials','overview'].includes(state.view))loadCredentials().catch(globalError)},15000); -(async()=>{try{const h=window.CommandCodeBridgeAuthHeaders?.()||{};if(!Object.keys(h).length){openDialog('authDialog');return}const status=await api('/status',{verify:true,timeout:15000});state.authenticated=true;$('authButton').textContent='管理身份';$('version').textContent='v'+status.version;await loadConfig();await loadCredentials();await refresh()}catch(e){globalError(e)}})(); +function editAccount(cid) { + const f = $('credentialForm'); + f.reset(); + const c = state.credentials.find((c) => c.id === cid); + f.elements.id.value = cid || ''; + f.elements.api_key.required = !c; + if (c) { + f.elements.label.value = c.label; + f.elements.priority.value = c.priority; + f.elements.disabled.checked = c.disabled; + } + $('credentialTitle').textContent = c ? '编辑凭据' : '添加凭据'; + openDialog('credentialDialog'); +} +const defaultAlias = (upstream) => upstream.trim().split('/').pop(); +let lastSuggestedAlias = ''; +$('modelForm').elements.upstream_id.oninput = (e) => { + const f = $('modelForm'); + if ( + f.elements.index.value !== '' || + (f.elements.id.value && f.elements.id.value !== lastSuggestedAlias) + ) + return; + lastSuggestedAlias = defaultAlias(e.target.value); + f.elements.id.value = lastSuggestedAlias; +}; +function editModel(index) { + const f = $('modelForm'); + f.reset(); + lastSuggestedAlias = ''; + const m = state.config?.models[index]; + f.elements.index.value = m ? index : ''; + if (m) { + f.elements.id.value = m.id; + f.elements.upstream_id.value = m.upstream_id; + f.elements.name.value = m.name || ''; + f.elements.endpoint.value = m.supported_endpoints.includes('/chat/completions') + ? '/chat/completions' + : '/messages'; + f.elements.thinking_mode.value = m.thinking_mode || 'adaptive'; + f.elements.reasoning_enabled.checked = m.reasoning_mapping.enabled; + f.elements.rules.value = JSON.stringify(m.reasoning_mapping.rules || {}, null, 2); + } + $('modelTitle').textContent = m ? '编辑模型' : '添加模型'; + openDialog('modelDialog'); +} +async function saveModels(models) { + const aliases = new Set(); + for (const m of models) { + if (aliases.has(m.id)) + throw new Error('客户端名称重复:' + m.id + '。请减少所选模型,或手动添加并设置不同名称。'); + aliases.add(m.id); + } + await api('/models', { method: 'PUT', body: { models } }); + await loadConfig(); + await loadModelEvidence(); +} +async function showCatalog() { + const identity = diagnosticIdentity(); + try { + const result = await api('/models/refresh', { method: 'POST', body: {}, timeout: 20000 }); + if (!state.authenticated || identity !== diagnosticIdentity()) return; + state.catalog = result.models; + state.selected.clear(); + $('catalogSearch').value = ''; + renderCatalog(); + openDialog('catalogDialog'); + } catch (e) { + if (state.authenticated && identity === diagnosticIdentity()) globalError(e); + } +} +function catalogFiltered() { + const text = $('catalogSearch').value.toLowerCase(); + const existing = new Set((state.config?.models || []).map((m) => m.upstream_id)); + return state.catalog.filter( + (m) => + !existing.has(m.upstream_id) && + (!text || (m.name + ' ' + m.upstream_id).toLowerCase().includes(text)) + ); +} +function renderCatalog() { + const rows = catalogFiltered(); + $('catalogRows').innerHTML = rows.length + ? rows + .map( + (m) => + `` + ) + .join('') + : '
没有可导入的模型。
'; + $('importCatalog').textContent = + `导入所选${state.selected.size ? '(' + state.selected.size + ')' : ''}`; +} +async function showTest(index) { + const identity = diagnosticIdentity(); + await loadCredentials(); + if (!state.authenticated || identity !== diagnosticIdentity()) return; + const m = state.config.models[index], + f = $('testForm'); + f.reset(); + f.elements.model.value = m.id; + $('testModelName').textContent = m.id; + f.elements.credential_id.innerHTML = state.credentials + .filter((c) => !c.disabled) + .map((c) => ``) + .join(''); + if (state.modelCredential) f.elements.credential_id.value = state.modelCredential; + $('testResult').hidden = true; + openDialog('testDialog'); +} +document.querySelectorAll('dialog').forEach((d) => { + d.addEventListener('close', () => restoreDialogFocus(d.id)); + d.setAttribute( + 'aria-labelledby', + d.querySelector('h2').id || (d.querySelector('h2').id = d.id + 'Title') + ); +}); +document + .querySelectorAll('[data-view]') + .forEach((b) => (b.onclick = () => navigate(b.dataset.view))); +document + .querySelectorAll('[data-jump]') + .forEach((b) => (b.onclick = () => navigate(b.dataset.jump))); +document + .querySelectorAll('[data-close]') + .forEach((b) => (b.onclick = () => $(b.dataset.close).close())); +document.querySelectorAll('[data-action="refresh"]').forEach((b) => (b.onclick = refresh)); +$('authButton').onclick = () => { + $('rememberAuth').checked = window.CommandCodeBridgeRememberAuth?.() || false; + openDialog('authDialog'); +}; +$('clearAuth').onclick = () => { + window.CommandCodeBridgeClearAuth?.(); + resetDiagnosticViews(); + state.key = ''; + state.authenticated = false; + state.blocked = true; + $('managementKey').value = ''; + toast('已退出登录'); +}; +$('authForm').onsubmit = async (e) => { + e.preventDefault(); + resetDiagnosticViews(); + state.authenticated = false; + state.key = $('managementKey').value.trim(); + state.authType = $('authType').value; + try { + const status = await api('/status', { verify: true, timeout: 15000 }); + window.CommandCodeBridgeSaveAuth?.(state.key, state.authType, $('rememberAuth').checked); + state.authenticated = true; + $('authButton').textContent = '管理身份'; + state.blocked = false; + $('managementKey').value = ''; + $('authDialog').close(); + $('version').textContent = 'v' + status.version; + await loadConfig(); + await loadCredentials(); + await refresh(); + toast('已登录'); + } catch (e) { + dialogError('authDialog', e); + } +}; +$('credentialForm').onsubmit = async (e) => { + e.preventDefault(); + const f = e.currentTarget, + cid = f.elements.id.value, + body = { + label: f.elements.label.value, + priority: Number(f.elements.priority.value), + disabled: f.elements.disabled.checked, + }; + if (f.elements.api_key.value.trim()) body.api_key = f.elements.api_key.value.trim(); + try { + const saved = await api('/credentials' + (cid ? '?id=' + encodeURIComponent(cid) : ''), { + method: cid ? 'PUT' : 'POST', + body, + }); + f.elements.api_key.value = ''; + $('credentialDialog').close(); + toast('凭据已保存 · ' + (saved.registration?.message || '宿主登记待确认')); + await Promise.all([loadCredentials(), loadDiagnostics()]).catch(globalError); + } catch (e) { + dialogError('credentialDialog', e); + } +}; +$('modelForm').onsubmit = async (e) => { + e.preventDefault(); + const f = e.currentTarget; + try { + const rules = JSON.parse(f.elements.rules.value); + if (!rules || Array.isArray(rules) || typeof rules !== 'object') + throw new Error('映射规则必须是 JSON 对象'); + const index = f.elements.index.value, + old = index === '' ? {} : state.config.models[Number(index)], + m = { + ...old, + id: f.elements.id.value.trim(), + upstream_id: f.elements.upstream_id.value.trim(), + name: f.elements.name.value.trim(), + supported_endpoints: [f.elements.endpoint.value], + thinking_mode: + f.elements.endpoint.value === '/messages' ? f.elements.thinking_mode.value : '', + reasoning_mapping: { enabled: f.elements.reasoning_enabled.checked, rules }, + }; + const models = structuredClone(state.config.models); + if (index === '') models.push(m); + else models[Number(index)] = m; + await saveModels(models); + $('modelDialog').close(); + toast('模型已保存'); + } catch (e) { + dialogError('modelDialog', e); + } +}; +$('testForm').onsubmit = async (e) => { + e.preventDefault(); + const identity = diagnosticIdentity(), + f = e.currentTarget, + b = f.querySelector('[type=submit]'); + b.disabled = true; + $('testResult').hidden = false; + $('testResult').textContent = '等待模型回复…'; + try { + const result = await api('/models/test', { + method: 'POST', + body: Object.fromEntries(new FormData(f)), + }); + if (!state.authenticated || identity !== diagnosticIdentity()) return; + $('testResult').textContent = JSON.stringify(result.response, null, 2); + toast('测试完成'); + } catch (e) { + if (state.authenticated && identity === diagnosticIdentity()) { + $('testResult').textContent = ''; + dialogError('testDialog', e); + } + } finally { + b.disabled = false; + if (state.authenticated && identity === diagnosticIdentity()) + await loadModelEvidence().catch(globalError); + } +}; +$('settingsForm').onsubmit = async (e) => { + e.preventDefault(); + const f = e.currentTarget; + try { + const retention = Number(f.elements.statistics_retention_days.value); + if ( + retention > 0 && + retention !== state.config.statistics_retention_days && + !(await confirmed( + '设置历史自动过期', + '超过 ' + retention + ' 天的统计将被删除,之后延长保留期也无法恢复。继续保存?' + )) + ) + return; + await api('/config', { + method: 'PUT', + body: { + statistics_retention_days: retention, + timeout_seconds: Number(f.elements.timeout_seconds.value), + first_byte_timeout_seconds: Number(f.elements.first_byte_timeout_seconds.value), + stream_idle_timeout_seconds: Number(f.elements.stream_idle_timeout_seconds.value), + log_retention: Number(f.elements.log_retention.value), + usage_refresh_seconds: Number(f.elements.usage_refresh_seconds.value), + max_response_bytes: Number(f.elements.max_response_mb.value) * 1048576, + zdr: f.elements.zdr.checked, + cpamp_install_dir: f.elements.cpamp_install_dir.value.trim(), + cpamp_config_path: f.elements.cpamp_config_path.value.trim(), + cpamp_panel_path: f.elements.cpamp_panel_path.value.trim(), + }, + }); + await loadConfig(); + toast('设置已保存并生效,无需重启'); + } catch (e) { + globalError(e); + } +}; +$('detectCPAMPPanel').onclick = async () => { + try { + await saveCPAMPPaths(); + await Promise.all([loadCPAMPPanel(), loadCompatibility()]); + } catch (e) { + globalError(e); + } +}; +$('installCPAMPPanel').onclick = async () => { + const button = $('installCPAMPPanel'); + button.disabled = true; + try { + await saveCPAMPPaths(); + await loadCPAMPPanel(); + const plan = state.cpampPanel; + if (!plan?.installable) throw new Error(plan?.message || '尚未确认安装目标'); + if ( + !(await confirmed( + plan.hot_update_ready ? '更新原生面板' : '安装原生面板', + [...(plan.planned_changes || []), '操作完成后将检查实际服务页面。'].join('\n') + )) + ) { + button.disabled = false; + return; + } + const result = await api('/cpamp/panel/install', { method: 'POST', body: {} }); + showCPAMPPanel(result); + toast(result.message); + await loadCompatibility().catch(globalError); + } catch (e) { + globalError(e); + button.disabled = false; + } +}; +for (const name of ['cpamp_install_dir', 'cpamp_config_path', 'cpamp_panel_path']) + $('settingsForm').elements[name].oninput = () => { + $('installCPAMPPanel').disabled = !state.cpampPanel?.available; + }; +$('addCredential').onclick = () => editAccount(); +$('addModel').onclick = () => editModel(); +$('fetchModels').onclick = showCatalog; +$('catalogSearch').oninput = renderCatalog; +$('selectCatalog').onclick = () => { + catalogFiltered().forEach((m) => state.selected.add(m.upstream_id)); + renderCatalog(); +}; +$('catalogRows').onchange = (e) => { + const id = e.target.dataset.catalogId; + if (!id) return; + if (e.target.checked) state.selected.add(id); + else state.selected.delete(id); + $('importCatalog').textContent = '导入所选(' + state.selected.size + ')'; +}; +$('importCatalog').onclick = async () => { + try { + if (!state.selected.size) throw new Error('请先选择模型'); + await saveModels([ + ...state.config.models, + ...state.catalog.filter((m) => state.selected.has(m.upstream_id)), + ]); + $('catalogDialog').close(); + toast('模型已导入'); + } catch (e) { + dialogError('catalogDialog', e); + } +}; +$('credentialRows').onclick = async (e) => { + const b = e.target.closest('button'); + if (!b) return; + if (b.hasAttribute('data-add-account')) return editAccount(); + if (b.dataset.accountSelect) { + state.activeCredential = b.dataset.accountSelect; + renderCredentials(); + [...$('credentialRows').querySelectorAll('[data-account-select]')] + .find((x) => x.dataset.accountSelect === state.activeCredential) + ?.focus(); + return; + } + if (b.dataset.accountEdit) return editAccount(b.dataset.accountEdit); + try { + if (b.dataset.accountRefresh) { + b.disabled = true; + await api( + '/credentials/usage?id=' + encodeURIComponent(b.dataset.accountRefresh) + '&refresh=1', + { timeout: 20000 } + ); + await loadCredentials(); + } + if ( + b.dataset.accountDelete && + (await confirmed('删除凭据', '此凭据会从 CPA 移除,后续请求将使用其他可用账号。')) + ) { + await api('/credentials?id=' + encodeURIComponent(b.dataset.accountDelete), { + method: 'DELETE', + }); + await loadCredentials(); + toast('凭据已删除'); + } + } catch (e) { + globalError(e); + } finally { + b.disabled = false; + } +}; +$('modelCredential').onchange = () => { + state.modelCredential = $('modelCredential').value; + loadModelEvidence().catch(globalError); +}; +async function modelAction(e) { + const b = e.target.closest('button'); + if (!b) return; + if (b.hasAttribute('data-fetch-catalog')) return showCatalog(); + if (b.hasAttribute('data-clear-model-filter')) { + $('modelSearch').value = ''; + $('modelStatus').value = 'all'; + renderModels(); + $('modelSearch').focus(); + return; + } + if (b.dataset.modelDetail !== undefined) return showModelDetail(Number(b.dataset.modelDetail)); + if (b.dataset.modelEdit !== undefined) return editModel(Number(b.dataset.modelEdit)); + try { + if (b.dataset.modelTest !== undefined) return await showTest(Number(b.dataset.modelTest)); + if ( + b.dataset.modelDelete !== undefined && + (await confirmed('移除模型映射', '客户端将无法再通过此名称调用模型。')) + ) { + await saveModels(state.config.models.filter((_, i) => i !== Number(b.dataset.modelDelete))); + toast('模型映射已移除'); + } + } catch (e) { + globalError(e); + } +} +$('modelRows').onclick = modelAction; +$('detailContent').onclick = modelAction; +$('modelSearch').oninput = renderModels; +$('modelStatus').onchange = renderModels; +$('credentialHealth').onclick = (e) => { + const b = e.target.closest('[data-health-credential]'); + if (!b) return; + state.activeCredential = b.dataset.healthCredential; + navigate('credentials'); +}; +$('logRows').onclick = (e) => { + const b = e.target.closest('[data-log-detail]'); + if (!b) return; + const row = state.logs?.find((x) => x.id === b.dataset.logDetail); + if (!row) return; + state.detail = 'log'; + showDetail(row.model, '请求详情', logDetailHTML(row)); +}; +$('refreshUsage').onclick = async () => { + const b = $('refreshUsage'); + b.disabled = true; + try { + for (const c of state.credentials.filter((c) => !c.disabled)) + await api('/credentials/usage?id=' + encodeURIComponent(c.id) + '&refresh=1', { + timeout: 20000, + }); + await loadCredentials(); + toast('用量已刷新'); + } catch (e) { + globalError(e); + } finally { + b.disabled = false; + } +}; +$('clearAffinity').onclick = async () => { + if ( + !(await confirmed( + '重新分配会话', + '清除本插件账号的旧会话绑定。下一条请求按当前优先级重新选账号,正在生成的回复继续完成。' + )) + ) + return; + try { + await api('/credentials/clear-affinity', { method: 'POST', body: {}, timeout: 30000 }); + toast('会话已重新分配 · 宿主已确认'); + await loadDiagnostics().catch(globalError); + } catch (e) { + globalError(e); + } +}; +$('resetStats').onclick = async () => { + if (!(await confirmed('清空统计', '统计将从零开始,请求日志保留。插件会保存原统计的恢复副本。'))) + return; + try { + await api('/statistics/reset', { method: 'POST', body: {} }); + await loadStats(); + toast('统计已清空'); + } catch (e) { + globalError(e); + } +}; +$('logStatus').onchange = () => { + state.offset = 0; + loadLogs().catch(globalError); +}; +$('searchLogs').onclick = () => { + state.offset = 0; + loadLogs().catch(globalError); +}; +$('logSearch').onkeydown = (e) => { + if (e.key === 'Enter') $('searchLogs').click(); +}; +$('prevLogs').onclick = () => { + state.offset = Math.max(0, state.offset - 50); + loadLogs().catch(globalError); +}; +$('nextLogs').onclick = () => { + state.offset += 50; + loadLogs().catch(globalError); +}; +$('applyStats').onclick = () => loadStats().catch(globalError); +$('statsRange').onchange = () => { + $('statsFrom').hidden = $('statsTo').hidden = $('statsRange').value !== 'custom'; +}; +function setTheme(theme, save = false) { + document.documentElement.dataset.theme = theme; + if (save) localStorage.setItem('commandcodebridge:theme', theme); +} +try { + let theme = localStorage.getItem('commandcodebridge:theme'); + if (!theme && parent !== window && parent.location.origin === location.origin) { + const root = parent.document.documentElement; + theme = root.classList.contains('dark') || root.dataset.theme === 'dark' ? 'dark' : 'light'; + } + setTheme(theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')); +} catch { + setTheme('light'); +} +$('themeButton').onclick = () => + setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark', true); +setInterval(() => { + if ( + state.authenticated && + !state.blocked && + !state.busy && + !document.hidden && + !document.querySelector('dialog[open]') && + !document.activeElement?.closest('#credentialRows,#credentialHealth') && + ['credentials', 'overview'].includes(state.view) + ) + loadCredentials().catch(globalError); +}, 15000); +(async () => { + try { + const h = window.CommandCodeBridgeAuthHeaders?.() || {}; + if (!Object.keys(h).length) { + openDialog('authDialog'); + return; + } + const status = await api('/status', { verify: true, timeout: 15000 }); + state.authenticated = true; + $('authButton').textContent = '管理身份'; + $('version').textContent = 'v' + status.version; + await loadConfig(); + await loadCredentials(); + await refresh(); + } catch (e) { + globalError(e); + } +})(); diff --git a/tests/console-diagnostics.test.cjs b/tests/console-diagnostics.test.cjs index 80824c9..ef22b4f 100644 --- a/tests/console-diagnostics.test.cjs +++ b/tests/console-diagnostics.test.cjs @@ -4,6 +4,12 @@ const path = require('node:path'); const vm = require('node:vm'); const { test } = require('node:test'); const script = fs.readFileSync(path.join(__dirname, '../internal/bridge/ui/console.js'), 'utf8'); +// Slice the formatted console source between two unique markers; fail loudly if either moves. +function between(text, start, end) { + const from = text.indexOf(start), to = text.indexOf(end, from); + assert.ok(from >= 0 && to > from && text.indexOf(start, from + 1) < 0, `console.js markers not found: ${start} … ${end}`); + return text.slice(from, to); +} function fixture(api = async () => ({})) { const nodes = new Map(); const context = { @@ -13,7 +19,7 @@ function fixture(api = async () => ({})) { $: id => { if (!nodes.has(id)) nodes.set(id, {}); return nodes.get(id); }, }; vm.createContext(context); - vm.runInContext(script.slice(script.indexOf('const diagnosticStatus='), script.indexOf("$('runDiagnostics').onclick")), context); + vm.runInContext(between(script, 'const diagnosticStatus =', "$('runDiagnostics').onclick"), context); return context; } function report(label) { @@ -56,7 +62,7 @@ test('rejected management access immediately clears rendered diagnostics', async fetch: async () => ({ok: false, status: 401, text: async () => '{"error":"unauthorized"}'}), apiURL: value => value, AbortSignal, openDialog: () => {}, }); - vm.runInContext(script.slice(script.indexOf('async function api('), script.indexOf('async function confirmed(')), context); + vm.runInContext(between(script, 'async function api(', 'async function confirmed('), context); await assert.rejects(context.api('/diagnostics'), /unauthorized/); assert.equal(context.state.authenticated, false); assert.equal(context.$('diagnosticResults').textContent, '登录后重新诊断'); diff --git a/tests/console-evidence.test.cjs b/tests/console-evidence.test.cjs index 3deef74..342eea9 100644 --- a/tests/console-evidence.test.cjs +++ b/tests/console-evidence.test.cjs @@ -3,6 +3,12 @@ const fs = require('node:fs'); const vm = require('node:vm'); const {test} = require('node:test'); const script = fs.readFileSync(require('node:path').join(__dirname,'../internal/bridge/ui/console.js'),'utf8'); +// Slice the formatted console source between two unique markers; fail loudly if either moves. +function between(text, start, end) { + const from = text.indexOf(start), to = text.indexOf(end, from); + assert.ok(from >= 0 && to > from && text.indexOf(start, from + 1) < 0, `console.js markers not found: ${start} … ${end}`); + return text.slice(from, to); +} function fixture(api) { const nodes = new Map(); const context = {state:{authenticated:true,key:'fixture',credentials:[{id:'one',label:'one'},{id:'two',label:'two'}]}, @@ -11,7 +17,7 @@ function fixture(api) { esc:x=>String(x??'').replace(/x,when:x=>x,fmt:x=>x, $:id=>{if(!nodes.has(id))nodes.set(id,{});return nodes.get(id)}}; vm.createContext(context); - vm.runInContext(script.slice(script.indexOf('const modelEvidenceLabels='),script.indexOf('function renderModels()')),context); + vm.runInContext(between(script, 'const modelEvidenceLabels =', 'function renderModels()'),context); return context; } test('evidence selector discards delayed results from another credential',async()=>{ @@ -37,7 +43,7 @@ test('evidence escapes protocol and distinguishes source, expiry and generic den test('model permission failures do not log out management access',async()=>{ const c=fixture();Object.assign(c,{AbortSignal,apiURL:x=>x,openDialog:()=>{},resetDiagnosticViews:()=>{throw Error('unexpected logout')}, fetch:async()=>({ok:false,status:403,text:async()=>' {"error":"model permission denied"}'})}); - vm.runInContext(script.slice(script.indexOf('async function api('),script.indexOf('async function confirmed(')),c); + vm.runInContext(between(script, 'async function api(', 'async function confirmed('),c); await assert.rejects(c.api('/models/test'),/model permission denied/); assert.equal(c.state.authenticated,true); }); diff --git a/tests/console-ui.test.cjs b/tests/console-ui.test.cjs index 78782d1..99c9537 100644 --- a/tests/console-ui.test.cjs +++ b/tests/console-ui.test.cjs @@ -3,10 +3,16 @@ const fs = require('node:fs'); const vm = require('node:vm'); const {test} = require('node:test'); const source = fs.readFileSync(require('node:path').join(__dirname, '../internal/bridge/ui/console.js'), 'utf8'); +// Slice the formatted console source between two unique markers; fail loudly if either moves. +function between(text, start, end) { + const from = text.indexOf(start), to = text.indexOf(end, from); + assert.ok(from >= 0 && to > from && text.indexOf(start, from + 1) < 0, `console.js markers not found: ${start} … ${end}`); + return text.slice(from, to); +} const escape = value => String(value ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); function functions(start, end, context) { vm.createContext(context); - vm.runInContext(source.slice(source.indexOf(start), source.indexOf(end)), context); + vm.runInContext(between(source, start, end), context); return context; } test('model filters combine evidence with case-insensitive aliases and retain original action indices', () => { @@ -46,13 +52,13 @@ test('closing a drawer after list refresh restores focus to its replacement trig let focused='';const old={isConnected:false,dataset:{logDetail:'request'}},replacement={dataset:{logDetail:'request'},focus(){focused='replacement'}}; const dialog={open:false,querySelectorAll:()=>[],showModal(){this.open=true}}; const document={activeElement:old,querySelector:()=>null,querySelectorAll:()=>[replacement]}; - const c=functions('const dialogOrigins=', 'function apiURL(',{state:{view:'logs'},document,$:()=>dialog}); + const c=functions('const dialogOrigins =', 'function apiURL(',{state:{view:'logs'},document,$:()=>dialog}); c.openDialog('detailDialog');c.restoreDialogFocus('detailDialog');assert.equal(focused,'replacement'); }); test('closing an older dialog never steals focus from the next modal',()=>{ let focused=false;const dialog={open:false,querySelectorAll:()=>[],showModal(){this.open=true}}; const document={activeElement:{isConnected:true,dataset:{},focus(){focused=true}},querySelector:()=>({open:true})}; - const c=functions('const dialogOrigins=', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog}); + const c=functions('const dialogOrigins =', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog}); c.openDialog('detailDialog');c.restoreDialogFocus('detailDialog');assert.equal(focused,false); }); @@ -62,14 +68,14 @@ test('focus restoration skips the hidden desktop trigger after a mobile layout c const visible={dataset:{modelDetail:'2'},getClientRects:()=>[{}],focus(){focused='visible'}}; const dialog={open:false,querySelectorAll:()=>[],showModal(){this.open=true}}; const document={activeElement:old,querySelector:()=>null,querySelectorAll:()=>[hidden,visible]}; - const c=functions('const dialogOrigins=', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog}); + const c=functions('const dialogOrigins =', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog}); c.openDialog('detailDialog');c.restoreDialogFocus('detailDialog');assert.equal(focused,'visible'); }); test('pending storage and recovery rows escape filenames and preserve original observation times',()=>{ const nodes=new Map();const c={$:id=>{if(!nodes.has(id))nodes.set(id,{});return nodes.get(id)},esc:escape,fmt:String,when:x=>x||'未知'}; functions('function statusRows(', 'function apiURL(',c); - vm.runInContext(source.slice(source.indexOf('function renderStorage('),source.indexOf('async function loadStorage(')),c); + vm.runInContext(between(source, 'function renderStorage(', 'async function loadStorage('),c); c.renderStorage({precision:'minute/day',last_success_at:'original time',pending_files:['usage.json'],pending_since:'pending time',statistics_buckets:7,recovery_warnings:[{file:'',message:'',checked_at:'recovery time'}]}); const html=c.$('storageStatus').innerHTML; for(const value of ['original time','pending time','usage.json','history gap','recovery time','恢复后的历史可能不完整'])assert.ok(html.includes(value)); @@ -80,14 +86,14 @@ test('catalog and test dialogs cannot reopen after management logout during load let resolve;const state={authenticated:true,key:'old',catalog:[]};const c={state,diagnosticIdentity:()=>state.key,api:()=>new Promise(done=>resolve=done),loadCredentials:()=>new Promise(done=>resolve=done),openDialog:()=>assert.fail('stale dialog opened'),globalError:()=>assert.fail('stale error rendered')}; functions('async function showCatalog(', 'function catalogFiltered(',c); const catalog=c.showCatalog();state.authenticated=false;state.key='';resolve({models:[{id:'private'}]});await catalog;assert.equal(state.catalog.length,0); - vm.runInContext(source.slice(source.indexOf('async function showTest('),source.indexOf("document.querySelectorAll('dialog')")),c); + vm.runInContext(between(source, 'async function showTest(', "document.querySelectorAll('dialog')"),c); state.authenticated=true;state.key='new';const test=c.showTest(0);state.authenticated=false;resolve();await test; }); test('late direct-test responses never restore output or evidence after logout',async()=>{ let resolve;const nodes=new Map(),state={authenticated:true,key:'one'},button={}; const c={state,diagnosticIdentity:()=>state.key,$:id=>{if(!nodes.has(id))nodes.set(id,{});return nodes.get(id)},api:()=>new Promise(done=>resolve=done),FormData:class{*[Symbol.iterator](){yield ['model','mock']}},toast:()=>assert.fail('stale success'),loadModelEvidence:()=>assert.fail('stale refresh'),dialogError:()=>assert.fail('stale error')}; - vm.createContext(c);vm.runInContext(source.slice(source.indexOf("$('testForm').onsubmit="),source.indexOf("$('settingsForm').onsubmit=")),c); + vm.createContext(c);vm.runInContext(between(source, "$('testForm').onsubmit =", "$('settingsForm').onsubmit ="),c); const request=c.$('testForm').onsubmit({preventDefault(){},currentTarget:{querySelector:()=>button}}); state.authenticated=false;state.key='two';c.$('testResult').textContent='';resolve({response:'private'});await request; assert.equal(c.$('testResult').textContent,'');assert.equal(button.disabled,false);