feat: AI Agents sidebar section (setup + playground)

Promotes the AI assistant out of Settings into a first-class "AI Agents"
sidebar area.

- Sidebar: new "AI Agents" item → /agents.
- /agents page with two tabs:
  - Playground — a test chat (POST /api/ai/playground) that runs the
    real auto-reply path (KB retrieval + provider, auto_reply prompt,
    multi-turn, shows handoff). loadAiConfig gains requireActive so you
    can test before enabling.
  - Setup — the existing provider/key + knowledge-base config, relocated.
- Removed the AI section from Settings (settings-sections + page); same
  account config, new home. No migration.

Tests +6 (query, loadAiConfig requireActive). 593 pass; typecheck, lint,
build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-07-02 18:20:18 +03:00
parent 5dbc786fdc
commit 10d31bcfc8
12 changed files with 477 additions and 9 deletions

View File

@ -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

View File

@ -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",

View File

@ -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<Tab>('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 (
<div>
<div className="flex items-center gap-2">
<Bot className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight text-foreground">
AI Agents
</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Your bring-your-own-key AI agent — set it up, then test it in the
playground before it replies to customers in the inbox.
</p>
{decided && (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as Tab)}
className="mt-6"
>
<TabsList>
<TabsTrigger value="playground">
<Sparkles className="mr-1.5 h-4 w-4" /> Playground
</TabsTrigger>
<TabsTrigger value="setup">
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
</TabsTrigger>
</TabsList>
<TabsContent value="playground" className="mt-4">
<AiPlayground onGoToSetup={() => setTab('setup')} />
</TabsContent>
<TabsContent value="setup" className="mt-4">
<AiConfig />
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@ -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: <FieldsAndTagsPanel />,
deals: <DealsSettings />,
members: <MembersTab />,
ai: <AiConfig />,
api: <ApiKeysSettings />,
};

View File

@ -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)
}
}

View File

