opencode-lobster-auth 0.1.1 → 0.1.2
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/README.md +181 -175
- package/README.zh-CN.md +156 -151
- package/index.mjs +210 -191
- package/lib/auth-login.mjs +154 -131
- package/lib/constants.mjs +41 -41
- package/lib/jwt.mjs +34 -34
- package/lib/sse.mjs +67 -67
- package/lib/usage.mjs +41 -41
- package/package.json +1 -1
package/lib/auth-login.mjs
CHANGED
|
@@ -1,131 +1,154 @@
|
|
|
1
|
-
// 浏览器登录,走 LobsterAI 桌面端自己的通道。
|
|
2
|
-
//
|
|
3
|
-
// portal 的 auth 模块允许把授权码回调到任意
|
|
4
|
-
// http://127.0.0.1:<port>/auth/callback(条件是 http + 127.0.0.1 +
|
|
5
|
-
// /auth/callback + 有端口),因此插件可以自带登录,不依赖桌面端。
|
|
6
|
-
// 参数必须追加到登录页的 hash query,不是 URL query;且必须带
|
|
7
|
-
// source=electron,否则 portal 不会带上 redirect_uri。
|
|
8
|
-
|
|
9
|
-
import { createServer } from "node:http"
|
|
10
|
-
import { randomBytes } from "node:crypto"
|
|
11
|
-
import { API, LOGIN_URL } from "./constants.mjs"
|
|
12
|
-
import { jwtExp, accountOf } from "./jwt.mjs"
|
|
13
|
-
|
|
14
|
-
const TIMEOUT_MS = 5 * 60 * 1000
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
server
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
listening.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
1
|
+
// 浏览器登录,走 LobsterAI 桌面端自己的通道。
|
|
2
|
+
//
|
|
3
|
+
// portal 的 auth 模块允许把授权码回调到任意
|
|
4
|
+
// http://127.0.0.1:<port>/auth/callback(条件是 http + 127.0.0.1 +
|
|
5
|
+
// /auth/callback + 有端口),因此插件可以自带登录,不依赖桌面端。
|
|
6
|
+
// 参数必须追加到登录页的 hash query,不是 URL query;且必须带
|
|
7
|
+
// source=electron,否则 portal 不会带上 redirect_uri。
|
|
8
|
+
|
|
9
|
+
import { createServer } from "node:http"
|
|
10
|
+
import { randomBytes } from "node:crypto"
|
|
11
|
+
import { API, LOGIN_URL } from "./constants.mjs"
|
|
12
|
+
import { jwtExp, accountOf } from "./jwt.mjs"
|
|
13
|
+
|
|
14
|
+
const TIMEOUT_MS = 5 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
// announce 把回调地址同时放进普通 query(hash 里那份是 portal 要的)。
|
|
17
|
+
// portal 只读 hash,多这一个参数不影响登录——实测带与不带取回的页面字节
|
|
18
|
+
// 完全相同。宿主 Magpie 却只从普通 query 里找 loopback 端口
|
|
19
|
+
// (pluginsignin_paste.go 的 loopbackPorts),找到才为这次登录提供
|
|
20
|
+
// 「回调 URL」输入框:浏览器在另一台机器上时,粘贴登录后落在地址栏的
|
|
21
|
+
// 地址就能完成登录,不用别的通道。
|
|
22
|
+
export function authorizeUrl(loginUrl, callbackUrl, state, { announce = false } = {}) {
|
|
23
|
+
const base = loginUrl || LOGIN_URL
|
|
24
|
+
const hashAt = base.indexOf("#")
|
|
25
|
+
const head = hashAt === -1 ? base : base.slice(0, hashAt)
|
|
26
|
+
const hash = hashAt === -1 ? "" : base.slice(hashAt + 1)
|
|
27
|
+
const [route, query = ""] = hash.split("?")
|
|
28
|
+
const q = new URLSearchParams(query)
|
|
29
|
+
q.set("source", "electron")
|
|
30
|
+
q.set("redirect_uri", callbackUrl)
|
|
31
|
+
q.set("state", state)
|
|
32
|
+
if (!announce) return `${head}#${route}?${q.toString()}`
|
|
33
|
+
const at = head.indexOf("?")
|
|
34
|
+
const plain = new URLSearchParams(at === -1 ? "" : head.slice(at + 1))
|
|
35
|
+
plain.set("redirect_uri", callbackUrl)
|
|
36
|
+
const path = at === -1 ? head : head.slice(0, at)
|
|
37
|
+
return `${path}?${plain.toString()}#${route}?${q.toString()}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 起一个只监听 127.0.0.1 的临时服务;端口由系统分配(listen 0)。
|
|
41
|
+
// 返回 { port, result(ms), close() },把「等回调」和「关服务」分开,
|
|
42
|
+
// 这样调用方可以先拿到端口拼 URL,再去等结果。
|
|
43
|
+
function listenOnce() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let deliver = () => {}
|
|
46
|
+
const got = new Promise((r) => { deliver = r })
|
|
47
|
+
let timer = null
|
|
48
|
+
const server = createServer((req, res) => {
|
|
49
|
+
const u = new URL(req.url, "http://127.0.0.1")
|
|
50
|
+
if (u.pathname !== "/auth/callback") { res.writeHead(404).end(); return }
|
|
51
|
+
const code = u.searchParams.get("code")
|
|
52
|
+
const state = u.searchParams.get("state")
|
|
53
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
54
|
+
res.end(code
|
|
55
|
+
? "<h2>登录成功,可以关闭此页面。</h2>"
|
|
56
|
+
: "<h2>登录失败:没有拿到授权码。</h2>")
|
|
57
|
+
deliver({ code, state })
|
|
58
|
+
})
|
|
59
|
+
server.on("error", reject)
|
|
60
|
+
server.listen(0, "127.0.0.1", () => {
|
|
61
|
+
const { port } = server.address()
|
|
62
|
+
resolve({
|
|
63
|
+
port,
|
|
64
|
+
close() {
|
|
65
|
+
if (timer) clearTimeout(timer)
|
|
66
|
+
try { server.close() } catch {}
|
|
67
|
+
},
|
|
68
|
+
result(ms) {
|
|
69
|
+
const late = new Promise((r) => {
|
|
70
|
+
timer = setTimeout(() => r({ error: new Error("登录超时(5 分钟)") }), ms)
|
|
71
|
+
})
|
|
72
|
+
return Promise.race([got, late]).then((v) => {
|
|
73
|
+
if (v?.error) throw v.error
|
|
74
|
+
return v
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function exchangeCode(code) {
|
|
83
|
+
const res = await fetch(`${API}/auth/exchange`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ authCode: code, firstKeyfrom: "official", latestKeyfrom: "official" }),
|
|
87
|
+
signal: AbortSignal.timeout(30_000),
|
|
88
|
+
})
|
|
89
|
+
const text = await res.text()
|
|
90
|
+
let v
|
|
91
|
+
try { v = JSON.parse(text) } catch { throw new Error(`LobsterAI 的登录响应无法解析:${text.slice(0, 200)}`) }
|
|
92
|
+
// /api/* 用 {code, message, data} 信封,业务错误也是 HTTP 200
|
|
93
|
+
if (v?.code !== 0 || !v?.data) {
|
|
94
|
+
throw new Error(`LobsterAI 拒绝了登录:${v?.message || `code ${v?.code}`}`)
|
|
95
|
+
}
|
|
96
|
+
return v.data
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// beginSignIn 起回调服务并拼出授权地址,然后把「等回调并换 token」交给
|
|
100
|
+
// finish()。分成两半是因为由 magpie 负责打开 authorize 返回的 url ——
|
|
101
|
+
// 插件自己再开一次就会开两个标签页。
|
|
102
|
+
//
|
|
103
|
+
// announce 见 authorizeUrl:让宿主知道这次登录的回调端口,它才会提供一个
|
|
104
|
+
// 粘贴回调地址的输入框(浏览器在另一台机器上时用它完成登录)。
|
|
105
|
+
export async function beginSignIn({ timeoutMs = TIMEOUT_MS, announce = false } = {}) {
|
|
106
|
+
const state = randomBytes(24).toString("base64url")
|
|
107
|
+
const listening = await listenOnce()
|
|
108
|
+
const callbackUrl = `http://127.0.0.1:${listening.port}/auth/callback`
|
|
109
|
+
const url = authorizeUrl(LOGIN_URL, callbackUrl, state, { announce })
|
|
110
|
+
|
|
111
|
+
const finish = async () => {
|
|
112
|
+
try {
|
|
113
|
+
const got = await listening.result(timeoutMs)
|
|
114
|
+
if (got.state !== state) throw new Error("登录回调的 state 不匹配,已忽略")
|
|
115
|
+
const data = await exchangeCode(got.code)
|
|
116
|
+
const access = String(data.accessToken ?? "")
|
|
117
|
+
if (!access) throw new Error("LobsterAI 没有返回 accessToken")
|
|
118
|
+
const who = accountOf(access)
|
|
119
|
+
return {
|
|
120
|
+
type: "success",
|
|
121
|
+
refresh: String(data.refreshToken ?? ""),
|
|
122
|
+
access,
|
|
123
|
+
expires: jwtExp(access) || Date.now() + 30 * 24 * 3600 * 1000,
|
|
124
|
+
accountId: who.yid ?? (who.id !== undefined ? String(who.id) : undefined),
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
listening.close()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { url, finish, close: () => listening.close() }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// beginSignInAnnounced 是给远程(NAS / Docker)用的 beginSignIn:回调服务
|
|
135
|
+
// 照常起在本机 127.0.0.1 上,登录地址额外在普通 query 里写明这个端口。
|
|
136
|
+
// 宿主 Magpie 看到它,就会在登录页旁给出「回调 URL」输入框——浏览器在
|
|
137
|
+
// 另一台机器上时,把登录后地址栏里的完整地址粘回去,Magpie 在本机把请求
|
|
138
|
+
// 送到这个端口,回调照常抵达。没有这一步,浏览器只能和插件在同一台机器上。
|
|
139
|
+
export async function beginSignInAnnounced(opts = {}) {
|
|
140
|
+
return beginSignIn({ ...opts, announce: true })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// signIn 是 beginSignIn 的「自己开浏览器」封装:给命令行与测试用。
|
|
144
|
+
// open 可注入,因此测试里不必真的弹浏览器。
|
|
145
|
+
export async function signIn({ open, timeoutMs = TIMEOUT_MS } = {}) {
|
|
146
|
+
const pending = await beginSignIn({ timeoutMs })
|
|
147
|
+
try {
|
|
148
|
+
await open(pending.url)
|
|
149
|
+
return await pending.finish()
|
|
150
|
+
} catch (e) {
|
|
151
|
+
pending.close()
|
|
152
|
+
throw e
|
|
153
|
+
}
|
|
154
|
+
}
|
package/lib/constants.mjs
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
// LobsterAI(有道龙虾)的固定事实:地址、客户端标识、未登录时的兜底模型表。
|
|
2
|
-
// 兜底表只在没登录时显示;登录后由 /api/models/available 的实时结果替换。
|
|
3
|
-
|
|
4
|
-
export const PROVIDER = "lobster"
|
|
5
|
-
export const NAME = "LobsterAI"
|
|
6
|
-
export const BASE = "https://lobsterai-server.youdao.com"
|
|
7
|
-
export const API = BASE + "/api"
|
|
8
|
-
export const CHAT_BASE = BASE + "/api/proxy/v1"
|
|
9
|
-
export const NPM = "@ai-sdk/openai-compatible"
|
|
10
|
-
|
|
11
|
-
export const LOGIN_URL = "https://lobsterai.youdao.com/portal#/login"
|
|
12
|
-
export const ICON = "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
13
|
-
|
|
14
|
-
// 桌面端 2026.9.23 发的标识头;上游不强制,但可能影响特性开关
|
|
15
|
-
export const CLIENT_VERSION = "2026.9.23"
|
|
16
|
-
export const CAPABILITIES = "kimi-k3-agentic-v1,thinking-level-control-v1"
|
|
17
|
-
|
|
18
|
-
// 接口不提供输出上限,取一个保守值(见设计文档 §8)
|
|
19
|
-
export const DEFAULT_OUTPUT = 32000
|
|
20
|
-
export const DEFAULT_CONTEXT = 1000000
|
|
21
|
-
|
|
22
|
-
export function configModel({ name, context, output, reasoning, image }) {
|
|
23
|
-
const input = ["text"]
|
|
24
|
-
if (image) input.push("image")
|
|
25
|
-
return {
|
|
26
|
-
name,
|
|
27
|
-
limit: { context: context || DEFAULT_CONTEXT, output: output || DEFAULT_OUTPUT },
|
|
28
|
-
reasoning: !!reasoning,
|
|
29
|
-
tool_call: true,
|
|
30
|
-
modalities: { input, output: ["text"] },
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// 兜底:未登录时让用户看到 provider 存在。字段来自实测的模型目录。
|
|
35
|
-
export const FALLBACK_MODELS = {
|
|
36
|
-
"deepseek-v4-pro": configModel({ name: "DeepSeek V4 Pro", reasoning: true }),
|
|
37
|
-
"deepseek-flash": configModel({ name: "DeepSeek Flash", reasoning: true }),
|
|
38
|
-
"glm-5.3": configModel({ name: "GLM-5.3", reasoning: true }),
|
|
39
|
-
"kimi-k3": configModel({ name: "Kimi K3", reasoning: true, image: true }),
|
|
40
|
-
"qwen3.8-max": configModel({ name: "Qwen3.8 Max", reasoning: true }),
|
|
41
|
-
}
|
|
1
|
+
// LobsterAI(有道龙虾)的固定事实:地址、客户端标识、未登录时的兜底模型表。
|
|
2
|
+
// 兜底表只在没登录时显示;登录后由 /api/models/available 的实时结果替换。
|
|
3
|
+
|
|
4
|
+
export const PROVIDER = "lobster"
|
|
5
|
+
export const NAME = "LobsterAI"
|
|
6
|
+
export const BASE = "https://lobsterai-server.youdao.com"
|
|
7
|
+
export const API = BASE + "/api"
|
|
8
|
+
export const CHAT_BASE = BASE + "/api/proxy/v1"
|
|
9
|
+
export const NPM = "@ai-sdk/openai-compatible"
|
|
10
|
+
|
|
11
|
+
export const LOGIN_URL = "https://lobsterai.youdao.com/portal#/login"
|
|
12
|
+
export const ICON = "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
13
|
+
|
|
14
|
+
// 桌面端 2026.9.23 发的标识头;上游不强制,但可能影响特性开关
|
|
15
|
+
export const CLIENT_VERSION = "2026.9.23"
|
|
16
|
+
export const CAPABILITIES = "kimi-k3-agentic-v1,thinking-level-control-v1"
|
|
17
|
+
|
|
18
|
+
// 接口不提供输出上限,取一个保守值(见设计文档 §8)
|
|
19
|
+
export const DEFAULT_OUTPUT = 32000
|
|
20
|
+
export const DEFAULT_CONTEXT = 1000000
|
|
21
|
+
|
|
22
|
+
export function configModel({ name, context, output, reasoning, image }) {
|
|
23
|
+
const input = ["text"]
|
|
24
|
+
if (image) input.push("image")
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
limit: { context: context || DEFAULT_CONTEXT, output: output || DEFAULT_OUTPUT },
|
|
28
|
+
reasoning: !!reasoning,
|
|
29
|
+
tool_call: true,
|
|
30
|
+
modalities: { input, output: ["text"] },
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 兜底:未登录时让用户看到 provider 存在。字段来自实测的模型目录。
|
|
35
|
+
export const FALLBACK_MODELS = {
|
|
36
|
+
"deepseek-v4-pro": configModel({ name: "DeepSeek V4 Pro", reasoning: true }),
|
|
37
|
+
"deepseek-flash": configModel({ name: "DeepSeek Flash", reasoning: true }),
|
|
38
|
+
"glm-5.3": configModel({ name: "GLM-5.3", reasoning: true }),
|
|
39
|
+
"kimi-k3": configModel({ name: "Kimi K3", reasoning: true, image: true }),
|
|
40
|
+
"qwen3.8-max": configModel({ name: "Qwen3.8 Max", reasoning: true }),
|
|
41
|
+
}
|
package/lib/jwt.mjs
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
// LobsterAI 的 accessToken 是标准 JWT(未加密,仅签名)。
|
|
2
|
-
// 插件只读它的载荷,不校验签名——校验由上游负责。
|
|
3
|
-
|
|
4
|
-
function decode(part) {
|
|
5
|
-
try {
|
|
6
|
-
const s = Buffer.from(String(part).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
7
|
-
const v = JSON.parse(s)
|
|
8
|
-
return v && typeof v === "object" ? v : null
|
|
9
|
-
} catch {
|
|
10
|
-
return null
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function jwtPayload(token) {
|
|
15
|
-
const parts = String(token ?? "").split(".")
|
|
16
|
-
if (parts.length < 2) return null
|
|
17
|
-
return decode(parts[1])
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// exp 以秒计;返回毫秒,读不到就是 0(magpie 把 0 当作「不续期」)
|
|
21
|
-
export function jwtExp(token) {
|
|
22
|
-
const p = jwtPayload(token)
|
|
23
|
-
const exp = Number(p?.exp)
|
|
24
|
-
return Number.isFinite(exp) && exp > 0 ? exp * 1000 : 0
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function accountOf(token) {
|
|
28
|
-
const p = jwtPayload(token)
|
|
29
|
-
if (!p) return {}
|
|
30
|
-
const out = {}
|
|
31
|
-
if (p.yid) out.yid = p.yid
|
|
32
|
-
if (p.sub !== undefined) out.id = p.sub
|
|
33
|
-
return out
|
|
34
|
-
}
|
|
1
|
+
// LobsterAI 的 accessToken 是标准 JWT(未加密,仅签名)。
|
|
2
|
+
// 插件只读它的载荷,不校验签名——校验由上游负责。
|
|
3
|
+
|
|
4
|
+
function decode(part) {
|
|
5
|
+
try {
|
|
6
|
+
const s = Buffer.from(String(part).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
7
|
+
const v = JSON.parse(s)
|
|
8
|
+
return v && typeof v === "object" ? v : null
|
|
9
|
+
} catch {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function jwtPayload(token) {
|
|
15
|
+
const parts = String(token ?? "").split(".")
|
|
16
|
+
if (parts.length < 2) return null
|
|
17
|
+
return decode(parts[1])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// exp 以秒计;返回毫秒,读不到就是 0(magpie 把 0 当作「不续期」)
|
|
21
|
+
export function jwtExp(token) {
|
|
22
|
+
const p = jwtPayload(token)
|
|
23
|
+
const exp = Number(p?.exp)
|
|
24
|
+
return Number.isFinite(exp) && exp > 0 ? exp * 1000 : 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function accountOf(token) {
|
|
28
|
+
const p = jwtPayload(token)
|
|
29
|
+
if (!p) return {}
|
|
30
|
+
const out = {}
|
|
31
|
+
if (p.yid) out.yid = p.yid
|
|
32
|
+
if (p.sub !== undefined) out.id = p.sub
|
|
33
|
+
return out
|
|
34
|
+
}
|
package/lib/sse.mjs
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
// LobsterAI 的推理端点永远以 SSE 作答,即使请求写了 stream:false。
|
|
2
|
-
// 这里把字节流切成 {event, data} 记录,两种情形都由上层决定怎么用。
|
|
3
|
-
|
|
4
|
-
const decoder = new TextDecoder()
|
|
5
|
-
|
|
6
|
-
export async function* sse(body) {
|
|
7
|
-
let buf = ""
|
|
8
|
-
let event = ""
|
|
9
|
-
let data = []
|
|
10
|
-
|
|
11
|
-
// 一条空行结束一个事件;没有 data 的事件(只有注释)不产出
|
|
12
|
-
const flush = () => {
|
|
13
|
-
if (data.length === 0) { event = ""; return null }
|
|
14
|
-
const rec = { event, data: data.join("\n") }
|
|
15
|
-
event = ""
|
|
16
|
-
data = []
|
|
17
|
-
return rec
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const feed = (raw) => {
|
|
21
|
-
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
|
|
22
|
-
if (line === "") return flush()
|
|
23
|
-
if (line.startsWith(":")) return null // 注释 / keep-alive
|
|
24
|
-
const i = line.indexOf(":")
|
|
25
|
-
const field = i === -1 ? line : line.slice(0, i)
|
|
26
|
-
let value = i === -1 ? "" : line.slice(i + 1)
|
|
27
|
-
if (value.startsWith(" ")) value = value.slice(1)
|
|
28
|
-
if (field === "event") event = value
|
|
29
|
-
else if (field === "data") data.push(value)
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
for await (const chunk of body) {
|
|
34
|
-
buf += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
|
|
35
|
-
let nl
|
|
36
|
-
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
37
|
-
const rec = feed(buf.slice(0, nl))
|
|
38
|
-
buf = buf.slice(nl + 1)
|
|
39
|
-
if (rec) yield rec
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
buf += decoder.decode()
|
|
43
|
-
if (buf !== "") {
|
|
44
|
-
const rec = feed(buf)
|
|
45
|
-
if (rec) yield rec
|
|
46
|
-
}
|
|
47
|
-
const last = flush()
|
|
48
|
-
if (last) yield last
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function* empty() {}
|
|
52
|
-
|
|
53
|
-
// first 读一条但把它放回去:上游把错误藏在流里,必须先看一眼首条
|
|
54
|
-
export async function first(it) {
|
|
55
|
-
const n = await it.next()
|
|
56
|
-
if (n.done) return { head: null, rest: empty() }
|
|
57
|
-
return { head: n.value, rest: chain([n.value], it) }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function* chain(held, it) {
|
|
61
|
-
yield* held
|
|
62
|
-
for (;;) {
|
|
63
|
-
const n = await it.next()
|
|
64
|
-
if (n.done) return
|
|
65
|
-
yield n.value
|
|
66
|
-
}
|
|
67
|
-
}
|
|
1
|
+
// LobsterAI 的推理端点永远以 SSE 作答,即使请求写了 stream:false。
|
|
2
|
+
// 这里把字节流切成 {event, data} 记录,两种情形都由上层决定怎么用。
|
|
3
|
+
|
|
4
|
+
const decoder = new TextDecoder()
|
|
5
|
+
|
|
6
|
+
export async function* sse(body) {
|
|
7
|
+
let buf = ""
|
|
8
|
+
let event = ""
|
|
9
|
+
let data = []
|
|
10
|
+
|
|
11
|
+
// 一条空行结束一个事件;没有 data 的事件(只有注释)不产出
|
|
12
|
+
const flush = () => {
|
|
13
|
+
if (data.length === 0) { event = ""; return null }
|
|
14
|
+
const rec = { event, data: data.join("\n") }
|
|
15
|
+
event = ""
|
|
16
|
+
data = []
|
|
17
|
+
return rec
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const feed = (raw) => {
|
|
21
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
|
|
22
|
+
if (line === "") return flush()
|
|
23
|
+
if (line.startsWith(":")) return null // 注释 / keep-alive
|
|
24
|
+
const i = line.indexOf(":")
|
|
25
|
+
const field = i === -1 ? line : line.slice(0, i)
|
|
26
|
+
let value = i === -1 ? "" : line.slice(i + 1)
|
|
27
|
+
if (value.startsWith(" ")) value = value.slice(1)
|
|
28
|
+
if (field === "event") event = value
|
|
29
|
+
else if (field === "data") data.push(value)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for await (const chunk of body) {
|
|
34
|
+
buf += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
|
|
35
|
+
let nl
|
|
36
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
37
|
+
const rec = feed(buf.slice(0, nl))
|
|
38
|
+
buf = buf.slice(nl + 1)
|
|
39
|
+
if (rec) yield rec
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
buf += decoder.decode()
|
|
43
|
+
if (buf !== "") {
|
|
44
|
+
const rec = feed(buf)
|
|
45
|
+
if (rec) yield rec
|
|
46
|
+
}
|
|
47
|
+
const last = flush()
|
|
48
|
+
if (last) yield last
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function* empty() {}
|
|
52
|
+
|
|
53
|
+
// first 读一条但把它放回去:上游把错误藏在流里,必须先看一眼首条
|
|
54
|
+
export async function first(it) {
|
|
55
|
+
const n = await it.next()
|
|
56
|
+
if (n.done) return { head: null, rest: empty() }
|
|
57
|
+
return { head: n.value, rest: chain([n.value], it) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function* chain(held, it) {
|
|
61
|
+
yield* held
|
|
62
|
+
for (;;) {
|
|
63
|
+
const n = await it.next()
|
|
64
|
+
if (n.done) return
|
|
65
|
+
yield n.value
|
|
66
|
+
}
|
|
67
|
+
}
|