dsh-model 0.23.4 → 0.26.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-model
4
4
 
5
- **📅 Daily check-in credits claimed for you · 🔁 one address that relays every model · 🧩 free and subscription models, your pick**
5
+ **📅 Daily check-in credits claimed for you (Trae, Qoder, WorkBuddy, Doubao) · 🔁 one address that relays every model · 🧩 free and subscription models, your pick**
6
6
 
7
7
  Install once and all three are taken care of. dsh gets a management page; without dsh, the command line does everything.
8
8
 
@@ -27,10 +27,14 @@ English · [中文](README.md)
27
27
  |---|---|---|
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
- | **WorkBuddy (China)** | Sign in once | Check-in credits |
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
32
 
32
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.
33
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).
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.
34
38
  - **Check-in only, credits untouched**: they stay on your account for use in each product's own app. Command line: `dsh-model checkin`.
35
39
 
36
40
  ## 🔁 2. Relay: one address, one key, every model
@@ -49,7 +53,7 @@ Your model sources each have their own API and sign-in. dsh-model puts them behi
49
53
  | Kind | Sources |
50
54
  |---|---|
51
55
  | **Free (suggested)** | **SiliconFlow** (reachable from China; some models free after identity verification), **Gemini** (if you can reach Google; the best free models) |
52
- | 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) |
53
57
  | Local | Ollama, LM Studio: appear once started, no sign-in |
54
58
  | Subscription | Codex (ChatGPT Plus / Pro), Kimi Code, Grok, Muse, Devin: sign in from the management page |
55
59
  | Pay as you go | DeepSeek official, OpenCode Zen, any OpenAI-compatible service |
@@ -110,6 +114,10 @@ If a source is signed in but has no usable plan (say, a Kimi account without Kim
110
114
  | Gemini (Google AI Studio), Groq | Free · limited | Paste a free API key; daily / per-minute limits apply |
111
115
  | OpenRouter | Free · limited | `:free` models are free (about 50 requests a day) |
112
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 |
113
121
  | DeepSeek official, any OpenAI-compatible service | Paid | Paste an API key (and a URL for other services) |
114
122
  | Codex | Paid | ChatGPT Plus / Pro |
115
123
  | Kimi | Paid | Kimi Code plan |
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-model
4
4
 
5
- **📅 每天自动签到领积分 · 🔁 一个地址中转所有模型 · 🧩 免费和订阅模型随你接**
5
+ **📅 每天自动签到领积分(Trae、Qoder、WorkBuddy、豆包)· 🔁 一个地址中转所有模型 · 🧩 免费和订阅模型随你接**
6
6
 
7
7
  装一次,三件事都替你做好。dsh 里有图形管理页,没有 dsh 也能用命令行。
8
8
 
@@ -27,10 +27,14 @@
27
27
  |---|---|---|
28
28
  | **Trae** | 本机装了 Trae 并登录过,自动识别,什么都不用做 | 签到送积分(实测每天 200) |
29
29
  | **Qoder** | 粘贴一次个人访问令牌(国内版、国际版都行) | 领当天的签到积分 |
30
- | **WorkBuddy 国内版** | 登录一次 | 签到送积分 |
30
+ | **WorkBuddy 国内版** | 登录一次(可以加几个账号) | 签到送积分 |
31
+ | **豆包**(实验) | 从浏览器复制一次登录 Cookie(只支持个人账号) | 「天天领额度」送体验额度 |
31
32
 
32
33
  - **自动签到默认打开**:北京时间零点后随机延后 5–55 分钟签好;失败的一小时后重试;电脑关机错过了,开机一分钟内补签。
33
34
  - **结果看得见**:设置页顶部一行「今日已完成签到:…」,没签上的可以「一键签到」;签完在 dsh 右下角提示一次。
35
+ - **这台还能签哪些**:卡片里列出支持、但这台还没设置的,旁边就是「+ 添加」。
36
+ - **放到 VPS 上签,不会漏**:「复制签到账号到另一台机器」导出一段加密文本和提取码,到 VPS 的页面里「导入」即可(Qoder、豆包;WorkBuddy 在 VPS 上各自登录,Trae 留在装了 Trae 的电脑上签)。
37
+ - **多账号**:一家可以签几个账号。在签到卡片的「详情」里「+ 添加 WorkBuddy 账号」(打开链接授权)、「+ 再加一个 Qoder 账号」;本机装了几个 Trae 会自动按账号各签一次。
34
38
  - **只签到,不碰积分**:积分留在你的账号里,照常在各家官方软件里用。命令行:`dsh-model checkin`。
35
39
 
36
40
  ## 🔁 2. 中转:一个地址、一把 key,用上所有模型
@@ -49,7 +53,7 @@
49
53
  | 类型 | 来源 |
50
54
  |---|---|
51
55
  | **免费(推荐)** | **硅基流动**(国内直连,实名后部分模型免费)、**Gemini**(能上外网,免费模型里质量最好) |
52
- | 免费 · 额度少 | Groq、OpenRouter(`:free` 模型) |
56
+ | 免费 · 额度少 | **智谱 GLM**(Flash 系列免费)、**讯飞星火**(Spark Lite 免费)、百度千帆(ERNIE Speed / Lite)、Groq、OpenRouter(`:free` 模型) |
53
57
  | 本机 | Ollama、LM Studio:启动后自动出现,不用登录 |
54
58
  | 订阅 | Codex(ChatGPT Plus / Pro)、Kimi Code、Grok、Muse、Devin:在管理页里登录即可 |
55
59
  | 按量付费 | DeepSeek 官方、OpenCode Zen、任意 OpenAI 兼容服务 |
@@ -110,6 +114,10 @@ dsh-model setup
110
114
  | Gemini(Google AI Studio)、Groq | 免费·额度少 | 填免费 API key 即可,每天 / 每分钟有上限 |
111
115
  | OpenRouter | 免费·额度少 | 名字带 `:free` 的模型免费(每天约 50 次) |
112
116
  | 硅基流动 | 免费·额度少 | 实名认证后部分小模型免费 |
117
+ | 智谱 GLM | 免费·额度少 | Flash 系列(如 GLM-4.7-Flash)免费,国内直连 |
118
+ | 讯飞星火 | 免费·额度少 | Spark Lite 免费不限量,每秒 2 次;key 填控制台的 APIPassword |
119
+ | 百度千帆 | 免费·额度少 | ERNIE Speed / Lite 长期免费(限速) |
120
+ | 火山方舟(豆包大模型)、MiniMax 开放平台、华为云 ModelArts | 需付费 | 官方 API,填 key 即可;火山方舟、华为云有新用户免费额度 |
113
121
  | DeepSeek 官方、其他 OpenAI 兼容服务 | 需付费 | 填 API key(其他服务还要填地址) |
114
122
  | Codex | 需付费 | ChatGPT Plus / Pro |
115
123
  | Kimi | 需付费 | Kimi Code 套餐 |
@@ -7,7 +7,7 @@
7
7
  // GET {base}/v2/plugin/login/account?state=…(Bearer) → 账号(uid / 企业 / 昵称)
8
8
  // 令牌按移植凭据库的"自有副本"格式写进 $DSH_MODEL_HOME/workbuddy/<ownFilename>(0600),bridge 直接用。
9
9
  // 网络请求走 curl:它认 -x 代理(Node 的 fetch 不走代理);国内版域名强制直连。
10
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
  import { DshModelError } from '../errors.js';
12
12
  import { L } from '../i18n.js';
13
13
  import { run } from '../util/exec.js';
@@ -110,9 +110,9 @@ export async function workbuddyLogin(variant, home, opts) {
110
110
  ...(nickname ? { nickname } : {}),
111
111
  source: 'dsh',
112
112
  };
113
- const dir = bridgeDir(home);
114
- await ensureDir(dir, { owner: opts.owner });
115
- await atomicWrite(join(dir, variant.ownFilename), JSON.stringify({ version: 1, credential }, null, 2) + '\n', { mode: 0o600, owner: opts.owner });
113
+ const file = opts.file ?? join(bridgeDir(home), variant.ownFilename);
114
+ await ensureDir(dirname(file), { owner: opts.owner });
115
+ await atomicWrite(file, JSON.stringify({ version: 1, credential }, null, 2) + '\n', { mode: 0o600, owner: opts.owner });
116
116
  return credential;
117
117
  }
