@convex-dev/agent 0.2.8-alpha.5 → 0.2.8

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.
Files changed (47) hide show
  1. package/dist/client/definePlaygroundAPI.d.ts +5 -5
  2. package/dist/client/index.d.ts +5 -5
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +2 -3
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/messages.d.ts +1 -1
  7. package/dist/client/search.d.ts +1 -1
  8. package/dist/client/streaming.d.ts +17 -12
  9. package/dist/client/streaming.d.ts.map +1 -1
  10. package/dist/client/streaming.js +49 -2
  11. package/dist/client/streaming.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +8 -8
  13. package/dist/component/messages.d.ts +11 -11
  14. package/dist/component/schema.d.ts +20 -20
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +1 -0
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/deltas.d.ts +2 -6
  19. package/dist/deltas.d.ts.map +1 -1
  20. package/dist/deltas.js +52 -49
  21. package/dist/deltas.js.map +1 -1
  22. package/dist/react/useDeltaStreams.d.ts +10 -0
  23. package/dist/react/useDeltaStreams.d.ts.map +1 -0
  24. package/dist/react/useDeltaStreams.js +101 -0
  25. package/dist/react/useDeltaStreams.js.map +1 -0
  26. package/dist/react/useStreamingUIMessages.d.ts.map +1 -1
  27. package/dist/react/useStreamingUIMessages.js +66 -181
  28. package/dist/react/useStreamingUIMessages.js.map +1 -1
  29. package/dist/react/useUIMessages.d.ts.map +1 -1
  30. package/dist/react/useUIMessages.js +4 -1
  31. package/dist/react/useUIMessages.js.map +1 -1
  32. package/dist/validators.d.ts +78 -78
  33. package/dist/validators.js +1 -1
  34. package/dist/validators.js.map +1 -1
  35. package/package.json +7 -5
  36. package/src/client/index.ts +2 -1
  37. package/src/client/streaming.test.ts +2 -2
  38. package/src/client/streaming.ts +58 -5
  39. package/src/component/_generated/api.d.ts +8 -8
  40. package/src/component/streams.ts +1 -0
  41. package/src/deltas.test.ts +262 -0
  42. package/src/deltas.ts +62 -54
  43. package/src/react/useDeltaStreams.ts +155 -0
  44. package/src/react/useStreamingUIMessages.ts +94 -251
  45. package/src/react/useUIMessages.ts +5 -2
  46. package/src/validators.ts +1 -1
  47. package/dist/package.json +0 -3
package/src/deltas.ts CHANGED
@@ -18,57 +18,6 @@ import {
18
18
  type StreamMessage,
19
19
  } from "./validators.js";
20
20
 
