dsh-model 0.25.0 → 0.27.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/README.en.md CHANGED
@@ -28,10 +28,12 @@ English · [中文](README.md)
28
28
  | **Trae** | Detected automatically if Trae is installed and signed in; nothing to do | Check-in credits (200 a day in testing) |
29
29
  | **Qoder** | Paste a personal access token once (China or international) | That day's check-in credits |
30
30
  | **WorkBuddy (China)** | Sign in once (several accounts are fine) | Check-in credits |
31
- | **Doubao** (experimental) | Copy the sign-in cookie from the browser once (personal accounts only) | Daily trial credits |
31
+ | **Doubao** (experimental) | On Mac / Windows click "Read from this computer" to use the account signed in to the Doubao app or a browser; or paste the cookie by hand (personal accounts only) | Daily trial credits |
32
32
 
33
33
  - **Automatic by default**: done at a random 5–55 minutes after midnight Beijing time; failures retry an hour later; if the computer was off, it catches up within a minute of starting.
34
34
  - **You can see it**: one line at the top of the settings page, "Checked in today: …", with a one-click "Check in all" for anything missed, plus a one-time notice in the corner of dsh.
35
+ - **What else this machine can do**: the card lists the supported products not set up here yet, each with "+ Add".
36
+ - **Move it to an always-on VPS**: "Copy check-in accounts to another machine" gives an encrypted text and a code to import on the VPS page (Qoder and Doubao; sign in to WorkBuddy on the VPS itself, and Trae stays on the computer that has Trae).
35
37
  - **Several accounts**: each product can have more than one. In the check-in card's Details: "+ Add a WorkBuddy account" (approve through a link) and "+ Add another Qoder account"; several Trae installs are checked in once per account automatically.
36
38
  - **Check-in only, credits untouched**: they stay on your account for use in each product's own app. Command line: `dsh-model checkin`.
37
39
 
@@ -51,7 +53,7 @@ Your model sources each have their own API and sign-in. dsh-model puts them behi
51
53
  | Kind | Sources |
52
54
  |---|---|
53
55
  | **Free (suggested)** | **SiliconFlow** (reachable from China; some models free after identity verification), **Gemini** (if you can reach Google; the best free models) |
54
- | Free · limited | Groq, OpenRouter (`:free` models) |
56
+ | Free · limited | **Zhipu GLM** (the Flash models), **iFlytek Spark** (Spark Lite), Baidu Qianfan (ERNIE Speed / Lite), Groq, OpenRouter (`:free` models) |
55
57
  | Local | Ollama, LM Studio: appear once started, no sign-in |
56
58
  | Subscription | Codex (ChatGPT Plus / Pro), Kimi Code, Grok, Muse, Devin: sign in from the management page |
57
59
  | Pay as you go | DeepSeek official, OpenCode Zen, any OpenAI-compatible service |
