wacrm/src/components/agents/ai-playground.tsx
Arnas Donauskas 10d31bcfc8 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>
2026-07-02 18:20:18 +03:00

199 lines
6.5 KiB
TypeScript

'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>
);
}