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.

ai@ai-sdk/react@ai-sdk/groq@prisma/client@prisma/adapter-pgpgdotenvzustandzodprisma@types/pg
shadcn/uibuttontextareaskeletonbadgecard@ai-elements/conversation@ai-elements/message@ai-elements/shimmerauto-installed by CLI

Preview

Add these to your .env before installing

The 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_URLrequired

    PostgreSQL connection string.

    e.g. postgresql://user:pass@localhost:5432/mydb
  • GROQ_API_KEYrequired

    Groq API key for LLM inference. Get a free key →

  • AI_SYSTEM_PROMPTrequired

    System prompt shaping the assistant's behavior.

    e.g. You are a helpful assistant for [AppName]...

Install via CLI

npx feature101@latest add ai

Import

import { AskAI } from '@/features/ai'

Usage

<AskAI />

PostInstall Guides

  1. 01Visit /ai to see the floating widget and live store state in action
  2. 02Swap Groq for another provider (OpenAI, Gemini, etc.) by changing the model in app/api/ai/route.ts — one line
  3. 03drop <AskAI /> anywhere — most commonly app/layout.tsx

Props

PropTypeDescription
className
stringCustom CSS classes for the widget container.

Data Flow

client

What the user clicks and types.

zustand

Updates the screen instantly, before the server replies.

server

Validates and saves the change securely.

prisma

Your data, permanently written to the database.

Files13
ai.actions.ts
"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[];
}