Skip to content

Commit 781fdac

Browse files
authored
Merge pull request #143 from get-convex/ian/ui-stream-chunks
Stream UIMessage chunks
2 parents 8e5fe23 + 9672010 commit 781fdac

33 files changed

+2064
-1450
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 0.2.8 alpha
4+
5+
- Adds useUIMessages and useUIStreamingMessages for UIMessage-first client
6+
usage. These have more metadata around reasoning streaming status, and
7+
provide support for metadata and custom data parts.
8+
- Uses the UIMessageChunk stream when streaming text, which fixes many bugs
9+
which stemmed from the AI SDK's onChunk callback.
10+
- Enables having streaming hooks skip streamIds so you can stream via HTTP
11+
for some clients and have others get the deltas.
12+
- Compresses the deltas saved to the DB - combining text deltas & reasoning
13+
deltas that come from the same part `id`.
14+
- `optimisticallySendMessage` will set fields for both MessageDoc and
15+
UIMessage, so it should transparently work for either hook strategy, though
16+
you may see more fields than you expect.
17+
318
## 0.2.7
419

520
- Updates types to match the latest `ai` package types - note: you should update

example/convex/_generated/api.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2559,6 +2559,7 @@ export declare const components: {
25592559
"internal",
25602560
{
25612561
agentName?: string;
2562+
format?: "UIMessageChunk" | "TextStreamPart";
25622563
model?: string;
25632564
order: number;
25642565
provider?: string;
@@ -2623,6 +2624,7 @@ export declare const components: {
26232624
},
26242625
Array<{
26252626
agentName?: string;
2627+
format?: "UIMessageChunk" | "TextStreamPart";
26262628
model?: string;
26272629
order: number;
26282630
provider?: string;

example/convex/chat/streaming.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createThread,
55
listMessages,
66
syncStreams,
7+
toUIMessages,
78
vStreamArgs,
89
} from "@convex-dev/agent";
910
import { components, internal } from "../_generated/api";
@@ -111,6 +112,7 @@ export const listThreadMessages = query({
111112
return {
112113
...paginated,
113114
streams,
115+
page: toUIMessages(paginated.page),
114116

115117
// ... you can return other metadata here too.
116118
// note: this function will be called with various permutations of delta

example/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

example/ui/chat/ChatStreaming.tsx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import { Toaster } from "../components/ui/toaster";
33
import { api } from "../../convex/_generated/api";
44
import {
55
optimisticallySendMessage,
6-
toUIMessages,
76
useSmoothText,
8-
useThreadMessages,
7+
useUIMessages,
98
type UIMessage,
109
} from "@convex-dev/agent/react";
1110
import { useCallback, useEffect, useRef, useState } from "react";
@@ -68,11 +67,27 @@ export default function ChatStreaming() {
6867
}
6968

7069
function Story({ threadId, reset }: { threadId: string; reset: () => void }) {
71-
const messages = useThreadMessages(
70+
const { results: messages } = useUIMessages(
7271
api.chat.streaming.listThreadMessages,
7372
{ threadId },
7473
{ initialNumItems: 10, stream: true },
7574
);
75+
76+
// If you don't want to use UIMessages, you can use this hook:
77+
// (note: you'll need to return MessageDoc from listThreadMessages)
78+
// const { results } = useThreadMessages(
79+
// api.chat.streaming.listThreadMessages,
80+
// { threadId },
81+
// { initialNumItems: 10, stream: true },
82+
// );
83+
// const messages = toUIMessages(results ?? []);
84+
85+
// If you only want to show streaming messages, you can use this hook:
86+
// const messages =
87+
// useStreamingUIMessages(api.chat.streaming.listThreadMessages, {
88+
// threadId,
89+
// paginationOpts: { numItems: 10, cursor: null },
90+
// }) ?? [];
7691
const sendMessage = useMutation(
7792
api.chat.streaming.initiateAsyncStreaming,
7893
).withOptimisticUpdate(
@@ -90,22 +105,22 @@ function Story({ threadId, reset }: { threadId: string; reset: () => void }) {
90105

91106
useEffect(() => {
92107
scrollToBottom();
93-
}, [messages.results]);
108+
}, [messages]);
94109

95110
function onSendClicked() {
96111
if (prompt.trim() === "") return;
97112
void sendMessage({ threadId, prompt }).catch(() => setPrompt(prompt));
98-
setPrompt("");
113+
setPrompt("Continue the story...");
99114
}
100115

101116
return (
102117
<>
103118
<div className="h-full flex flex-col max-w-4xl mx-auto w-full">
104119
{/* Messages area - scrollable */}
105120
<div className="flex-1 overflow-y-auto p-6">
106-
{messages.results?.length > 0 ? (
121+
{messages.length > 0 ? (
107122
<div className="flex flex-col gap-4 whitespace-pre">
108-
{toUIMessages(messages.results ?? []).map((m) => (
123+
{messages.map((m) => (
109124
<Message key={m.key} message={m} />
110125
))}
111126
<div ref={messagesEndRef} />
@@ -132,17 +147,17 @@ function Story({ threadId, reset }: { threadId: string; reset: () => void }) {
132147
onChange={(e) => setPrompt(e.target.value)}
133148
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-gray-50"
134149
placeholder={
135-
messages.results?.length > 0
150+
messages.length > 0
136151
? "Continue the story..."
137152
: "Tell me a story..."
138153
}
139154
/>
140-
{messages.results.find((m) => m.streaming) ? (
155+
{messages.find((m) => m.status === "streaming") ? (
141156
<button
142157
className="px-4 py-2 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition font-medium self-end"
143158
onClick={() => {
144159
const order =
145-
messages.results.find((m) => m.streaming)?.order ?? 0;
160+
messages.find((m) => m.status === "streaming")?.order ?? 0;
146161
void abortStreamByOrder({ threadId, order });
147162
}}
148163
type="button"
@@ -158,7 +173,7 @@ function Story({ threadId, reset }: { threadId: string; reset: () => void }) {
158173
Send
159174
</button>
160175
)}
161-
{messages.results?.length > 0 && (
176+
{messages.length > 0 && (
162177
<button
163178
className="px-4 py-2 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition font-medium self-end"
164179
onClick={() => {
@@ -187,6 +202,15 @@ function Message({ message }: { message: UIMessage }) {
187202
// wouldn't start streaming until the second chunk was received.
188203
startStreaming: message.status === "streaming",
189204
});
205+
const [reasoningText] = useSmoothText(
206+
message.parts
207+
.filter((p) => p.type === "reasoning")
208+
.map((p) => p.text)
209+
.join("\n") ?? "",
210+
{
211+
startStreaming: message.status === "streaming",
212+
},
213+
);
190214
return (
191215
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
192216
<div
@@ -199,6 +223,9 @@ function Message({ message }: { message: UIMessage }) {
199223
},
200224
)}
201225
>
226+
{reasoningText && (
227+
<div className="text-xs text-gray-500">💭{reasoningText}</div>
228+
)}
202229
{visibleText || "..."}
203230
</div>
204231
</div>

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"email": "support@convex.dev",
88
"url": "https://github.com/get-convex/agent/issues"
99
},
10-
"version": "0.2.7",
10+
"version": "0.2.8-alpha.0",
1111
"license": "Apache-2.0",
1212
"keywords": [
1313
"convex",

playground/.eslintignore

Lines changed: 0 additions & 1 deletion
This file was deleted.

playground/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
55
import tseslint from "typescript-eslint";
66

77
export default tseslint.config(
8-
{ ignores: ["dist"] },
8+
{ ignores: ["dist", "bin/agent-playground.js"] },
99
{
1010
extends: [js.configs.recommended, ...tseslint.configs.recommended],
1111
files: ["**/*.{ts,tsx}"],

src/react/toUIMessages.ts renamed to src/UIMessages.ts

Lines changed: 132 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1-
import type {
2-
UIMessage as AIUIMessage,
3-
DeepPartial,
4-
ReasoningUIPart,
5-
SourceDocumentUIPart,
6-
SourceUrlUIPart,
7-
StepStartUIPart,
8-
TextUIPart,
9-
ToolUIPart,
10-
UIDataTypes,
11-
UITools,
1+
import {
2+
convertToModelMessages,
3+
type UIMessage as AIUIMessage,
4+
type DeepPartial,
5+
type ReasoningUIPart,
6+
type SourceDocumentUIPart,
7+
type SourceUrlUIPart,
8+
type StepStartUIPart,
9+
type TextUIPart,
10+
type ToolUIPart,
11+
type UIDataTypes,
12+
type UITools,
1213
} from "ai";
13-
import { extractText, type MessageDoc } from "../client/index.js";
14-
import { deserializeMessage, toUIFilePart } from "../mapping.js";
15-
import type { MessageStatus, SourcePart, vSource } from "../validators.js";
16-
import { sorted } from "../shared.js";
1714
import type { Infer } from "convex/values";
15+
import {
16+
deserializeMessage,
17+
fromModelMessage,
18+
toUIFilePart,
19+
} from "./mapping.js";
20+
import { extractReasoning, extractText, isTool, sorted } from "./shared.js";
21+
import type {
22+
MessageDoc,
23+
MessageStatus,
24+
ProviderOptions,
25+
SourcePart,
26+
vSource,
27+
} from "./validators.js";
28+
import { omit, pick } from "convex-helpers";
1829

1930
export type UIMessage<
2031
METADATA = unknown,
@@ -30,11 +41,117 @@ export type UIMessage<
3041
_creationTime: number;
3142
};
3243

44+
/**
45+
* Converts a list of UIMessages to MessageDocs, along with extra metadata that
46+
* may be available to associate with the MessageDocs.
47+
* @param messages - The UIMessages to convert to MessageDocs.
48+
* @param meta - The metadata to add to the MessageDocs.
49+
* @returns
50+
*/
51+
export function fromUIMessages<METADATA = unknown>(
52+
messages: UIMessage<METADATA>[],
53+
meta: {
54+
threadId: string;
55+
userId?: string;
56+
model?: string;
57+
provider?: string;
58+
providerOptions?: ProviderOptions;
59+
metadata?: METADATA;
60+
},
61+
): (MessageDoc & { streaming: boolean; metadata?: METADATA })[] {
62+
return messages.flatMap((uiMessage) => {
63+
const stepOrder = uiMessage.stepOrder;
64+
const commonFields = {
65+
...pick(meta, [
66+
"threadId",
67+
"userId",
68+
"model",
69+
"provider",
70+
"providerOptions",
71+
"metadata",
72+
]),
73+
...omit(uiMessage, ["parts", "role", "key", "text"]),
74+
status: uiMessage.status === "streaming" ? "pending" : "success",
75+
streaming: uiMessage.status === "streaming",
76+
// to override
77+
_id: uiMessage.id,
78+
tool: false,
79+
} satisfies MessageDoc & { streaming: boolean; metadata?: METADATA };
80+
const modelMessages = convertToModelMessages([uiMessage]);
81+
return modelMessages
82+
.map((modelMessage, i) => {
83+
if (modelMessage.content.length === 0) {
84+
return undefined;
85+
}
86+
const message = fromModelMessage(modelMessage);
87+
const tool = isTool(message);
88+
const doc: MessageDoc & { streaming: boolean; metadata?: METADATA } = {
89+
...commonFields,
90+
_id: uiMessage.id + `-${i}`,
91+
stepOrder: stepOrder + i,
92+
message,
93+
tool,
94+
text: extractText(message),
95+
reasoning: extractReasoning(message),
96+
finishReason: tool ? "tool-calls" : "stop",
97+
sources: fromSourceParts(uiMessage.parts),
98+
};
99+
if (Array.isArray(modelMessage.content)) {
100+
const providerOptions = modelMessage.content.find(
101+
(c) => c.providerOptions,
102+
)?.providerOptions;
103+
if (providerOptions) {
104+
// convertToModelMessages changes providerMetadata to providerOptions
105+
doc.providerMetadata = providerOptions;
106+
doc.providerOptions ??= providerOptions;
107+
}
108+
}
109+
return doc;
110+
})
111+
.filter((d) => d !== undefined);
112+
});
113+
}
114+
115+
function fromSourceParts(parts: UIMessage["parts"]): Infer<typeof vSource>[] {
116+
return parts
117+
.map((part) => {
118+
if (part.type === "source-url") {
119+
return {
120+
type: "source",
121+
sourceType: "url",
122+
url: part.url,
123+
id: part.sourceId,
124+
providerMetadata: part.providerMetadata,
125+
title: part.title,
126+
} satisfies Infer<typeof vSource>;
127+
}
128+
if (part.type === "source-document") {
129+
return {
130+
type: "source",
131+
sourceType: "document",
132+
mediaType: part.mediaType,
133+
id: part.sourceId,
134+
providerMetadata: part.providerMetadata,
135+
title: part.title,
136+
} satisfies Infer<typeof vSource>;
137+
}
138+
return undefined;
139+
})
140+
.filter((p) => p !== undefined);
141+
}
142+
33143
type ExtraFields<METADATA = unknown> = {
34144
streaming?: boolean;
35145
metadata?: METADATA;
36146
};
37147

148+
/**
149+
* Converts a list of MessageDocs to UIMessages.
150+
* This is somewhat lossy, as many fields are not supported by UIMessages, e.g.
151+
* the model, provider, userId, etc.
152+
* The UIMessage type is the augmented type that includes more fields such as
153+
* key, order, stepOrder, status, agentName, text, etc.
154+
*/
38155
export function toUIMessages<
39156
METADATA = unknown,
40157
DATA_PARTS extends UIDataTypes = UIDataTypes,

0 commit comments

Comments
 (0)