Merge pull request #353 from ArnasDon/feat/ai-autoreply-polish

feat(ai): polish auto-reply — inbox controls, real handoff, usage logging
This commit is contained in:
Arnas Donauskas 2026-07-08 16:39:17 +03:00 committed by GitHub
commit 95f6052e34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1507 additions and 53 deletions

View File

@ -9,6 +9,46 @@ 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 + dashboard.** Every draft and auto-reply records
its provider token counts to the new `ai_usage_log` table
(admin-readable). A new **AI Agents → Usage** tab (admin-only) charts
daily token spend on your BYO key with per-mode and per-model
breakdowns, backed by `GET /api/ai/usage`. Counts only — no message
content is stored or shown.
### 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

@ -205,7 +205,9 @@
"template": "Template",
"buttonReply": "Button reply",
"interactiveReply": "[Interactive reply]",
"unsupported": "[Unsupported message type]"
"unsupported": "[Unsupported message type]",
"aiBadge": "AI",
"aiBadgeTitle": "Sent automatically by the AI assistant"
},
"actions": {
"reply": "Reply",
@ -251,6 +253,16 @@
"noTags": "No tags",
"noDeals": "No deals",
"addNotePlaceholder": "Add a note..."
},
"aiBanner": {
"pausedTitle": "AI assistant is paused here",
"activeText": "AI assistant is replying automatically",
"takeOver": "Take over",
"resume": "Resume AI",
"tookOver": "You’ve taken over this chat.",
"resumed": "AI resumed.",
"updateError": "Couldn’t update the AI assistant.",
"networkError": "Couldn’t reach the server."
}
},
"Contacts": {
@ -1623,6 +1635,9 @@
"autoReplyDesc": "The bot answers new inbound messages automatically (only when no flow handles them and no agent is assigned). Hands off to a human when it can’t help.",
"maxAutoReplies": "Max auto-replies per conversation",
"maxAutoRepliesDesc": "After this many bot replies in one thread, the bot goes quiet.",
"handoffTo": "Hand off to",
"handoffToDesc": "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.",
"handoffQueue": "Unassigned queue (any agent can pick it up)",
"remove": "Remove",
"save": "Save",
"missingModel": "Enter a model name.",

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

@ -1,14 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import { Bot, Sparkles, Settings2 } from 'lucide-react';
import { Bot, Sparkles, Settings2, BarChart3 } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { AiPlayground } from '@/components/agents/ai-playground';
import { AiUsageCard } from '@/components/agents/ai-usage';
import { AiConfig } from '@/components/settings/ai-config';
import { useAuth } from '@/hooks/use-auth';
import { canEditSettings } from '@/lib/auth/roles';
type Tab = 'playground' | 'setup';
type Tab = 'playground' | 'setup' | 'usage';
export default function AgentsPage() {
const { accountRole } = useAuth();
const canViewUsage = accountRole ? canEditSettings(accountRole) : false;
const [tab, setTab] = useState<Tab>('playground');
const [decided, setDecided] = useState(false);
@ -57,6 +62,11 @@ export default function AgentsPage() {
<TabsTrigger value="setup">
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
</TabsTrigger>
{canViewUsage && (
<TabsTrigger value="usage">
<BarChart3 className="mr-1.5 h-4 w-4" /> Usage
</TabsTrigger>
)}
</TabsList>
<TabsContent value="playground" className="mt-4">
@ -66,6 +76,12 @@ export default function AgentsPage() {
<TabsContent value="setup" className="mt-4">
<AiConfig />
</TabsContent>
{canViewUsage && (
<TabsContent value="usage" className="mt-4">
<AiUsageCard />
</TabsContent>
)}
</Tabs>
)}
</div>

View File

@ -0,0 +1,103 @@
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')
.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 hands the thread *back to the bot*. Clear the pause and
// the handoff note, and — crucially — release ANY assignment, not
// just the caller's own: the auto-reply eligibility gate stands
// down whenever a human is assigned, so leaving a stale assignee
// (e.g. the agent a prior handoff routed to) would silently keep
// the bot muted and make "Resume AI" a no-op. This is the explicit
// choice to let the bot own the thread again.
update.assigned_agent_id = null
// Give the bot a fresh reply budget on this thread. This is a
// deliberate, manual, rate-limited action (not automatable), so it
// can't be used to bypass the per-conversation cap at scale — it's
// a human choosing to re-engage the assistant.
update.ai_reply_count = 0
update.ai_handoff_summary = 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,28 @@ 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). This
// must not fail or delay the draft the agent is waiting on, so:
// - the whole thing is wrapped (constructing the admin client throws
// if the service-role key is unset — that must not 500 the draft);
// - it's fire-and-forget (`void`), not awaited, so the response
// isn't held for a DB round-trip.
try {
void logAiUsage(supabaseAdmin(), {
accountId,
conversationId,
mode: 'draft',
provider: config.provider,
model: config.model,
usage,
})
} catch (logErr) {
console.error('[ai/draft] usage log skipped:', logErr)
}
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,141 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { daysAgoStart, lastNDayKeys, localDayKey } from '@/lib/dashboard/date-utils'
// Rows are aggregated in-process over a bounded window. An active
// account writes a handful of rows per conversation, so 30 days sits
// comfortably under this cap; we surface `truncated` when it doesn't so
// the UI can say "showing a partial window" rather than under-reporting
// silently.
const MAX_ROWS = 10_000
const DEFAULT_WINDOW_DAYS = 30
interface UsageRow {
created_at: string
mode: 'auto_reply' | 'draft'
provider: string
model: string
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
/**
* GET /api/ai/usage?days=30 (admin+)
*
* Token-spend summary for the account's BYO key over the last `days`
* (1–90, default 30): totals, per-mode + per-model breakdowns, and a
* zero-filled daily series for charting. Admin-only, mirroring the
* `ai_usage_log` SELECT policy — spend is billing-class.
*/
export async function GET(request: Request) {
try {
const { supabase, accountId } = await requireRole('admin')
const url = new URL(request.url)
const rawDays = Number(url.searchParams.get('days'))
// Guard `>= 1`, not just `isFinite`: a missing/blank param is
// Number(null)/Number('') === 0, which is finite — without the lower
// bound the default would never apply and the window would collapse
// to a single day.
const days =
Number.isFinite(rawDays) && rawDays >= 1
? Math.min(90, Math.floor(rawDays))
: DEFAULT_WINDOW_DAYS
// Align the query cutoff to the START of the oldest local day we'll
// chart (not a rolling `now - N*24h` instant). Otherwise rows in the
// oldest partial day would be counted in the totals but fall outside
// every daily bucket, so the chart's bars wouldn't sum to the
// headline total. Local-day boundaries match every other dashboard
// chart (see lib/dashboard/date-utils).
const since = daysAgoStart(days - 1)
const { data, error } = await supabase
.from('ai_usage_log')
.select(
'created_at, mode, provider, model, prompt_tokens, completion_tokens, total_tokens',
)
.eq('account_id', accountId)
.gte('created_at', since.toISOString())
.order('created_at', { ascending: false })
.limit(MAX_ROWS + 1)
if (error) {
console.error('[ai/usage GET] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load usage' },
{ status: 500 },
)
}
const all = (data ?? []) as UsageRow[]
const truncated = all.length > MAX_ROWS
const rows = truncated ? all.slice(0, MAX_ROWS) : all
// Totals.
let promptTokens = 0
let completionTokens = 0
let totalTokens = 0
// Per-mode + per-model tallies.
const byMode = {
auto_reply: { calls: 0, tokens: 0 },
draft: { calls: 0, tokens: 0 },
}
const modelMap = new Map<
string,
{ model: string; provider: string; calls: number; tokens: number }
>()
// Zero-filled daily buckets so the chart shows quiet days as gaps,
// not missing points. Local-day keys, oldest → newest — the same
// helper every other dashboard chart uses, so day boundaries agree.
const daily = new Map<string, { date: string; tokens: number; calls: number }>()
for (const key of lastNDayKeys(days)) {
daily.set(key, { date: key, tokens: 0, calls: 0 })
}
for (const r of rows) {
promptTokens += r.prompt_tokens
completionTokens += r.completion_tokens
totalTokens += r.total_tokens
// `mode` is DB-CHECK-constrained to these two values.
byMode[r.mode].calls += 1
byMode[r.mode].tokens += r.total_tokens
const mk = `${r.provider}:${r.model}`
const m =
modelMap.get(mk) ??
{ model: r.model, provider: r.provider, calls: 0, tokens: 0 }
m.calls += 1
m.tokens += r.total_tokens
modelMap.set(mk, m)
const bucket = daily.get(localDayKey(r.created_at))
if (bucket) {
bucket.tokens += r.total_tokens
bucket.calls += 1
}
}
const byModel = [...modelMap.values()].sort((a, b) => b.tokens - a.tokens)
return NextResponse.json({
window_days: days,
truncated,
totals: {
calls: rows.length,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: totalTokens,
},
by_mode: byMode,
by_model: byModel,
daily: [...daily.values()],
})
} catch (err) {
return toErrorResponse(err)
}
}

View File

@ -0,0 +1,236 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { BarChart3, Bot, PencilLine } from 'lucide-react';
import { useAuth } from '@/hooks/use-auth';
import { canEditSettings } from '@/lib/auth/roles';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/dashboard/skeleton';
import { BarChart } from '@/components/tremor/bar-chart';
import { formatCompactNumber } from '@/lib/currency';
import { format, parseISO } from 'date-fns';
interface UsageResponse {
window_days: number;
truncated: boolean;
totals: {
calls: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
by_mode: {
auto_reply: { calls: number; tokens: number };
draft: { calls: number; tokens: number };
};
by_model: {
model: string;
provider: string;
calls: number;
tokens: number;
}[];
daily: { date: string; tokens: number; calls: number }[];
}
const WINDOWS = [7, 30, 90] as const;
/**
* Token-spend dashboard for the account's BYO key. Admin-only (spend is
* billing-class), mirroring the `ai_usage_log` SELECT policy and the
* `GET /api/ai/usage` route. Renders nothing for non-admins.
*/
export function AiUsageCard() {
const { accountId, accountRole, profileLoading } = useAuth();
const canView = accountRole ? canEditSettings(accountRole) : false;
const [days, setDays] = useState<number>(30);
const [loading, setLoading] = useState(true);
const [data, setData] = useState<UsageResponse | null>(null);
const loadedRef = useRef<string | null>(null);
const fetchUsage = useCallback(async (windowDays: number) => {
setLoading(true);
try {
const res = await fetch(`/api/ai/usage?days=${windowDays}`, {
cache: 'no-store',
});
const json = await res.json().catch(() => null);
if (!res.ok) {
toast.error(json?.error ?? 'Failed to load usage');
setData(null);
return;
}
setData(json as UsageResponse);
} catch {
toast.error('Failed to load usage');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!canView || !accountId) return;
// Refetch on account switch or window change.
const key = `${accountId}:${days}`;
if (loadedRef.current === key) return;
loadedRef.current = key;
void fetchUsage(days);
}, [canView, accountId, days, fetchUsage]);
if (profileLoading || !canView) return null;
const chartData =
data?.daily.map((d) => ({ day: format(parseISO(d.date), 'MMM d'), Tokens: d.tokens })) ??
[];
const hasSpend = (data?.totals.total_tokens ?? 0) > 0;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4 text-primary" /> Token usage
</CardTitle>
<CardDescription>
Tokens spent on your provider key by drafts and the auto-reply
bot. Counts only — no message content is stored here.
</CardDescription>
</div>
<Select
value={String(days)}
onValueChange={(v) => setDays(Number(v))}
>
<SelectTrigger className="w-32 flex-shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((w) => (
<SelectItem key={w} value={String(w)}>
Last {w} days
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-5">
{loading || !data ? (
<Skeleton className="h-[220px] w-full" />
) : !hasSpend ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm text-muted-foreground">
<BarChart3 className="h-8 w-8 opacity-40" />
<p>No AI usage in the last {data.window_days} days yet.</p>
<p className="text-xs">
This fills in as the assistant drafts and auto-replies.
</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Total tokens" value={formatCompactNumber(data.totals.total_tokens)} />
<Stat label="LLM calls" value={String(data.totals.calls)} />
<Stat
label="Auto-reply"
value={formatCompactNumber(data.by_mode.auto_reply.tokens)}
icon={Bot}
/>
<Stat
label="Drafts"
value={formatCompactNumber(data.by_mode.draft.tokens)}
icon={PencilLine}
/>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Tokens per day
</p>
<BarChart
data={chartData}
index="day"
categories={['Tokens']}
colors={['violet']}
valueFormatter={(v) => formatCompactNumber(v)}
showLegend={false}
yAxisWidth={48}
className="h-[200px]"
/>
</div>
{data.by_model.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
By model
</p>
<ul className="divide-y divide-border rounded-md border border-border">
{data.by_model.map((m) => (
<li
key={`${m.provider}:${m.model}`}
className="flex items-center justify-between px-3 py-2 text-sm"
>
<span className="min-w-0 truncate">
<span className="text-foreground">{m.model}</span>{' '}
<span className="text-xs text-muted-foreground">
({m.provider})
</span>
</span>
<span className="flex-shrink-0 tabular-nums text-muted-foreground">
{formatCompactNumber(m.tokens)} tok · {m.calls}{' '}
{m.calls === 1 ? 'call' : 'calls'}
</span>
</li>
))}
</ul>
</div>
)}
{data.truncated && (
<p className="text-xs text-muted-foreground">
Showing a partial window — usage is high enough that only the
most recent records are summarized here.
</p>
)}
</>
)}
</CardContent>
</Card>
);
}
function Stat({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon?: typeof Bot;
}) {
return (
<div className="rounded-md border border-border p-3">
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{Icon && <Icon className="h-3 w-3" />}
{label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-foreground">
{value}
</p>
</div>
);
}

View File

@ -0,0 +1,225 @@
"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";
import { useTranslations } from "next-intl";
import { useAuth } from "@/hooks/use-auth";
// ------------------------------------------------------------
// Account AI status is the same for every conversation, so cache it per
// account and reuse it across thread switches instead of hitting
// /api/ai/config every time the agent opens a chat.
//
// Keyed by accountId (a multi-account user switching workspaces must not
// see the previous account's status), and only *successful* fetches are
// cached — a transient failure returns a default without poisoning the
// cache, so it retries on the next thread open rather than hiding the
// banner for the whole session.
// ------------------------------------------------------------
interface AiAccountStatus {
autoReplyOn: boolean;
}
const statusCache = new Map<string, AiAccountStatus>();
async function fetchAiAccountStatus(accountId: string): Promise<AiAccountStatus> {
const cached = statusCache.get(accountId);
if (cached) return cached;
try {
const res = await fetch("/api/ai/config", { cache: "no-store" });
if (!res.ok) return { autoReplyOn: false }; // don't cache a transient failure
const j = await res.json();
const status = {
// 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),
};
statusCache.set(accountId, status);
return status;
} catch {
return { autoReplyOn: false }; // don't cache
}
}
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 t = useTranslations("Inbox.aiBanner");
const { accountId } = useAuth();
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(() => {
if (!accountId) return;
let alive = true;
fetchAiAccountStatus(accountId).then((s) => alive && setAutoReplyOn(s.autoReplyOn));
return () => {
alive = false;
};
}, [accountId]);
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 ?? t("updateError"));
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 ? t("tookOver") : t("resumed"));
} catch {
toast.error(t("networkError"));
} finally {
setBusy(false);
}
},
[conversationId, currentUserId, onChange, t],
);
// 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">{t("pausedTitle")}</p>
{handoffSummary && (
<p className="truncate text-muted-foreground" title={handoffSummary}>
{handoffSummary}
</p>
)}
</div>
<BannerButton onClick={() => toggle(false)} busy={busy} icon={Undo2}>
{t("resume")}
</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">
{t("activeText")}
</span>
</div>
<BannerButton onClick={() => toggle(true)} busy={busy} icon={Hand}>
{t("takeOver")}
</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";
@ -285,6 +286,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={t("aiBadgeTitle")}
>
<Sparkles className="h-2.5 w-2.5" />
{t("aiBadge")}
</span>
)}
<span
className={cn(
"text-[10px]",

View File

@ -47,6 +47,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";
@ -1078,6 +1079,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,10 +28,16 @@ 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';
import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
import { useTranslations } from 'next-intl';
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)',
@ -66,6 +72,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 —
@ -90,6 +99,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);
@ -108,6 +118,10 @@ 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 fetchAccountMembers().then(setMembers);
}, [accountId, fetchConfig]);
// Swap the model default when the provider changes, unless the user
@ -136,6 +150,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 () => {
@ -203,6 +218,7 @@ export function AiConfig() {
setIsActive(false);
setAutoReplyEnabled(false);
setSystemPrompt('');
setHandoffAgentId('');
} else {
const data = await res.json();
toast.error(data.error ?? t('removeFailed'));
@ -439,6 +455,34 @@ export function AiConfig() {
className="w-20"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-handoff">{t('handoffTo')}</Label>
<p className="text-xs text-muted-foreground">
{t('handoffToDesc')}
</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}>
{t('handoffQueue')}
</SelectItem>
{members.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}>
{memberLabel(m)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>

View File

@ -0,0 +1,25 @@
import type { AccountMember } from '@/types';
/**
* Fetch the current account's members from the API (which applies the
* email-visibility rules — agents/viewers don't see emails). Best-effort:
* returns `[]` on any error or on an older deployment without the
* endpoint, so callers can fall back to a queue-only / raw-id picker.
*
* Client-side only (uses `fetch` against the relative API route).
*/
export async function fetchAccountMembers(): Promise<AccountMember[]> {
try {
const res = await fetch('/api/account/members', { cache: 'no-store' });
if (!res.ok) return [];
const json = (await res.json()) as { members?: AccountMember[] };
return json.members ?? [];
} catch {
return [];
}
}
/** Display label for a member: full name → email → raw id. */
export function memberLabel(m: AccountMember): string {
return m.full_name || m.email || m.user_id;
}

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,48 @@ 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. Fire-and-forget so it
// never adds latency to the customer-facing send: `logAiUsage`
// swallows its own errors, so the floating promise can't reject.
// Logged regardless of handoff — the provider call happened either
// way.
void 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 +185,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

@ -90,8 +90,17 @@ export function formatCurrencyShort(
): string {
const code = currency || DEFAULT_CURRENCY;
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
const v = Number(value || 0);
if (v >= 1_000_000) return `${symbol}${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${symbol}${(v / 1_000).toFixed(1)}k`;
return `${symbol}${v.toFixed(0)}`;
return `${symbol}${formatCompactNumber(value)}`;
}
/**
* Compact number for tight spaces (chart tiles, legends): 1_234 → "1.2k",
* 1_200_000 → "1.2M", 900 → "900". The unit-less core shared with
* {@link formatCurrencyShort}.
*/
export function formatCompactNumber(value: number): string {
const v = Number(value || 0);
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
return v.toFixed(0);
}

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.

View File

@ -1,7 +1,14 @@
-- ============================================================
-- 031_fix_profiles_update_rls.sql — lock down privilege columns
-- 034_fix_profiles_update_rls.sql — lock down privilege columns
-- on profiles (GHSA-fg5p-2qc3-jmxr, C1)
--
-- NOTE: renamed from 031 → 034 to resolve a duplicate migration version.
-- The 031 slot was already taken by 031_ai_reply_slot_grant.sql (#345),
-- so shipping this as 031 too made a clean `supabase db` apply fail with
-- a duplicate schema_migrations key (SQLSTATE 23505). This migration is
-- idempotent (DROP POLICY IF EXISTS / CREATE OR REPLACE) and independent
-- of the AI tables, so re-sequencing it after 033 is safe.
--
-- The problem
--
-- The `profiles_update` RLS policy from migration 017 gates on