magicrew-auth 0.3.8 → 0.3.13

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.
@@ -1,100 +1,100 @@
1
- // 模型上下文窗口 —— 向 magpie 自己的模型库查
2
- //
3
- // ═══ 为什么不能自己填一个值 ═══════════════════════════════════════════
4
- //
5
- // magicrew 的两个模型接口**都不返回上下文窗口**:
6
- // GET /api/v1/flows/models → value / label / vision / tags / configs
7
- // GET /v1/models → id / object / created_at / owner_by
8
- //
9
- // 早前这里一律填 256_000 当保守值,后果是反的:
10
- // `pi-ai detected context overflow for model "magicrew/deepseek-v4.1-flash"`
11
- // —— agent 拿 256K 判断"放不下了",而实际窗口是 1M,白砍掉四分之三。
12
- //
13
- // magpie 自己有一份模型库(`~/.cache/magpie/models.json`,按厂商分组,
14
- // 每条带 `limit.context`),它本来就随 magpie 更新。**向它查,不要自己编。**
15
- //
16
- // 查不到才退回保守值 —— 宁可保守,不要给出超过真实窗口的数(那会让 agent
17
- // 以为还有余量,请求超了上游才报错)。
18
-
19
- import { readFileSync } from "node:fs"
20
- import { homedir } from "node:os"
21
- import { join } from "node:path"
22
-
23
- /**
24
- * 查不到时的兜底:**1M**。
25
- *
26
- * 优先级从高到低:
27
- * ① 用户在 magpie GUI 里手选的(「上下文窗口」那一行)—— magpie 直接用它,插件的值只是默认
28
- * ② 本文件从 magpie 模型库查到的真实值(models.dev 数据)
29
- * ③ 这个兜底
30
- *
31
- * ⚠ 兜底故意给大的:给 1M 而实际只有 128K 时,上游会在超限时报错,用户看得到;
32
- * 给 256K 而实际有 1M 时,agent 会主动拒绝一段本可以处理的对话 —— 那是静默的能力损失,
33
- * 比报错更难查(`pi-ai detected context overflow` 就是这么来的)。
34
- */
35
- const FALLBACK_CONTEXT = 1_048_576
36
-
37
- let cache = null
38
- let cacheAt = 0
39
-
40
- /** models.json 5-6MB,读一次缓存住;10 分钟内不重复读 */
41
- const CACHE_MS = 10 * 60_000
42
-
43
- function loadLibrary() {
44
- if (cache && Date.now() - cacheAt < CACHE_MS) return cache
45
- const idx = new Map()
46
- try {
47
- const raw = readFileSync(join(homedir(), ".cache", "magpie", "models.json"), "utf8")
48
- const walk = (o) => {
49
- if (Array.isArray(o)) {
50
- for (const v of o) walk(v)
51
- return
52
- }
53
- if (!o || typeof o !== "object") return
54
- const id = typeof o.id === "string" ? o.id : ""
55
- const ctx = o.limit?.context ?? o.context_length ?? o.context_window
56
- if (id && Number(ctx) > 0) {
57
- idx.set(id.toLowerCase(), Number(ctx))
58
- // 同一个模型可能挂在多个厂商下(deepseek-ai/x、Pro/deepseek-ai/x …),
59
- // 只保留最大值 —— 那才是它真能收的
60
- const tail = id.toLowerCase().split("/").pop()
61
- if (tail) {
62
- const prev = idx.get(`~${tail}`) || 0
63
- if (Number(ctx) > prev) idx.set(`~${tail}`, Number(ctx))
64
- }
65
- }
66
- for (const v of Object.values(o)) walk(v)
67
- }
68
- walk(JSON.parse(raw))
69
- } catch {}
70
- cache = idx
71
- cacheAt = Date.now()
72
- return idx
73
- }
74
-
75
- /**
76
- * 这个模型的上下文窗口。
77
- * @param {string} modelId 插件侧的模型 id,如 `deepseek-v4.1-flash`
78
- * @returns {number} 字节按 token 计;查不到给保守值
79
- */
80
- export function contextOf(modelId) {
81
- const id = String(modelId || "").toLowerCase()
82
- if (!id) return FALLBACK_CONTEXT
83
- const idx = loadLibrary()
84
-
85
- // ① 全名命中(插件 id 恰好等于库里的 id)
86
- if (idx.has(id)) return idx.get(id)
87
-
88
- // ② 尾段命中:库里的 `deepseek-ai/deepseek-v4.1-flash` ↔ 我们的 `deepseek-v4.1-flash`
89
- if (idx.has(`~${id}`)) return idx.get(`~${id}`)
90
-
91
- // ③ 同一个模型挂在不同厂商前缀下,取最大的那个(真能收的容量)
92
- let best = 0
93
- for (const [k, v] of idx) {
94
- if (k.startsWith("~")) continue
95
- if (k.endsWith(`/${id}`) && v > best) best = v
96
- }
97
- return best || FALLBACK_CONTEXT
98
- }
99
-
100
- export const _internal = { FALLBACK_CONTEXT, loadLibrary }
1
+ // 模型上下文窗口 —— 向 magpie 自己的模型库查
2
+ //
3
+ // ═══ 为什么不能自己填一个值 ═══════════════════════════════════════════
4
+ //
5
+ // magicrew 的两个模型接口**都不返回上下文窗口**:
6
+ // GET /api/v1/flows/models → value / label / vision / tags / configs
7
+ // GET /v1/models → id / object / created_at / owner_by
8
+ //
9
+ // 早前这里一律填 256_000 当保守值,后果是反的:
10
+ // `pi-ai detected context overflow for model "magicrew/deepseek-v4.1-flash"`
11
+ // —— agent 拿 256K 判断"放不下了",而实际窗口是 1M,白砍掉四分之三。
12
+ //
13
+ // magpie 自己有一份模型库(`~/.cache/magpie/models.json`,按厂商分组,
14
+ // 每条带 `limit.context`),它本来就随 magpie 更新。**向它查,不要自己编。**
15
+ //
16
+ // 查不到才退回保守值 —— 宁可保守,不要给出超过真实窗口的数(那会让 agent
17
+ // 以为还有余量,请求超了上游才报错)。
18
+
19
+ import { readFileSync } from "node:fs"
20
+ import { homedir } from "node:os"
21
+ import { join } from "node:path"
22
+
23
+ /**
24
+ * 查不到时的兜底:**1M**。
25
+ *
26
+ * 优先级从高到低:
27
+ * ① 用户在 magpie GUI 里手选的(「上下文窗口」那一行)—— magpie 直接用它,插件的值只是默认
28
+ * ② 本文件从 magpie 模型库查到的真实值(models.dev 数据)
29
+ * ③ 这个兜底
30
+ *
31
+ * ⚠ 兜底故意给大的:给 1M 而实际只有 128K 时,上游会在超限时报错,用户看得到;
32
+ * 给 256K 而实际有 1M 时,agent 会主动拒绝一段本可以处理的对话 —— 那是静默的能力损失,
33
+ * 比报错更难查(`pi-ai detected context overflow` 就是这么来的)。
34
+ */
35
+ const FALLBACK_CONTEXT = 1_048_576
36
+
37
+ let cache = null
38
+ let cacheAt = 0
39
+
40
+ /** models.json 5-6MB,读一次缓存住;10 分钟内不重复读 */
41
+ const CACHE_MS = 10 * 60_000
42
+
43
+ function loadLibrary() {
44
+ if (cache && Date.now() - cacheAt < CACHE_MS) return cache
45
+ const idx = new Map()
46
+ try {
47
+ const raw = readFileSync(join(homedir(), ".cache", "magpie", "models.json"), "utf8")
48
+ const walk = (o) => {
49
+ if (Array.isArray(o)) {
50
+ for (const v of o) walk(v)
51
+ return
52
+ }
53
+ if (!o || typeof o !== "object") return
54
+ const id = typeof o.id === "string" ? o.id : ""
55
+ const ctx = o.limit?.context ?? o.context_length ?? o.context_window
56
+ if (id && Number(ctx) > 0) {
57
+ idx.set(id.toLowerCase(), Number(ctx))
58
+ // 同一个模型可能挂在多个厂商下(deepseek-ai/x、Pro/deepseek-ai/x …),
59
+ // 只保留最大值 —— 那才是它真能收的
60
+ const tail = id.toLowerCase().split("/").pop()
61
+ if (tail) {
62
+ const prev = idx.get(`~${tail}`) || 0
63
+ if (Number(ctx) > prev) idx.set(`~${tail}`, Number(ctx))
64
+ }
65
+ }
66
+ for (const v of Object.values(o)) walk(v)
67
+ }
68
+ walk(JSON.parse(raw))
69
+ } catch {}
70
+ cache = idx
71
+ cacheAt = Date.now()
72
+ return idx
73
+ }
74
+
75
+ /**
76
+ * 这个模型的上下文窗口。
77
+ * @param {string} modelId 插件侧的模型 id,如 `deepseek-v4.1-flash`
78
+ * @returns {number} 字节按 token 计;查不到给保守值
79
+ */
80
+ export function contextOf(modelId) {
81
+ const id = String(modelId || "").toLowerCase()
82
+ if (!id) return FALLBACK_CONTEXT
83
+ const idx = loadLibrary()
84
+
85
+ // ① 全名命中(插件 id 恰好等于库里的 id)
86
+ if (idx.has(id)) return idx.get(id)
87
+
88
+ // ② 尾段命中:库里的 `deepseek-ai/deepseek-v4.1-flash` ↔ 我们的 `deepseek-v4.1-flash`
89
+ if (idx.has(`~${id}`)) return idx.get(`~${id}`)
90
+
91
+ // ③ 同一个模型挂在不同厂商前缀下,取最大的那个(真能收的容量)
92
+ let best = 0
93
+ for (const [k, v] of idx) {
94
+ if (k.startsWith("~")) continue
95
+ if (k.endsWith(`/${id}`) && v > best) best = v
96
+ }
97
+ return best || FALLBACK_CONTEXT
98
+ }
99
+
100
+ export const _internal = { FALLBACK_CONTEXT, loadLibrary }
package/src/store.mjs CHANGED
@@ -217,6 +217,109 @@ function noteOrg(siteId, org) {
217
217
  } catch {}
