@butlerbot/sdk 0.0.26 → 0.0.28
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/dist/modules/conversation.d.ts +20 -0
- package/dist/modules/conversation.js +29 -7
- package/dist/modules/stream_accumulator.d.ts +34 -0
- package/dist/modules/stream_accumulator.js +92 -0
- package/dist/types/conversation/v3/conversation_v3.d.ts +18 -2
- package/dist/types/response/v5/ai_response_v5.d.ts +18 -2
- package/package.json +1 -1
- package/readme.md +37 -0
|
@@ -64,6 +64,18 @@ export type ConversationOptions<V extends APIPath = "v4"> = {
|
|
|
64
64
|
* have — everything else, including the payloads you receive, is identical.
|
|
65
65
|
*/
|
|
66
66
|
transport?: "sse" | Link;
|
|
67
|
+
/**
|
|
68
|
+
* Whether streamed text is put back together for you. On by default.
|
|
69
|
+
*
|
|
70
|
+
* The server streams each message a piece at a time. With this on, `payload.message`
|
|
71
|
+
* is the whole message so far — what it has always been — and `payload.delta` is what
|
|
72
|
+
* the event added, for anyone who would rather append than re-render.
|
|
73
|
+
*
|
|
74
|
+
* Turn it off to be handed the wire payloads untouched, where a piece arrives as
|
|
75
|
+
* `delta` with no `message` beside it. Worth it only if you are appending anyway and
|
|
76
|
+
* want nothing between you and the socket.
|
|
77
|
+
*/
|
|
78
|
+
accumulateStream?: boolean;
|
|
67
79
|
};
|
|
68
80
|
export declare class Conversation<V extends APIPath = "v4"> {
|
|
69
81
|
convoId?: string;
|
|
@@ -73,6 +85,7 @@ export declare class Conversation<V extends APIPath = "v4"> {
|
|
|
73
85
|
private options?;
|
|
74
86
|
private events;
|
|
75
87
|
private transport;
|
|
88
|
+
private accumulateStream;
|
|
76
89
|
/** The link carrying this conversation, when it is not on SSE. */
|
|
77
90
|
readonly link?: Link;
|
|
78
91
|
private endpoints;
|
|
@@ -159,6 +172,13 @@ export declare class Conversation<V extends APIPath = "v4"> {
|
|
|
159
172
|
lastEventId?: string;
|
|
160
173
|
includeCompleted?: boolean;
|
|
161
174
|
}): Promise<TurnProgressEntryForVersion<V>[] | undefined>;
|
|
175
|
+
/**
|
|
176
|
+
* One stream's worth of delivery: rebuilds whole values, then hands them to the caller.
|
|
177
|
+
*
|
|
178
|
+
* The accumulator is made per stream rather than per conversation because it holds the
|
|
179
|
+
* text of whatever is still being written, and two streams are two different answers.
|
|
180
|
+
*/
|
|
181
|
+
private receiver;
|
|
162
182
|
/** Sends a message into the conversation */
|
|
163
183
|
send(message: string, cb: (chunk: RequestResponseByVersion[V]) => any, options?: DialogueRequestOptions): ConversationStream;
|
|
164
184
|
/**
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Conversation = void 0;
|
|
4
4
|
const config_1 = require("../config");
|
|
5
5
|
const emitter_1 = require("../util/emitter");
|
|
6
|
+
const stream_accumulator_1 = require("./stream_accumulator");
|
|
6
7
|
const transport_link_1 = require("./transport_link");
|
|
7
8
|
const transport_sse_1 = require("./transport_sse");
|
|
8
9
|
const url_formatter_1 = require("../util/url_formatter");
|
|
@@ -22,6 +23,7 @@ class Conversation {
|
|
|
22
23
|
};
|
|
23
24
|
this.apiKey = config.apiKey;
|
|
24
25
|
this.debug = config.debug || false;
|
|
26
|
+
this.accumulateStream = config.accumulateStream ?? true;
|
|
25
27
|
if (config.transport && config.transport !== "sse") {
|
|
26
28
|
this.link = config.transport;
|
|
27
29
|
this.transport = new transport_link_1.LinkConversationTransport(config.transport, () => ({
|
|
@@ -190,17 +192,20 @@ class Conversation {
|
|
|
190
192
|
fetchProgressStream(cb, options) {
|
|
191
193
|
if (!this.convoId)
|
|
192
194
|
throw new Error("Conversation ID is not set");
|
|
195
|
+
// A watcher joins mid-answer: it is caught up with whole values and then follows the
|
|
196
|
+
// rest a piece at a time, which the accumulator handles either way.
|
|
197
|
+
const receive = this.receiver(cb);
|
|
193
198
|
if (this.transport.attach) {
|
|
194
199
|
return this.transport.attach({
|
|
195
200
|
chatId: this.convoId,
|
|
196
201
|
...(options?.afterEventId ? { afterEventId: options.afterEventId } : {}),
|
|
197
202
|
}, {
|
|
198
|
-
payload:
|
|
203
|
+
payload: receive,
|
|
199
204
|
convoId: () => { },
|
|
200
205
|
});
|
|
201
206
|
}
|
|
202
207
|
const url = (0, url_formatter_1.formatURL)(this.endpoints.progressStream, { chatId: this.convoId }, { apiKey: this.apiKey, debug: this.debug });
|
|
203
|
-
return (0, transport_sse_1.streamSSE)(url, { debug: this.debug, onPayload:
|
|
208
|
+
return (0, transport_sse_1.streamSSE)(url, { debug: this.debug, onPayload: receive });
|
|
204
209
|
}
|
|
205
210
|
/**
|
|
206
211
|
* Fetches the conversation progress from the server
|
|
@@ -226,15 +231,28 @@ class Conversation {
|
|
|
226
231
|
return data.events;
|
|
227
232
|
}
|
|
228
233
|
// LIFE CYCLE
|
|
234
|
+
/**
|
|
235
|
+
* One stream's worth of delivery: rebuilds whole values, then hands them to the caller.
|
|
236
|
+
*
|
|
237
|
+
* The accumulator is made per stream rather than per conversation because it holds the
|
|
238
|
+
* text of whatever is still being written, and two streams are two different answers.
|
|
239
|
+
*/
|
|
240
|
+
receiver(cb) {
|
|
241
|
+
if (!this.accumulateStream)
|
|
242
|
+
return (payload) => cb(payload);
|
|
243
|
+
const accumulate = (0, stream_accumulator_1.createStreamAccumulator)();
|
|
244
|
+
return (payload) => cb(accumulate(payload));
|
|
245
|
+
}
|
|
229
246
|
/** Sends a message into the conversation */
|
|
230
247
|
send(message, cb, options) {
|
|
248
|
+
const receive = this.receiver(cb);
|
|
231
249
|
return this.transport.send({
|
|
232
250
|
message,
|
|
233
251
|
...this.options, // options set for convo
|
|
234
252
|
...options, // overwrite convo's for this call
|
|
235
253
|
...(this.convoId ? { chatId: this.convoId } : {}),
|
|
236
254
|
}, {
|
|
237
|
-
payload:
|
|
255
|
+
payload: receive,
|
|
238
256
|
convoId: (convoId) => {
|
|
239
257
|
if (this.convoId === convoId)
|
|
240
258
|
return;
|
|
@@ -262,10 +280,14 @@ class Conversation {
|
|
|
262
280
|
}
|
|
263
281
|
const response = chunk.data.response;
|
|
264
282
|
const metadata = chunk.data.response.metadata;
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
283
|
+
// Alfred's own notices are not part of the reply, so they are collected but
|
|
284
|
+
// not concatenated into it. Appending the piece and replacing on a whole
|
|
285
|
+
// value is right whether or not the caller left accumulation on.
|
|
286
|
+
if (response.type === "message" && metadata?.participantId !== "system") {
|
|
287
|
+
if (typeof response.payload?.delta === "string")
|
|
288
|
+
text += response.payload.delta;
|
|
289
|
+
else if (response.payload?.message)
|
|
290
|
+
text = response.payload.message;
|
|
269
291
|
}
|
|
270
292
|
if (chunk.data.quitStream)
|
|
271
293
|
resolve({ text, convoId: this.convoId, events });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STREAM ACCUMULATION
|
|
3
|
+
* ===================
|
|
4
|
+
*
|
|
5
|
+
* The server streams text a piece at a time.
|
|
6
|
+
*
|
|
7
|
+
* It used to send the whole message again on every token, which is O(N²) bytes for an
|
|
8
|
+
* N-token reply — slow everywhere, and fatal on a Link websocket, where the answer queues
|
|
9
|
+
* ahead of the connection's own heartbeat until this SDK closes it mid-sentence. A
|
|
10
|
+
* streamed value now arrives as one of two shapes, and never both:
|
|
11
|
+
*
|
|
12
|
+
* { messageId, message: "Good day to you", completed } // the whole value: replace
|
|
13
|
+
* { messageId, delta: " to you", completed: false } // what was added: append
|
|
14
|
+
*
|
|
15
|
+
* Deltas are also sent bare: no metadata block, since it is the same on every frame of a
|
|
16
|
+
* message and several times the size of the few characters a delta carries. The frame that
|
|
17
|
+
* opens a message brings it, and the one that finishes it brings it again.
|
|
18
|
+
*
|
|
19
|
+
* Callers should not have to care about any of that. This puts the message back together,
|
|
20
|
+
* so `payload.message` is the whole message so far exactly as it always was, restores the
|
|
21
|
+
* metadata onto every event, and keeps `payload.delta` for anyone who would rather append
|
|
22
|
+
* than re-render.
|
|
23
|
+
*
|
|
24
|
+
* Whole values arrive for the last event of a message and for anything replaying after a
|
|
25
|
+
* reconnect, and they replace rather than extend. That is what makes a reconnect cheap and
|
|
26
|
+
* a dropped delta harmless, and it is handled here so no caller has to know about it.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Rebuilds whole values from a stream of pieces.
|
|
30
|
+
*
|
|
31
|
+
* Stateful, and one per stream: it holds what every message the stream is still writing
|
|
32
|
+
* has said so far, so a turn and a progress stream never see each other's.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createStreamAccumulator(): (payload: unknown) => unknown;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* STREAM ACCUMULATION
|
|
4
|
+
* ===================
|
|
5
|
+
*
|
|
6
|
+
* The server streams text a piece at a time.
|
|
7
|
+
*
|
|
8
|
+
* It used to send the whole message again on every token, which is O(N²) bytes for an
|
|
9
|
+
* N-token reply — slow everywhere, and fatal on a Link websocket, where the answer queues
|
|
10
|
+
* ahead of the connection's own heartbeat until this SDK closes it mid-sentence. A
|
|
11
|
+
* streamed value now arrives as one of two shapes, and never both:
|
|
12
|
+
*
|
|
13
|
+
* { messageId, message: "Good day to you", completed } // the whole value: replace
|
|
14
|
+
* { messageId, delta: " to you", completed: false } // what was added: append
|
|
15
|
+
*
|
|
16
|
+
* Deltas are also sent bare: no metadata block, since it is the same on every frame of a
|
|
17
|
+
* message and several times the size of the few characters a delta carries. The frame that
|
|
18
|
+
* opens a message brings it, and the one that finishes it brings it again.
|
|
19
|
+
*
|
|
20
|
+
* Callers should not have to care about any of that. This puts the message back together,
|
|
21
|
+
* so `payload.message` is the whole message so far exactly as it always was, restores the
|
|
22
|
+
* metadata onto every event, and keeps `payload.delta` for anyone who would rather append
|
|
23
|
+
* than re-render.
|
|
24
|
+
*
|
|
25
|
+
* Whole values arrive for the last event of a message and for anything replaying after a
|
|
26
|
+
* reconnect, and they replace rather than extend. That is what makes a reconnect cheap and
|
|
27
|
+
* a dropped delta harmless, and it is handled here so no caller has to know about it.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.createStreamAccumulator = createStreamAccumulator;
|
|
31
|
+
/** Which field of a payload holds the streamed text, and what identifies it. */
|
|
32
|
+
const STREAMED_FIELDS = {
|
|
33
|
+
message: { id: "messageId", text: "message" },
|
|
34
|
+
reasoning: { id: "reasoningId", text: "reasoning" },
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Rebuilds whole values from a stream of pieces.
|
|
38
|
+
*
|
|
39
|
+
* Stateful, and one per stream: it holds what every message the stream is still writing
|
|
40
|
+
* has said so far, so a turn and a progress stream never see each other's.
|
|
41
|
+
*/
|
|
42
|
+
function createStreamAccumulator() {
|
|
43
|
+
/** Text so far and the metadata it was opened with, per streamed id. */
|
|
44
|
+
const values = new Map();
|
|
45
|
+
return (payload) => {
|
|
46
|
+
const response = payload;
|
|
47
|
+
const event = response?.data?.response;
|
|
48
|
+
const fields = event?.type ? STREAMED_FIELDS[event.type] : undefined;
|
|
49
|
+
if (!event?.payload || !fields)
|
|
50
|
+
return payload;
|
|
51
|
+
const id = event.payload[fields.id];
|
|
52
|
+
if (typeof id !== "string")
|
|
53
|
+
return payload;
|
|
54
|
+
// Which field is there is the whole discriminant — never `completed`, which says
|
|
55
|
+
// nothing about the shape: a whole value arrives incomplete whenever this client is
|
|
56
|
+
// being caught up in the middle of a message.
|
|
57
|
+
const { delta } = event.payload;
|
|
58
|
+
const whole = event.payload[fields.text];
|
|
59
|
+
if (typeof delta !== "string" && typeof whole !== "string")
|
|
60
|
+
return payload;
|
|
61
|
+
const held = values.get(id);
|
|
62
|
+
const text = typeof delta === "string" ? (held?.text ?? "") + delta : whole;
|
|
63
|
+
// Deltas are sent without metadata, because it is identical on every frame of a
|
|
64
|
+
// message and many times the size of the text. The frame that opened the message
|
|
65
|
+
// carried it, so it is remembered here and handed back on every event — a caller
|
|
66
|
+
// sees it throughout, exactly as when the server repeated it a thousand times.
|
|
67
|
+
const metadata = event.metadata ?? held?.metadata;
|
|
68
|
+
// A finished value is the last anyone will hear of that id. Holding it would only
|
|
69
|
+
// leak, and ids are reused across the steps of a turn.
|
|
70
|
+
if (event.payload.completed)
|
|
71
|
+
values.delete(id);
|
|
72
|
+
else
|
|
73
|
+
values.set(id, { text, metadata });
|
|
74
|
+
return {
|
|
75
|
+
...response,
|
|
76
|
+
data: {
|
|
77
|
+
...response.data,
|
|
78
|
+
response: {
|
|
79
|
+
...event,
|
|
80
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
81
|
+
payload: {
|
|
82
|
+
...event.payload,
|
|
83
|
+
[fields.text]: text,
|
|
84
|
+
// Kept as it came: present means this event appended, absent means it
|
|
85
|
+
// replaced. Callers rendering incrementally read exactly this.
|
|
86
|
+
...(typeof delta === "string" ? { delta } : {}),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -144,20 +144,36 @@ export type ResponseMetadata = {
|
|
|
144
144
|
};
|
|
145
145
|
};
|
|
146
146
|
export type MessagePayload = {
|
|
147
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* The message content, whole: everything written so far.
|
|
149
|
+
*
|
|
150
|
+
* The server streams a message a piece at a time; this is the SDK putting it back
|
|
151
|
+
* together, so it reads the same as it always has. With `accumulateStream: false` you
|
|
152
|
+
* get the wire payloads instead, where a piece arrives as `delta` and this is absent.
|
|
153
|
+
*/
|
|
148
154
|
message: string;
|
|
149
155
|
/** The UUID of this message */
|
|
150
156
|
messageId: string;
|
|
151
157
|
/** Whether this message chunk is the final one */
|
|
152
158
|
completed: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* What this event added to the message, when it added anything.
|
|
161
|
+
*
|
|
162
|
+
* Append this instead of re-rendering `message` and you never redraw text you already
|
|
163
|
+
* have. Absent when the whole value arrived at once — the last event of a message, and
|
|
164
|
+
* whatever catches you up after a reconnect — which replaces rather than extends.
|
|
165
|
+
*/
|
|
166
|
+
delta?: string;
|
|
153
167
|
};
|
|
154
168
|
export type ReasoningPayload = {
|
|
155
|
-
/** The reasoning content */
|
|
169
|
+
/** The reasoning content, whole. See {@link MessagePayload.message}. */
|
|
156
170
|
reasoning: string;
|
|
157
171
|
/** The UUID of this reasoning */
|
|
158
172
|
reasoningId: string;
|
|
159
173
|
/** Whether this reasoning chunk is the final one */
|
|
160
174
|
completed: boolean;
|
|
175
|
+
/** What this event added. See {@link MessagePayload.delta}. */
|
|
176
|
+
delta?: string;
|
|
161
177
|
};
|
|
162
178
|
export type FilePayload = {
|
|
163
179
|
/** The file URL */
|
|
@@ -55,20 +55,36 @@ export type BaseResponseMetadata = {
|
|
|
55
55
|
participantId?: string;
|
|
56
56
|
};
|
|
57
57
|
export type MessagePayload = {
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The message content, whole: everything written so far.
|
|
60
|
+
*
|
|
61
|
+
* The server streams a message a piece at a time; this is the SDK putting it back
|
|
62
|
+
* together, so it reads the same as it always has. With `accumulateStream: false` you
|
|
63
|
+
* get the wire payloads instead, where a piece arrives as `delta` and this is absent.
|
|
64
|
+
*/
|
|
59
65
|
message: string;
|
|
60
66
|
/** The UUID of this message */
|
|
61
67
|
messageId: string;
|
|
62
68
|
/** Whether this message chunk is the final one */
|
|
63
69
|
completed: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* What this event added to the message, when it added anything.
|
|
72
|
+
*
|
|
73
|
+
* Append this instead of re-rendering `message` and you never redraw text you already
|
|
74
|
+
* have. Absent when the whole value arrived at once — the last event of a message, and
|
|
75
|
+
* whatever catches you up after a reconnect — which replaces rather than extends.
|
|
76
|
+
*/
|
|
77
|
+
delta?: string;
|
|
64
78
|
};
|
|
65
79
|
export type ReasoningPayload = {
|
|
66
|
-
/** The reasoning content */
|
|
80
|
+
/** The reasoning content, whole. See {@link MessagePayload.message}. */
|
|
67
81
|
reasoning: string;
|
|
68
82
|
/** The UUID of this reasoning */
|
|
69
83
|
reasoningId: string;
|
|
70
84
|
/** Whether this reasoning chunk is the final one */
|
|
71
85
|
completed: boolean;
|
|
86
|
+
/** What this event added. See {@link MessagePayload.delta}. */
|
|
87
|
+
delta?: string;
|
|
72
88
|
};
|
|
73
89
|
export type FilePayload = {
|
|
74
90
|
/** The file URL */
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -39,6 +39,43 @@ Or, when you only want the answer:
|
|
|
39
39
|
const { text } = await convo.ask("Hey there Alfred!");
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
### Streaming
|
|
43
|
+
|
|
44
|
+
Alfred streams a reply as it writes it. A message opens with a whole value, is extended a
|
|
45
|
+
piece at a time, and finishes whole again. No event carries both:
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{ "messageId": "m1", "message": "Good day", "completed": false, "metadata": {...} }
|
|
49
|
+
{ "messageId": "m1", "delta": " to you", "completed": false }
|
|
50
|
+
{ "messageId": "m1", "message": "Good day to you", "completed": true, "metadata": {...} }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Deltas travel bare — no metadata block, since it is identical on every frame of a message
|
|
54
|
+
and many times the size of the few characters a delta carries. The SDK remembers it from
|
|
55
|
+
the frame that opened the message and puts it back, so **every event you receive has both
|
|
56
|
+
the whole message and its metadata**, exactly as it always did:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
convo.send("Tell me a story", (res) => {
|
|
60
|
+
if (!res.success || res.data.response.type !== "message") return;
|
|
61
|
+
|
|
62
|
+
const { message, delta } = res.data.response.payload;
|
|
63
|
+
// message — everything written so far
|
|
64
|
+
// delta — just what this event added, when it added anything
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Render `message` and you need do nothing else. Render `delta` and you never re-draw text
|
|
69
|
+
you already have, which for a long reply is the difference between a smooth stream and a
|
|
70
|
+
stuttering one — append it when it is there, and replace with `message` when it is not.
|
|
71
|
+
`delta` is absent in two cases: the last event of a message, and whatever catches you up
|
|
72
|
+
after a reconnect. Do not read `completed` to tell the two apart — a whole message arrives
|
|
73
|
+
with `completed: false` whenever you are being caught up mid-answer.
|
|
74
|
+
|
|
75
|
+
`accumulateStream: false` hands you the wire payloads untouched: deltas with no `message`
|
|
76
|
+
beside them and no metadata. Only worth it if you are appending anyway and want nothing
|
|
77
|
+
between you and the socket.
|
|
78
|
+
|
|
42
79
|
## Link
|
|
43
80
|
|
|
44
81
|
A Link is a live connection to Alfred. It does three things:
|