dsh-cliproxyapi-pro 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -26,6 +26,9 @@ window.__ModuleLoader__.load({
26
26
  updateCheck: "api/dsh-cliproxyapi/updates/check",
27
27
  updateSave: "api/dsh-cliproxyapi/updates/save",
28
28
  updateDownload: "api/dsh-cliproxyapi/updates/download",
29
+ quotaStatus: "api/dsh-cliproxyapi/quota/status",
30
+ quotaRefresh: "api/dsh-cliproxyapi/quota/refresh",
31
+ quotaSave: "api/dsh-cliproxyapi/quota/save",
29
32
  };
30
33
 
31
34
  // Public, versioned dshmarket Update API (UPDATE-API-V1.md in dshmarket).
@@ -41,48 +44,32 @@ window.__ModuleLoader__.load({
41
44
 
42
45
  const zh = {
43
46
  nav: "CLIProxyAPI",
44
- title: "CLIProxyAPI 自动发现与模型筛选",
45
- subtitle:
46
- "自动连接 CLIProxyAPI 服务,勾选你希望在对话框模型选择器中显示的模型,自动同步上下文长度、视觉与推理档位。",
47
47
  statusConnected: "已连接并同步",
48
48
  statusProbing: "正在探测同步…",
49
49
  statusError: "连接异常",
50
50
  statusDisabled: "已停用",
51
51
  statusIdle: "等待同步",
52
- endpointLabel: "当前活动节点",
53
- endpointNone: "未连接(将依次尝试配置地址与本地 8317 端口)",
54
- modelsCountLabel: "选择器展示模型",
55
- refreshedAtLabel: "上次同步时间",
56
52
  refreshBtn: "立即探测刷新",
57
53
  refreshingBtn: "正在探测…",
58
- saveBtn: "保存设置并应用",
54
+ saveBtn: "保存并应用",
59
55
  savingBtn: "正在应用…",
60
- saveSelectionBtn: "保存模型勾选",
61
- unsavedChanges: "有未保存的勾选更改",
56
+ unsavedChanges: "有未保存的更改",
62
57
  savedToast: "已保存并同步到对话框模型选择器",
63
- sectionConnection: "连接与认证配置",
64
58
  accountsLabel: "账号",
65
59
  addAccountBtn: "+ 添加账号",
66
- addingAccountBtn: "正在添加…",
67
60
  removeAccountBtn: "删除此账号",
68
61
  removeConfirm: "确定删除账号「{name}」吗?它同步到模型选择器中的模型路由也会一并移除(已保存的密钥不会删除)。",
69
- noAccounts: "还没有任何账号。点击「添加账号」接入一个 CLIProxyAPI 服务。",
70
- accountOffBadge: "未启用",
71
- addedToast: "已添加新账号(默认未启用)。填写服务地址与密钥后,打开「启用此账号」并保存。",
62
+ addedToast: "账号已添加并启用,已按每个家族勾选最新模型。",
72
63
  removedToast: "账号已删除,其模型已从模型选择器中移除",
73
- disabledHint:
74
- "该账号未启用,它的模型不会出现在对话框模型选择器中。可先填写地址与密钥并点击「立即探测刷新」预览模型列表,勾选后再开启「启用此账号」。",
64
+ disabledHint: "未启用:模型不在模型选择器中,配置已保留。",
75
65
  enabledLabel: "启用此账号",
76
- enabledHint: "开启后将该账号下勾选的模型同步到 DSH 对话框模型选择器;关闭后保留配置,但移除该账号的模型。",
77
- baseUrlLabel: "CLIProxyAPI 服务地址 (Base URL)",
78
- baseUrlPlaceholder: "留空则自动继承现有路由或探测 http://127.0.0.1:8317",
79
- baseUrlHint: "支持填写根地址(如 http://127.0.0.1:8317 或远程 IP:8317),无需手动拼接 /v1。",
80
- apiKeyLabel: "API Key 密钥",
66
+ enabledHint: "开启后,该账号勾选的模型会出现在对话框模型选择器中;关闭后移除这些模型,配置保留。",
67
+ baseUrlLabel: "服务地址",
68
+ baseUrlPlaceholder: "如 http://127.0.0.1:8317,无需加 /v1;留空自动发现",
69
+ apiKeyLabel: "API Key",
81
70
  apiKeySetPlaceholder: "已配置密钥(留空保持不变,输入新值可覆盖)",
82
71
  apiKeyUnsetPlaceholder: "输入 CLIProxyAPI 访问密钥(本地免密模式可留空)",
83
- apiKeyHint: "密钥通过 DSH 凭证服务安全存储在 .credentials.yaml 中,绝不明文写入配置文件。",
84
- apiKeyConfiguredBadge: "密钥已配置",
85
- apiKeyUnsetBadge: "未设置密钥",
72
+ apiKeyHint: "保存在 DSH 凭证服务({ref}),不会写入配置文件。本地免密的 CPA 可留空。",
86
73
  clearKeyBtn: "清除密钥",
87
74
  protocolLabel: "接口协议",
88
75
  protocolHint: "自动模式下每个模型走所属家族的原生协议,减少 CPA 中转转换;Gemini 等 DSH 不支持原生协议的模型走 OpenAI Chat。",
@@ -95,22 +82,19 @@ window.__ModuleLoader__.load({
95
82
  modelProtocolDefault: "跟随全局设置",
96
83
  modelProtocolHint: "原生协议:",
97
84
  sectionAdvanced: "高级路由与规则过滤",
98
- providerIdLabel: "DSH 路由 ID (Provider)",
85
+ providerIdLabel: "路由 ID",
99
86
  providerIdHint: "写入 llm-pi-ai 的提供商路由标识,每个账号必须不同(首个账号默认 cli-proxy,与现有配置无缝衔接)。",
100
- apiKeyEnvLabel: "密钥引用名 (Credential Ref)",
87
+ apiKeyEnvLabel: "密钥引用名",
101
88
  apiKeyEnvHint: "密钥在 DSH 凭证服务中的名称,每个账号建议使用不同的引用名。",
102
- displayNameLabel: "账号名称 (Display Name)",
89
+ displayNameLabel: "账号名称",
103
90
  displayNameHint: "用于区分账号,并作为 DSH 对话框模型选择器中的分组名称。",
104
- includePrefixLabel: "仅包含模型关键词 (可选)",
91
+ includePrefixLabel: "仅包含关键词",
105
92
  includePrefixPlaceholder: "例如: gpt-6, claude-opus, gemini-4 (多个用逗号分隔)",
106
93
  includePrefixHint: "附加规则:只允许模型 ID 包含指定关键词的模型。",
107
- excludePrefixLabel: "排除模型关键词 (可选)",
94
+ excludePrefixLabel: "排除关键词",
108
95
  excludePrefixPlaceholder: "例如: tts, image, video, zj/ (多个用逗号分隔)",
109
96
  excludePrefixHint: "附加规则:排除包含指定关键词的模型 ID。",
110
- refreshIntervalLabel: "自动刷新间隔 (秒,所有账号共用)",
111
- sectionModels: "模型显示筛选(仅勾选的模型会在回答选择器中显示)",
112
- sectionModelsHint:
113
- "点击模型行即可勾选或取消。不勾选任何模型(全部清空)时默认不限制;勾选后仅展示选中的模型。",
97
+ refreshIntervalLabel: "自动刷新间隔",
114
98
  searchModelsPlaceholder: "搜索模型 ID 或名称(如 claude-opus, gpt-6, gemini-4, swe)…",
115
99
  filterAll: "全部发现",
116
100
  filterSelected: "已勾选",
@@ -119,7 +103,7 @@ window.__ModuleLoader__.load({
119
103
  selectAllVisibleBtn: "全选当前列表",
120
104
  deselectAllVisibleBtn: "取消当前列表",
121
105
  selectRecommendedBtn: "恢复推荐精选",
122
- selectRecommendedHint: "按该服务的模型目录,每个模型家族勾选最新的一个",
106
+ selectRecommendedHint: "按该服务的模型目录,每个模型家族勾选最新的一个。",
123
107
  badgeVision: "视觉 Image",
124
108
  badgeReasoning: "推理",
125
109
  badgeSelected: "已启用",
@@ -133,7 +117,7 @@ window.__ModuleLoader__.load({
133
117
  imageInputLabel: "支持图片输入",
134
118
  reasoningLevelsLabel: "思考等级",
135
119
  resetParamsBtn: "恢复自动探测",
136
- paramsHint: "留空并保存即恢复 CLIProxyAPI 自动探测值。修改后请点击「保存模型勾选」才会写入模型选择器。",
120
+ paramsHint: "留空并保存即恢复 CLIProxyAPI 自动探测值。",
137
121
  discoveredValue: "探测值",
138
122
  updateTitle: "插件更新",
139
123
  updatePageHint: "这里只管理插件自身的更新。CLIProxyAPI 的账号、模型和协议设置位于「设置」左侧导航的「CLIProxyAPI」页。",
@@ -255,52 +239,147 @@ window.__ModuleLoader__.load({
255
239
  updateMarketFailed: "插件市场安装失败。可在插件市场中查看详情,稍后重试,或使用离线包下载。",
256
240
  updateAutoInstallLabel: "自动安装更新",
257
241
  updateAutoInstallHint: "自动检查发现新版本后,通过 DSH 插件市场自动安装(需开启自动检查并安装 dshmarket,仅在 DSH 页面打开时进行;同一版本 6 小时内最多尝试一次)。生效可能需要重启 DSH 或刷新页面。",
242
+ tabsLabel: "账号页面",
243
+ tabSettings: "设置",
244
+ tabQuota: "额度",
245
+ qTitle: "CPA 渠道额度",
246
+ qSubtitle: "填写管理密钥后,查看 CPA 上每个渠道凭证的剩余额度;对话框模型选择器旁也会显示当前模型的额度。",
247
+ qAccountsLabel: "选择账号",
248
+ qNoAccounts: "还没有 CLIProxyAPI 账号,请先在「设置」页添加并连接。",
249
+ qAccountDisabled: "账号未启用",
250
+ qKeyLabel: "管理密钥",
251
+ qKeyHint: "保存在 DSH 凭证服务({ref}),只由宿主使用,不会返回到页面。远程 CPA 需开启 remote-management.allow-remote。",
252
+ qKeySetPlaceholder: "已保存管理密钥(留空保持不变,输入新值可覆盖)",
253
+ qKeyUnsetPlaceholder: "CPA 配置中的 remote-management.secret-key",
254
+ qKeySave: "保存",
255
+ qKeySaving: "正在保存…",
256
+ qKeyClear: "清除密钥",
257
+ qKeyClearConfirm: "确定清除账号「{name}」的管理密钥吗?清除后不再读取它的额度。",
258
+ qKeyStatusSet: "已配置",
259
+ qKeyStatusUnset: "未配置",
260
+ qDataLabel: "额度数据",
261
+ qTotalCreds: "{n} 个凭证",
262
+ qKeySaved: "管理密钥已保存",
263
+ qKeyCleared: "管理密钥已清除",
264
+ qRefresh: "刷新额度",
265
+ qRefreshing: "正在刷新…",
266
+ qUpdated: "{time}更新",
267
+ qNeverUpdated: "尚未读取额度",
268
+ qAgoJustNow: "刚刚",
269
+ qAgoSeconds: "{n} 秒前",
270
+ qAgoMinutes: "{n} 分钟前",
271
+ qAgoHours: "{n} 小时前",
272
+ qAgoDays: "{n} 天前",
273
+ qStatHealthy: "充足",
274
+ qStatLow: "偏低",
275
+ qStatEmpty: "耗尽",
276
+ qStatIssue: "停用/异常",
277
+ qStatUnknown: "无额度数据",
278
+ qFilterAll: "全部渠道",
279
+ qSearchPlaceholder: "搜索邮箱、名称或模型…",
280
+ qNoCredentials: "该 CPA 服务器上没有凭证(auth files)。",
281
+ qNoMatch: "没有符合筛选条件的凭证。",
282
+ qShowModels: "服务 {n} 个模型",
283
+ qHideModels: "收起模型列表",
284
+ qModelsUnknown: "模型列表不可用",
285
+ qStatusActive: "可用",
286
+ qStatusDisabled: "已停用",
287
+ qStatusUnavailable: "暂不可用",
288
+ qStatusError: "异常",
289
+ qQuotaUnsupported: "此渠道没有可查询的额度接口,仅显示账号状态",
290
+ qQuotaError: "额度查询失败",
291
+ qQuotaNone: "上游未返回额度窗口",
292
+ qRecent: "近期请求:成功 {success} · 失败 {failed}",
293
+ qWin5h: "5 小时",
294
+ qWin7d: "每周",
295
+ qWin30d: "每月",
296
+ qWin1d: "每日",
297
+ qWinOther: "额度",
298
+ qResetIn: "{time}后重置",
299
+ qResetSoon: "即将重置",
300
+ qDurDH: "{d}天{h}小时",
301
+ qDurHM: "{h}小时{m}分",
302
+ qDurM: "{m}分钟",
303
+ qErrUnauthorized: "管理密钥无效(401)。为避免 CPA 因错误密钥封禁地址,已暂停自动刷新;更新密钥或手动刷新后重试。",
304
+ qErrForbidden: "CPA 拒绝了管理请求(403):请确认 CPA 已开启 remote-management.allow-remote,且密钥正确。",
305
+ qErrNetwork: "无法连接 CPA 管理接口,请检查服务地址与网络。",
306
+ qErrTimeout: "CPA 管理接口响应超时,请稍后重试。",
307
+ qErrHttp: "CPA 管理接口返回错误(HTTP {status})。",
308
+ qErrInvalid: "CPA 管理接口返回了无法识别的数据。",
309
+ qErrNoEndpoint: "该账号还没有可用的服务地址,请先在「设置」页连接 CPA。",
310
+ qErrLoad: "无法读取额度数据:{error}",
311
+ qStale: "以下为上次成功读取的数据。",
312
+ qMapTitle: "模型 → 渠道",
313
+ qMapHint: "该账号同步到模型选择器的每个模型,实际由哪些渠道凭证提供服务。模型选择器旁的额度按此匹配。",
314
+ qMapNone: "未匹配到凭证",
315
+ qMapEmpty: "该账号还没有同步到模型选择器的模型。",
316
+ qMatchModelList: "按模型列表",
317
+ qMatchPrefix: "按前缀",
318
+ qMatchFamily: "按家族推断",
319
+ qBadgeLabel: "{model} 的 CPA 额度",
320
+ qPeekCreds: "{n} 个凭证可服务此模型",
321
+ qPeekNoCreds: "CPA 上没有凭证提供这个模型。",
322
+ qPeekLoading: "正在读取额度…",
323
+ qPeekFooter: "完整列表见 设置 → CLIProxyAPI → 额度",
324
+ qPeekBest: "最充足",
325
+ infoLabel: "说明",
326
+ tabConnection: "连接",
327
+ tabModels: "模型",
328
+ enableShort: "启用",
329
+ moreActions: "更多操作",
330
+ testBtn: "测试连接",
331
+ testingBtn: "正在测试…",
332
+ testOk: "连接成功:{endpoint} · {n} 个模型",
333
+ testFail: "连接失败:{error}",
334
+ discardBtn: "放弃更改",
335
+ discardConfirm: "当前账号有未保存的更改,确定放弃吗?",
336
+ saveBarLabel: "保存",
337
+ modelsEmptyRule: "只有勾选的模型会出现在模型选择器中;一个都不勾选时显示全部模型。",
338
+ addTitle: "添加账号",
339
+ addNamePlaceholder: "如:家里的 CPA",
340
+ cancelBtn: "取消",
341
+ addAnywayBtn: "仍然添加(不启用)",
342
+ connectBtn: "连接",
343
+ connectingBtn: "正在连接…",
344
+ addedDisabledToast: "账号已添加但未启用。修正连接信息后,在账号标题旁打开「启用」。",
345
+ clearKeyConfirm: "确定清除该账号的 API Key 吗?",
346
+ clearedKeyToast: "API Key 已清除",
347
+ enabledToast: "已启用,模型已同步到模型选择器",
348
+ disabledToast: "已停用,模型已从模型选择器移除",
349
+ modelsCountShort: "{n}/{total} 个模型",
350
+ secondsUnit: "秒 · 所有账号共用",
351
+ paramsHintShort: "修改后点底部「保存并应用」生效。",
352
+ qProviderOther: "其他",
258
353
  };
259
354
 
260
355
  const en = {
261
356
  nav: "CLIProxyAPI",
262
- title: "CLIProxyAPI Auto-Discovery & Model Filter",
263
- subtitle:
264
- "Connect to CLIProxyAPI and check the exact models you want displayed in the DSH conversation model picker.",
265
357
  statusConnected: "Connected & Synced",
266
358
  statusProbing: "Probing…",
267
359
  statusError: "Connection Error",
268
360
  statusDisabled: "Disabled",
269
361
  statusIdle: "Idle",
270
- endpointLabel: "Active Endpoint",
271
- endpointNone: "Not connected (tries configured URL then localhost:8317)",
272
- modelsCountLabel: "Models in Picker",
273
- refreshedAtLabel: "Last Synced",
274
362
  refreshBtn: "Probe & Refresh",
275
363
  refreshingBtn: "Probing…",
276
364
  saveBtn: "Save & Apply",
277
365
  savingBtn: "Applying…",
278
- saveSelectionBtn: "Apply Selected Models",
279
- unsavedChanges: "Unsaved selection changes",
366
+ unsavedChanges: "Unsaved changes",
280
367
  savedToast: "Saved and synchronized to the DSH model picker",
281
- sectionConnection: "Connection & Authentication",
282
368
  accountsLabel: "Accounts",
283
369
  addAccountBtn: "+ Add Account",
284
- addingAccountBtn: "Adding…",
285
370
  removeAccountBtn: "Delete Account",
286
371
  removeConfirm: "Delete account \"{name}\"? Its model routes are removed from the model picker too (the stored key is kept).",
287
- noAccounts: "No accounts yet. Click Add Account to connect a CLIProxyAPI server.",
288
- accountOffBadge: "Off",
289
- addedToast: "Account added (disabled). Fill in its URL and key, then turn on Enable This Account and save.",
372
+ addedToast: "Account added and enabled; the newest model of each family is checked.",
290
373
  removedToast: "Account deleted and its models removed from the picker",
291
- disabledHint:
292
- "This account is disabled, so its models are not in the model picker. Fill in URL and key, click Probe & Refresh to preview its models, check the ones you want, then turn on Enable This Account.",
374
+ disabledHint: "Disabled: its models are not in the model picker; settings are kept.",
293
375
  enabledLabel: "Enable This Account",
294
- enabledHint: "Sync this account's checked models into the DSH model selector. Turning it off keeps the settings but removes its models.",
295
- baseUrlLabel: "CLIProxyAPI Base URL",
296
- baseUrlPlaceholder: "Leave blank to inherit existing route or auto-discover 127.0.0.1:8317",
297
- baseUrlHint: "Root URL (e.g. http://127.0.0.1:8317); /v1 is handled automatically.",
376
+ enabledHint: "When on, this account's checked models appear in the composer model picker; turning it off removes them and keeps the settings.",
377
+ baseUrlLabel: "Base URL",
378
+ baseUrlPlaceholder: "e.g. http://127.0.0.1:8317, no /v1 needed; blank = auto-discover",
298
379
  apiKeyLabel: "API Key",
299
380
  apiKeySetPlaceholder: "Key is stored (leave blank to keep, or type a new key)",
300
381
  apiKeyUnsetPlaceholder: "Enter CLIProxyAPI Bearer key (optional for local no-auth)",
301
- apiKeyHint: "Stored securely via DSH credentials service, never in plain settings.",
302
- apiKeyConfiguredBadge: "Configured",
303
- apiKeyUnsetBadge: "Not Set",
382
+ apiKeyHint: "Stored in the DSH credentials service ({ref}), never in settings files. Leave blank for a local CPA without a key.",
304
383
  clearKeyBtn: "Clear Key",
305
384
  protocolLabel: "Wire Protocol",
306
385
  protocolHint: "Auto sends each model over its family's own protocol to avoid CPA translation; models DSH cannot speak natively (such as Gemini) use OpenAI Chat.",
@@ -313,22 +392,19 @@ window.__ModuleLoader__.load({
313
392
  modelProtocolDefault: "Follow global setting",
314
393
  modelProtocolHint: "Native protocol: ",
315
394
  sectionAdvanced: "Advanced Routing & Keyword Rules",
316
- providerIdLabel: "Provider Route ID",
395
+ providerIdLabel: "Route ID",
317
396
  providerIdHint: "Provider key under llm-pi-ai; must differ per account (first account: cli-proxy).",
318
397
  apiKeyEnvLabel: "Credential Ref",
319
398
  apiKeyEnvHint: "Name of the key in the DSH credentials service; use a distinct one per account.",
320
399
  displayNameLabel: "Account Name",
321
400
  displayNameHint: "Tells accounts apart and titles their group in the DSH model selector.",
322
- includePrefixLabel: "Include Filter (optional)",
401
+ includePrefixLabel: "Include Keywords",
323
402
  includePrefixPlaceholder: "e.g. gpt-6, claude-opus, gemini-4 (comma-separated)",
324
403
  includePrefixHint: "Additional rule: only sync models matching these substrings.",
325
- excludePrefixLabel: "Exclude Filter (optional)",
404
+ excludePrefixLabel: "Exclude Keywords",
326
405
  excludePrefixPlaceholder: "e.g. tts, image, video, zj/ (comma-separated)",
327
406
  excludePrefixHint: "Additional rule: hide models matching any of these substrings.",
328
- refreshIntervalLabel: "Auto Refresh Interval (s, all accounts)",
329
- sectionModels: "Model Picker Filter (Only checked models appear in DSH)",
330
- sectionModelsHint:
331
- "Click any model row to toggle selection. Only checked models will be shown in the conversation model selector.",
407
+ refreshIntervalLabel: "Auto Refresh",
332
408
  searchModelsPlaceholder: "Search model ID or name (e.g. claude-opus, gpt-6, gemini-4)…",
333
409
  filterAll: "All Discovered",
334
410
  filterSelected: "Checked",
@@ -337,7 +413,7 @@ window.__ModuleLoader__.load({
337
413
  selectAllVisibleBtn: "Check Visible",
338
414
  deselectAllVisibleBtn: "Uncheck Visible",
339
415
  selectRecommendedBtn: "Reset to Recommended",
340
- selectRecommendedHint: "Check the newest model of each family in this server's catalog",
416
+ selectRecommendedHint: "Check the newest model of each family in this server's catalog.",
341
417
  badgeVision: "Image",
342
418
  badgeReasoning: "Reasoning",
343
419
  badgeSelected: "Active",
@@ -351,7 +427,7 @@ window.__ModuleLoader__.load({
351
427
  imageInputLabel: "Image Input",
352
428
  reasoningLevelsLabel: "Thinking Levels",
353
429
  resetParamsBtn: "Reset to Discovered",
354
- paramsHint: "Clear a value and save to restore the value discovered from CLIProxyAPI. Click Apply Selected Models to write changes to the picker.",
430
+ paramsHint: "Clear a value and save to restore the value discovered from CLIProxyAPI.",
355
431
  discoveredValue: "Discovered",
356
432
  updateTitle: "Plugin Updates",
357
433
  updatePageHint: "This page only manages updates of the plugin itself. CLIProxyAPI accounts, models and protocol settings are on the \"CLIProxyAPI\" page in the Settings dialog's left navigation.",
@@ -473,6 +549,117 @@ window.__ModuleLoader__.load({
473
549
  updateMarketFailed: "The plugin market installation failed. See the plugin market for details, retry later, or use the offline package download.",
474
550
  updateAutoInstallLabel: "Automatically Install Updates",
475
551
  updateAutoInstallHint: "Install versions found by automatic checks through the DSH plugin market (requires Auto Check and dshmarket; runs only while a DSH page is open, at most once per version every 6 hours). A DSH restart or page refresh may be needed for it to take effect.",
552
+ tabsLabel: "Account pages",
553
+ tabSettings: "Settings",
554
+ tabQuota: "Quota",
555
+ qTitle: "CPA Channel Quota",
556
+ qSubtitle: "With a management key, see the remaining quota of every channel credential on CPA; the composer also shows the current model's quota next to the model picker.",
557
+ qAccountsLabel: "Choose account",
558
+ qNoAccounts: "No CLIProxyAPI account yet. Add and connect one on the Settings tab first.",
559
+ qAccountDisabled: "Account disabled",
560
+ qKeyLabel: "Management Key",
561
+ qKeyHint: "Stored in the DSH credentials service ({ref}), used only by the host and never sent back to the page. A remote CPA needs remote-management.allow-remote.",
562
+ qKeySetPlaceholder: "Management key stored (leave blank to keep, or type a new one)",
563
+ qKeyUnsetPlaceholder: "remote-management.secret-key from the CPA config",
564
+ qKeySave: "Save",
565
+ qKeySaving: "Saving…",
566
+ qKeyClear: "Clear Key",
567
+ qKeyClearConfirm: "Clear the management key of account \"{name}\"? Its quota is no longer read afterwards.",
568
+ qKeyStatusSet: "Set",
569
+ qKeyStatusUnset: "Not set",
570
+ qDataLabel: "Quota data",
571
+ qTotalCreds: "{n} credentials",
572
+ qKeySaved: "Management key saved",
573
+ qKeyCleared: "Management key cleared",
574
+ qRefresh: "Refresh Quota",
575
+ qRefreshing: "Refreshing…",
576
+ qUpdated: "Updated {time}",
577
+ qNeverUpdated: "Quota not loaded yet",
578
+ qAgoJustNow: "just now",
579
+ qAgoSeconds: "{n}s ago",
580
+ qAgoMinutes: "{n} min ago",
581
+ qAgoHours: "{n} h ago",
582
+ qAgoDays: "{n} d ago",
583
+ qStatHealthy: "Healthy",
584
+ qStatLow: "Low",
585
+ qStatEmpty: "Exhausted",
586
+ qStatIssue: "Off / Error",
587
+ qStatUnknown: "No quota data",
588
+ qFilterAll: "All channels",
589
+ qSearchPlaceholder: "Search email, name or model…",
590
+ qNoCredentials: "This CPA server has no credentials (auth files).",
591
+ qNoMatch: "No credential matches the filters.",
592
+ qShowModels: "Serves {n} models",
593
+ qHideModels: "Hide models",
594
+ qModelsUnknown: "Model list unavailable",
595
+ qStatusActive: "Active",
596
+ qStatusDisabled: "Disabled",
597
+ qStatusUnavailable: "Unavailable",
598
+ qStatusError: "Error",
599
+ qQuotaUnsupported: "No quota endpoint for this channel; showing account status only",
600
+ qQuotaError: "Quota query failed",
601
+ qQuotaNone: "Upstream returned no quota window",
602
+ qRecent: "Recent requests: {success} ok · {failed} failed",
603
+ qWin5h: "5 hours",
604
+ qWin7d: "Weekly",
605
+ qWin30d: "Monthly",
606
+ qWin1d: "Daily",
607
+ qWinOther: "Quota",
608
+ qResetIn: "resets in {time}",
609
+ qResetSoon: "resetting now",
610
+ qDurDH: "{d}d {h}h",
611
+ qDurHM: "{h}h {m}m",
612
+ qDurM: "{m}m",
613
+ qErrUnauthorized: "The management key was rejected (401). Automatic refresh is paused so CPA does not ban this address for bad keys; update the key or refresh manually to retry.",
614
+ qErrForbidden: "CPA refused the management request (403). Make sure remote-management.allow-remote is on and the key is correct.",
615
+ qErrNetwork: "Could not reach the CPA management API. Check the server address and network.",
616
+ qErrTimeout: "The CPA management API timed out. Retry shortly.",
617
+ qErrHttp: "The CPA management API returned an error (HTTP {status}).",
618
+ qErrInvalid: "The CPA management API returned data that could not be read.",
619
+ qErrNoEndpoint: "This account has no usable server address yet. Connect CPA on the Settings tab first.",
620
+ qErrLoad: "Could not load quota data: {error}",
621
+ qStale: "Showing the last successful data below.",
622
+ qMapTitle: "Model → Channel",
623
+ qMapHint: "Which channel credentials actually serve each model this account puts in the model picker. The composer quota badge uses exactly this mapping.",
624
+ qMapNone: "No matching credential",
625
+ qMapEmpty: "This account has no models in the model picker yet.",
626
+ qMatchModelList: "by model list",
627
+ qMatchPrefix: "by prefix",
628
+ qMatchFamily: "inferred from family",
629
+ qBadgeLabel: "CPA quota of {model}",
630
+ qPeekCreds: "{n} credential(s) serve this model",
631
+ qPeekNoCreds: "No CPA credential serves this model.",
632
+ qPeekLoading: "Loading quota…",
633
+ qPeekFooter: "Full list in Settings → CLIProxyAPI → Quota",
634
+ qPeekBest: "Best",
635
+ infoLabel: "More info",
636
+ tabConnection: "Connection",
637
+ tabModels: "Models",
638
+ enableShort: "Enabled",
639
+ moreActions: "More actions",
640
+ testBtn: "Test Connection",
641
+ testingBtn: "Testing…",
642
+ testOk: "Connected: {endpoint} · {n} models",
643
+ testFail: "Connection failed: {error}",
644
+ discardBtn: "Discard",
645
+ discardConfirm: "This account has unsaved changes. Discard them?",
646
+ saveBarLabel: "Save",
647
+ modelsEmptyRule: "Only checked models appear in the model picker; with none checked, all models are shown.",
648
+ addTitle: "Add Account",
649
+ addNamePlaceholder: "e.g. Home CPA",
650
+ cancelBtn: "Cancel",
651
+ addAnywayBtn: "Add Anyway (Disabled)",
652
+ connectBtn: "Connect",
653
+ connectingBtn: "Connecting…",
654
+ addedDisabledToast: "Account added but disabled. Fix the connection, then turn on Enabled next to the account title.",
655
+ clearKeyConfirm: "Clear this account's API key?",
656
+ clearedKeyToast: "API key cleared",
657
+ enabledToast: "Enabled; its models are synced to the model picker",
658
+ disabledToast: "Disabled; its models were removed from the model picker",
659
+ modelsCountShort: "{n}/{total} models",
660
+ secondsUnit: "s · all accounts",
661
+ paramsHintShort: "Click Save & Apply at the bottom to apply changes.",
662
+ qProviderOther: "Other",
476
663
  };
477
664
 
478
665
  const CSS = `
@@ -1039,76 +1226,707 @@ window.__ModuleLoader__.load({
1039
1226
  flex-wrap: wrap;
1040
1227
  }
1041
1228
  }
1042
- `;
1043
-
1044
- const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
1045
- const PROTOCOL_NAME_KEYS = {
1046
- "openai-completions": "protocolNameCompletions",
1047
- "openai-responses": "protocolNameResponses",
1048
- "anthropic-messages": "protocolNameMessages",
1049
- };
1050
- const PROTOCOLS = Object.keys(PROTOCOL_NAME_KEYS);
1051
-
1052
- function cloneOverrides(value) {
1053
- const out = {};
1054
- for (const [id, entry] of Object.entries(value ?? {})) {
1055
- out[id] = {
1056
- ...entry,
1057
- ...(Array.isArray(entry.reasoningLevels) ? { reasoningLevels: [...entry.reasoningLevels] } : {}),
1058
- };
1059
- }
1060
- return out;
1061
- }
1062
-
1063
- function overridesKey(value) {
1064
- return JSON.stringify(value ?? {});
1065
- }
1066
-
1067
- function formatTokens(n) {
1068
- if (typeof n !== "number" || !Number.isFinite(n)) return "";
1069
- if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
1070
- if (n >= 1000) return `${Math.round(n / 1000)}K`;
1071
- return String(n);
1072
- }
1073
-
1074
- async function postJson(url, body) {
1075
- const res = await fetch(url, {
1076
- method: "POST",
1077
- headers: { "content-type": "application/json" },
1078
- body: JSON.stringify(body ?? {}),
1079
- });
1080
- const payload = await res.json();
1081
- if (!res.ok || payload.ok === false) {
1082
- throw new Error(payload.error || `HTTP ${res.status}`);
1083
- }
1084
- return payload;
1085
- }
1229
+ /* ---- Section tabs: DSH segmented control ---- */
1230
+ .dcpa-tabs {
1231
+ align-self: flex-start;
1232
+ display: inline-grid;
1233
+ grid-auto-flow: column;
1234
+ grid-auto-columns: 1fr;
1235
+ gap: 2px;
1236
+ padding: 4px;
1237
+ border-radius: var(--dsw-radius-md, 10px);
1238
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1239
+ }
1240
+ .dcpa-tab, .dcpa-q-seg-tab {
1241
+ box-sizing: border-box;
1242
+ height: 28px;
1243
+ padding: 0 16px;
1244
+ border: 0;
1245
+ border-radius: var(--dsw-radius-sm, 8px);
1246
+ background: transparent;
1247
+ color: var(--dsw-alias-label-secondary);
1248
+ font: inherit;
1249
+ font-size: 13px;
1250
+ line-height: 20px;
1251
+ font-weight: 500;
1252
+ white-space: nowrap;
1253
+ cursor: pointer;
1254
+ transition: color 0.12s ease, background 0.12s ease;
1255
+ }
1256
+ .dcpa-tab:hover, .dcpa-q-seg-tab:hover { color: var(--dsw-alias-label-primary); }
1257
+ .dcpa-tab[aria-selected="true"], .dcpa-q-seg-tab[aria-selected="true"] {
1258
+ color: var(--dsw-alias-label-primary);
1259
+ background: var(--dsw-alias-bg-layer-1);
1260
+ box-shadow: var(--dsw-elevation-soft, 0 1px 2px var(--dsw-alias-border-l1));
1261
+ }
1262
+ .dcpa-tabpanel[hidden] { display: none; }
1263
+ .dcpa-tab:focus-visible, .dcpa-q-seg-tab:focus-visible, .dcpa-q button:focus-visible, .dcpa-q summary:focus-visible,
1264
+ .dcpa-q-badge:focus-visible, .dcpa-q-pop button:focus-visible {
1265
+ outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
1266
+ outline-offset: -2px;
1267
+ }
1086
1268
 
1087
- function stateDotClass(state) {
1088
- return state === "connected"
1089
- ? "dcpa-dot dcpa-dot-connected"
1090
- : state === "probing"
1091
- ? "dcpa-dot dcpa-dot-probing"
1092
- : state === "error"
1093
- ? "dcpa-dot dcpa-dot-error"
1094
- : "dcpa-dot dcpa-dot-idle";
1095
- }
1269
+ /* ---- Quota page: flat DSH settings rows ---- */
1270
+ .dcpa-q {
1271
+ display: flex;
1272
+ flex-direction: column;
1273
+ color: var(--dsw-alias-label-primary);
1274
+ font-size: 14px;
1275
+ line-height: 22px;
1276
+ }
1277
+ .dcpa-q-head { padding: 4px 0 12px; }
1278
+ .dcpa-q-h { margin: 0; font-size: 16px; font-weight: 500; line-height: 24px; }
1279
+ .dcpa-q-desc {
1280
+ margin: 4px 0 0;
1281
+ font-size: 12px;
1282
+ line-height: 18px;
1283
+ color: var(--dsw-alias-label-secondary);
1284
+ }
1285
+ .dcpa-q-seg {
1286
+ align-self: flex-start;
1287
+ display: inline-flex;
1288
+ flex-wrap: wrap;
1289
+ gap: 2px;
1290
+ padding: 4px;
1291
+ margin: 4px 0 8px;
1292
+ border-radius: var(--dsw-radius-md, 10px);
1293
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1294
+ }
1295
+ .dcpa-q-seg-tab { display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; }
1296
+ .dcpa-q-seg-note { font-weight: 400; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
1297
+ .dcpa-q-row {
1298
+ display: flex;
1299
+ align-items: center;
1300
+ justify-content: space-between;
1301
+ gap: 24px;
1302
+ padding: 16px 0;
1303
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1304
+ }
1305
+ .dcpa-q-row-main { min-width: 0; }
1306
+ .dcpa-q-title { font-size: 14px; line-height: 20px; }
1307
+ .dcpa-q-sub {
1308
+ margin-top: 4px;
1309
+ font-size: 12px;
1310
+ line-height: 18px;
1311
+ color: var(--dsw-alias-label-secondary);
1312
+ overflow-wrap: anywhere;
1313
+ }
1314
+ .dcpa-q-actions { display: flex; align-items: center; gap: 8px; flex: none; }
1315
+ .dcpa-q-form {
1316
+ display: flex;
1317
+ align-items: center;
1318
+ gap: 8px;
1319
+ padding: 0 0 16px;
1320
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1321
+ }
1322
+ .dcpa-q-input {
1323
+ flex: 1;
1324
+ min-width: 0;
1325
+ box-sizing: border-box;
1326
+ height: 34px;
1327
+ padding: 0 12px;
1328
+ border: 0.5px solid var(--dsw-alias-border-l4, var(--dsw-alias-border-l2));
1329
+ border-radius: var(--dsw-radius-md, 10px);
1330
+ background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-base));
1331
+ color: var(--dsw-alias-label-primary);
1332
+ font: inherit;
1333
+ font-size: 13px;
1334
+ outline: none;
1335
+ }
1336
+ .dcpa-q-input:focus { border-color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
1337
+ .dcpa-q-input::placeholder { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
1338
+ .dcpa-q-btn {
1339
+ box-sizing: border-box;
1340
+ display: inline-flex;
1341
+ align-items: center;
1342
+ justify-content: center;
1343
+ gap: 4px;
1344
+ height: 28px;
1345
+ padding: 0 10px;
1346
+ border: none;
1347
+ border-radius: var(--dsw-radius-sm, 8px);
1348
+ background: transparent;
1349
+ color: var(--dsw-alias-label-primary);
1350
+ font: inherit;
1351
+ font-size: 12px;
1352
+ line-height: 18px;
1353
+ white-space: nowrap;
1354
+ cursor: pointer;
1355
+ }
1356
+ .dcpa-q-btn:disabled { cursor: not-allowed; opacity: 0.4; }
1357
+ .dcpa-q-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
1358
+ .dcpa-q-btn[data-variant="primary"] {
1359
+ height: 34px;
1360
+ padding: 0 14px;
1361
+ border-radius: var(--dsw-radius-md, 10px);
1362
+ background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary));
1363
+ color: var(--dsw-alias-label-primary-foreground, var(--dsw-alias-bg-base));
1364
+ }
1365
+ .dcpa-q-btn[data-variant="primary"]:hover:not(:disabled) {
1366
+ background: var(--dsw-alias-button-primary-hover, var(--dsw-alias-brand-primary));
1367
+ }
1368
+ .dcpa-q-btn[data-variant="outline"] { border: 0.5px solid var(--dsw-alias-border-l3, var(--dsw-alias-border-l2)); }
1369
+ .dcpa-q-btn[data-variant="danger"] { color: var(--dsw-alias-state-error-primary); }
1370
+ .dcpa-q-btn[data-variant="danger"]:hover:not(:disabled) {
1371
+ background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-bg-layer-2));
1372
+ }
1373
+ .dcpa-q-tag {
1374
+ display: inline-flex;
1375
+ align-items: center;
1376
+ border-radius: 999px;
1377
+ padding: 1px 8px;
1378
+ font-size: 11px;
1379
+ line-height: 17px;
1380
+ font-weight: 500;
1381
+ white-space: nowrap;
1382
+ border: 0.5px solid var(--dsw-alias-border-l4, var(--dsw-alias-border-l2));
1383
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));
1384
+ }
1385
+ .dcpa-q-tag[data-tone="success"] {
1386
+ border-color: transparent;
1387
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
1388
+ color: var(--dsw-alias-state-success-primary);
1389
+ }
1390
+ .dcpa-q-tag[data-tone="warning"] {
1391
+ border-color: transparent;
1392
+ background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);
1393
+ color: var(--dsw-alias-state-warn-primary);
1394
+ }
1395
+ .dcpa-q-tag[data-tone="danger"] {
1396
+ border-color: transparent;
1397
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
1398
+ color: var(--dsw-alias-state-error-primary);
1399
+ }
1400
+ .dcpa-q-tag[data-tone="info"] {
1401
+ border-color: transparent;
1402
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 10%, transparent);
1403
+ color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
1404
+ }
1405
+ .dcpa-q-msg { margin: 12px 0 0; font-size: 12px; line-height: 18px; }
1406
+ .dcpa-q-msg[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
1407
+ .dcpa-q-msg[data-tone="ok"] { color: var(--dsw-alias-state-success-primary); }
1408
+ .dcpa-q-summary { display: flex; flex-direction: column; gap: 8px; padding: 20px 0 4px; }
1409
+ .dcpa-q-dist {
1410
+ display: flex;
1411
+ gap: 2px;
1412
+ height: 6px;
1413
+ border-radius: 3px;
1414
+ overflow: hidden;
1415
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1416
+ }
1417
+ .dcpa-q-dist span { display: block; height: 100%; }
1418
+ .dcpa-q-legend {
1419
+ display: flex;
1420
+ flex-wrap: wrap;
1421
+ gap: 4px 16px;
1422
+ font-size: 12px;
1423
+ line-height: 18px;
1424
+ color: var(--dsw-alias-label-secondary);
1425
+ }
1426
+ .dcpa-q-legend span { display: inline-flex; align-items: center; gap: 6px; }
1427
+ .dcpa-q-legend b { font-weight: 500; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
1428
+ .dcpa-q-swatch { width: 8px; height: 8px; border-radius: 2px; }
1429
+ [data-level="ok"] > .dcpa-q-swatch, .dcpa-q-dist [data-level="ok"] { background: var(--dsw-alias-state-success-primary); }
1430
+ [data-level="low"] > .dcpa-q-swatch, .dcpa-q-dist [data-level="low"] { background: var(--dsw-alias-state-warn-primary); }
1431
+ [data-level="empty"] > .dcpa-q-swatch, .dcpa-q-dist [data-level="empty"] { background: var(--dsw-alias-state-error-primary); }
1432
+ [data-level="issue"] > .dcpa-q-swatch, .dcpa-q-dist [data-level="issue"] { background: var(--dsw-alias-state-idle-primary); }
1433
+ [data-level="unknown"] > .dcpa-q-swatch, .dcpa-q-dist [data-level="unknown"] { background: var(--dsw-alias-border-l3, var(--dsw-alias-border-l2)); }
1434
+ .dcpa-q-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 16px 0 0; }
1435
+ .dcpa-q-filter {
1436
+ height: 28px;
1437
+ padding: 0 10px;
1438
+ border: 0;
1439
+ border-radius: var(--dsw-radius-sm, 8px);
1440
+ background: transparent;
1441
+ color: var(--dsw-alias-label-secondary);
1442
+ font: inherit;
1443
+ font-size: 13px;
1444
+ cursor: pointer;
1445
+ }
1446
+ .dcpa-q-filter:hover { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
1447
+ .dcpa-q-filter[aria-pressed="true"] {
1448
+ color: var(--dsw-alias-label-primary);
1449
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1450
+ font-weight: 500;
1451
+ }
1452
+ .dcpa-q-filter small { margin-left: 4px; font-size: 11px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
1453
+ .dcpa-q-tools .dcpa-q-input { flex: 0 1 220px; height: 28px; margin-left: auto; border-radius: var(--dsw-radius-sm, 8px); }
1454
+ .dcpa-q-group-title {
1455
+ display: flex;
1456
+ align-items: baseline;
1457
+ gap: 6px;
1458
+ padding: 20px 0 4px;
1459
+ font-size: 12px;
1460
+ line-height: 18px;
1461
+ font-weight: 500;
1462
+ color: var(--dsw-alias-label-secondary);
1463
+ }
1464
+ .dcpa-q-group-title small { font-weight: 400; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
1465
+ .dcpa-q-cred {
1466
+ display: grid;
1467
+ grid-template-columns: minmax(160px, 1fr) auto;
1468
+ align-items: center;
1469
+ gap: 8px 24px;
1470
+ padding: 14px 0;
1471
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1472
+ }
1473
+ @media (max-width: 640px) { .dcpa-q-cred { grid-template-columns: 1fr; } }
1474
+ .dcpa-q-cred[data-status="disabled"] .dcpa-q-who, .dcpa-q-cred[data-status="unavailable"] .dcpa-q-who { opacity: 0.55; }
1475
+ .dcpa-q-who { min-width: 0; }
1476
+ .dcpa-q-name {
1477
+ font-size: 14px;
1478
+ line-height: 20px;
1479
+ overflow: hidden;
1480
+ text-overflow: ellipsis;
1481
+ white-space: nowrap;
1482
+ }
1483
+ .dcpa-q-meta {
1484
+ display: flex;
1485
+ align-items: center;
1486
+ flex-wrap: wrap;
1487
+ gap: 6px;
1488
+ margin-top: 4px;
1489
+ font-size: 12px;
1490
+ line-height: 18px;
1491
+ color: var(--dsw-alias-label-secondary);
1492
+ }
1493
+ .dcpa-q-link {
1494
+ padding: 0;
1495
+ border: 0;
1496
+ background: transparent;
1497
+ color: var(--dsw-alias-label-secondary);
1498
+ font: inherit;
1499
+ cursor: pointer;
1500
+ text-decoration: underline;
1501
+ text-decoration-color: var(--dsw-alias-border-l3, var(--dsw-alias-border-l2));
1502
+ text-underline-offset: 3px;
1503
+ }
1504
+ .dcpa-q-link:hover { color: var(--dsw-alias-label-primary); }
1505
+ .dcpa-q-meters { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px 20px; }
1506
+ @media (max-width: 640px) { .dcpa-q-meters { justify-content: flex-start; } }
1507
+ .dcpa-q-meter { width: 128px; display: flex; flex-direction: column; gap: 4px; }
1508
+ .dcpa-q-meter-top {
1509
+ display: flex;
1510
+ align-items: baseline;
1511
+ justify-content: space-between;
1512
+ gap: 6px;
1513
+ font-size: 12px;
1514
+ line-height: 16px;
1515
+ color: var(--dsw-alias-label-secondary);
1516
+ }
1517
+ .dcpa-q-meter-top span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1518
+ .dcpa-q-pct {
1519
+ font-size: 13px;
1520
+ font-weight: 500;
1521
+ font-variant-numeric: tabular-nums;
1522
+ color: var(--dsw-alias-label-primary);
1523
+ }
1524
+ .dcpa-q-meter[data-level="low"] .dcpa-q-pct { color: var(--dsw-alias-state-warn-primary); }
1525
+ .dcpa-q-meter[data-level="empty"] .dcpa-q-pct { color: var(--dsw-alias-state-error-primary); }
1526
+ .dcpa-q-track {
1527
+ height: 4px;
1528
+ border-radius: 2px;
1529
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1530
+ overflow: hidden;
1531
+ }
1532
+ .dcpa-q-fill { display: block; height: 100%; border-radius: 2px; background: var(--dsw-alias-state-idle-primary); }
1533
+ [data-level="ok"] > .dcpa-q-track > .dcpa-q-fill { background: var(--dsw-alias-state-success-primary); }
1534
+ [data-level="mid"] > .dcpa-q-track > .dcpa-q-fill { background: var(--dsw-alias-state-success-primary); opacity: 0.7; }
1535
+ [data-level="low"] > .dcpa-q-track > .dcpa-q-fill { background: var(--dsw-alias-state-warn-primary); }
1536
+ [data-level="empty"] > .dcpa-q-track > .dcpa-q-fill { background: var(--dsw-alias-state-error-primary); }
1537
+ .dcpa-q-reset { font-size: 11px; line-height: 14px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
1538
+ .dcpa-q-note {
1539
+ margin: 0;
1540
+ font-size: 12px;
1541
+ line-height: 18px;
1542
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));
1543
+ text-align: right;
1544
+ }
1545
+ .dcpa-q-note[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
1546
+ .dcpa-q-models { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px; }
1547
+ .dcpa-q-models code {
1548
+ padding: 1px 8px;
1549
+ border-radius: 999px;
1550
+ font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
1551
+ font-size: 11px;
1552
+ line-height: 17px;
1553
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1554
+ color: var(--dsw-alias-label-secondary);
1555
+ }
1556
+ .dcpa-q-models code[data-picker="true"] { color: var(--dsw-alias-label-primary); }
1557
+ .dcpa-q-map { margin-top: 8px; }
1558
+ .dcpa-q-map > summary {
1559
+ display: flex;
1560
+ align-items: center;
1561
+ justify-content: space-between;
1562
+ gap: 12px;
1563
+ padding: 16px 0;
1564
+ list-style: none;
1565
+ cursor: pointer;
1566
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1567
+ }
1568
+ .dcpa-q-map > summary::-webkit-details-marker { display: none; }
1569
+ .dcpa-q-map > summary::after {
1570
+ content: "";
1571
+ flex: none;
1572
+ width: 7px;
1573
+ height: 7px;
1574
+ margin-right: 4px;
1575
+ border-right: 1.5px solid var(--dsw-alias-label-secondary);
1576
+ border-bottom: 1.5px solid var(--dsw-alias-label-secondary);
1577
+ transform: rotate(45deg);
1578
+ }
1579
+ .dcpa-q-map[open] > summary::after { transform: rotate(-135deg); }
1580
+ .dcpa-q-map-row {
1581
+ display: grid;
1582
+ grid-template-columns: minmax(150px, 1fr) minmax(0, 1.5fr) 48px;
1583
+ align-items: center;
1584
+ gap: 12px;
1585
+ padding: 10px 0;
1586
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1);
1587
+ font-size: 13px;
1588
+ line-height: 20px;
1589
+ }
1590
+ .dcpa-q-map-model {
1591
+ font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
1592
+ font-size: 12px;
1593
+ overflow-wrap: anywhere;
1594
+ }
1595
+ .dcpa-q-map-creds { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
1596
+ .dcpa-q-map-pct { text-align: right; }
1096
1597
 
1097
- const SERVICE_UNAVAILABLE_STATUS = new Set([404, 405, 501]);
1098
- const UPDATE_ERROR_KEYS = {
1099
- "network-error": "updateNetworkError",
1100
- "invalid-metadata": "updateMetadataError",
1101
- "invalid-response": "updateMetadataError",
1102
- "service-unavailable": "updateServiceUnavailableError",
1103
- "forbidden-origin": "updateOriginError",
1104
- "channel-unavailable": "updateChannelUnavailable",
1105
- "unavailable-channel": "updateChannelUnavailable",
1106
- "integrity-error": "updateIntegrityError",
1107
- "invalid-package": "updateInvalidPackage",
1108
- "storage-error": "updateStorageError",
1109
- busy: "updateBusyError",
1110
- "stale-version": "updateStaleVersion",
1111
- disposed: "updateDisposedError",
1598
+ /* ---- Account page: bar, header, tabs, form rows, save bar ---- */
1599
+ .dcpa-acct-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1600
+ .dcpa-acct-bar .dcpa-q-seg { margin: 0; }
1601
+ .dcpa-q-seg-tab .dcpa-dot { width: 6px; height: 6px; }
1602
+ .dcpa-account { display: flex; flex-direction: column; }
1603
+ .dcpa-acct-head {
1604
+ display: flex;
1605
+ align-items: center;
1606
+ justify-content: space-between;
1607
+ gap: 16px;
1608
+ padding: 16px 0 12px;
1609
+ }
1610
+ .dcpa-status-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
1611
+ .dcpa-acct-tabs { margin: 8px 0 4px; }
1612
+ .dcpa-tabbody { display: flex; flex-direction: column; }
1613
+ .dcpa-frow {
1614
+ display: grid;
1615
+ grid-template-columns: 168px minmax(0, 1fr);
1616
+ align-items: center;
1617
+ gap: 8px 16px;
1618
+ padding: 12px 0;
1619
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1620
+ }
1621
+ @media (max-width: 620px) { .dcpa-frow { grid-template-columns: 1fr; } }
1622
+ .dcpa-frow-label {
1623
+ display: flex;
1624
+ align-items: center;
1625
+ gap: 4px;
1626
+ font-size: 14px;
1627
+ line-height: 20px;
1628
+ color: var(--dsw-alias-label-primary);
1629
+ }
1630
+ .dcpa-frow-control { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
1631
+ .dcpa-frow-control > .dcpa-q-input { flex: none; }
1632
+ .dcpa-frow-control .dcpa-q-msg { margin: 0; }
1633
+ .dcpa-inline { display: flex; align-items: center; gap: 8px; min-width: 0; }
1634
+ .dcpa-inline .dcpa-q-input { flex: 1; }
1635
+ .dcpa-inline-narrow .dcpa-q-input { flex: 0 0 120px; }
1636
+ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
1637
+ .dcpa-adv > summary {
1638
+ padding: 14px 0;
1639
+ font-size: 13px;
1640
+ color: var(--dsw-alias-label-secondary);
1641
+ cursor: pointer;
1642
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1643
+ }
1644
+ .dcpa-adv > summary:hover { color: var(--dsw-alias-label-primary); }
1645
+ .dcpa-panel { display: flex; flex-direction: column; padding-top: 16px; }
1646
+ .dcpa-form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; }
1647
+ .dcpa-switch:disabled { opacity: 0.5; cursor: default; }
1648
+ .dcpa-menu { position: relative; display: inline-flex; }
1649
+ .dcpa-menu-btn { width: 28px; padding: 0; font-size: 16px; letter-spacing: 1px; }
1650
+ .dcpa-menu-pop {
1651
+ position: absolute;
1652
+ top: calc(100% + 4px);
1653
+ right: 0;
1654
+ z-index: 20;
1655
+ isolation: isolate;
1656
+ min-width: 160px;
1657
+ padding: 4px;
1658
+ display: flex;
1659
+ flex-direction: column;
1660
+ border-radius: var(--dsw-radius-lg, 12px);
1661
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
1662
+ box-shadow: var(--dsw-elevation-prominent, 0 8px 24px var(--dsw-alias-border-l2));
1663
+ }
1664
+ .dcpa-menu-pop::before, .dcpa-info-pop::before {
1665
+ content: "";
1666
+ position: absolute;
1667
+ inset: 0;
1668
+ z-index: -1;
1669
+ border-radius: inherit;
1670
+ background: var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1));
1671
+ backdrop-filter: var(--dsw-menu-backdrop-filter, none);
1672
+ }
1673
+ .dcpa-menu-item {
1674
+ height: 32px;
1675
+ padding: 0 10px;
1676
+ border: 0;
1677
+ border-radius: var(--dsw-radius-md, 10px);
1678
+ background: transparent;
1679
+ color: var(--dsw-alias-label-primary);
1680
+ font: inherit;
1681
+ font-size: 13px;
1682
+ text-align: left;
1683
+ cursor: pointer;
1684
+ white-space: nowrap;
1685
+ }
1686
+ .dcpa-menu-item:hover:not(:disabled), .dcpa-menu-item:focus-visible { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); outline: none; }
1687
+ .dcpa-menu-item:disabled { opacity: 0.4; cursor: default; }
1688
+ .dcpa-menu-item[data-variant="danger"] { color: var(--dsw-alias-state-error-primary); }
1689
+ .dcpa-info { position: relative; display: inline-flex; align-items: center; }
1690
+ .dcpa-info-btn {
1691
+ box-sizing: border-box;
1692
+ width: 16px;
1693
+ height: 16px;
1694
+ padding: 0;
1695
+ border: 1px solid var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));
1696
+ border-radius: 50%;
1697
+ background: transparent;
1698
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));
1699
+ font: 600 10px/14px Georgia, "Times New Roman", serif;
1700
+ font-style: italic;
1701
+ text-align: center;
1702
+ cursor: help;
1703
+ }
1704
+ .dcpa-info-btn:hover, .dcpa-info-btn[aria-expanded="true"] {
1705
+ border-color: var(--dsw-alias-label-secondary);
1706
+ color: var(--dsw-alias-label-primary);
1707
+ }
1708
+ .dcpa-info-btn:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 1px; }
1709
+ .dcpa-info-pop {
1710
+ position: absolute;
1711
+ top: calc(100% + 6px);
1712
+ left: -8px;
1713
+ z-index: 30;
1714
+ isolation: isolate;
1715
+ width: max-content;
1716
+ max-width: 280px;
1717
+ padding: 8px 10px;
1718
+ border-radius: var(--dsw-radius-md, 10px);
1719
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
1720
+ box-shadow: var(--dsw-elevation-prominent, 0 8px 24px var(--dsw-alias-border-l2));
1721
+ color: var(--dsw-alias-label-primary);
1722
+ font-size: 12px;
1723
+ line-height: 18px;
1724
+ font-weight: 400;
1725
+ white-space: normal;
1726
+ text-align: left;
1727
+ }
1728
+ .dcpa-info-pop[data-align="end"] { left: auto; right: -8px; }
1729
+ .dcpa-savebar {
1730
+ position: sticky;
1731
+ bottom: 0;
1732
+ z-index: 10;
1733
+ display: flex;
1734
+ align-items: center;
1735
+ justify-content: space-between;
1736
+ gap: 12px;
1737
+ margin-top: 16px;
1738
+ padding: 12px 0;
1739
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
1740
+ background: var(--dsw-alias-bg-layer-2);
1741
+ }
1742
+ .dcpa-savebar-text { font-size: 13px; color: var(--dsw-alias-label-secondary); }
1743
+ .dcpa-savebar-text[data-tone="ok"] { color: var(--dsw-alias-state-success-primary); }
1744
+ .dcpa-savebar-text[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
1745
+ /* Model tab: same flat rows as the other tabs. */
1746
+ .dcpa-models { gap: 10px; padding-top: 12px; }
1747
+ .dcpa-models .dcpa-input { height: 34px; border-radius: var(--dsw-radius-md, 10px); background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-base)); border-color: var(--dsw-alias-border-l4, var(--dsw-alias-border-l2)); }
1748
+ .dcpa-models .dcpa-btn { border: 0; background: transparent; height: 28px; border-radius: var(--dsw-radius-sm, 8px); }
1749
+ .dcpa-models .dcpa-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
1750
+ .dcpa-models .dcpa-seg { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); border: 0; padding: 3px; }
1751
+ .dcpa-models .dcpa-seg-btn[data-active="true"] { background: var(--dsw-alias-bg-layer-1); box-shadow: var(--dsw-elevation-soft, none); }
1752
+ .dcpa-models .dcpa-chip { border: 0; background: transparent; }
1753
+ .dcpa-models .dcpa-chip[data-active="true"] { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); color: var(--dsw-alias-label-primary); }
1754
+ .dcpa-models .dcpa-model-list { max-height: none; overflow: visible; gap: 0; }
1755
+ .dcpa-models .dcpa-model-row,
1756
+ .dcpa-models .dcpa-model-row[data-checked="true"] {
1757
+ padding: 10px 2px;
1758
+ border: 0;
1759
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1760
+ border-radius: 0;
1761
+ background: transparent;
1762
+ }
1763
+ .dcpa-models .dcpa-model-row:hover { background: var(--dsw-alias-interactive-bg-hover, transparent); }
1764
+ .dcpa-models .dcpa-badge { border: 0; background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); border-radius: 999px; padding: 1px 8px; }
1765
+ .dcpa-models .dcpa-editor { border-top: 0; padding: 4px 0 6px 25px; }
1766
+ /* ---- Composer badge: same metrics as the model selector trigger ---- */
1767
+ .dcpa-q-wrap { display: inline-flex; align-items: center; flex: none; }
1768
+ .dcpa-q-badge {
1769
+ box-sizing: border-box;
1770
+ display: inline-flex;
1771
+ align-items: center;
1772
+ gap: 5px;
1773
+ height: 28px;
1774
+ padding: 0 6px;
1775
+ border: none;
1776
+ border-radius: var(--dsw-radius-sm, 8px);
1777
+ background: transparent;
1778
+ color: var(--dsw-alias-label-secondary);
1779
+ font: inherit;
1780
+ font-size: 13px;
1781
+ line-height: 20px;
1782
+ cursor: pointer;
1783
+ white-space: nowrap;
1784
+ }
1785
+ .dcpa-q-badge:hover, .dcpa-q-badge[aria-expanded="true"] {
1786
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1787
+ }
1788
+ .dcpa-q-badge-pct { font-variant-numeric: tabular-nums; }
1789
+ .dcpa-q-badge[data-level="low"] .dcpa-q-badge-pct { color: var(--dsw-alias-state-warn-primary); }
1790
+ .dcpa-q-badge[data-level="empty"] .dcpa-q-badge-pct { color: var(--dsw-alias-state-error-primary); }
1791
+ .dcpa-q-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 11px; }
1792
+ .dcpa-q-bars i { display: block; width: 3px; border-radius: 1px; background: var(--dsw-alias-label-tertiary, var(--dsw-alias-border-l2)); opacity: 0.45; }
1793
+ .dcpa-q-bars i:nth-child(1) { height: 5px; }
1794
+ .dcpa-q-bars i:nth-child(2) { height: 8px; }
1795
+ .dcpa-q-bars i:nth-child(3) { height: 11px; }
1796
+ .dcpa-q-bars[data-level="ok"] i { background: var(--dsw-alias-state-success-primary); opacity: 1; }
1797
+ .dcpa-q-bars[data-level="mid"] i:nth-child(-n+2) { background: var(--dsw-alias-state-success-primary); opacity: 1; }
1798
+ .dcpa-q-bars[data-level="low"] i:nth-child(1) { background: var(--dsw-alias-state-warn-primary); opacity: 1; }
1799
+ .dcpa-q-bars[data-level="empty"] i { background: var(--dsw-alias-state-error-primary); }
1800
+
1801
+ /* ---- Composer peek: the DSH menu surface ---- */
1802
+ .dcpa-q-pop {
1803
+ position: fixed;
1804
+ z-index: 1100;
1805
+ isolation: isolate;
1806
+ box-sizing: border-box;
1807
+ width: 340px;
1808
+ max-width: calc(100vw - 32px);
1809
+ max-height: min(440px, calc(100vh - 96px));
1810
+ overflow-y: auto;
1811
+ padding: 4px;
1812
+ border: 0;
1813
+ border-radius: var(--dsw-radius-lg, 12px);
1814
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
1815
+ box-shadow: var(--dsw-elevation-prominent, 0 8px 24px var(--dsw-alias-border-l2));
1816
+ color: var(--dsw-alias-label-primary);
1817
+ font-size: 13px;
1818
+ line-height: 20px;
1819
+ text-align: left;
1820
+ }
1821
+ .dcpa-q-pop::before {
1822
+ content: "";
1823
+ position: absolute;
1824
+ inset: 0;
1825
+ z-index: -1;
1826
+ border-radius: inherit;
1827
+ background: var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1));
1828
+ backdrop-filter: var(--dsw-menu-backdrop-filter, none);
1829
+ pointer-events: none;
1830
+ }
1831
+ .dcpa-q-pop-head { padding: 8px 8px 6px; }
1832
+ .dcpa-q-pop-model { font-weight: 500; overflow-wrap: anywhere; }
1833
+ .dcpa-q-pop-sub { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
1834
+ .dcpa-q-pop-cred {
1835
+ display: flex;
1836
+ flex-direction: column;
1837
+ gap: 8px;
1838
+ padding: 8px;
1839
+ border-radius: var(--dsw-radius-md, 10px);
1840
+ }
1841
+ .dcpa-q-pop-cred:hover { background: var(--dsw-alias-interactive-bg-hover, transparent); }
1842
+ .dcpa-q-pop-who { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 13px; }
1843
+ .dcpa-q-pop-who .dcpa-q-name { font-size: 13px; flex: 0 1 auto; }
1844
+ .dcpa-q-pop-cred .dcpa-q-meters { justify-content: flex-start; gap: 8px 16px; }
1845
+ .dcpa-q-pop-cred .dcpa-q-meter { width: 146px; }
1846
+ .dcpa-q-pop-cred .dcpa-q-note { text-align: left; }
1847
+ .dcpa-q-pop-text { margin: 0; padding: 4px 8px 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
1848
+ .dcpa-q-pop-text[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
1849
+ .dcpa-q-pop-foot {
1850
+ display: flex;
1851
+ align-items: center;
1852
+ justify-content: space-between;
1853
+ gap: 8px;
1854
+ margin-top: 4px;
1855
+ padding: 6px 4px 2px 8px;
1856
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
1857
+ font-size: 12px;
1858
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));
1859
+ }
1860
+ `;
1861
+
1862
+ const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
1863
+ const PROTOCOL_NAME_KEYS = {
1864
+ "openai-completions": "protocolNameCompletions",
1865
+ "openai-responses": "protocolNameResponses",
1866
+ "anthropic-messages": "protocolNameMessages",
1867
+ };
1868
+ const PROTOCOLS = Object.keys(PROTOCOL_NAME_KEYS);
1869
+
1870
+ function cloneOverrides(value) {
1871
+ const out = {};
1872
+ for (const [id, entry] of Object.entries(value ?? {})) {
1873
+ out[id] = {
1874
+ ...entry,
1875
+ ...(Array.isArray(entry.reasoningLevels) ? { reasoningLevels: [...entry.reasoningLevels] } : {}),
1876
+ };
1877
+ }
1878
+ return out;
1879
+ }
1880
+
1881
+ function overridesKey(value) {
1882
+ return JSON.stringify(value ?? {});
1883
+ }
1884
+
1885
+ function formatTokens(n) {
1886
+ if (typeof n !== "number" || !Number.isFinite(n)) return "";
1887
+ if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
1888
+ if (n >= 1000) return `${Math.round(n / 1000)}K`;
1889
+ return String(n);
1890
+ }
1891
+
1892
+ async function postJson(url, body) {
1893
+ const res = await fetch(url, {
1894
+ method: "POST",
1895
+ headers: { "content-type": "application/json" },
1896
+ body: JSON.stringify(body ?? {}),
1897
+ });
1898
+ const payload = await res.json();
1899
+ if (!res.ok || payload.ok === false) {
1900
+ throw new Error(payload.error || `HTTP ${res.status}`);
1901
+ }
1902
+ return payload;
1903
+ }
1904
+
1905
+ function stateDotClass(state) {
1906
+ return state === "connected"
1907
+ ? "dcpa-dot dcpa-dot-connected"
1908
+ : state === "probing"
1909
+ ? "dcpa-dot dcpa-dot-probing"
1910
+ : state === "error"
1911
+ ? "dcpa-dot dcpa-dot-error"
1912
+ : "dcpa-dot dcpa-dot-idle";
1913
+ }
1914
+
1915
+ const SERVICE_UNAVAILABLE_STATUS = new Set([404, 405, 501]);
1916
+ const UPDATE_ERROR_KEYS = {
1917
+ "network-error": "updateNetworkError",
1918
+ "invalid-metadata": "updateMetadataError",
1919
+ "invalid-response": "updateMetadataError",
1920
+ "service-unavailable": "updateServiceUnavailableError",
1921
+ "forbidden-origin": "updateOriginError",
1922
+ "channel-unavailable": "updateChannelUnavailable",
1923
+ "unavailable-channel": "updateChannelUnavailable",
1924
+ "integrity-error": "updateIntegrityError",
1925
+ "invalid-package": "updateInvalidPackage",
1926
+ "storage-error": "updateStorageError",
1927
+ busy: "updateBusyError",
1928
+ "stale-version": "updateStaleVersion",
1929
+ disposed: "updateDisposedError",
1112
1930
  "linked-install": "updateLinkedHint",
1113
1931
  "unsupported-host": "updateUnsupportedHint",
1114
1932
  "not-installed": "updateNotInstalledHint",
@@ -1925,19 +2743,113 @@ window.__ModuleLoader__.load({
1925
2743
  }
1926
2744
 
1927
2745
  /**
1928
- * Account list + switcher (the "CLIProxyAPI" settings section). Each
1929
- * account is edited in its own {@link AccountPanel}, remounted when the
1930
- * selection changes.
2746
+ * Small "i" help button: the explanation opens on hover, keyboard focus or
2747
+ * click (touch), closes on Escape / leaving. The text stays in the locale
2748
+ * dictionaries and is announced through aria-describedby.
2749
+ */
2750
+ function InfoTip({ t, text }) {
2751
+ const [open, setOpen] = useState(false);
2752
+ const [alignEnd, setAlignEnd] = useState(false);
2753
+ const ref = useRef(null);
2754
+ const id = useMemo(() => `dcpa-tip-${Math.random().toString(36).slice(2, 9)}`, []);
2755
+ if (!text) return null;
2756
+ // Open towards the roomier side of the surrounding page.
2757
+ const show = () => {
2758
+ const el = ref.current;
2759
+ const box = el?.closest?.(".dcpa-wrap, .dcpa-q-pop")?.getBoundingClientRect?.();
2760
+ const r = el?.getBoundingClientRect?.();
2761
+ if (box && r) setAlignEnd(r.left - box.left > box.width / 2);
2762
+ setOpen(true);
2763
+ };
2764
+ return h(
2765
+ "span",
2766
+ {
2767
+ ref,
2768
+ className: "dcpa-info",
2769
+ onMouseEnter: show,
2770
+ onMouseLeave: () => setOpen(false),
2771
+ },
2772
+ h(
2773
+ "button",
2774
+ {
2775
+ type: "button",
2776
+ className: "dcpa-info-btn",
2777
+ "aria-label": t("infoLabel"),
2778
+ "aria-describedby": open ? id : undefined,
2779
+ "aria-expanded": open,
2780
+ onClick: (e) => {
2781
+ e.preventDefault();
2782
+ e.stopPropagation();
2783
+ if (open) setOpen(false);
2784
+ else show();
2785
+ },
2786
+ onFocus: show,
2787
+ onBlur: () => setOpen(false),
2788
+ onKeyDown: (e) => {
2789
+ if (e.key === "Escape") setOpen(false);
2790
+ },
2791
+ },
2792
+ "i"
2793
+ ),
2794
+ open ? h("span", { id, role: "tooltip", className: "dcpa-info-pop", "data-align": alignEnd ? "end" : "start" }, text) : null
2795
+ );
2796
+ }
2797
+
2798
+ /** One labelled form row: label (+ help) on the left, control on the right. */
2799
+ function FieldRow({ t, label, help, htmlFor, children, stack = false }) {
2800
+ return h(
2801
+ "div",
2802
+ { className: stack ? "dcpa-frow dcpa-frow-stack" : "dcpa-frow" },
2803
+ h(
2804
+ "div",
2805
+ { className: "dcpa-frow-label" },
2806
+ htmlFor ? h("label", { htmlFor }, label) : h("span", null, label),
2807
+ help ? h(InfoTip, { t, text: help }) : null
2808
+ ),
2809
+ h("div", { className: "dcpa-frow-control" }, children)
2810
+ );
2811
+ }
2812
+
2813
+ function accountStateLabel(t, state) {
2814
+ return state === "connected"
2815
+ ? t("statusConnected")
2816
+ : state === "probing"
2817
+ ? t("statusProbing")
2818
+ : state === "error"
2819
+ ? t("statusError")
2820
+ : state === "disabled"
2821
+ ? t("statusDisabled")
2822
+ : t("statusIdle");
2823
+ }
2824
+
2825
+ /** Lowest remaining percentage among an account's usable credentials (tab hint). */
2826
+ function accountQuotaFloor(quotaData, accountId) {
2827
+ const account = (quotaData?.accounts ?? []).find((a) => a.accountId === accountId);
2828
+ if (!account || account.keyConfigured !== true) return null;
2829
+ const values = (account.credentials ?? [])
2830
+ .filter((c) => c.status === "active")
2831
+ .map((c) => credentialHeadroom(c))
2832
+ .filter((v) => v !== null);
2833
+ return values.length > 0 ? Math.min(...values) : null;
2834
+ }
2835
+
2836
+ /**
2837
+ * The CLIProxyAPI settings section: one account bar for every tab, then the
2838
+ * selected account's header, its 连接 / 模型 / 额度 tabs and one save bar.
2839
+ * Owns the account server data; the add-account form creates an account only
2840
+ * when the user connects it.
1931
2841
  */
1932
2842
  function CpaSettingsPanel({ t }) {
1933
2843
  const [loading, setLoading] = useState(true);
1934
- const [busy, setBusy] = useState(null); // null | "refresh" | "save" | "add" | "remove"
1935
- const [error, setError] = useState(null);
1936
- const [toast, setToast] = useState(null);
2844
+ const [busy, setBusy] = useState(null); // null | "refresh" | "save" | "patch" | "add" | "remove" | "test"
2845
+ const [loadError, setLoadError] = useState(null);
2846
+ const [notice, setNotice] = useState(null); // { tone: "ok" | "error", text }
1937
2847
  const [data, setData] = useState(null);
1938
2848
  const [activeId, setActiveId] = useState(null);
1939
2849
  // Bumped whenever the server's saved config should replace the drafts.
1940
2850
  const [rev, setRev] = useState(0);
2851
+ const [adding, setAdding] = useState(false);
2852
+ const dirtyRef = useRef(false);
1941
2853
 
1942
2854
  const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
1943
2855
  const active = accounts.find((a) => a.id === activeId) ?? accounts[0];
@@ -1951,9 +2863,9 @@ window.__ModuleLoader__.load({
1951
2863
  }
1952
2864
  setData(body);
1953
2865
  setRev((r) => r + 1);
1954
- setError(null);
2866
+ setLoadError(null);
1955
2867
  } catch (err) {
1956
- setError(err instanceof Error ? err.message : String(err));
2868
+ setLoadError(err instanceof Error ? err.message : String(err));
1957
2869
  } finally {
1958
2870
  setLoading(false);
1959
2871
  }
@@ -1963,123 +2875,153 @@ window.__ModuleLoader__.load({
1963
2875
  void fetchStatus();
1964
2876
  }, [fetchStatus]);
1965
2877
 
2878
+ // Success messages fade after a few seconds; errors stay until the next action.
2879
+ useEffect(() => {
2880
+ if (notice?.tone !== "ok") return undefined;
2881
+ const id = setTimeout(() => setNotice(null), 4000);
2882
+ return () => clearTimeout(id);
2883
+ }, [notice]);
2884
+
2885
+ const confirmDiscard = useCallback(() => {
2886
+ if (!dirtyRef.current) return true;
2887
+ if (typeof window === "undefined" || typeof window.confirm !== "function") return true;
2888
+ return window.confirm(t("discardConfirm"));
2889
+ }, [t]);
2890
+
1966
2891
  const run = useCallback(async (kind, fn) => {
1967
2892
  setBusy(kind);
1968
- setError(null);
1969
- setToast(null);
2893
+ setNotice(null);
1970
2894
  try {
1971
2895
  return await fn();
1972
2896
  } catch (err) {
1973
- setError(err instanceof Error ? err.message : String(err));
2897
+ setNotice({ tone: "error", text: err instanceof Error ? err.message : String(err) });
1974
2898
  return undefined;
1975
2899
  } finally {
1976
2900
  setBusy(null);
1977
2901
  }
1978
2902
  }, []);
1979
2903
 
1980
- const onRefresh = useCallback(
1981
- (accountId) =>
2904
+ const selectAccount = (id) => {
2905
+ if (id === active?.id && !adding) return;
2906
+ if (!confirmDiscard()) return;
2907
+ dirtyRef.current = false;
2908
+ setAdding(false);
2909
+ setNotice(null);
2910
+ setActiveId(id);
2911
+ setRev((r) => r + 1);
2912
+ };
2913
+
2914
+ const actions = {
2915
+ refresh: (accountId) =>
1982
2916
  run("refresh", async () => {
1983
2917
  setData(await postJson(API.refresh, { accountId }));
1984
2918
  }),
1985
- [run]
1986
- );
1987
-
1988
- const onSave = useCallback(
1989
- (accountId, payload) =>
2919
+ save: (accountId, payload) =>
1990
2920
  run("save", async () => {
1991
2921
  setData(await postJson(API.save, { ...payload, accountId }));
2922
+ dirtyRef.current = false;
1992
2923
  setRev((r) => r + 1);
1993
- setToast(t("savedToast"));
2924
+ setNotice({ tone: "ok", text: t("savedToast") });
1994
2925
  }),
1995
- [run, t]
1996
- );
1997
-
1998
- const onAdd = useCallback(
1999
- () =>
2000
- run("add", async () => {
2001
- const body = await postJson(API.addAccount, {});
2002
- setData(body);
2003
- if (body.createdId) setActiveId(body.createdId);
2004
- setRev((r) => r + 1);
2005
- setToast(t("addedToast"));
2926
+ // One field, applied at once (enable switch, clear key); drafts stay.
2927
+ patch: (accountId, patch, okText) =>
2928
+ run("patch", async () => {
2929
+ setData(await postJson(API.save, { ...patch, accountId }));
2930
+ if (okText) setNotice({ tone: "ok", text: okText });
2006
2931
  }),
2007
- [run, t]
2008
- );
2009
-
2010
- const onRemove = useCallback(
2011
- (account) => {
2932
+ test: (accountId, draft) =>
2933
+ run("test", async () => (await postJson(API.testAccount, { ...(accountId ? { accountId } : {}), ...draft })).test),
2934
+ remove: (account) => {
2012
2935
  const name = account.config?.displayName || account.id;
2013
- if (typeof window !== "undefined" && !window.confirm(t("removeConfirm").replace("{name}", name))) {
2014
- return;
2015
- }
2936
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(fmt(t, "removeConfirm", { name }))) return;
2016
2937
  void run("remove", async () => {
2017
2938
  const body = await postJson(API.removeAccount, { accountId: account.id });
2939
+ dirtyRef.current = false;
2018
2940
  setData(body);
2019
2941
  setActiveId(body.accounts?.[0]?.id ?? null);
2020
2942
  setRev((r) => r + 1);
2021
- setToast(t("removedToast"));
2943
+ setNotice({ tone: "ok", text: t("removedToast") });
2022
2944
  });
2023
2945
  },
2024
- [run, t]
2025
- );
2946
+ // Reload the drafts from the saved config.
2947
+ discard: () => {
2948
+ dirtyRef.current = false;
2949
+ setNotice(null);
2950
+ setRev((r) => r + 1);
2951
+ },
2952
+ add: (draft, enabled) =>
2953
+ run("add", async () => {
2954
+ const body = await postJson(API.addAccount, { ...draft, enabled });
2955
+ setData(body);
2956
+ if (body.createdId) setActiveId(body.createdId);
2957
+ setAdding(false);
2958
+ setRev((r) => r + 1);
2959
+ setNotice({ tone: "ok", text: enabled ? t("addedToast") : t("addedDisabledToast") });
2960
+ }),
2961
+ };
2962
+
2963
+ const showAdd = adding || (!loading && data !== null && accounts.length === 0);
2026
2964
 
2027
2965
  return h(
2028
2966
  "div",
2029
- { className: "dcpa-wrap", "data-dsh-plugin": MODULE_ID },
2967
+ { className: "dcpa-wrap dcpa-q", "data-dsh-plugin": MODULE_ID },
2030
2968
  h(
2031
2969
  "div",
2032
- { className: "dcpa-card" },
2033
- h(
2034
- "div",
2035
- { className: "dcpa-head" },
2036
- h(
2037
- "div",
2038
- null,
2039
- h("h3", { className: "dcpa-title" }, t("title")),
2040
- h("p", { className: "dcpa-subtitle" }, t("subtitle"))
2041
- )
2042
- ),
2043
- h(
2044
- "div",
2045
- { className: "dcpa-account-bar", role: "tablist", "aria-label": t("accountsLabel") },
2046
- accounts.map((a) =>
2047
- h(
2048
- "button",
2049
- {
2050
- key: a.id,
2051
- type: "button",
2052
- role: "tab",
2053
- className: "dcpa-account-chip",
2054
- "aria-selected": active?.id === a.id,
2055
- "data-active": active?.id === a.id ? "true" : "false",
2056
- "data-enabled": a.config?.enabled === false ? "false" : "true",
2057
- title: a.config?.provider,
2058
- onClick: () => setActiveId(a.id),
2059
- },
2060
- h("span", { className: stateDotClass(a.status?.state) }),
2061
- a.config?.displayName || a.id,
2062
- a.config?.enabled === false
2063
- ? h("span", { className: "dcpa-badge" }, t("accountOffBadge"))
2064
- : null
2970
+ { className: "dcpa-acct-bar" },
2971
+ accounts.length > 0
2972
+ ? h(
2973
+ "div",
2974
+ { className: "dcpa-q-seg", role: "tablist", "aria-label": t("accountsLabel") },
2975
+ accounts.map((a) =>
2976
+ h(
2977
+ "button",
2978
+ {
2979
+ key: a.id,
2980
+ type: "button",
2981
+ role: "tab",
2982
+ className: "dcpa-q-seg-tab",
2983
+ "aria-selected": !showAdd && active?.id === a.id,
2984
+ title: a.config?.provider,
2985
+ onClick: () => selectAccount(a.id),
2986
+ },
2987
+ h("span", { className: stateDotClass(a.config?.enabled === false ? "idle" : a.status?.state) }),
2988
+ a.config?.displayName || a.id
2989
+ )
2990
+ )
2065
2991
  )
2066
- ),
2067
- h(
2068
- "button",
2069
- {
2070
- type: "button",
2071
- className: "dcpa-btn dcpa-btn-sm",
2072
- disabled: busy !== null || loading,
2073
- onClick: onAdd,
2992
+ : null,
2993
+ h(
2994
+ "button",
2995
+ {
2996
+ type: "button",
2997
+ className: "dcpa-q-btn",
2998
+ "data-variant": "outline",
2999
+ disabled: busy !== null || loading || showAdd,
3000
+ onClick: () => {
3001
+ if (!confirmDiscard()) return;
3002
+ dirtyRef.current = false;
3003
+ setNotice(null);
3004
+ setAdding(true);
2074
3005
  },
2075
- busy === "add" ? t("addingAccountBtn") : t("addAccountBtn")
2076
- )
2077
- ),
2078
- !loading && accounts.length === 0 ? h("p", { className: "dcpa-hint" }, t("noAccounts")) : null,
2079
- error ? h("p", { className: "dcpa-error", role: "alert" }, error) : null,
2080
- toast ? h("p", { className: "dcpa-success", role: "status" }, toast) : null
3006
+ },
3007
+ t("addAccountBtn")
3008
+ )
2081
3009
  ),
2082
- active
3010
+ loadError ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, loadError) : null,
3011
+ showAdd
3012
+ ? h(AddAccountForm, {
3013
+ t,
3014
+ busy,
3015
+ notice,
3016
+ canCancel: accounts.length > 0,
3017
+ onCancel: () => {
3018
+ setAdding(false);
3019
+ setNotice(null);
3020
+ },
3021
+ onTest: (draft) => actions.test(undefined, draft),
3022
+ onAdd: actions.add,
3023
+ })
3024
+ : active
2083
3025
  ? h(AccountPanel, {
2084
3026
  key: active.id,
2085
3027
  t,
@@ -2087,18 +3029,105 @@ window.__ModuleLoader__.load({
2087
3029
  global: data?.global ?? {},
2088
3030
  rev,
2089
3031
  busy,
2090
- onRefresh: () => onRefresh(active.id),
2091
- onSave: (payload) => onSave(active.id, payload),
2092
- onRemove: () => onRemove(active),
3032
+ notice,
3033
+ actions,
3034
+ onDirtyChange: (dirty) => {
3035
+ dirtyRef.current = dirty;
3036
+ },
2093
3037
  })
2094
3038
  : null
2095
3039
  );
2096
3040
  }
2097
3041
 
2098
- function AccountPanel({ t, account, global, rev, busy, onRefresh, onSave: saveAccount, onRemove }) {
3042
+ /** Add an account: name, address, key → test, then create enabled (or disabled on request). */
3043
+ function AddAccountForm({ t, busy, notice, canCancel, onCancel, onTest, onAdd }) {
3044
+ const [displayName, setDisplayName] = useState("");
3045
+ const [baseURL, setBaseURL] = useState("");
3046
+ const [apiKey, setApiKey] = useState("");
3047
+ const [failure, setFailure] = useState(null);
3048
+
3049
+ const draft = () => ({
3050
+ ...(displayName.trim() ? { displayName: displayName.trim() } : {}),
3051
+ baseURL: baseURL.trim(),
3052
+ ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
3053
+ });
3054
+ const connect = async () => {
3055
+ setFailure(null);
3056
+ const result = await onTest({ baseURL: baseURL.trim(), apiKey: apiKey.trim() });
3057
+ if (result === undefined) return;
3058
+ if (result.ok) await onAdd(draft(), true);
3059
+ else setFailure(result.error || t("statusError"));
3060
+ };
3061
+
3062
+ return h(
3063
+ "div",
3064
+ { className: "dcpa-panel" },
3065
+ h("h3", { className: "dcpa-q-h" }, t("addTitle")),
3066
+ h(
3067
+ FieldRow,
3068
+ { t, label: t("displayNameLabel"), help: t("displayNameHint"), htmlFor: "dcpa-add-name" },
3069
+ h("input", {
3070
+ id: "dcpa-add-name",
3071
+ className: "dcpa-q-input",
3072
+ value: displayName,
3073
+ placeholder: t("addNamePlaceholder"),
3074
+ onChange: (e) => setDisplayName(e.target.value),
3075
+ })
3076
+ ),
3077
+ h(
3078
+ FieldRow,
3079
+ { t, label: t("baseUrlLabel"), htmlFor: "dcpa-add-url" },
3080
+ h("input", {
3081
+ id: "dcpa-add-url",
3082
+ className: "dcpa-q-input",
3083
+ value: baseURL,
3084
+ placeholder: t("baseUrlPlaceholder"),
3085
+ onChange: (e) => setBaseURL(e.target.value),
3086
+ })
3087
+ ),
3088
+ h(
3089
+ FieldRow,
3090
+ { t, label: t("apiKeyLabel"), help: t("apiKeyHint"), htmlFor: "dcpa-add-key" },
3091
+ h("input", {
3092
+ id: "dcpa-add-key",
3093
+ className: "dcpa-q-input",
3094
+ type: "password",
3095
+ autoComplete: "new-password",
3096
+ value: apiKey,
3097
+ placeholder: t("apiKeyUnsetPlaceholder"),
3098
+ onChange: (e) => setApiKey(e.target.value),
3099
+ })
3100
+ ),
3101
+ failure ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "testFail", { error: failure })) : null,
3102
+ notice?.tone === "error" ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, notice.text) : null,
3103
+ h(
3104
+ "div",
3105
+ { className: "dcpa-form-actions" },
3106
+ canCancel ? h("button", { type: "button", className: "dcpa-q-btn", disabled: busy !== null, onClick: onCancel }, t("cancelBtn")) : null,
3107
+ failure
3108
+ ? h(
3109
+ "button",
3110
+ { type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy !== null, onClick: () => void onAdd(draft(), false) },
3111
+ t("addAnywayBtn")
3112
+ )
3113
+ : null,
3114
+ h(
3115
+ "button",
3116
+ { type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: busy !== null, onClick: () => void connect() },
3117
+ busy === "test" || busy === "add" ? t("connectingBtn") : t("connectBtn")
3118
+ )
3119
+ )
3120
+ );
3121
+ }
3122
+
3123
+ function AccountPanel({ t, account, global, rev, busy, notice, actions, onDirtyChange }) {
2099
3124
  const syncing = busy === "refresh";
2100
3125
  const saving = busy === "save";
2101
- const loading = busy === "add" || busy === "remove";
3126
+
3127
+ const [tab, setTab] = useState("connection");
3128
+ const [menuOpen, setMenuOpen] = useState(false);
3129
+ const menuRef = useRef(null);
3130
+ const [testResult, setTestResult] = useState(null);
2102
3131
 
2103
3132
  // Model filter UI state
2104
3133
  const [search, setSearch] = useState("");
@@ -2111,7 +3140,6 @@ window.__ModuleLoader__.load({
2111
3140
  const [editingId, setEditingId] = useState(null);
2112
3141
 
2113
3142
  // Form draft state
2114
- const [enabled, setEnabled] = useState(true);
2115
3143
  const [baseURL, setBaseURL] = useState("");
2116
3144
  const [apiKeyDraft, setApiKeyDraft] = useState("");
2117
3145
  // "auto" or the protocol every model is fixed to.
@@ -2124,20 +3152,32 @@ window.__ModuleLoader__.load({
2124
3152
  const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useState("300");
2125
3153
  const [probeTimeoutMs, setProbeTimeoutMs] = useState("5000");
2126
3154
 
3155
+ const savedConnection = (c = account.config ?? {}) => ({
3156
+ baseURL: c.baseURL ?? "",
3157
+ protocol: c.protocolMode === "fixed" ? c.protocol ?? "openai-completions" : "auto",
3158
+ provider: c.provider ?? "cli-proxy",
3159
+ displayName: c.displayName ?? "CLI Proxy API",
3160
+ apiKeyEnv: c.effectiveApiKeyEnv ?? c.apiKeyEnv ?? "CLI_PROXY_API_KEY",
3161
+ includePrefix: c.includePrefix ?? "",
3162
+ excludePrefix: c.excludePrefix ?? "",
3163
+ refreshIntervalSeconds: String(global?.refreshIntervalSeconds ?? 300),
3164
+ });
3165
+
2127
3166
  // Load the drafts from the saved config on mount and after every save.
2128
3167
  useEffect(() => {
2129
3168
  const c = account.config ?? {};
2130
- setEnabled(c.enabled !== false);
2131
- setBaseURL(c.baseURL ?? "");
2132
- setProtocol(c.protocolMode === "fixed" ? c.protocol ?? "openai-completions" : "auto");
2133
- setProvider(c.provider ?? "cli-proxy");
2134
- setDisplayName(c.displayName ?? "CLI Proxy API");
2135
- setApiKeyEnv(c.effectiveApiKeyEnv ?? c.apiKeyEnv ?? "CLI_PROXY_API_KEY");
2136
- setIncludePrefix(c.includePrefix ?? "");
2137
- setExcludePrefix(c.excludePrefix ?? "");
2138
- setRefreshIntervalSeconds(String(global?.refreshIntervalSeconds ?? 300));
3169
+ const saved = savedConnection(c);
3170
+ setBaseURL(saved.baseURL);
3171
+ setProtocol(saved.protocol);
3172
+ setProvider(saved.provider);
3173
+ setDisplayName(saved.displayName);
3174
+ setApiKeyEnv(saved.apiKeyEnv);
3175
+ setIncludePrefix(saved.includePrefix);
3176
+ setExcludePrefix(saved.excludePrefix);
3177
+ setRefreshIntervalSeconds(saved.refreshIntervalSeconds);
2139
3178
  setProbeTimeoutMs(String(global?.probeTimeoutMs ?? 5000));
2140
3179
  setApiKeyDraft("");
3180
+ setTestResult(null);
2141
3181
 
2142
3182
  const sel = Array.isArray(c.selectedModels)
2143
3183
  ? c.selectedModels
@@ -2153,53 +3193,46 @@ window.__ModuleLoader__.load({
2153
3193
  // eslint-disable-next-line react-hooks/exhaustive-deps
2154
3194
  }, [rev]);
2155
3195
 
2156
- const onRefreshNow = onRefresh;
2157
-
2158
- const onSave = useCallback(
2159
- (clearKey = false) =>
2160
- saveAccount({
2161
- enabled,
2162
- baseURL: baseURL.trim(),
2163
- protocolMode: protocol === "auto" ? "auto" : "fixed",
2164
- ...(protocol === "auto" ? {} : { protocol }),
2165
- provider: provider.trim() || account.config?.provider || "cli-proxy",
2166
- displayName: displayName.trim() || account.config?.displayName || "CLI Proxy API",
2167
- apiKeyEnv: apiKeyEnv.trim() || account.config?.apiKeyEnv || "CLI_PROXY_API_KEY",
2168
- includePrefix: includePrefix.trim(),
2169
- excludePrefix: excludePrefix.trim(),
2170
- // Before the first catalog is seen, an empty draft is not a choice:
2171
- // leave the selection unset so the first probe can recommend one.
2172
- ...(selectedIds.size > 0 || (account.status?.allModels?.length ?? 0) > 0
2173
- ? { selectedModels: [...selectedIds] }
2174
- : {}),
2175
- modelOverrides,
2176
- refreshIntervalSeconds: Math.max(15, Number(refreshIntervalSeconds) || 300),
2177
- probeTimeoutMs: Math.max(250, Number(probeTimeoutMs) || 5000),
2178
- ...(apiKeyDraft.trim() !== "" ? { apiKey: apiKeyDraft.trim() } : {}),
2179
- ...(clearKey ? { clearApiKey: true } : {}),
2180
- }),
2181
- [
2182
- saveAccount,
2183
- account,
2184
- selectedIds,
2185
- modelOverrides,
2186
- enabled,
2187
- baseURL,
2188
- protocol,
2189
- provider,
2190
- displayName,
2191
- apiKeyEnv,
2192
- includePrefix,
2193
- excludePrefix,
2194
- refreshIntervalSeconds,
2195
- probeTimeoutMs,
2196
- apiKeyDraft,
2197
- ]
2198
- );
3196
+ useEffect(() => {
3197
+ if (!menuOpen) return undefined;
3198
+ const onDown = (e) => {
3199
+ if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
3200
+ };
3201
+ const onKey = (e) => {
3202
+ if (e.key === "Escape") setMenuOpen(false);
3203
+ };
3204
+ document.addEventListener("mousedown", onDown, true);
3205
+ document.addEventListener("keydown", onKey, true);
3206
+ return () => {
3207
+ document.removeEventListener("mousedown", onDown, true);
3208
+ document.removeEventListener("keydown", onKey, true);
3209
+ };
3210
+ }, [menuOpen]);
3211
+
3212
+ const payload = () => ({
3213
+ baseURL: baseURL.trim(),
3214
+ protocolMode: protocol === "auto" ? "auto" : "fixed",
3215
+ ...(protocol === "auto" ? {} : { protocol }),
3216
+ provider: provider.trim() || account.config?.provider || "cli-proxy",
3217
+ displayName: displayName.trim() || account.config?.displayName || "CLI Proxy API",
3218
+ apiKeyEnv: apiKeyEnv.trim() || account.config?.apiKeyEnv || "CLI_PROXY_API_KEY",
3219
+ includePrefix: includePrefix.trim(),
3220
+ excludePrefix: excludePrefix.trim(),
3221
+ // Before the first catalog is seen, an empty draft is not a choice:
3222
+ // leave the selection unset so the first probe can recommend one.
3223
+ ...(selectedIds.size > 0 || (account.status?.allModels?.length ?? 0) > 0
3224
+ ? { selectedModels: [...selectedIds] }
3225
+ : {}),
3226
+ modelOverrides,
3227
+ refreshIntervalSeconds: Math.max(15, Number(refreshIntervalSeconds) || 300),
3228
+ probeTimeoutMs: Math.max(250, Number(probeTimeoutMs) || 5000),
3229
+ ...(apiKeyDraft.trim() !== "" ? { apiKey: apiKeyDraft.trim() } : {}),
3230
+ });
2199
3231
 
2200
3232
  const st = account.status ?? { state: "idle", modelCount: 0, models: [], allModels: [] };
2201
3233
  const cred = account.credential ?? { configured: false, ref: apiKeyEnv };
2202
3234
  const effectiveUrl = account.config?.effectiveBaseURL || "";
3235
+ const enabled = account.config?.enabled !== false;
2203
3236
 
2204
3237
  const allModels = useMemo(() => {
2205
3238
  if (Array.isArray(st.allModels) && st.allModels.length > 0) return st.allModels;
@@ -2234,9 +3267,25 @@ window.__ModuleLoader__.load({
2234
3267
 
2235
3268
  const currentIdsKey = useMemo(() => [...selectedIds].sort().join("\0"), [selectedIds]);
2236
3269
  const currentOverridesKey = useMemo(() => overridesKey(modelOverrides), [modelOverrides]);
2237
- const selectionDirty =
2238
- (savedIdsKey !== "" && currentIdsKey !== savedIdsKey) ||
2239
- currentOverridesKey !== savedOverridesKey;
3270
+ const draftConnection = {
3271
+ baseURL,
3272
+ protocol,
3273
+ provider,
3274
+ displayName,
3275
+ apiKeyEnv,
3276
+ includePrefix,
3277
+ excludePrefix,
3278
+ refreshIntervalSeconds,
3279
+ };
3280
+ const dirty =
3281
+ currentIdsKey !== savedIdsKey ||
3282
+ currentOverridesKey !== savedOverridesKey ||
3283
+ apiKeyDraft.trim() !== "" ||
3284
+ JSON.stringify(draftConnection) !== JSON.stringify(savedConnection());
3285
+
3286
+ useEffect(() => {
3287
+ onDirtyChange?.(dirty);
3288
+ }, [dirty, onDirtyChange]);
2240
3289
 
2241
3290
  const toggleModel = useCallback((id) => {
2242
3291
  setSelectedIds((prev) => {
@@ -2282,18 +3331,10 @@ window.__ModuleLoader__.load({
2282
3331
  };
2283
3332
  const merged = { ...current, ...patch };
2284
3333
  const normalized = {};
2285
- if (
2286
- Number.isInteger(merged.contextWindow) &&
2287
- merged.contextWindow > 0 &&
2288
- merged.contextWindow !== discovered.contextWindow
2289
- ) {
3334
+ if (Number.isInteger(merged.contextWindow) && merged.contextWindow > 0 && merged.contextWindow !== discovered.contextWindow) {
2290
3335
  normalized.contextWindow = merged.contextWindow;
2291
3336
  }
2292
- if (
2293
- Number.isInteger(merged.maxTokens) &&
2294
- merged.maxTokens > 0 &&
2295
- merged.maxTokens !== discovered.maxTokens
2296
- ) {
3337
+ if (Number.isInteger(merged.maxTokens) && merged.maxTokens > 0 && merged.maxTokens !== discovered.maxTokens) {
2297
3338
  normalized.maxTokens = merged.maxTokens;
2298
3339
  }
2299
3340
  if (merged.image !== discovered.image) normalized.image = merged.image === true;
@@ -2309,9 +3350,7 @@ window.__ModuleLoader__.load({
2309
3350
 
2310
3351
  // The protocol a model would use with the panel's current draft settings.
2311
3352
  const effectiveProtocol = useCallback(
2312
- (m) =>
2313
- modelOverrides[m.id]?.protocol ??
2314
- (protocol === "auto" ? m.nativeProtocol ?? "openai-completions" : protocol),
3353
+ (m) => modelOverrides[m.id]?.protocol ?? (protocol === "auto" ? m.nativeProtocol ?? "openai-completions" : protocol),
2315
3354
  [modelOverrides, protocol]
2316
3355
  );
2317
3356
  const protocolName = (p) => t(PROTOCOL_NAME_KEYS[p] ?? "protocolNameCompletions");
@@ -2324,134 +3363,188 @@ window.__ModuleLoader__.load({
2324
3363
  });
2325
3364
  }, []);
2326
3365
 
2327
- const dotClass = stateDotClass(st.state);
3366
+ const quotaSnap = useQuota(sharedQuota);
3367
+ const quotaFloor = accountQuotaFloor(quotaSnap.data, account.id);
2328
3368
 
2329
- const stateLabel =
2330
- st.state === "connected"
2331
- ? t("statusConnected")
2332
- : st.state === "probing"
2333
- ? t("statusProbing")
2334
- : st.state === "error"
2335
- ? t("statusError")
2336
- : st.state === "disabled"
2337
- ? t("statusDisabled")
2338
- : t("statusIdle");
3369
+ const discard = () => {
3370
+ setTestResult(null);
3371
+ actions.discard();
3372
+ };
2339
3373
 
2340
- return h(
2341
- React.Fragment,
2342
- null,
2343
- // 1. Account Status Card
3374
+ const testConnection = async () => {
3375
+ setTestResult(null);
3376
+ const result = await actions.test(account.id, { baseURL: baseURL.trim(), apiKey: apiKeyDraft.trim() });
3377
+ if (result !== undefined) setTestResult(result);
3378
+ };
3379
+
3380
+ const statusParts = [
3381
+ accountStateLabel(t, enabled ? st.state : "disabled"),
3382
+ st.endpoint || effectiveUrl || null,
3383
+ allModels.length > 0 ? fmt(t, "modelsCountShort", { n: st.modelCount ?? 0, total: st.totalDiscovered ?? allModels.length }) : null,
3384
+ ].filter(Boolean);
3385
+
3386
+ const tabs = [
3387
+ ["connection", t("tabConnection")],
3388
+ ["models", `${t("tabModels")} ${selectedIds.size}`],
3389
+ ["quota", quotaFloor === null ? t("tabQuota") : `${t("tabQuota")} ${pctText(quotaFloor)}`],
3390
+ ];
3391
+
3392
+ const connectionTab = h(
3393
+ "div",
3394
+ { className: "dcpa-tabbody" },
2344
3395
  h(
2345
- "div",
2346
- { className: "dcpa-card" },
3396
+ FieldRow,
3397
+ { t, label: t("displayNameLabel"), help: t("displayNameHint"), htmlFor: `dcpa-name-${account.id}` },
3398
+ h("input", {
3399
+ id: `dcpa-name-${account.id}`,
3400
+ className: "dcpa-q-input",
3401
+ value: displayName,
3402
+ onChange: (e) => setDisplayName(e.target.value),
3403
+ })
3404
+ ),
3405
+ h(
3406
+ FieldRow,
3407
+ { t, label: t("baseUrlLabel"), htmlFor: `dcpa-url-${account.id}` },
2347
3408
  h(
2348
3409
  "div",
2349
- { className: "dcpa-head" },
2350
- h(
2351
- "div",
2352
- null,
2353
- h("h4", { className: "dcpa-section-title" }, account.config?.displayName || account.id),
2354
- h(
2355
- "p",
2356
- { className: "dcpa-hint" },
2357
- `${t("providerIdLabel")}: ${account.config?.provider ?? ""}`
2358
- )
2359
- ),
3410
+ { className: "dcpa-inline" },
3411
+ h("input", {
3412
+ id: `dcpa-url-${account.id}`,
3413
+ className: "dcpa-q-input",
3414
+ value: baseURL,
3415
+ placeholder: effectiveUrl || t("baseUrlPlaceholder"),
3416
+ onChange: (e) => {
3417
+ setBaseURL(e.target.value);
3418
+ setTestResult(null);
3419
+ },
3420
+ }),
2360
3421
  h(
2361
- "div",
2362
- { className: "dcpa-actions" },
2363
- h(
2364
- "button",
2365
- {
2366
- type: "button",
2367
- className: "dcpa-btn",
2368
- disabled: syncing || saving || loading,
2369
- onClick: onRefreshNow,
2370
- },
2371
- syncing ? t("refreshingBtn") : t("refreshBtn")
2372
- ),
2373
- h(
2374
- "button",
2375
- {
2376
- type: "button",
2377
- className: "dcpa-btn dcpa-btn-primary",
2378
- disabled: saving || syncing || loading,
2379
- onClick: () => onSave(false),
2380
- },
2381
- saving ? t("savingBtn") : t("saveBtn")
2382
- )
3422
+ "button",
3423
+ { type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy !== null, onClick: () => void testConnection() },
3424
+ busy === "test" ? t("testingBtn") : t("testBtn")
2383
3425
  )
2384
3426
  ),
3427
+ testResult
3428
+ ? h(
3429
+ "p",
3430
+ { className: "dcpa-q-msg", "data-tone": testResult.ok ? "ok" : "error", role: "status" },
3431
+ testResult.ok
3432
+ ? fmt(t, "testOk", { endpoint: testResult.endpoint, n: testResult.modelCount })
3433
+ : fmt(t, "testFail", { error: testResult.error })
3434
+ )
3435
+ : null
3436
+ ),
3437
+ h(
3438
+ FieldRow,
3439
+ { t, label: t("apiKeyLabel"), help: fmt(t, "apiKeyHint", { ref: cred.ref || apiKeyEnv }), htmlFor: `dcpa-key-${account.id}` },
2385
3440
  h(
2386
3441
  "div",
2387
- { className: "dcpa-status-bar" },
2388
- h(
2389
- "span",
2390
- { className: "dcpa-status-pill" },
2391
- h("span", { className: dotClass }),
2392
- stateLabel
2393
- ),
2394
- h(
2395
- "span",
2396
- { className: "dcpa-meta-item" },
2397
- `${t("endpointLabel")}:`,
2398
- h(
2399
- "span",
2400
- { className: "dcpa-meta-val" },
2401
- st.endpoint || effectiveUrl || t("endpointNone")
2402
- )
2403
- ),
2404
- h(
2405
- "span",
2406
- { className: "dcpa-meta-item" },
2407
- `${t("modelsCountLabel")}:`,
2408
- h(
2409
- "span",
2410
- { className: "dcpa-meta-val" },
2411
- `${st.modelCount ?? 0} / ${st.totalDiscovered ?? allModels.length}`
2412
- )
2413
- )
3442
+ { className: "dcpa-inline" },
3443
+ h("input", {
3444
+ id: `dcpa-key-${account.id}`,
3445
+ type: "password",
3446
+ className: "dcpa-q-input",
3447
+ autoComplete: "new-password",
3448
+ value: apiKeyDraft,
3449
+ placeholder: cred.configured ? t("apiKeySetPlaceholder") : t("apiKeyUnsetPlaceholder"),
3450
+ onChange: (e) => {
3451
+ setApiKeyDraft(e.target.value);
3452
+ setTestResult(null);
3453
+ },
3454
+ }),
3455
+ cred.configured
3456
+ ? h(
3457
+ "button",
3458
+ {
3459
+ type: "button",
3460
+ className: "dcpa-q-btn",
3461
+ "data-variant": "danger",
3462
+ disabled: busy !== null,
3463
+ onClick: () => {
3464
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(t("clearKeyConfirm"))) return;
3465
+ void actions.patch(account.id, { clearApiKey: true }, t("clearedKeyToast"));
3466
+ },
3467
+ },
3468
+ t("clearKeyBtn")
3469
+ )
3470
+ : null
3471
+ )
3472
+ ),
3473
+ h(
3474
+ FieldRow,
3475
+ { t, label: t("protocolLabel"), help: t("protocolHint"), htmlFor: `dcpa-proto-${account.id}` },
3476
+ h(
3477
+ "select",
3478
+ {
3479
+ id: `dcpa-proto-${account.id}`,
3480
+ className: "dcpa-q-input",
3481
+ value: protocol,
3482
+ onChange: (e) => setProtocol(e.target.value),
3483
+ },
3484
+ h("option", { value: "auto" }, t("protocolAuto")),
3485
+ PROTOCOLS.map((p) => h("option", { key: p, value: p }, `${t("protocolFixedPrefix")}${protocolName(p)}`))
3486
+ )
3487
+ ),
3488
+ h(
3489
+ "details",
3490
+ { className: "dcpa-adv" },
3491
+ h("summary", null, t("sectionAdvanced")),
3492
+ h(
3493
+ FieldRow,
3494
+ { t, label: t("providerIdLabel"), help: t("providerIdHint"), htmlFor: `dcpa-route-${account.id}` },
3495
+ h("input", { id: `dcpa-route-${account.id}`, className: "dcpa-q-input", value: provider, onChange: (e) => setProvider(e.target.value) })
2414
3496
  ),
2415
- account.config?.enabled === false ? h("p", { className: "dcpa-hint" }, t("disabledHint")) : null,
2416
- st.error ? h("p", { className: "dcpa-error", role: "alert" }, st.error) : null
3497
+ h(
3498
+ FieldRow,
3499
+ { t, label: t("apiKeyEnvLabel"), help: t("apiKeyEnvHint"), htmlFor: `dcpa-ref-${account.id}` },
3500
+ h("input", { id: `dcpa-ref-${account.id}`, className: "dcpa-q-input", value: apiKeyEnv, onChange: (e) => setApiKeyEnv(e.target.value) })
3501
+ ),
3502
+ h(
3503
+ FieldRow,
3504
+ { t, label: t("includePrefixLabel"), help: t("includePrefixHint"), htmlFor: `dcpa-inc-${account.id}` },
3505
+ h("input", {
3506
+ id: `dcpa-inc-${account.id}`,
3507
+ className: "dcpa-q-input",
3508
+ value: includePrefix,
3509
+ placeholder: t("includePrefixPlaceholder"),
3510
+ onChange: (e) => setIncludePrefix(e.target.value),
3511
+ })
3512
+ ),
3513
+ h(
3514
+ FieldRow,
3515
+ { t, label: t("excludePrefixLabel"), help: t("excludePrefixHint"), htmlFor: `dcpa-exc-${account.id}` },
3516
+ h("input", {
3517
+ id: `dcpa-exc-${account.id}`,
3518
+ className: "dcpa-q-input",
3519
+ value: excludePrefix,
3520
+ placeholder: t("excludePrefixPlaceholder"),
3521
+ onChange: (e) => setExcludePrefix(e.target.value),
3522
+ })
3523
+ )
2417
3524
  ),
2418
-
2419
- // 2. Interactive Model Selection Filter Card
2420
3525
  h(
2421
- "div",
2422
- { className: "dcpa-card" },
3526
+ FieldRow,
3527
+ { t, label: t("refreshIntervalLabel"), htmlFor: `dcpa-interval-${account.id}` },
2423
3528
  h(
2424
3529
  "div",
2425
- { className: "dcpa-head" },
2426
- h(
2427
- "div",
2428
- null,
2429
- h(
2430
- "h4",
2431
- { className: "dcpa-section-title" },
2432
- `${t("sectionModels")} (${selectedIds.size} / ${allModels.length})`
2433
- ),
2434
- h("p", { className: "dcpa-hint" }, t("sectionModelsHint"))
2435
- ),
2436
- h(
2437
- "div",
2438
- { className: "dcpa-actions" },
2439
- selectionDirty
2440
- ? h("span", { className: "dcpa-badge dcpa-badge-warn" }, t("unsavedChanges"))
2441
- : null,
2442
- h(
2443
- "button",
2444
- {
2445
- type: "button",
2446
- className: "dcpa-btn dcpa-btn-primary dcpa-btn-sm",
2447
- disabled: saving || syncing,
2448
- onClick: () => onSave(false),
2449
- },
2450
- saving ? t("savingBtn") : t("saveSelectionBtn")
2451
- )
2452
- )
2453
- ),
3530
+ { className: "dcpa-inline dcpa-inline-narrow" },
3531
+ h("input", {
3532
+ id: `dcpa-interval-${account.id}`,
3533
+ type: "number",
3534
+ min: "15",
3535
+ className: "dcpa-q-input",
3536
+ value: refreshIntervalSeconds,
3537
+ onChange: (e) => setRefreshIntervalSeconds(e.target.value),
3538
+ }),
3539
+ h("span", { className: "dcpa-q-sub" }, t("secondsUnit"))
3540
+ )
3541
+ )
3542
+ );
2454
3543
 
3544
+ const modelsTab = h(
3545
+ "div",
3546
+ { className: "dcpa-tabbody dcpa-models" },
3547
+ h("p", { className: "dcpa-q-sub" }, t("modelsEmptyRule")),
2455
3548
  // Search + Segmented Filter + Batch Actions
2456
3549
  h(
2457
3550
  "div",
@@ -2517,11 +3610,11 @@ window.__ModuleLoader__.load({
2517
3610
  type: "button",
2518
3611
  className: "dcpa-btn dcpa-btn-sm",
2519
3612
  disabled: recommended.length === 0,
2520
- title: t("selectRecommendedHint"),
2521
3613
  onClick: resetRecommended,
2522
3614
  },
2523
3615
  `${t("selectRecommendedBtn")} (${recommended.length})`
2524
- )
3616
+ ),
3617
+ h(InfoTip, { t, text: t("selectRecommendedHint") })
2525
3618
  )
2526
3619
  ),
2527
3620
 
@@ -2779,7 +3872,7 @@ window.__ModuleLoader__.load({
2779
3872
  `${t("modelProtocolHint")}${protocolName(m.nativeProtocol ?? "openai-completions")}`
2780
3873
  )
2781
3874
  ),
2782
- h("p", { className: "dcpa-hint" }, t("paramsHint")),
3875
+ h("div", { className: "dcpa-q-sub" }, t("paramsHintShort"), h(InfoTip, { t, text: t("paramsHint") })),
2783
3876
  hasOverride
2784
3877
  ? h(
2785
3878
  "div",
@@ -2800,22 +3893,30 @@ window.__ModuleLoader__.load({
2800
3893
  );
2801
3894
  })
2802
3895
  )
2803
- ),
3896
+ );
2804
3897
 
2805
- // 3. Connection & Auth Card
3898
+ return h(
3899
+ "div",
3900
+ { className: "dcpa-account" },
2806
3901
  h(
2807
3902
  "div",
2808
- { className: "dcpa-card" },
2809
- h("h4", { className: "dcpa-section-title" }, t("sectionConnection")),
3903
+ { className: "dcpa-acct-head" },
2810
3904
  h(
2811
3905
  "div",
2812
- { className: "dcpa-switch-row" },
3906
+ { className: "dcpa-q-row-main" },
3907
+ h("h3", { className: "dcpa-q-h" }, account.config?.displayName || account.id),
2813
3908
  h(
2814
3909
  "div",
2815
- null,
2816
- h("div", { className: "dcpa-label" }, t("enabledLabel")),
2817
- h("p", { className: "dcpa-hint" }, t("enabledHint"))
2818
- ),
3910
+ { className: "dcpa-q-sub dcpa-status-line" },
3911
+ h("span", { className: stateDotClass(enabled ? st.state : "idle") }),
3912
+ statusParts.join(" · ")
3913
+ )
3914
+ ),
3915
+ h(
3916
+ "div",
3917
+ { className: "dcpa-q-actions" },
3918
+ h("span", { className: "dcpa-q-sub" }, t("enableShort")),
3919
+ h(InfoTip, { t, text: t("enabledHint") }),
2819
3920
  h(
2820
3921
  "button",
2821
3922
  {
@@ -2824,234 +3925,1189 @@ window.__ModuleLoader__.load({
2824
3925
  className: "dcpa-switch",
2825
3926
  "aria-checked": enabled,
2826
3927
  "aria-label": t("enabledLabel"),
2827
- onClick: () => setEnabled(!enabled),
3928
+ disabled: busy !== null,
3929
+ onClick: () => void actions.patch(account.id, { enabled: !enabled }, enabled ? t("disabledToast") : t("enabledToast")),
2828
3930
  },
2829
3931
  h("span", { className: "dcpa-switch-thumb" })
2830
- )
2831
- ),
2832
- h(
2833
- "div",
2834
- { className: "dcpa-field" },
2835
- h("label", { className: "dcpa-label" }, t("baseUrlLabel")),
2836
- h("input", {
2837
- type: "text",
2838
- className: "dcpa-input",
2839
- value: baseURL,
2840
- placeholder: effectiveUrl || t("baseUrlPlaceholder"),
2841
- onChange: (e) => setBaseURL(e.target.value),
2842
- }),
2843
- h("p", { className: "dcpa-hint" }, t("baseUrlHint"))
2844
- ),
2845
- h(
2846
- "div",
2847
- { className: "dcpa-field" },
2848
- h(
2849
- "div",
2850
- { className: "dcpa-label-row" },
2851
- h("label", { className: "dcpa-label" }, `${t("apiKeyLabel")} (${cred.ref || apiKeyEnv})`),
2852
- h(
2853
- "span",
2854
- { className: cred.configured ? "dcpa-badge dcpa-badge-ok" : "dcpa-badge" },
2855
- cred.configured ? t("apiKeyConfiguredBadge") : t("apiKeyUnsetBadge")
2856
- )
2857
3932
  ),
2858
- h("input", {
2859
- type: "password",
2860
- className: "dcpa-input",
2861
- autoComplete: "new-password",
2862
- value: apiKeyDraft,
2863
- placeholder: cred.configured ? t("apiKeySetPlaceholder") : t("apiKeyUnsetPlaceholder"),
2864
- onChange: (e) => setApiKeyDraft(e.target.value),
2865
- }),
2866
- h("p", { className: "dcpa-hint" }, t("apiKeyHint"))
2867
- ),
2868
- h(
2869
- "div",
2870
- { className: "dcpa-grid" },
2871
3933
  h(
2872
- "div",
2873
- { className: "dcpa-field" },
2874
- h("label", { className: "dcpa-label" }, t("protocolLabel")),
3934
+ "span",
3935
+ { className: "dcpa-menu", ref: menuRef },
2875
3936
  h(
2876
- "select",
3937
+ "button",
2877
3938
  {
2878
- className: "dcpa-select",
2879
- value: protocol,
2880
- onChange: (e) => setProtocol(e.target.value),
3939
+ type: "button",
3940
+ className: "dcpa-q-btn dcpa-menu-btn",
3941
+ "aria-label": t("moreActions"),
3942
+ "aria-haspopup": "menu",
3943
+ "aria-expanded": menuOpen,
3944
+ onClick: () => setMenuOpen((v) => !v),
2881
3945
  },
2882
- h("option", { value: "auto" }, t("protocolAuto")),
2883
- PROTOCOLS.map((p) =>
2884
- h("option", { key: p, value: p }, `${t("protocolFixedPrefix")}${protocolName(p)}`)
2885
- )
2886
- ),
2887
- h("p", { className: "dcpa-hint" }, t("protocolHint"))
2888
- ),
2889
- h(
2890
- "div",
2891
- { className: "dcpa-field" },
2892
- h("label", { className: "dcpa-label" }, t("displayNameLabel")),
2893
- h("input", {
2894
- type: "text",
2895
- className: "dcpa-input",
2896
- value: displayName,
2897
- onChange: (e) => setDisplayName(e.target.value),
2898
- }),
2899
- h("p", { className: "dcpa-hint" }, t("displayNameHint"))
2900
- )
2901
- ),
2902
- h(
2903
- "details",
2904
- null,
2905
- h(
2906
- "summary",
2907
- { style: { cursor: "pointer", fontSize: "12px", fontWeight: 500 } },
2908
- t("sectionAdvanced")
2909
- ),
2910
- h(
2911
- "div",
2912
- { style: { display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" } },
2913
- h(
2914
- "div",
2915
- { className: "dcpa-grid" },
2916
- h(
2917
- "div",
2918
- { className: "dcpa-field" },
2919
- h("label", { className: "dcpa-label" }, t("includePrefixLabel")),
2920
- h("input", {
2921
- type: "text",
2922
- className: "dcpa-input",
2923
- value: includePrefix,
2924
- placeholder: t("includePrefixPlaceholder"),
2925
- onChange: (e) => setIncludePrefix(e.target.value),
2926
- }),
2927
- h("p", { className: "dcpa-hint" }, t("includePrefixHint"))
2928
- ),
2929
- h(
2930
- "div",
2931
- { className: "dcpa-field" },
2932
- h("label", { className: "dcpa-label" }, t("excludePrefixLabel")),
2933
- h("input", {
2934
- type: "text",
2935
- className: "dcpa-input",
2936
- value: excludePrefix,
2937
- placeholder: t("excludePrefixPlaceholder"),
2938
- onChange: (e) => setExcludePrefix(e.target.value),
2939
- }),
2940
- h("p", { className: "dcpa-hint" }, t("excludePrefixHint"))
2941
- )
2942
- ),
2943
- h(
2944
- "div",
2945
- { className: "dcpa-grid" },
2946
- h(
2947
- "div",
2948
- { className: "dcpa-field" },
2949
- h("label", { className: "dcpa-label" }, t("providerIdLabel")),
2950
- h("input", {
2951
- type: "text",
2952
- className: "dcpa-input",
2953
- value: provider,
2954
- onChange: (e) => setProvider(e.target.value),
2955
- }),
2956
- h("p", { className: "dcpa-hint" }, t("providerIdHint"))
2957
- ),
2958
- h(
2959
- "div",
2960
- { className: "dcpa-field" },
2961
- h("label", { className: "dcpa-label" }, t("apiKeyEnvLabel")),
2962
- h("input", {
2963
- type: "text",
2964
- className: "dcpa-input",
2965
- value: apiKeyEnv,
2966
- onChange: (e) => setApiKeyEnv(e.target.value),
2967
- }),
2968
- h("p", { className: "dcpa-hint" }, t("apiKeyEnvHint"))
2969
- )
3946
+ "⋯"
2970
3947
  ),
2971
- h(
2972
- "div",
2973
- { className: "dcpa-grid" },
2974
- h(
2975
- "div",
2976
- { className: "dcpa-field" },
2977
- h("label", { className: "dcpa-label" }, t("refreshIntervalLabel")),
2978
- h("input", {
2979
- type: "number",
2980
- className: "dcpa-input",
2981
- value: refreshIntervalSeconds,
2982
- onChange: (e) => setRefreshIntervalSeconds(e.target.value),
2983
- })
2984
- )
2985
- )
3948
+ menuOpen
3949
+ ? h(
3950
+ "div",
3951
+ { className: "dcpa-menu-pop", role: "menu" },
3952
+ h(
3953
+ "button",
3954
+ {
3955
+ type: "button",
3956
+ role: "menuitem",
3957
+ className: "dcpa-menu-item",
3958
+ disabled: busy !== null,
3959
+ onClick: () => {
3960
+ setMenuOpen(false);
3961
+ void actions.refresh(account.id);
3962
+ },
3963
+ },
3964
+ syncing ? t("refreshingBtn") : t("refreshBtn")
3965
+ ),
3966
+ h(
3967
+ "button",
3968
+ {
3969
+ type: "button",
3970
+ role: "menuitem",
3971
+ className: "dcpa-menu-item",
3972
+ "data-variant": "danger",
3973
+ disabled: busy !== null,
3974
+ onClick: () => {
3975
+ setMenuOpen(false);
3976
+ actions.remove(account);
3977
+ },
3978
+ },
3979
+ t("removeAccountBtn")
3980
+ )
3981
+ )
3982
+ : null
2986
3983
  )
2987
- ),
2988
- h(
2989
- "div",
2990
- { className: "dcpa-actions" },
3984
+ )
3985
+ ),
3986
+ !enabled ? h("p", { className: "dcpa-q-msg" }, t("disabledHint")) : null,
3987
+ st.error ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, st.error) : null,
3988
+ h(
3989
+ "div",
3990
+ { className: "dcpa-q-seg dcpa-acct-tabs", role: "tablist", "aria-label": t("tabsLabel") },
3991
+ tabs.map(([id, label]) =>
2991
3992
  h(
2992
3993
  "button",
2993
3994
  {
3995
+ key: id,
3996
+ id: `dcpa-tab-${account.id}-${id}`,
2994
3997
  type: "button",
2995
- className: "dcpa-btn dcpa-btn-primary",
2996
- disabled: saving || syncing,
2997
- onClick: () => onSave(false),
3998
+ role: "tab",
3999
+ className: "dcpa-q-seg-tab",
4000
+ "aria-selected": tab === id,
4001
+ "aria-controls": `dcpa-tabpanel-${account.id}-${id}`,
4002
+ onClick: () => setTab(id),
2998
4003
  },
2999
- saving ? t("savingBtn") : t("saveBtn")
4004
+ label
4005
+ )
4006
+ )
4007
+ ),
4008
+ h(
4009
+ "div",
4010
+ { id: `dcpa-tabpanel-${account.id}-${tab}`, role: "tabpanel", "aria-labelledby": `dcpa-tab-${account.id}-${tab}` },
4011
+ tab === "connection" ? connectionTab : tab === "models" ? modelsTab : h(QuotaPanel, { t, store: sharedQuota, accountId: account.id })
4012
+ ),
4013
+ dirty || notice
4014
+ ? h(
4015
+ "div",
4016
+ { className: "dcpa-savebar", role: "region", "aria-label": t("saveBarLabel") },
4017
+ h(
4018
+ "span",
4019
+ { className: "dcpa-savebar-text", "data-tone": notice && !dirty ? notice.tone : undefined, role: notice ? "status" : undefined },
4020
+ dirty ? (notice?.tone === "error" ? notice.text : t("unsavedChanges")) : notice?.text
4021
+ ),
4022
+ dirty
4023
+ ? h(
4024
+ "div",
4025
+ { className: "dcpa-q-actions" },
4026
+ h(
4027
+ "button",
4028
+ {
4029
+ type: "button",
4030
+ className: "dcpa-q-btn",
4031
+ disabled: busy !== null,
4032
+ onClick: discard,
4033
+ },
4034
+ t("discardBtn")
4035
+ ),
4036
+ h(
4037
+ "button",
4038
+ {
4039
+ type: "button",
4040
+ className: "dcpa-q-btn",
4041
+ "data-variant": "primary",
4042
+ disabled: busy !== null,
4043
+ onClick: () => void actions.save(account.id, payload()),
4044
+ },
4045
+ saving ? t("savingBtn") : t("saveBtn")
4046
+ )
4047
+ )
4048
+ : null
4049
+ )
4050
+ : null
4051
+ );
4052
+ }
4053
+
4054
+ // #region Quota (额度): shared store, model matching, tab page, composer badge
4055
+
4056
+ const QUOTA_POLL_MS = 60 * 1000;
4057
+ // While the host is still collecting a refresh, look again soon.
4058
+ const QUOTA_BUSY_POLL_MS = 4 * 1000;
4059
+ const PROVIDER_NAMES = {
4060
+ claude: "Claude",
4061
+ anthropic: "Claude",
4062
+ codex: "Codex",
4063
+ openai: "OpenAI",
4064
+ devin: "Devin",
4065
+ antigravity: "Antigravity",
4066
+ "gemini-cli": "Gemini CLI",
4067
+ gemini: "Gemini",
4068
+ vertex: "Vertex",
4069
+ aistudio: "AI Studio",
4070
+ kimi: "Kimi",
4071
+ meta: "Meta",
4072
+ xai: "xAI / Grok",
4073
+ qwen: "Qwen",
4074
+ iflow: "iFlow",
4075
+ glm: "GLM",
4076
+ deepseek: "DeepSeek",
4077
+ copilot: "GitHub Copilot",
4078
+ };
4079
+ const WINDOW_KEYS = { "5h": "qWin5h", "7d": "qWin7d", "30d": "qWin30d", "1d": "qWin1d" };
4080
+ const QUOTA_ERROR_KEYS = {
4081
+ unauthorized: "qErrUnauthorized",
4082
+ forbidden: "qErrForbidden",
4083
+ network: "qErrNetwork",
4084
+ timeout: "qErrTimeout",
4085
+ http: "qErrHttp",
4086
+ "invalid-response": "qErrInvalid",
4087
+ "no-endpoint": "qErrNoEndpoint",
4088
+ };
4089
+ const STATUS_KEYS = {
4090
+ active: "qStatusActive",
4091
+ disabled: "qStatusDisabled",
4092
+ unavailable: "qStatusUnavailable",
4093
+ error: "qStatusError",
4094
+ };
4095
+ const MATCH_KEYS = { "model-list": "qMatchModelList", prefix: "qMatchPrefix", family: "qMatchFamily" };
4096
+
4097
+ function fmt(t, key, vars = {}) {
4098
+ let out = String(t(key));
4099
+ for (const [name, value] of Object.entries(vars)) out = out.split(`{${name}}`).join(String(value));
4100
+ return out;
4101
+ }
4102
+
4103
+ function providerName(t, provider) {
4104
+ const key = String(provider ?? "").toLowerCase();
4105
+ return PROVIDER_NAMES[key] ?? (key === "" ? t("qProviderOther") : key);
4106
+ }
4107
+
4108
+ function quotaErrorText(t, account) {
4109
+ const key = QUOTA_ERROR_KEYS[account?.errorCode];
4110
+ if (key === undefined) return null;
4111
+ return fmt(t, key, { status: account.errorStatus ?? "?" });
4112
+ }
4113
+
4114
+ function windowLabel(t, w) {
4115
+ const base = WINDOW_KEYS[w.kind] !== undefined ? t(WINDOW_KEYS[w.kind]) : w.label || t("qWinOther");
4116
+ return w.group ? `${w.group} · ${base}` : base;
4117
+ }
4118
+
4119
+ function durationText(t, ms) {
4120
+ const total = Math.max(1, Math.ceil(ms / 60000));
4121
+ const d = Math.floor(total / 1440);
4122
+ const hrs = Math.floor((total % 1440) / 60);
4123
+ const m = total % 60;
4124
+ if (d > 0) return fmt(t, "qDurDH", { d, h: hrs });
4125
+ if (hrs > 0) return fmt(t, "qDurHM", { h: hrs, m });
4126
+ return fmt(t, "qDurM", { m });
4127
+ }
4128
+
4129
+ function resetText(t, iso, now) {
4130
+ const at = Date.parse(iso ?? "");
4131
+ if (!Number.isFinite(at)) return "";
4132
+ return at <= now ? t("qResetSoon") : fmt(t, "qResetIn", { time: durationText(t, at - now) });
4133
+ }
4134
+
4135
+ function agoText(t, iso, now) {
4136
+ const at = Date.parse(iso ?? "");
4137
+ if (!Number.isFinite(at)) return "";
4138
+ const s = Math.max(0, Math.floor((now - at) / 1000));
4139
+ if (s < 10) return t("qAgoJustNow");
4140
+ if (s < 60) return fmt(t, "qAgoSeconds", { n: s });
4141
+ if (s < 3600) return fmt(t, "qAgoMinutes", { n: Math.floor(s / 60) });
4142
+ if (s < 86400) return fmt(t, "qAgoHours", { n: Math.floor(s / 3600) });
4143
+ return fmt(t, "qAgoDays", { n: Math.floor(s / 86400) });
4144
+ }
4145
+
4146
+ function quotaLevel(pct) {
4147
+ if (typeof pct !== "number" || !Number.isFinite(pct)) return "unknown";
4148
+ if (pct <= 0) return "empty";
4149
+ if (pct < 20) return "low";
4150
+ if (pct < 50) return "mid";
4151
+ return "ok";
4152
+ }
4153
+
4154
+ function pctText(pct) {
4155
+ return typeof pct === "number" && Number.isFinite(pct) ? `${Math.round(pct)}%` : "—";
4156
+ }
4157
+
4158
+ function bareModel(id) {
4159
+ const lower = String(id ?? "").toLowerCase();
4160
+ const slash = lower.indexOf("/");
4161
+ return slash >= 0 ? lower.slice(slash + 1) : lower;
4162
+ }
4163
+
4164
+ /** Whether a quota window applies to `modelId` (untagged windows apply to every model). */
4165
+ function windowAppliesTo(w, modelId) {
4166
+ if (!modelId) return true;
4167
+ const full = String(modelId).toLowerCase();
4168
+ const bare = bareModel(modelId);
4169
+ if (Array.isArray(w.modelIds) && w.modelIds.length > 0) {
4170
+ return w.modelIds.some((m) => {
4171
+ const x = String(m).toLowerCase();
4172
+ return x === full || x === bare || bareModel(x) === bare;
4173
+ });
4174
+ }
4175
+ if (Array.isArray(w.modelPrefixes) && w.modelPrefixes.length > 0) {
4176
+ return w.modelPrefixes.some((p) => bare.startsWith(String(p).toLowerCase()));
4177
+ }
4178
+ return true;
4179
+ }
4180
+
4181
+ function credentialWindows(cred, modelId) {
4182
+ const windows = Array.isArray(cred?.windows) ? cred.windows : [];
4183
+ return modelId ? windows.filter((w) => windowAppliesTo(w, modelId)) : windows;
4184
+ }
4185
+
4186
+ /** The tightest remaining percentage of the windows that apply, or null. */
4187
+ function credentialHeadroom(cred, modelId) {
4188
+ const values = credentialWindows(cred, modelId)
4189
+ .map((w) => w.remainingPercent)
4190
+ .filter((v) => typeof v === "number" && Number.isFinite(v));
4191
+ return values.length > 0 ? Math.min(...values) : null;
4192
+ }
4193
+
4194
+ function quotaAccountForRoute(data, route) {
4195
+ const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
4196
+ return accounts.find((a) => Array.isArray(a.routes) && a.routes.includes(route)) ?? null;
4197
+ }
4198
+
4199
+ function byHeadroomDesc(a, b) {
4200
+ const ua = a.cred.status === "active" ? 0 : 1;
4201
+ const ub = b.cred.status === "active" ? 0 : 1;
4202
+ if (ua !== ub) return ua - ub;
4203
+ const ha = a.headroom ?? -1;
4204
+ const hb = b.headroom ?? -1;
4205
+ return hb - ha;
4206
+ }
4207
+
4208
+ /**
4209
+ * Quota for one picker selection: the account owning the route, then
4210
+ * exactly the credentials the host matched for this model id on that
4211
+ * server (modelIndex), never credentials of another account.
4212
+ */
4213
+ function summarizeModelQuota(data, route, modelId) {
4214
+ const account = quotaAccountForRoute(data, route);
4215
+ if (account === null) return null;
4216
+ const credentials = Array.isArray(account.credentials) ? account.credentials : [];
4217
+ const byId = new Map(credentials.map((c) => [c.id, c]));
4218
+ const ids = Array.isArray(account.modelIndex?.[modelId]) ? account.modelIndex[modelId] : [];
4219
+ const entries = ids
4220
+ .map((id) => byId.get(id))
4221
+ .filter(Boolean)
4222
+ .map((cred) => ({ cred, headroom: credentialHeadroom(cred, modelId), windows: credentialWindows(cred, modelId) }))
4223
+ .sort(byHeadroomDesc);
4224
+ const usable = entries.filter((e) => e.cred.status === "active");
4225
+ const known = usable.map((e) => e.headroom).filter((v) => v !== null);
4226
+ return {
4227
+ account,
4228
+ entries,
4229
+ usableCount: usable.length,
4230
+ best: known.length > 0 ? Math.max(...known) : null,
4231
+ };
4232
+ }
4233
+
4234
+ /** Totals for the overview tiles. */
4235
+ function quotaStats(credentials) {
4236
+ const out = { total: 0, healthy: 0, low: 0, empty: 0, issue: 0, unknown: 0 };
4237
+ for (const cred of credentials) {
4238
+ out.total++;
4239
+ if (cred.status !== "active" || cred.quotaState === "error") {
4240
+ out.issue++;
4241
+ continue;
4242
+ }
4243
+ const level = quotaLevel(credentialHeadroom(cred));
4244
+ if (level === "empty") out.empty++;
4245
+ else if (level === "low") out.low++;
4246
+ else if (level === "ok" || level === "mid") out.healthy++;
4247
+ else out.unknown++;
4248
+ }
4249
+ return out;
4250
+ }
4251
+
4252
+ /**
4253
+ * One page-wide quota client: GET the host snapshot (cached by the host,
4254
+ * which refreshes stale accounts in the background) while anything is
4255
+ * subscribed; POST refresh / key changes. Disposed with the activation.
4256
+ */
4257
+ function createQuotaStore(io = {}) {
4258
+ const fetchImpl = io.fetch ?? ((...args) => fetch(...args));
4259
+ const setT = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
4260
+ const clearT = io.clearTimeout ?? ((id) => clearTimeout(id));
4261
+ const listeners = new Set();
4262
+ let state = { data: null, loading: false, error: null, busy: null, busyAccount: null, loadedAt: 0 };
4263
+ let timer = null;
4264
+ let disposed = false;
4265
+ let seq = 0;
4266
+ const inflight = new Set();
4267
+
4268
+ const emit = (patch) => {
4269
+ if (disposed) return;
4270
+ state = { ...state, ...patch };
4271
+ for (const fn of [...listeners]) fn();
4272
+ };
4273
+
4274
+ const request = async (url, init) => {
4275
+ const controller = new AbortController();
4276
+ inflight.add(controller);
4277
+ try {
4278
+ const res = await fetchImpl(url, { cache: "no-store", ...init, signal: controller.signal });
4279
+ let body = null;
4280
+ try {
4281
+ body = await res.json();
4282
+ } catch {}
4283
+ if (!res.ok || body === null || body.ok === false) {
4284
+ throw new Error(body?.error || `HTTP ${res.status}`);
4285
+ }
4286
+ return body;
4287
+ } finally {
4288
+ inflight.delete(controller);
4289
+ }
4290
+ };
4291
+
4292
+ const schedule = () => {
4293
+ if (timer !== null) clearT(timer);
4294
+ timer = null;
4295
+ if (disposed || listeners.size === 0) return;
4296
+ const delay = state.data?.refreshing ? QUOTA_BUSY_POLL_MS : QUOTA_POLL_MS;
4297
+ timer = setT(() => {
4298
+ timer = null;
4299
+ void load().finally(schedule);
4300
+ }, delay);
4301
+ };
4302
+
4303
+ const load = async () => {
4304
+ const mine = ++seq;
4305
+ emit({ loading: true });
4306
+ try {
4307
+ const body = await request(API.quotaStatus);
4308
+ if (mine === seq) emit({ data: body.quota ?? null, error: null, loading: false, loadedAt: Date.now() });
4309
+ } catch (error) {
4310
+ if (disposed) return;
4311
+ if (mine === seq) emit({ error: error instanceof Error ? error.message : String(error), loading: false });
4312
+ }
4313
+ };
4314
+
4315
+ const mutate = async (kind, url, payload) => {
4316
+ const mine = ++seq;
4317
+ emit({ busy: kind, busyAccount: payload?.accountId ?? null, error: null });
4318
+ try {
4319
+ const body = await request(url, {
4320
+ method: "POST",
4321
+ headers: { "content-type": "application/json" },
4322
+ body: JSON.stringify(payload ?? {}),
4323
+ });
4324
+ if (mine === seq) emit({ data: body.quota ?? state.data, loadedAt: Date.now() });
4325
+ emit({ busy: null, busyAccount: null });
4326
+ schedule();
4327
+ return { ok: true };
4328
+ } catch (error) {
4329
+ const message = error instanceof Error ? error.message : String(error);
4330
+ emit({ busy: null, busyAccount: null, ...(mine === seq ? { error: message } : {}) });
4331
+ return { ok: false, error: message };
4332
+ }
4333
+ };
4334
+
4335
+ return {
4336
+ getSnapshot: () => state,
4337
+ subscribe(fn) {
4338
+ listeners.add(fn);
4339
+ if (listeners.size === 1) {
4340
+ if (state.data === null || Date.now() - state.loadedAt > QUOTA_POLL_MS) void load().finally(schedule);
4341
+ else schedule();
4342
+ }
4343
+ return () => {
4344
+ listeners.delete(fn);
4345
+ if (listeners.size === 0 && timer !== null) {
4346
+ clearT(timer);
4347
+ timer = null;
4348
+ }
4349
+ };
4350
+ },
4351
+ load,
4352
+ refresh: (accountId) => mutate("refresh", API.quotaRefresh, accountId ? { accountId } : {}),
4353
+ saveKey: (accountId, managementKey) => mutate("save", API.quotaSave, { accountId, managementKey }),
4354
+ clearKey: (accountId) => mutate("clear", API.quotaSave, { accountId, clear: true }),
4355
+ dispose() {
4356
+ disposed = true;
4357
+ if (timer !== null) clearT(timer);
4358
+ timer = null;
4359
+ for (const c of inflight) c.abort();
4360
+ inflight.clear();
4361
+ listeners.clear();
4362
+ },
4363
+ };
4364
+ }
4365
+
4366
+ let sharedQuota = null;
4367
+
4368
+ /** Subscribe a component to the shared quota store (null when inactive). */
4369
+ function useQuota(store) {
4370
+ const [, setTick] = useState(0);
4371
+ useEffect(() => {
4372
+ if (store === null || store === undefined) return undefined;
4373
+ let alive = true;
4374
+ const off = store.subscribe(() => {
4375
+ if (alive) setTick((n) => n + 1);
4376
+ });
4377
+ return () => {
4378
+ alive = false;
4379
+ off();
4380
+ };
4381
+ }, [store]);
4382
+ return store ? store.getSnapshot() : { data: null, loading: false, error: null, busy: null };
4383
+ }
4384
+
4385
+ /**
4386
+ * Which sessions currently have a composer badge mounted. The composer
4387
+ * renders conversation.input.right only once the session is bound (its
4388
+ * input state exists), but the New Session hero shows the model selector
4389
+ * earlier. The hero badge (conversation.hero.options) therefore renders
4390
+ * only while no composer badge is mounted for the same session, so the
4391
+ * user always sees exactly one badge. Plain in-memory counts; no DOM.
4392
+ */
4393
+ function createBadgePresence() {
4394
+ const counts = new Map();
4395
+ const listeners = new Set();
4396
+ const notify = () => {
4397
+ for (const fn of [...listeners]) fn();
4398
+ };
4399
+ return {
4400
+ add(id) {
4401
+ counts.set(id, (counts.get(id) ?? 0) + 1);
4402
+ notify();
4403
+ let done = false;
4404
+ return () => {
4405
+ if (done) return;
4406
+ done = true;
4407
+ const n = (counts.get(id) ?? 1) - 1;
4408
+ if (n > 0) counts.set(id, n);
4409
+ else counts.delete(id);
4410
+ notify();
4411
+ };
4412
+ },
4413
+ has: (id) => (counts.get(id) ?? 0) > 0,
4414
+ subscribe(fn) {
4415
+ listeners.add(fn);
4416
+ return () => listeners.delete(fn);
4417
+ },
4418
+ };
4419
+ }
4420
+ const composerBadges = createBadgePresence();
4421
+
4422
+ /**
4423
+ * Composer placement announces itself for its session; hero placement
4424
+ * reports whether a composer badge already covers the session.
4425
+ */
4426
+ function useBadgePlacement(placement, sessionId, presence = composerBadges) {
4427
+ const [, setTick] = useState(0);
4428
+ useEffect(() => {
4429
+ if (typeof sessionId !== "string" || sessionId === "") return undefined;
4430
+ if (placement === "hero") {
4431
+ let alive = true;
4432
+ const off = presence.subscribe(() => {
4433
+ if (alive) setTick((n) => n + 1);
4434
+ });
4435
+ setTick((n) => n + 1);
4436
+ return () => {
4437
+ alive = false;
4438
+ off();
4439
+ };
4440
+ }
4441
+ return presence.add(sessionId);
4442
+ }, [placement, sessionId, presence]);
4443
+ return placement === "hero" && typeof sessionId === "string" && presence.has(sessionId);
4444
+ }
4445
+
4446
+ /**
4447
+ * The session's model-directory store, resolved at render time. DSH caches
4448
+ * a slot entry's inject() result per session binding, and right after a
4449
+ * page load, host restart or session switch `directoryFor()` can still
4450
+ * throw (no scope/binding yet). Resolving inside inject would cache that
4451
+ * miss for good, so inject hands over a resolver and this hook retries
4452
+ * (1 s for the first 30 s, then every 5 s) until the directory exists.
4453
+ */
4454
+ function useDirectory(direct, resolve) {
4455
+ const [, setTick] = useState(0);
4456
+ let resolved = direct ?? null;
4457
+ if (resolved === null && typeof resolve === "function") {
4458
+ try {
4459
+ resolved = resolve() ?? null;
4460
+ } catch {
4461
+ resolved = null;
4462
+ }
4463
+ }
4464
+ const missing = resolved === null && typeof resolve === "function";
4465
+ useEffect(() => {
4466
+ if (!missing) return undefined;
4467
+ let alive = true;
4468
+ let id = null;
4469
+ let tries = 0;
4470
+ const tick = () => {
4471
+ id = null;
4472
+ if (!alive) return;
4473
+ tries += 1;
4474
+ setTick((n) => n + 1);
4475
+ id = setTimeout(tick, tries < 30 ? 1000 : 5000);
4476
+ };
4477
+ id = setTimeout(tick, 300);
4478
+ return () => {
4479
+ alive = false;
4480
+ if (id !== null) clearTimeout(id);
4481
+ };
4482
+ }, [missing, resolve]);
4483
+ return resolved;
4484
+ }
4485
+
4486
+ /** Re-render every `ms` so relative times stay current. */
4487
+ function useNow(ms) {
4488
+ const [now, setNow] = useState(() => Date.now());
4489
+ useEffect(() => {
4490
+ const id = setInterval(() => setNow(Date.now()), ms);
4491
+ return () => clearInterval(id);
4492
+ }, [ms]);
4493
+ return now;
4494
+ }
4495
+
4496
+ function QuotaMeter({ t, w, now }) {
4497
+ const pct = typeof w.remainingPercent === "number" ? Math.max(0, Math.min(100, w.remainingPercent)) : null;
4498
+ const level = quotaLevel(pct);
4499
+ const reset = resetText(t, w.resetAt, now);
4500
+ const label = windowLabel(t, w);
4501
+ return h(
4502
+ "div",
4503
+ { className: "dcpa-q-meter", "data-level": level },
4504
+ h(
4505
+ "div",
4506
+ { className: "dcpa-q-meter-top" },
4507
+ h("span", { title: label }, label),
4508
+ h("span", { className: "dcpa-q-pct" }, pctText(pct))
4509
+ ),
4510
+ h(
4511
+ "div",
4512
+ {
4513
+ className: "dcpa-q-track",
4514
+ role: "meter",
4515
+ "aria-label": label,
4516
+ "aria-valuemin": 0,
4517
+ "aria-valuemax": 100,
4518
+ ...(pct === null ? {} : { "aria-valuenow": Math.round(pct) }),
4519
+ },
4520
+ h("span", { className: "dcpa-q-fill", style: { width: `${pct ?? 0}%` } })
4521
+ ),
4522
+ reset ? h("span", { className: "dcpa-q-reset" }, reset) : null
4523
+ );
4524
+ }
4525
+
4526
+ function CredentialTags({ t, cred }) {
4527
+ return [
4528
+ cred.plan ? h("span", { key: "plan", className: "dcpa-q-tag", "data-tone": "info" }, cred.plan) : null,
4529
+ cred.prefix ? h("span", { key: "prefix", className: "dcpa-q-tag" }, `${cred.prefix}/`) : null,
4530
+ cred.status && cred.status !== "active"
4531
+ ? h("span", { key: "status", className: "dcpa-q-tag", "data-tone": "danger" }, t(STATUS_KEYS[cred.status] ?? "qStatusError"))
4532
+ : null,
4533
+ ];
4534
+ }
4535
+
4536
+ function CredentialWindows({ t, cred, windows, now }) {
4537
+ if (windows.length > 0) {
4538
+ return h(
4539
+ "div",
4540
+ { className: "dcpa-q-meters" },
4541
+ windows.map((w, i) => h(QuotaMeter, { key: w.id ?? i, t, w, now }))
4542
+ );
4543
+ }
4544
+ const lines = [
4545
+ cred.quotaState === "error"
4546
+ ? h("p", { key: "q", className: "dcpa-q-note", "data-tone": "error" }, t("qQuotaError"))
4547
+ : h("p", { key: "q", className: "dcpa-q-note" }, t(cred.quotaState === "unsupported" ? "qQuotaUnsupported" : "qQuotaNone")),
4548
+ cred.recent ? h("p", { key: "r", className: "dcpa-q-note" }, fmt(t, "qRecent", cred.recent)) : null,
4549
+ ];
4550
+ return h("div", { className: "dcpa-q-meters" }, h("div", null, lines));
4551
+ }
4552
+
4553
+ function CredentialRow({ t, cred, now, pickerIds }) {
4554
+ const [open, setOpen] = useState(false);
4555
+ const models = Array.isArray(cred.models) ? cred.models : null;
4556
+ return h(
4557
+ "div",
4558
+ { className: "dcpa-q-cred", "data-status": cred.status ?? "active" },
4559
+ h(
4560
+ "div",
4561
+ { className: "dcpa-q-who" },
4562
+ h("div", { className: "dcpa-q-name", title: cred.label || cred.id }, cred.label || cred.id),
4563
+ h(
4564
+ "div",
4565
+ { className: "dcpa-q-meta" },
4566
+ CredentialTags({ t, cred }),
4567
+ models === null
4568
+ ? h("span", null, t("qModelsUnknown"))
4569
+ : h(
4570
+ "button",
4571
+ { type: "button", className: "dcpa-q-link", "aria-expanded": open, onClick: () => setOpen((v) => !v) },
4572
+ open ? t("qHideModels") : fmt(t, "qShowModels", { n: models.length })
4573
+ ),
4574
+ cred.statusMessage ? h("span", { title: cred.statusMessage }, cred.statusMessage) : null
4575
+ )
4576
+ ),
4577
+ h(CredentialWindows, { t, cred, windows: credentialWindows(cred), now }),
4578
+ open && models !== null
4579
+ ? h(
4580
+ "div",
4581
+ { className: "dcpa-q-models" },
4582
+ models.map((m) => h("code", { key: m, "data-picker": pickerIds.has(m) ? "true" : "false" }, m))
4583
+ )
4584
+ : null
4585
+ );
4586
+ }
4587
+
4588
+ function ModelChannelMap({ t, account }) {
4589
+ const picker = Array.isArray(account.pickerModels) ? account.pickerModels : [];
4590
+ return h(
4591
+ "details",
4592
+ { className: "dcpa-q-map" },
4593
+ h(
4594
+ "summary",
4595
+ null,
4596
+ h(
4597
+ "div",
4598
+ { className: "dcpa-q-row-main" },
4599
+ h("div", { className: "dcpa-q-title" }, t("qMapTitle"), h(InfoTip, { t, text: t("qMapHint") }))
4600
+ )
4601
+ ),
4602
+ picker.length === 0
4603
+ ? h("p", { className: "dcpa-q-msg" }, t("qMapEmpty"))
4604
+ : picker.map((m) => {
4605
+ const summary = summarizeModelQuota({ accounts: [account] }, m.route, m.id);
4606
+ const entries = summary?.entries ?? [];
4607
+ const matchedBy = account.modelMatch?.[m.id];
4608
+ return h(
4609
+ "div",
4610
+ { key: `${m.route}:${m.id}`, className: "dcpa-q-map-row" },
4611
+ h("span", { className: "dcpa-q-map-model" }, m.id),
4612
+ h(
4613
+ "span",
4614
+ { className: "dcpa-q-map-creds" },
4615
+ entries.length === 0
4616
+ ? h("span", null, t("qMapNone"))
4617
+ : entries.map(({ cred }) =>
4618
+ h(
4619
+ "span",
4620
+ { key: cred.id, className: "dcpa-q-tag", "data-tone": cred.status === "active" ? undefined : "danger", title: cred.label || cred.id },
4621
+ `${providerName(t, cred.provider)} · ${cred.label || cred.id}`
4622
+ )
4623
+ ),
4624
+ matchedBy && MATCH_KEYS[matchedBy] ? h("span", null, t(MATCH_KEYS[matchedBy])) : null
4625
+ ),
4626
+ h("span", { className: "dcpa-q-map-pct dcpa-q-pct" }, pctText(summary?.best))
4627
+ );
4628
+ })
4629
+ );
4630
+ }
4631
+
4632
+ function QuotaSummary({ t, credentials }) {
4633
+ const stats = quotaStats(credentials);
4634
+ const parts = [
4635
+ ["ok", stats.healthy, "qStatHealthy"],
4636
+ ["low", stats.low, "qStatLow"],
4637
+ ["empty", stats.empty, "qStatEmpty"],
4638
+ ["issue", stats.issue, "qStatIssue"],
4639
+ ["unknown", stats.unknown, "qStatUnknown"],
4640
+ ];
4641
+ return h(
4642
+ "div",
4643
+ { className: "dcpa-q-summary" },
4644
+ h(
4645
+ "div",
4646
+ { className: "dcpa-q-dist", role: "img", "aria-label": fmt(t, "qTotalCreds", { n: stats.total }) },
4647
+ parts.filter(([, n]) => n > 0).map(([level, n]) => h("span", { key: level, "data-level": level, style: { flex: `${n} 1 0` } }))
4648
+ ),
4649
+ h(
4650
+ "div",
4651
+ { className: "dcpa-q-legend" },
4652
+ h("span", null, fmt(t, "qTotalCreds", { n: stats.total })),
4653
+ parts.filter(([level, n]) => level !== "unknown" || n > 0).map(([level, n, key]) =>
4654
+ h("span", { key: level, "data-level": level }, h("i", { className: "dcpa-q-swatch" }), t(key), h("b", null, String(n)))
4655
+ )
4656
+ )
4657
+ );
4658
+ }
4659
+
4660
+ /** The "额度" tab: flat DSH settings rows, one account at a time. */
4661
+ function QuotaPanel({ t, store = sharedQuota, accountId }) {
4662
+ const snap = useQuota(store);
4663
+ const now = useNow(30 * 1000);
4664
+ const [activeId, setActiveId] = useState(null);
4665
+ const [keyDraft, setKeyDraft] = useState("");
4666
+ const [notice, setNotice] = useState(null);
4667
+ const [providerFilter, setProviderFilter] = useState("all");
4668
+ const [search, setSearch] = useState("");
4669
+
4670
+ const accounts = Array.isArray(snap.data?.accounts) ? snap.data.accounts : [];
4671
+ const scoped = accountId !== undefined;
4672
+ const account = scoped
4673
+ ? accounts.find((a) => a.accountId === accountId) ?? null
4674
+ : accounts.find((a) => a.accountId === activeId) ?? accounts[0] ?? null;
4675
+ const credentials = Array.isArray(account?.credentials) ? account.credentials : [];
4676
+ const pickerIds = useMemo(() => new Set((account?.pickerModels ?? []).map((m) => m.id)), [account]);
4677
+ const busy = snap.busy !== null;
4678
+
4679
+ const providers = useMemo(() => {
4680
+ const counts = new Map();
4681
+ for (const c of credentials) counts.set(c.provider || "", (counts.get(c.provider || "") ?? 0) + 1);
4682
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
4683
+ }, [credentials]);
4684
+
4685
+ const groups = useMemo(() => {
4686
+ const q = search.trim().toLowerCase();
4687
+ const map = new Map();
4688
+ for (const c of credentials) {
4689
+ if (providerFilter !== "all" && (c.provider || "") !== providerFilter) continue;
4690
+ if (
4691
+ q !== "" &&
4692
+ !String(c.label ?? "").toLowerCase().includes(q) &&
4693
+ !String(c.id ?? "").toLowerCase().includes(q) &&
4694
+ !(Array.isArray(c.models) && c.models.some((m) => String(m).toLowerCase().includes(q)))
4695
+ ) continue;
4696
+ const key = c.provider || "";
4697
+ if (!map.has(key)) map.set(key, []);
4698
+ map.get(key).push(c);
4699
+ }
4700
+ // Problems first, then least remaining, so what needs attention is on top.
4701
+ const rank = (c) => (c.status !== "active" ? 1 : 0);
4702
+ return [...map.entries()].map(([key, list]) => [
4703
+ key,
4704
+ [...list].sort((a, b) => rank(a) - rank(b) || (credentialHeadroom(a) ?? 101) - (credentialHeadroom(b) ?? 101)),
4705
+ ]);
4706
+ }, [credentials, providerFilter, search]);
4707
+
4708
+ if (store === null || store === undefined) return null;
4709
+
4710
+ const saveKey = async () => {
4711
+ if (account === null || keyDraft.trim() === "") return;
4712
+ setNotice(null);
4713
+ const result = await store.saveKey(account.accountId, keyDraft.trim());
4714
+ if (result.ok) {
4715
+ setKeyDraft("");
4716
+ setNotice(t("qKeySaved"));
4717
+ }
4718
+ };
4719
+ const clearKey = async () => {
4720
+ if (account === null) return;
4721
+ const name = account.displayName || account.accountId;
4722
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(fmt(t, "qKeyClearConfirm", { name }))) return;
4723
+ setNotice(null);
4724
+ const result = await store.clearKey(account.accountId);
4725
+ if (result.ok) setNotice(t("qKeyCleared"));
4726
+ };
4727
+
4728
+ const errorText = account ? quotaErrorText(t, account) : null;
4729
+ const refreshing = account?.refreshing === true || (snap.busy === "refresh" && snap.busyAccount === account?.accountId);
4730
+ const shown = groups.reduce((n, [, list]) => n + list.length, 0);
4731
+
4732
+ return h(
4733
+ "div",
4734
+ { className: "dcpa-q", "data-dsh-plugin": MODULE_ID },
4735
+ scoped
4736
+ ? null
4737
+ : h(
4738
+ "div",
4739
+ { className: "dcpa-q-head" },
4740
+ h("h3", { className: "dcpa-q-h" }, t("qTitle")),
4741
+ h("p", { className: "dcpa-q-desc" }, t("qSubtitle"))
3000
4742
  ),
3001
- cred.configured
3002
- ? h(
4743
+ scoped && account === null ? h("p", { className: "dcpa-q-msg" }, t("qPeekLoading")) : null,
4744
+ !scoped && accounts.length > 1
4745
+ ? h(
4746
+ "div",
4747
+ { className: "dcpa-q-seg", role: "tablist", "aria-label": t("qAccountsLabel") },
4748
+ accounts.map((a) =>
4749
+ h(
3003
4750
  "button",
3004
4751
  {
4752
+ key: a.accountId,
3005
4753
  type: "button",
3006
- className: "dcpa-btn",
3007
- disabled: saving || syncing,
3008
- onClick: () => onSave(true),
4754
+ role: "tab",
4755
+ className: "dcpa-q-seg-tab",
4756
+ "aria-selected": account?.accountId === a.accountId,
4757
+ onClick: () => {
4758
+ setActiveId(a.accountId);
4759
+ setKeyDraft("");
4760
+ setNotice(null);
4761
+ setProviderFilter("all");
4762
+ setSearch("");
4763
+ },
3009
4764
  },
3010
- t("clearKeyBtn")
4765
+ a.displayName || a.accountId,
4766
+ a.keyConfigured ? null : h("span", { className: "dcpa-q-seg-note" }, t("qKeyStatusUnset"))
3011
4767
  )
3012
- : null,
3013
- h("span", { style: { flex: 1 } }),
4768
+ )
4769
+ )
4770
+ : null,
4771
+ snap.data !== null && accounts.length === 0 ? h("p", { className: "dcpa-q-msg" }, t("qNoAccounts")) : null,
4772
+ account
4773
+ ? h(
4774
+ "div",
4775
+ { className: "dcpa-q-row" },
4776
+ h(
4777
+ "div",
4778
+ { className: "dcpa-q-row-main" },
4779
+ h(
4780
+ "div",
4781
+ { className: "dcpa-q-title" },
4782
+ h("label", { htmlFor: `dcpa-q-key-${account.accountId}` }, t("qKeyLabel")),
4783
+ h(InfoTip, { t, text: fmt(t, "qKeyHint", { ref: account.keyRef ?? "" }) })
4784
+ )
4785
+ ),
4786
+ h(
4787
+ "div",
4788
+ { className: "dcpa-q-actions" },
4789
+ h(
4790
+ "span",
4791
+ { className: "dcpa-q-tag", "data-tone": account.keyConfigured ? "success" : undefined },
4792
+ account.keyConfigured ? t("qKeyStatusSet") : t("qKeyStatusUnset")
4793
+ )
4794
+ )
4795
+ )
4796
+ : null,
4797
+ account
4798
+ ? h(
4799
+ "div",
4800
+ { className: "dcpa-q-form" },
4801
+ h("input", {
4802
+ id: `dcpa-q-key-${account.accountId}`,
4803
+ className: "dcpa-q-input",
4804
+ type: "password",
4805
+ autoComplete: "off",
4806
+ spellCheck: false,
4807
+ value: keyDraft,
4808
+ placeholder: account.keyConfigured ? t("qKeySetPlaceholder") : t("qKeyUnsetPlaceholder"),
4809
+ onChange: (e) => setKeyDraft(e.target.value),
4810
+ onKeyDown: (e) => {
4811
+ if (e.key === "Enter") void saveKey();
4812
+ },
4813
+ }),
4814
+ h(
4815
+ "button",
4816
+ { type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: busy || keyDraft.trim() === "", onClick: () => void saveKey() },
4817
+ snap.busy === "save" ? t("qKeySaving") : t("qKeySave")
4818
+ ),
4819
+ account.keyConfigured
4820
+ ? h(
4821
+ "button",
4822
+ { type: "button", className: "dcpa-q-btn", "data-variant": "danger", disabled: busy, onClick: () => void clearKey() },
4823
+ t("qKeyClear")
4824
+ )
4825
+ : null
4826
+ )
4827
+ : null,
4828
+ account && account.keyConfigured
4829
+ ? h(
4830
+ "div",
4831
+ { className: "dcpa-q-row" },
4832
+ h(
4833
+ "div",
4834
+ { className: "dcpa-q-row-main" },
4835
+ h("div", { className: "dcpa-q-title" }, t("qDataLabel")),
4836
+ h(
4837
+ "div",
4838
+ { className: "dcpa-q-sub" },
4839
+ refreshing
4840
+ ? t("qRefreshing")
4841
+ : account.fetchedAt
4842
+ ? fmt(t, "qUpdated", { time: agoText(t, account.fetchedAt, now) })
4843
+ : t("qNeverUpdated"),
4844
+ account.enabled === false ? ` · ${t("qAccountDisabled")}` : ""
4845
+ )
4846
+ ),
4847
+ h(
4848
+ "div",
4849
+ { className: "dcpa-q-actions" },
4850
+ h(
4851
+ "button",
4852
+ { type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy || refreshing, onClick: () => void store.refresh(account.accountId) },
4853
+ t("qRefresh")
4854
+ )
4855
+ )
4856
+ )
4857
+ : null,
4858
+ errorText ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, errorText, credentials.length > 0 ? ` ${t("qStale")}` : "") : null,
4859
+ snap.error ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "qErrLoad", { error: snap.error })) : null,
4860
+ notice ? h("p", { className: "dcpa-q-msg", "data-tone": "ok", role: "status" }, notice) : null,
4861
+ account && account.keyConfigured && credentials.length === 0 && account.state === "ok"
4862
+ ? h("p", { className: "dcpa-q-msg" }, t("qNoCredentials"))
4863
+ : null,
4864
+ account && account.keyConfigured && credentials.length > 0 ? h(QuotaSummary, { t, credentials }) : null,
4865
+ account && account.keyConfigured && (providers.length > 1 || credentials.length > 8)
4866
+ ? h(
4867
+ "div",
4868
+ { className: "dcpa-q-tools" },
4869
+ providers.length > 1
4870
+ ? [["all", credentials.length], ...providers].map(([key, count]) =>
4871
+ h(
4872
+ "button",
4873
+ {
4874
+ key: key || "_",
4875
+ type: "button",
4876
+ className: "dcpa-q-filter",
4877
+ "aria-pressed": providerFilter === key,
4878
+ onClick: () => setProviderFilter(key),
4879
+ },
4880
+ key === "all" ? t("qFilterAll") : providerName(t, key),
4881
+ h("small", null, String(count))
4882
+ )
4883
+ )
4884
+ : null,
4885
+ credentials.length > 8
4886
+ ? h("input", {
4887
+ className: "dcpa-q-input",
4888
+ type: "search",
4889
+ value: search,
4890
+ placeholder: t("qSearchPlaceholder"),
4891
+ "aria-label": t("qSearchPlaceholder"),
4892
+ onChange: (e) => setSearch(e.target.value),
4893
+ })
4894
+ : null
4895
+ )
4896
+ : null,
4897
+ account && account.keyConfigured && credentials.length > 0 && shown === 0 ? h("p", { className: "dcpa-q-msg" }, t("qNoMatch")) : null,
4898
+ account && account.keyConfigured
4899
+ ? groups.map(([key, list]) =>
4900
+ h(
4901
+ "section",
4902
+ { key: key || "_", "aria-label": providerName(t, key) },
4903
+ h("div", { className: "dcpa-q-group-title" }, providerName(t, key), h("small", null, String(list.length))),
4904
+ list.map((cred) => h(CredentialRow, { key: cred.id, t, cred, now, pickerIds }))
4905
+ )
4906
+ )
4907
+ : null,
4908
+ account && account.keyConfigured && credentials.length > 0 ? h(ModelChannelMap, { t, account }) : null
4909
+ );
4910
+ }
4911
+
4912
+ /** The CLIProxyAPI settings section: accounts, each with 连接 / 模型 / 额度 tabs. */
4913
+ function CpaSectionPage({ t }) {
4914
+ return h(CpaSettingsPanel, { t });
4915
+ }
4916
+
4917
+ /** Snapshot of the session's model directory (current or pending selection). */
4918
+ function useSelection(directory) {
4919
+ const [, setTick] = useState(0);
4920
+ useEffect(() => {
4921
+ if (!directory || typeof directory.subscribe !== "function") return undefined;
4922
+ let alive = true;
4923
+ const off = directory.subscribe(() => {
4924
+ if (alive) setTick((n) => n + 1);
4925
+ });
4926
+ return () => {
4927
+ alive = false;
4928
+ off?.();
4929
+ };
4930
+ }, [directory]);
4931
+ const snapshot = directory && typeof directory.getSnapshot === "function" ? directory.getSnapshot() : null;
4932
+ const current = snapshot?.pending ?? snapshot?.current ?? null;
4933
+ return current && typeof current.provider === "string" && typeof current.model === "string" ? current : null;
4934
+ }
4935
+
4936
+ /**
4937
+ * Composer badge left of the model picker: signal bars + best remaining
4938
+ * percentage of the credentials that serve the selected model on the
4939
+ * selected model's own CPA account; click opens a peek list. Accounts
4940
+ * without a management key show nothing.
4941
+ */
4942
+ function QuotaBadge({ t, directory: directProp = null, resolveDirectory = null, store = sharedQuota, sessionId = null, placement = "composer", presence = composerBadges }) {
4943
+ const shadowed = useBadgePlacement(placement, sessionId, presence);
4944
+ const directory = useDirectory(directProp, shadowed ? null : resolveDirectory);
4945
+ const selection = useSelection(shadowed ? null : directory);
4946
+ const snap = useQuota(selection ? store : null);
4947
+ const now = useNow(60 * 1000);
4948
+ const [open, setOpen] = useState(false);
4949
+ const [pos, setPos] = useState(null);
4950
+ const wrapRef = useRef(null);
4951
+ const buttonRef = useRef(null);
4952
+
4953
+ const summary = selection ? summarizeModelQuota(snap.data, selection.provider, selection.model) : null;
4954
+ const visible = !shadowed && summary !== null && summary.account.keyConfigured === true;
4955
+
4956
+ useEffect(() => {
4957
+ if (!open) return undefined;
4958
+ const place = () => {
4959
+ const rect = buttonRef.current?.getBoundingClientRect?.();
4960
+ if (!rect || typeof window === "undefined") return;
4961
+ const width = Math.min(340, window.innerWidth - 32);
4962
+ const left = Math.max(16, Math.min(rect.left, window.innerWidth - width - 16));
4963
+ setPos({ left, bottom: Math.max(16, window.innerHeight - rect.top + 6), width });
4964
+ };
4965
+ place();
4966
+ const onDown = (e) => {
4967
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
4968
+ };
4969
+ const onKey = (e) => {
4970
+ if (e.key === "Escape") {
4971
+ setOpen(false);
4972
+ buttonRef.current?.focus?.();
4973
+ }
4974
+ };
4975
+ document.addEventListener("mousedown", onDown, true);
4976
+ document.addEventListener("keydown", onKey, true);
4977
+ window.addEventListener("resize", place);
4978
+ return () => {
4979
+ document.removeEventListener("mousedown", onDown, true);
4980
+ document.removeEventListener("keydown", onKey, true);
4981
+ window.removeEventListener("resize", place);
4982
+ };
4983
+ }, [open]);
4984
+
4985
+ useEffect(() => {
4986
+ if (!visible && open) setOpen(false);
4987
+ }, [visible, open]);
4988
+
4989
+ if (!visible) return null;
4990
+ const { account, entries, best, usableCount } = summary;
4991
+ const loadingFirst = account.fetchedAt === undefined && (account.state === "loading" || account.refreshing === true || account.state === "idle");
4992
+ const level = quotaLevel(best);
4993
+ const label = fmt(t, "qBadgeLabel", { model: selection.model });
4994
+ const errorText = quotaErrorText(t, account);
4995
+ const refreshing = account.refreshing === true || snap.busy === "refresh";
4996
+
4997
+ const peek = open
4998
+ ? h(
4999
+ "div",
5000
+ {
5001
+ className: "dcpa-q-pop",
5002
+ role: "dialog",
5003
+ "aria-label": label,
5004
+ style: pos ? { left: `${pos.left}px`, bottom: `${pos.bottom}px`, width: `${pos.width}px` } : { visibility: "hidden" },
5005
+ },
3014
5006
  h(
3015
- "button",
3016
- {
3017
- type: "button",
3018
- className: "dcpa-btn dcpa-btn-danger",
3019
- disabled: saving || syncing || loading,
3020
- onClick: onRemove,
3021
- },
3022
- t("removeAccountBtn")
5007
+ "div",
5008
+ { className: "dcpa-q-pop-head" },
5009
+ h("div", { className: "dcpa-q-pop-model" }, selection.model),
5010
+ h("div", { className: "dcpa-q-pop-sub" }, `${account.displayName || account.accountId} · ${fmt(t, "qPeekCreds", { n: usableCount })}`)
5011
+ ),
5012
+ errorText ? h("p", { className: "dcpa-q-pop-text", "data-tone": "error" }, errorText) : null,
5013
+ loadingFirst ? h("p", { className: "dcpa-q-pop-text" }, t("qPeekLoading")) : null,
5014
+ !loadingFirst && entries.length === 0 ? h("p", { className: "dcpa-q-pop-text" }, t("qPeekNoCreds")) : null,
5015
+ entries.map(({ cred, windows, headroom }, index) =>
5016
+ h(
5017
+ "div",
5018
+ { key: cred.id, className: "dcpa-q-pop-cred" },
5019
+ h(
5020
+ "div",
5021
+ { className: "dcpa-q-pop-who" },
5022
+ h("span", { className: "dcpa-q-tag" }, providerName(t, cred.provider)),
5023
+ h("span", { className: "dcpa-q-name", title: cred.label || cred.id }, cred.label || cred.id),
5024
+ index === 0 && cred.status === "active" && headroom !== null && entries.length > 1
5025
+ ? h("span", { className: "dcpa-q-tag", "data-tone": "success" }, t("qPeekBest"))
5026
+ : null,
5027
+ CredentialTags({ t, cred })
5028
+ ),
5029
+ h(CredentialWindows, { t, cred, windows, now })
5030
+ )
5031
+ ),
5032
+ h(
5033
+ "div",
5034
+ { className: "dcpa-q-pop-foot" },
5035
+ h("span", null, account.fetchedAt ? fmt(t, "qUpdated", { time: agoText(t, account.fetchedAt, now) }) : t("qPeekFooter")),
5036
+ h(
5037
+ "button",
5038
+ {
5039
+ type: "button",
5040
+ className: "dcpa-q-btn",
5041
+ disabled: snap.busy !== null || account.refreshing === true,
5042
+ onClick: () => void store?.refresh(account.accountId),
5043
+ },
5044
+ refreshing ? t("qRefreshing") : t("qRefresh")
5045
+ )
3023
5046
  )
3024
5047
  )
3025
- )
5048
+ : null;
5049
+
5050
+ return h(
5051
+ "span",
5052
+ { ref: wrapRef, className: "dcpa-q-wrap", "data-placement": placement, "data-dsh-plugin": MODULE_ID },
5053
+ h(
5054
+ "button",
5055
+ {
5056
+ ref: buttonRef,
5057
+ type: "button",
5058
+ className: "dcpa-q-badge",
5059
+ "data-level": level,
5060
+ "aria-label": label,
5061
+ "aria-haspopup": "dialog",
5062
+ "aria-expanded": open,
5063
+ title: label,
5064
+ onClick: () => setOpen((v) => !v),
5065
+ },
5066
+ h("span", { className: "dcpa-q-bars", "data-level": level, "aria-hidden": "true" }, h("i"), h("i"), h("i")),
5067
+ h("span", { className: "dcpa-q-badge-pct" }, loadingFirst ? "…" : pctText(best))
5068
+ ),
5069
+ peek
3026
5070
  );
3027
5071
  }
3028
5072
 
5073
+ // #endregion
5074
+
3029
5075
  const inject = ["slots", "locale"];
3030
5076
 
3031
5077
  function apply(ctx) {
3032
5078
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-cliproxyapi: dictionaries");
3033
5079
  const t = ctx.locale.bind(NS);
3034
5080
 
5081
+ // Each activation owns its stylesheet. A hot reload can start the new
5082
+ // activation before the old one is disposed; sharing one element would
5083
+ // let the old disposal remove the new activation's styles.
3035
5084
  if (typeof document !== "undefined") {
3036
- const styleId = "dsh-cliproxyapi-styles";
3037
- let styleEl = document.getElementById(styleId);
3038
- if (!styleEl) {
3039
- styleEl = document.createElement("style");
3040
- styleEl.id = styleId;
5085
+ ctx.effect(() => {
5086
+ const styleEl = document.createElement("style");
3041
5087
  styleEl.dataset.plugin = MODULE_ID;
3042
5088
  styleEl.textContent = CSS;
3043
5089
  document.head.appendChild(styleEl);
3044
- } else {
3045
- styleEl.textContent = CSS;
3046
- }
3047
- ctx.effect(
3048
- () => () => {
3049
- styleEl?.remove();
3050
- },
3051
- "dsh-cliproxyapi: styles"
3052
- );
5090
+ return () => styleEl.remove();
5091
+ }, "dsh-cliproxyapi: styles");
3053
5092
  }
3054
5093
 
5094
+ // List and keyed slots reject a second entry with the same id/key at the
5095
+ // same priority. During a hot reload the previous activation may still
5096
+ // hold it, so register one priority lower instead of losing the entry:
5097
+ // the lowest priority renders, and this entry takes over once the old
5098
+ // activation is disposed. The id/key itself stays stable.
5099
+ const registerListEntry = (slots, options, render) => {
5100
+ let priority = options.priority ?? 0;
5101
+ for (let attempt = 0; ; attempt++) {
5102
+ try {
5103
+ return slots.register(priority === 0 ? options : { ...options, priority }, render);
5104
+ } catch (error) {
5105
+ if (attempt >= 4 || !/already has an? (entry|registration)/.test(String(error?.message ?? error))) throw error;
5106
+ priority += 1;
5107
+ }
5108
+ }
5109
+ };
5110
+
3055
5111
  // dshmarket installation client, shared by the card and the opt-in
3056
5112
  // background auto-install runner. No DOM writes; timers and requests
3057
5113
  // are disposed with this activation (HMR/unmount).
@@ -3066,11 +5122,67 @@ window.__ModuleLoader__.load({
3066
5122
  };
3067
5123
  }, "dsh-cliproxyapi: market updates");
3068
5124
 
5125
+ // Quota client shared by the "额度" tab and every composer badge; it
5126
+ // polls only while something is subscribed and aborts on dispose.
5127
+ ctx.effect(() => {
5128
+ const store = createQuotaStore();
5129
+ sharedQuota = store;
5130
+ return () => {
5131
+ store.dispose();
5132
+ if (sharedQuota === store) sharedQuota = null;
5133
+ };
5134
+ }, "dsh-cliproxyapi: quota");
5135
+
5136
+ // Composer: the badge sits in conversation.input.right, which the
5137
+ // composer renders immediately before the model selector. The session's
5138
+ // model directory (ui-model-selection) tells it the selected route/model.
5139
+ // The composer renders that list only once the session is bound, so a
5140
+ // New Session hero (model selector already visible) gets a second entry
5141
+ // in conversation.hero.options; it steps aside while the composer badge
5142
+ // of the same session is mounted (see createBadgePresence).
5143
+ if (typeof ctx.inject === "function") {
5144
+ ctx.inject(["slots", "modelDirectories"], (scope) => {
5145
+ const models = scope.modelDirectories;
5146
+ // Never resolve the directory here: DSH caches this result per
5147
+ // session binding, so an early miss would hide the badge for good.
5148
+ const injectDirectory = (sessionId) => ({
5149
+ resolveDirectory: () => models.directoryFor(sessionId).store,
5150
+ sessionId: typeof sessionId === "string" ? sessionId : null,
5151
+ });
5152
+ const badgeEntry = (name, id, placement) =>
5153
+ scope.slots.inject(name, () =>
5154
+ registerListEntry(
5155
+ scope.slots,
5156
+ {
5157
+ name,
5158
+ id,
5159
+ order: 10000,
5160
+ label: () => t("tabQuota"),
5161
+ locale: NS,
5162
+ inject: injectDirectory,
5163
+ },
5164
+ (props = {}) =>
5165
+ h(QuotaBadge, {
5166
+ t,
5167
+ directory: props.directory ?? null,
5168
+ resolveDirectory: typeof props.resolveDirectory === "function" ? props.resolveDirectory : null,
5169
+ sessionId: props.sessionId ?? null,
5170
+ placement,
5171
+ store: sharedQuota,
5172
+ })
5173
+ )
5174
+ );
5175
+ badgeEntry("conversation.input.right", `${NS}:quota`, "composer");
5176
+ badgeEntry("conversation.hero.options", `${NS}:quota-hero`, "hero");
5177
+ });
5178
+ }
5179
+
3069
5180
  // 1. Accounts and models: a page of its own in the Settings dialog's left
3070
5181
  // navigation (like dshmarket's "Market" section). The slot owner gives
3071
5182
  // unregistered ids the default nav glyph; no DOM is touched here.
3072
5183
  ctx.slots.inject("settings.section", () =>
3073
- ctx.slots.register(
5184
+ registerListEntry(
5185
+ ctx.slots,
3074
5186
  {
3075
5187
  name: "settings.section",
3076
5188
  id: SETTINGS_SECTION_ID,
@@ -3078,13 +5190,14 @@ window.__ModuleLoader__.load({
3078
5190
  label: () => t("nav"),
3079
5191
  locale: NS,
3080
5192
  },
3081
- () => h(CpaSettingsPanel, { t })
5193
+ () => h(CpaSectionPage, { t })
3082
5194
  )
3083
5195
  );
3084
5196
 
3085
5197
  // 2. Plugin updates only, on the bundle page in Sidebar -> Plugins.
3086
5198
  ctx.slots.inject("plugins.bundle.config", () =>
3087
- ctx.slots.register(
5199
+ registerListEntry(
5200
+ ctx.slots,
3088
5201
  {
3089
5202
  name: "plugins.bundle.config",
3090
5203
  key: MODULE_ID,
@@ -3096,7 +5209,8 @@ window.__ModuleLoader__.load({
3096
5209
 
3097
5210
  // 3. Plugin updates only, as a tab in Settings -> Plugins.
3098
5211
  ctx.slots.inject("settings.plugins.tab", () =>
3099
- ctx.slots.register(
5212
+ registerListEntry(
5213
+ ctx.slots,
3100
5214
  {
3101
5215
  name: "settings.plugins.tab",
3102
5216
  id: NS,