From b0194dfc27cc9e66f6f2263102956e0b3b285d46 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 14:03:33 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(ai):=20polish=20auto-reply=20=E2=80=94?= =?UTF-8?q?=20inbox=20controls,=20real=20handoff,=20usage=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the AI auto-reply bot visible and controllable from the inbox, completes the handoff, and records token spend. Builds on the existing lib/ai engine rather than replacing it. Track A — user-facing - messages.ai_generated flag → small "AI" badge on bot-generated bubbles - "Take over" / "Resume AI" banner per conversation (POST /api/ai/autoreply/[id]) — take-over pauses + assigns to the agent - handoff now assigns to a configurable target (agent or unassigned queue) and writes an internal note summarizing the exchange; the existing on_conversation_assigned trigger notifies the assignee - Settings → AI: "Hand off to" target picker Track B — observability + safety - providers return normalized token usage; logged per run to ai_usage_log (admin-readable) from both auto-reply and draft - account-wide auto-reply rate limit (30/min) on top of the per-conversation cap Migration: 033_ai_reply_polish.sql (messages.ai_generated, ai_configs.handoff_agent_id, conversations.ai_handoff_summary, ai_usage_log). Tests + typecheck + lint pass (607 tests). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 37 +++ package.json | 2 +- .../ai/autoreply/[conversationId]/route.ts | 95 ++++++++ src/app/api/ai/config/route.ts | 25 ++- src/app/api/ai/draft/route.ts | 17 +- src/app/api/ai/test/route.ts | 1 + src/components/inbox/ai-thread-banner.tsx | 210 ++++++++++++++++++ src/components/inbox/message-bubble.tsx | 14 ++ src/components/inbox/message-thread.tsx | 17 ++ src/components/settings/ai-config.tsx | 53 +++++ src/lib/ai/auto-reply.test.ts | 20 +- src/lib/ai/auto-reply.ts | 60 ++++- src/lib/ai/config.ts | 4 +- src/lib/ai/generate.test.ts | 51 ++++- src/lib/ai/generate.ts | 31 ++- src/lib/ai/handoff.test.ts | 66 ++++++ src/lib/ai/handoff.ts | 47 ++++ src/lib/ai/providers/anthropic.ts | 17 +- src/lib/ai/providers/openai.ts | 21 +- src/lib/ai/providers/shared.ts | 26 ++- src/lib/ai/types.ts | 23 ++ src/lib/ai/usage.test.ts | 62 ++++++ src/lib/ai/usage.ts | 51 +++++ src/lib/flows/meta-send.ts | 5 + src/lib/rate-limit.ts | 8 + src/types/index.ts | 19 ++ supabase/migrations/033_ai_reply_polish.sql | 86 +++++++ 27 files changed, 1023 insertions(+), 45 deletions(-) create mode 100644 src/app/api/ai/autoreply/[conversationId]/route.ts create mode 100644 src/components/inbox/ai-thread-banner.tsx create mode 100644 src/lib/ai/handoff.test.ts create mode 100644 src/lib/ai/handoff.ts create mode 100644 src/lib/ai/usage.test.ts create mode 100644 src/lib/ai/usage.ts create mode 100644 supabase/migrations/033_ai_reply_polish.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbf650..6e8ad8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,43 @@ Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes and polish. +## [0.8.0] — 2026-07-08 + +Polishes the AI auto-reply bot: it's now **visible and controllable from +the inbox**, its **handoff actually hands off**, and its **token spend is +logged**. + +> **Migration required:** apply `supabase/migrations/033_ai_reply_polish.sql` +> (adds `messages.ai_generated`, `ai_configs.handoff_agent_id`, +> `conversations.ai_handoff_summary`, and the `ai_usage_log` table). + +### Added + +- **"AI" badge in the inbox.** Replies the bot sent are tagged with a + small ✨ AI badge, so agents can tell an automated reply from their own + or a Flow's at a glance. (New `messages.ai_generated` flag; only the + auto-reply bot sets it.) +- **Take over / Resume from the thread.** A banner on AI-handled + conversations lets an agent **Take over** (pauses the bot for that + thread and assigns it to them) or **Resume AI** (hands the thread back + and clears the pause). Backed by `POST /api/ai/autoreply/[id]`. +- **Real handoff.** When the bot bails (can't help, or hits the reply + cap) it now (1) routes the conversation to a configurable **handoff + target** — a specific agent, or the unassigned queue — and (2) leaves a + short **internal note** summarizing the exchange for whoever picks it + up. Assigning fires the existing assignment notification. Pick the + target under **AI Agents → Setup → Hand off to**. +- **Token-usage logging.** Every draft and auto-reply records its + provider token counts to the new `ai_usage_log` table (admin-readable), + for cost visibility on your BYO key. + +### Changed + +- Auto-reply now has an **account-wide rate limit** (30/min) on top of + the existing per-conversation cap, so a burst of inbound can't run your + provider key past its limit. Over the limit, inbounds simply wait in + the inbox for a human instead of being auto-answered. + ## [0.7.0] — 2026-07-02 Promotes the AI assistant to a first-class **AI Agents** section in the 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/api/ai/autoreply/[conversationId]/route.ts b/src/app/api/ai/autoreply/[conversationId]/route.ts new file mode 100644 index 0000000..83a5fb0 --- /dev/null +++ b/src/app/api/ai/autoreply/[conversationId]/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server' +import { requireRole, toErrorResponse } from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' + +type Params = { params: Promise<{ conversationId: string }> } + +/** + * POST /api/ai/autoreply/[conversationId] (agent+) + * + * Toggle the AI auto-reply bot for one conversation from the inbox — the + * "Take over" / "Resume AI" banner. + * + * Body: { paused: boolean, assign_to_me?: boolean } + * - paused: true → pause the bot here (a human is taking over). When + * `assign_to_me` is set, also assign the thread to the + * caller (the usual "Take over" flow). Assignment + * fires the `on_conversation_assigned` trigger. + * - paused: false → hand the thread back to the bot: clear the pause, + * reset the per-conversation reply count so it gets + * fresh slots, and clear the handoff note. If the + * caller currently owns the thread, unassign it too so + * the bot isn't blocked by the "human owns this" gate. + * + * Writes go through the RLS-scoped SSR client, so a conversation outside + * the caller's account simply isn't found (404). + */ +export async function POST(request: Request, { params }: Params) { + try { + const { supabase, accountId, userId } = await requireRole('agent') + + // Reuse the send bucket: this is a cheap per-user inbox action and + // toggling it in a tight loop has no legitimate use. + const limit = checkRateLimit(`ai-takeover:${userId}`, RATE_LIMITS.send) + if (!limit.success) return rateLimitResponse(limit) + + const { conversationId } = await params + const body = await request.json().catch(() => null) + if (!body || typeof body.paused !== 'boolean') { + return NextResponse.json( + { error: 'paused (boolean) is required' }, + { status: 400 }, + ) + } + const paused = body.paused as boolean + const assignToMe = body.assign_to_me === true + + // Confirm the conversation is in the caller's account before writing. + const { data: conv, error: convErr } = await supabase + .from('conversations') + .select('id, assigned_agent_id') + .eq('id', conversationId) + .eq('account_id', accountId) + .maybeSingle() + if (convErr) { + console.error('[ai/autoreply] conversation lookup error:', convErr) + return NextResponse.json( + { error: 'Failed to load conversation' }, + { status: 500 }, + ) + } + if (!conv) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) + } + + const update: Record = { ai_autoreply_disabled: paused } + + if (paused) { + if (assignToMe) update.assigned_agent_id = userId + } else { + // Resuming: give the bot a clean slate on this thread. + update.ai_reply_count = 0 + update.ai_handoff_summary = null + // Only release an assignment the caller holds — never yank a + // teammate's thread out from under them. + if (conv.assigned_agent_id === userId) update.assigned_agent_id = null + } + + const { error: upErr } = await supabase + .from('conversations') + .update(update) + .eq('id', conversationId) + .eq('account_id', accountId) + if (upErr) { + console.error('[ai/autoreply] update error:', upErr) + return NextResponse.json( + { error: 'Failed to update conversation' }, + { status: 500 }, + ) + } + + return NextResponse.json({ success: true, paused }) + } catch (err) { + return toErrorResponse(err) + } +} 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..ad50fcf 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,20 @@ export async function POST(request: Request) { knowledge, }) - const { text } = await generateReply({ config, systemPrompt, messages }) + const { text, usage } = await generateReply({ config, systemPrompt, messages }) + + // Record spend on the account's BYO key. Best-effort + via the + // service role (the log has no `authenticated` INSERT policy); a + // failure here must not fail the draft the agent is waiting on. + await logAiUsage(supabaseAdmin(), { + accountId, + conversationId, + mode: 'draft', + provider: config.provider, + model: config.model, + usage, + }) + return NextResponse.json({ draft: text }) } catch (err) { if (err instanceof AiError) { 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/components/inbox/ai-thread-banner.tsx b/src/components/inbox/ai-thread-banner.tsx new file mode 100644 index 0000000..fb94edd --- /dev/null +++ b/src/components/inbox/ai-thread-banner.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +// ------------------------------------------------------------ +// Account AI status is the same for every conversation, so fetch it +// once per session and share it across thread switches instead of +// hitting /api/ai/config every time the agent opens a new chat. The +// promise is cached so concurrent mounts dedupe onto one request. +// ------------------------------------------------------------ +interface AiAccountStatus { + autoReplyOn: boolean; +} +let statusPromise: Promise | null = null; + +async function fetchAiAccountStatus(): Promise { + if (!statusPromise) { + statusPromise = fetch("/api/ai/config", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => ({ + // AI auto-reply is "live" only when configured, the master + // switch is on, and the inbound bot is enabled. + autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled), + })) + .catch(() => ({ autoReplyOn: false })); + } + return statusPromise; +} + +interface AiThreadBannerProps { + conversationId: string; + /** `conversations.ai_autoreply_disabled` — bot paused on this thread. */ + disabled: boolean; + /** `conversations.ai_handoff_summary` — note the bot left on handoff. */ + handoffSummary?: string | null; + /** Current assignee; when a human owns the thread the bot won't run, + * so the "AI active" banner is suppressed. */ + assignedAgentId?: string | null; + /** The acting agent — "Take over" assigns the thread to them. */ + currentUserId?: string | null; + /** Called after a successful toggle so the parent can patch its local + * conversation state (the realtime UPDATE also arrives, but this keeps + * the banner instant). */ + onChange?: (patch: { + ai_autoreply_disabled: boolean; + assigned_agent_id?: string | null; + }) => void; +} + +/** + * Inbox banner that surfaces + controls the AI auto-reply bot per + * conversation: + * - bot active here → "AI is replying automatically" + [Take over] + * - bot paused here → the handoff note (if any) + [Resume AI] + * Renders nothing when the account has no auto-reply configured, or when + * the bot is active but a human already owns the thread (nothing to do). + */ +export function AiThreadBanner({ + conversationId, + disabled, + handoffSummary, + assignedAgentId, + currentUserId, + onChange, +}: AiThreadBannerProps) { + const [autoReplyOn, setAutoReplyOn] = useState(null); + const [busy, setBusy] = useState(false); + // Optimistic local mirror of the pause flag so the banner flips + // instantly on click; re-seeds whenever the thread (or its server + // state via realtime) changes. + const [paused, setPaused] = useState(disabled); + useEffect(() => setPaused(disabled), [conversationId, disabled]); + + useEffect(() => { + let alive = true; + fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn)); + return () => { + alive = false; + }; + }, []); + + const toggle = useCallback( + async (paused: boolean) => { + setBusy(true); + try { + const res = await fetch(`/api/ai/autoreply/${conversationId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + // "Take over" also assigns the thread to the acting agent. + body: JSON.stringify({ paused, assign_to_me: paused }), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + toast.error(j?.error ?? "Couldn't update the AI assistant."); + return; + } + setPaused(paused); + onChange?.({ + ai_autoreply_disabled: paused, + // Take over assigns to the acting agent; resume releases only + // the caller's own assignment. The realtime UPDATE reconciles + // the exact value either way. + ...(paused + ? currentUserId + ? { assigned_agent_id: currentUserId } + : {} + : { assigned_agent_id: null }), + }); + toast.success(paused ? "You've taken over this chat." : "AI resumed."); + } catch { + toast.error("Couldn't reach the server."); + } finally { + setBusy(false); + } + }, + [conversationId, currentUserId, onChange], + ); + + // Account has no auto-reply → nothing to show. (Still loading → nothing.) + if (!autoReplyOn) return null; + + // Paused here (a human took over, or the model handed off). + if (paused) { + return ( + +
+

