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
+127
-135
@@ -1,187 +1,178 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
// A command palette in the shadcn shape, implemented on this project's own
|
||||
// Dialog rather than cmdk so it stays dependency-free. Filtering is left to the
|
||||
// caller, which suits async sources like the Storefront API.
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<div
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground',
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
}: CommandDialogProps) {
|
||||
// Dialog portals straight into document.body, so hold off until mounted or
|
||||
// prerendering this component's page throws "document is not defined".
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
showCloseButton = true,
|
||||
// Forwarded to the inner Command so callers can drive filtering themselves
|
||||
// (e.g. results already filtered server-side).
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
shouldFilter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
// DialogContent merges with clsx, so its own `p-6`/`gap-4` need the
|
||||
// important flag to lose to these.
|
||||
className={cn(
|
||||
'top-24 max-w-xl translate-y-0 overflow-hidden p-0! gap-0!',
|
||||
className
|
||||
)}
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command>{children}</Command>
|
||||
<Command shouldFilter={shouldFilter} className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandInputProps
|
||||
extends Omit<React.ComponentProps<'input'>, 'onChange'> {
|
||||
onValueChange?: (value: string) => void;
|
||||
onClear?: () => void;
|
||||
onClose?: () => void;
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
value,
|
||||
onValueChange,
|
||||
onClear,
|
||||
onClose,
|
||||
...props
|
||||
}: CommandInputProps) {
|
||||
const hasValue = Boolean(String(value ?? '').length);
|
||||
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-12 items-center gap-2 px-3"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<RiSearchLine size={18} className="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange?.(event.target.value)}
|
||||
className={cn(
|
||||
'flex h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{hasValue && onClear && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<RiCloseLine size={18} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<div
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-empty"
|
||||
className={cn('px-4 py-8 text-center text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandGroupProps extends React.ComponentProps<'div'> {
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-group"
|
||||
className={cn('px-2 py-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{heading && (
|
||||
<div className="px-2 pb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-item"
|
||||
role="option"
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-3 rounded-md px-2 py-2 text-sm outline-none hover:bg-accent',
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-separator"
|
||||
className={cn('h-px bg-border', className)}
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -192,5 +183,6 @@ export {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user