mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #353 from ArnasDon/feat/ai-autoreply-polish
feat(ai): polish auto-reply — inbox controls, real handoff, usage logging
This commit is contained in:
commit
95f6052e34
40
CHANGELOG.md
40
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
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, Sparkles, Settings2 } from 'lucide-react';
|
||||
import { Bot, Sparkles, Settings2, BarChart3 } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { AiPlayground } from '@/components/agents/ai-playground';
|
||||
import { AiUsageCard } from '@/components/agents/ai-usage';
|
||||
import { AiConfig } from '@/components/settings/ai-config';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { canEditSettings } from '@/lib/auth/roles';
|
||||
|
||||
type Tab = 'playground' | 'setup';
|
||||
type Tab = 'playground' | 'setup' | 'usage';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { accountRole } = useAuth();
|
||||
const canViewUsage = accountRole ? canEditSettings(accountRole) : false;
|
||||
const [tab, setTab] = useState<Tab>('playground');
|
||||
const [decided, setDecided] = useState(false);
|
||||
|
||||
@ -57,6 +62,11 @@ export default function AgentsPage() {
|
||||
<TabsTrigger value="setup">
|
||||
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
|
||||
</TabsTrigger>
|
||||
{canViewUsage && (
|
||||
<TabsTrigger value="usage">
|
||||
<BarChart3 className="mr-1.5 h-4 w-4" /> Usage
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="playground" className="mt-4">
|
||||
@ -66,6 +76,12 @@ export default function AgentsPage() {
|
||||
<TabsContent value="setup" className="mt-4">
|
||||
<AiConfig />
|
||||
</TabsContent>
|
||||
|
||||
{canViewUsage && (
|
||||
<TabsContent value="usage" className="mt-4">
|
||||
<AiUsageCard />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
103
src/app/api/ai/autoreply/[conversationId]/route.ts
Normal file
103
src/app/api/ai/autoreply/[conversationId]/route.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireRole, toErrorResponse } from '@/lib/auth/account'
|
||||
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
type Params = { params: Promise<{ conversationId: string }> }
|
||||
|
||||
/**
|
||||
* POST /api/ai/autoreply/[conversationId] (agent+)
|
||||
*
|
||||
* Toggle the AI auto-reply bot for one conversation from the inbox — the
|
||||
* "Take over" / "Resume AI" banner.
|
||||
*
|
||||
* Body: { paused: boolean, assign_to_me?: boolean }
|
||||
* - paused: true → pause the bot here (a human is taking over). When
|
||||
* `assign_to_me` is set, also assign the thread to the
|
||||
* caller (the usual "Take over" flow). Assignment
|
||||
* fires the `on_conversation_assigned` trigger.
|
||||
* - paused: false → hand the thread back to the bot: clear the pause,
|
||||
* reset the per-conversation reply count so it gets
|
||||
* fresh slots, and clear the handoff note. If the
|
||||
* caller currently owns the thread, unassign it too so
|
||||
* the bot isn't blocked by the "human owns this" gate.
|
||||
*
|
||||
* Writes go through the RLS-scoped SSR client, so a conversation outside
|
||||
* the caller's account simply isn't found (404).
|
||||
*/
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
try {
|
||||
const { supabase, accountId, userId } = await requireRole('agent')
|
||||
|
||||
// Reuse the send bucket: this is a cheap per-user inbox action and
|
||||
// toggling it in a tight loop has no legitimate use.
|
||||
const limit = checkRateLimit(`ai-takeover:${userId}`, RATE_LIMITS.send)
|
||||
if (!limit.success) return rateLimitResponse(limit)
|
||||
|
||||
const { conversationId } = await params
|
||||
const body = await request.json().catch(() => null)
|
||||
if (!body || typeof body.paused !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'paused (boolean) is required' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const paused = body.paused as boolean
|
||||
const assignToMe = body.assign_to_me === true
|
||||
|
||||
// Confirm the conversation is in the caller's account before writing.
|
||||
const { data: conv, error: convErr } = await supabase
|
||||
.from('conversations')
|
||||
.select('id')
|
||||
.eq('id', conversationId)
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle()
|
||||
if (convErr) {
|
||||
console.error('[ai/autoreply] conversation lookup error:', convErr)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load conversation' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!conv) {
|
||||
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { ai_autoreply_disabled: paused }
|
||||
|
||||
if (paused) {
|
||||
if (assignToMe) update.assigned_agent_id = userId
|
||||
} else {
|
||||
// Resuming hands the thread *back to the bot*. Clear the pause and
|
||||
// the handoff note, and — crucially — release ANY assignment, not
|
||||
// just the caller's own: the auto-reply eligibility gate stands
|
||||
// down whenever a human is assigned, so leaving a stale assignee
|
||||
// (e.g. the agent a prior handoff routed to) would silently keep
|
||||
// the bot muted and make "Resume AI" a no-op. This is the explicit
|
||||
// choice to let the bot own the thread again.
|
||||
update.assigned_agent_id = null
|
||||
// Give the bot a fresh reply budget on this thread. This is a
|
||||
// deliberate, manual, rate-limited action (not automatable), so it
|
||||
// can't be used to bypass the per-conversation cap at scale — it's
|
||||
// a human choosing to re-engage the assistant.
|
||||
update.ai_reply_count = 0
|
||||
update.ai_handoff_summary = null
|
||||
}
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('conversations')
|
||||
.update(update)
|
||||
.eq('id', conversationId)
|
||||
.eq('account_id', accountId)
|
||||
if (upErr) {
|
||||
console.error('[ai/autoreply] update error:', upErr)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update conversation' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, paused })
|
||||
} catch (err) {
|
||||
return toErrorResponse(err)
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -71,6 +71,7 @@ export async function POST(request: Request) {
|
||||
isActive: true,
|
||||
autoReplyEnabled: false,
|
||||
autoReplyMaxPerConversation: 3,
|
||||
handoffAgentId: null,
|
||||
embeddingsApiKey: null,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
141
src/app/api/ai/usage/route.ts
Normal file
141
src/app/api/ai/usage/route.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireRole, toErrorResponse } from '@/lib/auth/account'
|
||||
import { daysAgoStart, lastNDayKeys, localDayKey } from '@/lib/dashboard/date-utils'
|
||||
|
||||
// Rows are aggregated in-process over a bounded window. An active
|
||||
// account writes a handful of rows per conversation, so 30 days sits
|
||||
// comfortably under this cap; we surface `truncated` when it doesn't so
|
||||
// the UI can say "showing a partial window" rather than under-reporting
|
||||
// silently.
|
||||
const MAX_ROWS = 10_000
|
||||
const DEFAULT_WINDOW_DAYS = 30
|
||||
|
||||
interface UsageRow {
|
||||
created_at: string
|
||||
mode: 'auto_reply' | 'draft'
|
||||
provider: string
|
||||
model: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/usage?days=30 (admin+)
|
||||
*
|
||||
* Token-spend summary for the account's BYO key over the last `days`
|
||||
* (1–90, default 30): totals, per-mode + per-model breakdowns, and a
|
||||
* zero-filled daily series for charting. Admin-only, mirroring the
|
||||
* `ai_usage_log` SELECT policy — spend is billing-class.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { supabase, accountId } = await requireRole('admin')
|
||||
|
||||
const url = new URL(request.url)
|
||||
const rawDays = Number(url.searchParams.get('days'))
|
||||
// Guard `>= 1`, not just `isFinite`: a missing/blank param is
|
||||
// Number(null)/Number('') === 0, which is finite — without the lower
|
||||
// bound the default would never apply and the window would collapse
|
||||
// to a single day.
|
||||
const days =
|
||||
Number.isFinite(rawDays) && rawDays >= 1
|
||||
? Math.min(90, Math.floor(rawDays))
|
||||
: DEFAULT_WINDOW_DAYS
|
||||
|
||||
// Align the query cutoff to the START of the oldest local day we'll
|
||||
// chart (not a rolling `now - N*24h` instant). Otherwise rows in the
|
||||
// oldest partial day would be counted in the totals but fall outside
|
||||
// every daily bucket, so the chart's bars wouldn't sum to the
|
||||
// headline total. Local-day boundaries match every other dashboard
|
||||
// chart (see lib/dashboard/date-utils).
|
||||
const since = daysAgoStart(days - 1)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('ai_usage_log')
|
||||
.select(
|
||||
'created_at, mode, provider, model, prompt_tokens, completion_tokens, total_tokens',
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.gte('created_at', since.toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(MAX_ROWS + 1)
|
||||
|
||||
if (error) {
|
||||
console.error('[ai/usage GET] fetch error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load usage' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const all = (data ?? []) as UsageRow[]
|
||||
const truncated = all.length > MAX_ROWS
|
||||
const rows = truncated ? all.slice(0, MAX_ROWS) : all
|
||||
|
||||
// Totals.
|
||||
let promptTokens = 0
|
||||
let completionTokens = 0
|
||||
let totalTokens = 0
|
||||
|
||||
// Per-mode + per-model tallies.
|
||||
const byMode = {
|
||||
auto_reply: { calls: 0, tokens: 0 },
|
||||
draft: { calls: 0, tokens: 0 },
|
||||
}
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{ model: string; provider: string; calls: number; tokens: number }
|
||||
>()
|
||||
|
||||
// Zero-filled daily buckets so the chart shows quiet days as gaps,
|
||||
// not missing points. Local-day keys, oldest → newest — the same
|
||||
// helper every other dashboard chart uses, so day boundaries agree.
|
||||
const daily = new Map<string, { date: string; tokens: number; calls: number }>()
|
||||
for (const key of lastNDayKeys(days)) {
|
||||
daily.set(key, { date: key, tokens: 0, calls: 0 })
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
promptTokens += r.prompt_tokens
|
||||
completionTokens += r.completion_tokens
|
||||
totalTokens += r.total_tokens
|
||||
|
||||
// `mode` is DB-CHECK-constrained to these two values.
|
||||
byMode[r.mode].calls += 1
|
||||
byMode[r.mode].tokens += r.total_tokens
|
||||
|
||||
const mk = `${r.provider}:${r.model}`
|
||||
const m =
|
||||
modelMap.get(mk) ??
|
||||
{ model: r.model, provider: r.provider, calls: 0, tokens: 0 }
|
||||
m.calls += 1
|
||||
m.tokens += r.total_tokens
|
||||
modelMap.set(mk, m)
|
||||
|
||||
const bucket = daily.get(localDayKey(r.created_at))
|
||||
if (bucket) {
|
||||
bucket.tokens += r.total_tokens
|
||||
bucket.calls += 1
|
||||
}
|
||||
}
|
||||
|
||||
const byModel = [...modelMap.values()].sort((a, b) => b.tokens - a.tokens)
|
||||
|
||||
return NextResponse.json({
|
||||
window_days: days,
|
||||
truncated,
|
||||
totals: {
|
||||
calls: rows.length,
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: totalTokens,
|
||||
},
|
||||
by_mode: byMode,
|
||||
by_model: byModel,
|
||||
daily: [...daily.values()],
|
||||
})
|
||||
} catch (err) {
|
||||
return toErrorResponse(err)
|
||||
}
|
||||
}
|
||||
236
src/components/agents/ai-usage.tsx
Normal file
236
src/components/agents/ai-usage.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { BarChart3, Bot, PencilLine } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { canEditSettings } from '@/lib/auth/roles';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/dashboard/skeleton';
|
||||
import { BarChart } from '@/components/tremor/bar-chart';
|
||||
import { formatCompactNumber } from '@/lib/currency';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
interface UsageResponse {
|
||||
window_days: number;
|
||||
truncated: boolean;
|
||||
totals: {
|
||||
calls: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
by_mode: {
|
||||
auto_reply: { calls: number; tokens: number };
|
||||
draft: { calls: number; tokens: number };
|
||||
};
|
||||
by_model: {
|
||||
model: string;
|
||||
provider: string;
|
||||
calls: number;
|
||||
tokens: number;
|
||||
}[];
|
||||
daily: { date: string; tokens: number; calls: number }[];
|
||||
}
|
||||
|
||||
const WINDOWS = [7, 30, 90] as const;
|
||||
|
||||
/**
|
||||
* Token-spend dashboard for the account's BYO key. Admin-only (spend is
|
||||
* billing-class), mirroring the `ai_usage_log` SELECT policy and the
|
||||
* `GET /api/ai/usage` route. Renders nothing for non-admins.
|
||||
*/
|
||||
export function AiUsageCard() {
|
||||
const { accountId, accountRole, profileLoading } = useAuth();
|
||||
const canView = accountRole ? canEditSettings(accountRole) : false;
|
||||
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<UsageResponse | null>(null);
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
|
||||
const fetchUsage = useCallback(async (windowDays: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/ai/usage?days=${windowDays}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
toast.error(json?.error ?? 'Failed to load usage');
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setData(json as UsageResponse);
|
||||
} catch {
|
||||
toast.error('Failed to load usage');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView || !accountId) return;
|
||||
// Refetch on account switch or window change.
|
||||
const key = `${accountId}:${days}`;
|
||||
if (loadedRef.current === key) return;
|
||||
loadedRef.current = key;
|
||||
void fetchUsage(days);
|
||||
}, [canView, accountId, days, fetchUsage]);
|
||||
|
||||
if (profileLoading || !canView) return null;
|
||||
|
||||
const chartData =
|
||||
data?.daily.map((d) => ({ day: format(parseISO(d.date), 'MMM d'), Tokens: d.tokens })) ??
|
||||
[];
|
||||
const hasSpend = (data?.totals.total_tokens ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="h-4 w-4 text-primary" /> Token usage
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tokens spent on your provider key by drafts and the auto-reply
|
||||
bot. Counts only — no message content is stored here.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Select
|
||||
value={String(days)}
|
||||
onValueChange={(v) => setDays(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-32 flex-shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WINDOWS.map((w) => (
|
||||
<SelectItem key={w} value={String(w)}>
|
||||
Last {w} days
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : !hasSpend ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 opacity-40" />
|
||||
<p>No AI usage in the last {data.window_days} days yet.</p>
|
||||
<p className="text-xs">
|
||||
This fills in as the assistant drafts and auto-replies.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Total tokens" value={formatCompactNumber(data.totals.total_tokens)} />
|
||||
<Stat label="LLM calls" value={String(data.totals.calls)} />
|
||||
<Stat
|
||||
label="Auto-reply"
|
||||
value={formatCompactNumber(data.by_mode.auto_reply.tokens)}
|
||||
icon={Bot}
|
||||
/>
|
||||
<Stat
|
||||
label="Drafts"
|
||||
value={formatCompactNumber(data.by_mode.draft.tokens)}
|
||||
icon={PencilLine}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Tokens per day
|
||||
</p>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="day"
|
||||
categories={['Tokens']}
|
||||
colors={['violet']}
|
||||
valueFormatter={(v) => formatCompactNumber(v)}
|
||||
showLegend={false}
|
||||
yAxisWidth={48}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.by_model.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
By model
|
||||
</p>
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{data.by_model.map((m) => (
|
||||
<li
|
||||
key={`${m.provider}:${m.model}`}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="text-foreground">{m.model}</span>{' '}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({m.provider})
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatCompactNumber(m.tokens)} tok · {m.calls}{' '}
|
||||
{m.calls === 1 ? 'call' : 'calls'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.truncated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing a partial window — usage is high enough that only the
|
||||
most recent records are summarized here.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: typeof Bot;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold tabular-nums text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
src/components/inbox/ai-thread-banner.tsx
Normal file
225
src/components/inbox/ai-thread-banner.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Account AI status is the same for every conversation, so cache it per
|
||||
// account and reuse it across thread switches instead of hitting
|
||||
// /api/ai/config every time the agent opens a chat.
|
||||
//
|
||||
// Keyed by accountId (a multi-account user switching workspaces must not
|
||||
// see the previous account's status), and only *successful* fetches are
|
||||
// cached — a transient failure returns a default without poisoning the
|
||||
// cache, so it retries on the next thread open rather than hiding the
|
||||
// banner for the whole session.
|
||||
// ------------------------------------------------------------
|
||||
interface AiAccountStatus {
|
||||
autoReplyOn: boolean;
|
||||
}
|
||||
const statusCache = new Map<string, AiAccountStatus>();
|
||||
|
||||
async function fetchAiAccountStatus(accountId: string): Promise<AiAccountStatus> {
|
||||
const cached = statusCache.get(accountId);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch("/api/ai/config", { cache: "no-store" });
|
||||
if (!res.ok) return { autoReplyOn: false }; // don't cache a transient failure
|
||||
const j = await res.json();
|
||||
const status = {
|
||||
// AI auto-reply is "live" only when configured, the master switch
|
||||
// is on, and the inbound bot is enabled.
|
||||
autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled),
|
||||
};
|
||||
statusCache.set(accountId, status);
|
||||
return status;
|
||||
} catch {
|
||||
return { autoReplyOn: false }; // don't cache
|
||||
}
|
||||
}
|
||||
|
||||
interface AiThreadBannerProps {
|
||||
conversationId: string;
|
||||
/** `conversations.ai_autoreply_disabled` — bot paused on this thread. */
|
||||
disabled: boolean;
|
||||
/** `conversations.ai_handoff_summary` — note the bot left on handoff. */
|
||||
handoffSummary?: string | null;
|
||||
/** Current assignee; when a human owns the thread the bot won't run,
|
||||
* so the "AI active" banner is suppressed. */
|
||||
assignedAgentId?: string | null;
|
||||
/** The acting agent — "Take over" assigns the thread to them. */
|
||||
currentUserId?: string | null;
|
||||
/** Called after a successful toggle so the parent can patch its local
|
||||
* conversation state (the realtime UPDATE also arrives, but this keeps
|
||||
* the banner instant). */
|
||||
onChange?: (patch: {
|
||||
ai_autoreply_disabled: boolean;
|
||||
assigned_agent_id?: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox banner that surfaces + controls the AI auto-reply bot per
|
||||
* conversation:
|
||||
* - bot active here → "AI is replying automatically" + [Take over]
|
||||
* - bot paused here → the handoff note (if any) + [Resume AI]
|
||||
* Renders nothing when the account has no auto-reply configured, or when
|
||||
* the bot is active but a human already owns the thread (nothing to do).
|
||||
*/
|
||||
export function AiThreadBanner({
|
||||
conversationId,
|
||||
disabled,
|
||||
handoffSummary,
|
||||
assignedAgentId,
|
||||
currentUserId,
|
||||
onChange,
|
||||
}: AiThreadBannerProps) {
|
||||
const t = useTranslations("Inbox.aiBanner");
|
||||
const { accountId } = useAuth();
|
||||
const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Optimistic local mirror of the pause flag so the banner flips
|
||||
// instantly on click; re-seeds whenever the thread (or its server
|
||||
// state via realtime) changes.
|
||||
const [paused, setPaused] = useState(disabled);
|
||||
useEffect(() => setPaused(disabled), [conversationId, disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
let alive = true;
|
||||
fetchAiAccountStatus(accountId).then((s) => alive && setAutoReplyOn(s.autoReplyOn));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (paused: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/ai/autoreply/${conversationId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// "Take over" also assigns the thread to the acting agent.
|
||||
body: JSON.stringify({ paused, assign_to_me: paused }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
toast.error(j?.error ?? t("updateError"));
|
||||
return;
|
||||
}
|
||||
setPaused(paused);
|
||||
onChange?.({
|
||||
ai_autoreply_disabled: paused,
|
||||
// Take over assigns to the acting agent; resume releases only
|
||||
// the caller's own assignment. The realtime UPDATE reconciles
|
||||
// the exact value either way.
|
||||
...(paused
|
||||
? currentUserId
|
||||
? { assigned_agent_id: currentUserId }
|
||||
: {}
|
||||
: { assigned_agent_id: null }),
|
||||
});
|
||||
toast.success(paused ? t("tookOver") : t("resumed"));
|
||||
} catch {
|
||||
toast.error(t("networkError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[conversationId, currentUserId, onChange, t],
|
||||
);
|
||||
|
||||
// Account has no auto-reply → nothing to show. (Still loading → nothing.)
|
||||
if (!autoReplyOn) return null;
|
||||
|
||||
// Paused here (a human took over, or the model handed off).
|
||||
if (paused) {
|
||||
return (
|
||||
<Banner tone="muted">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{t("pausedTitle")}</p>
|
||||
{handoffSummary && (
|
||||
<p className="truncate text-muted-foreground" title={handoffSummary}>
|
||||
{handoffSummary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<BannerButton onClick={() => toggle(false)} busy={busy} icon={Undo2}>
|
||||
{t("resume")}
|
||||
</BannerButton>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
// Active, but a human already owns it → the bot won't fire; no banner.
|
||||
if (assignedAgentId) return null;
|
||||
|
||||
// Active on this thread.
|
||||
return (
|
||||
<Banner tone="primary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{t("activeText")}
|
||||
</span>
|
||||
</div>
|
||||
<BannerButton onClick={() => toggle(true)} busy={busy} icon={Hand}>
|
||||
{t("takeOver")}
|
||||
</BannerButton>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "primary" | "muted";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 border-b px-3 py-2 text-xs sm:px-4",
|
||||
tone === "primary"
|
||||
? "border-primary/20 bg-primary/5"
|
||||
: "border-border bg-muted/40",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BannerButton({
|
||||
onClick,
|
||||
busy,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
icon: typeof Hand;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className="inline-flex flex-shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 py-1 font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-3 w-3" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -13,6 +13,7 @@ import {
|
||||
LayoutTemplate,
|
||||
ImageOff,
|
||||
CornerDownLeft,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ReplyQuote } from "./reply-quote";
|
||||
@ -285,6 +286,19 @@ export function MessageBubble({
|
||||
isAgent ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{/* AI badge — only on replies the auto-reply bot generated
|
||||
(always outbound, so it sits on the primary fill). Lets
|
||||
agents tell an AI reply from their own / a Flow's at a
|
||||
glance. */}
|
||||
{message.ai_generated && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-primary-foreground/20 px-1.5 py-px text-[9px] font-semibold uppercase leading-none tracking-wide text-primary-foreground"
|
||||
title={t("aiBadgeTitle")}
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{t("aiBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
|
||||
@ -47,6 +47,7 @@ import {
|
||||
} from "./message-composer";
|
||||
import { deleteAccountMedia } from "@/lib/storage/upload-media";
|
||||
import { TemplatePicker } from "./template-picker";
|
||||
import { AiThreadBanner } from "./ai-thread-banner";
|
||||
import { buildReplyPreview } from "./reply-quote";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@ -1078,6 +1079,22 @@ export function MessageThread({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI auto-reply banner — take over an active bot, or resume it
|
||||
after a handoff. Renders nothing unless the account has
|
||||
auto-reply configured. */}
|
||||
<AiThreadBanner
|
||||
conversationId={conversation.id}
|
||||
disabled={conversation.ai_autoreply_disabled ?? false}
|
||||
handoffSummary={conversation.ai_handoff_summary}
|
||||
assignedAgentId={assignedAgentId}
|
||||
currentUserId={user?.id}
|
||||
onChange={(patch) => {
|
||||
if ("assigned_agent_id" in patch) {
|
||||
onAssignChange(conversation.id, patch.assigned_agent_id ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Composer */}
|
||||
<MessageComposer
|
||||
conversationId={conversation.id}
|
||||
|
||||
@ -28,10 +28,16 @@ import { SettingsPanelHead } from './settings-panel-head';
|
||||
import { AiKnowledgeCard } from './ai-knowledge';
|
||||
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
|
||||
import type { AiProvider } from '@/lib/ai/types';
|
||||
import type { AccountMember } from '@/types';
|
||||
import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const MASKED_KEY = '••••••••••••••••';
|
||||
|
||||
// Radix Select can't use an empty-string item value, so the "leave
|
||||
// unassigned" choice gets a sentinel that maps to null in the payload.
|
||||
const HANDOFF_QUEUE = '__queue__';
|
||||
|
||||
const PROVIDER_LABEL: Record<AiProvider, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic (Claude)',
|
||||
@ -66,6 +72,9 @@ export function AiConfig() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [autoReplyEnabled, setAutoReplyEnabled] = useState(false);
|
||||
const [maxPerConversation, setMaxPerConversation] = useState(3);
|
||||
// Empty string = leave unassigned (shared queue).
|
||||
const [handoffAgentId, setHandoffAgentId] = useState('');
|
||||
const [members, setMembers] = useState<AccountMember[]>([]);
|
||||
|
||||
// Guard keyed on the account (not a bare boolean) so an in-place
|
||||
// account switch — ownership transfer, multi-account membership —
|
||||
@ -90,6 +99,7 @@ export function AiConfig() {
|
||||
setIsActive(data.is_active);
|
||||
setAutoReplyEnabled(data.auto_reply_enabled);
|
||||
setMaxPerConversation(data.auto_reply_max_per_conversation ?? 3);
|
||||
setHandoffAgentId(data.handoff_agent_id ?? '');
|
||||
setHasStoredKey(Boolean(data.has_key));
|
||||
setApiKey(data.has_key ? MASKED_KEY : '');
|
||||
setKeyEdited(false);
|
||||
@ -108,6 +118,10 @@ export function AiConfig() {
|
||||
if (!accountId || loadedAccountIdRef.current === accountId) return;
|
||||
loadedAccountIdRef.current = accountId;
|
||||
void fetchConfig();
|
||||
// Members populate the handoff-target picker. Best-effort — on an
|
||||
// older deployment without the endpoint the picker just shows the
|
||||
// queue option.
|
||||
void fetchAccountMembers().then(setMembers);
|
||||
}, [accountId, fetchConfig]);
|
||||
|
||||
// Swap the model default when the provider changes, unless the user
|
||||
@ -136,6 +150,7 @@ export function AiConfig() {
|
||||
is_active: isActive,
|
||||
auto_reply_enabled: autoReplyEnabled,
|
||||
auto_reply_max_per_conversation: maxPerConversation,
|
||||
handoff_agent_id: handoffAgentId || null,
|
||||
});
|
||||
|
||||
const handleTest = async () => {
|
||||
@ -203,6 +218,7 @@ export function AiConfig() {
|
||||
setIsActive(false);
|
||||
setAutoReplyEnabled(false);
|
||||
setSystemPrompt('');
|
||||
setHandoffAgentId('');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error ?? t('removeFailed'));
|
||||
@ -439,6 +455,34 @@ export function AiConfig() {
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-handoff">{t('handoffTo')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('handoffToDesc')}
|
||||
</p>
|
||||
<Select
|
||||
value={handoffAgentId || HANDOFF_QUEUE}
|
||||
onValueChange={(v) =>
|
||||
setHandoffAgentId(!v || v === HANDOFF_QUEUE ? '' : v)
|
||||
}
|
||||
disabled={disabled || !autoReplyEnabled}
|
||||
>
|
||||
<SelectTrigger id="ai-handoff">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={HANDOFF_QUEUE}>
|
||||
{t('handoffQueue')}
|
||||
</SelectItem>
|
||||
{members.map((m) => (
|
||||
<SelectItem key={m.user_id} value={m.user_id}>
|
||||
{memberLabel(m)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
25
src/lib/account/members.ts
Normal file
25
src/lib/account/members.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { AccountMember } from '@/types';
|
||||
|
||||
/**
|
||||
* Fetch the current account's members from the API (which applies the
|
||||
* email-visibility rules — agents/viewers don't see emails). Best-effort:
|
||||
* returns `[]` on any error or on an older deployment without the
|
||||
* endpoint, so callers can fall back to a queue-only / raw-id picker.
|
||||
*
|
||||
* Client-side only (uses `fetch` against the relative API route).
|
||||
*/
|
||||
export async function fetchAccountMembers(): Promise<AccountMember[]> {
|
||||
try {
|
||||
const res = await fetch('/api/account/members', { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
const json = (await res.json()) as { members?: AccountMember[] };
|
||||
return json.members ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Display label for a member: full name → email → raw id. */
|
||||
export function memberLabel(m: AccountMember): string {
|
||||
return m.full_name || m.email || m.user_id;
|
||||
}
|
||||
@ -75,6 +75,7 @@ function aiConfig(overrides: Partial<AiConfig> = {}): AiConfig {
|
||||
isActive: true,
|
||||
autoReplyEnabled: true,
|
||||
autoReplyMaxPerConversation: 3,
|
||||
handoffAgentId: null,
|
||||
embeddingsApiKey: null,
|
||||
...overrides,
|
||||
}
|
||||
@ -186,11 +187,26 @@ describe('dispatchInboundToAiReply — eligibility gates', () => {
|
||||
})
|
||||
|
||||
describe('dispatchInboundToAiReply — handoff', () => {
|
||||
it('disables auto-reply and does not send on handoff', async () => {
|
||||
it('disables auto-reply, writes a summary, and does not send on handoff', async () => {
|
||||
h.generateReply.mockResolvedValue({ text: '', handoff: true })
|
||||
await dispatchInboundToAiReply(ARGS)
|
||||
expect(h.engineSendText).not.toHaveBeenCalled()
|
||||
expect(h.state.updatePayload).toEqual({ ai_autoreply_disabled: true })
|
||||
expect(h.state.rpcCalls).toHaveLength(0)
|
||||
expect(h.state.updatePayload).toMatchObject({ ai_autoreply_disabled: true })
|
||||
expect(h.state.updatePayload?.ai_handoff_summary).toContain(
|
||||
'AI agent handed off',
|
||||
)
|
||||
// No handoff target configured → conversation left unassigned.
|
||||
expect(h.state.updatePayload).not.toHaveProperty('assigned_agent_id')
|
||||
})
|
||||
|
||||
it('routes to the configured handoff agent on handoff', async () => {
|
||||
h.loadAiConfig.mockResolvedValue(aiConfig({ handoffAgentId: 'agent-7' }))
|
||||
h.generateReply.mockResolvedValue({ text: '', handoff: true })
|
||||
await dispatchInboundToAiReply(ARGS)
|
||||
expect(h.state.updatePayload).toMatchObject({
|
||||
ai_autoreply_disabled: true,
|
||||
assigned_agent_id: 'agent-7',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,8 +4,11 @@ import { buildConversationContext } from './context'
|
||||
import { retrieveKnowledge } from './knowledge'
|
||||
import { generateReply } from './generate'
|
||||
import { buildSystemPrompt } from './defaults'
|
||||
import { buildHandoffSummary } from './handoff'
|
||||
import { logAiUsage } from './usage'
|
||||
import { latestUserMessage } from './query'
|
||||
import { engineSendText } from '@/lib/flows/meta-send'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
interface DispatchArgs {
|
||||
/** Tenancy key — drives config, contact, and whatsapp_config lookups. */
|
||||
@ -79,6 +82,22 @@ export async function dispatchInboundToAiReply(
|
||||
const messages = await buildConversationContext(db, conversationId)
|
||||
if (messages.length === 0) return
|
||||
|
||||
// Account-wide throttle on the shared BYO key. The per-conversation
|
||||
// cap bounds one thread; this bounds a burst across many threads (a
|
||||
// marketing blast landing 200 replies at once) so we never run the
|
||||
// owner's key past the provider's rate limit. Over the limit → skip
|
||||
// the auto-reply; the inbound still sits in the inbox for a human.
|
||||
const acctLimit = checkRateLimit(
|
||||
`ai-autoreply:${accountId}`,
|
||||
RATE_LIMITS.aiAutoReplyAccount,
|
||||
)
|
||||
if (!acctLimit.success) {
|
||||
console.warn(
|
||||
`[ai auto-reply] account ${accountId} hit the per-account rate limit — skipping this inbound.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Ground the reply in the account's knowledge base (best-effort).
|
||||
const knowledge = await retrieveKnowledge(
|
||||
db,
|
||||
@ -93,20 +112,48 @@ export async function dispatchInboundToAiReply(
|
||||
knowledge,
|
||||
})
|
||||
|
||||
const { text, handoff } = await generateReply({
|
||||
const { text, handoff, usage } = await generateReply({
|
||||
config,
|
||||
systemPrompt,
|
||||
messages,
|
||||
})
|
||||
|
||||
// Record token spend on the account's BYO key. Fire-and-forget so it
|
||||
// never adds latency to the customer-facing send: `logAiUsage`
|
||||
// swallows its own errors, so the floating promise can't reject.
|
||||
// Logged regardless of handoff — the provider call happened either
|
||||
// way.
|
||||
void logAiUsage(db, {
|
||||
accountId,
|
||||
conversationId,
|
||||
mode: 'auto_reply',
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
usage,
|
||||
})
|
||||
|
||||
if (handoff || !text) {
|
||||
// The model can't (or shouldn't) answer — stop auto-replying on
|
||||
// this thread and leave the inbound unanswered so it surfaces in
|
||||
// the inbox for a human. Sticky until an admin re-enables.
|
||||
await db
|
||||
.from('conversations')
|
||||
.update({ ai_autoreply_disabled: true })
|
||||
.eq('id', conversationId)
|
||||
// this thread and hand it to a human. We (a) pause the bot here
|
||||
// (sticky until re-enabled), (b) route the conversation to the
|
||||
// configured handoff agent — null leaves it in the shared queue —
|
||||
// and (c) leave a short internal note so whoever picks it up has
|
||||
// context. Assigning fires the `on_conversation_assigned` trigger,
|
||||
// which notifies the agent.
|
||||
const summary = buildHandoffSummary({
|
||||
messages,
|
||||
replyCount: conv.ai_reply_count ?? 0,
|
||||
})
|
||||
const update: Record<string, unknown> = {
|
||||
ai_autoreply_disabled: true,
|
||||
ai_handoff_summary: summary,
|
||||
}
|
||||
// Only set the assignee when a target is configured AND the thread
|
||||
// isn't already owned — never stomp an existing human assignment.
|
||||
if (config.handoffAgentId && !conv.assigned_agent_id) {
|
||||
update.assigned_agent_id = config.handoffAgentId
|
||||
}
|
||||
await db.from('conversations').update(update).eq('id', conversationId)
|
||||
return
|
||||
}
|
||||
|
||||
@ -138,6 +185,7 @@ export async function dispatchInboundToAiReply(
|
||||
conversationId,
|
||||
contactId,
|
||||
text,
|
||||
aiGenerated: true,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[ai auto-reply] dispatch failed:', err)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ function config(overrides: Partial<AiConfig> = {}): AiConfig {
|
||||
isActive: true,
|
||||
autoReplyEnabled: false,
|
||||
autoReplyMaxPerConversation: 3,
|
||||
handoffAgentId: null,
|
||||
embeddingsApiKey: null,
|
||||
...overrides,
|
||||
}
|
||||
@ -42,24 +43,40 @@ 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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { AiError, type AiConfig, type ChatMessage, type GenerateResult } from './types'
|
||||
import {
|
||||
AiError,
|
||||
type AiConfig,
|
||||
type AiUsage,
|
||||
type ChatMessage,
|
||||
type GenerateResult,
|
||||
} from './types'
|
||||
import { HANDOFF_SENTINEL, aiRequestTimeoutMs } from './defaults'
|
||||
import { generateOpenAi } from './providers/openai'
|
||||
import { generateAnthropic } from './providers/anthropic'
|
||||
@ -27,13 +33,13 @@ export async function generateReply(args: GenerateArgs): Promise<GenerateResult>
|
||||
timeoutMs,
|
||||
}
|
||||
|
||||
let raw: string
|
||||
let result: { text: string; usage: AiUsage | null }
|
||||
switch (config.provider) {
|
||||
case 'openai':
|
||||
raw = await generateOpenAi(providerArgs)
|
||||
result = await generateOpenAi(providerArgs)
|
||||
break
|
||||
case 'anthropic':
|
||||
raw = await generateAnthropic(providerArgs)
|
||||
result = await generateAnthropic(providerArgs)
|
||||
break
|
||||
default:
|
||||
throw new AiError(`Unsupported AI provider: ${config.provider}`, {
|
||||
@ -42,16 +48,21 @@ export async function generateReply(args: GenerateArgs): Promise<GenerateResult>
|
||||
})
|
||||
}
|
||||
|
||||
return parseGeneration(raw)
|
||||
return parseGeneration(result.text, result.usage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the raw model output into `{ text, handoff }`. The sentinel can
|
||||
* appear alone or trailing a partial reply; either way we treat the
|
||||
* turn as a handoff and strip the marker from any remaining text.
|
||||
* Split the raw model output into `{ text, handoff, usage }`. The
|
||||
* sentinel can appear alone or trailing a partial reply; either way we
|
||||
* treat the turn as a handoff and strip the marker from any remaining
|
||||
* text. `usage` is passed straight through (null when the provider
|
||||
* didn't report it).
|
||||
*/
|
||||
export function parseGeneration(raw: string): GenerateResult {
|
||||
export function parseGeneration(
|
||||
raw: string,
|
||||
usage: AiUsage | null = null,
|
||||
): GenerateResult {
|
||||
const handoff = raw.includes(HANDOFF_SENTINEL)
|
||||
const text = raw.split(HANDOFF_SENTINEL).join('').trim()
|
||||
return { text, handoff }
|
||||
return { text, handoff, usage }
|
||||
}
|
||||
|
||||
66
src/lib/ai/handoff.test.ts
Normal file
66
src/lib/ai/handoff.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildHandoffSummary } from './handoff'
|
||||
|
||||
describe('buildHandoffSummary', () => {
|
||||
it('notes the reply count and quotes the last customer message', () => {
|
||||
const summary = buildHandoffSummary({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Hello! How can I help?' },
|
||||
{ role: 'user', content: 'I want a refund' },
|
||||
],
|
||||
replyCount: 2,
|
||||
})
|
||||
expect(summary).toBe(
|
||||
'🤖 AI agent handed off after 2 replies. Last customer message: “I want a refund”',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the singular "reply" for a count of one', () => {
|
||||
const summary = buildHandoffSummary({
|
||||
messages: [{ role: 'user', content: 'help' }],
|
||||
replyCount: 1,
|
||||
})
|
||||
expect(summary).toContain('after 1 reply.')
|
||||
})
|
||||
|
||||
it('says "without replying" when the bot bailed on the first inbound', () => {
|
||||
const summary = buildHandoffSummary({
|
||||
messages: [{ role: 'user', content: 'agent please' }],
|
||||
replyCount: 0,
|
||||
})
|
||||
expect(summary).toContain('handed off without replying.')
|
||||
expect(summary).toContain('“agent please”')
|
||||
})
|
||||
|
||||
it('picks the most recent customer turn, ignoring assistant turns', () => {
|
||||
const summary = buildHandoffSummary({
|
||||
messages: [
|
||||
{ role: 'user', content: 'first' },
|
||||
{ role: 'user', content: 'second' },
|
||||
{ role: 'assistant', content: 'a reply' },
|
||||
],
|
||||
replyCount: 1,
|
||||
})
|
||||
expect(summary).toContain('“second”')
|
||||
})
|
||||
|
||||
it('collapses whitespace and truncates a long message', () => {
|
||||
const long = 'x'.repeat(300)
|
||||
const summary = buildHandoffSummary({
|
||||
messages: [{ role: 'user', content: long }],
|
||||
replyCount: 0,
|
||||
})
|
||||
expect(summary).toContain('…')
|
||||
// 160-char cap on the quote; the whole note stays well under 250.
|
||||
expect(summary.length).toBeLessThan(250)
|
||||
})
|
||||
|
||||
it('degrades gracefully when there is no customer message', () => {
|
||||
const summary = buildHandoffSummary({
|
||||
messages: [{ role: 'assistant', content: 'greeting' }],
|
||||
replyCount: 0,
|
||||
})
|
||||
expect(summary).toBe('🤖 AI agent handed off without replying.')
|
||||
})
|
||||
})
|
||||
47
src/lib/ai/handoff.ts
Normal file
47
src/lib/ai/handoff.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { ChatMessage } from './types'
|
||||
|
||||
/** Longest the quoted customer message runs before we ellipsize it —
|
||||
* keeps the internal note to a glanceable one-liner. */
|
||||
const MAX_QUOTE_LEN = 160
|
||||
|
||||
/**
|
||||
* Build the short internal note the auto-reply bot leaves on a
|
||||
* conversation when it hands off to a human. Deterministic — composed
|
||||
* from context we already have (no extra LLM call / token spend), so it
|
||||
* can't fail or add latency to the handoff.
|
||||
*
|
||||
* Reads as, e.g.:
|
||||
* "🤖 AI agent handed off after 2 replies. Last customer message:
|
||||
* “can I speak to a manager about my refund?”"
|
||||
*
|
||||
* `replyCount` is the bot's auto-reply tally for the thread (0 when it
|
||||
* bailed on the very first inbound without answering).
|
||||
*/
|
||||
export function buildHandoffSummary(args: {
|
||||
messages: ChatMessage[]
|
||||
replyCount: number
|
||||
}): string {
|
||||
const { messages, replyCount } = args
|
||||
|
||||
const lastCustomer = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user' && m.content.trim())
|
||||
|
||||
const replies =
|
||||
replyCount === 0
|
||||
? 'without replying'
|
||||
: `after ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
|
||||
|
||||
const base = `🤖 AI agent handed off ${replies}.`
|
||||
|
||||
if (!lastCustomer) return base
|
||||
|
||||
const quote = truncate(lastCustomer.content.trim(), MAX_QUOTE_LEN)
|
||||
return `${base} Last customer message: “${quote}”`
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
const collapsed = text.replace(/\s+/g, ' ')
|
||||
if (collapsed.length <= max) return collapsed
|
||||
return `${collapsed.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { AiError, type ChatMessage } from '../types'
|
||||
import { AiError, type ChatMessage, type ProviderResult } from '../types'
|
||||
import { MAX_OUTPUT_TOKENS } from '../defaults'
|
||||
import {
|
||||
mergeConsecutive,
|
||||
normalizeUsage,
|
||||
providerHttpError,
|
||||
toNetworkError,
|
||||
type ProviderArgs,
|
||||
@ -12,6 +13,7 @@ const ANTHROPIC_VERSION = '2023-06-01'
|
||||
|
||||
interface AnthropicResponse {
|
||||
content?: { type?: string; text?: string }[]
|
||||
usage?: { input_tokens?: number; output_tokens?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -34,10 +36,10 @@ function normalizeForAnthropic(messages: ChatMessage[]): ChatMessage[] {
|
||||
|
||||
/**
|
||||
* Call Anthropic's Messages endpoint with the caller's own key.
|
||||
* Returns the raw assistant text (handoff parsing happens in
|
||||
* `generateReply`).
|
||||
* Returns the raw assistant text + token usage (handoff parsing happens
|
||||
* in `generateReply`).
|
||||
*/
|
||||
export async function generateAnthropic(args: ProviderArgs): Promise<string> {
|
||||
export async function generateAnthropic(args: ProviderArgs): Promise<ProviderResult> {
|
||||
const { apiKey, model, systemPrompt, messages, timeoutMs } = args
|
||||
|
||||
let res: Response
|
||||
@ -76,5 +78,10 @@ export async function generateAnthropic(args: ProviderArgs): Promise<string> {
|
||||
code: 'empty_response',
|
||||
})
|
||||
}
|
||||
return text
|
||||
// Anthropic reports input/output but no total — normalizeUsage sums.
|
||||
const usage = normalizeUsage({
|
||||
prompt: data?.usage?.input_tokens,
|
||||
completion: data?.usage?.output_tokens,
|
||||
})
|
||||
return { text, usage }
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { AiError } from '../types'
|
||||
import { AiError, type ProviderResult } from '../types'
|
||||
import { MAX_OUTPUT_TOKENS } from '../defaults'
|
||||
import {
|
||||
mergeConsecutive,
|
||||
normalizeUsage,
|
||||
providerHttpError,
|
||||
toNetworkError,
|
||||
type ProviderArgs,
|
||||
@ -11,14 +12,19 @@ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
interface OpenAiResponse {
|
||||
choices?: { message?: { content?: string } }[]
|
||||
usage?: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenAI's Chat Completions endpoint with the caller's own key.
|
||||
* Returns the raw assistant text (handoff parsing happens in
|
||||
* `generateReply`).
|
||||
* Returns the raw assistant text + token usage (handoff parsing happens
|
||||
* in `generateReply`).
|
||||
*/
|
||||
export async function generateOpenAi(args: ProviderArgs): Promise<string> {
|
||||
export async function generateOpenAi(args: ProviderArgs): Promise<ProviderResult> {
|
||||
const { apiKey, model, systemPrompt, messages, timeoutMs } = args
|
||||
|
||||
let res: Response
|
||||
@ -54,5 +60,10 @@ export async function generateOpenAi(args: ProviderArgs): Promise<string> {
|
||||
code: 'empty_response',
|
||||
})
|
||||
}
|
||||
return text
|
||||
const usage = normalizeUsage({
|
||||
prompt: data?.usage?.prompt_tokens,
|
||||
completion: data?.usage?.completion_tokens,
|
||||
total: data?.usage?.total_tokens,
|
||||
})
|
||||
return { text, usage }
|
||||
}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -21,6 +21,10 @@ export interface AiConfig {
|
||||
isActive: boolean
|
||||
autoReplyEnabled: boolean
|
||||
autoReplyMaxPerConversation: number
|
||||
/** Where auto-reply hands a conversation off when the model bails: an
|
||||
* agent's `auth.users.id`, or null to leave it unassigned (drop into
|
||||
* the shared queue). */
|
||||
handoffAgentId: string | null
|
||||
/** Optional OpenAI-compatible key for embeddings. When set, the
|
||||
* knowledge base is embedded and semantic retrieval turns on; when
|
||||
* null, retrieval falls back to lexical full-text search. */
|
||||
@ -33,12 +37,31 @@ export interface ChatMessage {
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Token counts for one provider call, normalized across OpenAI
|
||||
* (`prompt`/`completion`) and Anthropic (`input`/`output`). Null when
|
||||
* the provider didn't return usage. Logged to `ai_usage_log`.
|
||||
*/
|
||||
export interface AiUsage {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
/** Raw text + usage a provider adapter returns before handoff parsing. */
|
||||
export interface ProviderResult {
|
||||
text: string
|
||||
usage: AiUsage | null
|
||||
}
|
||||
|
||||
/** Outcome of a generation call. */
|
||||
export interface GenerateResult {
|
||||
/** The reply text, with any handoff sentinel stripped. */
|
||||
text: string
|
||||
/** True when the model asked to hand off to a human (auto-reply mode). */
|
||||
handoff: boolean
|
||||
/** Provider token usage for this call, or null when unavailable. */
|
||||
usage: AiUsage | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
62
src/lib/ai/usage.test.ts
Normal file
62
src/lib/ai/usage.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { logAiUsage } from './usage'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
function fakeDb() {
|
||||
const insert = vi.fn().mockResolvedValue({ error: null })
|
||||
const db = { from: vi.fn(() => ({ insert })) }
|
||||
return { db: db as unknown as SupabaseClient, insert, from: db.from }
|
||||
}
|
||||
|
||||
describe('logAiUsage', () => {
|
||||
it('inserts a row mapping normalized usage to the log columns', async () => {
|
||||
const { db, insert, from } = fakeDb()
|
||||
await logAiUsage(db, {
|
||||
accountId: 'acct-1',
|
||||
conversationId: 'conv-1',
|
||||
mode: 'auto_reply',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-x',
|
||||
usage: { promptTokens: 30, completionTokens: 6, totalTokens: 36 },
|
||||
})
|
||||
expect(from).toHaveBeenCalledWith('ai_usage_log')
|
||||
expect(insert).toHaveBeenCalledWith({
|
||||
account_id: 'acct-1',
|
||||
conversation_id: 'conv-1',
|
||||
mode: 'auto_reply',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-x',
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 6,
|
||||
total_tokens: 36,
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when the provider reported no usage', async () => {
|
||||
const { db, from } = fakeDb()
|
||||
await logAiUsage(db, {
|
||||
accountId: 'acct-1',
|
||||
conversationId: null,
|
||||
mode: 'draft',
|
||||
provider: 'openai',
|
||||
model: 'gpt-x',
|
||||
usage: null,
|
||||
})
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never throws when the insert errors', async () => {
|
||||
const insert = vi.fn().mockResolvedValue({ error: { message: 'boom' } })
|
||||
const db = { from: vi.fn(() => ({ insert })) } as unknown as SupabaseClient
|
||||
await expect(
|
||||
logAiUsage(db, {
|
||||
accountId: 'acct-1',
|
||||
conversationId: 'conv-1',
|
||||
mode: 'draft',
|
||||
provider: 'openai',
|
||||
model: 'gpt-x',
|
||||
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
51
src/lib/ai/usage.ts
Normal file
51
src/lib/ai/usage.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { AiProvider, AiUsage } from './types'
|
||||
|
||||
export interface LogAiUsageArgs {
|
||||
accountId: string
|
||||
/** Null for a draft not tied to one thread, or when the row was
|
||||
* deleted between generation and logging. */
|
||||
conversationId: string | null
|
||||
mode: 'auto_reply' | 'draft'
|
||||
provider: AiProvider
|
||||
model: string
|
||||
/** Provider usage; a no-op when null (nothing worth recording). */
|
||||
usage: AiUsage | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort append to `ai_usage_log` — one row per LLM call, for cost
|
||||
* visibility on the account's BYO key. NEVER throws: usage accounting
|
||||
* must not fail a reply the customer is waiting on, so any DB error is
|
||||
* logged and swallowed. Skips entirely when the provider didn't report
|
||||
* usage (we'd only be writing zeros).
|
||||
*
|
||||
* Pass the service-role admin client from the webhook, or the RLS-scoped
|
||||
* SSR client from a route — writes land either way (there's no
|
||||
* `authenticated` INSERT policy, so an SSR write relies on the service
|
||||
* role; callers that must persist from a route should pass the admin
|
||||
* client).
|
||||
*/
|
||||
export async function logAiUsage(
|
||||
db: SupabaseClient,
|
||||
args: LogAiUsageArgs,
|
||||
): Promise<void> {
|
||||
if (!args.usage) return
|
||||
try {
|
||||
const { error } = await db.from('ai_usage_log').insert({
|
||||
account_id: args.accountId,
|
||||
conversation_id: args.conversationId,
|
||||
mode: args.mode,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
prompt_tokens: args.usage.promptTokens,
|
||||
completion_tokens: args.usage.completionTokens,
|
||||
total_tokens: args.usage.totalTokens,
|
||||
})
|
||||
if (error) {
|
||||
console.error('[ai usage] log insert failed:', error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ai usage] log insert threw:', err)
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
86
supabase/migrations/033_ai_reply_polish.sql
Normal file
86
supabase/migrations/033_ai_reply_polish.sql
Normal file
@ -0,0 +1,86 @@
|
||||
-- ============================================================
|
||||
-- 033_ai_reply_polish.sql — AI reply assistant polish
|
||||
--
|
||||
-- Follow-ups to 029_ai_reply / 030_ai_knowledge that make the
|
||||
-- auto-reply bot visible and controllable from the inbox, complete the
|
||||
-- handoff, and record token spend:
|
||||
--
|
||||
-- 1. messages.ai_generated — marks a reply the bot sent (vs a
|
||||
-- deterministic Flow/bot send), so
|
||||
-- the inbox can badge it "AI".
|
||||
-- 2. ai_configs.handoff_agent_id — where a handed-off conversation is
|
||||
-- routed. NULL = leave unassigned
|
||||
-- (drop into the shared queue).
|
||||
-- 3. conversations.ai_handoff_summary
|
||||
-- — a short internal note the bot writes
|
||||
-- when it hands off, surfaced to the
|
||||
-- agent who takes over.
|
||||
-- 4. ai_usage_log — per-run provider token usage, for
|
||||
-- cost visibility on the account's BYO
|
||||
-- key. Written by the service role from
|
||||
-- the draft route + auto-reply bot.
|
||||
--
|
||||
-- Idempotent — safe to run multiple times.
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Mark AI-generated messages.
|
||||
--
|
||||
-- Auto-replies are inserted as sender_type='bot' (same as Flow sends);
|
||||
-- this column is the only thing that distinguishes an LLM reply from a
|
||||
-- deterministic one, so the inbox can show the "AI" badge on the right
|
||||
-- bubbles only.
|
||||
-- ============================================================
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS ai_generated boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Handoff routing target + 3. handoff summary.
|
||||
-- ============================================================
|
||||
ALTER TABLE ai_configs
|
||||
ADD COLUMN IF NOT EXISTS handoff_agent_id uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN IF NOT EXISTS ai_handoff_summary text;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Per-run token-usage log.
|
||||
--
|
||||
-- One row per LLM call (draft or auto-reply). Best-effort: the writer
|
||||
-- never blocks a reply on a failed insert. Kept append-only; prune with
|
||||
-- a scheduled job if it grows (an active account writes a handful of
|
||||
-- rows per conversation).
|
||||
--
|
||||
-- RLS: admin+ read (spend is billing-class, not something a viewer/agent
|
||||
-- needs). Writes come from the service-role client (webhook + route),
|
||||
-- which bypasses RLS, so there is no INSERT policy for `authenticated`.
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
conversation_id uuid REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
-- 'auto_reply' | 'draft' — which surface spent the tokens.
|
||||
mode text NOT NULL CHECK (mode IN ('auto_reply', 'draft')),
|
||||
provider text NOT NULL CHECK (provider IN ('openai', 'anthropic')),
|
||||
model text NOT NULL,
|
||||
prompt_tokens integer NOT NULL DEFAULT 0,
|
||||
completion_tokens integer NOT NULL DEFAULT 0,
|
||||
total_tokens integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Account-scoped, newest-first reads (usage dashboards, "spend this
|
||||
-- month") — the only access pattern.
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_log_account_created
|
||||
ON ai_usage_log(account_id, created_at DESC);
|
||||
|
||||
ALTER TABLE ai_usage_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: admin+ only (spend visibility is settings/billing-class).
|
||||
DROP POLICY IF EXISTS ai_usage_log_select ON ai_usage_log;
|
||||
CREATE POLICY ai_usage_log_select ON ai_usage_log FOR SELECT
|
||||
USING (is_account_member(account_id, 'admin'));
|
||||
|
||||
-- No INSERT/UPDATE/DELETE policies for `authenticated`: the log is
|
||||
-- written exclusively by the service role (webhook + draft route) and
|
||||
-- is never mutated from the client.
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user