Add React editor project
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
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,
|
||||
PromptInputActionAddAttachments,
|
||||
PromptInputActionMenu,
|
||||
PromptInputActionMenuContent,
|
||||
PromptInputActionMenuTrigger,
|
||||
PromptInputBody,
|
||||
PromptInputFooter,
|
||||
PromptInputProvider,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
usePromptInputAttachments,
|
||||
type PromptInputMessage,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentHoverCard,
|
||||
AttachmentHoverCardContent,
|
||||
AttachmentHoverCardTrigger,
|
||||
AttachmentInfo,
|
||||
AttachmentPreview,
|
||||
AttachmentRemove,
|
||||
Attachments,
|
||||
getAttachmentLabel,
|
||||
getMediaCategory,
|
||||
type AttachmentData,
|
||||
} from '@/components/ai-elements/attachments';
|
||||
import {
|
||||
Suggestions,
|
||||
Suggestion,
|
||||
} from '@/components/ai-elements/suggestion';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RainbowButton } from '@/components/ui/rainbow-button';
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiSearchLine,
|
||||
RiPriceTag3Line,
|
||||
RiStore2Line,
|
||||
RiLayoutGridLine,
|
||||
RiShoppingBag3Line,
|
||||
} from '@remixicon/react';
|
||||
|
||||
// Feature flag. Written as a static member expression so Next inlines it at
|
||||
// build time; the assistant is off unless the env var is explicitly "1".
|
||||
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'What do you sell?',
|
||||
'Show me hoodies under $100',
|
||||
'What collections are there?',
|
||||
];
|
||||
|
||||
interface ToolProduct {
|
||||
handle: string;
|
||||
title: string;
|
||||
image: string | null;
|
||||
price?: string;
|
||||
}
|
||||
|
||||
interface ToolSummary {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
products: ToolProduct[];
|
||||
}
|
||||
|
||||
const LOADING_LABELS: Record<string, string> = {
|
||||
searchCatalogue: 'Searching the catalogue',
|
||||
getProductDetails: 'Reading product details',
|
||||
listCollections: 'Listing collections',
|
||||
getCollectionProducts: 'Browsing a collection',
|
||||
browseProducts: 'Browsing new arrivals',
|
||||
};
|
||||
|
||||
const toolName = (type: string) =>
|
||||
type.startsWith('tool-') ? type.slice(5) : type;
|
||||
|
||||
const plural = (count: number, noun: string) =>
|
||||
`${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
|
||||
// Turns a finished tool result into the one-line summary plus any products
|
||||
// worth previewing.
|
||||
const summariseTool = (
|
||||
name: string,
|
||||
output: Record<string, unknown> | undefined
|
||||
): ToolSummary => {
|
||||
const products = (output?.products as ToolProduct[] | undefined) ?? [];
|
||||
|
||||
switch (name) {
|
||||
case 'searchCatalogue': {
|
||||
const total = (output?.totalCount as number) ?? products.length;
|
||||
return {
|
||||
label: `Found ${plural(total, 'product')}`,
|
||||
icon: <RiSearchLine className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
}
|
||||
case 'getProductDetails':
|
||||
return {
|
||||
label: output?.found
|
||||
? `Read ${output.title as string}`
|
||||
: 'Product not found',
|
||||
icon: <RiPriceTag3Line className="size-3.5" />,
|
||||
products: output?.found
|
||||
? [
|
||||
{
|
||||
handle: output.handle as string,
|
||||
title: output.title as string,
|
||||
image: (output.image as string) ?? null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
case 'listCollections': {
|
||||
const collections =
|
||||
(output?.collections as Array<unknown> | undefined) ?? [];
|
||||
return {
|
||||
label: `Found ${plural(collections.length, 'collection')}`,
|
||||
icon: <RiStore2Line className="size-3.5" />,
|
||||
products: [],
|
||||
};
|
||||
}
|
||||
case 'getCollectionProducts':
|
||||
return {
|
||||
label: output?.found
|
||||
? `Found ${plural(products.length, 'product')} in ${output.collection}`
|
||||
: 'Collection not found',
|
||||
icon: <RiLayoutGridLine className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
case 'browseProducts':
|
||||
return {
|
||||
label: `Browsed ${plural(products.length, 'new arrival')}`,
|
||||
icon: <RiShoppingBag3Line className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: name,
|
||||
icon: <RiSearchLine className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
|
||||
const isInternalLink = (url: string) => {
|
||||
if (url.startsWith('/')) return true;
|
||||
try {
|
||||
return new URL(url, window.location.origin).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
|
||||
products,
|
||||
}) => {
|
||||
const withImages = products.filter((product) => product.image);
|
||||
if (withImages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Attachments variant="grid" className="ml-0 mt-2">
|
||||
{withImages.slice(0, 6).map((product) => (
|
||||
<Link
|
||||
key={product.handle}
|
||||
href={`/products/${product.handle}`}
|
||||
title={product.title}
|
||||
className="group/product w-20"
|
||||
>
|
||||
<Attachment
|
||||
data={{
|
||||
id: product.handle,
|
||||
type: 'file',
|
||||
url: product.image as string,
|
||||
mediaType: 'image/jpeg',
|
||||
filename: product.title,
|
||||
}}
|
||||
className="size-20"
|
||||
>
|
||||
<AttachmentPreview />
|
||||
</Attachment>
|
||||
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
|
||||
{product.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttachmentItemProps {
|
||||
attachment: AttachmentData;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
||||
const handleRemove = useCallback(
|
||||
() => onRemove(attachment.id),
|
||||
[onRemove, attachment.id]
|
||||
);
|
||||
const mediaCategory = getMediaCategory(attachment);
|
||||
const label = getAttachmentLabel(attachment);
|
||||
|
||||
return (
|
||||
<AttachmentHoverCard key={attachment.id}>
|
||||
<AttachmentHoverCardTrigger asChild>
|
||||
<Attachment data={attachment} onRemove={handleRemove}>
|
||||
{/* Thumbnail swaps to the remove button on hover. */}
|
||||
<div className="group relative size-5 shrink-0">
|
||||
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
|
||||
<AttachmentPreview />
|
||||
</div>
|
||||
<AttachmentRemove className="absolute inset-0" />
|
||||
</div>
|
||||
<AttachmentInfo />
|
||||
</Attachment>
|
||||
</AttachmentHoverCardTrigger>
|
||||
<AttachmentHoverCardContent>
|
||||
<div className="space-y-2">
|
||||
{mediaCategory === 'image' &&
|
||||
attachment.type === 'file' &&
|
||||
attachment.url && (
|
||||
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
|
||||
<img
|
||||
alt={label}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
height={384}
|
||||
src={attachment.url}
|
||||
width={320}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 px-0.5">
|
||||
<h4 className="text-sm font-semibold leading-none">{label}</h4>
|
||||
{attachment.mediaType && (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{attachment.mediaType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AttachmentHoverCardContent>
|
||||
</AttachmentHoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
AttachmentItem.displayName = 'AttachmentItem';
|
||||
|
||||
// Pending uploads, shown inline above the textarea.
|
||||
const PromptInputAttachmentsDisplay = () => {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => attachments.remove(id),
|
||||
[attachments]
|
||||
);
|
||||
|
||||
if (attachments.files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
|
||||
{attachments.files.map((attachment) => (
|
||||
<AttachmentItem
|
||||
attachment={attachment}
|
||||
key={attachment.id}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
};
|
||||
|
||||
const StoreAssistant: React.FC = () => {
|
||||
// Returns before any hooks run — safe because the flag is a build-time
|
||||
// constant and cannot change between renders.
|
||||
if (!AI_ENABLED) return null;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
// Drives the launcher's slide-in on first paint.
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const { messages, sendMessage, status, error } = useChat({
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
const isBusy = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
|
||||
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
|
||||
}`;
|
||||
|
||||
const send = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isBusy) return;
|
||||
sendMessage({ text: trimmed });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
// Attachments arrive on the submitted message, so images go out with it.
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
const text = (message.text ?? input).trim();
|
||||
const files = message.files ?? [];
|
||||
if ((!text && files.length === 0) || isBusy) return;
|
||||
|
||||
sendMessage({ text, files });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Popover panel, anchored above the launcher */}
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
|
||||
open
|
||||
? 'pointer-events-auto translate-y-0 opacity-100'
|
||||
: 'pointer-events-none translate-y-2 opacity-0'
|
||||
}`}
|
||||
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
|
||||
>
|
||||
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
|
||||
<span className="text-sm font-medium">Store Assistant</span>
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close assistant"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-4 p-3">
|
||||
{messages.length === 0 && (
|
||||
<ConversationEmptyState
|
||||
title="Ask about the store"
|
||||
description="Find products, compare options, browse collections."
|
||||
>
|
||||
{/* w-full + wrap so chips stack in the narrow popover rather
|
||||
than scrolling off the edge. */}
|
||||
<Suggestions className="mt-3 w-full flex-wrap justify-center">
|
||||
{SUGGESTIONS.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion}
|
||||
onClick={send}
|
||||
suggestion={suggestion}
|
||||
className="font-normal"
|
||||
/>
|
||||
))}
|
||||
</Suggestions>
|
||||
</ConversationEmptyState>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
const fileParts = message.parts.filter(
|
||||
(part) => part.type === 'file'
|
||||
);
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<MessageResponse
|
||||
key={index}
|
||||
// Only repair half-written markdown while it streams;
|
||||
// settled text is rendered exactly as sent.
|
||||
isAnimating={
|
||||
status === 'streaming' && part.state === 'streaming'
|
||||
}
|
||||
linkSafety={{
|
||||
enabled: true,
|
||||
onLinkCheck: isInternalLink,
|
||||
}}
|
||||
>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
return (
|
||||
<Reasoning
|
||||
key={index}
|
||||
className="w-full"
|
||||
isStreaming={
|
||||
status === 'streaming' &&
|
||||
part.state === 'streaming'
|
||||
}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const toolPart = part as typeof part & {
|
||||
state: string;
|
||||
output?: Record<string, unknown>;
|
||||
errorText?: string;
|
||||
};
|
||||
const name = toolName(part.type);
|
||||
|
||||
// Shimmer while the call is in flight; a quiet summary
|
||||
// line once it returns.
|
||||
if (
|
||||
toolPart.state === 'input-streaming' ||
|
||||
toolPart.state === 'input-available'
|
||||
) {
|
||||
return (
|
||||
<Shimmer key={index} className="text-xs">
|
||||
{LOADING_LABELS[name] ?? name}
|
||||
</Shimmer>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolPart.state === 'output-error') {
|
||||
return (
|
||||
<p key={index} className="text-xs text-muted-foreground">
|
||||
Couldn't load that.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { label, icon, products } = summariseTool(
|
||||
name,
|
||||
toolPart.output
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={index} className="my-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<ProductPreviews products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Images the shopper attached, shown under their message. */}
|
||||
{fileParts.length > 0 && (
|
||||
<Attachments variant="grid" className="ml-0 justify-start">
|
||||
{fileParts.map((part, index) => (
|
||||
<Attachment
|
||||
key={`${message.id}-file-${index}`}
|
||||
data={{
|
||||
id: `${message.id}-file-${index}`,
|
||||
type: 'file',
|
||||
url: part.url,
|
||||
mediaType: part.mediaType,
|
||||
filename: part.filename,
|
||||
}}
|
||||
className="size-20"
|
||||
>
|
||||
<AttachmentPreview />
|
||||
</Attachment>
|
||||
))}
|
||||
</Attachments>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
|
||||
{status === 'submitted' && (
|
||||
<Shimmer className="text-xs">Thinking</Shimmer>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<div className="shrink-0 p-2">
|
||||
<PromptInputProvider>
|
||||
<PromptInput
|
||||
globalDrop
|
||||
multiple
|
||||
accept="image/*"
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<PromptInputAttachmentsDisplay />
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="Ask about products…"
|
||||
className="min-h-12"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter>
|
||||
<PromptInputTools>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit status={status} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</PromptInputProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launcher */}
|
||||
{/* Rainbow treatment only while the assistant is open; otherwise the
|
||||
launcher matches the rest of the site's buttons. */}
|
||||
{open ? (
|
||||
<RainbowButton
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close store assistant"
|
||||
aria-expanded
|
||||
size="lg"
|
||||
className={launcherClasses}
|
||||
>
|
||||
Ask
|
||||
</RainbowButton>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open store assistant"
|
||||
aria-expanded={false}
|
||||
className={`h-11 ${launcherClasses}`}
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreAssistant;
|
||||
Reference in New Issue
Block a user