dsh-llm-adapters 1.2.0 → 1.2.2

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/client.js CHANGED
@@ -1,1733 +1,1802 @@
1
- /**
2
- * dsh-llm-adapters — browser half.
3
- *
4
- * One settings section per provider. Each follows the same shape as
5
- * dsh-agent-sdk-bridge: an account card first, then the live catalog with per
6
- * model on/off and context-window controls, then a collapsed advanced area.
7
- *
8
- * The account card is read-only by design. It never asks for a password or a
9
- * token; sign-in happens on the provider's own page (a WeChat QR code for Loomy
10
- * and Raccoon, a dedicated browser window for Trae), and the card only reports
11
- * the resulting account, quota and check-in state.
12
- *
13
- * Trae is one card with a region switch: `traeRegion` is a setting, not a
14
- * second brand, so both regions share this page and keep separate accounts.
15
- *
16
- * Hand-written ModuleLoader bundle: no build step, no dependency beyond the
17
- * `react` the shell provides.
18
- */
19
-
20
- window.__ModuleLoader__.load({
21
- id: 'dsh-llm-adapters',
22
- factory: require => {
23
- const module = { exports: {} }
24
- const exports = module.exports
25
- const React = require('react')
26
- const { createElement: h, useState, useEffect, useCallback, useMemo, useRef, useSyncExternalStore } = React
27
-
28
- const NS = 'llm-adapters'
29
- const inject = ['slots', 'locale', 'configForms']
30
-
31
- const zh = {
32
- pluginOptionsTitle: '启用的供应商',
33
- pluginOptionsHint: '关闭后,对应设置页、模型列表与模型分组都会移除;登录态与已保存的模型选择会保留。',
34
- pluginOptionsOn: '显示中',
35
- pluginOptionsOff: '已关闭',
36
- pluginOptionsReadOnly: '当前配置不可写',
37
- pluginOptionsRejected: '宿主拒绝了这次改动;若刚更新过插件,请重启 DSH 后再试',
38
- loadFailed: '读取失败',
39
- retry: '重试',
40
- refresh: '刷新状态与模型',
41
- refreshing: '刷新中…',
42
- saveFailed: '保存失败',
43
- notApplied: '服务器未报告这次改动,可能被其他窗口覆盖了,请刷新后重试',
44
- timeout: '保存超时,请重试',
45
- saved: '已保存',
46
- saving: '保存中…',
47
- enabledCount: '已启用',
48
- search: '搜索模型…',
49
- onlyEnabled: '只看已启用',
50
- selectAll: '全部启用',
51
- selectNone: '全部关闭',
52
- colEnabled: '启用',
53
- windowDefault: '默认',
54
- windowDefaultHint: '跟随上游声明的窗口',
55
- vision: '读图',
56
- reasoning: '思考',
57
- free: '免费',
58
- advanced: '高级设置',
59
- advancedHint: '以下为细节参数,通常无需改动。',
60
- show: '展开',
61
- hide: '收起',
62
- reset: '恢复默认',
63
- windowWarn: '窗口越大,DSH 的自动压缩触发得越晚。若对话经常超限,请选较小的窗口。',
64
- accountTitle: '登录与账号',
65
- account: '账号',
66
- accountUnknown: '账号信息未提供',
67
- expires: '预计到期',
68
- quota: '额度',
69
- quotaRefreshing: '正在读取额度…',
70
- quotaUnavailable: '暂时无法读取额度',
71
- quotaFailed: '额度读取失败',
72
- points: '积分',
73
- loomyAvailable: '可用',
74
- loomyPermanent: '永久积分',
75
- loomyDaily: '每日积分',
76
- loomyCycle: '每日周期',
77
- raccoonOwner: '账号类型',
78
- ownerPersonal: '个人',
79
- ownerEnterprise: '企业',
80
- signedIn: '已登录',
81
- notLoggedIn: '未登录',
82
- reread: '重新读取登录态',
83
- reading: '读取中…',
84
- loginGuide: '点“打开扫码登录”,用微信扫码后会自动接回。',
85
- browserTitle: '微信扫码登录',
86
- browserOpen: '打开扫码登录',
87
- browserWaiting: '等待扫码…',
88
- browserCancel: '取消',
89
- browserReopen: '重新打开登录页',
90
- browserPolling: '登录页已在浏览器打开:用微信扫码并在手机上确认,成功后会自动接回。若弹窗被拦截,点“重新打开登录页”。',
91
- browserHintLoomy: '会打开一个本地登录页:用微信扫码登录 Loomy;首次使用的微信账号需要在页面里绑定手机号。',
92
- browserHintRaccoon: '会打开一个本地登录页:用微信扫码登录小浣熊,扫码确认后插件会自动接回。',
93
- loomyHint: 'Loomy 会话约 14 天有效,且没有刷新令牌;过期后再点“打开扫码登录”即可。',
94
- raccoonHint: '令牌约 30 天有效。每日积分需手动点「签到领积分」:插件按网页方式请求并核对今日流水,未到账可点「打开网页签到」。',
95
- loginLogout: '退出登录',
96
- loginConfirmLogout: '确定要退出当前账号吗?',
97
- loginFailedPrefix: '登录失败',
98
- loomyLoginMissing: 'Loomy 尚未登录。请点「打开扫码登录」用微信扫码。',
99
- raccoonLoginMissing: '小浣熊尚未登录。请点「打开扫码登录」用微信扫码。',
100
- possiblyExpired: '登录态可能已过期',
101
- modelsEmptyLoomy: 'Loomy 没有返回模型。请确认账号已登录,然后点“重新读取登录态”。',
102
- modelsEmptyRaccoon: '小浣熊没有返回模型。请确认账号已登录,然后点“重新读取登录态”。',
103
- modelsHint: '登录后才会读取并显示可用模型。',
104
- catalogNotice: '模型列表提示',
105
- advancedFields: {
106
- displayName: '分组名称',
107
- visionModelIds: '强制启用读图的模型 ID(JSON 数组)',
108
- textOnlyModelIds: '强制按纯文本处理的模型 ID(JSON 数组)',
109
- modelAliases: '模型别名(JSON 对象)',
110
- defaultMaxTokens: '默认输出上限',
111
- },
112
- loomyNav: 'Loomy',
113
- loomyTitle: 'Loomy',
114
- loomyIntro: '点“打开扫码登录”,用微信扫码登录 Loomy 即可使用,无需安装客户端。改动即时生效,无需重启。',
115
- raccoonNav: '商汤小浣熊',
116
- raccoonTitle: '商汤小浣熊',
117
- raccoonIntro: '点“打开扫码登录”,用微信扫码登录小浣熊即可使用,无需安装客户端。改动即时生效,无需重启。',
118
- traeNav: 'Trae',
119
- traeTitle: 'Trae',
120
- traeIntro: '先选国内版或国际版,再点“打开浏览器登录”在 Trae 官网登录。设备指纹由这次网页登录生成,不需要安装桌面客户端。改动即时生效,无需重启。',
121
- traeHint: 'Trae 的网页令牌只有约 8 小时,插件会用登录时拿到的网页会话自动续期;网页会话失效后才需重新登录。',
122
- traeLoginMissing: 'Trae 尚未登录。请点“打开浏览器登录”。',
123
- regionLabel: '区域',
124
- regionCn: '国内版 · trae.cn',
125
- regionGlobal: '国际版 · trae.ai',
126
- regionHint: '两个区域各自保存账号:切换区域不会退出另一个区域的登录。',
127
- regionSwitching: '正在切换区域…',
128
- checkin: '签到',
129
- checkinClaim: '签到领积分',
130
- checkinBusy: '签到中…',
131
- checkinClaimed: '今日已签到',
132
- checkinDevice: '本机今日已签到',
133
- checkinReward: '可得',
134
- checkinReceived: '已到账',
135
- checkinGrantedAt: '到账时间',
136
- checkinOpenWeb: '打开网页签到',
137
- checkinFailed: '签到失败',
138
- checkinUnavailable: '当前账号没有可用的签到活动',
139
- checkinMonitorTitle: '小浣熊签到监测',
140
- checkinMonitorHint: '定时核对今日每日积分是否到账,未到账时按网页方式尝试触发。「打开网页签到」入口始终保留,可随时手动打开。',
141
- checkinInterval: '自动监测',
142
- checkinMonitorOff: '关闭自动监测',
143
- checkinEvery15: '每 15 分钟',
144
- checkinEvery30: '每 30 分钟',
145
- checkinEvery60: '每小时',
146
- checkinEvery120: '每 2 小时',
147
- plan: '套餐',
148
- planFree: '免费版',
149
- planTrial: '试用中',
150
- trialEnds: '试用到期',
151
- packs: '资源包',
152
- packExpires: '到期',
153
- packUsage: '已用 / 总额',
154
- packNearest: '最近到期',
155
- packDetail: '详细信息',
156
- packCollapse: '收起',
157
- usedOf: '已用',
158
- usageReset: '重置时间',
159
- browserHintTrae: '点下面的按钮会打开一个独立的浏览器窗口(不会动你自己的浏览器配置):在其中登录 Trae,登录成功后窗口会自动关闭。国际版若提示 account is at risk,请点 Log in 用你平时能登录的同一个账号(Google/GitHub)进入,不要用新邮箱重新注册;独立配置首次登录会被 Trae 当成新设备。',
160
- browserPollingTrae: '浏览器已打开:请在窗口里登录 Trae,成功后会自动接回,这个窗口随后会关闭。',
161
- browserTitleTrae: '浏览器登录',
162
- browserOpenTrae: '打开浏览器登录',
163
- browserWaitingTrae: '等待登录…',
164
- loginGuideTrae: '点“打开浏览器登录”,在弹出的窗口里登录 Trae 即可;登录成功后会自动接回。国际版请用 Log in 登录已有账号,不要新建邮箱账号,否则容易触发 account is at risk。',
165
- modelsEmptyTrae: '账号没有返回任何 Trae 模型。请确认账号已登录,然后点“重新读取登录态”。',
166
- codeartsNav: '华为云 CodeArts',
167
- codeartsTitle: '华为云 CodeArts',
168
- codeartsIntro: '点“打开浏览器登录”在华为云完成授权,插件用授权得到的 AK/SK 签名调用 CodeArts 网关,不读本机 IDE 的任何文件。改动即时生效,无需重启。',
169
- codeartsHint: 'CodeArts 用签名而不是令牌调用,STS 令牌两小时有效,到期会自动用续期令牌续上;续期令牌失效后重新登录一次即可。积分计费账户每天可以签到领积分。',
170
- codeartsVisionHint: '「读图」只标记模型原生支持图片。没有这个标记的模型也能收图:发送时会先由 kimi-k2.6-vl 把图片转述成文字,识别结果按图片内容缓存,同一张图只多花一次对话。',
171
- browserHintCodeArts: '点下面的按钮会在浏览器里打开华为云授权页:登录并同意授权后会自动回到本机,凭据由宿主接回,页面上不需要复制任何东西。',
172
- browserPollingCodeArts: '已打开华为云授权页:完成授权后会自动接回,这个标签页可以关闭。',
173
- browserTitleCodeArts: '华为云授权登录',
174
- browserOpenCodeArts: '打开浏览器登录',
175
- browserWaitingCodeArts: '等待授权…',
176
- loginGuideCodeArts: '点“打开浏览器登录”,在弹出的华为云页面里完成授权即可;成功后会自动接回。',
177
- codeartsLoginMissing: 'CodeArts 尚未登录。请点“打开浏览器登录”,在华为云页面完成授权。',
178
- modelsEmptyCodeArts: '账号没有返回任何 CodeArts 模型。请确认已登录,然后点“重新读取登录态”。',
179
- openRouterTitle: 'OpenRouter 模型同步',
180
- openRouterHint: 'OpenRouter 的实时模型表用来补充思考档位与读图能力,三个供应商共用一份。缓存保存在本机,连不上时会继续使用上次成功同步的数据;刷新模型不会清空缓存。',
181
- openRouterInterval: '自动同步',
182
- openRouterOff: '关闭自动同步',
183
- openRouterEvery15: '每 15 分钟',
184
- openRouterEvery60: '每小时',
185
- openRouterEvery360: '每 6 小时',
186
- openRouterEvery1440: '每 24 小时',
187
- openRouterSyncNow: '立即同步',
188
- openRouterSyncing: '同步中…',
189
- openRouterNever: '尚未同步',
190
- openRouterModels: '个模型',
191
- openRouterLastSync: '上次同步',
192
- openRouterSyncFailed: '同步失败',
193
- openRouterSyncDone: '已同步',
194
- }
195
- const en = {
196
- pluginOptionsTitle: 'Enabled providers',
197
- pluginOptionsHint: 'Turning one off removes its settings page, model list and model group. Login state and saved model choices are kept.',
198
- pluginOptionsOn: 'Shown',
199
- pluginOptionsOff: 'Off',
200
- pluginOptionsReadOnly: 'Configuration is read-only',
201
- pluginOptionsRejected: 'The host rejected this change; restart DSH after a plugin update and retry',
202
- loadFailed: 'Could not load',
203
- retry: 'Retry',
204
- refresh: 'Refresh status and models',
205
- refreshing: 'Refreshing…',
206
- saveFailed: 'Save failed',
207
- notApplied: 'The host did not report this change; another window may have overwritten it. Refresh and retry.',
208
- timeout: 'Save timed out; please retry',
209
- saved: 'Saved',
210
- saving: 'Saving…',
211
- enabledCount: 'enabled',
212
- search: 'Search models…',
213
- onlyEnabled: 'Enabled only',
214
- selectAll: 'Enable all',
215
- selectNone: 'Disable all',
216
- colEnabled: 'Enabled',
217
- windowDefault: 'Default',
218
- windowDefaultHint: 'Follow the upstream context window',
219
- vision: 'vision',
220
- reasoning: 'reasoning',
221
- free: 'free',
222
- advanced: 'Advanced',
223
- advancedHint: 'Rarely needed; read the help text before changing.',
224
- show: 'Show',
225
- hide: 'Hide',
226
- reset: 'Reset',
227
- windowWarn: 'A larger window delays automatic compaction. Choose a smaller value if conversations often overflow.',
228
- accountTitle: 'Sign-in',
229
- account: 'Account',
230
- accountUnknown: 'Account details are unavailable',
231
- expires: 'Expires (estimated)',
232
- quota: 'Quota',
233
- quotaRefreshing: 'Reading quota…',
234
- quotaUnavailable: 'Quota is temporarily unavailable',
235
- quotaFailed: 'Could not read quota',
236
- points: 'points',
237
- loomyAvailable: 'Available',
238
- loomyPermanent: 'Permanent',
239
- loomyDaily: 'Daily',
240
- loomyCycle: 'Daily cycle',
241
- raccoonOwner: 'Account type',
242
- ownerPersonal: 'Personal',
243
- ownerEnterprise: 'Enterprise',
244
- signedIn: 'Signed in',
245
- notLoggedIn: 'Not signed in',
246
- reread: 'Re-read login state',
247
- reading: 'Reading…',
248
- loginGuide: 'Use “Open QR sign-in” and scan the code in WeChat; the login is adopted automatically.',
249
- browserTitle: 'WeChat QR sign-in',
250
- browserOpen: 'Open QR sign-in',
251
- browserWaiting: 'Waiting for scan…',
252
- browserCancel: 'Cancel',
253
- browserReopen: 'Reopen the sign-in page',
254
- browserPolling: 'The sign-in page is open in your browser. Scan the code in WeChat and confirm on your phone; the plugin adopts the login automatically. If the popup was blocked, use “Reopen the sign-in page”.',
255
- browserHintLoomy: 'A local sign-in page opens: scan it in WeChat to sign in to Loomy. A WeChat account using Loomy for the first time must bind a phone number on that page.',
256
- browserHintRaccoon: 'A local sign-in page opens: scan it in WeChat to sign in to Raccoon, and the plugin adopts the login once you confirm.',
257
- loomyHint: 'A Loomy session lasts about 14 days and has no refresh token; use “Open QR sign-in” again when it expires.',
258
- raccoonHint: 'The QR login lasts about 30 days. Daily points are claimed manually with “Check in for credits”, which makes the website-style request and checks today’s ledger; “Open check-in page” is the fallback.',
259
- loginLogout: 'Sign out',
260
- loginConfirmLogout: 'Sign out of this account?',
261
- loginFailedPrefix: 'Sign-in failed',
262
- loomyLoginMissing: 'Loomy is not signed in. Use “Open QR sign-in” and scan the code in WeChat.',
263
- raccoonLoginMissing: 'Raccoon is not signed in. Use “Open QR sign-in” and scan the code in WeChat.',
264
- possiblyExpired: 'The login may have expired',
265
- modelsEmptyLoomy: 'Loomy returned no models. Confirm the account is signed in, then re-read the login state.',
266
- modelsEmptyRaccoon: 'Raccoon returned no models. Confirm the account is signed in, then re-read the login state.',
267
- modelsHint: 'Models are read only after sign-in.',
268
- catalogNotice: 'Model list notice',
269
- advancedFields: {
270
- displayName: 'Group name',
271
- visionModelIds: 'Force vision model IDs (JSON array)',
272
- textOnlyModelIds: 'Force text-only model IDs (JSON array)',
273
- modelAliases: 'Model aliases (JSON object)',
274
- defaultMaxTokens: 'Default output limit',
275
- },
276
- loomyNav: 'Loomy',
277
- loomyTitle: 'Loomy',
278
- loomyIntro: 'Use “Open QR sign-in” and scan the code in WeChat; no client install needed. Changes apply immediately.',
279
- raccoonNav: 'SenseTime Raccoon',
280
- raccoonTitle: 'SenseTime Raccoon',
281
- raccoonIntro: 'Use “Open QR sign-in” and scan the code in WeChat; no client install needed. Changes apply immediately.',
282
- traeNav: 'Trae',
283
- traeTitle: 'Trae',
284
- traeIntro: 'Pick the mainland or international edition, then use “Open browser sign-in” to sign in on Trae’s website. The device fingerprint comes from that web session, so no desktop client is needed. Changes apply immediately.',
285
- traeHint: 'Trae’s web token lasts about 8 hours; the plugin renews it with the website session captured at sign-in, so you only sign in again when that session dies.',
286
- traeLoginMissing: 'Trae is not signed in. Use “Open browser sign-in”.',
287
- regionLabel: 'Region',
288
- regionCn: 'Mainland · trae.cn',
289
- regionGlobal: 'International · trae.ai',
290
- regionHint: 'Each region keeps its own account: switching regions does not sign out the other one.',
291
- regionSwitching: 'Switching region…',
292
- checkin: 'Check-in',
293
- checkinClaim: 'Check in for credits',
294
- checkinBusy: 'Checking in…',
295
- checkinClaimed: 'Already checked in today',
296
- checkinDevice: 'This device checked in today',
297
- checkinReward: 'reward',
298
- checkinReceived: 'received',
299
- checkinGrantedAt: 'credited at',
300
- checkinOpenWeb: 'Open check-in page',
301
- checkinFailed: 'Check-in failed',
302
- checkinUnavailable: 'No check-in available for this account',
303
- checkinMonitorTitle: 'Raccoon check-in monitor',
304
- checkinMonitorHint: 'Periodically verifies that today’s daily points arrived, and tries the website call when they have not. The “Open check-in page” link always stays available for a manual visit.',
305
- checkinInterval: 'Monitor',
306
- checkinMonitorOff: 'Monitor off',
307
- checkinEvery15: 'Every 15 minutes',
308
- checkinEvery30: 'Every 30 minutes',
309
- checkinEvery60: 'Hourly',
310
- checkinEvery120: 'Every 2 hours',
311
- plan: 'Plan',
312
- planFree: 'Free',
313
- planTrial: 'Trial',
314
- trialEnds: 'Trial ends',
315
- packs: 'Credit packs',
316
- packExpires: 'expires',
317
- packUsage: 'used / total',
318
- packNearest: 'nearest expiry',
319
- packDetail: 'Details',
320
- packCollapse: 'Collapse',
321
- usedOf: 'used',
322
- usageReset: 'Resets',
323
- browserHintTrae: 'The button opens a dedicated browser window (your own browser profile is untouched): sign in to Trae there and the window closes itself. If the international edition says “account is at risk”, use Log in with the same Google/GitHub account that works in your normal browser — do not create another email account; a fresh profile looks like a new device to Trae.',
324
- browserPollingTrae: 'The browser is open: sign in to Trae in that window; the login is adopted automatically and the window then closes.',
325
- browserTitleTrae: 'Browser sign-in',
326
- browserOpenTrae: 'Open browser sign-in',
327
- browserWaitingTrae: 'Waiting for sign-in…',
328
- loginGuideTrae: 'Use “Open browser sign-in” and sign in to Trae in the window that opens; the login is adopted automatically. On the international edition, use Log in with your existing account instead of creating a new email account, which often triggers account is at risk.',
329
- modelsEmptyTrae: 'Trae returned no models. Confirm the account is signed in, then re-read the login state.',
330
- codeartsNav: 'Huawei CodeArts',
331
- codeartsTitle: 'Huawei CodeArts',
332
- codeartsIntro: 'Use “Open browser sign-in” to authorize on Huawei Cloud; the plugin signs CodeArts gateway calls with the resulting AK/SK and never reads the local IDE. Changes apply immediately.',
333
- codeartsHint: 'CodeArts is called with a signature, not a token. The STS token lasts two hours and is renewed automatically; sign in again once the refresh token expires. Credit-billed accounts can check in once a day for credits.',
334
- codeartsVisionHint: '“Vision” marks native image support only. Other models still accept images: kimi-k2.6-vl transcribes the picture to text first, and the transcript is cached per image, so the same picture costs one extra turn in total.',
335
- browserHintCodeArts: 'The button opens Huawei Cloud’s authorization page: sign in and approve, then the browser returns to this machine and the host adopts the credential. Nothing has to be copied.',
336
- browserPollingCodeArts: 'The Huawei Cloud page is open: finish authorizing and the login is adopted automatically; this tab can be closed.',
337
- browserTitleCodeArts: 'Huawei Cloud authorization',
338
- browserOpenCodeArts: 'Open browser sign-in',
339
- browserWaitingCodeArts: 'Waiting for authorization…',
340
- loginGuideCodeArts: 'Use “Open browser sign-in” and authorize on the Huawei Cloud page that opens; the login is adopted automatically.',
341
- codeartsLoginMissing: 'CodeArts is not signed in. Use “Open browser sign-in” and authorize on Huawei Cloud.',
342
- modelsEmptyCodeArts: 'CodeArts returned no models. Confirm the account is signed in, then re-read the login state.',
343
- openRouterTitle: 'OpenRouter model sync',
344
- openRouterHint: 'OpenRouter’s live catalog supplies thinking levels and image support for all three providers. The snapshot is cached on this machine, so a failed sync keeps the last successful data; refreshing a model list never clears it.',
345
- openRouterInterval: 'Auto sync',
346
- openRouterOff: 'Off',
347
- openRouterEvery15: 'Every 15 minutes',
348
- openRouterEvery60: 'Hourly',
349
- openRouterEvery360: 'Every 6 hours',
350
- openRouterEvery1440: 'Every 24 hours',
351
- openRouterSyncNow: 'Sync now',
352
- openRouterSyncing: 'Syncing…',
353
- openRouterNever: 'Not synced yet',
354
- openRouterModels: 'models',
355
- openRouterLastSync: 'Last sync',
356
- openRouterSyncFailed: 'Sync failed',
357
- openRouterSyncDone: 'Synced',
358
- }
359
-
360
- const BRANDS = [
361
- { id: 'loomy', settingsId: 'loomy', order: 40, titleKey: 'loomyTitle', navKey: 'loomyNav', introKey: 'loomyIntro' },
362
- { id: 'raccoon', settingsId: 'raccoon', order: 41, titleKey: 'raccoonTitle', navKey: 'raccoonNav', introKey: 'raccoonIntro' },
363
- { id: 'trae', settingsId: 'trae', order: 42, titleKey: 'traeTitle', navKey: 'traeNav', introKey: 'traeIntro' },
364
- { id: 'codearts', settingsId: 'codearts', order: 43, titleKey: 'codeartsTitle', navKey: 'codeartsNav', introKey: 'codeartsIntro' },
365
- ]
366
-
367
- const FIELDS = {
368
- loomy: {
369
- enabled: 'loomyEnabled',
370
- displayName: 'loomyDisplayName',
371
- disabled: 'loomyDisabledModels',
372
- windows: 'loomyModelContextWindows',
373
- aliases: 'loomyModelAliases',
374
- vision: 'loomyVisionModelIds',
375
- textOnly: 'loomyTextOnlyModelIds',
376
- },
377
- raccoon: {
378
- enabled: 'raccoonEnabled',
379
- displayName: 'raccoonDisplayName',
380
- disabled: 'raccoonDisabledModels',
381
- windows: 'raccoonModelContextWindows',
382
- aliases: 'raccoonModelAliases',
383
- vision: 'raccoonVisionModelIds',
384
- textOnly: 'raccoonTextOnlyModelIds',
385
- },
386
- trae: {
387
- enabled: 'traeEnabled',
388
- displayName: 'traeDisplayName',
389
- disabled: 'traeDisabledModels',
390
- windows: 'traeModelContextWindows',
391
- aliases: 'traeModelAliases',
392
- vision: 'traeVisionModelIds',
393
- textOnly: 'traeTextOnlyModelIds',
394
- region: 'traeRegion',
395
- },
396
- codearts: {
397
- enabled: 'codeartsEnabled',
398
- displayName: 'codeartsDisplayName',
399
- disabled: 'codeartsDisabledModels',
400
- windows: 'codeartsModelContextWindows',
401
- aliases: 'codeartsModelAliases',
402
- vision: 'codeartsVisionModelIds',
403
- textOnly: 'codeartsTextOnlyModelIds',
404
- },
405
- }
406
-
407
- const REGIONS = [
408
- { id: 'cn', labelKey: 'regionCn' },
409
- { id: 'global', labelKey: 'regionGlobal' },
410
- ]
411
-
412
- const OPENROUTER_INTERVALS = [
413
- { value: 0, labelKey: 'openRouterOff' },
414
- { value: 15, labelKey: 'openRouterEvery15' },
415
- { value: 60, labelKey: 'openRouterEvery60' },
416
- { value: 360, labelKey: 'openRouterEvery360' },
417
- { value: 1440, labelKey: 'openRouterEvery1440' },
418
- ]
419
-
420
- const CHECKIN_INTERVALS = [
421
- { value: 0, labelKey: 'checkinMonitorOff' },
422
- { value: 15, labelKey: 'checkinEvery15' },
423
- { value: 30, labelKey: 'checkinEvery30' },
424
- { value: 60, labelKey: 'checkinEvery60' },
425
- { value: 120, labelKey: 'checkinEvery120' },
426
- ]
427
-
428
- function regionOf(value) {
429
- return value === 'global' ? 'global' : 'cn'
430
- }
431
-
432
- const css = {
433
- page: { maxWidth: '860px', display: 'flex', flexDirection: 'column', gap: '14px', color: 'var(--dsw-alias-label-primary)' },
434
- h2: { margin: '0', fontSize: '1.25em' },
435
- h3: { margin: 0, fontSize: '0.95em' },
436
- intro: { margin: '0', opacity: 0.72, lineHeight: 1.55, fontSize: '0.92em' },
437
- bar: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
438
- btn: { padding: '6px 12px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: '0.88em' },
439
- btnPrimary: { border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.45))', background: '#0b0b0b', color: '#fff', fontWeight: 500 },
440
- btnAlert: { border: '1px solid var(--dsw-alias-label-danger, #e03131)', color: 'var(--dsw-alias-label-danger, #e03131)' },
441
- loginBox: { display: 'flex', flexDirection: 'column', gap: '10px', padding: '12px 14px', borderRadius: '10px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))' },
442
- h4: { margin: 0, fontSize: '0.92em' },
443
- input: { padding: '6px 10px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'var(--dsw-alias-bg-base, transparent)', color: 'inherit', font: 'inherit', fontSize: '0.88em' },
444
- search: { flex: '1 1 180px', minWidth: '140px' },
445
- count: { fontSize: '0.85em', opacity: 0.7, marginLeft: 'auto' },
446
- list: { display: 'flex', flexDirection: 'column', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', borderRadius: '10px', overflow: 'hidden' },
447
- row: { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 14px', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
448
- rowFirst: { borderTop: 'none' },
449
- rowOff: { opacity: 0.5 },
450
- name: { flex: '1 1 auto', minWidth: 0, display: 'flex', flexDirection: 'column', gap: '2px' },
451
- nameText: { fontSize: '0.92em', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
452
- id: { fontSize: '0.76em', opacity: 0.55, fontFamily: 'ui-monospace, monospace' },
453
- tag: { fontSize: '0.72em', padding: '1px 6px', borderRadius: '5px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', opacity: 0.75 },
454
- price: { fontSize: '0.9em', opacity: 0.7, fontWeight: 400 },
455
- select: { padding: '5px 8px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'var(--dsw-alias-bg-base, transparent)', color: 'inherit', font: 'inherit', fontSize: '0.85em', minWidth: '104px' },
456
- switch: { width: '17px', height: '17px', cursor: 'pointer', accentColor: 'var(--dsw-alias-brand-primary, #4c6ef5)' },
457
- note: { fontSize: '0.82em', opacity: 0.62, lineHeight: 1.5 },
458
- err: { fontSize: '0.86em', color: 'var(--dsw-alias-label-danger, #e03131)' },
459
- guide: { display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px 14px', borderRadius: '10px', border: '1px solid var(--dsw-alias-label-danger, #e03131)', background: 'rgba(224,49,49,0.07)' },
460
- guideText: { fontSize: '0.88em', lineHeight: 1.5, color: 'var(--dsw-alias-label-danger, #e03131)' },
461
- group: { display: 'flex', flexDirection: 'column', gap: '10px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', borderRadius: '10px', padding: '14px 16px' },
462
- card: { display: 'flex', flexDirection: 'column', gap: '10px', padding: '14px 16px', borderRadius: '10px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', background: 'var(--dsw-alias-bg-base, rgba(128,128,128,0.03))' },
463
- cardHead: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' },
464
- line: { display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap', fontSize: '0.86em' },
465
- label: { opacity: 0.62, minWidth: '78px' },
466
- value: { fontWeight: 500 },
467
- mono: { fontFamily: 'ui-monospace, monospace', fontSize: '0.8em', opacity: 0.7, wordBreak: 'break-all' },
468
- link: { color: 'var(--dsw-alias-brand-primary, #4c6ef5)', textDecoration: 'none', fontSize: '0.86em' },
469
- foot: { display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', fontSize: '0.8em', opacity: 0.6 },
470
- optionRow: { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 0', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
471
- optionFirst: { borderTop: 'none', paddingTop: 0 },
472
- optionText: { flex: '1 1 auto', minWidth: 0, display: 'flex', flexDirection: 'column', gap: '2px' },
473
- optionName: { fontSize: '0.92em', fontWeight: 500 },
474
- optionStatus: { fontSize: '0.82em', opacity: 0.62 },
475
- advLabel: { fontSize: '0.88em' },
476
- advHelp: { fontSize: '0.78em', opacity: 0.55, lineHeight: 1.4 },
477
- regionBox: { display: 'flex', flexDirection: 'column', gap: '6px', paddingBottom: '10px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
478
- seg: { display: 'flex', gap: '6px', flexWrap: 'wrap' },
479
- segBtn: { padding: '4px 10px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: '0.84em' },
480
- segOn: { border: '1px solid var(--dsw-alias-brand-primary, #4c6ef5)', fontWeight: 600 },
481
- section: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '14px', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
482
- packBlock: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '2px' },
483
- packHead: { display: 'flex', alignItems: 'baseline', gap: '8px' },
484
- packToggle: { marginLeft: 'auto', background: 'none', border: 'none', padding: 0, font: 'inherit', fontSize: '0.8em', color: 'var(--dsw-alias-brand-primary, #4c6ef5)', cursor: 'pointer' },
485
- packList: { display: 'flex', flexDirection: 'column', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', overflow: 'hidden' },
486
- packListScroll: { maxHeight: '190px', overflowY: 'auto' },
487
- packRow: { display: 'grid', gridTemplateColumns: 'minmax(90px, 1.5fr) auto auto', alignItems: 'baseline', gap: '10px', padding: '6px 10px', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))', fontSize: '0.82em' },
488
- packFirst: { borderTop: 'none' },
489
- packLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
490
- packAmount: { fontFamily: 'ui-monospace, monospace', textAlign: 'right', whiteSpace: 'nowrap' },
491
- packExpiry: { opacity: 0.6, whiteSpace: 'nowrap', fontSize: '0.92em' },
492
- meterBox: { display: 'flex', flexDirection: 'column', gap: '5px', marginTop: '2px' },
493
- meterText: { display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap', fontSize: '0.86em' },
494
- meterPercent: { marginLeft: 'auto', opacity: 0.62, fontSize: '0.92em' },
495
- meter: { height: '6px', borderRadius: '999px', background: 'var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', overflow: 'hidden' },
496
- meterFill: { height: '100%', borderRadius: '999px', background: 'var(--dsw-alias-brand-primary, #4c6ef5)' },
497
- }
498
-
499
- const API = (() => {
500
- try { return new URL('api/llm-adapters', document.baseURI).pathname } catch { return '/api/llm-adapters' }
501
- })()
502
-
503
- const CACHE_PREFIX = 'dsh-llm-adapters/cache/v1'
504
- const cacheMemory = new Map()
505
-
506
- function cacheKey(brand, kind) {
507
- return `${CACHE_PREFIX}:${brand}:${kind}`
508
- }
509
-
510
- function readCache(brand, kind) {
511
- const key = cacheKey(brand, kind)
512
- if (cacheMemory.has(key)) return cacheMemory.get(key)
513
- try {
514
- const raw = window.localStorage.getItem(key)
515
- if (raw === null) return undefined
516
- const parsed = JSON.parse(raw)
517
- cacheMemory.set(key, parsed)
518
- return parsed
519
- } catch {
520
- return undefined
521
- }
522
- }
523
-
524
- function writeCache(brand, kind, value) {
525
- const key = cacheKey(brand, kind)
526
- cacheMemory.set(key, value)
527
- try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* private mode */ }
528
- }
529
-
530
- async function api(path, options) {
531
- const { timeout = 30_000, ...rest } = options ?? {}
532
- const ctrl = new AbortController()
533
- const timer = setTimeout(() => ctrl.abort(), timeout)
534
- try {
535
- const response = await fetch(`${API}${path}`, { ...rest, signal: ctrl.signal })
536
- const text = await response.text()
537
- let payload
538
- try { payload = text === '' ? {} : JSON.parse(text) } catch { payload = { error: text.slice(0, 200) } }
539
- if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`)
540
- return payload
541
- } finally {
542
- clearTimeout(timer)
543
- }
544
- }
545
-
546
- const WRITE_TIMEOUT_MS = 20_000
547
-
548
- function withDeadline(promise, ms, message) {
549
- return new Promise((resolve, reject) => {
550
- const timer = setTimeout(() => reject(new Error(message)), ms)
551
- promise.then(
552
- value => { clearTimeout(timer); resolve(value) },
553
- error => { clearTimeout(timer); reject(error) },
554
- )
555
- })
556
- }
557
-
558
- function windowLabel(n) {
559
- if (typeof n !== 'number' || !Number.isFinite(n)) return '—'
560
- if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1))}M`
561
- if (n >= 1000) return `${Math.round(n / 1000)}K`
562
- return String(n)
563
- }
564
-
565
- function creditsOf(value) {
566
- if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
567
- return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
568
- }
569
-
570
- /** Trae's international plan reports dollars; every other quota is credits. */
571
- function amountText(quota, value) {
572
- if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
573
- return quota?.unit === 'usd' ? `$${value.toFixed(2)}` : creditsOf(value)
574
- }
575
-
576
- /**
577
- * A usage bar needs both halves: what was spent and what the account was
578
- * granted. Loomy and Raccoon only report what is left, so they keep the
579
- * plain figure rather than a bar with an invented denominator.
580
- */
581
- function meterPercentOf(quota) {
582
- const spent = typeof quota?.used === 'number' && Number.isFinite(quota.used) ? quota.used : undefined
583
- const granted = typeof quota?.total === 'number' && Number.isFinite(quota.total) ? quota.total : undefined
584
- if (spent === undefined || granted === undefined || granted <= 0) return undefined
585
- return Math.min(100, Math.max(0, (spent / granted) * 100))
586
- }
587
-
588
- function percentLabel(value) {
589
- const text = value > 0 && value < 1
590
- ? value.toFixed(2)
591
- : value < 10 ? value.toFixed(1) : String(Math.round(value))
592
- return `${text}%`
593
- }
594
-
595
- function dateLabel(value) {
596
- if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
597
- if (value > Date.now() + 100 * 365 * 24 * 3600 * 1000) return '—'
598
- return new Date(value).toLocaleString('zh-CN', {
599
- year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
600
- })
601
- }
602
-
603
- function priceText(t, factor) {
604
- if (typeof factor !== 'number' || !Number.isFinite(factor) || factor < 0) return ''
605
- if (factor === 0) return t('free')
606
- return `x${Number(factor.toFixed(2))}`
607
- }
608
-
609
- function ownerLabel(t, owner) {
610
- if (typeof owner !== 'string' || owner === '') return '—'
611
- if (owner === 'users' || owner === 'user' || owner === 'personal') return t('ownerPersonal')
612
- if (owner === 'org' || owner === 'enterprise') return t('ownerEnterprise')
613
- return owner
614
- }
615
-
616
- function AccountCard({ t, brand, region, regionBusy, onRegionChange, reloadSignal, onStatus, onAfterRefresh }) {
617
- // Trae keeps one snapshot per region, so switching back and forth never
618
- // shows the other region's account while a fresh read is in flight.
619
- const cacheId = brand.id === 'trae' ? `trae:${region}` : brand.id
620
- const cached = readCache(cacheId, 'status')
621
- const [status, setStatus] = useState(cached)
622
- const [busy, setBusy] = useState(false)
623
- const [error, setError] = useState(undefined)
624
- const seenReload = useRef(reloadSignal)
625
- const mounted = useRef(true)
626
- const [browser, setBrowser] = useState(undefined)
627
- const [actionBusy, setActionBusy] = useState(false)
628
- const [actionError, setActionError] = useState(undefined)
629
- const [checkinBusy, setCheckinBusy] = useState(false)
630
- const [packsExpanded, setPacksExpanded] = useState(false)
631
- const ticket = useRef(0)
632
-
633
- const load = useCallback(async (fresh, notify) => {
634
- const mine = ++ticket.current
635
- setBusy(true)
636
- setError(undefined)
637
- try {
638
- const payload = await api(`/status?brand=${brand.id}${fresh ? '&fresh=1' : ''}`, { timeout: 35_000 })
639
- // A region switch starts a second read; the older one must not win.
640
- if (!mounted.current || mine !== ticket.current) return
641
- setStatus(payload)
642
- writeCache(cacheId, 'status', { ...payload, cachedAt: Date.now() })
643
- onStatus?.(payload)
644
- if (notify && payload?.signedIn === true) onAfterRefresh?.()
645
- } catch (err) {
646
- if (!mounted.current || mine !== ticket.current) return
647
- setError(String(err?.message ?? err))
648
- } finally {
649
- if (mounted.current && mine === ticket.current) setBusy(false)
650
- }
651
- }, [brand.id, cacheId, onStatus, onAfterRefresh])
652
-
653
- useEffect(() => {
654
- mounted.current = true
655
- void load(false, false)
656
- return () => { mounted.current = false }
657
- }, [load])
658
-
659
- // A region switch changes which stored account the card describes, so the
660
- // previous region's snapshot is dropped before the new read lands.
661
- useEffect(() => {
662
- setStatus(readCache(cacheId, 'status'))
663
- setBrowser(undefined)
664
- setActionError(undefined)
665
- setPacksExpanded(false)
666
- }, [cacheId])
667
-
668
- useEffect(() => {
669
- if (reloadSignal === seenReload.current) return
670
- seenReload.current = reloadSignal
671
- void load(true, true)
672
- }, [reloadSignal, load])
673
-
674
- // Follow the server-side browser login. The flow lives in the host, so it
675
- // keeps going even if this page is closed and reopened mid-login.
676
- useEffect(() => {
677
- if (browser?.running !== true) return undefined
678
- let alive = true
679
- const tick = async () => {
680
- try {
681
- const payload = await api(`/account/browser?brand=${brand.id}`, { timeout: 10_000 })
682
- if (!alive) return
683
- setBrowser(payload)
684
- if (payload?.running === false && payload?.signedIn === true) {
685
- await load(true, true)
686
- }
687
- } catch {
688
- /* transient; the next tick retries */
689
- }
690
- }
691
- const timer = setInterval(() => { void tick() }, 1500)
692
- return () => { alive = false; clearInterval(timer) }
693
- }, [browser?.running, brand.id, load])
694
-
695
- const signedIn = status?.signedIn === true
696
- const account = status?.account ?? {}
697
- const accountText = [account.name, account.phone, account.ownerType === undefined ? undefined : ownerLabel(t, account.ownerType)]
698
- .filter(part => typeof part === 'string' && part.length > 0)
699
- .join(' · ') || t('accountUnknown')
700
- const quota = status?.quota
701
-
702
- const accountRequest = useCallback(async body => api('/account', {
703
- method: 'POST',
704
- headers: { 'content-type': 'application/json' },
705
- body: JSON.stringify({ brand: brand.id, ...(brand.id === 'trae' ? { region } : {}), ...body }),
706
- timeout: 45_000,
707
- }), [brand.id, region])
708
-
709
- const logout = useCallback(async () => {
710
- if (typeof window.confirm === 'function' && !window.confirm(t('loginConfirmLogout'))) return
711
- setActionBusy(true)
712
- setActionError(undefined)
713
- try {
714
- const payload = await accountRequest({ action: 'logout' })
715
- setStatus(payload)
716
- writeCache(cacheId, 'status', { ...payload, cachedAt: Date.now() })
717
- onStatus?.(payload)
718
- onAfterRefresh?.()
719
- } catch (err) {
720
- setActionError(`${t('loginFailedPrefix')}:${String(err?.message ?? err)}`)
721
- } finally {
722
- setActionBusy(false)
723
- }
724
- }, [accountRequest, cacheId, onStatus, onAfterRefresh, t])
725
-
726
- const startBrowserLogin = useCallback(async () => {
727
- setActionError(undefined)
728
- try {
729
- const payload = await accountRequest({ action: 'browser' })
730
- setBrowser(payload)
731
- if (typeof payload?.url === 'string' && payload.url !== '' && typeof window.open === 'function') {
732
- window.open(payload.url, '_blank', 'noopener')
733
- }
734
- } catch (err) {
735
- setActionError(`${t('loginFailedPrefix')}:${String(err?.message ?? err)}`)
736
- }
737
- }, [accountRequest, t])
738
-
739
- const cancelBrowserLogin = useCallback(async () => {
740
- try {
741
- const payload = await accountRequest({ action: 'browser-cancel' })
742
- setBrowser(payload)
743
- } catch (err) {
744
- setActionError(`${t('loginFailedPrefix')}:${String(err?.message ?? err)}`)
745
- }
746
- }, [accountRequest, t])
747
-
748
- // Claiming is the only state-changing account action. The reward changes
749
- // the quota, so the reading is refreshed afterwards instead of guessed.
750
- const claimCheckin = useCallback(async () => {
751
- setCheckinBusy(true)
752
- setActionError(undefined)
753
- try {
754
- const payload = await accountRequest({ action: 'checkin' })
755
- if (payload?.checkin !== undefined) {
756
- setStatus(previous => previous === undefined
757
- ? previous
758
- : { ...previous, checkin: payload.checkin })
759
- }
760
- await load(true, false)
761
- } catch (err) {
762
- setActionError(`${t('checkinFailed')}:${String(err?.message ?? err)}`)
763
- } finally {
764
- setCheckinBusy(false)
765
- }
766
- }, [accountRequest, load, t])
767
-
768
- const quotaContent = (() => {
769
- if (busy && quota === undefined) return h('div', { style: css.note }, t('quotaRefreshing'))
770
- if (status?.quotaError !== undefined) return h('div', { style: css.err }, `${t('quotaFailed')}:${status.quotaError}`)
771
- if (quota === undefined) return h('div', { style: css.note }, signedIn ? t('quotaUnavailable') : t('quotaRefreshing'))
772
- if (brand.id === 'loomy') {
773
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
774
- h('div', { style: css.line },
775
- h('span', { style: css.label }, t('loomyAvailable')),
776
- h('span', { style: css.value }, `${creditsOf(quota.available ?? quota.total)} ${t('points')}`),
777
- ),
778
- h('div', { style: css.line },
779
- h('span', { style: css.label }, t('loomyPermanent')),
780
- h('span', { style: css.value }, creditsOf(quota.permanent)),
781
- quota.daily === undefined ? null : h('span', { style: css.label }, t('loomyDaily')),
782
- quota.daily === undefined ? null : h('span', { style: css.value }, creditsOf(quota.daily)),
783
- ),
784
- quota.cycle === undefined ? null : h('div', { style: css.line },
785
- h('span', { style: css.label }, t('loomyCycle')),
786
- h('span', { style: css.value }, quota.cycle),
787
- ),
788
- )
789
- }
790
- if (brand.id === 'trae' || brand.id === 'codearts') {
791
- const packs = Array.isArray(quota.packs) ? quota.packs : []
792
- const plan = typeof quota.plan === 'string' && quota.plan !== '' ? quota.plan : undefined
793
- const spent = typeof quota.used === 'number' && Number.isFinite(quota.used) ? quota.used : undefined
794
- const granted = typeof quota.total === 'number' && Number.isFinite(quota.total) ? quota.total : undefined
795
- const percent = meterPercentOf(quota)
796
- const unitSuffix = quota.unit === 'usd' ? '' : ` ${t('points')}`
797
- const usedText = spent === undefined
798
- ? undefined
799
- : `${amountText(quota, spent)}${granted === undefined ? '' : ` / ${amountText(quota, granted)}`}${unitSuffix}`
800
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
801
- plan === undefined && granted === undefined && usedText === undefined && packs.length === 0
802
- ? h('div', { style: css.note }, t('quotaUnavailable'))
803
- : null,
804
- plan === undefined ? null : h('div', { style: css.line },
805
- h('span', { style: css.label }, t('plan')),
806
- h('span', { style: css.value }, plan),
807
- quota.trialEndsAt === undefined ? null : h('span', { style: css.label }, t('trialEnds')),
808
- quota.trialEndsAt === undefined ? null : h('span', { style: css.value }, dateLabel(quota.trialEndsAt)),
809
- ),
810
- // Both halves present → a bar. A visible sliver is kept for a
811
- // spend that rounds to zero, or the bar reads as "untouched".
812
- percent === undefined
813
- ? granted === undefined
814
- ? null
815
- : h('div', { style: css.line },
816
- h('span', { style: css.label }, t('quota')),
817
- h('span', { style: css.value }, `${amountText(quota, granted)}${unitSuffix}`),
818
- )
819
- : h('div', { style: css.meterBox },
820
- h('div', { style: css.meterText },
821
- h('span', { style: css.label }, t('usedOf')),
822
- h('span', { style: css.value }, usedText),
823
- h('span', { style: css.meterPercent }, percentLabel(percent)),
824
- ),
825
- h('div', { style: css.meter },
826
- h('div', {
827
- style: { ...css.meterFill, width: `${percent > 0 ? Math.max(percent, 1.5) : 0}%` },
828
- }),
829
- ),
830
- ),
831
- percent === undefined && usedText !== undefined
832
- ? h('div', { style: css.line },
833
- h('span', { style: css.label }, t('usedOf')),
834
- h('span', { style: css.value }, usedText),
835
- quota.ratio === undefined ? null : h('span', { style: css.label }, `${Math.round(quota.ratio)}%`),
836
- )
837
- : null,
838
- quota.resetAt === undefined ? null : h('div', { style: css.line },
839
- h('span', { style: css.label }, t('usageReset')),
840
- h('span', { style: css.value }, dateLabel(quota.resetAt)),
841
- ),
842
- packs.length === 0 ? null : (() => {
843
- // Collapsed (the default) is one summary row: how many packs, the
844
- // combined total and the nearest expiry. Expanding lists every
845
- // pack, aligned in a bounded scroll area.
846
- const total = packs.reduce((sum, pack) => sum + (pack.total ?? pack.used ?? 0), 0)
847
- const nearest = packs
848
- .map(pack => pack.expiresAt)
849
- .filter(value => typeof value === 'number')
850
- .sort((a, b) => a - b)[0]
851
- return h('div', { style: css.packBlock },
852
- h('div', { style: css.packHead },
853
- h('span', { style: css.label }, `${t('packs')}(${packs.length})`),
854
- h('span', { style: css.value }, `${creditsOf(total)} ${t('points')}`),
855
- nearest === undefined ? null : h('span', { style: css.packExpiry }, `${t('packNearest')} ${dateLabel(nearest)}`),
856
- h('button', {
857
- style: css.packToggle,
858
- onClick: () => setPacksExpanded(value => !value),
859
- }, packsExpanded ? t('packCollapse') : `(${t('packDetail')})`),
860
- ),
861
- packsExpanded
862
- ? h('div', { style: css.packBlock },
863
- h('div', { style: css.packHead },
864
- h('span', { style: css.label }, t('packUsage')),
865
- ),
866
- h('div', { style: { ...css.packList, ...css.packListScroll } },
867
- packs.map((pack, index) => h('div', {
868
- key: `${pack.label}-${index}`,
869
- style: { ...css.packRow, ...(index === 0 ? css.packFirst : {}) },
870
- },
871
- h('span', { style: css.packLabel, title: pack.label }, pack.label),
872
- h('span', { style: css.packAmount }, pack.total === undefined
873
- ? (pack.used === undefined ? '—' : creditsOf(pack.used))
874
- : `${creditsOf(pack.used ?? 0)} / ${creditsOf(pack.total)}`),
875
- h('span', { style: css.packExpiry }, pack.expiresAt === undefined ? '' : dateLabel(pack.expiresAt)),
876
- )),
877
- ),
878
- )
879
- : null,
880
- )
881
- })(),
882
- )
883
- }
884
- return h('div', { style: css.line },
885
- h('span', { style: css.label }, t('quota')),
886
- h('span', { style: css.value }, `${creditsOf(quota.total)} ${t('points')}`),
887
- )
888
- })()
889
-
890
- // CodeArts grants one reward per account per day, but its device flag can
891
- // also block the claim; the two reasons are shown apart so "本机已签"
892
- // never reads as "this account already claimed".
893
- const checkin = brand.id === 'codearts' || brand.id === 'raccoon'
894
- ? status?.checkin
895
- : undefined
896
- const checkinBlocked = checkin === undefined
897
- || checkin.available === false
898
- || checkin.claimed === true
899
- || checkin.deviceClaimed === true
900
- const checkinLabel = checkin?.claimed === true
901
- ? t('checkinClaimed')
902
- : checkin?.deviceClaimed === true ? t('checkinDevice') : t('checkinClaim')
903
- // A failed activity read is shown rather than hidden: hiding it is what
904
- // makes "no check-in" and "check-in broken" look the same.
905
- const checkinNote = checkin === undefined
906
- ? undefined
907
- : checkin.available === false
908
- ? checkin.message ?? t('checkinUnavailable')
909
- : brand.id === 'raccoon' && checkin.claimed === false
910
- ? checkin.message
911
- : undefined
912
- // The timestamp is part of the claim, not decoration: without it a grant
913
- // issued just after midnight reads as if it were still yesterday's.
914
- const grantedAt = typeof checkin?.grantedAt === 'number' ? checkin.grantedAt : undefined
915
- const rewardNote = checkin === undefined || checkin.reward === undefined
916
- || checkin.deviceClaimed === true
917
- ? null
918
- : h('span', { style: css.note },
919
- `${checkin.claimed === true ? t('checkinReceived') : t('checkinReward')} +${creditsOf(checkin.reward)} ${t('points')}`
920
- + (grantedAt === undefined ? '' : ` · ${t('checkinGrantedAt')} ${dateLabel(grantedAt)}`))
921
- const checkinPanel = signedIn && checkin !== undefined
922
- && (checkin.available !== false || brand.id === 'codearts')
923
- ? h('div', { style: css.bar },
924
- h('button', {
925
- style: { ...css.btn, ...css.btnPrimary },
926
- disabled: checkinBlocked || checkinBusy || actionBusy,
927
- onClick: () => void claimCheckin(),
928
- }, checkinBusy ? t('checkinBusy') : checkinLabel),
929
- checkinNote === undefined ? null : h('span', { style: css.note }, checkinNote),
930
- rewardNote,
931
- typeof checkin.url === 'string' && checkin.url !== ''
932
- ? h('a', {
933
- href: checkin.url,
934
- target: '_blank',
935
- rel: 'noreferrer',
936
- style: css.link,
937
- }, t('checkinOpenWeb'))
938
- : null,
939
- )
940
- : null
941
-
942
- const browserRunning = browser?.running === true
943
- const browserPanel = h('div', { style: css.loginBox },
944
- h('h4', { style: css.h4 }, brand.id === 'trae'
945
- ? t('browserTitleTrae')
946
- : brand.id === 'codearts' ? t('browserTitleCodeArts') : t('browserTitle')),
947
- h('div', { style: css.note }, brand.id === 'loomy'
948
- ? t('browserHintLoomy')
949
- : brand.id === 'raccoon'
950
- ? t('browserHintRaccoon')
951
- : brand.id === 'codearts' ? t('browserHintCodeArts') : t('browserHintTrae')),
952
- h('div', { style: css.bar },
953
- h('button', {
954
- style: { ...css.btn, ...css.btnPrimary },
955
- disabled: browserRunning || actionBusy || regionBusy,
956
- onClick: () => void startBrowserLogin(),
957
- }, browserRunning
958
- ? brand.id === 'trae'
959
- ? t('browserWaitingTrae')
960
- : brand.id === 'codearts' ? t('browserWaitingCodeArts') : t('browserWaiting')
961
- : brand.id === 'trae'
962
- ? t('browserOpenTrae')
963
- : brand.id === 'codearts' ? t('browserOpenCodeArts') : t('browserOpen')),
964
- browserRunning
965
- ? h('button', {
966
- style: css.btn,
967
- onClick: () => void cancelBrowserLogin(),
968
- }, t('browserCancel'))
969
- : null,
970
- browserRunning && typeof browser?.url === 'string'
971
- ? h('a', {
972
- href: browser.url,
973
- target: '_blank',
974
- rel: 'noreferrer',
975
- style: css.link,
976
- }, t('browserReopen'))
977
- : null,
978
- ),
979
- browserRunning
980
- ? h('div', { style: css.note }, typeof browser?.message === 'string' && browser.message !== ''
981
- ? browser.message
982
- : brand.id === 'trae'
983
- ? t('browserPollingTrae')
984
- : brand.id === 'codearts' ? t('browserPollingCodeArts') : t('browserPolling'))
985
- : null,
986
- browser !== undefined && browser.running === false && browser.error !== undefined
987
- ? h('div', { style: css.err }, browser.error)
988
- : null,
989
- actionError !== undefined ? h('div', { style: css.err }, actionError) : null,
990
- )
991
-
992
- // Trae's region is a saved setting, not a tab: the selector writes the
993
- // same `traeRegion` the provider reads, and the page then re-reads both
994
- // the account and the catalog for the newly selected region.
995
- const regionPicker = brand.id === 'trae'
996
- ? h('div', { style: css.regionBox },
997
- h('div', { style: css.bar },
998
- h('span', { style: css.label }, t('regionLabel')),
999
- h('div', { style: css.seg },
1000
- ...REGIONS.map(option => h('button', {
1001
- key: option.id,
1002
- style: { ...css.segBtn, ...(region === option.id ? css.segOn : {}) },
1003
- disabled: regionBusy || busy,
1004
- onClick: () => {
1005
- if (region === option.id) return
1006
- onRegionChange?.(option.id)
1007
- },
1008
- }, t(option.labelKey))),
1009
- ),
1010
- regionBusy ? h('span', { style: css.note }, t('regionSwitching')) : null,
1011
- ),
1012
- h('div', { style: css.note }, t('regionHint')),
1013
- )
1014
- : null
1015
-
1016
- return h('div', { style: css.card },
1017
- regionPicker,
1018
- h('div', { style: css.cardHead },
1019
- h('h3', { style: css.h3 }, t('accountTitle')),
1020
- h('span', { style: { ...css.note, opacity: 0.75 } }, signedIn ? t('signedIn') : t('notLoggedIn')),
1021
- h('button', {
1022
- style: { ...css.btn, marginLeft: 'auto' },
1023
- disabled: busy,
1024
- onClick: () => { void load(true, true) },
1025
- }, busy ? t('reading') : t('reread')),
1026
- ),
1027
- h('div', { style: css.note }, brand.id === 'loomy'
1028
- ? t('loomyHint')
1029
- : brand.id === 'raccoon'
1030
- ? t('raccoonHint')
1031
- : brand.id === 'codearts' ? t('codeartsHint') : t('traeHint')),
1032
- signedIn
1033
- ? h('div', { style: css.line },
1034
- h('span', { style: css.label }, t('account')),
1035
- h('span', { style: css.value }, accountText),
1036
- )
1037
- : null,
1038
- signedIn
1039
- ? h('div', { style: css.line },
1040
- h('span', { style: css.label }, t('expires')),
1041
- h('span', { style: css.value }, dateLabel(status?.expiresAt)),
1042
- )
1043
- : null,
1044
- signedIn && brand.id === 'raccoon'
1045
- ? h('div', { style: css.line },
1046
- h('span', { style: css.label }, t('raccoonOwner')),
1047
- h('span', { style: css.value }, ownerLabel(t, account.ownerType)),
1048
- )
1049
- : null,
1050
- signedIn ? quotaContent : null,
1051
- checkinPanel,
1052
- status?.possiblyExpired === true ? h('div', { style: css.err }, t('possiblyExpired')) : null,
1053
- !signedIn && status !== undefined
1054
- ? h('div', { style: css.guide },
1055
- h('div', { style: css.guideText }, status?.error ?? signInGuide(t, brand)),
1056
- h('div', { style: css.note }, brand.id === 'trae'
1057
- ? t('loginGuideTrae')
1058
- : brand.id === 'codearts' ? t('loginGuideCodeArts') : t('loginGuide')),
1059
- )
1060
- : null,
1061
- signedIn
1062
- ? h('div', { style: css.bar },
1063
- h('button', {
1064
- style: { ...css.btn, ...css.btnAlert },
1065
- disabled: actionBusy,
1066
- onClick: () => void logout(),
1067
- }, t('loginLogout')),
1068
- )
1069
- : null,
1070
- !signedIn || status?.possiblyExpired === true ? browserPanel : null,
1071
- error !== undefined ? h('div', { style: css.err }, `${t('loadFailed')}:${error}`) : null,
1072
- )
1073
- }
1074
-
1075
- function AdvancedInput({ t, form, fieldKey, label, kind, value, writable }) {
1076
- const [draft, setDraft] = useState(undefined)
1077
- const initial = kind === 'number'
1078
- ? value === undefined ? '' : String(value)
1079
- : kind === 'json'
1080
- ? value === undefined ? (fieldKey.includes('Aliases') ? '{}' : '[]') : JSON.stringify(value, null, 2)
1081
- : value === undefined ? '' : String(value)
1082
- const shown = draft === undefined ? initial : draft
1083
-
1084
- const commit = () => {
1085
- const raw = shown
1086
- if (raw.trim() === '') {
1087
- form.unset(fieldKey).catch(() => undefined)
1088
- return
1089
- }
1090
- if (kind === 'number') {
1091
- const number = Number(raw)
1092
- if (!Number.isFinite(number) || number <= 0) { setDraft(initial); return }
1093
- form.set(fieldKey, Math.floor(number)).catch(() => undefined)
1094
- return
1095
- }
1096
- if (kind === 'json') {
1097
- try {
1098
- const parsed = JSON.parse(raw)
1099
- const expectedArray = fieldKey.includes('Aliases') ? false : true
1100
- if (expectedArray && !Array.isArray(parsed)) { setDraft(initial); return }
1101
- if (!expectedArray && (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))) { setDraft(initial); return }
1102
- form.set(fieldKey, parsed).catch(() => undefined)
1103
- } catch {
1104
- setDraft(initial)
1105
- }
1106
- return
1107
- }
1108
- form.set(fieldKey, raw).catch(() => undefined)
1109
- }
1110
-
1111
- return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
1112
- h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1113
- h('span', { style: css.advLabel }, label),
1114
- value === undefined || !writable
1115
- ? null
1116
- : h('button', {
1117
- style: { fontSize: '0.75em', opacity: 0.55, background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit', color: 'inherit' },
1118
- onClick: () => { form.unset(fieldKey).catch(() => undefined) },
1119
- }, t('reset')),
1120
- ),
1121
- kind === 'json'
1122
- ? h('textarea', {
1123
- style: { ...css.input, minHeight: '64px', fontFamily: 'ui-monospace, monospace', resize: 'vertical' },
1124
- value: shown,
1125
- disabled: !writable,
1126
- onChange: event => setDraft(event.target.value),
1127
- onBlur: commit,
1128
- })
1129
- : h('input', {
1130
- style: css.input,
1131
- type: kind === 'number' ? 'number' : 'text',
1132
- value: shown,
1133
- disabled: !writable,
1134
- onChange: event => setDraft(event.target.value),
1135
- onBlur: commit,
1136
- }),
1137
- h('div', { style: css.advHelp }, t('advancedHint')),
1138
- )
1139
- }
1140
-
1141
- function Advanced({ ctx, t, brand, show, onToggle }) {
1142
- const form = ctx.configForms.get(NS)
1143
- const subscribe = useCallback(listener => form.subscribe(listener), [form])
1144
- const read = useCallback(() => form.getSnapshot(), [form])
1145
- const snapshot = useSyncExternalStore(subscribe, read)
1146
- const value = snapshot?.value ?? {}
1147
- const writable = snapshot?.writable !== false
1148
- const fields = FIELDS[brand.id]
1149
- const labels = t('advancedFields')
1150
- const items = [
1151
- [fields.displayName, labels.displayName, 'text', value[fields.displayName]],
1152
- [fields.vision, labels.visionModelIds, 'json', value[fields.vision]],
1153
- [fields.textOnly, labels.textOnlyModelIds, 'json', value[fields.textOnly]],
1154
- [fields.aliases, labels.modelAliases, 'json', value[fields.aliases]],
1155
- ['defaultMaxTokens', labels.defaultMaxTokens, 'number', value.defaultMaxTokens],
1156
- ]
1157
- return h('div', { style: css.group },
1158
- h('h3', { style: css.h3 }, t('advanced')),
1159
- h('div', { style: css.bar },
1160
- h('button', { style: css.btn, onClick: onToggle }, show ? t('hide') : t('show')),
1161
- h('span', { style: css.note }, t('advancedHint')),
1162
- ),
1163
- show
1164
- ? items.map(([key, label, kind, current]) => h(AdvancedInput, {
1165
- key,
1166
- t,
1167
- form,
1168
- fieldKey: key,
1169
- label,
1170
- kind,
1171
- value: current,
1172
- writable,
1173
- }))
1174
- : null,
1175
- )
1176
- }
1177
-
1178
- function SettingsPage({ ctx, t, brand }) {
1179
- const form = ctx.configForms.get(NS)
1180
- const subscribe = useCallback(listener => form.subscribe(listener), [form])
1181
- const read = useCallback(() => form.getSnapshot(), [form])
1182
- const snapshot = useSyncExternalStore(subscribe, read)
1183
- const fields = FIELDS[brand.id]
1184
- // Trae's two regions have independent accounts AND catalogs, so every
1185
- // snapshot (models and status) is keyed by region as well as by brand.
1186
- const region = brand.id === 'trae' ? regionOf(snapshot?.value?.[fields.region]) : undefined
1187
- const cacheId = region === undefined ? brand.id : `${brand.id}:${region}`
1188
- const cached = readCache(cacheId, 'models')
1189
- const [models, setModels] = useState(() => Array.isArray(cached?.rows) ? cached.rows : undefined)
1190
- const [notice, setNotice] = useState(() => typeof cached?.notice === 'string' ? cached.notice : undefined)
1191
- const [signedIn, setSignedIn] = useState(() => cached?.signedIn === true ? true : undefined)
1192
- const [error, setError] = useState(undefined)
1193
- const [saveStatus, setSaveStatus] = useState(undefined)
1194
- const [busy, setBusy] = useState(false)
1195
- const [query, setQuery] = useState('')
1196
- const [onlyEnabled, setOnlyEnabled] = useState(false)
1197
- const [showAdvanced, setShowAdvanced] = useState(false)
1198
- const [reloadSignal, setReloadSignal] = useState(0)
1199
- const [regionBusy, setRegionBusy] = useState(false)
1200
- const ticket = useRef(0)
1201
- const writeTicket = useRef(0)
1202
-
1203
- const fetchModels = useCallback(async (fresh = false) => {
1204
- const payload = await api(`/models?brand=${brand.id}${fresh ? '&fresh=1' : ''}`)
1205
- return {
1206
- rows: Array.isArray(payload?.models) ? payload.models : [],
1207
- notice: typeof payload?.notice === 'string' ? payload.notice : undefined,
1208
- signedIn: typeof payload?.signedIn === 'boolean' ? payload.signedIn : undefined,
1209
- }
1210
- }, [brand.id])
1211
-
1212
- const adopt = useCallback((rows, nextNotice, nextSignedIn) => {
1213
- setModels(rows)
1214
- setNotice(nextNotice)
1215
- if (nextSignedIn !== undefined) setSignedIn(nextSignedIn)
1216
- writeCache(cacheId, 'models', { rows, notice: nextNotice, signedIn: nextSignedIn, at: Date.now() })
1217
- }, [cacheId])
1218
-
1219
- const load = useCallback(async (fresh = false) => {
1220
- const mine = ++ticket.current
1221
- setError(undefined)
1222
- try {
1223
- const payload = await fetchModels(fresh)
1224
- if (mine !== ticket.current) return
1225
- adopt(payload.rows, payload.notice, payload.signedIn)
1226
- } catch (err) {
1227
- if (mine !== ticket.current) return
1228
- setError(String(err?.message ?? err))
1229
- setModels(previous => previous ?? [])
1230
- }
1231
- }, [adopt, fetchModels])
1232
-
1233
- useEffect(() => {
1234
- const saved = readCache(cacheId, 'models')
1235
- setModels(Array.isArray(saved?.rows) ? saved.rows : undefined)
1236
- setNotice(typeof saved?.notice === 'string' ? saved.notice : undefined)
1237
- setError(undefined)
1238
- void load(false)
1239
- }, [cacheId, load])
1240
-
1241
- // Switching region writes the same field the provider reads, so the host
1242
- // starts answering for the new region immediately; the cacheId change
1243
- // above then restores that region's own account and catalog.
1244
- const changeRegion = useCallback(async next => {
1245
- setRegionBusy(true)
1246
- setError(undefined)
1247
- try {
1248
- const accepted = await withDeadline(form.set(fields.region, next), WRITE_TIMEOUT_MS, t('timeout'))
1249
- if (accepted === false) throw new Error(t('saveFailed'))
1250
- } catch (err) {
1251
- setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1252
- } finally {
1253
- setRegionBusy(false)
1254
- }
1255
- }, [fields.region, form, t])
1256
-
1257
- const handleStatus = useCallback((payload) => {
1258
- if (typeof payload?.signedIn === 'boolean') setSignedIn(payload.signedIn)
1259
- }, [])
1260
-
1261
- const afterRefresh = useCallback(() => {
1262
- void load(true)
1263
- }, [load])
1264
-
1265
- const loadUntil = useCallback(async (verify, attempts = 5) => {
1266
- let rows
1267
- for (let attempt = 0; attempt < attempts; attempt++) {
1268
- try {
1269
- const payload = await fetchModels()
1270
- rows = payload.rows
1271
- adopt(rows, payload.notice, payload.signedIn)
1272
- } catch (err) {
1273
- setError(String(err?.message ?? err))
1274
- return undefined
1275
- }
1276
- if (verify === undefined || verify(rows)) return rows
1277
- await new Promise(resolve => setTimeout(resolve, 120 + attempt * 120))
1278
- }
1279
- return rows
1280
- }, [adopt, fetchModels])
1281
-
1282
- const write = useCallback(async (key, next, verify) => {
1283
- const mine = ++writeTicket.current
1284
- setBusy(true)
1285
- setSaveStatus(t('saving'))
1286
- setError(undefined)
1287
- try {
1288
- const accepted = await withDeadline(next === undefined ? form.unset(key) : form.set(key, next), WRITE_TIMEOUT_MS, t('timeout'))
1289
- if (accepted === false) throw new Error(t('saveFailed'))
1290
- const rows = await loadUntil(verify)
1291
- if (rows !== undefined && verify !== undefined && !verify(rows)) throw new Error(t('notApplied'))
1292
- if (mine === writeTicket.current) setSaveStatus(t('saved'))
1293
- } catch (err) {
1294
- if (mine !== writeTicket.current) return
1295
- setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1296
- setSaveStatus(undefined)
1297
- await load()
1298
- } finally {
1299
- if (mine === writeTicket.current) setBusy(false)
1300
- }
1301
- }, [form, load, loadUntil, t])
1302
-
1303
- const writeDisabled = useCallback((rows, id, enabled) => {
1304
- const next = new Set(rows.filter(model => !model.enabled).map(model => model.id))
1305
- if (enabled) next.delete(id)
1306
- else next.add(id)
1307
- setModels(current => current?.map(model => model.id === id ? { ...model, enabled } : model))
1308
- return write(fields.disabled, [...next], list => list.some(model => model.id === id && model.enabled === enabled))
1309
- }, [fields.disabled, write])
1310
-
1311
- const writeWindow = useCallback((rows, id, window) => {
1312
- const next = {}
1313
- for (const model of rows) if (model.override !== undefined) next[model.id] = model.override
1314
- if (window === undefined) delete next[id]
1315
- else next[id] = window
1316
- setModels(current => current?.map(model => model.id === id
1317
- ? { ...model, override: window, contextWindow: window ?? model.defaultContextWindow }
1318
- : model))
1319
- return write(fields.windows, next, list => list.some(model => model.id === id
1320
- && (window === undefined ? model.override === undefined : model.override === window)))
1321
- }, [fields.windows, write])
1322
-
1323
- const writeAll = useCallback((enabled) => {
1324
- const ids = (models ?? []).map(model => model.id)
1325
- setModels(current => current?.map(model => ({ ...model, enabled })))
1326
- return write(fields.disabled, enabled ? [] : ids, list => list.every(model => model.enabled === enabled))
1327
- }, [fields.disabled, models, write])
1328
-
1329
- const all = models ?? []
1330
- const enabledTotal = all.filter(model => model.enabled).length
1331
- const shown = useMemo(() => {
1332
- const q = query.trim().toLowerCase()
1333
- return all.filter(model => {
1334
- if (onlyEnabled && !model.enabled) return false
1335
- if (q === '') return true
1336
- return String(model.name).toLowerCase().includes(q) || String(model.id).toLowerCase().includes(q)
1337
- })
1338
- }, [all, query, onlyEnabled])
1339
-
1340
- const accountCard = h(AccountCard, {
1341
- t,
1342
- brand,
1343
- region,
1344
- regionBusy,
1345
- onRegionChange: next => { void changeRegion(next) },
1346
- reloadSignal,
1347
- onStatus: handleStatus,
1348
- onAfterRefresh: afterRefresh,
1349
- })
1350
-
1351
- const refreshButton = h('button', {
1352
- style: css.btn,
1353
- disabled: busy,
1354
- onClick: () => {
1355
- setReloadSignal(value => value + 1)
1356
- void load(true)
1357
- },
1358
- }, busy ? t('refreshing') : t('refresh'))
1359
-
1360
- const advanced = h(Advanced, {
1361
- ctx, t, brand, show: showAdvanced,
1362
- onToggle: () => setShowAdvanced(value => !value),
1363
- })
1364
-
1365
- if (models === undefined) {
1366
- return h('div', { style: css.page },
1367
- h('h2', { style: css.h2 }, t(brand.titleKey)),
1368
- h('p', { style: css.intro }, t(brand.introKey)),
1369
- accountCard,
1370
- error === undefined
1371
- ? h('div', { style: css.note }, t('reading'))
1372
- : h('div', null,
1373
- h('div', { style: css.err }, `${t('loadFailed')}:${error}`),
1374
- h('button', { style: { ...css.btn, marginTop: '8px' }, onClick: () => void load(true) }, t('retry')),
1375
- ),
1376
- )
1377
- }
1378
-
1379
- if (signedIn !== true) {
1380
- return h('div', { style: css.page },
1381
- h('h2', { style: css.h2 }, t(brand.titleKey)),
1382
- h('p', { style: css.intro }, t(brand.introKey)),
1383
- accountCard,
1384
- h('div', { style: css.guide },
1385
- h('div', { style: css.guideText }, signInGuide(t, brand)),
1386
- h('div', { style: css.note }, t('modelsHint')),
1387
- ),
1388
- refreshButton,
1389
- advanced,
1390
- error !== undefined ? h('div', { style: css.err }, error) : null,
1391
- )
1392
- }
1393
-
1394
- const rows = shown.map((model, index) => {
1395
- const choices = Array.isArray(model.contextWindows) ? model.contextWindows : []
1396
- const price = priceText(t, model.priceFactor)
1397
- return h('div', {
1398
- key: model.id,
1399
- style: { ...css.row, ...(index === 0 ? css.rowFirst : {}), ...(model.enabled ? {} : css.rowOff) },
1400
- },
1401
- h('input', {
1402
- type: 'checkbox',
1403
- style: css.switch,
1404
- checked: model.enabled === true,
1405
- disabled: busy,
1406
- title: t('colEnabled'),
1407
- onChange: event => { void writeDisabled(all, model.id, event.target.checked) },
1408
- }),
1409
- h('div', { style: css.name },
1410
- h('div', { style: css.nameText },
1411
- model.name,
1412
- // Only native image support gets the tag; relayed capability is
1413
- // explained once below the list instead of on every row.
1414
- model.vision === true && model.visionRelay !== true
1415
- ? h('span', { key: 'v', style: { ...css.tag, marginLeft: '6px' } }, t('vision'))
1416
- : null,
1417
- model.reasoning === true ? h('span', { key: 'r', style: { ...css.tag, marginLeft: '6px' } }, t('reasoning')) : null,
1418
- ...(Array.isArray(model.labels) ? model.labels : []).map((label, labelIndex) =>
1419
- h('span', { key: `l${labelIndex}`, style: { ...css.tag, marginLeft: '6px' } }, label)),
1420
- model.badge !== undefined
1421
- ? h('span', { key: 'b', style: { ...css.tag, marginLeft: '6px' } }, model.badge)
1422
- : null,
1423
- price !== '' ? h('span', { key: 'p', style: css.price }, ` · ${price}`) : null,
1424
- ),
1425
- h('div', { style: css.id }, model.id),
1426
- ),
1427
- h('select', {
1428
- style: css.select,
1429
- value: model.override === undefined ? '' : String(model.override),
1430
- disabled: busy,
1431
- title: t('windowDefaultHint'),
1432
- onChange: event => {
1433
- const value = event.target.value
1434
- void writeWindow(all, model.id, value === '' ? undefined : Number(value))
1435
- },
1436
- },
1437
- h('option', { key: 'default', value: '' }, `${t('windowDefault')} · ${windowLabel(model.defaultContextWindow)}`),
1438
- choices.map(value => h('option', { key: String(value), value: String(value) }, windowLabel(value))),
1439
- ),
1440
- )
1441
- })
1442
-
1443
- return h('div', { style: css.page },
1444
- h('h2', { style: css.h2 }, t(brand.titleKey)),
1445
- h('p', { style: css.intro }, t(brand.introKey)),
1446
- accountCard,
1447
- h('div', { style: css.bar },
1448
- h('input', {
1449
- style: { ...css.input, ...css.search },
1450
- placeholder: t('search'),
1451
- value: query,
1452
- onChange: event => setQuery(event.target.value),
1453
- }),
1454
- h('label', { style: { ...css.note, display: 'flex', alignItems: 'center', gap: '6px', whiteSpace: 'nowrap' } },
1455
- h('input', { type: 'checkbox', checked: onlyEnabled, onChange: event => setOnlyEnabled(event.target.checked) }),
1456
- t('onlyEnabled'),
1457
- ),
1458
- ),
1459
- h('div', { style: { ...css.bar, flexWrap: 'nowrap' } },
1460
- refreshButton,
1461
- h('button', {
1462
- style: css.btn,
1463
- disabled: busy || all.every(model => model.enabled),
1464
- onClick: () => void writeAll(true),
1465
- }, t('selectAll')),
1466
- h('button', {
1467
- style: css.btn,
1468
- disabled: busy || enabledTotal === 0,
1469
- onClick: () => void writeAll(false),
1470
- }, t('selectNone')),
1471
- h('span', { style: css.count }, `${t('enabledCount')} ${enabledTotal} / ${all.length}`),
1472
- ),
1473
- all.length === 0
1474
- ? h('div', { style: css.guide },
1475
- h('div', { style: css.guideText }, brand.id === 'loomy'
1476
- ? t('modelsEmptyLoomy')
1477
- : brand.id === 'raccoon'
1478
- ? t('modelsEmptyRaccoon')
1479
- : brand.id === 'codearts' ? t('modelsEmptyCodeArts') : t('modelsEmptyTrae')),
1480
- )
1481
- : h('div', { style: css.list }, rows.length > 0
1482
- ? rows
1483
- : h('div', { style: { ...css.note, padding: '12px 14px' } }, '—')),
1484
- all.length > 0 && brand.id === 'codearts'
1485
- ? h('div', { style: css.note }, t('codeartsVisionHint'))
1486
- : null,
1487
- notice !== undefined ? h('div', { style: css.note }, `${t('catalogNotice')}:${notice}`) : null,
1488
- all.length > 0 ? h('div', { style: css.note }, t('windowWarn')) : null,
1489
- error !== undefined ? h('div', { style: css.err }, error) : null,
1490
- saveStatus !== undefined && error === undefined
1491
- ? h('div', { style: css.note }, saveStatus)
1492
- : null,
1493
- advanced,
1494
- )
1495
- }
1496
-
1497
- function signInGuide(t, brand) {
1498
- if (brand.id === 'loomy') return t('loomyLoginMissing')
1499
- if (brand.id === 'raccoon') return t('raccoonLoginMissing')
1500
- return brand.id === 'codearts' ? t('codeartsLoginMissing') : t('traeLoginMissing')
1501
- }
1502
-
1503
- function BundleSettings({ ctx, t, view }) {
1504
- const form = ctx.configForms.get(NS)
1505
- const subscribe = useCallback(listener => form.subscribe(listener), [form])
1506
- const read = useCallback(() => form.getSnapshot(), [form])
1507
- const snapshot = useSyncExternalStore(subscribe, read)
1508
- const value = snapshot?.value ?? {}
1509
- const writable = snapshot?.writable !== false
1510
- const [busy, setBusy] = useState(false)
1511
- const [error, setError] = useState(undefined)
1512
- const [status, setStatus] = useState(undefined)
1513
- const [openRouter, setOpenRouter] = useState(undefined)
1514
- const [syncing, setSyncing] = useState(false)
1515
- const [routerError, setRouterError] = useState(undefined)
1516
- const [routerNotice, setRouterNotice] = useState(undefined)
1517
- const ticket = useRef(0)
1518
- const syncInterval = typeof value.openRouterSyncMinutes === 'number' ? value.openRouterSyncMinutes : 60
1519
- const checkinInterval = typeof value.raccoonCheckinMinutes === 'number' ? value.raccoonCheckinMinutes : 30
1520
-
1521
- const loadOpenRouter = useCallback(async () => {
1522
- try {
1523
- setOpenRouter(await api('/openrouter', { timeout: 10_000 }))
1524
- } catch (err) {
1525
- setRouterError(String(err?.message ?? err))
1526
- }
1527
- }, [])
1528
-
1529
- useEffect(() => { void loadOpenRouter() }, [loadOpenRouter])
1530
-
1531
- const syncOpenRouter = useCallback(async () => {
1532
- setSyncing(true)
1533
- setRouterError(undefined)
1534
- setRouterNotice(undefined)
1535
- try {
1536
- // The host may try twice with its own 30 s window; this deadline has
1537
- // to outlast that, or the page reports a timeout while a retry is
1538
- // still running and the user sees a failure that never happened.
1539
- const payload = await api('/openrouter/sync', { method: 'POST', timeout: 75_000 })
1540
- setOpenRouter(payload)
1541
- if (payload?.ok === true) {
1542
- setRouterNotice(`${t('openRouterSyncDone')} · ${payload.models} ${t('openRouterModels')}`)
1543
- } else {
1544
- setRouterError(`${t('openRouterSyncFailed')}:${payload?.error ?? '—'}`)
1545
- }
1546
- } catch (err) {
1547
- setRouterError(`${t('openRouterSyncFailed')}:${String(err?.message ?? err)}`)
1548
- } finally {
1549
- setSyncing(false)
1550
- }
1551
- }, [t])
1552
-
1553
- const setMinutes = useCallback(async (key, next) => {
1554
- setStatus(t('saving'))
1555
- setError(undefined)
1556
- try {
1557
- const accepted = await withDeadline(form.set(key, next), WRITE_TIMEOUT_MS, t('timeout'))
1558
- if (accepted === false) throw new Error(t('pluginOptionsRejected'))
1559
- setStatus(t('saved'))
1560
- } catch (err) {
1561
- setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1562
- setStatus(undefined)
1563
- }
1564
- }, [form, t])
1565
-
1566
- const setSyncInterval = useCallback(next => setMinutes('openRouterSyncMinutes', next), [setMinutes])
1567
- const setCheckinInterval = useCallback(next => setMinutes('raccoonCheckinMinutes', next), [setMinutes])
1568
-
1569
- const toggle = useCallback(async (key, next) => {
1570
- const mine = ++ticket.current
1571
- setBusy(true)
1572
- setStatus(t('saving'))
1573
- setError(undefined)
1574
- try {
1575
- const accepted = await withDeadline(form.set(key, next), WRITE_TIMEOUT_MS, t('timeout'))
1576
- if (accepted === false) throw new Error(t('pluginOptionsRejected'))
1577
- if (mine === ticket.current) setStatus(t('saved'))
1578
- } catch (err) {
1579
- if (mine !== ticket.current) return
1580
- setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1581
- setStatus(undefined)
1582
- } finally {
1583
- if (mine === ticket.current) setBusy(false)
1584
- }
1585
- }, [form, t])
1586
-
1587
- if (view !== undefined && view !== 'page') {
1588
- const names = BRANDS
1589
- .filter(brand => value[FIELDS[brand.id].enabled] !== false)
1590
- .map(brand => {
1591
- const stored = value[FIELDS[brand.id].displayName]
1592
- return typeof stored === 'string' && stored !== '' ? stored : t(brand.navKey)
1593
- })
1594
- return h('span', null, names.join(' · '))
1595
- }
1596
-
1597
- return h('div', { style: css.group },
1598
- h('h3', { style: css.h3 }, t('pluginOptionsTitle')),
1599
- h('div', { style: css.note }, t('pluginOptionsHint')),
1600
- BRANDS.map((brand, index) => {
1601
- const key = FIELDS[brand.id].enabled
1602
- const enabled = value[key] !== false
1603
- const stored = value[FIELDS[brand.id].displayName]
1604
- const name = typeof stored === 'string' && stored !== '' ? stored : t(brand.navKey)
1605
- return h('div', { key: brand.id, style: { ...css.optionRow, ...(index === 0 ? css.optionFirst : {}) } },
1606
- h('input', {
1607
- type: 'checkbox',
1608
- style: css.switch,
1609
- checked: enabled,
1610
- disabled: busy || !writable,
1611
- onChange: event => { void toggle(key, event.target.checked) },
1612
- }),
1613
- h('div', { style: css.optionText },
1614
- h('div', { style: css.optionName }, name),
1615
- h('div', { style: css.optionStatus }, enabled ? t('pluginOptionsOn') : t('pluginOptionsOff')),
1616
- ),
1617
- )
1618
- }),
1619
- !writable ? h('div', { style: css.note }, t('pluginOptionsReadOnly')) : null,
1620
- h('div', { style: css.section },
1621
- h('h3', { style: css.h3 }, t('openRouterTitle')),
1622
- h('div', { style: css.note }, t('openRouterHint')),
1623
- h('div', { style: css.bar },
1624
- h('span', { style: css.label }, t('openRouterInterval')),
1625
- h('select', {
1626
- style: css.select,
1627
- value: String(syncInterval),
1628
- disabled: busy || !writable,
1629
- onChange: event => { void setSyncInterval(Number(event.target.value)) },
1630
- },
1631
- OPENROUTER_INTERVALS.map(option => h('option', {
1632
- key: String(option.value),
1633
- value: String(option.value),
1634
- }, t(option.labelKey))),
1635
- ),
1636
- h('button', {
1637
- style: { ...css.btn, ...css.btnPrimary },
1638
- disabled: syncing || !writable,
1639
- onClick: () => { void syncOpenRouter() },
1640
- }, syncing ? t('openRouterSyncing') : t('openRouterSyncNow')),
1641
- ),
1642
- h('div', { style: css.bar },
1643
- h('span', { style: css.note }, openRouter === undefined
1644
- ? t('openRouterNever')
1645
- : `${openRouter.models ?? 0} ${t('openRouterModels')}${typeof openRouter.at === 'number' ? ` · ${t('openRouterLastSync')} ${dateLabel(openRouter.at)}` : ''}`),
1646
- typeof openRouter?.error === 'string'
1647
- ? h('span', { style: css.err }, `${t('openRouterSyncFailed')}:${openRouter.error}`)
1648
- : null,
1649
- ),
1650
- routerNotice !== undefined ? h('div', { style: css.note }, routerNotice) : null,
1651
- routerError !== undefined ? h('div', { style: css.err }, routerError) : null,
1652
- ),
1653
- h('div', { style: css.section },
1654
- h('h3', { style: css.h3 }, t('checkinMonitorTitle')),
1655
- h('div', { style: css.note }, t('checkinMonitorHint')),
1656
- h('div', { style: css.bar },
1657
- h('span', { style: css.label }, t('checkinInterval')),
1658
- h('select', {
1659
- style: css.select,
1660
- value: String(checkinInterval),
1661
- disabled: busy || !writable,
1662
- onChange: event => { void setCheckinInterval(Number(event.target.value)) },
1663
- },
1664
- CHECKIN_INTERVALS.map(option => h('option', {
1665
- key: String(option.value),
1666
- value: String(option.value),
1667
- }, t(option.labelKey))),
1668
- ),
1669
- ),
1670
- ),
1671
- error !== undefined ? h('div', { style: css.err }, error) : null,
1672
- status !== undefined && error === undefined ? h('div', { style: css.note }, status) : null,
1673
- )
1674
- }
1675
-
1676
- function apply(ctx) {
1677
- const t = ctx.locale.bind(NS)
1678
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'llm-adapters: dictionaries')
1679
-
1680
- ctx.effect(() => ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
1681
- name: 'plugins.bundle.config',
1682
- key: 'dsh-llm-adapters',
1683
- locale: NS,
1684
- }, props => h(BundleSettings, { ...props, ctx, t }))), 'llm-adapters: bundle options')
1685
-
1686
- ctx.effect(() => ctx.configForms.whileServed([NS], () => {
1687
- const form = ctx.configForms.get(NS)
1688
- const live = new Map()
1689
-
1690
- const sync = () => {
1691
- const snapshot = form.getSnapshot()
1692
- if (snapshot.status !== 'ready') return
1693
- const value = snapshot.value ?? {}
1694
- for (const brand of BRANDS) {
1695
- const enabled = value[FIELDS[brand.id].enabled] !== false
1696
- const current = live.get(brand.id)
1697
- if (enabled && current === undefined) {
1698
- live.set(brand.id, ctx.slots.inject('settings.section', () => ctx.slots.register({
1699
- name: 'settings.section',
1700
- id: brand.settingsId,
1701
- order: brand.order,
1702
- label: () => t(brand.navKey),
1703
- locale: NS,
1704
- }, props => h(SettingsPage, { ...props, ctx, t, brand }))))
1705
- } else if (!enabled && current !== undefined) {
1706
- current()
1707
- live.delete(brand.id)
1708
- }
1709
- }
1710
- }
1711
-
1712
- const unsubscribe = form.subscribe(sync)
1713
- sync()
1714
- return () => {
1715
- unsubscribe()
1716
- for (const off of live.values()) off()
1717
- live.clear()
1718
- }
1719
- }), 'llm-adapters: pages')
1720
- }
1721
-
1722
- exports.apply = apply
1723
- exports.inject = inject
1724
- exports.name = 'dsh-llm-adapters'
1725
- exports.__test = {
1726
- BRANDS, FIELDS, REGIONS, OPENROUTER_INTERVALS, CHECKIN_INTERVALS, regionOf, zh, en, css, windowLabel, creditsOf,
1727
- amountText,
1728
- meterPercentOf, percentLabel, ownerLabel, priceText, dateLabel, readCache, writeCache, AccountCard,
1729
- SettingsPage, BundleSettings,
1730
- }
1731
- return module.exports
1732
- },
1733
- })
1
+ /**
2
+ * dsh-llm-adapters — browser half.
3
+ *
4
+ * One settings section per provider. Each follows the same shape as
5
+ * dsh-agent-sdk-bridge: an account card first, then the live catalog with per
6
+ * model on/off and context-window controls, then a collapsed advanced area.
7
+ *
8
+ * The account card is read-only by design. It never asks for a password or a
9
+ * token; sign-in happens on the provider's own page (a WeChat QR code for Loomy
10
+ * and Raccoon, a dedicated browser window for Trae), and the card only reports
11
+ * the resulting account, quota and check-in state.
12
+ *
13
+ * Trae is one card with a region switch: `traeRegion` is a setting, not a
14
+ * second brand, so both regions share this page and keep separate accounts.
15
+ *
16
+ * Hand-written ModuleLoader bundle: no build step, no dependency beyond the
17
+ * `react` the shell provides.
18
+ */
19
+
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-llm-adapters',
22
+ factory: require => {
23
+ const module = { exports: {} }
24
+ const exports = module.exports
25
+ const React = require('react')
26
+ const { createElement: h, useState, useEffect, useCallback, useMemo, useRef, useSyncExternalStore } = React
27
+
28
+ const NS = 'llm-adapters'
29
+ const inject = ['slots', 'locale', 'configForms']
30
+
31
+ const zh = {
32
+ pluginOptionsTitle: '启用的供应商',
33
+ pluginOptionsHint: '关闭后,对应设置页、模型列表与模型分组都会移除;登录态与已保存的模型选择会保留。',
34
+ pluginOptionsOn: '显示中',
35
+ pluginOptionsOff: '已关闭',
36
+ pluginOptionsReadOnly: '当前配置不可写',
37
+ pluginOptionsRejected: '宿主拒绝了这次改动;若刚更新过插件,请重启 DSH 后再试',
38
+ loadFailed: '读取失败',
39
+ retry: '重试',
40
+ refresh: '刷新状态与模型',
41
+ refreshing: '刷新中…',
42
+ saveFailed: '保存失败',
43
+ notApplied: '服务器未报告这次改动,可能被其他窗口覆盖了,请刷新后重试',
44
+ timeout: '保存超时,请重试',
45
+ saved: '已保存',
46
+ saving: '保存中…',
47
+ enabledCount: '已启用',
48
+ search: '搜索模型…',
49
+ onlyEnabled: '只看已启用',
50
+ selectAll: '全部启用',
51
+ selectNone: '全部关闭',
52
+ colEnabled: '启用',
53
+ windowDefault: '默认',
54
+ windowDefaultHint: '跟随上游声明的窗口',
55
+ vision: '读图',
56
+ reasoning: '思考',
57
+ free: '免费',
58
+ advanced: '高级设置',
59
+ advancedHint: '以下为细节参数,通常无需改动。',
60
+ show: '展开',
61
+ hide: '收起',
62
+ reset: '恢复默认',
63
+ windowWarn: '窗口越大,DSH 的自动压缩触发得越晚。若对话经常超限,请选较小的窗口。',
64
+ accountTitle: '登录与账号',
65
+ account: '账号',
66
+ accountUnknown: '账号信息未提供',
67
+ expires: '预计到期',
68
+ quota: '额度',
69
+ quotaRefreshing: '正在读取额度…',
70
+ quotaUnavailable: '暂时无法读取额度',
71
+ quotaFailed: '额度读取失败',
72
+ points: '积分',
73
+ loomyAvailable: '可用',
74
+ loomyPermanent: '永久积分',
75
+ loomyDaily: '每日积分',
76
+ loomyCycle: '每日周期',
77
+ raccoonOwner: '账号类型',
78
+ ownerPersonal: '个人',
79
+ ownerEnterprise: '企业',
80
+ signedIn: '已登录',
81
+ notLoggedIn: '未登录',
82
+ reread: '重新读取登录态',
83
+ reading: '读取中…',
84
+ loginGuide: '点“打开扫码登录”,用微信扫码后会自动接回。',
85
+ browserTitle: '微信扫码登录',
86
+ browserOpen: '打开扫码登录',
87
+ browserWaiting: '等待扫码…',
88
+ browserCancel: '取消',
89
+ browserReopen: '重新打开登录页',
90
+ browserPolling: '登录页已在浏览器打开:用微信扫码并在手机上确认,成功后会自动接回。若弹窗被拦截,点“重新打开登录页”。',
91
+ browserHintLoomy: '会打开一个本地登录页:用微信扫码登录 Loomy;首次使用的微信账号需要在页面里绑定手机号。',
92
+ browserHintRaccoon: '会打开一个本地登录页:用微信扫码登录小浣熊,扫码确认后插件会自动接回。',
93
+ loomyHint: 'Loomy 会话约 14 天有效,且没有刷新令牌;过期后再点“打开扫码登录”即可。',
94
+ raccoonHint: '令牌约 30 天有效。每日积分由服务端在当天首次打开网页时发放;插件只检测到账情况(点「检测今日积分」读今日流水),不会替你触发领取。没到账就点「打开网页签到」到官网领。',
95
+ loginLogout: '退出登录',
96
+ loginConfirmLogout: '确定要退出当前账号吗?',
97
+ loginFailedPrefix: '登录失败',
98
+ loomyLoginMissing: 'Loomy 尚未登录。请点「打开扫码登录」用微信扫码。',
99
+ raccoonLoginMissing: '小浣熊尚未登录。请点「打开扫码登录」用微信扫码。',
100
+ possiblyExpired: '登录态可能已过期',
101
+ modelsEmptyLoomy: 'Loomy 没有返回模型。请确认账号已登录,然后点“重新读取登录态”。',
102
+ modelsEmptyRaccoon: '小浣熊没有返回模型。请确认账号已登录,然后点“重新读取登录态”。',
103
+ modelsHint: '登录后才会读取并显示可用模型。',
104
+ catalogNotice: '模型列表提示',
105
+ advancedFields: {
106
+ displayName: '分组名称',
107
+ visionModelIds: '强制启用读图的模型 ID(JSON 数组)',
108
+ textOnlyModelIds: '强制按纯文本处理的模型 ID(JSON 数组)',
109
+ modelAliases: '模型别名(JSON 对象)',
110
+ defaultMaxTokens: '默认输出上限',
111
+ },
112
+ loomyNav: 'Loomy',
113
+ loomyTitle: 'Loomy',
114
+ loomyIntro: '点“打开扫码登录”,用微信扫码登录 Loomy 即可使用,无需安装客户端。改动即时生效,无需重启。',
115
+ raccoonNav: '商汤小浣熊',
116
+ raccoonTitle: '商汤小浣熊',
117
+ raccoonIntro: '点“打开扫码登录”,用微信扫码登录小浣熊即可使用,无需安装客户端。改动即时生效,无需重启。',
118
+ traeNav: 'Trae',
119
+ traeTitle: 'Trae',
120
+ traeIntro: '先选国内版或国际版,再任选一种登录方式:在 Trae 官网网页登录,或复用本机 Trae 客户端的登录态。网页登录会用网页会话自动续期;只有复用客户端登录态时,国内版才显示签到按钮。改动即时生效,无需重启。',
121
+ traeHint: '网页登录的令牌约 8 小时,插件会用登录时拿到的网页会话自动续期;复用本机客户端登录态则直接使用客户端令牌,客户端退出登录或令牌过期后重新复用即可。签到仅在国内版、且使用客户端登录态时可用。',
122
+ traeLoginMissing: 'Trae 尚未登录。可以点“打开浏览器登录”,或复用本机已登录的 Trae 客户端。',
123
+ traeClientTitle: '复用本机客户端登录',
124
+ traeClientHint: '检测到本机已安装 Trae 客户端。这里只读取客户端当前的登录态,不会修改或退出客户端;复用后会替换插件里的当前登录态。用这种方式登录后才显示签到按钮。',
125
+ traeClientReady: '本机客户端已登录,可直接复用。',
126
+ traeClientNotLoggedIn: '检测到本机 Trae 客户端,但尚未登录。请先打开客户端完成登录,再点“重新读取登录态”。',
127
+ traeClientAccount: '客户端账号',
128
+ traeClientUse: '复用客户端登录态',
129
+ traeClientReuse: '重新读取客户端登录态',
130
+ traeClientBusy: '正在读取…',
131
+ traeClientFailed: '读取客户端登录态失败',
132
+ traeClientSource: '登录方式',
133
+ traeSourceWeb: '网页登录',
134
+ traeSourceClient: '本机客户端',
135
+ traeClientLogoutNote: '退出登录只会清除插件里的登录态,不会退出本机 Trae 客户端。',
136
+ regionLabel: '区域',
137
+ regionCn: '国内版 · trae.cn',
138
+ regionGlobal: '国际版 · trae.ai',
139
+ regionHint: '两个区域各自保存账号:切换区域不会退出另一个区域的登录。',
140
+ regionSwitching: '正在切换区域…',
141
+ checkin: '签到',
142
+ checkinClaim: '签到领积分',
143
+ checkinDetect: '检测今日积分',
144
+ checkinDetecting: '检测中…',
145
+ checkinBusy: '签到中…',
146
+ checkinClaimed: '今日已签到',
147
+ checkinDevice: '本机今日已签到',
148
+ checkinReward: '可得',
149
+ checkinReceived: '已到账',
150
+ checkinGrantedAt: '到账时间',
151
+ checkinOpenWeb: '打开网页签到',
152
+ checkinFailed: '签到失败',
153
+ checkinUnavailable: '当前账号没有可用的签到活动',
154
+ checkinMonitorTitle: '小浣熊每日积分(只检测)',
155
+ checkinMonitorHint: '插件只读今日积分流水做检测,不会替你触发领取;没到账就去官网用「打开网页签到」领。',
156
+ plan: '套餐',
157
+ planFree: '免费版',
158
+ planTrial: '试用中',
159
+ trialEnds: '试用到期',
160
+ packs: '资源包',
161
+ packExpires: '到期',
162
+ packUsage: '已用 / 总额',
163
+ packNearest: '最近到期',
164
+ packDetail: '详细信息',
165
+ packCollapse: '收起',
166
+ usedOf: '已用',
167
+ usageReset: '重置时间',
168
+ browserHintTrae: '点下面的按钮会打开一个独立的浏览器窗口(不会动你自己的浏览器配置):在其中登录 Trae,登录成功后窗口会自动关闭。国际版若提示 account is at risk,请点 Log in 用你平时能登录的同一个账号(Google/GitHub)进入,不要用新邮箱重新注册;独立配置首次登录会被 Trae 当成新设备。',
169
+ browserPollingTrae: '浏览器已打开:请在窗口里登录 Trae,成功后会自动接回,这个窗口随后会关闭。',
170
+ browserTitleTrae: '浏览器登录',
171
+ browserOpenTrae: '打开浏览器登录',
172
+ browserWaitingTrae: '等待登录…',
173
+ loginGuideTrae: '点“打开浏览器登录”,在弹出的窗口里登录 Trae 即可;登录成功后会自动接回。国际版请用 Log in 登录已有账号,不要新建邮箱账号,否则容易触发 account is at risk。',
174
+ modelsEmptyTrae: '账号没有返回任何 Trae 模型。请确认账号已登录,然后点“重新读取登录态”。',
175
+ codeartsNav: '华为云 CodeArts',
176
+ codeartsTitle: '华为云 CodeArts',
177
+ codeartsIntro: '点“打开浏览器登录”在华为云完成授权,插件用授权得到的 AK/SK 签名调用 CodeArts 网关,不读本机 IDE 的任何文件。改动即时生效,无需重启。',
178
+ codeartsHint: 'CodeArts 用签名而不是令牌调用,STS 令牌两小时有效,到期会自动用续期令牌续上;续期令牌失效后重新登录一次即可。积分计费账户每天可以签到领积分。',
179
+ codeartsVisionHint: '「读图」只标记模型原生支持图片。没有这个标记的模型也能收图:发送时会先由 kimi-k2.6-vl 把图片转述成文字,识别结果按图片内容缓存,同一张图只多花一次对话。',
180
+ browserHintCodeArts: '点下面的按钮会在浏览器里打开华为云授权页:登录并同意授权后会自动回到本机,凭据由宿主接回,页面上不需要复制任何东西。',
181
+ browserPollingCodeArts: '已打开华为云授权页:完成授权后会自动接回,这个标签页可以关闭。',
182
+ browserTitleCodeArts: '华为云授权登录',
183
+ browserOpenCodeArts: '打开浏览器登录',
184
+ browserWaitingCodeArts: '等待授权…',
185
+ loginGuideCodeArts: '点“打开浏览器登录”,在弹出的华为云页面里完成授权即可;成功后会自动接回。',
186
+ codeartsLoginMissing: 'CodeArts 尚未登录。请点“打开浏览器登录”,在华为云页面完成授权。',
187
+ modelsEmptyCodeArts: '账号没有返回任何 CodeArts 模型。请确认已登录,然后点“重新读取登录态”。',
188
+ openRouterTitle: 'OpenRouter 模型同步',
189
+ openRouterHint: 'OpenRouter 的实时模型表用来补充思考档位与读图能力,三个供应商共用一份。缓存保存在本机,连不上时会继续使用上次成功同步的数据;刷新模型不会清空缓存。',
190
+ openRouterInterval: '自动同步',
191
+ openRouterOff: '关闭自动同步',
192
+ openRouterEvery15: '每 15 分钟',
193
+ openRouterEvery60: '每小时',
194
+ openRouterEvery360: '每 6 小时',
195
+ openRouterEvery1440: '每 24 小时',
196
+ openRouterSyncNow: '立即同步',
197
+ openRouterSyncing: '同步中…',
198
+ openRouterNever: '尚未同步',
199
+ openRouterModels: '个模型',
200
+ openRouterLastSync: '上次同步',
201
+ openRouterSyncFailed: '同步失败',
202
+ openRouterSyncDone: '已同步',
203
+ }
204
+ const en = {
205
+ pluginOptionsTitle: 'Enabled providers',
206
+ pluginOptionsHint: 'Turning one off removes its settings page, model list and model group. Login state and saved model choices are kept.',
207
+ pluginOptionsOn: 'Shown',
208
+ pluginOptionsOff: 'Off',
209
+ pluginOptionsReadOnly: 'Configuration is read-only',
210
+ pluginOptionsRejected: 'The host rejected this change; restart DSH after a plugin update and retry',
211
+ loadFailed: 'Could not load',
212
+ retry: 'Retry',
213
+ refresh: 'Refresh status and models',
214
+ refreshing: 'Refreshing…',
215
+ saveFailed: 'Save failed',
216
+ notApplied: 'The host did not report this change; another window may have overwritten it. Refresh and retry.',
217
+ timeout: 'Save timed out; please retry',
218
+ saved: 'Saved',
219
+ saving: 'Saving…',
220
+ enabledCount: 'enabled',
221
+ search: 'Search models…',
222
+ onlyEnabled: 'Enabled only',
223
+ selectAll: 'Enable all',
224
+ selectNone: 'Disable all',
225
+ colEnabled: 'Enabled',
226
+ windowDefault: 'Default',
227
+ windowDefaultHint: 'Follow the upstream context window',
228
+ vision: 'vision',
229
+ reasoning: 'reasoning',
230
+ free: 'free',
231
+ advanced: 'Advanced',
232
+ advancedHint: 'Rarely needed; read the help text before changing.',
233
+ show: 'Show',
234
+ hide: 'Hide',
235
+ reset: 'Reset',
236
+ windowWarn: 'A larger window delays automatic compaction. Choose a smaller value if conversations often overflow.',
237
+ accountTitle: 'Sign-in',
238
+ account: 'Account',
239
+ accountUnknown: 'Account details are unavailable',
240
+ expires: 'Expires (estimated)',
241
+ quota: 'Quota',
242
+ quotaRefreshing: 'Reading quota…',
243
+ quotaUnavailable: 'Quota is temporarily unavailable',
244
+ quotaFailed: 'Could not read quota',
245
+ points: 'points',
246
+ loomyAvailable: 'Available',
247
+ loomyPermanent: 'Permanent',
248
+ loomyDaily: 'Daily',
249
+ loomyCycle: 'Daily cycle',
250
+ raccoonOwner: 'Account type',
251
+ ownerPersonal: 'Personal',
252
+ ownerEnterprise: 'Enterprise',
253
+ signedIn: 'Signed in',
254
+ notLoggedIn: 'Not signed in',
255
+ reread: 'Re-read login state',
256
+ reading: 'Reading…',
257
+ loginGuide: 'Use “Open QR sign-in” and scan the code in WeChat; the login is adopted automatically.',
258
+ browserTitle: 'WeChat QR sign-in',
259
+ browserOpen: 'Open QR sign-in',
260
+ browserWaiting: 'Waiting for scan…',
261
+ browserCancel: 'Cancel',
262
+ browserReopen: 'Reopen the sign-in page',
263
+ browserPolling: 'The sign-in page is open in your browser. Scan the code in WeChat and confirm on your phone; the plugin adopts the login automatically. If the popup was blocked, use “Reopen the sign-in page”.',
264
+ browserHintLoomy: 'A local sign-in page opens: scan it in WeChat to sign in to Loomy. A WeChat account using Loomy for the first time must bind a phone number on that page.',
265
+ browserHintRaccoon: 'A local sign-in page opens: scan it in WeChat to sign in to Raccoon, and the plugin adopts the login once you confirm.',
266
+ loomyHint: 'A Loomy session lasts about 14 days and has no refresh token; use “Open QR sign-in” again when it expires.',
267
+ raccoonHint: 'The QR login lasts about 30 days. Daily points are issued by the server on the day’s first website visit; the plugin only detects them (“Check today’s credits” reads today’s ledger) and never claims on your behalf. Use “Open check-in page” to claim on the website.',
268
+ loginLogout: 'Sign out',
269
+ loginConfirmLogout: 'Sign out of this account?',
270
+ loginFailedPrefix: 'Sign-in failed',
271
+ loomyLoginMissing: 'Loomy is not signed in. Use “Open QR sign-in” and scan the code in WeChat.',
272
+ raccoonLoginMissing: 'Raccoon is not signed in. Use “Open QR sign-in” and scan the code in WeChat.',
273
+ possiblyExpired: 'The login may have expired',
274
+ modelsEmptyLoomy: 'Loomy returned no models. Confirm the account is signed in, then re-read the login state.',
275
+ modelsEmptyRaccoon: 'Raccoon returned no models. Confirm the account is signed in, then re-read the login state.',
276
+ modelsHint: 'Models are read only after sign-in.',
277
+ catalogNotice: 'Model list notice',
278
+ advancedFields: {
279
+ displayName: 'Group name',
280
+ visionModelIds: 'Force vision model IDs (JSON array)',
281
+ textOnlyModelIds: 'Force text-only model IDs (JSON array)',
282
+ modelAliases: 'Model aliases (JSON object)',
283
+ defaultMaxTokens: 'Default output limit',
284
+ },
285
+ loomyNav: 'Loomy',
286
+ loomyTitle: 'Loomy',
287
+ loomyIntro: 'Use “Open QR sign-in” and scan the code in WeChat; no client install needed. Changes apply immediately.',
288
+ raccoonNav: 'SenseTime Raccoon',
289
+ raccoonTitle: 'SenseTime Raccoon',
290
+ raccoonIntro: 'Use “Open QR sign-in” and scan the code in WeChat; no client install needed. Changes apply immediately.',
291
+ traeNav: 'Trae',
292
+ traeTitle: 'Trae',
293
+ traeIntro: 'Pick the mainland or international edition, then use either method: sign in on Trae’s website, or reuse the local Trae client’s sign-in state. A browser login renews itself; the mainland check-in button appears only with the client sign-in. Changes apply immediately.',
294
+ traeHint: 'A browser login uses the website session captured at sign-in; a client login uses the desktop client’s token and is imported again after the client refreshes or signs out. Check-in is mainland-only and client-login-only.',
295
+ traeLoginMissing: 'Trae is not signed in. Use browser sign-in, or reuse a signed-in local Trae client.',
296
+ traeClientTitle: 'Reuse the local client sign-in',
297
+ traeClientHint: 'A Trae desktop client was found. This only reads its current sign-in state and never modifies or signs out the client; reusing it replaces the plugin’s current login. The check-in button appears only after signing in this way.',
298
+ traeClientReady: 'The local client is signed in and ready to reuse.',
299
+ traeClientNotLoggedIn: 'A Trae desktop client was found but is not signed in. Sign in there, then re-read the login state.',
300
+ traeClientAccount: 'Client account',
301
+ traeClientUse: 'Reuse client sign-in',
302
+ traeClientReuse: 'Re-read client sign-in',
303
+ traeClientBusy: 'Reading…',
304
+ traeClientFailed: 'Could not read the client sign-in',
305
+ traeClientSource: 'Sign-in method',
306
+ traeSourceWeb: 'Browser',
307
+ traeSourceClient: 'Local client',
308
+ traeClientLogoutNote: 'Signing out only clears the plugin’s login; the local Trae client stays signed in.',
309
+ regionLabel: 'Region',
310
+ regionCn: 'Mainland · trae.cn',
311
+ regionGlobal: 'International · trae.ai',
312
+ regionHint: 'Each region keeps its own account: switching regions does not sign out the other one.',
313
+ regionSwitching: 'Switching region…',
314
+ checkin: 'Check-in',
315
+ checkinClaim: 'Check in for credits',
316
+ checkinDetect: 'Check today’s credits',
317
+ checkinDetecting: 'Checking…',
318
+ checkinBusy: 'Checking in…',
319
+ checkinClaimed: 'Already checked in today',
320
+ checkinDevice: 'This device checked in today',
321
+ checkinReward: 'reward',
322
+ checkinReceived: 'received',
323
+ checkinGrantedAt: 'credited at',
324
+ checkinOpenWeb: 'Open check-in page',
325
+ checkinFailed: 'Check-in failed',
326
+ checkinUnavailable: 'No check-in available for this account',
327
+ checkinMonitorTitle: 'Raccoon daily credits (detect only)',
328
+ checkinMonitorHint: 'The plugin only reads today’s credit ledger to detect a grant; it never claims on your behalf. If nothing arrived, claim it on the website with “Open check-in page”.',
329
+ plan: 'Plan',
330
+ planFree: 'Free',
331
+ planTrial: 'Trial',
332
+ trialEnds: 'Trial ends',
333
+ packs: 'Credit packs',
334
+ packExpires: 'expires',
335
+ packUsage: 'used / total',
336
+ packNearest: 'nearest expiry',
337
+ packDetail: 'Details',
338
+ packCollapse: 'Collapse',
339
+ usedOf: 'used',
340
+ usageReset: 'Resets',
341
+ browserHintTrae: 'The button opens a dedicated browser window (your own browser profile is untouched): sign in to Trae there and the window closes itself. If the international edition says “account is at risk”, use Log in with the same Google/GitHub account that works in your normal browser — do not create another email account; a fresh profile looks like a new device to Trae.',
342
+ browserPollingTrae: 'The browser is open: sign in to Trae in that window; the login is adopted automatically and the window then closes.',
343
+ browserTitleTrae: 'Browser sign-in',
344
+ browserOpenTrae: 'Open browser sign-in',
345
+ browserWaitingTrae: 'Waiting for sign-in…',
346
+ loginGuideTrae: 'Use “Open browser sign-in” and sign in to Trae in the window that opens; the login is adopted automatically. On the international edition, use Log in with your existing account instead of creating a new email account, which often triggers account is at risk.',
347
+ modelsEmptyTrae: 'Trae returned no models. Confirm the account is signed in, then re-read the login state.',
348
+ codeartsNav: 'Huawei CodeArts',
349
+ codeartsTitle: 'Huawei CodeArts',
350
+ codeartsIntro: 'Use “Open browser sign-in” to authorize on Huawei Cloud; the plugin signs CodeArts gateway calls with the resulting AK/SK and never reads the local IDE. Changes apply immediately.',
351
+ codeartsHint: 'CodeArts is called with a signature, not a token. The STS token lasts two hours and is renewed automatically; sign in again once the refresh token expires. Credit-billed accounts can check in once a day for credits.',
352
+ codeartsVisionHint: '“Vision” marks native image support only. Other models still accept images: kimi-k2.6-vl transcribes the picture to text first, and the transcript is cached per image, so the same picture costs one extra turn in total.',
353
+ browserHintCodeArts: 'The button opens Huawei Cloud’s authorization page: sign in and approve, then the browser returns to this machine and the host adopts the credential. Nothing has to be copied.',
354
+ browserPollingCodeArts: 'The Huawei Cloud page is open: finish authorizing and the login is adopted automatically; this tab can be closed.',
355
+ browserTitleCodeArts: 'Huawei Cloud authorization',
356
+ browserOpenCodeArts: 'Open browser sign-in',
357
+ browserWaitingCodeArts: 'Waiting for authorization…',
358
+ loginGuideCodeArts: 'Use “Open browser sign-in” and authorize on the Huawei Cloud page that opens; the login is adopted automatically.',
359
+ codeartsLoginMissing: 'CodeArts is not signed in. Use “Open browser sign-in” and authorize on Huawei Cloud.',
360
+ modelsEmptyCodeArts: 'CodeArts returned no models. Confirm the account is signed in, then re-read the login state.',
361
+ openRouterTitle: 'OpenRouter model sync',
362
+ openRouterHint: 'OpenRouter’s live catalog supplies thinking levels and image support for all three providers. The snapshot is cached on this machine, so a failed sync keeps the last successful data; refreshing a model list never clears it.',
363
+ openRouterInterval: 'Auto sync',
364
+ openRouterOff: 'Off',
365
+ openRouterEvery15: 'Every 15 minutes',
366
+ openRouterEvery60: 'Hourly',
367
+ openRouterEvery360: 'Every 6 hours',
368
+ openRouterEvery1440: 'Every 24 hours',
369
+ openRouterSyncNow: 'Sync now',
370
+ openRouterSyncing: 'Syncing…',
371
+ openRouterNever: 'Not synced yet',
372
+ openRouterModels: 'models',
373
+ openRouterLastSync: 'Last sync',
374
+ openRouterSyncFailed: 'Sync failed',
375
+ openRouterSyncDone: 'Synced',
376
+ }
377
+
378
+ const BRANDS = [
379
+ { id: 'loomy', settingsId: 'loomy', order: 40, titleKey: 'loomyTitle', navKey: 'loomyNav', introKey: 'loomyIntro' },
380
+ { id: 'raccoon', settingsId: 'raccoon', order: 41, titleKey: 'raccoonTitle', navKey: 'raccoonNav', introKey: 'raccoonIntro' },
381
+ { id: 'trae', settingsId: 'trae', order: 42, titleKey: 'traeTitle', navKey: 'traeNav', introKey: 'traeIntro' },
382
+ { id: 'codearts', settingsId: 'codearts', order: 43, titleKey: 'codeartsTitle', navKey: 'codeartsNav', introKey: 'codeartsIntro' },
383
+ ]
384
+
385
+ const FIELDS = {
386
+ loomy: {
387
+ enabled: 'loomyEnabled',
388
+ displayName: 'loomyDisplayName',
389
+ disabled: 'loomyDisabledModels',
390
+ windows: 'loomyModelContextWindows',
391
+ aliases: 'loomyModelAliases',
392
+ vision: 'loomyVisionModelIds',
393
+ textOnly: 'loomyTextOnlyModelIds',
394
+ },
395
+ raccoon: {
396
+ enabled: 'raccoonEnabled',
397
+ displayName: 'raccoonDisplayName',
398
+ disabled: 'raccoonDisabledModels',
399
+ windows: 'raccoonModelContextWindows',
400
+ aliases: 'raccoonModelAliases',
401
+ vision: 'raccoonVisionModelIds',
402
+ textOnly: 'raccoonTextOnlyModelIds',
403
+ },
404
+ trae: {
405
+ enabled: 'traeEnabled',
406
+ displayName: 'traeDisplayName',
407
+ disabled: 'traeDisabledModels',
408
+ windows: 'traeModelContextWindows',
409
+ aliases: 'traeModelAliases',
410
+ vision: 'traeVisionModelIds',
411
+ textOnly: 'traeTextOnlyModelIds',
412
+ region: 'traeRegion',
413
+ },
414
+ codearts: {
415
+ enabled: 'codeartsEnabled',
416
+ displayName: 'codeartsDisplayName',
417
+ disabled: 'codeartsDisabledModels',
418
+ windows: 'codeartsModelContextWindows',
419
+ aliases: 'codeartsModelAliases',
420
+ vision: 'codeartsVisionModelIds',
421
+ textOnly: 'codeartsTextOnlyModelIds',
422
+ },
423
+ }
424
+
425
+ const REGIONS = [
426
+ { id: 'cn', labelKey: 'regionCn' },
427
+ { id: 'global', labelKey: 'regionGlobal' },
428
+ ]
429
+
430
+ const OPENROUTER_INTERVALS = [
431
+ { value: 0, labelKey: 'openRouterOff' },
432
+ { value: 15, labelKey: 'openRouterEvery15' },
433
+ { value: 60, labelKey: 'openRouterEvery60' },
434
+ { value: 360, labelKey: 'openRouterEvery360' },
435
+ { value: 1440, labelKey: 'openRouterEvery1440' },
436
+ ]
437
+
438
+ function regionOf(value) {
439
+ return value === 'global' ? 'global' : 'cn'
440
+ }
441
+
442
+ const css = {
443
+ page: { maxWidth: '860px', display: 'flex', flexDirection: 'column', gap: '14px', color: 'var(--dsw-alias-label-primary)' },
444
+ h2: { margin: '0', fontSize: '1.25em' },
445
+ h3: { margin: 0, fontSize: '0.95em' },
446
+ intro: { margin: '0', opacity: 0.72, lineHeight: 1.55, fontSize: '0.92em' },
447
+ bar: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
448
+ btn: { padding: '6px 12px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: '0.88em' },
449
+ btnPrimary: { border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.45))', background: '#0b0b0b', color: '#fff', fontWeight: 500 },
450
+ btnAlert: { border: '1px solid var(--dsw-alias-label-danger, #e03131)', color: 'var(--dsw-alias-label-danger, #e03131)' },
451
+ loginBox: { display: 'flex', flexDirection: 'column', gap: '10px', padding: '12px 14px', borderRadius: '10px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))' },
452
+ h4: { margin: 0, fontSize: '0.92em' },
453
+ input: { padding: '6px 10px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'var(--dsw-alias-bg-base, transparent)', color: 'inherit', font: 'inherit', fontSize: '0.88em' },
454
+ search: { flex: '1 1 180px', minWidth: '140px' },
455
+ count: { fontSize: '0.85em', opacity: 0.7, marginLeft: 'auto' },
456
+ list: { display: 'flex', flexDirection: 'column', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', borderRadius: '10px', overflow: 'hidden' },
457
+ row: { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 14px', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
458
+ rowFirst: { borderTop: 'none' },
459
+ rowOff: { opacity: 0.5 },
460
+ name: { flex: '1 1 auto', minWidth: 0, display: 'flex', flexDirection: 'column', gap: '2px' },
461
+ nameText: { fontSize: '0.92em', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
462
+ id: { fontSize: '0.76em', opacity: 0.55, fontFamily: 'ui-monospace, monospace' },
463
+ tag: { fontSize: '0.72em', padding: '1px 6px', borderRadius: '5px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', opacity: 0.75 },
464
+ price: { fontSize: '0.9em', opacity: 0.7, fontWeight: 400 },
465
+ select: { padding: '5px 8px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'var(--dsw-alias-bg-base, transparent)', color: 'inherit', font: 'inherit', fontSize: '0.85em', minWidth: '104px' },
466
+ switch: { width: '17px', height: '17px', cursor: 'pointer', accentColor: 'var(--dsw-alias-brand-primary, #4c6ef5)' },
467
+ note: { fontSize: '0.82em', opacity: 0.62, lineHeight: 1.5 },
468
+ err: { fontSize: '0.86em', color: 'var(--dsw-alias-label-danger, #e03131)' },
469
+ guide: { display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px 14px', borderRadius: '10px', border: '1px solid var(--dsw-alias-label-danger, #e03131)', background: 'rgba(224,49,49,0.07)' },
470
+ guideText: { fontSize: '0.88em', lineHeight: 1.5, color: 'var(--dsw-alias-label-danger, #e03131)' },
471
+ group: { display: 'flex', flexDirection: 'column', gap: '10px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', borderRadius: '10px', padding: '14px 16px' },
472
+ card: { display: 'flex', flexDirection: 'column', gap: '10px', padding: '14px 16px', borderRadius: '10px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', background: 'var(--dsw-alias-bg-base, rgba(128,128,128,0.03))' },
473
+ cardHead: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap' },
474
+ line: { display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap', fontSize: '0.86em' },
475
+ label: { opacity: 0.62, minWidth: '78px' },
476
+ value: { fontWeight: 500 },
477
+ mono: { fontFamily: 'ui-monospace, monospace', fontSize: '0.8em', opacity: 0.7, wordBreak: 'break-all' },
478
+ link: { color: 'var(--dsw-alias-brand-primary, #4c6ef5)', textDecoration: 'none', fontSize: '0.86em' },
479
+ foot: { display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', fontSize: '0.8em', opacity: 0.6 },
480
+ optionRow: { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 0', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
481
+ optionFirst: { borderTop: 'none', paddingTop: 0 },
482
+ optionText: { flex: '1 1 auto', minWidth: 0, display: 'flex', flexDirection: 'column', gap: '2px' },
483
+ optionName: { fontSize: '0.92em', fontWeight: 500 },
484
+ optionStatus: { fontSize: '0.82em', opacity: 0.62 },
485
+ advLabel: { fontSize: '0.88em' },
486
+ advHelp: { fontSize: '0.78em', opacity: 0.55, lineHeight: 1.4 },
487
+ regionBox: { display: 'flex', flexDirection: 'column', gap: '6px', paddingBottom: '10px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
488
+ seg: { display: 'flex', gap: '6px', flexWrap: 'wrap' },
489
+ segBtn: { padding: '4px 10px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))', background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit', fontSize: '0.84em' },
490
+ segOn: { border: '1px solid var(--dsw-alias-brand-primary, #4c6ef5)', fontWeight: 600 },
491
+ section: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '14px', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' },
492
+ packBlock: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '2px' },
493
+ packHead: { display: 'flex', alignItems: 'baseline', gap: '8px' },
494
+ packToggle: { marginLeft: 'auto', background: 'none', border: 'none', padding: 0, font: 'inherit', fontSize: '0.8em', color: 'var(--dsw-alias-brand-primary, #4c6ef5)', cursor: 'pointer' },
495
+ packList: { display: 'flex', flexDirection: 'column', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', overflow: 'hidden' },
496
+ packListScroll: { maxHeight: '190px', overflowY: 'auto' },
497
+ packRow: { display: 'grid', gridTemplateColumns: 'minmax(90px, 1.5fr) auto auto', alignItems: 'baseline', gap: '10px', padding: '6px 10px', borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))', fontSize: '0.82em' },
498
+ packFirst: { borderTop: 'none' },
499
+ packLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
500
+ packAmount: { fontFamily: 'ui-monospace, monospace', textAlign: 'right', whiteSpace: 'nowrap' },
501
+ packExpiry: { opacity: 0.6, whiteSpace: 'nowrap', fontSize: '0.92em' },
502
+ meterBox: { display: 'flex', flexDirection: 'column', gap: '5px', marginTop: '2px' },
503
+ meterText: { display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap', fontSize: '0.86em' },
504
+ meterPercent: { marginLeft: 'auto', opacity: 0.62, fontSize: '0.92em' },
505
+ meter: { height: '6px', borderRadius: '999px', background: 'var(--dsw-alias-border-l2, rgba(128,128,128,0.25))', overflow: 'hidden' },
506
+ meterFill: { height: '100%', borderRadius: '999px', background: 'var(--dsw-alias-brand-primary, #4c6ef5)' },
507
+ }
508
+
509
+ const API = (() => {
510
+ try { return new URL('api/llm-adapters', document.baseURI).pathname } catch { return '/api/llm-adapters' }
511
+ })()
512
+
513
+ const CACHE_PREFIX = 'dsh-llm-adapters/cache/v1'
514
+ const cacheMemory = new Map()
515
+
516
+ function cacheKey(brand, kind) {
517
+ return `${CACHE_PREFIX}:${brand}:${kind}`
518
+ }
519
+
520
+ function readCache(brand, kind) {
521
+ const key = cacheKey(brand, kind)
522
+ if (cacheMemory.has(key)) return cacheMemory.get(key)
523
+ try {
524
+ const raw = window.localStorage.getItem(key)
525
+ if (raw === null) return undefined
526
+ const parsed = JSON.parse(raw)
527
+ cacheMemory.set(key, parsed)
528
+ return parsed
529
+ } catch {
530
+ return undefined
531
+ }
532
+ }
533
+
534
+ function writeCache(brand, kind, value) {
535
+ const key = cacheKey(brand, kind)
536
+ cacheMemory.set(key, value)
537
+ try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* private mode */ }
538
+ }
539
+
540
+ async function api(path, options) {
541
+ const { timeout = 30_000, ...rest } = options ?? {}
542
+ const ctrl = new AbortController()
543
+ const timer = setTimeout(() => ctrl.abort(), timeout)
544
+ try {
545
+ const response = await fetch(`${API}${path}`, { ...rest, signal: ctrl.signal })
546
+ const text = await response.text()
547
+ let payload
548
+ try { payload = text === '' ? {} : JSON.parse(text) } catch { payload = { error: text.slice(0, 200) } }
549
+ if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`)
550
+ return payload
551
+ } finally {
552
+ clearTimeout(timer)
553
+ }
554
+ }
555
+
556
+ const WRITE_TIMEOUT_MS = 20_000
557
+
558
+ function withDeadline(promise, ms, message) {
559
+ return new Promise((resolve, reject) => {
560
+ const timer = setTimeout(() => reject(new Error(message)), ms)
561
+ promise.then(
562
+ value => { clearTimeout(timer); resolve(value) },
563
+ error => { clearTimeout(timer); reject(error) },
564
+ )
565
+ })
566
+ }
567
+
568
+ function windowLabel(n) {
569
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '—'
570
+ if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1))}M`
571
+ if (n >= 1000) return `${Math.round(n / 1000)}K`
572
+ return String(n)
573
+ }
574
+
575
+ function creditsOf(value) {
576
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
577
+ return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
578
+ }
579
+
580
+ /** Trae's international plan reports dollars; every other quota is credits. */
581
+ function amountText(quota, value) {
582
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
583
+ return quota?.unit === 'usd' ? `$${value.toFixed(2)}` : creditsOf(value)
584
+ }
585
+
586
+ /**
587
+ * A usage bar needs both halves: what was spent and what the account was
588
+ * granted. Loomy and Raccoon only report what is left, so they keep the
589
+ * plain figure rather than a bar with an invented denominator.
590
+ */
591
+ function meterPercentOf(quota) {
592
+ const spent = typeof quota?.used === 'number' && Number.isFinite(quota.used) ? quota.used : undefined
593
+ const granted = typeof quota?.total === 'number' && Number.isFinite(quota.total) ? quota.total : undefined
594
+ if (spent === undefined || granted === undefined || granted <= 0) return undefined
595
+ return Math.min(100, Math.max(0, (spent / granted) * 100))
596
+ }
597
+
598
+ function percentLabel(value) {
599
+ const text = value > 0 && value < 1
600
+ ? value.toFixed(2)
601
+ : value < 10 ? value.toFixed(1) : String(Math.round(value))
602
+ return `${text}%`
603
+ }
604
+
605
+ function dateLabel(value) {
606
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
607
+ if (value > Date.now() + 100 * 365 * 24 * 3600 * 1000) return '—'
608
+ return new Date(value).toLocaleString('zh-CN', {
609
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
610
+ })
611
+ }
612
+
613
+ function priceText(t, factor) {
614
+ if (typeof factor !== 'number' || !Number.isFinite(factor) || factor < 0) return ''
615
+ if (factor === 0) return t('free')
616
+ return `x${Number(factor.toFixed(2))}`
617
+ }
618
+
619
+ function ownerLabel(t, owner) {
620
+ if (typeof owner !== 'string' || owner === '') return '—'
621
+ if (owner === 'users' || owner === 'user' || owner === 'personal') return t('ownerPersonal')
622
+ if (owner === 'org' || owner === 'enterprise') return t('ownerEnterprise')
623
+ return owner
624
+ }
625
+
626
+ function AccountCard({ t, brand, region, regionBusy, onRegionChange, reloadSignal, onStatus, onAfterRefresh }) {
627
+ // Trae keeps one snapshot per region, so switching back and forth never
628
+ // shows the other region's account while a fresh read is in flight.
629
+ const cacheId = brand.id === 'trae' ? `trae:${region}` : brand.id
630
+ const cached = readCache(cacheId, 'status')
631
+ const [status, setStatus] = useState(cached)
632
+ const [busy, setBusy] = useState(false)
633
+ const [error, setError] = useState(undefined)
634
+ const seenReload = useRef(reloadSignal)
635
+ const mounted = useRef(true)
636
+ const [browser, setBrowser] = useState(undefined)
637
+ const [actionBusy, setActionBusy] = useState(false)
638
+ const [actionError, setActionError] = useState(undefined)
639
+ const [checkinBusy, setCheckinBusy] = useState(false)
640
+ const [clientBusy, setClientBusy] = useState(false)
641
+ const [packsExpanded, setPacksExpanded] = useState(false)
642
+ const ticket = useRef(0)
643
+
644
+ const load = useCallback(async (fresh, notify) => {
645
+ const mine = ++ticket.current
646
+ setBusy(true)
647
+ setError(undefined)
648
+ try {
649
+ const payload = await api(`/status?brand=${brand.id}${fresh ? '&fresh=1' : ''}`, { timeout: 35_000 })
650
+ // A region switch starts a second read; the older one must not win.
651
+ if (!mounted.current || mine !== ticket.current) return
652
+ setStatus(payload)
653
+ writeCache(cacheId, 'status', { ...payload, cachedAt: Date.now() })
654
+ onStatus?.(payload)
655
+ if (notify && payload?.signedIn === true) onAfterRefresh?.()
656
+ } catch (err) {
657
+ if (!mounted.current || mine !== ticket.current) return
658
+ setError(String(err?.message ?? err))
659
+ } finally {
660
+ if (mounted.current && mine === ticket.current) setBusy(false)
661
+ }
662
+ }, [brand.id, cacheId, onStatus, onAfterRefresh])
663
+
664
+ useEffect(() => {
665
+ mounted.current = true
666
+ void load(false, false)
667
+ return () => { mounted.current = false }
668
+ }, [load])
669
+
670
+ // A region switch changes which stored account the card describes, so the
671
+ // previous region's snapshot is dropped before the new read lands.
672
+ useEffect(() => {
673
+ setStatus(readCache(cacheId, 'status'))
674
+ setBrowser(undefined)
675
+ setActionError(undefined)
676
+ setPacksExpanded(false)
677
+ }, [cacheId])
678
+
679
+ useEffect(() => {
680
+ if (reloadSignal === seenReload.current) return
681
+ seenReload.current = reloadSignal
682
+ void load(true, true)
683
+ }, [reloadSignal, load])
684
+
685
+ // Follow the server-side browser login. The flow lives in the host, so it
686
+ // keeps going even if this page is closed and reopened mid-login.
687
+ useEffect(() => {
688
+ if (browser?.running !== true) return undefined
689
+ let alive = true
690
+ const tick = async () => {
691
+ try {
692
+ const payload = await api(`/account/browser?brand=${brand.id}`, { timeout: 10_000 })
693
+ if (!alive) return
694
+ setBrowser(payload)
695
+ if (payload?.running === false && payload?.signedIn === true) {
696
+ await load(true, true)
697
+ }
698
+ } catch {
699
+ /* transient; the next tick retries */
700
+ }
701
+ }
702
+ const timer = setInterval(() => { void tick() }, 1500)
703
+ return () => { alive = false; clearInterval(timer) }
704
+ }, [browser?.running, brand.id, load])
705
+
706
+ const signedIn = status?.signedIn === true
707
+ const account = status?.account ?? {}
708
+ const accountText = [account.name, account.phone, account.ownerType === undefined ? undefined : ownerLabel(t, account.ownerType)]
709
+ .filter(part => typeof part === 'string' && part.length > 0)
710
+ .join(' · ') || t('accountUnknown')
711
+ const quota = status?.quota
712
+
713
+ const accountRequest = useCallback(async body => api('/account', {
714
+ method: 'POST',
715
+ headers: { 'content-type': 'application/json' },
716
+ body: JSON.stringify({ brand: brand.id, ...(brand.id === 'trae' ? { region } : {}), ...body }),
717
+ timeout: 45_000,
718
+ }), [brand.id, region])
719
+
720
+ const logout = useCallback(async () => {
721
+ if (typeof window.confirm === 'function' && !window.confirm(t('loginConfirmLogout'))) return
722
+ setActionBusy(true)
723
+ setActionError(undefined)
724
+ try {
725
+ const payload = await accountRequest({ action: 'logout' })
726
+ setStatus(payload)
727
+ writeCache(cacheId, 'status', { ...payload, cachedAt: Date.now() })
728
+ onStatus?.(payload)
729
+ onAfterRefresh?.()
730
+ } catch (err) {
731
+ setActionError(`${t('loginFailedPrefix')}:${String(err?.message ?? err)}`)
732
+ } finally {
733
+ setActionBusy(false)
734
+ }
735
+ }, [accountRequest, cacheId, onStatus, onAfterRefresh, t])
736
+
737
+ const startBrowserLogin = useCallback(async () => {
738
+ setActionError(undefined)
739
+ try {
740
+ const payload = await accountRequest({ action: 'browser' })
741
+ setBrowser(payload)
742
+ if (typeof payload?.url === 'string' && payload.url !== '' && typeof window.open === 'function') {
743
+ window.open(payload.url, '_blank', 'noopener')
744
+ }
745
+ } catch (err) {
746
+ setActionError(`${t('loginFailedPrefix')}:${String(err?.message ?? err)}`)
747
+ }
748
+ }, [accountRequest, t])
749
+
750
+ const cancelBrowserLogin = useCallback(async () => {
751
+ try {
752
+ const payload = await accountRequest({ action: 'browser-cancel' })
753
+ setBrowser(payload)
754
+ } catch (err) {
755
+ setActionError(`${t('loginFailedPrefix')}:${String(err?.message ?? err)}`)
756
+ }
757
+ }, [accountRequest, t])
758
+
759
+ const importClientLogin = useCallback(async () => {
760
+ setClientBusy(true)
761
+ setActionError(undefined)
762
+ try {
763
+ const payload = await accountRequest({ action: 'client-login' })
764
+ setStatus(payload)
765
+ setBrowser({ running: false, phase: 'idle' })
766
+ writeCache(cacheId, 'status', { ...payload, cachedAt: Date.now() })
767
+ onStatus?.(payload)
768
+ onAfterRefresh?.()
769
+ } catch (err) {
770
+ setActionError(`${t('traeClientFailed')}:${String(err?.message ?? err)}`)
771
+ } finally {
772
+ setClientBusy(false)
773
+ }
774
+ }, [accountRequest, cacheId, onStatus, onAfterRefresh, t])
775
+
776
+ // Claiming is the only state-changing account action. The reward changes
777
+ // the quota, so the reading is refreshed afterwards instead of guessed.
778
+ //
779
+ // Raccoon has no claim endpoint: its points appear on the day's first
780
+ // website visit. Pressing this used to replay that visit for the user, which
781
+ // triggered the grant without them asking at that moment — so for Raccoon
782
+ // the button only re-reads today's ledger and the link does the claiming.
783
+ const detectOnly = brand.id === 'raccoon'
784
+ const claimCheckin = useCallback(async () => {
785
+ setCheckinBusy(true)
786
+ setActionError(undefined)
787
+ try {
788
+ if (detectOnly) {
789
+ await load(true, false)
790
+ return
791
+ }
792
+ const payload = await accountRequest({ action: 'checkin' })
793
+ if (payload?.checkin !== undefined) {
794
+ setStatus(previous => previous === undefined
795
+ ? previous
796
+ : { ...previous, checkin: payload.checkin })
797
+ }
798
+ await load(true, false)
799
+ } catch (err) {
800
+ setActionError(`${t('checkinFailed')}:${String(err?.message ?? err)}`)
801
+ } finally {
802
+ setCheckinBusy(false)
803
+ }
804
+ }, [accountRequest, detectOnly, load, t])
805
+
806
+ const quotaContent = (() => {
807
+ if (busy && quota === undefined) return h('div', { style: css.note }, t('quotaRefreshing'))
808
+ if (status?.quotaError !== undefined) return h('div', { style: css.err }, `${t('quotaFailed')}:${status.quotaError}`)
809
+ if (quota === undefined) return h('div', { style: css.note }, signedIn ? t('quotaUnavailable') : t('quotaRefreshing'))
810
+ if (brand.id === 'loomy') {
811
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
812
+ h('div', { style: css.line },
813
+ h('span', { style: css.label }, t('loomyAvailable')),
814
+ h('span', { style: css.value }, `${creditsOf(quota.available ?? quota.total)} ${t('points')}`),
815
+ ),
816
+ h('div', { style: css.line },
817
+ h('span', { style: css.label }, t('loomyPermanent')),
818
+ h('span', { style: css.value }, creditsOf(quota.permanent)),
819
+ quota.daily === undefined ? null : h('span', { style: css.label }, t('loomyDaily')),
820
+ quota.daily === undefined ? null : h('span', { style: css.value }, creditsOf(quota.daily)),
821
+ ),
822
+ quota.cycle === undefined ? null : h('div', { style: css.line },
823
+ h('span', { style: css.label }, t('loomyCycle')),
824
+ h('span', { style: css.value }, quota.cycle),
825
+ ),
826
+ )
827
+ }
828
+ if (brand.id === 'trae' || brand.id === 'codearts') {
829
+ const packs = Array.isArray(quota.packs) ? quota.packs : []
830
+ const plan = typeof quota.plan === 'string' && quota.plan !== '' ? quota.plan : undefined
831
+ const spent = typeof quota.used === 'number' && Number.isFinite(quota.used) ? quota.used : undefined
832
+ const granted = typeof quota.total === 'number' && Number.isFinite(quota.total) ? quota.total : undefined
833
+ const percent = meterPercentOf(quota)
834
+ const unitSuffix = quota.unit === 'usd' ? '' : ` ${t('points')}`
835
+ const usedText = spent === undefined
836
+ ? undefined
837
+ : `${amountText(quota, spent)}${granted === undefined ? '' : ` / ${amountText(quota, granted)}`}${unitSuffix}`
838
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
839
+ plan === undefined && granted === undefined && usedText === undefined && packs.length === 0
840
+ ? h('div', { style: css.note }, t('quotaUnavailable'))
841
+ : null,
842
+ plan === undefined ? null : h('div', { style: css.line },
843
+ h('span', { style: css.label }, t('plan')),
844
+ h('span', { style: css.value }, plan),
845
+ quota.trialEndsAt === undefined ? null : h('span', { style: css.label }, t('trialEnds')),
846
+ quota.trialEndsAt === undefined ? null : h('span', { style: css.value }, dateLabel(quota.trialEndsAt)),
847
+ ),
848
+ // Both halves present → a bar. A visible sliver is kept for a
849
+ // spend that rounds to zero, or the bar reads as "untouched".
850
+ percent === undefined
851
+ ? granted === undefined
852
+ ? null
853
+ : h('div', { style: css.line },
854
+ h('span', { style: css.label }, t('quota')),
855
+ h('span', { style: css.value }, `${amountText(quota, granted)}${unitSuffix}`),
856
+ )
857
+ : h('div', { style: css.meterBox },
858
+ h('div', { style: css.meterText },
859
+ h('span', { style: css.label }, t('usedOf')),
860
+ h('span', { style: css.value }, usedText),
861
+ h('span', { style: css.meterPercent }, percentLabel(percent)),
862
+ ),
863
+ h('div', { style: css.meter },
864
+ h('div', {
865
+ style: { ...css.meterFill, width: `${percent > 0 ? Math.max(percent, 1.5) : 0}%` },
866
+ }),
867
+ ),
868
+ ),
869
+ percent === undefined && usedText !== undefined
870
+ ? h('div', { style: css.line },
871
+ h('span', { style: css.label }, t('usedOf')),
872
+ h('span', { style: css.value }, usedText),
873
+ quota.ratio === undefined ? null : h('span', { style: css.label }, `${Math.round(quota.ratio)}%`),
874
+ )
875
+ : null,
876
+ quota.resetAt === undefined ? null : h('div', { style: css.line },
877
+ h('span', { style: css.label }, t('usageReset')),
878
+ h('span', { style: css.value }, dateLabel(quota.resetAt)),
879
+ ),
880
+ packs.length === 0 ? null : (() => {
881
+ // Collapsed (the default) is one summary row: how many packs, the
882
+ // combined total and the nearest expiry. Expanding lists every
883
+ // pack, aligned in a bounded scroll area.
884
+ const total = packs.reduce((sum, pack) => sum + (pack.total ?? pack.used ?? 0), 0)
885
+ const nearest = packs
886
+ .map(pack => pack.expiresAt)
887
+ .filter(value => typeof value === 'number')
888
+ .sort((a, b) => a - b)[0]
889
+ return h('div', { style: css.packBlock },
890
+ h('div', { style: css.packHead },
891
+ h('span', { style: css.label }, `${t('packs')}(${packs.length})`),
892
+ h('span', { style: css.value }, `${creditsOf(total)} ${t('points')}`),
893
+ nearest === undefined ? null : h('span', { style: css.packExpiry }, `${t('packNearest')} ${dateLabel(nearest)}`),
894
+ h('button', {
895
+ style: css.packToggle,
896
+ onClick: () => setPacksExpanded(value => !value),
897
+ }, packsExpanded ? t('packCollapse') : `(${t('packDetail')})`),
898
+ ),
899
+ packsExpanded
900
+ ? h('div', { style: css.packBlock },
901
+ h('div', { style: css.packHead },
902
+ h('span', { style: css.label }, t('packUsage')),
903
+ ),
904
+ h('div', { style: { ...css.packList, ...css.packListScroll } },
905
+ packs.map((pack, index) => h('div', {
906
+ key: `${pack.label}-${index}`,
907
+ style: { ...css.packRow, ...(index === 0 ? css.packFirst : {}) },
908
+ },
909
+ h('span', { style: css.packLabel, title: pack.label }, pack.label),
910
+ h('span', { style: css.packAmount }, pack.total === undefined
911
+ ? (pack.used === undefined ? '—' : creditsOf(pack.used))
912
+ : `${creditsOf(pack.used ?? 0)} / ${creditsOf(pack.total)}`),
913
+ h('span', { style: css.packExpiry }, pack.expiresAt === undefined ? '' : dateLabel(pack.expiresAt)),
914
+ )),
915
+ ),
916
+ )
917
+ : null,
918
+ )
919
+ })(),
920
+ )
921
+ }
922
+ return h('div', { style: css.line },
923
+ h('span', { style: css.label }, t('quota')),
924
+ h('span', { style: css.value }, `${creditsOf(quota.total)} ${t('points')}`),
925
+ )
926
+ })()
927
+
928
+ // Trae's check-in needs the device id only a client-login import has, so
929
+ // a web login must not show the panel even though the provider exposes
930
+ // the method. CodeArts grants one reward per account per day, but its
931
+ // device flag can also block the claim; the two reasons are shown apart
932
+ // so "本机已签" never reads as "this account already claimed".
933
+ const checkin = brand.id === 'trae'
934
+ ? (status?.loginSource === 'client' ? status?.checkin : undefined)
935
+ : (brand.id === 'codearts' || brand.id === 'raccoon' ? status?.checkin : undefined)
936
+ const checkinBlocked = checkin === undefined
937
+ || checkin.available === false
938
+ // A detect button stays useful after the points arrived, so only a real
939
+ // claim (or a device-level one) disables the action.
940
+ || (!detectOnly && (checkin.claimed === true || checkin.deviceClaimed === true))
941
+ const checkinLabel = detectOnly
942
+ ? (checkin?.claimed === true ? t('checkinClaimed') : t('checkinDetect'))
943
+ : checkin?.claimed === true
944
+ ? t('checkinClaimed')
945
+ : checkin?.deviceClaimed === true ? t('checkinDevice') : t('checkinClaim')
946
+ // A failed activity read is shown rather than hidden: hiding it is what
947
+ // makes "no check-in" and "check-in broken" look the same.
948
+ const checkinNote = checkin === undefined
949
+ ? undefined
950
+ : checkin.available === false
951
+ ? checkin.message ?? t('checkinUnavailable')
952
+ : brand.id === 'raccoon' && checkin.claimed === false
953
+ ? checkin.message
954
+ : undefined
955
+ // The timestamp is part of the claim, not decoration: without it a grant
956
+ // issued just after midnight reads as if it were still yesterday's.
957
+ const grantedAt = typeof checkin?.grantedAt === 'number' ? checkin.grantedAt : undefined
958
+ const rewardNote = checkin === undefined || checkin.reward === undefined
959
+ || checkin.deviceClaimed === true
960
+ ? null
961
+ : h('span', { style: css.note },
962
+ `${checkin.claimed === true ? t('checkinReceived') : t('checkinReward')} +${creditsOf(checkin.reward)} ${t('points')}`
963
+ + (grantedAt === undefined ? '' : ` · ${t('checkinGrantedAt')} ${dateLabel(grantedAt)}`))
964
+ const checkinPanel = signedIn && checkin !== undefined
965
+ && (checkin.available !== false || brand.id === 'codearts')
966
+ ? h('div', { style: css.bar },
967
+ h('button', {
968
+ style: { ...css.btn, ...css.btnPrimary },
969
+ disabled: checkinBlocked || checkinBusy || clientBusy || actionBusy,
970
+ onClick: () => void claimCheckin(),
971
+ }, checkinBusy ? t(detectOnly ? 'checkinDetecting' : 'checkinBusy') : checkinLabel),
972
+ checkinNote === undefined ? null : h('span', { style: css.note }, checkinNote),
973
+ rewardNote,
974
+ typeof checkin.url === 'string' && checkin.url !== ''
975
+ ? h('a', {
976
+ href: checkin.url,
977
+ target: '_blank',
978
+ rel: 'noreferrer',
979
+ style: css.link,
980
+ }, t('checkinOpenWeb'))
981
+ : null,
982
+ )
983
+ : null
984
+
985
+ const browserRunning = browser?.running === true
986
+ const browserPanel = h('div', { style: css.loginBox },
987
+ h('h4', { style: css.h4 }, brand.id === 'trae'
988
+ ? t('browserTitleTrae')
989
+ : brand.id === 'codearts' ? t('browserTitleCodeArts') : t('browserTitle')),
990
+ h('div', { style: css.note }, brand.id === 'loomy'
991
+ ? t('browserHintLoomy')
992
+ : brand.id === 'raccoon'
993
+ ? t('browserHintRaccoon')
994
+ : brand.id === 'codearts' ? t('browserHintCodeArts') : t('browserHintTrae')),
995
+ h('div', { style: css.bar },
996
+ h('button', {
997
+ style: { ...css.btn, ...css.btnPrimary },
998
+ disabled: browserRunning || actionBusy || regionBusy,
999
+ onClick: () => void startBrowserLogin(),
1000
+ }, browserRunning
1001
+ ? brand.id === 'trae'
1002
+ ? t('browserWaitingTrae')
1003
+ : brand.id === 'codearts' ? t('browserWaitingCodeArts') : t('browserWaiting')
1004
+ : brand.id === 'trae'
1005
+ ? t('browserOpenTrae')
1006
+ : brand.id === 'codearts' ? t('browserOpenCodeArts') : t('browserOpen')),
1007
+ browserRunning
1008
+ ? h('button', {
1009
+ style: css.btn,
1010
+ onClick: () => void cancelBrowserLogin(),
1011
+ }, t('browserCancel'))
1012
+ : null,
1013
+ browserRunning && typeof browser?.url === 'string'
1014
+ ? h('a', {
1015
+ href: browser.url,
1016
+ target: '_blank',
1017
+ rel: 'noreferrer',
1018
+ style: css.link,
1019
+ }, t('browserReopen'))
1020
+ : null,
1021
+ ),
1022
+ browserRunning
1023
+ ? h('div', { style: css.note }, typeof browser?.message === 'string' && browser.message !== ''
1024
+ ? browser.message
1025
+ : brand.id === 'trae'
1026
+ ? t('browserPollingTrae')
1027
+ : brand.id === 'codearts' ? t('browserPollingCodeArts') : t('browserPolling'))
1028
+ : null,
1029
+ browser !== undefined && browser.running === false && browser.error !== undefined
1030
+ ? h('div', { style: css.err }, browser.error)
1031
+ : null,
1032
+ )
1033
+
1034
+ const clientLogin = brand.id === 'trae' ? status?.clientLogin : undefined
1035
+ const clientAccount = clientLogin?.signedIn === true
1036
+ ? ([clientLogin.name, clientLogin.expiresAt === undefined
1037
+ ? undefined
1038
+ : `${t('expires')} ${dateLabel(clientLogin.expiresAt)}`]
1039
+ .filter(part => typeof part === 'string' && part.length > 0)
1040
+ .join(' · ') || t('accountUnknown'))
1041
+ : undefined
1042
+ const clientPanel = brand.id === 'trae' && clientLogin?.available === true
1043
+ ? h('div', { style: css.loginBox },
1044
+ h('h4', { style: css.h4 }, t('traeClientTitle')),
1045
+ h('div', { style: css.note }, t('traeClientHint')),
1046
+ clientLogin.signedIn === true
1047
+ ? h('div', { style: css.line },
1048
+ h('span', { style: css.label }, t('traeClientAccount')),
1049
+ h('span', { style: css.value }, clientAccount),
1050
+ )
1051
+ : h('div', { style: css.note }, clientLogin.error ?? t('traeClientNotLoggedIn')),
1052
+ h('div', { style: css.bar },
1053
+ h('button', {
1054
+ style: { ...css.btn, ...(clientLogin.signedIn === true ? css.btnPrimary : {}) },
1055
+ disabled: clientBusy || checkinBusy || actionBusy || browserRunning || clientLogin.signedIn !== true,
1056
+ onClick: () => void importClientLogin(),
1057
+ }, clientBusy
1058
+ ? t('traeClientBusy')
1059
+ : status?.loginSource === 'client' ? t('traeClientReuse') : t('traeClientUse')),
1060
+ clientLogin.signedIn === true
1061
+ ? h('span', { style: css.note }, t('traeClientReady'))
1062
+ : null,
1063
+ ),
1064
+ h('div', { style: css.note }, t('traeClientLogoutNote')),
1065
+ )
1066
+ : null
1067
+
1068
+ // Trae's region is a saved setting, not a tab: the selector writes the
1069
+ // same `traeRegion` the provider reads, and the page then re-reads both
1070
+ // the account and the catalog for the newly selected region.
1071
+ const regionPicker = brand.id === 'trae'
1072
+ ? h('div', { style: css.regionBox },
1073
+ h('div', { style: css.bar },
1074
+ h('span', { style: css.label }, t('regionLabel')),
1075
+ h('div', { style: css.seg },
1076
+ ...REGIONS.map(option => h('button', {
1077
+ key: option.id,
1078
+ style: { ...css.segBtn, ...(region === option.id ? css.segOn : {}) },
1079
+ disabled: regionBusy || busy,
1080
+ onClick: () => {
1081
+ if (region === option.id) return
1082
+ onRegionChange?.(option.id)
1083
+ },
1084
+ }, t(option.labelKey))),
1085
+ ),
1086
+ regionBusy ? h('span', { style: css.note }, t('regionSwitching')) : null,
1087
+ ),
1088
+ h('div', { style: css.note }, t('regionHint')),
1089
+ )
1090
+ : null
1091
+
1092
+ return h('div', { style: css.card },
1093
+ regionPicker,
1094
+ h('div', { style: css.cardHead },
1095
+ h('h3', { style: css.h3 }, t('accountTitle')),
1096
+ h('span', { style: { ...css.note, opacity: 0.75 } }, signedIn ? t('signedIn') : t('notLoggedIn')),
1097
+ h('button', {
1098
+ style: { ...css.btn, marginLeft: 'auto' },
1099
+ disabled: busy,
1100
+ onClick: () => { void load(true, true) },
1101
+ }, busy ? t('reading') : t('reread')),
1102
+ ),
1103
+ h('div', { style: css.note }, brand.id === 'loomy'
1104
+ ? t('loomyHint')
1105
+ : brand.id === 'raccoon'
1106
+ ? t('raccoonHint')
1107
+ : brand.id === 'codearts' ? t('codeartsHint') : t('traeHint')),
1108
+ signedIn
1109
+ ? h('div', { style: css.line },
1110
+ h('span', { style: css.label }, t('account')),
1111
+ h('span', { style: css.value }, accountText),
1112
+ )
1113
+ : null,
1114
+ signedIn
1115
+ ? h('div', { style: css.line },
1116
+ h('span', { style: css.label }, t('expires')),
1117
+ h('span', { style: css.value }, dateLabel(status?.expiresAt)),
1118
+ )
1119
+ : null,
1120
+ signedIn && brand.id === 'trae'
1121
+ ? h('div', { style: css.line },
1122
+ h('span', { style: css.label }, t('traeClientSource')),
1123
+ h('span', { style: css.value }, status?.loginSource === 'client'
1124
+ ? t('traeSourceClient')
1125
+ : t('traeSourceWeb')),
1126
+ )
1127
+ : null,
1128
+ signedIn && brand.id === 'raccoon'
1129
+ ? h('div', { style: css.line },
1130
+ h('span', { style: css.label }, t('raccoonOwner')),
1131
+ h('span', { style: css.value }, ownerLabel(t, account.ownerType)),
1132
+ )
1133
+ : null,
1134
+ signedIn ? quotaContent : null,
1135
+ checkinPanel,
1136
+ status?.possiblyExpired === true ? h('div', { style: css.err }, t('possiblyExpired')) : null,
1137
+ !signedIn && status !== undefined
1138
+ ? h('div', { style: css.guide },
1139
+ h('div', { style: css.guideText }, status?.error ?? signInGuide(t, brand)),
1140
+ h('div', { style: css.note }, brand.id === 'trae'
1141
+ ? t('loginGuideTrae')
1142
+ : brand.id === 'codearts' ? t('loginGuideCodeArts') : t('loginGuide')),
1143
+ )
1144
+ : null,
1145
+ signedIn
1146
+ ? h('div', { style: css.bar },
1147
+ h('button', {
1148
+ style: { ...css.btn, ...css.btnAlert },
1149
+ disabled: actionBusy,
1150
+ onClick: () => void logout(),
1151
+ }, t('loginLogout')),
1152
+ )
1153
+ : null,
1154
+ !signedIn || status?.possiblyExpired === true ? browserPanel : null,
1155
+ clientPanel,
1156
+ actionError !== undefined ? h('div', { style: css.err }, actionError) : null,
1157
+ error !== undefined ? h('div', { style: css.err }, `${t('loadFailed')}:${error}`) : null,
1158
+ )
1159
+ }
1160
+
1161
+ function AdvancedInput({ t, form, fieldKey, label, kind, value, writable }) {
1162
+ const [draft, setDraft] = useState(undefined)
1163
+ const initial = kind === 'number'
1164
+ ? value === undefined ? '' : String(value)
1165
+ : kind === 'json'
1166
+ ? value === undefined ? (fieldKey.includes('Aliases') ? '{}' : '[]') : JSON.stringify(value, null, 2)
1167
+ : value === undefined ? '' : String(value)
1168
+ const shown = draft === undefined ? initial : draft
1169
+
1170
+ const commit = () => {
1171
+ const raw = shown
1172
+ if (raw.trim() === '') {
1173
+ form.unset(fieldKey).catch(() => undefined)
1174
+ return
1175
+ }
1176
+ if (kind === 'number') {
1177
+ const number = Number(raw)
1178
+ if (!Number.isFinite(number) || number <= 0) { setDraft(initial); return }
1179
+ form.set(fieldKey, Math.floor(number)).catch(() => undefined)
1180
+ return
1181
+ }
1182
+ if (kind === 'json') {
1183
+ try {
1184
+ const parsed = JSON.parse(raw)
1185
+ const expectedArray = fieldKey.includes('Aliases') ? false : true
1186
+ if (expectedArray && !Array.isArray(parsed)) { setDraft(initial); return }
1187
+ if (!expectedArray && (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))) { setDraft(initial); return }
1188
+ form.set(fieldKey, parsed).catch(() => undefined)
1189
+ } catch {
1190
+ setDraft(initial)
1191
+ }
1192
+ return
1193
+ }
1194
+ form.set(fieldKey, raw).catch(() => undefined)
1195
+ }
1196
+
1197
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
1198
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1199
+ h('span', { style: css.advLabel }, label),
1200
+ value === undefined || !writable
1201
+ ? null
1202
+ : h('button', {
1203
+ style: { fontSize: '0.75em', opacity: 0.55, background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit', color: 'inherit' },
1204
+ onClick: () => { form.unset(fieldKey).catch(() => undefined) },
1205
+ }, t('reset')),
1206
+ ),
1207
+ kind === 'json'
1208
+ ? h('textarea', {
1209
+ style: { ...css.input, minHeight: '64px', fontFamily: 'ui-monospace, monospace', resize: 'vertical' },
1210
+ value: shown,
1211
+ disabled: !writable,
1212
+ onChange: event => setDraft(event.target.value),
1213
+ onBlur: commit,
1214
+ })
1215
+ : h('input', {
1216
+ style: css.input,
1217
+ type: kind === 'number' ? 'number' : 'text',
1218
+ value: shown,
1219
+ disabled: !writable,
1220
+ onChange: event => setDraft(event.target.value),
1221
+ onBlur: commit,
1222
+ }),
1223
+ h('div', { style: css.advHelp }, t('advancedHint')),
1224
+ )
1225
+ }
1226
+
1227
+ function Advanced({ ctx, t, brand, show, onToggle }) {
1228
+ const form = ctx.configForms.get(NS)
1229
+ const subscribe = useCallback(listener => form.subscribe(listener), [form])
1230
+ const read = useCallback(() => form.getSnapshot(), [form])
1231
+ const snapshot = useSyncExternalStore(subscribe, read)
1232
+ const value = snapshot?.value ?? {}
1233
+ const writable = snapshot?.writable !== false
1234
+ const fields = FIELDS[brand.id]
1235
+ const labels = t('advancedFields')
1236
+ const items = [
1237
+ [fields.displayName, labels.displayName, 'text', value[fields.displayName]],
1238
+ [fields.vision, labels.visionModelIds, 'json', value[fields.vision]],
1239
+ [fields.textOnly, labels.textOnlyModelIds, 'json', value[fields.textOnly]],
1240
+ [fields.aliases, labels.modelAliases, 'json', value[fields.aliases]],
1241
+ ['defaultMaxTokens', labels.defaultMaxTokens, 'number', value.defaultMaxTokens],
1242
+ ]
1243
+ return h('div', { style: css.group },
1244
+ h('h3', { style: css.h3 }, t('advanced')),
1245
+ h('div', { style: css.bar },
1246
+ h('button', { style: css.btn, onClick: onToggle }, show ? t('hide') : t('show')),
1247
+ h('span', { style: css.note }, t('advancedHint')),
1248
+ ),
1249
+ show
1250
+ ? items.map(([key, label, kind, current]) => h(AdvancedInput, {
1251
+ key,
1252
+ t,
1253
+ form,
1254
+ fieldKey: key,
1255
+ label,
1256
+ kind,
1257
+ value: current,
1258
+ writable,
1259
+ }))
1260
+ : null,
1261
+ )
1262
+ }
1263
+
1264
+ function SettingsPage({ ctx, t, brand }) {
1265
+ const form = ctx.configForms.get(NS)
1266
+ const subscribe = useCallback(listener => form.subscribe(listener), [form])
1267
+ const read = useCallback(() => form.getSnapshot(), [form])
1268
+ const snapshot = useSyncExternalStore(subscribe, read)
1269
+ const fields = FIELDS[brand.id]
1270
+ // Trae's two regions have independent accounts AND catalogs, so every
1271
+ // snapshot (models and status) is keyed by region as well as by brand.
1272
+ const region = brand.id === 'trae' ? regionOf(snapshot?.value?.[fields.region]) : undefined
1273
+ const cacheId = region === undefined ? brand.id : `${brand.id}:${region}`
1274
+ const cached = readCache(cacheId, 'models')
1275
+ const [models, setModels] = useState(() => Array.isArray(cached?.rows) ? cached.rows : undefined)
1276
+ const [notice, setNotice] = useState(() => typeof cached?.notice === 'string' ? cached.notice : undefined)
1277
+ const [signedIn, setSignedIn] = useState(() => cached?.signedIn === true ? true : undefined)
1278
+ const [error, setError] = useState(undefined)
1279
+ const [saveStatus, setSaveStatus] = useState(undefined)
1280
+ const [busy, setBusy] = useState(false)
1281
+ const [query, setQuery] = useState('')
1282
+ const [onlyEnabled, setOnlyEnabled] = useState(false)
1283
+ const [showAdvanced, setShowAdvanced] = useState(false)
1284
+ const [reloadSignal, setReloadSignal] = useState(0)
1285
+ const [regionBusy, setRegionBusy] = useState(false)
1286
+ const ticket = useRef(0)
1287
+ const writeTicket = useRef(0)
1288
+
1289
+ const fetchModels = useCallback(async (fresh = false) => {
1290
+ const payload = await api(`/models?brand=${brand.id}${fresh ? '&fresh=1' : ''}`)
1291
+ return {
1292
+ rows: Array.isArray(payload?.models) ? payload.models : [],
1293
+ notice: typeof payload?.notice === 'string' ? payload.notice : undefined,
1294
+ signedIn: typeof payload?.signedIn === 'boolean' ? payload.signedIn : undefined,
1295
+ }
1296
+ }, [brand.id])
1297
+
1298
+ const adopt = useCallback((rows, nextNotice, nextSignedIn) => {
1299
+ setModels(rows)
1300
+ setNotice(nextNotice)
1301
+ if (nextSignedIn !== undefined) setSignedIn(nextSignedIn)
1302
+ writeCache(cacheId, 'models', { rows, notice: nextNotice, signedIn: nextSignedIn, at: Date.now() })
1303
+ }, [cacheId])
1304
+
1305
+ const load = useCallback(async (fresh = false) => {
1306
+ const mine = ++ticket.current
1307
+ setError(undefined)
1308
+ try {
1309
+ const payload = await fetchModels(fresh)
1310
+ if (mine !== ticket.current) return
1311
+ adopt(payload.rows, payload.notice, payload.signedIn)
1312
+ } catch (err) {
1313
+ if (mine !== ticket.current) return
1314
+ setError(String(err?.message ?? err))
1315
+ setModels(previous => previous ?? [])
1316
+ }
1317
+ }, [adopt, fetchModels])
1318
+
1319
+ useEffect(() => {
1320
+ const saved = readCache(cacheId, 'models')
1321
+ setModels(Array.isArray(saved?.rows) ? saved.rows : undefined)
1322
+ setNotice(typeof saved?.notice === 'string' ? saved.notice : undefined)
1323
+ setError(undefined)
1324
+ void load(false)
1325
+ }, [cacheId, load])
1326
+
1327
+ // Switching region writes the same field the provider reads, so the host
1328
+ // starts answering for the new region immediately; the cacheId change
1329
+ // above then restores that region's own account and catalog.
1330
+ const changeRegion = useCallback(async next => {
1331
+ setRegionBusy(true)
1332
+ setError(undefined)
1333
+ try {
1334
+ const accepted = await withDeadline(form.set(fields.region, next), WRITE_TIMEOUT_MS, t('timeout'))
1335
+ if (accepted === false) throw new Error(t('saveFailed'))
1336
+ } catch (err) {
1337
+ setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1338
+ } finally {
1339
+ setRegionBusy(false)
1340
+ }
1341
+ }, [fields.region, form, t])
1342
+
1343
+ const handleStatus = useCallback((payload) => {
1344
+ if (typeof payload?.signedIn === 'boolean') setSignedIn(payload.signedIn)
1345
+ }, [])
1346
+
1347
+ const afterRefresh = useCallback(() => {
1348
+ void load(true)
1349
+ }, [load])
1350
+
1351
+ const loadUntil = useCallback(async (verify, attempts = 5) => {
1352
+ let rows
1353
+ for (let attempt = 0; attempt < attempts; attempt++) {
1354
+ try {
1355
+ const payload = await fetchModels()
1356
+ rows = payload.rows
1357
+ adopt(rows, payload.notice, payload.signedIn)
1358
+ } catch (err) {
1359
+ setError(String(err?.message ?? err))
1360
+ return undefined
1361
+ }
1362
+ if (verify === undefined || verify(rows)) return rows
1363
+ await new Promise(resolve => setTimeout(resolve, 120 + attempt * 120))
1364
+ }
1365
+ return rows
1366
+ }, [adopt, fetchModels])
1367
+
1368
+ const write = useCallback(async (key, next, verify) => {
1369
+ const mine = ++writeTicket.current
1370
+ setBusy(true)
1371
+ setSaveStatus(t('saving'))
1372
+ setError(undefined)
1373
+ try {
1374
+ const accepted = await withDeadline(next === undefined ? form.unset(key) : form.set(key, next), WRITE_TIMEOUT_MS, t('timeout'))
1375
+ if (accepted === false) throw new Error(t('saveFailed'))
1376
+ const rows = await loadUntil(verify)
1377
+ if (rows !== undefined && verify !== undefined && !verify(rows)) throw new Error(t('notApplied'))
1378
+ if (mine === writeTicket.current) setSaveStatus(t('saved'))
1379
+ } catch (err) {
1380
+ if (mine !== writeTicket.current) return
1381
+ setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1382
+ setSaveStatus(undefined)
1383
+ await load()
1384
+ } finally {
1385
+ if (mine === writeTicket.current) setBusy(false)
1386
+ }
1387
+ }, [form, load, loadUntil, t])
1388
+
1389
+ const writeDisabled = useCallback((rows, id, enabled) => {
1390
+ const next = new Set(rows.filter(model => !model.enabled).map(model => model.id))
1391
+ if (enabled) next.delete(id)
1392
+ else next.add(id)
1393
+ setModels(current => current?.map(model => model.id === id ? { ...model, enabled } : model))
1394
+ return write(fields.disabled, [...next], list => list.some(model => model.id === id && model.enabled === enabled))
1395
+ }, [fields.disabled, write])
1396
+
1397
+ const writeWindow = useCallback((rows, id, window) => {
1398
+ const next = {}
1399
+ for (const model of rows) if (model.override !== undefined) next[model.id] = model.override
1400
+ if (window === undefined) delete next[id]
1401
+ else next[id] = window
1402
+ setModels(current => current?.map(model => model.id === id
1403
+ ? { ...model, override: window, contextWindow: window ?? model.defaultContextWindow }
1404
+ : model))
1405
+ return write(fields.windows, next, list => list.some(model => model.id === id
1406
+ && (window === undefined ? model.override === undefined : model.override === window)))
1407
+ }, [fields.windows, write])
1408
+
1409
+ const writeAll = useCallback((enabled) => {
1410
+ const ids = (models ?? []).map(model => model.id)
1411
+ setModels(current => current?.map(model => ({ ...model, enabled })))
1412
+ return write(fields.disabled, enabled ? [] : ids, list => list.every(model => model.enabled === enabled))
1413
+ }, [fields.disabled, models, write])
1414
+
1415
+ const all = models ?? []
1416
+ const enabledTotal = all.filter(model => model.enabled).length
1417
+ const shown = useMemo(() => {
1418
+ const q = query.trim().toLowerCase()
1419
+ return all.filter(model => {
1420
+ if (onlyEnabled && !model.enabled) return false
1421
+ if (q === '') return true
1422
+ return String(model.name).toLowerCase().includes(q) || String(model.id).toLowerCase().includes(q)
1423
+ })
1424
+ }, [all, query, onlyEnabled])
1425
+
1426
+ const accountCard = h(AccountCard, {
1427
+ t,
1428
+ brand,
1429
+ region,
1430
+ regionBusy,
1431
+ onRegionChange: next => { void changeRegion(next) },
1432
+ reloadSignal,
1433
+ onStatus: handleStatus,
1434
+ onAfterRefresh: afterRefresh,
1435
+ })
1436
+
1437
+ const refreshButton = h('button', {
1438
+ style: css.btn,
1439
+ disabled: busy,
1440
+ onClick: () => {
1441
+ setReloadSignal(value => value + 1)
1442
+ void load(true)
1443
+ },
1444
+ }, busy ? t('refreshing') : t('refresh'))
1445
+
1446
+ const advanced = h(Advanced, {
1447
+ ctx, t, brand, show: showAdvanced,
1448
+ onToggle: () => setShowAdvanced(value => !value),
1449
+ })
1450
+
1451
+ if (models === undefined) {
1452
+ return h('div', { style: css.page },
1453
+ h('h2', { style: css.h2 }, t(brand.titleKey)),
1454
+ h('p', { style: css.intro }, t(brand.introKey)),
1455
+ accountCard,
1456
+ error === undefined
1457
+ ? h('div', { style: css.note }, t('reading'))
1458
+ : h('div', null,
1459
+ h('div', { style: css.err }, `${t('loadFailed')}:${error}`),
1460
+ h('button', { style: { ...css.btn, marginTop: '8px' }, onClick: () => void load(true) }, t('retry')),
1461
+ ),
1462
+ )
1463
+ }
1464
+
1465
+ if (signedIn !== true) {
1466
+ return h('div', { style: css.page },
1467
+ h('h2', { style: css.h2 }, t(brand.titleKey)),
1468
+ h('p', { style: css.intro }, t(brand.introKey)),
1469
+ accountCard,
1470
+ h('div', { style: css.guide },
1471
+ h('div', { style: css.guideText }, signInGuide(t, brand)),
1472
+ h('div', { style: css.note }, t('modelsHint')),
1473
+ ),
1474
+ refreshButton,
1475
+ advanced,
1476
+ error !== undefined ? h('div', { style: css.err }, error) : null,
1477
+ )
1478
+ }
1479
+
1480
+ const rows = shown.map((model, index) => {
1481
+ const choices = Array.isArray(model.contextWindows) ? model.contextWindows : []
1482
+ const price = priceText(t, model.priceFactor)
1483
+ return h('div', {
1484
+ key: model.id,
1485
+ style: { ...css.row, ...(index === 0 ? css.rowFirst : {}), ...(model.enabled ? {} : css.rowOff) },
1486
+ },
1487
+ h('input', {
1488
+ type: 'checkbox',
1489
+ style: css.switch,
1490
+ checked: model.enabled === true,
1491
+ disabled: busy,
1492
+ title: t('colEnabled'),
1493
+ onChange: event => { void writeDisabled(all, model.id, event.target.checked) },
1494
+ }),
1495
+ h('div', { style: css.name },
1496
+ h('div', { style: css.nameText },
1497
+ model.name,
1498
+ // Only native image support gets the tag; relayed capability is
1499
+ // explained once below the list instead of on every row.
1500
+ model.vision === true && model.visionRelay !== true
1501
+ ? h('span', { key: 'v', style: { ...css.tag, marginLeft: '6px' } }, t('vision'))
1502
+ : null,
1503
+ model.reasoning === true ? h('span', { key: 'r', style: { ...css.tag, marginLeft: '6px' } }, t('reasoning')) : null,
1504
+ ...(Array.isArray(model.labels) ? model.labels : []).map((label, labelIndex) =>
1505
+ h('span', { key: `l${labelIndex}`, style: { ...css.tag, marginLeft: '6px' } }, label)),
1506
+ model.badge !== undefined
1507
+ ? h('span', { key: 'b', style: { ...css.tag, marginLeft: '6px' } }, model.badge)
1508
+ : null,
1509
+ price !== '' ? h('span', { key: 'p', style: css.price }, ` · ${price}`) : null,
1510
+ ),
1511
+ h('div', { style: css.id }, model.id),
1512
+ ),
1513
+ h('select', {
1514
+ style: css.select,
1515
+ value: model.override === undefined ? '' : String(model.override),
1516
+ disabled: busy,
1517
+ title: t('windowDefaultHint'),
1518
+ onChange: event => {
1519
+ const value = event.target.value
1520
+ void writeWindow(all, model.id, value === '' ? undefined : Number(value))
1521
+ },
1522
+ },
1523
+ h('option', { key: 'default', value: '' }, `${t('windowDefault')} · ${windowLabel(model.defaultContextWindow)}`),
1524
+ choices.map(value => h('option', { key: String(value), value: String(value) }, windowLabel(value))),
1525
+ ),
1526
+ )
1527
+ })
1528
+
1529
+ return h('div', { style: css.page },
1530
+ h('h2', { style: css.h2 }, t(brand.titleKey)),
1531
+ h('p', { style: css.intro }, t(brand.introKey)),
1532
+ accountCard,
1533
+ h('div', { style: css.bar },
1534
+ h('input', {
1535
+ style: { ...css.input, ...css.search },
1536
+ placeholder: t('search'),
1537
+ value: query,
1538
+ onChange: event => setQuery(event.target.value),
1539
+ }),
1540
+ h('label', { style: { ...css.note, display: 'flex', alignItems: 'center', gap: '6px', whiteSpace: 'nowrap' } },
1541
+ h('input', { type: 'checkbox', checked: onlyEnabled, onChange: event => setOnlyEnabled(event.target.checked) }),
1542
+ t('onlyEnabled'),
1543
+ ),
1544
+ ),
1545
+ h('div', { style: { ...css.bar, flexWrap: 'nowrap' } },
1546
+ refreshButton,
1547
+ h('button', {
1548
+ style: css.btn,
1549
+ disabled: busy || all.every(model => model.enabled),
1550
+ onClick: () => void writeAll(true),
1551
+ }, t('selectAll')),
1552
+ h('button', {
1553
+ style: css.btn,
1554
+ disabled: busy || enabledTotal === 0,
1555
+ onClick: () => void writeAll(false),
1556
+ }, t('selectNone')),
1557
+ h('span', { style: css.count }, `${t('enabledCount')} ${enabledTotal} / ${all.length}`),
1558
+ ),
1559
+ all.length === 0
1560
+ ? h('div', { style: css.guide },
1561
+ h('div', { style: css.guideText }, brand.id === 'loomy'
1562
+ ? t('modelsEmptyLoomy')
1563
+ : brand.id === 'raccoon'
1564
+ ? t('modelsEmptyRaccoon')
1565
+ : brand.id === 'codearts' ? t('modelsEmptyCodeArts') : t('modelsEmptyTrae')),
1566
+ )
1567
+ : h('div', { style: css.list }, rows.length > 0
1568
+ ? rows
1569
+ : h('div', { style: { ...css.note, padding: '12px 14px' } }, '—')),
1570
+ all.length > 0 && brand.id === 'codearts'
1571
+ ? h('div', { style: css.note }, t('codeartsVisionHint'))
1572
+ : null,
1573
+ notice !== undefined ? h('div', { style: css.note }, `${t('catalogNotice')}:${notice}`) : null,
1574
+ all.length > 0 ? h('div', { style: css.note }, t('windowWarn')) : null,
1575
+ error !== undefined ? h('div', { style: css.err }, error) : null,
1576
+ saveStatus !== undefined && error === undefined
1577
+ ? h('div', { style: css.note }, saveStatus)
1578
+ : null,
1579
+ advanced,
1580
+ )
1581
+ }
1582
+
1583
+ function signInGuide(t, brand) {
1584
+ if (brand.id === 'loomy') return t('loomyLoginMissing')
1585
+ if (brand.id === 'raccoon') return t('raccoonLoginMissing')
1586
+ return brand.id === 'codearts' ? t('codeartsLoginMissing') : t('traeLoginMissing')
1587
+ }
1588
+
1589
+ function BundleSettings({ ctx, t, view }) {
1590
+ const form = ctx.configForms.get(NS)
1591
+ const subscribe = useCallback(listener => form.subscribe(listener), [form])
1592
+ const read = useCallback(() => form.getSnapshot(), [form])
1593
+ const snapshot = useSyncExternalStore(subscribe, read)
1594
+ const value = snapshot?.value ?? {}
1595
+ const writable = snapshot?.writable !== false
1596
+ const [busy, setBusy] = useState(false)
1597
+ const [error, setError] = useState(undefined)
1598
+ const [status, setStatus] = useState(undefined)
1599
+ const [openRouter, setOpenRouter] = useState(undefined)
1600
+ const [syncing, setSyncing] = useState(false)
1601
+ const [routerError, setRouterError] = useState(undefined)
1602
+ const [routerNotice, setRouterNotice] = useState(undefined)
1603
+ const ticket = useRef(0)
1604
+ const syncInterval = typeof value.openRouterSyncMinutes === 'number' ? value.openRouterSyncMinutes : 60
1605
+ const loadOpenRouter = useCallback(async () => {
1606
+ try {
1607
+ setOpenRouter(await api('/openrouter', { timeout: 10_000 }))
1608
+ } catch (err) {
1609
+ setRouterError(String(err?.message ?? err))
1610
+ }
1611
+ }, [])
1612
+
1613
+ useEffect(() => { void loadOpenRouter() }, [loadOpenRouter])
1614
+
1615
+ const syncOpenRouter = useCallback(async () => {
1616
+ setSyncing(true)
1617
+ setRouterError(undefined)
1618
+ setRouterNotice(undefined)
1619
+ try {
1620
+ // The host may try twice with its own 30 s window; this deadline has
1621
+ // to outlast that, or the page reports a timeout while a retry is
1622
+ // still running and the user sees a failure that never happened.
1623
+ const payload = await api('/openrouter/sync', { method: 'POST', timeout: 75_000 })
1624
+ setOpenRouter(payload)
1625
+ if (payload?.ok === true) {
1626
+ setRouterNotice(`${t('openRouterSyncDone')} · ${payload.models} ${t('openRouterModels')}`)
1627
+ } else {
1628
+ setRouterError(`${t('openRouterSyncFailed')}:${payload?.error ?? '—'}`)
1629
+ }
1630
+ } catch (err) {
1631
+ setRouterError(`${t('openRouterSyncFailed')}:${String(err?.message ?? err)}`)
1632
+ } finally {
1633
+ setSyncing(false)
1634
+ }
1635
+ }, [t])
1636
+
1637
+ const setMinutes = useCallback(async (key, next) => {
1638
+ setStatus(t('saving'))
1639
+ setError(undefined)
1640
+ try {
1641
+ const accepted = await withDeadline(form.set(key, next), WRITE_TIMEOUT_MS, t('timeout'))
1642
+ if (accepted === false) throw new Error(t('pluginOptionsRejected'))
1643
+ setStatus(t('saved'))
1644
+ } catch (err) {
1645
+ setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1646
+ setStatus(undefined)
1647
+ }
1648
+ }, [form, t])
1649
+
1650
+ const setSyncInterval = useCallback(next => setMinutes('openRouterSyncMinutes', next), [setMinutes])
1651
+
1652
+ const toggle = useCallback(async (key, next) => {
1653
+ const mine = ++ticket.current
1654
+ setBusy(true)
1655
+ setStatus(t('saving'))
1656
+ setError(undefined)
1657
+ try {
1658
+ const accepted = await withDeadline(form.set(key, next), WRITE_TIMEOUT_MS, t('timeout'))
1659
+ if (accepted === false) throw new Error(t('pluginOptionsRejected'))
1660
+ if (mine === ticket.current) setStatus(t('saved'))
1661
+ } catch (err) {
1662
+ if (mine !== ticket.current) return
1663
+ setError(`${t('saveFailed')}:${String(err?.message ?? err)}`)
1664
+ setStatus(undefined)
1665
+ } finally {
1666
+ if (mine === ticket.current) setBusy(false)
1667
+ }
1668
+ }, [form, t])
1669
+
1670
+ if (view !== undefined && view !== 'page') {
1671
+ const names = BRANDS
1672
+ .filter(brand => value[FIELDS[brand.id].enabled] !== false)
1673
+ .map(brand => {
1674
+ const stored = value[FIELDS[brand.id].displayName]
1675
+ return typeof stored === 'string' && stored !== '' ? stored : t(brand.navKey)
1676
+ })
1677
+ return h('span', null, names.join(' · '))
1678
+ }
1679
+
1680
+ return h('div', { style: css.group },
1681
+ h('h3', { style: css.h3 }, t('pluginOptionsTitle')),
1682
+ h('div', { style: css.note }, t('pluginOptionsHint')),
1683
+ BRANDS.map((brand, index) => {
1684
+ const key = FIELDS[brand.id].enabled
1685
+ const enabled = value[key] !== false
1686
+ const stored = value[FIELDS[brand.id].displayName]
1687
+ const name = typeof stored === 'string' && stored !== '' ? stored : t(brand.navKey)
1688
+ return h('div', { key: brand.id, style: { ...css.optionRow, ...(index === 0 ? css.optionFirst : {}) } },
1689
+ h('input', {
1690
+ type: 'checkbox',
1691
+ style: css.switch,
1692
+ checked: enabled,
1693
+ disabled: busy || !writable,
1694
+ onChange: event => { void toggle(key, event.target.checked) },
1695
+ }),
1696
+ h('div', { style: css.optionText },
1697
+ h('div', { style: css.optionName }, name),
1698
+ h('div', { style: css.optionStatus }, enabled ? t('pluginOptionsOn') : t('pluginOptionsOff')),
1699
+ ),
1700
+ )
1701
+ }),
1702
+ !writable ? h('div', { style: css.note }, t('pluginOptionsReadOnly')) : null,
1703
+ h('div', { style: css.section },
1704
+ h('h3', { style: css.h3 }, t('openRouterTitle')),
1705
+ h('div', { style: css.note }, t('openRouterHint')),
1706
+ h('div', { style: css.bar },
1707
+ h('span', { style: css.label }, t('openRouterInterval')),
1708
+ h('select', {
1709
+ style: css.select,
1710
+ value: String(syncInterval),
1711
+ disabled: busy || !writable,
1712
+ onChange: event => { void setSyncInterval(Number(event.target.value)) },
1713
+ },
1714
+ OPENROUTER_INTERVALS.map(option => h('option', {
1715
+ key: String(option.value),
1716
+ value: String(option.value),
1717
+ }, t(option.labelKey))),
1718
+ ),
1719
+ h('button', {
1720
+ style: { ...css.btn, ...css.btnPrimary },
1721
+ disabled: syncing || !writable,
1722
+ onClick: () => { void syncOpenRouter() },
1723
+ }, syncing ? t('openRouterSyncing') : t('openRouterSyncNow')),
1724
+ ),
1725
+ h('div', { style: css.bar },
1726
+ h('span', { style: css.note }, openRouter === undefined
1727
+ ? t('openRouterNever')
1728
+ : `${openRouter.models ?? 0} ${t('openRouterModels')}${typeof openRouter.at === 'number' ? ` · ${t('openRouterLastSync')} ${dateLabel(openRouter.at)}` : ''}`),
1729
+ typeof openRouter?.error === 'string'
1730
+ ? h('span', { style: css.err }, `${t('openRouterSyncFailed')}:${openRouter.error}`)
1731
+ : null,
1732
+ ),
1733
+ routerNotice !== undefined ? h('div', { style: css.note }, routerNotice) : null,
1734
+ routerError !== undefined ? h('div', { style: css.err }, routerError) : null,
1735
+ ),
1736
+ h('div', { style: css.section },
1737
+ h('h3', { style: css.h3 }, t('checkinMonitorTitle')),
1738
+ h('div', { style: css.note }, t('checkinMonitorHint')),
1739
+ ),
1740
+ error !== undefined ? h('div', { style: css.err }, error) : null,
1741
+ status !== undefined && error === undefined ? h('div', { style: css.note }, status) : null,
1742
+ )
1743
+ }
1744
+
1745
+ function apply(ctx) {
1746
+ const t = ctx.locale.bind(NS)
1747
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'llm-adapters: dictionaries')
1748
+
1749
+ ctx.effect(() => ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
1750
+ name: 'plugins.bundle.config',
1751
+ key: 'dsh-llm-adapters',
1752
+ locale: NS,
1753
+ }, props => h(BundleSettings, { ...props, ctx, t }))), 'llm-adapters: bundle options')
1754
+
1755
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => {
1756
+ const form = ctx.configForms.get(NS)
1757
+ const live = new Map()
1758
+
1759
+ const sync = () => {
1760
+ const snapshot = form.getSnapshot()
1761
+ if (snapshot.status !== 'ready') return
1762
+ const value = snapshot.value ?? {}
1763
+ for (const brand of BRANDS) {
1764
+ const enabled = value[FIELDS[brand.id].enabled] !== false
1765
+ const current = live.get(brand.id)
1766
+ if (enabled && current === undefined) {
1767
+ live.set(brand.id, ctx.slots.inject('settings.section', () => ctx.slots.register({
1768
+ name: 'settings.section',
1769
+ id: brand.settingsId,
1770
+ order: brand.order,
1771
+ label: () => t(brand.navKey),
1772
+ locale: NS,
1773
+ }, props => h(SettingsPage, { ...props, ctx, t, brand }))))
1774
+ } else if (!enabled && current !== undefined) {
1775
+ current()
1776
+ live.delete(brand.id)
1777
+ }
1778
+ }
1779
+ }
1780
+
1781
+ const unsubscribe = form.subscribe(sync)
1782
+ sync()
1783
+ return () => {
1784
+ unsubscribe()
1785
+ for (const off of live.values()) off()
1786
+ live.clear()
1787
+ }
1788
+ }), 'llm-adapters: pages')
1789
+ }
1790
+
1791
+ exports.apply = apply
1792
+ exports.inject = inject
1793
+ exports.name = 'dsh-llm-adapters'
1794
+ exports.__test = {
1795
+ BRANDS, FIELDS, REGIONS, OPENROUTER_INTERVALS, regionOf, zh, en, css, windowLabel, creditsOf,
1796
+ amountText,
1797
+ meterPercentOf, percentLabel, ownerLabel, priceText, dateLabel, readCache, writeCache, AccountCard,
1798
+ SettingsPage, BundleSettings,
1799
+ }
1800
+ return module.exports
1801
+ },
1802
+ })