Add AI store assistant with catalogue tool calls

- /api/chat streams via AI SDK v7 through OpenRouter (OPENROUTER_API_KEY),
  with a store-specific system prompt and five read-only tools:
  searchCatalogue, getProductDetails, listCollections,
  getCollectionProducts, browseProducts
- Sidebar assistant on the right that opens from a launcher and expands,
  built on ai-elements (conversation, message, prompt-input, tool) with
  shimmer on in-flight tool calls
- Extract services/shopify/catalog.ts so the Storefront fetchers have no
  React imports and can run in a Route Handler; the client hooks now
  re-export from it
- ai-elements pulled in the canonical shadcn primitives, replacing the
  hand-rolled command/dialog/button variants; search dialog moved to the
  cmdk-based Command and CommandDialog forwards shouldFilter
- Pin shiki to ^3.19.0 to match streamdown and drop the duplicate copy
- Add .env.example documenting the required env vars

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
Rami Bitar
2026-08-01 13:03:01 -04:00
co-authored by Claude Opus 5
parent 2ef5639a2e
commit 107959a4c3
34 changed files with 9339 additions and 1166 deletions
+243
View File
@@ -0,0 +1,243 @@
'use client';
import React, { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
} from '@/components/ai-elements/prompt-input';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Button } from '@/components/ui/button';
import {
RiSparkling2Line,
RiCloseLine,
RiExpandLeftRightLine,
} from '@remixicon/react';
const SUGGESTIONS = [
'What do you sell?',
'Show me hoodies under $100',
'What collections are there?',
];
// Human-readable labels for the tool names exposed by /api/chat.
const TOOL_LABELS: Record<string, string> = {
searchCatalogue: 'Searching the catalogue',
getProductDetails: 'Reading product details',
listCollections: 'Listing collections',
getCollectionProducts: 'Browsing a collection',
browseProducts: 'Browsing new arrivals',
};
const toolLabel = (type: string) => {
const name = type.startsWith('tool-') ? type.slice(5) : type;
return TOOL_LABELS[name] ?? name;
};
const StoreAssistant: React.FC = () => {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const [input, setInput] = useState('');
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const isBusy = status === 'submitted' || status === 'streaming';
const send = (text: string) => {
const trimmed = text.trim();
if (!trimmed || isBusy) return;
sendMessage({ text: trimmed });
setInput('');
};
return (
<>
{/* Launcher */}
{!open && (
<Button
onClick={() => setOpen(true)}
aria-label="Open store assistant"
className="fixed bottom-6 right-6 z-50 h-12 gap-x-2 rounded-full px-5 shadow-lg"
>
<RiSparkling2Line className="size-5" />
Ask
</Button>
)}
{/* Sidebar */}
<aside
aria-label="Store assistant"
aria-hidden={!open}
className={`fixed inset-y-0 right-0 z-50 flex flex-col border-l border-border bg-background transition-[transform,width] duration-300 ${
expanded ? 'w-full sm:w-[36rem]' : 'w-full sm:w-96'
} ${open ? 'translate-x-0' : 'pointer-events-none translate-x-full'}`}
>
<header className="flex h-14 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-x-2">
<RiSparkling2Line className="size-5" />
<span className="text-base font-medium">Store Assistant</span>
</div>
<div className="flex items-center">
<Button
onClick={() => setExpanded((prev) => !prev)}
variant="ghost"
size="icon"
aria-label={expanded ? 'Collapse panel' : 'Expand panel'}
className="hidden rounded-full sm:inline-flex"
>
<RiExpandLeftRightLine className="size-5" />
</Button>
<Button
onClick={() => setOpen(false)}
variant="ghost"
size="icon"
aria-label="Close assistant"
className="rounded-full"
>
<RiCloseLine className="size-5" />
</Button>
</div>
</header>
<Conversation className="flex-1">
<ConversationContent className="gap-4">
{messages.length === 0 && (
<ConversationEmptyState
icon={<RiSparkling2Line className="size-6" />}
title="Ask about the store"
description="Find products, compare options, and browse collections."
>
<div className="mt-4 flex flex-col gap-2">
{SUGGESTIONS.map((suggestion) => (
<Button
key={suggestion}
onClick={() => send(suggestion)}
variant="outline"
size="sm"
className="font-normal"
>
{suggestion}
</Button>
))}
</div>
</ConversationEmptyState>
)}
{messages.map((message) => (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return (
<MessageResponse key={index}>
{part.text}
</MessageResponse>
);
}
// Tool calls render as collapsible cards; while the model
// is still working, the header shimmers instead.
if (part.type.startsWith('tool-')) {
const toolPart = part as typeof part & {
state: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
const running =
toolPart.state === 'input-streaming' ||
toolPart.state === 'input-available';
return (
<Tool key={index} className="my-1">
{running ? (
<div className="flex items-center gap-2 p-3">
<Shimmer className="text-sm">
{toolLabel(part.type)}
</Shimmer>
</div>
) : (
<ToolHeader
type={part.type as never}
state={toolPart.state as never}
title={toolLabel(part.type)}
/>
)}
<ToolContent>
<ToolInput input={toolPart.input} />
<ToolOutput
output={toolPart.output}
errorText={toolPart.errorText}
/>
</ToolContent>
</Tool>
);
}
return null;
})}
</MessageContent>
</Message>
))}
{status === 'submitted' && (
<Shimmer className="px-1 text-sm">Thinking</Shimmer>
)}
{error && (
<p className="text-sm text-destructive">
Something went wrong. Please try again.
</p>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="shrink-0 p-3">
<PromptInput
onSubmit={(message) => send(message.text ?? input)}
className="rounded-lg"
>
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask about products, sizes, collections…"
/>
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit status={status} disabled={!input.trim()} />
</PromptInputFooter>
</PromptInput>
</div>
</aside>
</>
);
};
export default StoreAssistant;