mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #334 from ArnasDon/feat/ai-agents-sidebar
feat: AI Agents sidebar section (setup + playground)
This commit is contained in:
commit
274db1c7ce
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
|
||||
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
|
||||
|
||||
@ -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",
|
||||
|
||||
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 { 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 />,
|
||||
};
|
||||
|
||||
|
||||
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 {
|
||||
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 = [
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
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(
|
||||
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
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