@ -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<Turn[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
};
return (
<div className="flex h-[60vh] min-h-[420px] flex-col rounded-xl border border-border bg-card">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-foreground">Playground</span>
<span className="text-xs text-muted-foreground">
— test replies as if you were a customer
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setTurns([])}
disabled={turns.length === 0 || sending}
className="text-muted-foreground"
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> Reset
</Button>
</div>
{/* Transcript */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.length === 0 && (
<div className="flex h-full flex-col items-center justify-center text-center text-sm text-muted-foreground">
<Bot className="mb-2 h-8 w-8 text-muted-foreground/60" />
<p>Send a message to see how your agent would reply.</p>
<p className="mt-1 text-xs">
It uses your knowledge base and behaves exactly like the
auto-reply bot — including handoff.
</p>
{onGoToSetup && (
<Button
variant="link"
size="sm"
onClick={onGoToSetup}
className="mt-1 h-auto p-0 text-xs"
>
Not set up yet? Go to Setup <ArrowRight className="ml-1 h-3 w-3" />
</Button>
)}
</div>
)}
{turns.map((t, i) => (
<div
key={i}
className={cn(
'flex gap-2',
t.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
{t.role === 'assistant' && (
<Bot className="mt-1 h-5 w-5 shrink-0 text-primary" />
)}
<div
className={cn(
'max-w-[80%] rounded-2xl px-3.5 py-2 text-sm',
t.role === 'user'
? 'rounded-br-sm bg-primary text-primary-foreground'
: 'rounded-bl-sm bg-muted text-foreground',
)}
>
{t.content && <p className="whitespace-pre-wrap">{t.content}</p>}
{t.role === 'assistant' && t.handoff && (
<p
className={cn(
'flex items-center gap-1 text-xs text-amber-500',
t.content && 'mt-1.5 border-t border-border/50 pt-1.5',
)}
>
<UserCircle2 className="h-3.5 w-3.5" />
Would hand off to a human here
</p>
)}
</div>
{t.role === 'user' && (
<UserCircle2 className="mt-1 h-5 w-5 shrink-0 text-muted-foreground" />
)}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bot className="h-5 w-5 text-primary" />
<Loader2 className="h-4 w-4 animate-spin" /> Thinking…
</div>
)}
</div>
{/* Composer */}
<div className="flex items-end gap-2 border-t border-border p-3">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a customer message…"
rows={1}
className="flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
/>
<Button
size="sm"
onClick={send}
disabled={!input.trim() || sending}
className="h-9 w-9 shrink-0 p-0"
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@ -9,6 +9,7 @@ import { useTotalUnread } from "@/hooks/use-total-unread";
import { useUnreadNotifications } from "@/hooks/use-unread-notifications";
import {
Bell,
Bot,
Crown,
GitBranch,
LayoutDashboard,
@ -97,6 +98,7 @@ const navItems: NavItem[] = [
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap },
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
{ href: "/agents", label: "AI Agents", icon: Bot },
];
const bottomNavItems = [

View File

@ -225,8 +225,8 @@ export function AiConfig() {
return (
<div>
<SettingsPanelHead
title="AI Assistant"
description="Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. Powers AI-drafted replies in the inbox and an optional auto-reply bot."
title="Agent setup"
description="Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground."
/>
{!canEdit && (

View File

@ -6,7 +6,6 @@ import {
Palette,
PlugZap,
Shield,
Sparkles,
Tags,
User,
UsersRound,
@ -31,7 +30,6 @@ export const SETTINGS_SECTIONS = [
'fields',
'deals',
'members',
'ai',
'api',
] as const;
@ -57,7 +55,6 @@ export const SECTION_META: Record<SettingsSection, SectionMeta> = {
fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' },
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' },
members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' },
ai: { id: 'ai', label: 'AI Assistant', icon: Sparkles, group: 'workspace' },
api: { id: 'api', label: 'API keys', icon: KeyRound, group: 'workspace' },
};

51
src/lib/ai/config.test.ts Normal file
View File

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
// decrypt is identity in tests so we don't depend on real ciphertext.
vi.mock('@/lib/whatsapp/encryption', () => ({
decrypt: (v: string) => `plain:${v}`,
}))
import { loadAiConfig } from './config'
function dbReturning(row: Record<string, unknown> | null): SupabaseClient {
const chain = {
from: () => chain,
select: () => chain,
eq: () => chain,
maybeSingle: () => Promise.resolve({ data: row, error: null }),
}
return chain as unknown as SupabaseClient
}
const ROW = {
provider: 'openai',
model: 'gpt-x',
api_key: 'enc-key',
system_prompt: null,
is_active: false,
auto_reply_enabled: false,
auto_reply_max_per_conversation: 3,
embeddings_api_key: null,
}
describe('loadAiConfig requireActive', () => {
it('returns null for an inactive config by default', async () => {
expect(await loadAiConfig(dbReturning(ROW), 'acct')).toBeNull()
})
it('returns the config when requireActive is false (Playground path)', async () => {
const config = await loadAiConfig(dbReturning(ROW), 'acct', {
requireActive: false,
})
expect(config).not.toBeNull()
expect(config!.provider).toBe('openai')
expect(config!.apiKey).toBe('plain:enc-key')
})
it('returns null when there is no row', async () => {
expect(
await loadAiConfig(dbReturning(null), 'acct', { requireActive: false }),
).toBeNull()
})
})

View File

@ -30,7 +30,9 @@ const CONFIG_COLUMNS =
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)
@ -41,7 +43,9 @@ export async function loadAiConfig(
if (!data) return null
const row = data as AiConfigRow
if (!row.is_active) return null
// 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.

24
src/lib/ai/query.test.ts Normal file
View File

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { latestUserMessage } from './query'
describe('latestUserMessage', () => {
it('returns the most recent user turn', () => {
expect(
latestUserMessage([
{ role: 'user', content: 'first' },
{ role: 'assistant', content: 'reply' },
{ role: 'user', content: 'latest' },
]),
).toBe('latest')
})
it('falls back to the last message when none are user', () => {
expect(
latestUserMessage([{ role: 'assistant', content: 'only assistant' }]),
).toBe('only assistant')
})
it('returns empty string for no messages', () => {
expect(latestUserMessage([])).toBe('')
})
})