wacrm/src/lib/ai/config.ts
Arnas Donauskas b0194dfc27 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>
2026-07-08 14:03:33 +03:00

115 lines
4.1 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { decrypt } from '@/lib/whatsapp/encryption'
import type { AiConfig } from './types'
interface AiConfigRow {
provider: 'openai' | 'anthropic'
model: string
api_key: string
system_prompt: string | null
is_active: boolean
auto_reply_enabled: boolean
auto_reply_max_per_conversation: number
handoff_agent_id: string | null
embeddings_api_key: string | null
}
const CONFIG_COLUMNS =
'provider, model, api_key, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, handoff_agent_id, embeddings_api_key'
/**
* Load and decrypt the account's AI config for *use* (draft or
* auto-reply). Returns `null` when there's no row or the master switch
* (`is_active`) is off — both mean "AI is not available", which callers
* treat identically. Throws only if the stored key can't be decrypted
* (mismatched `ENCRYPTION_KEY`), so that distinct failure surfaces
* rather than looking like "not configured".
*
* Works with any client: pass the RLS-scoped SSR client from a
* dashboard route, or the service-role admin client from the webhook.
*/
export async function loadAiConfig(
db: SupabaseClient,
accountId: string,
opts: { requireActive?: boolean } = {},
): Promise<AiConfig | null> {
const { requireActive = true } = opts
const { data, error } = await db
.from('ai_configs')
.select(CONFIG_COLUMNS)
.eq('account_id', accountId)
.maybeSingle()
if (error) throw error
if (!data) return null
const row = data as AiConfigRow
// The Playground passes requireActive:false so an admin can test the
// agent before flipping the master switch on.
if (requireActive && !row.is_active) return null
// Defensive: the column is NOT NULL, but a partial write / manual DB
// edit could leave it empty. Treat a missing key as "not configured"
// rather than letting decrypt() throw on null.
if (!row.api_key) return null
// The embeddings key is optional and independent of the chat key —
// a corrupt/undecryptable one should downgrade to lexical KB, not
// take down draft/auto-reply, so decrypt failures are swallowed here.
let embeddingsApiKey: string | null = null
if (row.embeddings_api_key) {
try {
embeddingsApiKey = decrypt(row.embeddings_api_key)
} catch {
// Not silent — a rotated/mismatched ENCRYPTION_KEY here means
// semantic search quietly stops working, so leave a breadcrumb.
console.error(
`[ai config] embeddings key for account ${accountId} could not be decrypted — check ENCRYPTION_KEY; semantic search is disabled until it is re-entered.`,
)
embeddingsApiKey = null
}
}
return {
provider: row.provider,
model: row.model,
apiKey: decrypt(row.api_key),
systemPrompt: row.system_prompt,
isActive: row.is_active,
autoReplyEnabled: row.auto_reply_enabled,
autoReplyMaxPerConversation: row.auto_reply_max_per_conversation,
handoffAgentId: row.handoff_agent_id,
embeddingsApiKey,
}
}
/**
* Load + decrypt just the embeddings key, independent of `is_active`.
* Used by the knowledge-base ingest routes so the KB gets embedded (and
* semantic search works) whenever an embeddings key is present, even if
* the assistant's master switch is currently off.
*
* Returns `{ key, corrupt }`: `key` is null when there's no key OR it
* can't be decrypted; `corrupt` distinguishes those cases so callers can
* warn ("a key is set but unusable") rather than silently indexing
* lexical-only and reporting success.
*/
export async function loadEmbeddingsKey(
db: SupabaseClient,
accountId: string,
): Promise<{ key: string | null; corrupt: boolean }> {
const { data, error } = await db
.from('ai_configs')
.select('embeddings_api_key')
.eq('account_id', accountId)
.maybeSingle()
if (error || !data?.embeddings_api_key) return { key: null, corrupt: false }
try {
return { key: decrypt(data.embeddings_api_key), corrupt: false }
} catch {
console.error(
`[ai config] embeddings key for account ${accountId} could not be decrypted — check ENCRYPTION_KEY.`,
)
return { key: null, corrupt: true }
}
}