opencode-qq-v2 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/approver.js +52 -0
- package/commands.js +6 -0
- package/config.js +25 -0
- package/constants.js +16 -0
- package/event-pusher.js +81 -0
- package/index.js +441 -0
- package/logger.js +24 -0
- package/package.json +16 -4
- package/qq/api.js +75 -0
- package/qq/auth.js +29 -0
- package/qq/gateway.js +213 -0
- package/qq/stream.js +99 -0
- package/relay.js +26 -0
- package/session-manager.js +98 -0
- package/stream-buffer.js +36 -0
- package/util/chunk.js +46 -0
- package/util/media.js +19 -0
- package/util/quote.js +25 -0
- package/util/throttle.js +33 -0
package/qq/gateway.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { GATEWAY_PATH } from "../constants";
|
|
3
|
+
import { extractQuotedText } from "../util/quote";
|
|
4
|
+
import { log } from "../logger";
|
|
5
|
+
const OP_DISPATCH = 0;
|
|
6
|
+
const OP_HEARTBEAT = 1;
|
|
7
|
+
const OP_IDENTIFY = 2;
|
|
8
|
+
const OP_RESUME = 6;
|
|
9
|
+
const OP_HELLO = 10;
|
|
10
|
+
const OP_HEARTBEAT_ACK = 11;
|
|
11
|
+
export class QQGateway {
|
|
12
|
+
opts;
|
|
13
|
+
ws = null;
|
|
14
|
+
sessionId = null;
|
|
15
|
+
lastSeq = null;
|
|
16
|
+
resumeAttempted = false;
|
|
17
|
+
heartbeatTimer = null;
|
|
18
|
+
reconnectTimer = null;
|
|
19
|
+
reconnectAttempt = 0;
|
|
20
|
+
stopped = false;
|
|
21
|
+
seenIds = new Set();
|
|
22
|
+
seenOrder = [];
|
|
23
|
+
isDuplicate(key) {
|
|
24
|
+
if (!key)
|
|
25
|
+
return false;
|
|
26
|
+
if (this.seenIds.has(key))
|
|
27
|
+
return true;
|
|
28
|
+
this.seenIds.add(key);
|
|
29
|
+
this.seenOrder.push(key);
|
|
30
|
+
const cap = this.opts.maxSeen ?? 1000;
|
|
31
|
+
if (this.seenOrder.length > cap) {
|
|
32
|
+
const oldest = this.seenOrder.shift();
|
|
33
|
+
if (oldest !== undefined)
|
|
34
|
+
this.seenIds.delete(oldest);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
this.opts = opts;
|
|
40
|
+
}
|
|
41
|
+
start() {
|
|
42
|
+
this.stopped = false;
|
|
43
|
+
this.connect().catch(() => this.scheduleReconnect());
|
|
44
|
+
}
|
|
45
|
+
stop() {
|
|
46
|
+
this.stopped = true;
|
|
47
|
+
this.cleanup();
|
|
48
|
+
}
|
|
49
|
+
cleanup() {
|
|
50
|
+
if (this.heartbeatTimer)
|
|
51
|
+
clearInterval(this.heartbeatTimer);
|
|
52
|
+
this.heartbeatTimer = null;
|
|
53
|
+
if (this.reconnectTimer)
|
|
54
|
+
clearTimeout(this.reconnectTimer);
|
|
55
|
+
this.reconnectTimer = null;
|
|
56
|
+
if (this.ws) {
|
|
57
|
+
this.ws.removeAllListeners();
|
|
58
|
+
try {
|
|
59
|
+
this.ws.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.ws = null;
|
|
66
|
+
}
|
|
67
|
+
scheduleReconnect() {
|
|
68
|
+
if (this.stopped)
|
|
69
|
+
return;
|
|
70
|
+
if (this.resumeAttempted) {
|
|
71
|
+
this.resumeAttempted = false;
|
|
72
|
+
this.sessionId = null;
|
|
73
|
+
this.lastSeq = null;
|
|
74
|
+
}
|
|
75
|
+
const base = this.opts.reconnectBaseMs ?? 1000;
|
|
76
|
+
const delay = Math.min(base * 2 ** this.reconnectAttempt, 60_000);
|
|
77
|
+
this.reconnectAttempt++;
|
|
78
|
+
this.reconnectTimer = setTimeout(() => {
|
|
79
|
+
if (this.stopped)
|
|
80
|
+
return;
|
|
81
|
+
this.connect().catch(() => this.scheduleReconnect());
|
|
82
|
+
}, delay);
|
|
83
|
+
}
|
|
84
|
+
async connect() {
|
|
85
|
+
log("[gateway] 开始连接...");
|
|
86
|
+
const url = await this.opts.getGatewayUrl();
|
|
87
|
+
log("[gateway] 网关地址:", url);
|
|
88
|
+
const ws = new WebSocket(url);
|
|
89
|
+
this.ws = ws;
|
|
90
|
+
ws.on("open", () => {
|
|
91
|
+
log("[gateway] WebSocket 已连接,等待 HELLO...");
|
|
92
|
+
});
|
|
93
|
+
ws.on("message", (raw) => {
|
|
94
|
+
// 终审 I3:非 JSON 帧直接 JSON.parse 会抛异常击穿 opencode 进程,守卫后丢弃
|
|
95
|
+
let pkt;
|
|
96
|
+
try {
|
|
97
|
+
pkt = JSON.parse(raw.toString());
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
console.warn(`[opencode-qq] 丢弃非 JSON 网关帧: ${String(e).slice(0, 120)}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.handlePacket(pkt);
|
|
104
|
+
});
|
|
105
|
+
ws.on("close", (code) => {
|
|
106
|
+
log("[gateway] WebSocket 关闭 code=", code);
|
|
107
|
+
this.opts.disconnected();
|
|
108
|
+
this.scheduleReconnect();
|
|
109
|
+
});
|
|
110
|
+
ws.on("error", (e) => {
|
|
111
|
+
log("[gateway] WebSocket 错误:", String(e?.message ?? e));
|
|
112
|
+
ws.terminate();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
handlePacket(pkt) {
|
|
116
|
+
switch (pkt.op) {
|
|
117
|
+
case OP_HELLO: {
|
|
118
|
+
const interval = pkt.d.heartbeat_interval;
|
|
119
|
+
this.startHeartbeat(interval);
|
|
120
|
+
if (this.sessionId !== null && this.lastSeq !== null) {
|
|
121
|
+
this.resumeAttempted = true;
|
|
122
|
+
void this.sendResume().catch(() => this.ws?.terminate());
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.resumeAttempted = false;
|
|
126
|
+
void this.sendIdentify().catch(() => this.ws?.terminate());
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case OP_HEARTBEAT_ACK:
|
|
131
|
+
break;
|
|
132
|
+
case OP_DISPATCH: {
|
|
133
|
+
this.lastSeq = pkt.s ?? this.lastSeq;
|
|
134
|
+
const d = (pkt.d ?? {});
|
|
135
|
+
if (this.isDuplicate(pkt.id ?? String(d.msg_id ?? "")))
|
|
136
|
+
return;
|
|
137
|
+
this.handleDispatch(pkt.t ?? "", d);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async sendIdentify() {
|
|
143
|
+
const token = await this.opts.getToken();
|
|
144
|
+
log("[gateway] 发送 identify...");
|
|
145
|
+
this.ws?.send(JSON.stringify({
|
|
146
|
+
op: OP_IDENTIFY,
|
|
147
|
+
d: { token: `QQBot ${token}`, intents: this.opts.intents, shard: [0, 1] },
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
async sendResume() {
|
|
151
|
+
const token = await this.opts.getToken();
|
|
152
|
+
this.ws?.send(JSON.stringify({
|
|
153
|
+
op: OP_RESUME,
|
|
154
|
+
d: { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.lastSeq },
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
startHeartbeat(intervalMs) {
|
|
158
|
+
if (this.heartbeatTimer)
|
|
159
|
+
clearInterval(this.heartbeatTimer);
|
|
160
|
+
this.heartbeatTimer = setInterval(() => {
|
|
161
|
+
this.ws?.send(JSON.stringify({ op: OP_HEARTBEAT, d: this.lastSeq }));
|
|
162
|
+
}, intervalMs);
|
|
163
|
+
}
|
|
164
|
+
handleDispatch(t, d) {
|
|
165
|
+
if (t === "READY") {
|
|
166
|
+
this.sessionId = String(d.session_id);
|
|
167
|
+
this.resumeAttempted = false;
|
|
168
|
+
this.reconnectAttempt = 0;
|
|
169
|
+
log("[gateway] ✅ READY,bot 已上线 session=", this.sessionId);
|
|
170
|
+
this.opts.connected();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (t === "RESUMED") {
|
|
174
|
+
this.resumeAttempted = false;
|
|
175
|
+
this.reconnectAttempt = 0;
|
|
176
|
+
log("[gateway] RESUMED 会话恢复");
|
|
177
|
+
this.opts.connected();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (t === "C2C_MESSAGE_CREATE") {
|
|
181
|
+
log("[gateway] C2C 原始数据:", JSON.stringify(d).slice(0, 600));
|
|
182
|
+
log("[gateway] 收到单聊消息 openid=", String(d.author?.user_openid ?? d.openid ?? ""), "content=", String(d.content ?? "").slice(0, 80));
|
|
183
|
+
const rawAttachments = Array.isArray(d.attachments) ? d.attachments : [];
|
|
184
|
+
this.opts.message({
|
|
185
|
+
openid: String(d.author?.user_openid ?? d.author?.id ?? d.openid ?? ""),
|
|
186
|
+
content: String(d.content ?? ""),
|
|
187
|
+
msgId: String(d.id ?? d.msg_id ?? ""),
|
|
188
|
+
timestamp: Date.parse(String(d.timestamp ?? "")) || Date.now(),
|
|
189
|
+
attachments: rawAttachments
|
|
190
|
+
.filter((a) => !!a && typeof a === "object")
|
|
191
|
+
.filter((a) => String(a.content_type ?? a.contentType ?? "") === "image" || String(a.url ?? "").match(/\.(png|jpe?g|gif|webp)/i))
|
|
192
|
+
.map((a) => ({
|
|
193
|
+
contentType: "image",
|
|
194
|
+
url: String(a.url ?? ""),
|
|
195
|
+
filename: a.filename === undefined ? undefined : String(a.filename),
|
|
196
|
+
}))
|
|
197
|
+
.filter((a) => a.url),
|
|
198
|
+
quotedText: extractQuotedText(d),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function createGatewayUrlFetcher(restBase, getToken, fetchFn = fetch) {
|
|
204
|
+
return async () => {
|
|
205
|
+
const token = await getToken();
|
|
206
|
+
const res = await fetchFn(`${restBase}${GATEWAY_PATH}`, {
|
|
207
|
+
headers: { Authorization: `QQBot ${token}` },
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok)
|
|
210
|
+
throw new Error(`get gateway failed: HTTP ${res.status}`);
|
|
211
|
+
return (await res.json()).url;
|
|
212
|
+
};
|
|
213
|
+
}
|
package/qq/stream.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream_messages 打字机发送器。
|
|
3
|
+
*
|
|
4
|
+
* 延迟 begin:首次 update() 即为首片(input_state=1,index=0,content_raw=首批文本),
|
|
5
|
+
* 其后 update 为全量 replace 续片——正文天然以首片为前缀,保证 replace 前缀链一致
|
|
6
|
+
* (协议要求续片全量正文以上游已下发前缀开头,否则 40007)。
|
|
7
|
+
* 内部串行队列保证报文按序发出、index 有序,允许调用方 fire-and-forget。
|
|
8
|
+
*/
|
|
9
|
+
export class StreamSender {
|
|
10
|
+
opts;
|
|
11
|
+
ref;
|
|
12
|
+
streamMsgId = null;
|
|
13
|
+
index = 0;
|
|
14
|
+
begun = false;
|
|
15
|
+
finished = false;
|
|
16
|
+
failed = false;
|
|
17
|
+
queue = Promise.resolve();
|
|
18
|
+
constructor(opts, ref) {
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
this.ref = ref;
|
|
21
|
+
}
|
|
22
|
+
/** 推送当前累计全文;首次调用自动作为首片 */
|
|
23
|
+
update(fullText) {
|
|
24
|
+
if (this.failed || this.finished || !fullText)
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
if (!this.begun) {
|
|
27
|
+
this.begun = true;
|
|
28
|
+
return this.enqueue(() => ({
|
|
29
|
+
input_mode: "replace",
|
|
30
|
+
input_state: 1,
|
|
31
|
+
index: 0,
|
|
32
|
+
content_type: "markdown",
|
|
33
|
+
content_raw: fullText,
|
|
34
|
+
msg_id: this.ref.msgId,
|
|
35
|
+
msg_seq: this.ref.msgSeq,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
return this.enqueue(() => this.pieceBody(1, fullText));
|
|
39
|
+
}
|
|
40
|
+
/** 收尾片;从未发过片时先补首片再收尾 */
|
|
41
|
+
finish(fullText) {
|
|
42
|
+
if (this.failed || this.finished || !fullText)
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
if (!this.begun) {
|
|
45
|
+
const first = this.update(fullText);
|
|
46
|
+
this.finished = true;
|
|
47
|
+
return first.then(() => {
|
|
48
|
+
if (this.failed)
|
|
49
|
+
return;
|
|
50
|
+
return this.enqueue(() => this.pieceBody(10, fullText));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
this.finished = true;
|
|
54
|
+
return this.enqueue(() => this.pieceBody(10, fullText));
|
|
55
|
+
}
|
|
56
|
+
/** 续片/收尾通用报文;index 与 stream_msg_id 在实际发送时取最新值 */
|
|
57
|
+
pieceBody(inputState, fullText) {
|
|
58
|
+
return {
|
|
59
|
+
input_mode: "replace",
|
|
60
|
+
input_state: inputState,
|
|
61
|
+
index: this.index,
|
|
62
|
+
content_type: "markdown",
|
|
63
|
+
content_raw: fullText,
|
|
64
|
+
...(this.streamMsgId ? { stream_msg_id: this.streamMsgId } : {}),
|
|
65
|
+
msg_seq: this.ref.msgSeq,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** FIFO 串行队列:body 延迟到执行时构造,确保拿到最新 index/stream_msg_id */
|
|
69
|
+
enqueue(makeBody) {
|
|
70
|
+
const task = this.queue.then(async () => {
|
|
71
|
+
await this.post(makeBody());
|
|
72
|
+
});
|
|
73
|
+
this.queue = task.catch(() => { });
|
|
74
|
+
return task;
|
|
75
|
+
}
|
|
76
|
+
async post(body) {
|
|
77
|
+
if (this.failed)
|
|
78
|
+
return; // 排队期间被置败(如重入作废)则放弃执行
|
|
79
|
+
try {
|
|
80
|
+
const token = await this.opts.getToken();
|
|
81
|
+
const res = await (this.opts.fetchFn ?? fetch)(`${this.opts.restBase}/v2/users/${this.ref.openid}/stream_messages`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
this.failed = true; // 含 40007 前缀冲突 / 50002 限频 / 其他
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const data = (await res.json());
|
|
91
|
+
if (!this.streamMsgId && data.id)
|
|
92
|
+
this.streamMsgId = data.id;
|
|
93
|
+
this.index++;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.failed = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/relay.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V2 版工具钩子:`ctx.tool.hook("execute.after", cb)` 的回调只接收一个 input 参数,
|
|
3
|
+
* 结构为 { tool, sessionID, status, result?, error?, ... }。
|
|
4
|
+
* 这里合成总线形状事件 {type:"tool.execute.after", properties:{sessionID, tool, title}}
|
|
5
|
+
* 交给 index.js 的 listeners 分发。
|
|
6
|
+
*/
|
|
7
|
+
export function toolExecuteAfterHook(emit) {
|
|
8
|
+
return async (input) => {
|
|
9
|
+
let title = "";
|
|
10
|
+
if (input.status === "completed") {
|
|
11
|
+
const r = input.result ?? {};
|
|
12
|
+
title = r.title ?? r.description ?? (typeof r.output === "string" ? r.output : "") ?? "";
|
|
13
|
+
} else {
|
|
14
|
+
const e = input.error ?? {};
|
|
15
|
+
title = e.message ?? String(e.name ?? "error");
|
|
16
|
+
}
|
|
17
|
+
emit({
|
|
18
|
+
type: "tool.execute.after",
|
|
19
|
+
properties: {
|
|
20
|
+
sessionID: input.sessionID,
|
|
21
|
+
tool: input.tool,
|
|
22
|
+
title: typeof title === "string" ? title : String(title ?? ""),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { parseCommand } from "./commands";
|
|
3
|
+
export class SessionManager {
|
|
4
|
+
bridge;
|
|
5
|
+
persistPath;
|
|
6
|
+
fsMod;
|
|
7
|
+
pendingCount;
|
|
8
|
+
map = new Map();
|
|
9
|
+
constructor(bridge, persistPath, fsMod = fs,
|
|
10
|
+
/** 终审 I4a:可选,返回某会话当前待审批数(/status 展示) */
|
|
11
|
+
pendingCount) {
|
|
12
|
+
this.bridge = bridge;
|
|
13
|
+
this.persistPath = persistPath;
|
|
14
|
+
this.fsMod = fsMod;
|
|
15
|
+
this.pendingCount = pendingCount;
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(this.fsMod.readFileSync(persistPath, "utf8"));
|
|
18
|
+
for (const [k, v] of Object.entries(raw))
|
|
19
|
+
this.map.set(k, v);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* 首次运行无文件 */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async getSessionId(openid) {
|
|
26
|
+
return this.map.get(openid) ?? null;
|
|
27
|
+
}
|
|
28
|
+
isOurSession(sessionId) {
|
|
29
|
+
for (const sid of this.map.values())
|
|
30
|
+
if (sid === sessionId)
|
|
31
|
+
return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
snapshot() {
|
|
35
|
+
return Object.fromEntries(this.map);
|
|
36
|
+
}
|
|
37
|
+
reset(openid) {
|
|
38
|
+
const sid = this.map.get(openid);
|
|
39
|
+
this.map.delete(openid);
|
|
40
|
+
this.persist();
|
|
41
|
+
if (sid)
|
|
42
|
+
this.bridge.onSessionReset?.(sid);
|
|
43
|
+
}
|
|
44
|
+
/** 返回要发回 QQ 的文本 */
|
|
45
|
+
async dispatch(openid, text, files = []) {
|
|
46
|
+
const cmd = parseCommand(text);
|
|
47
|
+
if (cmd) {
|
|
48
|
+
switch (cmd.type) {
|
|
49
|
+
case "new":
|
|
50
|
+
this.reset(openid);
|
|
51
|
+
return "已重置会话,下次消息将开启新对话。";
|
|
52
|
+
case "status":
|
|
53
|
+
return this.statusReply(openid);
|
|
54
|
+
case "help":
|
|
55
|
+
return [
|
|
56
|
+
"opencode-qq 指令:",
|
|
57
|
+
"/new — 重置当前会话",
|
|
58
|
+
"/status — 查看会话状态",
|
|
59
|
+
"/help — 本帮助",
|
|
60
|
+
"其余文本将直接交给 opencode 处理。",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let sessionId = await this.getSessionId(openid);
|
|
65
|
+
if (!sessionId) {
|
|
66
|
+
const title = text.slice(0, 20);
|
|
67
|
+
const created = await this.bridge.sessionCreate(title);
|
|
68
|
+
sessionId = created.id;
|
|
69
|
+
this.map.set(openid, sessionId);
|
|
70
|
+
this.persist();
|
|
71
|
+
await this.bridge.sessionPrompt(sessionId, "以下用户将通过 QQ 单聊与你对话,回答请精炼。", true);
|
|
72
|
+
}
|
|
73
|
+
const result = await this.bridge.sessionPrompt(sessionId, text, false, files);
|
|
74
|
+
const out = result.parts
|
|
75
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
76
|
+
.map((p) => p.text)
|
|
77
|
+
.join("");
|
|
78
|
+
return out || "(无文本回复)";
|
|
79
|
+
}
|
|
80
|
+
async statusReply(openid) {
|
|
81
|
+
const sid = await this.getSessionId(openid);
|
|
82
|
+
if (!sid)
|
|
83
|
+
return "暂无会话,发任意消息即可开始。";
|
|
84
|
+
let reply = `当前会话: ${sid}\n状态: 已就绪`;
|
|
85
|
+
const pending = this.pendingCount?.(sid);
|
|
86
|
+
if (pending !== undefined)
|
|
87
|
+
reply += `\n待审批: ${pending} 条`;
|
|
88
|
+
return reply;
|
|
89
|
+
}
|
|
90
|
+
persist() {
|
|
91
|
+
try {
|
|
92
|
+
this.fsMod.writeFileSync(this.persistPath, JSON.stringify(Object.fromEntries(this.map)));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* 写失败不影响主流程 */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/stream-buffer.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V2 版流式文本缓冲:
|
|
3
|
+
* V2 事件模型里 assistant 文本以 `session.text.delta` 增量推送,
|
|
4
|
+
* 无需 V1 的 message.updated 角色映射,直接按 sessionID 累计即可。
|
|
5
|
+
*/
|
|
6
|
+
export class AssistantTextBuffer {
|
|
7
|
+
isOurSession;
|
|
8
|
+
/** sessionID → 累计文本 */
|
|
9
|
+
parts = new Map();
|
|
10
|
+
|
|
11
|
+
constructor(isOurSession) {
|
|
12
|
+
this.isOurSession = isOurSession;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
handle(evt) {
|
|
16
|
+
if (evt.type !== "session.text.delta") return;
|
|
17
|
+
const props = evt.properties ?? {};
|
|
18
|
+
const sid = String(props.sessionID ?? "");
|
|
19
|
+
const delta = typeof props.delta === "string" ? props.delta : "";
|
|
20
|
+
if (!sid || !delta || !this.isOurSession(sid)) return;
|
|
21
|
+
this.parts.set(sid, (this.parts.get(sid) ?? "") + delta);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
text(sessionId) {
|
|
25
|
+
const t = this.parts.get(sessionId);
|
|
26
|
+
return t && t.length > 0 ? t : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
clear(sessionId) {
|
|
30
|
+
this.parts.delete(sessionId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clearAll() {
|
|
34
|
+
this.parts.clear();
|
|
35
|
+
}
|
|
36
|
+
}
|
package/util/chunk.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function splitText(text, maxBytes = 1900) {
|
|
2
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes)
|
|
3
|
+
return text ? [text] : [];
|
|
4
|
+
const parts = [];
|
|
5
|
+
let current = "";
|
|
6
|
+
let currentBytes = 0;
|
|
7
|
+
for (const seg of text.split(/(?<=\n)/)) {
|
|
8
|
+
const segBytes = Buffer.byteLength(seg, "utf8");
|
|
9
|
+
if (segBytes > maxBytes) {
|
|
10
|
+
if (current) {
|
|
11
|
+
parts.push(current);
|
|
12
|
+
current = "";
|
|
13
|
+
currentBytes = 0;
|
|
14
|
+
}
|
|
15
|
+
let buf = "";
|
|
16
|
+
let bufBytes = 0;
|
|
17
|
+
for (const ch of seg) {
|
|
18
|
+
const chBytes = Buffer.byteLength(ch, "utf8");
|
|
19
|
+
if (bufBytes + chBytes > maxBytes) {
|
|
20
|
+
parts.push(buf);
|
|
21
|
+
buf = ch;
|
|
22
|
+
bufBytes = chBytes;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
buf += ch;
|
|
26
|
+
bufBytes += chBytes;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
current = buf;
|
|
30
|
+
currentBytes = bufBytes;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (currentBytes + segBytes > maxBytes) {
|
|
34
|
+
parts.push(current);
|
|
35
|
+
current = seg;
|
|
36
|
+
currentBytes = segBytes;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
current += seg;
|
|
40
|
+
currentBytes += segBytes;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (current)
|
|
44
|
+
parts.push(current);
|
|
45
|
+
return parts;
|
|
46
|
+
}
|
package/util/media.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const MIME_BY_EXT = {
|
|
2
|
+
png: "image/png",
|
|
3
|
+
jpg: "image/jpeg",
|
|
4
|
+
jpeg: "image/jpeg",
|
|
5
|
+
gif: "image/gif",
|
|
6
|
+
webp: "image/webp",
|
|
7
|
+
};
|
|
8
|
+
export function guessImageMime(url) {
|
|
9
|
+
const clean = url.split("?")[0] ?? "";
|
|
10
|
+
const ext = clean.split(".").pop()?.toLowerCase() ?? "";
|
|
11
|
+
return MIME_BY_EXT[ext] ?? "image/png";
|
|
12
|
+
}
|
|
13
|
+
export async function toImageDataUrl(url, fetchFn = fetch) {
|
|
14
|
+
const res = await fetchFn(url);
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`download attachment failed: HTTP ${res.status}`);
|
|
17
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
18
|
+
return `data:${guessImageMime(url)};base64,${buf.toString("base64")}`;
|
|
19
|
+
}
|
package/util/quote.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** 官方未完全文档化 message_type=103 的 msg_elements 结构,做防御性递归提取 */
|
|
2
|
+
export function extractQuotedText(d) {
|
|
3
|
+
if (Number(d.message_type) !== 103)
|
|
4
|
+
return "";
|
|
5
|
+
const out = [];
|
|
6
|
+
const walk = (node) => {
|
|
7
|
+
if (Array.isArray(node)) {
|
|
8
|
+
node.forEach(walk);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (node && typeof node === "object") {
|
|
12
|
+
const obj = node;
|
|
13
|
+
if (typeof obj.content === "string" && obj.content.trim())
|
|
14
|
+
out.push(obj.content);
|
|
15
|
+
if (obj.text_element)
|
|
16
|
+
walk(obj.text_element);
|
|
17
|
+
for (const key of Object.keys(obj)) {
|
|
18
|
+
if (key !== "content" && key !== "text_element")
|
|
19
|
+
walk(obj[key]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(d.msg_elements);
|
|
24
|
+
return out.join("\n").slice(0, 2000);
|
|
25
|
+
}
|
package/util/throttle.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class Throttler {
|
|
2
|
+
intervalMs;
|
|
3
|
+
flush;
|
|
4
|
+
buffers = new Map();
|
|
5
|
+
timers = new Map();
|
|
6
|
+
constructor(intervalMs, flush) {
|
|
7
|
+
this.intervalMs = intervalMs;
|
|
8
|
+
this.flush = flush;
|
|
9
|
+
}
|
|
10
|
+
push(key, line) {
|
|
11
|
+
let buf = this.buffers.get(key);
|
|
12
|
+
if (!buf) {
|
|
13
|
+
buf = [];
|
|
14
|
+
this.buffers.set(key, buf);
|
|
15
|
+
this.timers.set(key, setTimeout(() => this.flushKey(key), this.intervalMs));
|
|
16
|
+
}
|
|
17
|
+
buf.push(line);
|
|
18
|
+
}
|
|
19
|
+
flushKey(key) {
|
|
20
|
+
const timer = this.timers.get(key);
|
|
21
|
+
if (timer)
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
this.timers.delete(key);
|
|
24
|
+
const buf = this.buffers.get(key);
|
|
25
|
+
this.buffers.delete(key);
|
|
26
|
+
if (buf && buf.length > 0)
|
|
27
|
+
this.flush(key, buf);
|
|
28
|
+
}
|
|
29
|
+
dispose() {
|
|
30
|
+
for (const key of [...this.buffers.keys()])
|
|
31
|
+
this.flushKey(key);
|
|
32
|
+
}
|
|
33
|
+
}
|