feat(ai): polish auto-reply — inbox controls, real handoff, usage logging

Makes the AI auto-reply bot visible and controllable from the inbox,
completes the handoff, and records token spend. Builds on the existing
lib/ai engine rather than replacing it.

Track A — user-facing
- messages.ai_generated flag → small "AI" badge on bot-generated bubbles
- "Take over" / "Resume AI" banner per conversation
  (POST /api/ai/autoreply/[id]) — take-over pauses + assigns to the agent
- handoff now assigns to a configurable target (agent or unassigned
  queue) and writes an internal note summarizing the exchange; the
  existing on_conversation_assigned trigger notifies the assignee
- Settings → AI: "Hand off to" target picker

Track B — observability + safety
- providers return normalized token usage; logged per run to ai_usage_log
  (admin-readable) from both auto-reply and draft
- account-wide auto-reply rate limit (30/min) on top of the
  per-conversation cap

Migration: 033_ai_reply_polish.sql (messages.ai_generated,
ai_configs.handoff_agent_id, conversations.ai_handoff_summary,
ai_usage_log). Tests + typecheck + lint pass (607 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-07-08 14:03:33 +03:00
parent 23838a9959
commit b0194dfc27
27 changed files with 1023 additions and 45 deletions

View File

@ -9,6 +9,43 @@ Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes
and polish.
## [0.8.0] — 2026-07-08
Polishes the AI auto-reply bot: it's now **visible and controllable from
the inbox**, its **handoff actually hands off**, and its **token spend is
logged**.
> **Migration required:** apply `supabase/migrations/033_ai_reply_polish.sql`
> (adds `messages.ai_generated`, `ai_configs.handoff_agent_id`,
> `conversations.ai_handoff_summary`, and the `ai_usage_log` table).
### Added
- **"AI" badge in the inbox.** Replies the bot sent are tagged with a
small ✨ AI badge, so agents can tell an automated reply from their own
or a Flow's at a glance. (New `messages.ai_generated` flag; only the
auto-reply bot sets it.)
- **Take over / Resume from the thread.** A banner on AI-handled
conversations lets an agent **Take over** (pauses the bot for that
thread and assigns it to them) or **Resume AI** (hands the thread back
and clears the pause). Backed by `POST /api/ai/autoreply/[id]`.
- **Real handoff.** When the bot bails (can't help, or hits the reply
cap) it now (1) routes the conversation to a configurable **handoff
target** — a specific agent, or the unassigned queue — and (2) leaves a
short **internal note** summarizing the exchange for whoever picks it
up. Assigning fires the existing assignment notification. Pick the
target under **AI Agents → Setup → Hand off to**.
- **Token-usage logging.** Every draft and auto-reply records its
provider token counts to the new `ai_usage_log` table (admin-readable),
for cost visibility on your BYO key.
### Changed
- Auto-reply now has an **account-wide rate limit** (30/min) on top of
the existing per-conversation cap, so a burst of inbound can't run your
provider key past its limit. Over the limit, inbounds simply wait in
the inbox for a human instead of being auto-answered.
## [0.7.0] — 2026-07-02
Promotes the AI assistant to a first-class **AI Agents** section in the

View File

@ -1,6 +1,6 @@
{
"name": "wacrm",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"description": "Self-hostable CRM template for WhatsApp built on Next.js and Supabase — shared inbox, contacts, sales pipelines, broadcasts, and no-code automations.",
"license": "MIT",

View File

@ -0,0 +1,95 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
type Params = { params: Promise<{ conversationId: string }> }
/**
* POST /api/ai/autoreply/[conversationId] (agent+)
*
* Toggle the AI auto-reply bot for one conversation from the inbox — the
* "Take over" / "Resume AI" banner.
*
* Body: { paused: boolean, assign_to_me?: boolean }
* - paused: true → pause the bot here (a human is taking over). When
* `assign_to_me` is set, also assign the thread to the
* caller (the usual "Take over" flow). Assignment
* fires the `on_conversation_assigned` trigger.
* - paused: false → hand the thread back to the bot: clear the pause,
* reset the per-conversation reply count so it gets
* fresh slots, and clear the handoff note. If the
* caller currently owns the thread, unassign it too so
* the bot isn't blocked by the "human owns this" gate.
*
* Writes go through the RLS-scoped SSR client, so a conversation outside
* the caller's account simply isn't found (404).
*/
export async function POST(request: Request, { params }: Params) {
try {
const { supabase, accountId, userId } = await requireRole('agent')
// Reuse the send bucket: this is a cheap per-user inbox action and
// toggling it in a tight loop has no legitimate use.
const limit = checkRateLimit(`ai-takeover:${userId}`, RATE_LIMITS.send)
if (!limit.success) return rateLimitResponse(limit)
const { conversationId } = await params
const body = await request.json().catch(() => null)
if (!body || typeof body.paused !== 'boolean') {
return NextResponse.json(
{ error: 'paused (boolean) is required' },
{ status: 400 },
)
}
const paused = body.paused as boolean
const assignToMe = body.assign_to_me === true
// Confirm the conversation is in the caller's account before writing.
const { data: conv, error: convErr } = await supabase
.from('conversations')
.select('id, assigned_agent_id')
.eq('id', conversationId)
.eq('account_id', accountId)
.maybeSingle()
if (convErr) {
console.error('[ai/autoreply] conversation lookup error:', convErr)
return NextResponse.json(
{ error: 'Failed to load conversation' },
{ status: 500 },
)
}
if (!conv) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
}
const update: Record<string, unknown> = { ai_autoreply_disabled: paused }
if (paused) {
if (assignToMe) update.assigned_agent_id = userId
} else {
// Resuming: give the bot a clean slate on this thread.
update.ai_reply_count = 0
update.ai_handoff_summary = null
// Only release an assignment the caller holds — never yank a
// teammate's thread out from under them.
if (conv.assigned_agent_id === userId) update.assigned_agent_id = null
}
const { error: upErr } = await supabase
.from('conversations')
.update(update)
.eq('id', conversationId)
.eq('account_id', accountId)
if (upErr) {
console.error('[ai/autoreply] update error:', upErr)
return NextResponse.json(
{ error: 'Failed to update conversation' },
{ status: 500 },
)
}
return NextResponse.json({ success: true, paused })
} catch (err) {
return toErrorResponse(err)
}
}

