Our workflow is working pretty nicely. We're getting some decent outputs, but the user is not seeing anything on the screen for a considerable length of time. That's because we are using generateText instead of streamText.
We're going to change this to use streamText, and we're also going to get some practice in doing some custom data parts.
We want to:
I've added some necessary scaffolding inside ./api/chat:
createUIMessageStream with a MyMessage typeUIMessageStreamResponseYour first job is to declare custom data parts:
export type MyMessage = UIMessage<unknown,{// TODO: declare custom data parts here}>;
I recommend one part for the evaluation and one for the first draft. The final draft we can stream down as normal text parts.
You'll need to replace all instances of UIMessage with MyMessage in this folder.
generateText to streamTextNext, we need to strip out the generateText calls inside the execute function:
// TODO - change to streamText and write to the stream as custom data partsconst writeSlackResult = await generateText({model: google('gemini-2.0-flash-001'),system: WRITE_SLACK_MESSAGE_FIRST_DRAFT_SYSTEM,prompt: `Conversation history:${formatMessageHistory(messages)}`
Change each of these generateText calls into a streamText. Watch the text stream and as it happens, stream that to the frontend as that data part.
Make sure you use the ID trick so you update the same data part over time. I've got some reference material that explains this in more detail.
The final Slack attempt is already streaming, which is good:
const finalSlackAttempt = streamText({model: google('gemini-2.0-flash-001'),system: WRITE_SLACK_MESSAGE_FINAL_SYSTEM,prompt: `Conversation history:${formatMessageHistory(messages)}First draft:${writeSlackResult.text
We need to merge it into the writer. When we merge it, we need to pass sendStart: false.
By default, when you merge a UIMessageStream into another UIMessageStream, it will send the start and finish parts. But because we've already begun the message up above, we don't want it to send the start part again.
In fact, we want to manually start it ourselves, by following the TODO at the top of the execute function:
I've got some reference material that explains this in more detail.
Once the backend is done, we need to go to the frontend.
First, pass our MyMessage type to the useChat hook:
Then, we'll need to adjust the message component:
We'll need to render some UI to render the custom parts to the frontend.
Once all these changes are done, you should see each part of the workflow streaming to the frontend. This will:
// TODO: write a { type: 'start' } message via writer.writeTODO;
// TODO: pass MyMessage to the useChat hook: useChat<MyMessage>({})const { messages, sendMessage } = useChat({});
export const Message = ({role,parts,}: {role: string;parts: UIMessage['parts'];}) => (<div className="my-4">{parts.map((part) => {// TODO: use this component to handle the custom data parts// you have created in the api/chat.ts fileTODO;if (part.type === 'text') {return (<div className="mb-4"><p className="text-gray-400 text-xs"><ReactMarkdown>{(role === 'user' ? 'User: ' : 'AI: ') +part.text}</ReactMarkdown></p></div>);}return null;})}</div>);