@qingtui/openclaw-plugin 0.0.0-stage → 1.1.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/README.md +98 -2
- package/dist/index.js +65 -0
- package/dist/setup-entry.js +5 -0
- package/dist/src/channel.js +112 -0
- package/dist/src/client.js +416 -0
- package/dist/src/config.js +164 -0
- package/dist/src/event.js +81 -0
- package/dist/src/inbound.js +301 -0
- package/dist/src/logger.js +1 -0
- package/dist/src/service.js +115 -0
- package/dist/src/setup-wizard.js +94 -0
- package/dist/src/stream.js +244 -0
- package/dist/src/target.js +35 -0
- package/openclaw.plugin.json +72 -0
- package/package.json +51 -5
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分片节流间隔:onPartialReply 每个 token 都可能触发,每个分片是一次
|
|
3
|
+
* socket 往返,不节流会打爆代理;被跳过的内容由定时器/后续分片/收尾补齐。
|
|
4
|
+
*/
|
|
5
|
+
const STREAM_MIN_INTERVAL_MS = 700;
|
|
6
|
+
/** 空闲保活间隔:须明显小于代理 30 秒空闲关流阈值 */
|
|
7
|
+
const STREAM_KEEPALIVE_MS = 15_000;
|
|
8
|
+
/** 思考过程与正文的分隔标题:轻推流式只能追加,思考过程会保留在正文上方 */
|
|
9
|
+
const REASONING_HEADER = "【思考过程】\n";
|
|
10
|
+
const ANSWER_HEADER = "\n\n【回答】\n";
|
|
11
|
+
export class NudgeReplyStream {
|
|
12
|
+
ctx;
|
|
13
|
+
api;
|
|
14
|
+
logger;
|
|
15
|
+
tag;
|
|
16
|
+
minIntervalMs;
|
|
17
|
+
keepaliveMs;
|
|
18
|
+
/** 串行链:update/finish/abort/定时任务严格按序执行(增量分片必须有序) */
|
|
19
|
+
chain = Promise.resolve();
|
|
20
|
+
/** 首个非空增量前为 null(未开流) */
|
|
21
|
+
placeholder = null;
|
|
22
|
+
/** 已推送到轻推的累计文本(与最新快照比对得出增量) */
|
|
23
|
+
sentText = "";
|
|
24
|
+
/** 最新累计快照(节流跳过的部分由下一次 flush 补发) */
|
|
25
|
+
latestText = "";
|
|
26
|
+
/** 推理快照分段:每轮模型调用的推理各自累计,新一轮追加为新段 */
|
|
27
|
+
reasoningSegments = [];
|
|
28
|
+
/** 正文累计快照(不含思考过程) */
|
|
29
|
+
answerText = "";
|
|
30
|
+
lastSentAt = 0;
|
|
31
|
+
closed = false;
|
|
32
|
+
/** 流式链路失败后本回合不再尝试,调用方回退普通文本 */
|
|
33
|
+
failed = false;
|
|
34
|
+
timer = null;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.ctx = options.ctx;
|
|
37
|
+
this.api = options.api;
|
|
38
|
+
this.logger = options.logger;
|
|
39
|
+
this.tag = options.ctx.accountId ? `[${options.ctx.accountId}]` : "";
|
|
40
|
+
this.minIntervalMs = options.minIntervalMs ?? STREAM_MIN_INTERVAL_MS;
|
|
41
|
+
this.keepaliveMs = options.keepaliveMs ?? STREAM_KEEPALIVE_MS;
|
|
42
|
+
}
|
|
43
|
+
/** onPartialReply:记录最新快照,按节流间隔推送 status=1 增量分片 */
|
|
44
|
+
update(fullText) {
|
|
45
|
+
return this.enqueue(async () => {
|
|
46
|
+
if (!this.isActive() || !fullText)
|
|
47
|
+
return;
|
|
48
|
+
this.answerText = fullText;
|
|
49
|
+
await this.pushLatest();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* onReasoningStream:正文开始前把思考过程流式推送。正文开始后忽略,
|
|
54
|
+
* 否则插在正文前的内容会破坏"快照以已发文本为前缀"的增量换算。
|
|
55
|
+
*/
|
|
56
|
+
updateReasoning(fullText) {
|
|
57
|
+
return this.enqueue(async () => {
|
|
58
|
+
if (!this.isActive() || !fullText || this.answerText)
|
|
59
|
+
return;
|
|
60
|
+
const last = this.reasoningSegments.length - 1;
|
|
61
|
+
if (last >= 0 && fullText.startsWith(this.reasoningSegments[last])) {
|
|
62
|
+
this.reasoningSegments[last] = fullText;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
this.reasoningSegments.push(fullText);
|
|
66
|
+
}
|
|
67
|
+
await this.pushLatest();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** 重新拼接快照并按节流推送;已开流后的中间快照按间隔节流,首个分片不延迟 */
|
|
71
|
+
async pushLatest() {
|
|
72
|
+
this.latestText = this.composeText();
|
|
73
|
+
if (this.placeholder && Date.now() - this.lastSentAt < this.minIntervalMs) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await this.flushSafely(1, "流式分片推送失败");
|
|
77
|
+
}
|
|
78
|
+
composeText() {
|
|
79
|
+
if (!this.reasoningSegments.length)
|
|
80
|
+
return this.answerText;
|
|
81
|
+
const reasoning = REASONING_HEADER + this.reasoningSegments.join("\n\n");
|
|
82
|
+
return this.answerText
|
|
83
|
+
? reasoning + ANSWER_HEADER + this.answerText
|
|
84
|
+
: reasoning;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* final 回复收尾:补发剩余增量并以 status=2 结束流。
|
|
88
|
+
* 返回 false 表示本回合未真正开流、流式已失败,或已收尾后又收到内容
|
|
89
|
+
* 不同的 final,调用方应改走普通文本。
|
|
90
|
+
*/
|
|
91
|
+
async finish(finalText) {
|
|
92
|
+
let handled = false;
|
|
93
|
+
await this.enqueue(async () => {
|
|
94
|
+
if (this.failed)
|
|
95
|
+
return;
|
|
96
|
+
if (this.closed) {
|
|
97
|
+
// 已收尾:仅吞掉与已推送内容相同的重复 final(或空收尾);
|
|
98
|
+
// 内容不同的后续 final 是独立回复,返回 false 让调用方走普通文本
|
|
99
|
+
handled = !finalText || finalText === this.answerText;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (finalText) {
|
|
103
|
+
this.answerText = finalText;
|
|
104
|
+
this.latestText = this.composeText();
|
|
105
|
+
}
|
|
106
|
+
// 无任何增量(未开流),走普通文本
|
|
107
|
+
if (!this.placeholder)
|
|
108
|
+
return;
|
|
109
|
+
if (await this.flushSafely(2, "流式收尾失败")) {
|
|
110
|
+
this.close();
|
|
111
|
+
handled = true;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return handled;
|
|
115
|
+
}
|
|
116
|
+
/** 异常收尾(status=5,轻推客户端显示异常态);未开流时为空操作 */
|
|
117
|
+
abort() {
|
|
118
|
+
return this.enqueue(async () => {
|
|
119
|
+
if (!this.isActive() || !this.placeholder) {
|
|
120
|
+
this.stopTimer();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await this.api.sendChunk({
|
|
125
|
+
ctx: this.ctx,
|
|
126
|
+
placeholder: this.placeholder,
|
|
127
|
+
message: "",
|
|
128
|
+
status: 5,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
this.logger?.warn(`openclaw-nudge${this.tag}: 流式异常收尾失败: ${String(err)}`);
|
|
133
|
+
}
|
|
134
|
+
this.close();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
isActive() {
|
|
138
|
+
return !this.closed && !this.failed;
|
|
139
|
+
}
|
|
140
|
+
/** 执行 flush,失败时标记 failed;返回是否成功 */
|
|
141
|
+
async flushSafely(status, what) {
|
|
142
|
+
try {
|
|
143
|
+
await this.flush(status);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.failed = true;
|
|
148
|
+
this.stopTimer();
|
|
149
|
+
this.logger?.warn(`openclaw-nudge${this.tag}: ${what},本回合回退普通文本: ${String(err)}`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 把 sentText -> latestText 的增量发出去。
|
|
155
|
+
* status=1 且无增量时跳过;status=2 即使无增量也要发(关流信号)。
|
|
156
|
+
*/
|
|
157
|
+
async flush(status) {
|
|
158
|
+
const delta = this.resolveDelta();
|
|
159
|
+
if (status === 1 && !delta)
|
|
160
|
+
return;
|
|
161
|
+
if (!this.placeholder) {
|
|
162
|
+
this.placeholder = await this.api.createPlaceholder(this.ctx);
|
|
163
|
+
this.logger?.info(`openclaw-nudge${this.tag}: 流式占位已创建 (msgId=${this.placeholder.msgId})`);
|
|
164
|
+
this.startTimer();
|
|
165
|
+
}
|
|
166
|
+
await this.sendChunk(delta, status);
|
|
167
|
+
this.sentText = this.latestText;
|
|
168
|
+
}
|
|
169
|
+
async sendChunk(message, status) {
|
|
170
|
+
if (!this.placeholder)
|
|
171
|
+
return;
|
|
172
|
+
await this.api.sendChunk({
|
|
173
|
+
ctx: this.ctx,
|
|
174
|
+
placeholder: this.placeholder,
|
|
175
|
+
message,
|
|
176
|
+
status,
|
|
177
|
+
});
|
|
178
|
+
this.lastSentAt = Date.now();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 定时任务:有被节流的尾部增量且已过节流间隔则补发;否则空闲超过保活
|
|
182
|
+
* 间隔时发空的 status=1 分片重置代理的空闲关流计时。
|
|
183
|
+
*/
|
|
184
|
+
tick() {
|
|
185
|
+
return this.enqueue(async () => {
|
|
186
|
+
if (!this.isActive() || !this.placeholder)
|
|
187
|
+
return;
|
|
188
|
+
const idle = Date.now() - this.lastSentAt;
|
|
189
|
+
if (this.resolveDelta()) {
|
|
190
|
+
if (idle >= this.minIntervalMs) {
|
|
191
|
+
await this.flushSafely(1, "流式分片推送失败");
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (idle >= this.keepaliveMs) {
|
|
196
|
+
try {
|
|
197
|
+
await this.sendChunk("", 1);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
this.failed = true;
|
|
201
|
+
this.stopTimer();
|
|
202
|
+
this.logger?.warn(`openclaw-nudge${this.tag}: 流式保活失败,本回合回退普通文本: ${String(err)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
startTimer() {
|
|
208
|
+
if (this.timer)
|
|
209
|
+
return;
|
|
210
|
+
this.timer = setInterval(() => {
|
|
211
|
+
void this.tick();
|
|
212
|
+
}, this.minIntervalMs);
|
|
213
|
+
// 不因流式定时器阻止进程退出
|
|
214
|
+
this.timer.unref?.();
|
|
215
|
+
}
|
|
216
|
+
stopTimer() {
|
|
217
|
+
if (this.timer)
|
|
218
|
+
clearInterval(this.timer);
|
|
219
|
+
this.timer = null;
|
|
220
|
+
}
|
|
221
|
+
close() {
|
|
222
|
+
this.closed = true;
|
|
223
|
+
this.stopTimer();
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 增量 = 最新快照去掉已发送前缀。快照被替换(非前缀延续,如
|
|
227
|
+
* onPartialReply 的 replace / 回复边界)时无法撤回已发内容,
|
|
228
|
+
* 以空行分隔追加整段新正文(思考过程只会前缀式增长,不重复追加)。
|
|
229
|
+
*/
|
|
230
|
+
resolveDelta() {
|
|
231
|
+
if (this.latestText === this.sentText)
|
|
232
|
+
return "";
|
|
233
|
+
if (this.latestText.startsWith(this.sentText)) {
|
|
234
|
+
return this.latestText.slice(this.sentText.length);
|
|
235
|
+
}
|
|
236
|
+
return (this.sentText ? "\n\n" : "") + this.answerText;
|
|
237
|
+
}
|
|
238
|
+
/** 挂到串行链末尾;链上任务的异常已在任务内消化,不会中断后续任务 */
|
|
239
|
+
enqueue(task) {
|
|
240
|
+
const run = this.chain.then(task);
|
|
241
|
+
this.chain = run.then(() => undefined, () => undefined);
|
|
242
|
+
return run;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站目标编解码。
|
|
3
|
+
*
|
|
4
|
+
* ChannelOutboundContext.to 不携带会话类型,轻推单聊/群聊的发送接口又不同
|
|
5
|
+
* (single 用 to_user,channel 用 channel_id),因此在会话标识里编码前缀。
|
|
6
|
+
* 入站(inbound.ts)构造回复目标时用 encodeNudgeTarget,出站发送前解码。
|
|
7
|
+
*/
|
|
8
|
+
const USER_TARGET_PREFIX = "user:";
|
|
9
|
+
const CHANNEL_TARGET_PREFIX = "channel:";
|
|
10
|
+
export function encodeNudgeTarget(chatType, id) {
|
|
11
|
+
return chatType === "group"
|
|
12
|
+
? `${CHANNEL_TARGET_PREFIX}${id}`
|
|
13
|
+
: `${USER_TARGET_PREFIX}${id}`;
|
|
14
|
+
}
|
|
15
|
+
/** 解码出站目标;缺少 user:/channel: 前缀或 id 为空时抛错(不猜测会话类型) */
|
|
16
|
+
export function decodeNudgeTarget(to) {
|
|
17
|
+
const decoded = to.startsWith(CHANNEL_TARGET_PREFIX)
|
|
18
|
+
? { sendType: "channel", id: to.slice(CHANNEL_TARGET_PREFIX.length) }
|
|
19
|
+
: to.startsWith(USER_TARGET_PREFIX)
|
|
20
|
+
? { sendType: "single", id: to.slice(USER_TARGET_PREFIX.length) }
|
|
21
|
+
: null;
|
|
22
|
+
if (!decoded?.id) {
|
|
23
|
+
throw new Error(`openclaw-nudge: 无法解码出站目标 "${to}"(缺少 user:/channel: 前缀)`);
|
|
24
|
+
}
|
|
25
|
+
return decoded;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 按目标类型组装消息体:单聊放 to_user,群聊放 channel_id。
|
|
29
|
+
* 文本、引用、图片、文件消息共用这一外层结构,只是内层 message 不同。
|
|
30
|
+
*/
|
|
31
|
+
export function buildTargetedMessage(target, message) {
|
|
32
|
+
return target.sendType === "channel"
|
|
33
|
+
? { channel_id: target.id, message }
|
|
34
|
+
: { to_user: target.id, message };
|
|
35
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.1.0",
|
|
3
|
+
"id": "openclaw-nudge",
|
|
4
|
+
"name": "Openclaw Nudge",
|
|
5
|
+
"description": "connect nudge to OpenClaw",
|
|
6
|
+
"channels": ["qingtui"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {},
|
|
10
|
+
"additionalProperties": false
|
|
11
|
+
},
|
|
12
|
+
"channelConfigs": {
|
|
13
|
+
"qingtui": {
|
|
14
|
+
"schema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"properties": {
|
|
18
|
+
"enabled": {
|
|
19
|
+
"type": "boolean"
|
|
20
|
+
},
|
|
21
|
+
"streaming": {
|
|
22
|
+
"type": "boolean"
|
|
23
|
+
},
|
|
24
|
+
"serverUrl": {
|
|
25
|
+
"type": "string"
|
|
26
|
+
},
|
|
27
|
+
"openUrl": {
|
|
28
|
+
"type": "string"
|
|
29
|
+
},
|
|
30
|
+
"accounts": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"additionalProperties": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"enabled": {
|
|
37
|
+
"type": "boolean"
|
|
38
|
+
},
|
|
39
|
+
"name": {
|
|
40
|
+
"type": "string"
|
|
41
|
+
},
|
|
42
|
+
"appid": {
|
|
43
|
+
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"appsecret": {
|
|
46
|
+
"type": "string"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"uiHints": {
|
|
54
|
+
"serverUrl": {
|
|
55
|
+
"label": "轻推服务地址",
|
|
56
|
+
"help": "Socket 代理地址,缺省 https://open.qingtui.com;QA 为 https://qaqtopen.qingtui.com"
|
|
57
|
+
},
|
|
58
|
+
"openUrl": {
|
|
59
|
+
"label": "开放平台 REST 地址",
|
|
60
|
+
"help": "缺省与轻推服务地址相同"
|
|
61
|
+
},
|
|
62
|
+
"accounts.*.appid": {
|
|
63
|
+
"label": "应用ID"
|
|
64
|
+
},
|
|
65
|
+
"accounts.*.appsecret": {
|
|
66
|
+
"label": "应用密钥",
|
|
67
|
+
"sensitive": true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@qingtui/openclaw-plugin",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"format": "biome format --write",
|
|
7
|
+
"lint": "biome check .",
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"test": "tsc -p tsconfig.test.json && node --test \".test-dist/test/**/*.test.js\"",
|
|
10
|
+
"package": "pnpm run build && pnpm pack --out qingtui-openclaw-plugin-%v.tgz",
|
|
11
|
+
"link": "openclaw plugins install --link ./",
|
|
12
|
+
"plugin:build": "pnpm run build && openclaw plugins build --entry ./dist/index.js",
|
|
13
|
+
"plugin:validate": "pnpm run build && openclaw plugins validate --entry ./dist/index.js",
|
|
14
|
+
"plugin:install": "pnpm run package && openclaw plugins install npm-pack:./qingtui-openclaw-plugin-$npm_package_version.tgz --force",
|
|
15
|
+
"plugin:publish": "clawhub package publish . --source-repo cisdi/openclaw-nudge --source-commit $(git rev-parse HEAD)"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"openclaw.plugin.json"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@qingtui/socket-sdk": "1.1.0",
|
|
23
|
+
"typebox": "1.3.37"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"openclaw": ">=2026.10.8"
|
|
27
|
+
},
|
|
28
|
+
"openclaw": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./dist/index.js"
|
|
31
|
+
],
|
|
32
|
+
"setupEntry": "./dist/setup-entry.js",
|
|
33
|
+
"channel": {
|
|
34
|
+
"id": "qingtui",
|
|
35
|
+
"label": "qingtui (轻推)",
|
|
36
|
+
"blurb": "connect qingtui to OpenClaw"
|
|
37
|
+
},
|
|
38
|
+
"compat": {
|
|
39
|
+
"pluginApi": ">=2026.10.8",
|
|
40
|
+
"minGatewayVersion": "2026.10.8"
|
|
41
|
+
},
|
|
42
|
+
"build": {
|
|
43
|
+
"openclawVersion": "2026.10.8",
|
|
44
|
+
"pluginSdkVersion": "2026.10.8"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "2.5.15",
|
|
49
|
+
"openclaw": "2026.10.8",
|
|
50
|
+
"typescript": "7.0.2"
|
|
51
|
+
}
|
|
52
|
+
}
|