mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
115 lines
4.1 KiB
TypeScript
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 }
|
|
}
|
|
}
|