AI Chatbot Personalization & Citations
Add persona management, RAG citations, and feedback to your AI chatbot.
Introduction
30 Second Summary
Think about talking to a brand's chatbot that sounds like a robot reading a manual versus one that actually sounds like the person behind the business. That difference is what turns a demo into a product worth thousands.
In this project, you will upgrade your RAG chatbot with a persona system that makes responses match the business owner's voice, professional citations that show where answers come from, and a feedback widget that tracks quality over time.
What You'll Build
A professionally personalized AI assistant with an admin panel for persona configuration, enhanced citation components on RAG responses, and a feedback collection system stored in Upstash Redis.
By the end of this project, you'll have:
- Admin settings page with persona configuration (tone presets, custom instructions) stored in Redis and dynamically injected into Gemini at runtime.
- Citation cards displaying document names, categories, relevance scores, and source links below each assistant response.
- Feedback widget with thumbs up/down and optional comments, persisted in Redis and ready for analytics.
- Secret Mission: Add few-shot verbatim examples to personas for ultra-precise style control that makes the bot truly sound like its owner.
Are there any prerequisites?
Yes. This project builds on Projet 1 (Core RAG Chat with Gemini + Upstash Vector). You need a working Next.js 15 project with Upstash Vector and Gemini chat already functional.
Set Up Upstash Redis & New Components
Your RAG chatbot from Projet 1 works, but it has no memory for settings. Every time you want to change the bot's personality or retrieve feedback data, you would need to redeploy the app.
In this step, you will set up Upstash Redis as a fast key-value store for persona configurations and feedback data. You will also install the UI components needed to build the admin panel in later steps.
In this step, get ready to:
- Create a free-tier Upstash Redis database and add credentials to your environment.
- Install the @upstash/redis package and new shadcn/ui components.
- Create a Redis client utility and verify the connection with a test route.
Create an Upstash Redis database
Upstash Redis gives your app a lightning-fast key-value store accessible over HTTP. You will use it to persist persona settings, feedback entries, and analytics counters across requests without needing a traditional database.
- Open the Upstash Console in your browser. You already have an account from Projet 1.
- Select the Redis tab in the top navigation bar.
- Click + Create Database in the upper right corner.
- Enter a name for your database (e.g. assistant-persona).
- Select the region closest to you.
- Click Next and select the Free plan.
- Click Create to provision the database.
What is Upstash Redis?
Upstash Redis is a serverless, HTTP-based key-value database. Unlike a traditional Redis server you would host yourself, Upstash handles all infrastructure and charges per request.
The free tier includes 256 MB of storage and 500K commands per month. No credit card is required.
Now you need to copy the connection credentials for your app.
- On your new database's page, scroll down to the REST API section.
- Click the copy button next to UPSTASH_REDIS_REST_URL to copy the value to your clipboard.
- Open .env.local in your project and paste the URL as a new line:
UPSTASH_REDIS_REST_URL=your-redis-rest-url-here
- Return to the Upstash Console and click the copy button next to UPSTASH_REDIS_REST_TOKEN.
- Paste it on the next line of .env.local:
UPSTASH_REDIS_REST_TOKEN=your-redis-rest-token-here
- Save .env.local.
Why not hardcode these values?
Environment variables keep secrets out of your source code. The Redis.fromEnv() method reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN automatically, so you never expose credentials in committed files.
Don't see the REST API section?
Make sure you are on the database details page (click the database name from the list). The REST API credentials appear below the connection details. If you still cannot find them, try the Connect section and select the REST tab.
Help me find my Upstash Redis REST credentials.
Install new dependencies
Your project needs the Upstash Redis SDK to communicate with the database and several new shadcn/ui components for the admin interface you will build in later steps.
- Install the @upstash/redis package by running this command in your terminal:
npm install @upstash/redis
What does this package do?
The @upstash/redis SDK is an HTTP-based Redis client designed for serverless environments. It connects over REST instead of TCP, which makes it ideal for Next.js API routes and edge functions.
- Add the shadcn/ui components you will need by running:
npx shadcn@latest add tabs dialog field card
What are these components for?
These components form the building blocks of the admin panel:
- Tabs separates the persona config into sections (Tone, Style, Instructions).
- Dialog handles modal forms for creating new personas.
- Field provides accessible form inputs that work with Server Actions.
- Card displays citation sources and feedback widgets cleanly.
- The CLI may ask you configuration questions if this is your first time adding shadcn/ui components. Accept the defaults when prompted.
Seeing permission or registry errors?
Make sure you are running the commands from your project's root folder (the one containing package.json). If npx fails, try clearing the npm cache with npm cache clean --force and retrying.
Help me fix my npm install errors.
Create the Redis client and test the connection
With the credentials in place and the SDK installed, you can now create a shared Redis client that any part of your app can import.
- Create a new file at lib/redis.ts in your project. This goes in the same lib folder where your other utilities live.
- Add the following code to lib/redis.ts:
import { Redis } from '@upstash/redis';
// Create a shared Redis client that reads credentials from environment variables
export const redis = Redis.fromEnv();
What does this code do?
The Redis.fromEnv() method automatically reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from your environment variables. By exporting a single instance, every file that imports redis reuses the same client.
- Save lib/redis.ts.
Now create a quick test route to verify that your app can talk to Redis.
- Create the folder app/api/test-redis and add a file called route.ts inside it.
- Add the following code to app/api/test-redis/route.ts:
import { redis } from '@/lib/redis';
import { NextResponse } from 'next/server';
// Simple test route that increments a counter in Redis on each request
export async function GET() {
const count = await redis.incr('test:counter');
return NextResponse.json({ counter: count });
}
What does this code do?
This route imports the shared Redis client and calls redis.incr('test:counter') each time you hit it. The incr command atomically increments an integer stored at the key test:counter. If the key does not exist yet, Redis creates it with a value of 1.
The response is a JSON object showing the current counter value. Each refresh increases it by one, proving your app can read and write to Redis.
- Save app/api/test-redis/route.ts.
- Make sure your dev server is running (npm run dev if it is not already).
- Open http://localhost:3000/api/test-redis in your browser.
- You should see a JSON response like {"counter":1}. Refresh the page and the counter should increment to 2, then 3, and so on.
Seeing an error instead of the counter?
Check the following:
- Confirm both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are present in .env.local with no extra spaces or quotes around the values.
- Restart your dev server after editing .env.local. Next.js only loads environment variables at startup.
- Make sure the import path in your route file uses @/lib/redis (not a relative path that might resolve incorrectly).
Help me debug my Upstash Redis connection error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both lib/redis.ts and app/api/test-redis/route.ts.
ⓧ I'd like to double check the full code
Here is lib/redis.ts:
import { Redis } from '@upstash/redis';
export const redis = Redis.fromEnv();
Here is app/api/test-redis/route.ts:
import { redis } from '@/lib/redis';
import { NextResponse } from 'next/server';
export async function GET() {
const count = await redis.incr('test:counter');
return NextResponse.json({ counter: count });
}
Your Redis database is live and your app can read from and write to it. Next up, you will build the persona system that stores tone, style, and custom instructions in this database.
Build the Persona System
Your Redis database is live and connected. Now it is time to build the feature that transforms your chatbot from a generic assistant into a branded voice.
A persona system lets you define how the bot sounds. Formal or casual, technical or friendly. Each persona is a configuration stored in Redis that gets injected into Gemini's system prompt at runtime.
In this step, get ready to:
- Define the persona data structure and build the Redis service layer.
- Build an admin settings page with tone presets and custom instructions.
- Wire form submission to a Server Action that persists personas to Redis.
Define the persona types and Redis service layer
Before building any UI, you need a clear data shape for what a persona contains and a set of functions to read and write personas from Redis.
- Create a new file at lib/persona.ts by right-clicking the lib folder in your editor sidebar and selecting New File.
- Add the PersonaConfig interface and the four service functions by pasting this code:
import { redis } from './redis';
// Define the shape of a persona configuration
export interface PersonaConfig {
id: string;
name: string;
tone: string;
style: string;
customInstructions: string;
isActive: boolean;
createdAt: string;
}
// Retrieve the currently active persona from Redis
export async function getActivePersona(): Promise<PersonaConfig | null> {
const activeId = await redis.get<string>('persona:active');
if (!activeId) return null;
const persona = await redis.get<PersonaConfig>(`persona:${activeId}`);
return persona;
}
// Save a persona configuration to Redis
export async function savePersona(persona: PersonaConfig): Promise<void> {
await redis.set(`persona:${persona.id}`, persona);
}
// List all saved personas by scanning for persona keys
export async function listPersonas(): Promise<PersonaConfig[]> {
const keys = await redis.keys('persona:*');
const personaKeys = keys.filter((key) => key !== 'persona:active');
if (personaKeys.length === 0) return [];
const personas = await Promise.all(
personaKeys.map((key) => redis.get<PersonaConfig>(key))
);
return personas.filter((p): p is PersonaConfig => p !== null);
}
// Set a persona as the active one
export async function setActivePersona(id: string): Promise<void> {
await redis.set('persona:active', id);
}
What does this code do?
The PersonaConfig interface defines the shape of every persona: a name, tone preset, style description, custom instructions, and an active flag.
The four functions form a complete service layer. getActivePersona() first reads the persona:active key to find which profile ID is active, then fetches that profile. listPersonas() scans all keys matching persona:* and filters out the persona:active pointer key.
- Save lib/persona.ts.
Seeing a TypeScript error on the import?
Make sure lib/redis.ts exports the redis instance as a named export. Your existing file should have export const redis = Redis.fromEnv(); exactly.
Help me debug my TypeScript import error in lib/persona.ts
Build the admin settings page
The admin page is where business owners configure their bot's personality. It uses shadcn/ui Tabs to organize settings into logical sections and Field components for clean form layout.
- Create the folder app/admin by right-clicking the app folder and selecting New Folder.
- Create a new file at app/admin/page.tsx and paste the following code:
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Field } from '@/components/ui/field';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { listPersonas, getActivePersona } from '@/lib/persona';
import { savePersonaAction } from './actions';
export default async function AdminPage() {
// Load existing personas and the currently active one
const personas = await listPersonas();
const activePersona = await getActivePersona();
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Persona Settings</h1>
{/* Persona selector showing saved profiles */}
{personas.length > 0 && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Active Persona</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm mb-3">
Currently active: {activePersona?.name || 'None'}
</p>
<div className="flex gap-2 flex-wrap">
{personas.map((p) => (
<form key={p.id} action={savePersonaAction}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="name" value={p.name} />
<input type="hidden" name="tone" value={p.tone} />
<input type="hidden" name="style" value={p.style} />
<input type="hidden" name="customInstructions" value={p.customInstructions} />
<button
type="submit"
className={`px-3 py-1 rounded text-sm ${
p.id === activePersona?.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary'
}`}
>
{p.name}
</button>
</form>
))}
</div>
</CardContent>
</Card>
)}
{/* Persona configuration form with tabbed sections */}
<form action={savePersonaAction}>
<Tabs defaultValue="tone" className="w-full">
<TabsList>
<TabsTrigger value="tone">Tone</TabsTrigger>
<TabsTrigger value="style">Style</TabsTrigger>
<TabsTrigger value="instructions">Instructions</TabsTrigger>
</TabsList>
<TabsContent value="tone" className="space-y-4 mt-4">
<Field>
<label htmlFor="name" className="text-sm font-medium">
Persona Name
</label>
<input
id="name"
name="name"
type="text"
placeholder="e.g. Formel, Amical"
defaultValue={activePersona?.name || ''}
className="w-full border rounded px-3 py-2"
required
/>
</Field>
<Field>
<label htmlFor="tone" className="text-sm font-medium">
Tone Preset
</label>
<select
id="tone"
name="tone"
defaultValue={activePersona?.tone || 'friendly'}
className="w-full border rounded px-3 py-2"
>
<option value="formal">Formal</option>
<option value="friendly">Friendly</option>
<option value="technical">Technical</option>
<option value="casual">Casual</option>
</select>
</Field>
</TabsContent>
<TabsContent value="style" className="space-y-4 mt-4">
<Field>
<label htmlFor="style" className="text-sm font-medium">
Response Style
</label>
<textarea
id="style"
name="style"
placeholder="Describe the desired response style..."
defaultValue={activePersona?.style || ''}
className="w-full border rounded px-3 py-2 min-h-[100px]"
/>
</Field>
</TabsContent>
<TabsContent value="instructions" className="space-y-4 mt-4">
<Field>
<label htmlFor="customInstructions" className="text-sm font-medium">
Custom Instructions
</label>
<textarea
id="customInstructions"
name="customInstructions"
placeholder="Add specific instructions for how the bot should behave..."
defaultValue={activePersona?.customInstructions || ''}
className="w-full border rounded px-3 py-2 min-h-[150px]"
/>
</Field>
</TabsContent>
</Tabs>
<button
type="submit"
className="mt-6 w-full bg-primary text-primary-foreground py-2 rounded"
>
Save Persona
</button>
</form>
</div>
);
}
What does this page do?
This is a Next.js Server Component that loads personas from Redis on every request. The Tabs component splits the form into three sections: Tone (name and preset selector), Style (free-text description), and Instructions (custom behavioral rules).
The persona selector at the top lets the user switch between saved profiles by re-submitting the existing data, which marks that profile as active. The form at the bottom creates new personas or updates existing ones.
- Save app/admin/page.tsx.
Seeing an import error for ./actions?
That file does not exist yet. You will create it in the next substep. The error will disappear once you save the actions file.
Help me understand the import error in my admin page
Wire form submission to a Server Action
Server Actions let you handle form submissions directly on the server without building a separate API route. The action validates the form data and saves the persona to Redis.
- Create a new file at app/admin/actions.ts and paste the following code:
'use server';
import { savePersona, setActivePersona } from '@/lib/persona';
import { PersonaConfig } from '@/lib/persona';
import { revalidatePath } from 'next/cache';
// Server Action to validate and save a persona to Redis
export async function savePersonaAction(formData: FormData) {
const id = (formData.get('id') as string) || crypto.randomUUID();
const name = formData.get('name') as string;
const tone = formData.get('tone') as string;
const style = formData.get('style') as string;
const customInstructions = formData.get('customInstructions') as string;
// Validate required fields
if (!name || !tone) {
return { error: 'Name and tone are required.' };
}
// Build the persona object
const persona: PersonaConfig = {
id,
name,
tone,
style: style || '',
customInstructions: customInstructions || '',
isActive: true,
createdAt: new Date().toISOString(),
};
// Save to Redis and mark as active
await savePersona(persona);
await setActivePersona(persona.id);
// Refresh the page to show updated data
revalidatePath('/admin');
}
What does this Server Action do?
The 'use server' directive marks this file as containing Server Actions. When the form submits, Next.js calls savePersonaAction() directly on the server.
It extracts form data, generates a UUID for new personas (or reuses an existing ID for updates), validates the required fields, saves everything to Redis, and then refreshes the page via revalidatePath('/admin').
- Save app/admin/actions.ts.
- Make sure your dev server is running, then navigate to http://localhost:3000/admin in your browser.
- Enter Formel in the Persona Name field.
- Select Formal from the Tone Preset dropdown.
- Click Save Persona.
- Repeat with a second persona: enter Amical as the name and select Friendly as the tone.
- Click Save Persona again.
You should now see both persona buttons (Formel and Amical) in the Active Persona card at the top of the page. The most recently saved persona is highlighted as active.
- Click the Formel button to switch the active persona back.
- Refresh the page to confirm persistence. The Formel button should still be highlighted.
Page not loading or showing errors?
Check that your .env.local file has both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN defined. The Redis client loads these automatically via Redis.fromEnv().
If you see a module not found error for @/components/ui/tabs or similar, make sure you ran the shadcn/ui add commands in the previous step.
Help me troubleshoot my admin page
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files.
ⓧ I'd like to double check the full code
Here is lib/persona.ts:
import { redis } from './redis';
// Define the shape of a persona configuration
export interface PersonaConfig {
id: string;
name: string;
tone: string;
style: string;
customInstructions: string;
isActive: boolean;
createdAt: string;
}
// Retrieve the currently active persona from Redis
export async function getActivePersona(): Promise<PersonaConfig | null> {
const activeId = await redis.get<string>('persona:active');
if (!activeId) return null;
const persona = await redis.get<PersonaConfig>(`persona:${activeId}`);
return persona;
}
// Save a persona configuration to Redis
export async function savePersona(persona: PersonaConfig): Promise<void> {
await redis.set(`persona:${persona.id}`, persona);
}
// List all saved personas by scanning for persona keys
export async function listPersonas(): Promise<PersonaConfig[]> {
const keys = await redis.keys('persona:*');
const personaKeys = keys.filter((key) => key !== 'persona:active');
if (personaKeys.length === 0) return [];
const personas = await Promise.all(
personaKeys.map((key) => redis.get<PersonaConfig>(key))
);
return personas.filter((p): p is PersonaConfig => p !== null);
}
// Set a persona as the active one
export async function setActivePersona(id: string): Promise<void> {
await redis.set('persona:active', id);
}
Here is app/admin/page.tsx:
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Field } from '@/components/ui/field';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { listPersonas, getActivePersona } from '@/lib/persona';
import { savePersonaAction } from './actions';
export default async function AdminPage() {
// Load existing personas and the currently active one
const personas = await listPersonas();
const activePersona = await getActivePersona();
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Persona Settings</h1>
{/* Persona selector showing saved profiles */}
{personas.length > 0 && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Active Persona</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm mb-3">
Currently active: {activePersona?.name || 'None'}
</p>
<div className="flex gap-2 flex-wrap">
{personas.map((p) => (
<form key={p.id} action={savePersonaAction}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="name" value={p.name} />
<input type="hidden" name="tone" value={p.tone} />
<input type="hidden" name="style" value={p.style} />
<input type="hidden" name="customInstructions" value={p.customInstructions} />
<button
type="submit"
className={`px-3 py-1 rounded text-sm ${
p.id === activePersona?.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary'
}`}
>
{p.name}
</button>
</form>
))}
</div>
</CardContent>
</Card>
)}
{/* Persona configuration form with tabbed sections */}
<form action={savePersonaAction}>
<Tabs defaultValue="tone" className="w-full">
<TabsList>
<TabsTrigger value="tone">Tone</TabsTrigger>
<TabsTrigger value="style">Style</TabsTrigger>
<TabsTrigger value="instructions">Instructions</TabsTrigger>
</TabsList>
<TabsContent value="tone" className="space-y-4 mt-4">
<Field>
<label htmlFor="name" className="text-sm font-medium">
Persona Name
</label>
<input
id="name"
name="name"
type="text"
placeholder="e.g. Formel, Amical"
defaultValue={activePersona?.name || ''}
className="w-full border rounded px-3 py-2"
required
/>
</Field>
<Field>
<label htmlFor="tone" className="text-sm font-medium">
Tone Preset
</label>
<select
id="tone"
name="tone"
defaultValue={activePersona?.tone || 'friendly'}
className="w-full border rounded px-3 py-2"
>
<option value="formal">Formal</option>
<option value="friendly">Friendly</option>
<option value="technical">Technical</option>
<option value="casual">Casual</option>
</select>
</Field>
</TabsContent>
<TabsContent value="style" className="space-y-4 mt-4">
<Field>
<label htmlFor="style" className="text-sm font-medium">
Response Style
</label>
<textarea
id="style"
name="style"
placeholder="Describe the desired response style..."
defaultValue={activePersona?.style || ''}
className="w-full border rounded px-3 py-2 min-h-[100px]"
/>
</Field>
</TabsContent>
<TabsContent value="instructions" className="space-y-4 mt-4">
<Field>
<label htmlFor="customInstructions" className="text-sm font-medium">
Custom Instructions
</label>
<textarea
id="customInstructions"
name="customInstructions"
placeholder="Add specific instructions for how the bot should behave..."
defaultValue={activePersona?.customInstructions || ''}
className="w-full border rounded px-3 py-2 min-h-[150px]"
/>
</Field>
</TabsContent>
</Tabs>
<button
type="submit"
className="mt-6 w-full bg-primary text-primary-foreground py-2 rounded"
>
Save Persona
</button>
</form>
</div>
);
}
Here is app/admin/actions.ts:
'use server';
import { savePersona, setActivePersona } from '@/lib/persona';
import { PersonaConfig } from '@/lib/persona';
import { revalidatePath } from 'next/cache';
// Server Action to validate and save a persona to Redis
export async function savePersonaAction(formData: FormData) {
const id = (formData.get('id') as string) || crypto.randomUUID();
const name = formData.get('name') as string;
const tone = formData.get('tone') as string;
const style = formData.get('style') as string;
const customInstructions = formData.get('customInstructions') as string;
// Validate required fields
if (!name || !tone) {
return { error: 'Name and tone are required.' };
}
// Build the persona object
const persona: PersonaConfig = {
id,
name,
tone,
style: style || '',
customInstructions: customInstructions || '',
isActive: true,
createdAt: new Date().toISOString(),
};
// Save to Redis and mark as active
await savePersona(persona);
await setActivePersona(persona.id);
// Refresh the page to show updated data
revalidatePath('/admin');
}
Your persona system is fully functional. You can create, switch between, and persist multiple personality profiles. Next up, you will connect these personas to the chat route so Gemini actually uses them in its responses.
Inject Dynamic System Prompts into Chat
Your admin panel saves personas to Redis, but they sit there unused. The chatbot still responds with its default, generic voice.
In this step, you will build the bridge between persona configuration and live chat responses. A prompt builder utility assembles a structured system instruction from the active persona, and the chat route loads it fresh on every request.
In this step, get ready to:
- Create a prompt builder that turns persona settings into a Gemini system instruction.
- Update the chat API route to load the active persona and inject the dynamic prompt.
- Test persona switching in real-time with the same question.
Create the prompt builder utility
The prompt builder is a pure function that takes a persona and RAG context, then returns a single string ready to pass as the system parameter to Gemini. Structuring the system instruction into clear sections (identity, tone, grounding rules) is a best practice from Google's prompting documentation.
- Create a new file at lib/prompt-builder.ts and paste the following code:
import { PersonaConfig } from './persona';
// Build a complete system instruction from persona settings and RAG context
export function buildSystemPrompt(persona: PersonaConfig, ragContext: string): string {
const sections: string[] = [];
// Persona identity section
sections.push(`You are an AI assistant named "${persona.name}".`);
// Tone rules section
sections.push(`TONE: Respond in a ${persona.tone} tone.`);
sections.push(`STYLE: Use a ${persona.style} communication style.`);
// Custom instructions section
if (persona.customInstructions) {
sections.push(`CUSTOM INSTRUCTIONS:\n${persona.customInstructions}`);
}
// RAG grounding rules
sections.push(
'RAG GROUNDING RULES:\n' +
'- Base your answers ONLY on the provided context below.\n' +
'- If the context does not contain enough information, say so clearly.\n' +
'- Never invent or hallucinate information not present in the context.'
);
// Citation format instructions
sections.push(
'CITATION FORMAT:\n' +
'- When referencing information from the context, cite the source document name.\n' +
'- Format citations as [Source: document_name] at the end of the relevant sentence.\n' +
'- Include the relevance score as a percentage when available.'
);
// RAG context injection
if (ragContext) {
sections.push(`\nCONTEXT:\n${ragContext}`);
}
return sections.join('\n\n');
}
What does this code do?
The buildSystemPrompt function assembles a multi-section system instruction string that Gemini reads before processing any user message.
- The persona identity and tone sections tell Gemini who it is and how to sound.
- The RAG grounding rules prevent hallucination by constraining answers to the provided context.
- The citation format section ensures the model references its sources in a consistent way.
- The RAG context itself is appended at the end so Gemini treats it as the knowledge base for that request.
- Save lib/prompt-builder.ts.
You will not see a visible change yet. The next chunk wires this function into the chat route.
Seeing a TypeScript import error?
Make sure lib/persona.ts exports the PersonaConfig type. If your export uses interface, confirm it is exported with export interface PersonaConfig or export type.
help me fix my PersonaConfig import in prompt-builder.ts
Update the chat route to use the dynamic system prompt
Your existing chat route at app/api/chat/route.ts currently uses a static system prompt. You will replace it with logic that loads the active persona from Upstash Redis on every request, retrieves RAG context from Upstash Vector, and passes a freshly built system prompt to streamText.
- Replace the contents of app/api/chat/route.ts with the following code:
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { google } from '@ai-sdk/google';
import { Index } from '@upstash/vector';
import { getActivePersona } from '@/lib/persona';
import { buildSystemPrompt } from '@/lib/prompt-builder';
// Initialize the Upstash Vector index from environment variables
const index = new Index();
// Retrieve relevant context from Upstash Vector for the user's query
async function retrieveContext(query: string): Promise<string> {
const results = await index.query({
data: query,
topK: 3,
includeMetadata: true,
});
// Format results into a readable context string with metadata
return results
.map((r) => {
const meta = r.metadata as Record<string, string> | undefined;
const docName = meta?.documentName || 'Unknown';
const category = meta?.category || '';
const score = Math.round((r.score || 0) * 100);
const content = meta?.excerpt || meta?.content || '';
return `[${docName}] (${category}, ${score}% match):\n${content}`;
})
.join('\n\n');
}
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
// Load the active persona from Redis at request time
const persona = await getActivePersona();
// Use the last user message as the RAG query
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const queryText = lastUserMessage?.content || '';
// Retrieve RAG context from Upstash Vector
const ragContext = await retrieveContext(queryText as string);
// Build dynamic system prompt from persona + RAG context
const systemPrompt = buildSystemPrompt(persona, ragContext);
// Stream the response with the dynamic system prompt
const result = streamText({
model: google('gemini-2.5-flash'),
system: systemPrompt,
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
What does this code do?
- getActivePersona() fetches the currently active persona config from Redis. This runs on every request, so switching personas in the admin panel takes effect immediately.
- retrieveContext() queries Upstash Vector with includeMetadata: true to get relevant documents along with their metadata (document name, category, score).
- buildSystemPrompt() combines the persona tone, style, and instructions with the RAG context into a single system instruction string.
- The system parameter in streamText() passes this assembled prompt directly to Gemini as its system instruction.
Why load persona on every request?
Upstash Redis responds in single-digit milliseconds. Loading the persona fresh on each request means the admin can switch personas and see the change immediately in the next chat message. No redeploy or cache invalidation needed.
- Save app/api/chat/route.ts.
- Return to your browser and send a message in the chat. You should receive a response that reflects the tone of your currently active persona.
Getting a module not found error?
Confirm that @upstash/vector is installed in your project from Projet 1. If not, install it by running npm install @upstash/vector. Also verify that UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN are present in your .env.local file.
help me debug the module not found error in my chat route
✔️ Awesome, I've got everything!
Great. Double check you have saved both lib/prompt-builder.ts and app/api/chat/route.ts.
ⓧ I'd like to double check the full code
import { PersonaConfig } from './persona';
// Build a complete system instruction from persona settings and RAG context
export function buildSystemPrompt(persona: PersonaConfig, ragContext: string): string {
const sections: string[] = [];
// Persona identity section
sections.push(`You are an AI assistant named "${persona.name}".`);
// Tone rules section
sections.push(`TONE: Respond in a ${persona.tone} tone.`);
sections.push(`STYLE: Use a ${persona.style} communication style.`);
// Custom instructions section
if (persona.customInstructions) {
sections.push(`CUSTOM INSTRUCTIONS:\n${persona.customInstructions}`);
}
// RAG grounding rules
sections.push(
'RAG GROUNDING RULES:\n' +
'- Base your answers ONLY on the provided context below.\n' +
'- If the context does not contain enough information, say so clearly.\n' +
'- Never invent or hallucinate information not present in the context.'
);
// Citation format instructions
sections.push(
'CITATION FORMAT:\n' +
'- When referencing information from the context, cite the source document name.\n' +
'- Format citations as [Source: document_name] at the end of the relevant sentence.\n' +
'- Include the relevance score as a percentage when available.'
);
// RAG context injection
if (ragContext) {
sections.push(`\nCONTEXT:\n${ragContext}`);
}
return sections.join('\n\n');
}
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import { google } from '@ai-sdk/google';
import { Index } from '@upstash/vector';
import { getActivePersona } from '@/lib/persona';
import { buildSystemPrompt } from '@/lib/prompt-builder';
// Initialize the Upstash Vector index from environment variables
const index = new Index();
// Retrieve relevant context from Upstash Vector for the user's query
async function retrieveContext(query: string): Promise<string> {
const results = await index.query({
data: query,
topK: 3,
includeMetadata: true,
});
// Format results into a readable context string with metadata
return results
.map((r) => {
const meta = r.metadata as Record<string, string> | undefined;
const docName = meta?.documentName || 'Unknown';
const category = meta?.category || '';
const score = Math.round((r.score || 0) * 100);
const content = meta?.excerpt || meta?.content || '';
return `[${docName}] (${category}, ${score}% match):\n${content}`;
})
.join('\n\n');
}
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
// Load the active persona from Redis at request time
const persona = await getActivePersona();
// Use the last user message as the RAG query
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const queryText = lastUserMessage?.content || '';
// Retrieve RAG context from Upstash Vector
const ragContext = await retrieveContext(queryText as string);
// Build dynamic system prompt from persona + RAG context
const systemPrompt = buildSystemPrompt(persona, ragContext);
// Stream the response with the dynamic system prompt
const result = streamText({
model: google('gemini-2.5-flash'),
system: systemPrompt,
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
Test persona switching in real-time
The real test of the dynamic system prompt is switching personas and asking the same question. The factual content should stay identical (grounded by RAG), but the tone should change noticeably.
- Open your admin page at http://localhost:3000/admin.
- Select your formal persona (e.g., "Formel") and click to set it as active.
- Switch to the chat interface and ask: "What are your services?"
- Note the tone of the response. It should sound professional and structured.
- Return to the admin page and switch the active persona to your friendly one (e.g., "Amical").
- Go back to the chat and ask the same question: "What are your services?"
- The response should now sound warm and conversational, while containing the same factual content.
What should I see?
Both responses answer the same question with the same facts from your RAG documents. The difference is in tone and phrasing. The formal persona uses structured language and avoids contractions. The friendly persona uses casual language and may address the user warmly.
Both responses sound the same?
Verify the active persona actually changed in Redis. Visit http://localhost:3000/api/test-redis to confirm Redis is reachable. Then check that your getActivePersona() function reads the persona:active key and returns the correct profile. Add a console.log(persona) in the chat route to confirm which persona is loaded at request time.
help me debug why my persona switching is not changing the chat tone
Your chatbot now speaks with the voice of its owner. Next up, you will make the responses even more trustworthy by adding professional citation cards that show exactly where each answer came from.
Add Enhanced Citation Cards
Your chatbot now speaks in the right voice. But when it answers a question using your uploaded documents, users have no way to verify where the information came from.
In this step, you will build a citation system that shows the exact source documents behind each response. This turns your chatbot from a black-box into an accountable assistant that proves its answers.
In this step, get ready to:
- Enrich your vector query to return document metadata alongside content chunks.
- Build a CitationCard component that displays source names, categories, and relevance scores.
- Stream citation data alongside the AI response using custom data parts.
Enrich the vector query with metadata
Right now, your Upstash Vector query returns raw content chunks. To display citations, you need the metadata attached to each vector: document name, category, and source URL.
- Create a new file lib/citations.ts that defines the CitationData type and a helper to parse vector results into citation objects:
// lib/citations.ts
// Defines the shape of citation data extracted from vector query results
export interface CitationData {
documentName: string;
category: string;
relevanceScore: number;
excerpt: string;
sourceUrl?: string;
}
// Parse raw vector query results into structured citation objects
export function parseCitations(
results: Array<{
score: number;
metadata?: {
documentName?: string;
category?: string;
sourceUrl?: string;
uploadDate?: string;
excerpt?: string;
content?: string;
};
}>
): CitationData[] {
return results
.filter((r) => r.metadata?.documentName)
.map((result) => ({
documentName: result.metadata!.documentName!,
category: result.metadata?.category || 'General',
relevanceScore: Math.round(result.score * 100),
excerpt: result.metadata?.excerpt || result.metadata?.content?.slice(0, 150) || '',
sourceUrl: result.metadata?.sourceUrl,
}));
}
What does this code do?
The CitationData interface defines what each citation card will display: a document name, category, relevance score as a percentage, a short excerpt, and an optional source URL.
The parseCitations function takes raw Upstash Vector results and transforms them into clean citation objects. It filters out results without a document name and converts the similarity score (0 to 1) into a readable percentage.
- Save lib/citations.ts.
Now update the vector query in your chat route to request metadata alongside content.
- In app/api/chat/route.ts, find the line where you query Upstash Vector.
- Add includeMetadata: true to the query options so the response includes the metadata fields you stored during document upload:
// Query Upstash Vector with metadata included
const results = await index.query({
vector: embedding,
topK: 3,
includeMetadata: true,
});
What does includeMetadata do?
By default, vector queries only return the similarity score and ID. Setting includeMetadata: true tells Upstash Vector to also return the metadata object you attached when you upserted each document chunk (documentName, category, sourceUrl, excerpt).
- Save app/api/chat/route.ts.
Not seeing metadata in your results?
Make sure you stored metadata when you originally upserted your documents in Projet 1. If your vectors were uploaded without metadata fields, you will need to re-upsert them with the enriched structure (documentName, category, excerpt).
help me debug why my Upstash Vector query returns empty metadata
Build the CitationCard component
Each source document needs a visual card that shows the document name, a category badge, and the relevance score. You will use the shadcn/ui Card component for consistent styling.
- Create the file components/citation-card.tsx and add the following component:
// components/citation-card.tsx
// Displays a single source citation with metadata and optional link
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { CitationData } from '@/lib/citations';
interface CitationCardProps {
citation: CitationData;
}
export function CitationCard({ citation }: CitationCardProps) {
return (
<Card className="border-muted bg-muted/30">
<CardContent className="p-3">
{/* Header: document name and relevance score */}
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium truncate">
{citation.documentName}
</span>
<span className="text-xs text-muted-foreground ml-2">
{citation.relevanceScore}%
</span>
</div>
{/* Category badge */}
<span className="inline-block text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary mb-2">
{citation.category}
</span>
{/* Document excerpt */}
<p className="text-xs text-muted-foreground line-clamp-2">
{citation.excerpt}
</p>
{/* Optional source link */}
{citation.sourceUrl && (
<a
href={citation.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline mt-1 inline-block"
>
View source
</a>
)}
</CardContent>
</Card>
);
}
What does this component do?
The CitationCard renders a compact card for each source document. It shows the document name and relevance score in the header, a colored badge for the category, a two-line excerpt, and a clickable link if a source URL was provided in the metadata.
The line-clamp-2 class from Tailwind CSS truncates long excerpts to exactly two lines, keeping the card compact.
- Save components/citation-card.tsx.
Now create the collapsible wrapper that lets users toggle citation visibility.
- Create the file components/citations-section.tsx with the Show/Hide sources toggle:
// components/citations-section.tsx
// Collapsible section that wraps multiple CitationCards
'use client';
import { useState } from 'react';
import { CitationCard } from './citation-card';
import { CitationData } from '@/lib/citations';
interface CitationsSectionProps {
citations: CitationData[];
}
export function CitationsSection({ citations }: CitationsSectionProps) {
// Track whether the sources panel is expanded
const [isOpen, setIsOpen] = useState(false);
if (citations.length === 0) return null;
return (
<div className="mt-2">
{/* Toggle button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
{isOpen ? 'Hide sources' : `Show sources (${citations.length})`}
</button>
{/* Collapsible citation cards */}
{isOpen && (
<div className="mt-2 space-y-2">
{citations.map((citation, index) => (
<CitationCard key={index} citation={citation} />
))}
</div>
)}
</div>
);
}
What does this code do?
The CitationsSection component manages the show/hide state for the source cards. It displays a toggle button that shows the number of sources available. When clicked, it reveals the individual CitationCard components in a vertical stack.
If no citations are available (the array is empty), the component renders nothing.
- Save components/citations-section.tsx.
Import errors for Card component?
Make sure you installed the Card component in Step 1. If you see a module-not-found error for @/components/ui/card, run the shadcn CLI again:
npx shadcn@latest add card
help me fix shadcn/ui Card import errors in my Next.js project
Stream citation data alongside the AI response
The final piece connects everything. You need to send citation data from the server alongside the streamed text so the client can render source cards below each response.
The AI SDK provides createUIMessageStream which lets you write custom data parts alongside the LLM text stream. Citation data becomes a regular data part that appears in message.parts on the client.
- Update app/api/chat/route.ts to use createUIMessageStream and createUIMessageStreamResponse to stream citation data alongside the AI response:
// app/api/chat/route.ts
// Updated to stream citations as custom data parts
import {
streamText,
createUIMessageStream,
createUIMessageStreamResponse,
convertToModelMessages,
} from 'ai';
import { google } from '@ai-sdk/google';
import { getActivePersona } from '@/lib/persona';
import { buildSystemPrompt } from '@/lib/prompt-builder';
import { parseCitations } from '@/lib/citations';
import { index } from '@/lib/vector';
export async function POST(req: Request) {
const { messages } = await req.json();
// Load active persona from Redis
const persona = await getActivePersona();
// Get the latest user message for the vector query
const lastMessage = messages[messages.length - 1];
const embedding = await generateEmbedding(lastMessage.content);
// Query Upstash Vector with metadata included
const results = await index.query({
vector: embedding,
topK: 3,
includeMetadata: true,
});
// Extract RAG context for the system prompt
const ragContext = results
.map((r) => r.metadata?.content || '')
.filter(Boolean)
.join('\n\n');
// Parse citation data from the vector results
const citations = parseCitations(results);
// Build the dynamic system prompt with persona and RAG context
const systemPrompt = buildSystemPrompt(persona, ragContext);
// Create a UI message stream with custom citation data
const stream = createUIMessageStream({
execute: ({ writer }) => {
// Send citation data as a custom data part
writer.write({
type: 'data-citations',
id: 'citations',
data: { citations },
});
// Stream the AI response
const result = streamText({
model: google('gemini-2.5-flash'),
system: systemPrompt,
messages: convertToModelMessages(messages),
});
// Merge the text stream into the UI message stream
writer.merge(result.toUIMessageStream());
},
});
return createUIMessageStreamResponse({ stream });
}
What does this code do?
Instead of using result.toUIMessageStreamResponse() directly, you now create a custom UI message stream. This gives you a writer that can emit both custom data parts (citations) and the LLM text stream.
The writer.write() call with type data-citations sends the parsed citation array to the client as a message part. Then writer.merge() pipes the streamed AI text into the same response. On the client, citations appear in message.parts alongside the text content.
- Save app/api/chat/route.ts.
Now update your chat UI to render the CitationsSection below each assistant message.
- In your chat page component, find where you render assistant messages.
- Import CitationsSection and render it below each assistant message by extracting citation data from message.parts:
// In your chat page component
// Extract citations from message parts and render below assistant text
import { CitationsSection } from '@/components/citations-section';
import { CitationData } from '@/lib/citations';
// Inside the message rendering loop, for each assistant message:
const citationsPart = message.parts.find(
(part) => part.type === 'data-citations'
);
const citations: CitationData[] = citationsPart?.data?.citations || [];
// Render below the assistant message text
<CitationsSection citations={citations} />
How do data parts work on the client?
When you use writer.write() with a data-* type on the server, the AI SDK automatically adds that data to message.parts on the client. You can find it by filtering parts for the matching type name and access the data payload directly.
- Save your chat page file.
- Open your browser and ask a question that requires RAG context (something your uploaded documents can answer).
- You should see the AI response with a "Show sources" toggle below it. Click the toggle to reveal 1-3 citation cards with document names, category badges, relevance percentages, and excerpts.
Not seeing citation cards below the response?
Check that your vector database has documents with metadata. If the parseCitations function filters out results without a documentName field, you will get an empty array if your original uploads did not include that field.
Also verify that message.parts contains a part with type data-citations. Add a console.log(message.parts) in your component to inspect what the server sent.
help me debug why citations are not appearing in my chat messages
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: lib/citations.ts, components/citation-card.tsx, and components/citations-section.tsx.
ⓧ I'd like to double check the full code
Here is the complete app/api/chat/route.ts after this step:
// app/api/chat/route.ts
import {
streamText,
createUIMessageStream,
createUIMessageStreamResponse,
convertToModelMessages,
} from 'ai';
import { google } from '@ai-sdk/google';
import { getActivePersona } from '@/lib/persona';
import { buildSystemPrompt } from '@/lib/prompt-builder';
import { parseCitations } from '@/lib/citations';
import { index } from '@/lib/vector';
export async function POST(req: Request) {
const { messages } = await req.json();
// Load active persona from Redis
const persona = await getActivePersona();
// Get the latest user message for the vector query
const lastMessage = messages[messages.length - 1];
const embedding = await generateEmbedding(lastMessage.content);
// Query Upstash Vector with metadata included
const results = await index.query({
vector: embedding,
topK: 3,
includeMetadata: true,
});
// Extract RAG context for the system prompt
const ragContext = results
.map((r) => r.metadata?.content || '')
.filter(Boolean)
.join('\n\n');
// Parse citation data from the vector results
const citations = parseCitations(results);
// Build the dynamic system prompt with persona and RAG context
const systemPrompt = buildSystemPrompt(persona, ragContext);
// Create a UI message stream with custom citation data
const stream = createUIMessageStream({
execute: ({ writer }) => {
// Send citation data as a custom data part
writer.write({
type: 'data-citations',
id: 'citations',
data: { citations },
});
// Stream the AI response
const result = streamText({
model: google('gemini-2.5-flash'),
system: systemPrompt,
messages: convertToModelMessages(messages),
});
// Merge the text stream into the UI message stream
writer.merge(result.toUIMessageStream());
},
});
return createUIMessageStreamResponse({ stream });
}
Here is the complete lib/citations.ts:
// lib/citations.ts
export interface CitationData {
documentName: string;
category: string;
relevanceScore: number;
excerpt: string;
sourceUrl?: string;
}
export function parseCitations(
results: Array<{
score: number;
metadata?: {
documentName?: string;
category?: string;
sourceUrl?: string;
uploadDate?: string;
excerpt?: string;
content?: string;
};
}>
): CitationData[] {
return results
.filter((r) => r.metadata?.documentName)
.map((result) => ({
documentName: result.metadata!.documentName!,
category: result.metadata?.category || 'General',
relevanceScore: Math.round(result.score * 100),
excerpt: result.metadata?.excerpt || result.metadata?.content?.slice(0, 150) || '',
sourceUrl: result.metadata?.sourceUrl,
}));
}
Here is the complete components/citation-card.tsx:
// components/citation-card.tsx
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { CitationData } from '@/lib/citations';
interface CitationCardProps {
citation: CitationData;
}
export function CitationCard({ citation }: CitationCardProps) {
return (
<Card className="border-muted bg-muted/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium truncate">
{citation.documentName}
</span>
<span className="text-xs text-muted-foreground ml-2">
{citation.relevanceScore}%
</span>
</div>
<span className="inline-block text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary mb-2">
{citation.category}
</span>
<p className="text-xs text-muted-foreground line-clamp-2">
{citation.excerpt}
</p>
{citation.sourceUrl && (
<a
href={citation.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline mt-1 inline-block"
>
View source
</a>
)}
</CardContent>
</Card>
);
}
Here is the complete components/citations-section.tsx:
// components/citations-section.tsx
'use client';
import { useState } from 'react';
import { CitationCard } from './citation-card';
import { CitationData } from '@/lib/citations';
interface CitationsSectionProps {
citations: CitationData[];
}
export function CitationsSection({ citations }: CitationsSectionProps) {
const [isOpen, setIsOpen] = useState(false);
if (citations.length === 0) return null;
return (
<div className="mt-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
{isOpen ? 'Hide sources' : `Show sources (${citations.length})`}
</button>
{isOpen && (
<div className="mt-2 space-y-2">
{citations.map((citation, index) => (
<CitationCard key={index} citation={citation} />
))}
</div>
)}
</div>
);
}
Your chatbot now shows its work. Every response backed by your documents includes verifiable source citations. Next, you will add a feedback widget so users can rate the quality of these responses.
Build the Feedback Widget
Your chatbot now shows clean citations that prove its answers are grounded in real documents. The next step toward a production-ready product is measuring quality.
A feedback mechanism lets users signal whether a response was helpful. In this step, you will build a thumbs up/down widget with an optional comment field, store the results in Upstash Redis, and increment global counters ready for the analytics dashboard in Projet 3.
In this step, get ready to:
- Build a FeedbackWidget component with thumbs up/down and optional comment.
- Create a feedback API route that persists ratings in Redis.
- Attach the widget below each assistant message in the chat UI.
Build the FeedbackWidget component
The widget needs three visual states: idle (showing the two thumb buttons), comment (showing a textarea after the user picks a thumb), and submitted (showing a thank-you message that prevents duplicate votes).
- Create a new file at components/feedback-widget.tsx and add the following code:
'use client';
import { useState } from 'react';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface FeedbackWidgetProps {
messageId: string;
conversationId: string;
}
export function FeedbackWidget({ messageId, conversationId }: FeedbackWidgetProps) {
// Track the user's rating, comment text, and submission state
const [rating, setRating] = useState<'up' | 'down' | null>(null);
const [comment, setComment] = useState('');
const [submitted, setSubmitted] = useState(false);
const [showComment, setShowComment] = useState(false);
// Record which thumb was clicked and reveal the comment textarea
async function handleThumbClick(value: 'up' | 'down') {
setRating(value);
setShowComment(true);
}
// Send feedback to the API and lock the widget
async function handleSubmit() {
if (!rating) return;
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId,
conversationId,
rating,
comment: comment || undefined,
}),
});
setSubmitted(true);
}
// After submission, show a simple thank-you message
if (submitted) {
return (
<p className="text-sm text-muted-foreground">Thank you for your feedback!</p>
);
}
return (
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center gap-2">
<button
onClick={() => handleThumbClick('up')}
disabled={rating !== null}
className={`p-1 rounded hover:bg-muted ${
rating === 'up' ? 'text-green-600' : 'text-muted-foreground'
}`}
aria-label="Thumbs up"
>
<ThumbsUp className="h-4 w-4" />
</button>
<button
onClick={() => handleThumbClick('down')}
disabled={rating !== null}
className={`p-1 rounded hover:bg-muted ${
rating === 'down' ? 'text-red-600' : 'text-muted-foreground'
}`}
aria-label="Thumbs down"
>
<ThumbsDown className="h-4 w-4" />
</button>
</div>
{showComment && (
<div className="flex flex-col gap-2">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Optional: tell us more..."
className="w-full rounded border p-2 text-sm resize-none"
rows={2}
/>
<button
onClick={handleSubmit}
className="self-start rounded bg-primary px-3 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Submit
</button>
</div>
)}
</div>
);
}
What does this code do?
The component manages three states. Initially, it shows two small thumb icons. When the user clicks one, disabled={rating !== null} locks both buttons so they cannot vote twice.
After a thumb click, showComment flips to true and reveals an optional textarea. Clicking Submit sends the payload to /api/feedback and swaps the entire widget for a thank-you paragraph.
- Save components/feedback-widget.tsx.
You will not see the widget in your browser yet because nothing renders it. The API route comes next.
Create the feedback API route
The widget posts feedback to a new endpoint. This route stores the rating as a Redis hash and increments a global counter so Projet 3 can pull aggregate stats in constant time.
- Create a new file at app/api/feedback/route.ts and add the following code:
import { redis } from '@/lib/redis';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { messageId, conversationId, rating, comment } = await request.json();
// Store the full feedback record as a Redis hash
await redis.hset(`feedback:${messageId}`, {
rating,
comment: comment || '',
timestamp: Date.now(),
conversationId,
});
// Increment the global counter for quick analytics access
await redis.incr(`stats:feedback:${rating}`);
return NextResponse.json({ success: true });
}
What does this code do?
The redis.hset call creates a hash at the key feedback:{messageId} with four fields: the rating, optional comment, a Unix timestamp, and the conversation ID. Hashes are ideal here because each feedback record contains multiple named fields.
The redis.incr call atomically bumps either stats:feedback:up or stats:feedback:down. If the key does not exist yet, Redis initializes it to 0 before incrementing.
- Save app/api/feedback/route.ts.
Seeing a 500 error when submitting feedback?
Check that your UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set in .env.local. The Redis.fromEnv() call in lib/redis.ts reads these at runtime and throws if they are missing.
Also confirm your dev server is running. If you restarted it after editing .env.local, the new values should be picked up automatically.
help me debug a 500 error from my /api/feedback route in Next.js with Upstash Redis
Attach the widget below each assistant message
The FeedbackWidget needs to appear below every assistant response, right alongside the citation cards you built in the previous step.
- In your chat page component, find the section where you render assistant messages (the same place you added the CitationCard components).
- Import FeedbackWidget at the top of the file:
import { FeedbackWidget } from '@/components/feedback-widget';
- Below each assistant message (after the citation cards section), render the widget by passing the message ID and conversation ID:
<FeedbackWidget messageId={message.id} conversationId={conversationId} />
What does this integration do?
Each assistant message now gets its own independent FeedbackWidget instance. The messageId prop ties the feedback record to a specific response so you can trace which answers users liked or disliked. The conversationId groups all feedback from the same chat session.
- Save the chat page file and refresh your browser.
- Send a message to the chatbot. Below the assistant's response, you should see two small thumb icons.
- Click one of the thumbs. A textarea and Submit button should appear.
- Click Submit. The widget should swap to a "Thank you for your feedback!" message.
Don't see the thumb icons?
Make sure you imported FeedbackWidget from @/components/feedback-widget and placed it inside the assistant message render block. The component only appears for assistant messages, not user messages.
Also verify that lucide-react is installed. It ships as a dependency of shadcn/ui, so it should already be in your node_modules.
help me figure out why my FeedbackWidget is not rendering below assistant messages
To verify the Redis storage is working, open your Upstash Console, navigate to your Redis database, and use the Data Browser to search for keys starting with feedback:. You should see the hash fields (rating, comment, timestamp, conversationId) for the message you just rated.
Also check for the stats:feedback:up or stats:feedback:down key. Its value should be 1 after your first vote.
✔️ Awesome, I've got everything!
Great. Double check you have saved both components/feedback-widget.tsx and app/api/feedback/route.ts.
ⓧ I'd like to double check the full code
Here is the complete components/feedback-widget.tsx:
'use client';
import { useState } from 'react';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface FeedbackWidgetProps {
messageId: string;
conversationId: string;
}
export function FeedbackWidget({ messageId, conversationId }: FeedbackWidgetProps) {
const [rating, setRating] = useState<'up' | 'down' | null>(null);
const [comment, setComment] = useState('');
const [submitted, setSubmitted] = useState(false);
const [showComment, setShowComment] = useState(false);
async function handleThumbClick(value: 'up' | 'down') {
setRating(value);
setShowComment(true);
}
async function handleSubmit() {
if (!rating) return;
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId,
conversationId,
rating,
comment: comment || undefined,
}),
});
setSubmitted(true);
}
if (submitted) {
return (
<p className="text-sm text-muted-foreground">Thank you for your feedback!</p>
);
}
return (
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center gap-2">
<button
onClick={() => handleThumbClick('up')}
disabled={rating !== null}
className={`p-1 rounded hover:bg-muted ${
rating === 'up' ? 'text-green-600' : 'text-muted-foreground'
}`}
aria-label="Thumbs up"
>
<ThumbsUp className="h-4 w-4" />
</button>
<button
onClick={() => handleThumbClick('down')}
disabled={rating !== null}
className={`p-1 rounded hover:bg-muted ${
rating === 'down' ? 'text-red-600' : 'text-muted-foreground'
}`}
aria-label="Thumbs down"
>
<ThumbsDown className="h-4 w-4" />
</button>
</div>
{showComment && (
<div className="flex flex-col gap-2">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Optional: tell us more..."
className="w-full rounded border p-2 text-sm resize-none"
rows={2}
/>
<button
onClick={handleSubmit}
className="self-start rounded bg-primary px-3 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Submit
</button>
</div>
)}
</div>
);
}
And the complete app/api/feedback/route.ts:
import { redis } from '@/lib/redis';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { messageId, conversationId, rating, comment } = await request.json();
await redis.hset(`feedback:${messageId}`, {
rating,
comment: comment || '',
timestamp: Date.now(),
conversationId,
});
await redis.incr(`stats:feedback:${rating}`);
return NextResponse.json({ success: true });
}
Secret mission
Few-Shot Verbatim Examples
Your persona system sets tone and style, but sometimes that's not enough. What if the business owner has a very specific way of phrasing things? In this secret mission, you'll add verbatim input/output examples to the persona config and inject them as few-shot demonstrations into the system prompt. The result: Gemini mimics the exact writing style down to sentence structure.
Clean Up Your Resources
Clean Up Your Resources
This project runs on the Upstash Redis free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Upstash Redis database (free tier: 256 MB storage, 500K commands/month, no credit card required).
- Persona configs and feedback data stored as Redis keys.
- Environment variables in .env.local (UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with Projet 3.
- The Upstash Redis free tier is permanent with no credit card required. Your persona configs and feedback data persist indefinitely as long as the database remains active.
- Your .env.local credentials remain valid and ready for the next project.
Pause - I'll come back to this later
Free up local resources while keeping your cloud data intact. Upstash handles pausing automatically.
- Stop your local dev server if it is still running (press Ctrl+C in the terminal).
- Your Upstash Redis free-tier database is archived automatically after a minimum of 14 days of inactivity. Upstash sends warning emails before archiving. No data is lost because a backup is taken automatically.
- When you return, create a new database and restore from the backup in the Upstash Console.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Open the Upstash Console and select your Redis database.
- Scroll to the bottom of the Details page.
- Click Delete this database and follow the confirmation instructions. This removes all persona configs, feedback data, and counter keys.
- Remove the Redis environment variables from your .env.local file. Delete the lines containing UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
Nice Work!
Nice Work!
You just transformed a generic RAG chatbot into a branded, professional AI assistant. Your chatbot now speaks in a customizable voice, proves its answers with clear citations, and silently collects quality signals for future improvement.
You've learned how to:
- Built a multi-persona system with tone and style presets stored in Upstash Redis and dynamically injected into Gemini system instructions at runtime.
- Enhanced RAG responses with professional citation cards showing document metadata, relevance scores, and source links.
- Implemented a feedback widget (thumbs up/down with comments) persisted in Redis, preparing data for the Projet 3 analytics dashboard.
- Secret Mission: Added few-shot verbatim examples for ultra-precise style control that makes the bot truly sound like its owner.
Ready to quiz yourself?