dsh-model 0.19.1 → 0.20.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.
@@ -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':
@@ -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
+ }
@@ -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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
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
@@ -838,6 +838,104 @@ window.__ModuleLoader__.load({
838
838
  h('div', { style: { ...S.line, marginTop: 6, justifyContent: 'flex-end' } }, h('button', { type: 'button', style: S.btn, onClick: dismiss }, L('知道了', 'Got it'))))
839
839
  }
840
840
 
841
+ // —— 每日签到 ——
842
+ 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 || ''}`))
843
+
844
+ function QoderDialog({ onDone, onClose }) {
845
+ const [pat, setPat] = useState('')
846
+ const [region, setRegion] = useState('cn')
847
+ const [busy, setBusy] = useState(false)
848
+ const [msg, setMsg] = useState('')
849
+ const save = async () => {
850
+ setBusy(true)
851
+ setMsg(L('正在验证这个令牌并签到…', 'Checking the token and checking in…'))
852
+ try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
853
+ }
854
+ return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
855
+ 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.')),
856
+ h('div', { style: { ...S.line, marginBottom: 8 } },
857
+ ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
858
+ h('input', { type: 'radio', name: 'qoder-region', checked: region === r, onChange: () => setRegion(r) }), r === 'cn' ? L('国内版', 'China') : L('国际版', 'International')))),
859
+ h('div', { style: S.line },
860
+ h('input', { type: 'password', style: { ...S.input, ...S.grow }, placeholder: L('个人访问令牌', 'Personal access token'), value: pat, onChange: (e) => setPat(e.target.value) }),
861
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !pat.trim(), onClick: () => void save() }, L('保存', 'Save'))),
862
+ msg ? h('div', { style: { ...S.meta, marginTop: 8 } }, msg) : null)
863
+ }
864
+
865
+ /** 签到卡片:平时一行"今日已完成签到:…";没签完的列出来,一键签到;自动签到开关 */
866
+ function CheckinCard() {
867
+ const [st, setSt] = useState(null)
868
+ const [busy, setBusy] = useState(false)
869
+ const [open, setOpen] = useState(false)
870
+ const [qoder, setQoder] = useState(false)
871
+ const [err, setErr] = useState('')
872
+ const refresh = useCallback(() => api('GET', '/checkin').then(setSt, () => {}), [])
873
+ useEffect(() => { void refresh(); const t = setInterval(refresh, 5 * 60 * 1000); return () => clearInterval(t) }, [refresh])
874
+ if (!st) return null
875
+ const byId = new Map(st.results.map((r) => [r.id, r]))
876
+ const done = st.available.filter((a) => { const r = byId.get(a.id); return r && r.state !== 'failed' })
877
+ const pending = st.available.filter((a) => !done.includes(a))
878
+ const run = async () => {
879
+ setBusy(true)
880
+ setErr('')
881
+ try { setSt(await api('POST', '/checkin')); setOpen(true) } catch (e) { setErr(e.message) } finally { setBusy(false) }
882
+ }
883
+ const toggleAuto = async (on) => {
884
+ try { setSt(await api('POST', '/checkin/auto', { on })) } catch (e) { setErr(e.message) }
885
+ }
886
+ if (!st.available.length && st.qoder.configured === false) {
887
+ // 没有能签到的:只留一个很小的入口
888
+ return h('div', { style: { ...S.section, marginBottom: 16 } },
889
+ h('div', { style: { ...S.meta } }, L('每日签到:登录 WorkBuddy 国内版,或', 'Daily check-in: sign in to WorkBuddy (China), or '),
890
+ h('button', { type: 'button', style: { ...S.btn, marginLeft: 6 }, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder'))),
891
+ qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
892
+ }
893
+ return h('div', { style: S.section },
894
+ h('div', { style: { ...S.card, padding: '10px 14px' } },
895
+ h('div', { style: S.line },
896
+ h('span', { style: { ...S.grow, fontSize: 13 } },
897
+ 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,
898
+ done.length && pending.length ? h('span', { style: { color: C.sub } }, L(';', '; ')) : null,
899
+ pending.length ? h('span', { style: { color: C.text } }, L(`还没签:${pending.map((a) => a.label).join('、')}`, `Not yet: ${pending.map((a) => a.label).join(', ')}`)) : null),
900
+ pending.length ? h('button', { type: 'button', style: S.btnPrimary, disabled: busy, onClick: () => void run() }, busy ? L('签到中…', 'Checking in…') : L('一键签到', 'Check in all')) : null,
901
+ st.results.length ? h('button', { type: 'button', style: S.btn, onClick: () => setOpen(!open) }, open ? L('收起', 'Hide') : L('详情', 'Details')) : null,
902
+ h('label', { style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
903
+ h('input', { type: 'checkbox', checked: st.auto, onChange: (e) => void toggleAuto(e.target.checked) }), L('自动签到', 'Auto'))),
904
+ open ? h('div', { style: { marginTop: 8 } },
905
+ st.results.map((r) => h('div', { key: r.id, style: { fontSize: 12, lineHeight: 1.8, color: r.state === 'failed' ? C.err : C.text } },
906
+ `${r.label}:${CHECKIN_TEXT(r)}`, r.advice ? h('span', { style: { color: C.sub, marginLeft: 6 } }, r.advice) : null)),
907
+ h('div', { style: { ...S.line, marginTop: 6 } },
908
+ st.qoder.configured
909
+ ? 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'))
910
+ : h('button', { type: 'button', style: S.btn, onClick: () => setQoder(true) }, L('添加 Qoder', 'Add Qoder')))) : null,
911
+ !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,
912
+ err ? h('div', { style: { fontSize: 12, color: C.err, marginTop: 6 } }, err) : null),
913
+ qoder ? h(QoderDialog, { onDone: () => { setQoder(false); void refresh() }, onClose: () => setQoder(false) }) : null)
914
+ }
915
+
916
+ /** 签完在 dsh 右下角提示一次(成功或失败),一天一次 */
917
+ const CHECKIN_SEEN = 'dsh-model.checkinSeen'
918
+ function CheckinNotice() {
919
+ const [st, setSt] = useState(null)
920
+ const [seen, setSeen] = useState(() => { try { return window.localStorage.getItem(CHECKIN_SEEN) || '' } catch { return '' } })
921
+ useEffect(() => {
922
+ let stop = false
923
+ const tick = () => api('GET', '/checkin').then((r) => { if (!stop) setSt(r) }, () => {})
924
+ void tick()
925
+ const t = setInterval(tick, 15 * 60 * 1000)
926
+ return () => { stop = true; clearInterval(t) }
927
+ }, [])
928
+ if (!st || !st.results.length) return null
929
+ const key = `${st.date}:${st.results.map((r) => `${r.id}=${r.state}`).join(',')}`
930
+ if (seen === key) return null
931
+ const failed = st.results.filter((r) => r.state === 'failed')
932
+ const hide = () => { setSeen(key); try { window.localStorage.setItem(CHECKIN_SEEN, key) } catch { /* 无痕模式 */ } }
933
+ 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 } },
934
+ 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')),
935
+ st.results.map((r) => h('div', { key: r.id, style: { lineHeight: 1.6 } }, `${r.label}:${CHECKIN_TEXT(r)}`)),
936
+ h('div', { style: { ...S.line, marginTop: 6, justifyContent: 'flex-end' } }, h('button', { type: 'button', style: S.btn, onClick: hide }, L('知道了', 'Got it'))))
937
+ }
938
+
841
939
  // —— 首次使用向导 ——
842
940
  const GUIDE_KEY = 'dsh-model.guideDone'
843
941
  const readGuideDone = () => {
@@ -926,6 +1024,7 @@ window.__ModuleLoader__.load({
926
1024
  return h('div', { style: { color: C.text, maxWidth: 820 } },
927
1025
  banner,
928
1026
  h(Guide, { sources: data.sources, onConnect: (s) => void connect(s) }),
1027
+ h(CheckinCard, null),
929
1028
  err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
930
1029
  h(EndpointCard, { endpoints: data.endpoints }),
931
1030
  h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: (s) => setKeyDialog(s || true) }),
@@ -953,6 +1052,11 @@ window.__ModuleLoader__.load({
953
1052
  } catch (error) {
954
1053
  console.warn('[dsh-model] 额度提醒注册失败', error)
955
1054
  }
1055
+ try {
1056
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-model-checkin' }, CheckinNotice))
1057
+ } catch (error) {
1058
+ console.warn('[dsh-model] 签到提示注册失败', error)
1059
+ }
956
1060
  }
957
1061
 
958
1062
  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$/],