@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.5.4
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 +37 -11
- package/dist/index.js +27 -0
- package/dist/src/api-client.js +156 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +200 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +226 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +132 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +82 -0
- package/dist/src/outbound.js +181 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/reply-dispatcher.js +440 -0
- package/dist/src/runtime.js +288 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/tools-schema.js +38 -0
- package/dist/src/tools.js +287 -0
- package/openclaw.plugin.json +21 -0
- package/package.json +27 -5
- package/skills/clawchat-activate/SKILL.md +18 -9
- package/src/buffered-stream.test.ts +10 -0
- package/src/buffered-stream.ts +6 -6
- package/src/channel.outbound.test.ts +3 -3
- package/src/channel.test.ts +7 -1
- package/src/channel.ts +27 -8
- package/src/client.test.ts +8 -1
- package/src/client.ts +11 -10
- package/src/commands.test.ts +6 -0
- package/src/commands.ts +5 -1
- package/src/config.test.ts +47 -0
- package/src/config.ts +28 -5
- package/src/inbound.test.ts +4 -1
- package/src/inbound.ts +11 -10
- package/src/login.runtime.test.ts +36 -0
- package/src/login.runtime.ts +57 -27
- package/src/manifest.test.ts +156 -30
- package/src/outbound.test.ts +6 -5
- package/src/outbound.ts +8 -7
- package/src/plugin-entry.test.ts +7 -1
- package/src/reply-dispatcher.test.ts +418 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.ts +1 -0
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +6 -6
- package/src/tools.test.ts +81 -18
- package/src/tools.ts +65 -74
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { interactiveReplyToPresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime";
|
|
2
|
+
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
3
|
+
import { openBufferedStreamingSession, mergeStreamingText, } from "./buffered-stream.js";
|
|
4
|
+
import { emitFinalStreamReply } from "./client.js";
|
|
5
|
+
import { textToFragments } from "./message-mapper.js";
|
|
6
|
+
import { uploadOutboundMedia } from "./media-runtime.js";
|
|
7
|
+
import { sendOpenclawClawlingText } from "./outbound.js";
|
|
8
|
+
import { sendStreamingFailure } from "./streaming.js";
|
|
9
|
+
function normalizeReplyErrorText(error) {
|
|
10
|
+
const raw = String(error);
|
|
11
|
+
const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
|
|
12
|
+
if (retryWrapped?.[1]?.trim())
|
|
13
|
+
return retryWrapped[1].trim();
|
|
14
|
+
const retryWrappedBare = raw.match(/^Retry failed for delivery [^:]+:\s*(.+)$/s);
|
|
15
|
+
if (retryWrappedBare?.[1]?.trim())
|
|
16
|
+
return retryWrappedBare[1].trim();
|
|
17
|
+
return raw;
|
|
18
|
+
}
|
|
19
|
+
function isMessagePresentation(value) {
|
|
20
|
+
return Boolean(value &&
|
|
21
|
+
typeof value === "object" &&
|
|
22
|
+
Array.isArray(value.blocks));
|
|
23
|
+
}
|
|
24
|
+
function resolvePresentation(payload) {
|
|
25
|
+
if (isMessagePresentation(payload.presentation))
|
|
26
|
+
return payload.presentation;
|
|
27
|
+
if (payload.interactive)
|
|
28
|
+
return interactiveReplyToPresentation(payload.interactive);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function normalizeActionId(value, label, index) {
|
|
32
|
+
const raw = value?.trim() || label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
33
|
+
return raw.replace(/^-+|-+$/g, "") || `action-${index + 1}`;
|
|
34
|
+
}
|
|
35
|
+
function collectPresentationActions(blocks) {
|
|
36
|
+
const actions = [];
|
|
37
|
+
for (const block of blocks) {
|
|
38
|
+
if (block.type === "buttons") {
|
|
39
|
+
for (const button of block.buttons) {
|
|
40
|
+
const value = button.value?.trim();
|
|
41
|
+
const url = button.url?.trim();
|
|
42
|
+
const action = {
|
|
43
|
+
id: normalizeActionId(value ?? url, button.label, actions.length),
|
|
44
|
+
label: button.label,
|
|
45
|
+
...(button.style ? { style: button.style } : {}),
|
|
46
|
+
...(value || url ? { payload: { ...(value ? { value } : {}), ...(url ? { url } : {}) } } : {}),
|
|
47
|
+
};
|
|
48
|
+
actions.push(action);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (block.type === "select") {
|
|
52
|
+
for (const option of block.options) {
|
|
53
|
+
actions.push({
|
|
54
|
+
id: normalizeActionId(option.value, option.label, actions.length),
|
|
55
|
+
label: option.label,
|
|
56
|
+
style: "secondary",
|
|
57
|
+
payload: { value: option.value },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return actions;
|
|
63
|
+
}
|
|
64
|
+
function looksLikeApproval(actions, presentation) {
|
|
65
|
+
if (presentation.tone === "warning" || presentation.tone === "danger")
|
|
66
|
+
return true;
|
|
67
|
+
const ids = new Set(actions.map((action) => action.id.toLowerCase()));
|
|
68
|
+
return ids.has("approve") || ids.has("deny") || ids.has("reject");
|
|
69
|
+
}
|
|
70
|
+
function buildRichInteractionFragment(payload) {
|
|
71
|
+
const presentation = resolvePresentation(payload);
|
|
72
|
+
if (!presentation)
|
|
73
|
+
return null;
|
|
74
|
+
const actions = collectPresentationActions(presentation.blocks);
|
|
75
|
+
if (actions.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const fallbackText = renderMessagePresentationFallbackText({
|
|
78
|
+
presentation,
|
|
79
|
+
text: payload.text ?? null,
|
|
80
|
+
}).trim();
|
|
81
|
+
if (!fallbackText)
|
|
82
|
+
return null;
|
|
83
|
+
return {
|
|
84
|
+
kind: looksLikeApproval(actions, presentation) ? "approval_request" : "action_card",
|
|
85
|
+
...(presentation.title?.trim() ? { title: presentation.title.trim() } : {}),
|
|
86
|
+
fallback_text: fallbackText,
|
|
87
|
+
state: "pending",
|
|
88
|
+
actions,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function resolvePayloadText(payload) {
|
|
92
|
+
const presentation = resolvePresentation(payload);
|
|
93
|
+
if (!presentation)
|
|
94
|
+
return payload.text ?? "";
|
|
95
|
+
return renderMessagePresentationFallbackText({ presentation, text: payload.text ?? null });
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reply dispatcher for openclaw-clawchat.
|
|
99
|
+
*
|
|
100
|
+
* Streaming mode (`account.replyMode === "stream"`, no replyCtx):
|
|
101
|
+
* 1. `onReplyStart` just resets accumulators. It does NOT open the session
|
|
102
|
+
* yet — that would leave a ghost `message.created + done` bubble if the
|
|
103
|
+
* run produces nothing (e.g., agent not configured, send-policy denied).
|
|
104
|
+
* 2. On first real content (deliver block/tool or partial/reasoning
|
|
105
|
+
* snapshot), `queueStreamSnapshot` lazily opens the session, which emits
|
|
106
|
+
* `message.created` plus the first `message.add`.
|
|
107
|
+
* 3. Subsequent snapshots/deltas emit `message.add` frames (chunked by
|
|
108
|
+
* `stream.flushIntervalMs` + `stream.minChunkChars`).
|
|
109
|
+
* 4. On run end (`onIdle`), the session flushes pending buffer, emits
|
|
110
|
+
* `message.done`, then the merged full text plus any accumulated
|
|
111
|
+
* media is emitted as a separate `message.send` / `message.reply` —
|
|
112
|
+
* mirroring the clawling-channel v1 pattern where streaming `agent`
|
|
113
|
+
* events are followed by a consolidated `chat` final. Empty runs emit
|
|
114
|
+
* nothing at all (no created/done/reply) — they only log a skip line.
|
|
115
|
+
*
|
|
116
|
+
* Static mode or replyCtx: bypass streaming and emit one `message.send` /
|
|
117
|
+
* `message.reply` per deliver with text + media.
|
|
118
|
+
*/
|
|
119
|
+
export function createOpenclawClawlingReplyDispatcher(options) {
|
|
120
|
+
const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, inboundForFinalReply, log, } = options;
|
|
121
|
+
const routing = { chatId: target.chatId, chatType: target.chatType };
|
|
122
|
+
const humanDelay = runtime.channel.reply.resolveHumanDelayConfig(cfg, account.userId);
|
|
123
|
+
const streamingEnabled = account.replyMode === "stream" && !replyCtx;
|
|
124
|
+
const buildApiClient = () => {
|
|
125
|
+
if (!account.baseUrl || !account.token)
|
|
126
|
+
return null;
|
|
127
|
+
return createOpenclawClawlingApiClient({
|
|
128
|
+
baseUrl: account.baseUrl,
|
|
129
|
+
token: account.token,
|
|
130
|
+
userId: account.userId,
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
async function uploadMediaUrls(urls) {
|
|
134
|
+
if (urls.length === 0)
|
|
135
|
+
return [];
|
|
136
|
+
const apiClient = buildApiClient();
|
|
137
|
+
if (!apiClient) {
|
|
138
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat outbound media skipped: baseUrl not configured`);
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
return await uploadOutboundMedia(urls, { apiClient, runtime, log });
|
|
142
|
+
}
|
|
143
|
+
// ----- Streaming session state -----------------------------------------
|
|
144
|
+
let streamingSession = null;
|
|
145
|
+
let streamingMessageId = "";
|
|
146
|
+
let streamText = "";
|
|
147
|
+
let reasoningText = "";
|
|
148
|
+
const accumulatedMediaUrls = [];
|
|
149
|
+
const finalRichFragments = [];
|
|
150
|
+
let finalEmitted = false;
|
|
151
|
+
let streamingClosed = false;
|
|
152
|
+
let runFailed = false;
|
|
153
|
+
let runDone = false;
|
|
154
|
+
// `streamCreatedEmitted` is the authoritative guard: once a `message.created`
|
|
155
|
+
// has been emitted for this dispatcher instance, never emit another — even
|
|
156
|
+
// if `onReplyStart` fires again or a pre-onReplyStart `onPartialReply`
|
|
157
|
+
// raced the lazy open path.
|
|
158
|
+
let streamCreatedEmitted = false;
|
|
159
|
+
const mintStreamingMessageId = () => `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
160
|
+
const openSessionIfNeeded = () => {
|
|
161
|
+
if (!streamingEnabled || streamingSession || streamCreatedEmitted)
|
|
162
|
+
return;
|
|
163
|
+
streamCreatedEmitted = true;
|
|
164
|
+
// Mint a fresh agent-side message_id at `message.created` time. All
|
|
165
|
+
// subsequent `message.add` / `message.done` / `message.reply` frames for
|
|
166
|
+
// this stream reuse it. Once the stream finalizes (done or reply), this
|
|
167
|
+
// id is retired — the next inbound turn spawns a new dispatcher instance
|
|
168
|
+
// which mints its own id. The inbound user message_id lives on
|
|
169
|
+
// `replyTo.msgId`; keeping the two distinct avoids the agent's reply
|
|
170
|
+
// frames shadowing the user turn they answer.
|
|
171
|
+
streamingMessageId = mintStreamingMessageId();
|
|
172
|
+
streamingSession = openBufferedStreamingSession({
|
|
173
|
+
client,
|
|
174
|
+
routing,
|
|
175
|
+
sender: {
|
|
176
|
+
id: account.userId,
|
|
177
|
+
type: target.chatType,
|
|
178
|
+
nick_name: account.userId,
|
|
179
|
+
},
|
|
180
|
+
messageId: streamingMessageId,
|
|
181
|
+
flushIntervalMs: account.stream.flushIntervalMs,
|
|
182
|
+
minChunkChars: account.stream.minChunkChars,
|
|
183
|
+
maxBufferChars: account.stream.maxBufferChars,
|
|
184
|
+
});
|
|
185
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat streaming opened msg=${streamingMessageId}`);
|
|
186
|
+
};
|
|
187
|
+
const buildCombinedStreamSnapshot = () => {
|
|
188
|
+
if (!reasoningText && !streamText)
|
|
189
|
+
return "";
|
|
190
|
+
if (!reasoningText)
|
|
191
|
+
return streamText;
|
|
192
|
+
if (!streamText)
|
|
193
|
+
return reasoningText;
|
|
194
|
+
return `${reasoningText}\n\n${streamText}`;
|
|
195
|
+
};
|
|
196
|
+
const queueStreamSnapshot = async () => {
|
|
197
|
+
openSessionIfNeeded();
|
|
198
|
+
if (!streamingSession)
|
|
199
|
+
return;
|
|
200
|
+
const combined = buildCombinedStreamSnapshot();
|
|
201
|
+
if (!combined)
|
|
202
|
+
return;
|
|
203
|
+
await streamingSession.queueSnapshot(combined);
|
|
204
|
+
};
|
|
205
|
+
const closeStreamingSession = async (reason, failReason) => {
|
|
206
|
+
if (!streamingSession || streamingClosed)
|
|
207
|
+
return;
|
|
208
|
+
streamingClosed = true;
|
|
209
|
+
if (reason === "fail") {
|
|
210
|
+
await streamingSession.fail(failReason);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
await streamingSession.done();
|
|
214
|
+
}
|
|
215
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat streaming closed msg=${streamingMessageId} reason=${reason ?? "done"}`);
|
|
216
|
+
};
|
|
217
|
+
// ----- Static send ------------------------------------------------------
|
|
218
|
+
const sendStatic = async (text, mediaFragments = [], richFragments = []) => {
|
|
219
|
+
if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0)
|
|
220
|
+
return;
|
|
221
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat sending static text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
|
|
222
|
+
await sendOpenclawClawlingText({
|
|
223
|
+
client,
|
|
224
|
+
account,
|
|
225
|
+
to: target,
|
|
226
|
+
text,
|
|
227
|
+
...(replyCtx ? { replyCtx } : {}),
|
|
228
|
+
...(richFragments.length > 0 ? { richFragments } : {}),
|
|
229
|
+
...(mediaFragments.length > 0 ? { mediaFragments } : {}),
|
|
230
|
+
log,
|
|
231
|
+
});
|
|
232
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat send complete to=${target.chatId}`);
|
|
233
|
+
};
|
|
234
|
+
const emitFinalConsolidatedMessage = async () => {
|
|
235
|
+
if (finalEmitted)
|
|
236
|
+
return;
|
|
237
|
+
finalEmitted = true;
|
|
238
|
+
const mergedMedia = await uploadMediaUrls(accumulatedMediaUrls.slice());
|
|
239
|
+
const mergedText = streamText.trim();
|
|
240
|
+
if (!mergedText && finalRichFragments.length === 0 && mergedMedia.length === 0) {
|
|
241
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat no merged final content; skip consolidated reply`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat emitting consolidated final (message.reply) msg=${streamingMessageId} text_len=${mergedText.length} media=${mergedMedia.length}`);
|
|
245
|
+
const bodyFragments = [
|
|
246
|
+
...(mergedText ? textToFragments(mergedText) : []),
|
|
247
|
+
...finalRichFragments,
|
|
248
|
+
// mediaFragments is the local wide shape; cast at SDK boundary as
|
|
249
|
+
// we do in outbound.ts.
|
|
250
|
+
...mergedMedia,
|
|
251
|
+
];
|
|
252
|
+
// Streaming message_id must match the created/add/done frames so the
|
|
253
|
+
// backend can correlate the consolidated reply with the stream.
|
|
254
|
+
emitFinalStreamReply(client, {
|
|
255
|
+
messageId: streamingMessageId,
|
|
256
|
+
routing,
|
|
257
|
+
replyTo: {
|
|
258
|
+
msgId: inboundMessageId ?? streamingMessageId,
|
|
259
|
+
previewId: inboundForFinalReply?.chatId ?? target.chatId,
|
|
260
|
+
nickName: inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
|
|
261
|
+
fragments: inboundForFinalReply?.bodyText
|
|
262
|
+
? [{ kind: "text", text: inboundForFinalReply.bodyText }]
|
|
263
|
+
: [],
|
|
264
|
+
},
|
|
265
|
+
body: { fragments: bodyFragments },
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
const ingestFinalPayload = (payload, text, richFragment) => {
|
|
269
|
+
if (richFragment && account.richInteractions) {
|
|
270
|
+
finalRichFragments.push(richFragment);
|
|
271
|
+
}
|
|
272
|
+
if (text)
|
|
273
|
+
streamText = mergeStreamingText(streamText, text);
|
|
274
|
+
const urls = [
|
|
275
|
+
...(payload.mediaUrl ? [payload.mediaUrl] : []),
|
|
276
|
+
...(payload.mediaUrls ?? []),
|
|
277
|
+
].filter((u) => Boolean(u));
|
|
278
|
+
for (const url of urls) {
|
|
279
|
+
if (!accumulatedMediaUrls.includes(url))
|
|
280
|
+
accumulatedMediaUrls.push(url);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const ingestBlockText = async (text) => {
|
|
284
|
+
if (!text)
|
|
285
|
+
return;
|
|
286
|
+
streamText = `${streamText}${text}`;
|
|
287
|
+
await queueStreamSnapshot();
|
|
288
|
+
};
|
|
289
|
+
// ----- Dispatcher -------------------------------------------------------
|
|
290
|
+
const base = runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
291
|
+
humanDelay,
|
|
292
|
+
onReplyStart: async () => {
|
|
293
|
+
// Only clear transient accumulators the first time the run starts.
|
|
294
|
+
// If `onReplyStart` fires again during the same dispatcher instance
|
|
295
|
+
// (e.g. typing controller re-entry), we must NOT tear down the stream
|
|
296
|
+
// session — that would cause a second `message.created`.
|
|
297
|
+
//
|
|
298
|
+
// We deliberately do NOT open the streaming session here. Opening it
|
|
299
|
+
// eagerly would emit `message.created` even for runs that ultimately
|
|
300
|
+
// produce nothing (unknown agent, send-policy denied, etc.), leaving a
|
|
301
|
+
// ghost empty bubble. The session opens lazily via queueStreamSnapshot
|
|
302
|
+
// on the first real content.
|
|
303
|
+
if (!streamCreatedEmitted) {
|
|
304
|
+
streamText = "";
|
|
305
|
+
reasoningText = "";
|
|
306
|
+
accumulatedMediaUrls.length = 0;
|
|
307
|
+
finalRichFragments.length = 0;
|
|
308
|
+
finalEmitted = false;
|
|
309
|
+
streamingClosed = false;
|
|
310
|
+
runDone = false;
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
deliver: async (payload, info) => {
|
|
314
|
+
const richFragment = buildRichInteractionFragment(payload);
|
|
315
|
+
const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
|
|
316
|
+
const urls = [
|
|
317
|
+
...(payload.mediaUrl ? [payload.mediaUrl] : []),
|
|
318
|
+
...(payload.mediaUrls ?? []),
|
|
319
|
+
].filter((u) => Boolean(u));
|
|
320
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`);
|
|
321
|
+
if (payload.isReasoning) {
|
|
322
|
+
if (!account.forwardThinking)
|
|
323
|
+
return;
|
|
324
|
+
if (streamingEnabled) {
|
|
325
|
+
reasoningText = mergeStreamingText(reasoningText, text);
|
|
326
|
+
await queueStreamSnapshot();
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
await sendStatic(text);
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (info?.kind === "tool" && !account.forwardToolCalls)
|
|
334
|
+
return;
|
|
335
|
+
if (info?.kind === "final") {
|
|
336
|
+
ingestFinalPayload(payload, text, richFragment && account.richInteractions ? richFragment : null);
|
|
337
|
+
// For streaming: consolidated final is emitted in onIdle after done.
|
|
338
|
+
// For static: emit immediately.
|
|
339
|
+
if (!streamingEnabled) {
|
|
340
|
+
const mediaFragments = await uploadMediaUrls(urls);
|
|
341
|
+
await sendStatic(text, mediaFragments, richFragment && account.richInteractions ? [richFragment] : []);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// kind === "block" or "tool" — text during the run
|
|
346
|
+
if (streamingEnabled) {
|
|
347
|
+
if (text)
|
|
348
|
+
await ingestBlockText(text);
|
|
349
|
+
if (urls.length > 0) {
|
|
350
|
+
const mediaFragments = await uploadMediaUrls(urls);
|
|
351
|
+
if (mediaFragments.length > 0) {
|
|
352
|
+
log?.info?.(`[${account.accountId}] openclaw-clawchat mid-stream media emitted as separate message (count=${mediaFragments.length})`);
|
|
353
|
+
await sendOpenclawClawlingText({
|
|
354
|
+
client,
|
|
355
|
+
account,
|
|
356
|
+
to: target,
|
|
357
|
+
text: "",
|
|
358
|
+
mediaFragments,
|
|
359
|
+
log,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const mediaFragments = await uploadMediaUrls(urls);
|
|
366
|
+
const richFragments = richFragment && account.richInteractions ? [richFragment] : [];
|
|
367
|
+
if (text.trim() || mediaFragments.length > 0 || richFragments.length > 0) {
|
|
368
|
+
await sendStatic(text, mediaFragments, richFragments);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
onError: (error, info) => {
|
|
373
|
+
const errorText = normalizeReplyErrorText(error);
|
|
374
|
+
log?.error?.(`[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${errorText}`);
|
|
375
|
+
if (!streamingEnabled) {
|
|
376
|
+
void sendStatic(errorText);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
runFailed = true;
|
|
380
|
+
if (streamingSession && !streamingClosed) {
|
|
381
|
+
void closeStreamingSession("fail", errorText);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (streamingClosed)
|
|
385
|
+
return;
|
|
386
|
+
streamingClosed = true;
|
|
387
|
+
if (!streamingMessageId)
|
|
388
|
+
streamingMessageId = mintStreamingMessageId();
|
|
389
|
+
void sendStreamingFailure({
|
|
390
|
+
client,
|
|
391
|
+
routing,
|
|
392
|
+
messageId: streamingMessageId,
|
|
393
|
+
currentSequence: 0,
|
|
394
|
+
reason: errorText,
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
onIdle: async () => {
|
|
398
|
+
if (runDone)
|
|
399
|
+
return;
|
|
400
|
+
runDone = true;
|
|
401
|
+
if (!streamingEnabled)
|
|
402
|
+
return;
|
|
403
|
+
if (runFailed)
|
|
404
|
+
return;
|
|
405
|
+
await closeStreamingSession("done");
|
|
406
|
+
await emitFinalConsolidatedMessage();
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
const streamingHooks = streamingEnabled
|
|
410
|
+
? {
|
|
411
|
+
onPartialReply: async (payload) => {
|
|
412
|
+
if (!payload.text || payload.isReasoning)
|
|
413
|
+
return;
|
|
414
|
+
// onPartialReply gives progressive snapshots of the current assistant
|
|
415
|
+
// message text (not deltas). Use mergeStreamingText so overlapping
|
|
416
|
+
// prefixes collapse into a single growing snapshot.
|
|
417
|
+
streamText = mergeStreamingText(streamText, payload.text);
|
|
418
|
+
await queueStreamSnapshot();
|
|
419
|
+
},
|
|
420
|
+
...(account.forwardThinking
|
|
421
|
+
? {
|
|
422
|
+
onReasoningStream: async (payload) => {
|
|
423
|
+
if (!payload.text)
|
|
424
|
+
return;
|
|
425
|
+
reasoningText = mergeStreamingText(reasoningText, payload.text);
|
|
426
|
+
await queueStreamSnapshot();
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
: {}),
|
|
430
|
+
}
|
|
431
|
+
: {};
|
|
432
|
+
return {
|
|
433
|
+
dispatcher: base.dispatcher,
|
|
434
|
+
replyOptions: {
|
|
435
|
+
...base.replyOptions,
|
|
436
|
+
...streamingHooks,
|
|
437
|
+
},
|
|
438
|
+
markDispatchIdle: base.markDispatchIdle,
|
|
439
|
+
};
|
|
440
|
+
}
|