@firstlovecenter/ai-chat
v0.30.4
Published
Reusable AI chat module: agent loop, Vertex providers (Claude + Gemini), narrators, persistence schema, and chat UI components. Host injects auth, scope, tools, and credentials via configureAiChat().
Maintainers
Readme
@firstlovecenter/ai-chat
Reusable AI chat module for Next.js apps. Ships:
- An agent tool loop that runs against Vertex AI (Claude or Gemini).
- Two chat UI components: a custom hand-rolled chat and a Vercel AI SDK chat.
- Persistence routes (sessions, messages, admin AI settings).
- ORM-agnostic persistence: bring your own Drizzle (
drizzle-orm) or Prisma (@prisma/client). - Registries for available tool-calling models and chat interfaces — your app picks which to expose.
The host app supplies its own auth, scope, tools, prompts, Vertex credentials, and settings UI. The package handles everything else.
Status
Pre-release. See the project plan for the current roadmap.
Install
pnpm add @firstlovecenter/ai-chat next react react-dom
# pick one persistence backend:
pnpm add drizzle-orm
# or:
pnpm add @prisma/clientWire up styles
The package ships precompiled CSS, so host apps no longer need to
configure Tailwind to scan node_modules or hand-author the chat's
colour variables. In your root global stylesheet:
/* app/globals.css (or equivalent) */
@import "@firstlovecenter/ai-chat/styles.css"; /* compiled utilities used by the chat components */
@import "tailwindcss"; /* your own Tailwind, if any */
@import "@firstlovecenter/ai-chat/theme.css"; /* default light / dark OkLCh palette */That's it. Dark mode toggles on either the .dark class or
[data-theme="dark"] attribute on any ancestor (typically <html>).
Import order matters. Both
styles.cssand@import "tailwindcss"emit universal utilities (.hidden,.md:block,.flex, …) into@layer utilities. Within a layer, later source wins. Putting the package import BEFORE@import "tailwindcss"lets your own utilities win cascade. If you reverse the order, components like the shadcn sidebar (className="hidden md:block") will be hidden by the package's trailing.hidden{display:none}even on desktop.theme.cssonly declares CSS variables (no utilities), so its position doesn't matter — put it after your own theme overrides for the cleanest cascade.
To brand the chat, override the CSS variables defined in theme.css
from your own stylesheet AFTER the imports:
:root { --primary: oklch(0.45 0.22 265); }
.dark { --primary: oklch(0.65 0.18 265); }If you'd rather skip theme.css and supply your own values, just
define the same variables (--background, --foreground, --card*,
--popover*, --primary*, --secondary*, --muted*, --accent*,
--destructive*, --border, --input, --ring, --sidebar*,
--chart-1 … --chart-5) on :root and .dark.
Migrating from 0.10.x
If you previously wired the chat manually:
- Remove any
@source "…/@firstlovecenter/ai-chat"line from your global CSS — the package now ships its compiled utilities. - Remove any manual
--sidebar*,--background,--foreground,--card*,--popover*,--primary*,--secondary*,--muted*,--accent*,--destructive*,--border,--input,--ringdefinitions that existed only to back this package. Host-specific brand tokens (your own--canvas,--surface, etc.) stay where they are. - Remove the corresponding
--color-*entries from your@theme inlineblock. - Remove the manual
@custom-variant darkif it was added only for this package. - Add the two
@importlines above.
The migration is purely additive — 0.10.x hosts that don't touch their stylesheet continue working unchanged.
Tailwind v3 hosts (optional)
If you prefer your own Tailwind pipeline tree-shake the package's classes rather than ship the precompiled bundle, use the Tailwind v3 preset:
// tailwind.config.js
module.exports = {
presets: [require('@firstlovecenter/ai-chat/tailwind-preset')],
content: ['./src/**/*.{ts,tsx}' /* …plus your own globs… */]
};Still import theme.css for the default palette (or define your own
CSS variables).
Quick start
// src/lib/ai-chat.ts (host)
import { configureAiChat } from '@firstlovecenter/ai-chat/server';
import { createDrizzlePersistence } from '@firstlovecenter/ai-chat/server/drizzle';
// or: import { createPrismaPersistence } from '@firstlovecenter/ai-chat/server/prisma';
export const aiChat = configureAiChat({
persistence: createDrizzlePersistence(db),
auth: { requireAuth, isSuperAdmin },
scope: { resolveScopeLabel, buildScopeSummary },
tools: { tools: ALL_TOOLS, buildSystemBlocks },
vertex: {
projectId: process.env.GCP_PROJECT_ID!,
defaultLocation: process.env.GCP_LOCATION ?? 'us-east5',
auth: getVertexAuth(),
modelIds: { claude: process.env.GCP_CLAUDE_MODEL!, gemini: process.env.GCP_GEMINI_MODEL! }
}
});Detailed integration docs (Drizzle migrations, Prisma fragment paste, Vertex auth recipes, tool authoring, settings UI) ship with v0.1.0.
UI components
The ./ui entry exports two full-page chat shells — AiChat (bespoke
SSE) and VercelChat (Vercel AI SDK) — plus, since 0.30.0, a floating
chat bubble, AiChatBubble.
AiChatBubble (floating launcher + docked panel)
AiChatBubble is a dockable, Intercom-style launcher that opens a compact
chat panel. It is a presentational shell over the same chat engine as
AiChat (identical /api/agent SSE path, session persistence, narrator
picker, streaming, and AnswerBlocks) — there is no new server engine.
Unlike AiChat it never navigates the host page: open/closed state and
the last session id are persisted to localStorage, so it can float on
every page and restore itself in place.
Render it once in your authenticated shell so it floats everywhere:
// app/(app)/layout.tsx
'use client';
import { AiChatBubble } from '@firstlovecenter/ai-chat/ui';
<AiChatBubble
userFirstName={user.firstName}
scopeLabel={scopeLabel} // "All estates" / "Estate A, Estate B"
initialProvider={mySettings.narrativeProvider} // 'claude' | 'grok' | 'gemini'
side="bottom-right"
panelTitle="Estate Assistant"
/>;It takes all of AiChatProps plus:
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| side | "bottom-left" \| "bottom-right" | "bottom-right" | Corner the launcher docks to. |
| offset | { x?: number; y?: number } | 0 | Extra px from the corner (added to the safe-area inset). |
| defaultOpen | boolean | false | Open on first mount when nothing is persisted. |
| panelTitle | string | "Assistant" | Panel header label. |
| launcherLabel | string | "Open assistant" | Accessible label for the launcher button. |
| unreadDot | boolean | false | Attention dot on the launcher while closed. |
Behaviour: the launcher floats at the chosen corner (lucide MessageCircle,
mobile safe-area aware); click toggles the panel. The panel is a compact
dock on desktop and a near-full-screen sheet on small screens, with a
recent-sessions + "New chat" menu, the thread, and the input pill. Esc
closes it, focus is trapped while open and restored to the launcher on
close. basePath is not navigated to for the bubble — it only
namespaces the localStorage keys (flc-ai-chat:<basePath>:open /
:session) so multiple bubbles can coexist. Wire the same routes AiChat
uses (/api/agent, /api/chat/sessions/**, /api/settings/me).
Per-user narrator preference (host-owned storage)
The chat UI exposes a per-user narrator picker (claude / gemini / grok)
backed by GET / PATCH /api/settings/me. The package does not own
a users table, so storage is the host's responsibility — but the
package ships a route factory (routes.userSettings) that handles auth,
body validation, and the wire format on top of two thin host-supplied
hooks.
Wire contract
GET /api/settings/me → { "narrative_provider": "claude" | "gemini" | "grok" }
PATCH /api/settings/me ← { "narrative_provider": "claude" | "gemini" | "grok" }
→ { "ok": true }- Auth: authenticated user only (no role check). Add same-origin / CSRF protection at your framework layer if your app needs it; the package's other PATCH routes do not perform CSRF checks either.
- 400 when body is missing or
narrative_providerisn't one of the accepted ids. - 401 when unauthenticated.
- 501 when the host hasn't configured the corresponding hook
(
resolveNarratorIdfor GET,setNarratorIdfor PATCH).
Option A — use the shipped factory (recommended)
// src/lib/ai-chat.ts
export const aiChat = configureAiChat({
// ...persistence, auth, scope, tools, vertex...
resolveNarratorId: async (scope) => {
const row = await db.select({ v: users.narrative_provider })
.from(users).where(eq(users.id, scope.userId)).limit(1);
return row[0]?.v ?? 'claude';
},
setNarratorId: async ({ userId, narratorId }) => {
await db.update(users).set({ narrative_provider: narratorId })
.where(eq(users.id, userId));
}
});// app/api/settings/me/route.ts
import { aiChat } from '@/lib/ai-chat';
export const GET = aiChat.routes.userSettings.GET;
export const PATCH = aiChat.routes.userSettings.PATCH;Option B — implement the endpoint yourself
The factory exists so hosts don't have to, but the wire contract above
is stable. Hosts that want a different framework's idioms or extra
fields on the response can omit setNarratorId (PATCH then returns 501)
and ship their own handler against the same wire format. The chat UI
will keep working.
Error taxonomy
Every error the package surfaces over the wire — both the custom SSE
route (event: error) and the Vercel AI SDK route ({ type: 'error' }
data part) — carries a stable code drawn from a fixed taxonomy.
Persistence stores the same code on chat_messages.errorJson.code.
End users never see the code itself; the UI maps it to friendly copy
and the right action (Retry / Continue / Sign in / New chat / Dismiss).
| Code | Meaning | Auto-retry? |
| --- | --- | --- |
| network_offline | Client lost connectivity before send. | yes, on online event |
| network_blip | Fetch threw (DNS / connection reset). | yes, exp backoff |
| stream_interrupted | Server alive, stream reader threw mid-flight. | once, then Continue/Retry |
| aborted_by_user | User clicked Stop. | no |
| rate_limited | HTTP 429. Honors Retry-After. | yes |
| quota_exhausted | 429 / 403 with RESOURCE_EXHAUSTED. | no |
| model_unavailable | 503, ECONNRESET, ETIMEDOUT. | yes, exp backoff |
| model_timeout | 504 / DEADLINE_EXCEEDED. | yes, once |
| context_overflow | 400 + "prompt is too long" / context_length_exceeded. | no |
| content_blocked | Safety filter / finishReason=SAFETY. | no |
| auth_expired | HTTP 401. | no |
| forbidden | HTTP 403 (non-quota). | no |
| csrf_invalid | HTTP 419. | yes, once after token refresh |
| bad_request | HTTP 400 (other). | no |
| agent_no_present | Agent finished without calling present(). | no |
| agent_turn_limit | Agent exceeded maxAgentSteps. | no |
| narrator_failed | Prose narrator threw; structured blocks still rendered. | n/a (non-fatal) |
| internal | Catch-all 5xx / unrecognised. | no |
The classifier and default copy live in @firstlovecenter/ai-chat/server
(classifyError, friendlyMessage, FRIENDLY_COPY, ChatErrorCode,
ChatError) and are re-exported from @firstlovecenter/ai-chat/ui
so the wire format, persistence shape, and UI copy stay aligned.
Database footprint
See DATABASE.md for the full footprint: which three tables the package adds, what it explicitly does not add (no FKs to the host, no users table, no triggers/views), MySQL-only assumptions, the VARCHAR(30) user-id columns (intended for host CUIDs, opaque to the package), table-name configurability per ORM, and the known independence gaps for hosts whose schema doesn't match FLC's.
