Let's try another fun pattern where we're going to produce multiple outputs based on the same question. The user is then going to choose which output they prefer.
Generating multiple outputs is really great for A/B testing. In our case, we'll just be comparing two different models, but you may want to compare two entirely different approaches and allow users to choose which one is better. This can give you incredibly valuable data.
The basic setup uses a createUIMessageStream again, with two streamText calls right next to each other:
const stream = createUIMessageStream<MyMessage>({execute: async ({ writer }) => {const firstStreamResult = streamText({model: google('gemini-2.0-flash-lite'),messages: modelMessages,});const
Both stream calls are being passed exactly the same messages, but we're using two different models - one using gemini-2.0-flash-lite and the other using gemini-2.0-flash.
Just below this, we need to use Promise.all to call streamModelText for each model and pass in the appropriate model.
streamModelText FunctionSo, what is the streamModelText function? It takes three parameters inside a single object:
textStream: an AsyncIterableStream<string>model: a stringwriter: a UIMessageStreamWriter<MyMessage>const streamModelText = async (opts: {textStream: AsyncIterableStream<string>;model: string;writer: UIMessageStreamWriter<MyMessage>;}) => {// TODO: Stream the text from the textStream to the// data-output part};
Inside here, we'll use a for await loop to iterate over the text stream and write the text to the data-output part. Check the reference material for a reminder on how to do this.
data-output PartWe'll need to define the data-output part in the MyMessage type:
type MyMessage = UIMessage<never,{// TODO: Declare the data-output type here.// We need two properties:// - model: string - the name of the model that generated the text// - text: string - the text generated by the modeloutput: TODO;}>;
The data-output part requires two properties:
model of type string (the name of the model that generates the text)text generated by the model itselfThe model name will be displayed to the user, so we want it to be human readable.
Let's now look at the frontend to see what needs to be done there. At the top level, we have a latestMessageIsAwaitingResponse boolean that checks if the latest message has parts with data-output:
If it does, we want to disable the chat input field. We also show a different placeholder based on whether we're waiting for a response or not:
Inside the Message component, there is an onSelectModel callback:
This callback receives a partId parameter and extracts the part it represents.
The goal is to take the two data-output parts and replace them with a single text part - the output chosen as the best one.
When a user chooses one of the responses, it calls the onSelectModel callback. The way we'll represent this choice is by elevating one of the data output parts to be a regular text part, which will go down in the message history as the canonical reply.
In practical terms, we need to:
messages.slice to take all the messages before the current messagenewMessages array that is a copy of the current message, but with the data-output parts replaced with a text partuseChat (which returns a setMessages function)Once this is done, you should be able to:
Try it on some math questions or content generation tasks! Good luck, and I'll see you in the solution.
const latestMessage = messages[messages.length - 1];// NOTE: This checks to see if the latest message is awaiting// a response. If it is, we want to disable the input field.const latestMessageIsAwaitingResponse =latestMessage?.role === 'assistant' &&latestMessage.parts.some((part) => part.type === 'data-output',);
<ChatInputplaceholder={latestMessageIsAwaitingResponse? 'Select a response to continue...': 'Say something...'}// ... other properties ...disabled={latestMessageIsAwaitingResponse}/>
<MessageonSelectModel={(partId) => {const part = message.parts.filter((part) => part.type === 'data-output').find((part) => part.id === partId);if (!part) {return;}// TODO: The goal of onSelectModel is to take the two// data-output parts and replace them with a single text part -// the output we've chosen as the best one.// TODO: That means we need to update the messages in useChat// to replace the one we are currently on with a new message// that has a single text part.// TODO: Use messages.slice to take all the messages before// the current message.const newMessages = TODO;// TODO: Push a new message to the newMessages array that// is a copy of the current message, but with the data-output// parts replaced with a text part.newMessages.push(TODO);// TODO: Set the new messages array as the messages in useChat// (useChat returns a setMessages function)TODO;}}/>