AI Knowledge Assistant Lite
Build a React and Express study assistant powered by Gemini.
Introduction
30 Second Summary
Long study notes can hide the exact detail you need when revision time is short. Searching through every paragraph slows down the moment when you want a focused answer.
In this project, you will build a React study assistant that answers questions from notes you paste. A TypeScript Express backend protects the API key during each request to the Gemini Developer API.
What You'll Build
You will watch a focused answer appear in the chat based only on the study notes beside it.
By the end of this project, you'll have:
- A responsive notes workspace where you can paste study material before checking its live character count.
- A grounded question flow that returns answers based on your supplied notes while keeping the API key outside the browser.
- A resilient chat experience with visible loading, readable errors, manual retry, clear chat, input limits, and frontend-only history.
- Secret Mission: Add a one-click summary control that turns the pasted notes into five concise study bullets through the existing secure endpoint.
Are there any prerequisites?
You need a Google account that can access Google AI Studio. Use non-sensitive study notes because free-tier prompts and responses may be used to improve Google products.
Before We Start
Before the hands-on work begins, this is your moment to choose non-sensitive study notes and lock in why grounded answers from them would help you. That purpose gives every later request a clear goal.
Set Up the Full-Stack Workspace
Your study assistant needs a compatible Node.js runtime before its browser interface can run. Exact dependency versions also keep every later step aligned with the finished project.
The Gemini Developer API key needs a protected home outside the browser. You will finish this step with the pinned workspace open in Visual Studio Code and a starter page running locally.
In this step, get ready to:
- Verify Node.js, npm, and Visual Studio Code.
- Create the workspace with exact dependency versions.
- Protect the Gemini API key and run the starter page.
Verify your local tools
This project requires Node.js 22.21 or newer. This runtime supports the TypeScript server and the local environment file used later.
- Press the Windows key to open Windows search.
- Type Windows PowerShell into the search field.
- Press Enter to open Windows PowerShell.
- Check the three local tools by running these commands:
node --version
npm --version
code --version
What Do These Checks Prove?
- The first result shows whether the Node.js runtime meets the project requirement.
- The second result confirms that npm can install the project packages.
- The third result confirms that Visual Studio Code is available from PowerShell.
Match your Node.js result with one of these scenarios.
✔️ I see version 22.21 or higher
Your Node.js runtime meets the project requirement. Continue when the npm command also prints a version number.
ⓧ I see an older version
Your installed Node.js runtime is below the required version. Upgrade it before creating the workspace.
- Visit the official Node.js download page.
- Download a Windows release that is version 22.21 or newer.
- Run the downloaded installer.
- Restart Windows PowerShell after the installation finishes.
- Verify the upgraded runtime by running these commands:
node --version
npm --version
What Should You See?
Node.js should now report version 22.21 or newer. npm should report its own version number.
ⓧ Command not found
PowerShell cannot find Node.js yet. Install the runtime before continuing.
- Visit the official Node.js download page.
- Download a Windows release that is version 22.21 or newer.
- Run the downloaded installer.
- Restart Windows PowerShell after the installation finishes.
- Verify the installation by running these commands:
node --version
npm --version
What Should You See?
Both commands should print version numbers. The Node.js result must be 22.21 or newer.
Now match your Visual Studio Code result with one of these scenarios.
✔️ I see a version number
Visual Studio Code is ready. PowerShell can use its folder-opening command.
ⓧ Command not found
Install Visual Studio Code with the Windows User setup. This setup is recommended for most Windows users.
- Visit the official Visual Studio Code Windows setup guide.
- Download the Windows User setup.
- Run the downloaded installer.
- Restart Windows PowerShell after the installation finishes.
- Verify the editor command by running:
code --version
What Should You See?
PowerShell should print the installed Visual Studio Code version. That output confirms the editor command is ready.
Still Missing a Tool?
Restart Windows if PowerShell still cannot find an installed tool. This refreshes paths changed by an installer.
For targeted help, diagnose my missing development command.
Your local tools are ready. The project can now have a permanent home on your Desktop.
Create the workspace and install dependencies
The workspace pins React, Express, TypeScript, and Vite to exact versions. These pins keep the installed packages aligned with the project code.
- Press the Windows key to open Windows search.
- Type File Explorer into the search field.
- Press Enter to open File Explorer.
- Select Desktop in the left sidebar.
- Create a folder named ai-knowledge-assistant-lite on your Desktop.
- Return to Windows PowerShell.
- Open the workspace in Visual Studio Code by running:
code "$HOME\Desktop\ai-knowledge-assistant-lite"
What Does This Command Do?
Visual Studio Code opens the ai-knowledge-assistant-lite folder from your Desktop. You should see the empty folder in the Explorer sidebar.
- Create package.json inside the ai-knowledge-assistant-lite folder from the Explorer sidebar.
- Add the pinned package configuration by pasting this code:
{
"name": "ai-knowledge-assistant-lite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "node --env-file=.env server/app.ts",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "2.30.0",
"express": "5.3.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.4"
}
}
What Does This Configuration Do?
- The scripts define the development server and later server commands.
- The dependencies provide the browser runtime and API packages.
- The development dependencies provide type checking and build tooling.
- Save package.json.
- Confirm package.json appears in the Explorer sidebar.
Seeing a JSON Error?
Check every comma and quotation mark against the reference. Confirm every opening brace has a closing brace.
For targeted help, check my package configuration.
- Create tsconfig.json inside the ai-knowledge-assistant-lite folder from the Explorer sidebar.
- Add the TypeScript configuration by pasting this code:
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "Preserve",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "server", "vite.config.ts"]
}
What Does This Configuration Do?
- Strict checking catches unsafe values during development.
- The JSX setting lets TypeScript understand React components.
- The no-emit setting checks code without generating JavaScript files.
- Save tsconfig.json.
- Confirm tsconfig.json appears beside package.json.
Why Node-Native TypeScript?
Node.js 22.21 can execute erasable TypeScript syntax directly. Its stable --env-file option loads the server secret.
One runtime can handle the server code and its environment configuration. This keeps the API setup small.
- Select Terminal in the Visual Studio Code menu bar.
- Select New Terminal to open a PowerShell terminal inside the workspace.
- Install the exact runtime dependencies by running:
npm install @google/genai@2.30.0 express@5.3.0 react@19.3.1 react-dom@19.3.1 --save-exact
What Does This Install?
npm installs the Google Gen AI SDK for TypeScript and JavaScript. It also installs Express and the React runtime packages.
The first installation can take a minute while npm downloads packages. A quiet terminal during that time is expected.
- Wait for the PowerShell prompt to return.
- Confirm package-lock.json appears in the Explorer sidebar.
Did the Runtime Install Fail?
Confirm the terminal path ends with ai-knowledge-assistant-lite. Check that package.json is saved.
For targeted help, troubleshoot my runtime dependency installation.
- Install the exact development dependencies by running:
npm install @types/express@5.0.6 @types/node@26.6.5 @types/react@19.3.0 @types/react-dom@19.3.0 @vitejs/plugin-react@6.1.2 typescript@7.0.2 vite@8.3.4 --save-dev --save-exact
What Does This Install?
npm installs the type definitions and build tools. The exact-save option keeps every requested version fixed in package.json.
- Wait for the PowerShell prompt to return.
- Confirm package-lock.json remains visible in the Explorer sidebar.
- Compare the dependency versions in package.json with the values you pasted.
Your dependency lock file now records the exact installed package tree. That gives every later command the same starting point.
Did the Development Install Fail?
Check that Node.js is version 22.21 or newer. An older runtime can reject packages required by this project.
For targeted help, diagnose my development dependency installation.
Protect the API key and start the page
A local .env file gives the future Express server access to the Gemini API key. The matching rule in .gitignore keeps that credential out of Git commits.
Use Non-Sensitive Notes
Free-tier Gemini prompts and responses may be used to improve Google products. Use only non-sensitive study notes throughout this project.
Creating a key is the sensitive part of this setup. Keep Cloud Billing disabled so the project remains on the free tier.
- Visit the official Gemini API key guide.
- Follow its link to Google AI Studio.
- Sign in with your Google account.
- Accept the Terms of Service if prompted.
- Confirm that Cloud Billing remains disabled.
- Create an API key in the available Google Cloud project.
- Copy the issued API key to your clipboard.
Treat the Key Like a Password
Keep the key out of browser code and screenshots. Paste it only into the local .env file.
- Create index.html inside the ai-knowledge-assistant-lite folder from the Explorer sidebar.
- Add the browser entry document by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A lightweight AI study assistant grounded in pasted notes."
/>
<title>AI Knowledge Assistant Lite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
What Does This Document Do?
- The viewport metadata keeps the page usable on smaller screens.
- The root element gives React a place to render the interface.
- The module script loads the React entry point.
- Save index.html.
- Confirm index.html appears in the Explorer sidebar.
Is the HTML Marked as Invalid?
Confirm the module source is exactly /src/main.tsx. Check that the root element sits inside the body.
For targeted help, check my browser entry document.
- Create .gitignore inside the ai-knowledge-assistant-lite folder from the Explorer sidebar.
- Add the ignored local paths by pasting this code:
node_modules/
dist/
.env
*.local
What Does This File Protect?
Git ignores downloaded packages and production output. It also ignores local environment files that may contain credentials.
- Save .gitignore.
- Confirm .gitignore appears in the Explorer sidebar.
- Create .env.example inside the ai-knowledge-assistant-lite folder from the Explorer sidebar.
- Add the safe environment template by pasting this code:
GEMINI_API_KEY=your-api-key-here
Why Keep an Example File?
The example records the required environment variable without containing a live credential. It is safe to keep with the project code.
- Save .env.example.
- Confirm .env.example appears in the Explorer sidebar.
- Create .env beside .env.example.
- Copy the single line from .env.example into .env.
- Replace only your-api-key-here with the API key from your clipboard.
- Save .env.
- Close the .env editor tab.
How Is the Secret Kept Local?
The local .env contains the live key. The matching rule in .gitignore prevents Git from including that file in a commit.
- Create a folder named src inside ai-knowledge-assistant-lite from the Explorer sidebar.
- Create main.tsx inside the src folder.
- Add the React entry point by pasting this code:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
What Does This Entry Point Do?
- The root lookup connects React to the element in index.html.
- Strict Mode enables extra development checks.
- The stylesheet import loads the starter design.
- Save src/main.tsx.
- Confirm main.tsx appears inside the src folder.
Seeing Import Warnings?
Confirm both dependency installations completed. Check each import against the reference.
For targeted help, diagnose my React entry point imports.
- Create App.tsx inside the src folder.
- Add the starter component by pasting this code:
export default function App() {
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">FULL-STACK AI STUDY TOOL</p>
<h1>AI Knowledge Assistant Lite</h1>
<p>
Paste trusted notes, ask a focused question, and keep the API key on
the server.
</p>
</header>
</div>
);
}
What Does This Component Show?
The component renders the starter shell for the study assistant. Its heading provides visible proof that React loaded.
- Save src/App.tsx.
- Confirm App.tsx appears beside main.tsx.
Seeing JSX Errors?
Confirm the file ends with .tsx. Check that every opening element has a matching closing element.
For targeted help, check my starter component.
- Create index.css inside the src folder.
- Add the global starter styles by pasting this code:
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #18212f;
background: #edf2f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
What Do These Styles Change?
The root styles set the page typography and colors. Every element inherits this visual foundation.
- Add the page shell styles below the closing brace:
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hero {
padding: 3.5rem max(1.25rem, 6vw) 2.5rem;
color: #f8fbff;
background:
radial-gradient(circle at top right, rgba(77, 208, 225, 0.35), transparent 32%),
linear-gradient(135deg, #132238, #1f3a5f 58%, #27547a);
}
What Do These Styles Change?
- The body fills the browser viewport without default spacing.
- The app shell fills the available page height.
- The hero selector creates the colored starter header.
- Save src/index.css.
- Confirm index.css appears beside the other source files.
Seeing CSS Warnings?
Confirm the file is named index.css. Check that every selector has a closing brace.
For targeted help, check my starter stylesheet.
Use this checkpoint to compare the saved workspace before starting the development server.
✔️ Awesome, I've got everything!
Save every open file. Keep the live API key only in your local .env file.
ⓧ I'd like to double check the full code
Compare each saved file with its reference. package-lock.json is generated by npm.
{
"name": "ai-knowledge-assistant-lite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "node --env-file=.env server/app.ts",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "2.30.0",
"express": "5.3.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "Preserve",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "server", "vite.config.ts"]
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A lightweight AI study assistant grounded in pasted notes."
/>
<title>AI Knowledge Assistant Lite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
node_modules/
dist/
.env
*.local
GEMINI_API_KEY=your-api-key-here
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
export default function App() {
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">FULL-STACK AI STUDY TOOL</p>
<h1>AI Knowledge Assistant Lite</h1>
<p>
Paste trusted notes, ask a focused question, and keep the API key on
the server.
</p>
</header>
</div>
);
}
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #18212f;
background: #edf2f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hero {
padding: 3.5rem max(1.25rem, 6vw) 2.5rem;
color: #f8fbff;
background:
radial-gradient(circle at top right, rgba(77, 208, 225, 0.35), transparent 32%),
linear-gradient(135deg, #132238, #1f3a5f 58%, #27547a);
}
Before you run the app, what title do you expect to see in the browser?
- Start the Vite development server in the integrated terminal by running:
npm run dev
What Does This Command Do?
Vite starts the local React development server. The process keeps control of this terminal until you stop it with Ctrl+C.
- Open http://localhost:5173 in your browser.
You should see “AI Knowledge Assistant Lite” inside a blue gradient header. The terminal should remain free of errors.
That is the workspace working from browser to build tool. Your pinned starter now runs with its API key kept outside the browser.
Page Missing or Showing an Error?
Confirm the integrated terminal belongs to the ai-knowledge-assistant-lite workspace. Check that index.html points to /src/main.tsx.
For targeted help, trace my Vite startup error.
Your full-stack workspace is ready. Next, you will turn this starter shell into the notes and chat experience.
Build the Context and Chat Experience
Your starter page proves that Vite can serve your React app.
A static starter page cannot demonstrate the study workflow. This step turns it into an interface that accepts notes, submits questions, and keeps the conversation in browser memory.
In this step, get ready to:
- Build study fields as React controlled inputs with live character counts.
- Keep chat messages in React memory.
- Test the styled interface against the planned API path.
Build the React state and request flow
The app needs state for the notes, current question, conversation, loading status, and errors. The request function coordinates those values whenever the learner submits or retries a question.
- In the Explorer sidebar in Visual Studio Code, select src/App.tsx.
- Press Ctrl+A to select the current starter code.
- Replace the selected code with the exact src/App.tsx reference in the second tab below.
✔️ Awesome, I've got everything!
Your component now owns the study inputs, chat messages, request status, error recovery, and frontend-only history.
ⓧ I'd like to double check the full code
import { useState } from "react";
type Message = {
id: number;
role: "user" | "assistant";
text: string;
};
type ApiResponse = {
answer?: string;
error?: string;
};
const MAX_NOTES_LENGTH = 12_000;
const MAX_QUESTION_LENGTH = 500;
let nextMessageId = 1;
function createMessage(role: Message["role"], text: string): Message {
return { id: nextMessageId++, role, text };
}
export default function App() {
const [notes, setNotes] = useState("");
const [question, setQuestion] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [failedQuestion, setFailedQuestion] = useState<string | null>(null);
async function sendQuestion(
questionToSend: string,
showAsNewMessage: boolean
) {
const cleanNotes = notes.trim();
const cleanQuestion = questionToSend.trim();
if (!cleanNotes) {
setError("Paste some study notes before asking a question.");
return;
}
if (!cleanQuestion) {
setError("Enter a question before submitting.");
return;
}
if (cleanNotes.length > MAX_NOTES_LENGTH) {
setError(`Keep notes under ${MAX_NOTES_LENGTH.toLocaleString()} characters.`);
return;
}
if (cleanQuestion.length > MAX_QUESTION_LENGTH) {
setError(`Keep the question under ${MAX_QUESTION_LENGTH} characters.`);
return;
}
setError("");
setFailedQuestion(null);
setIsLoading(true);
if (showAsNewMessage) {
setMessages((current) => [
...current,
createMessage("user", cleanQuestion)
]);
setQuestion("");
}
try {
const response = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: cleanNotes, question: cleanQuestion })
});
let data: ApiResponse;
try {
data = (await response.json()) as ApiResponse;
} catch {
throw new Error("The API returned an unreadable response.");
}
if (!response.ok) {
throw new Error(data.error ?? "The assistant request failed.");
}
if (!data.answer) {
throw new Error("The assistant returned an empty answer.");
}
setMessages((current) => [
...current,
createMessage("assistant", data.answer as string)
]);
} catch (requestError) {
const message =
requestError instanceof Error
? requestError.message
: "The assistant could not be reached.";
setError(message);
setFailedQuestion(cleanQuestion);
} finally {
setIsLoading(false);
}
}
function clearChat() {
setMessages([]);
setError("");
setFailedQuestion(null);
}
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">FULL-STACK AI STUDY TOOL</p>
<h1>AI Knowledge Assistant Lite</h1>
<p>
Paste trusted notes, ask a focused question, and keep the API key on
the server.
</p>
</header>
<main className="workspace">
<section className="card context-panel">
<div className="panel-header">
<div>
<p className="step-label">1. Add context</p>
<h2>Study notes</h2>
</div>
<span className="character-count">
{notes.length.toLocaleString()} / {MAX_NOTES_LENGTH.toLocaleString()}
</span>
</div>
<label className="sr-only" htmlFor="notes">
Study notes
</label>
<textarea
id="notes"
className="notes-input"
value={notes}
maxLength={MAX_NOTES_LENGTH}
onChange={(event) => setNotes(event.target.value)}
placeholder="Paste lecture notes, documentation, or revision material here..."
/>
<p className="privacy-note">
Free-tier content may be used to improve Google products. Do not
paste secrets or sensitive personal information.
</p>
</section>
<section className="card assistant-panel">
<div className="panel-header">
<div>
<p className="step-label">2. Ask and review</p>
<h2>Assistant</h2>
</div>
<div className="panel-actions">
<button
className="secondary-button"
type="button"
onClick={clearChat}
disabled={isLoading || messages.length === 0}
>
Clear chat
</button>
</div>
</div>
<div className="chat-log" aria-live="polite">
{messages.length === 0 ? (
<div className="empty-state">
<p>Your questions and grounded answers will appear here.</p>
</div>
) : (
messages.map((message) => (
<article
className={`message message-${message.role}`}
key={message.id}
>
<span className="role-label">
{message.role === "user" ? "You" : "Assistant"}
</span>
<p>{message.text}</p>
</article>
))
)}
{isLoading && (
<article className="message message-assistant loading-message">
<span className="role-label">Assistant</span>
<p>Thinking...</p>
</article>
)}
</div>
{error && (
<div className="error-box" role="alert">
<p>{error}</p>
{failedQuestion && (
<button
className="retry-button"
type="button"
disabled={isLoading}
onClick={() => void sendQuestion(failedQuestion, false)}
>
Retry
</button>
)}
</div>
)}
<form
className="composer"
onSubmit={(event) => {
event.preventDefault();
void sendQuestion(question, true);
}}
>
<label htmlFor="question">Question</label>
<div className="composer-row">
<input
id="question"
value={question}
maxLength={MAX_QUESTION_LENGTH}
onChange={(event) => setQuestion(event.target.value)}
placeholder="What does this material say about...?"
/>
<button
className="primary-button"
type="submit"
disabled={isLoading || !notes.trim() || !question.trim()}
>
{isLoading ? "Sending..." : "Ask"}
</button>
</div>
<span className="question-count">
{question.length} / {MAX_QUESTION_LENGTH}
</span>
</form>
</section>
</main>
<footer>
Direct context, no database, no RAG, and no server-side conversation
history.
</footer>
</div>
);
}
How does the chat flow work?
- The notes state holds the current study material.
- The messages state holds user entries plus assistant entries for the current browser session.
- The sendQuestion() function validates the current text before sending a request.
- The failedQuestion state lets Retry repeat the failed request without adding a duplicate user message.
- Save src/App.tsx.
- Return to the browser tab from the previous step.
You should see the study notes field, the Assistant panel, the Question field, and a disabled Ask button. The page may still look plain until the stylesheet is updated.
Page not updating?
Confirm that src/App.tsx matches the reference exactly. A missing brace or closing tag prevents Vite from refreshing the page.
Check the PowerShell terminal running Vite for the file and line connected to the compile error.
Help me find the mismatch in my App component.
Style the two-panel workspace
The component now contains every visible state of the assistant. The stylesheet turns those states into a responsive notes panel, chat panel, loading message, and inline error region.
- In the Explorer sidebar, select src/index.css.
- Press Ctrl+A to select the starter stylesheet.
- Replace the selected styles with the exact src/index.css reference in the second tab below.
✔️ Awesome, I've got everything!
Your stylesheet now covers the desktop workspace, mobile layout, chat states, form controls, and accessibility helper.
ⓧ I'd like to double check the full code
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #18212f;
background: #edf2f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hero {
padding: 3.5rem max(1.25rem, 6vw) 2.5rem;
color: #f8fbff;
background:
radial-gradient(circle at top right, rgba(77, 208, 225, 0.35), transparent 32%),
linear-gradient(135deg, #132238, #1f3a5f 58%, #27547a);
}
.hero h1 {
max-width: 900px;
margin: 0.35rem 0 0.75rem;
font-size: clamp(2rem, 5vw, 4rem);
line-height: 1.05;
}
.hero p:last-child {
max-width: 690px;
margin: 0;
color: #d5e7f7;
font-size: 1.05rem;
}
.eyebrow,
.step-label,
.role-label {
margin: 0;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow {
color: #7ee4ef;
}
.workspace {
width: min(1180px, calc(100% - 2rem));
margin: -1.25rem auto 2rem;
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 1.25rem;
flex: 1;
}
.card {
min-height: 560px;
padding: 1.4rem;
border: 1px solid #d8e0ea;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 18px 50px rgba(24, 48, 78, 0.12);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.panel-header h2 {
margin: 0.2rem 0 0;
font-size: 1.35rem;
}
.step-label,
.role-label {
color: #55718f;
}
.panel-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.55rem;
}
.character-count,
.question-count {
color: #63758a;
font-size: 0.78rem;
}
.context-panel {
display: flex;
flex-direction: column;
}
.notes-input {
width: 100%;
min-height: 420px;
flex: 1;
resize: vertical;
padding: 1rem;
border: 1px solid #c6d1df;
border-radius: 14px;
color: #162234;
background: #f9fbfd;
line-height: 1.55;
}
.notes-input:focus,
.composer input:focus {
border-color: #2673b8;
outline: 3px solid rgba(38, 115, 184, 0.15);
}
.privacy-note {
margin: 0.85rem 0 0;
color: #6b5360;
font-size: 0.78rem;
line-height: 1.45;
}
.assistant-panel {
display: flex;
flex-direction: column;
}
.chat-log {
min-height: 330px;
max-height: 430px;
display: flex;
flex-direction: column;
gap: 0.8rem;
overflow-y: auto;
padding: 1rem;
border: 1px solid #dbe3ec;
border-radius: 14px;
background: #f5f8fc;
}
.empty-state {
min-height: 270px;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
color: #708095;
}
.message {
max-width: 88%;
padding: 0.8rem 0.95rem;
border-radius: 14px;
white-space: pre-wrap;
}
.message p {
margin: 0.25rem 0 0;
line-height: 1.5;
}
.message-user {
align-self: flex-end;
color: #ffffff;
background: #2368a2;
border-bottom-right-radius: 4px;
}
.message-user .role-label {
color: #cce8ff;
}
.message-assistant {
align-self: flex-start;
background: #ffffff;
border: 1px solid #d7e0ea;
border-bottom-left-radius: 4px;
}
.loading-message {
color: #5a6e84;
}
.error-box {
margin-top: 0.8rem;
padding: 0.8rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border: 1px solid #e8a2a2;
border-radius: 12px;
color: #7f1d1d;
background: #fff1f1;
}
.error-box p {
margin: 0;
}
.composer {
margin-top: auto;
padding-top: 1rem;
}
.composer label {
display: block;
margin-bottom: 0.45rem;
font-weight: 750;
}
.composer-row {
display: flex;
gap: 0.65rem;
}
.composer input {
min-width: 0;
flex: 1;
padding: 0.8rem 0.9rem;
border: 1px solid #c6d1df;
border-radius: 12px;
}
.question-count {
display: block;
margin-top: 0.35rem;
text-align: right;
}
.primary-button,
.secondary-button,
.retry-button {
border-radius: 10px;
font-weight: 750;
}
.primary-button {
padding: 0.8rem 1.1rem;
border: 1px solid #164f7d;
color: #ffffff;
background: #2368a2;
}
.secondary-button,
.retry-button {
padding: 0.58rem 0.75rem;
border: 1px solid #b8c6d6;
color: #26415f;
background: #ffffff;
}
.retry-button {
flex-shrink: 0;
border-color: #d47c7c;
color: #7f1d1d;
}
footer {
padding: 0 1rem 1.5rem;
text-align: center;
color: #65758a;
font-size: 0.85rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 820px) {
.workspace {
grid-template-columns: 1fr;
margin-top: 1rem;
}
.card {
min-height: auto;
}
.notes-input {
min-height: 300px;
}
.composer-row {
flex-direction: column;
}
.primary-button {
width: 100%;
}
}
What does this stylesheet do?
- The .workspace grid places notes beside the assistant on wider screens.
- The message classes give user entries and assistant entries distinct positions.
- The mobile breakpoint stacks the panels when the screen is narrower than 820px.
- Save src/index.css.
- Return to the browser tab from earlier.
You should see a blue hero above two white cards. The left card contains the notes field while the right card contains the empty chat area and question composer.
Layout still looks plain?
Confirm that src/main.tsx still imports ./index.css. The stylesheet cannot affect the page when that import is missing.
Check that selectors such as .workspace and .card match the class names in src/App.tsx.
Help me diagnose my missing assistant styles.
Test the missing API path
The interface now has a complete browser-side request flow. A real submission reveals what currently exists at the other end of that path.
- Return to the assistant at http://localhost:5173.
- Paste The course review session begins on Thursday at 4 PM. into the Study notes field.
- Enter When does the review session begin? in the Question field.
Before you submit, do you think the request can complete with only the Vite process running?
- Click Ask.
You should briefly see the loading state. Your question then stays in the chat beside a connection error and a Retry control.
Why is this failure useful?
The visible error proves that the frontend reaches its request boundary and handles a failed response. The missing side of the contract is the Express API that the next step creates.
- Refresh the browser page.
Your chat message disappears because the conversation lives only in React state. That is the intended privacy boundary for this local assistant.
You now have the complete study interface and a visible request failure that explains the next piece of the architecture. Next, you will create the Express contract that turns this connection error into a deterministic answer.
Create the Express API Contract
Your React interface now collects notes and questions. Its request still ends in a connection error because no server owns /api/ask.
An Express contract gives that request a validated destination with a deterministic answer. A Vite proxy carries browser requests to the new server.
In this step, get ready to:
- Create an Express server with health and question endpoints.
- Connect Vite to Express through the local development proxy.
- Send one question through the complete browser-to-server path.
Create the Express API
A health endpoint provides a quick signal that the server is running. It gives you a simple check before the chat request adds more moving parts.
- In the VS Code Explorer sidebar, select New File.
- Enter server/app.ts as the file path.
- Add the Express startup code by pasting this into server/app.ts:
import express from "express";
const PORT = 3000;
const app = express();
app.use(express.json());
app.get("/api/health", (_request, response) => {
response.json({ status: "ok" });
});
app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});
What Does This Server Code Do?
- The Express application listens for requests on port 3000.
- The express.json() middleware reads incoming JSON request bodies.
- The /api/health route returns a small JSON response that proves the server is reachable.
- Save server/app.ts.
- Create a second integrated PowerShell terminal from the VS Code terminal panel.
The server process keeps this second terminal busy while it listens for requests. Keep your original Vite terminal running.
- Start Express by running this command:
npm run server
What Does This Command Do?
The package script runs server/app.ts through Node.js. It also loads the local .env file into the server process.
You should see API running at http://localhost:3000. The server is now listening for requests.
Server Fails to Start?
Check that the terminal opened inside your project folder. Confirm that server/app.ts is saved inside the server folder.
If the terminal reports a syntax problem, compare the brackets and quotation marks with the code above.
Help me diagnose why my Express server does not start.
The health route proves that Express can respond. The question route now needs a clear rule for accepting browser input.
- In server/app.ts, find this line:
app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});
Why Find This Block?
The request handler must be registered before the server starts listening. This block marks the insertion point.
- Insert this request handler directly above the app.listen block:
app.post("/api/ask", async (request, response) => {
const body =
typeof request.body === "object" && request.body !== null
? (request.body as Record<string, unknown>)
: {};
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
const question =
typeof body.question === "string" ? body.question.trim() : "";
if (!notes || !question) {
return response.status(400).json({
error: "Both notes and question must be non-empty strings."
});
}
const answer = "Mock answer: Your notes and question reached Express.";
return response.json({ answer });
});
How Does the Contract Work?
- The handler accepts requests sent to /api/ask.
- The type checks allow notes and question to continue only when both values are strings.
- The empty-input check returns status 400 when either cleaned value is missing.
- The fixed answer makes the first end-to-end result predictable.
- Save server/app.ts.
- Stop the Express process by pressing Ctrl+C in its terminal.
- Restart Express with the updated request handler by running:
npm run server
Why Restart the Server?
The server script runs without an automatic file watcher. Restarting loads the new /api/ask handler into the active process.
You should see the API startup message again. This confirms that the updated server file still runs.
Seeing a TypeScript Error?
Check that the request handler sits above app.listen. Confirm that its closing brace ends with });.
Help me find the TypeScript error in my Express request handler.
✔️ Awesome, I've got everything!
Great. Save server/app.ts before continuing.
ⓧ I'd like to double check the full code
import express from "express";
const PORT = 3000;
const app = express();
app.use(express.json());
app.get("/api/health", (_request, response) => {
response.json({ status: "ok" });
});
app.post("/api/ask", async (request, response) => {
const body =
typeof request.body === "object" && request.body !== null
? (request.body as Record<string, unknown>)
: {};
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
const question =
typeof body.question === "string" ? body.question.trim() : "";
if (!notes || !question) {
return response.status(400).json({
error: "Both notes and question must be non-empty strings."
});
}
const answer = "Mock answer: Your notes and question reached Express.";
return response.json({ answer });
});
app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});
What Should Match?
Use this full-file reference to compare the import, middleware, two routes, validation logic, mock answer, and listener placement.
Connect Vite to Express
The browser still sends its request to Vite on port 5173. The development proxy forwards every /api request to Express on port 3000.
- In the VS Code Explorer sidebar, select New File.
- Enter vite.config.ts as the file name.
- Configure the development proxy by pasting this into vite.config.ts:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000"
}
}
});
What Does the Proxy Do?
The React plugin keeps the existing frontend setup active. The proxy sends matching development requests to Express while the browser continues using port 5173.
The browser sees one local origin through Vite. This keeps CORS configuration out of this local development path.
- Save vite.config.ts.
- Switch back to the PowerShell terminal running Vite.
- Stop Vite by pressing Ctrl+C.
- Restart Vite with the proxy configuration by running:
npm run dev
Why Restart Vite?
Restarting makes Vite load vite.config.ts. The new development server can now forward /api requests.
Before you check the health route, which process do you think answers a request sent through port 5173?
- Open http://localhost:5173/api/health in your browser.
You should see {"status":"ok"}. The response proves that Vite forwarded the browser request to Express.
Health Route Does Not Load?
Confirm that both terminals are still running. The Vite terminal should use port 5173. The Express terminal should use port 3000.
Check that the proxy key is /api and its target is http://localhost:3000.
Help me troubleshoot why my Vite proxy cannot reach the Express health route.
✔️ Awesome, I've got everything!
Your Vite proxy is saved and the health request reaches Express.
ⓧ I'd like to double check the full code
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000"
}
}
});
What Should Match?
Use this full-file reference to compare the two imports, React plugin, proxy path, and Express target.
Verify the Browser-to-Server Path
The health response confirms that the proxy works. A real chat submission now checks the full contract from the controlled inputs through /api/ask and back into the message list.
- Return to http://localhost:5173 in your browser.
- Click Clear chat to remove the earlier failed request.
- Enter Mars has two moons named Phobos and Deimos. in the Study notes field.
- Enter How many moons does Mars have? in the Question field.
Before you submit, do you expect the earlier connection error or a response from the new API contract?
- Click Ask.
You should see your question followed by Mock answer: Your notes and question reached Express. The connection error is gone.
That is the complete request path working. React sends the data through Vite before Express validates it and returns the mock answer.
Your browser and server now share a working contract. Next, you will replace the fixed response with an answer grounded in the supplied notes.
Replace the Mock with Gemini
Your React client can now reach Express through a stable API contract. The deterministic mock proves the browser-to-server path works.
The mock cannot answer from the supplied notes. In this step, Gemini replaces that response while the existing endpoint stays unchanged.
In this step, get ready to:
- Initialize the Gemini client from the existing server-side API key.
- Construct a grounded prompt from the validated notes and question.
- Send the prompt to Gemini with server-only key protection.
Initialize Gemini on the server
The API key belongs inside the server process. The Google Gen AI SDK for TypeScript and JavaScript uses that key to create a client that browser code cannot access.
Working around a live key can feel risky. This edit only reads the existing value from .env inside the server process.
- In server/app.ts, select the opening block from the current Express import through const app = express();.
- Replace the selected block with this code:
import { GoogleGenAI } from "@google/genai";
import express from "express";
const PORT = 3000;
const MAX_NOTES_LENGTH = 12_000;
const MAX_QUESTION_LENGTH = 500;
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.error("Missing GEMINI_API_KEY. Add it to the local .env file.");
process.exit(1);
}
const ai = new GoogleGenAI({ apiKey });
const app = express();
What does this startup code do?
- The GoogleGenAI import makes the Gemini client available only in server/app.ts.
- The GEMINI_API_KEY lookup reads the value loaded from your local .env file.
- The missing-key guard stops the server before it accepts requests without valid credentials.
- The ai client becomes the server-only connection to Gemini.
- Save server/app.ts.
- In the Express terminal from earlier, press Ctrl+C to stop the current server.
- Restart the Express server by running this command:
npm run server
What does this command confirm?
The server script loads .env before executing server/app.ts. A successful startup confirms that the key is available to the server process.
You should see API running at http://localhost:3000. Your server is now ready to make authenticated Gemini requests.
Does the server stop during startup?
If you see Missing GEMINI_API_KEY. Add it to the local .env file., confirm that .env is inside the ai-knowledge-assistant-lite folder. Check that the file contains GEMINI_API_KEY with your real key as its value.
Restart the server after saving the file. The environment file is loaded only when the process starts.
Help me diagnose why my Express server cannot read GEMINI_API_KEY from .env.
Build the grounded prompt
A grounded prompt tells the model which evidence it may use. Clear constraints prevent the answer from drifting beyond the notes.
The notes sit inside explicit tags. The question comes last so the model reads the supplied context before deciding what to answer.
- In server/app.ts, select the opening portion of the current POST /api/ask handler through its existing validation block.
- Replace the selected portion with this code:
app.post("/api/ask", async (request, response) => {
const body =
typeof request.body === "object" && request.body !== null
? (request.body as Record<string, unknown>)
: {};
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
const question =
typeof body.question === "string" ? body.question.trim() : "";
if (!notes || !question) {
return response.status(400).json({
error: "Both notes and question must be non-empty strings."
});
}
if (notes.length > MAX_NOTES_LENGTH) {
return response.status(400).json({
error: `Notes must be ${MAX_NOTES_LENGTH.toLocaleString()} characters or fewer.`
});
}
if (question.length > MAX_QUESTION_LENGTH) {
return response.status(400).json({
error: `Question must be ${MAX_QUESTION_LENGTH} characters or fewer.`
});
}
How does the validation protect the model call?
- The body check treats every incoming request as untrusted data.
- The string checks prevent objects or other unexpected values from reaching the prompt.
- The trim() calls reject inputs that contain only whitespace.
- The length checks keep each model request within the limits defined by this project.
- Place this prompt block immediately below the validation code:
const prompt = [
"You are a study assistant.",
"Use only the supplied notes to answer the question.",
"If the answer is not explicitly supported by the notes, reply exactly: I can't find that in the supplied notes.",
"Do not add facts from your own knowledge.",
"",
"<notes>",
notes,
"</notes>",
"",
"<question>",
question,
"</question>"
].join("\n");
How does the prompt stay grounded?
- The opening instructions limit answers to facts supported by the notes.
- The limitation response gives the model an exact fallback when the notes lack an answer.
- The <notes> tags separate source material from behavioral instructions.
- The <question> tags place the learner's request after the context.
- Save server/app.ts.
- In the Express terminal, press Ctrl+C to stop the server.
- Restart the server with the updated validation and prompt by running:
npm run server
What does this restart prove?
A successful restart proves that Node.js can execute the updated handler. The server remains available at http://localhost:3000.
- Return to the browser tab running the assistant.
- Submit the notes and question from your earlier mock test.
You should still see the deterministic mock answer. The browser-to-server contract remains intact while the grounded prompt is now ready for the model call.
Does the request stop returning the mock answer?
Check that the prompt block sits inside the POST /api/ask handler. It must appear after the validation blocks and before the existing mock response.
Help me find a syntax or placement error in my Express prompt block.
Call Gemini and return the answer
The Interactions API accepts the finished prompt and returns the model's text output. Setting store: false keeps each request independent from earlier interactions.
- In server/app.ts, select the deterministic mock response at the end of the POST /api/ask handler.
- Replace the mock response and the handler's closing line with this code:
try {
const interaction = await ai.interactions.create({
model: "gemini-3.8-flash",
input: prompt,
store: false
});
const answer = interaction.output_text?.trim();
if (!answer) {
return response.status(502).json({
error: "Gemini returned an empty response."
});
}
return response.json({ answer });
} catch (error) {
console.error("Gemini request failed:", error);
return response.status(502).json({
error: "Gemini could not answer right now. Please try again manually."
});
}
});
What does the Gemini call do?
- The ai.interactions.create() call sends the grounded prompt to gemini-3.8-flash.
- The store: false option prevents this interaction from being stored for later interaction chaining.
- The output_text value contains the final text returned by Gemini.
- The empty-output and request-failure branches return readable errors through the existing API contract.
Why direct context instead of RAG?
This app sends one notes block directly with every question. That keeps the request path visible while you learn how the browser, Express server, and model communicate.
A RAG workflow imports documents into a persistent search system. It then chunks, embeds, indexes, and retrieves relevant pieces for each question.
- Save server/app.ts.
- In the Express terminal, press Ctrl+C to stop the mock server.
- Start the Gemini-enabled server by running:
npm run server
What should the server show?
The terminal should show API running at http://localhost:3000 and remain occupied by the running server. Keep this terminal open while you test the assistant.
- Return to the browser tab running at http://localhost:5173.
- Press Ctrl+Shift+I to open your browser's developer tools.
- Select the Network tool.
- Replace the contents of Study notes with The water cycle moves water through evaporation, condensation, and precipitation..
- Enter Which three processes are named in the notes? in the Question field.
Before you submit, do you expect the answer to stay inside the supplied sentence or add outside facts?
- Click Ask.
You should see a Gemini answer naming evaporation, condensation, and precipitation. The mock response is gone.
That is the full request path working. Your React interface now receives an answer grounded in the notes through the secure Express endpoint.
- Select the /api/ask request in the Network table.
- Select the Payload tab for that request.
The payload should contain only notes and question. The API key does not appear in the browser request.
- Select the Sources tool.
- Search all loaded source files for GEMINI_API_KEY.
You should find no browser source containing the environment variable or its value. The key remains confined to server/app.ts and the server process.
Does Gemini fail to answer?
Check the Express terminal for the logged Gemini failure. Confirm that the API key in .env is active without pasting the key into chat or browser tools.
Check that the model is exactly gemini-3.8-flash. Confirm that input receives prompt.
Help me debug my Gemini Interactions API request without exposing my API key.
Use these tabs to confirm that your completed server file matches the target version.
✔️ Awesome, I've got everything!
Great. Double-check that server/app.ts is saved while both development processes remain running.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from "@google/genai";
import express from "express";
const PORT = 3000;
const MAX_NOTES_LENGTH = 12_000;
const MAX_QUESTION_LENGTH = 500;
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.error("Missing GEMINI_API_KEY. Add it to the local .env file.");
process.exit(1);
}
const ai = new GoogleGenAI({ apiKey });
const app = express();
app.use(express.json({ limit: "20kb" }));
app.get("/api/health", (_request, response) => {
response.json({ status: "ok" });
});
app.post("/api/ask", async (request, response) => {
const body =
typeof request.body === "object" && request.body !== null
? (request.body as Record<string, unknown>)
: {};
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
const question =
typeof body.question === "string" ? body.question.trim() : "";
if (!notes || !question) {
return response.status(400).json({
error: "Both notes and question must be non-empty strings."
});
}
if (notes.length > MAX_NOTES_LENGTH) {
return response.status(400).json({
error: `Notes must be ${MAX_NOTES_LENGTH.toLocaleString()} characters or fewer.`
});
}
if (question.length > MAX_QUESTION_LENGTH) {
return response.status(400).json({
error: `Question must be ${MAX_QUESTION_LENGTH} characters or fewer.`
});
}
const prompt = [
"You are a study assistant.",
"Use only the supplied notes to answer the question.",
"If the answer is not explicitly supported by the notes, reply exactly: I can't find that in the supplied notes.",
"Do not add facts from your own knowledge.",
"",
"<notes>",
notes,
"</notes>",
"",
"<question>",
question,
"</question>"
].join("\n");
try {
const interaction = await ai.interactions.create({
model: "gemini-3.8-flash",
input: prompt,
store: false
});
const answer = interaction.output_text?.trim();
if (!answer) {
return response.status(502).json({
error: "Gemini returned an empty response."
});
}
return response.json({ answer });
} catch (error) {
console.error("Gemini request failed:", error);
return response.status(502).json({
error: "Gemini could not answer right now. Please try again manually."
});
}
});
app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});
How to use this reference
Compare this reference with your saved server/app.ts from top to bottom. Keep every identifier, string, and route exactly aligned.
Your assistant now answers from supplied notes through a server-only Gemini integration. Next, you will harden the flow with request limits, clearer loading behavior, retry controls, and a production build check.
Harden the Study Flow
Your React interface now reaches Gemini through a secure Express endpoint. The complete browser-to-model path works.
A real API can still receive malformed input or become temporarily unavailable. This final pass makes those failures understandable without automatic retries or surprise requests.
In this step, get ready to:
- Enforce matching request limits in the browser and server.
- Add loading states plus readable errors with manual retry controls.
- Clear frontend-only chat history and confirm a successful production build.
Enforce the server request limits
Every request body is untrusted input. The server must check its shape and size before sending anything to Gemini.
- Switch back to server/app.ts in Visual Studio Code.
- Find the existing express.json() middleware line.
- Replace that line with the request-size limit below:
app.use(express.json({ limit: "20kb" }));
What does this limit do?
- The JSON parser rejects request bodies beyond 20kb before the route reaches Gemini.
- This body limit provides an outer boundary around the smaller character limits checked inside the route.
- Locate the current body-reading and input-validation section inside app.post("/api/ask").
- Replace that section with the next two chunks in the same order:
const body =
typeof request.body === "object" && request.body !== null
? (request.body as Record<string, unknown>)
: {};
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
const question =
typeof body.question === "string" ? body.question.trim() : "";
if (!notes || !question) {
return response.status(400).json({
error: "Both notes and question must be non-empty strings."
});
}
How does the shape check work?
- The body check accepts a non-null object before reading any fields.
- The field checks accept only string values for notes and question.
- Trimming catches strings that contain only whitespace.
- Place the character-limit checks directly below the non-empty check:
if (notes.length > MAX_NOTES_LENGTH) {
return response.status(400).json({
error: `Notes must be ${MAX_NOTES_LENGTH.toLocaleString()} characters or fewer.`
});
}
if (question.length > MAX_QUESTION_LENGTH) {
return response.status(400).json({
error: `Question must be ${MAX_QUESTION_LENGTH} characters or fewer.`
});
}
Why check both fields separately?
- The notes limit protects the larger context sent with each request.
- The question limit keeps each user request focused.
- Each response names the field that failed so the browser can show a useful message.
- Save server/app.ts.
- Stop the Express process in its PowerShell terminal by pressing Ctrl+C.
- Restart the hardened API by running:
npm run server
What should the server confirm?
The server starts with the same local API address. This confirms the stricter parser and validation code compile successfully.
Before submitting another question, consider whether a valid request should behave differently after these checks.
- Return to the study assistant in your browser.
- Submit a supported question using the notes already in the app.
You should see another grounded answer. The stricter checks preserve the successful path while blocking malformed or oversized requests.
Server no longer starts?
Check that the parser line uses matching braces around the limit option. Confirm that both character checks remain inside the /api/ask handler.
Ask for targeted help with the server edit:
Help me compare my Express body validation with the required server code.
Use the tabs below to compare the complete hardened server before moving to the browser changes.
✔️ My server matches
Your API now validates body shape, required strings, field lengths, and total JSON size before calling Gemini.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from "@google/genai";
import express from "express";
const PORT = 3000;
const MAX_NOTES_LENGTH = 12_000;
const MAX_QUESTION_LENGTH = 500;
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.error("Missing GEMINI_API_KEY. Add it to the local .env file.");
process.exit(1);
}
const ai = new GoogleGenAI({ apiKey });
const app = express();
app.use(express.json({ limit: "20kb" }));
app.get("/api/health", (_request, response) => {
response.json({ status: "ok" });
});
app.post("/api/ask", async (request, response) => {
const body =
typeof request.body === "object" && request.body !== null
? (request.body as Record<string, unknown>)
: {};
const notes = typeof body.notes === "string" ? body.notes.trim() : "";
const question =
typeof body.question === "string" ? body.question.trim() : "";
if (!notes || !question) {
return response.status(400).json({
error: "Both notes and question must be non-empty strings."
});
}
if (notes.length > MAX_NOTES_LENGTH) {
return response.status(400).json({
error: `Notes must be ${MAX_NOTES_LENGTH.toLocaleString()} characters or fewer.`
});
}
if (question.length > MAX_QUESTION_LENGTH) {
return response.status(400).json({
error: `Question must be ${MAX_QUESTION_LENGTH} characters or fewer.`
});
}
const prompt = [
"You are a study assistant.",
"Use only the supplied notes to answer the question.",
"If the answer is not explicitly supported by the notes, reply exactly: I can't find that in the supplied notes.",
"Do not add facts from your own knowledge.",
"",
"<notes>",
notes,
"</notes>",
"",
"<question>",
question,
"</question>"
].join("\n");
try {
const interaction = await ai.interactions.create({
model: "gemini-3.8-flash",
input: prompt,
store: false
});
const answer = interaction.output_text?.trim();
if (!answer) {
return response.status(502).json({
error: "Gemini returned an empty response."
});
}
return response.json({ answer });
} catch (error) {
console.error("Gemini request failed:", error);
return response.status(502).json({
error: "Gemini could not answer right now. Please try again manually."
});
}
});
app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});
How to use this reference
Compare the imports, constants, middleware, validation order, prompt, Gemini request, and error responses with your saved file. Keep the API key initialization only in this server file.
Add matching browser safeguards
Matching browser limits give immediate feedback before a request leaves the page. Server validation remains the final authority because browser checks can be bypassed.
- Switch back to src/App.tsx.
- Add the two frontend limits directly below the ApiResponse type:
const MAX_NOTES_LENGTH = 12_000;
const MAX_QUESTION_LENGTH = 500;
Why match the server values?
The interface prevents input beyond the same boundaries enforced by Express. Matching values keep browser feedback consistent with the API contract.
- Replace the state declarations at the start of App with this complete state group:
const [notes, setNotes] = useState("");
const [question, setQuestion] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [failedQuestion, setFailedQuestion] = useState<string | null>(null);
What does the new state track?
- The isLoading value prevents overlapping requests.
- The error value preserves a readable failure message.
- The failedQuestion value holds the last failed question for a manual retry.
The final sendQuestion function is longer than one safe editing chunk. The next three snippets form one function in sequence.
- Replace the current sendQuestion function with the first function section below:
async function sendQuestion(
questionToSend: string,
showAsNewMessage: boolean
) {
const cleanNotes = notes.trim();
const cleanQuestion = questionToSend.trim();
if (!cleanNotes) {
setError("Paste some study notes before asking a question.");
return;
}
if (!cleanQuestion) {
setError("Enter a question before submitting.");
return;
}
if (cleanNotes.length > MAX_NOTES_LENGTH) {
setError(`Keep notes under ${MAX_NOTES_LENGTH.toLocaleString()} characters.`);
return;
}
if (cleanQuestion.length > MAX_QUESTION_LENGTH) {
setError(`Keep the question under ${MAX_QUESTION_LENGTH} characters.`);
return;
}
What does the first section protect?
The function trims both inputs before checking them. It stops empty or oversized values before starting a request.
- Continue the same function with the request setup below:
setError("");
setFailedQuestion(null);
setIsLoading(true);
if (showAsNewMessage) {
setMessages((current) => [
...current,
createMessage("user", cleanQuestion)
]);
setQuestion("");
}
try {
const response = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: cleanNotes, question: cleanQuestion })
});
let data: ApiResponse;
try {
data = (await response.json()) as ApiResponse;
} catch {
throw new Error("The API returned an unreadable response.");
}
How does this prevent duplicate messages?
A normal submission passes true for showAsNewMessage. A retry passes false so the original user message stays single.
The request still sends only the cleaned notes and question. The API key remains inside the Git-ignored .env file used by the server.
- Finish the same function with the response and failure handling below:
if (!response.ok) {
throw new Error(data.error ?? "The assistant request failed.");
}
if (!data.answer) {
throw new Error("The assistant returned an empty answer.");
}
setMessages((current) => [
...current,
createMessage("assistant", data.answer as string)
]);
} catch (requestError) {
const message =
requestError instanceof Error
? requestError.message
: "The assistant could not be reached.";
setError(message);
setFailedQuestion(cleanQuestion);
} finally {
setIsLoading(false);
}
}
How does failure recovery work?
API errors and connection failures enter the same readable error path. The failed question is saved only for a deliberate retry.
The finally block always ends the loading state. This restores the controls after success or failure.
- Save src/App.tsx.
- Return to the browser after Vite refreshes the page.
- Submit a supported question using your existing notes.
The request should still return a grounded answer. The browser now records loading and failure state around each request.
- Replace the notes textarea with this limited controlled input:
<textarea
id="notes"
className="notes-input"
value={notes}
maxLength={MAX_NOTES_LENGTH}
onChange={(event) => setNotes(event.target.value)}
placeholder="Paste lecture notes, documentation, or revision material here..."
/>
What changes in the notes field?
The textarea stops accepting input at the notes limit. Its existing counter displays progress against the same constant.
- Replace the question input and submit control with this limited loading-aware group:
<input
id="question"
value={question}
maxLength={MAX_QUESTION_LENGTH}
onChange={(event) => setQuestion(event.target.value)}
placeholder="What does this material say about...?"
/>
<button
className="primary-button"
type="submit"
disabled={isLoading || !notes.trim() || !question.trim()}
>
{isLoading ? "Sending..." : "Ask"}
</button>
</div>
<span className="question-count">
{question.length} / {MAX_QUESTION_LENGTH}
</span>
What changes in the composer?
The question field stops at its matching limit. The submit button stays disabled during an active request or while either required input is blank.
- Place the loading message inside chat-log after the existing message list:
{isLoading && (
<article className="message message-assistant loading-message">
<span className="role-label">Assistant</span>
<p>Thinking...</p>
</article>
)}
Why show a chat message for loading?
The loading message appears where the answer will arrive. It gives the request a visible state while the controls remain disabled.
- Save src/App.tsx.
- Submit another supported question in the browser.
You should briefly see Thinking... in the chat. The submit button should show Sending... until the grounded answer arrives.
Loading state stays visible?
Confirm that setIsLoading(false) remains inside the finally block. Check that both local terminals are still running.
Get help tracing the request state:
Help me debug a React request that stays in its loading state.
Add retry and clear-chat controls
A failed request should remain recoverable without silently repeating itself. Chat history also needs an explicit reset because every message lives only in React memory.
- Add clearChat directly below sendQuestion inside App:
function clearChat() {
setMessages([]);
setError("");
setFailedQuestion(null);
}
What does clearing reset?
The function removes the chat messages plus any visible error and saved retry question. It leaves the pasted notes available for the learner's next question.
- Place the clear control inside the assistant panel header:
<div className="panel-actions">
<button
className="secondary-button"
type="button"
onClick={clearChat}
disabled={isLoading || messages.length === 0}
>
Clear chat
</button>
</div>
When is clearing available?
The control is available only when messages exist and no request is active. This prevents the chat from changing underneath an unfinished request.
- Save src/App.tsx.
- Click Clear chat in the browser.
The chat should return to its empty state. Any earlier error should disappear with the messages.
- Replace the existing inline error region with this retry-aware error box:
{error && (
<div className="error-box" role="alert">
<p>{error}</p>
{failedQuestion && (
<button
className="retry-button"
type="button"
disabled={isLoading}
onClick={() => void sendQuestion(failedQuestion, false)}
>
Retry
</button>
)}
</div>
)}
Why is retry manual?
The button repeats the saved question only after you choose to retry. The app never sends background requests or automatic retries.
Passing false keeps the existing user message in place. A successful retry adds only the assistant response.
- Save src/App.tsx.
Use the tabs below to compare the complete frontend before running the production check.
✔️ My frontend matches
Your interface now limits input, displays loading, preserves readable errors, retries deliberately, and clears frontend-only chat.
ⓧ I'd like to double check the full code
import { useState } from "react";
type Message = {
id: number;
role: "user" | "assistant";
text: string;
};
type ApiResponse = {
answer?: string;
error?: string;
};
const MAX_NOTES_LENGTH = 12_000;
const MAX_QUESTION_LENGTH = 500;
let nextMessageId = 1;
function createMessage(role: Message["role"], text: string): Message {
return { id: nextMessageId++, role, text };
}
export default function App() {
const [notes, setNotes] = useState("");
const [question, setQuestion] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [failedQuestion, setFailedQuestion] = useState<string | null>(null);
async function sendQuestion(
questionToSend: string,
showAsNewMessage: boolean
) {
const cleanNotes = notes.trim();
const cleanQuestion = questionToSend.trim();
if (!cleanNotes) {
setError("Paste some study notes before asking a question.");
return;
}
if (!cleanQuestion) {
setError("Enter a question before submitting.");
return;
}
if (cleanNotes.length > MAX_NOTES_LENGTH) {
setError(`Keep notes under ${MAX_NOTES_LENGTH.toLocaleString()} characters.`);
return;
}
if (cleanQuestion.length > MAX_QUESTION_LENGTH) {
setError(`Keep the question under ${MAX_QUESTION_LENGTH} characters.`);
return;
}
setError("");
setFailedQuestion(null);
setIsLoading(true);
if (showAsNewMessage) {
setMessages((current) => [
...current,
createMessage("user", cleanQuestion)
]);
setQuestion("");
}
try {
const response = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: cleanNotes, question: cleanQuestion })
});
let data: ApiResponse;
try {
data = (await response.json()) as ApiResponse;
} catch {
throw new Error("The API returned an unreadable response.");
}
if (!response.ok) {
throw new Error(data.error ?? "The assistant request failed.");
}
if (!data.answer) {
throw new Error("The assistant returned an empty answer.");
}
setMessages((current) => [
...current,
createMessage("assistant", data.answer as string)
]);
} catch (requestError) {
const message =
requestError instanceof Error
? requestError.message
: "The assistant could not be reached.";
setError(message);
setFailedQuestion(cleanQuestion);
} finally {
setIsLoading(false);
}
}
function clearChat() {
setMessages([]);
setError("");
setFailedQuestion(null);
}
return (
<div className="app-shell">
<header className="hero">
<p className="eyebrow">FULL-STACK AI STUDY TOOL</p>
<h1>AI Knowledge Assistant Lite</h1>
<p>
Paste trusted notes, ask a focused question, and keep the API key on
the server.
</p>
</header>
<main className="workspace">
<section className="card context-panel">
<div className="panel-header">
<div>
<p className="step-label">1. Add context</p>
<h2>Study notes</h2>
</div>
<span className="character-count">
{notes.length.toLocaleString()} / {MAX_NOTES_LENGTH.toLocaleString()}
</span>
</div>
<label className="sr-only" htmlFor="notes">
Study notes
</label>
<textarea
id="notes"
className="notes-input"
value={notes}
maxLength={MAX_NOTES_LENGTH}
onChange={(event) => setNotes(event.target.value)}
placeholder="Paste lecture notes, documentation, or revision material here..."
/>
<p className="privacy-note">
Free-tier content may be used to improve Google products. Do not
paste secrets or sensitive personal information.
</p>
</section>
<section className="card assistant-panel">
<div className="panel-header">
<div>
<p className="step-label">2. Ask and review</p>
<h2>Assistant</h2>
</div>
<div className="panel-actions">
<button
className="secondary-button"
type="button"
onClick={clearChat}
disabled={isLoading || messages.length === 0}
>
Clear chat
</button>
</div>
</div>
<div className="chat-log" aria-live="polite">
{messages.length === 0 ? (
<div className="empty-state">
<p>Your questions and grounded answers will appear here.</p>
</div>
) : (
messages.map((message) => (
<article
className={`message message-${message.role}`}
key={message.id}
>
<span className="role-label">
{message.role === "user" ? "You" : "Assistant"}
</span>
<p>{message.text}</p>
</article>
))
)}
{isLoading && (
<article className="message message-assistant loading-message">
<span className="role-label">Assistant</span>
<p>Thinking...</p>
</article>
)}
</div>
{error && (
<div className="error-box" role="alert">
<p>{error}</p>
{failedQuestion && (
<button
className="retry-button"
type="button"
disabled={isLoading}
onClick={() => void sendQuestion(failedQuestion, false)}
>
Retry
</button>
)}
</div>
)}
<form
className="composer"
onSubmit={(event) => {
event.preventDefault();
void sendQuestion(question, true);
}}
>
<label htmlFor="question">Question</label>
<div className="composer-row">
<input
id="question"
value={question}
maxLength={MAX_QUESTION_LENGTH}
onChange={(event) => setQuestion(event.target.value)}
placeholder="What does this material say about...?"
/>
<button
className="primary-button"
type="submit"
disabled={isLoading || !notes.trim() || !question.trim()}
>
{isLoading ? "Sending..." : "Ask"}
</button>
</div>
<span className="question-count">
{question.length} / {MAX_QUESTION_LENGTH}
</span>
</form>
</section>
</main>
<footer>
Direct context, no database, no RAG, and no server-side conversation
history.
</footer>
</div>
);
}
How to use this reference
Compare the state declarations, request function, limits, loading message, error box, buttons, and disabled conditions with your saved file. The complete file should match before you build.
The production build checks the complete TypeScript project and creates the optimized browser files. This catches type or JSX errors that a quick browser test can miss.
- Stop the Vite process in its PowerShell terminal by pressing Ctrl+C.
Before the build, predict whether the hardened frontend and server pass the complete project check.
- Run the production type-check and build with:
npm run build
What does the build prove?
The script checks the TypeScript project before Vite creates the production bundle. A successful completion confirms the frontend and server code agree with the strict configuration.
That is the production check complete. Your hardened full-stack project now compiles successfully.
- Restart the Vite development server by running:
npm run dev
Why restart Vite?
The local development server restores the browser app for the final failure-recovery test. The Express process remains in the other PowerShell terminal.
Before the final check, consider whether one failed request should produce a retry control without adding the same user message twice.
- Stop the Express process in its PowerShell terminal by pressing Ctrl+C.
- Return to the study assistant in your browser.
- Enter a question supported by your current notes.
- Click Ask once.
You should see your question once followed by a visible connection error. A Retry control should appear beneath the error.
- Restart the Express API in its PowerShell terminal by running:
npm run server
What changes after the restart?
The saved failed question can reach the API again. The app waits for you to choose the retry instead of sending it automatically.
- Click Retry once.
You should see the grounded assistant answer appear beneath the original question. The user message should still appear only once.
- Refresh the browser page.
The chat should return to its empty state because messages exist only in React memory. No database, RAG store, or server-side conversation history preserves them.
One request at a time
The app sends one Gemini request only after an intentional submit or retry. It does not use automatic retries, background execution, or Google Search.
Retry adds a duplicate question?
Confirm that the retry button calls sendQuestion(failedQuestion, false). The normal form submission should call sendQuestion(question, true).
Ask for help comparing the two paths:
Help me stop Retry from duplicating the user message in my React chat.
You have finished the reliability pass. The assistant now validates both sides, recovers from connection failures, avoids duplicate requests, clears local chat state, and passes its production build.
Secret mission
Add One-Click Summaries
Turn pasted notes into five concise study bullets with one click. Reuse the existing secure endpoint. The new action inherits the current loading, error, and Retry flow.
Clean Up Your Resources
Clean Up Your Resources
This project runs locally with no ongoing cost from stopped terminals or saved files. Gemini free-tier requests cost $0 while Cloud Billing stays disabled.
Resources you used:
- Two Windows PowerShell terminals running Vite and Express.
- Local project files containing .env, package-lock.json, installed packages, source code, and production build files.
- A project authorization key managed through Google AI Studio.
Keep everything running
No action needed. Choose this if you want to keep testing questions or summaries in your local assistant.
- Keep both PowerShell terminals running while you use the assistant.
- Keep .env on your local machine because it contains GEMINI_API_KEY.
- Expect a Gemini request only when you select Ask, Summarize notes, or Retry.
- Use only non-sensitive study notes because free-tier content may be used to improve Google products.
Pause - I'll come back to this later
Stopping the local processes preserves your project files for later. Removing the value from .env does not delete the remote authorization key in Google AI Studio.
- Stop the Vite process in its PowerShell terminal by pressing Ctrl+C.
- Stop the Express process in its PowerShell terminal by pressing Ctrl+C.
- Replace only the real key value in .env with your-api-key-here.
- Keep the remaining project files unchanged for your next session.
Delete - I don't want to use this again
Deleting the local project folder is permanent. The remote authorization key needs separate removal because deleting .env only erases the local copy.
- Stop the Vite process in its PowerShell terminal by pressing Ctrl+C.
- Stop the Express process in its PowerShell terminal by pressing Ctrl+C.
- Disable or delete the authorization key for this project from the Google AI Studio key-management page.
- Use Windows file management to delete the local project folder that contains package.json.
- Confirm that the folder no longer appears in its previous location.
Nice Work!
Nice Work!
Mission complete. Your full-stack React study assistant now turns pasted notes into grounded answers through a secure Express API.
What you learned:
- Built a responsive study interface with controlled inputs. Kept the complete chat history in frontend React memory.
- Protected the Gemini API key behind a validated TypeScript Express API. Stored the credential only in the local .env file.
- Integrated the Gemini Developer API through stateless direct-context requests. Made failures recoverable with readable errors plus manual Retry. Confirmed release readiness with a successful production build.
- Secret Mission: Added one-click summaries that reuse the same secure endpoint to produce five concise study bullets.
Ready to quiz yourself?