dsh-deepseek-web-login 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2165 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-deepseek-web-login",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ //#region src/file-picker.ts
9
+ const win = () => globalThis;
10
+ /**
11
+ * 把界面选中的 File 还原成**真实磁盘路径**。
12
+ *
13
+ * 这一步的价值:拿到路径后,宿主可以**自己去读那个文件**,
14
+ * 于是导入的凭证明文根本不经过 HTTP 请求体 —— 只是把"文件在哪"告诉宿主。
15
+ * 桥不可用(宿主没注入 / 非磁盘来源的 File)就返回 undefined,调用方改用内容导入。
16
+ */
17
+ function resolvePickedPath(file, w = win()) {
18
+ if (!file) return void 0;
19
+ try {
20
+ const resolved = w.__DSH_DESKTOP_FILE_PATH__?.getPathForFile?.(file);
21
+ return typeof resolved === "string" && resolved.trim() ? resolved : void 0;
22
+ } catch {
23
+ return;
24
+ }
25
+ }
26
+ /** 渲染进程是否支持 File System Access 的「另存为」。 */
27
+ function canSaveWithPicker(w = win()) {
28
+ return typeof w?.showSaveFilePicker === "function";
29
+ }
30
+ /**
31
+ * 备份文件建议名。
32
+ *
33
+ * 刻意只用 **数字 / 字母 / 短横线 / 点**:这个字符串会进系统另存为框的
34
+ * `suggestedName`,冒号、斜杠在 Windows 上是非法文件名字符(`toISOString()` 恰好
35
+ * 带冒号,直接拿来当文件名会踩坑)。
36
+ */
37
+ function suggestedExportName(now = /* @__PURE__ */ new Date()) {
38
+ const pad = (value) => String(value).padStart(2, "0");
39
+ return `deepseek-accounts-${`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`}.json`;
40
+ }
41
+ /** 用户取消选择时 Chromium 抛的是 AbortError,要和其他失败区分开(取消不是错误)。 */
42
+ function isPickerCancel(error) {
43
+ return error?.name === "AbortError";
44
+ }
45
+ /**
46
+ * 弹系统「另存为」,并把 `produce()` 产出的文本写进用户选定的位置。
47
+ *
48
+ * ⚠️ 为什么收一个回调而不是直接收文本:**必须先弹框、再取内容**。
49
+ * Chromium 的瞬时用户激活是有时限的,若调用方先 `await` 一次网络请求
50
+ * (取备份内容)再弹框,很可能被判成"没有用户手势"而直接抛错。
51
+ * 把取数包成回调,顺序就锁在这个函数里,调用方想写错都难。
52
+ */
53
+ async function saveWithPicker(suggestedName, produce, w = win()) {
54
+ if (!canSaveWithPicker(w)) return { kind: "unsupported" };
55
+ let writable;
56
+ try {
57
+ const handle = await w.showSaveFilePicker({
58
+ suggestedName,
59
+ types: [{
60
+ description: "JSON 备份",
61
+ accept: { "application/json": [".json"] }
62
+ }]
63
+ });
64
+ const text = await produce();
65
+ writable = await handle.createWritable();
66
+ await writable.write(new Blob([text], { type: "application/json" }));
67
+ await writable.close();
68
+ writable = void 0;
69
+ return {
70
+ kind: "saved",
71
+ name: typeof handle?.name === "string" && handle.name ? handle.name : suggestedName
72
+ };
73
+ } catch (error) {
74
+ if (writable) try {
75
+ await writable.abort(error);
76
+ } catch {}
77
+ if (isPickerCancel(error)) return { kind: "cancelled" };
78
+ return {
79
+ kind: "failed",
80
+ reason: `${error?.name ?? ""} ${error?.message ?? error}`.trim()
81
+ };
82
+ }
83
+ }
84
+ /**
85
+ * 弹系统「打开」框选一个 JSON。返回 undefined 表示用户取消。
86
+ *
87
+ * 用 `<input type="file">` 而不是 `showOpenFilePicker`:前者在**任何** Chromium
88
+ * 环境里都可用(包括跨域 iframe,Chromium 150 起第三方 iframe 是被禁止弹
89
+ * File System Access 文件框的),是我们最可靠的"让人选文件"手段。
90
+ */
91
+ function pickJsonFile(doc = document) {
92
+ return new Promise((resolve) => {
93
+ const input = doc.createElement("input");
94
+ input.type = "file";
95
+ input.accept = ".json,application/json";
96
+ input.style.display = "none";
97
+ let settled = false;
98
+ const timer = setTimeout(() => finish(void 0), 6e5);
99
+ const finish = (file) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ input.remove();
104
+ resolve(file);
105
+ };
106
+ input.addEventListener("change", () => finish(input.files?.[0]));
107
+ input.addEventListener("cancel", () => finish(void 0));
108
+ doc.body.appendChild(input);
109
+ input.click();
110
+ });
111
+ }
112
+ /** 导入文件的大小上限:与宿主侧一致(实测单账号 ~1.7 KB、账号数上限 500 → ~850 KB,
113
+ * 取 2 MiB 留 2 倍余量)。两边都要有 —— 前端挡是为了不白传,宿主挡才是真边界。 */
114
+ const IMPORT_FILE_LIMIT_BYTES = 2097152;
115
+ /**
116
+ * 把用户选中的文件变成"宿主能用的东西"。
117
+ *
118
+ * 优先走路径(凭证不进 HTTP),拿不到路径才退回内容 —— 后者是必要的兜底:
119
+ * 宿主没注入路径桥时功能不该整体失效。
120
+ */
121
+ async function readImportSource(file, w = win()) {
122
+ const name = file.name || "备份文件";
123
+ if (typeof file.size === "number" && file.size > 2097152) return {
124
+ kind: "unreadable",
125
+ name,
126
+ reason: `文件 ${Math.ceil(file.size / 1024)} KiB,超过上限 ${Math.floor(IMPORT_FILE_LIMIT_BYTES / 1024 / 1024)} MiB`
127
+ };
128
+ const path = resolvePickedPath(file, w);
129
+ if (path) return {
130
+ kind: "path",
131
+ path,
132
+ name
133
+ };
134
+ try {
135
+ return {
136
+ kind: "content",
137
+ payload: JSON.parse(await file.text()),
138
+ name
139
+ };
140
+ } catch (error) {
141
+ return {
142
+ kind: "unreadable",
143
+ name,
144
+ reason: `${error?.message ?? error}`
145
+ };
146
+ }
147
+ }
148
+ //#endregion
149
+ //#region src/cookies.ts
150
+ /**
151
+ * 汇总结论。**没有记录时返回 `undefined`**(而不是全 0 的对象)——
152
+ * 调用方要区分"没采到过期信息"(老记录 / 手动粘 token)和"采到了、全是会话级",
153
+ * 这两种情况的界面文案完全不同。
154
+ */
155
+ function summarizeCookieLife(metas, now = Date.now()) {
156
+ const list = metas ?? [];
157
+ if (list.length === 0) return void 0;
158
+ const persistent = list.filter((item) => !item.session && Number.isFinite(item.expiresAt));
159
+ let latest;
160
+ for (const item of persistent) {
161
+ const expiresAt = item.expiresAt;
162
+ if (!latest || expiresAt > latest.expiresAt) latest = {
163
+ name: item.name,
164
+ expiresAt,
165
+ daysLeft: (expiresAt - now) / 864e5
166
+ };
167
+ }
168
+ return {
169
+ total: list.length,
170
+ sessionCount: list.length - persistent.length,
171
+ persistentCount: persistent.length,
172
+ ...latest ? { latest } : {}
173
+ };
174
+ }
175
+ /** 剩余时间的口语化写法。 */
176
+ function describeRemaining(daysLeft) {
177
+ if (daysLeft <= 0) return "已过期";
178
+ if (daysLeft >= 1) return `还剩 ${Math.floor(daysLeft)} 天`;
179
+ return `还剩 ${Math.max(1, Math.round(daysLeft * 24))} 小时`;
180
+ }
181
+ /**
182
+ * 一句话说清 cookie 的寿命构成(界面直接展示)。
183
+ *
184
+ * 三种情况分开写,因为它们的含义完全不同:
185
+ * - 没记录 → 老记录 / 手动粘 token,下次重新登录会补上;
186
+ * - 全是会话级 → 浏览器侧本来就没有到期时间,看它撑多久没意义;
187
+ * - 有持久级 → 给出最晚那个的剩余时间(**只是浏览器侧的上界,不是登录态寿命**)。
188
+ */
189
+ function describeCookieLife(summary, now = Date.now()) {
190
+ if (!summary) return "⚠️ 未记录(重新登录后会补上)";
191
+ const parts = [`${summary.total} 项`];
192
+ parts.push(summary.sessionCount > 0 ? `${summary.sessionCount} 会话级` : "无会话级");
193
+ if (summary.persistentCount > 0) parts.push(`${summary.persistentCount} 持久级`);
194
+ if (summary.latest) {
195
+ const daysLeft = (summary.latest.expiresAt - now) / 864e5;
196
+ parts.push(`${summary.latest.name} ${describeRemaining(daysLeft)}`);
197
+ }
198
+ return parts.join(" · ");
199
+ }
200
+ //#endregion
201
+ //#region src/account-sync.ts
202
+ /**
203
+ * 账号库的「什么时候该重读」判据 + 内容签名(纯函数,无依赖,便于单测)。
204
+ *
205
+ * ── 为什么要单独有这么一个模块(2026-09-15 用户反馈)──
206
+ *
207
+ * 现象:**新登录一个账号后,账号库列表不会自己更新**,要关掉设置页再打开才看得到。
208
+ *
209
+ * 根因:账号库列表**不在状态轮询(`/status`)里**。面板每 2 秒轮询一次,但只读
210
+ * `/status` 并渲染**当前账号**的信息;列表本身只有 `loadAccounts()` 被显式调用的那几个
211
+ * 时刻才重读(面板初始化 / 切号 / 移除 / 重命名 / 重新登录 / 添加 / 导入)。
212
+ *
213
+ * 而登录捕获**是异步落地的**:
214
+ * - CDP 那条路(宿主在 utility 进程时的常态)要等用户在浏览器里登录完;
215
+ * - 能开 Electron 窗口那条路更直接 —— `openLoginWindow()` 是"开窗即返回",
216
+ * 捕获发生在响应之后。
217
+ * ⇒ 「加完账号」的捕获很可能晚于那次 `loadAccounts()`,列表就停在旧快照上,
218
+ * 而且**此后不会再更新**(轮询不碰账号库)。
219
+ *
220
+ * 最直接的证据是代码里一句注释(client 的「登录新账号」处理里):
221
+ * 「期间保持轮询更密一点,万一捕获是异步落地的也能及时刷出来」
222
+ * —— 作者本来就指望轮询兜住这件事,但轮询只读 `/status`,那个兜底**从未生效**。
223
+ *
224
+ * 同源的第二个缺口:探活会更新记录(补上账号名、限制状态、失败标记),
225
+ * 而**账号名是捕获之后由校验拿到的**(见 index.ts 里那段回写),
226
+ * 所以"列表里只有一串 `acc_xxxxxxxx`"这件事也要等下一次刷新才消失。
227
+ *
228
+ * 所以修法是:把轮询的兜底做成真的 —— 按下面的节拍重读 `/accounts`,
229
+ * 并用 `accountsSignature()` 判断内容是否真的变了(没变就不重建 DOM,
230
+ * 免得每几秒把用户正在点的按钮换掉)。
231
+ */
232
+ /** 登录流程进行中(窗口开着 / 刚操作过)的重读间隔:够快,用户看不到"没更新"。 */
233
+ const ACCOUNTS_SYNC_ACTIVE_MS = 3e3;
234
+ /** 空闲时的重读间隔:只为让探活补上的账号名 / 限制状态 / 失败标记自己出现,不必太快。 */
235
+ const ACCOUNTS_SYNC_IDLE_MS = 3e4;
236
+ /**
237
+ * 现在该不该重读账号库。
238
+ *
239
+ * @param now 当前时间戳
240
+ * @param lastSyncedAt 上次重读的时刻(从未读过传 0)
241
+ * @param active 登录流程是否进行中(窗口开着,或在操作后的加速窗口内)
242
+ */
243
+ function shouldSyncAccounts(now, lastSyncedAt, active) {
244
+ const gap = active ? ACCOUNTS_SYNC_ACTIVE_MS : ACCOUNTS_SYNC_IDLE_MS;
245
+ return now - lastSyncedAt >= gap;
246
+ }
247
+ /**
248
+ * 账号库响应的内容签名 —— 变了才重建列表。
249
+ *
250
+ * 只取**界面真正渲染的东西**(账号数组 + 分组定义 + 分组分区 + 当前账号 id),不取 `footprint`:
251
+ * 后者是给台账页用的文件统计,跟列表无关,把它算进来只会平白触发重建。
252
+ *
253
+ * ⚠️ `groups` / `sections` 必须是**顶层字段显式列进来**:整包 stringify 只覆盖这里写出的键,
254
+ * 漏一个就会出现"新建了组、列表却不刷新"—— 与 0.1.63(读错字段名)和 0.1.67(数据压根不在
255
+ * `/status` 里)是同一类坑:**界面渲染什么,签名就得覆盖什么。**
256
+ *
257
+ * 整包 `JSON.stringify` 而不是逐个字段挑:**按构造就是完整的** ——
258
+ * 将来 `/accounts` 多返回一个会被渲染的字段,这里不用改也不会漏。
259
+ * (代价:若哪天往 accounts 里塞了每调用一次都不同的值,会变成每轮都重建;
260
+ * 真出现那种字段,应该在这里显式排除。)
261
+ */
262
+ function accountsSignature(payload) {
263
+ const data = payload ?? {};
264
+ const accounts = Array.isArray(data.accounts) ? data.accounts : [];
265
+ const groups = Array.isArray(data.groups) ? data.groups : [];
266
+ const sections = Array.isArray(data.sections) ? data.sections : [];
267
+ try {
268
+ return JSON.stringify({
269
+ activeId: data.activeId ?? null,
270
+ accounts,
271
+ groups,
272
+ sections
273
+ });
274
+ } catch {
275
+ return "";
276
+ }
277
+ }
278
+ //#endregion
279
+ //#region src/client/index.ts
280
+ /**
281
+ * dsh-deepseek-web-login — client 设置页(slots: settings.section)。
282
+ *
283
+ * 与 host 通过同源 fetch 通信:/deepseek-web-login/api/*
284
+ * 渲染契约(沿用生态实测结论):槽位 register 必须带 name 字段,
285
+ * 渲染函数返回 React 元素(createElement),React #130 的坑即来自返回非元素。
286
+ */
287
+ const inject = ["slots"];
288
+ const API = "/deepseek-web-login/api";
289
+ async function api(path, init) {
290
+ return await (await fetch(API + path, {
291
+ headers: { "content-type": "application/json" },
292
+ ...init
293
+ })).json();
294
+ }
295
+ /**
296
+ * 样式表 —— 全部走宿主的主题令牌(`--dsw-alias-*`)。
297
+ *
298
+ * 历史坑(2026-09-12 用户实测):以前用的是 `var(--theme-text, #ddd)` 这套**并不存在**的变量名,
299
+ * 于是每个颜色都落到兜底值上;兜底又全是深色 → 浅色主题下整个面板仍是黑底,
300
+ * 灰色文字贴在深底上几乎读不出来。
301
+ *
302
+ * 现在的做法:
303
+ * 1. 颜色优先取宿主令牌(`--dsw-alias-label-primary` / `bg-layer-*` / `border-l*` / `state-*` …),
304
+ * 这些令牌由 DSH 按当前主题(浅 / 深 / 皮肤)重定义,插件无需自己判断主题;
305
+ * 2. 令牌缺失时(老宿主 / 令牌改名)退回 `--fb-*`,而 `--fb-*` 由 `prefers-color-scheme` 切换,
306
+ * 保证「浅色主题不会出现黑界面」这条底线永远成立;
307
+ * 3. 正文改用系统无衬线字体(原来是等宽字体铺满全屏,观感像终端日志),等宽只留给命令与代码。
308
+ */
309
+ const styles = `
310
+ .dsw-page{
311
+ /* 兜底色:令牌缺失时才用到,随系统主题切换 */
312
+ --fb-fg:#1f2329;--fb-fg2:#545b66;--fb-fg3:#8b93a3;
313
+ --fb-bg1:#f6f7f9;--fb-bg2:#ffffff;--fb-bd:#e8eaee;--fb-bd2:#d7dae0;
314
+ --fb-hover:#0000000f;--fb-code:#f3f4f6;
315
+ /* 语义层 */
316
+ --fg:var(--dsw-alias-label-primary,var(--fb-fg));
317
+ --fg2:var(--dsw-alias-label-secondary,var(--fb-fg2));
318
+ --fg3:var(--dsw-alias-label-tertiary,var(--fb-fg3));
319
+ --bg1:var(--dsw-alias-bg-layer-1,var(--fb-bg1));
320
+ --bg2:var(--dsw-alias-bg-layer-2,var(--fb-bg2));
321
+ --bd:var(--dsw-alias-border-l1,var(--fb-bd));
322
+ --bd2:var(--dsw-alias-border-l2,var(--fb-bd2));
323
+ --hover:var(--dsw-alias-interactive-bg-hover,var(--fb-hover));
324
+ --code-bg:var(--dsw-alias-markdown-code-block,var(--fb-code));
325
+ --accent:var(--dsw-alias-brand-primary,#3b6ef0);
326
+ --on-accent:var(--dsw-alias-label-primary-foreground,#ffffff);
327
+ --ok:var(--dsw-alias-state-success-primary,#1a9f5a);
328
+ --warn:var(--dsw-alias-state-warn-label,#9a6a00);
329
+ --err:var(--dsw-alias-state-error-primary,#d93025);
330
+ --mono:var(--dsw-alias-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);
331
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
332
+ font-size:13px;line-height:1.6;color:var(--fg);max-width:760px;padding:2px 0 12px;
333
+ -webkit-font-smoothing:antialiased;
334
+ }
335
+ @media (prefers-color-scheme:dark){
336
+ .dsw-page{--fb-fg:#ededed;--fb-fg2:#b6bcc6;--fb-fg3:#858c99;
337
+ --fb-bg1:#212121;--fb-bg2:#2a2a2a;--fb-bd:#333333;--fb-bd2:#3d3d3d;
338
+ --fb-hover:#ffffff14;--fb-code:#1c1c1c}
339
+ }
340
+ .dsw-title{margin:0 0 3px;font-size:14px;font-weight:500;color:var(--fg)}
341
+ .dsw-sub{margin:0 0 14px;font-size:12px;line-height:1.55;color:var(--fg3)}
342
+ /* provider 名带连字符,万一折行会断成 deepseek- / web(看着像故障)—— 整词不拆 */
343
+ .dsw-nobreak{white-space:nowrap}
344
+ .dsw-card{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;padding:14px 16px;margin-bottom:10px}
345
+ .dsw-cardhead{display:flex;align-items:center;gap:8px;margin:0 0 8px;font-size:13px;font-weight:500;color:var(--fg)}
346
+ .dsw-card > .name{margin:0 0 8px;font-size:13px;font-weight:500;color:var(--fg)}
347
+ .dsw-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
348
+ .dsw-badge{font-size:11px;font-weight:500;line-height:1.7;padding:1px 9px;border-radius:999px;white-space:nowrap}
349
+ .dsw-badge.on{color:var(--ok);background:rgba(26,159,90,.15)}
350
+ .dsw-badge.off{color:var(--warn);background:rgba(200,140,20,.16)}
351
+ .dsw-badge.err{color:var(--err);background:rgba(217,48,37,.15)}
352
+ .dsw-btn{font:inherit;font-size:12px;font-weight:500;line-height:1.5;padding:6px 13px;border-radius:8px;
353
+ border:1px solid transparent;background:var(--accent);color:var(--on-accent);cursor:pointer;
354
+ transition:background-color .15s ease,border-color .15s ease,opacity .15s ease}
355
+ .dsw-btn:hover:not(:disabled){opacity:.88}
356
+ .dsw-btn:active:not(:disabled){opacity:.72}
357
+ .dsw-btn:disabled{opacity:.4;cursor:not-allowed}
358
+ .dsw-btn.ghost{background:transparent;border-color:var(--bd2);color:var(--fg)}
359
+ .dsw-btn.ghost:hover:not(:disabled){background:var(--hover);opacity:1}
360
+ .dsw-btn.danger{background:transparent;border-color:var(--bd2);color:var(--err)}
361
+ .dsw-btn.danger:hover:not(:disabled){background:rgba(217,48,37,.1);border-color:var(--err);opacity:1}
362
+ .dsw-btn.armed{background:var(--err);border-color:var(--err);color:#ffffff}
363
+ .dsw-btn.armed:hover:not(:disabled){opacity:.9}
364
+ .dsw-input,.dsw-area{width:100%;box-sizing:border-box;font:inherit;font-size:12.5px;
365
+ background:var(--bg1);color:var(--fg);border:1px solid var(--bd2);border-radius:8px;padding:7px 10px;
366
+ outline:none;transition:border-color .15s ease,box-shadow .15s ease}
367
+ .dsw-input:focus,.dsw-area:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--hover)}
368
+ .dsw-area{min-height:68px;resize:vertical;font-family:var(--mono);font-size:12px;line-height:1.5}
369
+ .dsw-kv{display:grid;grid-template-columns:auto 1fr;gap:0 12px;margin:2px 0 0;align-items:baseline}
370
+ .dsw-kv > *{padding:5px 0;border-top:1px solid var(--bd);min-width:0}
371
+ .dsw-kv > :nth-child(1),.dsw-kv > :nth-child(2){border-top:none}
372
+ .dsw-kv .k{color:var(--fg3);font-size:12px;white-space:nowrap}
373
+ .dsw-kv > *:not(.k){color:var(--fg);word-break:break-word}
374
+ .dsw-msg{margin-top:10px;padding:9px 12px;border-radius:8px;background:var(--bg1);border:1px solid var(--bd);
375
+ border-left:3px solid var(--fg3);white-space:pre-wrap;max-height:240px;overflow:auto;
376
+ font-size:12px;line-height:1.6;color:var(--fg)}
377
+ .dsw-msg.err{border-left-color:var(--err);color:var(--err)}
378
+ .dsw-msg.ok{border-left-color:var(--ok)}
379
+ .dsw-models{list-style:none;margin:2px 0 0;padding:0}
380
+ .dsw-models li{padding:9px 0;border-top:1px solid var(--bd)}
381
+ .dsw-models li:first-child{border-top:none;padding-top:2px}
382
+ .dsw-models .name{font-size:13px;font-weight:500;color:var(--fg)}
383
+ .dsw-models .id{color:var(--fg3);font-size:12px;margin-top:1px;word-break:break-word}
384
+ .dsw-hint{color:var(--fg3);font-size:12px;line-height:1.6;margin:8px 0 0}
385
+ .dsw-gate-row{display:flex;align-items:center;gap:10px;margin:10px 0;flex-wrap:wrap}
386
+ .dsw-gate-label{color:var(--fg3);font-size:12px;min-width:52px;flex:0 0 auto}
387
+ .dsw-gate-value{font-size:12px;color:var(--fg);min-width:56px;text-align:right;font-variant-numeric:tabular-nums}
388
+ /* 「上下限」成对的一行:两个滑块并排,右边一个合成值(如「6~10 个」)。
389
+ —— 比"每个边界各占一行"省一半高度,而且两个边界的相对位置一眼能比出来。 */
390
+ .dsw-range-pair{flex:1 1 240px;display:flex;gap:8px;align-items:center;min-width:190px}
391
+ .dsw-gate-pair-value{font-size:12px;color:var(--fg);min-width:78px;text-align:right;font-variant-numeric:tabular-nums}
392
+ .dsw-switch{display:inline-flex;align-items:center;gap:9px;font-size:13px;color:var(--fg);cursor:pointer;user-select:none}
393
+ .dsw-switch input{position:absolute;opacity:0;width:0;height:0}
394
+ .dsw-switch-track{width:34px;height:20px;border-radius:999px;background:var(--bd2);position:relative;transition:background-color .15s ease;flex:0 0 auto}
395
+ .dsw-switch-track::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .15s ease}
396
+ .dsw-switch input:checked + .dsw-switch-track{background:var(--err)}
397
+ .dsw-switch input:checked + .dsw-switch-track::after{transform:translateX(14px)}
398
+ .dsw-range{-webkit-appearance:none;appearance:none;flex:1 1 160px;height:4px;border-radius:999px;background:var(--bd2);outline:none;cursor:pointer}
399
+ .dsw-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:none}
400
+ .dsw-preset{padding:4px 10px;font-size:12px}
401
+ .dsw-preset.active{background:var(--accent);border-color:transparent;color:var(--on-accent)}
402
+ .dsw-gate-msg{color:var(--fg3);min-height:1.6em}
403
+ .dsw-code{display:block;margin:6px 0;padding:7px 10px;background:var(--code-bg);border:1px solid var(--bd);
404
+ border-radius:8px;font-family:var(--mono);font-size:12px;line-height:1.5;color:var(--fg);
405
+ overflow-x:auto;white-space:pre}
406
+ .dsw-msg::-webkit-scrollbar,.dsw-code::-webkit-scrollbar,.dsw-area::-webkit-scrollbar{width:8px;height:8px}
407
+ .dsw-msg::-webkit-scrollbar-thumb,.dsw-code::-webkit-scrollbar-thumb,.dsw-area::-webkit-scrollbar-thumb{
408
+ background:var(--bd2);border-radius:4px}
409
+ /* ── 标签页:把原来一页到底的 7 张卡拆成 4 页 ────────────────────────
410
+ 标签栏做成带边框的圆角容器;未选中 70% 透明度 + 下划线指示当前页。
411
+ 颜色全部走设计令牌,浅色/深色自动跟随,不用写第二份。 */
412
+ .dsw-tabs{display:inline-flex;gap:4px;border:1px solid var(--bd);border-radius:8px;
413
+ padding:0 10px;margin:4px 0 14px;background:var(--bg1)}
414
+ .dsw-tab{appearance:none;background:transparent;border:none;border-bottom:2px solid transparent;
415
+ padding:8px 14px;font:inherit;font-size:13px;color:var(--fg2);opacity:.7;cursor:pointer;
416
+ transition:opacity .15s ease,border-color .15s ease}
417
+ .dsw-tab:hover{opacity:1}
418
+ .dsw-tab.active{opacity:1;color:var(--fg);border-bottom-color:var(--fg)}
419
+ .dsw-pane[hidden]{display:none}
420
+ /* 二级(子页面)标签:**分段胶囊**。
421
+ 刻意和一级标签长得不一样(一级是"下划线 + 大字号"),这样一眼能看出自己在第几层;
422
+ 否则两层长得一样、用户会以为回到了同一层。 */
423
+ .dsw-subtabs{display:inline-flex;gap:2px;padding:3px;margin:0 0 14px;border-radius:999px;
424
+ background:var(--bg1);border:1px solid var(--bd)}
425
+ .dsw-subtab{appearance:none;border:0;background:transparent;font:inherit;font-size:12px;
426
+ font-weight:500;color:var(--fg2);padding:4px 14px;border-radius:999px;cursor:pointer;
427
+ transition:color .15s ease,background-color .15s ease}
428
+ .dsw-subtab:hover{color:var(--fg)}
429
+ /* 选中态:底色用 bg2 铺在 bg1 上,深色主题里这两档只差一点点,
430
+ 所以必须再加一圈描边(bd2 比 bd 明显)才立得住 —— 实测深色下不加就跟没选中一样。 */
431
+ .dsw-subtab.active{background:var(--bg2);color:var(--fg);font-weight:600;
432
+ box-shadow:inset 0 0 0 1px var(--bd2)}
433
+ /* 调用台账 */
434
+ .dsw-spark{display:block;width:100%;height:40px;margin:8px 0 2px}
435
+ .dsw-spark rect{fill:var(--bd2)}
436
+ .dsw-spark rect.on{fill:var(--accent)}
437
+ .dsw-spark rect.bad{fill:var(--err)}
438
+ .dsw-path{font-family:var(--mono);font-size:11px;color:var(--fg2);word-break:break-all}
439
+ /* 账号库 */
440
+ .dsw-accounts{list-style:none;margin:8px 0 0;padding:0;display:flex;flex-direction:column;gap:8px}
441
+ .dsw-account{display:flex;align-items:center;gap:10px;flex-wrap:wrap;border:1px solid var(--bd);
442
+ border-radius:10px;padding:9px 11px;background:var(--bg1)}
443
+ .dsw-account.active{border-color:var(--accent)}
444
+ .dsw-account-main{flex:1 1 200px;min-width:0}
445
+ .dsw-account-title{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:13px;font-weight:500}
446
+ .dsw-account-meta{font-size:11px;color:var(--fg3);margin-top:3px;word-break:break-all}
447
+ .dsw-account-fix{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:6px}
448
+ .dsw-account-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
449
+ .dsw-labelinput{font:inherit;font-size:12px;padding:3px 7px;border-radius:6px;border:1px solid var(--bd2);
450
+ background:var(--bg2);color:var(--fg);width:150px;margin-top:4px}
451
+ .dsw-limit{color:var(--warn)}
452
+ /* 分组:组标题行 + 组内缩进 + 归组下拉 */
453
+ .dsw-grouphead{display:flex;align-items:center;gap:8px;margin:12px 0 0;font-size:12px}
454
+ .dsw-grouphead .gname{font-weight:500}
455
+ .dsw-grouphead .gcount{color:var(--fg3);font-size:11px}
456
+ .dsw-grouphead .gtoggle{cursor:pointer;user-select:none;color:var(--fg2);width:12px;display:inline-block;text-align:center}
457
+ .dsw-grouphead .gspacer{flex:1 1 auto}
458
+ .dsw-grouphead .dsw-btn{font-size:11px;padding:1px 7px}
459
+ .dsw-account.grouped{margin-left:14px}
460
+ .dsw-groupsel{font:inherit;font-size:11px;padding:2px 4px;border-radius:6px;border:1px solid var(--bd2);
461
+ background:var(--bg2);color:var(--fg);max-width:110px}
462
+ /* 操作反馈:不归属任何一页,常驻在标签栏之上 */
463
+ .dsw-alert{margin:0 0 12px;padding:8px 10px;border-radius:8px;border:1px solid var(--bd);
464
+ background:var(--bg1);white-space:pre-wrap;font-size:12px}
465
+ .dsw-alert.ok{border-left:3px solid var(--ok);color:var(--ok)}
466
+ .dsw-alert.err{border-left:3px solid var(--err);color:var(--err)}
467
+ `;
468
+ /** 短时间:今天只显示时分,其它显示月/日 时:分。 */
469
+ function shortTime(input) {
470
+ if (input === void 0 || input === null || input === "") return "";
471
+ const date = new Date(input);
472
+ if (Number.isNaN(date.getTime())) return "";
473
+ const now = /* @__PURE__ */ new Date();
474
+ const sameDay = date.toDateString() === now.toDateString();
475
+ const hhmm = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
476
+ return sameDay ? hhmm : `${date.getMonth() + 1}/${date.getDate()} ${hhmm}`;
477
+ }
478
+ /** 相对时间:刚刚 / N 分钟前 / N 小时前 / N 天前。 */
479
+ function relTime(input) {
480
+ if (!input) return "";
481
+ const at = new Date(input).getTime();
482
+ if (!Number.isFinite(at)) return "";
483
+ const diff = Date.now() - at;
484
+ if (diff < 6e4) return "刚刚";
485
+ if (diff < 36e5) return `${Math.floor(diff / 6e4)} 分钟前`;
486
+ if (diff < 864e5) return `${Math.floor(diff / 36e5)} 小时前`;
487
+ return `${Math.floor(diff / 864e5)} 天前`;
488
+ }
489
+ /** 倒计时:还剩 2 小时 13 分 / 已解除。 */
490
+ function countdown(untilMs) {
491
+ if (!untilMs || !Number.isFinite(untilMs)) return "";
492
+ const left = untilMs - Date.now();
493
+ if (left <= 0) return "已解除";
494
+ const totalMinutes = Math.ceil(left / 6e4);
495
+ const hours = Math.floor(totalMinutes / 60);
496
+ const minutes = totalMinutes % 60;
497
+ if (hours > 0) return `剩余 ${hours} 小时 ${minutes} 分`;
498
+ return `剩余 ${minutes} 分`;
499
+ }
500
+ function el(tag, cls, text) {
501
+ const node = document.createElement(tag);
502
+ if (cls) node.className = cls;
503
+ if (text !== void 0) node.textContent = text;
504
+ return node;
505
+ }
506
+ /** 面板本体:命令式 DOM(生态既有插件同款做法),挂在 React 容器里。 */
507
+ function Panel() {
508
+ const hostRef = (0, react.useRef)(null);
509
+ (0, react.useEffect)(() => {
510
+ const root = hostRef.current;
511
+ if (!root) return;
512
+ let disposed = false;
513
+ let timer;
514
+ const style = document.createElement("style");
515
+ style.textContent = styles;
516
+ const page = el("div", "dsw-page");
517
+ const title = el("h3", "dsw-title", "DeepSeek 网页登录(免费模型)");
518
+ const sub = el("p", "dsw-sub");
519
+ sub.append("用 chat.deepseek.com 的登录态驱动 DSH,不需要 API Key(provider:");
520
+ sub.append(el("span", "dsw-nobreak", "deepseek-web"));
521
+ sub.append(")");
522
+ page.append(style, title, sub);
523
+ const message = el("div", "dsw-alert");
524
+ message.style.display = "none";
525
+ page.append(message);
526
+ const showMessage = (text, kind = "") => {
527
+ message.textContent = text;
528
+ message.className = `dsw-alert${kind ? ` ${kind}` : ""}`;
529
+ message.style.display = "block";
530
+ };
531
+ const TAB_KEYS = [
532
+ "account",
533
+ "model",
534
+ "gate",
535
+ "transport",
536
+ "context",
537
+ "about"
538
+ ];
539
+ const TAB_LABELS = {
540
+ account: "账号",
541
+ model: "模型",
542
+ gate: "防风控",
543
+ transport: "传输层",
544
+ context: "上下文",
545
+ about: "关于"
546
+ };
547
+ const accountPane = el("div", "dsw-pane");
548
+ const modelPane = el("div", "dsw-pane");
549
+ const gatePane = el("div", "dsw-pane");
550
+ const transportPane = el("div", "dsw-pane");
551
+ const contextPane = el("div", "dsw-pane");
552
+ const aboutPane = el("div", "dsw-pane");
553
+ const panes = {
554
+ account: accountPane,
555
+ model: modelPane,
556
+ gate: gatePane,
557
+ transport: transportPane,
558
+ context: contextPane,
559
+ about: aboutPane
560
+ };
561
+ const tabButtons = {};
562
+ const selectTab = (key) => {
563
+ for (const each of TAB_KEYS) {
564
+ const on = each === key;
565
+ panes[each].hidden = !on;
566
+ tabButtons[each].classList.toggle("active", on);
567
+ tabButtons[each].setAttribute("aria-selected", String(on));
568
+ }
569
+ };
570
+ const tabBar = el("div", "dsw-tabs");
571
+ tabBar.setAttribute("role", "tablist");
572
+ for (const key of TAB_KEYS) {
573
+ const btn = el("button", "dsw-tab", TAB_LABELS[key]);
574
+ btn.type = "button";
575
+ btn.setAttribute("role", "tab");
576
+ btn.addEventListener("click", () => selectTab(key));
577
+ tabButtons[key] = btn;
578
+ tabBar.append(btn);
579
+ panes[key].setAttribute("role", "tabpanel");
580
+ }
581
+ page.append(tabBar, ...TAB_KEYS.map((key) => panes[key]));
582
+ selectTab("account");
583
+ const ACCT_KEYS = ["status", "library"];
584
+ const ACCT_LABELS = {
585
+ status: "登录状态",
586
+ library: "账号库"
587
+ };
588
+ const acctStatusPane = el("div", "dsw-pane");
589
+ const acctLibraryPane = el("div", "dsw-pane");
590
+ const acctPanes = {
591
+ status: acctStatusPane,
592
+ library: acctLibraryPane
593
+ };
594
+ const acctButtons = {};
595
+ const selectAcctTab = (key) => {
596
+ for (const each of ACCT_KEYS) {
597
+ const on = each === key;
598
+ acctPanes[each].hidden = !on;
599
+ acctButtons[each].classList.toggle("active", on);
600
+ acctButtons[each].setAttribute("aria-selected", String(on));
601
+ }
602
+ };
603
+ const acctTabBar = el("div", "dsw-subtabs");
604
+ acctTabBar.setAttribute("role", "tablist");
605
+ for (const key of ACCT_KEYS) {
606
+ const btn = el("button", "dsw-subtab", ACCT_LABELS[key]);
607
+ btn.type = "button";
608
+ btn.setAttribute("role", "tab");
609
+ btn.addEventListener("click", () => selectAcctTab(key));
610
+ acctButtons[key] = btn;
611
+ acctTabBar.append(btn);
612
+ acctPanes[key].setAttribute("role", "tabpanel");
613
+ }
614
+ accountPane.append(acctTabBar, acctStatusPane, acctLibraryPane);
615
+ selectAcctTab("status");
616
+ const loginCard = el("div", "dsw-card");
617
+ const loginHead = el("div", "dsw-cardhead");
618
+ const loginTitle = el("div");
619
+ loginTitle.append(el("span", "name", "登录状态"));
620
+ const badge = el("span", "dsw-badge off", "⚪ 未登录");
621
+ loginTitle.append(badge);
622
+ loginHead.append(loginTitle);
623
+ loginCard.append(loginHead);
624
+ const statusKv = el("div", "dsw-kv");
625
+ loginCard.append(statusKv);
626
+ let activeLimitUntilMs = null;
627
+ const limitRow = el("p", "dsw-hint dsw-limit");
628
+ limitRow.style.display = "none";
629
+ limitRow.style.marginTop = "8px";
630
+ loginCard.append(limitRow);
631
+ const paintLimit = () => {
632
+ if (!activeLimitUntilMs) {
633
+ limitRow.style.display = "none";
634
+ return;
635
+ }
636
+ const text = countdown(activeLimitUntilMs);
637
+ limitRow.style.display = "";
638
+ limitRow.textContent = text === "已解除" ? `账号级限制已解除(${shortTime(activeLimitUntilMs)})。` : `⚠️ 账号级限制:${text}(${shortTime(activeLimitUntilMs)} 解除)—— 期间生成会被拒;只读调用(校验 / 探活)不受影响。`;
639
+ };
640
+ const accountCard = el("div", "dsw-card");
641
+ accountCard.append(el("div", "dsw-cardhead", "当前账号"));
642
+ const accountLine = el("div", "dsw-kv");
643
+ accountCard.append(accountLine);
644
+ const accountActions = el("div", "dsw-row");
645
+ accountActions.style.marginTop = "8px";
646
+ const logoutBtn = el("button", "dsw-btn danger", "退出当前账号");
647
+ const switchBtn = el("button", "dsw-btn ghost", "退出并登录其它账号");
648
+ accountActions.append(logoutBtn, switchBtn);
649
+ accountCard.append(accountActions);
650
+ const accountHint = el("p", "dsw-hint", "⚠️ 「退出」= 把该账号从账号库移除,不是\"只登出\":本地凭证与浏览器登录态会一起清掉。想留住它就先「导出备份」;只是想换个号用,去「账号库 → 登录新账号」。");
651
+ accountCard.append(accountHint);
652
+ const loginActions = el("div", "dsw-row");
653
+ loginActions.style.marginTop = "10px";
654
+ const browserBtn = el("button", "dsw-btn", "浏览器窗口登录");
655
+ const externalBtn = el("button", "dsw-btn ghost", "用我的默认浏览器登录");
656
+ const recoverBtn = el("button", "dsw-btn ghost", "从已登录窗口恢复");
657
+ const refreshBtn = el("button", "dsw-btn ghost", "刷新状态");
658
+ loginActions.append(browserBtn, externalBtn, recoverBtn, refreshBtn);
659
+ loginCard.append(loginActions);
660
+ loginCard.append(el("p", "dsw-hint", "「用我的默认浏览器登录」= 用系统浏览器打开 chat.deepseek.com(网页端若提示「使用环境异常」,走这条)。外部浏览器的登录态插件抓不到,所以要用 F12 控制台取 token 粘到下面那张卡(命令已备好)。"));
661
+ loginCard.append(el("p", "dsw-hint", "「从已登录窗口恢复」= 复用上次登录过的窗口分区直接取凭证(免重新登录),凭证丢失时用它救急。"));
662
+ acctStatusPane.append(loginCard, accountCard);
663
+ /**
664
+ * 一句话反馈节点:给它的 `textContent` 赋值时**自动选样式** ——
665
+ * 命中「失败/错误/无法」→ 红框红字(醒目),以「已…」开头 → 绿框,其余 → 灰色小字。
666
+ *
667
+ * 为什么自动判定、而不是让每个调用点传 kind(2026-09-15 用户反馈):
668
+ * 「账号切换失败」原来和旁边的说明文字长得一模一样(都是灰色小字),根本注意不到。
669
+ * 这三个节点加起来 40 多个赋值点,靠人手传 kind 迟早会漏 —— 而漏掉的那个往往正是报错。
670
+ */
671
+ const createMsgNode = () => {
672
+ const node = el("p", "dsw-hint dsw-gate-msg", "");
673
+ let text = "";
674
+ return {
675
+ node,
676
+ get textContent() {
677
+ return text;
678
+ },
679
+ set textContent(value) {
680
+ text = value ?? "";
681
+ node.textContent = text;
682
+ node.className = /失败|错误|无法|不对|⚠️/.test(text) ? "dsw-msg err" : /^(✅|已切换|已移除|已捕获|已保存|已原地)/.test(text) ? "dsw-msg ok" : "dsw-hint dsw-gate-msg";
683
+ }
684
+ };
685
+ };
686
+ const accountsCard = el("div", "dsw-card");
687
+ const accountsHead = el("div", "dsw-cardhead");
688
+ accountsHead.append(el("span", "name", "账号库"));
689
+ const accountsBadge = el("span", "dsw-badge off", "—");
690
+ accountsHead.append(accountsBadge);
691
+ accountsCard.append(accountsHead);
692
+ accountsCard.append(el("p", "dsw-hint", "💡 保存过的账号都在本机,点「切换」即时生效、不用重新登录 —— 切换后从下一次请求开始生效。"));
693
+ const accountsList = el("ul", "dsw-accounts");
694
+ accountsCard.append(accountsList);
695
+ const accountsEmpty = el("p", "dsw-hint", "账号库是空的 —— 用上面的「登录」或「手动粘贴 Token」添加一个。");
696
+ accountsCard.append(accountsEmpty);
697
+ const accountsIOPanel = el("div", "dsw-row");
698
+ accountsIOPanel.style.marginTop = "10px";
699
+ const addAccountBtn = el("button", "dsw-btn", "登录新账号(添加)");
700
+ const exportBtn = el("button", "dsw-btn ghost", "导出备份…");
701
+ const importBtn = el("button", "dsw-btn ghost", "导入备份…");
702
+ const refreshAccountsBtn = el("button", "dsw-btn ghost", "校验全部");
703
+ refreshAccountsBtn.title = "对每个账号做一次只读校验(零额度):刷新登录态、补上账号名、清掉已恢复的失败标记";
704
+ const newGroupBtn = el("button", "dsw-btn ghost", "新建分组");
705
+ newGroupBtn.title = "给账号分类,只影响列表的显示方式 —— 不参与切号、也不影响会话复用与清理";
706
+ accountsIOPanel.append(addAccountBtn, exportBtn, importBtn, refreshAccountsBtn, newGroupBtn);
707
+ accountsCard.append(accountsIOPanel);
708
+ accountsCard.append(el("p", "dsw-hint", "💡 「登录新账号」会先清掉上次的浏览器登录态(库里已有的账号不受影响),登录后新账号只入库、不切换当前账号 ——加完在列表里点「切换」即可使用。某个账号标着「需要重新登录」时,用它自己那行上的按钮修 ——那条路不清浏览器登录态,能复用就直接复用。导出/导入会弹系统对话框,自己选位置和文件。"));
709
+ const accountsMsg = createMsgNode();
710
+ accountsCard.append(accountsMsg.node);
711
+ accountsCard.append(el("p", "dsw-hint", "⚠️ 导出的备份文件就是可完整登录的凭证(等同于账号本身)—— 别分享、别提交到仓库。"));
712
+ acctLibraryPane.append(accountsCard);
713
+ /**
714
+ * 一个组的标题行:折叠箭头 + 组名 + 数量 + (真实组才有)改名 / 删除。
715
+ *
716
+ * 「未分组」是**伪组**(`groupId === null`),不给改名/删除入口 —— 它不是一个实体,
717
+ * 只是"没归组的账号"的落脚处。
718
+ */
719
+ const sectionRow = (section, groups) => {
720
+ const key = String(section?.key ?? "");
721
+ const collapsed = collapsedGroups.has(key);
722
+ const head = el("div", "dsw-grouphead");
723
+ const toggle = el("span", "gtoggle", collapsed ? "▸" : "▾");
724
+ toggle.title = collapsed ? "展开" : "折叠";
725
+ toggle.addEventListener("click", () => {
726
+ if (collapsedGroups.has(key)) collapsedGroups.delete(key);
727
+ else collapsedGroups.add(key);
728
+ persistCollapsedGroups();
729
+ redrawAccounts();
730
+ });
731
+ head.append(toggle, el("span", "gname", String(section?.name ?? "")));
732
+ head.append(el("span", "gcount", `${Array.isArray(section?.accounts) ? section.accounts.length : 0} 个`));
733
+ head.append(el("span", "gspacer"));
734
+ if (section?.groupId) {
735
+ const renameGroupBtn = el("button", "dsw-btn ghost", "改名");
736
+ renameGroupBtn.addEventListener("click", () => {
737
+ const input = el("input", "dsw-labelinput");
738
+ input.value = String(section.name ?? "");
739
+ input.placeholder = "分组名";
740
+ head.append(input);
741
+ input.focus();
742
+ let settled = false;
743
+ const commit = () => {
744
+ if (settled) return;
745
+ settled = true;
746
+ renameAccountGroup(String(section.groupId), input.value);
747
+ };
748
+ input.addEventListener("keydown", (event) => {
749
+ if (event.key === "Enter") commit();
750
+ if (event.key === "Escape") {
751
+ settled = true;
752
+ redrawAccounts();
753
+ }
754
+ });
755
+ input.addEventListener("blur", commit);
756
+ });
757
+ const deleteGroupBtn = el("button", "dsw-btn danger", "删除");
758
+ deleteGroupBtn.title = "只删分组本身;组里的账号会回到「未分组」,账号与凭证都不动";
759
+ let armed = false;
760
+ deleteGroupBtn.addEventListener("click", () => {
761
+ if (!armed) {
762
+ armed = true;
763
+ deleteGroupBtn.textContent = "确认删除?";
764
+ window.setTimeout(() => {
765
+ armed = false;
766
+ deleteGroupBtn.textContent = "删除";
767
+ }, 4e3);
768
+ return;
769
+ }
770
+ deleteAccountGroup(String(section.groupId));
771
+ });
772
+ head.append(renameGroupBtn, deleteGroupBtn);
773
+ }
774
+ return head;
775
+ };
776
+ const renderAccounts = (data) => {
777
+ const items = Array.isArray(data?.accounts) ? data.accounts : [];
778
+ const groups = Array.isArray(data?.groups) ? data.groups : [];
779
+ const sections = Array.isArray(data?.sections) ? data.sections : [];
780
+ accountsBadge.textContent = `${items.length} 个`;
781
+ accountsBadge.className = `dsw-badge ${items.length ? "on" : "off"}`;
782
+ accountsEmpty.hidden = items.length > 0;
783
+ accountsList.textContent = "";
784
+ if (!sections.length) {
785
+ for (const item of items) accountsList.append(accountRow(item, groups, false));
786
+ return;
787
+ }
788
+ for (const section of sections) {
789
+ accountsList.append(sectionRow(section, groups));
790
+ if (collapsedGroups.has(String(section?.key ?? ""))) continue;
791
+ for (const item of Array.isArray(section?.accounts) ? section.accounts : []) accountsList.append(accountRow(item, groups, true));
792
+ }
793
+ };
794
+ /**
795
+ * 「重新登录这个账号」:给凭证失效(探活失败)的账号用。
796
+ *
797
+ * 与「登录新账号」的唯一区别是**不清浏览器登录态**:后者要先清干净才能加到别的号,
798
+ * 而修同一个号正相反 —— 留着才可能一打开就复用上。落库仍走添加模式(只入库、不切换),
799
+ * 所以修好它也不会顶掉你正在用的账号;若它本来就是当前账号,那正是你要的效果。
800
+ */
801
+ const reloginAccount = (id, title) => {
802
+ (async () => {
803
+ accountsMsg.textContent = `正在为「${title}」打开登录窗口……`;
804
+ try {
805
+ const prep = await api("/login/relogin", {
806
+ method: "POST",
807
+ body: JSON.stringify({ id })
808
+ });
809
+ if (prep?.ok === false) {
810
+ accountsMsg.textContent = `准备失败:${prep?.error ?? "未知原因"}`;
811
+ return;
812
+ }
813
+ boostUntil = Date.now() + 3e5;
814
+ accountsMsg.textContent = String(prep?.hint ?? "登录窗口已打开:如果浏览器里还留着这个账号的登录态会立刻复用,否则在里面重新登录一次……");
815
+ const result = await api("/login/browser", {
816
+ method: "POST",
817
+ body: "{}"
818
+ });
819
+ if (result?.started === false) {
820
+ accountsMsg.textContent = `打开登录窗口失败:${result?.reason ?? "未知原因"}(可改用「手动粘贴 Token」)`;
821
+ return;
822
+ }
823
+ accountsMsg.textContent = result?.relogin ? `「${title}」的凭证已原地更新(同一条记录、当前账号未变),旧的失败标记也清掉了。` : result?.added ? `「${title}」已加入账号库 —— 当前使用的账号没有改变。` : "已捕获并保存凭证。";
824
+ await loadAccounts();
825
+ } catch (error) {
826
+ accountsMsg.textContent = `重新登录失败:${error?.message ?? error}`;
827
+ } finally {
828
+ await refresh(false).catch(() => void 0);
829
+ }
830
+ })();
831
+ };
832
+ const accountRow = (item, groups = [], indent = false) => {
833
+ const row = el("li", `dsw-account${item.isActive ? " active" : ""}${indent ? " grouped" : ""}`);
834
+ const main = el("div", "dsw-account-main");
835
+ const title = el("div", "dsw-account-title");
836
+ title.append(el("span", void 0, item.title || item.id));
837
+ if (item.isActive) title.append(el("span", "dsw-badge on", "✅ 当前"));
838
+ if (item.unverified) title.append(el("span", "dsw-badge off", "❔ 未校验"));
839
+ if (item.limit && Number.isFinite(item.limit.untilMs) && item.limit.untilMs > Date.now()) title.append(el("span", "dsw-badge off", `⏳ 受限至 ${shortTime(item.limit.untilMs)}`));
840
+ if (item.lastVerifyError) title.append(el("span", "dsw-badge err", "❌ 需要重新登录"));
841
+ main.append(title);
842
+ const meta = [];
843
+ if (item.display) meta.push(item.display);
844
+ if (item.capturedAt) meta.push(`${shortTime(item.capturedAt)} 捕获`);
845
+ meta.push(item.lastVerifiedAt ? `最近校验 ${relTime(item.lastVerifiedAt)}` : "尚未校验");
846
+ const cookieMeta = Array.isArray(item.cookieMeta) ? item.cookieMeta : [];
847
+ meta.push(`cookie:${describeCookieLife(summarizeCookieLife(cookieMeta))}`);
848
+ main.append(el("div", "dsw-account-meta", meta.join(" · ")));
849
+ if (item.lastVerifyError) main.append(el("div", "dsw-account-fix", `⚠️ ${relTime(item.lastVerifyError.at)}校验失败:${item.lastVerifyError.message}`));
850
+ row.append(main);
851
+ const actions = el("div", "dsw-account-actions");
852
+ if (item.lastVerifyError) {
853
+ const reloginBtn = el("button", "dsw-btn dsw-preset", "重登");
854
+ reloginBtn.title = "能复用浏览器里的登录态就直接复用(不必敲密码);若这条账号已被标记失效,会先清掉登录态、让你重新登录一次。捕获后原地更新这条记录,不新增、也不切换当前账号";
855
+ reloginBtn.addEventListener("click", () => reloginAccount(item.id, item.title || item.id));
856
+ actions.append(reloginBtn);
857
+ }
858
+ if (item.isActive) actions.append(el("span", "dsw-hint", "使用中"));
859
+ else {
860
+ const useBtn = el("button", "dsw-btn ghost dsw-preset", "切换");
861
+ useBtn.addEventListener("click", () => void switchToAccount(item.id));
862
+ actions.append(useBtn);
863
+ }
864
+ const renameBtn = el("button", "dsw-btn ghost dsw-preset", "备注");
865
+ let editing = false;
866
+ renameBtn.addEventListener("click", () => {
867
+ if (editing) return;
868
+ editing = true;
869
+ const input = el("input", "dsw-labelinput");
870
+ input.value = item.label || "";
871
+ input.placeholder = "备注名,如「工作号」";
872
+ main.append(input);
873
+ input.focus();
874
+ let settled = false;
875
+ const commit = () => {
876
+ if (settled) return;
877
+ settled = true;
878
+ saveAccountLabel(item.id, input.value);
879
+ };
880
+ input.addEventListener("keydown", (event) => {
881
+ if (event.key === "Enter") commit();
882
+ if (event.key === "Escape") {
883
+ settled = true;
884
+ loadAccounts();
885
+ }
886
+ });
887
+ input.addEventListener("blur", commit);
888
+ });
889
+ actions.append(renameBtn);
890
+ const groupSel = el("select", "dsw-groupsel");
891
+ groupSel.title = "放进某个分组(只影响显示方式,不影响切号与会话复用)";
892
+ groupSel.append(new Option("未分组", ""));
893
+ for (const group of groups) groupSel.append(new Option(String(group?.name ?? ""), String(group?.id ?? "")));
894
+ groupSel.value = String(item.groupId ?? "");
895
+ groupSel.addEventListener("change", () => void assignAccountGroup(item.id, groupSel.value));
896
+ actions.append(groupSel);
897
+ const removeBtn = el("button", "dsw-btn danger dsw-preset", "移除");
898
+ let armed = false;
899
+ removeBtn.addEventListener("click", () => {
900
+ if (!armed) {
901
+ armed = true;
902
+ removeBtn.textContent = "确认移除?";
903
+ removeBtn.classList.add("armed");
904
+ window.setTimeout(() => {
905
+ armed = false;
906
+ removeBtn.textContent = "移除";
907
+ removeBtn.classList.remove("armed");
908
+ }, 4e3);
909
+ return;
910
+ }
911
+ removeAccountById(item.id);
912
+ });
913
+ actions.append(removeBtn);
914
+ row.append(actions);
915
+ return row;
916
+ };
917
+ /**
918
+ * 渲染账号库,但**内容没变就不重建 DOM**。
919
+ *
920
+ * 为什么要有这道守卫:轮询会按秒级节拍重读 `/accounts`(见下面的 syncAccounts),
921
+ * 无脑重建会把用户正在悬停/正要点的按钮换掉(重命名输入框更糟 —— 直接失焦)。
922
+ * 签名取的是界面真正渲染的东西,见 account-sync.ts。
923
+ */
924
+ const applyAccounts = (data) => {
925
+ lastAccountsPayload = data;
926
+ const signature = accountsSignature(data);
927
+ if (signature && signature === accountsSignatureCache) return;
928
+ accountsSignatureCache = signature;
929
+ renderAccounts(data);
930
+ };
931
+ /** 用最近一次载荷重画(折叠这类纯前端状态用它,不走重建签名、也不打 HTTP)。 */
932
+ const redrawAccounts = () => {
933
+ if (lastAccountsPayload) renderAccounts(lastAccountsPayload);
934
+ };
935
+ /** 显式读取(失败要说给用户听):初始化 / 切号 / 移除 / 添加 / 重新登录 / 导入之后用。 */
936
+ const loadAccounts = async () => {
937
+ try {
938
+ applyAccounts(await api("/accounts"));
939
+ } catch (error) {
940
+ accountsMsg.textContent = `账号库读取失败:${error?.message ?? error}`;
941
+ } finally {
942
+ accountsSyncedAt = Date.now();
943
+ }
944
+ };
945
+ /**
946
+ * 后台同步账号库(静默;只在内容变了时重建列表)。
947
+ *
948
+ * 为什么必须有它:账号库列表**不在 `/status` 里**,登录捕获又是异步落地的
949
+ * (CDP 那条路要等用户在浏览器里登录完;能开 Electron 窗口那条路更是"开窗即返回")
950
+ * ⇒ 只靠上面几个显式调用,捕获晚一步就永远不显示,得关掉设置页再打开。
951
+ *
952
+ * 后台同步失败**不写进 accountsMsg** —— 那是给用户操作反馈用的,
953
+ * 被一个后台轮询的偶发失败覆盖掉会更让人困惑(下一次成功就自然好了)。
954
+ */
955
+ const syncAccounts = async () => {
956
+ accountsSyncedAt = Date.now();
957
+ try {
958
+ applyAccounts(await api("/accounts"));
959
+ } catch {}
960
+ };
961
+ /**
962
+ * 分组动作:**只有显示方式会变,账号与凭证一律不动。**
963
+ *
964
+ * 每次操作后都 `loadAccounts()` 重读,而不是就地改 DOM:
965
+ * 宿主的 `/accounts` 是唯一事实来源;重读既能拿到组的新状态,也会顺带走一遍内容签名 ——
966
+ * 签名没变就不重建,正在输入的框不会被打断。
967
+ */
968
+ const groupApi = async (path, body, okText) => {
969
+ try {
970
+ const result = await api(path, {
971
+ method: "POST",
972
+ body: JSON.stringify(body)
973
+ });
974
+ if (result?.ok === false) {
975
+ accountsMsg.textContent = `分组操作失败:${result?.error ?? "未知原因"}`;
976
+ return;
977
+ }
978
+ accountsMsg.textContent = okText;
979
+ } catch (error) {
980
+ accountsMsg.textContent = `分组操作失败:${error?.message ?? error}`;
981
+ } finally {
982
+ await loadAccounts();
983
+ }
984
+ };
985
+ const assignAccountGroup = (id, groupId) => groupApi("/accounts/group/assign", {
986
+ id,
987
+ groupId
988
+ }, groupId ? "已归组。" : "已移出分组(回到「未分组」)。");
989
+ const renameAccountGroup = (id, name) => groupApi("/accounts/group/rename", {
990
+ id,
991
+ name
992
+ }, `分组已改名为「${name.trim()}」。`);
993
+ const deleteAccountGroup = (id) => groupApi("/accounts/group/delete", { id }, "分组已删除;组里的账号回到「未分组」(账号本身没动)。");
994
+ /** 「新建分组」的内联输入框(与「备注」同款交互:Enter 提交、Esc 取消、失焦也提交)。 */
995
+ let newGroupInput = null;
996
+ const promptNewGroup = () => {
997
+ if (newGroupInput && newGroupInput.isConnected) {
998
+ newGroupInput.focus();
999
+ return;
1000
+ }
1001
+ const input = el("input", "dsw-labelinput");
1002
+ input.placeholder = "分组名,如「工作号」";
1003
+ accountsIOPanel.append(input);
1004
+ input.focus();
1005
+ newGroupInput = input;
1006
+ let settled = false;
1007
+ const dismiss = () => {
1008
+ settled = true;
1009
+ newGroupInput = null;
1010
+ input.remove();
1011
+ };
1012
+ const commit = () => {
1013
+ if (settled) return;
1014
+ const name = input.value;
1015
+ dismiss();
1016
+ if (!name.trim()) return;
1017
+ groupApi("/accounts/group/create", { name }, `已创建分组「${name.trim()}」。`);
1018
+ };
1019
+ input.addEventListener("keydown", (event) => {
1020
+ if (event.key === "Enter") commit();
1021
+ if (event.key === "Escape") dismiss();
1022
+ });
1023
+ input.addEventListener("blur", commit);
1024
+ };
1025
+ /**
1026
+ * 手动刷新账号状态:只读探活,零额度。
1027
+ *
1028
+ * 按钮会**禁用并改文案** —— 库里账号是串行校验的,7 个要十几秒;不置灰的话
1029
+ * 用户会以为没反应而反复点(宿主侧也有互斥,但界面先挡住更好)。
1030
+ */
1031
+ const refreshAccountStates = async () => {
1032
+ refreshAccountsBtn.disabled = true;
1033
+ const original = refreshAccountsBtn.textContent;
1034
+ refreshAccountsBtn.textContent = "校验中…";
1035
+ accountsMsg.textContent = "正在逐个校验账号(只读、零额度,串行进行,请稍候)……";
1036
+ try {
1037
+ const result = await api("/accounts/refresh", {
1038
+ method: "POST",
1039
+ body: "{}"
1040
+ });
1041
+ if (result?.ok === false) {
1042
+ accountsMsg.textContent = `刷新失败:${result?.error ?? "未知原因"}`;
1043
+ return;
1044
+ }
1045
+ const failed = Number(result?.failed ?? 0);
1046
+ accountsMsg.textContent = failed > 0 ? `已校验 ${result?.checked ?? 0} 个账号:${result?.passed ?? 0} 个正常、${failed} 个需要重登(列表里已标出)。` : `已校验 ${result?.checked ?? 0} 个账号:全部正常。`;
1047
+ } catch (error) {
1048
+ accountsMsg.textContent = `刷新失败:${error?.message ?? error}`;
1049
+ } finally {
1050
+ refreshAccountsBtn.disabled = false;
1051
+ refreshAccountsBtn.textContent = original;
1052
+ await refresh(false).catch(() => void 0);
1053
+ }
1054
+ };
1055
+ refreshAccountsBtn.addEventListener("click", () => void refreshAccountStates());
1056
+ newGroupBtn.addEventListener("click", () => promptNewGroup());
1057
+ const switchToAccount = async (id) => {
1058
+ accountsMsg.textContent = "切换中……";
1059
+ try {
1060
+ const result = await api("/accounts/switch", {
1061
+ method: "POST",
1062
+ body: JSON.stringify({ id })
1063
+ });
1064
+ if (!result?.ok) {
1065
+ accountsMsg.textContent = `切换失败:${result?.error ?? "未知原因"}`;
1066
+ return;
1067
+ }
1068
+ accountsMsg.textContent = "已切换(下一次请求生效)。正在刷新登录状态……";
1069
+ await loadAccounts();
1070
+ await refresh(false);
1071
+ accountsMsg.textContent = "已切换(下一次请求生效)";
1072
+ } catch (error) {
1073
+ accountsMsg.textContent = `切换失败:${error?.message ?? error}`;
1074
+ }
1075
+ };
1076
+ const removeAccountById = async (id) => {
1077
+ try {
1078
+ const result = await api("/accounts/remove", {
1079
+ method: "POST",
1080
+ body: JSON.stringify({ id })
1081
+ });
1082
+ if (!result?.ok) {
1083
+ accountsMsg.textContent = `移除失败:${result?.error ?? "未知原因"}`;
1084
+ return;
1085
+ }
1086
+ accountsMsg.textContent = "已移除该账号(凭证已删除)";
1087
+ await loadAccounts();
1088
+ await refresh(true);
1089
+ } catch (error) {
1090
+ accountsMsg.textContent = `移除失败:${error?.message ?? error}`;
1091
+ }
1092
+ };
1093
+ const saveAccountLabel = async (id, label) => {
1094
+ try {
1095
+ await api("/accounts/rename", {
1096
+ method: "POST",
1097
+ body: JSON.stringify({
1098
+ id,
1099
+ label
1100
+ })
1101
+ });
1102
+ } catch {}
1103
+ await loadAccounts();
1104
+ };
1105
+ addAccountBtn.addEventListener("click", () => {
1106
+ (async () => {
1107
+ addAccountBtn.disabled = true;
1108
+ accountsMsg.textContent = "正在准备登录窗口(会清掉上次的浏览器登录态,不影响账号库里的账号)……";
1109
+ try {
1110
+ const prep = await api("/login/add", {
1111
+ method: "POST",
1112
+ body: "{}"
1113
+ });
1114
+ if (prep?.ok === false) {
1115
+ accountsMsg.textContent = `准备失败:${prep?.error ?? "未知原因"}`;
1116
+ return;
1117
+ }
1118
+ boostUntil = Date.now() + 3e5;
1119
+ accountsMsg.textContent = "登录窗口已打开:请在窗口里登录另一个账号(不要登当前这个,否则只是刷新凭证)……";
1120
+ const result = await api("/login/browser", {
1121
+ method: "POST",
1122
+ body: "{}"
1123
+ });
1124
+ if (result?.started === false) {
1125
+ accountsMsg.textContent = `打开登录窗口失败:${result?.reason ?? "未知原因"}(可改用「手动粘贴 Token」)`;
1126
+ return;
1127
+ }
1128
+ if (result?.added) accountsMsg.textContent = result.created ? "已把新账号加入账号库 —— 当前账号没有改动,点列表里的「切换」才会用它。" : "这个账号本来就在库里(凭证已更新)—— 当前账号未改动。";
1129
+ else accountsMsg.textContent = "已捕获并保存凭证。";
1130
+ await loadAccounts();
1131
+ } catch (error) {
1132
+ accountsMsg.textContent = `添加账号失败:${error?.message ?? error}`;
1133
+ } finally {
1134
+ addAccountBtn.disabled = false;
1135
+ await refresh(false).catch(() => void 0);
1136
+ }
1137
+ })();
1138
+ });
1139
+ exportBtn.addEventListener("click", () => {
1140
+ (async () => {
1141
+ exportBtn.disabled = true;
1142
+ accountsMsg.textContent = "导出中……";
1143
+ try {
1144
+ const outcome = await saveWithPicker(suggestedExportName(), async () => {
1145
+ const data = await api("/accounts/export-json", {
1146
+ method: "POST",
1147
+ body: "{}"
1148
+ });
1149
+ if (!data?.ok) throw new Error(data?.error ?? "读取备份内容失败");
1150
+ const { ok: _ok, ...backup } = data;
1151
+ return JSON.stringify(backup, null, 2);
1152
+ });
1153
+ if (outcome.kind === "saved") accountsMsg.textContent = `已保存:${outcome.name}(含明文凭证,请妥善保管)`;
1154
+ else if (outcome.kind === "cancelled") accountsMsg.textContent = "已取消导出。";
1155
+ else {
1156
+ const result = await api("/accounts/export", {
1157
+ method: "POST",
1158
+ body: "{}"
1159
+ });
1160
+ const why = outcome.kind === "unsupported" ? "当前环境不支持系统另存为" : `系统另存为失败(${outcome.reason})`;
1161
+ accountsMsg.textContent = result?.ok ? `${why},已改为保存到插件目录:${result.path}` : `导出失败:${result?.error ?? "未知原因"}`;
1162
+ }
1163
+ } catch (error) {
1164
+ accountsMsg.textContent = `导出失败:${error?.message ?? error}`;
1165
+ } finally {
1166
+ exportBtn.disabled = false;
1167
+ }
1168
+ })();
1169
+ });
1170
+ importBtn.addEventListener("click", () => {
1171
+ (async () => {
1172
+ importBtn.disabled = true;
1173
+ accountsMsg.textContent = "请选择备份文件……";
1174
+ try {
1175
+ const file = await pickJsonFile();
1176
+ if (!file) {
1177
+ accountsMsg.textContent = "已取消导入。";
1178
+ return;
1179
+ }
1180
+ accountsMsg.textContent = `正在读取 ${file.name}……`;
1181
+ const source = await readImportSource(file);
1182
+ if (source.kind === "unreadable") {
1183
+ accountsMsg.textContent = `读取失败:${source.name} 不是有效的 JSON 备份(${source.reason})`;
1184
+ return;
1185
+ }
1186
+ const body = source.kind === "path" ? { path: source.path } : { payload: source.payload };
1187
+ const result = await api("/accounts/import", {
1188
+ method: "POST",
1189
+ body: JSON.stringify(body)
1190
+ });
1191
+ accountsMsg.textContent = result?.ok ? `导入完成(${source.name}):新增 ${result.imported} / 更新 ${result.updated} / 跳过 ${result.skipped}` : `导入失败:${result?.error ?? "未知原因"}`;
1192
+ if (result?.ok) await loadAccounts();
1193
+ } catch (error) {
1194
+ accountsMsg.textContent = `导入失败:${error?.message ?? error}`;
1195
+ } finally {
1196
+ importBtn.disabled = false;
1197
+ }
1198
+ })();
1199
+ });
1200
+ const tokenCard = el("div", "dsw-card");
1201
+ tokenCard.append(el("div", "dsw-cardhead", "手动粘贴 Token(可选路径)"));
1202
+ tokenCard.append(el("p", "dsw-hint", "在浏览器打开 chat.deepseek.com 并登录 → F12 控制台执行下面一行 → 把结果粘贴到输入框:"));
1203
+ const snippet = el("code", "dsw-code", "JSON.parse(localStorage.getItem('userToken')).value");
1204
+ tokenCard.append(snippet);
1205
+ tokenCard.append(el("p", "dsw-hint", "(新版网页端 token 存在 {\"value\": …} 包装里;若上面那行报错,改成 localStorage.getItem('userToken') 直接复制整串,插件会自动解包)"));
1206
+ const tokenInput = el("textarea", "dsw-area");
1207
+ tokenInput.placeholder = "粘贴 token(包装 JSON 或裸 token 都行;可选:下一行粘贴 Cookie,格式 name=value; name2=value2)";
1208
+ tokenCard.append(tokenInput);
1209
+ const tokenActions = el("div", "dsw-row");
1210
+ tokenActions.style.marginTop = "8px";
1211
+ const tokenBtn = el("button", "dsw-btn", "保存并验证");
1212
+ tokenActions.append(tokenBtn);
1213
+ tokenCard.append(tokenActions);
1214
+ acctLibraryPane.append(tokenCard);
1215
+ const testCard = el("div", "dsw-card");
1216
+ testCard.append(el("div", "dsw-cardhead", "连通性测试"));
1217
+ testCard.append(el("p", "dsw-hint", "直接经适配器发一次最小请求(会消耗一点网页端额度):"));
1218
+ const testActions = el("div", "dsw-row");
1219
+ testActions.style.marginTop = "6px";
1220
+ const modelSelect = el("select", "dsw-input");
1221
+ modelSelect.style.maxWidth = "220px";
1222
+ const testBtn = el("button", "dsw-btn", "发送测试");
1223
+ testActions.append(modelSelect, testBtn);
1224
+ testCard.append(testActions);
1225
+ const testOut = el("div", "dsw-msg");
1226
+ testOut.style.display = "none";
1227
+ testCard.append(testOut);
1228
+ const modelsCard = el("div", "dsw-card");
1229
+ modelsCard.append(el("div", "dsw-cardhead", "可用模型(网页免费)"));
1230
+ const modelsList = el("ul", "dsw-models");
1231
+ modelsCard.append(modelsList);
1232
+ const modelsHint = el("p", "dsw-hint", "");
1233
+ modelsCard.append(modelsHint);
1234
+ modelPane.append(modelsCard, testCard);
1235
+ let loggedIn = false;
1236
+ let electron = false;
1237
+ let windowOpen = false;
1238
+ /** 上次同步账号库的时刻(0 = 还没读过;初始化那次 refresh 之前会先读一次)。 */
1239
+ let accountsSyncedAt = 0;
1240
+ /** 上次渲染用的内容签名(见 account-sync.ts)。 */
1241
+ let accountsSignatureCache = "";
1242
+ /**
1243
+ * 最近一次 `/accounts` 载荷。
1244
+ *
1245
+ * 折叠是**纯前端状态**(不进账号库、也不该进重建签名),但切换折叠要重画列表 ——
1246
+ * 所以留一份载荷,让"折叠"能直接触发一次局部重绘,而不必多打一次 HTTP。
1247
+ */
1248
+ let lastAccountsPayload = null;
1249
+ const COLLAPSE_KEY = "dsw-accounts-collapsed-groups";
1250
+ /** 折叠的组 key。读一次就常驻,避免每行都去碰 localStorage。 */
1251
+ const collapsedGroups = new Set((() => {
1252
+ try {
1253
+ const raw = window.localStorage.getItem(COLLAPSE_KEY);
1254
+ const parsed = raw ? JSON.parse(raw) : [];
1255
+ return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
1256
+ } catch {
1257
+ return [];
1258
+ }
1259
+ })());
1260
+ const persistCollapsedGroups = () => {
1261
+ try {
1262
+ window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...collapsedGroups]));
1263
+ } catch {}
1264
+ };
1265
+ let probeIntervalMs = 0;
1266
+ const renderKv = (rows) => {
1267
+ statusKv.textContent = "";
1268
+ for (const [key, value] of rows) statusKv.append(el("div", "k", key), el("div", void 0, value));
1269
+ };
1270
+ /**
1271
+ * 上下文页的状态渲染。卡片在下面才建,这里用"先声明后赋值"的方式接上 ——
1272
+ * 直接引用下面的 const 会在时序上踩 TDZ(applyStatus 早于卡片构造)。
1273
+ */
1274
+ let renderContextStatus;
1275
+ const applyStatus = (status) => {
1276
+ loggedIn = !!status.auth?.loggedIn;
1277
+ electron = !!status.electron;
1278
+ windowOpen = !!status.loginWindowOpen;
1279
+ const cfg = status.config;
1280
+ if (cfg?.contextMode) renderContextStatus?.(String(cfg.contextMode), cfg.contextChain);
1281
+ badge.textContent = loggedIn ? status.auth.unverified ? "❔ 已捕获(未校验)" : "✅ 已登录" : "⚪ 未登录";
1282
+ badge.className = `dsw-badge ${loggedIn ? status.auth.unverified ? "off" : "on" : "off"}`;
1283
+ if (loggedIn && !status.auth.unverified) {
1284
+ const valid = status.validation;
1285
+ if (valid && !valid.ok) {
1286
+ badge.textContent = "❌ 登录态校验失败";
1287
+ badge.className = "dsw-badge err";
1288
+ }
1289
+ }
1290
+ const rows = [];
1291
+ activeLimitUntilMs = Number.isFinite(status.auth?.limitUntilMs) ? Number(status.auth?.limitUntilMs) : null;
1292
+ paintLimit();
1293
+ probeIntervalMs = Number(status.config?.probeIntervalMs ?? 0);
1294
+ renderVersion({
1295
+ current: status.config?.version,
1296
+ probeIntervalMs
1297
+ });
1298
+ renderPaths(status.paths);
1299
+ rows.push(["适配器注册", (status.registeredProviders ?? []).includes(status.provider) ? `✅ ${status.provider} 已注册到 llm` : `⚠️ 未在 llm 中找到 ${status.provider}`]);
1300
+ if (loggedIn) {
1301
+ rows.push(["账号", status.auth.display || "(未获取到账号信息)"]);
1302
+ rows.push(["捕获时间", status.auth.capturedAt ? new Date(status.auth.capturedAt).toLocaleString() : "未知"]);
1303
+ if (!status.auth.hasCookie && !status.auth.hasFingerprint) rows.push(["凭证来源", "手动粘贴 token(实测:仅凭 Bearer token 即可完成校验/求解/生成)"]);
1304
+ else rows.push(["凭证来源", "浏览器登录捕获(token + cookie + 指纹头,登录态通常更耐久)"]);
1305
+ rows.push(["Cookie", status.auth.hasCookie ? "✅ 已捕获" : "未捕获 —— 手动 token 模式本就没有(已验证不影响请求;若日后频繁遇到 AUTH/40003,改用「浏览器登录」)"]);
1306
+ if (status.auth.hasCookie) rows.push(["Cookie 过期", `${describeCookieLife(status.auth.cookieLife)}(会话级 = 浏览器关掉就没了;这是浏览器侧的上界,不是登录态寿命)`]);
1307
+ rows.push(["指纹头", status.auth.hasFingerprint ? "✅ 已捕获(x-hif-* / x-client-*)" : "未捕获 —— 同上,已实测可用"]);
1308
+ rows.push(["PoW WASM", status.auth.wasmHost || "默认地址"]);
1309
+ rows.push(["token 长度", `${status.auth.tokenLength ?? 0} 字符`]);
1310
+ if (status.validation) rows.push(["服务端校验", status.validation.ok ? "通过" : `失败:${status.validation.error ?? ""}`]);
1311
+ const verifiedAt = status.auth?.lastVerifiedAt;
1312
+ const verifyError = status.auth?.lastVerifyError;
1313
+ if (verifyError) rows.push(["登录态探活", `❌ ${relTime(verifyError.at)}失败:${verifyError.message}(可能已过期,建议重新登录)`]);
1314
+ else if (verifiedAt) rows.push(["登录态探活", `✅ ${relTime(verifiedAt)}通过(后台定时校验,零额度)`]);
1315
+ const interval = status.config?.minRequestIntervalMs;
1316
+ if (interval !== void 0) rows.push(["请求节流", status.config?.allowConcurrent ? `⚠️ 允许并发 · 间隔 ${interval}ms(并发生成有账号级限制风险,不建议)` : `串行(一次只发一条)· 间隔 ${interval}ms`]);
1317
+ } else {
1318
+ const available = status.loginCapability;
1319
+ rows.push(["登录方式", available ? available.canOpenWindow ? "插件自开窗口(Electron 主进程,带指纹伪装)" : available.browser ? `用真实浏览器登录(${available.browser} + 调试协议,自动读取凭证)` : "未找到 Edge/Chrome:请用「用我的默认浏览器登录」+ 手动粘贴 token" : electron ? "可开浏览器窗口" : "请用「用我的默认浏览器登录」+ 手动粘贴 token"]);
1320
+ }
1321
+ if (status.lastLoginResult) rows.push(["最近结果", `${status.lastLoginResult.message} · ${new Date(status.lastLoginResult.at).toLocaleTimeString()}`]);
1322
+ if (status.loginWindowOpen && status.loginProgress?.captured) {
1323
+ const captured = status.loginProgress.captured;
1324
+ rows.push(["捕获进度", `token ${captured.token ? "✓" : "…"} / cookie ${captured.cookie ? "✓" : "…"} / 指纹 ${captured.fingerprint ? "✓" : "…"}`]);
1325
+ }
1326
+ if (status.loginProgress?.lastError && status.loginWindowOpen) rows.push(["窗口提示", status.loginProgress.lastError]);
1327
+ if (status.fingerprint && status.fingerprint.stripped.length > 0) rows.push(["指纹清理", `已剔除 ${status.fingerprint.stripped.length} 个 Electron 头:${status.fingerprint.stripped.join(", ")}`]);
1328
+ else if (status.loginWindowOpen) rows.push(["指纹清理", "窗口已打开(尚未命中需要清理的头)"]);
1329
+ if (status.fingerprint?.pageUa) {
1330
+ const bad = /electron/i.test(status.fingerprint.pageUa);
1331
+ rows.push(["页面看到 UA", `${bad ? "⚠️ 仍含 Electron:" : "✅ "}${status.fingerprint.pageUa}`]);
1332
+ }
1333
+ if (status.fingerprint?.pageBrands?.length) {
1334
+ const dirty = status.fingerprint.pageBrands.filter((b) => /electron|dsh/i.test(b));
1335
+ rows.push(["页面品牌", `${dirty.length ? `⚠️ ${dirty.join(", ")}` : "✅ "}${status.fingerprint.pageBrands.join(", ")}`]);
1336
+ }
1337
+ if (status.fingerprint?.pageWebdriver !== void 0) rows.push(["webdriver", status.fingerprint.pageWebdriver ? "⚠️ true(自动化痕迹)" : "✅ false"]);
1338
+ const capability = status.loginCapability;
1339
+ if (capability) {
1340
+ const mode = capability.canOpenWindow ? "可开 Electron 窗口" : capability.browser ? `无窗口 API(${capability.processType} 进程)→ 用真实浏览器` : `无窗口 API(${capability.processType} 进程)且未找到 Edge/Chrome`;
1341
+ rows.push(["宿主进程", `${capability.processType} · ${mode}`]);
1342
+ }
1343
+ renderKv(rows);
1344
+ if (!browserBtn.dataset.busy) browserBtn.disabled = false;
1345
+ if (capability) {
1346
+ browserBtn.textContent = capability.canOpenWindow ? "浏览器窗口登录" : capability.browser ? `用 ${capability.browser} 登录` : "浏览器窗口登录";
1347
+ browserBtn.disabled = !capability.canOpenWindow && !capability.browser;
1348
+ browserBtn.title = capability.canOpenWindow ? "插件自己开窗口(带指纹伪装)" : capability.browser ? `拉起真实的 ${capability.browser}(独立 profile)完成登录,插件通过调试协议读取登录态` : "既不能开窗口也没找到 Edge/Chrome:请用「用我的默认浏览器登录」+ 手动粘贴 Token";
1349
+ } else {
1350
+ browserBtn.disabled = !electron;
1351
+ browserBtn.textContent = windowOpen ? "登录窗口已打开" : "浏览器窗口登录";
1352
+ browserBtn.title = electron ? "" : "当前宿主无法开窗:请用「用我的默认浏览器登录」+ 手动粘贴 Token";
1353
+ }
1354
+ accountLine.textContent = "";
1355
+ if (loggedIn) {
1356
+ accountLine.append(el("div", "k", "账号"), el("div", void 0, status.auth.display || "(未获取到账号信息)"));
1357
+ accountLine.append(el("div", "k", "登录时间"), el("div", void 0, status.auth.capturedAt ? new Date(status.auth.capturedAt).toLocaleString() : "未知"));
1358
+ } else accountLine.append(el("div", "k", "状态"), el("div", void 0, "未登录(没有可退出的账号)"));
1359
+ logoutBtn.disabled = !loggedIn;
1360
+ switchBtn.disabled = !loggedIn || !electron;
1361
+ switchBtn.title = electron ? "" : "当前不是 Electron 桌面端:请先「退出当前账号」,再手动粘贴另一个账号的 token";
1362
+ if (modelSelect.options.length !== status.models.length) {
1363
+ modelSelect.textContent = "";
1364
+ for (const model of status.models) {
1365
+ const option = document.createElement("option");
1366
+ option.value = model.id;
1367
+ option.textContent = model.name;
1368
+ modelSelect.append(option);
1369
+ }
1370
+ }
1371
+ modelsList.textContent = "";
1372
+ for (const model of status.models) {
1373
+ const item = el("li");
1374
+ item.append(el("div", "name", model.name));
1375
+ const ctxLabel = model.contextWindow >= 1048576 ? `${(model.contextWindow / 1048576).toFixed(0)}M` : `${Math.round(model.contextWindow / 1024)}K`;
1376
+ item.append(el("div", "id", `${model.id} · thinking ${model.thinking ? "开" : "关"} · 上下文 ${ctxLabel} token(标称)`));
1377
+ item.append(el("div", "id", model.description));
1378
+ modelsList.append(item);
1379
+ }
1380
+ modelsHint.textContent = `两条是同一个「快速模式」的思考开关两档预设(也可在模型选择器的推理强度里切换)。图片输入直接可用(走网页端文件上传通道)。每次调用会新建并删除临时会话;prompt 字符上限 ${status.config?.maxPromptChars ?? 0}(服务端硬上限 2621440 字符,另附件 token 预算 890880 —— 后者常被误读成「上下文窗口」)。`;
1381
+ };
1382
+ const refresh = async (light = true) => {
1383
+ try {
1384
+ const status = await api(`/status${light ? "?light=1" : ""}`);
1385
+ if (disposed) return;
1386
+ applyStatus(status);
1387
+ } catch (error) {
1388
+ showMessage(`状态读取失败:${error?.message ?? error}`, "err");
1389
+ }
1390
+ };
1391
+ const gateCard = el("div", "dsw-card");
1392
+ gateCard.append(el("div", "dsw-cardhead", "请求节流(防风控)"));
1393
+ const rangeInput = () => {
1394
+ const input = el("input", "dsw-range");
1395
+ input.type = "range";
1396
+ input.min = "0";
1397
+ input.max = "30000";
1398
+ input.step = "500";
1399
+ return input;
1400
+ };
1401
+ const concRow = el("div", "dsw-gate-row");
1402
+ const concLabel = el("label", "dsw-switch");
1403
+ const concInput = el("input");
1404
+ concInput.type = "checkbox";
1405
+ const concTrack = el("span", "dsw-switch-track");
1406
+ concLabel.append(concInput, concTrack, el("span", void 0, "允许并发(同一账号同时发两条)"));
1407
+ concRow.append(concLabel);
1408
+ gateCard.append(concRow);
1409
+ const minRow = el("div", "dsw-gate-row");
1410
+ minRow.append(el("span", "dsw-gate-label", "间隔下限"));
1411
+ const minRange = rangeInput();
1412
+ const minValue = el("span", "dsw-gate-value", "—");
1413
+ minRow.append(minRange, minValue);
1414
+ gateCard.append(minRow);
1415
+ const maxRow = el("div", "dsw-gate-row");
1416
+ maxRow.append(el("span", "dsw-gate-label", "间隔上限"));
1417
+ const maxRange = rangeInput();
1418
+ const maxValue = el("span", "dsw-gate-value", "—");
1419
+ maxRow.append(maxRange, maxValue);
1420
+ gateCard.append(maxRow);
1421
+ const intervalHint = el("p", "dsw-hint", "");
1422
+ gateCard.append(intervalHint);
1423
+ const presetRow = el("div", "dsw-gate-row");
1424
+ gateCard.append(presetRow);
1425
+ const promptRow = el("div", "dsw-gate-row");
1426
+ promptRow.append(el("span", "dsw-gate-label", "prompt 上限"));
1427
+ const promptRange = el("input", "dsw-range");
1428
+ promptRange.type = "range";
1429
+ promptRange.setAttribute("aria-label", "prompt 字符上限");
1430
+ promptRange.title = "每次请求最多发送多少字符(含工具目录与全部历史)";
1431
+ const promptValue = el("span", "dsw-gate-value", "—");
1432
+ promptRow.append(promptRange, promptValue);
1433
+ gateCard.append(promptRow);
1434
+ const promptHint = el("p", "dsw-hint", "");
1435
+ gateCard.append(promptHint);
1436
+ /** 字符数 → 「万字符」。界面上出现一长串 0 没人看得出差别。 */
1437
+ const fmtChars = (n) => `${Number.isFinite(n) ? (n / 1e4).toFixed(n % 1e4 === 0 ? 0 : 1) : "—"} 万字符`;
1438
+ const paintPromptCap = () => {
1439
+ const chars = Number(promptRange.value);
1440
+ promptValue.textContent = fmtChars(chars);
1441
+ const level = chars >= 12e5 ? "偏高" : chars >= 6e5 ? "中等" : "保守";
1442
+ promptHint.textContent = `每次请求最多发送 ${fmtChars(chars)}(${level})。网页端是无状态的,每一轮都会把整段对话历史重新发一遍:上限越大,模型越不容易「忘事」,但单次消耗的 token 也越多。长任务建议调小,或拆成多个会话(新会话从零开始,单次最省)。`;
1443
+ };
1444
+ const tokenNote = el("p", "dsw-hint", "");
1445
+ tokenNote.textContent = "💡 说明:DSH 底部的 token 数目前是按字符估算的(网页端只回一个「本消息累计 token」,我们还没接进来);「缓存命中 0%」是因为网页端不提供缓存信息、我们也就没有上报 —— 不代表真的没命中。";
1446
+ gateCard.append(tokenNote);
1447
+ promptRange.addEventListener("input", paintPromptCap);
1448
+ promptRange.addEventListener("change", () => {
1449
+ paintPromptCap();
1450
+ saveGate({ maxPromptChars: Number(promptRange.value) });
1451
+ });
1452
+ const imgRow = el("div", "dsw-gate-row");
1453
+ imgRow.append(el("span", "dsw-gate-label", "图片上限"));
1454
+ const imgRange = el("input", "dsw-range");
1455
+ imgRange.type = "range";
1456
+ imgRange.setAttribute("aria-label", "单次请求的图片数量上限");
1457
+ imgRange.title = "一次请求最多带多少张历史图片(0 = 不限制)";
1458
+ const imgValue = el("span", "dsw-gate-value", "—");
1459
+ imgRow.append(imgRange, imgValue);
1460
+ gateCard.append(imgRow);
1461
+ const imgHint = el("p", "dsw-hint", "");
1462
+ gateCard.append(imgHint);
1463
+ const paintImageCap = () => {
1464
+ const count = Number(imgRange.value);
1465
+ imgValue.textContent = count === 0 ? "不限制" : `${count} 张`;
1466
+ imgHint.textContent = count === 0 ? "不限制(不推荐):历史里不同图片数超过 40 张左右后,网页端会以 code 10 拒绝整轮,且该会话此后每轮都失败。" : `一次请求最多带最近的 ${count} 张图片,更早的在本次请求里略过 —— 这是正常的长度控制,不是错误。网页端能引用的图片数上限实测在 40~52 之间,默认值留了足够余量。`;
1467
+ };
1468
+ imgRange.addEventListener("input", paintImageCap);
1469
+ imgRange.addEventListener("change", () => {
1470
+ paintImageCap();
1471
+ saveGate({ maxRefImages: Number(imgRange.value) });
1472
+ });
1473
+ const cleanupRow = el("div", "dsw-gate-row");
1474
+ cleanupRow.append(el("span", "dsw-gate-label", "会话清理"));
1475
+ const cleanupBtns = {};
1476
+ for (const pair of [
1477
+ ["immediate", "立即"],
1478
+ ["deferred", "延迟(推荐)"],
1479
+ ["keep", "不删"]
1480
+ ]) {
1481
+ const key = pair[0];
1482
+ const btn = el("button", "dsw-btn ghost dsw-preset", pair[1]);
1483
+ btn.addEventListener("click", () => void saveGate({ sessionCleanup: key }));
1484
+ cleanupBtns[key] = btn;
1485
+ cleanupRow.append(btn);
1486
+ }
1487
+ gateCard.append(cleanupRow);
1488
+ const cleanupHint = el("p", "dsw-hint", "");
1489
+ gateCard.append(cleanupHint);
1490
+ const cleanupRanges = el("div");
1491
+ cleanupRanges.style.display = "none";
1492
+ gateCard.append(cleanupRanges);
1493
+ /** 一对「上下限」滑块:一行两个 range + 右侧合成值。拖动中只更新数字,松手才提交。 */
1494
+ const rangePair = (label, bounds, step, format, commit) => {
1495
+ const row = el("div", "dsw-gate-row");
1496
+ row.append(el("span", "dsw-gate-label", label));
1497
+ const lo = el("input", "dsw-range");
1498
+ const hi = el("input", "dsw-range");
1499
+ for (const [input, which] of [[lo, "下限"], [hi, "上限"]]) {
1500
+ input.type = "range";
1501
+ input.min = String(bounds.min);
1502
+ input.max = String(bounds.max);
1503
+ input.step = step;
1504
+ input.setAttribute("aria-label", `${label}${which}`);
1505
+ input.title = `${label}${which}`;
1506
+ }
1507
+ const value = el("span", "dsw-gate-pair-value", "—");
1508
+ const paint = () => {
1509
+ value.textContent = format(Number(lo.value), Number(hi.value));
1510
+ };
1511
+ const onInput = () => {
1512
+ if (Number(lo.value) > Number(hi.value)) {
1513
+ const swap = lo.value;
1514
+ lo.value = hi.value;
1515
+ hi.value = swap;
1516
+ }
1517
+ paint();
1518
+ };
1519
+ const onCommit = () => commit(Number(lo.value), Number(hi.value));
1520
+ lo.addEventListener("input", onInput);
1521
+ hi.addEventListener("input", onInput);
1522
+ lo.addEventListener("change", onCommit);
1523
+ hi.addEventListener("change", onCommit);
1524
+ const pair = el("div", "dsw-range-pair");
1525
+ pair.append(lo, hi);
1526
+ row.append(pair, value);
1527
+ return {
1528
+ row,
1529
+ set: (lo2, hi2) => {
1530
+ lo.value = String(lo2);
1531
+ hi.value = String(hi2);
1532
+ paint();
1533
+ }
1534
+ };
1535
+ };
1536
+ const batchPair = rangePair("攒够数量", {
1537
+ min: 1,
1538
+ max: 50
1539
+ }, "1", (lo, hi) => `${lo}~${hi} 个`, (lo, hi) => void saveGate({ cleanupBatch: {
1540
+ min: lo,
1541
+ max: hi
1542
+ } }));
1543
+ const delayPair = rangePair("最长等待", {
1544
+ min: 5,
1545
+ max: 600
1546
+ }, "5", (lo, hi) => `${lo}~${hi} 秒`, (lo, hi) => void saveGate({ cleanupDelayMs: {
1547
+ min: Math.round(lo * 1e3),
1548
+ max: Math.round(hi * 1e3)
1549
+ } }));
1550
+ const gapPair = rangePair("删除间隔", {
1551
+ min: 0,
1552
+ max: 60
1553
+ }, "0.1", (lo, hi) => hi <= 0 ? "不等待" : `${lo.toFixed(1)}~${hi.toFixed(1)} 秒`, (lo, hi) => void saveGate({ cleanupGapMs: {
1554
+ min: Math.round(lo * 1e3),
1555
+ max: Math.round(hi * 1e3)
1556
+ } }));
1557
+ cleanupRanges.append(batchPair.row, delayPair.row, gapPair.row);
1558
+ cleanupRanges.append(el("p", "dsw-hint", "上面三个都是「下限 ~ 上限」:实际取值每次在区间内随机抽 —— 攒批阈值与最长等待每轮清理重抽、删除间隔每删一个重抽。这样\"什么时候动手删\"就没有固定规律了。"));
1559
+ cleanupRanges.append(el("p", "dsw-hint", "「删除间隔」只在逐个删除时起作用:优先走一个请求批量删;若服务端不接受批量删(会自动退化为逐个删),相邻两个删除请求之间就按这个间隔停一下 —— 避免\"一下子连发几十个删除请求\"。"));
1560
+ gateCard.append(el("p", "dsw-hint", "并发默认关闭:DSH 的会话标题生成会与主回答同时发往同一账号,网页端同一账号同时只能生成一条,实测双窗口并发不到 6 分钟即触发账号级限制(1 天)。"));
1561
+ const gateMsg = el("p", "dsw-hint dsw-gate-msg", "");
1562
+ gateCard.append(gateMsg);
1563
+ gatePane.append(gateCard);
1564
+ const ledgerCard = el("div", "dsw-card");
1565
+ const ledgerHead = el("div", "dsw-cardhead");
1566
+ ledgerHead.append(el("span", "name", "调用台账"));
1567
+ const ledgerBadge = el("span", "dsw-badge off", "近 24 小时");
1568
+ ledgerHead.append(ledgerBadge);
1569
+ ledgerCard.append(ledgerHead);
1570
+ ledgerCard.append(el("p", "dsw-hint", "本地记录每次调用的结果(不含任何对话内容与凭证)。用来判断节流是否真的在起作用。"));
1571
+ const ledgerKv = el("div", "dsw-kv");
1572
+ ledgerCard.append(ledgerKv);
1573
+ const ledgerSparkBox = el("div");
1574
+ ledgerCard.append(ledgerSparkBox);
1575
+ const ledgerRow = el("div", "dsw-row");
1576
+ const ledgerRefreshBtn = el("button", "dsw-btn ghost", "刷新台账");
1577
+ ledgerRow.append(ledgerRefreshBtn);
1578
+ ledgerCard.append(ledgerRow);
1579
+ const ledgerMsg = el("p", "dsw-hint dsw-gate-msg", "");
1580
+ ledgerCard.append(ledgerMsg);
1581
+ gatePane.append(ledgerCard);
1582
+ /** 24 根小柱:每小时调用量。高度按最大值归一,失败多的时段用红色。 */
1583
+ const sparkSvg = (hourly, failedHours) => {
1584
+ const values = hourly.length ? hourly : [0];
1585
+ const max = Math.max(1, ...values);
1586
+ const barW = 100 / values.length;
1587
+ let out = "<svg class=\"dsw-spark\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\">";
1588
+ values.forEach((value, index) => {
1589
+ const height = value / max * 100;
1590
+ const cls = failedHours.has(index) ? " class=\"bad\"" : "";
1591
+ out += `<rect${cls} x="${(index * barW).toFixed(2)}" y="${(100 - height).toFixed(2)}" width="${(barW * .7).toFixed(2)}" height="${Math.max(height, value > 0 ? 3 : .6).toFixed(2)}" rx="1"></rect>`;
1592
+ });
1593
+ out += "</svg>";
1594
+ return out;
1595
+ };
1596
+ const fmtMs = (ms) => ms >= 6e4 ? `${(ms / 6e4).toFixed(1)} 分` : `${(ms / 1e3).toFixed(1)} 秒`;
1597
+ const renderLedger = (data) => {
1598
+ const calls = Number(data?.calls ?? 0);
1599
+ ledgerBadge.textContent = `近 ${data?.hours ?? 24} 小时`;
1600
+ ledgerBadge.className = `dsw-badge ${calls > 0 ? "on" : "off"}`;
1601
+ ledgerKv.textContent = "";
1602
+ ledgerSparkBox.textContent = "";
1603
+ if (calls === 0) {
1604
+ ledgerMsg.textContent = "还没有记录 —— 跑一次对话后回来看。";
1605
+ return;
1606
+ }
1607
+ const push = (key, value) => {
1608
+ ledgerKv.append(el("div", "k", key), el("div", void 0, value));
1609
+ };
1610
+ push("调用次数", `${calls}(成功 ${data.succeeded ?? 0} / 失败 ${data.failed ?? 0})`);
1611
+ const gaps = data.gaps;
1612
+ push("相邻对话间隔", gaps ? `中位 ${fmtMs(gaps.p50)} · p90 ${fmtMs(gaps.p90)} · 最短 ${fmtMs(gaps.min)}(样本 ${gaps.samples})` : "样本不足");
1613
+ if (gaps && gaps.min < 1500) ledgerMsg.textContent = `⚠️ 出现过 ${fmtMs(gaps.min)} 的极短间隔 —— 检查一下节流是否被关掉了。`;
1614
+ const failures = data.failures ?? {};
1615
+ push("失败分类", Object.keys(failures).length ? Object.entries(failures).map(([key, count]) => `${key} ${count}`).join(" · ") : "无");
1616
+ push("台账占用", `${data.footprint?.files ?? 0} 个文件 · ${Math.round((data.footprint?.bytes ?? 0) / 1024)} KB`);
1617
+ const byAccount = Array.isArray(data.byAccount) ? data.byAccount : [];
1618
+ if (byAccount.length > 0) {
1619
+ const nameOf = (id) => {
1620
+ const hit = (Array.isArray(lastAccountsPayload?.accounts) ? lastAccountsPayload.accounts : []).find((item) => item.id === id);
1621
+ return hit ? String(hit.label || hit.display || hit.id) : id;
1622
+ };
1623
+ push("按账号", byAccount.sort((a, b) => (Number(b?.calls) || 0) - (Number(a?.calls) || 0)).map((row) => `${nameOf(row.accountId)} ${row.calls} 次(失败 ${row.failed})`).join(" · "));
1624
+ }
1625
+ const hourly = Array.isArray(data.hourly) ? data.hourly : [];
1626
+ const failedHours = /* @__PURE__ */ new Set();
1627
+ (Array.isArray(data.hourlyFailed) ? data.hourlyFailed : []).forEach((count, index) => {
1628
+ if (count > 0) failedHours.add(index);
1629
+ });
1630
+ ledgerSparkBox.innerHTML = sparkSvg(hourly, failedHours);
1631
+ ledgerSparkBox.append(el("p", "dsw-hint", `每小时调用量(最近 1 小时在最右,峰值 ${Math.max(0, ...hourly)} 次)`));
1632
+ };
1633
+ const loadLedger = async () => {
1634
+ try {
1635
+ renderLedger(await api("/ledger?hours=24"));
1636
+ } catch (error) {
1637
+ ledgerMsg.textContent = `台账读取失败:${error?.message ?? error}`;
1638
+ }
1639
+ };
1640
+ ledgerRefreshBtn.addEventListener("click", () => void loadLedger());
1641
+ const transportCard = el("div", "dsw-card");
1642
+ transportCard.append(el("div", "dsw-cardhead", "传输层(指纹)"));
1643
+ const transportRow = el("div", "dsw-gate-row");
1644
+ transportRow.append(el("span", "dsw-gate-label", "请求从哪出去"));
1645
+ const transportBtns = {};
1646
+ for (const pair of [["chromium", "Chromium 网络栈(推荐)"], ["node", "Node"]]) {
1647
+ const key = pair[0];
1648
+ const btn = el("button", "dsw-btn ghost dsw-preset", pair[1]);
1649
+ btn.addEventListener("click", () => void saveTransport(key));
1650
+ transportBtns[key] = btn;
1651
+ transportRow.append(btn);
1652
+ }
1653
+ transportCard.append(transportRow);
1654
+ const transportHint = el("p", "dsw-hint", "");
1655
+ transportCard.append(transportHint);
1656
+ const transportProxyHint = el("p", "dsw-hint", "");
1657
+ transportCard.append(transportProxyHint);
1658
+ const testRow = el("div", "dsw-gate-row");
1659
+ const transportTestBtn = el("button", "dsw-btn ghost", "测试传输层(零额度)");
1660
+ testRow.append(transportTestBtn);
1661
+ transportCard.append(testRow);
1662
+ const transportTestOut = el("div", "dsw-msg", "");
1663
+ transportTestOut.style.display = "none";
1664
+ transportCard.append(transportTestOut);
1665
+ const transportMsg = createMsgNode();
1666
+ transportCard.append(transportMsg.node);
1667
+ transportPane.append(transportCard);
1668
+ const contextCard = el("div", "dsw-card");
1669
+ contextCard.append(el("div", "dsw-cardhead", "上下文投喂方式"));
1670
+ const contextRow = el("div", "dsw-gate-row");
1671
+ contextRow.append(el("span", "dsw-gate-label", "每轮发什么"));
1672
+ const contextBtns = {};
1673
+ for (const pair of [["full", "每轮全量(默认)"], ["chained", "链式投喂(只发增量)"]]) {
1674
+ const key = pair[0];
1675
+ const btn = el("button", "dsw-btn ghost dsw-preset", pair[1]);
1676
+ btn.addEventListener("click", () => void saveContextMode(key));
1677
+ contextBtns[key] = btn;
1678
+ contextRow.append(btn);
1679
+ }
1680
+ contextCard.append(contextRow);
1681
+ const contextStatus = el("p", "dsw-hint", "");
1682
+ contextCard.append(contextStatus);
1683
+ const contextHintText = el("p", "dsw-hint", "");
1684
+ contextCard.append(contextHintText);
1685
+ const contextMsg = createMsgNode();
1686
+ contextCard.append(contextMsg.node);
1687
+ contextPane.append(contextCard);
1688
+ renderContextStatus = (rawMode, chain) => {
1689
+ const mode = rawMode === "chained" ? "chained" : "full";
1690
+ for (const key of Object.keys(contextBtns)) contextBtns[key].classList.toggle("active", key === mode);
1691
+ if (chain) contextStatus.textContent = `链式投喂正在跑:网页端会话 ${String(chain.sessionId ?? "").slice(0, 8)},链上已发 ${chain.turns} 段,父消息 ${chain.parentId}`;
1692
+ else contextStatus.textContent = mode === "chained" ? "链式投喂:下一条消息会重新起链(当前没有可续的链,或还没开始用)" : "当前:每轮重发全量 prompt(和以前完全一致)";
1693
+ };
1694
+ const applyContextCard = (info) => {
1695
+ renderContextStatus?.(String(info?.mode ?? "full"), info?.chain);
1696
+ const hint = String(info?.hint ?? "");
1697
+ const path = String(info?.settingsPath ?? "");
1698
+ contextHintText.textContent = path ? `${hint}\n配置文件:${path}` : hint;
1699
+ };
1700
+ const saveContextMode = async (mode) => {
1701
+ contextMsg.textContent = "切换中……";
1702
+ try {
1703
+ const result = await api("/context-mode", {
1704
+ method: "POST",
1705
+ body: JSON.stringify({ mode })
1706
+ });
1707
+ if (result?.ok) {
1708
+ applyContextCard(result);
1709
+ contextMsg.textContent = `已切到${result.mode === "chained" ? "链式投喂" : "每轮全量"},即时生效` + (result.persisted === false ? "(未能写入配置,重启后会回到上次保存的值)" : "(已写入配置,重启后仍生效)");
1710
+ } else contextMsg.textContent = `切换失败:${result?.error ?? "未知原因"}`;
1711
+ } catch (error) {
1712
+ contextMsg.textContent = `切换失败:${error?.message ?? error}`;
1713
+ }
1714
+ };
1715
+ const aboutCard = el("div", "dsw-card");
1716
+ aboutCard.append(el("div", "dsw-cardhead", "版本与更新"));
1717
+ const versionKv = el("div", "dsw-kv");
1718
+ aboutCard.append(versionKv);
1719
+ const updateRow = el("div", "dsw-row");
1720
+ updateRow.style.marginTop = "8px";
1721
+ const updateBtn = el("button", "dsw-btn ghost", "检查更新");
1722
+ updateRow.append(updateBtn);
1723
+ aboutCard.append(updateRow);
1724
+ const updateMsg = el("p", "dsw-hint dsw-gate-msg", "");
1725
+ aboutCard.append(updateMsg);
1726
+ aboutCard.append(el("p", "dsw-hint", "插件装不了包,所以这里只做\"检查 + 给链接\"。GitHub 在国内可能连不上,检查失败是正常的。"));
1727
+ aboutPane.append(aboutCard);
1728
+ const renderVersion = (info) => {
1729
+ versionKv.textContent = "";
1730
+ versionKv.append(el("div", "k", "当前版本"), el("div", void 0, info?.current || "未知"));
1731
+ versionKv.append(el("div", "k", "登录态探活"), el("div", void 0, info?.probeIntervalMs > 0 ? `每 ${Math.round(info.probeIntervalMs / 6e4)} 分钟一次(只读、零额度)` : "已关闭"));
1732
+ };
1733
+ updateBtn.addEventListener("click", () => {
1734
+ (async () => {
1735
+ updateBtn.disabled = true;
1736
+ updateMsg.textContent = "正在检查……";
1737
+ try {
1738
+ const result = await api("/update-check", {
1739
+ method: "POST",
1740
+ body: "{}"
1741
+ });
1742
+ if (!result?.ok) {
1743
+ updateMsg.textContent = `检查失败:${result?.error ?? "未知原因"}`;
1744
+ return;
1745
+ }
1746
+ const current = result.current;
1747
+ renderVersion({
1748
+ current,
1749
+ probeIntervalMs
1750
+ });
1751
+ if (result.hasUpdate) updateMsg.textContent = `发现新版本 ${result.latest}(当前 ${current})${result.publishedAt ? ` · ${shortTime(result.publishedAt)} 发布` : ""}${result.url ? ` · ${result.url}` : ""}`;
1752
+ else updateMsg.textContent = `已是最新版本(${current})。`;
1753
+ } catch (error) {
1754
+ updateMsg.textContent = `检查失败:${error?.message ?? error}`;
1755
+ } finally {
1756
+ updateBtn.disabled = false;
1757
+ }
1758
+ })();
1759
+ });
1760
+ const pathsCard = el("div", "dsw-card");
1761
+ pathsCard.append(el("div", "dsw-cardhead", "数据位置"));
1762
+ pathsCard.append(el("p", "dsw-hint", "插件的本地状态都在 DSH 主目录下,不进通用配置面(避免凭证混进 settings/credentials)。"));
1763
+ const pathsKv = el("div", "dsw-kv");
1764
+ pathsCard.append(pathsKv);
1765
+ pathsCard.append(el("p", "dsw-hint", "⚠️ 账号库里每个文件都是可完整登录的凭证;导出的备份同样是明文 —— 账号库卡片里的提示请当真。"));
1766
+ aboutPane.append(pathsCard);
1767
+ const renderPaths = (paths) => {
1768
+ pathsKv.textContent = "";
1769
+ const rows = [
1770
+ ["账号库", paths?.accounts || "(未知)"],
1771
+ ["账号索引", paths?.webLogin ? `${paths.webLogin}/accounts.json` : "(未知)"],
1772
+ ["调用台账", paths?.ledger || "(未知)"],
1773
+ ["导出备份", paths?.webLogin ? `${paths.webLogin}/exports/` : "(未知)"]
1774
+ ];
1775
+ for (const [key, value] of rows) pathsKv.append(el("div", "k", key), el("div", "dsw-path", value));
1776
+ };
1777
+ const riskCard = el("div", "dsw-card");
1778
+ riskCard.append(el("div", "dsw-cardhead", "为什么没有「自动换号」"));
1779
+ riskCard.append(el("p", "dsw-hint", "💡 账号库支持一键手动切换,但刻意不做自动轮换(检测到限流就自动换一个号继续发)。原因不是保守:"));
1780
+ riskCard.append(el("p", "dsw-hint", "参考项目切的是「CLI 下次启动用哪个账号」——服务端看不到;而我们每次对话都实时发请求。真人不会在几分钟内换一个账号接着发消息,自动换号是极强的机器行为特征,与本插件在传输层指纹、随机间隔、会话清理上「降低机器可识别性」的努力直接冲突。"));
1781
+ riskCard.append(el("p", "dsw-hint", "另外:同一服务商会把多账号关联起来(同设备 / 同 IP / 同指纹 / 相近的行为模式)。一旦被判定为同一人的多开小号,处置通常比单账号超频更重,而且可能波及全部关联账号。所以账号库的目标是「在自己的多个正常账号之间切换更省事」,不是「靠轮换把限流绕过去」。"));
1782
+ aboutPane.append(riskCard);
1783
+ const applyTransportCard = (info) => {
1784
+ if (!info) return;
1785
+ const requested = String(info.requested ?? info.effective ?? "chromium");
1786
+ const effective = String(info.effective ?? requested);
1787
+ for (const key of Object.keys(transportBtns)) transportBtns[key].classList.toggle("active", key === requested);
1788
+ const available = info.chromiumAvailable !== false;
1789
+ transportBtns.chromium.disabled = !available;
1790
+ transportBtns.chromium.title = available ? "" : "本环境拿不到 electron.net.fetch(只有 Electron 的 utility 进程里有)";
1791
+ const parts = [effective === "chromium" ? "实际生效:Chromium 网络栈" : "实际生效:Node fetch"];
1792
+ if (info.degraded) parts.push("配置要求 Chromium,但本环境不支持,已降级为 Node");
1793
+ if (effective === "chromium") parts.push("cipher 列表哈希与 Chrome 一致、ALPN 走 h2");
1794
+ transportHint.textContent = parts.join(" · ");
1795
+ transportProxyHint.textContent = String(info.hint ?? "");
1796
+ };
1797
+ const saveTransport = async (kind) => {
1798
+ transportMsg.textContent = "切换中……";
1799
+ try {
1800
+ const result = await api("/transport", {
1801
+ method: "POST",
1802
+ body: JSON.stringify({ transport: kind })
1803
+ });
1804
+ if (result?.ok) {
1805
+ applyTransportCard(result);
1806
+ transportMsg.textContent = `已切换到 ${result.effective === "chromium" ? "Chromium 网络栈" : "Node fetch"},即时生效` + (result.persisted === false ? "(未能写入配置,重启后会回到上次保存的值)" : "(已写入配置,重启后仍生效)");
1807
+ } else transportMsg.textContent = `切换失败:${result?.error ?? "未知原因"}`;
1808
+ } catch (error) {
1809
+ transportMsg.textContent = `切换失败:${error?.message ?? error}`;
1810
+ }
1811
+ };
1812
+ transportTestBtn.addEventListener("click", () => {
1813
+ (async () => {
1814
+ transportTestBtn.disabled = true;
1815
+ transportTestOut.style.display = "";
1816
+ transportTestOut.textContent = "正在探测(指纹 / 流式 / 鉴权)……";
1817
+ try {
1818
+ const result = await api("/diagnostics/net-fetch", {
1819
+ method: "POST",
1820
+ body: JSON.stringify({ mode: "probe" })
1821
+ });
1822
+ const lines = [];
1823
+ for (const step of result?.results ?? []) {
1824
+ const title = String(step.step ?? "");
1825
+ if (title.startsWith("①")) lines.push(`① 指纹 ja4=${step.ja4 ?? "-"} HTTP=${step.http_version ?? "-"}`);
1826
+ else if (title.startsWith("②")) lines.push(`② 流式 ${step.ok ? "支持" : "不支持"} 分片=${step.chunks ?? 0} abort=${step.abortedEarly ? "生效" : "未生效"}`);
1827
+ else if (title.startsWith("③")) lines.push(`③ 鉴权 HTTP ${step.status ?? "-"} ${step.ok ? "通过" : step.error ?? "未通过"}`);
1828
+ }
1829
+ transportTestOut.textContent = lines.length ? lines.join("\n") : JSON.stringify(result);
1830
+ } catch (error) {
1831
+ transportTestOut.textContent = `探测失败:${error?.message ?? error}`;
1832
+ } finally {
1833
+ transportTestBtn.disabled = false;
1834
+ }
1835
+ })();
1836
+ });
1837
+ (async () => {
1838
+ try {
1839
+ applyTransportCard(await api("/transport"));
1840
+ } catch {
1841
+ transportMsg.textContent = "传输层设置读取失败(宿主未响应)";
1842
+ }
1843
+ try {
1844
+ applyContextCard(await api("/context-mode"));
1845
+ } catch {
1846
+ contextMsg.textContent = "上下文设置读取失败(宿主未响应)";
1847
+ }
1848
+ })();
1849
+ loadAccounts();
1850
+ loadLedger();
1851
+ refresh(true);
1852
+ /** 渲染宿主返回的节流设置(含可选档位与清理策略)。 */
1853
+ const applyGate = (g) => {
1854
+ if (!g) return;
1855
+ concInput.checked = !!g.allowConcurrent;
1856
+ const cap = String(g.maxIntervalMs ?? 3e4);
1857
+ minRange.max = cap;
1858
+ maxRange.max = cap;
1859
+ const lo = Number(g.minRequestIntervalMs ?? 2e3);
1860
+ const hi = Number(g.maxRequestIntervalMs ?? lo);
1861
+ minRange.value = String(lo);
1862
+ maxRange.value = String(hi);
1863
+ minValue.textContent = `${lo}ms`;
1864
+ maxValue.textContent = `${hi}ms`;
1865
+ intervalHint.textContent = lo === hi ? `固定间隔 ${lo}ms(上下限相等)。建议拉开成区间 —— 固定值方差≈0,是明显的「定时器特征」。` : `实际等待在 ${lo}~${hi}ms 之间随机取值(均值约 ${Math.round((lo + hi) / 2)}ms)。`;
1866
+ presetRow.textContent = "";
1867
+ presetRow.append(el("span", "dsw-gate-label", "快捷"));
1868
+ const presets = g.presets ?? [
1869
+ {
1870
+ min: 1500,
1871
+ max: 2500
1872
+ },
1873
+ {
1874
+ min: 2e3,
1875
+ max: 4e3
1876
+ },
1877
+ {
1878
+ min: 5e3,
1879
+ max: 9e3
1880
+ }
1881
+ ];
1882
+ for (const preset of presets) {
1883
+ const isDefault = preset.min === (g.defaultMinIntervalMs ?? 2e3) && preset.max === (g.defaultMaxIntervalMs ?? 4e3);
1884
+ const btn = el("button", "dsw-btn ghost dsw-preset", `${preset.min}~${preset.max}ms${isDefault ? "(推荐)" : ""}`);
1885
+ btn.addEventListener("click", () => void saveGate({
1886
+ minRequestIntervalMs: preset.min,
1887
+ maxRequestIntervalMs: preset.max
1888
+ }));
1889
+ if (isDefault && g.minRequestIntervalMs === preset.min && g.maxRequestIntervalMs === preset.max) btn.classList.add("active");
1890
+ presetRow.append(btn);
1891
+ }
1892
+ const capBounds = g.maxPromptCharsBounds ?? {
1893
+ min: 12e4,
1894
+ max: 15e5
1895
+ };
1896
+ promptRange.min = String(capBounds.min);
1897
+ promptRange.max = String(capBounds.max);
1898
+ promptRange.step = "20000";
1899
+ promptRange.value = String(g.maxPromptChars ?? g.maxPromptCharsDefault ?? capBounds.max);
1900
+ paintPromptCap();
1901
+ const imgBounds = g.maxRefImagesBounds ?? {
1902
+ min: 0,
1903
+ max: 100
1904
+ };
1905
+ imgRange.min = String(imgBounds.min);
1906
+ imgRange.max = String(imgBounds.max);
1907
+ imgRange.step = "1";
1908
+ imgRange.value = String(g.maxRefImages ?? g.maxRefImagesDefault ?? 24);
1909
+ paintImageCap();
1910
+ const mode = g.cleanup?.mode ?? "deferred";
1911
+ for (const key of Object.keys(cleanupBtns)) cleanupBtns[key].classList.toggle("active", key === mode);
1912
+ const showRanges = mode === "deferred";
1913
+ cleanupRanges.style.display = showRanges ? "" : "none";
1914
+ if (showRanges) {
1915
+ const batchRange = g.cleanup?.batchRange ?? g.cleanupDefaults?.batch;
1916
+ if (batchRange) batchPair.set(batchRange.min, batchRange.max);
1917
+ const delayRange = g.cleanup?.delayRange ?? g.cleanupDefaults?.delayMs;
1918
+ if (delayRange) delayPair.set(Math.round(delayRange.min / 1e3), Math.round(delayRange.max / 1e3));
1919
+ const gapRange = g.cleanup?.gapRange ?? g.cleanupDefaults?.gapMs;
1920
+ if (gapRange) gapPair.set(+(gapRange.min / 1e3).toFixed(1), +(gapRange.max / 1e3).toFixed(1));
1921
+ }
1922
+ cleanupHint.textContent = mode === "keep" ? "不删:请求最少,但网页端会留下临时会话记录。" : mode === "immediate" ? "立即:调用结束后 1.5 秒删掉(老行为,每轮多一个删除请求)。" : `延迟:这一轮攒够 ${g.cleanup?.batchSize ?? 8} 个、或最多等 ${Math.round((g.cleanup?.delayMs ?? 9e4) / 1e3)} 秒就集中清理,并优先用一个请求批量删 ——减少「每轮建一个立刻删一个」的机器特征。上面三个区间决定"这一轮具体攒几个 / 等多久"。`;
1923
+ };
1924
+ const saveGate = async (patch) => {
1925
+ gateMsg.textContent = "保存中……";
1926
+ try {
1927
+ const result = await api("/gate", {
1928
+ method: "POST",
1929
+ body: JSON.stringify(patch)
1930
+ });
1931
+ if (result?.ok) {
1932
+ applyGate(result);
1933
+ gateMsg.textContent = `已生效:${result.allowConcurrent ? "允许并发(不推荐)" : "串行"} · 间隔 ${result.minRequestIntervalMs}~${result.maxRequestIntervalMs}ms · 清理 ${result.sessionCleanup ?? result.cleanup?.mode ?? "-"}` + (result.cleanup?.batchRange ? `(阈值 ${result.cleanup.batchRange.min}~${result.cleanup.batchRange.max} 个 · 等待 ${Math.round((result.cleanup.delayRange?.min ?? 0) / 1e3)}~${Math.round((result.cleanup.delayRange?.max ?? 0) / 1e3)}s · ` + (result.cleanup.gapRange ? `删除间隔 ${(result.cleanup.gapRange.min / 1e3).toFixed(1)}~${(result.cleanup.gapRange.max / 1e3).toFixed(1)}s` : "删除间隔关") + ")" : "") + ` · prompt 上限 ${fmtChars(Number(result.maxPromptChars ?? 0))}` + (result.persisted === false ? ` ⚠️ ${result.warning ?? "未能写入配置"}` : "(已写入配置,重启后仍生效)");
1934
+ } else gateMsg.textContent = `保存失败:${result?.error ?? "未知原因"}`;
1935
+ } catch (error) {
1936
+ gateMsg.textContent = `保存失败:${error?.message ?? error}`;
1937
+ }
1938
+ };
1939
+ concInput.addEventListener("change", () => void saveGate({ allowConcurrent: concInput.checked }));
1940
+ minRange.addEventListener("input", () => {
1941
+ minValue.textContent = `${minRange.value}ms`;
1942
+ });
1943
+ maxRange.addEventListener("input", () => {
1944
+ maxValue.textContent = `${maxRange.value}ms`;
1945
+ });
1946
+ minRange.addEventListener("change", () => void saveGate({ minRequestIntervalMs: Number(minRange.value) }));
1947
+ maxRange.addEventListener("change", () => void saveGate({ maxRequestIntervalMs: Number(maxRange.value) }));
1948
+ (async () => {
1949
+ try {
1950
+ applyGate(await api("/gate"));
1951
+ } catch {
1952
+ gateMsg.textContent = "节流设置读取失败(宿主未响应)";
1953
+ }
1954
+ })();
1955
+ let boostUntil = 0;
1956
+ recoverBtn.addEventListener("click", () => {
1957
+ (async () => {
1958
+ recoverBtn.disabled = true;
1959
+ showMessage("正在从已登录窗口分区读取凭证……(复用上次登录态,不需要重新登录)");
1960
+ try {
1961
+ const result = await api("/login/recover", {
1962
+ method: "POST",
1963
+ body: "{}"
1964
+ });
1965
+ if (result?.ok) showMessage(`${result.verified ? "✅" : "⚠️"} ${result.message}`, result.verified ? "ok" : "");
1966
+ else showMessage(`❌ ${result?.message ?? "恢复失败"}`, "err");
1967
+ } catch (error) {
1968
+ showMessage(`恢复失败:${error?.message ?? error}`, "err");
1969
+ }
1970
+ recoverBtn.disabled = false;
1971
+ await refresh(false);
1972
+ })();
1973
+ });
1974
+ externalBtn.addEventListener("click", () => {
1975
+ (async () => {
1976
+ externalBtn.disabled = true;
1977
+ try {
1978
+ const result = await api("/login/external", {
1979
+ method: "POST",
1980
+ body: "{}"
1981
+ });
1982
+ if (result?.ok) showMessage(`已用系统默认浏览器打开 ${result.url}\n① 在浏览器里正常登录;②按 F12 → Console,粘贴下方「手动粘贴 Token」卡里的那行命令;③ 把打印出来的结果粘到那张卡的输入框 → 点「保存并验证」。`);
1983
+ else showMessage(`打开失败:${result?.message ?? "未知原因"};请手动在浏览器访问 ${result?.url ?? "https://chat.deepseek.com"}`, "err");
1984
+ } catch (error) {
1985
+ showMessage(`打开失败:${error?.message ?? error}`, "err");
1986
+ }
1987
+ externalBtn.disabled = false;
1988
+ })();
1989
+ });
1990
+ browserBtn.addEventListener("click", () => {
1991
+ (async () => {
1992
+ browserBtn.disabled = true;
1993
+ browserBtn.dataset.busy = "1";
1994
+ showMessage("正在启动浏览器……会先清掉上次的登录状态,请在其中登录 DeepSeek(登录成功后会自动捕获,不用复制粘贴)。");
1995
+ try {
1996
+ const result = await api("/login/browser", {
1997
+ method: "POST",
1998
+ body: JSON.stringify({ fresh: true })
1999
+ });
2000
+ if (result?.mode === "browser") {
2001
+ if (result.ok) showMessage(`${result.verified ? "✅" : "⚠️"} ${result.message}${result.display ? `(${result.display})` : ""}`, result.verified ? "ok" : "");
2002
+ else showMessage(`❌ ${result.message ?? `登录未完成(${result.reason ?? "未知"})`}`, "err");
2003
+ } else if (!result?.started) showMessage(result?.reason === "not-electron" ? "当前宿主进程无法开 Electron 窗口,且没找到可用的 Edge/Chrome:请用「用我的默认浏览器登录」+ 手动粘贴 Token。" : `打开失败:${result?.reason ?? "未知原因"}`, "err");
2004
+ else showMessage("登录窗口已打开,正在旁路捕获登录凭证……");
2005
+ } catch (error) {
2006
+ showMessage(`打开登录窗口失败:${error?.message ?? error}`, "err");
2007
+ }
2008
+ delete browserBtn.dataset.busy;
2009
+ boostUntil = Date.now() + 3e5;
2010
+ await refresh(false);
2011
+ })();
2012
+ });
2013
+ tokenBtn.addEventListener("click", () => {
2014
+ (async () => {
2015
+ const raw = tokenInput.value.trim();
2016
+ if (!raw) {
2017
+ showMessage("请先粘贴 userToken", "err");
2018
+ return;
2019
+ }
2020
+ const [tokenLine, ...rest] = raw.split("\n");
2021
+ const token = tokenLine.trim();
2022
+ const cookie = rest.join(" ").trim();
2023
+ tokenBtn.disabled = true;
2024
+ showMessage("正在校验 token……");
2025
+ try {
2026
+ const result = await api("/login/token", {
2027
+ method: "POST",
2028
+ body: JSON.stringify({
2029
+ token,
2030
+ cookie
2031
+ })
2032
+ });
2033
+ if (result?.ok) {
2034
+ if (result.error) showMessage(`⚠️ ${result.error}`, "");
2035
+ else showMessage(`✅ ${result.display ? `账号 ${result.display} · ` : ""}token 校验通过,已保存`, "ok");
2036
+ tokenInput.value = "";
2037
+ } else showMessage(`❌ 校验失败:${result?.error ?? "未知原因"}`, "err");
2038
+ } catch (error) {
2039
+ showMessage(`保存失败:${error?.message ?? error}`, "err");
2040
+ }
2041
+ tokenBtn.disabled = false;
2042
+ await refresh(false);
2043
+ })();
2044
+ });
2045
+ /**
2046
+ * 二次确认:退出账号是不可逆操作(凭证 + 浏览器登录态都会被清),
2047
+ * 所以第一次点击只把按钮变成「确认退出?」,3 秒内再点一次才真的执行。
2048
+ * 不用 window.confirm(插件面板里被宿主拦截的风险,且样式不可控)。
2049
+ */
2050
+ const armConfirm = (button, label, confirmLabel, run) => {
2051
+ let armed = false;
2052
+ let timer;
2053
+ const reset = () => {
2054
+ armed = false;
2055
+ if (timer !== void 0) window.clearTimeout(timer);
2056
+ button.textContent = label;
2057
+ button.classList.remove("armed");
2058
+ };
2059
+ button.textContent = label;
2060
+ button.addEventListener("click", () => {
2061
+ if (!armed) {
2062
+ armed = true;
2063
+ button.textContent = confirmLabel;
2064
+ button.classList.add("armed");
2065
+ timer = window.setTimeout(reset, 3e3);
2066
+ return;
2067
+ }
2068
+ reset();
2069
+ run();
2070
+ });
2071
+ };
2072
+ const doLogout = (thenLogin) => {
2073
+ (async () => {
2074
+ logoutBtn.disabled = true;
2075
+ switchBtn.disabled = true;
2076
+ try {
2077
+ await api("/logout", {
2078
+ method: "POST",
2079
+ body: "{}"
2080
+ });
2081
+ tokenInput.value = "";
2082
+ showMessage(thenLogin ? "已退出当前账号,正在打开登录窗口……" : "已退出当前账号:本地凭证与浏览器登录态都已清除。");
2083
+ await refresh(false);
2084
+ if (thenLogin) {
2085
+ const result = await api("/login/browser", {
2086
+ method: "POST",
2087
+ body: "{}"
2088
+ });
2089
+ if (result?.started === false) showMessage(`已退出账号;打开登录窗口失败:${result?.reason ?? "未知原因"}(可改用手动粘贴 token)`, "err");
2090
+ else {
2091
+ boostUntil = Date.now() + 18e4;
2092
+ showMessage("已退出账号,登录窗口已打开:请在窗口里登录其它账号,凭证会自动捕获。");
2093
+ }
2094
+ }
2095
+ } catch (error) {
2096
+ showMessage(`退出失败:${error?.message ?? error}`, "err");
2097
+ } finally {
2098
+ await refresh(false);
2099
+ }
2100
+ })();
2101
+ };
2102
+ armConfirm(logoutBtn, "退出当前账号", "确认退出?", () => doLogout(false));
2103
+ armConfirm(switchBtn, "退出并登录其它账号", "确认退出并换号?", () => doLogout(true));
2104
+ testBtn.addEventListener("click", () => {
2105
+ (async () => {
2106
+ testBtn.disabled = true;
2107
+ testOut.style.display = "block";
2108
+ testOut.className = "dsw-msg";
2109
+ testOut.textContent = "请求中……(首次调用要解 PoW,可能需要几秒)";
2110
+ try {
2111
+ const result = await api("/test", {
2112
+ method: "POST",
2113
+ body: JSON.stringify({ model: modelSelect.value })
2114
+ });
2115
+ if (result?.ok) {
2116
+ testOut.className = "dsw-msg ok";
2117
+ testOut.textContent = `✅ ${result.ms}ms · ${result.model}\n${result.text || "(空响应)"}${result.reasoning ? `\n\n[思考] ${result.reasoning}` : ""}`;
2118
+ } else {
2119
+ testOut.className = "dsw-msg err";
2120
+ testOut.textContent = `❌ ${result?.code ? `[${result.code}] ` : ""}${result?.error ?? "失败"}`;
2121
+ }
2122
+ } catch (error) {
2123
+ testOut.className = "dsw-msg err";
2124
+ testOut.textContent = `❌ ${error?.message ?? error}`;
2125
+ }
2126
+ testBtn.disabled = false;
2127
+ })();
2128
+ });
2129
+ refreshBtn.addEventListener("click", () => void refresh(false));
2130
+ root.append(page);
2131
+ refresh(false);
2132
+ const countdownTimer = window.setInterval(paintLimit, 3e4);
2133
+ timer = window.setInterval(() => {
2134
+ const active = windowOpen || Date.now() < boostUntil;
2135
+ if (shouldSyncAccounts(Date.now(), accountsSyncedAt, active)) syncAccounts();
2136
+ if (active) {
2137
+ refresh(true);
2138
+ return;
2139
+ }
2140
+ if (Math.random() < .15) refresh(false);
2141
+ }, 2e3);
2142
+ return () => {
2143
+ disposed = true;
2144
+ if (timer !== void 0) window.clearInterval(timer);
2145
+ window.clearInterval(countdownTimer);
2146
+ };
2147
+ }, []);
2148
+ return (0, react.createElement)("div", { ref: hostRef });
2149
+ }
2150
+ function apply(ctx) {
2151
+ ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
2152
+ name: "settings.section",
2153
+ id: "deepseek-web-login",
2154
+ order: 46,
2155
+ label: () => "DeepSeek 网页登录"
2156
+ }, () => (0, react.createElement)(Panel))), "deepseek-web-login: settings page");
2157
+ }
2158
+ //#endregion
2159
+ exports.apply = apply;
2160
+ exports.inject = inject;
2161
+ return module.exports;
2162
+ }
2163
+ });
2164
+
2165
+ //# sourceMappingURL=client.js.map