118
118
  /** 退出登录:删掉 dsh-model 保存的那份(App 的文件不动) */
@@ -0,0 +1,147 @@
1
+ // checkin/accounts.ts — 多账号签到(设计 §14.19)
2
+ //
3
+ // 每个签到项一个 id:第一个账号沿用 workbuddy / qoder / trae,多出来的是 <家>:<账号>(如 workbuddy:12345)。
4
+ // - WorkBuddy:主账号就是来源里登录的那个(bridge 的凭据);多出来的账号只用来签到,
5
+ // 各存一份 $DSH_MODEL_HOME/workbuddy/checkin/<uid>.json(与 dsh-model 自己登录的格式相同,0600),不接模型。
6
+ // - Qoder:secrets.json 里一组个人访问令牌,各带一个名字(Qoder 的接口不返回账号名)。
7
+ // - Trae:本机装的每个 Trae 都读,按账号(userId)去重,每个账号签一次。
8
+ // - 豆包:secrets.json 里一组 Cookie(用户从浏览器复制),各带一个名字。
9
+ import { readdir, rm } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { bridgeDir } from '../bridge/runtime.js';
12
+ import { WorkBuddyCredentialStore } from '../bridge/workbuddy/auth.js';
13
+ import { atRestKeyProviderFor } from '../bridge/workbuddy/desktop-credential-protection.js';
14
+ import { WorkBuddyUpstreamClient } from '../bridge/workbuddy/upstream.js';
15
+ import { CN_VARIANT } from '../bridge/workbuddy/variants.js';
16
+ import { loadSecrets, saveSecrets } from '../secrets.js';
17
+ import { readJson } from '../util/fs.js';
18
+ import { randomBytes } from 'node:crypto';
19
+ /** 一个签到项属于哪一家:workbuddy:123 → workbuddy */
20
+ export const checkinKind = (id) => id.split(':')[0];
21
+ // —— WorkBuddy 签到账号 ——
22
+ export const workbuddyAccountsDir = (home) => join(bridgeDir(home), 'checkin');
23
+ /** 多出来的 WorkBuddy 签到账号(不含来源里那个主账号) */
24
+ export async function listWorkbuddyAccounts(home) {
25
+ const dir = workbuddyAccountsDir(home);
26
+ let names = [];
27
+ try {
28
+ names = (await readdir(dir)).filter((n) => /^[A-Za-z0-9_-]+\.json$/.test(n));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const out = [];
34
+ for (const n of names.sort()) {
35
+ const doc = await readJson(join(dir, n));
36
+ const uid = doc?.credential?.uid || n.replace(/\.json$/, '');
37
+ out.push({ uid, ...(doc?.credential?.nickname ? { nickname: doc.credential.nickname } : {}), file: join(dir, n) });
38
+ }
39
+ return out;
40
+ }
41
+ /** 一个签到账号的凭据库:只用它自己的那份文件,不读桌面 App(否则会读到主账号) */
42
+ export function workbuddyAccountStore(home, file) {
43
+ const client = new WorkBuddyUpstreamClient();
44
+ const store = new WorkBuddyCredentialStore({
45
+ variant: CN_VARIANT,
46
+ keyProvider: atRestKeyProviderFor(CN_VARIANT),
47
+ refresh: (credential) => client.refreshToken(credential),
48
+ ownPath: file,
49
+ desktopPath: join(workbuddyAccountsDir(home), '.no-desktop-credential'),
50
+ });
51
+ return { store, client };
52
+ }
53
+ export async function removeWorkbuddyAccount(home, uid) {
54
+ const acct = (await listWorkbuddyAccounts(home)).find((a) => a.uid === uid);
55
+ if (!acct)
56
+ return false;
57
+ await rm(acct.file, { force: true });
58
+ return true;
59
+ }
60
+ /** 名字里显示账号:昵称,没有就 uid 后 4 位 */
61
+ export const accountTag = (nickname, uid) => nickname || `…${uid.slice(-4)}`;
62
+ /** 读全部 Qoder 令牌;旧版只存了一个(secrets.checkin.qoder),读出来当第一个 */
63
+ export async function loadQoders(ctx) {
64
+ const c = (await loadSecrets(ctx)).checkin;
65
+ if (c?.qoders?.length)
66
+ return c.qoders;
67
+ return c?.qoder?.pat ? [{ id: '1', pat: c.qoder.pat, region: c.qoder.region, ...(c.qoder.savedAt ? { savedAt: c.qoder.savedAt } : {}) }] : [];
68
+ }
69
+ export async function saveQoders(ctx, list) {
70
+ const secrets = await loadSecrets(ctx);
71
+ const checkin = { ...secrets.checkin };
72
+ delete checkin.qoder;
73
+ if (list.length)
74
+ checkin.qoders = list;
75
+ else
76
+ delete checkin.qoders;
77
+ secrets.checkin = Object.keys(checkin).length ? checkin : undefined;
78
+ await saveSecrets(ctx, secrets);
79
+ }
80
+ /** 加一个令牌:同一个令牌再加就是改名字 / 区域;返回它的 id */
81
+ export async function addQoder(ctx, entry) {
82
+ const list = await loadQoders(ctx);
83
+ const same = list.find((q) => q.pat === entry.pat);
84
+ const name = entry.name?.trim().slice(0, 20);
85
+ if (same) {
86
+ same.region = entry.region;
87
+ if (name)
88
+ same.name = name;
89
+ await saveQoders(ctx, list);
90
+ return same.id;
91
+ }
92
+ const used = new Set(list.map((q) => q.id));
93
+ let n = list.length + 1;
94
+ while (used.has(String(n)))
95
+ n++;
96
+ const id = String(n);
97
+ list.push({ id, pat: entry.pat, region: entry.region, ...(name ? { name } : {}), savedAt: new Date().toISOString() });
98
+ await saveQoders(ctx, list);
99
+ return id;
100
+ }
101
+ export async function removeQoder(ctx, id) {
102
+ const list = await loadQoders(ctx);
103
+ // 不给 id:删第一个(旧版页面的"删除 Qoder 令牌")
104
+ const target = id ?? list[0]?.id;
105
+ await saveQoders(ctx, list.filter((q) => q.id !== target));
106
+ }
107
+ export async function loadDoubaos(ctx) {
108
+ return (await loadSecrets(ctx)).checkin?.doubaos ?? [];
109
+ }
110
+ async function saveDoubaos(ctx, list) {
111
+ const secrets = await loadSecrets(ctx);
112
+ const checkin = { ...secrets.checkin };
113
+ if (list.length)
114
+ checkin.doubaos = list;
115
+ else
116
+ delete checkin.doubaos;
117
+ secrets.checkin = Object.keys(checkin).length ? checkin : undefined;
118
+ await saveSecrets(ctx, secrets);
119
+ }
120
+ /** 加一个豆包账号;同一个 Cookie 再加 = 改名字。返回 id */
121
+ export async function addDoubao(ctx, entry) {
122
+ const list = await loadDoubaos(ctx);
123
+ const name = entry.name?.trim().slice(0, 20);
124
+ const same = list.find((d) => d.cookie === entry.cookie);
125
+ if (same) {
126
+ if (name)
127
+ same.name = name;
128
+ await saveDoubaos(ctx, list);
129
+ return same.id;
130
+ }
131
+ const used = new Set(list.map((d) => d.id));
132
+ let n = list.length + 1;
133
+ while (used.has(String(n)))
134
+ n++;
135
+ const id = String(n);
136
+ list.push({ id, cookie: entry.cookie, seed: entry.seed ?? randomBytes(16).toString('hex'), ...(name ? { name } : {}), savedAt: new Date().toISOString() });
137
+ await saveDoubaos(ctx, list);
138
+ return id;
139
+ }
140
+ export async function removeDoubao(ctx, id) {
141
+ const list = await loadDoubaos(ctx);
142
+ const next = list.filter((d) => d.id !== id);
143
+ if (next.length === list.length)
144
+ return false;
145
+ await saveDoubaos(ctx, next);
146
+ return true;
147
+ }
@@ -0,0 +1,112 @@
1
+ // checkin/doubao.ts — 豆包每日签到(「天天领额度」,每天 +200 体验额度)
2
+ //
3
+ // 协议照 techysy/CreditDaddy 的 src/doubaoClient.js(MIT),对方 2026-10-19/20 抓包实测:
4
+ // POST https://www.doubao.com/alice/ug/campaign/v3/detail/ 任务面板(含今天签没签)
5
+ // POST https://www.doubao.com/alice/ug/campaign/landing/checkin 领取;服务端幂等,重复领返回 idempotent:true
6
+ // 鉴权只要 doubao.com 的登录 Cookie,没有 a_bogus / msToken 这类签名(dsh-model 也不伪造签名)。
7
+ // 请求带豆包电脑版网页的平台参数(版本号、web 平台等),不带的话接口回"请求非法"。
8
+ // 只支持个人账号:飞书企业账号的任务面板里没有签到任务。
9
+ // Cookie 由用户从浏览器复制一次(存 secrets.json,0600),dsh-model 不读豆包客户端的文件。
10
+ import { createHash } from 'node:crypto';
11
+ const DETAIL_URL = 'https://www.doubao.com/alice/ug/campaign/v3/detail/';
12
+ const CHECKIN_URL = 'https://www.doubao.com/alice/ug/campaign/landing/checkin';
13
+ /** 任务面板所属活动组;签到是其中 task_kind = daily_signin 的任务 */
14
+ const CAMPAIGN_GROUP = 'pc_incentive_task_mvp_v3';
15
+ /** 登录过期 / 未登录的业务码 */
16
+ const LOGIN_INVALID = 710012001;
17
+ const TIMEOUT_MS = 20_000;
18
+ const CLIENT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) DoubaoDesktop/2.32.6 Chrome/147.0.7727.149 Safari/537.36';
19
+ const COMMON_PARAMS = {
20
+ version_code: '20800',
21
+ language: 'zh',
22
+ device_platform: 'web',
23
+ doubao_device_platform: 'web',
24
+ aid: '497858',
25
+ real_aid: '497858',
26
+ pkg_type: 'release_version',
27
+ pc_version: '2.32.6',
28
+ doubao_pc_version: '5.72.66',
29
+ samantha_web: '1',
30
+ web_platform: 'desktop',
31
+ 'use-olympus-account': '1',
32
+ web_tab_id: '0',
33
+ };
34
+ export class DoubaoAuthError extends Error {
35
+ }
36
+ /** 每个账号一组稳定的网页标识(由这个账号自己的随机种子派生,账号之间不同) */
37
+ export function deviceIdsFor(seed) {
38
+ const h = createHash('sha256').update(`doubao-dev:${seed}`).digest();
39
+ return {
40
+ web_id: '76' + [...h.subarray(0, 8)].map((b) => b % 10).join(''),
41
+ tea_uuid: [...h.subarray(8, 16)].map((b) => b % 10).join('').padEnd(16, '7'),
42
+ };
43
+ }
44
+ function apiUrl(base, seed) {
45
+ return `${base}?${new URLSearchParams({ ...COMMON_PARAMS, ...deviceIdsFor(seed), device_id: '', region: '', sys_region: '' })}`;
46
+ }
47
+ /** 用户粘贴的可能是整行 "Cookie: a=b; c=d",也可能带换行:规整成 "a=b; c=d" */
48
+ export function normalizeCookie(raw) {
49
+ return raw.replace(/^\s*cookie\s*:\s*/i, '').replace(/[\r\n]+/g, ' ').replace(/\s*;\s*/g, '; ').replace(/;\s*$/, '').trim();
50
+ }
51
+ async function post(url, cookie, body) {
52
+ const res = await fetch(url, {
53
+ method: 'POST',
54
+ headers: {
55
+ Cookie: cookie,
56
+ 'Content-Type': 'application/json',
57
+ Accept: 'application/json, text/plain, */*',
58
+ Referer: 'https://www.doubao.com/pc/agent-explore',
59
+ Origin: 'https://www.doubao.com',
60
+ 'User-Agent': CLIENT_UA,
61
+ },
62
+ body: JSON.stringify(body),
63
+ signal: AbortSignal.timeout(TIMEOUT_MS),
64
+ });
65
+ const text = await res.text();
66
+ try {
67
+ const json = JSON.parse(text);
68
+ if (json && typeof json === 'object')
69
+ return json;
70
+ }
71
+ catch {
72
+ // 非 JSON
73
+ }
74
+ throw new Error(`豆包接口返回无法解析(HTTP ${res.status})`);
75
+ }
76
+ /** 签到状态(detail 接口)。Cookie 不对抛 DoubaoAuthError */
77
+ export async function doubaoStatus(cookie, seed) {
78
+ const body = await post(apiUrl(DETAIL_URL, seed), cookie, { campaign_group_code: CAMPAIGN_GROUP });
79
+ const code = Number(body.code);
80
+ if (code === LOGIN_INVALID)
81
+ throw new DoubaoAuthError('豆包登录已过期(Cookie 失效)');
82
+ if (code !== 0)
83
+ throw new Error(`豆包任务面板返回异常:${String(body.msg ?? body.message ?? '')}(code ${code})`);
84
+ const data = (body.data ?? {});
85
+ const task = Array.isArray(data.tasks) ? data.tasks.find((t) => t?.task_kind === 'daily_signin') : undefined;
86
+ const c = task?.daily_checkin ?? {};
87
+ return {
88
+ checkedIn: task ? c.checked_in_today === true : null,
89
+ continuousDays: Number(c.continuous_days) || 0,
90
+ rewardAmount: Number(c.reward?.reward_amount) || 0,
91
+ totalAmount: Number(data.progress?.total_amount) || 0,
92
+ };
93
+ }
94
+ /** 签到:先看今天签没签,没签才领 */
95
+ export async function doubaoCheckin(cookie, seed) {
96
+ const st = await doubaoStatus(cookie, seed);
97
+ if (st.checkedIn === null)
98
+ throw new Error('这个豆包账号没有签到任务:只有个人账号能领,飞书企业账号不行(也可能活动暂时下线)');
99
+ if (st.checkedIn)
100
+ return { state: 'already' };
101
+ const body = await post(apiUrl(CHECKIN_URL, seed), cookie, { scene: CAMPAIGN_GROUP });
102
+ const code = Number(body.code);
103
+ if (code === LOGIN_INVALID)
104
+ throw new DoubaoAuthError('豆包登录已过期(Cookie 失效)');
105
+ if (code !== 0)
106
+ throw new Error(`豆包签到失败:${String(body.msg ?? body.message ?? '')}(code ${code})`);
107
+ const d = (body.data ?? {});
108
+ if (d.idempotent === true)
109
+ return { state: 'already' };
110
+ const credit = Number(d.reward_amount) || st.rewardAmount || undefined;
111
+ return { state: 'signed', ...(credit ? { credit } : {}) };
112
+ }
@@ -1,4 +1,4 @@
1
- // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder、Trae;以后加 WPS 灵犀
1
+ // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder、Trae、豆包;每家可以有几个账号(§14.19,checkin/accounts.ts)
2
2
  //
