diff --git a/CHANGELOG.md b/CHANGELOG.md index b46f4a4..5dbf650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ 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.7.0] — 2026-07-02 + +Promotes the AI assistant to a first-class **AI Agents** section in the +sidebar — it's no longer tucked inside Settings. + +### Added + +- **AI Agents (sidebar).** A dedicated `/agents` area with two tabs: + - **Playground** — a test chat to message your agent and see its + grounded, multi-turn replies (and where it would hand off to a human) + *before* it ever answers a real customer. Runs the exact same path as + the auto-reply bot (knowledge-base retrieval + your provider), and + works even before you flip the master switch on, so you can try, then + enable. Backed by `POST /api/ai/playground`. + - **Setup** — the provider/key, business context, knowledge base, and + auto-reply controls (moved here from Settings → AI Assistant). + +### Changed + +- The AI configuration moved out of **Settings → AI Assistant** into the + new **AI Agents** section. No data change — same account config, new + home. No migration required. + ## [0.6.0] — 2026-07-02 Adds an **AI knowledge base** so the assistant (0.5.0) can answer from diff --git a/package.json b/package.json index f45bd67..e43b5c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wacrm", - "version": "0.6.0", + "version": "0.7.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)/agents/page.tsx b/src/app/(dashboard)/agents/page.tsx new file mode 100644 index 0000000..151ec4d --- /dev/null +++ b/src/app/(dashboard)/agents/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Bot, Sparkles, Settings2 } from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { AiPlayground } from '@/components/agents/ai-playground'; +import { AiConfig } from '@/components/settings/ai-config'; + +type Tab = 'playground' | 'setup'; + +export default function AgentsPage() { + const [tab, setTab] = useState('playground'); + const [decided, setDecided] = useState(false); + + // Land first-time users on Setup, returning users on the Playground. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/ai/config'); + const data = await res.json().catch(() => ({})); + if (!cancelled) setTab(data?.configured ? 'playground' : 'setup'); + } catch { + if (!cancelled) setTab('setup'); + } finally { + if (!cancelled) setDecided(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+ +

+ AI Agents +

+
+

+ Your bring-your-own-key AI agent — set it up, then test it in the + playground before it replies to customers in the inbox. +

+ + {decided && ( + setTab(v as Tab)} + className="mt-6" + > + + + Playground + + + Setup + + + + + setTab('setup')} /> + + + + + + + )} +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index db2f978..9f6971d 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -16,7 +16,6 @@ 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, @@ -61,7 +60,6 @@ export default function SettingsPage() { fields: , deals: , members: , - ai: , api: , }; diff --git a/src/app/api/ai/playground/route.ts b/src/app/api/ai/playground/route.ts new file mode 100644 index 0000000..12ff0db --- /dev/null +++ b/src/app/api/ai/playground/route.ts @@ -0,0 +1,98 @@ +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 { retrieveKnowledge } from '@/lib/ai/knowledge' +import { generateReply } from '@/lib/ai/generate' +import { buildSystemPrompt } from '@/lib/ai/defaults' +import { latestUserMessage } from '@/lib/ai/query' +import { AiError, type ChatMessage } from '@/lib/ai/types' + +// Keep the tested transcript bounded, mirroring the live context window. +const MAX_TURNS = 20 + +/** + * POST /api/ai/playground (agent+) + * + * Test-chat with the account's agent WITHOUT touching WhatsApp. Runs the + * exact same path the auto-reply bot uses — knowledge-base retrieval + + * `auto_reply` system prompt + the configured provider — so what you see + * here is what a real customer would get. Reads the config even when the + * master switch is off (requireActive:false) so you can try it before + * going live. Stateless: the client sends the running transcript each turn. + */ +export async function POST(request: Request) { + try { + const { supabase, accountId, userId } = await requireRole('agent') + + const limit = checkRateLimit(`ai-playground:${userId}`, RATE_LIMITS.aiDraft) + if (!limit.success) return rateLimitResponse(limit) + + const body = await request.json().catch(() => null) + const rawMessages = Array.isArray(body?.messages) ? body.messages : null + if (!rawMessages) { + return NextResponse.json({ error: 'messages is required' }, { status: 400 }) + } + + const messages: ChatMessage[] = rawMessages + .filter( + (m: unknown): m is ChatMessage => + !!m && + typeof m === 'object' && + ((m as ChatMessage).role === 'user' || + (m as ChatMessage).role === 'assistant') && + typeof (m as ChatMessage).content === 'string' && + (m as ChatMessage).content.trim().length > 0, + ) + .slice(-MAX_TURNS) + + if (messages.length === 0) { + return NextResponse.json( + { error: 'Send a message to test the agent.' }, + { status: 400 }, + ) + } + + const config = await loadAiConfig(supabase, accountId, { + requireActive: false, + }).catch((err) => { + console.error('[ai/playground] 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: 'No agent configured yet. Add your provider key in Setup.', + code: 'ai_not_configured', + }, + { status: 400 }, + ) + } + + const knowledge = await retrieveKnowledge( + supabase, + accountId, + config, + latestUserMessage(messages), + ) + const systemPrompt = buildSystemPrompt({ + userPrompt: config.systemPrompt, + mode: 'auto_reply', + knowledge, + }) + + const { text, handoff } = await generateReply({ config, systemPrompt, messages }) + return NextResponse.json({ reply: text, handoff }) + } 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/components/agents/ai-playground.tsx b/src/components/agents/ai-playground.tsx new file mode 100644 index 0000000..6033cc2 --- /dev/null +++ b/src/components/agents/ai-playground.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { Bot, RotateCcw, Send, Loader2, UserCircle2, ArrowRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface Turn { + role: 'user' | 'assistant'; + content: string; + /** assistant-only: the agent signalled a human handoff on this turn. */ + handoff?: boolean; +} + +export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) { + const [turns, setTurns] = useState([]); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [turns, sending]); + + const send = async () => { + const text = input.trim(); + if (!text || sending) return; + + const next: Turn[] = [...turns, { role: 'user', content: text }]; + setTurns(next); + setInput(''); + setSending(true); + try { + const res = await fetch('/api/ai/playground', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // Send only role+content — the server ignores anything else. + body: JSON.stringify({ + messages: next.map((t) => ({ role: t.role, content: t.content })), + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (data.code === 'ai_not_configured') { + toast.error('No agent configured yet — finish Setup first.'); + } else { + toast.error(data.error ?? "Couldn't get a reply."); + } + // Roll the unsent user turn back so the transcript stays clean. + setTurns(turns); + setInput(text); + return; + } + setTurns([ + ...next, + { + role: 'assistant', + content: + typeof data.reply === 'string' && data.reply.trim() + ? data.reply + : '', + handoff: Boolean(data.handoff), + }, + ]); + } catch { + toast.error("Couldn't reach the agent."); + setTurns(turns); + setInput(text); + } finally { + setSending(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void send(); + } + }; + + return ( +
+ {/* Header */} +
+
+ + Playground + + — test replies as if you were a customer + +
+ +
+ + {/* Transcript */} +
+ {turns.length === 0 && ( +
+ +

Send a message to see how your agent would reply.

+

+ It uses your knowledge base and behaves exactly like the + auto-reply bot — including handoff. +

+ {onGoToSetup && ( + + )} +
+ )} + + {turns.map((t, i) => ( +
+ {t.role === 'assistant' && ( + + )} +
+ {t.content &&

{t.content}

} + {t.role === 'assistant' && t.handoff && ( +

+ + Would hand off to a human here +

+ )} +
+ {t.role === 'user' && ( + + )} +
+ ))} + + {sending && ( +
+ + Thinking… +
+ )} +
+ + {/* Composer */} +
+