Template
- /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
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
'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;
|