fix(ai): address code-review findings on auto-reply polish

Correctness:
- Resume AI now releases ANY assignment (not just the caller's own).
  A handoff-assigned thread previously stayed assigned after resume, so
  the eligibility gate silently kept the bot muted — Resume was a no-op.
- GET /api/ai/usage: guard days >= 1 (Number(null)===0 is finite, so the
  30-day default was unreachable and the window collapsed to 1 day).
- GET /api/ai/usage: align the query cutoff to the start of the oldest
  charted local day so rows in the oldest partial day no longer count in
  totals while missing from the daily series (totals now match the bars).
- ai-thread-banner: cache account AI-status per accountId and never cache
  a transient fetch failure — fixes cross-account leak and the banner
  staying hidden all session after one failed /api/ai/config call.
- /api/ai/draft: construct the service-role client inside a guard and
  fire-and-forget the usage log, so a missing service-role key (or any
  log failure) can't 500 an already-generated, already-billed draft.

Efficiency:
- Don't await usage logging on the reply hot path (auto-reply + draft);
  logAiUsage swallows its own errors, so fire-and-forget is safe and
  removes a DB round-trip from the customer-facing send.

Reuse / consistency:
- usage route reuses date-utils (daysAgoStart/lastNDayKeys/localDayKey)
  and now buckets by LOCAL day like every other dashboard chart (was UTC).
- extract formatCompactNumber (shared with formatCurrencyShort); drop the
  hand-rolled month table in favour of date-fns; extract
  fetchAccountMembers/memberLabel and use them in the AI settings picker.

Resume still resets the per-conversation reply count (a deliberate,
manual, rate-limited re-engagement — documented in the handler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-07-08 15:35:41 +03:00
parent c736e7a43f
commit e132cc7a15
9 changed files with 141 additions and 91 deletions

View File

@ -47,7 +47,7 @@ export async function POST(request: Request, { params }: Params) {
// Confirm the conversation is in the caller's account before writing.
const { data: conv, error: convErr } = await supabase
.from('conversations')
.select('id, assigned_agent_id')
.select('id')
.eq('id', conversationId)
.eq('account_id', accountId)
.maybeSingle()
@ -67,12 +67,20 @@ export async function POST(request: Request, { params }: Params) {
if (paused) {
if (assignToMe) update.assigned_agent_id = userId
} else {
// Resuming: give the bot a clean slate on this thread.
// Resuming hands the thread *back to the bot*. Clear the pause and
// the handoff note, and — crucially — release ANY assignment, not
// just the caller's own: the auto-reply eligibility gate stands
// down whenever a human is assigned, so leaving a stale assignee
// (e.g. the agent a prior handoff routed to) would silently keep
// the bot muted and make "Resume AI" a no-op. This is the explicit
// choice to let the bot own the thread again.
update.assigned_agent_id = null
// Give the bot a fresh reply budget on this thread. This is a
// deliberate, manual, rate-limited action (not automatable), so it
// can't be used to bypass the per-conversation cap at scale — it's
// a human choosing to re-engage the assistant.
update.ai_reply_count = 0
update.ai_handoff_summary = null
// Only release an assignment the caller holds — never yank a
// teammate's thread out from under them.
if (conv.assigned_agent_id === userId) update.assigned_agent_id = null
}
const { error: upErr } = await supabase

View File

@ -107,9 +107,14 @@ export async function POST(request: Request) {
const { text, usage } = await generateReply({ config, systemPrompt, messages })
// Record spend on the account's BYO key. Best-effort + via the
// service role (the log has no `authenticated` INSERT policy); a
// failure here must not fail the draft the agent is waiting on.
await logAiUsage(supabaseAdmin(), {
// 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',
@ -117,6 +122,9 @@ export async function POST(request: Request) {
model: config.model,
usage,
})
} catch (logErr) {
console.error('[ai/draft] usage log skipped:', logErr)
}
return NextResponse.json({ draft: text })
} catch (err) {

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { daysAgoStart, lastNDayKeys, localDayKey } from '@/lib/dashboard/date-utils'
// Rows are aggregated in-process over a bounded window. An active
// account writes a handful of rows per conversation, so 30 days sits
@ -19,11 +20,6 @@ interface UsageRow {
total_tokens: number
}
/** UTC yyyy-mm-dd — stable regardless of server timezone. */
function dayKey(d: Date): string {
return d.toISOString().slice(0, 10)
}
/**
* GET /api/ai/usage?days=30 (admin+)
*
@ -38,11 +34,22 @@ export async function GET(request: Request) {
const url = new URL(request.url)
const rawDays = Number(url.searchParams.get('days'))
const days = Number.isFinite(rawDays)
? Math.min(90, Math.max(1, Math.floor(rawDays)))
// Guard `>= 1`, not just `isFinite`: a missing/blank param is
// Number(null)/Number('') === 0, which is finite — without the lower
// bound the default would never apply and the window would collapse
// to a single day.
const days =
Number.isFinite(rawDays) && rawDays >= 1
? Math.min(90, Math.floor(rawDays))
: DEFAULT_WINDOW_DAYS
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
// Align the query cutoff to the START of the oldest local day we'll
// chart (not a rolling `now - N*24h` instant). Otherwise rows in the
// oldest partial day would be counted in the totals but fall outside
// every daily bucket, so the chart's bars wouldn't sum to the
// headline total. Local-day boundaries match every other dashboard
// chart (see lib/dashboard/date-utils).
const since = daysAgoStart(days - 1)
const { data, error } = await supabase
.from('ai_usage_log')
@ -82,10 +89,10 @@ export async function GET(request: Request) {
>()
// Zero-filled daily buckets so the chart shows quiet days as gaps,
// not missing points. Keyed by UTC date, oldest → newest.
// not missing points. Local-day keys, oldest → newest — the same
// helper every other dashboard chart uses, so day boundaries agree.
const daily = new Map<string, { date: string; tokens: number; calls: number }>()
for (let i = days - 1; i >= 0; i--) {
const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000))
for (const key of lastNDayKeys(days)) {
daily.set(key, { date: key, tokens: 0, calls: 0 })
}
@ -94,9 +101,9 @@ export async function GET(request: Request) {
completionTokens += r.completion_tokens
totalTokens += r.total_tokens
const mode = r.mode === 'draft' ? 'draft' : 'auto_reply'
byMode[mode].calls += 1
byMode[mode].tokens += r.total_tokens
// `mode` is DB-CHECK-constrained to these two values.
byMode[r.mode].calls += 1
byMode[r.mode].tokens += r.total_tokens
const mk = `${r.provider}:${r.model}`
const m =
@ -106,7 +113,7 @@ export async function GET(request: Request) {
m.tokens += r.total_tokens
modelMap.set(mk, m)
const bucket = daily.get(dayKey(new Date(r.created_at)))
const bucket = daily.get(localDayKey(r.created_at))
if (bucket) {
bucket.tokens += r.total_tokens
bucket.calls += 1

View File

@ -21,6 +21,8 @@ import {
} from '@/components/ui/select';
import { Skeleton } from '@/components/dashboard/skeleton';
import { BarChart } from '@/components/tremor/bar-chart';
import { formatCompactNumber } from '@/lib/currency';
import { format, parseISO } from 'date-fns';
interface UsageResponse {
window_days: number;
@ -46,23 +48,6 @@ interface UsageResponse {
const WINDOWS = [7, 30, 90] as const;
/** Compact token count — 1234 → "1.2k", 1_200_000 → "1.2M". */
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
/** "2026-07-08" → "Jul 8" for the chart axis. */
function shortDay(iso: string): string {
const [, m, d] = iso.split('-');
const month = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
][Number(m) - 1];
return `${month} ${Number(d)}`;
}
/**
* Token-spend dashboard for the account's BYO key. Admin-only (spend is
* billing-class), mirroring the `ai_usage_log` SELECT policy and the
@ -110,7 +95,8 @@ export function AiUsageCard() {
if (profileLoading || !canView) return null;
const chartData =
data?.daily.map((d) => ({ day: shortDay(d.date), Tokens: d.tokens })) ?? [];
data?.daily.map((d) => ({ day: format(parseISO(d.date), 'MMM d'), Tokens: d.tokens })) ??
[];
const hasSpend = (data?.totals.total_tokens ?? 0) > 0;
return (
@ -157,16 +143,16 @@ export function AiUsageCard() {
) : (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Total tokens" value={fmt(data.totals.total_tokens)} />
<Stat label="Total tokens" value={formatCompactNumber(data.totals.total_tokens)} />
<Stat label="LLM calls" value={String(data.totals.calls)} />
<Stat
label="Auto-reply"
value={fmt(data.by_mode.auto_reply.tokens)}
value={formatCompactNumber(data.by_mode.auto_reply.tokens)}
icon={Bot}
/>
<Stat
label="Drafts"
value={fmt(data.by_mode.draft.tokens)}
value={formatCompactNumber(data.by_mode.draft.tokens)}
icon={PencilLine}
/>
</div>
@ -180,7 +166,7 @@ export function AiUsageCard() {
index="day"
categories={['Tokens']}
colors={['violet']}
valueFormatter={(v) => fmt(v)}
valueFormatter={(v) => formatCompactNumber(v)}
showLegend={false}
yAxisWidth={48}
className="h-[200px]"
@ -205,7 +191,7 @@ export function AiUsageCard() {
</span>
</span>
<span className="flex-shrink-0 tabular-nums text-muted-foreground">
{fmt(m.tokens)} tok · {m.calls}{' '}
{formatCompactNumber(m.tokens)} tok · {m.calls}{' '}
{m.calls === 1 ? 'call' : 'calls'}
</span>
</li>

View File

@ -4,30 +4,41 @@ import { useState, useEffect, useCallback } from "react";
import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
// ------------------------------------------------------------
// Account AI status is the same for every conversation, so fetch it
// once per session and share it across thread switches instead of
// hitting /api/ai/config every time the agent opens a new chat. The
// promise is cached so concurrent mounts dedupe onto one request.
// Account AI status is the same for every conversation, so cache it per
// account and reuse it across thread switches instead of hitting
// /api/ai/config every time the agent opens a chat.
//
// Keyed by accountId (a multi-account user switching workspaces must not
// see the previous account's status), and only *successful* fetches are
// cached — a transient failure returns a default without poisoning the
// cache, so it retries on the next thread open rather than hiding the
// banner for the whole session.
// ------------------------------------------------------------
interface AiAccountStatus {
autoReplyOn: boolean;
}
let statusPromise: Promise<AiAccountStatus> | null = null;
const statusCache = new Map<string, AiAccountStatus>();
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.
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),
}))
.catch(() => ({ autoReplyOn: false }));
};
statusCache.set(accountId, status);
return status;
} catch {
return { autoReplyOn: false }; // don't cache
}
return statusPromise;
}
interface AiThreadBannerProps {
@ -66,6 +77,7 @@ export function AiThreadBanner({
currentUserId,
onChange,
}: AiThreadBannerProps) {
const { accountId } = useAuth();
const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
// Optimistic local mirror of the pause flag so the banner flips
@ -75,12 +87,13 @@ export function AiThreadBanner({
useEffect(() => setPaused(disabled), [conversationId, disabled]);
useEffect(() => {
if (!accountId) return;
let alive = true;
fetchAiAccountStatus().then((s) => alive && setAutoReplyOn(s.autoReplyOn));
fetchAiAccountStatus(accountId).then((s) => alive && setAutoReplyOn(s.autoReplyOn));
return () => {
alive = false;
};
}, []);
}, [accountId]);
const toggle = useCallback(
async (paused: boolean) => {

View File

@ -29,6 +29,7 @@ import { AiKnowledgeCard } from './ai-knowledge';
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
import type { AiProvider } from '@/lib/ai/types';
import type { AccountMember } from '@/types';
import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
const MASKED_KEY = '••••••••••••••••';
@ -118,16 +119,7 @@ export function AiConfig() {
// Members populate the handoff-target picker. Best-effort — on an
// older deployment without the endpoint the picker just shows the
// queue option.
void (async () => {
try {
const res = await fetch('/api/account/members', { cache: 'no-store' });
if (!res.ok) return;
const json = (await res.json()) as { members?: AccountMember[] };
setMembers(json.members ?? []);
} catch {
// ignore — picker falls back to "unassigned queue" only.
}
})();
void fetchAccountMembers().then(setMembers);
}, [accountId, fetchConfig]);
// Swap the model default when the provider changes, unless the user
@ -491,7 +483,7 @@ export function AiConfig() {
</SelectItem>
{members.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id}
{memberLabel(m)}
</SelectItem>
))}
</SelectContent>

View 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;
}

View File

@ -118,10 +118,12 @@ export async function dispatchInboundToAiReply(
messages,
})
// Record token spend on the account's BYO key (best-effort — never
// blocks or fails the reply). Logged regardless of handoff: the
// provider call happened either way.
await logAiUsage(db, {
// Record token spend on the account's BYO key. Fire-and-forget so it
// never adds latency to the customer-facing send: `logAiUsage`
// swallows its own errors, so the floating promise can't reject.
// Logged regardless of handoff — the provider call happened either
// way.
void logAiUsage(db, {
accountId,
conversationId,
mode: 'auto_reply',

View File

@ -90,8 +90,17 @@ export function formatCurrencyShort(
): string {
const code = currency || DEFAULT_CURRENCY;
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
const v = Number(value || 0);
if (v >= 1_000_000) return `${symbol}${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${symbol}${(v / 1_000).toFixed(1)}k`;
return `${symbol}${v.toFixed(0)}`;
return `${symbol}${formatCompactNumber(value)}`;
}
/**
* Compact number for tight spaces (chart tiles, legends): 1_234 → "1.2k",
* 1_200_000 → "1.2M", 900 → "900". The unit-less core shared with
* {@link formatCurrencyShort}.
*/
export function formatCompactNumber(value: number): string {
const v = Number(value || 0);
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
return v.toFixed(0);
}