Template
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:
co-authored by
Claude Opus 5
parent
2ef5639a2e
commit
107959a4c3
@@ -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;
|
||||
Reference in New Issue
Block a user