dsh-cliproxyapi-pro 1.5.1 → 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
@@ -12,6 +12,8 @@ window.__ModuleLoader__.load({
12
12
  const PACKAGE_NAME = "dsh-cliproxyapi-pro";
13
13
  const MODULE_ID = "dsh-cliproxyapi-pro";
14
14
  const NS = "dsh-cliproxyapi-pro";
15
+ // Stable id of the account/model page in the Settings left navigation.
16
+ const SETTINGS_SECTION_ID = "cliproxyapi";
15
17
 
16
18
  // Document-relative API paths (compatible with <base href="./"> in DSH Web/Desktop)
17
19
  const API = {
@@ -24,52 +26,50 @@ window.__ModuleLoader__.load({
24
26
  updateCheck: "api/dsh-cliproxyapi/updates/check",
25
27
  updateSave: "api/dsh-cliproxyapi/updates/save",
26
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",
27
32
  };
28
33
 
34
+ // Public, versioned dshmarket Update API (UPDATE-API-V1.md in dshmarket).
35
+ // Installation is delegated to the market; this plugin never runs a
36
+ // package manager. Paths are document-relative like API above.
37
+ const MARKET_API = {
38
+ capabilities: "dsh-market/api/v1/capabilities",
39
+ updates: "dsh-market/api/v1/updates",
40
+ operations: "dsh-market/api/v1/operations",
41
+ restart: "dsh-market/api/v1/restart",
42
+ };
43
+ const MARKET_SCHEMA = "dsh-market/update-api/v1";
44
+
29
45
  const zh = {
30
46
  nav: "CLIProxyAPI",
31
- title: "CLIProxyAPI 自动发现与模型筛选",
32
- subtitle:
33
- "自动连接 CLIProxyAPI 服务,勾选你希望在对话框模型选择器中显示的模型,自动同步上下文长度、视觉与推理档位。",
34
47
  statusConnected: "已连接并同步",
35
48
  statusProbing: "正在探测同步…",
36
49
  statusError: "连接异常",
37
50
  statusDisabled: "已停用",
38
51
  statusIdle: "等待同步",
39
- endpointLabel: "当前活动节点",
40
- endpointNone: "未连接(将依次尝试配置地址与本地 8317 端口)",
41
- modelsCountLabel: "选择器展示模型",
42
- refreshedAtLabel: "上次同步时间",
43
52
  refreshBtn: "立即探测刷新",
44
53
  refreshingBtn: "正在探测…",
45
- saveBtn: "保存设置并应用",
54
+ saveBtn: "保存并应用",
46
55
  savingBtn: "正在应用…",
47
- saveSelectionBtn: "保存模型勾选",
48
- unsavedChanges: "有未保存的勾选更改",
56
+ unsavedChanges: "有未保存的更改",
49
57
  savedToast: "已保存并同步到对话框模型选择器",
50
- sectionConnection: "连接与认证配置",
51
58
  accountsLabel: "账号",
52
59
  addAccountBtn: "+ 添加账号",
53
- addingAccountBtn: "正在添加…",
54
60
  removeAccountBtn: "删除此账号",
55
61
  removeConfirm: "确定删除账号「{name}」吗?它同步到模型选择器中的模型路由也会一并移除(已保存的密钥不会删除)。",
56
- noAccounts: "还没有任何账号。点击「添加账号」接入一个 CLIProxyAPI 服务。",
57
- accountOffBadge: "未启用",
58
- addedToast: "已添加新账号(默认未启用)。填写服务地址与密钥后,打开「启用此账号」并保存。",
62
+ addedToast: "账号已添加并启用,已按每个家族勾选最新模型。",
59
63
  removedToast: "账号已删除,其模型已从模型选择器中移除",
60
- disabledHint:
61
- "该账号未启用,它的模型不会出现在对话框模型选择器中。可先填写地址与密钥并点击「立即探测刷新」预览模型列表,勾选后再开启「启用此账号」。",
64
+ disabledHint: "未启用:模型不在模型选择器中,配置已保留。",
62
65
  enabledLabel: "启用此账号",
63
- enabledHint: "开启后将该账号下勾选的模型同步到 DSH 对话框模型选择器;关闭后保留配置,但移除该账号的模型。",
64
- baseUrlLabel: "CLIProxyAPI 服务地址 (Base URL)",
65
- baseUrlPlaceholder: "留空则自动继承现有路由或探测 http://127.0.0.1:8317",
66
- baseUrlHint: "支持填写根地址(如 http://127.0.0.1:8317 或远程 IP:8317),无需手动拼接 /v1。",
67
- apiKeyLabel: "API Key 密钥",
66
+ enabledHint: "开启后,该账号勾选的模型会出现在对话框模型选择器中;关闭后移除这些模型,配置保留。",
67
+ baseUrlLabel: "服务地址",
68
+ baseUrlPlaceholder: "如 http://127.0.0.1:8317,无需加 /v1;留空自动发现",
69
+ apiKeyLabel: "API Key",
68
70
  apiKeySetPlaceholder: "已配置密钥(留空保持不变,输入新值可覆盖)",
69
71
  apiKeyUnsetPlaceholder: "输入 CLIProxyAPI 访问密钥(本地免密模式可留空)",
70
- apiKeyHint: "密钥通过 DSH 凭证服务安全存储在 .credentials.yaml 中,绝不明文写入配置文件。",
71
- apiKeyConfiguredBadge: "密钥已配置",
72
- apiKeyUnsetBadge: "未设置密钥",
72
+ apiKeyHint: "保存在 DSH 凭证服务({ref}),不会写入配置文件。本地免密的 CPA 可留空。",
73
73
  clearKeyBtn: "清除密钥",
74
74
  protocolLabel: "接口协议",
75
75
  protocolHint: "自动模式下每个模型走所属家族的原生协议,减少 CPA 中转转换;Gemini 等 DSH 不支持原生协议的模型走 OpenAI Chat。",
@@ -82,22 +82,19 @@ window.__ModuleLoader__.load({
82
82
  modelProtocolDefault: "跟随全局设置",
83
83
  modelProtocolHint: "原生协议:",
84
84
  sectionAdvanced: "高级路由与规则过滤",
85
- providerIdLabel: "DSH 路由 ID (Provider)",
85
+ providerIdLabel: "路由 ID",
86
86
  providerIdHint: "写入 llm-pi-ai 的提供商路由标识,每个账号必须不同(首个账号默认 cli-proxy,与现有配置无缝衔接)。",
87
- apiKeyEnvLabel: "密钥引用名 (Credential Ref)",
87
+ apiKeyEnvLabel: "密钥引用名",
88
88
  apiKeyEnvHint: "密钥在 DSH 凭证服务中的名称,每个账号建议使用不同的引用名。",
89
- displayNameLabel: "账号名称 (Display Name)",
89
+ displayNameLabel: "账号名称",
90
90
  displayNameHint: "用于区分账号,并作为 DSH 对话框模型选择器中的分组名称。",
91
- includePrefixLabel: "仅包含模型关键词 (可选)",
91
+ includePrefixLabel: "仅包含关键词",
92
92
  includePrefixPlaceholder: "例如: gpt-6, claude-opus, gemini-4 (多个用逗号分隔)",
93
93
  includePrefixHint: "附加规则:只允许模型 ID 包含指定关键词的模型。",
94
- excludePrefixLabel: "排除模型关键词 (可选)",
94
+ excludePrefixLabel: "排除关键词",
95
95
  excludePrefixPlaceholder: "例如: tts, image, video, zj/ (多个用逗号分隔)",
96
96
  excludePrefixHint: "附加规则:排除包含指定关键词的模型 ID。",
97
- refreshIntervalLabel: "自动刷新间隔 (秒,所有账号共用)",
98
- sectionModels: "模型显示筛选(仅勾选的模型会在回答选择器中显示)",
99
- sectionModelsHint:
100
- "点击模型行即可勾选或取消。不勾选任何模型(全部清空)时默认不限制;勾选后仅展示选中的模型。",
97
+ refreshIntervalLabel: "自动刷新间隔",
101
98
  searchModelsPlaceholder: "搜索模型 ID 或名称(如 claude-opus, gpt-6, gemini-4, swe)…",
102
99
  filterAll: "全部发现",
103
100
  filterSelected: "已勾选",
@@ -106,7 +103,7 @@ window.__ModuleLoader__.load({
106
103
  selectAllVisibleBtn: "全选当前列表",
107
104
  deselectAllVisibleBtn: "取消当前列表",
108
105
  selectRecommendedBtn: "恢复推荐精选",
109
- selectRecommendedHint: "按该服务的模型目录,每个模型家族勾选最新的一个",
106
+ selectRecommendedHint: "按该服务的模型目录,每个模型家族勾选最新的一个。",
110
107
  badgeVision: "视觉 Image",
111
108
  badgeReasoning: "推理",
112
109
  badgeSelected: "已启用",
@@ -120,9 +117,10 @@ window.__ModuleLoader__.load({
120
117
  imageInputLabel: "支持图片输入",
121
118
  reasoningLevelsLabel: "思考等级",
122
119
  resetParamsBtn: "恢复自动探测",
123
- paramsHint: "留空并保存即恢复 CLIProxyAPI 自动探测值。修改后请点击「保存模型勾选」才会写入模型选择器。",
120
+ paramsHint: "留空并保存即恢复 CLIProxyAPI 自动探测值。",
124
121
  discoveredValue: "探测值",
125
122
  updateTitle: "插件更新",
123
+ updatePageHint: "这里只管理插件自身的更新。CLIProxyAPI 的账号、模型和协议设置位于「设置」左侧导航的「CLIProxyAPI」页。",
126
124
  updateToggleDetails: "展开或收起插件更新设置",
127
125
  updateCompactCurrent: "当前 {version}",
128
126
  updateCompactAvailable: "有新版本 {version}",
@@ -133,6 +131,7 @@ window.__ModuleLoader__.load({
133
131
  updateCompactErrorChannel: "通道未发布",
134
132
  updateStatusUnavailableHint: "无法读取更新服务,可能是插件宿主代码尚未重载:重启 DSH(仅刷新页面不够),或检查更新接口是否可用。",
135
133
  updateServiceUnavailableError: "更新服务未就绪:当前运行的插件宿主代码仍是旧版,可能尚未重启 DSH。重启 DSH 后重试。",
134
+ updateOriginError: "请求来源校验未通过,更新操作已拒绝。请从 DSH 打开的页面重试;使用反向代理时请保持与页面一致的地址访问。",
136
135
  updateSubtitle: "仅更新 CLIProxyAPI 插件自身,不更新 CPA 服务或 DSH 宿主。设置修改后立即保存。",
137
136
  updateNewVersion: "新版本",
138
137
  updateVersionSummary: "当前版本 {current} · 最新版本 {latest}",
@@ -195,53 +194,192 @@ window.__ModuleLoader__.load({
195
194
  updateDownloadPathLabel: "已校验的离线包",
196
195
  updateUpgradeCommandLabel: "关闭 DSH 后执行升级命令",
197
196
  updateCommandUnavailable: "未生成升级命令。请先确认 DSH 支持的离线包升级方式;升级前关闭 DSH,完成后重新启动。",
198
- updateOfflineHint: "自动下载不等于自动安装。插件不会关闭、重启 DSH,也不会覆盖正在使用的插件文件。",
197
+ updateOfflineHint: "离线包下载不会自动安装。本插件自身不会运行包管理器、关闭或重启 DSH;一键更新和自动安装都交给 DSH 插件市场(dshmarket)完成。",
198
+ updateCompactInstalled: "{version} 已安装",
199
+ updateMarketLabel: "通过插件市场更新",
200
+ updateMarketDetecting: "正在检测 DSH 插件市场…",
201
+ updateMarketReadyHint: "已检测到 DSH 插件市场(dshmarket),可一键安装新版本。安装和版本解析都由插件市场完成,可能需要重启 DSH 或刷新页面才能生效。",
202
+ updateMarketUnavailableHint: "未检测到 DSH 插件市场(dshmarket)的更新接口。安装插件市场后即可在此一键更新或自动更新;目前可使用下方的离线包下载。",
203
+ updateMarketLinkedHint: "当前为本地链接安装,不通过插件市场安装,以免替换源码链接。",
204
+ updateMarketInstallBtn: "立即更新",
205
+ updateMarketInstalledBtn: "已安装",
206
+ updateMarketInstalling: "正在通过插件市场安装…",
207
+ updateMarketProgress: "正在通过插件市场安装… {percent}%",
208
+ updateMarketRedetectBtn: "重新检测",
209
+ updateMarketConfirmTitle: "通过插件市场安装版本 {version}?",
210
+ updateMarketConfirmHint: "DSH 插件市场会安装本插件的新版本,并在安装后核对实际装上的版本,不一致时恢复原版本。安装期间请勿关闭 DSH;完成后可能需要重启 DSH 或刷新页面。",
211
+ updateMarketConfirmBtn: "确认安装",
212
+ updateMarketAutomaticPrefix: "自动更新:",
213
+ updateMarketInstalled: "已安装版本 {version}。",
214
+ updateMarketVersionLower: "插件市场安装的是 {installed},低于检查到的 {target}:插件市场按其自身的通道与镜像解析版本。",
215
+ updateMarketRestartHint: "重启 DSH 后新版本生效。",
216
+ updateMarketRestartDesktopHint: "请完全退出 DSH 桌面版并重新打开,新版本才会生效。",
217
+ updateMarketRestartManualHint: "请重启 DSH 服务,新版本才会生效。",
218
+ updateMarketRefreshHint: "刷新页面后新版本界面生效。",
219
+ updateMarketAppliedHint: "新版本已生效;若界面仍是旧版,请刷新页面。",
220
+ updateMarketRestartBtn: "重启 DSH",
221
+ updateMarketRestarting: "正在请求重启…",
222
+ updateMarketRestartRequested: "已请求重启 DSH,页面将在 DSH 重新启动后恢复。",
223
+ updateMarketRefreshBtn: "刷新页面",
224
+ updateMarketUnchanged: "插件市场报告完成,但插件版本没有变化。",
225
+ updateMarketCancelled: "插件市场的安装任务已取消。",
226
+ updateMarketRolledBack: "插件市场已回滚此次更新,插件保持原版本。",
227
+ updateMarketForceBtn: "仍然安装",
228
+ updateMarketBusy: "插件市场正在执行另一项更新,请稍后重试。",
229
+ updateMarketAgentsRunning: "有智能体正在运行,插件市场暂不安装。请等待任务结束后重试。",
230
+ updateMarketTooFresh: "新版本刚发布,仍在插件市场的发布冷却期内。可稍后重试,或选择「仍然安装」。",
231
+ updateMarketVersionUnchanged: "插件市场没有解析到更新的版本(镜像可能尚未同步)。可稍后重试,或选择「仍然安装」。",
232
+ updateMarketDowngrade: "插件市场解析到的版本低于当前版本,已自动恢复,未做更改。",
233
+ updateMarketMismatch: "插件市场安装的版本与目标版本不一致,已自动恢复。请等待镜像同步后重试。",
234
+ updateMarketTimeout: "插件市场安装超时,请稍后重试。",
235
+ updateMarketForbidden: "插件市场拒绝了此次更新请求,请从 DSH 打开的页面重试。",
236
+ updateMarketLost: "无法继续跟踪插件市场的安装任务(DSH 可能已重启)。请重新检查更新以确认当前版本。",
237
+ updateMarketUnreachable: "无法连接 DSH 插件市场的更新接口,请确认 dshmarket 已启用后重试。",
238
+ updateMarketRestartFailed: "重启请求失败,请手动重启 DSH。",
239
+ updateMarketFailed: "插件市场安装失败。可在插件市场中查看详情,稍后重试,或使用离线包下载。",
240
+ updateAutoInstallLabel: "自动安装更新",
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: "其他",
199
353
  };
200
354
 
201
355
  const en = {
202
356
  nav: "CLIProxyAPI",
203
- title: "CLIProxyAPI Auto-Discovery & Model Filter",
204
- subtitle:
205
- "Connect to CLIProxyAPI and check the exact models you want displayed in the DSH conversation model picker.",
206
357
  statusConnected: "Connected & Synced",
207
358
  statusProbing: "Probing…",
208
359
  statusError: "Connection Error",
209
360
  statusDisabled: "Disabled",
210
361
  statusIdle: "Idle",
211
- endpointLabel: "Active Endpoint",
212
- endpointNone: "Not connected (tries configured URL then localhost:8317)",
213
- modelsCountLabel: "Models in Picker",
214
- refreshedAtLabel: "Last Synced",
215
362
  refreshBtn: "Probe & Refresh",
216
363
  refreshingBtn: "Probing…",
217
364
  saveBtn: "Save & Apply",
218
365
  savingBtn: "Applying…",
219
- saveSelectionBtn: "Apply Selected Models",
220
- unsavedChanges: "Unsaved selection changes",
366
+ unsavedChanges: "Unsaved changes",
221
367
  savedToast: "Saved and synchronized to the DSH model picker",
222
- sectionConnection: "Connection & Authentication",
223
368
  accountsLabel: "Accounts",
224
369
  addAccountBtn: "+ Add Account",
225
- addingAccountBtn: "Adding…",
226
370
  removeAccountBtn: "Delete Account",
227
371
  removeConfirm: "Delete account \"{name}\"? Its model routes are removed from the model picker too (the stored key is kept).",
228
- noAccounts: "No accounts yet. Click Add Account to connect a CLIProxyAPI server.",
229
- accountOffBadge: "Off",
230
- 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.",
231
373
  removedToast: "Account deleted and its models removed from the picker",
232
- disabledHint:
233
- "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.",
234
375
  enabledLabel: "Enable This Account",
235
- enabledHint: "Sync this account's checked models into the DSH model selector. Turning it off keeps the settings but removes its models.",
236
- baseUrlLabel: "CLIProxyAPI Base URL",
237
- baseUrlPlaceholder: "Leave blank to inherit existing route or auto-discover 127.0.0.1:8317",
238
- 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",
239
379
  apiKeyLabel: "API Key",
240
380
  apiKeySetPlaceholder: "Key is stored (leave blank to keep, or type a new key)",
241
381
  apiKeyUnsetPlaceholder: "Enter CLIProxyAPI Bearer key (optional for local no-auth)",
242
- apiKeyHint: "Stored securely via DSH credentials service, never in plain settings.",
243
- apiKeyConfiguredBadge: "Configured",
244
- 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.",
245
383
  clearKeyBtn: "Clear Key",
246
384
  protocolLabel: "Wire Protocol",
247
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.",
@@ -254,22 +392,19 @@ window.__ModuleLoader__.load({
254
392
  modelProtocolDefault: "Follow global setting",
255
393
  modelProtocolHint: "Native protocol: ",
256
394
  sectionAdvanced: "Advanced Routing & Keyword Rules",
257
- providerIdLabel: "Provider Route ID",
395
+ providerIdLabel: "Route ID",
258
396
  providerIdHint: "Provider key under llm-pi-ai; must differ per account (first account: cli-proxy).",
259
397
  apiKeyEnvLabel: "Credential Ref",
260
398
  apiKeyEnvHint: "Name of the key in the DSH credentials service; use a distinct one per account.",
261
399
  displayNameLabel: "Account Name",
262
400
  displayNameHint: "Tells accounts apart and titles their group in the DSH model selector.",
263
- includePrefixLabel: "Include Filter (optional)",
401
+ includePrefixLabel: "Include Keywords",
264
402
  includePrefixPlaceholder: "e.g. gpt-6, claude-opus, gemini-4 (comma-separated)",
265
403
  includePrefixHint: "Additional rule: only sync models matching these substrings.",
266
- excludePrefixLabel: "Exclude Filter (optional)",
404
+ excludePrefixLabel: "Exclude Keywords",
267
405
  excludePrefixPlaceholder: "e.g. tts, image, video, zj/ (comma-separated)",
268
406
  excludePrefixHint: "Additional rule: hide models matching any of these substrings.",
269
- refreshIntervalLabel: "Auto Refresh Interval (s, all accounts)",
270
- sectionModels: "Model Picker Filter (Only checked models appear in DSH)",
271
- sectionModelsHint:
272
- "Click any model row to toggle selection. Only checked models will be shown in the conversation model selector.",
407
+ refreshIntervalLabel: "Auto Refresh",
273
408
  searchModelsPlaceholder: "Search model ID or name (e.g. claude-opus, gpt-6, gemini-4)…",
274
409
  filterAll: "All Discovered",
275
410
  filterSelected: "Checked",
@@ -278,7 +413,7 @@ window.__ModuleLoader__.load({
278
413
  selectAllVisibleBtn: "Check Visible",
279
414
  deselectAllVisibleBtn: "Uncheck Visible",
280
415
  selectRecommendedBtn: "Reset to Recommended",
281
- 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.",
282
417
  badgeVision: "Image",
283
418
  badgeReasoning: "Reasoning",
284
419
  badgeSelected: "Active",
@@ -292,9 +427,10 @@ window.__ModuleLoader__.load({
292
427
  imageInputLabel: "Image Input",
293
428
  reasoningLevelsLabel: "Thinking Levels",
294
429
  resetParamsBtn: "Reset to Discovered",
295
- 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.",
296
431
  discoveredValue: "Discovered",
297
432
  updateTitle: "Plugin Updates",
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.",
298
434
  updateToggleDetails: "Expand or collapse plugin update settings",
299
435
  updateCompactCurrent: "Current {version}",
300
436
  updateCompactAvailable: "Update {version}",
@@ -305,6 +441,7 @@ window.__ModuleLoader__.load({
305
441
  updateCompactErrorChannel: "Channel unpublished",
306
442
  updateStatusUnavailableHint: "Could not reach the update service; the running host plugin code may still be the previous version. Restart DSH (refreshing the page is not enough), or check that the update endpoint is registered.",
307
443
  updateServiceUnavailableError: "The update service is not ready: the running host plugin code is still the previous version, so DSH may not have been restarted. Restart DSH and retry.",
444
+ updateOriginError: "The request origin was rejected, so the update action was refused. Retry from the page DSH opened, and reach it through the same address you see (relevant behind a reverse proxy).",
308
445
  updateSubtitle: "Updates this CLIProxyAPI plugin only, not the CPA server or DSH. Changes to update settings are saved immediately.",
309
446
  updateNewVersion: "New Version",
310
447
  updateVersionSummary: "Current {current} · Latest {latest}",
@@ -367,7 +504,162 @@ window.__ModuleLoader__.load({
367
504
  updateDownloadPathLabel: "Verified Offline Package",
368
505
  updateUpgradeCommandLabel: "Upgrade Command — Run After Closing DSH",
369
506
  updateCommandUnavailable: "No upgrade command was generated. First verify the offline-package upgrade method supported by your DSH version. Close DSH before upgrading, then start it again.",
370
- updateOfflineHint: "Automatic downloading is not automatic installation. This plugin never closes or restarts DSH, or overwrites plugin files that are in use.",
507
+ updateOfflineHint: "Downloading an offline package never installs it. This plugin itself never runs a package manager, closes or restarts DSH; one-click and automatic installation are handed to the DSH plugin market (dshmarket).",
508
+ updateCompactInstalled: "{version} installed",
509
+ updateMarketLabel: "Update Through Plugin Market",
510
+ updateMarketDetecting: "Detecting the DSH plugin market…",
511
+ updateMarketReadyHint: "The DSH plugin market (dshmarket) was detected, so new versions can be installed in one click. The market installs and resolves the version; a DSH restart or page refresh may be needed for it to take effect.",
512
+ updateMarketUnavailableHint: "The DSH plugin market (dshmarket) update API was not detected. Install the plugin market to enable one-click and automatic updates here; until then, use the offline package download below.",
513
+ updateMarketLinkedHint: "This is a linked local installation, so it is not installed through the plugin market, which would replace the source link.",
514
+ updateMarketInstallBtn: "Update Now",
515
+ updateMarketInstalledBtn: "Installed",
516
+ updateMarketInstalling: "Installing through the plugin market…",
517
+ updateMarketProgress: "Installing through the plugin market… {percent}%",
518
+ updateMarketRedetectBtn: "Detect Again",
519
+ updateMarketConfirmTitle: "Install version {version} through the plugin market?",
520
+ updateMarketConfirmHint: "The DSH plugin market installs the new version of this plugin, then verifies the version it actually placed and restores the previous one on a mismatch. Keep DSH open while it runs; a DSH restart or page refresh may be needed afterwards.",
521
+ updateMarketConfirmBtn: "Confirm Install",
522
+ updateMarketAutomaticPrefix: "Automatic update:",
523
+ updateMarketInstalled: "Version {version} is installed.",
524
+ updateMarketVersionLower: "The plugin market installed {installed}, older than the checked {target}: the market resolves versions with its own channel and mirror.",
525
+ updateMarketRestartHint: "Restart DSH for the new version to take effect.",
526
+ updateMarketRestartDesktopHint: "Quit the DSH desktop app completely and reopen it for the new version to take effect.",
527
+ updateMarketRestartManualHint: "Restart the DSH service for the new version to take effect.",
528
+ updateMarketRefreshHint: "Refresh the page to load the new interface.",
529
+ updateMarketAppliedHint: "The new version is active; refresh the page if the interface still looks old.",
530
+ updateMarketRestartBtn: "Restart DSH",
531
+ updateMarketRestarting: "Requesting restart…",
532
+ updateMarketRestartRequested: "A DSH restart was requested. The page recovers once DSH is back.",
533
+ updateMarketRefreshBtn: "Refresh Page",
534
+ updateMarketUnchanged: "The plugin market reported success, but the plugin version did not change.",
535
+ updateMarketCancelled: "The plugin market cancelled the installation.",
536
+ updateMarketRolledBack: "The plugin market rolled this update back; the previous version is kept.",
537
+ updateMarketForceBtn: "Install Anyway",
538
+ updateMarketBusy: "The plugin market is running another update. Retry shortly.",
539
+ updateMarketAgentsRunning: "Agents are running, so the plugin market will not install now. Retry when they finish.",
540
+ updateMarketTooFresh: "This release is still inside the plugin market's release-age wait. Retry later, or choose Install Anyway.",
541
+ updateMarketVersionUnchanged: "The plugin market did not resolve a newer version (its mirror may still be syncing). Retry later, or choose Install Anyway.",
542
+ updateMarketDowngrade: "The plugin market resolved a version older than the current one and restored it; nothing changed.",
543
+ updateMarketMismatch: "The plugin market installed a version other than the target and restored the previous one. Retry after the mirror catches up.",
544
+ updateMarketTimeout: "The plugin market installation timed out. Retry later.",
545
+ updateMarketForbidden: "The plugin market refused this update request. Retry from the page DSH opened.",
546
+ updateMarketLost: "The plugin market operation can no longer be followed (DSH may have restarted). Check for updates again to confirm the current version.",
547
+ updateMarketUnreachable: "Could not reach the DSH plugin market update API. Make sure dshmarket is enabled and retry.",
548
+ updateMarketRestartFailed: "The restart request failed. Restart DSH manually.",
549
+ updateMarketFailed: "The plugin market installation failed. See the plugin market for details, retry later, or use the offline package download.",
550
+ updateAutoInstallLabel: "Automatically Install Updates",
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",
371
663
  };
372
664
 
373
665
  const CSS = `
@@ -858,7 +1150,9 @@ window.__ModuleLoader__.load({
858
1150
  .dcpa-update-summary-current { flex: 0 1 auto; }
859
1151
  .dcpa-update-summary-status { flex: 1; text-align: right; }
860
1152
  .dcpa-update-summary-status[data-state="available"],
861
- .dcpa-update-summary-status[data-state="downloaded"] { color: var(--dsw-alias-brand-primary); }
1153
+ .dcpa-update-summary-status[data-state="downloaded"],
1154
+ .dcpa-update-summary-status[data-state="installing"],
1155
+ .dcpa-update-summary-status[data-state="installed"] { color: var(--dsw-alias-brand-primary); }
862
1156
  .dcpa-update-summary-status[data-state="error"] { color: var(--dsw-alias-state-error-primary); }
863
1157
  .dcpa-update-summary::after {
864
1158
  content: "";
@@ -932,74 +1226,706 @@ window.__ModuleLoader__.load({
932
1226
  flex-wrap: wrap;
933
1227
  }
934
1228
  }
935
- `;
936
-
937
- const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
938
- const PROTOCOL_NAME_KEYS = {
939
- "openai-completions": "protocolNameCompletions",
940
- "openai-responses": "protocolNameResponses",
941
- "anthropic-messages": "protocolNameMessages",
942
- };
943
- const PROTOCOLS = Object.keys(PROTOCOL_NAME_KEYS);
944
-
945
- function cloneOverrides(value) {
946
- const out = {};
947
- for (const [id, entry] of Object.entries(value ?? {})) {
948
- out[id] = {
949
- ...entry,
950
- ...(Array.isArray(entry.reasoningLevels) ? { reasoningLevels: [...entry.reasoningLevels] } : {}),
951
- };
952
- }
953
- return out;
954
- }
955
-
956
- function overridesKey(value) {
957
- return JSON.stringify(value ?? {});
958
- }
959
-
960
- function formatTokens(n) {
961
- if (typeof n !== "number" || !Number.isFinite(n)) return "";
962
- if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
963
- if (n >= 1000) return `${Math.round(n / 1000)}K`;
964
- return String(n);
965
- }
966
-
967
- async function postJson(url, body) {
968
- const res = await fetch(url, {
969
- method: "POST",
970
- headers: { "content-type": "application/json" },
971
- body: JSON.stringify(body ?? {}),
972
- });
973
- const payload = await res.json();
974
- if (!res.ok || payload.ok === false) {
975
- throw new Error(payload.error || `HTTP ${res.status}`);
976
- }
977
- return payload;
978
- }
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
+ }
979
1268
 
980
- function stateDotClass(state) {
981
- return state === "connected"
982
- ? "dcpa-dot dcpa-dot-connected"
983
- : state === "probing"
984
- ? "dcpa-dot dcpa-dot-probing"
985
- : state === "error"
986
- ? "dcpa-dot dcpa-dot-error"
987
- : "dcpa-dot dcpa-dot-idle";
988
- }
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; }
989
1597
 
990
- const SERVICE_UNAVAILABLE_STATUS = new Set([404, 405, 501]);
991
- const UPDATE_ERROR_KEYS = {
992
- "network-error": "updateNetworkError",
993
- "invalid-metadata": "updateMetadataError",
994
- "invalid-response": "updateMetadataError",
995
- "service-unavailable": "updateServiceUnavailableError",
996
- "channel-unavailable": "updateChannelUnavailable",
997
- "unavailable-channel": "updateChannelUnavailable",
998
- "integrity-error": "updateIntegrityError",
999
- "invalid-package": "updateInvalidPackage",
1000
- "storage-error": "updateStorageError",
1001
- busy: "updateBusyError",
1002
- "stale-version": "updateStaleVersion",
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",
1003
1929
  disposed: "updateDisposedError",
1004
1930
  "linked-install": "updateLinkedHint",
1005
1931
  "unsupported-host": "updateUnsupportedHint",
@@ -1062,8 +1988,303 @@ window.__ModuleLoader__.load({
1062
1988
  return body.update;
1063
1989
  }
1064
1990
 
1991
+ // #region dshmarket installation (Update API v1)
1992
+
1993
+ const MARKET_TERMINAL_STATES = new Set(["succeeded", "failed", "cancelled", "rolled-back"]);
1994
+ const MARKET_POLL_MS = 1500;
1995
+ const MARKET_MAX_POLL_FAILURES = 20;
1996
+ const MARKET_FORCEABLE = new Set(["RELEASE_TOO_FRESH", "VERSION_UNCHANGED"]);
1997
+ const AUTO_INSTALL_FIRST_MS = 15000;
1998
+ const AUTO_INSTALL_INTERVAL_MS = 5 * 60 * 1000;
1999
+ const AUTO_INSTALL_RETRY_MS = 6 * 60 * 60 * 1000;
2000
+ const AUTO_INSTALL_MEMORY_KEY = `${PACKAGE_NAME}:market-auto-install`;
2001
+ const MARKET_ERROR_KEYS = {
2002
+ OPERATION_BUSY: "updateMarketBusy",
2003
+ AGENTS_RUNNING: "updateMarketAgentsRunning",
2004
+ RELEASE_TOO_FRESH: "updateMarketTooFresh",
2005
+ VERSION_UNCHANGED: "updateMarketVersionUnchanged",
2006
+ DOWNGRADE_DETECTED: "updateMarketDowngrade",
2007
+ RESOLVED_VERSION_MISMATCH: "updateMarketMismatch",
2008
+ UPDATE_TIMEOUT: "updateMarketTimeout",
2009
+ UPDATE_FORBIDDEN: "updateMarketForbidden",
2010
+ PLUGIN_NOT_INSTALLED: "updateNotInstalledHint",
2011
+ OPERATION_LOST: "updateMarketLost",
2012
+ MARKET_UNREACHABLE: "updateMarketUnreachable",
2013
+ RESTART_FAILED: "updateMarketRestartFailed",
2014
+ };
2015
+
2016
+ function parseVersion(value) {
2017
+ const match = typeof value === "string"
2018
+ ? /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
2019
+ : null;
2020
+ return match ? { parts: match.slice(1, 4).map(Number), pre: match[4] ? match[4].split(".") : [] } : null;
2021
+ }
2022
+
2023
+ /** SemVer precedence, or null when either side is not a version. */
2024
+ function compareVersions(a, b) {
2025
+ const x = parseVersion(a), y = parseVersion(b);
2026
+ if (!x || !y) return null;
2027
+ for (let i = 0; i < 3; i++) if (x.parts[i] !== y.parts[i]) return x.parts[i] > y.parts[i] ? 1 : -1;
2028
+ if (!x.pre.length || !y.pre.length) return x.pre.length ? -1 : y.pre.length ? 1 : 0;
2029
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
2030
+ const p = x.pre[i], q = y.pre[i];
2031
+ if (p === undefined || q === undefined) return p === undefined ? -1 : 1;
2032
+ if (p === q) continue;
2033
+ const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
2034
+ if (pn && qn) return Number(p) > Number(q) ? 1 : -1;
2035
+ if (pn !== qn) return pn ? -1 : 1;
2036
+ return p > q ? 1 : -1;
2037
+ }
2038
+ return 0;
2039
+ }
2040
+
2041
+ function validMarketOperation(value) {
2042
+ return !!value && typeof value === "object" && value.schema === MARKET_SCHEMA &&
2043
+ typeof value.operationId === "string" && value.operationId !== "" &&
2044
+ value.packageName === PACKAGE_NAME && typeof value.state === "string";
2045
+ }
2046
+
2047
+ const marketFailureCode = (operation) =>
2048
+ typeof operation?.failure?.code === "string" && operation.failure.code ? operation.failure.code : "UPDATE_FAILED";
2049
+
2050
+ function marketErrorText(t, code) {
2051
+ return t(Object.prototype.hasOwnProperty.call(MARKET_ERROR_KEYS, code) ? MARKET_ERROR_KEYS[code] : "updateMarketFailed");
2052
+ }
2053
+
2054
+ /**
2055
+ * Shared, page-wide client of the dshmarket Update API. One instance lives
2056
+ * per client activation so the settings card and the background
2057
+ * auto-install runner observe the same operation.
2058
+ */
2059
+ function createMarketUpdater(io = {}) {
2060
+ const doFetch = io.fetch ?? ((url, init) => fetch(url, init));
2061
+ const later = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
2062
+ const cancelLater = io.clearTimeout ?? ((id) => clearTimeout(id));
2063
+ let state = {
2064
+ phase: "unknown", // unknown | discovering | ready | unavailable
2065
+ runtime: null,
2066
+ restartSupported: false,
2067
+ installing: false,
2068
+ automatic: false,
2069
+ targetVersion: null,
2070
+ operation: null,
2071
+ errorCode: null,
2072
+ restarting: false,
2073
+ restartRequested: false,
2074
+ };
2075
+ const listeners = new Set();
2076
+ const controllers = new Set();
2077
+ let disposed = false;
2078
+ let discovering = null;
2079
+ let pollTimer = null;
2080
+ let pollFailures = 0;
2081
+
2082
+ const set = (patch) => {
2083
+ if (disposed) return;
2084
+ state = { ...state, ...patch };
2085
+ for (const listener of [...listeners]) listener(state);
2086
+ };
2087
+ const request = async (url, init = {}) => {
2088
+ const controller = new AbortController();
2089
+ controllers.add(controller);
2090
+ try {
2091
+ const res = await doFetch(url, { cache: "no-store", ...init, signal: controller.signal });
2092
+ let body = null;
2093
+ try { body = await res.json(); } catch {}
2094
+ return { status: res.status, ok: res.ok, body };
2095
+ } finally {
2096
+ controllers.delete(controller);
2097
+ }
2098
+ };
2099
+ const post = (url, payload) => request(url, {
2100
+ method: "POST",
2101
+ headers: { "content-type": "application/json" },
2102
+ body: JSON.stringify(payload),
2103
+ });
2104
+ const applyOperation = (operation) => {
2105
+ const done = MARKET_TERMINAL_STATES.has(operation.state);
2106
+ set({
2107
+ operation,
2108
+ installing: !done,
2109
+ errorCode: done && operation.state === "failed" ? marketFailureCode(operation) : null,
2110
+ });
2111
+ if (!done) schedulePoll();
2112
+ };
2113
+ const schedulePoll = () => {
2114
+ if (pollTimer !== null) cancelLater(pollTimer);
2115
+ pollTimer = later(poll, MARKET_POLL_MS);
2116
+ };
2117
+ const poll = async () => {
2118
+ pollTimer = null;
2119
+ const id = state.operation?.operationId;
2120
+ if (disposed || !id) return;
2121
+ try {
2122
+ const { status, body } = await request(`${MARKET_API.operations}?operationId=${encodeURIComponent(id)}`);
2123
+ if (disposed || state.operation?.operationId !== id) return;
2124
+ if (status === 404) {
2125
+ // Operations live in the current Host process only.
2126
+ set({ installing: false, errorCode: "OPERATION_LOST" });
2127
+ return;
2128
+ }
2129
+ if (status !== 200 || !validMarketOperation(body?.operation) || body.operation.operationId !== id) {
2130
+ throw new Error("Invalid market operation");
2131
+ }
2132
+ pollFailures = 0;
2133
+ applyOperation(body.operation);
2134
+ } catch (error) {
2135
+ if (disposed || error?.name === "AbortError") return;
2136
+ if (++pollFailures >= MARKET_MAX_POLL_FAILURES) set({ installing: false, errorCode: "OPERATION_LOST" });
2137
+ else schedulePoll();
2138
+ }
2139
+ };
2140
+
2141
+ return {
2142
+ getState: () => state,
2143
+ subscribe(listener) {
2144
+ listeners.add(listener);
2145
+ return () => listeners.delete(listener);
2146
+ },
2147
+ /** Reads only the local capability document; never contacts a registry. */
2148
+ discover() {
2149
+ if (disposed) return Promise.resolve(state);
2150
+ if (discovering) return discovering;
2151
+ if (state.phase !== "ready") set({ phase: "discovering" });
2152
+ discovering = request(MARKET_API.capabilities)
2153
+ .then(({ ok, body }) => {
2154
+ const usable = ok && body?.schema === MARKET_SCHEMA && body.apiVersion === 1 && body.features?.update === true;
2155
+ set({
2156
+ phase: usable ? "ready" : "unavailable",
2157
+ runtime: usable && typeof body.runtime === "string" ? body.runtime : null,
2158
+ restartSupported: usable && body.features?.restart === true && body.restart?.supported === true,
2159
+ });
2160
+ }, () => {
2161
+ set({ phase: "unavailable", runtime: null, restartSupported: false });
2162
+ })
2163
+ .finally(() => { discovering = null; })
2164
+ .then(() => state);
2165
+ return discovering;
2166
+ },
2167
+ /** Starts one market update operation, then follows it until it settles. */
2168
+ async install({ targetVersion = null, force = false, automatic = false } = {}) {
2169
+ if (disposed || state.phase !== "ready" || state.installing) return state;
2170
+ set({ installing: true, automatic, targetVersion, operation: null, errorCode: null, restartRequested: false });
2171
+ try {
2172
+ const { status, body } = await post(
2173
+ MARKET_API.updates,
2174
+ force ? { packageName: PACKAGE_NAME, force: true } : { packageName: PACKAGE_NAME }
2175
+ );
2176
+ if (disposed) return state;
2177
+ if (status === 202 && validMarketOperation(body?.operation)) {
2178
+ pollFailures = 0;
2179
+ applyOperation(body.operation);
2180
+ return state;
2181
+ }
2182
+ const code = typeof body?.failure?.code === "string" && body.failure.code ? body.failure.code
2183
+ : status === 409 ? "OPERATION_BUSY"
2184
+ : status === 403 ? "UPDATE_FORBIDDEN"
2185
+ : status === 400 ? "UPDATE_REJECTED"
2186
+ : status === 404 ? "MARKET_UNREACHABLE"
2187
+ : "UPDATE_FAILED";
2188
+ set({ installing: false, errorCode: code });
2189
+ } catch (error) {
2190
+ if (!disposed) set({ installing: false, errorCode: error?.name === "AbortError" ? null : "MARKET_UNREACHABLE" });
2191
+ }
2192
+ return state;
2193
+ },
2194
+ /** Only offered when the market reports that it owns process restarts. */
2195
+ async restart() {
2196
+ if (disposed || !state.restartSupported || state.restarting) return state;
2197
+ set({ restarting: true, errorCode: null });
2198
+ try {
2199
+ const { ok } = await post(MARKET_API.restart, {});
2200
+ set({ restarting: false, restartRequested: ok, errorCode: ok ? null : "RESTART_FAILED" });
2201
+ } catch (error) {
2202
+ set({ restarting: false, errorCode: error?.name === "AbortError" ? null : "RESTART_FAILED" });
2203
+ }
2204
+ return state;
2205
+ },
2206
+ dispose() {
2207
+ disposed = true;
2208
+ if (pollTimer !== null) cancelLater(pollTimer);
2209
+ pollTimer = null;
2210
+ for (const controller of controllers) controller.abort();
2211
+ controllers.clear();
2212
+ listeners.clear();
2213
+ },
2214
+ };
2215
+ }
2216
+
2217
+ function readAutoInstallMemory() {
2218
+ try {
2219
+ const raw = JSON.parse(globalThis.localStorage?.getItem(AUTO_INSTALL_MEMORY_KEY) ?? "null");
2220
+ return raw && typeof raw.version === "string" && Number.isFinite(raw.at) ? raw : null;
2221
+ } catch {
2222
+ return null;
2223
+ }
2224
+ }
2225
+
2226
+ function writeAutoInstallMemory(record) {
2227
+ try { globalThis.localStorage?.setItem(AUTO_INSTALL_MEMORY_KEY, JSON.stringify(record)); } catch {}
2228
+ }
2229
+
2230
+ /**
2231
+ * Opt-in automatic installation. The Host's own automatic check finds the
2232
+ * version (channel/region aware); dshmarket installs it. Runs only while a
2233
+ * DSH page is open, reads the local status endpoint only, and tries each
2234
+ * version at most once per {@link AUTO_INSTALL_RETRY_MS} across reloads.
2235
+ */
2236
+ function startAutoInstall(market, io = {}) {
2237
+ const later = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
2238
+ const cancelLater = io.clearTimeout ?? ((id) => clearTimeout(id));
2239
+ const now = io.now ?? (() => Date.now());
2240
+ const memory = io.memory ?? { read: readAutoInstallMemory, write: writeAutoInstallMemory };
2241
+ const controller = new AbortController();
2242
+ const readStatus = io.readStatus ?? ((signal) => requestPluginUpdate(API.updateStatus, undefined, signal));
2243
+ let stopped = false;
2244
+ let timer = null;
2245
+ const unsubscribe = market.subscribe((state) => {
2246
+ const operation = state.operation;
2247
+ if (state.automatic && operation?.state === "succeeded" && typeof state.targetVersion === "string" &&
2248
+ compareVersions(operation.installedVersion, operation.beforeVersion) === 1) {
2249
+ memory.write({ version: state.targetVersion, at: now(), done: true });
2250
+ }
2251
+ });
2252
+ const tick = async () => {
2253
+ timer = null;
2254
+ try {
2255
+ const status = await readStatus(controller.signal);
2256
+ if (stopped) return;
2257
+ const version = status?.latestVersion;
2258
+ if (status?.autoInstall !== true || status.autoCheck !== true || status.available !== true ||
2259
+ status.blockedReason || typeof version !== "string" || compareVersions(version, status.currentVersion) !== 1) return;
2260
+ const last = memory.read();
2261
+ if (last && last.version === version && (last.done === true || now() - last.at < AUTO_INSTALL_RETRY_MS)) return;
2262
+ if (market.getState().phase !== "ready") await market.discover();
2263
+ const snapshot = market.getState();
2264
+ if (stopped || snapshot.phase !== "ready" || snapshot.installing) return;
2265
+ memory.write({ version, at: now() });
2266
+ await market.install({ targetVersion: version, automatic: true });
2267
+ } catch {
2268
+ // Background work never surfaces errors; the card shows the market state.
2269
+ } finally {
2270
+ if (!stopped) timer = later(tick, AUTO_INSTALL_INTERVAL_MS);
2271
+ }
2272
+ };
2273
+ timer = later(tick, AUTO_INSTALL_FIRST_MS);
2274
+ return () => {
2275
+ stopped = true;
2276
+ if (timer !== null) cancelLater(timer);
2277
+ controller.abort();
2278
+ unsubscribe();
2279
+ };
2280
+ }
2281
+
2282
+ let sharedMarket = null;
2283
+
2284
+ // #endregion
2285
+
1065
2286
  /** Update state is isolated: polling never reloads account data or drafts. */
1066
- function PluginUpdatePanel({ t }) {
2287
+ function PluginUpdatePanel({ t, market = null, initiallyOpen = false }) {
1067
2288
  const [update, setUpdate] = useState(null);
1068
2289
  const [loading, setLoading] = useState(true);
1069
2290
  const [busy, setBusy] = useState(null);
@@ -1169,6 +2390,21 @@ window.__ModuleLoader__.load({
1169
2390
  }
1170
2391
  }, []);
1171
2392
 
2393
+ // dshmarket state is shared with the background auto-install runner.
2394
+ const [marketState, setMarketState] = useState(() => market?.getState() ?? null);
2395
+ const [confirmInstall, setConfirmInstall] = useState(null);
2396
+ useEffect(() => {
2397
+ if (!market) return undefined;
2398
+ let live = true;
2399
+ const unsubscribe = market.subscribe((next) => { if (live) setMarketState(next); });
2400
+ setMarketState(market.getState());
2401
+ if (market.getState().phase === "unknown") void market.discover();
2402
+ return () => {
2403
+ live = false;
2404
+ unsubscribe();
2405
+ };
2406
+ }, [market]);
2407
+
1172
2408
  const working = loading || busy !== null || update?.checking === true || update?.downloading === true;
1173
2409
  const blockedReason = update?.blockedReason;
1174
2410
  const downloadBlocked = !!blockedReason && blockedReason !== "linked-install";
@@ -1189,7 +2425,17 @@ window.__ModuleLoader__.load({
1189
2425
  const statusError = update?.error || update?.errorCode
1190
2426
  ? { updateCode: update.errorCode }
1191
2427
  : null;
1192
- const compactState = loading ? "loading" : downloading ? "downloading" : checking ? "checking" :
2428
+ const linkedInstall = blockedReason === "linked-install";
2429
+ const marketReady = marketState?.phase === "ready";
2430
+ const marketOperation = marketState?.operation ?? null;
2431
+ const marketInstalling = marketState?.installing === true;
2432
+ const marketInstalledVersion = marketOperation?.state === "succeeded" &&
2433
+ compareVersions(marketOperation.installedVersion, marketOperation.beforeVersion) === 1
2434
+ ? marketOperation.installedVersion : null;
2435
+ const canMarketInstall = marketReady && !linkedInstall && !downloadBlocked && !marketInstalling &&
2436
+ !marketInstalledVersion && !!update?.available && typeof update.latestVersion === "string" && !!update.latestVersion;
2437
+ const compactState = loading ? "loading" : marketInstalling ? "installing" : downloading ? "downloading" : checking ? "checking" :
2438
+ marketInstalledVersion ? "installed" :
1193
2439
  error || statusError ? "error" : update?.downloadedVersion && update.downloadedVersion === update.latestVersion ? "downloaded" :
1194
2440
  update?.available ? "available" : "idle";
1195
2441
  // Name the actual failure instead of a generic "update error": the
@@ -1202,7 +2448,9 @@ window.__ModuleLoader__.load({
1202
2448
  : failureCode === "service-unavailable" ? "updateCompactStatusUnavailable"
1203
2449
  : failureCode === "channel-unavailable" ? "updateCompactErrorChannel"
1204
2450
  : "updateCompactError";
1205
- const compactText = loading ? t("updateLoading") : downloading ? t("updateDownloading") : checking ? t("updateChecking") :
2451
+ const compactText = loading ? t("updateLoading") : marketInstalling ? t("updateMarketInstalling") :
2452
+ downloading ? t("updateDownloading") : checking ? t("updateChecking") :
2453
+ marketInstalledVersion ? t("updateCompactInstalled").replace("{version}", marketInstalledVersion) :
1206
2454
  error || statusError ? t(compactErrorKey) : compactState === "downloaded"
1207
2455
  ? t("updateCompactDownloaded").replace("{version}", update.downloadedVersion)
1208
2456
  : update?.available ? t("updateCompactAvailable").replace("{version}", availableVersion)
@@ -1229,7 +2477,7 @@ window.__ModuleLoader__.load({
1229
2477
  }, t(key))
1230
2478
  )
1231
2479
  );
1232
- const toggle = (field, label, hint, checked) =>
2480
+ const toggle = (field, label, hint, checked, unavailable = false) =>
1233
2481
  h(
1234
2482
  "div",
1235
2483
  { className: "dcpa-update-row dcpa-switch-row" },
@@ -1243,16 +2491,143 @@ window.__ModuleLoader__.load({
1243
2491
  className: "dcpa-switch",
1244
2492
  "aria-label": t(label),
1245
2493
  "aria-checked": checked,
1246
- disabled: working || !update,
2494
+ // An unavailable feature can still be switched off, never on.
2495
+ disabled: working || !update || unavailable && !checked,
1247
2496
  onClick: () => { void runAction("save", { [field]: !checked }); },
1248
2497
  }, h("span", { className: "dcpa-switch-thumb" }))
1249
2498
  );
1250
2499
 
2500
+ // One-click/automatic installation through dshmarket. Hidden entirely
2501
+ // when the card is rendered without a market client (tests, previews).
2502
+ let marketSection = null;
2503
+ if (market) {
2504
+ const phase = marketState?.phase ?? "unknown";
2505
+ const hintKey = phase === "ready"
2506
+ ? linkedInstall ? "updateMarketLinkedHint" : "updateMarketReadyHint"
2507
+ : phase === "unavailable" ? "updateMarketUnavailableHint" : "updateMarketDetecting";
2508
+ const operation = marketOperation;
2509
+ const progress = operation?.progress;
2510
+ const percent = typeof progress?.percent === "number" && Number.isFinite(progress.percent) ? Math.round(progress.percent) : null;
2511
+ const errorCode = marketState?.errorCode ?? null;
2512
+ const outcome = operation?.outcome ?? {};
2513
+ const target = marketState?.targetVersion;
2514
+ const prefix = marketState?.automatic ? `${t("updateMarketAutomaticPrefix")} ` : "";
2515
+ let result = null;
2516
+ if (marketInstalling) {
2517
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
2518
+ h("p", { className: "dcpa-label" }, prefix + (percent === null
2519
+ ? t("updateMarketInstalling")
2520
+ : t("updateMarketProgress").replace("{percent}", String(percent))))
2521
+ );
2522
+ } else if (marketInstalledVersion) {
2523
+ const restartHint = outcome.restartRequired
2524
+ ? marketState?.restartSupported ? "updateMarketRestartHint"
2525
+ : marketState?.runtime === "desktop" ? "updateMarketRestartDesktopHint" : "updateMarketRestartManualHint"
2526
+ : outcome.refreshRequired ? "updateMarketRefreshHint" : "updateMarketAppliedHint";
2527
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
2528
+ h("p", { className: "dcpa-success" }, prefix + t("updateMarketInstalled").replace("{version}", marketInstalledVersion)),
2529
+ typeof target === "string" && compareVersions(marketInstalledVersion, target) === -1
2530
+ ? h("p", { className: "dcpa-hint" }, t("updateMarketVersionLower").replace("{installed}", marketInstalledVersion).replace("{target}", target))
2531
+ : null,
2532
+ h("p", { className: "dcpa-hint" }, t(marketState?.restartRequested ? "updateMarketRestartRequested" : restartHint)),
2533
+ outcome.restartRequired && marketState?.restartSupported && !marketState?.restartRequested
2534
+ ? h("div", { className: "dcpa-actions" },
2535
+ h("button", {
2536
+ type: "button",
2537
+ className: "dcpa-btn dcpa-btn-primary",
2538
+ disabled: marketState?.restarting === true,
2539
+ onClick: () => { void market.restart(); },
2540
+ }, marketState?.restarting ? t("updateMarketRestarting") : t("updateMarketRestartBtn")))
2541
+ : !outcome.restartRequired && outcome.refreshRequired
2542
+ ? h("div", { className: "dcpa-actions" },
2543
+ h("button", {
2544
+ type: "button",
2545
+ className: "dcpa-btn dcpa-btn-primary",
2546
+ onClick: () => { globalThis.location?.reload(); },
2547
+ }, t("updateMarketRefreshBtn")))
2548
+ : null
2549
+ );
2550
+ } else if (operation?.state === "succeeded") {
2551
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
2552
+ h("p", { className: "dcpa-hint" }, prefix + t("updateMarketUnchanged")));
2553
+ } else if (operation?.state === "cancelled" || operation?.state === "rolled-back") {
2554
+ result = h("div", { className: "dcpa-update-notice", role: "status" },
2555
+ h("p", { className: "dcpa-hint" }, prefix + t(operation.state === "cancelled" ? "updateMarketCancelled" : "updateMarketRolledBack")));
2556
+ }
2557
+ const errorBlock = errorCode && !marketInstalling
2558
+ ? h("div", { className: "dcpa-update-notice" },
2559
+ h("p", { className: "dcpa-error", role: "alert" }, prefix + marketErrorText(t, errorCode)),
2560
+ MARKET_FORCEABLE.has(errorCode) && canMarketInstall
2561
+ ? h("div", { className: "dcpa-actions" },
2562
+ h("button", {
2563
+ type: "button",
2564
+ className: "dcpa-btn",
2565
+ disabled: working,
2566
+ onClick: () => { void market.install({ targetVersion: update.latestVersion, force: true }); },
2567
+ }, t("updateMarketForceBtn")))
2568
+ : null)
2569
+ : null;
2570
+ marketSection = h(React.Fragment, null,
2571
+ h("div", { className: "dcpa-update-row" },
2572
+ h("div", { className: "dcpa-field" },
2573
+ h("span", { className: "dcpa-label" }, t("updateMarketLabel")),
2574
+ h("p", { className: "dcpa-hint" }, t(hintKey))
2575
+ ),
2576
+ phase === "ready" && !linkedInstall
2577
+ ? h("div", { className: "dcpa-actions" },
2578
+ h("button", {
2579
+ type: "button",
2580
+ className: "dcpa-btn dcpa-btn-primary",
2581
+ disabled: working || !canMarketInstall,
2582
+ onClick: () => { setConfirmVersion(null); setConfirmInstall(update.latestVersion); },
2583
+ }, marketInstalling ? t("updateMarketInstalling")
2584
+ : marketInstalledVersion ? t("updateMarketInstalledBtn") : t("updateMarketInstallBtn")))
2585
+ : phase === "unavailable"
2586
+ ? h("div", { className: "dcpa-actions" },
2587
+ h("button", { type: "button", className: "dcpa-btn", onClick: () => { void market.discover(); } }, t("updateMarketRedetectBtn")))
2588
+ : null
2589
+ ),
2590
+ confirmInstall ? h("div", {
2591
+ className: "dcpa-update-notice",
2592
+ role: "alertdialog",
2593
+ "aria-modal": false,
2594
+ "aria-label": t("updateMarketConfirmTitle").replace("{version}", confirmInstall),
2595
+ },
2596
+ h("p", { className: "dcpa-label" }, t("updateMarketConfirmTitle").replace("{version}", confirmInstall)),
2597
+ h("p", { className: "dcpa-hint" }, t("updateMarketConfirmHint")),
2598
+ h("div", { className: "dcpa-actions" },
2599
+ h("button", {
2600
+ type: "button",
2601
+ className: "dcpa-btn dcpa-btn-primary",
2602
+ disabled: working || !canMarketInstall || confirmInstall !== update?.latestVersion,
2603
+ onClick: () => {
2604
+ const version = confirmInstall;
2605
+ setConfirmInstall(null);
2606
+ void market.install({ targetVersion: version });
2607
+ },
2608
+ }, t("updateMarketConfirmBtn")),
2609
+ h("button", { type: "button", className: "dcpa-btn", onClick: () => setConfirmInstall(null) }, t("updateCancelBtn"))
2610
+ ),
2611
+ confirmInstall !== update?.latestVersion ? h("p", { className: "dcpa-hint" }, t("updateStaleVersion")) : null
2612
+ ) : null,
2613
+ result,
2614
+ errorBlock
2615
+ );
2616
+ }
2617
+
1251
2618
  // Native details starts closed. Do not control `open`: polling and saves
1252
2619
  // must preserve the user's expanded/collapsed choice without remounting.
2620
+ // `initiallyOpen` (the update-only plugin page) passes a constant `true`
2621
+ // that never changes, so React never rewrites it after mount and the
2622
+ // user can still collapse the card.
1253
2623
  return h(
1254
2624
  "details",
1255
- { className: "dcpa-card dcpa-update-card", "aria-label": t("updateTitle"), "aria-busy": working },
2625
+ {
2626
+ className: "dcpa-card dcpa-update-card",
2627
+ "aria-label": t("updateTitle"),
2628
+ "aria-busy": working,
2629
+ ...(initiallyOpen ? { open: true } : {}),
2630
+ },
1256
2631
  h("summary", { className: "dcpa-update-summary", title: t("updateToggleDetails") },
1257
2632
  h("span", { className: "dcpa-update-summary-title" }, t("updateTitle")),
1258
2633
  h("span", { className: "dcpa-update-summary-current" },
@@ -1280,9 +2655,9 @@ window.__ModuleLoader__.load({
1280
2655
  }, checking ? t("updateChecking") : !update && !loading ? t("updateRetryBtn") : t("updateCheckBtn")),
1281
2656
  h("button", {
1282
2657
  type: "button",
1283
- className: "dcpa-btn dcpa-btn-primary",
2658
+ className: marketReady && !linkedInstall ? "dcpa-btn" : "dcpa-btn dcpa-btn-primary",
1284
2659
  disabled: working || !canDownload,
1285
- onClick: () => setConfirmVersion(update.latestVersion),
2660
+ onClick: () => { setConfirmInstall(null); setConfirmVersion(update.latestVersion); },
1286
2661
  }, downloading ? t("updateDownloading") : update?.downloadedVersion === update?.latestVersion && update?.downloadedVersion
1287
2662
  ? t("updateDownloadedBtn") : t("updateDownloadBtn"))
1288
2663
  )
@@ -1306,6 +2681,7 @@ window.__ModuleLoader__.load({
1306
2681
  ),
1307
2682
  confirmVersion !== update?.latestVersion ? h("p", { className: "dcpa-hint" }, t("updateStaleVersion")) : null
1308
2683
  ) : null,
2684
+ marketSection,
1309
2685
  h("div", { className: "dcpa-update-row" },
1310
2686
  h("div", { className: "dcpa-field" },
1311
2687
  h("span", { className: "dcpa-label" }, t("updateChannelLabel")),
@@ -1327,6 +2703,8 @@ window.__ModuleLoader__.load({
1327
2703
  ),
1328
2704
  toggle("autoCheck", "updateAutoCheckLabel", "updateAutoCheckHint", update?.autoCheck !== false),
1329
2705
  toggle("autoDownload", "updateAutoDownloadLabel", "updateAutoDownloadHint", update?.autoDownload === true),
2706
+ toggle("autoInstall", "updateAutoInstallLabel", "updateAutoInstallHint", update?.autoInstall === true,
2707
+ !marketReady || linkedInstall || downloadBlocked),
1330
2708
  blockedReason ? h("div", { className: "dcpa-update-notice" }, h("p", { className: "dcpa-hint" }, t(blockedKey))) : null,
1331
2709
  update?.downloadedVersion && update?.downloadPath ? h("div", { className: "dcpa-update-notice", role: "status" },
1332
2710
  h("p", { className: "dcpa-label" }, t(blockedReason === "linked-install" ? "updateLinkedDownloadedHint" : "updateDownloadedHint").replace("{version}", update.downloadedVersion)),
@@ -1351,24 +2729,133 @@ window.__ModuleLoader__.load({
1351
2729
  }
1352
2730
 
1353
2731
  /**
1354
- * Account list + switcher. Each account is edited in its own
1355
- * {@link AccountPanel}, remounted when the selection changes.
2732
+ * The plugin's own page under Plugins (bundle page and Plugins tab): only
2733
+ * plugin updates. Account and model settings live in their own settings
2734
+ * section ({@link CpaSettingsPanel}).
1356
2735
  */
1357
- function CpaSettingsPanel({ t }) {
1358
- const [loading, setLoading] = useState(true);
1359
- const [busy, setBusy] = useState(null); // null | "refresh" | "save" | "add" | "remove"
1360
- const [error, setError] = useState(null);
1361
- const [toast, setToast] = useState(null);
1362
- const [data, setData] = useState(null);
1363
- const [activeId, setActiveId] = useState(null);
1364
- // Bumped whenever the server's saved config should replace the drafts.
1365
- const [rev, setRev] = useState(0);
1366
-
1367
- const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
1368
- const active = accounts.find((a) => a.id === activeId) ?? accounts[0];
2736
+ function PluginUpdatePage({ t }) {
2737
+ return h(
2738
+ "div",
2739
+ { className: "dcpa-wrap", "data-dsh-plugin": MODULE_ID },
2740
+ h("p", { className: "dcpa-hint" }, t("updatePageHint")),
2741
+ h(PluginUpdatePanel, { t, market: sharedMarket, initiallyOpen: true })
2742
+ );
2743
+ }
1369
2744
 
1370
- const fetchStatus = useCallback(async () => {
1371
- try {
2745
+ /**
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.
2841
+ */
2842
+ function CpaSettingsPanel({ t }) {
2843
+ const [loading, setLoading] = useState(true);
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 }
2847
+ const [data, setData] = useState(null);
2848
+ const [activeId, setActiveId] = useState(null);
2849
+ // Bumped whenever the server's saved config should replace the drafts.
2850
+ const [rev, setRev] = useState(0);
2851
+ const [adding, setAdding] = useState(false);
2852
+ const dirtyRef = useRef(false);
2853
+
2854
+ const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
2855
+ const active = accounts.find((a) => a.id === activeId) ?? accounts[0];
2856
+
2857
+ const fetchStatus = useCallback(async () => {
2858
+ try {
1372
2859
  const res = await fetch(API.status, { cache: "no-store" });
1373
2860
  const body = await res.json();
1374
2861
  if (!res.ok || body.ok === false) {
@@ -1376,9 +2863,9 @@ window.__ModuleLoader__.load({
1376
2863
  }
1377
2864
  setData(body);
1378
2865
  setRev((r) => r + 1);
1379
- setError(null);
2866
+ setLoadError(null);
1380
2867
  } catch (err) {
1381
- setError(err instanceof Error ? err.message : String(err));
2868
+ setLoadError(err instanceof Error ? err.message : String(err));
1382
2869
  } finally {
1383
2870
  setLoading(false);
1384
2871
  }
@@ -1388,124 +2875,153 @@ window.__ModuleLoader__.load({
1388
2875
  void fetchStatus();
1389
2876
  }, [fetchStatus]);
1390
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
+
1391
2891
  const run = useCallback(async (kind, fn) => {
1392
2892
  setBusy(kind);
1393
- setError(null);
1394
- setToast(null);
2893
+ setNotice(null);
1395
2894
  try {
1396
2895
  return await fn();
1397
2896
  } catch (err) {
1398
- setError(err instanceof Error ? err.message : String(err));
2897
+ setNotice({ tone: "error", text: err instanceof Error ? err.message : String(err) });
1399
2898
  return undefined;
1400
2899
  } finally {
1401
2900
  setBusy(null);
1402
2901
  }
1403
2902
  }, []);
1404
2903
 
1405
- const onRefresh = useCallback(
1406
- (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) =>
1407
2916
  run("refresh", async () => {
1408
2917
  setData(await postJson(API.refresh, { accountId }));
1409
2918
  }),
1410
- [run]
1411
- );
1412
-
1413
- const onSave = useCallback(
1414
- (accountId, payload) =>
2919
+ save: (accountId, payload) =>
1415
2920
  run("save", async () => {
1416
2921
  setData(await postJson(API.save, { ...payload, accountId }));
2922
+ dirtyRef.current = false;
1417
2923
  setRev((r) => r + 1);
1418
- setToast(t("savedToast"));
2924
+ setNotice({ tone: "ok", text: t("savedToast") });
1419
2925
  }),
1420
- [run, t]
1421
- );
1422
-
1423
- const onAdd = useCallback(
1424
- () =>
1425
- run("add", async () => {
1426
- const body = await postJson(API.addAccount, {});
1427
- setData(body);
1428
- if (body.createdId) setActiveId(body.createdId);
1429
- setRev((r) => r + 1);
1430
- 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 });
1431
2931
  }),
1432
- [run, t]
1433
- );
1434
-
1435
- const onRemove = useCallback(
1436
- (account) => {
2932
+ test: (accountId, draft) =>
2933
+ run("test", async () => (await postJson(API.testAccount, { ...(accountId ? { accountId } : {}), ...draft })).test),
2934
+ remove: (account) => {
1437
2935
  const name = account.config?.displayName || account.id;
1438
- if (typeof window !== "undefined" && !window.confirm(t("removeConfirm").replace("{name}", name))) {
1439
- return;
1440
- }
2936
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(fmt(t, "removeConfirm", { name }))) return;
1441
2937
  void run("remove", async () => {
1442
2938
  const body = await postJson(API.removeAccount, { accountId: account.id });
2939
+ dirtyRef.current = false;
1443
2940
  setData(body);
1444
2941
  setActiveId(body.accounts?.[0]?.id ?? null);
1445
2942
  setRev((r) => r + 1);
1446
- setToast(t("removedToast"));
2943
+ setNotice({ tone: "ok", text: t("removedToast") });
1447
2944
  });
1448
2945
  },
1449
- [run, t]
1450
- );
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);
1451
2964
 
1452
2965
  return h(
1453
2966
  "div",
1454
- { className: "dcpa-wrap", "data-dsh-plugin": MODULE_ID },
2967
+ { className: "dcpa-wrap dcpa-q", "data-dsh-plugin": MODULE_ID },
1455
2968
  h(
1456
2969
  "div",
1457
- { className: "dcpa-card" },
1458
- h(
1459
- "div",
1460
- { className: "dcpa-head" },
1461
- h(
1462
- "div",
1463
- null,
1464
- h("h3", { className: "dcpa-title" }, t("title")),
1465
- h("p", { className: "dcpa-subtitle" }, t("subtitle"))
1466
- )
1467
- ),
1468
- h(
1469
- "div",
1470
- { className: "dcpa-account-bar", role: "tablist", "aria-label": t("accountsLabel") },
1471
- accounts.map((a) =>
1472
- h(
1473
- "button",
1474
- {
1475
- key: a.id,
1476
- type: "button",
1477
- role: "tab",
1478
- className: "dcpa-account-chip",
1479
- "aria-selected": active?.id === a.id,
1480
- "data-active": active?.id === a.id ? "true" : "false",
1481
- "data-enabled": a.config?.enabled === false ? "false" : "true",
1482
- title: a.config?.provider,
1483
- onClick: () => setActiveId(a.id),
1484
- },
1485
- h("span", { className: stateDotClass(a.status?.state) }),
1486
- a.config?.displayName || a.id,
1487
- a.config?.enabled === false
1488
- ? h("span", { className: "dcpa-badge" }, t("accountOffBadge"))
1489
- : 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
+ )
1490
2991
  )
1491
- ),
1492
- h(
1493
- "button",
1494
- {
1495
- type: "button",
1496
- className: "dcpa-btn dcpa-btn-sm",
1497
- disabled: busy !== null || loading,
1498
- 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);
1499
3005
  },
1500
- busy === "add" ? t("addingAccountBtn") : t("addAccountBtn")
1501
- )
1502
- ),
1503
- !loading && accounts.length === 0 ? h("p", { className: "dcpa-hint" }, t("noAccounts")) : null,
1504
- error ? h("p", { className: "dcpa-error", role: "alert" }, error) : null,
1505
- toast ? h("p", { className: "dcpa-success", role: "status" }, toast) : null
3006
+ },
3007
+ t("addAccountBtn")
3008
+ )
1506
3009
  ),
1507
- h(PluginUpdatePanel, { t }),
1508
- 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
1509
3025
  ? h(AccountPanel, {
1510
3026
  key: active.id,
1511
3027
  t,
@@ -1513,18 +3029,105 @@ window.__ModuleLoader__.load({
1513
3029
  global: data?.global ?? {},
1514
3030
  rev,
1515
3031
  busy,
1516
- onRefresh: () => onRefresh(active.id),
1517
- onSave: (payload) => onSave(active.id, payload),
1518
- onRemove: () => onRemove(active),
3032
+ notice,
3033
+ actions,
3034
+ onDirtyChange: (dirty) => {
3035
+ dirtyRef.current = dirty;
3036
+ },
1519
3037
  })
1520
3038
  : null
1521
3039
  );
1522
3040
  }
1523
3041
 
1524
- 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 }) {
1525
3124
  const syncing = busy === "refresh";
1526
3125
  const saving = busy === "save";
1527
- 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);
1528
3131
 
1529
3132
  // Model filter UI state
1530
3133
  const [search, setSearch] = useState("");
@@ -1537,7 +3140,6 @@ window.__ModuleLoader__.load({
1537
3140
  const [editingId, setEditingId] = useState(null);
1538
3141
 
1539
3142
  // Form draft state
1540
- const [enabled, setEnabled] = useState(true);
1541
3143
  const [baseURL, setBaseURL] = useState("");
1542
3144
  const [apiKeyDraft, setApiKeyDraft] = useState("");
1543
3145
  // "auto" or the protocol every model is fixed to.
@@ -1550,20 +3152,32 @@ window.__ModuleLoader__.load({
1550
3152
  const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useState("300");
1551
3153
  const [probeTimeoutMs, setProbeTimeoutMs] = useState("5000");
1552
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
+
1553
3166
  // Load the drafts from the saved config on mount and after every save.
1554
3167
  useEffect(() => {
1555
3168
  const c = account.config ?? {};
1556
- setEnabled(c.enabled !== false);
1557
- setBaseURL(c.baseURL ?? "");
1558
- setProtocol(c.protocolMode === "fixed" ? c.protocol ?? "openai-completions" : "auto");
1559
- setProvider(c.provider ?? "cli-proxy");
1560
- setDisplayName(c.displayName ?? "CLI Proxy API");
1561
- setApiKeyEnv(c.effectiveApiKeyEnv ?? c.apiKeyEnv ?? "CLI_PROXY_API_KEY");
1562
- setIncludePrefix(c.includePrefix ?? "");
1563
- setExcludePrefix(c.excludePrefix ?? "");
1564
- 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);
1565
3178
  setProbeTimeoutMs(String(global?.probeTimeoutMs ?? 5000));
1566
3179
  setApiKeyDraft("");
3180
+ setTestResult(null);
1567
3181
 
1568
3182
  const sel = Array.isArray(c.selectedModels)
1569
3183
  ? c.selectedModels
@@ -1579,53 +3193,46 @@ window.__ModuleLoader__.load({
1579
3193
  // eslint-disable-next-line react-hooks/exhaustive-deps
1580
3194
  }, [rev]);
1581
3195
 
1582
- const onRefreshNow = onRefresh;
1583
-
1584
- const onSave = useCallback(
1585
- (clearKey = false) =>
1586
- saveAccount({
1587
- enabled,
1588
- baseURL: baseURL.trim(),
1589
- protocolMode: protocol === "auto" ? "auto" : "fixed",
1590
- ...(protocol === "auto" ? {} : { protocol }),
1591
- provider: provider.trim() || account.config?.provider || "cli-proxy",
1592
- displayName: displayName.trim() || account.config?.displayName || "CLI Proxy API",
1593
- apiKeyEnv: apiKeyEnv.trim() || account.config?.apiKeyEnv || "CLI_PROXY_API_KEY",
1594
- includePrefix: includePrefix.trim(),
1595
- excludePrefix: excludePrefix.trim(),
1596
- // Before the first catalog is seen, an empty draft is not a choice:
1597
- // leave the selection unset so the first probe can recommend one.
1598
- ...(selectedIds.size > 0 || (account.status?.allModels?.length ?? 0) > 0
1599
- ? { selectedModels: [...selectedIds] }
1600
- : {}),
1601
- modelOverrides,
1602
- refreshIntervalSeconds: Math.max(15, Number(refreshIntervalSeconds) || 300),
1603
- probeTimeoutMs: Math.max(250, Number(probeTimeoutMs) || 5000),
1604
- ...(apiKeyDraft.trim() !== "" ? { apiKey: apiKeyDraft.trim() } : {}),
1605
- ...(clearKey ? { clearApiKey: true } : {}),
1606
- }),
1607
- [
1608
- saveAccount,
1609
- account,
1610
- selectedIds,
1611
- modelOverrides,
1612
- enabled,
1613
- baseURL,
1614
- protocol,
1615
- provider,
1616
- displayName,
1617
- apiKeyEnv,
1618
- includePrefix,
1619
- excludePrefix,
1620
- refreshIntervalSeconds,
1621
- probeTimeoutMs,
1622
- apiKeyDraft,
1623
- ]
1624
- );
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
+ });
1625
3231
 
1626
3232
  const st = account.status ?? { state: "idle", modelCount: 0, models: [], allModels: [] };
1627
3233
  const cred = account.credential ?? { configured: false, ref: apiKeyEnv };
1628
3234
  const effectiveUrl = account.config?.effectiveBaseURL || "";
3235
+ const enabled = account.config?.enabled !== false;
1629
3236
 
1630
3237
  const allModels = useMemo(() => {
1631
3238
  if (Array.isArray(st.allModels) && st.allModels.length > 0) return st.allModels;
@@ -1660,9 +3267,25 @@ window.__ModuleLoader__.load({
1660
3267
 
1661
3268
  const currentIdsKey = useMemo(() => [...selectedIds].sort().join("\0"), [selectedIds]);
1662
3269
  const currentOverridesKey = useMemo(() => overridesKey(modelOverrides), [modelOverrides]);
1663
- const selectionDirty =
1664
- (savedIdsKey !== "" && currentIdsKey !== savedIdsKey) ||
1665
- 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]);
1666
3289
 
1667
3290
  const toggleModel = useCallback((id) => {
1668
3291
  setSelectedIds((prev) => {
@@ -1708,18 +3331,10 @@ window.__ModuleLoader__.load({
1708
3331
  };
1709
3332
  const merged = { ...current, ...patch };
1710
3333
  const normalized = {};
1711
- if (
1712
- Number.isInteger(merged.contextWindow) &&
1713
- merged.contextWindow > 0 &&
1714
- merged.contextWindow !== discovered.contextWindow
1715
- ) {
3334
+ if (Number.isInteger(merged.contextWindow) && merged.contextWindow > 0 && merged.contextWindow !== discovered.contextWindow) {
1716
3335
  normalized.contextWindow = merged.contextWindow;
1717
3336
  }
1718
- if (
1719
- Number.isInteger(merged.maxTokens) &&
1720
- merged.maxTokens > 0 &&
1721
- merged.maxTokens !== discovered.maxTokens
1722
- ) {
3337
+ if (Number.isInteger(merged.maxTokens) && merged.maxTokens > 0 && merged.maxTokens !== discovered.maxTokens) {
1723
3338
  normalized.maxTokens = merged.maxTokens;
1724
3339
  }
1725
3340
  if (merged.image !== discovered.image) normalized.image = merged.image === true;
@@ -1735,9 +3350,7 @@ window.__ModuleLoader__.load({
1735
3350
 
1736
3351
  // The protocol a model would use with the panel's current draft settings.
1737
3352
  const effectiveProtocol = useCallback(
1738
- (m) =>
1739
- modelOverrides[m.id]?.protocol ??
1740
- (protocol === "auto" ? m.nativeProtocol ?? "openai-completions" : protocol),
3353
+ (m) => modelOverrides[m.id]?.protocol ?? (protocol === "auto" ? m.nativeProtocol ?? "openai-completions" : protocol),
1741
3354
  [modelOverrides, protocol]
1742
3355
  );
1743
3356
  const protocolName = (p) => t(PROTOCOL_NAME_KEYS[p] ?? "protocolNameCompletions");
@@ -1750,170 +3363,224 @@ window.__ModuleLoader__.load({
1750
3363
  });
1751
3364
  }, []);
1752
3365
 
1753
- const dotClass = stateDotClass(st.state);
3366
+ const quotaSnap = useQuota(sharedQuota);
3367
+ const quotaFloor = accountQuotaFloor(quotaSnap.data, account.id);
1754
3368
 
1755
- const stateLabel =
1756
- st.state === "connected"
1757
- ? t("statusConnected")
1758
- : st.state === "probing"
1759
- ? t("statusProbing")
1760
- : st.state === "error"
1761
- ? t("statusError")
1762
- : st.state === "disabled"
1763
- ? t("statusDisabled")
1764
- : t("statusIdle");
3369
+ const discard = () => {
3370
+ setTestResult(null);
3371
+ actions.discard();
3372
+ };
1765
3373
 
1766
- return h(
1767
- React.Fragment,
1768
- null,
1769
- // 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" },
1770
3395
  h(
1771
- "div",
1772
- { 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}` },
1773
3408
  h(
1774
3409
  "div",
1775
- { className: "dcpa-head" },
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
+ }),
1776
3421
  h(
1777
- "div",
1778
- null,
1779
- h("h4", { className: "dcpa-section-title" }, account.config?.displayName || account.id),
1780
- h(
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")
3425
+ )
3426
+ ),
3427
+ testResult
3428
+ ? h(
1781
3429
  "p",
1782
- { className: "dcpa-hint" },
1783
- `${t("providerIdLabel")}: ${account.config?.provider ?? ""}`
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 })
1784
3434
  )
1785
- ),
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}` },
3440
+ h(
3441
+ "div",
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) })
3496
+ ),
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
+ )
3524
+ ),
3525
+ h(
3526
+ FieldRow,
3527
+ { t, label: t("refreshIntervalLabel"), htmlFor: `dcpa-interval-${account.id}` },
3528
+ h(
3529
+ "div",
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
+ );
3543
+
3544
+ const modelsTab = h(
3545
+ "div",
3546
+ { className: "dcpa-tabbody dcpa-models" },
3547
+ h("p", { className: "dcpa-q-sub" }, t("modelsEmptyRule")),
3548
+ // Search + Segmented Filter + Batch Actions
3549
+ h(
3550
+ "div",
3551
+ { className: "dcpa-toolbar" },
1786
3552
  h(
1787
3553
  "div",
1788
- { className: "dcpa-actions" },
3554
+ { className: "dcpa-seg" },
1789
3555
  h(
1790
3556
  "button",
1791
3557
  {
1792
3558
  type: "button",
1793
- className: "dcpa-btn",
1794
- disabled: syncing || saving || loading,
1795
- onClick: onRefreshNow,
3559
+ className: "dcpa-seg-btn",
3560
+ "data-active": tabFilter === "all" ? "true" : "false",
3561
+ onClick: () => setTabFilter("all"),
1796
3562
  },
1797
- syncing ? t("refreshingBtn") : t("refreshBtn")
3563
+ `${t("filterAll")} (${allModels.length})`
1798
3564
  ),
1799
3565
  h(
1800
3566
  "button",
1801
3567
  {
1802
3568
  type: "button",
1803
- className: "dcpa-btn dcpa-btn-primary",
1804
- disabled: saving || syncing || loading,
1805
- onClick: () => onSave(false),
3569
+ className: "dcpa-seg-btn",
3570
+ "data-active": tabFilter === "selected" ? "true" : "false",
3571
+ onClick: () => setTabFilter("selected"),
1806
3572
  },
1807
- saving ? t("savingBtn") : t("saveBtn")
1808
- )
1809
- )
1810
- ),
1811
- h(
1812
- "div",
1813
- { className: "dcpa-status-bar" },
1814
- h(
1815
- "span",
1816
- { className: "dcpa-status-pill" },
1817
- h("span", { className: dotClass }),
1818
- stateLabel
1819
- ),
1820
- h(
1821
- "span",
1822
- { className: "dcpa-meta-item" },
1823
- `${t("endpointLabel")}:`,
3573
+ `${t("filterSelected")} (${selectedIds.size})`
3574
+ ),
1824
3575
  h(
1825
- "span",
1826
- { className: "dcpa-meta-val" },
1827
- st.endpoint || effectiveUrl || t("endpointNone")
1828
- )
1829
- ),
1830
- h(
1831
- "span",
1832
- { className: "dcpa-meta-item" },
1833
- `${t("modelsCountLabel")}:`,
1834
- h(
1835
- "span",
1836
- { className: "dcpa-meta-val" },
1837
- `${st.modelCount ?? 0} / ${st.totalDiscovered ?? allModels.length}`
1838
- )
1839
- )
1840
- ),
1841
- account.config?.enabled === false ? h("p", { className: "dcpa-hint" }, t("disabledHint")) : null,
1842
- st.error ? h("p", { className: "dcpa-error", role: "alert" }, st.error) : null
1843
- ),
1844
-
1845
- // 2. Interactive Model Selection Filter Card
1846
- h(
1847
- "div",
1848
- { className: "dcpa-card" },
1849
- h(
1850
- "div",
1851
- { className: "dcpa-head" },
1852
- h(
1853
- "div",
1854
- null,
1855
- h(
1856
- "h4",
1857
- { className: "dcpa-section-title" },
1858
- `${t("sectionModels")} (${selectedIds.size} / ${allModels.length})`
1859
- ),
1860
- h("p", { className: "dcpa-hint" }, t("sectionModelsHint"))
1861
- ),
1862
- h(
1863
- "div",
1864
- { className: "dcpa-actions" },
1865
- selectionDirty
1866
- ? h("span", { className: "dcpa-badge dcpa-badge-warn" }, t("unsavedChanges"))
1867
- : null,
1868
- h(
1869
- "button",
1870
- {
1871
- type: "button",
1872
- className: "dcpa-btn dcpa-btn-primary dcpa-btn-sm",
1873
- disabled: saving || syncing,
1874
- onClick: () => onSave(false),
1875
- },
1876
- saving ? t("savingBtn") : t("saveSelectionBtn")
1877
- )
1878
- )
1879
- ),
1880
-
1881
- // Search + Segmented Filter + Batch Actions
1882
- h(
1883
- "div",
1884
- { className: "dcpa-toolbar" },
1885
- h(
1886
- "div",
1887
- { className: "dcpa-seg" },
1888
- h(
1889
- "button",
1890
- {
1891
- type: "button",
1892
- className: "dcpa-seg-btn",
1893
- "data-active": tabFilter === "all" ? "true" : "false",
1894
- onClick: () => setTabFilter("all"),
1895
- },
1896
- `${t("filterAll")} (${allModels.length})`
1897
- ),
1898
- h(
1899
- "button",
1900
- {
1901
- type: "button",
1902
- className: "dcpa-seg-btn",
1903
- "data-active": tabFilter === "selected" ? "true" : "false",
1904
- onClick: () => setTabFilter("selected"),
1905
- },
1906
- `${t("filterSelected")} (${selectedIds.size})`
1907
- ),
1908
- h(
1909
- "button",
1910
- {
1911
- type: "button",
1912
- className: "dcpa-seg-btn",
1913
- "data-active": tabFilter === "unselected" ? "true" : "false",
1914
- onClick: () => setTabFilter("unselected"),
1915
- },
1916
- `${t("filterUnselected")} (${Math.max(0, allModels.length - selectedIds.size)})`
3576
+ "button",
3577
+ {
3578
+ type: "button",
3579
+ className: "dcpa-seg-btn",
3580
+ "data-active": tabFilter === "unselected" ? "true" : "false",
3581
+ onClick: () => setTabFilter("unselected"),
3582
+ },
3583
+ `${t("filterUnselected")} (${Math.max(0, allModels.length - selectedIds.size)})`
1917
3584
  )
1918
3585
  ),
1919
3586
  h(
@@ -1943,11 +3610,11 @@ window.__ModuleLoader__.load({
1943
3610
  type: "button",
1944
3611
  className: "dcpa-btn dcpa-btn-sm",
1945
3612
  disabled: recommended.length === 0,
1946
- title: t("selectRecommendedHint"),
1947
3613
  onClick: resetRecommended,
1948
3614
  },
1949
3615
  `${t("selectRecommendedBtn")} (${recommended.length})`
1950
- )
3616
+ ),
3617
+ h(InfoTip, { t, text: t("selectRecommendedHint") })
1951
3618
  )
1952
3619
  ),
1953
3620
 
@@ -2205,7 +3872,7 @@ window.__ModuleLoader__.load({
2205
3872
  `${t("modelProtocolHint")}${protocolName(m.nativeProtocol ?? "openai-completions")}`
2206
3873
  )
2207
3874
  ),
2208
- h("p", { className: "dcpa-hint" }, t("paramsHint")),
3875
+ h("div", { className: "dcpa-q-sub" }, t("paramsHintShort"), h(InfoTip, { t, text: t("paramsHint") })),
2209
3876
  hasOverride
2210
3877
  ? h(
2211
3878
  "div",
@@ -2226,22 +3893,30 @@ window.__ModuleLoader__.load({
2226
3893
  );
2227
3894
  })
2228
3895
  )
2229
- ),
3896
+ );
2230
3897
 
2231
- // 3. Connection & Auth Card
3898
+ return h(
3899
+ "div",
3900
+ { className: "dcpa-account" },
2232
3901
  h(
2233
3902
  "div",
2234
- { className: "dcpa-card" },
2235
- h("h4", { className: "dcpa-section-title" }, t("sectionConnection")),
3903
+ { className: "dcpa-acct-head" },
2236
3904
  h(
2237
3905
  "div",
2238
- { className: "dcpa-switch-row" },
3906
+ { className: "dcpa-q-row-main" },
3907
+ h("h3", { className: "dcpa-q-h" }, account.config?.displayName || account.id),
2239
3908
  h(
2240
3909
  "div",
2241
- null,
2242
- h("div", { className: "dcpa-label" }, t("enabledLabel")),
2243
- h("p", { className: "dcpa-hint" }, t("enabledHint"))
2244
- ),
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") }),
2245
3920
  h(
2246
3921
  "button",
2247
3922
  {
@@ -2250,249 +3925,1292 @@ window.__ModuleLoader__.load({
2250
3925
  className: "dcpa-switch",
2251
3926
  "aria-checked": enabled,
2252
3927
  "aria-label": t("enabledLabel"),
2253
- onClick: () => setEnabled(!enabled),
3928
+ disabled: busy !== null,
3929
+ onClick: () => void actions.patch(account.id, { enabled: !enabled }, enabled ? t("disabledToast") : t("enabledToast")),
2254
3930
  },
2255
3931
  h("span", { className: "dcpa-switch-thumb" })
2256
- )
2257
- ),
2258
- h(
2259
- "div",
2260
- { className: "dcpa-field" },
2261
- h("label", { className: "dcpa-label" }, t("baseUrlLabel")),
2262
- h("input", {
2263
- type: "text",
2264
- className: "dcpa-input",
2265
- value: baseURL,
2266
- placeholder: effectiveUrl || t("baseUrlPlaceholder"),
2267
- onChange: (e) => setBaseURL(e.target.value),
2268
- }),
2269
- h("p", { className: "dcpa-hint" }, t("baseUrlHint"))
2270
- ),
2271
- h(
2272
- "div",
2273
- { className: "dcpa-field" },
2274
- h(
2275
- "div",
2276
- { className: "dcpa-label-row" },
2277
- h("label", { className: "dcpa-label" }, `${t("apiKeyLabel")} (${cred.ref || apiKeyEnv})`),
2278
- h(
2279
- "span",
2280
- { className: cred.configured ? "dcpa-badge dcpa-badge-ok" : "dcpa-badge" },
2281
- cred.configured ? t("apiKeyConfiguredBadge") : t("apiKeyUnsetBadge")
2282
- )
2283
3932
  ),
2284
- h("input", {
2285
- type: "password",
2286
- className: "dcpa-input",
2287
- autoComplete: "new-password",
2288
- value: apiKeyDraft,
2289
- placeholder: cred.configured ? t("apiKeySetPlaceholder") : t("apiKeyUnsetPlaceholder"),
2290
- onChange: (e) => setApiKeyDraft(e.target.value),
2291
- }),
2292
- h("p", { className: "dcpa-hint" }, t("apiKeyHint"))
2293
- ),
2294
- h(
2295
- "div",
2296
- { className: "dcpa-grid" },
2297
3933
  h(
2298
- "div",
2299
- { className: "dcpa-field" },
2300
- h("label", { className: "dcpa-label" }, t("protocolLabel")),
3934
+ "span",
3935
+ { className: "dcpa-menu", ref: menuRef },
2301
3936
  h(
2302
- "select",
3937
+ "button",
2303
3938
  {
2304
- className: "dcpa-select",
2305
- value: protocol,
2306
- 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),
2307
3945
  },
2308
- h("option", { value: "auto" }, t("protocolAuto")),
2309
- PROTOCOLS.map((p) =>
2310
- h("option", { key: p, value: p }, `${t("protocolFixedPrefix")}${protocolName(p)}`)
2311
- )
3946
+ "⋯"
2312
3947
  ),
2313
- h("p", { className: "dcpa-hint" }, t("protocolHint"))
2314
- ),
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
3983
+ )
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]) =>
2315
3992
  h(
3993
+ "button",
3994
+ {
3995
+ key: id,
3996
+ id: `dcpa-tab-${account.id}-${id}`,
3997
+ type: "button",
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),
4003
+ },
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(
2316
4015
  "div",
2317
- { className: "dcpa-field" },
2318
- h("label", { className: "dcpa-label" }, t("displayNameLabel")),
2319
- h("input", {
2320
- type: "text",
2321
- className: "dcpa-input",
2322
- value: displayName,
2323
- onChange: (e) => setDisplayName(e.target.value),
2324
- }),
2325
- h("p", { className: "dcpa-hint" }, t("displayNameHint"))
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
2326
4049
  )
2327
- ),
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),
2328
4563
  h(
2329
- "details",
2330
- null,
2331
- h(
2332
- "summary",
2333
- { style: { cursor: "pointer", fontSize: "12px", fontWeight: 500 } },
2334
- t("sectionAdvanced")
2335
- ),
2336
- 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(
2337
4580
  "div",
2338
- { style: { display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" } },
2339
- h(
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(
2340
4609
  "div",
2341
- { className: "dcpa-grid" },
4610
+ { key: `${m.route}:${m.id}`, className: "dcpa-q-map-row" },
4611
+ h("span", { className: "dcpa-q-map-model" }, m.id),
2342
4612
  h(
2343
- "div",
2344
- { className: "dcpa-field" },
2345
- h("label", { className: "dcpa-label" }, t("includePrefixLabel")),
2346
- h("input", {
2347
- type: "text",
2348
- className: "dcpa-input",
2349
- value: includePrefix,
2350
- placeholder: t("includePrefixPlaceholder"),
2351
- onChange: (e) => setIncludePrefix(e.target.value),
2352
- }),
2353
- h("p", { className: "dcpa-hint" }, t("includePrefixHint"))
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
2354
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"))
4742
+ ),
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(
4750
+ "button",
4751
+ {
4752
+ key: a.accountId,
4753
+ type: "button",
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
+ },
4764
+ },
4765
+ a.displayName || a.accountId,
4766
+ a.keyConfigured ? null : h("span", { className: "dcpa-q-seg-note" }, t("qKeyStatusUnset"))
4767
+ )
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" },
2355
4779
  h(
2356
4780
  "div",
2357
- { className: "dcpa-field" },
2358
- h("label", { className: "dcpa-label" }, t("excludePrefixLabel")),
2359
- h("input", {
2360
- type: "text",
2361
- className: "dcpa-input",
2362
- value: excludePrefix,
2363
- placeholder: t("excludePrefixPlaceholder"),
2364
- onChange: (e) => setExcludePrefix(e.target.value),
2365
- }),
2366
- h("p", { className: "dcpa-hint" }, t("excludePrefixHint"))
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 ?? "" }) })
2367
4784
  )
2368
4785
  ),
2369
4786
  h(
2370
4787
  "div",
2371
- { className: "dcpa-grid" },
4788
+ { className: "dcpa-q-actions" },
2372
4789
  h(
2373
- "div",
2374
- { className: "dcpa-field" },
2375
- h("label", { className: "dcpa-label" }, t("providerIdLabel")),
2376
- h("input", {
2377
- type: "text",
2378
- className: "dcpa-input",
2379
- value: provider,
2380
- onChange: (e) => setProvider(e.target.value),
2381
- }),
2382
- h("p", { className: "dcpa-hint" }, t("providerIdHint"))
2383
- ),
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")),
2384
4836
  h(
2385
4837
  "div",
2386
- { className: "dcpa-field" },
2387
- h("label", { className: "dcpa-label" }, t("apiKeyEnvLabel")),
2388
- h("input", {
2389
- type: "text",
2390
- className: "dcpa-input",
2391
- value: apiKeyEnv,
2392
- onChange: (e) => setApiKeyEnv(e.target.value),
2393
- }),
2394
- h("p", { className: "dcpa-hint" }, t("apiKeyEnvHint"))
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")}` : ""
2395
4845
  )
2396
4846
  ),
2397
4847
  h(
2398
4848
  "div",
2399
- { className: "dcpa-grid" },
4849
+ { className: "dcpa-q-actions" },
2400
4850
  h(
2401
- "div",
2402
- { className: "dcpa-field" },
2403
- h("label", { className: "dcpa-label" }, t("refreshIntervalLabel")),
2404
- h("input", {
2405
- type: "number",
2406
- className: "dcpa-input",
2407
- value: refreshIntervalSeconds,
2408
- onChange: (e) => setRefreshIntervalSeconds(e.target.value),
2409
- })
4851
+ "button",
4852
+ { type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy || refreshing, onClick: () => void store.refresh(account.accountId) },
4853
+ t("qRefresh")
2410
4854
  )
2411
4855
  )
2412
4856
  )
2413
- ),
2414
- h(
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(
2415
4999
  "div",
2416
- { className: "dcpa-actions" },
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
+ },
2417
5006
  h(
2418
- "button",
2419
- {
2420
- type: "button",
2421
- className: "dcpa-btn dcpa-btn-primary",
2422
- disabled: saving || syncing,
2423
- onClick: () => onSave(false),
2424
- },
2425
- saving ? t("savingBtn") : t("saveBtn")
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
+ )
2426
5031
  ),
2427
- cred.configured
2428
- ? h(
2429
- "button",
2430
- {
2431
- type: "button",
2432
- className: "dcpa-btn",
2433
- disabled: saving || syncing,
2434
- onClick: () => onSave(true),
2435
- },
2436
- t("clearKeyBtn")
2437
- )
2438
- : null,
2439
- h("span", { style: { flex: 1 } }),
2440
5032
  h(
2441
- "button",
2442
- {
2443
- type: "button",
2444
- className: "dcpa-btn dcpa-btn-danger",
2445
- disabled: saving || syncing || loading,
2446
- onClick: onRemove,
2447
- },
2448
- t("removeAccountBtn")
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
+ )
2449
5046
  )
2450
5047
  )
2451
- )
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
2452
5070
  );
2453
5071
  }
2454
5072
 
5073
+ // #endregion
5074
+
2455
5075
  const inject = ["slots", "locale"];
2456
5076
 
2457
5077
  function apply(ctx) {
2458
5078
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-cliproxyapi: dictionaries");
2459
5079
  const t = ctx.locale.bind(NS);
2460
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.
2461
5084
  if (typeof document !== "undefined") {
2462
- const styleId = "dsh-cliproxyapi-styles";
2463
- let styleEl = document.getElementById(styleId);
2464
- if (!styleEl) {
2465
- styleEl = document.createElement("style");
2466
- styleEl.id = styleId;
5085
+ ctx.effect(() => {
5086
+ const styleEl = document.createElement("style");
2467
5087
  styleEl.dataset.plugin = MODULE_ID;
2468
5088
  styleEl.textContent = CSS;
2469
5089
  document.head.appendChild(styleEl);
2470
- } else {
2471
- styleEl.textContent = CSS;
5090
+ return () => styleEl.remove();
5091
+ }, "dsh-cliproxyapi: styles");
5092
+ }
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
+ }
2472
5108
  }
2473
- ctx.effect(
2474
- () => () => {
2475
- styleEl?.remove();
2476
- },
2477
- "dsh-cliproxyapi: styles"
2478
- );
5109
+ };
5110
+
5111
+ // dshmarket installation client, shared by the card and the opt-in
5112
+ // background auto-install runner. No DOM writes; timers and requests
5113
+ // are disposed with this activation (HMR/unmount).
5114
+ ctx.effect(() => {
5115
+ const market = createMarketUpdater();
5116
+ sharedMarket = market;
5117
+ const stopAutoInstall = startAutoInstall(market);
5118
+ return () => {
5119
+ stopAutoInstall();
5120
+ market.dispose();
5121
+ if (sharedMarket === market) sharedMarket = null;
5122
+ };
5123
+ }, "dsh-cliproxyapi: market updates");
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
+ });
2479
5178
  }
2480
5179
 
2481
- // 1. Register on the Bundle detail page in Sidebar -> Plugins -> CLIProxyAPI
5180
+ // 1. Accounts and models: a page of its own in the Settings dialog's left
5181
+ // navigation (like dshmarket's "Market" section). The slot owner gives
5182
+ // unregistered ids the default nav glyph; no DOM is touched here.
5183
+ ctx.slots.inject("settings.section", () =>
5184
+ registerListEntry(
5185
+ ctx.slots,
5186
+ {
5187
+ name: "settings.section",
5188
+ id: SETTINGS_SECTION_ID,
5189
+ order: 45,
5190
+ label: () => t("nav"),
5191
+ locale: NS,
5192
+ },
5193
+ () => h(CpaSectionPage, { t })
5194
+ )
5195
+ );
5196
+
5197
+ // 2. Plugin updates only, on the bundle page in Sidebar -> Plugins.
2482
5198
  ctx.slots.inject("plugins.bundle.config", () =>
2483
- ctx.slots.register(
5199
+ registerListEntry(
5200
+ ctx.slots,
2484
5201
  {
2485
5202
  name: "plugins.bundle.config",
2486
5203
  key: MODULE_ID,
2487
5204
  locale: NS,
2488
5205
  },
2489
- (ownerProps = {}) => (ownerProps.view === "summary" ? null : h(CpaSettingsPanel, { t }))
5206
+ (ownerProps = {}) => (ownerProps.view === "summary" ? null : h(PluginUpdatePage, { t }))
2490
5207
  )
2491
5208
  );
2492
5209
 
2493
- // 2. Register as a dedicated tab in Settings -> Built-in plugins -> CLIProxyAPI
5210
+ // 3. Plugin updates only, as a tab in Settings -> Plugins.
2494
5211
  ctx.slots.inject("settings.plugins.tab", () =>
2495
- ctx.slots.register(
5212
+ registerListEntry(
5213
+ ctx.slots,
2496
5214
  {
2497
5215
  name: "settings.plugins.tab",
2498
5216
  id: NS,
@@ -2500,7 +5218,7 @@ window.__ModuleLoader__.load({
2500
5218
  label: () => t("nav"),
2501
5219
  locale: NS,
2502
5220
  },
2503
- () => h(CpaSettingsPanel, { t })
5221
+ () => h(PluginUpdatePage, { t })
2504
5222
  )
2505
5223
  );
2506
5224
  }