Skip to content

Commit 405dcdc

Browse files
committed
demo streaming deltas by streaming an array
1 parent 72e499d commit 405dcdc

File tree

11 files changed

+431
-256
lines changed

11 files changed

+431
-256
lines changed

example/convex/_generated/api.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ import type * as chat_streaming from "../chat/streaming.js";
2020
import type * as chat_streamingReasoning from "../chat/streamingReasoning.js";
2121
import type * as crons from "../crons.js";
2222
import type * as debugging_rawRequestResponseHandler from "../debugging/rawRequestResponseHandler.js";
23-
import type * as etc_objects from "../etc/objects.js";
2423
import type * as files_addFile from "../files/addFile.js";
2524
import type * as files_autoSave from "../files/autoSave.js";
2625
import type * as files_generateImage from "../files/generateImage.js";
2726
import type * as files_vacuum from "../files/vacuum.js";
2827
import type * as http from "../http.js";
2928
import type * as modelsForDemo from "../modelsForDemo.js";
29+
import type * as objects_generateObject from "../objects/generateObject.js";
30+
import type * as objects_streamArray from "../objects/streamArray.js";
3031
import type * as playground from "../playground.js";
3132
import type * as rag_ragAsPrompt from "../rag/ragAsPrompt.js";
3233
import type * as rag_ragAsTools from "../rag/ragAsTools.js";
@@ -73,13 +74,14 @@ declare const fullApi: ApiFromModules<{
7374
"chat/streamingReasoning": typeof chat_streamingReasoning;
7475
crons: typeof crons;
7576
"debugging/rawRequestResponseHandler": typeof debugging_rawRequestResponseHandler;
76-
"etc/objects": typeof etc_objects;
7777
"files/addFile": typeof files_addFile;
7878
"files/autoSave": typeof files_autoSave;
7979
"files/generateImage": typeof files_generateImage;
8080
"files/vacuum": typeof files_vacuum;
8181
http: typeof http;
8282
modelsForDemo: typeof modelsForDemo;
83+
"objects/generateObject": typeof objects_generateObject;
84+
"objects/streamArray": typeof objects_streamArray;
8385
playground: typeof playground;
8486
"rag/ragAsPrompt": typeof rag_ragAsPrompt;
8587
"rag/ragAsTools": typeof rag_ragAsTools;

example/convex/etc/objects.ts renamed to example/convex/objects/generateObject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { action } from "../_generated/server.js";
2-
import { Agent, createThread, saveMessage } from "@convex-dev/agent";
2+
import { Agent, createThread } from "@convex-dev/agent";
33
import { components } from "../_generated/api.js";
44
import { defaultConfig } from "../agents/config.js";
55
import z from "zod/v4";
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { action, query } from "../_generated/server.js";
2+
import { vStreamArgs } from "@convex-dev/agent";
3+
import { components } from "../_generated/api.js";
4+
import { defaultConfig } from "../agents/config.js";
5+
import z from "zod/v4";
6+
import { v } from "convex/values";
7+
import { syncStreams, DeltaStreamer } from "@convex-dev/agent";
8+
import { streamObject } from "ai";
9+
import { authorizeThreadAccess } from "../threads.js";
10+
import { getAuthUserId } from "../utils.js";
11+
12+
export const streamArray = action({
13+
args: { threadId: v.string() },
14+
handler: async (ctx, { threadId }) => {
15+
const response = streamObject({
16+
model: defaultConfig.languageModel,
17+
prompt: "Make a list of items needed to host a birthday party",
18+
schema: z.object({
19+
name: z.string().describe("The name of the item"),
20+
quantity: z.number().describe("How many to bring"),
21+
}),
22+
output: "array",
23+
});
24+
const streamer = new DeltaStreamer(
25+
components.agent,
26+
ctx,
27+
{
28+
throttleMs: 100,
29+
onAsyncAbort: async () => console.error("Aborted asynchronously"),
30+
compress: null,
31+
abortSignal: undefined,
32+
},
33+
{
34+
threadId,
35+
format: undefined,
36+
order: 0, // we are only sending one message in this thread.
37+
stepOrder: 0, // we are only sending one message in this thread.
38+
userId: await getAuthUserId(ctx),
39+
},
40+
);
41+
await streamer.consumeStream(response.elementStream);
42+
return {
43+
streamId: streamer.streamId,
44+
object: await response.object,
45+
};
46+
},
47+
});
48+
49+
export const listDeltas = query({
50+
args: {
51+
threadId: v.string(),
52+
streamArgs: vStreamArgs,
53+
},
54+
handler: async (ctx, args) => {
55+
await authorizeThreadAccess(ctx, args.threadId);
56+
const streams = await syncStreams(ctx, components.agent, {
57+
...args,
58+
includeStatuses: ["streaming", "aborted", "finished"],
59+
});
60+
return { streams };
61+
},
62+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useMutation } from "convex/react";
2+
import { useCallback, useEffect, useState } from "react";
3+
import { api } from "../../convex/_generated/api";
4+
5+
export function useDemoThread(title: string) {
6+
const createThread = useMutation(api.threads.createNewThread);
7+
const [threadId, setThreadId] = useState<string | undefined>(
8+
typeof window !== "undefined" ? getThreadIdFromHash() : undefined,
9+
);
10+
11+
// Listen for hash changes
12+
useEffect(() => {
13+
function onHashChange() {
14+
setThreadId(getThreadIdFromHash());
15+
}
16+
window.addEventListener("hashchange", onHashChange);
17+
return () => window.removeEventListener("hashchange", onHashChange);
18+
}, []);
19+
20+
const resetThread = useCallback(() => {
21+
return createThread({
22+
title,
23+
}).then((newId) => {
24+
window.location.hash = newId;
25+
setThreadId(newId);
26+
});
27+
}, [createThread, title]);
28+
29+
// On mount or when threadId changes, if no threadId, create one and set hash
30+
useEffect(() => {
31+
if (!threadId) {
32+
void resetThread();
33+
}
34+
}, [resetThread, threadId]);
35+
36+
return { threadId, resetThread };
37+
}
38+
39+
function getThreadIdFromHash() {
40+
return window.location.hash.replace(/^#/, "") || undefined;
41+
}

example/ui/main.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function App() {
4040
<Route path="/rag-basic" element={<RagBasic />} />
4141
<Route path="/rate-limiting" element={<RateLimiting />} />
4242
<Route path="/weather-fashion" element={<WeatherFashion />} />
43+
<Route path="/stream-array" element={<StreamArray />} />
4344
</Routes>
4445
</main>
4546
<Toaster />

example/ui/objects/StreamArray.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useState } from "react";
2+
import { useDeltaStreams } from "../../../src/react/useDeltaStreams";
3+
import { api } from "../../convex/_generated/api";
4+
import { Toaster } from "../components/ui/toaster";
5+
import { useDemoThread } from "@/hooks/use-demo-thread";
6+
import { useAction } from "convex/react";
7+
8+
export default function StreamArray() {
9+
const { threadId, resetThread } = useDemoThread("Streaming Objects Example");
10+
const [used, setUsed] = useState(false);
11+
12+
const generateList = useAction(api.objects.streamArray.streamArray);
13+
14+
const messages = useDeltaStreams(
15+
api.objects.streamArray.listDeltas,
16+
threadId ? { threadId } : "skip",
17+
);
18+
19+
return (
20+
<>
21+
<header className="sticky top-0 h-16 z-10 bg-white/80 backdrop-blur-sm p-4 flex justify-between items-center border-b">
22+
<h1 className="text-xl font-semibold accent-text">
23+
Streaming Array Example
24+
</h1>
25+
{threadId}
26+
</header>
27+
<h2 className="text-center text-xl text-gray-500">
28+
What might you bring to a birthday party?
29+
</h2>
30+
<div className="h-[calc(100vh-8rem)] flex flex-col bg-gray-50">
31+
{used ? (
32+
<div>
33+
<button
34+
className="bg-blue-500 text-white p-2 rounded "
35+
onClick={() =>
36+
void resetThread()
37+
.then(() => setUsed(false))
38+
.catch(console.error)
39+
}
40+
>
41+
Reset
42+
</button>
43+
{messages?.map((message) => (
44+
<div key={message.streamMessage.streamId}>
45+
{message.streamMessage.streamId}
46+
{message.deltas
47+
.flatMap(({ parts }) => parts)
48+
.map((part: { name: string; quantity: number }, i) => (
49+
<div key={message.streamMessage.streamId + "-" + i}>
50+
{part.name}: {part.quantity}
51+
</div>
52+
))}
53+
</div>
54+
))}
55+
</div>
56+
) : (
57+
<div className="flex justify-center items-center h-full">
58+
<button
59+
className="bg-blue-500 text-white p-2 rounded "
60+
disabled={!threadId}
61+
onClick={() => {
62+
setUsed(true);
63+
void generateList({ threadId: threadId! }).catch(console.error);
64+
}}
65+
>
66+
Generate List
67+
</button>
68+
</div>
69+
)}
70+
<Toaster />
71+
</div>
72+
</>
73+
);
74+
}

src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export {
161161
} from "./search.js";
162162
export {
163163
DEFAULT_STREAMING_OPTIONS,
164+
DeltaStreamer,
164165
abortStream,
165166
listStreams,
166167
syncStreams,

src/component/streams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export const create = mutation({
9191
returns: v.id("streamingMessages"),
9292
handler: async (ctx, args) => {
9393
const state = { kind: "streaming" as const, lastHeartbeat: Date.now() };
94+
// TODO: enforce order/stepOrder uniqueness?
9495
const streamId = await ctx.db.insert("streamingMessages", {
9596
...args,
9697
state,

src/react/useDeltaStreams.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"use client";
2+
3+
import type { StreamQuery, StreamQueryArgs } from "./types.js";
4+
import type { FunctionArgs } from "convex/server";
5+
import type { StreamArgs, StreamDelta, StreamMessage } from "../validators.js";
6+
import type { SyncStreamsReturnValue } from "@convex-dev/agent";
7+
import { useQuery } from "convex/react";
8+
import { useState } from "react";
9+
import { assert } from "convex-helpers";
10+
11+
export function useDeltaStreams<
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
Query extends StreamQuery<any> = StreamQuery<object>,
14+
>(
15+
query: Query,
16+
args: StreamQueryArgs<Query> | "skip",
17+
options?: {
18+
startOrder?: number;
19+
skipStreamIds?: string[];
20+
},
21+
): { streamMessage: StreamMessage; deltas: StreamDelta[] }[] | undefined {
22+
// We hold onto and modify state directly to avoid re-running unnecessarily.
23+
const [state] = useState<{
24+
startOrder: number;
25+
threadId: string | undefined;
26+
deltaStreams:
27+
| Array<{
28+
streamMessage: StreamMessage;
29+
deltas: StreamDelta[];
30+
}>
31+
| undefined;
32+
}>({
33+
startOrder: options?.startOrder ?? 0,
34+
deltaStreams: undefined,
35+
threadId: args === "skip" ? undefined : args.threadId,
36+
});
37+
const [cursors, setCursors] = useState<Record<string, number>>({});
38+
if (args !== "skip" && state.threadId !== args.threadId) {
39+
state.threadId = args.threadId;
40+
state.deltaStreams = undefined;
41+
state.startOrder = options?.startOrder ?? 0;
42+
setCursors({});
43+
}
44+
if (
45+
state.deltaStreams?.length ||
46+
(options?.startOrder && options.startOrder < state.startOrder)
47+
) {
48+
const cacheFriendlyStartOrder = options?.startOrder
49+
? // round down to the nearest 10 for some cache benefits
50+
options.startOrder - (options.startOrder % 10)
51+
: 0;
52+
if (cacheFriendlyStartOrder !== state.startOrder) {
53+
state.startOrder = cacheFriendlyStartOrder;
54+
}
55+
}
56+
57+
// Get all the active streams
58+
const streamList = useQuery(
59+
query,
60+
args === "skip"
61+
? args
62+
: ({
63+
...args,
64+
streamArgs: {
65+
kind: "list",
66+
startOrder: state.startOrder,
67+
} as StreamArgs,
68+
} as FunctionArgs<Query>),
69+
) as
70+
| { streams: Extract<SyncStreamsReturnValue, { kind: "list" }> }
71+
| undefined;
72+
73+
const streamMessages =
74+
args === "skip"
75+
? undefined
76+
: !streamList
77+
? state.deltaStreams?.map(({ streamMessage }) => streamMessage)
78+
: streamList.streams.messages.filter(
79+
({ streamId, order }) =>
80+
!options?.skipStreamIds?.includes(streamId) &&
81+
(!options?.startOrder || order >= options.startOrder),
82+
);
83+
84+
// Get the deltas for all the active streams, if any.
85+
const cursorQuery = useQuery(
86+
query,
87+
args === "skip" || !streamMessages?.length
88+
? ("skip" as const)
89+
: ({
90+
...args,
91+
streamArgs: {
92+
kind: "deltas",
93+
cursors: streamMessages.map(({ streamId }) => ({
94+
streamId,
95+
cursor: cursors[streamId] ?? 0,
96+
})),
97+
} as StreamArgs,
98+
} as FunctionArgs<Query>),
99+
) as
100+
| { streams: Extract<SyncStreamsReturnValue, { kind: "deltas" }> }
101+
| undefined;
102+
103+
const newDeltas = cursorQuery?.streams.deltas;
104+
if (newDeltas?.length && streamMessages) {
105+
const newDeltasByStreamId = new Map<string, StreamDelta[]>();
106+
for (const delta of newDeltas) {
107+
const oldCursor = cursors[delta.streamId];
108+
if (oldCursor && delta.start < oldCursor) continue;
109+
const existing = newDeltasByStreamId.get(delta.streamId);
110+
if (existing) {
111+
const previousEnd = existing.at(-1)!.end;
112+
assert(
113+
previousEnd === delta.start,
114+
`Gap found in deltas for ${delta.streamId} jumping to ${delta.start} from ${previousEnd}`,
115+
);
116+
existing.push(delta);
117+
} else {
118+
assert(
119+
!oldCursor || oldCursor === delta.start,
120+
`Gap found - first delta after ${oldCursor} is ${delta.start} for stream ${delta.streamId}`,
121+
);
122+
newDeltasByStreamId.set(delta.streamId, [delta]);
123+
}
124+
}
125+
const newCursors: Record<string, number> = {};
126+
for (const { streamId } of streamMessages) {
127+
const cursor =
128+
newDeltasByStreamId.get(streamId)?.at(-1)?.end ?? cursors[streamId];
129+
if (cursor !== undefined) {
130+
newCursors[streamId] = cursor;
131+
}
132+
}
133+
setCursors(newCursors);
134+
135+
// we defensively create a new object so object identity matches contents
136+
state.deltaStreams = streamMessages.map((streamMessage) => {
137+
const streamId = streamMessage.streamId;
138+
const old = state.deltaStreams?.find(
139+
(ds) => ds.streamMessage.streamId === streamId,
140+
);
141+
const newDeltas = newDeltasByStreamId.get(streamId);
142+
if (!newDeltas && streamMessage === old?.streamMessage) {
143+
return old;
144+
}
145+
return {
146+
streamMessage,
147+
deltas: [...(old?.deltas ?? []), ...(newDeltas ?? [])],
148+
};
149+
});
150+
}
151+
return state.deltaStreams;
152+
}

0 commit comments

Comments
 (0)