AI Helper
The only component library upgrade you'll ever need.
I'm sure you just clicked on the button above and were confused, dumbass. You need to click on the AI Helper button floating at the bottom right of your screen and try to say "say hello to me" or "turn off dark mode" or even better "fill in the email as your.name@email.com" and see the magic unfold.
Ok there is a lot that we need to cover and learn in this documentation so sit back, relax and deploy ai helper within seconds in your application.
Intro
Listen up. You're probably used to "chatbots" that are just glorified search bars. This isn't that.
The AI Helper is a digital nervous system for your application. It reads your DOM, knows your current route, and—if you're smart enough to configure it correctly—it can physically click buttons, fill inputs, and navigate your app for the user.
It turns "How do I turn off dark mode?" from a support ticket into an automated action. It turns "Sign me up" into a filled form.
We support Google Gemini (fast, cheap) AND OpenAI (smart, expensive). Pick your poison. We don't care which billionaire you want to give your money to, we just make it work.
Installation
I know you hate reading, but you need to do this part or nothing works.
Get your keys
Decide who you want to pay. Get an API key. I'll wait.
Add them to your .env.local. You only need the one you plan to use, but don't come crying to me if you forget it.
Add it to your .env.local:
GEMINI_API_KEY=AIzaSyYourKeyHere...Now install the relevant package for it:
npm install @google/generative-aiOPENAI_API_KEY=sk-YourKeyHere...Now install the relevant package for it:
npm install openaiCopy the Core Files
You need three files. The ai-context.tsx file would've already been created in your contexts folder if you followed the getting-started or manual-setup guide. Back to the remaining 2 files.
app/actions/ask-ai.ts (The Muscle - Server Action)
components/ui/ai/ai-helper.tsx (The Face)
Ok so let's start with ✨THE MUSCLE✨
Setting up Server Action
Add the ask-ai.ts file to your app/actions folder. This is the server action that will communicate with your AI provider.
Copy the entire code below:
"use server";
import {
GoogleGenerativeAI,
SchemaType,
FunctionCallingMode,
Tool,
Content // Import Content type
} from "@google/generative-ai";
import { AIContextState } from '@/contexts/ai-context';
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
// Define the Message type here or import it if you have it in a shared types file
export interface AIMessage {
role: 'user' | 'assistant';
content: string;
}
export interface AIResponse {
message: string;
toolCall?: {
componentId: string;
actionName: string;
actionValue?: string;
};
}
export async function askAI(
userMessage: string,
contextState: AIContextState,
history: AIMessage[] = [] // 🆕 Accept History
): Promise<AIResponse> {
try {
// 1. Prepare Tools Context
const toolsContext = Array.from(contextState.activeComponents.values()).map(comp => ({
id: comp.id,
description: comp.description,
actions: comp.availableActions
}));
// 2. Define Tools
const tools = [{
functionDeclarations: [{
name: "trigger_component",
description: "Triggers an action on a specific UI component based on its ID.",
parameters: {
type: SchemaType.OBJECT,
properties: {
componentId: { type: SchemaType.STRING, description: "The ID of the component" },
actionName: { type: SchemaType.STRING, description: "The action to perform" },
actionValue: { type: SchemaType.STRING, description: "Value if provided for an action" }
},
required: ["componentId", "actionName"]
}
}]
}];
// 3. 🆕 Format History for Gemini
// Gemini expects roles to be 'user' or 'model'
let formattedHistory: Content[] = history.map(msg => ({
role: msg.role === 'user' ? 'user' : 'model',
parts: [{ text: msg.content }]
}));
// 🚨 THE FIX: Sanitize History
// Gemini crashes if history starts with 'model'.
// We remove the first item if it is not from 'user'.
if (formattedHistory.length > 0 && formattedHistory[0].role === 'model') {
formattedHistory.shift();
}
// 4. Initialize Model
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash-lite",
tools: tools as Tool[],
toolConfig: { functionCallingConfig: { mode: FunctionCallingMode.AUTO } },
systemInstruction: `
You are an intelligent interface assistant.
=== YOUR PERSONA ===
${contextState.assistantPersona === 'sales_rep'
? 'You are a charismatic Sales Representative. Focus on upselling and benefits.'
: 'You are a helpful, technical Guide. Focus on clarity and accuracy.'}
=== KNOWLEDGE BASE ===
${contextState.knowledgeBase || "No specific knowledge base provided."}
=== CURRENT CONTEXT ===
- Route: ${contextState.route.path}
- Visible Screen Text: "${contextState.pageContent.rawText.slice(0, 10000)}..."
=== AVAILABLE TOOLS ===
${JSON.stringify(toolsContext, null, 2)}
=== INSTRUCTIONS ===
1. CHECK TOOLS FIRST: If the user request matches a tool, CALL "trigger_component".
2. CHECK KNOWLEDGE BASE.
3. CHECK SCREEN TEXT.
4. Be concise.
`
});
// 5. Start Chat with History
const chat = model.startChat({
history: formattedHistory
});
// 6. Send the NEW message
const result = await chat.sendMessage(userMessage);
const response = result.response;
// 7. Check for Function Calls
const functionCalls = response.functionCalls();
if (functionCalls && functionCalls.length > 0) {
const call = functionCalls[0];
if (call.name === "trigger_component") {
const args = call.args as any;
return {
message: `Executing ${args.actionName} on ${args.componentId}...`,
toolCall: {
componentId: args.componentId,
actionName: args.actionName,
actionValue: args.actionValue // 🆕 Pass actionValue if provided
}
};
}
}
return { message: response.text() };
} catch (error) {
console.error("Gemini AI Error:", error);
return { message: "Sorry, I had trouble connecting to Gemini." };
}
}Setting up the AI Helper Component
Add the ai-helper.tsx file to your components/ui/ai folder. This is the actual AI Helper component that users will interact with.
Copy the entire code below:
"use client"
import React, { useState, useRef, useEffect } from 'react';
import { useAIContext } from '@/contexts/ai-context'; // Import our Brain
import { TWJComponentsProps } from '@/twj-lib/types'; // Your library types
import { useTheme } from '@/contexts/ui-theme-context';
import { fontApplier } from '@/twj-lib/font-applier';
import { askAI, AIMessage } from '@/actions/ask-ai';
import { Button } from '../button';
import { cn } from '@/twj-lib/tw';
// --- ICONS (Inline SVGs for zero dependencies) ---
const Icons = {
Sparkles: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
),
X: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
),
Send: () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
</svg>
),
Bot: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="10" x="3" y="11" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" x2="8" y1="16" y2="16"/><line x1="16" x2="16" y1="16" y2="16"/></svg>
)
};
// --- TYPES ---
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
}
interface AIHelperProps extends TWJComponentsProps {
greeting?: string;
aiName?: string;
alignment?: 'left' | 'right';
}
// --- MAIN COMPONENT ---
export const AIHelper: React.FC<AIHelperProps> = ({
theme,
greeting = "How can I help you navigate?",
aiName = "AI Assistant",
alignment = "right"
}) => {
const { state, executeAction } = useAIContext(); // 🧠 Connecting to the Brain
const {theme: contextTheme} = useTheme()
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState("");
// Fix hydration mismatch by only enabling theming on client
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
// Pick theme in priority: Prop → Context → Default("modern")
const activeTheme = theme || contextTheme || "modern";
// Before hydration finishes, keep theme stable
const appliedTheme = mounted ? activeTheme : "modern";
// Apply fonts + theme class
const fontClass = fontApplier(appliedTheme);
const themeClass = `theme-${appliedTheme}`;
// Local Chat State (Visual only for now)
const [messages, setMessages] = useState<Message[]>([
{ id: '1', role: 'assistant', content: greeting }
]);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom of chat
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isOpen]);
// inside AIHelper.tsx
const handleSend = async () => {
if (!input.trim()) return;
// 1. Snapshot current input
const currentInput = input;
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: currentInput };
// 2. Optimistic Update
setMessages(prev => [...prev, newMessage]);
setInput("");
const loadingId = (Date.now() + 1).toString();
setMessages(prev => [...prev, { id: loadingId, role: 'assistant', content: "Thinking..." }]);
try {
// 3. 🆕 Prepare History
// We take the current 'messages' state (BEFORE the new input was added to state logic above,
// but since setMessages is async, we use the `messages` var directly).
// CRITICAL: Filter out any previous "Thinking..." or error messages if you have them.
const validHistory: AIMessage[] = messages
.filter(m => m.content !== "Thinking..." && m.content !== "Error connecting to AI.")
.map(m => ({
role: m.role,
content: m.content
}));
// 4. Call Server Action with History
const response = await askAI(currentInput, state, validHistory);
// 5. Update UI
if (response.toolCall) {
setMessages(prev => prev.map(msg =>
msg.id === loadingId ? { ...msg, content: response.message } : msg
));
await executeAction(
response.toolCall.componentId,
response.toolCall.actionName,
response.toolCall.actionValue
);
setTimeout(() => {
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'assistant',
content: "Done! ✅"
}]);
}, 500);
} else {
setMessages(prev => prev.map(msg =>
msg.id === loadingId ? { ...msg, content: response.message } : msg
));
}
} catch (err) {
console.error(err);
setMessages(prev => prev.map(msg =>
msg.id === loadingId ? { ...msg, content: "Error connecting to AI." } : msg
));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={`fixed bottom-6 ${alignment === 'left' ? 'left-6' : 'right-6'} z-50 flex flex-col items-end gap-4 ${themeClass} ${fontClass}`}>
{/* 1. THE CHAT WINDOW (Conditionally Rendered with basic animation logic) */}
<div
className={cn(`
w-[380px] h-[80vh] max-h-[550px] bg-background dark:bg-background rounded-theme
flex flex-col overflow-hidden transition-all duration-300 origin-bottom-right
${isOpen ? 'opacity-100 scale-100 translate-y-0 bottom-20' : 'opacity-0 translate-y-0 pointer-events-none absolute bottom-20'}
`,
appliedTheme === 'brutalist' && `border-4 border-border `
,
)}
>
{/* Header */}
<div
className="p-4 flex items-center justify-between bg-surface dark:bg-surface-dark text-foreground dark:text-foreground-dark"
>
<div className="flex items-center gap-2">
<Icons.Bot />
<div className="flex flex-col">
<span className="font-semibold text-sm">{aiName}</span>
{/* 🧠 Proving context works: showing current route */}
<span className="text-[10px] opacity-80 uppercase tracking-wider">
{state.route.path === '/' ? 'Home' : state.route.path.replace('/', '')}
</span>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="hover:bg-white/20 p-1 rounded-full transition-colors"
>
<Icons.X />
</button>
</div>
{/* Message List */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-background dark:bg-background-dark text-foreground dark:text-foreground-dark">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`
max-w-[85%] rounded-theme px-4 py-2.5 text-sm leading-relaxed shadow-sm
${msg.role === 'user'
? 'bg-primary dark:bg-primary-dark text-primary-foreground dark:text-primary-foreground-dark rounded-br-none'
: 'bg-surface dark:bg-surface-dark rounded-bl-none'
}
`}
>
{msg.content}
</div>
</div>
))}
{/* Debug Info (Optional - Good for dev) */}
{messages.length === 1 && state.activeComponents.size > 0 && (
<div className="text-xs text-center text-zinc-400 mt-4">
⚡ {state.activeComponents.size} tools active on this page
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-3 bg-surface dark:bg-surface-dark text-foreground dark:text-foreground-dark">
<div className="flex items-end gap-2 bg-foreground/5 dark:bg-foreground-dark/5 p-2 rounded-xl focus-within:ring-2 ring-offset-1 focus-within:ring-blue-500/20 transition-all">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me to do something..."
className="w-full border-none focus:ring-0 text-sm p-1.5 min-h-10 max-h-[120px] resize-none text-zinc-800 dark:text-zinc-100 placeholder:text-zinc-400"
rows={1}
/>
<button
onClick={handleSend}
disabled={!input.trim()}
className="p-2 rounded-lg text-primary dark:text-primary-dark-mode disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:brightness-110 active:scale-95"
>
<Icons.Send />
</button>
</div>
<div className="text-[10px] text-zinc-400 text-center mt-2">
AI can make mistakes. Check important info.
</div>
</div>
</div>
{/* 2. THE FLOATING TRIGGER BUTTON */}
<Button
size='small'
onClick={() => setIsOpen(!isOpen)}
className="w-14 h-14 rounded-full shadow-xl flex items-center justify-center bg-primary text-white transition-all hover:scale-105 hover:shadow-2xl active:scale-95 z-50"
>
{isOpen ? <Icons.X /> : <Icons.Sparkles />}
</Button>
</div>
);
};Add AI Helper in your layout.tsx
Finally, you need to add the AIHelper component inside your main layout file so that it is available across all pages.
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/contexts/ui-theme-context";
import {
Manrope,
Lora,
Orbitron,
Fredoka,
Roboto_Condensed,
Nunito,
Grenze_Gotisch
} from "next/font/google";
import { AIHelper } from "@/components/ui/ai/ai-helper";
import { AIContextProvider } from "@/contexts/ai-context";
import { KNOWLEDGE_BASE } from "@/data/knowledge-base";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope", display: "swap" });
const lora = Lora({ subsets: ["latin"], variable: "--font-lora", display: "swap" });
const orbitron = Orbitron({ subsets: ["latin"], variable: "--font-orbitron", display: "swap" });
const fredoka = Fredoka({ subsets: ["latin"], variable: "--font-fredoka", display: "swap" });
const robotoCondensed = Roboto_Condensed({ subsets: ["latin"], variable: "--font-roboto-condensed", display: "swap" });
const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display: "swap" });
const grenzeGotisch = Grenze_Gotisch({ subsets: ["latin"], variable: "--font-grenze-gotisch", display: "swap" });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ThemeProvider initialTheme="brutalist">
<AIContextProvider
initialPersona="sales_rep"
knowledgeBase={KNOWLEDGE_BASE}
>
<html lang="en" className={`
${manrope.variable}
${lora.variable}
${orbitron.variable}
${fredoka.variable}
${robotoCondensed.variable}
${nunito.variable}
${grenzeGotisch.variable}`
}>
<body
className={`
antialiased bg-background text-foreground
`}
>
<AIHelper />
{children}
</body>
</html>
</AIContextProvider>
</ThemeProvider>
);
}That's it! You now have the AI Helper component set up in your application. Test it out by clicking the floating button and asking it to perform actions on components you've registered in the AI context.
Usage
This is the part that actually matters. By default, the AI can read your screen. But if you want it to do things, you need to register your components.
You have two options: pass props (easy) or use the hook (pro).
Option A: The Easy Way - Props
If you are using our Button or any supported ai components, just pass aiID and aiDescription. We handled the hard stuff for you.
<Button
onClick={() => setDarkMode(true)}
aiID="btn-dark-mode"
aiDescription="Enables dark mode for the application."
>
Dark Mode
</Button>
<Input
value={email}
onChange={e => setEmail(e.target.value)}
aiID="input-email"
aiDescription="The user's email address field."
/>What just happened? The AI now knows that btn-dark-mode exists. If the user types "Turn off the lights," the AI maps that intent to your button and clicks it. Magic.
Option B: The Hard Way - useAIControl Hook
Want to make a custom div or a complex widget controllable? Use the useAIControl hook.
import { useAIControl } from "@/contexts/ai-context";
export const MyCustomWidget = () => {
const [count, setCount] = React.useState(0);
// Register tools directly to the brain
useAIControl({
id: "counter-widget",
description: "A counter that can be incremented or reset.",
actions: {
increment: async () => setCount(c => c + 1),
reset: async () => setCount(0),
setExact: async (val: any) => setCount(Number(val))
}
});
return <div>Count: {count}</div>;
};Features
-
DOM Awareness (Sight)
The AI reads the innerText of your page automatically. It knows what the user is looking at. It debounces updates so it won't tank your performance score. You're welcome. -
Route Awareness (Location)
The AI knows it's on/dashboard/settings. It won't hallucinate features from the/homepage. It's context-aware, unlike your ex. -
Knowledge Base (Memory)
Pass a string intoknowledgeBasein the provider. This is where you dump your FAQ, pricing, or brand guidelines. The AI prioritizes Tools > Knowledge > Screen Content. -
Persona Injection (Personality)
SetinitialPersonatosales_rep,technical_debugger, orhelpful_guide.- Sales Rep: Will try to upsell your users.
- Debugger: Will give you JSON outputs and technical jargon.
- Helpful Guide: The boring default.
Props
AIContextProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| knowledgeBase | string | """" | The static brain of your app. Paste your docs here. |
| initialPersona | enum | 'helpful_guide' | The vibe of the assistant. |
| currentPath | string | window.location | Override if you use a weird router. |
AIHelper
| Prop | Type | Default | Description |
|---|---|---|---|
| aiName | string | """AI Assistant""" | The name shown in the header. |
| greeting | string | """...""" | The first message sent to the user. |
| alignment | 'left' | 'right' | 'right' |
Troubleshooting
-
"It says 'Undefined' in my input!" You messed up the
actionValuehandling. The AI is dumb sometimes and forgets to send text. Our library handles it, but only if you copied the code correctly. -
"The AI is hallucinating buttons." You probably didn't wrap the app in the Provider, or your
aiDescriptionis vague. Be specific. "Clicks the button" is bad. "Submits the registration form and redirects to home" is good. -
"Why did you insult me in the docs?" Because it builds character. Now go build something awesome.