@@ -112,6 +114,10 @@ If a source is signed in but has no usable plan (say, a Kimi account without Kim
112
114
  | Gemini (Google AI Studio), Groq | Free · limited | Paste a free API key; daily / per-minute limits apply |
113
115
  | OpenRouter | Free · limited | `:free` models are free (about 50 requests a day) |
114
116
  | SiliconFlow | Free · limited | Some small models are free after identity verification |
117
+ | Zhipu GLM | Free · limited | The Flash models (e.g. GLM-4.7-Flash) are free; reachable from China |
118
+ | iFlytek Spark | Free · limited | Spark Lite is free with no token cap, 2 requests per second; use the console's APIPassword as the key |
119
+ | Baidu Qianfan | Free · limited | ERNIE Speed / Lite are free for now (rate limited) |
120
+ | Volcengine Ark (Doubao models), MiniMax, Huawei Cloud ModelArts | Paid | Official APIs, just add a key; Ark and Huawei Cloud have free quotas for new users |
115
121
  | DeepSeek official, any OpenAI-compatible service | Paid | Paste an API key (and a URL for other services) |
116
122
  | Codex | Paid | ChatGPT Plus / Pro |
117
123
  | Kimi | Paid | Kimi Code plan |
package/README.md CHANGED
@@ -28,10 +28,12 @@
28
28
  | **Trae** | 本机装了 Trae 并登录过,自动识别,什么都不用做 | 签到送积分(实测每天 200) |
29
29
  | **Qoder** | 粘贴一次个人访问令牌(国内版、国际版都行) | 领当天的签到积分 |
30
30
  | **WorkBuddy 国内版** | 登录一次(可以加几个账号) | 签到送积分 |
31
- | **豆包**(实验) | 从浏览器复制一次登录 Cookie(只支持个人账号) | 「天天领额度」送体验额度 |
31
+ | **豆包**(实验) | Mac / Windows 上点「从本机自动读取」,读豆包电脑版或浏览器里已登录的账号;也可以手动粘贴 Cookie(只支持个人账号) | 「天天领额度」送体验额度 |
32
32
 
33
33
  - **自动签到默认打开**:北京时间零点后随机延后 5–55 分钟签好;失败的一小时后重试;电脑关机错过了,开机一分钟内补签。
34
34
  - **结果看得见**:设置页顶部一行「今日已完成签到:…」,没签上的可以「一键签到」;签完在 dsh 右下角提示一次。
35
+ - **这台还能签哪些**:卡片里列出支持、但这台还没设置的,旁边就是「+ 添加」。
36
+ - **放到 VPS 上签,不会漏**:「复制签到账号到另一台机器」导出一段加密文本和提取码,到 VPS 的页面里「导入」即可(Qoder、豆包;WorkBuddy 在 VPS 上各自登录,Trae 留在装了 Trae 的电脑上签)。
35
37
  - **多账号**:一家可以签几个账号。在签到卡片的「详情」里「+ 添加 WorkBuddy 账号」(打开链接授权)、「+ 再加一个 Qoder 账号」;本机装了几个 Trae 会自动按账号各签一次。
36
38
  - **只签到,不碰积分**:积分留在你的账号里,照常在各家官方软件里用。命令行:`dsh-model checkin`。
37
39
 
@@ -51,7 +53,7 @@
51
53
  | 类型 | 来源 |
52
54
  |---|---|
53
55
  | **免费(推荐)** | **硅基流动**(国内直连,实名后部分模型免费)、**Gemini**(能上外网,免费模型里质量最好) |
54
- | 免费 · 额度少 | Groq、OpenRouter(`:free` 模型) |
56
+ | 免费 · 额度少 | **智谱 GLM**(Flash 系列免费)、**讯飞星火**(Spark Lite 免费)、百度千帆(ERNIE Speed / Lite)、Groq、OpenRouter(`:free` 模型) |
55
57
  | 本机 | Ollama、LM Studio:启动后自动出现,不用登录 |
56
58
  | 订阅 | Codex(ChatGPT Plus / Pro)、Kimi Code、Grok、Muse、Devin:在管理页里登录即可 |
57
59
  | 按量付费 | DeepSeek 官方、OpenCode Zen、任意 OpenAI 兼容服务 |
@@ -112,6 +114,10 @@ dsh-model setup
112
114
  | Gemini(Google AI Studio)、Groq | 免费·额度少 | 填免费 API key 即可,每天 / 每分钟有上限 |
113
115
  | OpenRouter | 免费·额度少 | 名字带 `:free` 的模型免费(每天约 50 次) |
114
116
  | 硅基流动 | 免费·额度少 | 实名认证后部分小模型免费 |
117
+ | 智谱 GLM | 免费·额度少 | Flash 系列(如 GLM-4.7-Flash)免费,国内直连 |
118
+ | 讯飞星火 | 免费·额度少 | Spark Lite 免费不限量,每秒 2 次;key 填控制台的 APIPassword |
119
+ | 百度千帆 | 免费·额度少 | ERNIE Speed / Lite 长期免费(限速) |
120
+ | 火山方舟(豆包大模型)、MiniMax 开放平台、华为云 ModelArts | 需付费 | 官方 API,填 key 即可;火山方舟、华为云有新用户免费额度 |
115
121
  | DeepSeek 官方、其他 OpenAI 兼容服务 | 需付费 | 填 API key(其他服务还要填地址) |
116
122
  | Codex | 需付费 | ChatGPT Plus / Pro |
117
123
  | Kimi | 需付费 | Kimi Code 套餐 |
@@ -104,6 +104,20 @@ export async function removeQoder(ctx, id) {
104
104
  const target = id ?? list[0]?.id;
105
105
  await saveQoders(ctx, list.filter((q) => q.id !== target));
106
106
  }
107
+ /** Cookie 串里某一项的值 */
108
+ export function cookieValue(cookie, name) {
109
+ for (const part of cookie.split(/;\s*/)) {
110
+ const i = part.indexOf('=');
111
+ if (i > 0 && part.slice(0, i).trim() === name)
112
+ return part.slice(i + 1);
113
+ }
114
+ return undefined;
115
+ }
116
+ /** 同一个豆包账号:uid_tt 一样(Cookie 里别的项每次都会变) */
117
+ const sameDoubaoAccount = (a, b) => {
118
+ const x = cookieValue(a, 'uid_tt');
119
+ return a === b || (Boolean(x) && x === cookieValue(b, 'uid_tt'));
120
+ };
107
121
  export async function loadDoubaos(ctx) {
108
122
  return (await loadSecrets(ctx)).checkin?.doubaos ?? [];
109
123
  }
@@ -117,14 +131,17 @@ async function saveDoubaos(ctx, list) {
117
131
  secrets.checkin = Object.keys(checkin).length ? checkin : undefined;
118
132
  await saveSecrets(ctx, secrets);
119
133
  }
120
- /** 加一个豆包账号;同一个 Cookie 再加 = 改名字。返回 id */
134
+ /** 加一个豆包账号;同一个账号再加 = 换成新的 Cookie(和名字)。返回 id */
121
135
  export async function addDoubao(ctx, entry) {
122
136
  const list = await loadDoubaos(ctx);
123
137
  const name = entry.name?.trim().slice(0, 20);
124
- const same = list.find((d) => d.cookie === entry.cookie);
138
+ const same = list.find((d) => sameDoubaoAccount(d.cookie, entry.cookie));
125
139
  if (same) {
140
+ same.cookie = entry.cookie;
126
141
  if (name)
127
142
  same.name = name;
143
+ if (entry.from)
144
+ same.from = entry.from;
128
145
  await saveDoubaos(ctx, list);
129
146
  return same.id;
130
147
  }
@@ -133,7 +150,7 @@ export async function addDoubao(ctx, entry) {
133
150
  while (used.has(String(n)))
134
151
  n++;
135
152
  const id = String(n);
136
- list.push({ id, cookie: entry.cookie, seed: entry.seed ?? randomBytes(16).toString('hex'), ...(name ? { name } : {}), savedAt: new Date().toISOString() });
153
+ list.push({ id, cookie: entry.cookie, seed: entry.seed ?? randomBytes(16).toString('hex'), ...(name ? { name } : {}), ...(entry.from ? { from: entry.from } : {}), savedAt: new Date().toISOString() });
137
154
  await saveDoubaos(ctx, list);
138
155
  return id;
139
156
  }
@@ -0,0 +1,219 @@
1
+ // checkin/browser-cookies.ts — 从本机已登录的豆包电脑版 / 浏览器里读 doubao.com 的登录 Cookie(设计 §14.21)
2
+ //
3
+ // 用户嫌"F12 → 网络 → 复制 Cookie"太麻烦(2026-10-29)。豆包电脑版(DoubaoWork)和 Chrome、Edge、Brave 都是 Chromium,
4
+ // Cookie 存在 <用户数据>/<配置>/Cookies(新版在 Network/Cookies)这个 SQLite 里,值是加密的:
5
+ // macOS:v10 + AES-128-CBC,密钥 = PBKDF2(钥匙串里「<应用> Safe Storage」的密码, 'saltysalt', 1003, sha1)。
6
+ // 读钥匙串时 macOS 会弹窗问用户是否允许,用户点允许才读得到。
7
+ // Windows:v10 + AES-256-GCM,密钥在 Local State 的 os_crypt.encrypted_key 里,用当前用户的 DPAPI 解开。
8
+ // 新版 Chrome 的 v20(应用绑定加密)读不了,跳过。
9
+ // Linux:不读(VPS 上也没有这些应用)。
10
+ // Cookie 库版本 ≥ 24 时,解开的值前面多 32 字节(host 的 sha256),要去掉。
11
+ // 只读 doubao.com 的 Cookie,只在用户点「从本机找」时读;先只看有没有登录(不碰密钥),有才去解密。
12
+ import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
13
+ import { copyFile, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { run, which } from '../util/exec.js';
17
+ /** 要读的 Cookie 所在域 */
18
+ const HOSTS = ['doubao.com', '.doubao.com', 'www.doubao.com'];
19
+ /** 有这个 Cookie 才算登录了 */
20
+ const SESSION_COOKIE = 'sessionid';
21
+ /** 本机可能有 doubao.com 登录的 Chromium 应用 */
22
+ export function cookieStores(home = homedir(), platform = process.platform, env = process.env) {
23
+ if (platform === 'darwin') {
24
+ const s = join(home, 'Library', 'Application Support');
25
+ return [
26
+ { label: '豆包电脑版', root: join(s, 'DoubaoWork'), services: ['DoubaoWork Browser Safe Storage', 'DoubaoWork Safe Storage'] },
27
+ { label: '豆包', root: join(s, 'Doubao'), services: ['Doubao Browser Safe Storage', 'Doubao Safe Storage'] },
28
+ { label: 'Chrome', root: join(s, 'Google', 'Chrome'), services: ['Chrome Safe Storage'] },
29
+ { label: 'Edge', root: join(s, 'Microsoft Edge'), services: ['Microsoft Edge Safe Storage'] },
30
+ { label: 'Brave', root: join(s, 'BraveSoftware', 'Brave-Browser'), services: ['Brave Safe Storage'] },
31
+ ];
32
+ }
33
+ if (platform === 'win32') {
34
+ const local = env.LOCALAPPDATA || join(home, 'AppData', 'Local');
35
+ const roaming = env.APPDATA || join(home, 'AppData', 'Roaming');
36
+ return [
37
+ { label: '豆包电脑版', root: join(local, 'DoubaoWork', 'User Data'), services: [] },
38
+ { label: '豆包电脑版', root: join(roaming, 'DoubaoWork'), services: [] },
39
+ { label: '豆包', root: join(local, 'Doubao', 'User Data'), services: [] },
40
+ { label: 'Chrome', root: join(local, 'Google', 'Chrome', 'User Data'), services: [] },
41
+ { label: 'Edge', root: join(local, 'Microsoft', 'Edge', 'User Data'), services: [] },
42
+ { label: 'Brave', root: join(local, 'BraveSoftware', 'Brave-Browser', 'User Data'), services: [] },
43
+ ];
44
+ }
45
+ return [];
46
+ }
47
+ const exists = async (p) => stat(p).then(() => true, () => false);
48
+ /** 列出 store 里每个配置的 Cookie 库 */
49
+ async function cookieDbs(store) {
50
+ let names;
51
+ try {
52
+ names = await readdir(store.root);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ const out = [];
58
+ for (const profile of names.filter((n) => n === 'Default' || /^Profile \d+$/.test(n)).sort()) {
59
+ for (const rel of [['Network', 'Cookies'], ['Cookies']]) {
60
+ const file = join(store.root, profile, ...rel);
61
+ if (await exists(file)) {
62
+ out.push({ store, profile, file });
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /** Chromium 的时间:1601 年起的毫秒(SQL 里已从微秒除下来,微秒超出 JS 安全整数);0 = 会话 Cookie */
70
+ const chromeTimeToMs = (t) => (t ? t - 11_644_473_600_000 : 0);
71
+ /** 读 Cookie 库:先复制一份再读(应用开着时原文件被锁)。返回 doubao.com 的行和库版本 */
72
+ export async function readCookieDb(file) {
73
+ const dir = await mkdtemp(join(tmpdir(), 'dshm-ck-'));
74
+ try {
75
+ const copy = join(dir, 'Cookies');
76
+ await copyFile(file, copy);
77
+ for (const ext of ['-journal', '-wal'])
78
+ await copyFile(file + ext, copy + ext).catch(() => { });
79
+ return await querySqlite(copy);
80
+ }
81
+ finally {
82
+ await rm(dir, { recursive: true, force: true });
83
+ }
84
+ }
85
+ const SQL_ROWS = `select host_key, name, value, hex(encrypted_value) as ev, expires_utc / 1000 as expires_utc from cookies where host_key in (${HOSTS.map((h) => `'${h}'`).join(',')})`;
86
+ const SQL_VERSION = `select value from meta where key = 'version'`;
87
+ async function querySqlite(file) {
88
+ let raw;
89
+ let version;
90
+ try {
91
+ // Node 22.5 起自带 SQLite;更老的 Node 退回系统的 sqlite3 命令
92
+ const mod = 'node:sqlite'; // 写成变量:@types/node 还没有它的类型
93
+ const { DatabaseSync } = (await import(mod));
94
+ const db = new DatabaseSync(file, { readOnly: true });
95
+ try {
96
+ raw = db.prepare(SQL_ROWS).all();
97
+ version = Number(db.prepare(SQL_VERSION).get()?.value) || 0;
98
+ }
99
+ finally {
100
+ db.close();
101
+ }
102
+ }
103
+ catch (error) {
104
+ if (error.code !== 'ERR_UNKNOWN_BUILTIN_MODULE' && !/node:sqlite/.test(String(error)))
105
+ throw error;
106
+ const bin = await which('sqlite3');
107
+ if (!bin)
108
+ throw new Error('读不了浏览器的 Cookie:Node 太旧(要 22.5 以上)且没有 sqlite3 命令');
109
+ const a = await run(bin, ['-readonly', '-json', file, SQL_ROWS], { timeoutMs: 20_000 });
110
+ const b = await run(bin, ['-readonly', file, SQL_VERSION], { timeoutMs: 20_000 });
111
+ if (a.code !== 0)
112
+ throw new Error(`读 Cookie 库失败:${a.stderr.trim()}`);
113
+ raw = a.stdout.trim() ? JSON.parse(a.stdout) : [];
114
+ version = Number(b.stdout.trim()) || 0;
115
+ }
116
+ return {
117
+ rows: raw.map((r) => ({ host_key: r.host_key, name: r.name, value: r.value ?? '', encrypted_value: Buffer.from(r.ev ?? '', 'hex'), expires_utc: Number(r.expires_utc) || 0 })),
118
+ version,
119
+ };
120
+ }
121
+ /** 没过期的 Cookie(会话 Cookie 也算) */
122
+ const alive = (r, now) => {
123
+ const ms = chromeTimeToMs(r.expires_utc);
124
+ return !ms || ms > now;
125
+ };
126
+ /** 只看哪些地方登录着豆包(不解密、不碰钥匙串,不会弹窗) */
127
+ export async function findDoubaoLogins(home = homedir(), platform = process.platform, now = Date.now()) {
128
+ const out = [];
129
+ for (const store of cookieStores(home, platform)) {
130
+ const dbs = await cookieDbs(store);
131
+ for (const db of dbs) {
132
+ const { rows } = await readCookieDb(db.file).catch(() => ({ rows: [] }));
133
+ if (rows.some((r) => r.name === SESSION_COOKIE && alive(r, now))) {
134
+ out.push({ label: dbs.length > 1 && db.profile !== 'Default' ? `${store.label}(${db.profile})` : store.label, db });
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /** macOS:Cookie 密钥 = PBKDF2(钥匙串密码)。读钥匙串会弹窗问用户 */
141
+ async function macKey(services) {
142
+ let last = '';
143
+ for (const s of services) {
144
+ const r = await run('/usr/bin/security', ['find-generic-password', '-w', '-s', s], { timeoutMs: 120_000 });
145
+ if (r.code === 0 && r.stdout.trim())
146
+ return pbkdf2Sync(r.stdout.trim(), 'saltysalt', 1003, 16, 'sha1');
147
+ last = r.stderr.trim();
148
+ // 用户点了拒绝:不再试别的名字
149
+ if (/cancel|denied|拒绝|取消/i.test(last) || r.code === 128)
150
+ throw new CookieAccessDenied();
151
+ }
152
+ throw new Error(`钥匙串里没找到它的 Cookie 密钥${last ? `(${last})` : ''}`);
153
+ }
154
+ /** Windows:Local State 里的密钥,用 DPAPI 解开 */
155
+ async function winKey(root) {
156
+ const state = JSON.parse(await readFile(join(root, 'Local State'), 'utf8'));
157
+ const enc = Buffer.from(state.os_crypt?.encrypted_key ?? '', 'base64');
158
+ if (enc.subarray(0, 5).toString() !== 'DPAPI')
159
+ throw new Error('Local State 里没有能用的 Cookie 密钥');
160
+ const script = "Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String($env:DSHM_BLOB); [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'))";
161
+ const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { env: { ...process.env, DSHM_BLOB: enc.subarray(5).toString('base64') }, timeoutMs: 30_000 });
162
+ if (r.code !== 0 || !r.stdout.trim())
163
+ throw new Error(`解不开 Cookie 密钥:${r.stderr.trim() || `退出码 ${r.code}`}`);
164
+ return Buffer.from(r.stdout.trim(), 'base64');
165
+ }
166
+ export class CookieAccessDenied extends Error {
167
+ constructor() {
168
+ super('没有允许读取钥匙串');
169
+ }
170
+ }
171
+ /** 解开一个 Cookie 值。读不了(如 v20)返回 null。导出给测试 */
172
+ export function decryptCookie(enc, key, platform, dbVersion) {
173
+ const tag = enc.subarray(0, 3).toString();
174
+ if (tag !== 'v10' && tag !== 'v11')
175
+ return null;
176
+ let plain;
177
+ if (platform === 'win32') {
178
+ const nonce = enc.subarray(3, 15);
179
+ const body = enc.subarray(15, enc.length - 16);
180
+ const d = createDecipheriv('aes-256-gcm', key, nonce);
181
+ d.setAuthTag(enc.subarray(enc.length - 16));
182
+ plain = Buffer.concat([d.update(body), d.final()]);
183
+ }
184
+ else {
185
+ const d = createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
186
+ plain = Buffer.concat([d.update(enc.subarray(3)), d.final()]);
187
+ }
188
+ return (dbVersion >= 24 ? plain.subarray(32) : plain).toString('utf8');
189
+ }
190
+ /** 读出一处登录的完整 Cookie 串(a=1; b=2)。macOS 会弹钥匙串授权 */
191
+ export async function readDoubaoCookie(login, platform = process.platform, now = Date.now(), testKey) {
192
+ const { rows, version } = await readCookieDb(login.db.file);
193
+ const key = testKey ?? (platform === 'darwin' ? await macKey(login.db.store.services) : await winKey(login.db.store.root));
194
+ // 同名的取更具体的域(www.doubao.com 盖过 .doubao.com)
195
+ const byName = new Map();
196
+ let unreadable = 0;
197
+ for (const r of rows.filter((x) => alive(x, now))) {
198
+ let value = r.value;
199
+ if (!value && r.encrypted_value.length) {
200
+ try {
201
+ value = decryptCookie(r.encrypted_value, key, platform, version);
202
+ }
203
+ catch {
204
+ value = null;
205
+ }
206
+ }
207
+ if (value === null) {
208
+ unreadable++;
209
+ continue;
210
+ }
211
+ const prev = byName.get(r.name);
212
+ if (!prev || r.host_key.length > prev.host.length)
213
+ byName.set(r.name, { host: r.host_key, value });
214
+ }
215
+ if (!byName.get(SESSION_COOKIE)?.value) {
216
+ throw new Error(unreadable ? `${login.label} 用的是新的加密方式,读不了:请用「手动粘贴 Cookie」` : `${login.label} 里的豆包登录不完整`);
217
+ }
218
+ return [...byName].map(([n, v]) => `${n}=${v.value}`).join('; ');
219
+ }
@@ -24,7 +24,7 @@ export function checkinAdvice(itemId, message) {
24
24
  const id = itemId.split(':')[0];
25
25
  if (/无效|过期|401|403|unauthor|expired|not able to authenticate/i.test(message)) {
26
26
  if (id === 'doubao')
27
- return L('豆包登录过期:在浏览器里重新登录 doubao.com,到签到卡片里删掉它,再复制 Cookie 添加一次', 'Doubao sign-in expired: sign in at doubao.com again, remove it in the check-in card and add the new cookie');
27
+ return L('豆包登录过期:在豆包电脑版或浏览器里重新登录,再点签到卡片里的「+ 添加豆包 → 从本机自动读取」(会换成新的登录)', 'Doubao sign-in expired: sign in again in the Doubao app or a browser, then use "+ Add Doubao → Read from this computer" in the check-in card (it replaces the old sign-in)');
28
28
  if (id === 'trae')
29
29
  return L('Trae 登录过期:打开 Trae 重新登录一次', 'Trae sign-in expired: open Trae and sign in again');
30
30
  if (id === 'qoder')
@@ -0,0 +1,65 @@
1
+ // checkin/transfer.ts — 把签到账号复制到另一台机器(设计 §14.20):比如 Mac 上加好的,搬到不关机的 VPS 上签
2
+ //
3
+ // 只搬能在任何机器上用的:Qoder 令牌、豆包 Cookie。
4
+ // WorkBuddy 不搬:登录令牌会续期轮换,两台用同一份会互相顶掉,所以每台各自登录。
5
+ // Trae 不搬:读的是本机 Trae 软件的登录,要在装了 Trae 的电脑上签。
6
+ // 导出 = 一段加密文本 + 一个 8 位提取码(分开显示,光有文本解不开)。AES-256-GCM,密钥由提取码经 scrypt 派生。
7
+ import { createCipheriv, createDecipheriv, randomBytes, randomInt, scryptSync } from 'node:crypto';
8
+ import { DshModelError } from '../errors.js';
9
+ import { L } from '../i18n.js';
10
+ import { addDoubao, addQoder, loadDoubaos, loadQoders } from './accounts.js';
11
+ const PREFIX = 'dshm-checkin:v1:';
12
+ /** 去掉容易看错的 0/O、1/I/L */
13
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
14
+ const keyFrom = (code, salt) => scryptSync(code.replace(/[\s-]/g, '').toUpperCase(), salt, 32, { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
15
+ export function sealBundle(bundle, code) {
16
+ const c = code ?? Array.from({ length: 8 }, () => ALPHABET[randomInt(ALPHABET.length)]).join('');
17
+ const salt = randomBytes(16);
18
+ const iv = randomBytes(12);
19
+ const cipher = createCipheriv('aes-256-gcm', keyFrom(c, salt), iv);
20
+ const body = Buffer.concat([cipher.update(JSON.stringify(bundle), 'utf8'), cipher.final()]);
21
+ const text = PREFIX + Buffer.concat([salt, iv, cipher.getAuthTag(), body]).toString('base64url');
22
+ return { text, code: `${c.slice(0, 4)}-${c.slice(4)}` };
23
+ }
24
+ export function openBundle(text, code) {
25
+ const t = text.trim();
26
+ if (!t.startsWith(PREFIX))
27
+ throw new DshModelError('bad_transfer', L('这不是 dsh-model 导出的签到账号(应以 dshm-checkin: 开头)', 'This is not a dsh-model check-in export (it should start with dshm-checkin:)'));
28
+ const raw = Buffer.from(t.slice(PREFIX.length), 'base64url');
29
+ if (raw.length < 16 + 12 + 16 + 2)
30
+ throw new DshModelError('bad_transfer', L('导出的文本不完整:复制时可能少了一截', 'The export text is incomplete: part of it may be missing'));
31
+ const salt = raw.subarray(0, 16);
32
+ const iv = raw.subarray(16, 28);
33
+ const tag = raw.subarray(28, 44);
34
+ try {
35
+ const d = createDecipheriv('aes-256-gcm', keyFrom(code, salt), iv);
36
+ d.setAuthTag(tag);
37
+ const json = Buffer.concat([d.update(raw.subarray(44)), d.final()]).toString('utf8');
38
+ const b = JSON.parse(json);
39
+ return { qoders: Array.isArray(b.qoders) ? b.qoders : [], doubaos: Array.isArray(b.doubaos) ? b.doubaos : [] };
40
+ }
41
+ catch {
42
+ throw new DshModelError('bad_transfer_code', L('提取码不对,或者文本被改动过', 'Wrong code, or the text was changed'));
43
+ }
44
+ }
45
+ /** 导出这台机器上能搬的签到账号 */
46
+ export async function exportCheckin(ctx) {
47
+ const qoders = await loadQoders(ctx);
48
+ const doubaos = await loadDoubaos(ctx);
49
+ if (!qoders.length && !doubaos.length)
50
+ throw new DshModelError('nothing_to_export', L('这台机器上没有能复制的签到账号(只能复制 Qoder 和豆包;WorkBuddy 请在那台机器上各自登录,Trae 要在装了 Trae 的电脑上签)', 'Nothing to copy from this machine (only Qoder and Doubao can be copied; sign in to WorkBuddy on each machine, and Trae needs a computer with Trae installed)'));
51
+ const sealed = sealBundle({
52
+ qoders: qoders.map((q) => ({ pat: q.pat, region: q.region, ...(q.name ? { name: q.name } : {}) })),
53
+ doubaos: doubaos.map((d) => ({ cookie: d.cookie, seed: d.seed, ...(d.name ? { name: d.name } : {}) })),
54
+ });
55
+ return { ...sealed, counts: { qoder: qoders.length, doubao: doubaos.length } };
56
+ }
57
+ /** 导入:已有的同一个令牌 / Cookie 不会重复加 */
58
+ export async function importCheckin(ctx, text, code) {
59
+ const b = openBundle(text, code);
60
+ for (const q of b.qoders)
61
+ await addQoder(ctx, q);
62
+ for (const d of b.doubaos)
63
+ await addDoubao(ctx, d);
64
+ return { qoder: b.qoders.length, doubao: b.doubaos.length };
65
+ }
@@ -20,7 +20,9 @@ import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheck
20
20
  import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
21
21
  import { findTraeCredentials, traeCheckin } from '../checkin/trae.js';
22
22
  import { DoubaoAuthError, doubaoCheckin, doubaoStatus, normalizeCookie } from '../checkin/doubao.js';
23
- import { accountTag, addDoubao, addQoder, listWorkbuddyAccounts, loadDoubaos, loadQoders, removeDoubao, removeQoder, removeWorkbuddyAccount, workbuddyAccountStore, workbuddyAccountsDir } from '../checkin/accounts.js';
23
+ import { CookieAccessDenied, findDoubaoLogins, readDoubaoCookie } from '../checkin/browser-cookies.js';
24
+ import { exportCheckin, importCheckin } from '../checkin/transfer.js';
25
+ import { accountTag, addDoubao, checkinKind, cookieValue, addQoder, listWorkbuddyAccounts, loadDoubaos, loadQoders, removeDoubao, removeQoder, removeWorkbuddyAccount, workbuddyAccountStore, workbuddyAccountsDir } from '../checkin/accounts.js';
24
26
  import { CN_VARIANT } from '../bridge/workbuddy/variants.js';
25
27
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
26
28
  import { randomBytes } from 'node:crypto';
@@ -792,6 +794,16 @@ export class Daemon {
792
794
  qoder: qoders.map((q, i) => ({ id: q.id, label: checkers.find((c) => c.id === (i === 0 ? 'qoder' : `qoder:${q.id}`))?.label ?? q.id, region: q.region })),
793
795
  doubao: (await loadDoubaos(this.ctx)).map((d, i) => ({ id: d.id, label: checkers.find((c) => c.id === (i === 0 ? 'doubao' : `doubao:${d.id}`))?.label ?? d.id })),
794
796
  },
797
+ // 能不能从本机的豆包电脑版 / 浏览器读登录(看守护进程所在的机器,不是看浏览器)
798
+ localRead: process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'win' : null,
799
+ // 支持、但这台还没设置的(签到卡片里列出来,旁边就是「添加」)
800
+ missing: [
801
+ ...(checkers.some((c) => checkinKind(c.id) === 'workbuddy') ? [] : [{ id: 'workbuddy', label: L('WorkBuddy 国内版', 'WorkBuddy (China)'), action: 'workbuddy' }]),
802
+ ...(checkers.some((c) => checkinKind(c.id) === 'qoder') ? [] : [{ id: 'qoder', label: 'Qoder', action: 'qoder' }]),
803
+ // 本机豆包电脑版 / 浏览器登录着豆包:说出来,点「添加」就能自动读(只看有没有登录,不解密)
804
+ ...(checkers.some((c) => checkinKind(c.id) === 'doubao') ? [] : [{ id: 'doubao', label: L('豆包', 'Doubao'), action: 'doubao', found: (await findDoubaoLogins().catch(() => [])).map((f) => f.label) }]),
805
+ ...(checkers.some((c) => checkinKind(c.id) === 'trae') ? [] : [{ id: 'trae', label: 'Trae', reason: L('这台没装 Trae:要在装了 Trae 的电脑上签', 'Trae is not installed here: it is checked in on a computer with Trae') }]),
806
+ ],
795
807
  };
796
808
  }
797
809
  checkinInflight;
@@ -842,6 +854,48 @@ export class Daemon {
842
854
  throw new DshModelError('doubao_no_task', L('这个豆包账号没有签到任务:只有个人账号能领每日额度,飞书企业账号不行。请换成个人账号登录后再复制 Cookie', 'This Doubao account has no check-in task: only personal accounts get the daily credits, not Feishu enterprise accounts. Sign in with a personal account and copy the cookie again'));
843
855
  await addDoubao(this.ctx, { cookie, seed, ...(name ? { name } : {}) });
844
856
  }
857
+ /**
858
+ * 从本机找豆包登录(豆包电脑版、Chrome、Edge、Brave),读出 Cookie 加成签到账号。
859
+ * macOS 会弹窗问用户是否允许读钥匙串;同一个账号在几处都登录着只加一次,已加过的换成新的 Cookie。
860
+ */
861
+ async scanDoubao() {
862
+ const logins = await findDoubaoLogins();
863
+ if (!logins.length)
864
+ throw new DshModelError('doubao_not_found', L('这台电脑上没找到登录着的豆包:先在豆包电脑版或浏览器里登录 doubao.com,或用下面的「手动粘贴 Cookie」', 'No signed-in Doubao found on this computer: sign in to doubao.com in the Doubao app or a browser first, or paste the cookie by hand below'));
865
+ const added = [];
866
+ const skipped = [];
867
+ const seen = new Set();
868
+ for (const login of logins) {
869
+ let cookie;
870
+ try {
871
+ cookie = await readDoubaoCookie(login);
872
+ }
873
+ catch (error) {
874
+ skipped.push({ label: login.label, reason: error instanceof CookieAccessDenied ? L('没有允许读取(钥匙串弹窗里点了拒绝)', 'access was not allowed in the keychain prompt') : String(error.message ?? error) });
875
+ continue;
876
+ }
877
+ const uid = cookieValue(cookie, 'uid_tt') ?? cookie;
878
+ if (seen.has(uid))
879
+ continue;
880
+ seen.add(uid);
881
+ const existing = (await loadDoubaos(this.ctx)).find((d) => cookieValue(d.cookie, 'uid_tt') === uid);
882
+ const seed = existing?.seed ?? randomBytes(16).toString('hex');
883
+ try {
884
+ const st = await doubaoStatus(cookie, seed);
885
+ if (st.checkedIn === null) {
886
+ skipped.push({ label: login.label, reason: L('登录的是飞书企业账号,没有每日额度(只有个人账号能领)', 'signed in with a Feishu enterprise account, which gets no daily credits (personal accounts only)') });
887
+ continue;
888
+ }
889
+ }
890
+ catch (error) {
891
+ skipped.push({ label: login.label, reason: error instanceof DoubaoAuthError ? L('登录已过期:在那里重新登录豆包', 'the sign-in expired: sign in to Doubao there again') : String(error.message ?? error) });
892
+ continue;
893
+ }
894
+ await addDoubao(this.ctx, { cookie, seed, from: login.label, ...(existing?.name ? {} : { name: login.label }) });
895
+ added.push(login.label);
896
+ }
897
+ return { added, skipped };
898
+ }
845
899
  /** 加一个 WorkBuddy 签到账号:打开链接授权(同来源登录一样),存成单独一份,只用来签到 */
846
900
  async startWorkbuddyCheckinLogin() {
847
901
  const all = await loadAll(this.ctx);
@@ -1219,6 +1273,18 @@ export class Daemon {
1219
1273
  }
1220
1274
  if (method === 'POST' && path === '/checkin/workbuddy')
1221
1275
  return ok({ login: await this.startWorkbuddyCheckinLogin() });
1276
+ if (method === 'POST' && path === '/checkin/export')
1277
+ return ok(await exportCheckin(this.ctx));
1278
+ if (method === 'POST' && path === '/checkin/import') {
1279
+ const n = await importCheckin(this.ctx, String(b.text ?? ''), String(b.code ?? ''));
1280
+ return ok({ imported: n, ...(await this.checkinNow(true)) });
1281
+ }
1282
+ if (method === 'POST' && path === '/checkin/doubao/scan') {
1283
+ const found = await this.scanDoubao();
1284
+ if (found.added.length)
1285
+ await this.checkinNow(false).catch(() => null);
1286
+ return ok({ ...found, status: await this.checkinStatus() });
1287
+ }
1222
1288
  if (method === 'POST' && path === '/checkin/doubao') {
1223
1289
  await this.addDoubaoCookie(String(b.cookie ?? ''), typeof b.name === 'string' ? b.name : undefined);
1224
1290
  return ok(await this.checkinNow(true));
@@ -13,7 +13,7 @@ import { join } from 'node:path';
13
13
  import { beijingTimeToIso } from '../bridge/workbuddy/upstream.js';
14
14
  const TIMEOUT_MS = 15_000;
15
15
  /** 这几家暂不支持(见设计 §14.6) */
16
- export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio', 'gemini', 'groq', 'openrouter', 'siliconflow', 'deepseek', 'custom']);
16
+ export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio', 'gemini', 'groq', 'openrouter', 'siliconflow', 'zhipu', 'spark', 'qianfan', 'ark', 'minimax', 'modelarts', 'deepseek', 'custom']);
17
17
  async function getJson(url, headers) {
18
18
  const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
19
19
  const text = await res.text();
@@ -71,7 +71,7 @@ export async function loadCompatUpstreams(ctx) {
71
71
  });
72
72
  }
73
73
  }
74
- // 自带 API key 的来源:有 key、有模型、没被关掉就接进来(走出站代理,不 direct)
74
+ // 自带 API key 的来源:有 key、有模型、没被关掉就接进来(海外的走出站代理,国内平台直连)
75
75
  const keyCat = await loadKeyCatalog(ctx.paths.home);
76
76
  for (const [id, s] of Object.entries(secrets.apikeys ?? {})) {
77
77
  const p = keyProvider(id);
@@ -83,6 +83,8 @@ export async function loadCompatUpstreams(ctx) {
83
83
  label: p.label,
84
84
  baseUrl: s.base ?? p.base,
85
85
  apiKey: s.key,
86
+ // 国内平台直连,不绕出站代理
87
+ ...(p.direct ? { direct: true } : {}),
86
88
  models: models.map((m) => ({ name: m.id, alias: `${id}/${m.id}`, displayName: `${p.label} · ${m.id}`, dshName: m.free ? `${m.id} · Free` : m.id, ...(m.free ? { rate: 0 } : {}), group: id, groupLabel: p.label })),
87
89
  });
88
90
  }
@@ -13,6 +13,13 @@ export const KEY_PROVIDERS = [
13
13
  { id: 'groq', label: 'Groq', base: 'https://api.groq.com/openai/v1', keyUrl: 'https://console.groq.com/keys' },
14
14
  { id: 'openrouter', label: 'OpenRouter', base: 'https://openrouter.ai/api/v1', keyUrl: 'https://openrouter.ai/keys' },
15
15
  { id: 'siliconflow', label: '硅基流动 SiliconFlow', base: 'https://api.siliconflow.cn/v1', keyUrl: 'https://cloud.siliconflow.cn/account/ak', listQuery: 'type=text&sub_type=chat' },
16
+ // 国内大厂的官方 API(2026-10-29 加):智谱、讯飞星火有长期免费的模型
17
+ { id: 'zhipu', label: '智谱 GLM', base: 'https://open.bigmodel.cn/api/paas/v4', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', fallbackModels: ['glm-4.7-flash', 'glm-4.6', 'glm-4.5-air'], freeRe: /flash/i, direct: true },
18
+ { id: 'spark', label: '讯飞星火', base: 'https://spark-api-open.xf-yun.com/v1', keyUrl: 'https://console.xfyun.cn/services/cbm', fallbackModels: ['lite', 'generalv3', 'pro-128k', 'generalv3.5', 'max-32k', '4.0Ultra'], freeRe: /^lite$/i, direct: true },
19
+ { id: 'qianfan', label: '百度千帆', base: 'https://qianfan.baidubce.com/v2', keyUrl: 'https://console.bce.baidu.com/iam/#/iam/apikey/list', fallbackModels: ['ernie-speed-128k', 'ernie-lite-8k', 'ernie-4.5-turbo-128k', 'deepseek-v3'], freeRe: /^ernie-(speed|lite)/i, direct: true },
20
+ { id: 'ark', label: '火山方舟(豆包大模型)', base: 'https://ark.cn-beijing.volces.com/api/v3', keyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey', fallbackModels: ['doubao-seed-1-6-flash-250615', 'doubao-seed-1-6-250615', 'deepseek-v3-1-250821'], direct: true },
21
+ { id: 'minimax', label: 'MiniMax 开放平台', base: 'https://api.minimaxi.com/v1', keyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', fallbackModels: ['MiniMax-M2', 'MiniMax-M1', 'MiniMax-Text-01'], direct: true },
22
+ { id: 'modelarts', label: '华为云 ModelArts', base: 'https://api.modelarts-maas.com/v1', keyUrl: 'https://console.huaweicloud.com/modelarts/?region=cn-southwest-2#/model-studio/authmanage', fallbackModels: ['DeepSeek-V3', 'DeepSeek-R1', 'qwen3-235b-a22b'], direct: true },
16
23
  { id: 'deepseek', label: 'DeepSeek 官方', base: 'https://api.deepseek.com/v1', keyUrl: 'https://platform.deepseek.com/api_keys' },
17
24
  { id: 'custom', label: '其他 OpenAI 兼容服务', base: '', keyUrl: '' },
18
25
  ];
@@ -37,22 +44,60 @@ export function parseModelList(providerId, body) {
37
44
  if (!id)
38
45
  continue;
39
46
  const pricing = m.pricing;
40
- const free = id.endsWith(':free') || (pricing ? Number(pricing.prompt) === 0 && Number(pricing.completion) === 0 : false);
47
+ const free = id.endsWith(':free') || (pricing ? Number(pricing.prompt) === 0 && Number(pricing.completion) === 0 : false) || Boolean(keyProvider(providerId)?.freeRe?.test(id));
41
48
  out.push({ id, ...(free ? { free: true } : {}) });
42
49
  }
43
50
  // OpenRouter 有几百个模型:付费的照样能用,但先把免费的排前面,挑默认时就先挑它们
44
51
  return out.sort((a, b) => Number(Boolean(b.free)) - Number(Boolean(a.free)) || a.id.localeCompare(b.id));
45
52
  }
46
53
  /** 用这把 key 拉模型列表:拉得到就是可用的 key(不花 token) */
47
- export async function fetchKeyModels(provider, key, base, proxy) {
48
- const url = `${base}/models${provider.listQuery ? `?${provider.listQuery}` : ''}`;
49
- const r = await run('curl', ['-s', '-m', '25', '-w', '\n%{http_code}', '-K', '-', ...(proxy ? ['-x', proxy] : []), url], {
54
+ /** 带 key 的一次请求(curl:认代理,key 经 stdin 传、不进进程参数) */
55
+ async function keyRequest(provider, key, url, proxy) {
56
+ const viaProxy = proxy && !provider.direct;
57
+ const r = await run('curl', ['-s', '-m', '25', '-w', '\n%{http_code}', '-K', '-', ...(viaProxy ? ['-x', proxy] : provider.direct ? ['--noproxy', '*'] : []), url], {
50
58
  input: `header = "Authorization: Bearer ${key.replace(/"/g, '')}"\n`,
51
59
  timeoutMs: 40_000,
52
60
  });
53
61
  const lines = r.stdout.trimEnd().split('\n');
54
- const status = Number(lines.pop()) || null;
55
- const text = lines.join('\n');
62
+ return { status: Number(lines.pop()) || null, text: lines.join('\n') };
63
+ }
64
+ /** 不支持列模型的平台:发一次 1 个 token 的对话验证 key,返回内置的常用模型 */
65
+ async function verifyByChat(provider, key, base, proxy) {
66
+ const models = provider.fallbackModels ?? [];
67
+ const probe = models[0];
68
+ // curl 的 --data-binary @- 和 -K - 都读 stdin,这里把请求体写进临时文件
69
+ const { writeFile, rm, mkdtemp } = await import('node:fs/promises');
70
+ const { tmpdir } = await import('node:os');
71
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-model-key-'));
72
+ const file = join(dir, 'body.json');
73
+ await writeFile(file, JSON.stringify({ model: probe, messages: [{ role: 'user', content: 'ok' }], max_tokens: 1, stream: false }), { mode: 0o600 });
74
+ try {
75
+ const viaProxy = proxy && !provider.direct;
76
+ const r = await run('curl', ['-s', '-m', '40', '-w', '\n%{http_code}', '-K', '-', ...(viaProxy ? ['-x', proxy] : provider.direct ? ['--noproxy', '*'] : []), '-X', 'POST', '--data-binary', `@${file}`, `${base}/chat/completions`], {
77
+ input: `header = "Authorization: Bearer ${key.replace(/"/g, '')}"\nheader = "Content-Type: application/json"\n`,
78
+ timeoutMs: 60_000,
79
+ });
80
+ const lines = r.stdout.trimEnd().split('\n');
81
+ const status = Number(lines.pop()) || null;
82
+ const text = lines.join('\n');
83
+ if (status === 401 || status === 403 || /invalid.*(api.?key|token)|apikey not found|authenticat|鉴权|令牌/i.test(status === 200 ? '' : text)) {
84
+ throw new DshModelError('key_rejected', L(`${provider.label} 不认这把 key(HTTP ${status ?? '-'})`, `${provider.label} rejected the key (HTTP ${status ?? '-'})`), L(`到 ${provider.keyUrl} 重新创建一把`, `Create a new one at ${provider.keyUrl}`));
85
+ }
86
+ // 200 = 能用;429 = key 对、只是限流;其余(模型没开通等)key 也是对的,照常保存
87
+ if (!status)
88
+ throw new DshModelError('key_check_failed', L(`连不上 ${base}`, `Cannot reach ${base}`));
89
+ }
90
+ finally {
91
+ await rm(dir, { recursive: true, force: true });
92
+ }
93
+ return parseModelList(provider.id, { data: models.map((id) => ({ id })) });
94
+ }
95
+ export async function fetchKeyModels(provider, key, base, proxy) {
96
+ const url = `${base}/models${provider.listQuery ? `?${provider.listQuery}` : ''}`;
97
+ const { status, text } = await keyRequest(provider, key, url, proxy);
98
+ // 有内置列表的平台:列模型不成功(不支持,或这个接口要额外权限)就改用一次短对话验证 key,由它来判断 key 对不对
99
+ if (provider.fallbackModels?.length && status !== 200)
100
+ return verifyByChat(provider, key, base, proxy);
56
101
  if (status === 401 || status === 403)
57
102
  throw new DshModelError('key_rejected', L(`${provider.label} 不认这把 key(HTTP ${status})`, `${provider.label} rejected the key (HTTP ${status})`), provider.keyUrl ? L(`到 ${provider.keyUrl} 重新创建一把`, `Create a new one at ${provider.keyUrl}`) : undefined);
58
103
  if (status !== 200) {
@@ -63,8 +108,12 @@ export async function fetchKeyModels(provider, key, base, proxy) {
63
108
  models = parseModelList(provider.id, JSON.parse(text));
64
109
  }
65
110
  catch {
111
+ if (provider.fallbackModels?.length)
112
+ return verifyByChat(provider, key, base, proxy);
66
113
  throw new DshModelError('key_check_failed', L(`${provider.label} 的模型列表看不懂`, `Could not read the ${provider.label} model list`));
67
114
  }
115
+ if (!models.length && provider.fallbackModels?.length)
116
+ return verifyByChat(provider, key, base, proxy);
68
117
  if (!models.length)
69
118
  throw new DshModelError('key_no_models', L(`${provider.label} 没有返回任何模型`, `${provider.label} returned no models`));
70
119
  return models;
package/dist/sources.js CHANGED
@@ -20,6 +20,13 @@ export const SOURCES = [
20
20
  { id: 'groq', label: 'Groq', kind: 'apikey', login: 'key', subscribeUrl: 'https://console.groq.com/keys', pricing: { tier: 'limited', zh: '免费 key,每个模型每分钟 / 每天有上限', en: 'Free key with per-model per-minute / per-day limits' } },
21
21
  { id: 'openrouter', label: 'OpenRouter', kind: 'apikey', login: 'key', subscribeUrl: 'https://openrouter.ai/keys', pricing: { tier: 'limited', zh: '名字带 :free 的模型免费(每天约 50 次,充值 10 美元后约 1000 次);其余按量付费', en: ':free models are free (about 50/day, about 1,000/day after a $10 top-up); others pay as you go' } },
22
22
  { id: 'siliconflow', label: '硅基流动 SiliconFlow', kind: 'apikey', login: 'key', subscribeUrl: 'https://cloud.siliconflow.cn/account/ak', pricing: { tier: 'limited', zh: '实名认证后部分小模型免费(限速);其余按量付费', en: 'Some small models are free after identity verification (rate limited); others pay as you go' } },
23
+ // 国内大厂官方 API(2026-10-29 加):免费档按各家公开说明,以官网为准
24
+ { id: 'zhipu', label: '智谱 GLM', kind: 'apikey', login: 'key', subscribeUrl: 'https://open.bigmodel.cn/usercenter/apikeys', pricing: { tier: 'limited', zh: 'Flash 系列(如 GLM-4.7-Flash)免费;其余按量付费。国内直连', en: 'The Flash models (e.g. GLM-4.7-Flash) are free; others pay as you go. Reachable from China' } },
25
+ { id: 'spark', label: '讯飞星火', kind: 'apikey', login: 'key', subscribeUrl: 'https://console.xfyun.cn/services/cbm', pricing: { tier: 'limited', zh: 'Spark Lite 免费不限量,但每秒只能 2 次;key 填控制台里的 APIPassword。国内直连', en: 'Spark Lite is free with no token cap but only 2 requests per second; use the APIPassword from the console. Reachable from China' } },
26
+ { id: 'qianfan', label: '百度千帆', kind: 'apikey', login: 'key', subscribeUrl: 'https://console.bce.baidu.com/iam/#/iam/apikey/list', pricing: { tier: 'limited', zh: 'ERNIE Speed / Lite 长期免费(限速);其余按量付费。国内直连', en: 'ERNIE Speed / Lite are free for now (rate limited); others pay as you go. Reachable from China' } },
27
+ { id: 'ark', label: '火山方舟(豆包大模型)', kind: 'apikey', login: 'key', subscribeUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey', pricing: { tier: 'paid', zh: '按量付费,新用户有限时免费额度;模型要先在控制台开通。国内直连', en: 'Pay as you go with a time-limited free quota for new users; enable models in the console first. Reachable from China' } },
28
+ { id: 'minimax', label: 'MiniMax 开放平台', kind: 'apikey', login: 'key', subscribeUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', pricing: { tier: 'paid', zh: '按量付费或 Coding Plan 套餐。国内直连', en: 'Pay as you go or a Coding Plan. Reachable from China' } },
29
+ { id: 'modelarts', label: '华为云 ModelArts', kind: 'apikey', login: 'key', subscribeUrl: 'https://console.huaweicloud.com/modelarts/?region=cn-southwest-2#/model-studio/authmanage', pricing: { tier: 'paid', zh: '按量付费,新用户有免费额度;模型要先在 ModelArts Studio 里开通。国内直连', en: 'Pay as you go with a free quota for new users; enable models in ModelArts Studio first. Reachable from China' } },
23
30
  { id: 'deepseek', label: 'DeepSeek 官方', kind: 'apikey', login: 'key', subscribeUrl: 'https://platform.deepseek.com/api_keys', pricing: { tier: 'paid', zh: '按量付费(价格低)', en: 'Pay as you go (low prices)' } },
24
31
  { id: 'custom', label: '其他 OpenAI 兼容服务', kind: 'apikey', login: 'key', pricing: { tier: 'paid', zh: '填地址和 key,接入任意 OpenAI 兼容服务', en: 'Enter a URL and a key for any OpenAI-compatible service' } },
25
32
  { id: 'opencode', label: 'OpenCode Zen', kind: 'opencode', login: 'key', subscribeUrl: 'https://opencode.ai/zen', pricing: { tier: 'paid', zh: '要先充值拿 key 按量计费;它的免费模型只能在 OpenCode 软件里用', en: 'Top up for a pay-as-you-go key; its free models only work inside the OpenCode app' } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Daily check-in credits claimed for you (Trae, Qoder, WorkBuddy China), one OpenAI-compatible address that relays every model, and free or subscription model sources (SiliconFlow, Gemini, Codex, Kimi, Ollama…). For DeepSeek Harness (dsh) and any OpenAI-compatible tool. 每天自动签到领积分 · 一个地址中转所有模型 · 免费和订阅模型随你接。",
5
5
  "keywords": [
6
6
  "siliconflow",
package/plugin/client.js CHANGED
@@ -1011,20 +1011,49 @@ window.__ModuleLoader__.load({
1011
1011
  msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1012
1012
  }
1013
1013
 
1014
- /** 添加豆包签到:从浏览器复制一次登录 Cookie(豆包没有令牌,也不读豆包客户端的文件) */
1015
- function DoubaoDialog({ onDone, onClose, another }) {
1014
+ /** 从本机读豆包登录的结果说成一句话 */
1015
+ const scanText = (r) => [
1016
+ r.added.length ? L(`已添加:${r.added.join('、')}`, `Added: ${r.added.join(', ')}`) : '',
1017
+ ...r.skipped.map((x) => `${x.label}:${x.reason}`),
1018
+ ].filter(Boolean).join(';')
1019
+
1020
+ /** 添加豆包签到:优先从本机已登录的豆包电脑版 / 浏览器自动读;读不了再手动粘贴 Cookie */
1021
+ function DoubaoDialog({ onDone, onClose, another, found, localRead }) {
1016
1022
  const [cookie, setCookie] = useState('')
1017
1023
  const [name, setName] = useState('')
1018
1024
  const [busy, setBusy] = useState(false)
1019
1025
  const [msg, setMsg] = useState('')
1026
+ const [manual, setManual] = useState(false)
1027
+ const scan = async () => {
1028
+ setBusy(true)
1029
+ setMsg(localRead === 'mac' ? L('正在读取…macOS 会弹窗问是否允许读取钥匙串,请点「允许」(点「始终允许」以后就不再问)', 'Reading… macOS will ask to allow keychain access: click Allow (Always Allow skips the question next time)') : L('正在读取…', 'Reading…'))
1030
+ try {
1031
+ const r = await api('POST', '/checkin/doubao/scan')
1032
+ if (r.added.length) { onDone(r.status, scanText(r)); return }
1033
+ setMsg(scanText(r) || L('没读到能用的豆包登录', 'No usable Doubao sign-in found'))
1034
+ setManual(true)
1035
+ } catch (e) { setMsg(e.message); setManual(true) }
1036
+ setBusy(false)
1037
+ }
1020
1038
  const save = async () => {
1021
1039
  setBusy(true)
1022
1040
  setMsg(L('正在用这份 Cookie 查签到任务并签到…', 'Checking this cookie and checking in…'))
1023
1041
  try { await api('POST', '/checkin/doubao', { cookie: cookie.trim(), ...(name.trim() ? { name: name.trim() } : {}) }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
1024
1042
  }
1025
1043
  const step = (n, text) => h('div', { style: { ...S.meta, lineHeight: 1.7 } }, `${n}. `, text)
1044
+ const canScan = Boolean(localRead)
1026
1045
  return h(Modal, { title: another ? L('再加一个豆包账号', 'Add another Doubao account') : L('添加豆包签到(实验)', 'Add Doubao check-in (experimental)'), onClose },
1027
- h('p', { style: S.note }, L('豆包「天天领额度」每天送体验额度,只有个人账号能领(飞书企业账号不行)。豆包没有令牌,要从浏览器复制一次登录 Cookie:', 'Doubao gives daily credits to personal accounts (not Feishu enterprise accounts). Doubao has no token, so copy its sign-in cookie from the browser once:')),
1046
+ h('p', { style: S.note }, L('豆包「天天领额度」每天送体验额度,只有个人账号能领(飞书企业账号不行)。', 'Doubao gives daily credits to personal accounts (not Feishu enterprise accounts).')),
1047
+ canScan ? h('div', { style: { ...S.card, padding: '10px 12px', marginBottom: 10 } },
1048
+ h('div', { style: { ...S.meta, marginBottom: 6 } }, found && found.length
1049
+ ? L(`这台电脑上 ${found.join('、')} 登录着豆包,直接读它的登录就行:`, `Doubao is signed in here (${found.join(', ')}); just read that sign-in:`)
1050
+ : L('在豆包电脑版、Chrome、Edge 或 Brave 里登录过 doubao.com 的话,直接读它的登录:', 'If you are signed in to doubao.com in the Doubao app, Chrome, Edge or Brave, read that sign-in:')),
1051
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: () => void scan() }, busy ? L('读取中…', 'Reading…') : L('从本机自动读取', 'Read from this computer')),
1052
+ h('div', { style: { ...S.meta, marginTop: 6, color: C.faint } }, L('只读 doubao.com 的登录,保存在本机;豆包里退出登录或过期后,再点一次就会换成新的。', 'Only the doubao.com sign-in is read, and it stays on this machine; after it expires, click again to replace it.'))) : null,
1053
+ canScan && !manual ? h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px', fontSize: 12 }, onClick: () => setManual(true) }, L('▸ 手动粘贴 Cookie', '▸ Paste the cookie by hand')) : null,
1054
+ msg && !manual ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null,
1055
+ !canScan || manual ? h('div', null,
1056
+ h('p', { style: S.note }, L('手动:从浏览器复制一次登录 Cookie:', 'By hand: copy the sign-in cookie from the browser once:')),
1028
1057
  step(1, h('span', null, L('用电脑浏览器打开 ', 'Open '), h('a', { href: 'https://www.doubao.com/', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent } }, 'doubao.com ↗'), L(' 并登录', ' in a desktop browser and sign in'))),
1029
1058
  step(2, L('按 F12 打开开发者工具,切到「网络 / Network」,刷新一下页面', 'Press F12, open the Network tab, and reload the page')),
1030
1059
  step(3, L('点列表里任意一条 doubao.com 的请求,在「请求标头 / Request Headers」里找到 Cookie 那一行,右键复制它的值', 'Click any doubao.com request, find Cookie under Request Headers, and copy its value')),
@@ -1034,6 +1063,49 @@ window.__ModuleLoader__.load({
1034
1063
  h('div', { style: S.line },
1035
1064
  h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('粘贴 Cookie', 'Paste the cookie'), value: cookie, onChange: (e) => setCookie(e.target.value), 'aria-label': 'Cookie' }),
1036
1065
  h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !cookie.trim(), onClick: () => void save() }, L('保存', 'Save'))),
1066
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null) : null)
1067
+ }
1068
+
1069
+ /** 把签到账号复制到另一台机器:导出一段加密文本 + 提取码,到那台的页面里导入 */
1070
+ function TransferDialog({ mode, onDone, onClose }) {
1071
+ const [out, setOut] = useState(null)
1072
+ const [text, setText] = useState('')
1073
+ const [code, setCode] = useState('')
1074
+ const [busy, setBusy] = useState(false)
1075
+ const [msg, setMsg] = useState('')
1076
+ useEffect(() => {
1077
+ if (mode !== 'export') return
1078
+ api('POST', '/checkin/export').then(setOut, (e) => setMsg(e.message))
1079
+ }, [mode])
1080
+ const doImport = async () => {
1081
+ setBusy(true)
1082
+ setMsg(L('正在导入并签到…', 'Importing and checking in…'))
1083
+ try {
1084
+ const r = await api('POST', '/checkin/import', { text: text.trim(), code: code.trim() })
1085
+ onDone(r)
1086
+ } catch (e) { setMsg(e.message); setBusy(false) }
1087
+ }
1088
+ if (mode === 'export') {
1089
+ return h(Modal, { title: L('复制签到账号到另一台机器', 'Copy check-in accounts to another machine'), onClose },
1090
+ h('p', { style: S.note }, L('比如搬到不关机的 VPS 上签,就不会漏。会复制 Qoder 令牌和豆包 Cookie;WorkBuddy 请在那台机器上各自登录(复制过去两边会互相顶掉),Trae 要在装了 Trae 的电脑上签。', 'For example move them to an always-on VPS so no day is missed. Qoder tokens and Doubao cookies are copied; sign in to WorkBuddy on that machine itself (a copied sign-in would knock the other out), and Trae needs a computer with Trae.')),
1091
+ out ? h('div', null,
1092
+ h('div', { style: { ...S.meta, marginBottom: 4 } }, L(`1. 复制这段文本(${out.counts.qoder} 个 Qoder、${out.counts.doubao} 个豆包):`, `1. Copy this text (${out.counts.qoder} Qoder, ${out.counts.doubao} Doubao):`)),
1093
+ h('div', { style: S.line },
1094
+ h('textarea', { readOnly: true, value: out.text, rows: 3, style: { ...S.input, ...S.mono, ...S.grow, resize: 'none' }, 'aria-label': L('导出的文本', 'Exported text') }),
1095
+ h(CopyButton, { get: out.text })),
1096
+ h('div', { style: { ...S.meta, margin: '10px 0 4px' } }, L('2. 记下提取码(光有文本解不开):', '2. Note the code (the text alone cannot be opened):')),
1097
+ h('div', { style: S.line }, h('span', { style: { ...S.mono, fontSize: 18, letterSpacing: 2 } }, out.code), h(CopyButton, { get: out.code })),
1098
+ h('div', { style: { ...S.meta, marginTop: 10 } }, L('3. 打开那台机器的「设置 → dsh-model」,签到卡片「详情」→「导入签到账号」,把两样都粘进去。', '3. On the other machine open Settings → dsh-model, check-in card Details → "Import check-in accounts", and paste both.')),
1099
+ h('div', { style: { fontSize: 12, color: C.warn, marginTop: 8 } }, L('这段文本加提取码就等于你的登录,只在自己的两台机器之间用。', 'Text plus code amount to your sign-ins: use them only between your own machines.'))) : null,
1100
+ msg ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 8 } }, msg) : null)
1101
+ }
1102
+ return h(Modal, { title: L('导入签到账号', 'Import check-in accounts'), onClose },
1103
+ h('p', { style: S.note }, L('粘贴另一台机器「复制签到账号」给出的文本和提取码。已有的同一个账号不会重复加。', 'Paste the text and code from "Copy check-in accounts" on the other machine. Accounts already here are not added twice.')),
1104
+ h('textarea', { rows: 3, placeholder: 'dshm-checkin:v1:…', value: text, onChange: (e) => setText(e.target.value), style: { ...S.input, ...S.mono, width: '100%', boxSizing: 'border-box', resize: 'none', marginBottom: 8 }, 'aria-label': L('导出的文本', 'Exported text') }),
1105
+ h('div', { style: S.line },
1106
+ h('input', { style: { ...S.input, ...S.mono, width: 160 }, placeholder: L('提取码', 'Code'), value: code, onChange: (e) => setCode(e.target.value), 'aria-label': L('提取码', 'Code') }),
1107
+ h('span', { style: S.grow }),
1108
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !text.trim() || !code.trim(), onClick: () => void doImport() }, L('导入', 'Import'))),
1037
1109
  msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1038
1110
  }
1039
1111
 
@@ -1045,6 +1117,7 @@ window.__ModuleLoader__.load({
1045
1117
  const [qoder, setQoder] = useState(false)
1046
1118
  const [wbLogin, setWbLogin] = useState(null)
1047
1119
  const [doubao, setDoubao] = useState(false)
1120
+ const [transfer, setTransfer] = useState('')
1048
1121
  const [err, setErr] = useState('')
1049
1122
  const refresh = useCallback(() => api('GET', '/checkin').then(setSt, () => {}), [])
1050
1123
  const addWorkbuddy = async () => {
@@ -1073,9 +1146,11 @@ window.__ModuleLoader__.load({
1073
1146
  return h('div', { style: { ...S.section, marginBottom: 16 } },
1074
1147
  h('div', { style: { ...S.meta } }, L('每日签到:登录 WorkBuddy 国内版,或', 'Daily check-in: sign in to WorkBuddy (China), or '),
1075
1148
  h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder')),
1076
- h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setDoubao(true) }, L('添加豆包', 'Add Doubao'))),
1149
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setDoubao(true) }, L('添加豆包', 'Add Doubao')),
1150
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setTransfer('import') }, L('从另一台机器导入', 'Import from another machine'))),
1151
+ transfer ? h(TransferDialog, { mode: transfer, onDone: (r) => { setTransfer(''); setSt(r); setOpen(true) }, onClose: () => setTransfer('') }) : null,
1077
1152
  qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1078
- doubao ? h(DoubaoDialog, { onDone: () => { setDoubao(false); void refresh() }, onClose: () => setDoubao(false) }) : null)
1153
+ doubao ? h(DoubaoDialog, { localRead: st.localRead, found: ((st.missing || []).find((m) => m.id === 'doubao') || {}).found, onDone: (next) => { setDoubao(false); if (next) setSt(next); else void refresh() }, onClose: () => setDoubao(false) }) : null)
1079
1154
  }
1080
1155
  return h('div', { style: S.section },
1081
1156
  h('div', { style: { ...S.card, padding: '10px 14px' } },
@@ -1089,6 +1164,12 @@ window.__ModuleLoader__.load({
1089
1164
  h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')),
1090
1165
  h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
1091
1166
  h('input', { type: 'checkbox', checked: st.auto, onChange: (e) => void toggleAuto(e.target.checked) }), L('自动签到', 'Auto'))),
1167
+ // 支持、但这台还没设置的:一眼看出还能签哪些,旁边就能加
1168
+ (st.missing || []).length ? h('div', { style: { ...S.line, flexWrap: 'wrap', gap: 6, marginTop: 4, fontSize: 12, color: C.sub } },
1169
+ h('span', null, L('这台还能签:', 'Also available here:')),
1170
+ (st.missing || []).map((m) => h('span', { key: m.id, style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
1171
+ m.found && m.found.length ? L(`${m.label}(本机 ${m.found.join('、')} 已登录)`, `${m.label} (signed in: ${m.found.join(', ')})`) : m.label,
1172
+ m.action ? h('button', { type: 'button', style: { ...S.btn, padding: '1px 6px', fontSize: 11 }, onClick: () => { if (m.action === 'workbuddy') void addWorkbuddy(); else if (m.action === 'qoder') setQoder(true); else setDoubao(true) } }, L('+ 添加', '+ Add')) : h('span', { style: { color: C.faint } }, `(${m.reason})`)))) : null,
1092
1173
  open ? h('div', { style: { marginTop: 8 } },
1093
1174
  st.results.map((r) => h('div', { key: r.id, style: { fontSize: 12, lineHeight: 1.8, color: r.state === 'failed' ? C.err : C.text } },
1094
1175
  `${r.label}:${CHECKIN_TEXT(r)}`, r.advice ? h('span', { style: { color: C.sub, marginLeft: 6 } }, r.advice) : null)),
@@ -1102,13 +1183,17 @@ window.__ModuleLoader__.load({
1102
1183
  h('div', { style: { ...S.line, marginTop: 4, flexWrap: 'wrap' } },
1103
1184
  h('button', { type: 'button', style: S.btn, onClick: () => void addWorkbuddy() }, L('+ 添加 WorkBuddy 账号', '+ Add a WorkBuddy account')),
1104
1185
  h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, st.qoder.configured ? L('+ 再加一个 Qoder 账号', '+ Add another Qoder account') : L('+ 添加 Qoder', '+ Add Qoder')),
1105
- h('button', { type: 'button', style: S.btn, onClick: () => setDoubao(true) }, (st.accounts && st.accounts.doubao && st.accounts.doubao.length) ? L('+ 再加一个豆包账号', '+ Add another Doubao account') : L('+ 添加豆包', '+ Add Doubao')))),
1186
+ h('button', { type: 'button', style: S.btn, onClick: () => setDoubao(true) }, (st.accounts && st.accounts.doubao && st.accounts.doubao.length) ? L('+ 再加一个豆包账号', '+ Add another Doubao account') : L('+ 添加豆包', '+ Add Doubao'))),
1187
+ h('div', { style: { ...S.line, marginTop: 4, flexWrap: 'wrap' } },
1188
+ h('button', { type: 'button', style: S.btn, onClick: () => setTransfer('export') }, L('复制签到账号到另一台机器', 'Copy check-in accounts to another machine')),
1189
+ h('button', { type: 'button', style: S.btn, onClick: () => setTransfer('import') }, L('导入签到账号', 'Import check-in accounts')))),
1106
1190
  // Trae 读本机 Trae 的登录:这台没装(比如 VPS)就不会出现,在装了 Trae 的电脑上签就行(每个账号一天一次)
1107
1191
  !st.available.some((a) => a.id === 'trae') ? h('div', { style: { ...S.meta, marginTop: 6 } }, L('Trae:这台电脑上没装 Trae,所以不签。在装了 Trae 的电脑上会自动签(一个账号一天签一次就够)。', 'Trae: not installed on this machine, so it is skipped. A computer with Trae installed checks it in automatically (once a day per account is enough).')) : null) : null,
1108
1192
  !open && !st.qoder.configured ? h('div', { style: { ...S.meta, marginTop: 4 } }, h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px' }, onClick: () => setQoder(true) }, L('+ 添加 Qoder', '+ Add Qoder'))) : null,
1109
1193
  err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null),
1110
1194
  qoder ? h(QoderDialog, { another: st.qoder.configured, onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1111
- doubao ? h(DoubaoDialog, { another: Boolean(st.accounts && st.accounts.doubao && st.accounts.doubao.length), onDone: () => { setDoubao(false); setOpen(true); void refresh() }, onClose: () => setDoubao(false) }) : null,
1195
+ transfer ? h(TransferDialog, { mode: transfer, onDone: (r) => { setTransfer(''); setSt(r); setOpen(true) }, onClose: () => setTransfer('') }) : null,
1196
+ doubao ? h(DoubaoDialog, { another: Boolean(st.accounts && st.accounts.doubao && st.accounts.doubao.length), localRead: st.localRead, found: ((st.missing || []).find((m) => m.id === 'doubao') || {}).found, onDone: (next, note) => { setDoubao(false); setOpen(true); if (next) setSt(next); else void refresh(); if (note) setErr(note) }, onClose: () => setDoubao(false) }) : null,
1112
1197
  wbLogin ? h(LoginDialog, { source: { id: 'workbuddy-checkin', label: L('WorkBuddy 签到账号', 'WorkBuddy check-in account'), login: 'link' }, session: wbLogin, onDone: () => { setWbLogin(null); setOpen(true); setTimeout(() => void refresh(), 3000) }, onClose: () => { setWbLogin(null); void refresh() } }) : null)
1113
1198
  }
1114
1199
 
package/plugin/index.js CHANGED
@@ -63,7 +63,7 @@ const ALLOWED = [
63
63
  ['GET', /^\/self$/],
64
64
  ['GET', /^\/failures$/],
65
65
  ['GET', /^\/checkin$/],
66
- ['POST', /^\/checkin(\/auto|\/qoder|\/workbuddy|\/doubao)?$/],
66
+ ['POST', /^\/checkin(\/auto|\/qoder|\/workbuddy|\/doubao|\/doubao\/scan|\/export|\/import)?$/],
67
67
  ['DELETE', /^\/checkin\/qoder(\/[A-Za-z0-9_-]{1,40})?$/],
68
68
  ['DELETE', /^\/checkin\/workbuddy\/[A-Za-z0-9_-]{1,80}$/],
69
69
  ['DELETE', /^\/checkin\/doubao\/[A-Za-z0-9_-]{1,40}$/],