AI Assistant
Self-hosted floating AI chat widget with streaming responses and persisted conversation history. Powered by Groq and the Vercel AI SDK — zero paid runtime dependency.
Preview
.env before installingThe CLI checks for these at install time — missing values mean the database step gets skipped, and the feature won't work until it's added.
DATABASE_URLrequiredPostgreSQL connection string.
e.g. postgresql://user:pass@localhost:5432/mydbGROQ_API_KEYrequiredGroq API key for LLM inference. Get a free key →
AI_SYSTEM_PROMPTrequiredSystem prompt shaping the assistant's behavior.
e.g. You are a helpful assistant for [AppName]...
Install via CLI
npx feature101@latest add aiImport
import { AskAI } from '@/features/ai'Usage
<AskAI />PostInstall Guides
- 01Visit
/aito see the floating widget and live store state in action - 02Swap Groq for another provider (OpenAI, Gemini, etc.) by changing the model in app
/api/ai/route.ts— one line - 03drop
<AskAI />anywhere — most commonly app/layout.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Custom CSS classes for the widget container. |
Data Flow
What the user clicks and types.
Updates the screen instantly, before the server replies.
Validates and saves the change securely.
Your data, permanently written to the database.
What the user clicks and types.
Updates the screen instantly, before the server replies.
Validates and saves the change securely.
Your data, permanently written to the database.
Files
"use server";
import { getCurrentUserId } from "@/lib/get-current-user";
import { prisma } from "@/lib/prisma";
import { z } from "zod/v4";
import type { AiChatMessage } from "./ai.types";
import { HISTORY_LIMIT } from "./ai.constants";
// ─── getOrCreateConversation ──────────────────────────────────────────────────
// Finds or creates the single active conversation for the current user.
// Safe to call on every mount — idempotent.
export async function getOrCreateConversation(): Promise<{ id: string }> {
const userId = await getCurrentUserId();
const conversation = await prisma.aiConversation.upsert({
where: { userId },
create: { userId },
update: {},
select: { id: true },
});
return conversation;
}
// ─── getHistory ───────────────────────────────────────────────────────────────
// Fetches the most recent message history for a conversation, oldest-first.
// Verifies ownership — a user can only read their own conversation.
const conversationIdSchema = z.string().min(1);
export async function getHistory(
conversationId: string,
): Promise<AiChatMessage[]> {
const userId = await getCurrentUserId();
const parsed = conversationIdSchema.safeParse(conversationId);
if (!parsed.success) throw new Error("Invalid conversation ID");
const conversation = await prisma.aiConversation.findUnique({
where: { id: conversationId, userId },
select: { id: true },
});
if (!conversation) throw new Error("Conversation not found");
// Order descending + take, THEN reverse — take + ascending grabs the OLDEST
// N messages, not the most recent N. This matters once a conversation grows
// past HISTORY_LIMIT. Mirrors the same pattern in app/api/ai/route.ts.
const recent = await prisma.aiMessage.findMany({
where: { conversationId },
orderBy: { createdAt: "desc" },
take: HISTORY_LIMIT,
select: {
id: true,
role: true,
content: true,
createdAt: true,
},
});
return recent.reverse() as AiChatMessage[];
}