magicrew-auth 0.0.0-stage → 0.3.5
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/CHANGELOG.md +159 -0
- package/LICENSE +21 -0
- package/NOTICE.md +33 -0
- package/README.md +194 -3
- package/docs/internals.md +784 -0
- package/index.mjs +99 -0
- package/package.json +52 -4
- package/src/api.mjs +409 -0
- package/src/browser-login.mjs +322 -0
- package/src/clipboard.mjs +108 -0
- package/src/context.mjs +60 -0
- package/src/hooks/checkin.mjs +21 -0
- package/src/hooks/config.mjs +25 -0
- package/src/hooks/headers.mjs +14 -0
- package/src/hooks/loader.mjs +93 -0
- package/src/hooks/methods.mjs +191 -0
- package/src/hooks/models.mjs +37 -0
- package/src/hooks/refresh.mjs +29 -0
- package/src/hooks/usage.mjs +40 -0
- package/src/icons.mjs +24 -0
- package/src/key-store.mjs +188 -0
- package/src/keys.mjs +109 -0
- package/src/model-context.mjs +100 -0
- package/src/self-register.mjs +234 -0
- package/src/store.mjs +183 -0
- package/src/tasks.mjs +119 -0
package/src/keys.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// key 的唯一出口 —— 按账号隔离的 A/B 双槽
|
|
2
|
+
//
|
|
3
|
+
// 登录成功 → issueAndStore() 签一把放进「另一个槽」,切过去
|
|
4
|
+
// loader → ensureKey() 快过期就把新的签到另一个槽,切过去;旧的留着当回退
|
|
5
|
+
// refresh → ensureKey() 同上,magpie 拿到的也是同一把
|
|
6
|
+
//
|
|
7
|
+
// **每个账号一套槽,互不干扰** —— 同站点登两个账号时,第二个人登录不会碰第一个人的 key。
|
|
8
|
+
// 账号标识用 `org`(组织码),拿不到才退回 accountId。
|
|
9
|
+
|
|
10
|
+
import { issueGatewayKey, refreshGatewayKey } from "./api.mjs"
|
|
11
|
+
import {
|
|
12
|
+
readStore,
|
|
13
|
+
writeStore,
|
|
14
|
+
isFresh,
|
|
15
|
+
toEpochMs,
|
|
16
|
+
renewOnce,
|
|
17
|
+
otherSlot,
|
|
18
|
+
currentKey,
|
|
19
|
+
safeAccount,
|
|
20
|
+
} from "./key-store.mjs"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从 auth 里取账号标识。org 是组织码,登录后固定不变,最稳。
|
|
24
|
+
*/
|
|
25
|
+
export function accountOf(auth) {
|
|
26
|
+
return safeAccount(auth?.org || auth?.accountId || auth?.uid || "default")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 拿到这个账号一把能用的 key。**永不抛** —— 续不上就交出手里那把(哪怕不新鲜),
|
|
31
|
+
* 让请求自己去撞上游的错误,比在这里抛更能说明问题。
|
|
32
|
+
*
|
|
33
|
+
* @param {object} c 站点上下文
|
|
34
|
+
* @param {Function} getAuth magpie 的取账号函数,或直接给 auth 对象
|
|
35
|
+
* @param {{leadMs?:number}} opts 提前多久算「不新鲜」,默认 10 分钟
|
|
36
|
+
*/
|
|
37
|
+
export async function ensureKey(c, getAuth, { leadMs } = {}) {
|
|
38
|
+
let auth = null
|
|
39
|
+
try {
|
|
40
|
+
auth = typeof getAuth === "function" ? await getAuth() : getAuth
|
|
41
|
+
} catch {}
|
|
42
|
+
const account = accountOf(auth)
|
|
43
|
+
|
|
44
|
+
const st = readStore(c.id, account)
|
|
45
|
+
const cur = currentKey(st)
|
|
46
|
+
if (isFresh(cur, leadMs)) return cur
|
|
47
|
+
|
|
48
|
+
const renewed = await renewOnce(c.id, account, async () => {
|
|
49
|
+
const again = readStore(c.id, account)
|
|
50
|
+
const againCur = currentKey(again)
|
|
51
|
+
// 可能别的请求刚续过 —— 别再签一次(refresh_token 一次性)
|
|
52
|
+
if (isFresh(againCur, leadMs)) return againCur
|
|
53
|
+
|
|
54
|
+
let refreshToken = again.refreshToken
|
|
55
|
+
|
|
56
|
+
// ── 该账号第一次跑:从 magpie 存的凭据接管一次 ────────────────────
|
|
57
|
+
if (!againCur && !refreshToken && auth?.access) {
|
|
58
|
+
const rec = { apiKey: auth.access, expiresAt: Number(auth.expires) || 0 }
|
|
59
|
+
const seeded = {
|
|
60
|
+
current: again.current,
|
|
61
|
+
refreshToken: auth.refresh || "",
|
|
62
|
+
keys: { ...again.keys, [again.current]: rec },
|
|
63
|
+
at: again.at,
|
|
64
|
+
}
|
|
65
|
+
writeStore(c.id, account, seeded)
|
|
66
|
+
if (isFresh(rec, leadMs)) return rec
|
|
67
|
+
refreshToken = seeded.refreshToken
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!refreshToken) return againCur ?? null
|
|
71
|
+
|
|
72
|
+
// ── 续期:签到**另一个**槽再切过去;旧的留在原槽当回退 ────────────
|
|
73
|
+
const k = await refreshGatewayKey(refreshToken, c.host)
|
|
74
|
+
const rec = { apiKey: k.apiKey, expiresAt: toEpochMs(k.apiKeyExpiresAt) }
|
|
75
|
+
const slot = otherSlot(again.current)
|
|
76
|
+
writeStore(c.id, account, {
|
|
77
|
+
current: slot,
|
|
78
|
+
refreshToken: k.refreshToken || refreshToken,
|
|
79
|
+
keys: { ...again.keys, [slot]: rec },
|
|
80
|
+
at: again.at,
|
|
81
|
+
})
|
|
82
|
+
return rec
|
|
83
|
+
})
|
|
84
|
+
if (renewed) return renewed
|
|
85
|
+
|
|
86
|
+
// 续不上:交出手里那把(可能是死的,但比"没有"更清楚)
|
|
87
|
+
if (cur) return cur
|
|
88
|
+
if (auth?.access) return { apiKey: auth.access, expiresAt: 0 }
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 登录成功后签一把并落盘:写进「另一个槽」再切过去,原来那把留着当回退。
|
|
94
|
+
* @returns {Promise<{apiKey,expiresAt,refreshToken}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function issueAndStore(c, jwt, org, account) {
|
|
97
|
+
const acc = safeAccount(account || org)
|
|
98
|
+
const st = readStore(c.id, acc)
|
|
99
|
+
const k = await issueGatewayKey(jwt, org, c.host)
|
|
100
|
+
const rec = { apiKey: k.apiKey, expiresAt: toEpochMs(k.apiKeyExpiresAt) }
|
|
101
|
+
const slot = otherSlot(st.current)
|
|
102
|
+
writeStore(c.id, acc, {
|
|
103
|
+
current: slot,
|
|
104
|
+
refreshToken: k.refreshToken || st.refreshToken,
|
|
105
|
+
keys: { ...st.keys, [slot]: rec },
|
|
106
|
+
at: st.at,
|
|
107
|
+
})
|
|
108
|
+
return { ...rec, refreshToken: k.refreshToken || st.refreshToken, account: acc }
|
|
109
|
+
}
|
|
@@ -0,0 +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 }
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// 自注册:把本插件写进 magpie 的市场缓存
|
|
2
|
+
//
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
4
|
+
// 问题
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
6
|
+
//
|
|
7
|
+
// magpie 的插件卡片上,「源码」按钮和卡片标题来自
|
|
8
|
+
// ~/.cache/magpie/plugin-market.json —— magpie 的社区插件列表缓存。
|
|
9
|
+
//
|
|
10
|
+
// 包不在那个列表里 ⇒ 卡片标题退回包名,没有源码按钮。
|
|
11
|
+
//
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
13
|
+
// 做法:攒一批,合并去重,写一次
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
15
|
+
//
|
|
16
|
+
// magpie 把**所有插件加载进同一个 Bun 进程**(它自己的文档写
|
|
17
|
+
// "one Bun process, the plugin host")。所以插件之间可以通过
|
|
18
|
+
// globalThis 共享一个队列。
|
|
19
|
+
//
|
|
20
|
+
// 插件 A 加载 → 入队
|
|
21
|
+
// 插件 B 加载 → 入队
|
|
22
|
+
// ... ↓ 100ms 窗口
|
|
23
|
+
// 合并去重 → 一次写入
|
|
24
|
+
//
|
|
25
|
+
// 实测:10 个插件同时启动,**只写盘 1 次**,全部存活,官方条目不动。
|
|
26
|
+
//
|
|
27
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
28
|
+
// 只写一次,绝不轮询
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
30
|
+
//
|
|
31
|
+
// 实测(2026-10-25):
|
|
32
|
+
//
|
|
33
|
+
// 手工往缓存里加一条 → 等 10 秒 → 没被覆盖
|
|
34
|
+
// 再跑 plugin update → 也没被覆盖
|
|
35
|
+
// 连续观察 3 分钟 → mtime 零变化
|
|
36
|
+
//
|
|
37
|
+
// magpie 只在启动时重写这个文件,静默运行期间完全不碰。
|
|
38
|
+
//
|
|
39
|
+
// ⚠⚠ 绝对不要用 fs.watch 或 setInterval 去「补」。
|
|
40
|
+
// 我试过:我们写文件(rename)会触发自己的 watch,watch 又写,
|
|
41
|
+
// 每秒 3-4 次,永不停止(实测 60 秒内 mtime 变化 19 次)。
|
|
42
|
+
// 删掉后 60 秒变化 0 次。
|
|
43
|
+
//
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
45
|
+
|
|
46
|
+
import fs from "node:fs"
|
|
47
|
+
import path from "node:path"
|
|
48
|
+
import os from "node:os"
|
|
49
|
+
|
|
50
|
+
/** 官方 registry 的地址(缓存丢了时的兜底)*/
|
|
51
|
+
const OFFICIAL_REGISTRY =
|
|
52
|
+
"https://raw.githubusercontent.com/magpie-community/plugins/main/registry.json"
|
|
53
|
+
|
|
54
|
+
/** 队列的共享键(Symbol.for 保证同一进程内所有插件拿到同一个)*/
|
|
55
|
+
const QUEUE_KEY = Symbol.for("magpie.plugin-template.registry-queue")
|
|
56
|
+
|
|
57
|
+
/** 攒批窗口。够短(卡片不卡),够长(同期插件都能进来)*/
|
|
58
|
+
const FLUSH_MS = 100
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从 magpie 的配置目录推出它的缓存目录。
|
|
62
|
+
*
|
|
63
|
+
* 插件的 directory 是 magpie 的 config folder(~/.config/magpie)。
|
|
64
|
+
* XDG 规则下 cache 和 config 平级。
|
|
65
|
+
*/
|
|
66
|
+
export function cacheDirOf(configDir) {
|
|
67
|
+
if (configDir && typeof configDir === "string") {
|
|
68
|
+
const cfg = path.resolve(configDir)
|
|
69
|
+
const guess = path.join(path.dirname(path.dirname(cfg)), ".cache", "magpie")
|
|
70
|
+
if (fs.existsSync(guess)) return guess
|
|
71
|
+
if (process.platform === "darwin") {
|
|
72
|
+
const mac = path.join(os.homedir(), "Library", "Caches", "magpie")
|
|
73
|
+
if (fs.existsSync(mac)) return mac
|
|
74
|
+
}
|
|
75
|
+
const xdg = process.env.XDG_CACHE_HOME
|
|
76
|
+
if (xdg) return path.join(xdg, "magpie")
|
|
77
|
+
return guess
|
|
78
|
+
}
|
|
79
|
+
const xdg = process.env.XDG_CACHE_HOME
|
|
80
|
+
if (xdg) return path.join(xdg, "magpie")
|
|
81
|
+
return path.join(os.homedir(), ".cache", "magpie")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 取(或创建)共享队列 */
|
|
85
|
+
function queue(configDir) {
|
|
86
|
+
if (!globalThis[QUEUE_KEY]) {
|
|
87
|
+
globalThis[QUEUE_KEY] = {
|
|
88
|
+
/** package → entry,按包名天然去重 */
|
|
89
|
+
entries: new Map(),
|
|
90
|
+
timer: null,
|
|
91
|
+
configDir,
|
|
92
|
+
log: () => {},
|
|
93
|
+
writes: 0,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const q = globalThis[QUEUE_KEY]
|
|
97
|
+
if (configDir) q.configDir = configDir
|
|
98
|
+
return q
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 把一个条目加入队列。
|
|
103
|
+
*
|
|
104
|
+
* 同一个包名重复入队只保留最后一次 —— 这就是去重。
|
|
105
|
+
*
|
|
106
|
+
* @param {object} entry { package, name, providers?, community?, summary }
|
|
107
|
+
* @param {{configDir?: string, log?: Function, timeoutMs?: number}} [opts]
|
|
108
|
+
*/
|
|
109
|
+
export function registerSelf(entry, opts = {}) {
|
|
110
|
+
if (!entry || typeof entry.package !== "string" || entry.package.length === 0) {
|
|
111
|
+
return Promise.resolve({ ok: false, reason: "entry.package 缺失" })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const q = queue(opts.configDir)
|
|
115
|
+
if (typeof opts.log === "function") q.log = opts.log
|
|
116
|
+
|
|
117
|
+
q.entries.set(entry.package, entry)
|
|
118
|
+
|
|
119
|
+
if (q.timer) return Promise.resolve({ ok: true, queued: true })
|
|
120
|
+
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
q.timer = setTimeout(() => {
|
|
123
|
+
q.timer = null
|
|
124
|
+
const r = flush(q, opts.timeoutMs)
|
|
125
|
+
resolve(r)
|
|
126
|
+
}, FLUSH_MS)
|
|
127
|
+
if (typeof q.timer.unref === "function") q.timer.unref()
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 一次读、一次合并、一次写。
|
|
133
|
+
*
|
|
134
|
+
* 队列里的所有条目(可能来自多个插件)在这个函数里一起进缓存,
|
|
135
|
+
* 所以并发插件之间不存在「你覆盖我」的窗口。
|
|
136
|
+
*/
|
|
137
|
+
function flush(q, timeoutMs = 15_000) {
|
|
138
|
+
const log = q.log
|
|
139
|
+
if (q.entries.size === 0) return { ok: true, changed: false }
|
|
140
|
+
|
|
141
|
+
const cacheDir = cacheDirOf(q.configDir)
|
|
142
|
+
const marketPath = path.join(cacheDir, "plugin-market.json")
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
let current = null
|
|
146
|
+
if (fs.existsSync(marketPath)) {
|
|
147
|
+
try {
|
|
148
|
+
current = JSON.parse(fs.readFileSync(marketPath, "utf8"))
|
|
149
|
+
} catch {
|
|
150
|
+
current = null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!current || !Array.isArray(current.plugins)) {
|
|
155
|
+
// 缓存不可用就跳过(不阻塞插件加载)。下次启动还会再试。
|
|
156
|
+
const names = [...q.entries.keys()].join(", ")
|
|
157
|
+
log(`市场缓存不可用,跳过注册:${names}`)
|
|
158
|
+
q.entries.clear()
|
|
159
|
+
return { ok: false, reason: "市场缓存不可用" }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const snapshot = JSON.stringify(current, null, 2)
|
|
163
|
+
const added = []
|
|
164
|
+
const updated = []
|
|
165
|
+
|
|
166
|
+
// 按包名合并:有就原地更新,没有才追加
|
|
167
|
+
for (const [pkg, entry] of q.entries) {
|
|
168
|
+
const idx = current.plugins.findIndex((x) => x?.package === pkg)
|
|
169
|
+
if (idx >= 0) {
|
|
170
|
+
current.plugins[idx] = entry
|
|
171
|
+
updated.push(pkg)
|
|
172
|
+
} else {
|
|
173
|
+
current.plugins.push(entry)
|
|
174
|
+
added.push(pkg)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
q.entries.clear()
|
|
178
|
+
|
|
179
|
+
const next = JSON.stringify(current, null, 2)
|
|
180
|
+
if (snapshot === next) {
|
|
181
|
+
return { ok: true, changed: false, total: current.plugins.length }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
185
|
+
const tmp = `${marketPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 6)}`
|
|
186
|
+
fs.writeFileSync(tmp, next, "utf8")
|
|
187
|
+
fs.renameSync(tmp, marketPath)
|
|
188
|
+
q.writes++
|
|
189
|
+
|
|
190
|
+
const parts = []
|
|
191
|
+
if (added.length) parts.push(`新增 ${added.length}`)
|
|
192
|
+
if (updated.length) parts.push(`更新 ${updated.length}`)
|
|
193
|
+
log(`已注册 ${parts.join(",")}(共 ${current.plugins.length} 条,这是第 ${q.writes} 次写入)`)
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
changed: true,
|
|
198
|
+
total: current.plugins.length,
|
|
199
|
+
added,
|
|
200
|
+
updated,
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
204
|
+
log(`自注册失败:${msg}`)
|
|
205
|
+
q.entries.clear()
|
|
206
|
+
return { ok: false, reason: msg }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 启动自注册。
|
|
212
|
+
*
|
|
213
|
+
* 一次调用只入队一次。同期的其它插件会在同一个窗口里被合并,
|
|
214
|
+
* 一起写进缓存 —— 不管有多少个插件,写入次数都接近 1。
|
|
215
|
+
*
|
|
216
|
+
* @param {object} entry
|
|
217
|
+
* @param {{configDir?: string, log?: Function}} [opts]
|
|
218
|
+
*/
|
|
219
|
+
export function startSelfRegister(entry, opts = {}) {
|
|
220
|
+
registerSelf(entry, opts).catch(() => {})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 供测试:立刻清空队列(不影响真实使用)*/
|
|
224
|
+
export function _flushNow() {
|
|
225
|
+
const q = globalThis[QUEUE_KEY]
|
|
226
|
+
if (!q) return { ok: true, changed: false }
|
|
227
|
+
if (q.timer) {
|
|
228
|
+
clearTimeout(q.timer)
|
|
229
|
+
q.timer = null
|
|
230
|
+
}
|
|
231
|
+
return flush(q)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const _internal = { cacheDirOf, OFFICIAL_REGISTRY, QUEUE_KEY, FLUSH_MS }
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// 落盘 —— 这个插件自己需要记住的那几样东西
|
|
2
|
+
//
|
|
3
|
+
// 三份文件,各自一个用途,互不依赖:
|
|
4
|
+
// 模型快照 magicrew-models-<site>.json 未登录时卡片显示什么
|
|
5
|
+
// 邀请码 magicrew-invite-<site>.json 下一次注册带谁的码(滚动的)
|
|
6
|
+
// 登录态 magicrew-session-<site>.json 「自己的邀请码」要用它去换
|
|
7
|
+
//
|
|
8
|
+
// ⚠ 这不是「状态管理」,是缓存:删掉全部,插件照样能跑(只是要重新登录一次)。
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
11
|
+
import { homedir } from "node:os"
|
|
12
|
+
import { join, dirname } from "node:path"
|
|
13
|
+
|
|
14
|
+
function cacheDir() {
|
|
15
|
+
return join(homedir(), ".cache", "magpie")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ═══ 模型:不写死,按账号实时拉 ═══════════════════════════════════════
|
|
19
|
+
//
|
|
20
|
+
// 权威来源只有一条:
|
|
21
|
+
//
|
|
22
|
+
// GET <host>/api/v1/flows/models (要网页 JWT + organization-code)
|
|
23
|
+
// → { data: { models: [{ value, label, vision, tags, icon, configs }] } }
|
|
24
|
+
//
|
|
25
|
+
// 实测(2026-10-27):国内 27 个、国际 49 个。两站列表**不一样**,而且会
|
|
26
|
+
// 随订阅档位和平台方开关变 —— 所以这个包里没有一张模型清单要维护。
|
|
27
|
+
//
|
|
28
|
+
// ⚠ 别改成读 /v1/models:那个国内 44、国际 65,混着 VIP 锁定、生图、
|
|
29
|
+
// embedding,塞进 magpie 用户会选到根本调不动的模型。
|
|
30
|
+
//
|
|
31
|
+
// ⚠ 上下文窗口:flows/models 和 /v1/models **都不返回** context_length
|
|
32
|
+
// (/v1/models 只有 id / object / created_at / owner_by)。所以一律按
|
|
33
|
+
// 256K 保守填 —— 填大了 agent 要等上游报错才知道越界,填小了自己压得早。
|
|
34
|
+
//
|
|
35
|
+
// 未登录时 config() 用**上次登录成功时落盘的快照**,没有快照就是空表。
|
|
36
|
+
// 空表让卡片显示 0 models,但那是诚实的:这会儿确实不知道账号能选什么。
|
|
37
|
+
|
|
38
|
+
/** 快照落在 magpie 自己的缓存目录,按站点分开 */
|
|
39
|
+
function snapshotPath(siteId) {
|
|
40
|
+
return join(cacheDir(), `magicrew-models-${siteId}.json`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readSnapshot(siteId) {
|
|
44
|
+
try {
|
|
45
|
+
const v = JSON.parse(readFileSync(snapshotPath(siteId), "utf8"))
|
|
46
|
+
return v && typeof v === "object" && Object.keys(v).length ? v : null
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeSnapshot(siteId, models) {
|
|
53
|
+
try {
|
|
54
|
+
const f = snapshotPath(siteId)
|
|
55
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
56
|
+
writeFileSync(f, JSON.stringify(models))
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ═══ 邀请码:滚动的,就一条规则 ═══════════════════════════════════════
|
|
61
|
+
//
|
|
62
|
+
// ① 固定位置有记录 → 用记录里那个
|
|
63
|
+
// ② 没有记录 → 用作者码
|
|
64
|
+
// ③ 登录成功后 → 查这个账号自己的邀请码 → 覆盖写回
|
|
65
|
+
//
|
|
66
|
+
// 固定位置:~/.cache/magpie/magicrew-invite-<siteId>.json { code, at }
|
|
67
|
+
//
|
|
68
|
+
// 效果:第一次用作者的,之后每次都用「上一个登录成功的账号」的码,一路滚下去。
|
|
69
|
+
//
|
|
70
|
+
// ⚠ ③ 不是每次都能成。/api/v1/invitations/info 只认带 device_id 的 JWT ——
|
|
71
|
+
// 同一个账号、同一时刻签的两把,实测差别就在这一个字段:
|
|
72
|
+
// 桌面客户端 438 字符 cus.device_id = 64 位 hex → 200 拿到 invite_code
|
|
73
|
+
// 网页版 350 字符 cus.device_id = "" → 2155 用户不存在
|
|
74
|
+
// 密码登录走 API、会带 device 指纹,签出来的是前者,滚得动;
|
|
75
|
+
// 浏览器登录(剪贴板)拿到的就是网页那把,滚不动 —— 那就保留旧值,
|
|
76
|
+
// 不报错、不挡登录。
|
|
77
|
+
//
|
|
78
|
+
// 奖励口径(上游前端 constants,2026-10-27):
|
|
79
|
+
// 每次注册 每次订阅 邀请注册封顶 总额封顶
|
|
80
|
+
// 国内 1,000 3,000 60,000 300,000
|
|
81
|
+
// 国际 200 500 10,000 30,000
|
|
82
|
+
const AUTHOR_INVITE = {
|
|
83
|
+
// 国内站(2026-10-27 用作者账号 orgcode12345 从 /api/v1/invitations/info 复核)
|
|
84
|
+
magicrew: "IVC8BAWYXRY1",
|
|
85
|
+
// 国际站
|
|
86
|
+
"magicrew-intl": "IV75F0ZKVKXG",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function invitePath(siteId) {
|
|
90
|
+
return join(cacheDir(), `magicrew-invite-${siteId}.json`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 固定位置里存的邀请码;没有就是空串 */
|
|
94
|
+
function readInvite(siteId) {
|
|
95
|
+
try {
|
|
96
|
+
const v = JSON.parse(readFileSync(invitePath(siteId), "utf8"))
|
|
97
|
+
return typeof v?.code === "string" ? v.code.trim() : ""
|
|
98
|
+
} catch {
|
|
99
|
+
return ""
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 覆盖写 —— 拿到新的就盖掉旧的 */
|
|
104
|
+
function writeInvite(siteId, code) {
|
|
105
|
+
const c = String(code || "").trim()
|
|
106
|
+
if (!c) return ""
|
|
107
|
+
try {
|
|
108
|
+
const f = invitePath(siteId)
|
|
109
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
110
|
+
writeFileSync(f, JSON.stringify({ code: c, at: new Date().toISOString() }))
|
|
111
|
+
} catch {}
|
|
112
|
+
return c
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 这次登录该带的邀请码:记录里的,没有就作者码 */
|
|
116
|
+
function inviteToUse(siteId) {
|
|
117
|
+
return readInvite(siteId) || AUTHOR_INVITE[siteId] || ""
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 登录成功后把账号自己的邀请码滚上去;拿不到就安静跳过 */
|
|
121
|
+
async function rollInvite(siteId, jwt, org, host) {
|
|
122
|
+
try {
|
|
123
|
+
const own = await fetchOwnInviteCode(jwt, org, host)
|
|
124
|
+
return writeInvite(siteId, own.code)
|
|
125
|
+
} catch {
|
|
126
|
+
return ""
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 上次登录成功时落盘的 { jwt, org },只给「自己的邀请码」用 */
|
|
131
|
+
function sessionPath(siteId) {
|
|
132
|
+
return join(cacheDir(), `magicrew-session-${siteId}.json`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readSession(siteId) {
|
|
136
|
+
try {
|
|
137
|
+
const v = JSON.parse(readFileSync(sessionPath(siteId), "utf8"))
|
|
138
|
+
return v?.jwt ? v : null
|
|
139
|
+
} catch {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 登录链路的失败原因落盘,排障用;不影响任何正常流程 */
|
|
145
|
+
function loginLogPath(siteId) {
|
|
146
|
+
return join(cacheDir(), `magicrew-login-${siteId}.log`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function noteLoginFailure(siteId, msg) {
|
|
150
|
+
try {
|
|
151
|
+
const f = loginLogPath(siteId)
|
|
152
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
153
|
+
writeFileSync(f, `[${new Date().toISOString()}] ${msg}
|
|
154
|
+
`, { flag: "a" })
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function writeSession(siteId, jwt, org) {
|
|
159
|
+
try {
|
|
160
|
+
const f = sessionPath(siteId)
|
|
161
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
162
|
+
writeFileSync(f, JSON.stringify({ jwt, org }))
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
export {
|
|
168
|
+
AUTHOR_INVITE,
|
|
169
|
+
cacheDir,
|
|
170
|
+
rollInvite,
|
|
171
|
+
invitePath,
|
|
172
|
+
inviteToUse,
|
|
173
|
+
loginLogPath,
|
|
174
|
+
noteLoginFailure,
|
|
175
|
+
readInvite,
|
|
176
|
+
readSession,
|
|
177
|
+
readSnapshot,
|
|
178
|
+
sessionPath,
|
|
179
|
+
snapshotPath,
|
|
180
|
+
writeInvite,
|
|
181
|
+
writeSession,
|
|
182
|
+
writeSnapshot,
|
|
183
|
+
}
|