Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,25 @@ FLUTTERWAVE_BASE_URL=https://api.flutterwave.com/v3
FLUTTERWAVE_SECRET_KEY=
FLUTTERWAVE_WEBHOOK_HASH=
# F.A.T.U Concierge (AI customer care). Keys are server-only — never NEXT_PUBLIC_*.
# Default provider is mock (deterministic grounded answers) so CI works without secrets.
# AI_PROVIDER=auto tries providers in AI_PROVIDER_ORDER until one succeeds.
# Default mock keeps CI green without secrets. For live polish use auto + at least one key.
AI_ENABLED=true
AI_PROVIDER=mock
AI_MODEL=gpt-4.1-mini
AI_API_KEY=
AI_FALLBACK_MODEL=
AI_PROVIDER=auto
AI_PROVIDER_ORDER=openai,anthropic,gemini
AI_ASSISTANT_NAME=F.A.T.U Concierge
AI_MAX_DAILY_REQUESTS=2000
AI_CHAT_RETENTION_DAYS=90
AI_KNOWLEDGE_SYNC_SECRET=
AI_SUPPORT_EMAIL=
AI_CHAT_RETENTION_DAYS=90
AI_ASSISTANT_NAME=F.A.T.U Concierge
# OpenAI (flagship alias → gpt-5.6-sol). AI_API_KEY is accepted as an OPENAI_API_KEY alias.
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6
AI_API_KEY=
AI_MODEL=gpt-5.6
AI_FALLBACK_MODEL=
# Anthropic (Sonnet 5 is the strong default; set claude-fable-5 for frontier)
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-5
# Google Gemini (3.5 Flash is the current GA flagship; override if you have Pro access)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-3.5-flash
8 changes: 4 additions & 4 deletions apps/storefront/components/concierge/concierge-launcher.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import { cn } from '@tms/ui';
import { MessageCircle } from 'lucide-react';
import { useConcierge } from './concierge-provider';
import { BrandLogo } from '@/components/site/brand-logo';

