@ably/ai-transport 0.0.1
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/LICENSE +176 -0
- package/README.md +426 -0
- package/dist/ably-ai-transport.js +1388 -0
- package/dist/ably-ai-transport.js.map +1 -0
- package/dist/ably-ai-transport.umd.cjs +2 -0
- package/dist/ably-ai-transport.umd.cjs.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/core/codec/decoder.d.ts +62 -0
- package/dist/core/codec/encoder.d.ts +56 -0
- package/dist/core/codec/index.d.ts +8 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +74 -0
- package/dist/core/codec/types.d.ts +188 -0
- package/dist/core/transport/client-transport.d.ts +10 -0
- package/dist/core/transport/conversation-tree.d.ts +9 -0
- package/dist/core/transport/decode-history.d.ts +41 -0
- package/dist/core/transport/headers.d.ts +26 -0
- package/dist/core/transport/index.d.ts +4 -0
- package/dist/core/transport/pipe-stream.d.ts +16 -0
- package/dist/core/transport/server-transport.d.ts +7 -0
- package/dist/core/transport/stream-router.d.ts +19 -0
- package/dist/core/transport/turn-manager.d.ts +34 -0
- package/dist/core/transport/types.d.ts +407 -0
- package/dist/errors.d.ts +46 -0
- package/dist/event-emitter.d.ts +65 -0
- package/dist/index.d.ts +11 -0
- package/dist/logger.d.ts +103 -0
- package/dist/react/ably-ai-transport-react.js +823 -0
- package/dist/react/ably-ai-transport-react.js.map +1 -0
- package/dist/react/ably-ai-transport-react.umd.cjs +2 -0
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/use-ably-messages.d.ts +18 -0
- package/dist/react/use-active-turns.d.ts +8 -0
- package/dist/react/use-client-transport.d.ts +7 -0
- package/dist/react/use-conversation-tree.d.ts +20 -0
- package/dist/react/use-edit.d.ts +7 -0
- package/dist/react/use-history.d.ts +19 -0
- package/dist/react/use-messages.d.ts +7 -0
- package/dist/react/use-regenerate.d.ts +7 -0
- package/dist/react/use-send.d.ts +7 -0
- package/dist/utils.d.ts +127 -0
- package/dist/vercel/ably-ai-transport-vercel.js +2331 -0
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -0
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +2 -0
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -0
- package/dist/vercel/codec/accumulator.d.ts +21 -0
- package/dist/vercel/codec/decoder.d.ts +22 -0
- package/dist/vercel/codec/encoder.d.ts +41 -0
- package/dist/vercel/codec/index.d.ts +22 -0
- package/dist/vercel/index.d.ts +3 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2082 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +2 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -0
- package/dist/vercel/react/index.d.ts +3 -0
- package/dist/vercel/react/use-chat-transport.d.ts +29 -0
- package/dist/vercel/react/use-message-sync.d.ts +19 -0
- package/dist/vercel/transport/chat-transport.d.ts +118 -0
- package/dist/vercel/transport/index.d.ts +36 -0
- package/package.json +123 -0
- package/react/README.md +3 -0
- package/react/index.d.ts +1 -0
- package/react/index.js +1 -0
- package/react/index.umd.cjs +1 -0
- package/src/constants.ts +98 -0
- package/src/core/codec/decoder.ts +402 -0
- package/src/core/codec/encoder.ts +470 -0
- package/src/core/codec/index.ts +28 -0
- package/src/core/codec/lifecycle-tracker.ts +140 -0
- package/src/core/codec/types.ts +249 -0
- package/src/core/transport/client-transport.ts +959 -0
- package/src/core/transport/conversation-tree.ts +434 -0
- package/src/core/transport/decode-history.ts +337 -0
- package/src/core/transport/headers.ts +46 -0
- package/src/core/transport/index.ts +34 -0
- package/src/core/transport/pipe-stream.ts +95 -0
- package/src/core/transport/server-transport.ts +458 -0
- package/src/core/transport/stream-router.ts +118 -0
- package/src/core/transport/turn-manager.ts +147 -0
- package/src/core/transport/types.ts +533 -0
- package/src/errors.ts +58 -0
- package/src/event-emitter.ts +103 -0
- package/src/index.ts +89 -0
- package/src/logger.ts +241 -0
- package/src/react/index.ts +11 -0
- package/src/react/use-ably-messages.ts +37 -0
- package/src/react/use-active-turns.ts +61 -0
- package/src/react/use-client-transport.ts +37 -0
- package/src/react/use-conversation-tree.ts +71 -0
- package/src/react/use-edit.ts +24 -0
- package/src/react/use-history.ts +111 -0
- package/src/react/use-messages.ts +32 -0
- package/src/react/use-regenerate.ts +24 -0
- package/src/react/use-send.ts +25 -0
- package/src/react/vite.config.ts +32 -0
- package/src/tsconfig.json +25 -0
- package/src/utils.ts +230 -0
- package/src/vercel/codec/accumulator.ts +603 -0
- package/src/vercel/codec/decoder.ts +615 -0
- package/src/vercel/codec/encoder.ts +396 -0
- package/src/vercel/codec/index.ts +37 -0
- package/src/vercel/index.ts +12 -0
- package/src/vercel/react/index.ts +4 -0
- package/src/vercel/react/use-chat-transport.ts +60 -0
- package/src/vercel/react/use-message-sync.ts +34 -0
- package/src/vercel/react/vite.config.ts +33 -0
- package/src/vercel/transport/chat-transport.ts +278 -0
- package/src/vercel/transport/index.ts +56 -0
- package/src/vercel/vite.config.ts +33 -0
- package/src/vite.config.ts +31 -0
- package/vercel/README.md +3 -0
- package/vercel/index.d.ts +1 -0
- package/vercel/index.js +1 -0
- package/vercel/index.umd.cjs +1 -0
- package/vercel/react/README.md +3 -0
- package/vercel/react/index.d.ts +1 -0
- package/vercel/react/index.js +1 -0
- package/vercel/react/index.umd.cjs +1 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel AI SDK Decoder
|
|
3
|
+
*
|
|
4
|
+
* Maps Ably inbound messages to DecoderOutput<UIMessageChunk, UIMessage>[].
|
|
5
|
+
*
|
|
6
|
+
* Delegates action dispatch and serial tracking to the decoder core.
|
|
7
|
+
* This file contains only the Vercel-specific event building, discrete
|
|
8
|
+
* event decoding, and synthetic event emission.
|
|
9
|
+
*
|
|
10
|
+
* Domain-specific headers use the `x-domain-` prefix. Transport-level
|
|
11
|
+
* headers use the `x-ably-` prefix.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type * as Ably from 'ably';
|
|
15
|
+
import type * as AI from 'ai';
|
|
16
|
+
|
|
17
|
+
import { HEADER_ROLE, HEADER_TURN_ID } from '../../constants.js';
|
|
18
|
+
import type { DecoderCore, DecoderCoreHooks, DecoderCoreOptions } from '../../core/codec/decoder.js';
|
|
19
|
+
import { createDecoderCore, eventOutput } from '../../core/codec/decoder.js';
|
|
20
|
+
import type { LifecycleTracker } from '../../core/codec/lifecycle-tracker.js';
|
|
21
|
+
import { createLifecycleTracker } from '../../core/codec/lifecycle-tracker.js';
|
|
22
|
+
import type { DecoderOutput, MessagePayload, StreamDecoder, StreamTrackerState } from '../../core/codec/types.js';
|
|
23
|
+
import { type DomainHeaderReader, headerReader as rawHeaderReader, stripUndefined } from '../../utils.js';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Vercel-specific header reader (casts providerMetadata to AI.ProviderMetadata)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface VercelHeaderReader extends DomainHeaderReader {
|
|
30
|
+
/** Read the `providerMetadata` domain header, cast to the AI SDK type. */
|
|
31
|
+
providerMetadata(): AI.ProviderMetadata | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a header reader that adds Vercel-specific `providerMetadata` typing.
|
|
36
|
+
* @param headers - The raw headers record to read domain headers from.
|
|
37
|
+
* @returns A typed accessor with Vercel-specific providerMetadata typing.
|
|
38
|
+
*/
|
|
39
|
+
const headerReader = (headers: Record<string, string>): VercelHeaderReader => {
|
|
40
|
+
const base = rawHeaderReader(headers);
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
// CAST: Trust boundary — the encoder serialized a valid ProviderMetadata value.
|
|
44
|
+
providerMetadata: () => base.json('providerMetadata') as AI.ProviderMetadata | undefined,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Wire format types (trust boundaries for JSON-parsed data)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** Wire format for tool-input-error data payload. */
|
|
53
|
+
interface ToolInputErrorWireData {
|
|
54
|
+
errorText?: string;
|
|
55
|
+
input?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wire format for tool-output-available data payload. */
|
|
59
|
+
interface ToolOutputAvailableWireData {
|
|
60
|
+
output?: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wire format for tool-output-error data payload. */
|
|
64
|
+
interface ToolOutputErrorWireData {
|
|
65
|
+
errorText?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Shared output type alias
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
type Out = DecoderOutput<AI.UIMessageChunk, AI.UIMessage>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bind eventOutput to the Vercel domain types.
|
|
76
|
+
* @param chunk - The UIMessageChunk to wrap.
|
|
77
|
+
* @returns A single-element decoder output array.
|
|
78
|
+
*/
|
|
79
|
+
const event = (chunk: AI.UIMessageChunk): Out[] => eventOutput<AI.UIMessageChunk, AI.UIMessage>(chunk);
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// JSON boundary helpers
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate a finish reason string against the FinishReason union.
|
|
87
|
+
* @param value - The finish reason string from the wire, or undefined.
|
|
88
|
+
* @param fallback - Default finish reason if the value is not recognized.
|
|
89
|
+
* @returns The validated FinishReason.
|
|
90
|
+
*/
|
|
91
|
+
const parseFinishReason = (value: string | undefined, fallback: AI.FinishReason): AI.FinishReason => {
|
|
92
|
+
if (
|
|
93
|
+
value === 'stop' ||
|
|
94
|
+
value === 'length' ||
|
|
95
|
+
value === 'content-filter' ||
|
|
96
|
+
value === 'tool-calls' ||
|
|
97
|
+
value === 'error' ||
|
|
98
|
+
value === 'other'
|
|
99
|
+
) {
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
return fallback;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Type predicate for data-* message names.
|
|
107
|
+
* @param name - The message name to check.
|
|
108
|
+
* @returns True if the name starts with "data-".
|
|
109
|
+
*/
|
|
110
|
+
const isDataEventName = (name: string): name is `data-${string}` => name.startsWith('data-');
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse a string as JSON, returning the raw string if parsing fails
|
|
114
|
+
* or undefined if empty.
|
|
115
|
+
* @param value - The string to attempt JSON parsing on.
|
|
116
|
+
* @returns The parsed value, the raw string on parse failure, or undefined if empty.
|
|
117
|
+
*/
|
|
118
|
+
const parseJsonOrString = (value: string): unknown => {
|
|
119
|
+
if (!value) return undefined;
|
|
120
|
+
try {
|
|
121
|
+
// CAST: JSON.parse returns any; unknown is the safe trust-boundary type.
|
|
122
|
+
return JSON.parse(value) as unknown;
|
|
123
|
+
} catch {
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Streamed message event builders
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const buildStartChunk = (tracker: StreamTrackerState): AI.UIMessageChunk => {
|
|
133
|
+
const r = headerReader(tracker.headers);
|
|
134
|
+
switch (tracker.name) {
|
|
135
|
+
case 'text': {
|
|
136
|
+
return stripUndefined({
|
|
137
|
+
type: 'text-start' as const,
|
|
138
|
+
id: tracker.streamId,
|
|
139
|
+
providerMetadata: r.providerMetadata(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
case 'reasoning': {
|
|
143
|
+
return stripUndefined({
|
|
144
|
+
type: 'reasoning-start' as const,
|
|
145
|
+
id: tracker.streamId,
|
|
146
|
+
providerMetadata: r.providerMetadata(),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
case 'tool-input': {
|
|
150
|
+
return stripUndefined({
|
|
151
|
+
type: 'tool-input-start' as const,
|
|
152
|
+
toolCallId: tracker.streamId,
|
|
153
|
+
toolName: r.strOr('toolName', ''),
|
|
154
|
+
dynamic: r.bool('dynamic'),
|
|
155
|
+
title: r.str('title'),
|
|
156
|
+
providerExecuted: r.bool('providerExecuted'),
|
|
157
|
+
providerMetadata: r.providerMetadata(),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
default: {
|
|
161
|
+
return { type: 'text-start', id: tracker.streamId };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const buildDeltaChunk = (tracker: StreamTrackerState, delta: string): AI.UIMessageChunk => {
|
|
167
|
+
switch (tracker.name) {
|
|
168
|
+
case 'text': {
|
|
169
|
+
return { type: 'text-delta', id: tracker.streamId, delta };
|
|
170
|
+
}
|
|
171
|
+
case 'reasoning': {
|
|
172
|
+
return { type: 'reasoning-delta', id: tracker.streamId, delta };
|
|
173
|
+
}
|
|
174
|
+
case 'tool-input': {
|
|
175
|
+
return { type: 'tool-input-delta', toolCallId: tracker.streamId, inputTextDelta: delta };
|
|
176
|
+
}
|
|
177
|
+
default: {
|
|
178
|
+
return { type: 'text-delta', id: tracker.streamId, delta };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const buildEndChunk = (tracker: StreamTrackerState, closingHeaders: Record<string, string>): AI.UIMessageChunk => {
|
|
184
|
+
const r = headerReader(closingHeaders);
|
|
185
|
+
switch (tracker.name) {
|
|
186
|
+
case 'text': {
|
|
187
|
+
return stripUndefined({
|
|
188
|
+
type: 'text-end' as const,
|
|
189
|
+
id: tracker.streamId,
|
|
190
|
+
providerMetadata: r.providerMetadata(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
case 'reasoning': {
|
|
194
|
+
return stripUndefined({
|
|
195
|
+
type: 'reasoning-end' as const,
|
|
196
|
+
id: tracker.streamId,
|
|
197
|
+
providerMetadata: r.providerMetadata(),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
case 'tool-input': {
|
|
201
|
+
return stripUndefined({
|
|
202
|
+
type: 'tool-input-available' as const,
|
|
203
|
+
toolCallId: tracker.streamId,
|
|
204
|
+
toolName: r.strOr('toolName', headerReader(tracker.headers).strOr('toolName', '')),
|
|
205
|
+
input: parseJsonOrString(tracker.accumulated),
|
|
206
|
+
providerMetadata: r.providerMetadata(),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
default: {
|
|
210
|
+
return { type: 'text-end', id: tracker.streamId };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Lifecycle tracker configuration (synthetic event phases)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
const createVercelLifecycleTracker = (): LifecycleTracker<AI.UIMessageChunk> =>
|
|
220
|
+
createLifecycleTracker<AI.UIMessageChunk>([
|
|
221
|
+
{
|
|
222
|
+
key: 'start',
|
|
223
|
+
build: (ctx) => [stripUndefined({ type: 'start' as const, messageId: ctx.messageId })],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
key: 'start-step',
|
|
227
|
+
build: () => [{ type: 'start-step' as const }],
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run the lifecycle tracker and wrap results as DecoderOutput events.
|
|
233
|
+
* @param lifecycle - The lifecycle tracker instance.
|
|
234
|
+
* @param turnId - The turn scope ID.
|
|
235
|
+
* @param context - Context passed through to phase build functions.
|
|
236
|
+
* @returns Decoder outputs for any synthesized lifecycle events.
|
|
237
|
+
*/
|
|
238
|
+
const ensurePhases = (
|
|
239
|
+
lifecycle: LifecycleTracker<AI.UIMessageChunk>,
|
|
240
|
+
turnId: string,
|
|
241
|
+
context: Record<string, string | undefined>,
|
|
242
|
+
): Out[] => lifecycle.ensurePhases(turnId, context).map((e) => ({ kind: 'event', event: e }));
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Discrete event decoders (one function per event type)
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
const decodeStart = (r: VercelHeaderReader, turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
|
|
249
|
+
lifecycle.markEmitted(turnId, 'start');
|
|
250
|
+
return event(
|
|
251
|
+
stripUndefined({
|
|
252
|
+
type: 'start' as const,
|
|
253
|
+
messageId: r.str('messageId'),
|
|
254
|
+
messageMetadata: r.json('messageMetadata'),
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const decodeStartStep = (turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
|
|
260
|
+
lifecycle.markEmitted(turnId, 'start-step');
|
|
261
|
+
return event({ type: 'start-step' });
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const decodeFinishStep = (turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
|
|
265
|
+
lifecycle.resetPhase(turnId, 'start-step');
|
|
266
|
+
return event({ type: 'finish-step' });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const decodeFinish = (r: VercelHeaderReader, turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
|
|
270
|
+
lifecycle.clearScope(turnId);
|
|
271
|
+
return event(
|
|
272
|
+
stripUndefined({
|
|
273
|
+
type: 'finish' as const,
|
|
274
|
+
finishReason: parseFinishReason(r.str('finishReason'), 'stop'),
|
|
275
|
+
messageMetadata: r.json('messageMetadata'),
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const decodeError = (data: unknown): Out[] => {
|
|
281
|
+
const errorText = typeof data === 'string' ? data : '';
|
|
282
|
+
return event({ type: 'error', errorText });
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const decodeAbort = (data: unknown, turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
|
|
286
|
+
lifecycle.clearScope(turnId);
|
|
287
|
+
const reason = typeof data === 'string' && data ? data : undefined;
|
|
288
|
+
return event(stripUndefined({ type: 'abort' as const, reason }));
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const decodeMessageMetadata = (r: VercelHeaderReader): Out[] =>
|
|
292
|
+
event({ type: 'message-metadata', messageMetadata: r.json('messageMetadata') });
|
|
293
|
+
|
|
294
|
+
const decodeFile = (r: VercelHeaderReader, data: unknown): Out[] =>
|
|
295
|
+
event(
|
|
296
|
+
stripUndefined({
|
|
297
|
+
type: 'file' as const,
|
|
298
|
+
url: typeof data === 'string' ? data : '',
|
|
299
|
+
mediaType: r.strOr('mediaType', ''),
|
|
300
|
+
providerMetadata: r.providerMetadata(),
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const decodeSourceUrl = (r: VercelHeaderReader, data: unknown): Out[] =>
|
|
305
|
+
event(
|
|
306
|
+
stripUndefined({
|
|
307
|
+
type: 'source-url' as const,
|
|
308
|
+
sourceId: r.strOr('sourceId', ''),
|
|
309
|
+
url: typeof data === 'string' ? data : '',
|
|
310
|
+
title: r.str('title'),
|
|
311
|
+
providerMetadata: r.providerMetadata(),
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const decodeSourceDocument = (r: VercelHeaderReader): Out[] =>
|
|
316
|
+
event(
|
|
317
|
+
stripUndefined({
|
|
318
|
+
type: 'source-document' as const,
|
|
319
|
+
sourceId: r.strOr('sourceId', ''),
|
|
320
|
+
mediaType: r.strOr('mediaType', ''),
|
|
321
|
+
title: r.strOr('title', ''),
|
|
322
|
+
filename: r.str('filename'),
|
|
323
|
+
providerMetadata: r.providerMetadata(),
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const decodeToolInputError = (r: VercelHeaderReader, data: unknown): Out[] => {
|
|
328
|
+
// CAST: Trust boundary — encoder produced the expected object shape.
|
|
329
|
+
const parsed = data as ToolInputErrorWireData | undefined;
|
|
330
|
+
return event(
|
|
331
|
+
stripUndefined({
|
|
332
|
+
type: 'tool-input-error' as const,
|
|
333
|
+
toolCallId: r.strOr('toolCallId', ''),
|
|
334
|
+
toolName: r.strOr('toolName', ''),
|
|
335
|
+
errorText: parsed?.errorText ?? '',
|
|
336
|
+
input: parsed?.input,
|
|
337
|
+
dynamic: r.bool('dynamic'),
|
|
338
|
+
title: r.str('title'),
|
|
339
|
+
providerExecuted: r.bool('providerExecuted'),
|
|
340
|
+
providerMetadata: r.providerMetadata(),
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const decodeToolOutputAvailable = (r: VercelHeaderReader, data: unknown): Out[] => {
|
|
346
|
+
// CAST: Trust boundary — encoder produced the expected object shape.
|
|
347
|
+
const parsed = data as ToolOutputAvailableWireData | undefined;
|
|
348
|
+
return event(
|
|
349
|
+
stripUndefined({
|
|
350
|
+
type: 'tool-output-available' as const,
|
|
351
|
+
toolCallId: r.strOr('toolCallId', ''),
|
|
352
|
+
output: parsed?.output,
|
|
353
|
+
dynamic: r.bool('dynamic'),
|
|
354
|
+
providerExecuted: r.bool('providerExecuted'),
|
|
355
|
+
preliminary: r.bool('preliminary'),
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const decodeToolOutputError = (r: VercelHeaderReader, data: unknown): Out[] => {
|
|
361
|
+
// CAST: Trust boundary — encoder produced the expected object shape.
|
|
362
|
+
const parsed = data as ToolOutputErrorWireData | undefined;
|
|
363
|
+
return event(
|
|
364
|
+
stripUndefined({
|
|
365
|
+
type: 'tool-output-error' as const,
|
|
366
|
+
toolCallId: r.strOr('toolCallId', ''),
|
|
367
|
+
errorText: parsed?.errorText ?? '',
|
|
368
|
+
dynamic: r.bool('dynamic'),
|
|
369
|
+
providerExecuted: r.bool('providerExecuted'),
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const decodeToolApprovalRequest = (r: VercelHeaderReader): Out[] =>
|
|
375
|
+
event({
|
|
376
|
+
type: 'tool-approval-request',
|
|
377
|
+
toolCallId: r.strOr('toolCallId', ''),
|
|
378
|
+
approvalId: r.strOr('approvalId', ''),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const decodeToolOutputDenied = (r: VercelHeaderReader): Out[] =>
|
|
382
|
+
event({ type: 'tool-output-denied', toolCallId: r.strOr('toolCallId', '') });
|
|
383
|
+
|
|
384
|
+
const decodeDataEvent = (name: `data-${string}`, r: VercelHeaderReader, data: unknown): Out[] =>
|
|
385
|
+
event(
|
|
386
|
+
stripUndefined({
|
|
387
|
+
type: name,
|
|
388
|
+
data,
|
|
389
|
+
id: r.str('id'),
|
|
390
|
+
transient: r.bool('transient'),
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Non-streaming tool-input helper
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
const decodeNonStreamingToolInput = (
|
|
399
|
+
r: VercelHeaderReader,
|
|
400
|
+
data: unknown,
|
|
401
|
+
turnId: string,
|
|
402
|
+
lifecycle: LifecycleTracker<AI.UIMessageChunk>,
|
|
403
|
+
): Out[] => {
|
|
404
|
+
const outputs = ensurePhases(lifecycle, turnId, { messageId: r.str('messageId') });
|
|
405
|
+
|
|
406
|
+
outputs.push(
|
|
407
|
+
{
|
|
408
|
+
kind: 'event',
|
|
409
|
+
event: stripUndefined({
|
|
410
|
+
type: 'tool-input-start' as const,
|
|
411
|
+
toolCallId: r.strOr('toolCallId', ''),
|
|
412
|
+
toolName: r.strOr('toolName', ''),
|
|
413
|
+
dynamic: r.bool('dynamic'),
|
|
414
|
+
title: r.str('title'),
|
|
415
|
+
providerExecuted: r.bool('providerExecuted'),
|
|
416
|
+
providerMetadata: r.providerMetadata(),
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
kind: 'event',
|
|
421
|
+
event: stripUndefined({
|
|
422
|
+
type: 'tool-input-available' as const,
|
|
423
|
+
toolCallId: r.strOr('toolCallId', ''),
|
|
424
|
+
toolName: r.strOr('toolName', ''),
|
|
425
|
+
input: data,
|
|
426
|
+
providerMetadata: r.providerMetadata(),
|
|
427
|
+
}),
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
return outputs;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Discrete event dispatch
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Reconstruct a UIMessage from a discrete message part published by writeMessages.
|
|
440
|
+
* The encoder splits each UIMessage into per-part Ably messages with a shared
|
|
441
|
+
* x-domain-messageId. This function rebuilds a single-part UIMessage from one
|
|
442
|
+
* such Ably message. The transport's tree upsert merges parts that share the
|
|
443
|
+
* same x-ably-msg-id, so multi-part messages accumulate correctly over
|
|
444
|
+
* successive decoder calls.
|
|
445
|
+
* @param input - The discrete message payload to decode.
|
|
446
|
+
* @returns A single-element array with the reconstructed UIMessage, or empty if unrecognized.
|
|
447
|
+
*/
|
|
448
|
+
const decodeDiscreteMessage = (input: MessagePayload): Out[] => {
|
|
449
|
+
const h = input.headers ?? {};
|
|
450
|
+
const r = headerReader(h);
|
|
451
|
+
const role = (h[HEADER_ROLE] ?? 'user') as AI.UIMessage['role'];
|
|
452
|
+
const messageId = r.str('messageId') ?? '';
|
|
453
|
+
|
|
454
|
+
let part: AI.UIMessage['parts'][number] | undefined;
|
|
455
|
+
|
|
456
|
+
switch (input.name) {
|
|
457
|
+
case 'text': {
|
|
458
|
+
part = { type: 'text', text: typeof input.data === 'string' ? input.data : '' };
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
case 'file': {
|
|
462
|
+
part = {
|
|
463
|
+
type: 'file',
|
|
464
|
+
mediaType: r.strOr('mediaType', ''),
|
|
465
|
+
url: typeof input.data === 'string' ? input.data : '',
|
|
466
|
+
};
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
default: {
|
|
470
|
+
if (isDataEventName(input.name)) {
|
|
471
|
+
// CAST: data-* part type matches the DataUIPart shape.
|
|
472
|
+
part = stripUndefined({ type: input.name, id: r.str('id'), data: input.data }) as AI.UIMessage['parts'][number];
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!part) return [];
|
|
479
|
+
|
|
480
|
+
const message: AI.UIMessage = { id: messageId, role, parts: [part] };
|
|
481
|
+
return [{ kind: 'message', message }];
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Whether a message name represents a discrete message part (written by writeMessages)
|
|
486
|
+
* rather than a streaming lifecycle event. Discrete message parts carry x-ably-role
|
|
487
|
+
* and encode a single UIMessage part each.
|
|
488
|
+
* @param name - The Ably message name to check.
|
|
489
|
+
* @param headers - The Ably message headers to inspect for role presence.
|
|
490
|
+
* @returns True if this is a discrete message part, false if it's a lifecycle event.
|
|
491
|
+
*/
|
|
492
|
+
const isDiscreteMessagePart = (name: string, headers: Record<string, string>): boolean =>
|
|
493
|
+
(name === 'text' || name === 'file' || isDataEventName(name)) && HEADER_ROLE in headers;
|
|
494
|
+
|
|
495
|
+
const decodeDiscretePayload = (input: MessagePayload, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
|
|
496
|
+
const h = input.headers ?? {};
|
|
497
|
+
const r = headerReader(h);
|
|
498
|
+
const turnId = h[HEADER_TURN_ID] ?? '';
|
|
499
|
+
|
|
500
|
+
// Discrete message parts from writeMessages (user messages, history entries).
|
|
501
|
+
// Distinguished from lifecycle events by the presence of x-ably-role.
|
|
502
|
+
if (isDiscreteMessagePart(input.name, h)) {
|
|
503
|
+
return decodeDiscreteMessage(input);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (input.name === 'tool-input') {
|
|
507
|
+
return decodeNonStreamingToolInput(r, input.data, turnId, lifecycle);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
switch (input.name) {
|
|
511
|
+
case 'start': {
|
|
512
|
+
return decodeStart(r, turnId, lifecycle);
|
|
513
|
+
}
|
|
514
|
+
case 'start-step': {
|
|
515
|
+
return decodeStartStep(turnId, lifecycle);
|
|
516
|
+
}
|
|
517
|
+
case 'finish-step': {
|
|
518
|
+
return decodeFinishStep(turnId, lifecycle);
|
|
519
|
+
}
|
|
520
|
+
case 'finish': {
|
|
521
|
+
return decodeFinish(r, turnId, lifecycle);
|
|
522
|
+
}
|
|
523
|
+
case 'error': {
|
|
524
|
+
return decodeError(input.data);
|
|
525
|
+
}
|
|
526
|
+
case 'abort': {
|
|
527
|
+
return decodeAbort(input.data, turnId, lifecycle);
|
|
528
|
+
}
|
|
529
|
+
case 'message-metadata': {
|
|
530
|
+
return decodeMessageMetadata(r);
|
|
531
|
+
}
|
|
532
|
+
case 'file': {
|
|
533
|
+
return decodeFile(r, input.data);
|
|
534
|
+
}
|
|
535
|
+
case 'source-url': {
|
|
536
|
+
return decodeSourceUrl(r, input.data);
|
|
537
|
+
}
|
|
538
|
+
case 'source-document': {
|
|
539
|
+
return decodeSourceDocument(r);
|
|
540
|
+
}
|
|
541
|
+
case 'tool-input-error': {
|
|
542
|
+
return decodeToolInputError(r, input.data);
|
|
543
|
+
}
|
|
544
|
+
case 'tool-output-available': {
|
|
545
|
+
return decodeToolOutputAvailable(r, input.data);
|
|
546
|
+
}
|
|
547
|
+
case 'tool-output-error': {
|
|
548
|
+
return decodeToolOutputError(r, input.data);
|
|
549
|
+
}
|
|
550
|
+
case 'tool-approval-request': {
|
|
551
|
+
return decodeToolApprovalRequest(r);
|
|
552
|
+
}
|
|
553
|
+
case 'tool-output-denied': {
|
|
554
|
+
return decodeToolOutputDenied(r);
|
|
555
|
+
}
|
|
556
|
+
default: {
|
|
557
|
+
return isDataEventName(input.name) ? decodeDataEvent(input.name, r, input.data) : [];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Decoder core hooks
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
const createHooks = (
|
|
567
|
+
lifecycle: LifecycleTracker<AI.UIMessageChunk>,
|
|
568
|
+
): DecoderCoreHooks<AI.UIMessageChunk, AI.UIMessage> => ({
|
|
569
|
+
buildStartEvents: (tracker: StreamTrackerState): Out[] => {
|
|
570
|
+
const turnId = tracker.headers[HEADER_TURN_ID] ?? '';
|
|
571
|
+
const messageId = headerReader(tracker.headers).str('messageId');
|
|
572
|
+
const outputs = ensurePhases(lifecycle, turnId, { messageId });
|
|
573
|
+
outputs.push({ kind: 'event', event: buildStartChunk(tracker) });
|
|
574
|
+
return outputs;
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
buildDeltaEvents: (tracker: StreamTrackerState, delta: string): Out[] => event(buildDeltaChunk(tracker, delta)),
|
|
578
|
+
|
|
579
|
+
buildEndEvents: (tracker: StreamTrackerState, closingHeaders: Record<string, string>): Out[] =>
|
|
580
|
+
event(buildEndChunk(tracker, closingHeaders)),
|
|
581
|
+
|
|
582
|
+
decodeDiscrete: (payload: MessagePayload): Out[] => decodeDiscretePayload(payload, lifecycle),
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Default implementation
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
class DefaultUIMessageDecoder implements StreamDecoder<AI.UIMessageChunk, AI.UIMessage> {
|
|
590
|
+
private readonly _core: DecoderCore<AI.UIMessageChunk, AI.UIMessage>;
|
|
591
|
+
|
|
592
|
+
constructor(options: DecoderCoreOptions = {}) {
|
|
593
|
+
this._core = createDecoderCore<AI.UIMessageChunk, AI.UIMessage>(
|
|
594
|
+
createHooks(createVercelLifecycleTracker()),
|
|
595
|
+
options,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
decode(message: Ably.InboundMessage): Out[] {
|
|
600
|
+
return this._core.decode(message);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Factory
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Create a Vercel AI SDK decoder that maps Ably messages to UIMessageChunk
|
|
610
|
+
* events and UIMessage objects via the decoder core.
|
|
611
|
+
* @param options - Decoder configuration (callbacks, logger).
|
|
612
|
+
* @returns A {@link StreamDecoder} for UIMessageChunk/UIMessage.
|
|
613
|
+
*/
|
|
614
|
+
export const createDecoder = (options: DecoderCoreOptions = {}): StreamDecoder<AI.UIMessageChunk, AI.UIMessage> =>
|
|
615
|
+
new DefaultUIMessageDecoder(options);
|