@core-workspace/infoflow-openclaw-plugin 2026.3.9 → 2026.3.27-beta.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/CHANGELOG.md +91 -0
- package/CLAUDE.md +135 -0
- package/COLLABORATION_REPORT.md +209 -0
- package/PROJECT_GUIDE.md +355 -0
- package/README.md +158 -66
- package/docs/dev-guide.md +63 -50
- package/docs/qa-feature-list.md +452 -0
- package/docs/webhook-guide.md +178 -0
- package/index.ts +28 -2
- package/openclaw.plugin.json +131 -21
- package/package.json +16 -3
- package/scripts/deploy.sh +66 -7
- package/scripts/postinstall.cjs +80 -0
- package/skills/infoflow-dev/SKILL.md +2 -2
- package/skills/infoflow-dev/references/api.md +1 -1
- package/src/adapter/inbound/webhook-parser.ts +27 -5
- package/src/adapter/inbound/ws-receiver.ts +304 -43
- package/src/adapter/outbound/markdown-local-images.ts +80 -0
- package/src/adapter/outbound/reply-dispatcher.ts +146 -65
- package/src/adapter/outbound/target-resolver.ts +4 -3
- package/src/channel/accounts.ts +97 -22
- package/src/channel/channel.ts +456 -12
- package/src/channel/media.ts +20 -6
- package/src/channel/monitor.ts +8 -3
- package/src/channel/outbound.ts +358 -21
- package/src/channel/streaming.ts +740 -0
- package/src/commands/changelog.ts +80 -0
- package/src/commands/doctor.ts +545 -0
- package/src/commands/logs.ts +449 -0
- package/src/commands/version.ts +20 -0
- package/src/compat/openclaw-sdk.ts +218 -0
- package/src/handler/message-handler.ts +673 -166
- package/src/logging.ts +1 -1
- package/src/runtime.ts +1 -1
- package/src/security/dm-policy.ts +1 -4
- package/src/security/group-policy.ts +174 -51
- package/src/tools/actions/index.ts +15 -13
- package/src/tools/cron/relay.ts +1154 -0
- package/src/tools/hooks/index.ts +13 -1
- package/src/tools/index.ts +714 -32
- package/src/types.ts +144 -25
- package/src/utils/audio/g722/dct_tables.ts +381 -0
- package/src/utils/audio/g722/decoder.ts +919 -0
- package/src/utils/audio/g722/defs.ts +105 -0
- package/src/utils/audio/g722/hd-parser.ts +247 -0
- package/src/utils/audio/g722/huff_tables.ts +240 -0
- package/src/utils/audio/g722/index.ts +78 -0
- package/src/utils/audio/g722/output_decoded.pcm +0 -0
- package/src/utils/audio/g722/output_decoded.wav +0 -0
- package/src/utils/audio/g722/tables.ts +173 -0
- package/src/utils/audio/g722/test_api.ts +31 -0
- package/src/utils/audio/g722/test_voice.hd +0 -0
- package/src/utils/bos/im-bos-client.ts +219 -0
- package/src/utils/group-agent-cache.ts +142 -0
- package/src/utils/token-adapter.ts +120 -51
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import { formatInfoflowError, getInfoflowSendLog, logVerbose } from "../logging.js";
|
|
4
|
+
import type { InfoflowMessageFormat, ResolvedInfoflowAccount } from "../types.js";
|
|
5
|
+
import { resolveInfoflowAccount } from "./accounts.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_TIMEOUT_MS,
|
|
8
|
+
ensureHttps,
|
|
9
|
+
extractIdFromRawJson,
|
|
10
|
+
getAppAccessToken,
|
|
11
|
+
sendInfoflowMessage,
|
|
12
|
+
} from "./outbound.js";
|
|
13
|
+
|
|
14
|
+
export const INFOFLOW_STREAMING_CREATE_PATH = "/api/v1/msg/sender/interactivity_msg";
|
|
15
|
+
export const INFOFLOW_STREAMING_UPDATE_PERSONAL_PATH =
|
|
16
|
+
"/api/v1/msg/modifier/interactivity_personal_msg_content";
|
|
17
|
+
export const INFOFLOW_STREAMING_UPDATE_GROUP_PATH = "/api/v1/msg/modifier/dynamic_content";
|
|
18
|
+
|
|
19
|
+
const STREAMING_TEMPLATE_NAME = "streaming_render";
|
|
20
|
+
const STREAMING_TEMPLATE_VERSION = 30;
|
|
21
|
+
const STREAMING_GROUP_VERSION = 111;
|
|
22
|
+
const STREAMING_INTERACTIVITY_EXPIRE_SECONDS = 7_776_000;
|
|
23
|
+
const STREAMING_FLUSH_INTERVAL_MS = 600;
|
|
24
|
+
const MAX_CARD_TEXT_LENGTH = 6_000;
|
|
25
|
+
const MAX_THINKING_TEXT_LENGTH = 10_000;
|
|
26
|
+
const MAX_TOOL_ENTRIES = 12;
|
|
27
|
+
const MAX_PHASE_ENTRIES = 8;
|
|
28
|
+
const DEFAULT_OFFLINE_NOTIFY_TEXT = "你收到一条卡片消息";
|
|
29
|
+
const DEFAULT_PENDING_STATUS = "收到啦,正在处理中~";
|
|
30
|
+
const DEFAULT_THINKING_STATUS = "思考中";
|
|
31
|
+
const DEFAULT_DONE_STATUS = "思考完成";
|
|
32
|
+
const DEFAULT_FAILED_STATUS = "生成失败";
|
|
33
|
+
|
|
34
|
+
type StreamingTextNode = {
|
|
35
|
+
type: "text";
|
|
36
|
+
content: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type InfoflowStreamingCardContent = Record<string, StreamingTextNode | undefined>;
|
|
40
|
+
|
|
41
|
+
export type CreateStreamingCardResult = {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
messageid?: string;
|
|
44
|
+
msgseqid?: string;
|
|
45
|
+
modifyToken?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type UpdateStreamingCardResult = {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
error?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ToolTrace = {
|
|
55
|
+
id: number;
|
|
56
|
+
name: string;
|
|
57
|
+
status: "running" | "done";
|
|
58
|
+
details: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type StreamingTarget =
|
|
62
|
+
| {
|
|
63
|
+
kind: "direct";
|
|
64
|
+
receiverId: string;
|
|
65
|
+
receiverType: "user";
|
|
66
|
+
to: string;
|
|
67
|
+
userIds: string[];
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
kind: "group";
|
|
71
|
+
receiverId: string;
|
|
72
|
+
receiverType: "group";
|
|
73
|
+
to: string;
|
|
74
|
+
groupId: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function toTextNode(content: string | undefined): StreamingTextNode | undefined {
|
|
78
|
+
const normalized = content?.trimEnd();
|
|
79
|
+
if (!normalized) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return { type: "text", content: normalized };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncateTail(text: string, maxLength: number): string {
|
|
86
|
+
if (text.length <= maxLength) {
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
return `...\n${text.slice(text.length - maxLength + 4)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mergeStreamingSnapshot(current: string, incoming: string): string {
|
|
93
|
+
const next = incoming.trimEnd();
|
|
94
|
+
if (!next) {
|
|
95
|
+
return current;
|
|
96
|
+
}
|
|
97
|
+
if (!current) {
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
if (next === current) {
|
|
101
|
+
return current;
|
|
102
|
+
}
|
|
103
|
+
// Most OpenClaw partials are "latest snapshots", not pure deltas.
|
|
104
|
+
if (next.startsWith(current)) {
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
if (current.startsWith(next)) {
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
110
|
+
// If the new chunk clearly continues the tail, append only the non-overlap.
|
|
111
|
+
const maxOverlap = Math.min(current.length, next.length);
|
|
112
|
+
for (let overlap = maxOverlap; overlap >= 16; overlap -= 1) {
|
|
113
|
+
if (current.slice(-overlap) === next.slice(0, overlap)) {
|
|
114
|
+
return current + next.slice(overlap);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Otherwise prefer the latest snapshot to avoid duplicated paragraphs.
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeStreamingTarget(to: string): StreamingTarget | undefined {
|
|
122
|
+
const normalized = to.replace(/^infoflow:/i, "");
|
|
123
|
+
const groupMatch = normalized.match(/^group:(\d+)$/i);
|
|
124
|
+
if (groupMatch) {
|
|
125
|
+
return {
|
|
126
|
+
kind: "group",
|
|
127
|
+
receiverId: groupMatch[1],
|
|
128
|
+
receiverType: "group",
|
|
129
|
+
to: normalized,
|
|
130
|
+
groupId: Number(groupMatch[1]),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const userId = normalized.trim();
|
|
134
|
+
if (!userId) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: "direct",
|
|
139
|
+
receiverId: userId,
|
|
140
|
+
receiverType: "user",
|
|
141
|
+
to: userId,
|
|
142
|
+
userIds: [userId],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildToolTraceMarkdown(entries: ToolTrace[]): string {
|
|
147
|
+
if (entries.length === 0) {
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
return entries
|
|
151
|
+
.map((entry) => {
|
|
152
|
+
const summary = `${entry.id}. ${entry.name} · ${entry.status === "done" ? "已完成" : "执行中"}`;
|
|
153
|
+
const details = entry.details.length > 0 ? entry.details.join("\n\n") : "- 暂无详情";
|
|
154
|
+
return [`<details>`, `<summary>${summary}</summary>`, ``, details, ``, `</details>`].join(
|
|
155
|
+
"\n",
|
|
156
|
+
);
|
|
157
|
+
})
|
|
158
|
+
.join("\n\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildThinkingSection(params: {
|
|
162
|
+
phaseHistory: string[];
|
|
163
|
+
reasoningText: string;
|
|
164
|
+
toolEntries: ToolTrace[];
|
|
165
|
+
}): string {
|
|
166
|
+
const sections: string[] = [];
|
|
167
|
+
if (params.phaseHistory.length > 0) {
|
|
168
|
+
sections.push(`## 处理状态\n${params.phaseHistory.map((item) => `- ${item}`).join("\n")}`);
|
|
169
|
+
}
|
|
170
|
+
const reasoning = params.reasoningText.trim();
|
|
171
|
+
if (reasoning) {
|
|
172
|
+
sections.push(`## Agent 过程\n${reasoning}`);
|
|
173
|
+
}
|
|
174
|
+
if (params.toolEntries.length > 0) {
|
|
175
|
+
sections.push(`## 工具调用详情\n${buildToolTraceMarkdown(params.toolEntries)}`);
|
|
176
|
+
}
|
|
177
|
+
return truncateTail(sections.join("\n\n"), MAX_THINKING_TEXT_LENGTH);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildCardContent(params: {
|
|
181
|
+
answerText: string;
|
|
182
|
+
answerFormat: "text" | "markdown";
|
|
183
|
+
phaseHistory: string[];
|
|
184
|
+
reasoningText: string;
|
|
185
|
+
toolEntries: ToolTrace[];
|
|
186
|
+
statusInfo: string;
|
|
187
|
+
phaseLabel: string;
|
|
188
|
+
done: boolean;
|
|
189
|
+
failed: boolean;
|
|
190
|
+
}): InfoflowStreamingCardContent {
|
|
191
|
+
const thinkingSection = buildThinkingSection({
|
|
192
|
+
phaseHistory: params.phaseHistory,
|
|
193
|
+
reasoningText: params.reasoningText,
|
|
194
|
+
toolEntries: params.toolEntries,
|
|
195
|
+
});
|
|
196
|
+
const answerKey = params.answerFormat === "markdown" ? "ai_markdown" : "ai_text";
|
|
197
|
+
const answerText = truncateTail(params.answerText.trim(), MAX_CARD_TEXT_LENGTH);
|
|
198
|
+
const hasThinkingSection = thinkingSection.trim().length > 0;
|
|
199
|
+
const summaryText = truncateTail(params.statusInfo, 160);
|
|
200
|
+
return {
|
|
201
|
+
card_init: toTextNode("1"),
|
|
202
|
+
answer_summary: toTextNode(summaryText),
|
|
203
|
+
[answerKey]: toTextNode(answerText),
|
|
204
|
+
status_info: toTextNode(params.statusInfo),
|
|
205
|
+
thinking_aio: hasThinkingSection ? toTextNode(thinkingSection) : undefined,
|
|
206
|
+
think_star_img: toTextNode("ast/think_star_static.png"),
|
|
207
|
+
think_status_img: toTextNode("ast/thinking_yes.png"),
|
|
208
|
+
think_status_color: toTextNode(params.failed ? "#D03050" : "#5C6473"),
|
|
209
|
+
think_status_text: toTextNode(params.phaseLabel),
|
|
210
|
+
think_arrow_img: hasThinkingSection ? toTextNode("ast/arrow_down.png") : undefined,
|
|
211
|
+
think_layout_install: toTextNode(hasThinkingSection ? "1" : "0"),
|
|
212
|
+
status_info_1_install: toTextNode("0"),
|
|
213
|
+
flex_item_status_info_1_install: toTextNode("0"),
|
|
214
|
+
dc_print_end: params.done ? toTextNode("1") : undefined,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function fetchStreamingJson(params: {
|
|
219
|
+
account: ResolvedInfoflowAccount;
|
|
220
|
+
path: string;
|
|
221
|
+
payload: Record<string, unknown>;
|
|
222
|
+
timeoutMs?: number;
|
|
223
|
+
}): Promise<{ ok: boolean; raw?: string; data?: Record<string, unknown>; error?: string }> {
|
|
224
|
+
const { account, path, payload, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
|
|
225
|
+
const { apiHost, appKey, appSecret } = account.config;
|
|
226
|
+
|
|
227
|
+
if (!appKey || !appSecret) {
|
|
228
|
+
return { ok: false, error: "Infoflow appKey/appSecret not configured." };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
|
|
232
|
+
if (!tokenResult.ok || !tokenResult.token) {
|
|
233
|
+
return { ok: false, error: tokenResult.error ?? "failed to get token" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
237
|
+
try {
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
240
|
+
const bodyStr = JSON.stringify(payload);
|
|
241
|
+
const headers = {
|
|
242
|
+
Authorization: `Bearer-${tokenResult.token}`,
|
|
243
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
244
|
+
LOGID: randomUUID(),
|
|
245
|
+
};
|
|
246
|
+
const res = await fetch(`${ensureHttps(apiHost)}${path}`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers,
|
|
249
|
+
body: bodyStr,
|
|
250
|
+
signal: controller.signal,
|
|
251
|
+
});
|
|
252
|
+
const raw = await res.text();
|
|
253
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
254
|
+
const outerCode = typeof data.code === "string" ? data.code : "";
|
|
255
|
+
if (outerCode && outerCode !== "ok") {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
raw,
|
|
259
|
+
data,
|
|
260
|
+
error: String(data.message ?? data.errmsg ?? `code=${outerCode}`),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const nested = data.data as Record<string, unknown> | undefined;
|
|
264
|
+
const nestedErrCode = nested?.errcode;
|
|
265
|
+
if (nestedErrCode != null && nestedErrCode !== 0) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
raw,
|
|
269
|
+
data,
|
|
270
|
+
error: String(nested?.errmsg ?? `errcode ${nestedErrCode}`),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { ok: true, raw, data };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return { ok: false, error: formatInfoflowError(err) };
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(timeout);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function createStreamingCard(params: {
|
|
282
|
+
account: ResolvedInfoflowAccount;
|
|
283
|
+
to: string;
|
|
284
|
+
content: InfoflowStreamingCardContent;
|
|
285
|
+
timeoutMs?: number;
|
|
286
|
+
}): Promise<CreateStreamingCardResult> {
|
|
287
|
+
const target = normalizeStreamingTarget(params.to);
|
|
288
|
+
if (!target) {
|
|
289
|
+
return { ok: false, error: `invalid streaming target: ${params.to}` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const timestamp = Date.now();
|
|
293
|
+
const payload = {
|
|
294
|
+
contents: params.content,
|
|
295
|
+
meta: {
|
|
296
|
+
client_msg_id: timestamp,
|
|
297
|
+
client_send_time: timestamp,
|
|
298
|
+
interactivity_expire: STREAMING_INTERACTIVITY_EXPIRE_SECONDS,
|
|
299
|
+
interactivity_mode: "normal",
|
|
300
|
+
modify_expire: STREAMING_INTERACTIVITY_EXPIRE_SECONDS,
|
|
301
|
+
offline_notify_txt: DEFAULT_OFFLINE_NOTIFY_TEXT,
|
|
302
|
+
template: {
|
|
303
|
+
name: STREAMING_TEMPLATE_NAME,
|
|
304
|
+
version: STREAMING_TEMPLATE_VERSION,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
type: 1,
|
|
308
|
+
content_id: String(timestamp),
|
|
309
|
+
receiver_id: target.receiverId,
|
|
310
|
+
receiver_type: target.receiverType,
|
|
311
|
+
scene: "IM-server",
|
|
312
|
+
user_msg: false,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
getInfoflowSendLog().info(
|
|
316
|
+
`[streaming:create] to=${target.to}, receiverType=${target.receiverType}, template=${STREAMING_TEMPLATE_NAME}`,
|
|
317
|
+
);
|
|
318
|
+
logVerbose(`[streaming:create] POST body: ${JSON.stringify(payload)}`);
|
|
319
|
+
|
|
320
|
+
const result = await fetchStreamingJson({
|
|
321
|
+
account: params.account,
|
|
322
|
+
path: INFOFLOW_STREAMING_CREATE_PATH,
|
|
323
|
+
payload,
|
|
324
|
+
timeoutMs: params.timeoutMs,
|
|
325
|
+
});
|
|
326
|
+
if (!result.ok || !result.raw || !result.data) {
|
|
327
|
+
getInfoflowSendLog().error(`[streaming:create] failed: ${result.error}`);
|
|
328
|
+
return { ok: false, error: result.error ?? "create streaming card failed" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const nested = (result.data.data as Record<string, unknown> | undefined) ?? result.data;
|
|
332
|
+
const receiver =
|
|
333
|
+
Array.isArray(nested.receivers) && nested.receivers.length > 0
|
|
334
|
+
? (nested.receivers[0] as Record<string, unknown>)
|
|
335
|
+
: undefined;
|
|
336
|
+
const messageid =
|
|
337
|
+
extractIdFromRawJson(result.raw, "messageid") ??
|
|
338
|
+
extractIdFromRawJson(result.raw, "msgid") ??
|
|
339
|
+
extractIdFromRawJson(result.raw, "msg_id") ??
|
|
340
|
+
(nested.messageid != null ? String(nested.messageid) : undefined) ??
|
|
341
|
+
(receiver?.msg_id != null ? String(receiver.msg_id) : undefined);
|
|
342
|
+
const msgseqid =
|
|
343
|
+
extractIdFromRawJson(result.raw, "msgseqid") ??
|
|
344
|
+
extractIdFromRawJson(result.raw, "msg_seq") ??
|
|
345
|
+
(nested.msgseqid != null ? String(nested.msgseqid) : undefined) ??
|
|
346
|
+
(receiver?.msg_seq != null ? String(receiver.msg_seq) : undefined);
|
|
347
|
+
const modifyToken =
|
|
348
|
+
extractIdFromRawJson(result.raw, "modify_token") ??
|
|
349
|
+
extractIdFromRawJson(result.raw, "interactivity_token") ??
|
|
350
|
+
(nested.modify_token != null ? String(nested.modify_token) : undefined) ??
|
|
351
|
+
(receiver?.modify_token != null ? String(receiver.modify_token) : undefined);
|
|
352
|
+
|
|
353
|
+
if (!modifyToken) {
|
|
354
|
+
getInfoflowSendLog().error(`[streaming:create] missing modify_token in response`);
|
|
355
|
+
return { ok: false, error: "create streaming card succeeded but modify_token is missing" };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
getInfoflowSendLog().info(
|
|
359
|
+
`[streaming:create] ok: to=${target.to}, messageid=${messageid ?? "?"}`,
|
|
360
|
+
);
|
|
361
|
+
return { ok: true, messageid, msgseqid, modifyToken };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function updateStreamingCard(params: {
|
|
365
|
+
account: ResolvedInfoflowAccount;
|
|
366
|
+
to: string;
|
|
367
|
+
modifyToken: string;
|
|
368
|
+
content: InfoflowStreamingCardContent;
|
|
369
|
+
groupVersion?: number;
|
|
370
|
+
timeoutMs?: number;
|
|
371
|
+
}): Promise<UpdateStreamingCardResult> {
|
|
372
|
+
const target = normalizeStreamingTarget(params.to);
|
|
373
|
+
if (!target) {
|
|
374
|
+
return { ok: false, error: `invalid streaming target: ${params.to}` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const payload =
|
|
378
|
+
target.kind === "group"
|
|
379
|
+
? {
|
|
380
|
+
modify_token: params.modifyToken,
|
|
381
|
+
new_dynamic_msg_content: params.content,
|
|
382
|
+
version: params.groupVersion ?? STREAMING_GROUP_VERSION,
|
|
383
|
+
notify_list: [
|
|
384
|
+
{
|
|
385
|
+
to_type: 2,
|
|
386
|
+
to_ids: [target.groupId],
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
}
|
|
390
|
+
: {
|
|
391
|
+
modify_token: params.modifyToken,
|
|
392
|
+
new_personal_msg_content: [
|
|
393
|
+
{
|
|
394
|
+
user_ids: target.userIds,
|
|
395
|
+
personal_msg_content: params.content,
|
|
396
|
+
notify_msg_content: null,
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const path =
|
|
402
|
+
target.kind === "group"
|
|
403
|
+
? INFOFLOW_STREAMING_UPDATE_GROUP_PATH
|
|
404
|
+
: INFOFLOW_STREAMING_UPDATE_PERSONAL_PATH;
|
|
405
|
+
|
|
406
|
+
getInfoflowSendLog().info(
|
|
407
|
+
`[streaming:update] to=${target.to}, path=${path}, hasThinking=${Boolean(params.content.thinking_aio)}`,
|
|
408
|
+
);
|
|
409
|
+
logVerbose(`[streaming:update] POST body: ${JSON.stringify(payload)}`);
|
|
410
|
+
|
|
411
|
+
const result = await fetchStreamingJson({
|
|
412
|
+
account: params.account,
|
|
413
|
+
path,
|
|
414
|
+
payload,
|
|
415
|
+
timeoutMs: params.timeoutMs,
|
|
416
|
+
});
|
|
417
|
+
if (!result.ok) {
|
|
418
|
+
getInfoflowSendLog().error(`[streaming:update] failed: ${result.error}`);
|
|
419
|
+
return { ok: false, error: result.error ?? "update streaming card failed" };
|
|
420
|
+
}
|
|
421
|
+
getInfoflowSendLog().info(
|
|
422
|
+
`[streaming:update] ok: to=${target.to}${target.kind === "group" ? `, version=${params.groupVersion ?? STREAMING_GROUP_VERSION}` : ""}`,
|
|
423
|
+
);
|
|
424
|
+
return { ok: true };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export class InfoflowStreamingCardSession {
|
|
428
|
+
private readonly account: ResolvedInfoflowAccount;
|
|
429
|
+
private readonly cfg: OpenClawConfig;
|
|
430
|
+
private readonly to: string;
|
|
431
|
+
private readonly accountId?: string;
|
|
432
|
+
private readonly answerFormat: "text" | "markdown";
|
|
433
|
+
private readonly fallbackFormat: "text" | "markdown";
|
|
434
|
+
private readonly toolEntries: ToolTrace[] = [];
|
|
435
|
+
private readonly phaseHistory: string[] = [DEFAULT_PENDING_STATUS];
|
|
436
|
+
private assistantText = "";
|
|
437
|
+
private reasoningText = "";
|
|
438
|
+
private statusInfo = DEFAULT_PENDING_STATUS;
|
|
439
|
+
private phaseLabel = DEFAULT_PENDING_STATUS;
|
|
440
|
+
private sawAssistantStream = false;
|
|
441
|
+
private started = false;
|
|
442
|
+
private failed = false;
|
|
443
|
+
private completed = false;
|
|
444
|
+
private createError: string | undefined;
|
|
445
|
+
private modifyToken: string | undefined;
|
|
446
|
+
private groupUpdateVersion = STREAMING_GROUP_VERSION;
|
|
447
|
+
private nextToolTraceId = 1;
|
|
448
|
+
private activeToolTraceId: number | undefined;
|
|
449
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
450
|
+
private flushPromise: Promise<void> | undefined;
|
|
451
|
+
private flushQueued = false;
|
|
452
|
+
|
|
453
|
+
constructor(params: {
|
|
454
|
+
cfg: OpenClawConfig;
|
|
455
|
+
accountId?: string;
|
|
456
|
+
to: string;
|
|
457
|
+
answerFormat?: "text" | "markdown";
|
|
458
|
+
fallbackFormat?: "text" | "markdown";
|
|
459
|
+
}) {
|
|
460
|
+
this.cfg = params.cfg;
|
|
461
|
+
this.accountId = params.accountId;
|
|
462
|
+
this.account = resolveInfoflowAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
463
|
+
this.to = params.to;
|
|
464
|
+
this.answerFormat = params.answerFormat ?? "markdown";
|
|
465
|
+
this.fallbackFormat = params.fallbackFormat ?? "markdown";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async start(): Promise<boolean> {
|
|
469
|
+
const content = buildCardContent({
|
|
470
|
+
answerText: "",
|
|
471
|
+
answerFormat: this.answerFormat,
|
|
472
|
+
phaseHistory: this.phaseHistory,
|
|
473
|
+
reasoningText: "",
|
|
474
|
+
toolEntries: [],
|
|
475
|
+
statusInfo: DEFAULT_PENDING_STATUS,
|
|
476
|
+
phaseLabel: DEFAULT_PENDING_STATUS,
|
|
477
|
+
done: false,
|
|
478
|
+
failed: false,
|
|
479
|
+
});
|
|
480
|
+
const result = await createStreamingCard({
|
|
481
|
+
account: this.account,
|
|
482
|
+
to: this.to,
|
|
483
|
+
content,
|
|
484
|
+
});
|
|
485
|
+
if (!result.ok || !result.modifyToken) {
|
|
486
|
+
this.failed = true;
|
|
487
|
+
this.createError = result.error ?? "create streaming card failed";
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
this.started = true;
|
|
491
|
+
this.modifyToken = result.modifyToken;
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
isUsable(): boolean {
|
|
496
|
+
return this.started && !this.failed && !!this.modifyToken;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fallbackMessageFormat(): "text" | "markdown" {
|
|
500
|
+
return this.fallbackFormat;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
noteAssistantText(text?: string): void {
|
|
504
|
+
if (!this.isUsable() || text == null) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
this.assistantText = mergeStreamingSnapshot(this.assistantText, text);
|
|
508
|
+
this.sawAssistantStream = true;
|
|
509
|
+
this.setPhase(DEFAULT_THINKING_STATUS);
|
|
510
|
+
this.scheduleFlush();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
noteReasoning(text?: string): void {
|
|
514
|
+
if (!this.isUsable() || !text) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.reasoningText = mergeStreamingSnapshot(this.reasoningText, text);
|
|
518
|
+
this.setPhase(DEFAULT_THINKING_STATUS);
|
|
519
|
+
this.scheduleFlush();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
noteToolStart(payload: { name?: string; phase?: string }): void {
|
|
523
|
+
if (!this.isUsable()) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const name = payload.name?.trim() || "工具";
|
|
527
|
+
const phase = payload.phase === "update" ? "进行中" : "开始执行";
|
|
528
|
+
this.pushToolEntry(name, `- 状态: ${phase}`);
|
|
529
|
+
this.setPhase(DEFAULT_THINKING_STATUS);
|
|
530
|
+
this.scheduleFlush();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
noteToolResult(text?: string): void {
|
|
534
|
+
if (!this.isUsable() || !text?.trim()) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
this.appendActiveToolDetail(text.trim(), true);
|
|
538
|
+
this.setPhase(DEFAULT_THINKING_STATUS);
|
|
539
|
+
this.scheduleFlush();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async handleDeliveredPayload(params: {
|
|
543
|
+
kind: "tool" | "block" | "final";
|
|
544
|
+
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
|
545
|
+
}): Promise<boolean> {
|
|
546
|
+
if (!this.isUsable()) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
if (params.kind === "tool") {
|
|
550
|
+
this.noteToolResult(params.payload.text);
|
|
551
|
+
if (params.payload.mediaUrls?.length) {
|
|
552
|
+
for (const mediaUrl of params.payload.mediaUrls) {
|
|
553
|
+
this.appendActiveToolDetail(`- 附件: ${mediaUrl}`, true);
|
|
554
|
+
}
|
|
555
|
+
this.scheduleFlush();
|
|
556
|
+
}
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
if (params.payload.text && !this.sawAssistantStream) {
|
|
560
|
+
this.assistantText = params.payload.text;
|
|
561
|
+
this.setPhase(DEFAULT_THINKING_STATUS);
|
|
562
|
+
this.scheduleFlush();
|
|
563
|
+
}
|
|
564
|
+
if (params.payload.mediaUrls?.length) {
|
|
565
|
+
const existing = this.assistantText.trim();
|
|
566
|
+
const mediaSection = params.payload.mediaUrls.map((url) => `- ${url}`).join("\n");
|
|
567
|
+
this.assistantText = existing
|
|
568
|
+
? `${existing}\n\n## 附件\n${mediaSection}`
|
|
569
|
+
: `## 附件\n${mediaSection}`;
|
|
570
|
+
this.scheduleFlush();
|
|
571
|
+
}
|
|
572
|
+
if (params.kind === "final") {
|
|
573
|
+
await this.complete();
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async complete(): Promise<void> {
|
|
579
|
+
if (!this.isUsable()) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.completed = true;
|
|
583
|
+
this.setPhase(DEFAULT_DONE_STATUS);
|
|
584
|
+
await this.flushNow();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async failWithMessage(message: string): Promise<void> {
|
|
588
|
+
if (!this.started || !this.modifyToken) {
|
|
589
|
+
this.failed = true;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.completed = true;
|
|
593
|
+
this.setPhase(DEFAULT_FAILED_STATUS);
|
|
594
|
+
if (!this.assistantText.trim()) {
|
|
595
|
+
this.assistantText = message;
|
|
596
|
+
}
|
|
597
|
+
await this.flushNow();
|
|
598
|
+
this.failed = true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async fallbackSendFinalText(text: string): Promise<void> {
|
|
602
|
+
await sendInfoflowMessage({
|
|
603
|
+
cfg: this.cfg,
|
|
604
|
+
to: this.to,
|
|
605
|
+
accountId: this.accountId,
|
|
606
|
+
contents: [{ type: this.fallbackFormat, content: text }],
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private setPhase(label: string): void {
|
|
611
|
+
this.statusInfo = label;
|
|
612
|
+
this.phaseLabel = label;
|
|
613
|
+
if (this.phaseHistory[this.phaseHistory.length - 1] !== label) {
|
|
614
|
+
this.phaseHistory.push(label);
|
|
615
|
+
if (this.phaseHistory.length > MAX_PHASE_ENTRIES) {
|
|
616
|
+
this.phaseHistory.splice(0, this.phaseHistory.length - MAX_PHASE_ENTRIES);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private pushToolEntry(name: string, detail: string): void {
|
|
622
|
+
const trimmedName = name.trim() || "工具";
|
|
623
|
+
const trimmedDetail = truncateTail(detail.trim(), 1_200);
|
|
624
|
+
const entry: ToolTrace = {
|
|
625
|
+
id: this.nextToolTraceId++,
|
|
626
|
+
name: trimmedName,
|
|
627
|
+
status: "running",
|
|
628
|
+
details: trimmedDetail ? [trimmedDetail] : [],
|
|
629
|
+
};
|
|
630
|
+
this.toolEntries.push(entry);
|
|
631
|
+
this.activeToolTraceId = entry.id;
|
|
632
|
+
if (this.toolEntries.length > MAX_TOOL_ENTRIES) {
|
|
633
|
+
this.toolEntries.splice(0, this.toolEntries.length - MAX_TOOL_ENTRIES);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private appendActiveToolDetail(text: string, markDone = false): void {
|
|
638
|
+
const trimmed = truncateTail(text.trim(), 1_200);
|
|
639
|
+
if (!trimmed) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const active =
|
|
643
|
+
(this.activeToolTraceId != null
|
|
644
|
+
? this.toolEntries.find((entry) => entry.id === this.activeToolTraceId)
|
|
645
|
+
: undefined) ?? this.toolEntries[this.toolEntries.length - 1];
|
|
646
|
+
if (!active) {
|
|
647
|
+
this.pushToolEntry("工具", trimmed);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
active.details.push(trimmed);
|
|
651
|
+
if (markDone) {
|
|
652
|
+
active.status = "done";
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private scheduleFlush(): void {
|
|
657
|
+
if (this.flushTimer) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
this.flushTimer = setTimeout(() => {
|
|
661
|
+
this.flushTimer = undefined;
|
|
662
|
+
void this.flushNow();
|
|
663
|
+
}, STREAMING_FLUSH_INTERVAL_MS);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async flushNow(): Promise<void> {
|
|
667
|
+
if (this.flushTimer) {
|
|
668
|
+
clearTimeout(this.flushTimer);
|
|
669
|
+
this.flushTimer = undefined;
|
|
670
|
+
}
|
|
671
|
+
if (!this.isUsable()) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (this.flushPromise) {
|
|
675
|
+
this.flushQueued = true;
|
|
676
|
+
await this.flushPromise;
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
this.flushQueued = false;
|
|
680
|
+
const answerText = this.assistantText.trim();
|
|
681
|
+
const content = buildCardContent({
|
|
682
|
+
answerText,
|
|
683
|
+
answerFormat: this.answerFormat,
|
|
684
|
+
phaseHistory: this.phaseHistory,
|
|
685
|
+
reasoningText: this.reasoningText,
|
|
686
|
+
toolEntries: this.toolEntries,
|
|
687
|
+
statusInfo: this.statusInfo,
|
|
688
|
+
phaseLabel: this.phaseLabel,
|
|
689
|
+
done: this.completed,
|
|
690
|
+
failed: this.phaseLabel === DEFAULT_FAILED_STATUS,
|
|
691
|
+
});
|
|
692
|
+
this.flushPromise = updateStreamingCard({
|
|
693
|
+
account: this.account,
|
|
694
|
+
to: this.to,
|
|
695
|
+
modifyToken: this.modifyToken!,
|
|
696
|
+
content,
|
|
697
|
+
groupVersion: /^group:\d+$/i.test(this.to) ? this.groupUpdateVersion++ : undefined,
|
|
698
|
+
})
|
|
699
|
+
.then(async (result) => {
|
|
700
|
+
if (!result.ok) {
|
|
701
|
+
this.failed = true;
|
|
702
|
+
this.createError = result.error;
|
|
703
|
+
getInfoflowSendLog().error(`[streaming] update failed: ${result.error}`);
|
|
704
|
+
}
|
|
705
|
+
})
|
|
706
|
+
.finally(() => {
|
|
707
|
+
this.flushPromise = undefined;
|
|
708
|
+
});
|
|
709
|
+
await this.flushPromise;
|
|
710
|
+
if (this.flushQueued && this.isUsable()) {
|
|
711
|
+
this.flushQueued = false;
|
|
712
|
+
await this.flushNow();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function normalizeStreamingFallbackFormat(
|
|
718
|
+
format: InfoflowMessageFormat | undefined,
|
|
719
|
+
): "text" | "markdown" {
|
|
720
|
+
return format === "text" ? "text" : "markdown";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export async function sendStreamingFallbackError(params: {
|
|
724
|
+
cfg: OpenClawConfig;
|
|
725
|
+
to: string;
|
|
726
|
+
accountId?: string;
|
|
727
|
+
error: unknown;
|
|
728
|
+
}): Promise<void> {
|
|
729
|
+
const message = `处理出错,请稍后重试\n${formatInfoflowError(params.error)}`;
|
|
730
|
+
try {
|
|
731
|
+
await sendInfoflowMessage({
|
|
732
|
+
cfg: params.cfg,
|
|
733
|
+
to: params.to,
|
|
734
|
+
accountId: params.accountId,
|
|
735
|
+
contents: [{ type: "text", content: message }],
|
|
736
|
+
});
|
|
737
|
+
} catch (err) {
|
|
738
|
+
logVerbose(`[streaming] fallback error message failed: ${formatInfoflowError(err)}`);
|
|
739
|
+
}
|
|
740
|
+
}
|