@convex-dev/agent
Version:
A agent component for Convex.
172 lines (161 loc) • 5.49 kB
text/typescript
"use client";
import { useMemo, useState, useEffect } from "react";
import { type UIDataTypes, type UIMessageChunk, type UITools } from "ai";
import type { StreamQuery, StreamQueryArgs } from "./types.js";
import { type UIMessage } from "../UIMessages.js";
import {
applyUIMessageChunksIncremental,
blankUIMessage,
emptyIncrementalStreamState,
getParts,
statusFromStreamStatus,
type IncrementalStreamState,
} from "../deltas.js";
import { useDeltaStreams } from "./useDeltaStreams.js";
// Polyfill structuredClone to support readUIMessageStream on ReactNative
if (!("structuredClone" in globalThis)) {
void import("@ungap/structured-clone" as any).then(
({ default: structuredClone }) =>
(globalThis.structuredClone = structuredClone),
);
}
/**
* A hook that fetches streaming messages from a thread and converts them to UIMessages
* using AI SDK's readUIMessageStream.
* This ONLY returns streaming UIMessages. To get both full and streaming messages,
* use `useUIMessages`.
*
* @param query The query to use to fetch messages.
* It must take as arguments `{ threadId, paginationOpts, streamArgs }` and
* return a `streams` object returned from `agent.syncStreams`.
* @param args The arguments to pass to the query other than `paginationOpts`
* and `streamArgs`. So `{ threadId }` at minimum, plus any other arguments that
* you want to pass to the query.
* @returns The streaming UIMessages.
*/
export function useStreamingUIMessages<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
Query extends StreamQuery<any> = StreamQuery<object>,
>(
query: Query,
args: StreamQueryArgs<Query> | "skip",
options?: {
startOrder?: number;
skipStreamIds?: string[];
},
// TODO: make generic on metadata, etc.
): UIMessage<METADATA, DATA_PARTS, TOOLS>[] | undefined {
const [messageState, setMessageState] = useState<
Record<
string,
{
uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
cursor: number;
streamState: IncrementalStreamState;
}
>
>({});
const streams = useDeltaStreams(query, args, options);
const threadId = args === "skip" ? undefined : args.threadId;
useEffect(() => {
if (!streams) return;
let noNewDeltas = true;
for (const stream of streams) {
const existingStreamState = messageState[stream.streamMessage.streamId];
const cursor = existingStreamState?.cursor;
if (existingStreamState === undefined || cursor === undefined) {
noNewDeltas = false;
break;
}
if (stream.deltas.some((d) => d.parts.length > 0 && d.end > cursor)) {
noNewDeltas = false;
break;
}
if (
existingStreamState &&
existingStreamState.uiMessage.status !==
statusFromStreamStatus(stream.streamMessage.status)
) {
noNewDeltas = false;
break;
}
}
if (noNewDeltas) {
return;
}
const abortController = new AbortController();
void (async () => {
const newMessageState: Record<
string,
{
uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
cursor: number;
streamState: IncrementalStreamState;
}
> = Object.fromEntries(
await Promise.all(
streams.map(async ({ deltas, streamMessage }) => {
const streamId = streamMessage.streamId;
const existing = messageState[streamId];
const fromCursor = existing?.cursor ?? 0;
const status = statusFromStreamStatus(streamMessage.status);
const prevState =
existing?.streamState ?? emptyIncrementalStreamState();
const { parts: newParts, cursor } = getParts<UIMessageChunk>(
deltas,
fromCursor,
);
const base =
existing?.uiMessage ??
blankUIMessage(streamMessage, threadId as string);
if (newParts.length === 0) {
if (existing && existing.uiMessage.status !== status) {
return [
streamId,
{
uiMessage: { ...existing.uiMessage, status },
cursor: existing.cursor,
streamState: prevState,
},
];
}
return [
streamId,
existing ?? { uiMessage: base, cursor: 0, streamState: prevState },
];
}
const { message, streamState } = applyUIMessageChunksIncremental(
base as UIMessage,
newParts,
prevState,
);
message.status = status;
return [
streamId,
{
uiMessage: message as UIMessage<METADATA, DATA_PARTS, TOOLS>,
cursor,
streamState,
},
];
}),
),
);
if (abortController.signal.aborted) return;
setMessageState(newMessageState);
})();
return () => {
abortController.abort();
};
}, [messageState, streams, threadId]);
return useMemo(() => {
if (!streams) return undefined;
return streams
.map(
({ streamMessage }) => messageState[streamMessage.streamId]?.uiMessage,
)
.filter((uiMessage) => uiMessage !== undefined);
}, [messageState, streams]);
}