export function ConciergeLauncher() {
const { panel, open, assistantName } = useConcierge();
Expand All @@ -14,14 +14,14 @@ export function ConciergeLauncher() {
onClick={open}
aria-label={`Open ${assistantName}`}
className={cn(
'fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full border border-line bg-ink px-4 py-3 text-sm font-medium text-canvas shadow-lg outline-none',
'fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-2.5 text-sm font-medium text-ink shadow-lg outline-none',
'transition-[transform,opacity] duration-200 hover:scale-[1.02] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]',
'motion-reduce:transition-none motion-reduce:hover:scale-100',
'min-h-11 min-w-11',
)}
>
<MessageCircle className="size-5" aria-hidden />
<span className="hidden sm:inline">{assistantName}</span>
<BrandLogo className="size-8 rounded-full" alt="" sizes="32px" />
<span className="hidden max-w-[10rem] truncate sm:inline">{assistantName}</span>
{panel === 'minimised' ? (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-accent" aria-hidden />
) : null}
Expand Down
17 changes: 10 additions & 7 deletions apps/storefront/components/concierge/concierge-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import {
RotateCw,
Send,
ShieldCheck,
Sparkles,
X,
} from 'lucide-react';
import Link from 'next/link';
import { useEffect, useId, useRef } from 'react';
import { BrandLogo } from '@/components/site/brand-logo';
import { type ConciergeMessage, useConcierge } from './concierge-provider';

export function ConciergePanel() {
Expand Down Expand Up @@ -71,16 +71,19 @@ export function ConciergePanel() {
)}
>
<header className="flex items-start gap-3 border-b border-line bg-canvas-2 p-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-ink text-canvas">
<Sparkles className="size-5" aria-hidden />
</span>
<BrandLogo
className="size-10 shrink-0 rounded-full"
alt=""
sizes="40px"
priority
/>
<div className="min-w-0 flex-1">
<p className="font-display text-lg text-ink">{assistantName}</p>
<p className="truncate font-display text-lg text-ink">{assistantName}</p>
<p className="text-sm text-muted">
Artwork, sizing, delivery, Design Studio, and orders — grounded in studio sources.
</p>
</div>
<div className="flex gap-1">
<div className="flex shrink-0 gap-1">
<button
type="button"
onClick={minimise}
Expand Down Expand Up @@ -129,7 +132,7 @@ export function ConciergePanel() {

{pending ? (
<div className="flex items-center gap-2 text-sm text-muted" aria-label="Concierge is responding">
<Sparkles className="size-4 animate-pulse text-ink" aria-hidden />
<BrandLogo className="size-4 animate-pulse rounded-full" alt="" sizes="16px" />
<span>Working…</span>
</div>
) : null}
Expand Down
46 changes: 9 additions & 37 deletions apps/storefront/lib/concierge/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import { routeIntent } from './intent';
import { formatCitationLinks, retrieveKnowledge } from './knowledge/corpus';
import { buildSystemPrompt } from './prompts/system';
import { hasAnyLiveProvider, polishWithFallback } from './providers/fallback';
import { redactSensitive, sanitizeModelOutput } from './security/redact';
import { getCartTool } from './tools/cart';
import {
Expand Down Expand Up @@ -223,17 +224,20 @@ export async function runConciergeTurn(
text = `${contextBits.join(' ')}\n\n${text}`;
}

// Optional LLM polish — never invents tools; only rephrases when configured.
if (process.env.AI_PROVIDER === 'openai' && process.env.AI_API_KEY) {
// Optional LLM polish with OpenAI → Anthropic → Gemini fallback.
// Never invents tools/facts — only rephrases the grounded draft.
if (hasAnyLiveProvider()) {
try {
const polished = await polishWithOpenAI({
const polished = await polishWithFallback({
system: buildSystemPrompt(assistantName()),
user: message,
draft: text,
});
if (polished) {
text = polished;
provider = 'openai';
text = polished.text;
provider = polished.provider;
} else {
provider = 'fallback';
}
} catch {
provider = 'fallback';
Expand All @@ -252,35 +256,3 @@ export async function runConciergeTurn(
guarded,
};
}

async function polishWithOpenAI(input: {
system: string;
user: string;
draft: string;
}): Promise<string | null> {
const model = process.env.AI_MODEL || 'gpt-4.1-mini';
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
temperature: 0.3,
messages: [
{ role: 'system', content: input.system },
{
role: 'user',
content: `Customer said: ${input.user}\n\nGrounded draft (do not add facts):\n${input.draft}\n\nRewrite briefly in Concierge voice. Do not add products, prices, policies, or promises absent from the draft.`,
},
],
}),
signal: AbortSignal.timeout(12_000),
});
if (!response.ok) return null;
const json = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
return json.choices?.[0]?.message?.content?.trim() || null;
}
42 changes: 42 additions & 0 deletions apps/storefront/lib/concierge/providers/fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, afterEach } from 'vitest';
import { hasAnyLiveProvider, polishWithFallback } from './fallback';

const KEYS = [
'AI_PROVIDER',
'AI_PROVIDER_ORDER',
'OPENAI_API_KEY',
'AI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
] as const;

afterEach(() => {
for (const key of KEYS) delete process.env[key];
});

describe('hasAnyLiveProvider', () => {
it('is false in mock mode', () => {
process.env.AI_PROVIDER = 'mock';
process.env.OPENAI_API_KEY = 'sk-test';
expect(hasAnyLiveProvider()).toBe(false);
});

it('is true in auto mode when any key is present', () => {
process.env.AI_PROVIDER = 'auto';
process.env.ANTHROPIC_API_KEY = 'ant-test';
expect(hasAnyLiveProvider()).toBe(true);
});
});

describe('polishWithFallback', () => {
it('returns null when no keys are configured', async () => {
process.env.AI_PROVIDER = 'auto';
const result = await polishWithFallback({
system: 'sys',
user: 'hi',
draft: 'draft',
});
expect(result).toBeNull();
});
});
Loading
Loading