@vibearound/plugin-channel-sdk 0.1.2 → 0.4.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 +62 -108
- package/dist/advanced.d.ts +19 -0
- package/dist/advanced.d.ts.map +1 -0
- package/dist/advanced.js +18 -0
- package/dist/advanced.js.map +1 -0
- package/dist/connection.d.ts +10 -4
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +10 -4
- package/dist/connection.js.map +1 -1
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +33 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +27 -41
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -41
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +80 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +131 -0
- package/dist/plugin.js.map +1 -0
- package/dist/renderer.d.ts +52 -46
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +131 -88
- package/dist/renderer.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/advanced.ts +43 -0
- package/src/connection.ts +10 -4
- package/src/errors.ts +29 -0
- package/src/index.ts +48 -52
- package/src/plugin.ts +247 -0
- package/src/renderer.ts +199 -94
- package/src/types.ts +24 -0
package/src/renderer.ts
CHANGED
|
@@ -25,36 +25,68 @@
|
|
|
25
25
|
*
|
|
26
26
|
* ## Usage
|
|
27
27
|
*
|
|
28
|
+
* Subclass and implement `sendText` + `sendBlock` (+ optionally `editBlock`):
|
|
29
|
+
*
|
|
28
30
|
* ```ts
|
|
29
31
|
* class MyRenderer extends BlockRenderer<string> {
|
|
30
|
-
* protected async
|
|
31
|
-
*
|
|
32
|
+
* protected async sendText(chatId, text) {
|
|
33
|
+
* await myApi.sendMessage(chatId, text);
|
|
34
|
+
* }
|
|
35
|
+
* protected async sendBlock(chatId, kind, content) {
|
|
36
|
+
* const msg = await myApi.sendMessage(chatId, content);
|
|
32
37
|
* return msg.id;
|
|
33
38
|
* }
|
|
34
|
-
* protected async editBlock(
|
|
39
|
+
* protected async editBlock(chatId, ref, kind, content, sealed) {
|
|
35
40
|
* await myApi.editMessage(ref, content);
|
|
36
41
|
* }
|
|
37
42
|
* }
|
|
38
|
-
*
|
|
39
|
-
* // In main.ts:
|
|
40
|
-
* const renderer = new MyRenderer({ verbose: { showThinking: false } });
|
|
41
|
-
*
|
|
42
|
-
* // When user sends a message:
|
|
43
|
-
* renderer.onPromptSent(channelId);
|
|
44
|
-
* try {
|
|
45
|
-
* await agent.prompt({ sessionId, content });
|
|
46
|
-
* await renderer.onTurnEnd(channelId);
|
|
47
|
-
* } catch (e) {
|
|
48
|
-
* await renderer.onTurnError(channelId, String(e));
|
|
49
|
-
* }
|
|
50
|
-
*
|
|
51
|
-
* // In the ACP client's sessionUpdate handler:
|
|
52
|
-
* renderer.onSessionUpdate(notification);
|
|
53
43
|
* ```
|
|
44
|
+
*
|
|
45
|
+
* The SDK's `runChannelPlugin` wires all ACP events to this renderer
|
|
46
|
+
* automatically — plugins don't call onSessionUpdate/onPromptSent/etc
|
|
47
|
+
* directly.
|
|
54
48
|
*/
|
|
55
49
|
|
|
56
50
|
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
|
57
|
-
import type { BlockKind, BlockRendererOptions, VerboseConfig } from "./types.js";
|
|
51
|
+
import type { BlockKind, BlockRendererOptions, CommandEntry, VerboseConfig } from "./types.js";
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Local ACP session-update narrowing
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
//
|
|
57
|
+
// The ACP SDK's `SessionNotification.update` is a discriminated union keyed on
|
|
58
|
+
// the `sessionUpdate` field, but the shape we get back at runtime varies by
|
|
59
|
+
// version. We only consume four variants here, so we define a narrow local
|
|
60
|
+
// view that documents exactly the fields this renderer depends on. A mismatch
|
|
61
|
+
// against the upstream type will show up as a compile error when the SDK is
|
|
62
|
+
// bumped, instead of silently producing `undefined` at runtime.
|
|
63
|
+
|
|
64
|
+
interface AgentMessageChunk {
|
|
65
|
+
sessionUpdate: "agent_message_chunk";
|
|
66
|
+
content?: { text?: string };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface AgentThoughtChunk {
|
|
70
|
+
sessionUpdate: "agent_thought_chunk";
|
|
71
|
+
content?: { text?: string };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ToolCall {
|
|
75
|
+
sessionUpdate: "tool_call";
|
|
76
|
+
title?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ToolCallUpdate {
|
|
80
|
+
sessionUpdate: "tool_call_update";
|
|
81
|
+
title?: string;
|
|
82
|
+
status?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ConsumedSessionUpdate =
|
|
86
|
+
| AgentMessageChunk
|
|
87
|
+
| AgentThoughtChunk
|
|
88
|
+
| ToolCall
|
|
89
|
+
| ToolCallUpdate;
|
|
58
90
|
|
|
59
91
|
// ---------------------------------------------------------------------------
|
|
60
92
|
// Internal state types
|
|
@@ -62,7 +94,7 @@ import type { BlockKind, BlockRendererOptions, VerboseConfig } from "./types.js"
|
|
|
62
94
|
|
|
63
95
|
interface ManagedBlock<TRef> {
|
|
64
96
|
/** Channel this block belongs to. Captured at creation time. */
|
|
65
|
-
|
|
97
|
+
chatId: string;
|
|
66
98
|
kind: BlockKind;
|
|
67
99
|
content: string;
|
|
68
100
|
/** Platform message reference set after the first successful send. */
|
|
@@ -101,13 +133,21 @@ const DEFAULT_MIN_EDIT_INTERVAL_MS = 1000;
|
|
|
101
133
|
* return type of `sendBlock` and the first argument of `editBlock`.
|
|
102
134
|
*/
|
|
103
135
|
export abstract class BlockRenderer<TRef = string> {
|
|
136
|
+
/** When true, blocks are sent and edited in real-time. When false, each
|
|
137
|
+
* block is held until complete, then sent once (send-only mode). */
|
|
138
|
+
protected readonly streaming: boolean;
|
|
104
139
|
protected readonly flushIntervalMs: number;
|
|
105
140
|
protected readonly minEditIntervalMs: number;
|
|
106
141
|
protected readonly verbose: VerboseConfig;
|
|
107
142
|
|
|
108
143
|
private states = new Map<string, ChannelState<TRef>>();
|
|
109
144
|
|
|
145
|
+
/** The chatId of the most recent prompt. Used as fallback target for
|
|
146
|
+
* notifications that arrive without an explicit chatId. */
|
|
147
|
+
private lastActiveChatId: string | null = null;
|
|
148
|
+
|
|
110
149
|
constructor(options: BlockRendererOptions = {}) {
|
|
150
|
+
this.streaming = options.streaming ?? true;
|
|
111
151
|
this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
112
152
|
this.minEditIntervalMs = options.minEditIntervalMs ?? DEFAULT_MIN_EDIT_INTERVAL_MS;
|
|
113
153
|
this.verbose = {
|
|
@@ -117,21 +157,31 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
117
157
|
}
|
|
118
158
|
|
|
119
159
|
// ---------------------------------------------------------------------------
|
|
120
|
-
// Abstract
|
|
160
|
+
// Abstract — plugin MUST implement these
|
|
121
161
|
// ---------------------------------------------------------------------------
|
|
122
162
|
|
|
123
163
|
/**
|
|
124
|
-
* Send a
|
|
164
|
+
* Send a plain text message to the IM. Used for system text, agent ready
|
|
165
|
+
* notifications, session ready, and error messages.
|
|
166
|
+
*/
|
|
167
|
+
protected abstract sendText(chatId: string, text: string): Promise<void>;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Send a new streaming block message to the platform.
|
|
125
171
|
*
|
|
126
172
|
* Return the platform message reference that will be passed to future
|
|
127
173
|
* `editBlock` calls. Return `null` if editing is not supported.
|
|
128
174
|
*/
|
|
129
175
|
protected abstract sendBlock(
|
|
130
|
-
|
|
176
|
+
chatId: string,
|
|
131
177
|
kind: BlockKind,
|
|
132
178
|
content: string,
|
|
133
179
|
): Promise<TRef | null>;
|
|
134
180
|
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Optional overrides — plugin MAY implement these
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
135
185
|
/**
|
|
136
186
|
* Edit an existing block message in-place.
|
|
137
187
|
*
|
|
@@ -142,7 +192,7 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
142
192
|
* Use to switch from a "streaming" card format to a finalized one.
|
|
143
193
|
*/
|
|
144
194
|
protected editBlock?(
|
|
145
|
-
|
|
195
|
+
chatId: string,
|
|
146
196
|
ref: TRef,
|
|
147
197
|
kind: BlockKind,
|
|
148
198
|
content: string,
|
|
@@ -169,31 +219,18 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
169
219
|
|
|
170
220
|
/**
|
|
171
221
|
* Called after the last block has been flushed and the turn is complete.
|
|
172
|
-
* Override to perform cleanup (e.g. remove a "typing" indicator
|
|
173
|
-
* processing reaction, etc.).
|
|
174
|
-
*/
|
|
175
|
-
protected onAfterTurnEnd(_channelId: string): Promise<void> {
|
|
176
|
-
return Promise.resolve();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Called after a turn error, once state has been cleaned up.
|
|
181
|
-
* Override to send an error message to the user.
|
|
222
|
+
* Override to perform cleanup (e.g. remove a "typing" indicator).
|
|
182
223
|
*/
|
|
183
|
-
protected
|
|
224
|
+
protected onAfterTurnEnd(_chatId: string): Promise<void> {
|
|
184
225
|
return Promise.resolve();
|
|
185
226
|
}
|
|
186
227
|
|
|
187
228
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* Default: identity (sessionId === channelId).
|
|
191
|
-
*
|
|
192
|
-
* Override if your plugin namespaces channel IDs (e.g. Feishu uses
|
|
193
|
-
* `"feishu:<sessionId>"`, WeChat uses `"weixin-openclaw-bridge:<sessionId>"`).
|
|
229
|
+
* Called after a turn error. Default sends an error message via sendText.
|
|
230
|
+
* Override for platform-specific error rendering (e.g. error card).
|
|
194
231
|
*/
|
|
195
|
-
protected
|
|
196
|
-
|
|
232
|
+
protected async onAfterTurnError(chatId: string, error: string): Promise<void> {
|
|
233
|
+
await this.sendText(chatId, `❌ Error: ${error}`);
|
|
197
234
|
}
|
|
198
235
|
|
|
199
236
|
// ---------------------------------------------------------------------------
|
|
@@ -206,39 +243,46 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
206
243
|
* Routes the event to the correct block based on its variant, appending
|
|
207
244
|
* deltas to the current block or starting a new one when the kind changes.
|
|
208
245
|
*
|
|
209
|
-
*
|
|
246
|
+
* Called automatically by `runChannelPlugin` — plugins don't call this directly.
|
|
210
247
|
*/
|
|
211
248
|
onSessionUpdate(notification: SessionNotification): void {
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
const
|
|
249
|
+
// sessionId from ACP = chatId (the host replaces the real agent session
|
|
250
|
+
// ID with the chat ID before forwarding to the plugin).
|
|
251
|
+
const chatId = notification.sessionId;
|
|
252
|
+
const rawUpdate = notification.update as unknown as { sessionUpdate: string };
|
|
253
|
+
const variant = rawUpdate.sessionUpdate;
|
|
254
|
+
if (
|
|
255
|
+
variant !== "agent_message_chunk" &&
|
|
256
|
+
variant !== "agent_thought_chunk" &&
|
|
257
|
+
variant !== "tool_call" &&
|
|
258
|
+
variant !== "tool_call_update"
|
|
259
|
+
) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const update = rawUpdate as ConsumedSessionUpdate;
|
|
217
263
|
|
|
218
|
-
switch (
|
|
264
|
+
switch (update.sessionUpdate) {
|
|
219
265
|
case "agent_message_chunk": {
|
|
220
|
-
const delta =
|
|
221
|
-
if (delta) this.appendToBlock(
|
|
266
|
+
const delta = update.content?.text ?? "";
|
|
267
|
+
if (delta) this.appendToBlock(chatId, "text", delta);
|
|
222
268
|
break;
|
|
223
269
|
}
|
|
224
270
|
case "agent_thought_chunk": {
|
|
225
|
-
if (!this.verbose.showThinking) return;
|
|
226
|
-
const delta =
|
|
227
|
-
if (delta) this.appendToBlock(
|
|
271
|
+
if (!this.verbose.showThinking) return;
|
|
272
|
+
const delta = update.content?.text ?? "";
|
|
273
|
+
if (delta) this.appendToBlock(chatId, "thinking", delta);
|
|
228
274
|
break;
|
|
229
275
|
}
|
|
230
276
|
case "tool_call": {
|
|
231
|
-
if (!this.verbose.showToolUse) return;
|
|
232
|
-
|
|
233
|
-
if (title) this.appendToBlock(channelId, "tool", `🔧 ${title}\n`);
|
|
277
|
+
if (!this.verbose.showToolUse) return;
|
|
278
|
+
if (update.title) this.appendToBlock(chatId, "tool", `🔧 ${update.title}\n`);
|
|
234
279
|
break;
|
|
235
280
|
}
|
|
236
281
|
case "tool_call_update": {
|
|
237
|
-
if (!this.verbose.showToolUse) return;
|
|
238
|
-
const title =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
this.appendToBlock(channelId, "tool", `✅ ${title}\n`);
|
|
282
|
+
if (!this.verbose.showToolUse) return;
|
|
283
|
+
const title = update.title ?? "tool";
|
|
284
|
+
if (update.status === "completed" || update.status === "error") {
|
|
285
|
+
this.appendToBlock(chatId, "tool", `✅ ${title}\n`);
|
|
242
286
|
}
|
|
243
287
|
break;
|
|
244
288
|
}
|
|
@@ -246,15 +290,14 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
246
290
|
}
|
|
247
291
|
|
|
248
292
|
/**
|
|
249
|
-
* Call
|
|
250
|
-
*
|
|
251
|
-
* Clears any leftover state from a previous turn so the new turn starts
|
|
252
|
-
* with a clean slate.
|
|
293
|
+
* Call before sending a prompt. Tracks the active chatId and clears
|
|
294
|
+
* leftover state from a previous turn.
|
|
253
295
|
*/
|
|
254
|
-
onPromptSent(
|
|
255
|
-
|
|
296
|
+
onPromptSent(chatId: string): void {
|
|
297
|
+
this.lastActiveChatId = chatId;
|
|
298
|
+
const old = this.states.get(chatId);
|
|
256
299
|
if (old?.flushTimer) clearTimeout(old.flushTimer);
|
|
257
|
-
this.states.set(
|
|
300
|
+
this.states.set(chatId, {
|
|
258
301
|
blocks: [],
|
|
259
302
|
flushTimer: null,
|
|
260
303
|
lastEditMs: 0,
|
|
@@ -262,14 +305,70 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
262
305
|
});
|
|
263
306
|
}
|
|
264
307
|
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Host notification handlers — built-in defaults, no per-plugin duplication
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
/** Handle `va/system_text` from host. */
|
|
313
|
+
onSystemText(chatId: string, text: string): void {
|
|
314
|
+
this.sendText(chatId, text).catch(() => {});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Handle `va/agent_ready` from host. */
|
|
318
|
+
onAgentReady(chatId: string, agent: string, version: string): void {
|
|
319
|
+
this.sendText(chatId, `🤖 Agent: ${agent} v${version}`).catch(() => {});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Handle `va/session_ready` from host. */
|
|
323
|
+
onSessionReady(chatId: string, sessionId: string): void {
|
|
324
|
+
this.sendText(chatId, `📋 Session: ${sessionId}`).catch(() => {});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Handle `va/command_menu` from host — display available commands.
|
|
329
|
+
*
|
|
330
|
+
* Default renders a plain-text list. Override for platform-specific
|
|
331
|
+
* rendering (e.g. Feishu interactive card, Slack Block Kit, Telegram
|
|
332
|
+
* inline keyboard).
|
|
333
|
+
*/
|
|
334
|
+
onCommandMenu(
|
|
335
|
+
chatId: string,
|
|
336
|
+
systemCommands: CommandEntry[],
|
|
337
|
+
agentCommands: CommandEntry[],
|
|
338
|
+
): void {
|
|
339
|
+
const lines: string[] = [];
|
|
340
|
+
|
|
341
|
+
lines.push("System commands:");
|
|
342
|
+
for (const cmd of systemCommands) {
|
|
343
|
+
const usage = cmd.args ? `/${cmd.name} ${cmd.args}` : `/${cmd.name}`;
|
|
344
|
+
lines.push(` ${usage} — ${cmd.description}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (agentCommands.length > 0) {
|
|
348
|
+
lines.push("");
|
|
349
|
+
lines.push("Agent commands (use /agent <command>):");
|
|
350
|
+
for (const cmd of agentCommands) {
|
|
351
|
+
const desc = cmd.description.length > 80
|
|
352
|
+
? `${cmd.description.slice(0, 77)}...`
|
|
353
|
+
: cmd.description;
|
|
354
|
+
lines.push(` /${cmd.name} — ${desc}`);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
lines.push("");
|
|
358
|
+
lines.push("Agent commands will appear after sending your first message.");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.sendText(chatId, lines.join("\n")).catch(() => {});
|
|
362
|
+
}
|
|
363
|
+
|
|
265
364
|
/**
|
|
266
365
|
* Call this after `agent.prompt()` resolves (turn complete).
|
|
267
366
|
*
|
|
268
367
|
* Seals and flushes the last block, then waits for all pending sends/edits
|
|
269
368
|
* to complete before calling `onAfterTurnEnd`.
|
|
270
369
|
*/
|
|
271
|
-
async onTurnEnd(
|
|
272
|
-
const state = this.states.get(
|
|
370
|
+
async onTurnEnd(chatId: string): Promise<void> {
|
|
371
|
+
const state = this.states.get(chatId);
|
|
273
372
|
if (!state) return;
|
|
274
373
|
|
|
275
374
|
if (state.flushTimer) {
|
|
@@ -283,35 +382,33 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
283
382
|
this.enqueueFlush(state, last);
|
|
284
383
|
}
|
|
285
384
|
|
|
286
|
-
// Wait for the entire chain to drain before cleanup
|
|
287
385
|
await state.sendChain;
|
|
288
|
-
this.states.delete(
|
|
289
|
-
await this.onAfterTurnEnd(
|
|
386
|
+
this.states.delete(chatId);
|
|
387
|
+
await this.onAfterTurnEnd(chatId);
|
|
290
388
|
}
|
|
291
389
|
|
|
292
390
|
/**
|
|
293
391
|
* Call this when `agent.prompt()` throws (turn error).
|
|
294
392
|
*
|
|
295
|
-
* Discards pending state and calls `onAfterTurnError
|
|
296
|
-
* send an error message to the user.
|
|
393
|
+
* Discards pending state and calls `onAfterTurnError`.
|
|
297
394
|
*/
|
|
298
|
-
async onTurnError(
|
|
299
|
-
const state = this.states.get(
|
|
395
|
+
async onTurnError(chatId: string, error: string): Promise<void> {
|
|
396
|
+
const state = this.states.get(chatId);
|
|
300
397
|
if (state?.flushTimer) clearTimeout(state.flushTimer);
|
|
301
|
-
this.states.delete(
|
|
302
|
-
await this.onAfterTurnError(
|
|
398
|
+
this.states.delete(chatId);
|
|
399
|
+
await this.onAfterTurnError(chatId, error);
|
|
303
400
|
}
|
|
304
401
|
|
|
305
402
|
// ---------------------------------------------------------------------------
|
|
306
403
|
// Internal — block management
|
|
307
404
|
// ---------------------------------------------------------------------------
|
|
308
405
|
|
|
309
|
-
private appendToBlock(
|
|
310
|
-
let state = this.states.get(
|
|
406
|
+
private appendToBlock(chatId: string, kind: BlockKind, delta: string): void {
|
|
407
|
+
let state = this.states.get(chatId);
|
|
311
408
|
if (!state) {
|
|
312
409
|
// Auto-create state if onPromptSent wasn't called (e.g. host-initiated turns)
|
|
313
410
|
state = { blocks: [], flushTimer: null, lastEditMs: 0, sendChain: Promise.resolve() };
|
|
314
|
-
this.states.set(
|
|
411
|
+
this.states.set(chatId, state);
|
|
315
412
|
}
|
|
316
413
|
|
|
317
414
|
const last = state.blocks.at(-1);
|
|
@@ -330,25 +427,33 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
330
427
|
}
|
|
331
428
|
this.enqueueFlush(state, last);
|
|
332
429
|
}
|
|
333
|
-
state.blocks.push({
|
|
430
|
+
state.blocks.push({ chatId, kind, content: delta, ref: null, creating: false, sealed: false });
|
|
334
431
|
}
|
|
335
432
|
|
|
336
|
-
this.scheduleFlush(
|
|
433
|
+
this.scheduleFlush(chatId, state);
|
|
337
434
|
}
|
|
338
435
|
|
|
339
|
-
private scheduleFlush(
|
|
436
|
+
private scheduleFlush(chatId: string, state: ChannelState<TRef>): void {
|
|
340
437
|
if (state.flushTimer) return; // already scheduled
|
|
341
438
|
|
|
342
439
|
state.flushTimer = setTimeout(() => {
|
|
343
440
|
state.flushTimer = null;
|
|
344
|
-
this.flush(
|
|
441
|
+
this.flush(chatId, state);
|
|
345
442
|
}, this.flushIntervalMs);
|
|
346
443
|
}
|
|
347
444
|
|
|
348
|
-
private flush(
|
|
445
|
+
private flush(chatId: string, state: ChannelState<TRef>): void {
|
|
349
446
|
const block = state.blocks.at(-1);
|
|
350
447
|
if (!block || block.sealed || !block.content) return;
|
|
351
448
|
|
|
449
|
+
// Send-only mode (streaming=false): defer intermediate sends.
|
|
450
|
+
// Only sealed blocks (from onTurnEnd or block boundary transitions)
|
|
451
|
+
// will actually POST. This prevents the user seeing a partial chunk
|
|
452
|
+
// followed by the full message as two separate messages.
|
|
453
|
+
if (!this.streaming) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
352
457
|
const now = Date.now();
|
|
353
458
|
if (now - state.lastEditMs < this.minEditIntervalMs) {
|
|
354
459
|
// Throttled — reschedule for the remaining window
|
|
@@ -356,7 +461,7 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
356
461
|
if (!state.flushTimer) {
|
|
357
462
|
state.flushTimer = setTimeout(() => {
|
|
358
463
|
state.flushTimer = null;
|
|
359
|
-
this.flush(
|
|
464
|
+
this.flush(chatId, state);
|
|
360
465
|
}, delay);
|
|
361
466
|
}
|
|
362
467
|
return;
|
|
@@ -379,12 +484,12 @@ export abstract class BlockRenderer<TRef = string> {
|
|
|
379
484
|
if (block.ref === null && !block.creating) {
|
|
380
485
|
// First send — use sentinel to prevent concurrent creates
|
|
381
486
|
block.creating = true;
|
|
382
|
-
block.ref = await this.sendBlock(block.
|
|
487
|
+
block.ref = await this.sendBlock(block.chatId, block.kind, content);
|
|
383
488
|
block.creating = false;
|
|
384
489
|
state.lastEditMs = Date.now();
|
|
385
|
-
} else if (block.ref !== null && !block.creating && this.editBlock) {
|
|
386
|
-
// Subsequent update — edit in-place
|
|
387
|
-
await this.editBlock(block.
|
|
490
|
+
} else if (block.ref !== null && !block.creating && this.streaming && this.editBlock) {
|
|
491
|
+
// Subsequent update — edit in-place (streaming mode only)
|
|
492
|
+
await this.editBlock(block.chatId, block.ref, block.kind, content, block.sealed);
|
|
388
493
|
state.lastEditMs = Date.now();
|
|
389
494
|
}
|
|
390
495
|
// else: create is in-flight (creating === true) — skip
|
package/src/types.ts
CHANGED
|
@@ -43,6 +43,18 @@ export interface PluginManifest {
|
|
|
43
43
|
capabilities?: PluginCapabilities;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Command menu
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** A command entry from the host's command list. */
|
|
51
|
+
export interface CommandEntry {
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
args?: string;
|
|
55
|
+
aliases?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
// ---------------------------------------------------------------------------
|
|
47
59
|
// Block rendering
|
|
48
60
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +70,18 @@ export interface VerboseConfig {
|
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
export interface BlockRendererOptions {
|
|
73
|
+
/**
|
|
74
|
+
* Whether the IM platform supports message editing (streaming mode).
|
|
75
|
+
*
|
|
76
|
+
* - `true` (default): blocks stream in real-time — `sendBlock()` creates
|
|
77
|
+
* the message, `editBlock()` updates it as more content arrives.
|
|
78
|
+
* - `false`: each block is held until complete, then sent once via
|
|
79
|
+
* `sendBlock()`. `editBlock()` is never called.
|
|
80
|
+
*
|
|
81
|
+
* Set to `false` for platforms that don't support editing sent messages
|
|
82
|
+
* (e.g. QQ Bot, WhatsApp, WeChat, LINE).
|
|
83
|
+
*/
|
|
84
|
+
streaming?: boolean;
|
|
61
85
|
/**
|
|
62
86
|
* Debounce interval before flushing an unsealed block (ms).
|
|
63
87
|
* Controls how often in-progress blocks are sent to the platform.
|