218
218
  }
219
219
 
220
+ // ═══ 「首次 AI 工作 / 首个对话」的一次性标记 ═════════════════════════
221
+ //
222
+ // 这件事对每个账号**只该做一次**。目的只有一个:满足服务端
223
+ // 「被邀请人完成首个任务或对话后,邀请人才能拿到积分」那条判定。
224
+ // 邀请奖励只发一次,所以补一次就够,再发纯属白烧积分。
225
+ //
226
+ // 早前的闸门写错了 —— 按「今天发过没有」记日期,于是:
227
+ //
228
+ // ① 服务端结算慢(几十秒到几分钟)、或者任务状态压根不翻 COMPLETED 时,
229
+ // 第二天签到又看到 PENDING,再发一条。一个号天天烧钱。
230
+ // ② 只按「有没有项目」判:项目建了但消息没发出去,会被当成「已用过」
231
+ // 而永远不补;反过来项目没建成,每次进来都重试。
232
+ //
233
+ // 现在的规则:
234
+ // · 发送成功 → 写 `status: "sent"`,**此后再也不发**(换天、重登、重启都一样)
235
+ // · 真去发了但失败 → 记 attempts,最多 MAX_AUTOCHAT_ATTEMPTS 次,到顶写
236
+ // `gave-up` 也不再发(别让一个永远失败的号每次签到都撞一遍)
237
+ // · 「还没有工作台/项目」不算失败 —— 那是账号没就绪,不计数,下次再来
238
+ // · 老文件里的 `"YYYY-MM-DD"` 字符串按「已发过」读,升级后不会重发
239
+ //
240
+ // 文件:~/.cache/magpie/magicrew-autochat-<siteId>.json
241
+ // { [org]: { at: "ISO", status: "sent" | "failed" | "gave-up", attempts: n, reason?: "…" } }
242
+
243
+ const MAX_AUTOCHAT_ATTEMPTS = 3
244
+
245
+ function autochatPath(siteId) {
246
+ return join(cacheDir(), `magicrew-autochat-${siteId}.json`)
247
+ }
248
+
249
+ /**
250
+ * 读整张表。老格式(值为 `"YYYY-MM-DD"` 字符串)归一成 `{ at, status: "sent" }`,
251
+ * 这样升级后的第一次读就把历史账号认成「已经补过」。
252
+ */
253
+ export function readAutoChat(siteId) {
254
+ try {
255
+ const v = JSON.parse(readFileSync(autochatPath(siteId), "utf8"))
256
+ if (!v || typeof v !== "object") return {}
257
+ const out = {}
258
+ for (const [org, e] of Object.entries(v)) {
259
+ if (typeof e === "string") out[org] = { at: e, status: "sent", attempts: 1, legacy: true }
260
+ else if (e && typeof e === "object") out[org] = e
261
+ }
262
+ return out
263
+ } catch {
264
+ return {}
265
+ }
266
+ }
267
+
268
+ /** 这个账号的那一笔,没有就 null */
269
+ export function readAutoChatEntry(siteId, org) {
270
+ return readAutoChat(siteId)[org] ?? null
271
+ }
272
+
273
+ /** 已经补过(或已放弃)→ 一次都不再发。换天也不会。 */
274
+ export function isAutoChatSettled(siteId, org) {
275
+ const e = readAutoChatEntry(siteId, org)
276
+ return !!e && (e.status === "sent" || e.status === "gave-up")
277
+ }
278
+
279
+ function writeAutoChat(siteId, org, entry) {
280
+ try {
281
+ const m = readAutoChat(siteId)
282
+ m[org] = entry
283
+ // 只留最近 200 个账号,文件别无限涨
284
+ const keys = Object.keys(m)
285
+ if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete m[k]
286
+ const f = autochatPath(siteId)
287
+ mkdirSync(dirname(f), { recursive: true })
288
+ writeFileSync(f, JSON.stringify(m))
289
+ } catch {}
290
+ }
291
+
292
+ /** 消息确实发出去了 —— 从此这个账号不再补。只在**发送成功**后调用。 */
293
+ export function noteAutoChat(siteId, org, extra = {}) {
294
+ const prev = readAutoChatEntry(siteId, org)
295
+ writeAutoChat(siteId, org, {
296
+ at: new Date().toISOString(),
297
+ status: "sent",
298
+ attempts: (Number(prev?.attempts) || 0) + 1,
299
+ ...extra,
300
+ })
301
+ }
302
+
303
+ /**
304
+ * 真去发了但没成 —— 记一次尝试,留给下一次。
305
+ *
306
+ * @returns {{attempts:number, givenUp:boolean}}
307
+ */
308
+ export function noteAutoChatFailure(siteId, org, reason) {
309
+ const prev = readAutoChatEntry(siteId, org)
310
+ const attempts = (Number(prev?.attempts) || 0) + 1
311
+ const givenUp = attempts >= MAX_AUTOCHAT_ATTEMPTS
312
+ writeAutoChat(siteId, org, {
313
+ at: new Date().toISOString(),
314
+ status: givenUp ? "gave-up" : "failed",
315
+ attempts,
316
+ reason: String(reason ?? "").slice(0, 200),
317
+ })
318
+ return { attempts, givenUp }
319
+ }
320
+
321
+ export { MAX_AUTOCHAT_ATTEMPTS }
322
+
220
323
  export {
221
324
  AUTHOR_INVITE,
222
325
  cacheDir,
package/src/tasks.mjs CHANGED
@@ -20,7 +20,13 @@
20
20
  //
21
21
  // 每天跑一次,两步都做,任一步失败不影响另一步。
22
22
 
23
- import { fetchPoints, claimLoginGift, fetchOnboardingTasks, completeOnboardingTask } from "./api.mjs"
23
+ import { fetchPoints, claimLoginGift, fetchOnboardingTasks, completeOnboardingTask, fetchFirstWorkspaceId } from "./api.mjs"
24
+ import { isAutoChatSettled, noteAutoChat, noteAutoChatFailure } from "./store.mjs"
25
+
26
+ /** 站点 id —— 去重标记按站点分文件存 */
27
+ function siteIdOf(host) {
28
+ return host.includes("magicrew") ? "magicrew-intl" : "magicrew"
29
+ }
24
30
 
25
31
  /** 已知不支持手动完成的任务,别再白试 */
26
32
  const NOT_MANUALLY_COMPLETABLE = new Set(["first_ai_work"])
@@ -74,8 +80,24 @@ export async function runCheckin(auth) {
74
80
  failed = true
75
81
  parts.push(`新手任务读取失败:${tasksErr?.message ?? tasksErr}`)
76
82
  } else if (!tasks.length) {
77
- // ⚠ 空列表 ≠ 已完成。国际站这个接口对未登记账号恒返回空数组。
78
- parts.push("本站无新手福利任务")
83
+ // 国际站:onboarding 接口恒返回空数组,没有「新手福利」这套任务。
84
+ // 但**邀请奖励的判据是一样的** —— 官方 locale 原文「被邀请人完成首个任务
85
+ // 或对话后,即可获得 {{points}} 积分奖励」,而国际站没有任务清单,
86
+ // 所以那条判据在这边就等于「发过一条消息」。
87
+ //
88
+ // 实测(2026-10-28):新号发一条消息后,邀请人立刻 +200。
89
+ //
90
+ // 怎么判断「要不要发」:看这个账号有没有工作台项目 ——
91
+ // 一个项目都没有 = 从没发过消息 → 建项目 + 发一条
92
+ // 已经有项目 = 用过 → 跳过(别每天签到都发,白烧积分)
93
+ const r = await autoFirstChat(h)
94
+ if (r.ok) {
95
+ claimedAnything = true
96
+ parts.push(`首对话:已自动完成(${r.topicName},模型 ${r.model})`)
97
+ } else if (!r.settled) {
98
+ // settled = 这个账号早就补过了(一次性任务)—— 不必每天在签到消息里复述
99
+ parts.push(`首对话:${r.reason}`)
100
+ }
79
101
  } else {
80
102
  const pending = tasks.filter((t) => t.status !== "COMPLETED" && t.rewardStatus !== "GRANTED")
81
103
 
@@ -87,8 +109,20 @@ export async function runCheckin(auth) {
87
109
  const taskFailed = []
88
110
 
89
111
  for (const t of pending) {
90
- if (NOT_MANUALLY_COMPLETABLE.has(t.code)) {
91
- skipped.push(`${t.title}(${t.points} 分,要真跑一次 AI 任务)`)
112
+ // 「完成第一次 AI 工作」是唯一 PUT 不掉的:服务端认死必须是产品内
113
+ // 真的跑完过一次对话(PUT 回 9303)。所以改成**替它发一条消息** ——
114
+ // 走 chat-task 的纯协议通道,不开浏览器。
115
+ // 这也是邀请奖励的触发条件:被邀请人完成首个任务后,邀请人才拿到分。
116
+ if (t.code === "first_ai_work") {
117
+ const r = await autoFirstWork(h)
118
+ if (r.ok) {
119
+ claimed.push({ code: t.code, title: t.title, points: t.points })
120
+ if (r.pending) parts.push("首任务:消息已发出,等结算")
121
+ } else if (r.settled) {
122
+ // 这个账号早就补过了(一次性任务)—— 安静跳过,不必每天复述
123
+ } else {
124
+ skipped.push(`${t.title}(${t.points} 分):${r.reason}`)
125
+ }
92
126
  continue
93
127
  }
94
128
  try {
@@ -134,4 +168,147 @@ export async function runCheckin(auth) {
134
168
  }
135
169
  }
136
170
 
171
+ /**
172
+ * 替账号完成「第一次 AI 工作」:建个话题、发一条消息、等结算。
173
+ *
174
+ * 为什么不能 PUT:`PUT /onboarding/tasks/first_ai_work/completion` 回 9303
175
+ * 「该新人引导任务不支持手动完成」—— 服务端只认产品内真的有一次对话。
176
+ *
177
+ * 为什么不用浏览器:消息走 Socket.IO,协议已经摸清(见 chat-task.mjs),
178
+ * 纯 Node 发得出去,比拉一个 Chrome 再点输入框可靠得多。
179
+ *
180
+ * @returns {{ok:boolean, pending?:boolean, reason?:string}}
181
+ */
182
+ async function autoFirstWork(h) {
183
+ const siteId = siteIdOf(h.host)
184
+
185
+ // 闸门:这个账号**曾经**补过就不再发 —— 不是「今天发过没有」。
186
+ // 邀请奖励只发一次,补第二遍纯属白烧积分(见 store.mjs 那节注释)。
187
+ if (isAutoChatSettled(siteId, h.org)) {
188
+ return { ok: false, settled: true, reason: "首任务此前已补过(一次性任务)" }
189
+ }
190
+
191
+ try {
192
+ const wsId = await fetchFirstWorkspaceId(h.jwt, h.org, h.host).catch(() => "")
193
+ if (!wsId) {
194
+ // 账号还没进过工作台 —— 不算失败、也不计数:只读接口,下次签到再来
195
+ return { ok: false, reason: "这个账号还没有工作台 —— 先在网页里进一次工作台" }
196
+ }
197
+
198
+ // 重模块按需加载:WebSocket 那套挂了不该拖累整个签到
199
+ const { runChatTask } = await import("./chat-task.mjs")
200
+ const r = await runChatTask({
201
+ jwt: h.jwt,
202
+ org: h.org,
203
+ host: h.host,
204
+ workspaceId: wsId,
205
+ })
206
+ if (!r.ok) {
207
+ // 真去发了但没成 —— 计数,到上限就放弃,别让一个永远失败的号每次签到撞一遍
208
+ const f = noteAutoChatFailure(siteId, h.org, r.reason || "发送失败")
209
+ const why = r.reason || "发送失败"
210
+ return { ok: false, reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
211
+ }
212
+
213
+ // 消息确实发出去了 —— 立刻写**一次性**标记。之后不管结算成不成功、换不换天、
214
+ // 重不重登,都不会再发第二条:这件事判的是「有没有过」,不是「今天有没有」。
215
+ noteAutoChat(siteId, h.org, { topicId: r.topicId || undefined, model: r.model || undefined })
216
+
217
+ // 顺手等一会儿结算,让签到消息报得准。拿不到也无所谓 —— 标记已经写了,
218
+ // 下次签到看到 PENDING 只会安静跳过,不会再发消息。
219
+ for (let i = 0; i < 12; i++) {
220
+ await new Promise((res) => setTimeout(res, 5000))
221
+ const list = await fetchOnboardingTasks(h.jwt, h.org, h.host).catch(() => null)
222
+ if (!list) continue
223
+ const fw = list.find((x) => x.code === "first_ai_work")
224
+ if (fw && (fw.status === "COMPLETED" || fw.rewardStatus === "GRANTED")) {
225
+ return { ok: true }
226
+ }
227
+ }
228
+ return { ok: true, pending: true }
229
+ } catch (e) {
230
+ const why = String(e?.message ?? e)
231
+ const f = noteAutoChatFailure(siteId, h.org, why)
232
+ return { ok: false, reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 国际站的「首个对话」:没有任务清单可依,只能看它用没用过。
238
+ *
239
+ * 判据是**有没有工作台项目**:项目是发第一条消息时前端顺带建的,
240
+ * 一个都没有基本等于从没说过话。已经有项目 = 用过 → 直接记成已补过
241
+ * (一次性),以后再也不用查。
242
+ *
243
+ * @returns {{ok?:boolean, settled?:boolean, reason?:string, topicName?:string, model?:string}}
244
+ */
245
+ async function autoFirstChat(h) {
246
+ const siteId = siteIdOf(h.host)
247
+ // 同一道闸门:一次性。换天、重登、重启都不会再发。
248
+ if (isAutoChatSettled(siteId, h.org)) {
249
+ return { settled: true, reason: "首对话此前已补过(一次性任务)" }
250
+ }
251
+
252
+ try {
253
+ const wsId = await fetchFirstWorkspaceId(h.jwt, h.org, h.host).catch(() => "")
254
+ if (!wsId) return { reason: "这个账号还没有工作区,先在网页里进一次" }
255
+
256
+ const { findProjectId, runChatTask } = await import("./chat-task.mjs")
257
+ const existing = await findProjectId(h.jwt, h.org, h.host, wsId).catch(() => "")
258
+ if (existing) {
259
+ // 已有项目 = 早就说过话。直接落一次性标记,省得以后每次登录都查一遍。
260
+ noteAutoChat(siteId, h.org, { reason: "已有工作台项目,视为用过" })
261
+ return { settled: true, reason: "此前已用过(已有工作台项目)" }
262
+ }
263
+
264
+ const r = await runChatTask({ jwt: h.jwt, org: h.org, host: h.host, workspaceId: wsId })
265
+ if (!r.ok) {
266
+ const f = noteAutoChatFailure(siteId, h.org, r.reason || "发送失败")
267
+ const why = r.reason || "发送失败"
268
+ return { reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
269
+ }
270
+
271
+ // 发出去了才写一次性标记;失败的一直不写,留给下一次
272
+ noteAutoChat(siteId, h.org, { topicId: r.topicId || undefined, model: r.model || undefined })
273
+ return { ok: true, topicName: r.topicName, model: r.model }
274
+ } catch (e) {
275
+ const why = String(e?.message ?? e)
276
+ const f = noteAutoChatFailure(siteId, h.org, why)
277
+ return { reason: f.givenUp ? `${why}(已试 ${f.attempts} 次,不再重试)` : why }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * 账号刚加进来时的一次性补完 —— 登录成功后调用,和签到共用同一道闸门。
283
+ *
284
+ * 为什么单独开一个入口:主人的要求是「一个账号加入的时候做一次」,
285
+ * 而不是「每天签到都做」。登录那一刻就是「加入」,做完就永久标记,
286
+ * 之后无论重登多少次、签到多少天,都不会再发第二条消息。
287
+ *
288
+ * 调用方**不要 await** —— 建项目 + 发消息 + 兜底等结算最坏要一分钟,
289
+ * 登录必须立刻返回。失败也不用管:下一个签到日 checkin 会接着做(同一道闸门)。
290
+ *
291
+ * @param {{jwt:string, org:string, host:string}} h
292
+ */
293
+ export async function ensureFirstTaskOnce(h) {
294
+ const siteId = siteIdOf(h.host)
295
+ if (isAutoChatSettled(siteId, h.org)) return { settled: true }
296
+
297
+ // 国内站:有任务清单。清单里没有 first_ai_work、或者已经完成 → 什么都不用做
298
+ const tasks = await fetchOnboardingTasks(h.jwt, h.org, h.host).catch(() => null)
299
+ if (Array.isArray(tasks) && tasks.length) {
300
+ const fw = tasks.find((t) => t.code === "first_ai_work")
301
+ if (!fw) return { skipped: true, reason: "本站没有这项任务" }
302
+ if (fw.status === "COMPLETED" || fw.rewardStatus === "GRANTED") {
303
+ // 服务端已经认过了 —— 落标记,以后不查了
304
+ noteAutoChat(siteId, h.org, { reason: "服务端已标记完成" })
305
+ return { skipped: true, reason: "已完成" }
306
+ }
307
+ return autoFirstWork(h)
308
+ }
309
+
310
+ // 国际站:没有任务清单 → 看有没有工作台项目
311
+ return autoFirstChat(h)
312
+ }
313
+
137
314
  export { NOT_MANUALLY_COMPLETABLE }