mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(ai): polish auto-reply — inbox controls, real handoff, usage logging
Makes the AI auto-reply bot visible and controllable from the inbox, completes the handoff, and records token spend. Builds on the existing lib/ai engine rather than replacing it. Track A — user-facing - messages.ai_generated flag → small "AI" badge on bot-generated bubbles - "Take over" / "Resume AI" banner per conversation (POST /api/ai/autoreply/[id]) — take-over pauses + assigns to the agent - handoff now assigns to a configurable target (agent or unassigned queue) and writes an internal note summarizing the exchange; the existing on_conversation_assigned trigger notifies the assignee - Settings → AI: "Hand off to" target picker Track B — observability + safety - providers return normalized token usage; logged per run to ai_usage_log (admin-readable) from both auto-reply and draft - account-wide auto-reply rate limit (30/min) on top of the per-conversation cap Migration: 033_ai_reply_polish.sql (messages.ai_generated, ai_configs.handoff_agent_id, conversations.ai_handoff_summary, ai_usage_log). Tests + typecheck + lint pass (607 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
23838a9959
commit
b0194dfc27
37
CHANGELOG.md
37
CHANGELOG.md
@ -9,6 +9,43 @@ Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes
|
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.** Every draft and auto-reply records its
|
||||||
|
provider token counts to the new `ai_usage_log` table (admin-readable),
|
||||||
|
for cost visibility on your BYO key.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Auto-reply now has an **account-wide rate limit** (30/min) on top of
|
||||||
|
the existing per-conversation cap, so a burst of inbound can't run your
|
||||||
|
provider key past its limit. Over the limit, inbounds simply wait in
|
||||||
|
the inbox for a human instead of being auto-answered.
|
||||||
|
|
||||||
## [0.7.0] — 2026-07-02
|
## [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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
95
src/app/api/ai/autoreply/[conversationId]/route.ts
Normal file
95
src/app/api/ai/autoreply/[conversationId]/route.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireRole, toErrorResponse } from '@/lib/auth/account'
|
||||||
|
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ conversationId: string }> }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/autoreply/[conversationId] (agent+)
|
||||||
|
*
|
||||||
|
* Toggle the AI auto-reply bot for one conversation from the inbox — the
|
||||||
|
* "Take over" / "Resume AI" banner.
|
||||||
|
*
|
||||||
|
* Body: { paused: boolean, assign_to_me?: boolean }
|
||||||
|
* - paused: true → pause the bot here (a human is taking over). When
|
||||||
|
* `assign_to_me` is set, also assign the thread to the
|
||||||
|
* caller (the usual "Take over" flow). Assignment
|
||||||
|
* fires the `on_conversation_assigned` trigger.
|
||||||
|
* - paused: false → hand the thread back to the bot: clear the pause,
|
||||||
|
* reset the per-conversation reply count so it gets
|
||||||
|
* fresh slots, and clear the handoff note. If the
|
||||||
|
* caller currently owns the thread, unassign it too so
|
||||||
|
* the bot isn't blocked by the "human owns this" gate.
|
||||||
|
*
|
||||||
|
* Writes go through the RLS-scoped SSR client, so a conversation outside
|
||||||
|
* the caller's account simply isn't found (404).
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { supabase, accountId, userId } = await requireRole('agent')
|
||||||
|
|
||||||
|
// Reuse the send bucket: this is a cheap per-user inbox action and
|
||||||
|
// toggling it in a tight loop has no legitimate use.
|
||||||
|
const limit = checkRateLimit(`ai-takeover:${userId}`, RATE_LIMITS.send)
|
||||||
|
if (!limit.success) return rateLimitResponse(limit)
|
||||||
|
|
||||||
|
const { conversationId } = await params
|
||||||
|
const body = await request.json().catch(() => null)
|
||||||
|
if (!body || typeof body.paused !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'paused (boolean) is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const paused = body.paused as boolean
|
||||||
|
const assignToMe = body.assign_to_me === true
|
||||||
|
|
||||||
|
// Confirm the conversation is in the caller's account before writing.
|
||||||
|
const { data: conv, error: convErr } = await supabase
|
||||||
|
.from('conversations')
|
||||||
|
.select('id, assigned_agent_id')
|
||||||
|
.eq('id', conversationId)
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.maybeSingle()
|
||||||
|
if (convErr) {
|
||||||
|
console.error('[ai/autoreply] conversation lookup error:', convErr)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to load conversation' },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!conv) {
|
||||||
|
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const update: Record<string, unknown> = { ai_autoreply_disabled: paused }
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
if (assignToMe) update.assigned_agent_id = userId
|
||||||
|
} else {
|
||||||
|
// Resuming: give the bot a clean slate on this thread.
|
||||||
|
update.ai_reply_count = 0
|
||||||
|
update.ai_handoff_summary = null
|
||||||
|
// Only release an assignment the caller holds — never yank a
|
||||||
|
// teammate's thread out from under them.
|
||||||
|
if (conv.assigned_agent_id === userId) update.assigned_agent_id = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: upErr } = await supabase
|
||||||
|
.from('conversations')
|
||||||
|
.update(update)
|
||||||
|
.eq('id', conversationId)
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
if (upErr) {
|
||||||
|
console.error('[ai/autoreply] update error:', upErr)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update conversation' },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, paused })
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorResponse(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,20 @@ 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); a
|
||||||
|
// failure here must not fail the draft the agent is waiting on.
|
||||||
|
await logAiUsage(supabaseAdmin(), {
|
||||||
|
accountId,
|
||||||
|
conversationId,
|
||||||
|
mode: 'draft',
|
||||||
|
provider: config.provider,
|
||||||
|
model: config.model,
|
||||||
|
usage,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ draft: text })
|
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) {
|
||||||
|
|||||||
210
src/components/inbox/ai-thread-banner.tsx
Normal file
210
src/components/inbox/ai-thread-banner.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Account AI status is the same for every conversation, so fetch it
|
||||||
|
// once per session and share it across thread switches instead of
|
||||||
|
// hitting /api/ai/config every time the agent opens a new chat. The
|
||||||
|
// promise is cached so concurrent mounts dedupe onto one request.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
interface AiAccountStatus {
|
||||||
|
autoReplyOn: boolean;
|
||||||
|
}
|
||||||
|
let statusPromise: Promise<AiAccountStatus> | null = null;
|
||||||
|
|
||||||
|
async function fetchAiAccountStatus(): Promise<AiAccountStatus> {
|
||||||
|
if (!statusPromise) {
|
||||||
|
statusPromise = fetch("/api/ai/config", { cache: "no-store" })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((j) => ({
|
||||||
|
// AI auto-reply is "live" only when configured, the master
|
||||||
|
// switch is on, and the inbound bot is enabled.
|
||||||
|
autoReplyOn: !!(j?.configured && j?.is_active && j?.auto_reply_enabled),
|
||||||
|
}))
|
||||||
|
.catch(() => ({ autoReplyOn: false }));
|
||||||
|
}
|
||||||
|
return statusPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiThreadBannerProps {
|
||||||
|
conversationId: string;
|
||||||
|
/** `conversations.ai_autoreply_disabled` — bot paused on this thread. */
|
||||||
|
disabled: boolean;
|
||||||
|
/** `conversations.ai_handoff_summary` — note the bot left on handoff. */
|
||||||
|
handoffSummary?: string | null;
|
||||||
|
/** Current assignee; when a human owns the thread the bot won't run,
|
||||||
|
* so the "AI active" banner is suppressed. */
|
||||||
|
assignedAgentId?: string | null;
|
||||||
|
/** The acting agent — "Take over" assigns the thread to them. */
|
||||||
|
currentUserId?: string | null;
|
||||||
|
/** Called after a successful toggle so the parent can patch its local
|
||||||
|
* conversation state (the realtime UPDATE also arrives, but this keeps
|
||||||
|
* the banner instant). */
|
||||||
|
onChange?: (patch: {
|
||||||
|
ai_autoreply_disabled: boolean;
|
||||||
|
assigned_agent_id?: string | null;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox banner that surfaces + controls the AI auto-reply bot per
|
||||||
|
* conversation:
|
||||||
|
* - bot active here → "AI is replying automatically" + [Take over]
|
||||||
|
* - bot paused here → the handoff note (if any) + [Resume AI]
|
||||||
|
* Renders nothing when the account has no auto-reply configured, or when
|
||||||
|
* the bot is active but a human already owns the thread (nothing to do).
|
||||||
|
*/
|
||||||
|
export function AiThreadBanner({
|
||||||
|
conversationId,
|
||||||
|
disabled,
|
||||||
|
handoffSummary,
|
||||||
|
assignedAgentId,
|
||||||
|
currentUserId,
|
||||||
|
onChange,
|
||||||
|
}: AiThreadBannerProps) {
|
||||||
|
const [autoReplyOn, setAutoReplyOn] = useState<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(() => {
|
||||||
|
let alive = true;
|
||||||
|
fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
async (paused: boolean) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ai/autoreply/${conversationId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
// "Take over" also assigns the thread to the acting agent.
|
||||||
|
body: JSON.stringify({ paused, assign_to_me: paused }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
toast.error(j?.error ?? "Couldn't update the AI assistant.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPaused(paused);
|
||||||
|
onChange?.({
|
||||||
|
ai_autoreply_disabled: paused,
|
||||||
|
// Take over assigns to the acting agent; resume releases only
|
||||||
|
// the caller's own assignment. The realtime UPDATE reconciles
|
||||||
|
// the exact value either way.
|
||||||
|
...(paused
|
||||||
|
? currentUserId
|
||||||
|
? { assigned_agent_id: currentUserId }
|
||||||
|
: {}
|
||||||
|
: { assigned_agent_id: null }),
|
||||||
|
});
|
||||||
|
toast.success(paused ? "You've taken over this chat." : "AI resumed.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't reach the server.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId, currentUserId, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Account has no auto-reply → nothing to show. (Still loading → nothing.)
|
||||||
|
if (!autoReplyOn) return null;
|
||||||
|
|
||||||
|
// Paused here (a human took over, or the model handed off).
|
||||||
|
if (paused) {
|
||||||
|
return (
|
||||||
|
<Banner tone="muted">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-foreground">AI assistant is paused here</p>
|
||||||
|
{handoffSummary && (
|
||||||
|
<p className="truncate text-muted-foreground" title={handoffSummary}>
|
||||||
|
{handoffSummary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<BannerButton onClick={() => toggle(false)} busy={busy} icon={Undo2}>
|
||||||
|
Resume AI
|
||||||
|
</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">
|
||||||
|
AI assistant is replying automatically
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<BannerButton onClick={() => toggle(true)} busy={busy} icon={Hand}>
|
||||||
|
Take over
|
||||||
|
</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";
|
||||||
@ -282,6 +283,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="Sent automatically by the AI assistant"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-2.5 w-2.5" />
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px]",
|
"text-[10px]",
|
||||||
|
|||||||
@ -46,6 +46,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";
|
||||||
|
|
||||||
@ -1070,6 +1071,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,9 +28,14 @@ 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';
|
||||||
|
|
||||||
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)',
|
||||||
@ -64,6 +69,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 —
|
||||||
@ -88,6 +96,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);
|
||||||
@ -106,6 +115,19 @@ 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 (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/account/members', { cache: 'no-store' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const json = (await res.json()) as { members?: AccountMember[] };
|
||||||
|
setMembers(json.members ?? []);
|
||||||
|
} catch {
|
||||||
|
// ignore — picker falls back to "unassigned queue" only.
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [accountId, fetchConfig]);
|
}, [accountId, fetchConfig]);
|
||||||
|
|
||||||
// Swap the model default when the provider changes, unless the user
|
// Swap the model default when the provider changes, unless the user
|
||||||
@ -134,6 +156,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 () => {
|
||||||
@ -201,6 +224,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 ?? 'Failed to remove.');
|
toast.error(data.error ?? 'Failed to remove.');
|
||||||
@ -444,6 +468,35 @@ export function AiConfig() {
|
|||||||
className="w-20"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-handoff">Hand off to</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</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}>
|
||||||
|
Unassigned queue (any agent can pick it up)
|
||||||
|
</SelectItem>
|
||||||
|
{members.map((m) => (
|
||||||
|
<SelectItem key={m.user_id} value={m.user_id}>
|
||||||
|
{m.full_name || m.email || m.user_id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -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,46 @@ 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 (best-effort — never
|
||||||
|
// blocks or fails the reply). Logged regardless of handoff: the
|
||||||
|
// provider call happened either way.
|
||||||
|
await logAiUsage(db, {
|
||||||
|
accountId,
|
||||||
|
conversationId,
|
||||||
|
mode: 'auto_reply',
|
||||||
|
provider: config.provider,
|
||||||
|
model: config.model,
|
||||||
|
usage,
|
||||||
|
})
|
||||||
|
|
||||||
if (handoff || !text) {
|
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 +183,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,24 +43,40 @@ 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)
|
||||||
|
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
Loading…
Reference in New Issue
Block a user