diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbf650..e7e9a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/messages/en.json b/messages/en.json index 8ae4363..1e7b98f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/package.json b/package.json index 4e28051..8542dd4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(dashboard)/agents/page.tsx b/src/app/(dashboard)/agents/page.tsx index 151ec4d..c377484 100644 --- a/src/app/(dashboard)/agents/page.tsx +++ b/src/app/(dashboard)/agents/page.tsx @@ -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('playground'); const [decided, setDecided] = useState(false); @@ -57,6 +62,11 @@ export default function AgentsPage() { Setup + {canViewUsage && ( + + Usage + + )} @@ -66,6 +76,12 @@ export default function AgentsPage() { + + {canViewUsage && ( + + + + )} )} diff --git a/src/app/api/ai/autoreply/[conversationId]/route.ts b/src/app/api/ai/autoreply/[conversationId]/route.ts new file mode 100644 index 0000000..aaa69fb --- /dev/null +++ b/src/app/api/ai/autoreply/[conversationId]/route.ts @@ -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 = { 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) + } +} diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts index 113cb01..08025df 100644 --- a/src/app/api/ai/config/route.ts +++ b/src/app/api/ai/config/route.ts @@ -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) { diff --git a/src/app/api/ai/draft/route.ts b/src/app/api/ai/draft/route.ts index 975af0f..0f35995 100644 --- a/src/app/api/ai/draft/route.ts +++ b/src/app/api/ai/draft/route.ts @@ -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) { diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 400e3dc..8fa1f5f 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -71,6 +71,7 @@ export async function POST(request: Request) { isActive: true, autoReplyEnabled: false, autoReplyMaxPerConversation: 3, + handoffAgentId: null, embeddingsApiKey: null, }) } catch (err) { diff --git a/src/app/api/ai/usage/route.ts b/src/app/api/ai/usage/route.ts new file mode 100644 index 0000000..cc5973a --- /dev/null +++ b/src/app/api/ai/usage/route.ts @@ -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() + 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) + } +} diff --git a/src/components/agents/ai-usage.tsx b/src/components/agents/ai-usage.tsx new file mode 100644 index 0000000..d24f16f --- /dev/null +++ b/src/components/agents/ai-usage.tsx @@ -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(30); + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const loadedRef = useRef(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 ( + + +
+
+ + Token usage + + + Tokens spent on your provider key by drafts and the auto-reply + bot. Counts only — no message content is stored here. + +
+ +
+
+ + {loading || !data ? ( + + ) : !hasSpend ? ( +
+ +

No AI usage in the last {data.window_days} days yet.

+

+ This fills in as the assistant drafts and auto-replies. +

+
+ ) : ( + <> +
+ + + + +
+ +
+

+ Tokens per day +

+ formatCompactNumber(v)} + showLegend={false} + yAxisWidth={48} + className="h-[200px]" + /> +
+ + {data.by_model.length > 0 && ( +
+

+ By model +

+
    + {data.by_model.map((m) => ( +
  • + + {m.model}{' '} + + ({m.provider}) + + + + {formatCompactNumber(m.tokens)} tok · {m.calls}{' '} + {m.calls === 1 ? 'call' : 'calls'} + +
  • + ))} +
+
+ )} + + {data.truncated && ( +

+ Showing a partial window — usage is high enough that only the + most recent records are summarized here. +

+ )} + + )} +
+
+ ); +} + +function Stat({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon?: typeof Bot; +}) { + return ( +
+

+ {Icon && } + {label} +

+

+ {value} +

+
+ ); +} diff --git a/src/components/inbox/ai-thread-banner.tsx b/src/components/inbox/ai-thread-banner.tsx new file mode 100644 index 0000000..9436115 --- /dev/null +++ b/src/components/inbox/ai-thread-banner.tsx @@ -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(); + +async function fetchAiAccountStatus(accountId: string): Promise { + 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(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 ( + +
+

{t("pausedTitle")}

+ {handoffSummary && ( +

+ {handoffSummary} +

+ )} +
+ toggle(false)} busy={busy} icon={Undo2}> + {t("resume")} + +
+ ); + } + + // Active, but a human already owns it → the bot won't fire; no banner. + if (assignedAgentId) return null; + + // Active on this thread. + return ( + +
+ + + {t("activeText")} + +
+ toggle(true)} busy={busy} icon={Hand}> + {t("takeOver")} + +
+ ); +} + +function Banner({ + tone, + children, +}: { + tone: "primary" | "muted"; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +function BannerButton({ + onClick, + busy, + icon: Icon, + children, +}: { + onClick: () => void; + busy: boolean; + icon: typeof Hand; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 6cedbfb..80b8a9d 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -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 && ( + + + {t("aiBadge")} + + )} + {/* AI auto-reply banner — take over an active bot, or resume it + after a handoff. Renders nothing unless the account has + auto-reply configured. */} + { + if ("assigned_agent_id" in patch) { + onAssignChange(conversation.id, patch.assigned_agent_id ?? null); + } + }} + /> + {/* Composer */} = { 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([]); // 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" /> + +
+ +

+ {t('handoffToDesc')} +

+ +
diff --git a/src/lib/account/members.ts b/src/lib/account/members.ts new file mode 100644 index 0000000..fc5eefb --- /dev/null +++ b/src/lib/account/members.ts @@ -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 { + 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; +} diff --git a/src/lib/ai/auto-reply.test.ts b/src/lib/ai/auto-reply.test.ts index df5ca91..b2e2a28 100644 --- a/src/lib/ai/auto-reply.test.ts +++ b/src/lib/ai/auto-reply.test.ts @@ -75,6 +75,7 @@ function aiConfig(overrides: Partial = {}): 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', + }) }) }) diff --git a/src/lib/ai/auto-reply.ts b/src/lib/ai/auto-reply.ts index 4b50f1c..df758f9 100644 --- a/src/lib/ai/auto-reply.ts +++ b/src/lib/ai/auto-reply.ts @@ -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 = { + 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) diff --git a/src/lib/ai/config.ts b/src/lib/ai/config.ts index 9201aec..49cd6a2 100644 --- a/src/lib/ai/config.ts +++ b/src/lib/ai/config.ts @@ -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, } } diff --git a/src/lib/ai/generate.test.ts b/src/lib/ai/generate.test.ts index c30b717..3b6a452 100644 --- a/src/lib/ai/generate.test.ts +++ b/src/lib/ai/generate.test.ts @@ -11,6 +11,7 @@ function config(overrides: Partial = {}): 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') diff --git a/src/lib/ai/generate.ts b/src/lib/ai/generate.ts index ae88030..31315b2 100644 --- a/src/lib/ai/generate.ts +++ b/src/lib/ai/generate.ts @@ -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 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 }) } - 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 } } diff --git a/src/lib/ai/handoff.test.ts b/src/lib/ai/handoff.test.ts new file mode 100644 index 0000000..e7881d5 --- /dev/null +++ b/src/lib/ai/handoff.test.ts @@ -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.') + }) +}) diff --git a/src/lib/ai/handoff.ts b/src/lib/ai/handoff.ts new file mode 100644 index 0000000..b8055c3 --- /dev/null +++ b/src/lib/ai/handoff.ts @@ -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()}…` +} diff --git a/src/lib/ai/providers/anthropic.ts b/src/lib/ai/providers/anthropic.ts index 25fca32..b2bb469 100644 --- a/src/lib/ai/providers/anthropic.ts +++ b/src/lib/ai/providers/anthropic.ts @@ -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 { +export async function generateAnthropic(args: ProviderArgs): Promise { const { apiKey, model, systemPrompt, messages, timeoutMs } = args let res: Response @@ -76,5 +78,10 @@ export async function generateAnthropic(args: ProviderArgs): Promise { 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 } } diff --git a/src/lib/ai/providers/openai.ts b/src/lib/ai/providers/openai.ts index dcca61c..9f63246 100644 --- a/src/lib/ai/providers/openai.ts +++ b/src/lib/ai/providers/openai.ts @@ -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 { +export async function generateOpenAi(args: ProviderArgs): Promise { const { apiKey, model, systemPrompt, messages, timeoutMs } = args let res: Response @@ -54,5 +60,10 @@ export async function generateOpenAi(args: ProviderArgs): Promise { 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 } } diff --git a/src/lib/ai/providers/shared.ts b/src/lib/ai/providers/shared.ts index 06fb07d..d685fbb 100644 --- a/src/lib/ai/providers/shared.ts +++ b/src/lib/ai/providers/shared.ts @@ -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') { diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 7af27ed..ac0eebf 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -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 } /** diff --git a/src/lib/ai/usage.test.ts b/src/lib/ai/usage.test.ts new file mode 100644 index 0000000..51ac088 --- /dev/null +++ b/src/lib/ai/usage.test.ts @@ -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() + }) +}) diff --git a/src/lib/ai/usage.ts b/src/lib/ai/usage.ts new file mode 100644 index 0000000..72991dd --- /dev/null +++ b/src/lib/ai/usage.ts @@ -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 { + 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) + } +} diff --git a/src/lib/currency.ts b/src/lib/currency.ts index c85f3eb..50d405c 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -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); } diff --git a/src/lib/flows/meta-send.ts b/src/lib/flows/meta-send.ts index 48f382a..133c11a 100644 --- a/src/lib/flows/meta-send.ts +++ b/src/lib/flows/meta-send.ts @@ -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}`) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d24431a..6c3bfea 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -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 diff --git a/src/types/index.ts b/src/types/index.ts index ea94769..afc1420 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'; diff --git a/supabase/migrations/033_ai_reply_polish.sql b/supabase/migrations/033_ai_reply_polish.sql new file mode 100644 index 0000000..1ba7a42 --- /dev/null +++ b/supabase/migrations/033_ai_reply_polish.sql @@ -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. diff --git a/supabase/migrations/031_fix_profiles_update_rls.sql b/supabase/migrations/034_fix_profiles_update_rls.sql similarity index 89% rename from supabase/migrations/031_fix_profiles_update_rls.sql rename to supabase/migrations/034_fix_profiles_update_rls.sql index 131ab94..50bd634 100644 --- a/supabase/migrations/031_fix_profiles_update_rls.sql +++ b/supabase/migrations/034_fix_profiles_update_rls.sql @@ -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