mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
5dbc786fdc
commit
10d31bcfc8
23
CHANGELOG.md
23
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
|
Pre-1.0, `MINOR` bumps cover new modules; `PATCH` bumps cover bug fixes
|
||||||
and polish.
|
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
|
## [0.6.0] — 2026-07-02
|
||||||
|
|
||||||
Adds an **AI knowledge base** so the assistant (0.5.0) can answer from
|
Adds an **AI knowledge base** so the assistant (0.5.0) can answer from
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wacrm",
|
"name": "wacrm",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"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.",
|
"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",
|
"license": "MIT",
|
||||||
|
|||||||
73
src/app/(dashboard)/agents/page.tsx
Normal file
73
src/app/(dashboard)/agents/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,7 +16,6 @@ import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'
|
|||||||
import { DealsSettings } from '@/components/settings/deals-settings';
|
import { DealsSettings } from '@/components/settings/deals-settings';
|
||||||
import { MembersTab } from '@/components/settings/members-tab';
|
import { MembersTab } from '@/components/settings/members-tab';
|
||||||
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
|
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
|
||||||
import { AiConfig } from '@/components/settings/ai-config';
|
|
||||||
import {
|
import {
|
||||||
resolveSection,
|
resolveSection,
|
||||||
type SettingsSection,
|
type SettingsSection,
|
||||||
@ -61,7 +60,6 @@ export default function SettingsPage() {
|
|||||||
fields: <FieldsAndTagsPanel />,
|
fields: <FieldsAndTagsPanel />,
|
||||||
deals: <DealsSettings />,
|
deals: <DealsSettings />,
|
||||||
members: <MembersTab />,
|
members: <MembersTab />,
|
||||||
ai: <AiConfig />,
|
|
||||||
api: <ApiKeysSettings />,
|
api: <ApiKeysSettings />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
98
src/app/api/ai/playground/route.ts
Normal file
98
src/app/api/ai/playground/route.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/components/agents/ai-playground.tsx
Normal file
198
src/components/agents/ai-playground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { useTotalUnread } from "@/hooks/use-total-unread";
|
|||||||
import { useUnreadNotifications } from "@/hooks/use-unread-notifications";
|
import { useUnreadNotifications } from "@/hooks/use-unread-notifications";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
Bot,
|
||||||
Crown,
|
Crown,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -97,6 +98,7 @@ const navItems: NavItem[] = [
|
|||||||
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
||||||
{ href: "/automations", label: "Automations", icon: Zap },
|
{ href: "/automations", label: "Automations", icon: Zap },
|
||||||
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
|
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
|
||||||
|
{ href: "/agents", label: "AI Agents", icon: Bot },
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomNavItems = [
|
const bottomNavItems = [
|
||||||
|
|||||||
@ -225,8 +225,8 @@ export function AiConfig() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="AI Assistant"
|
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. Powers AI-drafted replies in the inbox and an optional auto-reply bot."
|
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 && (
|
{!canEdit && (
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
|
||||||
Tags,
|
Tags,
|
||||||
User,
|
User,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
@ -31,7 +30,6 @@ export const SETTINGS_SECTIONS = [
|
|||||||
'fields',
|
'fields',
|
||||||
'deals',
|
'deals',
|
||||||
'members',
|
'members',
|
||||||
'ai',
|
|
||||||
'api',
|
'api',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -57,7 +55,6 @@ export const SECTION_META: Record<SettingsSection, SectionMeta> = {
|
|||||||
fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' },
|
fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' },
|
||||||
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' },
|
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' },
|
||||||
members: { id: 'members', label: 'Team members', icon: UsersRound, 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' },
|
api: { id: 'api', label: 'API keys', icon: KeyRound, group: 'workspace' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
51
src/lib/ai/config.test.ts
Normal file
51
src/lib/ai/config.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -30,7 +30,9 @@ const CONFIG_COLUMNS =
|
|||||||
export async function loadAiConfig(
|
export async function loadAiConfig(
|
||||||
db: SupabaseClient,
|
db: SupabaseClient,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
|
opts: { requireActive?: boolean } = {},
|
||||||
): Promise<AiConfig | null> {
|
): Promise<AiConfig | null> {
|
||||||
|
const { requireActive = true } = opts
|
||||||
const { data, error } = await db
|
const { data, error } = await db
|
||||||
.from('ai_configs')
|
.from('ai_configs')
|
||||||
.select(CONFIG_COLUMNS)
|
.select(CONFIG_COLUMNS)
|
||||||
@ -41,7 +43,9 @@ export async function loadAiConfig(
|
|||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const row = data as AiConfigRow
|
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
|
// Defensive: the column is NOT NULL, but a partial write / manual DB
|
||||||
// edit could leave it empty. Treat a missing key as "not configured"
|
// edit could leave it empty. Treat a missing key as "not configured"
|
||||||
// rather than letting decrypt() throw on null.
|
// rather than letting decrypt() throw on null.
|
||||||
|
|||||||
24
src/lib/ai/query.test.ts
Normal file
24
src/lib/ai/query.test.ts
Normal 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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user