21
- /**
22
- * Compressing parts when streaming to save bandwidth in deltas.
23
- */
24
-
25
- export function compressUIMessageChunks(
26
- parts: UIMessageChunk[],
27
- ): UIMessageChunk[] {
28
- const compressed: UIMessageChunk[] = [];
29
- for (const part of parts) {
30
- const last = compressed.at(-1);
31
- if (part.type === "text-delta" || part.type === "reasoning-delta") {
32
- if (last?.type === part.type && part.id === last.id) {
33
- last.delta += part.delta;
34
- } else {
35
- compressed.push(part);
36
- }
37
- } else {
38
- compressed.push(part);
39
- }
40
- }
41
- return compressed;
42
- }
43
-
44
- export function compressTextStreamParts(
45
- parts: TextStreamPart<ToolSet>[],
46
- ): TextStreamPart<ToolSet>[] {
47
- const compressed: TextStreamPart<ToolSet>[] = [];
48
- for (const part of parts) {
49
- const last = compressed.at(-1);
50
- if (part.type === "text-delta" || part.type === "reasoning-delta") {
51
- if (last?.type === part.type && part.id === last.id) {
52
- last.text += part.text;
53
- } else {
54
- compressed.push(part);
55
- }
56
- } else {
57
- if (part.type === "file") {
58
- compressed.push({
59
- type: "file",
60
- file: {
61
- ...part.file,
62
- uint8Array: undefined as unknown as Uint8Array,
63
- },
64
- });
65
- }
66
- compressed.push(part);
67
- }
68
- }
69
- return compressed;
70
- }
71
-
72
21
  export function blankUIMessage<METADATA = unknown>(
73
22
  streamMessage: StreamMessage & { metadata?: METADATA },
74
23
  threadId: string,
@@ -238,9 +187,6 @@ export function getParts<T extends StreamDelta["parts"][number]>(
238
187
  }
239
188
  if (cursor !== delta.start) {
240
189
  if (cursor >= delta.end) {
241
- console.debug(
242
- `Got duplicate delta for stream ${delta.streamId} at ${delta.start}`,
243
- );
244
190
  continue;
245
191
  } else if (cursor < delta.start) {
246
192
  console.warn(
@@ -591,3 +537,65 @@ function mergeProviderMetadata(
591
537
  }
592
538
  return merged;
593
539
  }
540
+
541
+ export function combineUIMessages(messages: UIMessage[]): UIMessage[] {
542
+ const combined = messages.reduce((acc, message) => {
543
+ if (!acc.length) {
544
+ return [message];
545
+ }
546
+ const previous = acc.at(-1)!;
547
+ if (previous.role !== message.role) {
548
+ acc.push(message);
549
+ return acc;
550
+ }
551
+ // We will replace it with a combined message
552
+ acc.pop();
553
+ const newParts = [...previous.parts];
554
+ for (const part of message.parts) {
555
+ const toolCallId = getToolCallId(part);
556
+ if (!toolCallId) {
557
+ newParts.push(part);
558
+ continue;
559
+ }
560
+ const previousPartIndex = newParts.findIndex(
561
+ (p) => getToolCallId(p) === toolCallId,
562
+ );
563
+ const previousPart = newParts.splice(previousPartIndex, 1)[0];
564
+ if (!previousPart) {
565
+ newParts.push(part);
566
+ continue;
567
+ }
568
+ newParts.push(mergeParts(previousPart, part));
569
+ }
570
+ acc.push({
571
+ ...previous,
572
+ ...pick(message, ["status", "metadata", "agentName"]),
573
+ parts: newParts,
574
+ text: newParts
575
+ .filter((p) => p.type === "text")
576
+ .map((p) => p.text)
577
+ .join(""),
578
+ });
579
+ return acc;
580
+ }, [] as UIMessage[]);
581
+ return combined;
582
+ }
583
+
584
+ function getToolCallId(
585
+ part: UIMessage["parts"][number] & { toolCallId?: string },
586
+ ) {
587
+ return part.toolCallId;
588
+ }
589
+
590
+ function mergeParts(
591
+ previousPart: UIMessage["parts"][number],
592
+ part: UIMessage["parts"][number],
593
+ ): UIMessage["parts"][number] {
594
+ const merged: Record<string, unknown> = { ...previousPart };
595
+ for (const [key, value] of Object.entries(part)) {
596
+ if (value !== undefined) {
597
+ merged[key] = value;
598
+ }
599
+ }
600
+ return merged as ToolUIPart | DynamicToolUIPart;
601
+ }
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import type { StreamQuery, StreamQueryArgs } from "./types.js";
4
+ import type { SyncStreamsReturnValue } from "../client/types.js";
5
+ import type { FunctionArgs } from "convex/server";
6
+ import type { StreamArgs, StreamDelta, StreamMessage } from "../validators.js";
7
+ import { sorted } from "../shared.js";
8
+ import { useQuery } from "convex/react";
9
+ import { useState } from "react";
10
+ import { assert } from "convex-helpers";
11
+
12
+ export function useDeltaStreams<
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ Query extends StreamQuery<any> = StreamQuery<object>,
15
+ >(
16
+ query: Query,
17
+ args: StreamQueryArgs<Query> | "skip",
18
+ options?: {
19
+ startOrder?: number;
20
+ skipStreamIds?: string[];
21
+ },
22
+ ): { streamMessage: StreamMessage; deltas: StreamDelta[] }[] | undefined {
23
+ // We hold onto and modify state directly to avoid re-running unnecessarily.
24
+ const [state] = useState<{
25
+ startOrder: number;
26
+ threadId: string | undefined;
27
+ deltaStreams:
28
+ | Array<{
29
+ streamMessage: StreamMessage;
30
+ deltas: StreamDelta[];
31
+ }>
32
+ | undefined;
33
+ }>({
34
+ startOrder: options?.startOrder ?? 0,
35
+ deltaStreams: undefined,
36
+ threadId: args === "skip" ? undefined : args.threadId,
37
+ });
38
+ const [cursors, setCursors] = useState<Record<string, number>>({});
39
+ if (args !== "skip" && state.threadId !== args.threadId) {
40
+ state.threadId = args.threadId;
41
+ state.deltaStreams = undefined;
42
+ state.startOrder = options?.startOrder ?? 0;
43
+ setCursors({});
44
+ }
45
+ if (
46
+ state.deltaStreams?.length ||
47
+ (options?.startOrder && options.startOrder < state.startOrder)
48
+ ) {
49
+ const cacheFriendlyStartOrder = options?.startOrder
50
+ ? // round down to the nearest 10 for some cache benefits
51
+ options.startOrder - (options.startOrder % 10)
52
+ : 0;
53
+ if (cacheFriendlyStartOrder !== state.startOrder) {
54
+ state.startOrder = cacheFriendlyStartOrder;
55
+ }
56
+ }
57
+
58
+ // Get all the active streams
59
+ const streamList = useQuery(
60
+ query,
61
+ args === "skip"
62
+ ? args
63
+ : ({
64
+ ...args,
65
+ streamArgs: {
66
+ kind: "list",
67
+ startOrder: state.startOrder,
68
+ } as StreamArgs,
69
+ } as FunctionArgs<Query>),
70
+ ) as
71
+ | { streams: Extract<SyncStreamsReturnValue, { kind: "list" }> }
72
+ | undefined;
73
+
74
+ const streamMessages =
75
+ args === "skip"
76
+ ? undefined
77
+ : !streamList
78
+ ? state.deltaStreams?.map(({ streamMessage }) => streamMessage)
79
+ : sorted(
80
+ streamList.streams.messages.filter(
81
+ ({ streamId, order }) =>
82
+ !options?.skipStreamIds?.includes(streamId) &&
83
+ (!options?.startOrder || order >= options.startOrder),
84
+ ),
85
+ );
86
+
87
+ // Get the deltas for all the active streams, if any.
88
+ const cursorQuery = useQuery(
89
+ query,
90
+ args === "skip" || !streamMessages?.length
91
+ ? ("skip" as const)
92
+ : ({
93
+ ...args,
94
+ streamArgs: {
95
+ kind: "deltas",
96
+ cursors: streamMessages.map(({ streamId }) => ({
97
+ streamId,
98
+ cursor: cursors[streamId] ?? 0,
99
+ })),
100
+ } as StreamArgs,
101
+ } as FunctionArgs<Query>),
102
+ ) as
103
+ | { streams: Extract<SyncStreamsReturnValue, { kind: "deltas" }> }
104
+ | undefined;
105
+
106
+ const newDeltas = cursorQuery?.streams.deltas;
107
+ if (newDeltas?.length && streamMessages) {
108
+ const newDeltasByStreamId = new Map<string, StreamDelta[]>();
109
+ for (const delta of newDeltas) {
110
+ const oldCursor = cursors[delta.streamId];
111
+ if (oldCursor && delta.start < oldCursor) continue;
112
+ const existing = newDeltasByStreamId.get(delta.streamId);
113
+ if (existing) {
114
+ const previousEnd = existing.at(-1)!.end;
115
+ assert(
116
+ previousEnd === delta.start,
117
+ `Gap found in deltas for ${delta.streamId} jumping to ${delta.start} from ${previousEnd}`,
118
+ );
119
+ existing.push(delta);
120
+ } else {
121
+ assert(
122
+ !oldCursor || oldCursor === delta.start,
123
+ `Gap found - first delta after ${oldCursor} is ${delta.start} for stream ${delta.streamId}`,
124
+ );
125
+ newDeltasByStreamId.set(delta.streamId, [delta]);
126
+ }
127
+ }
128
+ const newCursors: Record<string, number> = {};
129
+ for (const { streamId } of streamMessages) {
130
+ const cursor =
131
+ newDeltasByStreamId.get(streamId)?.at(-1)?.end ?? cursors[streamId];
132
+ if (cursor !== undefined) {
133
+ newCursors[streamId] = cursor;
134
+ }
135
+ }
136
+ setCursors(newCursors);
137
+
138
+ // we defensively create a new object so object identity matches contents
139
+ state.deltaStreams = streamMessages.map((streamMessage) => {
140
+ const streamId = streamMessage.streamId;
141
+ const old = state.deltaStreams?.find(
142
+ (ds) => ds.streamMessage.streamId === streamId,
143
+ );
144
+ const newDeltas = newDeltasByStreamId.get(streamId);
145
+ if (!newDeltas && streamMessage === old?.streamMessage) {
146
+ return old;
147
+ }
148
+ return {
149
+ streamMessage,
150
+ deltas: [...(old?.deltas ?? []), ...(newDeltas ?? [])],
151
+ };
152
+ });
153
+ }
154
+ return state.deltaStreams;
155
+ }
@@ -1,26 +1,24 @@
1
1
  "use client";
2
- import { omit } from "convex-helpers";
3
- import { useQuery } from "convex/react";
4
- import type { FunctionArgs } from "convex/server";
5
- import { useMemo, useState, useEffect, useRef } from "react";
6
- import {
7
- readUIMessageStream,
8
- type TextUIPart,
9
- type UIDataTypes,
10
- type UIMessageChunk,
11
- type UITools,
12
- } from "ai";
13
- import type { SyncStreamsReturnValue } from "../client/types.js";
14
- import type { StreamArgs } from "../validators.js";
2
+ import { useMemo, useState, useEffect } from "react";
3
+ import { type UIDataTypes, type UIMessageChunk, type UITools } from "ai";
15
4
  import type { StreamQuery, StreamQueryArgs } from "./types.js";
16
5
  import { type UIMessage } from "../UIMessages.js";
17
6
  import {
18
7
  blankUIMessage,
19
8
  getParts,
9
+ updateFromUIMessageChunks,
20
10
  deriveUIMessagesFromTextStreamParts,
21
- statusFromStreamStatus,
22
11
  } from "../deltas.js";
23
- import { sorted } from "../shared.js";
12
+ import { useDeltaStreams } from "./useDeltaStreams.js";
13
+
14
+ // Polyfill structuredClone to support readUIMessageStream on ReactNative
15
+ if (!("structuredClone" in globalThis)) {
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ void import("@ungap/structured-clone" as any).then(
18
+ ({ default: structuredClone }) =>
19
+ (globalThis.structuredClone = structuredClone),
20
+ );
21
+ }
24
22
 
25
23
  /**
26
24
  * A hook that fetches streaming messages from a thread and converts them to UIMessages
@@ -51,252 +49,97 @@ export function useStreamingUIMessages<
51
49
  },
52
50
  // TODO: make generic on metadata, etc.
53
51
  ): UIMessage<METADATA, DATA_PARTS, TOOLS>[] | undefined {
54
- const [uiMessages, setUIMessages] = useState<
55
- Record<string, UIMessage<METADATA, DATA_PARTS, TOOLS>>
52
+ const [messageState, setMessageState] = useState<
53
+ Record<
54
+ string,
55
+ {
56
+ uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
57
+ cursor: number;
58
+ }
59
+ >
56
60
  >({});
57
- const [streamCursors, setStreamCursors] = useState<Record<string, number>>(
58
- {},
59
- );
60
- const uiMessageStreamControllers = useRef<
61
- Map<string, ReadableStreamDefaultController<UIMessageChunk>>
62
- >(new Map());
63
-
64
- const startOrder = options?.startOrder
65
- ? // round down to the nearest 10 for some cache benefits
66
- options.startOrder - (options.startOrder % 10)
67
- : 0;
68
61
 
69
- // Get all the active streams
70
- const streamList = useQuery(
71
- query,
72
- args === "skip"
73
- ? args
74
- : ({
75
- ...args,
76
- streamArgs: {
77
- kind: "list",
78
- startOrder,
79
- } as StreamArgs,
80
- } as FunctionArgs<Query>),
81
- ) as
82
- | { streams: Extract<SyncStreamsReturnValue, { kind: "list" }> }
83
- | undefined;
84
-
85
- const minOrder = Math.max(
86
- options?.startOrder ?? 0,
87
- streamList?.streams.messages.length
88
- ? Math.min(...streamList.streams.messages.map(({ order }) => order))
89
- : 0,
90
- );
91
-
92
- // Get the cursors for all the active streams
93
- const cursors = useMemo(() => {
94
- if (!streamList?.streams) return [];
95
- if (streamList.streams.kind !== "list") {
96
- throw new Error("Expected list streams");
97
- }
98
- return streamList.streams.messages
99
- .filter(({ streamId }) => !options?.skipStreamIds?.includes(streamId))
100
- .filter(({ order }) => order >= minOrder)
101
- .map(({ streamId }) => {
102
- const cursor = streamCursors[streamId] ?? 0;
103
- return { streamId, cursor };
104
- });
105
- }, [streamList, streamCursors, options?.skipStreamIds, minOrder]);
106
-
107
- // Get the deltas for all the active streams, if any.
108
- const cursorQuery = useQuery(
109
- query,
110
- args === "skip" || !streamList
111
- ? ("skip" as const)
112
- : ({
113
- ...args,
114
- streamArgs: { kind: "deltas", cursors } as StreamArgs,
115
- } as FunctionArgs<Query>),
116
- ) as
117
- | { streams: Extract<SyncStreamsReturnValue, { kind: "deltas" }> }
118
- | undefined;
62
+ const streams = useDeltaStreams(query, args, options);
119
63
 
120
64
  const threadId = args === "skip" ? undefined : args.threadId;
121
65
 
122
66
  useEffect(() => {
123
- const activeStreamIds = new Set(
124
- streamList?.streams.messages.map((m) => m.streamId) ?? [],
125
- );
126
- // Clean up finished streams
127
- setUIMessages((prev) => {
128
- const toRemove = Object.keys(prev).filter(
129
- (streamId) => !activeStreamIds.has(streamId),
130
- );
131
- return toRemove.length ? omit(prev, toRemove) : prev;
132
- });
133
- for (const [
134
- streamId,
135
- controller,
136
- ] of uiMessageStreamControllers.current.entries()) {
137
- if (!activeStreamIds.has(streamId)) {
138
- uiMessageStreamControllers.current.delete(streamId);
139
- controller.close();
67
+ if (!streams) return;
68
+ // return if there are no new deltas beyond the cursors
69
+ let noNewDeltas = true;
70
+ for (const stream of streams) {
71
+ const lastDelta = stream.deltas.at(-1);
72
+ const cursor = messageState[stream.streamMessage.streamId]?.cursor;
73
+ if (!cursor) {
74
+ noNewDeltas = false;
75
+ break;
140
76
  }
141
- }
142
- if (!cursorQuery?.streams?.deltas || !threadId) return;
143
-
144
- const deltasByStream = new Map<string, typeof cursorQuery.streams.deltas>();
145
-
146
- // Group deltas by streamId
147
- for (const delta of cursorQuery.streams.deltas) {
148
- if (!deltasByStream.has(delta.streamId)) {
149
- deltasByStream.set(delta.streamId, []);
77
+ if (lastDelta && lastDelta.start >= cursor) {
78
+ noNewDeltas = false;
79
+ break;
150
80
  }
151
- deltasByStream.get(delta.streamId)!.push(delta);
152
81
  }
153
-
154
- // Process each stream's deltas
155
- for (const [streamId, deltas] of deltasByStream.entries()) {
156
- const streamMessage = streamList?.streams?.messages.find(
157
- (m) => m.streamId === streamId,
158
- );
159
- if (!streamMessage) continue;
160
- if (streamMessage.format === "UIMessageChunk") {
161
- setStreamCursors((streamCursors) => {
162
- const currentCursor = streamCursors[streamId] ?? 0;
163
-
164
- const { parts, cursor } = getParts<UIMessageChunk>(
165
- deltas,
166
- currentCursor,
167
- );
168
-
169
- const newStreamCursors =
170
- cursor === currentCursor
171
- ? streamCursors
172
- : { ...streamCursors, [streamId]: cursor };
173
-
174
- if (parts.length === 0) return newStreamCursors;
175
-
176
- // Get existing message for this stream as starting point
177
- const existingStream =
178
- uiMessageStreamControllers.current.get(streamId);
179
- if (existingStream) {
180
- for (const part of parts) {
181
- existingStream.enqueue(part);
182
- }
183
- } else {
184
- const stream = new ReadableStream<UIMessageChunk>({
185
- start(controller) {
186
- uiMessageStreamControllers.current.set(streamId, controller);
187
- for (const part of parts) {
188
- controller.enqueue(part);
189
- }
190
- },
191
- });
192
- const initialMessage = blankUIMessage(streamMessage, threadId);
193
- setUIMessages((prev) => ({
194
- ...prev,
195
- [streamId]: initialMessage as UIMessage<
196
- METADATA,
197
- DATA_PARTS,
198
- TOOLS
199
- >,
200
- }));
201
-
202
- const messageStream = readUIMessageStream({
203
- message: initialMessage,
204
- stream,
205
- onError: (error) => {
206
- setUIMessages((prev) => ({
207
- ...prev,
208
- [streamId]: {
209
- ...prev[streamId],
210
- status: "failed",
211
- },
212
- }));
213
- console.error(`Error in stream ${streamId}:`, error);
214
- },
215
- terminateOnError: false,
216
- });
217
-
218
- // Process the async iterator
219
- void (async () => {
220
- for await (const message of messageStream) {
221
- message.text = message.parts
222
- .filter((p) => p.type === "text")
223
- .map((p) => p.text)
224
- .join("");
225
- setUIMessages((prev) =>
226
- // If we don't have a ui message assume we've aborted.
227
- prev[streamId]
228
- ? {
229
- ...prev,
230
- [streamId]: message as UIMessage<
231
- METADATA,
232
- DATA_PARTS,
233
- TOOLS
234
- >,
235
- }
236
- : prev,
237
- );
238
- }
239
- })().catch((error) => {
240
- console.error(`Error processing stream ${streamId}:`, error);
241
- });
242
- }
243
- return newStreamCursors;
244
- });
245
- } else {
246
- setUIMessages((uiMessages) => {
247
- const existingUIMessage = uiMessages[streamId];
248
- const [[uiMessage], [streamMetadata], changed] =
249
- deriveUIMessagesFromTextStreamParts(
250
- threadId,
251
- [streamMessage],
252
- existingUIMessage
253
- ? [
254
- {
255
- streamId,
256
- cursor: streamCursors[streamId] ?? 0,
257
- message: existingUIMessage,
258
- },
259
- ]
260
- : [],
261
- deltas,
262
- );
263
- if (changed) {
264
- setStreamCursors((prev) => ({
265
- ...prev,
266
- [streamId]: streamMetadata.cursor,
267
- }));
268
- return {
269
- ...uiMessages,
270
- [streamId]: uiMessage as UIMessage<METADATA, DATA_PARTS, TOOLS>,
271
- };
272
- }
273
- return uiMessages;
274
- });
275
- }
82
+ if (noNewDeltas) {
83
+ return;
276
84
  }
277
- }, [cursorQuery, streamCursors, threadId, streamList?.streams?.messages]);
85
+ const abortController = new AbortController();
86
+ void (async () => {
87
+ const newMessageState: Record<
88
+ string,
89
+ {
90
+ uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
91
+ cursor: number;
92
+ }
93
+ > = Object.fromEntries(
94
+ await Promise.all(
95
+ streams.map(async ({ deltas, streamMessage }) => {
96
+ const { parts, cursor } = getParts<UIMessageChunk>(deltas, 0);
97
+ if (streamMessage.format === "UIMessageChunk") {
98
+ // Unfortunately this can't handle resuming from a UIMessage and
99
+ // adding more chunks, so we re-create it from scratch each time.
100
+ const uiMessage = await updateFromUIMessageChunks(
101
+ blankUIMessage(streamMessage, threadId),
102
+ parts,
103
+ );
104
+ return [
105
+ streamMessage.streamId,
106
+ {
107
+ uiMessage,
108
+ cursor,
109
+ },
110
+ ];
111
+ } else {
112
+ const [uiMessages] = deriveUIMessagesFromTextStreamParts(
113
+ threadId,
114
+ [streamMessage],
115
+ [],
116
+ deltas,
117
+ );
118
+ return [
119
+ streamMessage.streamId,
120
+ {
121
+ uiMessage: uiMessages[0],
122
+ cursor,
123
+ },
124
+ ];
125
+ }
126
+ }),
127
+ ),
128
+ );
129
+ if (abortController.signal.aborted) return;
130
+ setMessageState(newMessageState);
131
+ })();
132
+ return () => {
133
+ abortController.abort();
134
+ };
135
+ }, [messageState, streams, threadId]);
278
136
 
279
137
  return useMemo(() => {
280
- if (!streamList) return undefined;
281
- return sorted(
282
- Array.from(
283
- Object.entries(uiMessages)
284
- .map(([streamId, uiMessage]) => {
285
- const streamMessage = streamList?.streams.messages.find(
286
- (m) => m.streamId === streamId,
287
- );
288
- if (!streamMessage) return undefined;
289
- uiMessage.status = statusFromStreamStatus(
290
- streamMessage?.status ?? "finished",
291
- );
292
- uiMessage.text = uiMessage.parts
293
- .filter((p): p is TextUIPart => p.type === "text")
294
- .map((p) => p.text)
295
- .join("");
296
- return uiMessage;
297
- })
298
- .filter((uiMessage) => uiMessage !== undefined),
299
- ),
300
- );
301
- }, [uiMessages, streamList]);
138
+ if (!streams) return undefined;
139
+ return streams
140
+ .map(
141
+ ({ streamMessage }) => messageState[streamMessage.streamId]?.uiMessage,
142
+ )
143
+ .filter((uiMessage) => uiMessage !== undefined);
144
+ }, [messageState, streams]);
302
145
  }
@@ -17,11 +17,12 @@ import type {
17
17
  } from "convex/server";
18
18
  import { useMemo } from "react";
19
19
  import type { SyncStreamsReturnValue } from "../client/types.js";
20
- import type { MessageStatus, StreamArgs } from "../validators.js";
20
+ import type { StreamArgs } from "../validators.js";
21
21
  import type { StreamQuery } from "./types.js";
22
22
  import { type UIMessage, type UIStatus } from "../UIMessages.js";
23
23
  import { sorted } from "../shared.js";
24
24
  import { useStreamingUIMessages } from "./useStreamingUIMessages.js";
25
+ import { combineUIMessages } from "../deltas.js";
25
26
 
26
27
  export type UIMessageLike = {
27
28
  order: number;
@@ -155,9 +156,11 @@ export function useUIMessages<
155
156
  );
156
157
 
157
158
  const merged = useMemo(() => {
159
+ // Messages may have been split by pagination. Re-combine them here.
160
+ const combined = combineUIMessages(sorted(paginated.results));
158
161
  return {
159
162
  ...paginated,
160
- results: dedupeMessages(paginated.results, streamMessages ?? []),
163
+ results: dedupeMessages(combined, streamMessages ?? []),
161
164
  };
162
165
  }, [paginated, streamMessages]);
163
166
 
package/src/validators.ts CHANGED
@@ -93,7 +93,7 @@ export const vSourcePart = v.union(
93
93
  sourceType: v.literal("url"),
94
94
  id: v.string(),
95
95
  url: v.string(),
96
- title: v.string(),
96
+ title: v.optional(v.string()),
97
97
  providerOptions,
98
98
  providerMetadata,
99
99
  }),
package/dist/package.json DELETED
@@ -1,3 +0,0 @@
1
- {
2
- "type": "module"
3
- }