3
3
  // 「今天」按北京时间算:这几家都在北京时间零点换天(VPS 的系统时间是 UTC)。
4
4
  // 自动签到默认打开:守护进程每 15 分钟看一眼,过了当天随机的几分钟后签一次;失败的隔一小时再试;错过了下次开机补。
@@ -20,11 +20,16 @@ export function todayResults(file, now = new Date()) {
20
20
  return file.date === beijingNow(now).date ? file.results : [];
21
21
  }
22
22
  /** 失败原因 → 该怎么办 */
23
- export function checkinAdvice(id, message) {
24
- if (/无效|过期|401|403|unauthor|expired/i.test(message)) {
23
+ export function checkinAdvice(itemId, message) {
24
+ const id = itemId.split(':')[0];
25
+ if (/无效|过期|401|403|unauthor|expired|not able to authenticate/i.test(message)) {
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');
25
28
  if (id === 'trae')
26
29
  return L('Trae 登录过期:打开 Trae 重新登录一次', 'Trae sign-in expired: open Trae and sign in again');
27
- return id === 'qoder' ? L('Qoder 个人访问令牌失效:在签到卡片里重新填一个', 'The Qoder access token no longer works: enter a new one in the check-in card') : L('WorkBuddy 登录过期:在来源里重新登录 WorkBuddy', 'WorkBuddy sign-in expired: sign in to WorkBuddy again under Sources');
30
+ if (id === 'qoder')
31
+ return L('Qoder 个人访问令牌失效:在签到卡片里删掉它再添加一个新的', 'The Qoder access token no longer works: remove it in the check-in card and add a new one');
32
+ return itemId.includes(':') ? L('这个 WorkBuddy 签到账号的登录过期:在签到卡片里删掉它再添加一次', 'This WorkBuddy check-in account has expired: remove it in the check-in card and add it again') : L('WorkBuddy 登录过期:在来源里重新登录 WorkBuddy', 'WorkBuddy sign-in expired: sign in to WorkBuddy again under Sources');
28
33
  }
29
34
  if (/timeout|timed out|fetch failed|network|ECONN/i.test(message))
30
35
  return L('网络不通:稍后会自动再试', 'Network problem: it will retry automatically');
@@ -42,6 +42,17 @@ export function decryptStorageValue(encoded) {
42
42
  throw new Error('Trae 登录信息校验不通过');
43
43
  return plain.toString('utf8');
44
44
  }
45
+ /** JWT 里的账号 id(data.id);读不出来返回 undefined */
46
+ export function tokenAccountId(token) {
47
+ try {
48
+ const p = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString());
49
+ const id = p.data?.id ?? p.sub;
50
+ return id === undefined || id === null || id === '' ? undefined : String(id);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
45
56
  /** 各平台的应用数据目录 */
46
57
  function appDataRoots(home, platform = process.platform, env = process.env) {
47
58
  if (platform === 'darwin')
@@ -56,9 +67,25 @@ function appDataRoots(home, platform = process.platform, env = process.env) {
56
67
  * 所以全读一遍再挑:没过期的优先,其中桌面 App(带设备 id)优先,再按到期最晚。找不到返回 null(没装 Trae 就不显示)。
57
68
  */
58
69
  export async function findTraeCredential(home = homedir()) {
70
+ return (await findTraeCredentials(home))[0] ?? null;
71
+ }
72
+ /** 本机的每个 Trae 账号各一份登录(同一个账号装了几个 Trae 只算一个),第一个是最该用的 */
73
+ export async function findTraeCredentials(home = homedir()) {
74
+ const found = await readTraeCredentials(home);
75
+ const groups = new Map();
76
+ for (const c of found) {
77
+ const key = c.accountId ?? `?${c.source}`;
78
+ groups.set(key, [...(groups.get(key) ?? []), c]);
79
+ }
80
+ const best = [...groups.values()].map((g) => pickTraeCredential(g)).filter(Boolean);
81
+ // 账号之间:没过期的在前
82
+ const first = pickTraeCredential(best);
83
+ return first ? [first, ...best.filter((c) => c !== first)] : [];
84
+ }
85
+ async function readTraeCredentials(home) {
59
86
  // 测试环境不看本机真装了什么(与不发现桌面 App 同一个开关)
60
87
  if (process.env.DSH_MODEL_NO_APP_DISCOVERY && home === homedir())
61
- return null;
88
+ return [];
62
89
  const found = [];
63
90
  for (const root of appDataRoots(home)) {
64
91
  for (const app of APPS) {
@@ -74,7 +101,8 @@ export async function findTraeCredential(home = homedir()) {
74
101
  if (!token)
75
102
  continue;
76
103
  const dc = Object.keys(storage).find((k) => k.startsWith(DEVICE_PREFIX));
77
- found.push({ token, deviceId: dc ? dc.slice(DEVICE_PREFIX.length) : String(storage['telemetry.devDeviceId'] ?? ''), source: app });
104
+ const accountId = auth.userId !== undefined && auth.userId !== null && auth.userId !== '' ? String(auth.userId) : tokenAccountId(token);
105
+ found.push({ token, deviceId: dc ? dc.slice(DEVICE_PREFIX.length) : String(storage['telemetry.devDeviceId'] ?? ''), source: app, ...(accountId ? { accountId } : {}) });
78
106
  }
79
107
  catch {
80
108
  // 没装、读不了或解不开:看下一个
@@ -85,14 +113,15 @@ export async function findTraeCredential(home = homedir()) {
85
113
  try {
86
114
  const text = (await readFile(join(home, dir, 'trae-jwt-token'), 'utf8')).trim();
87
115
  const token = text.startsWith('{') ? String(JSON.parse(text).token ?? '') : text;
116
+ const accountId = tokenAccountId(token);
88
117
  if (token.split('.').length === 3)
89
- found.push({ token, deviceId: '', source: `~/${dir}` });
118
+ found.push({ token, deviceId: '', source: `~/${dir}`, ...(accountId ? { accountId } : {}) });
90
119
  }
91
120
  catch {
92
121
  // 没有
93
122
  }
94
123
  }
95
- return pickTraeCredential(found);
124
+ return found;
96
125
  }
97
126
  /** 几份登录里挑一份:没过期的优先 → 带设备 id 的(桌面 App)优先 → 到期最晚的。导出给测试 */
98
127
  export function pickTraeCredential(list, now = Date.now()) {
@@ -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
+ }
@@ -5,7 +5,7 @@
5
5
  // 职责:来源状态与开关、登录会话(引擎 OAuth / WorkBuddy 自有登录 / OpenCode key)、key 管理、用量统计。
6
6
  // CLI 与 dsh 插件都只通过 /control/* 调这里,逻辑只有一份。
7
7
  import { spawn } from 'node:child_process';
8
- import { readFile } from 'node:fs/promises';
8
+ import { readFile, rename, rm } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
  import { latestVersion, selfJobLog } from '../commands/self.js';
11
11
  import { LOCAL_SERVERS, loadLocalCatalog, refreshLocalCatalog } from '../integrations/local.js';
@@ -18,7 +18,11 @@ import { spawnDetached } from '../util/detach.js';
18
18
  import { classify, recentFailures } from './failures.js';
19
19
  import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheckin, setAuto, todayResults } from '../checkin/index.js';
20
20
  import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
21
- import { findTraeCredential, traeCheckin } from '../checkin/trae.js';
21
+ import { findTraeCredentials, traeCheckin } from '../checkin/trae.js';
22
+ import { DoubaoAuthError, doubaoCheckin, doubaoStatus, normalizeCookie } from '../checkin/doubao.js';
23
+ import { exportCheckin, importCheckin } from '../checkin/transfer.js';
24
+ import { accountTag, addDoubao, checkinKind, addQoder, listWorkbuddyAccounts, loadDoubaos, loadQoders, removeDoubao, removeQoder, removeWorkbuddyAccount, workbuddyAccountStore, workbuddyAccountsDir } from '../checkin/accounts.js';
25
+ import { CN_VARIANT } from '../bridge/workbuddy/variants.js';
22
26
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
23
27
  import { randomBytes } from 'node:crypto';
24
28
  import { currentBinary } from '../engine/install.js';
@@ -34,7 +38,7 @@ import { blockedNotice as workbuddyBlockedNotice, riskNotice as workbuddyRiskNot
34
38
  import { DSH_KEY_NAME, addKey, findActive, loadKeys, revokeKey, rotateKey, saveKeys } from '../keys.js';
35
39
  import { applyKeyChange, dshCandidates, dshKey, loadAll, saveAll, syncAll } from '../ops.js';
36
40
  import { defaultPick, pickedFor } from '../dsh/pick.js';
37
- import { loadSecrets, saveSecrets } from '../secrets.js';
41
+ import { loadSecrets } from '../secrets.js';
38
42
  import { SOURCES, credsFor, findSource, paymentRequired } from '../sources.js';
39
43
  import { withLock } from '../state.js';
40
44
  import { riskNotice as upstreamRiskNotice } from '../upstreams.js';
@@ -717,35 +721,85 @@ export class Daemon {
717
721
  }
718
722
  // —— 每日签到 ——
719
723
  /** 现在能签到的:WorkBuddy 国内版(已登录)、Qoder(填了个人访问令牌) */
724
+ /**
725
+ * 能签到的每个账号(设计 §14.19):WorkBuddy 主账号 + 多加的签到账号、每个 Qoder 令牌、本机每个 Trae 账号。
726
+ * 第一个账号的 id 沿用 workbuddy / qoder / trae(旧结果照常认),多出来的是 <家>:<账号>。同一个账号只签一次。
727
+ */
720
728
  async checkers() {
721
729
  const out = [];
730
+ const home = this.ctx.paths.home;
731
+ const extra = await listWorkbuddyAccounts(home);
732
+ const seen = new Set();
722
733
  for (const rt of this.runtimes) {
723
734
  if (rt.variant.id !== 'workbuddy')
724
735
  continue; // 国际版没有签到
725
736
  const st = await rt.wbStore.status().catch(() => null);
726
737
  if (st?.state !== 'signed-in')
727
738
  continue;
728
- out.push({ id: 'workbuddy', label: rt.label, run: async () => rt.wbClient.dailyCheckin(await rt.wbStore.resolve()) });
739
+ const uid = (await rt.wbStore.resolve().catch(() => null))?.uid;
740
+ if (uid)
741
+ seen.add(uid);
742
+ const tag = extra.length ? `(${accountTag(st.nickname, uid ?? '')})` : '';
743
+ out.push({ id: 'workbuddy', label: `${rt.label}${tag}`, run: async () => rt.wbClient.dailyCheckin(await rt.wbStore.resolve()) });
744
+ }
745
+ for (const a of extra) {
746
+ if (seen.has(a.uid))
747
+ continue;
748
+ seen.add(a.uid);
749
+ const { store, client } = workbuddyAccountStore(home, a.file);
750
+ out.push({ id: `workbuddy:${a.uid}`, label: `WorkBuddy(${accountTag(a.nickname, a.uid)})`, run: async () => client.dailyCheckin(await store.resolve()) });
729
751
  }
730
- const q = (await loadSecrets(this.ctx)).checkin?.qoder;
731
- if (q?.pat)
732
- out.push({ id: 'qoder', label: QODER_REGIONS[q.region].label, run: () => qoderCheckin(q.pat, q.region) });
733
- // Trae:本机装了并登录过 Trae 才有(读它自己的登录信息,不需要另外设置;VPS 上一般没有)
734
- const trae = await findTraeCredential().catch(() => null);
735
- if (trae)
736
- out.push({ id: 'trae', label: 'Trae', run: async () => traeCheckin((await findTraeCredential()) ?? trae) });
752
+ const qoders = await loadQoders(this.ctx);
753
+ qoders.forEach((q, i) => {
754
+ // 一个令牌:「Qoder(国内版)」;几个:带上名字(没起名就用编号),如「Qoder(小号 · 国内版)」
755
+ const region = q.region === 'cn' ? L('国内版', 'China') : '';
756
+ const tag = q.name || (qoders.length > 1 ? `#${q.id}` : '');
757
+ const label = tag ? `Qoder(${[tag, region].filter(Boolean).join(' · ')})` : QODER_REGIONS[q.region].label;
758
+ out.push({ id: i === 0 ? 'qoder' : `qoder:${q.id}`, label, run: () => qoderCheckin(q.pat, q.region) });
759
+ });
760
+ // 豆包:用户粘贴的 Cookie,每个账号一个
761
+ const doubaos = await loadDoubaos(this.ctx);
762
+ doubaos.forEach((d, i) => {
763
+ const tag = d.name || (doubaos.length > 1 ? `#${d.id}` : '');
764
+ out.push({ id: i === 0 ? 'doubao' : `doubao:${d.id}`, label: tag ? L(`豆包(${tag})`, `Doubao (${tag})`) : L('豆包', 'Doubao'), run: () => doubaoCheckin(d.cookie, d.seed) });
765
+ });
766
+ // Trae:本机装了并登录过 Trae 才有(读它自己的登录信息,不需要另外设置;VPS 上一般没有)。每个账号签一次
767
+ const traes = await findTraeCredentials().catch(() => []);
768
+ traes.forEach((t, i) => {
769
+ const id = i === 0 ? 'trae' : `trae:${t.accountId ?? t.source}`;
770
+ out.push({
771
+ id,
772
+ label: traes.length > 1 ? `Trae(${t.accountId ? `…${t.accountId.slice(-4)}` : t.source})` : 'Trae',
773
+ run: async () => traeCheckin((await findTraeCredentials()).find((x) => (x.accountId ?? x.source) === (t.accountId ?? t.source)) ?? t),
774
+ });
775
+ });
737
776
  return out;
738
777
  }
739
778
  async checkinStatus() {
740
779
  const file = await loadCheckin(this.ctx.paths.home);
741
780
  const checkers = await this.checkers();
742
- const q = (await loadSecrets(this.ctx)).checkin?.qoder;
781
+ const qoders = await loadQoders(this.ctx);
782
+ const extra = await listWorkbuddyAccounts(this.ctx.paths.home);
783
+ const ids = new Set(checkers.map((c) => c.id));
743
784
  return {
744
785
  auto: file.auto !== false,
745
786
  date: beijingNow().date,
746
- results: todayResults(file).map((r) => (r.state === 'failed' ? { ...r, advice: checkinAdvice(r.id, r.message ?? '') } : r)),
787
+ // 删掉的账号今天的结果不再显示
788
+ results: todayResults(file).filter((r) => ids.has(r.id)).map((r) => (r.state === 'failed' ? { ...r, advice: checkinAdvice(r.id, r.message ?? '') } : r)),
747
789
  available: checkers.map((c) => ({ id: c.id, label: c.label })),
748
- qoder: { configured: Boolean(q?.pat), ...(q ? { region: q.region } : {}) },
790
+ qoder: { configured: qoders.length > 0, ...(qoders[0] ? { region: qoders[0].region } : {}) },
791
+ accounts: {
792
+ workbuddy: extra.map((a) => ({ uid: a.uid, label: `WorkBuddy(${accountTag(a.nickname, a.uid)})` })),
793
+ 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 })),
794
+ 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 })),
795
+ },
796
+ // 支持、但这台还没设置的(签到卡片里列出来,旁边就是「添加」)
797
+ missing: [
798
+ ...(checkers.some((c) => checkinKind(c.id) === 'workbuddy') ? [] : [{ id: 'workbuddy', label: L('WorkBuddy 国内版', 'WorkBuddy (China)'), action: 'workbuddy' }]),
799
+ ...(checkers.some((c) => checkinKind(c.id) === 'qoder') ? [] : [{ id: 'qoder', label: 'Qoder', action: 'qoder' }]),
800
+ ...(checkers.some((c) => checkinKind(c.id) === 'doubao') ? [] : [{ id: 'doubao', label: L('豆包', 'Doubao'), action: 'doubao' }]),
801
+ ...(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') }]),
802
+ ],
749
803
  };
750
804
  }
751
805
  checkinInflight;
@@ -767,20 +821,80 @@ export class Daemon {
767
821
  if (results)
768
822
  log_(`daemon: auto check-in ${results.results.map((r) => `${r.id}=${r.state}`).join(' ')}`);
769
823
  }
770
- async setQoderPat(pat, region) {
824
+ /** 加一个 Qoder 令牌(同一个令牌再加 = 改名字 / 区域) */
825
+ async addQoderPat(pat, region, name) {
771
826
  if (!pat || /\s/.test(pat) || pat.length < 8)
772
827
  throw new DshModelError('invalid_key', L('这看起来不是 Qoder 的个人访问令牌', 'That does not look like a Qoder access token'));
773
828
  await exchangePat(pat, region); // 换得到短期 token 才算可用
774
- const secrets = await loadSecrets(this.ctx);
775
- secrets.checkin = { ...secrets.checkin, qoder: { pat, region, savedAt: new Date().toISOString() } };
776
- await saveSecrets(this.ctx, secrets);
829
+ await addQoder(this.ctx, { pat, region, ...(name ? { name } : {}) });
777
830
  }
778
- async removeQoderPat() {
779
- const secrets = await loadSecrets(this.ctx);
780
- if (secrets.checkin?.qoder) {
781
- delete secrets.checkin.qoder;
782
- await saveSecrets(this.ctx, secrets);
831
+ async removeQoderPat(id) {
832
+ await removeQoder(this.ctx, id);
833
+ }
834
+ /** 加一个豆包账号:先用这份 Cookie 查一次签到任务,查得到才保存 */
835
+ async addDoubaoCookie(raw, name) {
836
+ const cookie = normalizeCookie(raw);
837
+ if (cookie.length < 20 || !cookie.includes('='))
838
+ throw new DshModelError('invalid_key', L('这看起来不是豆包的 Cookie:要从浏览器开发者工具里复制整串 Cookie(形如 a=1; b=2; …)', 'That does not look like a Doubao cookie: copy the whole Cookie string from the browser developer tools (like a=1; b=2; …)'));
839
+ const seed = randomBytes(16).toString('hex');
840
+ let st;
841
+ try {
842
+ st = await doubaoStatus(cookie, seed);
783
843
  }
844
+ catch (error) {
845
+ if (error instanceof DoubaoAuthError)
846
+ throw new DshModelError('key_rejected', L('豆包说这份 Cookie 没有登录或已过期:在浏览器里登录 doubao.com 后重新复制', 'Doubao says this cookie is signed out or expired: sign in at doubao.com in the browser and copy it again'));
847
+ throw new DshModelError('doubao_failed', String(error.message ?? error));
848
+ }
849
+ if (st.checkedIn === null)
850
+ 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'));
851
+ await addDoubao(this.ctx, { cookie, seed, ...(name ? { name } : {}) });
852
+ }
853
+ /** 加一个 WorkBuddy 签到账号:打开链接授权(同来源登录一样),存成单独一份,只用来签到 */
854
+ async startWorkbuddyCheckinLogin() {
855
+ const all = await loadAll(this.ctx);
856
+ const home = this.ctx.paths.home;
857
+ const s = {
858
+ id: `wbc-${randomBytes(8).toString('hex')}`,
859
+ source: 'workbuddy-checkin',
860
+ kind: 'link',
861
+ status: 'pending',
862
+ needsPaste: false,
863
+ startedAt: new Date().toISOString(),
864
+ expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
865
+ abort: new AbortController(),
866
+ };
867
+ this.sessions.set(s.id, s);
868
+ const tmp = join(workbuddyAccountsDir(home), `.pending-${s.id}.json`);
869
+ let urlReady;
870
+ const ready = new Promise((r) => (urlReady = r));
871
+ void workbuddyLogin(CN_VARIANT, home, { proxy: all.config.proxy, owner: this.ctx.owner, signal: s.abort.signal, file: tmp, onUrl: (url) => { s.url = url; urlReady(); } })
872
+ .then(async (cred) => {
873
+ const uid = cred.uid || randomBytes(4).toString('hex');
874
+ const primary = this.runtimes.find((r) => r.variant.id === 'workbuddy');
875
+ const primaryUid = primary ? (await primary.wbStore.resolve().catch(() => null))?.uid : undefined;
876
+ if (primaryUid && primaryUid === uid) {
877
+ await rm(tmp, { force: true });
878
+ s.status = 'error';
879
+ s.error = L('这个账号就是来源里登录的 WorkBuddy,已经在签到了。请在浏览器里换另一个账号授权', 'This is the WorkBuddy account already signed in under Sources, so it is checked in already. Authorise a different account');
880
+ return;
881
+ }
882
+ await rename(tmp, join(workbuddyAccountsDir(home), `${uid.replace(/[^A-Za-z0-9_-]/g, '_')}.json`));
883
+ s.status = 'ok';
884
+ void this.checkinNow(false).catch(() => { });
885
+ })
886
+ .catch(async (error) => {
887
+ await rm(tmp, { force: true }).catch(() => { });
888
+ if (s.status === 'pending') {
889
+ s.status = 'error';
890
+ s.error = String(error.message ?? error);
891
+ }
892
+ urlReady();
893
+ });
894
+ await Promise.race([ready, new Promise((r) => setTimeout(r, 30_000))]);
895
+ if (s.status === 'error')
896
+ throw new DshModelError('login_failed', s.error ?? 'login failed');
897
+ return publicSession(s);
784
898
  }
785
899
  // —— 更新 / 卸载(管理页的按钮)——
786
900
  selfJob;
@@ -1104,11 +1218,33 @@ export class Daemon {
1104
1218
  return ok(await this.checkinStatus());
1105
1219
  }
1106
1220
  if (method === 'POST' && path === '/checkin/qoder') {
1107
- await this.setQoderPat(String(b.pat ?? '').trim(), b.region === 'intl' ? 'intl' : 'cn');
1221
+ await this.addQoderPat(String(b.pat ?? '').trim(), b.region === 'intl' ? 'intl' : 'cn', typeof b.name === 'string' ? b.name : undefined);
1222
+ return ok(await this.checkinNow(true));
1223
+ }
1224
+ if (method === 'DELETE' && (path === '/checkin/qoder' || (seg[0] === 'checkin' && seg[1] === 'qoder' && seg[2]))) {
1225
+ await this.removeQoderPat(seg[2]);
1226
+ return ok(await this.checkinStatus());
1227
+ }
1228
+ if (method === 'POST' && path === '/checkin/workbuddy')
1229
+ return ok({ login: await this.startWorkbuddyCheckinLogin() });
1230
+ if (method === 'POST' && path === '/checkin/export')
1231
+ return ok(await exportCheckin(this.ctx));
1232
+ if (method === 'POST' && path === '/checkin/import') {
1233
+ const n = await importCheckin(this.ctx, String(b.text ?? ''), String(b.code ?? ''));
1234
+ return ok({ imported: n, ...(await this.checkinNow(true)) });
1235
+ }
1236
+ if (method === 'POST' && path === '/checkin/doubao') {
1237
+ await this.addDoubaoCookie(String(b.cookie ?? ''), typeof b.name === 'string' ? b.name : undefined);
1108
1238
  return ok(await this.checkinNow(true));
1109
1239
  }
1110
- if (method === 'DELETE' && path === '/checkin/qoder') {
1111
- await this.removeQoderPat();
1240
+ if (method === 'DELETE' && seg[0] === 'checkin' && seg[1] === 'doubao' && seg[2]) {
1241
+ if (!(await removeDoubao(this.ctx, seg[2])))
1242
+ throw new DshModelError('no_account', L('没有这个签到账号', 'No such check-in account'));
1243
+ return ok(await this.checkinStatus());
1244
+ }
1245
+ if (method === 'DELETE' && seg[0] === 'checkin' && seg[1] === 'workbuddy' && seg[2]) {
1246
+ if (!(await removeWorkbuddyAccount(this.ctx.paths.home, seg[2])))
1247
+ throw new DshModelError('no_account', L('没有这个签到账号', 'No such check-in account'));
1112
1248
  return ok(await this.checkinStatus());
1113
1249
  }
1114
1250
  if (method === 'GET' && path === '/failures') {
@@ -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.23.4",
3
+ "version": "0.26.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
@@ -985,37 +985,120 @@ window.__ModuleLoader__.load({
985
985
  // —— 每日签到 ——
986
986
  const CHECKIN_TEXT = (r) => (r.state === 'signed' ? L(`成功${r.credit ? ` +${r.credit} 积分` : ''}`, `done${r.credit ? ` +${r.credit} credits` : ''}`) : r.state === 'already' ? L('今天已签过', 'already today') : L(`失败:${r.message || ''}`, `failed: ${r.message || ''}`))
987
987
 
988
- function QoderDialog({ onDone, onClose }) {
988
+ function QoderDialog({ onDone, onClose, another }) {
989
989
  const [pat, setPat] = useState('')
990
+ const [name, setName] = useState('')
990
991
  const [region, setRegion] = useState('cn')
991
992
  const [busy, setBusy] = useState(false)
992
993
  const [msg, setMsg] = useState('')
993
994
  const save = async () => {
994
995
  setBusy(true)
995
996
  setMsg(L('正在验证这个令牌并签到…', 'Checking the token and checking in…'))
996
- try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
997
+ try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region, ...(name.trim() ? { name: name.trim() } : {}) }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
997
998
  }
998
- return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
999
+ return h(Modal, { title: another ? L('再加一个 Qoder 账号', 'Add another Qoder account') : L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
999
1000
  h('p', { style: S.note },
1000
1001
  L('登录 Qoder 网站,在「设置 → API 令牌」(有的版本叫「账号 → 集成」)里点「创建令牌」,复制生成的个人访问令牌(以 pt- 开头,只显示一次),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Sign in on the Qoder website, open Settings → API Tokens (Account → Integrations in some versions), click Create token, then paste the personal access token here (it starts with pt- and is shown only once). It is only used to claim the daily credits, and stays on this machine.'),
1001
1002
  h('a', { href: region === 'cn' ? 'https://qoder.com.cn/account/integrations' : 'https://qoder.com/account/integrations', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 6 } }, region === 'cn' ? L('去 Qoder 国内版创建 ↗', 'Create on Qoder China ↗') : L('去 Qoder 国际版创建 ↗', 'Create on Qoder ↗'))),
1002
1003
  h('div', { style: { ...S.line, marginBottom: 8 } },
1003
1004
  ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
1004
1005
  h('input', { type: 'radio', name: 'qoder-region', checked: region === r, onChange: () => setRegion(r) }), r === 'cn' ? L('国内版', 'China') : L('国际版', 'International')))),
1006
+ another ? h('div', { style: { ...S.line, marginBottom: 8 } },
1007
+ h('input', { style: { ...S.input, ...S.grow }, maxLength: 20, placeholder: L('给这个账号起个名字,如:小号', 'A name for this account, e.g. Second'), value: name, onChange: (e) => setName(e.target.value), 'aria-label': L('账号名字', 'Account name') })) : null,
1005
1008
  h('div', { style: S.line },
1006
1009
  h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('个人访问令牌', 'Personal access token'), value: pat, onChange: (e) => setPat(e.target.value) }),
1007
1010
  h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !pat.trim(), onClick: () => void save() }, L('保存', 'Save'))),
1008
1011
  msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1009
1012
  }
1010
1013
 
1014
+ /** 添加豆包签到:从浏览器复制一次登录 Cookie(豆包没有令牌,也不读豆包客户端的文件) */
1015
+ function DoubaoDialog({ onDone, onClose, another }) {
1016
+ const [cookie, setCookie] = useState('')
1017
+ const [name, setName] = useState('')
1018
+ const [busy, setBusy] = useState(false)
1019
+ const [msg, setMsg] = useState('')
1020
+ const save = async () => {
1021
+ setBusy(true)
1022
+ setMsg(L('正在用这份 Cookie 查签到任务并签到…', 'Checking this cookie and checking in…'))
1023
+ try { await api('POST', '/checkin/doubao', { cookie: cookie.trim(), ...(name.trim() ? { name: name.trim() } : {}) }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
1024
+ }
1025
+ const step = (n, text) => h('div', { style: { ...S.meta, lineHeight: 1.7 } }, `${n}. `, text)
1026
+ 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:')),
1028
+ 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
+ step(2, L('按 F12 打开开发者工具,切到「网络 / Network」,刷新一下页面', 'Press F12, open the Network tab, and reload the page')),
1030
+ step(3, L('点列表里任意一条 doubao.com 的请求,在「请求标头 / Request Headers」里找到 Cookie 那一行,右键复制它的值', 'Click any doubao.com request, find Cookie under Request Headers, and copy its value')),
1031
+ h('div', { style: { fontSize: 12, color: C.warn, margin: '8px 0' } }, L('Cookie 等于你的登录:只粘在这里(保存在本机),不要发给别人。', 'The cookie is your sign-in: paste it only here (it stays on this machine) and never share it.')),
1032
+ another ? h('div', { style: { ...S.line, marginBottom: 8 } },
1033
+ h('input', { style: { ...S.input, ...S.grow }, maxLength: 20, placeholder: L('给这个账号起个名字,如:小号', 'A name for this account, e.g. Second'), value: name, onChange: (e) => setName(e.target.value), 'aria-label': L('账号名字', 'Account name') })) : null,
1034
+ h('div', { style: S.line },
1035
+ 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
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !cookie.trim(), onClick: () => void save() }, L('保存', 'Save'))),
1037
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1038
+ }
1039
+
1040
+ /** 把签到账号复制到另一台机器:导出一段加密文本 + 提取码,到那台的页面里导入 */
1041
+ function TransferDialog({ mode, onDone, onClose }) {
1042
+ const [out, setOut] = useState(null)
1043
+ const [text, setText] = useState('')
1044
+ const [code, setCode] = useState('')
1045
+ const [busy, setBusy] = useState(false)
1046
+ const [msg, setMsg] = useState('')
1047
+ useEffect(() => {
1048
+ if (mode !== 'export') return
1049
+ api('POST', '/checkin/export').then(setOut, (e) => setMsg(e.message))
1050
+ }, [mode])
1051
+ const doImport = async () => {
1052
+ setBusy(true)
1053
+ setMsg(L('正在导入并签到…', 'Importing and checking in…'))
1054
+ try {
1055
+ const r = await api('POST', '/checkin/import', { text: text.trim(), code: code.trim() })
1056
+ onDone(r)
1057
+ } catch (e) { setMsg(e.message); setBusy(false) }
1058
+ }
1059
+ if (mode === 'export') {
1060
+ return h(Modal, { title: L('复制签到账号到另一台机器', 'Copy check-in accounts to another machine'), onClose },
1061
+ 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.')),
1062
+ out ? h('div', null,
1063
+ 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):`)),
1064
+ h('div', { style: S.line },
1065
+ h('textarea', { readOnly: true, value: out.text, rows: 3, style: { ...S.input, ...S.mono, ...S.grow, resize: 'none' }, 'aria-label': L('导出的文本', 'Exported text') }),
1066
+ h(CopyButton, { get: out.text })),
1067
+ h('div', { style: { ...S.meta, margin: '10px 0 4px' } }, L('2. 记下提取码(光有文本解不开):', '2. Note the code (the text alone cannot be opened):')),
1068
+ h('div', { style: S.line }, h('span', { style: { ...S.mono, fontSize: 18, letterSpacing: 2 } }, out.code), h(CopyButton, { get: out.code })),
1069
+ 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.')),
1070
+ 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,
1071
+ msg ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 8 } }, msg) : null)
1072
+ }
1073
+ return h(Modal, { title: L('导入签到账号', 'Import check-in accounts'), onClose },
1074
+ 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.')),
1075
+ 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') }),
1076
+ h('div', { style: S.line },
1077
+ h('input', { style: { ...S.input, ...S.mono, width: 160 }, placeholder: L('提取码', 'Code'), value: code, onChange: (e) => setCode(e.target.value), 'aria-label': L('提取码', 'Code') }),
1078
+ h('span', { style: S.grow }),
1079
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !text.trim() || !code.trim(), onClick: () => void doImport() }, L('导入', 'Import'))),
1080
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
1081
+ }
1082
+
1011
1083
  /** 签到卡片:平时一行"今日已完成签到:…";没签完的列出来,一键签到;自动签到开关 */
1012
1084
  function CheckinCard() {
1013
1085
  const [st, setSt] = useState(null)
1014
1086
  const [busy, setBusy] = useState(false)
1015
1087
  const [open, setOpen] = useState(false)
1016
1088
  const [qoder, setQoder] = useState(false)
1089
+ const [wbLogin, setWbLogin] = useState(null)
1090
+ const [doubao, setDoubao] = useState(false)
1091
+ const [transfer, setTransfer] = useState('')
1017
1092
  const [err, setErr] = useState('')
1018
1093
  const refresh = useCallback(() => api('GET', '/checkin').then(setSt, () => {}), [])
1094
+ const addWorkbuddy = async () => {
1095
+ setErr('')
1096
+ try { setWbLogin((await api('POST', '/checkin/workbuddy')).login) } catch (e) { setErr(e.message) }
1097
+ }
1098
+ const removeAccount = async (kind, id, label) => {
1099
+ if (!window.confirm(L(`不再给「${label}」签到?`, `Stop checking in "${label}"?`))) return
1100
+ try { setSt(await api('DELETE', `/checkin/${kind}/${encodeURIComponent(id)}`)) } catch (e) { setErr(e.message) }
1101
+ }
1019
1102
  useEffect(() => { void refresh(); const t = setInterval(refresh, 5 * 60 * 1000); return () => clearInterval(t) }, [refresh])
1020
1103
  if (!st) return null
1021
1104
  const byId = new Map(st.results.map((r) => [r.id, r]))
@@ -1033,8 +1116,12 @@ window.__ModuleLoader__.load({
1033
1116
  // 没有能签到的:只留一个很小的入口
1034
1117
  return h('div', { style: { ...S.section, marginBottom: 16 } },
1035
1118
  h('div', { style: { ...S.meta } }, L('每日签到:登录 WorkBuddy 国内版,或', 'Daily check-in: sign in to WorkBuddy (China), or '),
1036
- h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder'))),
1037
- qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
1119
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder')),
1120
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setDoubao(true) }, L('添加豆包', 'Add Doubao')),
1121
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setTransfer('import') }, L('从另一台机器导入', 'Import from another machine'))),
1122
+ transfer ? h(TransferDialog, { mode: transfer, onDone: (r) => { setTransfer(''); setSt(r); setOpen(true) }, onClose: () => setTransfer('') }) : null,
1123
+ qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1124
+ doubao ? h(DoubaoDialog, { onDone: () => { setDoubao(false); void refresh() }, onClose: () => setDoubao(false) }) : null)
1038
1125
  }
1039
1126
  return h('div', { style: S.section },
1040
1127
  h('div', { style: { ...S.card, padding: '10px 14px' } },
@@ -1044,21 +1131,41 @@ window.__ModuleLoader__.load({
1044
1131
  done.length && pending.length ? h('span', { style: { color: C.sub } }, L(';', '; ')) : null,
1045
1132
  pending.length ? h('span', { style: { color: C.text } }, L(`还没签:${pending.map((a) => a.label).join('、')}`, `Not yet: ${pending.map((a) => a.label).join(', ')}`)) : null),
1046
1133
  pending.length ? h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: () => void run() }, busy ? L('签到中…', 'Checking in…') : L('一键签到', 'Check in all')) : null,
1047
- st.results.length ? h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')) : null,
1134
+ // 详情里还有管理账号的按钮,所以没签过也要能打开
1135
+ h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')),
1048
1136
  h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
1049
1137
  h('input', { type: 'checkbox', checked: st.auto, onChange: (e) => void toggleAuto(e.target.checked) }), L('自动签到', 'Auto'))),
1138
+ // 支持、但这台还没设置的:一眼看出还能签哪些,旁边就能加
1139
+ (st.missing || []).length ? h('div', { style: { ...S.line, flexWrap: 'wrap', gap: 6, marginTop: 4, fontSize: 12, color: C.sub } },
1140
+ h('span', null, L('这台还能签:', 'Also available here:')),
1141
+ (st.missing || []).map((m) => h('span', { key: m.id, style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
1142
+ m.label,
1143
+ 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,
1050
1144
  open ? h('div', { style: { marginTop: 8 } },
1051
1145
  st.results.map((r) => h('div', { key: r.id, style: { fontSize: 12, lineHeight: 1.8, color: r.state === 'failed' ? C.err : C.text } },
1052
1146
  `${r.label}:${CHECKIN_TEXT(r)}`, r.advice ? h('span', { style: { color: C.sub, marginLeft: 6 } }, r.advice) : null)),
1053
- h('div', { style: { ...S.line, marginTop: 6 } },
1054
- st.qoder.configured
1055
- ? h('button', { type: 'button', style: S.btn, onClick: () => { if (window.confirm(L('删除 Qoder 的令牌?之后不再给 Qoder 签到。', 'Remove the Qoder token? Qoder will no longer be checked in.'))) void api('DELETE', '/checkin/qoder').then(setSt, (e) => setErr(e.message)) } }, L('删除 Qoder 令牌', 'Remove Qoder token'))
1056
- : h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder'))),
1147
+ // 多账号:多加的 WorkBuddy 签到账号、每个 Qoder 令牌,都能删;再加一个
1148
+ h('div', { style: { marginTop: 8, borderTop: `1px solid ${C.border}`, paddingTop: 8 } },
1149
+ h('div', { style: { ...S.meta, marginBottom: 4 } }, L('签到账号(同一家可以加几个账号)', 'Check-in accounts (several per product are fine)')),
1150
+ [...((st.accounts && st.accounts.workbuddy) || []).map((a) => ({ kind: 'workbuddy', id: a.uid, label: a.label })), ...((st.accounts && st.accounts.qoder) || []).map((q) => ({ kind: 'qoder', id: q.id, label: q.label })), ...((st.accounts && st.accounts.doubao) || []).map((d) => ({ kind: 'doubao', id: d.id, label: d.label }))].map((a) =>
1151
+ h('div', { key: `${a.kind}:${a.id}`, style: { ...S.line, minHeight: 24, fontSize: 12 } },
1152
+ h('span', { style: S.grow }, a.label),
1153
+ h('button', { type: 'button', style: { ...S.btn, padding: '2px 8px' }, onClick: () => void removeAccount(a.kind, a.id, a.label) }, L('删除', 'Remove')))),
1154
+ h('div', { style: { ...S.line, marginTop: 4, flexWrap: 'wrap' } },
1155
+ h('button', { type: 'button', style: S.btn, onClick: () => void addWorkbuddy() }, L('+ 添加 WorkBuddy 账号', '+ Add a WorkBuddy account')),
1156
+ h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, st.qoder.configured ? L('+ 再加一个 Qoder 账号', '+ Add another Qoder account') : L('+ 添加 Qoder', '+ Add Qoder')),
1157
+ 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'))),
1158
+ h('div', { style: { ...S.line, marginTop: 4, flexWrap: 'wrap' } },
1159
+ h('button', { type: 'button', style: S.btn, onClick: () => setTransfer('export') }, L('复制签到账号到另一台机器', 'Copy check-in accounts to another machine')),
1160
+ h('button', { type: 'button', style: S.btn, onClick: () => setTransfer('import') }, L('导入签到账号', 'Import check-in accounts')))),
1057
1161
  // Trae 读本机 Trae 的登录:这台没装(比如 VPS)就不会出现,在装了 Trae 的电脑上签就行(每个账号一天一次)
1058
1162
  !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,
1059
1163
  !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,
1060
1164
  err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null),
1061
- qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
1165
+ qoder ? h(QoderDialog, { another: st.qoder.configured, onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null,
1166
+ transfer ? h(TransferDialog, { mode: transfer, onDone: (r) => { setTransfer(''); setSt(r); setOpen(true) }, onClose: () => setTransfer('') }) : null,
1167
+ 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,
1168
+ 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)
1062
1169
  }
1063
1170
 
1064
1171
  /** 签完在 dsh 右下角提示一次(成功或失败),一天一次 */
package/plugin/index.js CHANGED
@@ -63,8 +63,10 @@ const ALLOWED = [
63
63
  ['GET', /^\/self$/],
64
64
  ['GET', /^\/failures$/],
65
65
  ['GET', /^\/checkin$/],
66
- ['POST', /^\/checkin(\/auto|\/qoder)?$/],
67
- ['DELETE', /^\/checkin\/qoder$/],
66
+ ['POST', /^\/checkin(\/auto|\/qoder|\/workbuddy|\/doubao|\/export|\/import)?$/],
67
+ ['DELETE', /^\/checkin\/qoder(\/[A-Za-z0-9_-]{1,40})?$/],
68
+ ['DELETE', /^\/checkin\/workbuddy\/[A-Za-z0-9_-]{1,80}$/],
69
+ ['DELETE', /^\/checkin\/doubao\/[A-Za-z0-9_-]{1,40}$/],
68
70
  ['POST', /^\/models\/test$/],
69
71
  ['GET', /^\/combos$/],
70
72
  ['POST', /^\/combos$/],