View File

@ -30,7 +30,7 @@ export async function GET() {
// `api_key` is selected only to derive `has_key` — it is stripped
// out below and never returned to the client.
.select(
'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, api_key, embeddings_api_key',
'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, handoff_agent_id, api_key, embeddings_api_key',
)
.eq('account_id', accountId)
.maybeSingle()
@ -95,6 +95,25 @@ export async function POST(request: Request) {
if (!Number.isFinite(maxPer)) maxPer = 3
maxPer = Math.min(20, Math.max(1, Math.floor(maxPer)))
// Handoff routing target for auto-reply. A non-empty string must be a
// member of this account (else the conversation would be assigned to a
// stranger); an empty string / null means "leave unassigned" (the
// shared queue). Absent → left unchanged on update below.
const rawHandoff =
typeof body.handoff_agent_id === 'string' ? body.handoff_agent_id.trim() : ''
const handoffProvided = 'handoff_agent_id' in body
let handoffAgentId: string | null = null
if (rawHandoff) {
const { data: member } = await supabase
.from('profiles')
.select('user_id')
.eq('account_id', accountId)
.eq('user_id', rawHandoff)
.maybeSingle()
if (!member) return bad('handoff_agent_id must be a member of this account')
handoffAgentId = rawHandoff
}
const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : ''
// Embeddings key (optional, for semantic KB search): a non-empty
@ -146,6 +165,7 @@ export async function POST(request: Request) {
isActive,
autoReplyEnabled,
autoReplyMaxPerConversation: maxPer,
handoffAgentId: null,
embeddingsApiKey: null,
})
} catch (err) {
@ -186,6 +206,9 @@ export async function POST(request: Request) {
auto_reply_enabled: autoReplyEnabled,
auto_reply_max_per_conversation: maxPer,
}
// Only touch the handoff target when the form actually sent the field,
// so a partial save (e.g. flipping a toggle) doesn't wipe it.
if (handoffProvided) shared.handoff_agent_id = handoffAgentId
if (rawEmbeddingsKey) {
shared.embeddings_api_key = encrypt(rawEmbeddingsKey)
} else if (clearEmbeddingsKey) {

View File

@ -7,6 +7,8 @@ import { retrieveKnowledge } from '@/lib/ai/knowledge'
import { generateReply } from '@/lib/ai/generate'
import { buildSystemPrompt } from '@/lib/ai/defaults'
import { latestUserMessage } from '@/lib/ai/query'
import { logAiUsage } from '@/lib/ai/usage'
import { supabaseAdmin } from '@/lib/ai/admin-client'
import { AiError } from '@/lib/ai/types'
/**
@ -102,7 +104,20 @@ export async function POST(request: Request) {
knowledge,
})
const { text } = await generateReply({ config, systemPrompt, messages })
const { text, usage } = await generateReply({ config, systemPrompt, messages })
// Record spend on the account's BYO key. Best-effort + via the
// service role (the log has no `authenticated` INSERT policy); a
// failure here must not fail the draft the agent is waiting on.
await logAiUsage(supabaseAdmin(), {
accountId,
conversationId,
mode: 'draft',
provider: config.provider,
model: config.model,
usage,
})
return NextResponse.json({ draft: text })
} catch (err) {
if (err instanceof AiError) {

View File

@ -71,6 +71,7 @@ export async function POST(request: Request) {
isActive: true,
autoReplyEnabled: false,
autoReplyMaxPerConversation: 3,
handoffAgentId: null,
embeddingsApiKey: null,
})
} catch (err) {

View File

@ -0,0 +1,210 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
// ------------------------------------------------------------
// Account AI status is the same for every conversation, so fetch it
// once per session and share it across thread switches instead of
// hitting /api/ai/config every time the agent opens a new chat. The
// promise is cached so concurrent mounts dedupe onto one request.
// ------------------------------------------------------------
interface AiAccountStatus {
autoReplyOn: boolean;
}
let statusPromise: Promise<AiAccountStatus> | null = null;
async function fetchAiAccountStatus(): Promise<AiAccountStatus> {
if (!statusPromise) {
statusPromise = fetch("/api/ai/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((j) => ({
// AI auto-reply is "live" only when configured, the master
// switch is on, and the inbound bot is enabled.
autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled),
}))
.catch(() => ({ autoReplyOn: false }));
}
return statusPromise;
}
interface AiThreadBannerProps {
conversationId: string;
/** `conversations.ai_autoreply_disabled` — bot paused on this thread. */
disabled: boolean;
/** `conversations.ai_handoff_summary` — note the bot left on handoff. */
handoffSummary?: string | null;
/** Current assignee; when a human owns the thread the bot won't run,
* so the "AI active" banner is suppressed. */
assignedAgentId?: string | null;
/** The acting agent — "Take over" assigns the thread to them. */
currentUserId?: string | null;
/** Called after a successful toggle so the parent can patch its local
* conversation state (the realtime UPDATE also arrives, but this keeps
* the banner instant). */
onChange?: (patch: {
ai_autoreply_disabled: boolean;
assigned_agent_id?: string | null;
}) => void;
}
/**
* Inbox banner that surfaces + controls the AI auto-reply bot per
* conversation:
* - bot active here → "AI is replying automatically" + [Take over]
* - bot paused here → the handoff note (if any) + [Resume AI]
* Renders nothing when the account has no auto-reply configured, or when
* the bot is active but a human already owns the thread (nothing to do).
*/
export function AiThreadBanner({
conversationId,
disabled,
handoffSummary,
assignedAgentId,
currentUserId,
onChange,
}: AiThreadBannerProps) {
const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
// Optimistic local mirror of the pause flag so the banner flips
// instantly on click; re-seeds whenever the thread (or its server
// state via realtime) changes.
const [paused, setPaused] = useState(disabled);
useEffect(() => setPaused(disabled), [conversationId, disabled]);
useEffect(() => {
let alive = true;
fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn));
return () => {
alive = false;
};
}, []);
const toggle = useCallback(
async (paused: boolean) => {
setBusy(true);
try {
const res = await fetch(`/api/ai/autoreply/${conversationId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// "Take over" also assigns the thread to the acting agent.
body: JSON.stringify({ paused, assign_to_me: paused }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
toast.error(j?.error ?? "Couldn't update the AI assistant.");
return;
}
setPaused(paused);
onChange?.({
ai_autoreply_disabled: paused,
// Take over assigns to the acting agent; resume releases only
// the caller's own assignment. The realtime UPDATE reconciles
// the exact value either way.
...(paused
? currentUserId
? { assigned_agent_id: currentUserId }
: {}
: { assigned_agent_id: null }),
});
toast.success(paused ? "You've taken over this chat." : "AI resumed.");
} catch {
toast.error("Couldn't reach the server.");
} finally {
setBusy(false);
}
},
[conversationId, currentUserId, onChange],
);
// Account has no auto-reply → nothing to show. (Still loading → nothing.)
if (!autoReplyOn) return null;
// Paused here (a human took over, or the model handed off).
if (paused) {
return (
<Banner tone="muted">
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground">AI assistant is paused here</p>
{handoffSummary && (
<p className="truncate text-muted-foreground" title={handoffSummary}>
{handoffSummary}
</p>
)}
</div>
<BannerButton onClick={() => toggle(false)} busy={busy} icon={Undo2}>
Resume AI
</BannerButton>
</Banner>
);
}
// Active, but a human already owns it → the bot won't fire; no banner.
if (assignedAgentId) return null;
// Active on this thread.
return (
<Banner tone="primary">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
<span className="truncate font-medium text-foreground">
AI assistant is replying automatically
</span>
</div>
<BannerButton onClick={() => toggle(true)} busy={busy} icon={Hand}>
Take over
</BannerButton>
</Banner>
);
}
function Banner({
tone,
children,
}: {
tone: "primary" | "muted";
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex items-center gap-3 border-b px-3 py-2 text-xs sm:px-4",
tone === "primary"
? "border-primary/20 bg-primary/5"
: "border-border bg-muted/40",
)}
>
{children}
</div>
);
}
function BannerButton({
onClick,
busy,
icon: Icon,
children,
}: {
onClick: () => void;
busy: boolean;
icon: typeof Hand;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="inline-flex flex-shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 py-1 font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-60"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Icon className="h-3 w-3" />
)}
{children}
</button>
);
}

View File

@ -13,6 +13,7 @@ import {
LayoutTemplate,
ImageOff,
CornerDownLeft,
Sparkles,
} from "lucide-react";
import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote";
@ -282,6 +283,19 @@ export function MessageBubble({
isAgent ? "justify-end" : "justify-start",
)}
>
{/* AI badge — only on replies the auto-reply bot generated
(always outbound, so it sits on the primary fill). Lets
agents tell an AI reply from their own / a Flow's at a
glance. */}
{message.ai_generated && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-foreground/20 px-1.5 py-px text-[9px] font-semibold uppercase leading-none tracking-wide text-primary-foreground"
title="Sent automatically by the AI assistant"
>
<Sparkles className="h-2.5 w-2.5" />
AI
</span>
)}
<span
className={cn(
"text-[10px]",

View File

@ -46,6 +46,7 @@ import {
} from "./message-composer";
import { deleteAccountMedia } from "@/lib/storage/upload-media";
import { TemplatePicker } from "./template-picker";
import { AiThreadBanner } from "./ai-thread-banner";
import { buildReplyPreview } from "./reply-quote";
import { toast } from "sonner";
@ -1070,6 +1071,22 @@ export function MessageThread({
)}
</div>
{/* AI auto-reply banner — take over an active bot, or resume it
after a handoff. Renders nothing unless the account has
auto-reply configured. */}
<AiThreadBanner
conversationId={conversation.id}
disabled={conversation.ai_autoreply_disabled ?? false}
handoffSummary={conversation.ai_handoff_summary}
assignedAgentId={assignedAgentId}
currentUserId={user?.id}
onChange={(patch) => {
if ("assigned_agent_id" in patch) {
onAssignChange(conversation.id, patch.assigned_agent_id ?? null);
}
}}
/>
{/* Composer */}
<MessageComposer
conversationId={conversation.id}

View File

@ -28,9 +28,14 @@ import { SettingsPanelHead } from './settings-panel-head';
import { AiKnowledgeCard } from './ai-knowledge';
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
import type { AiProvider } from '@/lib/ai/types';
import type { AccountMember } from '@/types';
const MASKED_KEY = '••••••••••••••••';
// Radix Select can't use an empty-string item value, so the "leave
// unassigned" choice gets a sentinel that maps to null in the payload.
const HANDOFF_QUEUE = '__queue__';
const PROVIDER_LABEL: Record<AiProvider, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic (Claude)',
@ -64,6 +69,9 @@ export function AiConfig() {
const [isActive, setIsActive] = useState(false);
const [autoReplyEnabled, setAutoReplyEnabled] = useState(false);
const [maxPerConversation, setMaxPerConversation] = useState(3);
// Empty string = leave unassigned (shared queue).
const [handoffAgentId, setHandoffAgentId] = useState('');
const [members, setMembers] = useState<AccountMember[]>([]);
// Guard keyed on the account (not a bare boolean) so an in-place
// account switch — ownership transfer, multi-account membership —
@ -88,6 +96,7 @@ export function AiConfig() {
setIsActive(data.is_active);
setAutoReplyEnabled(data.auto_reply_enabled);
setMaxPerConversation(data.auto_reply_max_per_conversation ?? 3);
setHandoffAgentId(data.handoff_agent_id ?? '');
setHasStoredKey(Boolean(data.has_key));
setApiKey(data.has_key ? MASKED_KEY : '');
setKeyEdited(false);
@ -106,6 +115,19 @@ export function AiConfig() {
if (!accountId || loadedAccountIdRef.current === accountId) return;
loadedAccountIdRef.current = accountId;
void fetchConfig();
// Members populate the handoff-target picker. Best-effort — on an
// older deployment without the endpoint the picker just shows the
// queue option.
void (async () => {
try {
const res = await fetch('/api/account/members', { cache: 'no-store' });
if (!res.ok) return;
const json = (await res.json()) as { members?: AccountMember[] };
setMembers(json.members ?? []);
} catch {
// ignore — picker falls back to "unassigned queue" only.
}
})();
}, [accountId, fetchConfig]);
// Swap the model default when the provider changes, unless the user
@ -134,6 +156,7 @@ export function AiConfig() {
is_active: isActive,
auto_reply_enabled: autoReplyEnabled,
auto_reply_max_per_conversation: maxPerConversation,
handoff_agent_id: handoffAgentId || null,
});
const handleTest = async () => {
@ -201,6 +224,7 @@ export function AiConfig() {
setIsActive(false);
setAutoReplyEnabled(false);
setSystemPrompt('');
setHandoffAgentId('');
} else {
const data = await res.json();
toast.error(data.error ?? 'Failed to remove.');
@ -444,6 +468,35 @@ export function AiConfig() {
className="w-20"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-handoff">Hand off to</Label>
<p className="text-xs text-muted-foreground">
When the bot can’t help — or hits the reply cap — it pauses and
routes the chat here, with a short note on what happened.
</p>
<Select
value={handoffAgentId || HANDOFF_QUEUE}
onValueChange={(v) =>
setHandoffAgentId(!v || v === HANDOFF_QUEUE ? '' : v)
}
disabled={disabled || !autoReplyEnabled}
>
<SelectTrigger id="ai-handoff">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={HANDOFF_QUEUE}>
Unassigned queue (any agent can pick it up)
</SelectItem>
{members.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>

View File

@ -75,6 +75,7 @@ function aiConfig(overrides: Partial<AiConfig> = {}): AiConfig {
isActive: true,
autoReplyEnabled: true,
autoReplyMaxPerConversation: 3,
handoffAgentId: null,
embeddingsApiKey: null,
...overrides,
}
@ -186,11 +187,26 @@ describe('dispatchInboundToAiReply — eligibility gates', () => {
})
describe('dispatchInboundToAiReply — handoff', () => {
it('disables auto-reply and does not send on handoff', async () => {
it('disables auto-reply, writes a summary, and does not send on handoff', async () => {
h.generateReply.mockResolvedValue({ text: '', handoff: true })
await dispatchInboundToAiReply(ARGS)
expect(h.engineSendText).not.toHaveBeenCalled()
expect(h.state.updatePayload).toEqual({ ai_autoreply_disabled: true })
expect(h.state.rpcCalls).toHaveLength(0)
expect(h.state.updatePayload).toMatchObject({ ai_autoreply_disabled: true })
expect(h.state.updatePayload?.ai_handoff_summary).toContain(
'AI agent handed off',
)
// No handoff target configured → conversation left unassigned.
expect(h.state.updatePayload).not.toHaveProperty('assigned_agent_id')
})
it('routes to the configured handoff agent on handoff', async () => {
h.loadAiConfig.mockResolvedValue(aiConfig({ handoffAgentId: 'agent-7' }))
h.generateReply.mockResolvedValue({ text: '', handoff: true })
await dispatchInboundToAiReply(ARGS)
expect(h.state.updatePayload).toMatchObject({
ai_autoreply_disabled: true,
assigned_agent_id: 'agent-7',
})
})
})

View File

@ -4,8 +4,11 @@ import { buildConversationContext } from './context'
import { retrieveKnowledge } from './knowledge'
import { generateReply } from './generate'
import { buildSystemPrompt } from './defaults'
import { buildHandoffSummary } from './handoff'
import { logAiUsage } from './usage'
import { latestUserMessage } from './query'
import { engineSendText } from '@/lib/flows/meta-send'
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
interface DispatchArgs {
/** Tenancy key — drives config, contact, and whatsapp_config lookups. */
@ -79,6 +82,22 @@ export async function dispatchInboundToAiReply(
const messages = await buildConversationContext(db, conversationId)
if (messages.length === 0) return
// Account-wide throttle on the shared BYO key. The per-conversation
// cap bounds one thread; this bounds a burst across many threads (a
// marketing blast landing 200 replies at once) so we never run the
// owner's key past the provider's rate limit. Over the limit → skip
// the auto-reply; the inbound still sits in the inbox for a human.
const acctLimit = checkRateLimit(
`ai-autoreply:${accountId}`,
RATE_LIMITS.aiAutoReplyAccount,
)
if (!acctLimit.success) {
console.warn(
`[ai auto-reply] account ${accountId} hit the per-account rate limit — skipping this inbound.`,
)
return
}
// Ground the reply in the account's knowledge base (best-effort).
const knowledge = await retrieveKnowledge(
db,
@ -93,20 +112,46 @@ export async function dispatchInboundToAiReply(
knowledge,
})
const { text, handoff } = await generateReply({
const { text, handoff, usage } = await generateReply({
config,
systemPrompt,
messages,
})
// Record token spend on the account's BYO key (best-effort — never
// blocks or fails the reply). Logged regardless of handoff: the
// provider call happened either way.
await logAiUsage(db, {
accountId,
conversationId,
mode: 'auto_reply',
provider: config.provider,
model: config.model,
usage,
})
if (handoff || !text) {
// The model can't (or shouldn't) answer — stop auto-replying on
// this thread and leave the inbound unanswered so it surfaces in
// the inbox for a human. Sticky until an admin re-enables.
await db
.from('conversations')
.update({ ai_autoreply_disabled: true })
.eq('id', conversationId)
// this thread and hand it to a human. We (a) pause the bot here
// (sticky until re-enabled), (b) route the conversation to the
// configured handoff agent — null leaves it in the shared queue —
// and (c) leave a short internal note so whoever picks it up has
// context. Assigning fires the `on_conversation_assigned` trigger,
// which notifies the agent.
const summary = buildHandoffSummary({
messages,
replyCount: conv.ai_reply_count ?? 0,
})
const update: Record<string, unknown> = {
ai_autoreply_disabled: true,
ai_handoff_summary: summary,
}
// Only set the assignee when a target is configured AND the thread
// isn't already owned — never stomp an existing human assignment.
if (config.handoffAgentId && !conv.assigned_agent_id) {
update.assigned_agent_id = config.handoffAgentId
}
await db.from('conversations').update(update).eq('id', conversationId)
return
}
@ -138,6 +183,7 @@ export async function dispatchInboundToAiReply(
conversationId,
contactId,
text,
aiGenerated: true,
})
} catch (err) {
console.error('[ai auto-reply] dispatch failed:', err)

View File

@ -10,11 +10,12 @@ interface AiConfigRow {
is_active: boolean
auto_reply_enabled: boolean
auto_reply_max_per_conversation: number
handoff_agent_id: string | null
embeddings_api_key: string | null
}
const CONFIG_COLUMNS =
'provider, model, api_key, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, embeddings_api_key'
'provider, model, api_key, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, handoff_agent_id, embeddings_api_key'
/**
* Load and decrypt the account's AI config for *use* (draft or
@ -76,6 +77,7 @@ export async function loadAiConfig(
isActive: row.is_active,
autoReplyEnabled: row.auto_reply_enabled,
autoReplyMaxPerConversation: row.auto_reply_max_per_conversation,
handoffAgentId: row.handoff_agent_id,
embeddingsApiKey,
}
}

View File

@ -11,6 +11,7 @@ function config(overrides: Partial<AiConfig> = {}): AiConfig {
isActive: true,
autoReplyEnabled: false,
autoReplyMaxPerConversation: 3,
handoffAgentId: null,
embeddingsApiKey: null,
...overrides,
}
@ -42,25 +43,41 @@ describe('parseGeneration', () => {
expect(parseGeneration('Hello there')).toEqual({
text: 'Hello there',
handoff: false,
usage: null,
})
})
it('detects + strips the handoff sentinel', () => {
expect(parseGeneration('[[HANDOFF]]')).toEqual({ text: '', handoff: true })
expect(parseGeneration('[[HANDOFF]]')).toEqual({
text: '',
handoff: true,
usage: null,
})
expect(parseGeneration('Let me get a human [[HANDOFF]]')).toEqual({
text: 'Let me get a human',
handoff: true,
usage: null,
})
})
it('passes usage straight through', () => {
const usage = { promptTokens: 10, completionTokens: 5, totalTokens: 15 }
expect(parseGeneration('Hi', usage)).toEqual({
text: 'Hi',
handoff: false,
usage,
})
})
})
describe('generateReply — OpenAI', () => {
it('calls the chat completions endpoint and returns the reply', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
okResponse({ choices: [{ message: { content: 'Sure — happy to help!' } }] }),
)
const fetchMock = vi.fn().mockResolvedValue(
okResponse({
choices: [{ message: { content: 'Sure — happy to help!' } }],
usage: { prompt_tokens: 42, completion_tokens: 8, total_tokens: 50 },
}),
)
vi.stubGlobal('fetch', fetchMock)
const res = await generateReply({
@ -69,7 +86,11 @@ describe('generateReply — OpenAI', () => {
messages: [{ role: 'user', content: 'Hi' }],
})
expect(res).toEqual({ text: 'Sure — happy to help!', handoff: false })
expect(res).toEqual({
text: 'Sure — happy to help!',
handoff: false,
usage: { promptTokens: 42, completionTokens: 8, totalTokens: 50 },
})
const [url, opts] = fetchMock.mock.calls[0]
expect(url).toContain('api.openai.com')
expect(opts.headers.Authorization).toBe('Bearer sk-test')
@ -109,9 +130,12 @@ describe('generateReply — OpenAI', () => {
describe('generateReply — Anthropic', () => {
it('calls the messages endpoint with the version header and parses text blocks', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(okResponse({ content: [{ type: 'text', text: 'Hi there!' }] }))
const fetchMock = vi.fn().mockResolvedValue(
okResponse({
content: [{ type: 'text', text: 'Hi there!' }],
usage: { input_tokens: 30, output_tokens: 6 },
}),
)
vi.stubGlobal('fetch', fetchMock)
const res = await generateReply({
@ -120,7 +144,12 @@ describe('generateReply — Anthropic', () => {
messages: [{ role: 'user', content: 'Hello' }],
})
expect(res).toEqual({ text: 'Hi there!', handoff: false })
// Anthropic reports input/output only — total is summed by normalizeUsage.
expect(res).toEqual({
text: 'Hi there!',
handoff: false,
usage: { promptTokens: 30, completionTokens: 6, totalTokens: 36 },
})
const [url, opts] = fetchMock.mock.calls[0]
expect(url).toContain('api.anthropic.com')
expect(opts.headers['x-api-key']).toBe('sk-ant-x')

View File

@ -1,4 +1,10 @@
import { AiError, type AiConfig, type ChatMessage, type GenerateResult } from './types'
import {
AiError,
type AiConfig,
type AiUsage,
type ChatMessage,
type GenerateResult,
} from './types'
import { HANDOFF_SENTINEL, aiRequestTimeoutMs } from './defaults'
import { generateOpenAi } from './providers/openai'
import { generateAnthropic } from './providers/anthropic'
@ -27,13 +33,13 @@ export async function generateReply(args: GenerateArgs): Promise<GenerateResult>
timeoutMs,
}
let raw: string
let result: { text: string; usage: AiUsage | null }
switch (config.provider) {
case 'openai':
raw = await generateOpenAi(providerArgs)
result = await generateOpenAi(providerArgs)
break
case 'anthropic':
raw = await generateAnthropic(providerArgs)
result = await generateAnthropic(providerArgs)
break
default:
throw new AiError(`Unsupported AI provider: ${config.provider}`, {
@ -42,16 +48,21 @@ export async function generateReply(args: GenerateArgs): Promise<GenerateResult>
})
}
return parseGeneration(raw)
return parseGeneration(result.text, result.usage)
}
/**
* Split the raw model output into `{ text, handoff }`. The sentinel can
* appear alone or trailing a partial reply; either way we treat the
* turn as a handoff and strip the marker from any remaining text.
* Split the raw model output into `{ text, handoff, usage }`. The
* sentinel can appear alone or trailing a partial reply; either way we
* treat the turn as a handoff and strip the marker from any remaining
* text. `usage` is passed straight through (null when the provider
* didn't report it).
*/
export function parseGeneration(raw: string): GenerateResult {
export function parseGeneration(
raw: string,
usage: AiUsage | null = null,
): GenerateResult {
const handoff = raw.includes(HANDOFF_SENTINEL)
const text = raw.split(HANDOFF_SENTINEL).join('').trim()
return { text, handoff }
return { text, handoff, usage }
}

View File

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { buildHandoffSummary } from './handoff'
describe('buildHandoffSummary', () => {
it('notes the reply count and quotes the last customer message', () => {
const summary = buildHandoffSummary({
messages: [
{ role: 'user', content: 'Hi' },
{ role: 'assistant', content: 'Hello! How can I help?' },
{ role: 'user', content: 'I want a refund' },
],
replyCount: 2,
})
expect(summary).toBe(
'🤖 AI agent handed off after 2 replies. Last customer message: “I want a refund”',
)
})
it('uses the singular "reply" for a count of one', () => {
const summary = buildHandoffSummary({
messages: [{ role: 'user', content: 'help' }],
replyCount: 1,
})
expect(summary).toContain('after 1 reply.')
})
it('says "without replying" when the bot bailed on the first inbound', () => {
const summary = buildHandoffSummary({
messages: [{ role: 'user', content: 'agent please' }],
replyCount: 0,
})
expect(summary).toContain('handed off without replying.')
expect(summary).toContain('“agent please”')
})
it('picks the most recent customer turn, ignoring assistant turns', () => {
const summary = buildHandoffSummary({
messages: [
{ role: 'user', content: 'first' },
{ role: 'user', content: 'second' },
{ role: 'assistant', content: 'a reply' },
],
replyCount: 1,
})
expect(summary).toContain('“second”')
})
it('collapses whitespace and truncates a long message', () => {
const long = 'x'.repeat(300)
const summary = buildHandoffSummary({
messages: [{ role: 'user', content: long }],
replyCount: 0,
})
expect(summary).toContain('…')
// 160-char cap on the quote; the whole note stays well under 250.
expect(summary.length).toBeLessThan(250)
})
it('degrades gracefully when there is no customer message', () => {
const summary = buildHandoffSummary({
messages: [{ role: 'assistant', content: 'greeting' }],
replyCount: 0,
})
expect(summary).toBe('🤖 AI agent handed off without replying.')
})
})

47
src/lib/ai/handoff.ts Normal file
View File

@ -0,0 +1,47 @@
import type { ChatMessage } from './types'
/** Longest the quoted customer message runs before we ellipsize it —
* keeps the internal note to a glanceable one-liner. */
const MAX_QUOTE_LEN = 160
/**
* Build the short internal note the auto-reply bot leaves on a
* conversation when it hands off to a human. Deterministic — composed
* from context we already have (no extra LLM call / token spend), so it
* can't fail or add latency to the handoff.
*
* Reads as, e.g.:
* "🤖 AI agent handed off after 2 replies. Last customer message:
* “can I speak to a manager about my refund?”"
*
* `replyCount` is the bot's auto-reply tally for the thread (0 when it
* bailed on the very first inbound without answering).
*/
export function buildHandoffSummary(args: {
messages: ChatMessage[]
replyCount: number
}): string {
const { messages, replyCount } = args
const lastCustomer = [...messages]
.reverse()
.find((m) => m.role === 'user' && m.content.trim())
const replies =
replyCount === 0
? 'without replying'
: `after ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
const base = `🤖 AI agent handed off ${replies}.`
if (!lastCustomer) return base
const quote = truncate(lastCustomer.content.trim(), MAX_QUOTE_LEN)
return `${base} Last customer message: “${quote}”`
}
function truncate(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, ' ')
if (collapsed.length <= max) return collapsed
return `${collapsed.slice(0, max - 1).trimEnd()}…`
}

View File

@ -1,7 +1,8 @@
import { AiError, type ChatMessage } from '../types'
import { AiError, type ChatMessage, type ProviderResult } from '../types'
import { MAX_OUTPUT_TOKENS } from '../defaults'
import {
mergeConsecutive,
normalizeUsage,
providerHttpError,
toNetworkError,
type ProviderArgs,
@ -12,6 +13,7 @@ const ANTHROPIC_VERSION = '2023-06-01'
interface AnthropicResponse {
content?: { type?: string; text?: string }[]
usage?: { input_tokens?: number; output_tokens?: number }
}
/**
@ -34,10 +36,10 @@ function normalizeForAnthropic(messages: ChatMessage[]): ChatMessage[] {
/**
* Call Anthropic's Messages endpoint with the caller's own key.
* Returns the raw assistant text (handoff parsing happens in
* `generateReply`).
* Returns the raw assistant text + token usage (handoff parsing happens
* in `generateReply`).
*/
export async function generateAnthropic(args: ProviderArgs): Promise<string> {
export async function generateAnthropic(args: ProviderArgs): Promise<ProviderResult> {
const { apiKey, model, systemPrompt, messages, timeoutMs } = args
let res: Response
@ -76,5 +78,10 @@ export async function generateAnthropic(args: ProviderArgs): Promise<string> {
code: 'empty_response',
})
}
return text
// Anthropic reports input/output but no total — normalizeUsage sums.
const usage = normalizeUsage({
prompt: data?.usage?.input_tokens,
completion: data?.usage?.output_tokens,
})
return { text, usage }
}

View File

@ -1,7 +1,8 @@
import { AiError } from '../types'
import { AiError, type ProviderResult } from '../types'
import { MAX_OUTPUT_TOKENS } from '../defaults'
import {
mergeConsecutive,
normalizeUsage,
providerHttpError,
toNetworkError,
type ProviderArgs,
@ -11,14 +12,19 @@ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'
interface OpenAiResponse {
choices?: { message?: { content?: string } }[]
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
}
}
/**
* Call OpenAI's Chat Completions endpoint with the caller's own key.
* Returns the raw assistant text (handoff parsing happens in
* `generateReply`).
* Returns the raw assistant text + token usage (handoff parsing happens
* in `generateReply`).
*/
export async function generateOpenAi(args: ProviderArgs): Promise<string> {
export async function generateOpenAi(args: ProviderArgs): Promise<ProviderResult> {
const { apiKey, model, systemPrompt, messages, timeoutMs } = args
let res: Response
@ -54,5 +60,10 @@ export async function generateOpenAi(args: ProviderArgs): Promise<string> {
code: 'empty_response',
})
}
return text
const usage = normalizeUsage({
prompt: data?.usage?.prompt_tokens,
completion: data?.usage?.completion_tokens,
total: data?.usage?.total_tokens,
})
return { text, usage }
}

View File

@ -1,4 +1,4 @@
import { AiError, type ChatMessage } from '../types'
import { AiError, type AiUsage, type ChatMessage } from '../types'
// ============================================================
// Bits shared by the OpenAI + Anthropic adapters.
@ -12,6 +12,30 @@ export interface ProviderArgs {
timeoutMs: number
}
/**
* Coerce a provider's usage block into our normalized `AiUsage`, tolerant
* of missing/partial fields (providers differ and older API versions may
* omit counts). Returns null when there's nothing usable, so logging can
* distinguish "no usage reported" from "zero tokens". `total` falls back
* to prompt + completion when the provider doesn't send it (Anthropic).
*/
export function normalizeUsage(raw: {
prompt?: unknown
completion?: unknown
total?: unknown
}): AiUsage | null {
const num = (v: unknown): number =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0
const promptTokens = num(raw.prompt)
const completionTokens = num(raw.completion)
const total = num(raw.total)
const totalTokens = total > 0 ? total : promptTokens + completionTokens
if (promptTokens === 0 && completionTokens === 0 && totalTokens === 0) {
return null
}
return { promptTokens, completionTokens, totalTokens }
}
/** Map a fetch rejection (timeout / DNS / offline) to a typed AiError. */
export function toNetworkError(err: unknown): AiError {
if (err instanceof DOMException && err.name === 'TimeoutError') {

View File

@ -21,6 +21,10 @@ export interface AiConfig {
isActive: boolean
autoReplyEnabled: boolean
autoReplyMaxPerConversation: number
/** Where auto-reply hands a conversation off when the model bails: an
* agent's `auth.users.id`, or null to leave it unassigned (drop into
* the shared queue). */
handoffAgentId: string | null
/** Optional OpenAI-compatible key for embeddings. When set, the
* knowledge base is embedded and semantic retrieval turns on; when
* null, retrieval falls back to lexical full-text search. */
@ -33,12 +37,31 @@ export interface ChatMessage {
content: string
}
/**
* Token counts for one provider call, normalized across OpenAI
* (`prompt`/`completion`) and Anthropic (`input`/`output`). Null when
* the provider didn't return usage. Logged to `ai_usage_log`.
*/
export interface AiUsage {
promptTokens: number
completionTokens: number
totalTokens: number
}
/** Raw text + usage a provider adapter returns before handoff parsing. */
export interface ProviderResult {
text: string
usage: AiUsage | null
}
/** Outcome of a generation call. */
export interface GenerateResult {
/** The reply text, with any handoff sentinel stripped. */
text: string
/** True when the model asked to hand off to a human (auto-reply mode). */
handoff: boolean
/** Provider token usage for this call, or null when unavailable. */
usage: AiUsage | null
}
/**

62
src/lib/ai/usage.test.ts Normal file
View File

@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest'
import { logAiUsage } from './usage'
import type { SupabaseClient } from '@supabase/supabase-js'
function fakeDb() {
const insert = vi.fn().mockResolvedValue({ error: null })
const db = { from: vi.fn(() => ({ insert })) }
return { db: db as unknown as SupabaseClient, insert, from: db.from }
}
describe('logAiUsage', () => {
it('inserts a row mapping normalized usage to the log columns', async () => {
const { db, insert, from } = fakeDb()
await logAiUsage(db, {
accountId: 'acct-1',
conversationId: 'conv-1',
mode: 'auto_reply',
provider: 'anthropic',
model: 'claude-x',
usage: { promptTokens: 30, completionTokens: 6, totalTokens: 36 },
})
expect(from).toHaveBeenCalledWith('ai_usage_log')
expect(insert).toHaveBeenCalledWith({
account_id: 'acct-1',
conversation_id: 'conv-1',
mode: 'auto_reply',
provider: 'anthropic',
model: 'claude-x',
prompt_tokens: 30,
completion_tokens: 6,
total_tokens: 36,
})
})
it('is a no-op when the provider reported no usage', async () => {
const { db, from } = fakeDb()
await logAiUsage(db, {
accountId: 'acct-1',
conversationId: null,
mode: 'draft',
provider: 'openai',
model: 'gpt-x',
usage: null,
})
expect(from).not.toHaveBeenCalled()
})
it('never throws when the insert errors', async () => {
const insert = vi.fn().mockResolvedValue({ error: { message: 'boom' } })
const db = { from: vi.fn(() => ({ insert })) } as unknown as SupabaseClient
await expect(
logAiUsage(db, {
accountId: 'acct-1',
conversationId: 'conv-1',
mode: 'draft',
provider: 'openai',
model: 'gpt-x',
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
}),
).resolves.toBeUndefined()
})
})

51
src/lib/ai/usage.ts Normal file
View File

@ -0,0 +1,51 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { AiProvider, AiUsage } from './types'
export interface LogAiUsageArgs {
accountId: string
/** Null for a draft not tied to one thread, or when the row was
* deleted between generation and logging. */
conversationId: string | null
mode: 'auto_reply' | 'draft'
provider: AiProvider
model: string
/** Provider usage; a no-op when null (nothing worth recording). */
usage: AiUsage | null
}
/**
* Best-effort append to `ai_usage_log` — one row per LLM call, for cost
* visibility on the account's BYO key. NEVER throws: usage accounting
* must not fail a reply the customer is waiting on, so any DB error is
* logged and swallowed. Skips entirely when the provider didn't report
* usage (we'd only be writing zeros).
*
* Pass the service-role admin client from the webhook, or the RLS-scoped
* SSR client from a route — writes land either way (there's no
* `authenticated` INSERT policy, so an SSR write relies on the service
* role; callers that must persist from a route should pass the admin
* client).
*/
export async function logAiUsage(
db: SupabaseClient,
args: LogAiUsageArgs,
): Promise<void> {
if (!args.usage) return
try {
const { error } = await db.from('ai_usage_log').insert({
account_id: args.accountId,
conversation_id: args.conversationId,
mode: args.mode,
provider: args.provider,
model: args.model,
prompt_tokens: args.usage.promptTokens,
completion_tokens: args.usage.completionTokens,
total_tokens: args.usage.totalTokens,
})
if (error) {
console.error('[ai usage] log insert failed:', error)
}
} catch (err) {
console.error('[ai usage] log insert threw:', err)
}
}

View File

@ -43,6 +43,10 @@ interface SendTextEngineArgs {
conversationId: string
contactId: string
text: string
/** Marks the persisted message row `ai_generated = true` so the inbox
* badges it as an AI reply. Only the auto-reply bot sets this;
* deterministic Flow/automation sends leave it false. */
aiGenerated?: boolean
}
/**
@ -127,6 +131,7 @@ export async function engineSendText(
content_text: args.text,
message_id: waMessageId,
status: 'sent',
ai_generated: args.aiGenerated ?? false,
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)

View File

@ -159,6 +159,14 @@ export const RATE_LIMITS = {
* key past the provider's own rate limit. 60/min ≈ three busy agents
* drafting flat-out. */
aiDraftAccount: { limit: 60, windowMs: 60_000 },
/** AI auto-reply generation, per account. The per-conversation cap
* (`auto_reply_max_per_conversation`) bounds one thread; this bounds
* the whole account across threads, so a burst of inbound from many
* customers at once can't run the BYO key past the provider's limit
* or the owner's budget. 30/min is generous for organic inbound while
* capping a stampede; excess inbounds simply don't get an auto-reply
* (they still land in the inbox for a human). */
aiAutoReplyAccount: { limit: 30, windowMs: 60_000 },
} as const;
/** Test-only helper. Clears the in-memory state so unit tests don't

View File

@ -159,6 +159,18 @@ export interface Conversation {
created_at: string;
updated_at: string;
contact?: Contact;
/**
* AI auto-reply state for this thread (migration 029 + 033):
* - `ai_autoreply_disabled` — the bot is paused here (a human took
* over, or the model handed off). Sticky until re-enabled.
* - `ai_reply_count` — how many times the bot has auto-replied,
* checked against the account's per-conversation cap.
* - `ai_handoff_summary` — short internal note the bot wrote when it
* handed off, shown to whoever takes the thread over.
*/
ai_autoreply_disabled?: boolean;
ai_reply_count?: number;
ai_handoff_summary?: string | null;
}
// ============================================================
@ -216,6 +228,13 @@ export interface Message {
* cue (renders with a "↩ button reply" affordance).
*/
interactive_reply_id?: string;
/**
* True when the AI auto-reply bot generated + sent this message (as
* opposed to a human agent or a deterministic Flow/automation send,
* which all share `sender_type='bot'`/`'agent'`). Drives the "AI"
* badge in the inbox. Migration 033.
*/
ai_generated?: boolean;
}
export type ReactionActor = 'customer' | 'agent';

View File

@ -0,0 +1,86 @@
-- ============================================================
-- 033_ai_reply_polish.sql — AI reply assistant polish
--
-- Follow-ups to 029_ai_reply / 030_ai_knowledge that make the
-- auto-reply bot visible and controllable from the inbox, complete the
-- handoff, and record token spend:
--
-- 1. messages.ai_generated — marks a reply the bot sent (vs a
-- deterministic Flow/bot send), so
-- the inbox can badge it "AI".
-- 2. ai_configs.handoff_agent_id — where a handed-off conversation is
-- routed. NULL = leave unassigned
-- (drop into the shared queue).
-- 3. conversations.ai_handoff_summary
-- — a short internal note the bot writes
-- when it hands off, surfaced to the
-- agent who takes over.
-- 4. ai_usage_log — per-run provider token usage, for
-- cost visibility on the account's BYO
-- key. Written by the service role from
-- the draft route + auto-reply bot.
--
-- Idempotent — safe to run multiple times.
-- ============================================================
-- ============================================================
-- 1. Mark AI-generated messages.
--
-- Auto-replies are inserted as sender_type='bot' (same as Flow sends);
-- this column is the only thing that distinguishes an LLM reply from a
-- deterministic one, so the inbox can show the "AI" badge on the right
-- bubbles only.
-- ============================================================
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS ai_generated boolean NOT NULL DEFAULT false;
-- ============================================================
-- 2. Handoff routing target + 3. handoff summary.
-- ============================================================
ALTER TABLE ai_configs
ADD COLUMN IF NOT EXISTS handoff_agent_id uuid REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS ai_handoff_summary text;
-- ============================================================
-- 4. Per-run token-usage log.
--
-- One row per LLM call (draft or auto-reply). Best-effort: the writer
-- never blocks a reply on a failed insert. Kept append-only; prune with
-- a scheduled job if it grows (an active account writes a handful of
-- rows per conversation).
--
-- RLS: admin+ read (spend is billing-class, not something a viewer/agent
-- needs). Writes come from the service-role client (webhook + route),
-- which bypasses RLS, so there is no INSERT policy for `authenticated`.
-- ============================================================
CREATE TABLE IF NOT EXISTS ai_usage_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
conversation_id uuid REFERENCES conversations(id) ON DELETE SET NULL,
-- 'auto_reply' | 'draft' — which surface spent the tokens.
mode text NOT NULL CHECK (mode IN ('auto_reply', 'draft')),
provider text NOT NULL CHECK (provider IN ('openai', 'anthropic')),
model text NOT NULL,
prompt_tokens integer NOT NULL DEFAULT 0,
completion_tokens integer NOT NULL DEFAULT 0,
total_tokens integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Account-scoped, newest-first reads (usage dashboards, "spend this
-- month") — the only access pattern.
CREATE INDEX IF NOT EXISTS idx_ai_usage_log_account_created
ON ai_usage_log(account_id, created_at DESC);
ALTER TABLE ai_usage_log ENABLE ROW LEVEL SECURITY;
-- SELECT: admin+ only (spend visibility is settings/billing-class).
DROP POLICY IF EXISTS ai_usage_log_select ON ai_usage_log;
CREATE POLICY ai_usage_log_select ON ai_usage_log FOR SELECT
USING (is_account_member(account_id, 'admin'));
-- No INSERT/UPDATE/DELETE policies for `authenticated`: the log is
-- written exclusively by the service role (webhook + draft route) and
-- is never mutated from the client.