dsh-model 0.19.1 → 0.20.1

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.
@@ -643,6 +643,25 @@ export class WorkBuddyUpstreamClient {
643
643
  * `enterpriseId` must stay on the measured personal path instead of being
644
644
  * moved onto an unmeasured one.
645
645
  */
646
+ /**
647
+ * 每日签到(只有国内版有)。dsh-model 加:接口与"已签到"的判断照 corrinehu/dsh-buddy-checkin(MIT)。
648
+ * 返回 signed(这次签上了,credit 是送的积分)或 already(今天已经签过);其他情况抛错。
649
+ */
650
+ async dailyCheckin(credential) {
651
+ const response = await fetch(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
652
+ method: 'POST',
653
+ headers: billingHeaders(credential),
654
+ body: '{}',
655
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
656
+ });
657
+ const envelope = await readEnvelope(response);
658
+ if (envelope.msg.includes('已签到'))
659
+ return { state: 'already' };
660
+ if (!response.ok || (envelope.code !== undefined && envelope.code !== 0))
661
+ throw envelopeError(response.status, envelope);
662
+ const data = typeof envelope.data === 'object' && envelope.data !== null ? envelope.data : {};
663
+ return { state: 'signed', ...(typeof data['credit'] === 'number' ? { credit: data['credit'] } : {}) };
664
+ }
646
665
  async fetchCredits(credential) {
647
666
  if (regionOf(credential.domain) === 'cn'
648
667
  && credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
@@ -0,0 +1,86 @@
1
+ // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder;以后加 Trae、WPS 灵犀
2
+ //
3
+ // 「今天」按北京时间算:这几家都在北京时间零点换天(VPS 的系统时间是 UTC)。
4
+ // 自动签到默认打开:守护进程每 15 分钟看一眼,过了当天随机的几分钟后签一次;失败的隔一小时再试;错过了下次开机补。
5
+ // 结果存 <home>/checkin.json:开关、当天日期、每家的结果。
6
+ import { join } from 'node:path';
7
+ import { L } from '../i18n.js';
8
+ import { readJson, writeJson } from '../util/fs.js';
9
+ export const checkinPath = (home) => join(home, 'checkin.json');
10
+ /** 北京时间的日期与时刻 */
11
+ export function beijingNow(now = new Date()) {
12
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now).map((p) => [p.type, p.value]));
13
+ return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
14
+ }
15
+ export async function loadCheckin(home) {
16
+ return (await readJson(checkinPath(home))) ?? { results: [] };
17
+ }
18
+ /** 今天的结果(换天了就是空的) */
19
+ export function todayResults(file, now = new Date()) {
20
+ return file.date === beijingNow(now).date ? file.results : [];
21
+ }
22
+ /** 失败原因 → 该怎么办 */
23
+ export function checkinAdvice(id, message) {
24
+ if (/无效|过期|401|403|unauthor|expired/i.test(message)) {
25
+ 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');
26
+ }
27
+ if (/timeout|timed out|fetch failed|network|ECONN/i.test(message))
28
+ return L('网络不通:稍后会自动再试', 'Network problem: it will retry automatically');
29
+ return L('稍后会自动再试', 'It will retry automatically');
30
+ }
31
+ /**
32
+ * 签到。force:今天已经成功的也再问一遍(一键签到时用;各家都是幂等的,重复签只会返回"已签到")。
33
+ * 返回今天的全部结果。
34
+ */
35
+ export async function runCheckin(home, checkers, opts = {}) {
36
+ const file = await loadCheckin(home);
37
+ const { date } = beijingNow();
38
+ const results = new Map(todayResults(file).map((r) => [r.id, r]));
39
+ for (const c of checkers) {
40
+ const prev = results.get(c.id);
41
+ if (!opts.force && prev && prev.state !== 'failed')
42
+ continue;
43
+ const at = new Date().toISOString();
44
+ try {
45
+ const r = await c.run();
46
+ results.set(c.id, { id: c.id, label: c.label, state: r.state, ...(r.credit !== undefined ? { credit: r.credit } : {}), at });
47
+ }
48
+ catch (error) {
49
+ const message = String(error.message ?? error).slice(0, 200);
50
+ results.set(c.id, { id: c.id, label: c.label, state: 'failed', message, at });
51
+ }
52
+ }
53
+ const next = { ...file, date, results: [...results.values()] };
54
+ await writeJson(checkinPath(home), next, { owner: opts.owner });
55
+ return next.results;
56
+ }
57
+ /** 现在该不该自动签:打开了、过了今天的随机延后、有没签上的(失败的隔一小时再试) */
58
+ export function autoDue(file, checkers, now = new Date()) {
59
+ if (file.auto === false || !checkers.length)
60
+ return false;
61
+ const { date, minutes } = beijingNow(now);
62
+ const jitter = file.jitter?.date === date ? file.jitter.minutes : 0;
63
+ if (minutes < jitter)
64
+ return false;
65
+ const today = new Map(todayResults(file, now).map((r) => [r.id, r]));
66
+ return checkers.some((c) => {
67
+ const r = today.get(c.id);
68
+ if (!r)
69
+ return true;
70
+ return r.state === 'failed' && now.getTime() - Date.parse(r.at) > 60 * 60 * 1000;
71
+ });
72
+ }
73
+ /** 今天的随机延后(5–55 分钟),一天定一次 */
74
+ export async function ensureJitter(home, owner) {
75
+ const file = await loadCheckin(home);
76
+ const { date } = beijingNow();
77
+ if (file.jitter?.date === date)
78
+ return file;
79
+ const next = { ...file, jitter: { date, minutes: 5 + Math.floor(Math.random() * 50) } };
80
+ await writeJson(checkinPath(home), next, { owner });
81
+ return next;
82
+ }
83
+ export async function setAuto(home, on, owner) {
84
+ const file = await loadCheckin(home);
85
+ await writeJson(checkinPath(home), { ...file, auto: on }, { owner });
86
+ }
@@ -0,0 +1,94 @@
1
+ // checkin/qoder.ts — Qoder 每日签到(领取每日赠送积分)
2
+ //
3
+ // 协议照 G57651/dsh-dual-checkin 的 qoder 适配器(MIT),与 Qoder 桌面端「用量面板」自己的领取链路一致:
4
+ // 个人访问令牌(PAT)→ POST {openApi}/api/v1/jobToken/exchange → 短期 jobToken
5
+ // GET {openApi}/sash/api/v1/me/campaigns 找当天的 CLAIM_BENEFIT 活动
6
+ // POST {openApi}/sash/api/v1/me/campaigns/{id}/claim 幂等:重复领取返回 replayed,不会多发
7
+ // PAT 存在 dsh-model 的 secrets.json(0600);jobToken 只在内存里用,不落盘。
8
+ export const QODER_REGIONS = {
9
+ cn: { label: 'Qoder(国内版)', openApi: 'https://openapi.qoder.com.cn' },
10
+ intl: { label: 'Qoder', openApi: 'https://openapi.qoder.sh' },
11
+ };
12
+ const TIMEOUT_MS = 20_000;
13
+ async function call(url, init) {
14
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
15
+ const text = await res.text();
16
+ let json = {};
17
+ try {
18
+ json = text ? JSON.parse(text) : {};
19
+ }
20
+ catch {
21
+ // 非 JSON:按状态码判断
22
+ }
23
+ return { status: res.status, json };
24
+ }
25
+ /** PAT 换短期 token;PAT 不对就抛"PAT 无效" */
26
+ export async function exchangePat(pat, region) {
27
+ const r = await call(`${QODER_REGIONS[region].openApi}/api/v1/jobToken/exchange`, {
28
+ method: 'POST',
29
+ headers: { accept: 'application/json', 'content-type': 'application/json', 'cosy-clienttype': '5', 'cosy-version': '1.0.1', 'user-agent': 'Qoder' },
30
+ body: JSON.stringify({ personal_token: pat }),
31
+ });
32
+ const token = typeof r.json.token === 'string' ? r.json.token : '';
33
+ if (r.status >= 400 || !token)
34
+ throw new QoderAuthError(`Qoder 个人访问令牌无效或已过期(HTTP ${r.status})`);
35
+ return token;
36
+ }
37
+ export class QoderAuthError extends Error {
38
+ }
39
+ const headers = (token) => ({ accept: 'application/json', authorization: `Bearer ${token}`, 'cosy-clienttype': '10', 'user-agent': 'Qoder' });
40
+ const epoch = (v) => {
41
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
42
+ return v >= 1e12 ? v : v * 1000;
43
+ if (typeof v === 'string' && v) {
44
+ const t = Date.parse(v);
45
+ return Number.isNaN(t) ? undefined : t;
46
+ }
47
+ return undefined;
48
+ };
49
+ /** 当天能领的 CLAIM_BENEFIT 活动。导出给测试 */
50
+ export function claimableCampaign(payload, now = Date.now()) {
51
+ const list = payload?.campaigns ?? payload?.data?.campaigns;
52
+ if (!Array.isArray(list))
53
+ return undefined;
54
+ for (const c of list) {
55
+ if (!c || c.actionType !== 'CLAIM_BENEFIT')
56
+ continue;
57
+ const start = epoch(c.startAt ?? c.beginAt);
58
+ const end = epoch(c.endAt);
59
+ if (start !== undefined && now < start)
60
+ continue;
61
+ if (end !== undefined && now > end)
62
+ continue;
63
+ return c;
64
+ }
65
+ return undefined;
66
+ }
67
+ const amountOf = (claim, campaign) => {
68
+ const n = Number(claim.data?.benefit?.amount ?? claim.benefit?.amount ?? campaign.benefit?.amount);
69
+ return Number.isFinite(n) && n > 0 ? n : undefined;
70
+ };
71
+ export async function qoderCheckin(pat, region) {
72
+ const base = QODER_REGIONS[region].openApi;
73
+ const token = await exchangePat(pat, region);
74
+ const list = await call(`${base}/sash/api/v1/me/campaigns`, { headers: headers(token) });
75
+ if (list.status === 401 || list.status === 403)
76
+ throw new QoderAuthError(`Qoder 登录无效(HTTP ${list.status})`);
77
+ if (list.status >= 400)
78
+ throw new Error(`Qoder 活动列表 HTTP ${list.status}`);
79
+ const campaign = claimableCampaign(list.json);
80
+ if (!campaign)
81
+ throw new Error('Qoder 现在没有可领取的签到活动(每天滚动发放,稍后会再试)');
82
+ if (campaign.claimStatus === 'CLAIMED')
83
+ return { state: 'already' };
84
+ const claim = await call(`${base}/sash/api/v1/me/campaigns/${encodeURIComponent(String(campaign.campaignId ?? campaign.id ?? ''))}/claim`, { method: 'POST', headers: { ...headers(token), 'content-type': 'application/json' }, body: '{}' });
85
+ if (claim.status >= 400)
86
+ throw new Error(`Qoder 领取失败(HTTP ${claim.status}):${String(claim.json.message ?? '').slice(0, 120)}`);
87
+ const body = (typeof claim.json.data === 'object' && claim.json.data !== null ? claim.json.data : claim.json);
88
+ if (body.replayed === true)
89
+ return { state: 'already' };
90
+ if (body.status !== 'CLAIMED' && body.success !== true)
91
+ throw new Error(`Qoder 签到返回异常:${JSON.stringify(claim.json).slice(0, 160)}`);
92
+ const credit = amountOf(claim.json, campaign);
93
+ return { state: 'signed', ...(credit !== undefined ? { credit } : {}) };
94
+ }
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { supervise } from './service/windows.js';
14
14
  import { opencode } from './commands/opencode.js';