AI assistant is paused here

+ {handoffSummary && ( +

+ {handoffSummary} +

+ )} +
+ toggle(false)} busy={busy} icon={Undo2}> + Resume AI + +
+ ); + } + + // Active, but a human already owns it → the bot won't fire; no banner. + if (assignedAgentId) return null; + + // Active on this thread. + return ( + +
+ + + AI assistant is replying automatically + +
+ toggle(true)} busy={busy} icon={Hand}> + Take over + +
+ ); +} + +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 510f25c..20a674b 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"; @@ -282,6 +283,19 @@ export function MessageBubble({ isAgent ? "justify-end" : "justify-start", )} > + {/* AI badge — only on replies the auto-reply bot generated + (always outbound, so it sits on the primary fill). Lets + agents tell an AI reply from their own / a Flow's at a + glance. */} + {message.ai_generated && ( + + + AI + + )} + {/* 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)', @@ -64,6 +69,9 @@ export function AiConfig() { const [isActive, setIsActive] = useState(false); const [autoReplyEnabled, setAutoReplyEnabled] = useState(false); const [maxPerConversation, setMaxPerConversation] = useState(3); + // Empty string = leave unassigned (shared queue). + const [handoffAgentId, setHandoffAgentId] = useState(''); + const [members, setMembers] = useState([]); // Guard keyed on the account (not a bare boolean) so an in-place // account switch — ownership transfer, multi-account membership — @@ -88,6 +96,7 @@ export function AiConfig() { setIsActive(data.is_active); setAutoReplyEnabled(data.auto_reply_enabled); setMaxPerConversation(data.auto_reply_max_per_conversation ?? 3); + setHandoffAgentId(data.handoff_agent_id ?? ''); setHasStoredKey(Boolean(data.has_key)); setApiKey(data.has_key ? MASKED_KEY : ''); setKeyEdited(false); @@ -106,6 +115,19 @@ export function AiConfig() { if (!accountId || loadedAccountIdRef.current === accountId) return; loadedAccountIdRef.current = accountId; void fetchConfig(); + // Members populate the handoff-target picker. Best-effort — on an + // older deployment without the endpoint the picker just shows the + // queue option. + void (async () => { + try { + const res = await fetch('/api/account/members', { cache: 'no-store' }); + if (!res.ok) return; + const json = (await res.json()) as { members?: AccountMember[] }; + setMembers(json.members ?? []); + } catch { + // ignore — picker falls back to "unassigned queue" only. + } + })(); }, [accountId, fetchConfig]); // Swap the model default when the provider changes, unless the user @@ -134,6 +156,7 @@ export function AiConfig() { is_active: isActive, auto_reply_enabled: autoReplyEnabled, auto_reply_max_per_conversation: maxPerConversation, + handoff_agent_id: handoffAgentId || null, }); const handleTest = async () => { @@ -201,6 +224,7 @@ export function AiConfig() { setIsActive(false); setAutoReplyEnabled(false); setSystemPrompt(''); + setHandoffAgentId(''); } else { const data = await res.json(); toast.error(data.error ?? 'Failed to remove.'); @@ -444,6 +468,35 @@ export function AiConfig() { className="w-20" /> + +
+ +

