Follow-up Questions
Generate and display contextual follow-up suggestions
Suggest follow-up questions after AI responses, shown only on the last message.
Overview
After the AI responds, generate contextual follow-up questions using a fast model. These are streamed as data-* parts which are UI-only - they’re filtered out before sending context to the LLM.
File convention: lib/ai/followup-suggestions.ts → components/followup-suggestions.tsx
How it works
- Generate suggestions after the main response using a cheap/fast model
- Stream as
data-followupSuggestionspart (data-* prefix = UI-only) - Ignore data-* parts via
convertToModelMessages({ convertDataPart: () => undefined }) - Render only on the last message
Code
1. Generate & Stream Suggestions
import { type ModelMessage, Output, streamText } from "ai";
import { z } from "zod";
import { getLanguageModel } from "@/lib/ai/providers";
import type { StreamWriter } from "@/lib/ai/types";
import { config } from "@/lib/config";
import { generateUUID } from "@/lib/utils";
const FOLLOWUP_CONTEXT_MESSAGES = 2;
export async function generateFollowupSuggestions(
modelMessages: ModelMessage[]
) {
const maxQuestionCount = 5;
const minQuestionCount = 3;
const maxCharactersPerQuestion = 80;
const recentMessages = modelMessages.slice(-FOLLOWUP_CONTEXT_MESSAGES);
return streamText({
model: await getLanguageModel(config.ai.tools.followupSuggestions.default),
messages: [
...recentMessages,
{
role: "user",
content: `What question should I ask next? Return an array of suggested questions (minimum ${minQuestionCount}, maximum ${maxQuestionCount}). Each question should be no more than ${maxCharactersPerQuestion} characters.`,
},
],
output: Output.object({
schema: z.object({
suggestions: z
.array(z.string())
.min(minQuestionCount)
.max(maxQuestionCount),
}),
}),
});
}
export async function streamFollowupSuggestions({
followupSuggestionsResult,
writer,
}: {
followupSuggestionsResult: ReturnType<typeof generateFollowupSuggestions>;
writer: StreamWriter;
}) {
const dataPartId = generateUUID();
const result = await followupSuggestionsResult;
for await (const chunk of result.partialOutputStream) {
writer.write({
id: dataPartId,
type: "data-followupSuggestions", // data-* = UI-only, filtered from LLM context
data: {
suggestions:
chunk.suggestions?.filter((s): s is string => s !== undefined) ?? [],
},
});
}
}
2. Call After Response
// Inside createUIMessageStream execute callback, after result.consumeStream()
await result.consumeStream();
const response = await result.response;
const responseMessages = response.messages;
if (config.ai.tools.followupSuggestions.enabled) {
const followupSuggestionsResult = generateFollowupSuggestions([
...contextForLLM,
...responseMessages,
]);
await streamFollowupSuggestions({
followupSuggestionsResult,
writer: dataStream,
});
}
3. Ignore Data Parts in Conversion
import { convertToModelMessages } from "ai";
import { filterPartsForLLM } from "@/app/(chat)/api/chat/filter-reasoning-parts";
// Convert to model messages, ignoring data-* parts (UI-only)
const filteredMessages = filterPartsForLLM(messages);
const modelMessages = await convertToModelMessages(filteredMessages, {
convertDataPart: (_part): undefined => undefined,
});
4. Render on Last Message Only
"use client";
import { useCallback } from "react";
import { useChatStoreApi } from "@/lib/stores/base";
import { useMessageIds } from "@/lib/stores/hooks-base";
import {
useMessagePartByPartIdx,
useMessagePartTypesById,
} from "@/lib/stores/hooks-message-parts";
import type { ChatMessage, UiToolName } from "@/lib/ai/types";
import { generateUUID } from "@/lib/utils";
import { useChatInput } from "@/providers/chat-input-provider";
function FollowUpSuggestions({ suggestions }: { suggestions: string[] }) {
const storeApi = useChatStoreApi();
const { selectedModelId, selectedTool } = useChatInput();
const handleClick = useCallback(
(suggestion: string) => {
const sendMessage = storeApi.getState().sendMessage;
if (!sendMessage) return;
const message: ChatMessage = {
id: generateUUID(),
role: "user",
parts: [{ type: "text", text: suggestion }],
metadata: {
createdAt: new Date(),
parentMessageId: storeApi.getState().getLastMessageId(),
selectedModel: selectedModelId,
activeStreamId: null,
selectedTool: (selectedTool as UiToolName | null) || undefined,
},
};
sendMessage(message);
},
[storeApi, selectedModelId, selectedTool]
);
if (suggestions.length === 0) return null;
return (
<div>
<div>Related</div>
{suggestions.map((suggestion) => (
<button key={suggestion} onClick={() => handleClick(suggestion)}>
{suggestion}
</button>
))}
</div>
);
}
export function FollowUpSuggestionsParts({ messageId }: { messageId: string }) {
const types = useMessagePartTypesById(messageId);
const ids = useMessageIds();
const isLastMessage = ids.at(-1) === messageId;
// Only show on the last message
if (!isLastMessage) {
return null;
}
const partIdx = types.indexOf("data-followupSuggestions");
if (partIdx === -1) {
return null;
}
return <FollowUpSuggestionsPart messageId={messageId} partIdx={partIdx} />;
}
function FollowUpSuggestionsPart({
messageId,
partIdx,
}: {
messageId: string;
partIdx: number;
}) {
const part = useMessagePartByPartIdx(
messageId,
partIdx,
"data-followupSuggestions"
);
return <FollowUpSuggestions suggestions={part.data.suggestions} />;
}