15
15
  import { workbuddy } from './commands/workbuddy.js';
16
16
  import { bridge } from './commands/bridge.js';
17
- import { enableInteractive, failures, source, sources, stats } from './commands/sources.js';
17
+ import { checkin, enableInteractive, failures, source, sources, stats } from './commands/sources.js';
18
18
  import { createContext } from './context.js';
19
19
  import { isDshModelError } from './errors.js';
20
20
  import { L, initLang } from './i18n.js';
@@ -69,6 +69,7 @@ function help() {
69
69
  login <来源> / logout <来源> 同 source enable / source logout
70
70
  stats 按 key / 来源 / 模型的成功率、延迟、速度
71
71
  failures 最近 24 小时失败的请求,以及该怎么办
72
+ checkin 现在签到一次(WorkBuddy 国内版、Qoder;默认每天自动签)
72
73
  status 一屏总览
73
74
  doctor [--e2e] [--all] [--model M] 逐项自检;--e2e 实测流式与工具调用
74
75
  connect-dsh [--dry-run] [--force] 写入 / 刷新 dsh 配置
@@ -101,6 +102,7 @@ Usage: dsh-model <command> [options]
101
102
  login <source> / logout <source> Same as source enable / source logout
102
103
  stats Success rate, latency and speed by key / source / model
103
104
  failures Failed requests in the last 24 hours, and what to do
105
+ checkin Check in now (WorkBuddy China, Qoder; automatic daily by default)
104
106
  status Overview
105
107
  doctor [--e2e] [--all] [--model M] Health checks; --e2e tests streaming and tool calls
106
108
  connect-dsh [--dry-run] [--force] Write / refresh dsh config
@@ -171,6 +173,8 @@ export async function main(argv) {
171
173
  return await stats(ctx);
172
174
  case 'failures':
173
175
  return await failures(ctx);
176
+ case 'checkin':
177
+ return await checkin(ctx);
174
178
  case 'status':
175
179
  return await status(ctx);
176
180
  case 'doctor':
@@ -5,7 +5,7 @@
5
5
  // 3. 用新版本执行一次 repair:重写服务文件、重启引擎与守护进程、同步模型。
6
6
  import { spawn } from 'node:child_process';
7
7
  import { closeSync, openSync } from 'node:fs';
8
- import { chown, realpath } from 'node:fs/promises';
8
+ import { chown, realpath, writeFile } from 'node:fs/promises';
9
9
  import { delimiter, dirname, join } from 'node:path';
10
10
  import { requireRootInVps } from '../context.js';
11
11
  import { DshModelError, isDshModelError } from '../errors.js';
@@ -120,8 +120,12 @@ export async function selfUpdate(ctx, opts = {}) {
120
120
  }
121
121
  /** 管理页请求的日志:更新写在 home 里;卸载会删 home,写在它外面 */
122
122
  export function selfJobLog(ctx, action) {
123
- return action === 'uninstall' ? join(dirname(ctx.paths.home), 'dsh-model-uninstall.log') : join(ctx.paths.home, 'update.log');
123
+ if (action === 'uninstall')
124
+ return join(dirname(ctx.paths.home), 'dsh-model-uninstall.log');
125
+ return join(ctx.paths.home, action === 'repair' ? 'repair.log' : 'update.log');
124
126
  }
127
+ /** admin-run 跑完在日志最后写的一行:页面据此知道结束了、成没成 */
128
+ export const ADMIN_DONE_RE = /^\[dsh-model\] exit (-?\d+)$/m;
125
129
  /** 由 dsh-model-admin.service(root)执行:取出管理页的请求单,只做 update / uninstall */
126
130
  export async function adminRun(ctx) {
127
131
  if (!ctx.isRoot)
@@ -138,7 +142,8 @@ export async function adminRun(ctx) {
138
142
  const out = openSync(log, 'w', 0o600);
139
143
  if (ctx.owner)
140
144
  await chown(log, ctx.owner.uid, ctx.owner.gid).catch(() => { });
141
- const args = req.action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(req.keepAuth ? ['--keep-auth'] : [])];
145
+ // repair 就是重跑 setup(幂等,什么缺了补什么;比 repair 命令能修的多)
146
+ const args = req.action === 'update' ? ['update'] : req.action === 'repair' ? ['setup', '--skip-dsh-plugin'] : ['uninstall', '--yes', '--remove-plugin', ...(req.keepAuth ? ['--keep-auth'] : [])];
142
147
  const code = await new Promise((resolve) => {
143
148
  const child = spawn(process.execPath, [join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', req.lang ?? 'zh'], {
144
149
  stdio: ['ignore', out, out],
@@ -149,5 +154,6 @@ export async function adminRun(ctx) {
149
154
  child.on('exit', (c) => resolve(c ?? -1));
150
155
  });
151
156
  closeSync(out);
157
+ await writeFile(log, `\n[dsh-model] exit ${code}\n`, { flag: 'a' }).catch(() => { });
152
158
  return code;
153
159
  }
@@ -213,3 +213,26 @@ export async function failures(ctx) {
213
213
  }
214
214
  return 0;
215
215
  }
216
+ /** dsh-model checkin:现在签到一次(WorkBuddy 国内版、Qoder),显示每家的结果 */
217
+ export async function checkin(ctx) {
218
+ const st = await control(ctx, 'POST', '/checkin', {}, 120_000);
219
+ if (isJsonMode()) {
220
+ printJson(st);
221
+ return 0;
222
+ }
223
+ if (!st.available.length) {
224
+ warn(L('没有能签到的:登录 WorkBuddy 国内版,或在 dsh 管理页的签到卡片里添加 Qoder', 'Nothing to check in: sign in to WorkBuddy (China), or add Qoder in the check-in card of the dsh page'));
225
+ return 1;
226
+ }
227
+ let failed = 0;
228
+ for (const r of st.results) {
229
+ if (r.state === 'failed') {
230
+ failed++;
231
+ warn(`${r.label}:${L('失败', 'failed')}:${r.message ?? ''}${r.advice ? ` — ${r.advice}` : ''}`);
232
+ }
233
+ else
234
+ ok(`${r.label}:${r.state === 'signed' ? L(`签到成功${r.credit ? ` +${r.credit} 积分` : ''}`, `checked in${r.credit ? ` +${r.credit} credits` : ''}`) : L('今天已签过', 'already checked in today')}`);
235
+ }
236
+ info(dim(L(`自动签到:${st.auto ? '开' : '关'}(北京时间 ${st.date})`, `Auto check-in: ${st.auto ? 'on' : 'off'} (Beijing date ${st.date})`)));
237
+ return failed ? 1 : 0;
238
+ }
@@ -57,10 +57,15 @@ export async function uninstall(ctx, opts) {
57
57
  if (all.state.remote && all.state.remote.mode !== 'off')
58
58
  await disableRemote(ctx, all);
59
59
  });
60
- await step('dsh-plugin', async () => {
60
+ // 移除插件只是尽力而为:dsh 的 pnpm 可能因"新版本要等一天"的规则拒绝改动(2026-10-14 实测,连删除也会被拦)。
61
+ // 这一步失败不该让卸载停在半路:服务、配置照样清掉,home 照样删,插件请用户在 dsh 的插件页手动删。
62
+ try {
61
63
  if (await removeDshPlugin(ctx, all, { force: opts.removePlugin }))
62
64
  ok(L('已从 dsh 移除 dsh-model 插件', 'Removed the dsh-model plugin from dsh'));
63
- });
65
+ }
66
+ catch (error) {
67
+ warn(L(`没能自动从 dsh 移除 dsh-model 插件(${error.message}):请在 dsh 的插件页里手动删掉它`, `Could not remove the dsh-model plugin from dsh automatically (${error.message}); remove it on the dsh Plugins page`));
68
+ }
64
69
  await step('workbuddy', async () => {
65
70
  if (await removeWorkbuddyPlugin(ctx, all))
66
71
  ok(L('已移除上一版安装的 WorkBuddy 插件', 'Removed the WorkBuddy plugin installed by a previous version'));
@@ -16,6 +16,8 @@ import { lang } from '../i18n.js';
16
16
  import { which } from '../util/exec.js';
17
17
  import { spawnDetached } from '../util/detach.js';
18
18
  import { classify, recentFailures } from './failures.js';
19
+ import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheckin, setAuto, todayResults } from '../checkin/index.js';
20
+ import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
19
21
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
20
22
  import { randomBytes } from 'node:crypto';
21
23
  import { currentBinary } from '../engine/install.js';
@@ -31,7 +33,7 @@ import { riskNotice as workbuddyRiskNotice } from '../integrations/workbuddy.js'
31
33
  import { DSH_KEY_NAME, addKey, loadKeys, revokeKey, rotateKey, saveKeys } from '../keys.js';
32
34
  import { applyEngineConfig, dshCandidates, dshKey, loadAll, saveAll, syncAll } from '../ops.js';
33
35
  import { defaultPick, pickedFor } from '../dsh/pick.js';
34
- import { loadSecrets } from '../secrets.js';
36
+ import { loadSecrets, saveSecrets } from '../secrets.js';
35
37
  import { SOURCES, credsFor, findSource, paymentRequired } from '../sources.js';
36
38
  import { withLock } from '../state.js';
37
39
  import { riskNotice as upstreamRiskNotice } from '../upstreams.js';
@@ -84,6 +86,9 @@ export class Daemon {
84
86
  if (await this.refreshCatalogs())
85
87
  void this.resync();
86
88
  this.timers.push(setInterval(() => void this.refreshCatalogs().then((c) => (c ? this.resync() : undefined)), CATALOG_REFRESH_MS));
89
+ // 每日签到:每 15 分钟看一眼该不该签(到点、有没签上的);启动一分钟后先看一次(错过的补签)
90
+ this.timers.push(setInterval(() => void this.autoCheckin().catch(() => { }), 15 * 60 * 1000));
91
+ setTimeout(() => void this.autoCheckin().catch(() => { }), 60_000);
87
92
  // 本机模型服务(Ollama、LM Studio)随时可能启动或退出:两分钟问一次,变了就同步
88
93
  this.timers.push(setInterval(() => void refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).then((c) => (c ? this.resync() : undefined), () => { }), 2 * 60 * 1000));
89
94
  this.timers.push(setInterval(() => void this.pollUsage(), USAGE_POLL_MS));
@@ -705,6 +710,69 @@ export class Daemon {
705
710
  .filter((k) => !k.revokedAt)
706
711
  .map((k) => ({ name: k.name, key: redactKey(k.key), createdAt: k.createdAt, ...(snap.byKey[k.name] ? { stats: snap.byKey[k.name] } : {}) }));
707
712
  }
713
+ // —— 每日签到 ——
714
+ /** 现在能签到的:WorkBuddy 国内版(已登录)、Qoder(填了个人访问令牌) */
715
+ async checkers() {
716
+ const out = [];
717
+ for (const rt of this.runtimes) {
718
+ if (rt.variant.id !== 'workbuddy')
719
+ continue; // 国际版没有签到
720
+ const st = await rt.wbStore.status().catch(() => null);
721
+ if (st?.state !== 'signed-in')
722
+ continue;
723
+ out.push({ id: 'workbuddy', label: rt.label, run: async () => rt.wbClient.dailyCheckin(await rt.wbStore.resolve()) });
724
+ }
725
+ const q = (await loadSecrets(this.ctx)).checkin?.qoder;
726
+ if (q?.pat)
727
+ out.push({ id: 'qoder', label: QODER_REGIONS[q.region].label, run: () => qoderCheckin(q.pat, q.region) });
728
+ return out;
729
+ }
730
+ async checkinStatus() {
731
+ const file = await loadCheckin(this.ctx.paths.home);
732
+ const checkers = await this.checkers();
733
+ const q = (await loadSecrets(this.ctx)).checkin?.qoder;
734
+ return {
735
+ auto: file.auto !== false,
736
+ date: beijingNow().date,
737
+ results: todayResults(file).map((r) => (r.state === 'failed' ? { ...r, advice: checkinAdvice(r.id, r.message ?? '') } : r)),
738
+ available: checkers.map((c) => ({ id: c.id, label: c.label })),
739
+ qoder: { configured: Boolean(q?.pat), ...(q ? { region: q.region } : {}) },
740
+ };
741
+ }
742
+ checkinInflight;
743
+ /** 一键签到(force:今天已成功的也再确认一遍) */
744
+ async checkinNow(force = true) {
745
+ this.checkinInflight ??= runCheckin(this.ctx.paths.home, await this.checkers(), { force, ...(this.ctx.owner ? { owner: this.ctx.owner } : {}) }).finally(() => (this.checkinInflight = undefined));
746
+ await this.checkinInflight;
747
+ return this.checkinStatus();
748
+ }
749
+ /** 自动签到:到点且有没签上的就签 */
750
+ async autoCheckin() {
751
+ const file = await ensureJitter(this.ctx.paths.home, this.ctx.owner).catch(() => null);
752
+ if (!file)
753
+ return;
754
+ const checkers = await this.checkers();
755
+ if (!autoDue(file, checkers))
756
+ return;
757
+ const results = await this.checkinNow(false).catch(() => null);
758
+ if (results)
759
+ log_(`daemon: auto check-in ${results.results.map((r) => `${r.id}=${r.state}`).join(' ')}`);
760
+ }
761
+ async setQoderPat(pat, region) {
762
+ if (!pat || /\s/.test(pat) || pat.length < 8)
763
+ throw new DshModelError('invalid_key', L('这看起来不是 Qoder 的个人访问令牌', 'That does not look like a Qoder access token'));
764
+ await exchangePat(pat, region); // 换得到短期 token 才算可用
765
+ const secrets = await loadSecrets(this.ctx);
766
+ secrets.checkin = { ...secrets.checkin, qoder: { pat, region, savedAt: new Date().toISOString() } };
767
+ await saveSecrets(this.ctx, secrets);
768
+ }
769
+ async removeQoderPat() {
770
+ const secrets = await loadSecrets(this.ctx);
771
+ if (secrets.checkin?.qoder) {
772
+ delete secrets.checkin.qoder;
773
+ await saveSecrets(this.ctx, secrets);
774
+ }
775
+ }
708
776
  // —— 更新 / 卸载(管理页的按钮)——
709
777
  selfJob;
710
778
  /** 版本与能不能在页面上自管理(VPS 上要 root,只给命令) */
@@ -952,6 +1020,22 @@ export class Daemon {
952
1020
  return ok(await this.endpoints());
953
1021
  if (method === 'GET' && path === '/self')
954
1022
  return ok(await this.selfInfo());
1023
+ if (method === 'GET' && path === '/checkin')
1024
+ return ok(await this.checkinStatus());
1025
+ if (method === 'POST' && path === '/checkin')
1026
+ return ok(await this.checkinNow(true));
1027
+ if (method === 'POST' && path === '/checkin/auto') {
1028
+ await setAuto(this.ctx.paths.home, b.on !== false, this.ctx.owner);
1029
+ return ok(await this.checkinStatus());
1030
+ }
1031
+ if (method === 'POST' && path === '/checkin/qoder') {
1032
+ await this.setQoderPat(String(b.pat ?? '').trim(), b.region === 'intl' ? 'intl' : 'cn');
1033
+ return ok(await this.checkinNow(true));
1034
+ }
1035
+ if (method === 'DELETE' && path === '/checkin/qoder') {
1036
+ await this.removeQoderPat();
1037
+ return ok(await this.checkinStatus());
1038
+ }
955
1039
  if (method === 'GET' && path === '/failures')
956
1040
  return ok(await recentFailures(this.ctx.paths.logs));
957
1041
  if (method === 'POST' && path === '/models/test')
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 守护进程以 dsh 用户运行,而更新(npm 全局目录、系统服务)和卸载(Caddy、防火墙、系统服务)要 root。
4
4
  // 所以 setup(root)装一个 systemd path 单元:守护进程在 <home>/admin/request 写一张请求单,
5
- // systemd 以 root 运行 `dsh-model admin-run`,它只认三件固定的事:更新到 npm 最新版、卸载、重启 dsh(插件更新后要重启才生效)。别的一律拒绝。
5
+ // systemd 以 root 运行 `dsh-model admin-run`,它只认四件固定的事:更新到 npm 最新版、卸载、重启 dsh(插件更新后要重启才生效)、修复(重跑 setup)。别的一律拒绝。
6
6
  import { chown, mkdir, readFile, rm } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { run } from '../util/exec.js';
@@ -76,7 +76,7 @@ export async function takeAdminRequest(ctx) {
76
76
  await rm(file, { force: true });
77
77
  try {
78
78
  const r = JSON.parse(raw);
79
- if (r.action !== 'update' && r.action !== 'uninstall' && r.action !== 'restart-dsh')
79
+ if (r.action !== 'update' && r.action !== 'uninstall' && r.action !== 'restart-dsh' && r.action !== 'repair')
80
80
  return null;
81
81
  return { action: r.action, keepAuth: r.keepAuth === true, lang: r.lang === 'en' ? 'en' : 'zh' };
82
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
4
4
  "description": "Many model sources behind one authenticated OpenAI-compatible endpoint for DeepSeek Harness (dsh) and your tools: WorkBuddy, OpenCode Zen and your CLI subscriptions (Codex, Kimi…), with a management page inside dsh. 多家模型汇成一个 OpenAI 兼容地址,dsh 和其他软件都能直接用。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
package/plugin/client.js CHANGED
@@ -614,7 +614,8 @@ window.__ModuleLoader__.load({
614
614
  }
615
615
  }
616
616
  const cmds = notInstalled ? ['npm install -g dsh-model', 'dsh-model setup'] : ['dsh-model repair']
617
- const vpsCmds = notInstalled ? ['sudo npm install -g dsh-model', 'sudo dsh-model setup'] : ['sudo dsh-model repair']
617
+ // setup 什么缺了补什么(卸载到一半、服务被删了也能救回来),比 repair 稳
618
+ const vpsCmds = notInstalled ? ['sudo npm install -g dsh-model', 'sudo dsh-model setup'] : ['sudo dsh-model setup']
618
619
  const cmdBlock = (list) => h('div', { style: { ...S.card, padding: '8px 12px', marginTop: 8 } }, list.map((c) => h('div', { key: c, style: { ...S.line, minHeight: 30 } }, h('code', { style: { ...S.mono, ...S.grow } }, c), h(CopyButton, { get: c }))))
619
620
  return h('div', { style: { color: C.text, maxWidth: 820 } },
620
621
  h('div', { style: S.section },
@@ -623,7 +624,7 @@ window.__ModuleLoader__.load({
623
624
  ? L('这个页面只是管理界面;模型由 dsh-model 在本机运行的服务提供(统一端点、WorkBuddy bridge、登录与用量)。装好后,来源、key 和模型都在这里管理。', 'This page is only the control panel; models are served by dsh-model\'s local service (the unified endpoint, WorkBuddy bridge, sign-ins and usage). Once it is set up, manage sources, keys and models here.')
624
625
  : L('可能是电脑重启后服务没起来,或者刚升级。修复会按记录重新注册并启动服务。', 'The service may not have started after a reboot, or was just upgraded. Repair re-registers and starts it from the saved records.')),
625
626
  res?.vps ? h('div', null,
626
- h('p', { style: { ...S.note, color: C.text } }, L('这是 dsh-vps 服务器:安装要 root 权限,请在服务器终端里执行:', 'This is a dsh-vps server: setup needs root, so run this in a server terminal:')),
627
+ h('p', { style: { ...S.note, color: C.text } }, L('这是 dsh-vps 服务器:这一步要 root 权限,而这台机器上还没有 dsh-model 的管理员服务。请在服务器终端里执行一次(之后页面上的按钮就能用):', 'This is a dsh-vps server: this needs root, and dsh-model\'s admin service is not on this machine yet. Run this once in a server terminal (the page buttons work afterwards):')),
627
628
  cmdBlock(vpsCmds),
628
629
  h('p', { style: { ...S.note, marginTop: 8 } }, L('装好后刷新本页。', 'Reload this page when done.'))) : h('div', null,
629
630
  h('div', { style: S.line },
@@ -838,6 +839,104 @@ window.__ModuleLoader__.load({
838
839
  h('div', { style: { ...S.line, marginTop: 6, justifyContent: 'flex-end' } }, h('button', { type: 'button', style: S.btn, onClick: dismiss }, L('知道了', 'Got it'))))
839
840
  }
840
841
 
842
+ // —— 每日签到 ——
843
+ 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 || ''}`))
844
+
845
+ function QoderDialog({ onDone, onClose }) {
846
+ const [pat, setPat] = useState('')
847
+ const [region, setRegion] = useState('cn')
848
+ const [busy, setBusy] = useState(false)
849
+ const [msg, setMsg] = useState('')
850
+ const save = async () => {
851
+ setBusy(true)
852
+ setMsg(L('正在验证这个令牌并签到…', 'Checking the token and checking in…'))
853
+ try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
854
+ }
855
+ return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
856
+ h('p', { style: S.note }, L('在 Qoder 的账号设置里创建一个个人访问令牌(Personal Access Token),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Create a Personal Access Token in your Qoder account settings and paste it here. It is only used to claim the daily credits, and stays on this machine.')),
857
+ h('div', { style: { ...S.line, marginBottom: 8 } },
858
+ ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
859
+ h('input', { type: 'radio', name: 'qoder-region', checked: region === r, onChange: () => setRegion(r) }), r === 'cn' ? L('国内版', 'China') : L('国际版', 'International')))),
860
+ h('div', { style: S.line },
861
+ h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('个人访问令牌', 'Personal access token'), value: pat, onChange: (e) => setPat(e.target.value) }),
862
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !pat.trim(), onClick: () => void save() }, L('保存', 'Save'))),
863
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
864
+ }
865
+
866
+ /** 签到卡片:平时一行"今日已完成签到:…";没签完的列出来,一键签到;自动签到开关 */
867
+ function CheckinCard() {
868
+ const [st, setSt] = useState(null)
869
+ const [busy, setBusy] = useState(false)
870
+ const [open, setOpen] = useState(false)
871
+ const [qoder, setQoder] = useState(false)
872
+ const [err, setErr] = useState('')
873
+ const refresh = useCallback(() => api('GET', '/checkin').then(setSt, () => {}), [])
874
+ useEffect(() => { void refresh(); const t = setInterval(refresh, 5 * 60 * 1000); return () => clearInterval(t) }, [refresh])
875
+ if (!st) return null
876
+ const byId = new Map(st.results.map((r) => [r.id, r]))
877
+ const done = st.available.filter((a) => { const r = byId.get(a.id); return r && r.state !== 'failed' })
878
+ const pending = st.available.filter((a) => !done.includes(a))
879
+ const run = async () => {
880
+ setBusy(true)
881
+ setErr('')
882
+ try { setSt(await api('POST', '/checkin')); setOpen(true) } catch (e) { setErr(e.message) } finally { setBusy(false) }
883
+ }
884
+ const toggleAuto = async (on) => {
885
+ try { setSt(await api('POST', '/checkin/auto', { on })) } catch (e) { setErr(e.message) }
886
+ }
887
+ if (!st.available.length && st.qoder.configured === false) {
888
+ // 没有能签到的:只留一个很小的入口
889
+ return h('div', { style: { ...S.section, marginBottom: 16 } },
890
+ h('div', { style: { ...S.meta } }, L('每日签到:登录 WorkBuddy 国内版,或', 'Daily check-in: sign in to WorkBuddy (China), or '),
891
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder'))),
892
+ qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
893
+ }
894
+ return h('div', { style: S.section },
895
+ h('div', { style: { ...S.card, padding: '10px 14px' } },
896
+ h('div', { style: S.line },
897
+ h('span', { style: { ...S.grow, fontSize: 13 } },
898
+ done.length ? h('span', { style: { color: C.ok } }, L(`今日已完成签到:${done.map((a) => a.label).join('、')}`, `Checked in today: ${done.map((a) => a.label).join(', ')}`)) : null,
899
+ done.length && pending.length ? h('span', { style: { color: C.sub } }, L(';', '; ')) : null,
900
+ pending.length ? h('span', { style: { color: C.text } }, L(`还没签:${pending.map((a) => a.label).join('、')}`, `Not yet: ${pending.map((a) => a.label).join(', ')}`)) : null),
901
+ pending.length ? h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: () => void run() }, busy ? L('签到中…', 'Checking in…') : L('一键签到', 'Check in all')) : null,
902
+ st.results.length ? h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')) : null,
903
+ h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
904
+ h('input', { type: 'checkbox', checked: st.auto, onChange: (e) => void toggleAuto(e.target.checked) }), L('自动签到', 'Auto'))),
905
+ open ? h('div', { style: { marginTop: 8 } },
906
+ st.results.map((r) => h('div', { key: r.id, style: { fontSize: 12, lineHeight: 1.8, color: r.state === 'failed' ? C.err : C.text } },
907
+ `${r.label}:${CHECKIN_TEXT(r)}`, r.advice ? h('span', { style: { color: C.sub, marginLeft: 6 } }, r.advice) : null)),
908
+ h('div', { style: { ...S.line, marginTop: 6 } },
909
+ st.qoder.configured
910
+ ? 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'))
911
+ : h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder')))) : null,
912
+ !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,
913
+ err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null),
914
+ qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
915
+ }
916
+
917
+ /** 签完在 dsh 右下角提示一次(成功或失败),一天一次 */
918
+ const CHECKIN_SEEN = 'dsh-model.checkinSeen'
919
+ function CheckinNotice() {
920
+ const [st, setSt] = useState(null)
921
+ const [seen, setSeen] = useState(() => { try { return window.localStorage.getItem(CHECKIN_SEEN) || '' } catch { return '' } })
922
+ useEffect(() => {
923
+ let stop = false
924
+ const tick = () => api('GET', '/checkin').then((r) => { if (!stop) setSt(r) }, () => {})
925
+ void tick()
926
+ const t = setInterval(tick, 15 * 60 * 1000)
927
+ return () => { stop = true; clearInterval(t) }
928
+ }, [])
929
+ if (!st || !st.results.length) return null
930
+ const key = `${st.date}:${st.results.map((r) => `${r.id}=${r.state}`).join(',')}`
931
+ if (seen === key) return null
932
+ const failed = st.results.filter((r) => r.state === 'failed')
933
+ const hide = () => { setSeen(key); try { window.localStorage.setItem(CHECKIN_SEEN, key) } catch { /* 无痕模式 */ } }
934
+ return h('div', { role: 'status', style: { position: 'fixed', right: 16, bottom: 120, zIndex: 900, maxWidth: 360, background: C.base, color: C.text, border: `1px solid ${failed.length ? C.err : C.ok}`, borderRadius: 10, padding: '10px 12px', boxShadow: 'var(--dsw-elevation-soft, 0 8px 30px rgba(0,0,0,.2))', fontSize: 13 } },
935
+ h('div', { style: { fontWeight: 600, marginBottom: 4, color: failed.length ? C.err : C.ok } }, failed.length ? L('dsh-model:有签到没成功', 'dsh-model: a check-in failed') : L('dsh-model:今日签到完成', 'dsh-model: checked in for today')),
936
+ st.results.map((r) => h('div', { key: r.id, style: { lineHeight: 1.6 } }, `${r.label}:${CHECKIN_TEXT(r)}`)),
937
+ h('div', { style: { ...S.line, marginTop: 6, justifyContent: 'flex-end' } }, h('button', { type: 'button', style: S.btn, onClick: hide }, L('知道了', 'Got it'))))
938
+ }
939
+
841
940
  // —— 首次使用向导 ——
842
941
  const GUIDE_KEY = 'dsh-model.guideDone'
843
942
  const readGuideDone = () => {
@@ -926,6 +1025,7 @@ window.__ModuleLoader__.load({
926
1025
  return h('div', { style: { color: C.text, maxWidth: 820 } },
927
1026
  banner,
928
1027
  h(Guide, { sources: data.sources, onConnect: (s) => void connect(s) }),
1028
+ h(CheckinCard, null),
929
1029
  err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
930
1030
  h(EndpointCard, { endpoints: data.endpoints }),
931
1031
  h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: (s) => setKeyDialog(s || true) }),
@@ -953,6 +1053,11 @@ window.__ModuleLoader__.load({
953
1053
  } catch (error) {
954
1054
  console.warn('[dsh-model] 额度提醒注册失败', error)
955
1055
  }
1056
+ try {
1057
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-model-checkin' }, CheckinNotice))
1058
+ } catch (error) {
1059
+ console.warn('[dsh-model] 签到提示注册失败', error)
1060
+ }
956
1061
  }
957
1062
 
958
1063
  module.exports = { name, inject, apply, __test: { until, pct, ms, lowQuota } }
package/plugin/index.js CHANGED
@@ -62,6 +62,9 @@ const ALLOWED = [
62
62
  ['GET', /^\/endpoints$/],
63
63
  ['GET', /^\/self$/],
64
64
  ['GET', /^\/failures$/],
65
+ ['GET', /^\/checkin$/],
66
+ ['POST', /^\/checkin(\/auto|\/qoder)?$/],
67
+ ['DELETE', /^\/checkin\/qoder$/],
65
68
  ['POST', /^\/models\/test$/],
66
69
  ['POST', /^\/self\/(update|uninstall)$/],
67
70
  ['GET', /^\/sources\/[a-z-]{2,20}\/models$/],
@@ -235,7 +238,19 @@ export function apply(ctx) {
235
238
  } catch {
236
239
  return json(res, 400, { error: { code: 'invalid_json', message: 'invalid JSON' } })
237
240
  }
238
- if (await exists(VPS_ROOT)) return json(res, 200, { vps: true })
241
+ if (await exists(VPS_ROOT)) {
242
+ // dsh-vps:安装要 root。管理员小服务还在(setup 装的)就交给它跑 setup,页面照样看进度;不在就只能给命令
243
+ if (!(await exists(ADMIN_PATH_UNIT))) return json(res, 200, { vps: true })
244
+ const log = join(home(), 'repair.log')
245
+ if (body.poll) {
246
+ const text = await readFile(log, 'utf8').catch(() => '')
247
+ const m = /^\[dsh-model\] exit (-?\d+)$/m.exec(text)
248
+ return json(res, 200, { running: !m, output: text.replace(/\n?\[dsh-model\] exit -?\d+\n?$/, '').replace(/\x1b\[[0-9;]*m/g, ''), code: m ? Number(m[1]) : null, action: 'repair' })
249
+ }
250
+ await writeFile(log, '', { mode: 0o600 }).catch(() => {})
251
+ await writeFile(join(home(), 'admin', 'request'), JSON.stringify({ action: 'repair', lang: body.lang === 'en' ? 'en' : 'zh' }), { mode: 0o600 })
252
+ return json(res, 200, { running: true, output: '', code: null, action: 'repair' })
253
+ }
239
254
  // { poll: true }:只取进度;否则开始(已经在跑就接着看同一个)
240
255
  if (body.poll) return json(res, 200, job ? { running: job.running, output: job.output, code: job.code, action: job.action } : { running: false, output: '', code: null })
241
256
  const action = body.action === 'repair' ? 'repair' : 'setup'