+ 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. +

+ +
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..5c75d20 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,46 @@ export async function dispatchInboundToAiReply( knowledge, }) - const { text, handoff } = await generateReply({ + const { text, handoff, usage } = await generateReply({ config, systemPrompt, messages, }) + // Record token spend on the account's BYO key (best-effort — never + // blocks or fails the reply). Logged regardless of handoff: the + // provider call happened either way. + await logAiUsage(db, { + accountId, + conversationId, + mode: 'auto_reply', + provider: config.provider, + model: config.model, + usage, + }) + if (handoff || !text) { // The model can't (or shouldn't) answer — stop auto-replying on - // this thread and leave the inbound unanswered so it surfaces in - // the inbox for a human. Sticky until an admin re-enables. - await db - .from('conversations') - .update({ ai_autoreply_disabled: true }) - .eq('id', conversationId) + // this thread and hand it to a human. We (a) pause the bot here + // (sticky until re-enabled), (b) route the conversation to the + // configured handoff agent — null leaves it in the shared queue — + // and (c) leave a short internal note so whoever picks it up has + // context. Assigning fires the `on_conversation_assigned` trigger, + // which notifies the agent. + const summary = buildHandoffSummary({ + messages, + replyCount: conv.ai_reply_count ?? 0, + }) + const update: Record = { + ai_autoreply_disabled: true, + ai_handoff_summary: summary, + } + // Only set the assignee when a target is configured AND the thread + // isn't already owned — never stomp an existing human assignment. + if (config.handoffAgentId && !conv.assigned_agent_id) { + update.assigned_agent_id = config.handoffAgentId + } + await db.from('conversations').update(update).eq('id', conversationId) return } @@ -138,6 +183,7 @@ export async function dispatchInboundToAiReply( conversationId, contactId, text, + aiGenerated: true, }) } catch (err) { console.error('[ai auto-reply] dispatch failed:', err) 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/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. From c736e7a43f6c7b7cba4bbd94fec8072d1f34cda0 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 14:31:36 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(ai):=20add=20token-usage=20dashboard?= =?UTF-8?q?=20(AI=20Agents=20=E2=86=92=20Usage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the ai_usage_log data added in the previous commit: - GET /api/ai/usage — admin-only aggregation (totals, per-mode, per-model, zero-filled daily series) over a 7/30/90-day window, mirroring the ai_usage_log SELECT policy - AiUsageCard — stat tiles + daily token bar chart + by-model list, rendered under a new admin-only "Usage" tab on the AI Agents page Counts only; no message content is stored or shown. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 +- src/app/(dashboard)/agents/page.tsx | 20 ++- src/app/api/ai/usage/route.ts | 134 +++++++++++++++ src/components/agents/ai-usage.tsx | 250 ++++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 src/app/api/ai/usage/route.ts create mode 100644 src/components/agents/ai-usage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8ad8f..e7e9a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,9 +35,12 @@ logged**. short **internal note** summarizing the exchange for whoever picks it up. Assigning fires the existing assignment notification. Pick the target under **AI Agents → Setup → Hand off to**. -- **Token-usage logging.** Every draft and auto-reply records its - provider token counts to the new `ai_usage_log` table (admin-readable), - for cost visibility on your BYO key. +- **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 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/usage/route.ts b/src/app/api/ai/usage/route.ts new file mode 100644 index 0000000..983e95b --- /dev/null +++ b/src/app/api/ai/usage/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from 'next/server' +import { requireRole, toErrorResponse } from '@/lib/auth/account' + +// 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 +} + +/** UTC yyyy-mm-dd — stable regardless of server timezone. */ +function dayKey(d: Date): string { + return d.toISOString().slice(0, 10) +} + +/** + * 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')) + const days = Number.isFinite(rawDays) + ? Math.min(90, Math.max(1, Math.floor(rawDays))) + : DEFAULT_WINDOW_DAYS + + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + + 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. Keyed by UTC date, oldest → newest. + const daily = new Map() + for (let i = days - 1; i >= 0; i--) { + const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000)) + 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 + + const mode = r.mode === 'draft' ? 'draft' : 'auto_reply' + byMode[mode].calls += 1 + byMode[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(dayKey(new Date(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..085cc65 --- /dev/null +++ b/src/components/agents/ai-usage.tsx @@ -0,0 +1,250 @@ +'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'; + +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; + +/** Compact token count — 1234 → "1.2k", 1_200_000 → "1.2M". */ +function fmt(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +/** "2026-07-08" → "Jul 8" for the chart axis. */ +function shortDay(iso: string): string { + const [, m, d] = iso.split('-'); + const month = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ][Number(m) - 1]; + return `${month} ${Number(d)}`; +} + +/** + * 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: shortDay(d.date), 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 +

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

+ By model +

+
    + {data.by_model.map((m) => ( +
  • + + {m.model}{' '} + + ({m.provider}) + + + + {fmt(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} +

+
+ ); +} From e132cc7a159c351c018c3b03bfb34bcea061242a Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 15:35:41 +0300 Subject: [PATCH 3/4] fix(ai): address code-review findings on auto-reply polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Resume AI now releases ANY assignment (not just the caller's own). A handoff-assigned thread previously stayed assigned after resume, so the eligibility gate silently kept the bot muted — Resume was a no-op. - GET /api/ai/usage: guard days >= 1 (Number(null)===0 is finite, so the 30-day default was unreachable and the window collapsed to 1 day). - GET /api/ai/usage: align the query cutoff to the start of the oldest charted local day so rows in the oldest partial day no longer count in totals while missing from the daily series (totals now match the bars). - ai-thread-banner: cache account AI-status per accountId and never cache a transient fetch failure — fixes cross-account leak and the banner staying hidden all session after one failed /api/ai/config call. - /api/ai/draft: construct the service-role client inside a guard and fire-and-forget the usage log, so a missing service-role key (or any log failure) can't 500 an already-generated, already-billed draft. Efficiency: - Don't await usage logging on the reply hot path (auto-reply + draft); logAiUsage swallows its own errors, so fire-and-forget is safe and removes a DB round-trip from the customer-facing send. Reuse / consistency: - usage route reuses date-utils (daysAgoStart/lastNDayKeys/localDayKey) and now buckets by LOCAL day like every other dashboard chart (was UTC). - extract formatCompactNumber (shared with formatCurrencyShort); drop the hand-rolled month table in favour of date-fns; extract fetchAccountMembers/memberLabel and use them in the AI settings picker. Resume still resets the per-conversation reply count (a deliberate, manual, rate-limited re-engagement — documented in the handler). Co-Authored-By: Claude Opus 4.8 --- .../ai/autoreply/[conversationId]/route.ts | 18 +++++-- src/app/api/ai/draft/route.ts | 28 +++++++---- src/app/api/ai/usage/route.ts | 39 +++++++++------ src/components/agents/ai-usage.tsx | 32 ++++-------- src/components/inbox/ai-thread-banner.tsx | 49 ++++++++++++------- src/components/settings/ai-config.tsx | 14 ++---- src/lib/account/members.ts | 25 ++++++++++ src/lib/ai/auto-reply.ts | 10 ++-- src/lib/currency.ts | 17 +++++-- 9 files changed, 141 insertions(+), 91 deletions(-) create mode 100644 src/lib/account/members.ts diff --git a/src/app/api/ai/autoreply/[conversationId]/route.ts b/src/app/api/ai/autoreply/[conversationId]/route.ts index 83a5fb0..aaa69fb 100644 --- a/src/app/api/ai/autoreply/[conversationId]/route.ts +++ b/src/app/api/ai/autoreply/[conversationId]/route.ts @@ -47,7 +47,7 @@ export async function POST(request: Request, { params }: Params) { // Confirm the conversation is in the caller's account before writing. const { data: conv, error: convErr } = await supabase .from('conversations') - .select('id, assigned_agent_id') + .select('id') .eq('id', conversationId) .eq('account_id', accountId) .maybeSingle() @@ -67,12 +67,20 @@ export async function POST(request: Request, { params }: Params) { if (paused) { if (assignToMe) update.assigned_agent_id = userId } else { - // Resuming: give the bot a clean slate on this thread. + // 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 - // Only release an assignment the caller holds — never yank a - // teammate's thread out from under them. - if (conv.assigned_agent_id === userId) update.assigned_agent_id = null } const { error: upErr } = await supabase diff --git a/src/app/api/ai/draft/route.ts b/src/app/api/ai/draft/route.ts index ad50fcf..0f35995 100644 --- a/src/app/api/ai/draft/route.ts +++ b/src/app/api/ai/draft/route.ts @@ -107,16 +107,24 @@ export async function POST(request: Request) { const { text, usage } = await generateReply({ config, systemPrompt, messages }) // Record spend on the account's BYO key. Best-effort + via the - // service role (the log has no `authenticated` INSERT policy); a - // failure here must not fail the draft the agent is waiting on. - await logAiUsage(supabaseAdmin(), { - accountId, - conversationId, - mode: 'draft', - provider: config.provider, - model: config.model, - usage, - }) + // 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) { diff --git a/src/app/api/ai/usage/route.ts b/src/app/api/ai/usage/route.ts index 983e95b..cc5973a 100644 --- a/src/app/api/ai/usage/route.ts +++ b/src/app/api/ai/usage/route.ts @@ -1,5 +1,6 @@ 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 @@ -19,11 +20,6 @@ interface UsageRow { total_tokens: number } -/** UTC yyyy-mm-dd — stable regardless of server timezone. */ -function dayKey(d: Date): string { - return d.toISOString().slice(0, 10) -} - /** * GET /api/ai/usage?days=30 (admin+) * @@ -38,11 +34,22 @@ export async function GET(request: Request) { const url = new URL(request.url) const rawDays = Number(url.searchParams.get('days')) - const days = Number.isFinite(rawDays) - ? Math.min(90, Math.max(1, Math.floor(rawDays))) - : DEFAULT_WINDOW_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 - const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + // 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') @@ -82,10 +89,10 @@ export async function GET(request: Request) { >() // Zero-filled daily buckets so the chart shows quiet days as gaps, - // not missing points. Keyed by UTC date, oldest → newest. + // 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 (let i = days - 1; i >= 0; i--) { - const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000)) + for (const key of lastNDayKeys(days)) { daily.set(key, { date: key, tokens: 0, calls: 0 }) } @@ -94,9 +101,9 @@ export async function GET(request: Request) { completionTokens += r.completion_tokens totalTokens += r.total_tokens - const mode = r.mode === 'draft' ? 'draft' : 'auto_reply' - byMode[mode].calls += 1 - byMode[mode].tokens += 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 = @@ -106,7 +113,7 @@ export async function GET(request: Request) { m.tokens += r.total_tokens modelMap.set(mk, m) - const bucket = daily.get(dayKey(new Date(r.created_at))) + const bucket = daily.get(localDayKey(r.created_at)) if (bucket) { bucket.tokens += r.total_tokens bucket.calls += 1 diff --git a/src/components/agents/ai-usage.tsx b/src/components/agents/ai-usage.tsx index 085cc65..d24f16f 100644 --- a/src/components/agents/ai-usage.tsx +++ b/src/components/agents/ai-usage.tsx @@ -21,6 +21,8 @@ import { } 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; @@ -46,23 +48,6 @@ interface UsageResponse { const WINDOWS = [7, 30, 90] as const; -/** Compact token count — 1234 → "1.2k", 1_200_000 → "1.2M". */ -function fmt(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -/** "2026-07-08" → "Jul 8" for the chart axis. */ -function shortDay(iso: string): string { - const [, m, d] = iso.split('-'); - const month = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ][Number(m) - 1]; - return `${month} ${Number(d)}`; -} - /** * Token-spend dashboard for the account's BYO key. Admin-only (spend is * billing-class), mirroring the `ai_usage_log` SELECT policy and the @@ -110,7 +95,8 @@ export function AiUsageCard() { if (profileLoading || !canView) return null; const chartData = - data?.daily.map((d) => ({ day: shortDay(d.date), Tokens: d.tokens })) ?? []; + data?.daily.map((d) => ({ day: format(parseISO(d.date), 'MMM d'), Tokens: d.tokens })) ?? + []; const hasSpend = (data?.totals.total_tokens ?? 0) > 0; return ( @@ -157,16 +143,16 @@ export function AiUsageCard() { ) : ( <>
- +
@@ -180,7 +166,7 @@ export function AiUsageCard() { index="day" categories={['Tokens']} colors={['violet']} - valueFormatter={(v) => fmt(v)} + valueFormatter={(v) => formatCompactNumber(v)} showLegend={false} yAxisWidth={48} className="h-[200px]" @@ -205,7 +191,7 @@ export function AiUsageCard() {
- {fmt(m.tokens)} tok · {m.calls}{' '} + {formatCompactNumber(m.tokens)} tok · {m.calls}{' '} {m.calls === 1 ? 'call' : 'calls'} diff --git a/src/components/inbox/ai-thread-banner.tsx b/src/components/inbox/ai-thread-banner.tsx index fb94edd..a45c7b7 100644 --- a/src/components/inbox/ai-thread-banner.tsx +++ b/src/components/inbox/ai-thread-banner.tsx @@ -4,30 +4,41 @@ import { useState, useEffect, useCallback } from "react"; import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; // ------------------------------------------------------------ -// Account AI status is the same for every conversation, so fetch it -// once per session and share it across thread switches instead of -// hitting /api/ai/config every time the agent opens a new chat. The -// promise is cached so concurrent mounts dedupe onto one request. +// 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; } -let statusPromise: Promise | null = null; +const statusCache = new Map(); -async function fetchAiAccountStatus(): Promise { - if (!statusPromise) { - statusPromise = fetch("/api/ai/config", { cache: "no-store" }) - .then((r) => (r.ok ? r.json() : null)) - .then((j) => ({ - // AI auto-reply is "live" only when configured, the master - // switch is on, and the inbound bot is enabled. - autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled), - })) - .catch(() => ({ autoReplyOn: false })); +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 } - return statusPromise; } interface AiThreadBannerProps { @@ -66,6 +77,7 @@ export function AiThreadBanner({ currentUserId, onChange, }: AiThreadBannerProps) { + const { accountId } = useAuth(); const [autoReplyOn, setAutoReplyOn] = useState(null); const [busy, setBusy] = useState(false); // Optimistic local mirror of the pause flag so the banner flips @@ -75,12 +87,13 @@ export function AiThreadBanner({ useEffect(() => setPaused(disabled), [conversationId, disabled]); useEffect(() => { + if (!accountId) return; let alive = true; - fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn)); + fetchAiAccountStatus(accountId).then((s) => alive && setAutoReplyOn(s.autoReplyOn)); return () => { alive = false; }; - }, []); + }, [accountId]); const toggle = useCallback( async (paused: boolean) => { diff --git a/src/components/settings/ai-config.tsx b/src/components/settings/ai-config.tsx index 4c4307a..7e609e3 100644 --- a/src/components/settings/ai-config.tsx +++ b/src/components/settings/ai-config.tsx @@ -29,6 +29,7 @@ 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'; const MASKED_KEY = '••••••••••••••••'; @@ -118,16 +119,7 @@ export function AiConfig() { // Members populate the handoff-target picker. Best-effort — on an // older deployment without the endpoint the picker just shows the // queue option. - void (async () => { - try { - const res = await fetch('/api/account/members', { cache: 'no-store' }); - if (!res.ok) return; - const json = (await res.json()) as { members?: AccountMember[] }; - setMembers(json.members ?? []); - } catch { - // ignore — picker falls back to "unassigned queue" only. - } - })(); + void fetchAccountMembers().then(setMembers); }, [accountId, fetchConfig]); // Swap the model default when the provider changes, unless the user @@ -491,7 +483,7 @@ export function AiConfig() { {members.map((m) => ( - {m.full_name || m.email || m.user_id} + {memberLabel(m)} ))} 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.ts b/src/lib/ai/auto-reply.ts index 5c75d20..df758f9 100644 --- a/src/lib/ai/auto-reply.ts +++ b/src/lib/ai/auto-reply.ts @@ -118,10 +118,12 @@ export async function dispatchInboundToAiReply( messages, }) - // Record token spend on the account's BYO key (best-effort — never - // blocks or fails the reply). Logged regardless of handoff: the - // provider call happened either way. - await logAiUsage(db, { + // 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', 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); } From eefb6179c22012a0d013702b3bd0f131cfdd6c88 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 16:04:39 +0300 Subject: [PATCH 4/4] fix(db): resolve duplicate migration version 031 (rename profiles-RLS fix to 034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security PR (#352) shipped 031_fix_profiles_update_rls.sql while 031_ai_reply_slot_grant.sql (#345) already held version 031. Supabase keys schema_migrations on the numeric prefix, so applying both to a fresh DB fails with `duplicate key ... (version)=(031)` (SQLSTATE 23505) — which is what broke the Supabase Preview check on this PR. Rename the newer, colliding file to 034 to restore unique versions. It's idempotent and independent of the AI tables, so re-sequencing after 033 is safe; a prod DB that already applied 031 will apply this fresh as 034 without side effects. Co-Authored-By: Claude Opus 4.8 --- ...es_update_rls.sql => 034_fix_profiles_update_rls.sql} | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename supabase/migrations/{031_fix_profiles_update_rls.sql => 034_fix_profiles_update_rls.sql} (89%) 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