magicrew-auth 0.3.8 → 0.3.14

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/src/api.mjs CHANGED
@@ -419,6 +419,88 @@ export async function fetchOwnInviteCode(token, org, host = SITES.cn.host) {
419
419
  }
420
420
  }
421
421
 
422
+ // ── ④b-2 邀请奖励明细 ────────────────────────────────────────────────
423
+ //
424
+ // GET /api/v1/invitations/rewards → { page, total, list:[…] }
425
+ //
426
+ // 每条 = 一笔「因为邀请而该发给我的奖励」:
427
+ //
428
+ // { reward_points: 1000, description: "注册奖励", status: "COMPLETED",
429
+ // status_description: "已完成", scheduled_at: "…", invited_user_id: "usi_…" }
430
+ //
431
+ // 发放条件(前端 locale 原文,2026-10-27 核对):
432
+ //
433
+ // 「被邀请人完成首个任务或对话后,即可获得 {{points}} 积分奖励。」
434
+ // 「邀请的用户开通付费套餐,在其套餐每个月度周期结束后,发放奖励积分」
435
+ //
436
+ // ⚠ 这就是「小号注册了、邀请人却没到账」的答案:**光注册不发**。
437
+ // 被邀请人得真的在**产品里**跑完一次任务/对话 —— 走 socket 的那条路。
438
+ // 通过 magpie / OpenAI 兼容网关调模型**不算**:实测同一个号用 magpie
439
+ // 调了好几轮、真扣了积分,`first_ai_work` 仍然是 PENDING(见 CHANGELOG 0.3.9)。
440
+ export async function fetchInviteRewards(token, org, host = SITES.cn.host) {
441
+ const res = await fetch(`${host}/api/v1/invitations/rewards`, {
442
+ headers: headersFor(token, org, host),
443
+ })
444
+ const body = await read(res)
445
+ const d = body?.data ?? {}
446
+ const list = Array.isArray(d.list) ? d.list : []
447
+ return {
448
+ total: Number(d.total ?? list.length),
449
+ earned: list
450
+ .filter((r) => String(r.status).toUpperCase() === "COMPLETED")
451
+ .reduce((s, r) => s + Number(r.reward_points ?? 0), 0),
452
+ list: list.map((r) => ({
453
+ points: Number(r.reward_points ?? 0),
454
+ description: String(r.description ?? ""),
455
+ status: String(r.status ?? ""),
456
+ statusText: String(r.status_description ?? ""),
457
+ scheduledAt: r.scheduled_at ?? null,
458
+ invitedUserId: String(r.invited_user_id ?? ""),
459
+ })),
460
+ }
461
+ }
462
+
463
+ // ── ④d 第一个工作区 ──────────────────────────────────────────────────
464
+ //
465
+ // 自动完成首任务要打开 `/<站点>/global/super/workspace/<id>`,这个 id 只有接口给。
466
+ // 每个账号注册时会自动建一个默认工作区,取第一个就够。
467
+ export async function fetchFirstWorkspaceId(token, org, host = SITES.cn.host) {
468
+ const res = await fetch(
469
+ `${host}/api/v1/super-agent/workspaces/queries?page=1&page_size=1&pin_priority=true`,
470
+ { headers: headersFor(token, org, host) },
471
+ )
472
+ const body = await read(res)
473
+ const d = body?.data ?? {}
474
+ const list = d.list ?? d.items ?? []
475
+ const w = list[0] ?? null
476
+ return String(w?.id ?? w?.workspace_id ?? w?.workspaceId ?? "")
477
+ }
478
+
479
+ // ── ④e 某个项目下的话题 ──────────────────────────────────────────────
480
+ //
481
+ // GET /api/v1/super-agent/projects/{id}/topics
482
+ // → { total, list:[{ id, chat_topic_id, chat_conversation_id, topic_name, task_status, … }] }
483
+ //
484
+ // 判断「这个账号到底发没发过消息」看的是它,**不是「有没有项目」** ——
485
+ // 项目在打开工作台时就会自动建,「有项目、没话题」的新号很常见。
486
+ // 实测(2026-10-28):国内站 magicrew / 国际站 magicrew-intl 两站同构,
487
+ // 同一个 workspace 下都返回 { code:1000, data:{ total, list } }。
488
+ export async function fetchProjectTopics(token, org, projectId, host = SITES.cn.host) {
489
+ const res = await fetch(`${host}/api/v1/super-agent/projects/${projectId}/topics`, {
490
+ headers: headersFor(token, org, host),
491
+ })
492
+ const body = await read(res)
493
+ const d = body?.data ?? {}
494
+ const list = Array.isArray(d.list) ? d.list : []
495
+ return list.map((t) => ({
496
+ id: String(t.id ?? ""),
497
+ chatTopicId: String(t.chat_topic_id ?? ""),
498
+ chatConversationId: String(t.chat_conversation_id ?? ""),
499
+ name: String(t.topic_name ?? ""),
500
+ status: String(t.task_status ?? ""),
501
+ }))
502
+ }
503
+
422
504
  // ── ④c 这个登录态是谁 ────────────────────────────────────────────────
