diff --git a/.env.local.example b/.env.local.example index 27e1e11..9374919 100644 --- a/.env.local.example +++ b/.env.local.example @@ -91,3 +91,19 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com # Set this in CI and local development so you can exercise the full # template UI without a real WABA. Leave unset (or "false") in prod. # WHATSAPP_TEMPLATES_DRY_RUN=true + +# ------------------------------------------------------------------ +# AI reply assistant (optional) +# ------------------------------------------------------------------ +# The AI assistant is bring-your-own-key: each account pastes its own +# OpenAI or Anthropic key under Settings → AI Assistant. The key is +# stored AES-256-GCM-encrypted with ENCRYPTION_KEY (above) — there is +# NO global provider key env var, and nothing here is required for the +# feature to work. The two vars below only tune behaviour. + +# Per-call timeout for provider requests, in milliseconds. Default 30000. +# AI_REQUEST_TIMEOUT_MS=30000 + +# How many recent text messages of a conversation to send the model as +# context (draft + auto-reply). Default 20. +# AI_CONTEXT_MESSAGE_LIMIT=20 diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc0550..09c31bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,42 @@ 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 and polish. +## [0.5.0] — 2026-07-02 + +Adds the **AI reply assistant** — bring-your-own-key. Each account +pastes its own OpenAI or Anthropic key under **Settings → AI +Assistant**; wacrm calls the provider directly with that key, so +there's no per-seat AI fee and your conversation data never leaves +your own infrastructure for a wacrm-run service. The key is stored +AES-256-GCM-encrypted at rest (same as WhatsApp tokens) and never +returned to the client after saving. + +### Added + +- **AI-drafted replies in the inbox.** A ✨ button in the composer + (agent+) reads the recent conversation and drops a suggested reply + into the box for the agent to edit and send. Read-only server-side — + `POST /api/ai/draft` never sends or stores anything. Respects your + business context / persona from the settings prompt. +- **AI auto-reply bot.** When enabled, inbound messages that no + deterministic Flow consumed and that have no agent assigned get an + automatic LLM reply. Bounded by a per-conversation cap + (`auto_reply_max_per_conversation`, default 3) and a clean human + handoff: when the model can't confidently help — or the customer + asks for a person — it stays silent and leaves the message for a + human, and won't auto-reply on that thread again until re-enabled. + Flows always win over the bot. +- **Settings → AI Assistant** (admin+ to edit): pick provider + model, + paste your key, add business context/tone, toggle the assistant and + auto-reply, set the per-conversation cap, and **Test key** against + the provider before saving. +- Providers: OpenAI (Chat Completions) and Anthropic (Messages) behind + one interface; model is a free-text field with sensible defaults, so + you can point it at any current model your key can access. + **Migration required:** apply + `supabase/migrations/029_ai_reply.sql` (adds `ai_configs` + + per-conversation auto-reply columns on `conversations`). + ## [0.4.0] — 2026-07-01 Completes the public API (#245): **outbound event webhooks** so diff --git a/README.md b/README.md index 0696fcd..d20376a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ clone or fork it to run your own CRM. - **No-code automations** — triggers on inbound messages, new contacts, keywords, or schedule; conditional branches, waits, tags, webhooks. Visual builder. +- **AI reply assistant** — bring your own OpenAI or Anthropic key + (stored encrypted; no per-seat AI fee, your data stays yours). + One-click AI-drafted replies in the inbox, plus an optional + auto-reply bot with a per-conversation cap and clean human handoff. - **Real-time dashboard** — response times, daily volume, pipeline value, cross-module activity feed. - **Team accounts** — invite teammates by link, role-based access diff --git a/package.json b/package.json index 4e4f539..a45cb23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wacrm", - "version": "0.4.0", + "version": "0.5.0", "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.", "license": "MIT", diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 9f6971d..db2f978 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -16,6 +16,7 @@ import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel' import { DealsSettings } from '@/components/settings/deals-settings'; import { MembersTab } from '@/components/settings/members-tab'; import { ApiKeysSettings } from '@/components/settings/api-keys-settings'; +import { AiConfig } from '@/components/settings/ai-config'; import { resolveSection, type SettingsSection, @@ -60,6 +61,7 @@ export default function SettingsPage() { fields: , deals: , members: , + ai: , api: , }; diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts new file mode 100644 index 0000000..9fa0716 --- /dev/null +++ b/src/app/api/ai/config/route.ts @@ -0,0 +1,213 @@ +import { NextResponse } from 'next/server' +import { + getCurrentAccount, + requireRole, + toErrorResponse, +} from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' +import { encrypt, decrypt } from '@/lib/whatsapp/encryption' +import { validateAiCredentials } from '@/lib/ai/validate' +import { AiError, type AiProvider } from '@/lib/ai/types' + +function bad(message: string) { + return NextResponse.json({ error: message }, { status: 400 }) +} + +/** + * GET /api/ai/config + * + * Any member may read the config so the inbox/settings can reflect + * whether AI is set up. The encrypted key is NEVER returned — only a + * `has_key` flag; the settings form shows a masked placeholder. + */ +export async function GET() { + try { + const { supabase, accountId } = await getCurrentAccount() + + const { data, error } = await supabase + .from('ai_configs') + // `api_key` is selected only to derive `has_key` — it is stripped + // out below and never returned to the client. + .select( + 'provider, model, system_prompt, is_active, auto_reply_enabled, auto_reply_max_per_conversation, api_key', + ) + .eq('account_id', accountId) + .maybeSingle() + + if (error) { + console.error('[ai/config GET] fetch error:', error) + return NextResponse.json( + { error: 'Failed to load AI configuration' }, + { status: 500 }, + ) + } + + if (!data) return NextResponse.json({ configured: false }) + const { api_key, ...safe } = data + return NextResponse.json({ configured: true, has_key: !!api_key, ...safe }) + } catch (err) { + return toErrorResponse(err) + } +} + +/** + * POST /api/ai/config (admin+) + * + * Upsert the account's AI config. Validates the key with the provider + * before persisting (mirrors the WhatsApp config verifying with Meta + * first), then stores the key AES-256-GCM-encrypted. When `api_key` is + * omitted the existing stored key is reused (the form sends it only + * when the user re-enters it). + */ +export async function POST(request: Request) { + try { + const { supabase, accountId, userId } = await requireRole('admin') + + const limit = checkRateLimit(`ai-config:${userId}`, RATE_LIMITS.adminAction) + if (!limit.success) return rateLimitResponse(limit) + + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') return bad('Invalid request body') + + const provider = body.provider as AiProvider + if (provider !== 'openai' && provider !== 'anthropic') { + return bad('provider must be "openai" or "anthropic"') + } + const model = typeof body.model === 'string' ? body.model.trim() : '' + if (!model) return bad('model is required') + + const systemPrompt = + typeof body.system_prompt === 'string' && body.system_prompt.trim() + ? body.system_prompt.trim() + : null + const isActive = body.is_active === true + const autoReplyEnabled = body.auto_reply_enabled === true + + let maxPer = Number(body.auto_reply_max_per_conversation) + if (!Number.isFinite(maxPer)) maxPer = 3 + maxPer = Math.min(20, Math.max(1, Math.floor(maxPer))) + + const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : '' + + // Reuse the stored key when the form didn't send a fresh one. + const { data: existing } = await supabase + .from('ai_configs') + .select('id, provider, model, api_key') + .eq('account_id', accountId) + .maybeSingle() + + let apiKeyPlain: string + if (rawKey) { + apiKeyPlain = rawKey + } else if (existing?.api_key) { + try { + apiKeyPlain = decrypt(existing.api_key) + } catch { + return bad('Stored API key could not be decrypted — re-enter your key.') + } + } else { + return bad('api_key is required') + } + + // Only spend a provider round-trip when the credentials that affect + // reachability actually changed. A save that just flips a toggle or + // edits the system prompt on an existing, already-validated config + // skips the call — no wasted token/latency on the account's key. + const credentialsChanged = + !existing || + rawKey !== '' || + provider !== existing.provider || + model !== existing.model + + if (credentialsChanged) { + try { + await validateAiCredentials({ + provider, + model, + apiKey: apiKeyPlain, + systemPrompt, + isActive, + autoReplyEnabled, + autoReplyMaxPerConversation: maxPer, + }) + } catch (err) { + if (err instanceof AiError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 400 }, + ) + } + console.error('[ai/config POST] validation error:', err) + return bad('Could not validate the API key with the provider.') + } + } + + const encryptedKey = rawKey ? encrypt(rawKey) : null + const shared = { + provider, + model, + system_prompt: systemPrompt, + is_active: isActive, + auto_reply_enabled: autoReplyEnabled, + auto_reply_max_per_conversation: maxPer, + } + + if (existing) { + const { error: upErr } = await supabase + .from('ai_configs') + .update(encryptedKey ? { ...shared, api_key: encryptedKey } : shared) + .eq('account_id', accountId) + if (upErr) { + console.error('[ai/config POST] update error:', upErr) + return NextResponse.json( + { error: 'Failed to save AI configuration' }, + { status: 500 }, + ) + } + } else { + const { error: insErr } = await supabase.from('ai_configs').insert({ + account_id: accountId, + created_by: userId, + api_key: encryptedKey, // guaranteed non-null: rawKey required when no existing row + ...shared, + }) + if (insErr) { + console.error('[ai/config POST] insert error:', insErr) + return NextResponse.json( + { error: 'Failed to save AI configuration' }, + { status: 500 }, + ) + } + } + + return NextResponse.json({ success: true }) + } catch (err) { + return toErrorResponse(err) + } +} + +/** + * DELETE /api/ai/config (admin+) + * + * Removes the account's AI config (turns everything off and forgets the + * key). Also used to recover from a corrupted encrypted key. + */ +export async function DELETE() { + try { + const { supabase, accountId } = await requireRole('admin') + const { error } = await supabase + .from('ai_configs') + .delete() + .eq('account_id', accountId) + if (error) { + console.error('[ai/config DELETE] error:', error) + return NextResponse.json( + { error: 'Failed to delete AI configuration' }, + { status: 500 }, + ) + } + return NextResponse.json({ success: true }) + } catch (err) { + return toErrorResponse(err) + } +} diff --git a/src/app/api/ai/draft/route.ts b/src/app/api/ai/draft/route.ts new file mode 100644 index 0000000..23b303c --- /dev/null +++ b/src/app/api/ai/draft/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server' +import { requireRole, toErrorResponse } from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' +import { loadAiConfig } from '@/lib/ai/config' +import { buildConversationContext } from '@/lib/ai/context' +import { generateReply } from '@/lib/ai/generate' +import { buildSystemPrompt } from '@/lib/ai/defaults' +import { AiError } from '@/lib/ai/types' + +/** + * POST /api/ai/draft (agent+) + * + * Body: { conversation_id } + * Returns: { draft } — a suggested reply for the agent to edit + send. + * + * Uses the account's configured provider/key (BYO). Read-only: it never + * sends or stores anything, just hands text back to the composer. + */ +export async function POST(request: Request) { + try { + const { supabase, accountId, userId } = await requireRole('agent') + + const userLimit = checkRateLimit(`ai-draft:${userId}`, RATE_LIMITS.aiDraft) + if (!userLimit.success) return rateLimitResponse(userLimit) + // Also cap the whole team's draws on the shared BYO provider key. + const accountLimit = checkRateLimit( + `ai-draft-acct:${accountId}`, + RATE_LIMITS.aiDraftAccount, + ) + if (!accountLimit.success) return rateLimitResponse(accountLimit) + + const body = await request.json().catch(() => null) + const conversationId = + body && typeof body.conversation_id === 'string' ? body.conversation_id : '' + if (!conversationId) { + return NextResponse.json( + { error: 'conversation_id is required' }, + { status: 400 }, + ) + } + + // RLS scopes the SSR client to the caller's account, so a missing + // row means "not yours / not found" either way. + const { data: conversation, error: convErr } = await supabase + .from('conversations') + .select('id') + .eq('id', conversationId) + .maybeSingle() + if (convErr) { + console.error('[ai/draft] conversation lookup error:', convErr) + return NextResponse.json({ error: 'Failed to load conversation' }, { status: 500 }) + } + if (!conversation) { + return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) + } + + const config = await loadAiConfig(supabase, accountId).catch((err) => { + // Decrypt failure — surface distinctly from "not configured". + console.error('[ai/draft] loadAiConfig error:', err) + throw new AiError('Stored API key could not be decrypted.', { + code: 'key_decrypt_failed', + status: 400, + }) + }) + if (!config) { + return NextResponse.json( + { + error: 'AI assistant is not set up. Enable it in Settings → AI Assistant.', + code: 'ai_not_configured', + }, + { status: 400 }, + ) + } + + const messages = await buildConversationContext(supabase, conversationId) + // Nothing to draft from — a brand-new thread with no customer text + // would otherwise produce a nonsensical reply-to-nothing. + if (messages.length === 0) { + return NextResponse.json( + { + error: 'No messages to draft from yet.', + code: 'no_messages', + }, + { status: 400 }, + ) + } + + const systemPrompt = buildSystemPrompt({ + userPrompt: config.systemPrompt, + mode: 'draft', + }) + + const { text } = await generateReply({ config, systemPrompt, messages }) + return NextResponse.json({ draft: text }) + } catch (err) { + if (err instanceof AiError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: err.status }, + ) + } + return toErrorResponse(err) + } +} diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts new file mode 100644 index 0000000..2568dcd --- /dev/null +++ b/src/app/api/ai/test/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server' +import { requireRole, toErrorResponse } from '@/lib/auth/account' +import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from '@/lib/rate-limit' +import { decrypt } from '@/lib/whatsapp/encryption' +import { validateAiCredentials } from '@/lib/ai/validate' +import { AiError, type AiProvider } from '@/lib/ai/types' + +/** + * POST /api/ai/test (admin+) + * + * "Test key" button: validate a candidate provider/model/key against + * the provider WITHOUT saving. When `api_key` is omitted the stored + * key is used, so an admin can re-test an existing config (e.g. after + * changing the model). Returns `{ ok: true }` on success, 400 with the + * provider's message on failure. + */ +export async function POST(request: Request) { + try { + const { supabase, accountId, userId } = await requireRole('admin') + + const limit = checkRateLimit(`ai-test:${userId}`, RATE_LIMITS.adminAction) + if (!limit.success) return rateLimitResponse(limit) + + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const provider = body.provider as AiProvider + if (provider !== 'openai' && provider !== 'anthropic') { + return NextResponse.json( + { error: 'provider must be "openai" or "anthropic"' }, + { status: 400 }, + ) + } + const model = typeof body.model === 'string' ? body.model.trim() : '' + if (!model) { + return NextResponse.json({ error: 'model is required' }, { status: 400 }) + } + + const rawKey = typeof body.api_key === 'string' ? body.api_key.trim() : '' + let apiKeyPlain = rawKey + if (!apiKeyPlain) { + const { data: existing } = await supabase + .from('ai_configs') + .select('api_key') + .eq('account_id', accountId) + .maybeSingle() + if (!existing?.api_key) { + return NextResponse.json( + { error: 'Enter an API key to test.' }, + { status: 400 }, + ) + } + try { + apiKeyPlain = decrypt(existing.api_key) + } catch { + return NextResponse.json( + { error: 'Stored API key could not be decrypted — re-enter your key.' }, + { status: 400 }, + ) + } + } + + try { + await validateAiCredentials({ + provider, + model, + apiKey: apiKeyPlain, + systemPrompt: null, + isActive: true, + autoReplyEnabled: false, + autoReplyMaxPerConversation: 3, + }) + } catch (err) { + if (err instanceof AiError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 400 }, + ) + } + console.error('[ai/test] validation error:', err) + return NextResponse.json( + { error: 'Could not validate the API key.' }, + { status: 400 }, + ) + } + + return NextResponse.json({ ok: true }) + } catch (err) { + return toErrorResponse(err) + } +} diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index a1f1504..3e6de50 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -7,6 +7,7 @@ import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' +import { dispatchInboundToAiReply } from '@/lib/ai/auto-reply' import { dispatchWebhookEvent } from '@/lib/webhooks/deliver' import { handleTemplateWebhookChange, @@ -784,6 +785,20 @@ async function processMessage( }).catch((err) => console.error('[automations] dispatch failed:', err)) } + // AI auto-reply. Runs only for plain-text inbound the deterministic + // flow runner did NOT consume (flows win over the LLM), and only when + // the account has enabled it. Awaited inside `after()` (same reason as + // the webhook dispatch below); `dispatchInboundToAiReply` owns its + // eligibility gates + try/catch and never throws. + if (!flowConsumed && !interactiveReplyId && inboundText.trim()) { + await dispatchInboundToAiReply({ + accountId, + conversationId: conversation.id, + contactId: contactRecord.id, + configOwnerUserId, + }) + } + // message.received webhook (public API). Awaited — not fire-and-forget // — because we're inside the route's `after()` block, which only keeps // the function alive for promises it can see; a detached promise could diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index 225b258..a72808c 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -18,6 +18,7 @@ import { Square, X, Loader2, + Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { GatedButton } from "@/components/ui/gated-button"; @@ -122,6 +123,7 @@ export function MessageComposer({ }: MessageComposerProps) { const [text, setText] = useState(""); const [sending, setSending] = useState(false); + const [drafting, setDrafting] = useState(false); const textareaRef = useRef(null); // Media attachment state. `draft` holds an uploaded-but-not-yet-sent @@ -223,6 +225,50 @@ export function MessageComposer({ [adjustHeight] ); + // Ask the AI assistant for a suggested reply and drop it into the + // composer for the agent to edit + send. Read-only server-side — + // nothing is sent until the agent hits Send. + const handleDraft = useCallback(async () => { + if (drafting) return; + setDrafting(true); + try { + const res = await fetch("/api/ai/draft", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversation_id: conversationId }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (data.code === "ai_not_configured") { + toast.error("AI isn't set up yet — enable it in Settings → AI Assistant."); + } else { + toast.error(data.error ?? "Couldn't draft a reply."); + } + return; + } + const draftText = typeof data.draft === "string" ? data.draft.trim() : ""; + if (!draftText) { + toast.error("The assistant didn't return a reply."); + return; + } + setText(draftText); + // Let the textarea grow to fit and drop the cursor at the end so + // the agent can tweak immediately. + requestAnimationFrame(() => { + adjustHeight(); + const el = textareaRef.current; + if (el) { + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + } + }); + } catch { + toast.error("Couldn't reach the AI assistant."); + } finally { + setDrafting(false); + } + }, [drafting, conversationId, adjustHeight]); + // Upload a captured file to chat-media and stage it as a draft. const stageUpload = useCallback( async (kind: ComposerMediaKind, file: File) => { @@ -523,6 +569,23 @@ export function MessageComposer({ + + {drafting ? ( + + ) : ( + + )} + +