mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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
|
Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes
|
||||||
and polish.
|
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
|
## [0.7.0] — 2026-07-02
|
||||||
|
|
||||||
Promotes the AI assistant to a first-class **AI Agents** section in the
|
Promotes the AI assistant to a first-class **AI Agents** section in the
|
||||||
|
|||||||
@ -205,7 +205,9 @@
|
|||||||
"template": "Template",
|
"template": "Template",
|
||||||
"buttonReply": "Button reply",
|
"buttonReply": "Button reply",
|
||||||
"interactiveReply": "[Interactive reply]",
|
"interactiveReply": "[Interactive reply]",
|
||||||
"unsupported": "[Unsupported message type]"
|
"unsupported": "[Unsupported message type]",
|
||||||
|
"aiBadge": "AI",
|
||||||
|
"aiBadgeTitle": "Sent automatically by the AI assistant"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
@ -251,6 +253,16 @@
|
|||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
"noDeals": "No deals",
|
"noDeals": "No deals",
|
||||||
"addNotePlaceholder": "Add a note..."
|
"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": {
|
"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.",
|
"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",
|
"maxAutoReplies": "Max auto-replies per conversation",
|
||||||
"maxAutoRepliesDesc": "After this many bot replies in one thread, the bot goes quiet.",
|
"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",
|
"remove": "Remove",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"missingModel": "Enter a model name.",
|
"missingModel": "Enter a model name.",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wacrm",
|
"name": "wacrm",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"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.",
|
"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",
|
"license": "MIT",
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { AiPlayground } from '@/components/agents/ai-playground';
|
import { AiPlayground } from '@/components/agents/ai-playground';
|
||||||
|
import { AiUsageCard } from '@/components/agents/ai-usage';
|
||||||
import { AiConfig } from '@/components/settings/ai-config';
|
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() {
|
export default function AgentsPage() {
|
||||||
|
const { accountRole } = useAuth();
|
||||||
|
const canViewUsage = accountRole ? canEditSettings(accountRole) : false;
|
||||||
const [tab, setTab] = useState<Tab>('playground');
|
const [tab, setTab] = useState<Tab>('playground');
|
||||||
const [decided, setDecided] = useState(false);
|
const [decided, setDecided] = useState(false);
|
||||||
|
|
||||||
@ -57,6 +62,11 @@ export default function AgentsPage() {
|
|||||||
<TabsTrigger value="setup">
|
<TabsTrigger value="setup">
|
||||||
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
|
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{canViewUsage && (
|
||||||
|
<TabsTrigger value="usage">
|
||||||
|
<BarChart3 className="mr-1.5 h-4 w-4" /> Usage
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="playground" className="mt-4">
|
<TabsContent value="playground" className="mt-4">
|
||||||
@ -66,6 +76,12 @@ export default function AgentsPage() {
|
|||||||
<TabsContent value="setup" className="mt-4">
|
<TabsContent value="setup" className="mt-4">
|
||||||
<AiConfig />
|
<AiConfig />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{canViewUsage && (
|
||||||
|
<TabsContent value="usage" className="mt-4">
|
||||||
|
<AiUsageCard />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
// `api_key` is selected only to derive `has_key` — it is stripped
|
||||||
// out below and never returned to the client.
|
// out below and never returned to the client.
|
||||||
.select(
|
.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)
|
.eq('account_id', accountId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
@ -95,6 +95,25 @@ export async function POST(request: Request) {
|
|||||||
if (!Number.isFinite(maxPer)) maxPer = 3
|
if (!Number.isFinite(maxPer)) maxPer = 3
|
||||||
maxPer = Math.min(20, Math.max(1, Math.floor(maxPer)))
|
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() : ''
|
const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : ''
|
||||||
|
|
||||||
// Embeddings key (optional, for semantic KB search): a non-empty
|
// Embeddings key (optional, for semantic KB search): a non-empty
|
||||||
@ -146,6 +165,7 @@ export async function POST(request: Request) {
|
|||||||
isActive,
|
isActive,
|
||||||
autoReplyEnabled,
|
autoReplyEnabled,
|
||||||
autoReplyMaxPerConversation: maxPer,
|
autoReplyMaxPerConversation: maxPer,
|
||||||
|
handoffAgentId: null,
|
||||||
embeddingsApiKey: null,
|
embeddingsApiKey: null,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -186,6 +206,9 @@ export async function POST(request: Request) {
|
|||||||
auto_reply_enabled: autoReplyEnabled,
|
auto_reply_enabled: autoReplyEnabled,
|
||||||
auto_reply_max_per_conversation: maxPer,
|
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) {
|
if (rawEmbeddingsKey) {
|
||||||
shared.embeddings_api_key = encrypt(rawEmbeddingsKey)
|
shared.embeddings_api_key = encrypt(rawEmbeddingsKey)
|
||||||
} else if (clearEmbeddingsKey) {
|
} else if (clearEmbeddingsKey) {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { retrieveKnowledge } from '@/lib/ai/knowledge'
|
|||||||
import { generateReply } from '@/lib/ai/generate'
|
import { generateReply } from '@/lib/ai/generate'
|
||||||
import { buildSystemPrompt } from '@/lib/ai/defaults'
|
import { buildSystemPrompt } from '@/lib/ai/defaults'
|
||||||
import { latestUserMessage } from '@/lib/ai/query'
|
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'
|
import { AiError } from '@/lib/ai/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -102,7 +104,28 @@ export async function POST(request: Request) {
|
|||||||
knowledge,
|
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 })
|
return NextResponse.json({ draft: text })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AiError) {
|
if (err instanceof AiError) {
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export async function POST(request: Request) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
autoReplyEnabled: false,
|
autoReplyEnabled: false,
|
||||||
autoReplyMaxPerConversation: 3,
|
autoReplyMaxPerConversation: 3,
|
||||||
|
handoffAgentId: null,
|
||||||
embeddingsApiKey: null,
|
embeddingsApiKey: null,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} 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,
|
LayoutTemplate,
|
||||||
ImageOff,
|
ImageOff,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ReplyQuote } from "./reply-quote";
|
import { ReplyQuote } from "./reply-quote";
|
||||||
@ -285,6 +286,19 @@ export function MessageBubble({
|
|||||||
isAgent ? "justify-end" : "justify-start",
|
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px]",
|
"text-[10px]",
|
||||||
|
|||||||
@ -47,6 +47,7 @@ import {
|
|||||||
} from "./message-composer";
|
} from "./message-composer";
|
||||||
import { deleteAccountMedia } from "@/lib/storage/upload-media";
|
import { deleteAccountMedia } from "@/lib/storage/upload-media";
|
||||||
import { TemplatePicker } from "./template-picker";
|
import { TemplatePicker } from "./template-picker";
|
||||||
|
import { AiThreadBanner } from "./ai-thread-banner";
|
||||||
import { buildReplyPreview } from "./reply-quote";
|
import { buildReplyPreview } from "./reply-quote";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@ -1078,6 +1079,22 @@ export function MessageThread({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Composer */}
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
conversationId={conversation.id}
|
conversationId={conversation.id}
|
||||||
|
|||||||
@ -28,10 +28,16 @@ import { SettingsPanelHead } from './settings-panel-head';
|
|||||||
import { AiKnowledgeCard } from './ai-knowledge';
|
import { AiKnowledgeCard } from './ai-knowledge';
|
||||||
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
|
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
|
||||||
import type { AiProvider } from '@/lib/ai/types';
|
import type { AiProvider } from '@/lib/ai/types';
|
||||||
|
import type { AccountMember } from '@/types';
|
||||||
|
import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const MASKED_KEY = '••••••••••••••••';
|
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> = {
|
const PROVIDER_LABEL: Record<AiProvider, string> = {
|
||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
anthropic: 'Anthropic (Claude)',
|
anthropic: 'Anthropic (Claude)',
|
||||||
@ -66,6 +72,9 @@ export function AiConfig() {
|
|||||||
const [isActive, setIsActive] = useState(false);
|
const [isActive, setIsActive] = useState(false);
|
||||||
const [autoReplyEnabled, setAutoReplyEnabled] = useState(false);
|
const [autoReplyEnabled, setAutoReplyEnabled] = useState(false);
|
||||||
const [maxPerConversation, setMaxPerConversation] = useState(3);
|
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
|
// Guard keyed on the account (not a bare boolean) so an in-place
|
||||||
// account switch — ownership transfer, multi-account membership —
|
// account switch — ownership transfer, multi-account membership —
|
||||||
@ -90,6 +99,7 @@ export function AiConfig() {
|
|||||||
setIsActive(data.is_active);
|
setIsActive(data.is_active);
|
||||||
setAutoReplyEnabled(data.auto_reply_enabled);
|
setAutoReplyEnabled(data.auto_reply_enabled);
|
||||||
setMaxPerConversation(data.auto_reply_max_per_conversation ?? 3);
|
setMaxPerConversation(data.auto_reply_max_per_conversation ?? 3);
|
||||||
|
setHandoffAgentId(data.handoff_agent_id ?? '');
|
||||||
setHasStoredKey(Boolean(data.has_key));
|
setHasStoredKey(Boolean(data.has_key));
|
||||||
setApiKey(data.has_key ? MASKED_KEY : '');
|
setApiKey(data.has_key ? MASKED_KEY : '');
|
||||||
setKeyEdited(false);
|
setKeyEdited(false);
|
||||||
@ -108,6 +118,10 @@ export function AiConfig() {
|
|||||||
if (!accountId || loadedAccountIdRef.current === accountId) return;
|
if (!accountId || loadedAccountIdRef.current === accountId) return;
|
||||||
loadedAccountIdRef.current = accountId;
|
loadedAccountIdRef.current = accountId;
|
||||||
void fetchConfig();
|
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]);
|
}, [accountId, fetchConfig]);
|
||||||
|
|
||||||
// Swap the model default when the provider changes, unless the user
|
// Swap the model default when the provider changes, unless the user
|
||||||
@ -136,6 +150,7 @@ export function AiConfig() {
|
|||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
auto_reply_enabled: autoReplyEnabled,
|
auto_reply_enabled: autoReplyEnabled,
|
||||||
auto_reply_max_per_conversation: maxPerConversation,
|
auto_reply_max_per_conversation: maxPerConversation,
|
||||||
|
handoff_agent_id: handoffAgentId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
@ -203,6 +218,7 @@ export function AiConfig() {
|
|||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
setAutoReplyEnabled(false);
|
setAutoReplyEnabled(false);
|
||||||
setSystemPrompt('');
|
setSystemPrompt('');
|
||||||
|
setHandoffAgentId('');
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
toast.error(data.error ?? t('removeFailed'));
|
toast.error(data.error ?? t('removeFailed'));
|
||||||
@ -439,6 +455,34 @@ export function AiConfig() {
|
|||||||
className="w-20"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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,
|
isActive: true,
|
||||||
autoReplyEnabled: true,
|
autoReplyEnabled: true,
|
||||||
autoReplyMaxPerConversation: 3,
|
autoReplyMaxPerConversation: 3,
|
||||||
|
handoffAgentId: null,
|
||||||
embeddingsApiKey: null,
|
embeddingsApiKey: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
@ -186,11 +187,26 @@ describe('dispatchInboundToAiReply — eligibility gates', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('dispatchInboundToAiReply — handoff', () => {
|
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 })
|
h.generateReply.mockResolvedValue({ text: '', handoff: true })
|
||||||
await dispatchInboundToAiReply(ARGS)
|
await dispatchInboundToAiReply(ARGS)
|
||||||
expect(h.engineSendText).not.toHaveBeenCalled()
|
expect(h.engineSendText).not.toHaveBeenCalled()
|
||||||
expect(h.state.updatePayload).toEqual({ ai_autoreply_disabled: true })
|
|
||||||
expect(h.state.rpcCalls).toHaveLength(0)
|
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 { retrieveKnowledge } from './knowledge'
|
||||||
import { generateReply } from './generate'
|
import { generateReply } from './generate'
|
||||||
import { buildSystemPrompt } from './defaults'
|
import { buildSystemPrompt } from './defaults'
|
||||||
|
import { buildHandoffSummary } from './handoff'
|
||||||
|
import { logAiUsage } from './usage'
|
||||||
import { latestUserMessage } from './query'
|
import { latestUserMessage } from './query'
|
||||||
import { engineSendText } from '@/lib/flows/meta-send'
|
import { engineSendText } from '@/lib/flows/meta-send'
|
||||||
|
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||||
|
|
||||||
interface DispatchArgs {
|
interface DispatchArgs {
|
||||||
/** Tenancy key — drives config, contact, and whatsapp_config lookups. */
|
/** Tenancy key — drives config, contact, and whatsapp_config lookups. */
|
||||||
@ -79,6 +82,22 @@ export async function dispatchInboundToAiReply(
|
|||||||
const messages = await buildConversationContext(db, conversationId)
|
const messages = await buildConversationContext(db, conversationId)
|
||||||
if (messages.length === 0) return
|
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).
|
// Ground the reply in the account's knowledge base (best-effort).
|
||||||
const knowledge = await retrieveKnowledge(
|
const knowledge = await retrieveKnowledge(
|
||||||
db,
|
db,
|
||||||
@ -93,20 +112,48 @@ export async function dispatchInboundToAiReply(
|
|||||||
knowledge,
|
knowledge,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { text, handoff } = await generateReply({
|
const { text, handoff, usage } = await generateReply({
|
||||||
config,
|
config,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
messages,
|
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) {
|
if (handoff || !text) {
|
||||||
// The model can't (or shouldn't) answer — stop auto-replying on
|
// The model can't (or shouldn't) answer — stop auto-replying on
|
||||||
// this thread and leave the inbound unanswered so it surfaces in
|
// this thread and hand it to a human. We (a) pause the bot here
|
||||||
// the inbox for a human. Sticky until an admin re-enables.
|
// (sticky until re-enabled), (b) route the conversation to the
|
||||||
await db
|
// configured handoff agent — null leaves it in the shared queue —
|
||||||
.from('conversations')
|
// and (c) leave a short internal note so whoever picks it up has
|
||||||
.update({ ai_autoreply_disabled: true })
|
// context. Assigning fires the `on_conversation_assigned` trigger,
|
||||||
.eq('id', conversationId)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +185,7 @@ export async function dispatchInboundToAiReply(
|
|||||||
conversationId,
|
conversationId,
|
||||||
contactId,
|
contactId,
|
||||||
text,
|
text,
|
||||||
|
aiGenerated: true,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ai auto-reply] dispatch failed:', err)
|
console.error('[ai auto-reply] dispatch failed:', err)
|
||||||
|
|||||||
@ -10,11 +10,12 @@ interface AiConfigRow {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
auto_reply_enabled: boolean
|
auto_reply_enabled: boolean
|
||||||
auto_reply_max_per_conversation: number
|
auto_reply_max_per_conversation: number
|
||||||
|
handoff_agent_id: string | null
|
||||||
embeddings_api_key: string | null
|
embeddings_api_key: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_COLUMNS =
|
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
|
* Load and decrypt the account's AI config for *use* (draft or
|
||||||
@ -76,6 +77,7 @@ export async function loadAiConfig(
|
|||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
autoReplyEnabled: row.auto_reply_enabled,
|
autoReplyEnabled: row.auto_reply_enabled,
|
||||||
autoReplyMaxPerConversation: row.auto_reply_max_per_conversation,
|
autoReplyMaxPerConversation: row.auto_reply_max_per_conversation,
|
||||||
|
handoffAgentId: row.handoff_agent_id,
|
||||||
embeddingsApiKey,
|
embeddingsApiKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ function config(overrides: Partial<AiConfig> = {}): AiConfig {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
autoReplyEnabled: false,
|
autoReplyEnabled: false,
|
||||||
autoReplyMaxPerConversation: 3,
|
autoReplyMaxPerConversation: 3,
|
||||||
|
handoffAgentId: null,
|
||||||
embeddingsApiKey: null,
|
embeddingsApiKey: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
@ -42,25 +43,41 @@ describe('parseGeneration', () => {
|
|||||||
expect(parseGeneration('Hello there')).toEqual({
|
expect(parseGeneration('Hello there')).toEqual({
|
||||||
text: 'Hello there',
|
text: 'Hello there',
|
||||||
handoff: false,
|
handoff: false,
|
||||||
|
usage: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('detects + strips the handoff sentinel', () => {
|
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({
|
expect(parseGeneration('Let me get a human [[HANDOFF]]')).toEqual({
|
||||||
text: 'Let me get a human',
|
text: 'Let me get a human',
|
||||||
handoff: true,
|
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', () => {
|
describe('generateReply — OpenAI', () => {
|
||||||
it('calls the chat completions endpoint and returns the reply', async () => {
|
it('calls the chat completions endpoint and returns the reply', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
.fn()
|
okResponse({
|
||||||
.mockResolvedValue(
|
choices: [{ message: { content: 'Sure — happy to help!' } }],
|
||||||
okResponse({ choices: [{ message: { content: 'Sure — happy to help!' } }] }),
|
usage: { prompt_tokens: 42, completion_tokens: 8, total_tokens: 50 },
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
const res = await generateReply({
|
const res = await generateReply({
|
||||||
@ -69,7 +86,11 @@ describe('generateReply — OpenAI', () => {
|
|||||||
messages: [{ role: 'user', content: 'Hi' }],
|
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]
|
const [url, opts] = fetchMock.mock.calls[0]
|
||||||
expect(url).toContain('api.openai.com')
|
expect(url).toContain('api.openai.com')
|
||||||
expect(opts.headers.Authorization).toBe('Bearer sk-test')
|
expect(opts.headers.Authorization).toBe('Bearer sk-test')
|
||||||
@ -109,9 +130,12 @@ describe('generateReply — OpenAI', () => {
|
|||||||
|
|
||||||
describe('generateReply — Anthropic', () => {
|
describe('generateReply — Anthropic', () => {
|
||||||
it('calls the messages endpoint with the version header and parses text blocks', async () => {
|
it('calls the messages endpoint with the version header and parses text blocks', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
.fn()
|
okResponse({
|
||||||
.mockResolvedValue(okResponse({ content: [{ type: 'text', text: 'Hi there!' }] }))
|
content: [{ type: 'text', text: 'Hi there!' }],
|
||||||
|
usage: { input_tokens: 30, output_tokens: 6 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
const res = await generateReply({
|
const res = await generateReply({
|
||||||
@ -120,7 +144,12 @@ describe('generateReply — Anthropic', () => {
|
|||||||
messages: [{ role: 'user', content: 'Hello' }],
|
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]
|
const [url, opts] = fetchMock.mock.calls[0]
|
||||||
expect(url).toContain('api.anthropic.com')
|
expect(url).toContain('api.anthropic.com')
|
||||||
expect(opts.headers['x-api-key']).toBe('sk-ant-x')
|
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 { HANDOFF_SENTINEL, aiRequestTimeoutMs } from './defaults'
|
||||||
import { generateOpenAi } from './providers/openai'
|
import { generateOpenAi } from './providers/openai'
|
||||||
import { generateAnthropic } from './providers/anthropic'
|
import { generateAnthropic } from './providers/anthropic'
|
||||||
@ -27,13 +33,13 @@ export async function generateReply(args: GenerateArgs): Promise<GenerateResult>
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw: string
|
let result: { text: string; usage: AiUsage | null }
|
||||||
switch (config.provider) {
|
switch (config.provider) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
raw = await generateOpenAi(providerArgs)
|
result = await generateOpenAi(providerArgs)
|
||||||
break
|
break
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
raw = await generateAnthropic(providerArgs)
|
result = await generateAnthropic(providerArgs)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
throw new AiError(`Unsupported AI provider: ${config.provider}`, {
|
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
|
* Split the raw model output into `{ text, handoff, usage }`. The
|
||||||
* appear alone or trailing a partial reply; either way we treat the
|
* sentinel can appear alone or trailing a partial reply; either way we
|
||||||
* turn as a handoff and strip the marker from any remaining text.
|
* 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 handoff = raw.includes(HANDOFF_SENTINEL)
|
||||||
const text = raw.split(HANDOFF_SENTINEL).join('').trim()
|
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 { MAX_OUTPUT_TOKENS } from '../defaults'
|
||||||
import {
|
import {
|
||||||
mergeConsecutive,
|
mergeConsecutive,
|
||||||
|
normalizeUsage,
|
||||||
providerHttpError,
|
providerHttpError,
|
||||||
toNetworkError,
|
toNetworkError,
|
||||||
type ProviderArgs,
|
type ProviderArgs,
|
||||||
@ -12,6 +13,7 @@ const ANTHROPIC_VERSION = '2023-06-01'
|
|||||||
|
|
||||||
interface AnthropicResponse {
|
interface AnthropicResponse {
|
||||||
content?: { type?: string; text?: string }[]
|
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.
|
* Call Anthropic's Messages endpoint with the caller's own key.
|
||||||
* Returns the raw assistant text (handoff parsing happens in
|
* Returns the raw assistant text + token usage (handoff parsing happens
|
||||||
* `generateReply`).
|
* 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
|
const { apiKey, model, systemPrompt, messages, timeoutMs } = args
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
@ -76,5 +78,10 @@ export async function generateAnthropic(args: ProviderArgs): Promise<string> {
|
|||||||
code: 'empty_response',
|
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 { MAX_OUTPUT_TOKENS } from '../defaults'
|
||||||
import {
|
import {
|
||||||
mergeConsecutive,
|
mergeConsecutive,
|
||||||
|
normalizeUsage,
|
||||||
providerHttpError,
|
providerHttpError,
|
||||||
toNetworkError,
|
toNetworkError,
|
||||||
type ProviderArgs,
|
type ProviderArgs,
|
||||||
@ -11,14 +12,19 @@ const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'
|
|||||||
|
|
||||||
interface OpenAiResponse {
|
interface OpenAiResponse {
|
||||||
choices?: { message?: { content?: string } }[]
|
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.
|
* Call OpenAI's Chat Completions endpoint with the caller's own key.
|
||||||
* Returns the raw assistant text (handoff parsing happens in
|
* Returns the raw assistant text + token usage (handoff parsing happens
|
||||||
* `generateReply`).
|
* 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
|
const { apiKey, model, systemPrompt, messages, timeoutMs } = args
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
@ -54,5 +60,10 @@ export async function generateOpenAi(args: ProviderArgs): Promise<string> {
|
|||||||
code: 'empty_response',
|
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.
|
// Bits shared by the OpenAI + Anthropic adapters.
|
||||||
@ -12,6 +12,30 @@ export interface ProviderArgs {
|
|||||||
timeoutMs: number
|
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. */
|
/** Map a fetch rejection (timeout / DNS / offline) to a typed AiError. */
|
||||||
export function toNetworkError(err: unknown): AiError {
|
export function toNetworkError(err: unknown): AiError {
|
||||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||||
|
|||||||
@ -21,6 +21,10 @@ export interface AiConfig {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
autoReplyEnabled: boolean
|
autoReplyEnabled: boolean
|
||||||
autoReplyMaxPerConversation: number
|
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
|
/** Optional OpenAI-compatible key for embeddings. When set, the
|
||||||
* knowledge base is embedded and semantic retrieval turns on; when
|
* knowledge base is embedded and semantic retrieval turns on; when
|
||||||
* null, retrieval falls back to lexical full-text search. */
|
* null, retrieval falls back to lexical full-text search. */
|
||||||
@ -33,12 +37,31 @@ export interface ChatMessage {
|
|||||||
content: string
|
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. */
|
/** Outcome of a generation call. */
|
||||||
export interface GenerateResult {
|
export interface GenerateResult {
|
||||||
/** The reply text, with any handoff sentinel stripped. */
|
/** The reply text, with any handoff sentinel stripped. */
|
||||||
text: string
|
text: string
|
||||||
/** True when the model asked to hand off to a human (auto-reply mode). */
|
/** True when the model asked to hand off to a human (auto-reply mode). */
|
||||||
handoff: boolean
|
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 {
|
): string {
|
||||||
const code = currency || DEFAULT_CURRENCY;
|
const code = currency || DEFAULT_CURRENCY;
|
||||||
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
|
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
|
||||||
const v = Number(value || 0);
|
return `${symbol}${formatCompactNumber(value)}`;
|
||||||
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)}`;
|
/**
|
||||||
|
* 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
|
conversationId: string
|
||||||
contactId: string
|
contactId: string
|
||||||
text: 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,
|
content_text: args.text,
|
||||||
message_id: waMessageId,
|
message_id: waMessageId,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
ai_generated: args.aiGenerated ?? false,
|
||||||
})
|
})
|
||||||
if (msgErr) {
|
if (msgErr) {
|
||||||
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
|
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
|
* key past the provider's own rate limit. 60/min ≈ three busy agents
|
||||||
* drafting flat-out. */
|
* drafting flat-out. */
|
||||||
aiDraftAccount: { limit: 60, windowMs: 60_000 },
|
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;
|
} as const;
|
||||||
|
|
||||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||||
|
|||||||
@ -159,6 +159,18 @@ export interface Conversation {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
contact?: Contact;
|
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).
|
* cue (renders with a "↩ button reply" affordance).
|
||||||
*/
|
*/
|
||||||
interactive_reply_id?: string;
|
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';
|
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)
|
-- 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 problem
|
||||||
--
|
--
|
||||||
-- The `profiles_update` RLS policy from migration 017 gates on
|
-- The `profiles_update` RLS policy from migration 017 gates on
|
||||||
Loading…
Reference in New Issue
Block a user