423
505
  //
424
506
  // 卡片上原来显示组织码(orgcode67890 / orgcode12345),那是随机串,用户认不出是谁。
@@ -0,0 +1,335 @@
1
+ // 纯协议完成「第一次 AI 工作」—— 不开浏览器、不点页面
2
+ //
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+ // 为什么能做到
5
+ // ═══════════════════════════════════════════════════════════════════════
6
+ //
7
+ // 2026-10-27 用 CDP 抓了网页发消息的全过程,发现:
8
+ //
9
+ // · 消息走 Socket.IO(Engine.IO v3),wss://<host>/socket.io/?EIO=3&transport=websocket
10
+ // · **鉴权在消息体里**(context.authorization = JWT),不在握手头 —— 所以不用带 Cookie
11
+ // · 不需要先发 `40` CONNECT,直接 `42/im,<ns>["login",…]` 就能被受理
12
+ // · <ns> 是客户端自己生成的唯一数字(浏览器用 Date.now()),随便给不重复的就行
13
+ //
14
+ // 帧格式(实测):
15
+ //
16
+ // 42/im,<ns>["login",{context:{…}}] → 43/im,<ns>[{code:1000,…user}]
17
+ // 42/im,<ns>["chat", {context:{…},data:{…}}] → 43/im,<ns>[{code:1000,…seq}]
18
+ //
19
+ // ═══════════════════════════════════════════════════════════════════════
20
+ // 那三个 id 从哪来(这是最容易卡住的地方)
21
+ // ═══════════════════════════════════════════════════════════════════════
22
+ //
23
+ // conversation_id ← topic.chat_conversation_id
24
+ // message.topic_id ← topic.chat_topic_id
25
+ // (⚠ 不是 project.current_topic_id —— 那个传过去回 3016「话题未找到」)
26
+ //
27
+ // 话题可以直接建:POST /api/v1/super-agent/topics { topic_name, project_id }
28
+ // 返回体里就带着 chat_topic_id / chat_conversation_id,拿来即用。
29
+ //
30
+ // ═══════════════════════════════════════════════════════════════════════
31
+ // 模型
32
+ // ═══════════════════════════════════════════════════════════════════════
33
+ //
34
+ // chat 帧里必须写死一个模型。查 /api/v1/products/model-points 拿每个模型的
35
+ // input_points / output_points,按两者之和升序取最小的 —— 目前是 qwen3.5-flash
36
+ // (20 / 200),也正是网页的默认模型。没有免费档。
37
+
38
+ import { fetchModels, fetchModelPoints, SITES } from "./api.mjs"
39
+
40
+ /** 建话题用的名字池 —— 随机挑一个,别每次都是「测试」那种一眼假的 */
41
+ const TOPIC_NAMES = [
42
+ "你好",
43
+ "介绍一下你自己",
44
+ "今天适合做什么",
45
+ "帮我理一下思路",
46
+ "随便聊聊",
47
+ "写一句鼓励的话",
48
+ "解释一下什么是 AI",
49
+ "推荐一本值得读的书",
50
+ ]
51
+
52
+ const PICK_TEXT = "你好,介绍一下你自己"
53
+
54
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
55
+
56
+ function pickName() {
57
+ return TOPIC_NAMES[Math.floor(Math.random() * TOPIC_NAMES.length)]
58
+ }
59
+
60
+ /** 极简 CDP 无关:这是 WebSocket 客户端,只收发文本帧 */
61
+ async function openSocket(host, origin) {
62
+ const url = `wss://${host}/socket.io/?EIO=3&transport=websocket&timestamp=${Date.now()}`
63
+ const ws = new WebSocket(url, { headers: { Origin: origin } })
64
+ const queue = []
65
+ let notify = null
66
+
67
+ ws.addEventListener("message", (ev) => {
68
+ const d = String(ev.data || "")
69
+ if (d === "2") { try { ws.send("3") } catch {} return } // Engine.IO ping → pong
70
+ queue.push(d)
71
+ if (notify) { const n = notify; notify = null; n() }
72
+ })
73
+
74
+ await new Promise((res, rej) => {
75
+ const t = setTimeout(() => rej(new Error("WebSocket 连接超时")), 15_000)
76
+ ws.addEventListener("open", () => { clearTimeout(t); res() }, { once: true })
77
+ ws.addEventListener("error", () => { clearTimeout(t); rej(new Error("WebSocket 连接失败")) }, { once: true })
78
+ })
79
+
80
+ return {
81
+ ws,
82
+ /** 等下一条满足条件的帧;超时返回 null */
83
+ async next(pred, timeoutMs) {
84
+ const deadline = Date.now() + timeoutMs
85
+ while (Date.now() < deadline) {
86
+ for (let i = 0; i < queue.length; i++) {
87
+ if (pred(queue[i])) return queue.splice(i, 1)[0]
88
+ }
89
+ await new Promise((res) => {
90
+ notify = res
91
+ setTimeout(() => { if (notify === res) { notify = null; res() } }, 500)
92
+ })
93
+ }
94
+ return null
95
+ },
96
+ /** 把队列里所有帧读掉(用于等 AI 回复) */
97
+ drain() {
98
+ const all = queue.slice()
99
+ queue.length = 0
100
+ return all
101
+ },
102
+ close() { try { ws.close() } catch {} },
103
+ }
104
+ }
105
+
106
+ /** 按投入+产出积分之和挑最便宜的模型 */
107
+ export async function pickCheapestModel(jwt, org, host = SITES.cn.host) {
108
+ const models = await fetchModels(jwt, org, host).catch(() => [])
109
+ if (!models.length) return null
110
+ const byId = new Map(models.map((m) => [m.id, m]))
111
+ let points = {}
112
+ try {
113
+ points = await fetchModelPoints(jwt, org, models.map((m) => m.id), host)
114
+ } catch {}
115
+
116
+ const scored = models
117
+ .map((m) => {
118
+ const p = points[m.id] || {}
119
+ const cost = Number(p.inputPoints ?? 0) + Number(p.outputPoints ?? 0)
120
+ return { id: m.id, name: m.name, cost, known: p.inputPoints != null }
121
+ })
122
+ // 有计费信息的优先;都没有就按名字兜底
123
+ .sort((a, b) => (b.known - a.known) || (a.cost - b.cost))
124
+
125
+ const best = scored[0]
126
+ if (!best) return null
127
+ const icon = models.find((m) => m.id === best.id)?.icon || ""
128
+ return { model_id: best.id, model_name: byId.get(best.id)?.name || best.id, model_icon: icon || "" }
129
+ }
130
+
131
+ /** 找一个能挂话题的项目;没有就返回空串 */
132
+ export async function findProjectId(jwt, org, host, workspaceId) {
133
+ const res = await fetch(`${host}/api/v1/super-agent/projects/participated`, {
134
+ method: "POST",
135
+ headers: {
136
+ authorization: `Bearer ${jwt}`,
137
+ "content-type": "application/json",
138
+ accept: "application/json",
139
+ origin: host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn",
140
+ "organization-code": org,
141
+ },
142
+ body: JSON.stringify({ workspace_id: workspaceId, page: 1, page_size: 20, show_collaboration: 1, include_special: 0 }),
143
+ })
144
+ const body = await res.json().catch(() => ({}))
145
+ const list = body?.data?.list ?? body?.data?.items ?? []
146
+ return String(list[0]?.id ?? "")
147
+ }
148
+
149
+ /** 建一个项目 —— 新注册的号只有工作区、没有项目,不建就发不了消息 */
150
+ export async function createProject(jwt, org, host, workspaceId, name = pickName()) {
151
+ const res = await fetch(`${host}/api/v1/super-agent/projects`, {
152
+ method: "POST",
153
+ headers: {
154
+ authorization: `Bearer ${jwt}`,
155
+ "content-type": "application/json",
156
+ accept: "application/json",
157
+ origin: host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn",
158
+ "organization-code": org,
159
+ },
160
+ body: JSON.stringify({ workspace_id: workspaceId, project_name: name }),
161
+ })
162
+ const body = await res.json().catch(() => ({}))
163
+ const d = body?.data?.project ?? body?.data ?? {}
164
+ if (body?.code !== 1000 || !d.id) {
165
+ throw new Error(`建项目失败:${body?.message ?? "无返回"}(code ${body?.code})`)
166
+ }
167
+ return String(d.id)
168
+ }
169
+
170
+ /** 建一个话题,返回 chat_topic_id / chat_conversation_id */
171
+ export async function createTopic(jwt, org, host, projectId, name = pickName()) {
172
+ const res = await fetch(`${host}/api/v1/super-agent/topics`, {
173
+ method: "POST",
174
+ headers: {
175
+ authorization: `Bearer ${jwt}`,
176
+ "content-type": "application/json",
177
+ accept: "application/json",
178
+ origin: host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn",
179
+ "organization-code": org,
180
+ },
181
+ body: JSON.stringify({ topic_name: name, project_id: projectId }),
182
+ })
183
+ const body = await res.json().catch(() => ({}))
184
+ const d = body?.data ?? {}
185
+ if (body?.code !== 1000 || !d.chat_topic_id || !d.chat_conversation_id) {
186
+ throw new Error(`建话题失败:${body?.message ?? "无返回"}(code ${body?.code})`)
187
+ }
188
+ return {
189
+ name,
190
+ topicId: String(d.chat_topic_id),
191
+ conversationId: String(d.chat_conversation_id),
192
+ id: String(d.id ?? ""),
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 发一条消息并等 AI 回复完。
198
+ *
199
+ * @returns {{ok:boolean, messageId?:string, replies?:number, reason?:string}}
200
+ */
201
+ export async function sendChat(jwt, org, host, { topicId, conversationId, text = PICK_TEXT, model }) {
202
+ const origin = host.includes("magicrew") ? "https://www.magicrew.ai" : "https://www.letsmagic.cn"
203
+ const sock = await openSocket(host.replace(/^https?:\/\//, ""), origin)
204
+ const ns = String(Date.now())
205
+ const appMsgId = `${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 14)}`
206
+ const ctx = () => ({
207
+ timestamp: Date.now(),
208
+ authorization: jwt,
209
+ organization_code: org,
210
+ language: "zh_CN",
211
+ signature: "",
212
+ })
213
+
214
+ try {
215
+ // ① 登录
216
+ sock.ws.send(
217
+ `42/im,${ns}` +
218
+ JSON.stringify([
219
+ "login",
220
+ { context: ctx(), data: { message: { type: "text", text: { content: "登录" }, app_message_id: "WEB-" + appMsgId }, conversation_id: "" } },
221
+ ]),
222
+ )
223
+ const loginAck = await sock.next((d) => d.startsWith(`43/im,${ns}`), 20_000)
224
+ if (!loginAck) return { ok: false, reason: "登录无响应" }
225
+ const la = JSON.parse(loginAck.slice(`43/im,${ns}`.length))?.[0] || {}
226
+ if (la.code !== 1000) return { ok: false, reason: `登录失败:${la.message}(code ${la.code})` }
227
+ const senderId = String(la.data?.user?.user_id ?? "")
228
+
229
+ // ② 发消息
230
+ const richText = JSON.stringify({
231
+ type: "doc",
232
+ content: [
233
+ {
234
+ type: "paragraph",
235
+ attrs: { suggestion: "", wechatMediaState: null, wechatInstruction: false },
236
+ content: [{ type: "text", text }],
237
+ },
238
+ ],
239
+ })
240
+ const m = model || { model_id: "qwen3.5-flash", model_name: "Qwen3.5 Flash", model_icon: "" }
241
+ sock.ws.send(
242
+ `42/im,${ns}` +
243
+ JSON.stringify([
244
+ "chat",
245
+ {
246
+ context: ctx(),
247
+ data: {
248
+ message: {
249
+ type: "rich_text",
250
+ rich_text: {
251
+ content: richText,
252
+ instructs: [{ value: "normal" }],
253
+ extra: {
254
+ super_agent: {
255
+ mentions: [],
256
+ chat_mode: "normal",
257
+ topic_pattern: "general",
258
+ model: m,
259
+ enable_web_search: false, // 首任务不需要联网,关掉省时间也省钱
260
+ image_model: { model_id: "doubao-seedream-5-0-pro", model_name: "Seedream 5.0 Pro", model_icon: "" },
261
+ dynamic_params: { message_version: "v2" },
262
+ },
263
+ },
264
+ },
265
+ send_timestamp: Date.now(),
266
+ send_time: Date.now(),
267
+ sender_id: senderId,
268
+ app_message_id: appMsgId,
269
+ message_id: appMsgId,
270
+ topic_id: topicId,
271
+ },
272
+ conversation_id: conversationId,
273
+ },
274
+ },
275
+ ]),
276
+ )
277
+ const chatAck = await sock.next((d) => d.startsWith(`43/im,${ns}`), 25_000)
278
+ if (!chatAck) return { ok: false, reason: "发送无响应" }
279
+ const ca = JSON.parse(chatAck.slice(`43/im,${ns}`.length))?.[0] || {}
280
+ if (ca.code !== 1000) return { ok: false, reason: `发送失败:${ca.message}(code ${ca.code})` }
281
+
282
+ // ③ 等 AI 真的回一条(看到 super_magic_message 就算数),最多 90 秒
283
+ const deadline = Date.now() + 90_000
284
+ let replies = 0
285
+ while (Date.now() < deadline) {
286
+ const f = await sock.next((d) => d.includes(`42/im,["chat"`) || d.includes("super_magic_message") || d.includes("end_conversation_input"), 5_000)
287
+ if (f && f.includes("super_magic_message")) replies++
288
+ if (replies >= 1) break
289
+ }
290
+ return { ok: true, messageId: String(ca.data?.seq?.seq_id ?? ""), replies }
291
+ } finally {
292
+ sock.close()
293
+ }
294
+ }
295
+
296
+ /**
297
+ * 完整链路:找项目 → 建话题(随机名)→ 挑最便宜的模型 → 发一条消息。
298
+ * 不改任何账号状态,失败只返回原因。
299
+ */
300
+ export async function runChatTask({ jwt, org, host = SITES.cn.host, workspaceId, text, onProgress = () => {} }) {
301
+ onProgress("挑选模型…")
302
+ const model = await pickCheapestModel(jwt, org, host).catch(() => null)
303
+
304
+ onProgress("定位项目…")
305
+ let projectId = await findProjectId(jwt, org, host, workspaceId)
306
+ if (!projectId) {
307
+ // 新注册的号只有工作区、没有项目 —— 不建就发不了消息。
308
+ // 实测 POST /super-agent/projects {workspace_id, project_name} 就能建。
309
+ onProgress("新建一个项目…")
310
+ projectId = await createProject(jwt, org, host, workspaceId).catch(() => "")
311
+ if (!projectId) {
312
+ return { ok: false, reason: "建项目失败 —— 这个账号可能还没进过工作台,先在网页里打开一次" }
313
+ }
314
+ }
315
+
316
+ onProgress("建一个话题…")
317
+ const topic = await createTopic(jwt, org, host, projectId, pickName())
318
+
319
+ onProgress("发送消息…")
320
+ const r = await sendChat(jwt, org, host, {
321
+ topicId: topic.topicId,
322
+ conversationId: topic.conversationId,
323
+ text: text || PICK_TEXT,
324
+ model,
325
+ })
326
+
327
+ return {
328
+ ...r,
329
+ topicName: topic.name,
330
+ topicId: topic.topicId,
331
+ model: model?.model_name || "(默认)",
332
+ }
333
+ }
334
+
335
+ export { TOPIC_NAMES, PICK_TEXT }
package/src/context.mjs CHANGED
@@ -1,60 +1,78 @@
1
- // 站点上下文 —— 每个供应商拿到的那一份「我是谁」
2
- //
3
- // 一个包两个供应商,所以这里不是常量,是**按站点生成**的:
4
- //
5
- // siteCtx(SITES.cn) → magicrew 超级麦吉
6
- // siteCtx(SITES.intl) → magicrew-intl MagiCrew
7
- //
8
- // 所有小插头(hooks/*)拿到的都是这个对象,它们不自己去认站点。
9
-
10
- import { SITES } from "./api.mjs"
11
- import { ICON_BY_SITE } from "./icons.mjs"
12
- import { contextOf } from "./model-context.mjs"
13
-
14
- export { SITES }
15
-
16
- /** 决定 API 形状,别乱选(见 src/api.mjs 的 base 与 hooks/models 的 api 字段) */
17
- export const SDK = "@ai-sdk/openai-compatible"
18
-
19
- /** api_key 只活 2 小时时,提前多久换 */
20
- export const REFRESH_LEAD_MS = 10 * 60_000
21
-
22
- /**
23
- * 站点 → 上下文。
24
- *
25
- * 除了 SITES 里那些(id / host / web / needsOrgOnGateway),再补三个派生值:
26
- * SHORT 卡片上显示的短名
27
- * BASE 模型网关 <host>/v1
28
- * ICON 这个供应商自己的图标(插件卡片的图标另外在 package.json.magpie.icon)
29
- */
30
- export function siteCtx(SITE) {
31
- return {
32
- ...SITE,
33
- PROVIDER: SITE.id,
34
- SHORT: SITE.id === "magicrew" ? "超级麦吉" : "MagiCrew",
35
- BASE: `${SITE.host}/v1`,
36
- ICON: SITE.id === "magicrew" ? ICON_BY_SITE.cn : ICON_BY_SITE.intl,
37
- }
38
- }
39
-
40
- /** 把一条 flows/models 变成一个 magpie 模型条目 */
41
- export function modelEntry(c, id, name, vision) {
42
- return {
43
- id,
44
- name,
45
- api: { id, url: c.BASE, npm: SDK },
46
- status: "active",
47
- cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
48
- // 上下文窗口向 magpie 的模型库查 —— 两个模型接口都不返回它,
49
- // 早前一律填 256K,结果 agent 拿 256K 判断"放不下",而实际是 1M
50
- limit: { context: contextOf(id), output: 64_000 },
51
- capabilities: {
52
- temperature: true,
53
- reasoning: true,
54
- toolcall: true,
55
- attachment: !!vision,
56
- input: { text: true, image: !!vision },
57
- output: { text: true },
58
- },
59
- }
60
- }
1
+ // 站点上下文 —— 每个供应商拿到的那一份「我是谁」
2
+ //
3
+ // 一个包两个供应商,所以这里不是常量,是**按站点生成**的:
4
+ //
5
+ // siteCtx(SITES.cn) → magicrew 超级麦吉
6
+ // siteCtx(SITES.intl) → magicrew-intl MagiCrew
7
+ //
8
+ // 所有小插头(hooks/*)拿到的都是这个对象,它们不自己去认站点。
9
+
10
+ import { SITES } from "./api.mjs"
11
+ import { ICON_BY_SITE } from "./icons.mjs"
12
+ import { contextOf } from "./model-context.mjs"
13
+
14
+ export { SITES }
15
+
16
+ /** 决定 API 形状,别乱选(见 src/api.mjs 的 base 与 hooks/models 的 api 字段) */
17
+ export const SDK = "@ai-sdk/openai-compatible"
18
+
19
+ /** api_key 只活 2 小时时,提前多久换 */
20
+ export const REFRESH_LEAD_MS = 10 * 60_000
21
+
22
+ /**
23
+ * 站点 → 上下文。
24
+ *
25
+ * 除了 SITES 里那些(id / host / web / needsOrgOnGateway),再补三个派生值:
26
+ * SHORT 卡片上显示的短名
27
+ * BASE 模型网关 <host>/v1
28
+ * ICON 这个供应商自己的图标(插件卡片的图标另外在 package.json.magpie.icon)
29
+ */
30
+ export function siteCtx(SITE) {
31
+ return {
32
+ ...SITE,
33
+ PROVIDER: SITE.id,
34
+ SHORT: SITE.id === "magicrew" ? "超级麦吉" : "MagiCrew",
35
+ BASE: `${SITE.host}/v1`,
36
+ ICON: SITE.id === "magicrew" ? ICON_BY_SITE.cn : ICON_BY_SITE.intl,
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 把一条 flows/models 变成一个 magpie 模型条目。
42
+ *
43
+ * `opts.rate` / `opts.free` 是 **magpie 自己的字段**(从 codearts 插件里挖到的,
44
+ * packages/codearts/index.mjs 的 modelOf):
45
+ *
46
+ * free boolean → 名字后面画一个绿色的「免费」胶囊
47
+ * rate number → 画一个灰色的倍率胶囊(0.09× 这种,就是网站上的「消耗系数」)
48
+ *
49
+ * 关键规则(codearts 原注释):**free 的时候不给 rate** ——
50
+ * 免费模型不该再标一个倍率,两个胶囊并排没意义。
51
+ * 没有 rate 就什么都不画,正好符合「没有倍率就不显示」。
52
+ *
53
+ * ⚠ 别再用 name 拼倍率了:那样筛选框会连倍率一起匹配,
54
+ * 而且和真正的胶囊样式差得远。
55
+ */
56
+ export function modelEntry(c, id, name, vision, opts = {}) {
57
+ const { rate = 0, free = false } = opts
58
+ return {
59
+ id,
60
+ name,
61
+ api: { id, url: c.BASE, npm: SDK },
62
+ status: "active",
63
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
64
+ // 上下文窗口向 magpie 的模型库查 —— 两个模型接口都不返回它,
65
+ // 早前一律填 256K,结果 agent 拿 256K 判断"放不下",而实际是 1M
66
+ limit: { context: contextOf(id), output: 64_000 },
67
+ capabilities: {
68
+ temperature: true,
69
+ reasoning: true,
70
+ toolcall: true,
71
+ attachment: !!vision,
72
+ input: { text: true, image: !!vision },
73
+ output: { text: true },
74
+ },
75
+ ...(free ? { free: true } : {}),
76
+ ...(!free && Number(rate) > 0 ? { rate: Number(rate) } : {}),
77
+ }
78
+ }
@@ -22,6 +22,19 @@ import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, read
22
22
  import { readLocalCreds } from "../local-browser.mjs"
23
23
  import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
24
24
 
25
+ /**
26
+ * 账号刚加进来(登录成功)→ 一次性把「首次 AI 工作 / 首个对话」补掉。
27
+ *
28
+ * 不 await、不抛:建项目 + 发消息 + 兜底等结算最坏一分钟,登录必须立刻返回;
29
+ * 失败也不该让登录报错。真正的兜底是签到 —— 两边共用同一道闸门
30
+ * (`isAutoChatSettled`:每账号一次,永久),所以重复登录、刷新页面都不会重发。
31
+ */
32
+ function kickoffFirstTask(c, jwt, org) {
33
+ import("../tasks.mjs")
34
+ .then((m) => m.ensureFirstTaskOnce({ jwt, org, host: c.host }))
35
+ .catch(() => {})
36
+ }
37
+
25
38
  export function makeMethods(c) {
26
39
  return [
27
40
  {
@@ -69,6 +82,7 @@ export function makeMethods(c) {
69
82
  // 等它落盘再返回:这条以前是 .catch(()=>{}) 发射后不管,
70
83
  // 一旦 host 进程在 callback 之后被回收,滚动就永远不生效
71
84
  await rollInvite(c.id, local.jwt, local.org, c.host)
85
+ kickoffFirstTask(c, local.jwt, local.org)
72
86
  const who = await fetchWho(local.jwt, local.org, c.host).catch(() => null)
73
87
  return {
74
88
  type: "success",
@@ -150,6 +164,7 @@ export function makeMethods(c) {
150
164
  noteOrg(c.id, got.org)
151
165
  // 滚邀请码:把这个账号自己的码存成下一次用的(等落盘再返回)
152
166
  await rollInvite(c.id, got.jwt, got.org, c.host)
167
+ kickoffFirstTask(c, got.jwt, got.org)
153
168
  return {
154
169
  type: "success",
155
170
  access: key.apiKey,
@@ -223,6 +238,7 @@ export function makeMethods(c) {
223
238
  writeSession(c.id, jwt, org)
224
239
  noteOrg(c.id, org)
225
240
  await rollInvite(c.id, jwt, org, c.host)
241
+ kickoffFirstTask(c, jwt, org)
226
242
  // 卡片上显示手机号/邮箱/昵称,取不到就退回组织码
227
243
  const who = await fetchWho(jwt, org, c.host).catch(() => null)
228
244
  return {
@@ -9,10 +9,17 @@
9
9
  //
10
10
  // 登录态失效 → 抛带 signIn: "expired" 的错误。
11
11
 
12
- import { fetchModels } from "../api.mjs"
12
+ import { fetchModels, fetchModelPoints } from "../api.mjs"
13
13
  import { modelEntry } from "../context.mjs"
14
14
  import { readSnapshot, writeSnapshot } from "../store.mjs"
15
15
 
16
+ // 倍率系数:input_points / 2000 与网站实测值吻合
17
+ // Qwen3.5 Flash 20 → 0.01 (网站 0.01) ✓
18
+ // Qwen3.8 Flash 100 → 0.05 (网站 0.05) ✓
19
+ // Qwen3.7 Plus 200 → 0.10 (网站 0.10) ✓
20
+ // Qwen3.8 Max 1200 → 0.60 (网站 0.56)
21
+ const MULT_BASE = 2000
22
+
16
23
  export function makeModels(c) {
17
24
  return async (provider, { auth } = {}) => {
18
25
  const snapshot = readSnapshot(c.id)
@@ -22,8 +29,43 @@ export function makeModels(c) {
22
29
  const live = await fetchModels(auth.jwt, auth.org, c.host)
23
30
  if (!live.length) return snapshot ?? {}
24
31
 
32
+ // 计费信息拿不到就退回原名,不挡列表
33
+ const points = await fetchModelPoints(auth.jwt, auth.org, live.map((m) => m.id), c.host).catch(() => ({}))
34
+ const pointsOk = Object.keys(points).length > 0
35
+
25
36
  const out = {}
26
- for (const m of live) out[m.id] = modelEntry(c, m.id, m.name, m.vision)
37
+ for (const m of live) {
38
+ // VIP / 锁定的模型不列 —— 选中会报错,插件注释里那条铁律
39
+ // (语言模型这批实测 tags 全是 `Magic`,没有 VIP;VIP 只出现在
40
+ // 网站的「生图模型 / 视频模型」里,那两个接口不在本插件范围内)
41
+ const locked = (m.tags || []).some((t) => /vip|lock|pay|premium/i.test(String(t?.value ?? "")))
42
+ if (locked) continue
43
+
44
+ const p = points[m.id] || {}
45
+ // 三分支 —— 按 HOOKS.md 十一之二:`free` 的判据是「花不花这份订阅的
46
+ // 额度」,**不能从 rate 反推**(实测 rate===0 的 51 条里,38 条是
47
+ // 「套餐内不按次计费」,照样吃配额;标成免费会让 Free first 一直派到它们身上)。
48
+ //
49
+ // input_points > 0 → 有倍率,free 不设
50
+ // input_points === 0 → 厂商声明免费 → free: true,且不给 rate
51
+ // input_points 缺失 → 价格未知 → 不列(不猜,也不谎标免费)
52
+ //
53
+ // 「已知」要挡住空串与非数字:`Number("") === 0` 会把没填的价格当成免费。
54
+ const raw = p.inputPoints
55
+ const known = raw != null && String(raw).trim() !== "" && Number.isFinite(Number(raw))
56
+ const isFree = known && Number(raw) === 0
57
+ // 计费接口整体成功、但这条没价格 → 不列。
58
+ // 不填 rate 就不会画倍率胶囊(正确),但也别让它出现在列表里 ——
59
+ // 用户选了不知道花多少。接口整体失败时不过滤,免得列表空掉。
60
+ if (pointsOk && !known) continue
61
+
62
+ // name 保持干净,倍率走 magpie 自己的 `rate` 字段 → 渲染成灰色胶囊,
63
+ // 免费走 `free` → 进 Free first 那一档(codearts 的 modelOf 认的就是这两个)。
64
+ out[m.id] = modelEntry(c, m.id, m.name, m.vision, {
65
+ rate: known ? Number(raw) / MULT_BASE : 0,
66
+ free: isFree,
67
+ })
68
+ }
27
69
  writeSnapshot(c.id, out)
28
70
  return out
29
71
  } catch (e) {
@@ -35,3 +77,4 @@ export function makeModels(c) {
35
77
  }
36
78
  }
37
79
  }
80
+