From 7c6abb4648434aa8ca490d6958c40b20bbb85d9c Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sat, 1 Aug 2026 14:55:04 -0400 Subject: [PATCH] Refine the store assistant UI and gate it behind a flag - Gate the assistant on NEXT_PUBLIC_ENABLE_AI=1 (UI only; the /api/chat route still responds if called directly) - Chat model is openai/gpt-5.6-luna-pro with reasoning requested and sendReasoning enabled, rendered via the Reasoning component - Suggestion chips and reasoning come from ai-elements; attachments gain hover-card previews, left alignment, and render under the user message - Launcher: plain Button when closed, magicui RainbowButton when open - Tool results replace the collapsible Tool UI with a shimmer while running and a muted icon + summary line after, plus product previews - Commerce icons in place of the folder icon for collections - Add a thin announcement bar above the header; footer splits links and socials onto separate rows and clears the launcher corner - Add the shadcn base layer so Tailwind v4's bare `border` picks up the theme colour instead of currentColor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX --- .env.example | 5 +- app/api/chat/route.ts | 10 +- app/globals.css | 44 ++ components.json | 5 +- components/ai-elements/attachments.tsx | 426 ++++++++++++++++++ components/ai-elements/reasoning.tsx | 226 ++++++++++ components/ai-elements/suggestion.tsx | 57 +++ components/shopify/footer.tsx | 40 +- components/shopify/header.tsx | 31 +- components/shopify/store-assistant.tsx | 570 +++++++++++++++++++------ components/ui/rainbow-button.tsx | 61 +++ components/ui/scroll-area.tsx | 58 +++ package.json | 1 + yarn.lock | 3 +- 14 files changed, 1389 insertions(+), 148 deletions(-) create mode 100644 components/ai-elements/attachments.tsx create mode 100644 components/ai-elements/reasoning.tsx create mode 100644 components/ai-elements/suggestion.tsx create mode 100644 components/ui/rainbow-button.tsx create mode 100644 components/ui/scroll-area.tsx diff --git a/.env.example b/.env.example index 6b94237..400a157 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,10 @@ NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop # NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN= # NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07 +# Store assistant — set to 1 to show the Ask launcher; anything else hides it +NEXT_PUBLIC_ENABLE_AI=0 + # Store assistant (/api/chat) — https://openrouter.ai/keys OPENROUTER_API_KEY= -# Optional; defaults to anthropic/claude-sonnet-4.5 +# Optional; defaults to openai/gpt-5.6-luna-pro # OPENROUTER_MODEL= diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 5330ad5..cce2cdd 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -13,7 +13,7 @@ import { // server-side on each tool call. export const maxDuration = 30; -const MODEL = process.env.OPENROUTER_MODEL ?? 'anthropic/claude-sonnet-4.5'; +const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro'; const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify. @@ -84,7 +84,9 @@ export async function POST(req: Request) { const openrouter = createOpenRouter({ apiKey }); const result = streamText({ - model: openrouter(MODEL), + // `reasoning` asks OpenRouter to stream the model's thinking; the UI + // renders it via the Reasoning component. + model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }), system: SYSTEM_PROMPT, messages: await convertToModelMessages(messages), // Let the model call a tool, read the result, then answer. @@ -193,5 +195,7 @@ export async function POST(req: Request) { }, }); - return result.toUIMessageStreamResponse(); + // sendReasoning forwards reasoning parts to the client; without it the + // stream carries text and tool calls only. + return result.toUIMessageStreamResponse({ sendReasoning: true }); } diff --git a/app/globals.css b/app/globals.css index ad08907..9615c91 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,7 @@ @import 'tailwindcss'; +@custom-variant dark (&:is(.dark *)); + @theme { /* Refined neutral modern palette */ --color-background: hsl(0 0% 100%); @@ -56,6 +58,15 @@ } /* Base styles for light, modern Shopify storefront */ +/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn + components (e.g. Button's outline variant) rely on this base layer to pick up + the theme's border colour instead of the text colour. */ +@layer base { + * { + border-color: var(--color-border); + } +} + body { font-feature-settings: 'kern' 1, @@ -184,3 +195,36 @@ button, .price-display { font-feature-settings: 'tnum'; } + +@theme inline { + --animate-rainbow: rainbow var(--speed, 2s) infinite linear; + --color-color-5: var(--color-5); + --color-color-4: var(--color-4); + --color-color-3: var(--color-3); + --color-color-2: var(--color-2); + --color-color-1: var(--color-1); + @keyframes rainbow { + 0% { + background-position: 0%; + } + 100% { + background-position: 200%; + } + } +} + +:root { + --color-1: oklch(66.2% 0.225 25.9); + --color-2: oklch(60.4% 0.26 302); + --color-3: oklch(69.6% 0.165 251); + --color-4: oklch(80.2% 0.134 225); + --color-5: oklch(90.7% 0.231 133); +} + +.dark { + --color-1: oklch(66.2% 0.225 25.9); + --color-2: oklch(60.4% 0.26 302); + --color-3: oklch(69.6% 0.165 251); + --color-4: oklch(80.2% 0.134 225); + --color-5: oklch(90.7% 0.231 133); +} \ No newline at end of file diff --git a/components.json b/components.json index 4ee62ee..c5d8163 100644 --- a/components.json +++ b/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "registries": { + "@magicui": "https://magicui.design/r/{name}" + } } diff --git a/components/ai-elements/attachments.tsx b/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..d4e4e34 --- /dev/null +++ b/components/ai-elements/attachments.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, SourceDocumentUIPart } from "ai"; +import { + FileTextIcon, + GlobeIcon, + ImageIcon, + Music2Icon, + PaperclipIcon, + VideoIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AttachmentData = + | (FileUIPart & { id: string }) + | (SourceDocumentUIPart & { id: string }); + +export type AttachmentMediaCategory = + | "image" + | "video" + | "audio" + | "document" + | "source" + | "unknown"; + +export type AttachmentVariant = "grid" | "inline" | "list"; + +const mediaCategoryIcons: Record = { + audio: Music2Icon, + document: FileTextIcon, + image: ImageIcon, + source: GlobeIcon, + unknown: PaperclipIcon, + video: VideoIcon, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export const getMediaCategory = ( + data: AttachmentData +): AttachmentMediaCategory => { + if (data.type === "source-document") { + return "source"; + } + + const mediaType = data.mediaType ?? ""; + + if (mediaType.startsWith("image/")) { + return "image"; + } + if (mediaType.startsWith("video/")) { + return "video"; + } + if (mediaType.startsWith("audio/")) { + return "audio"; + } + if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { + return "document"; + } + + return "unknown"; +}; + +export const getAttachmentLabel = (data: AttachmentData): string => { + if (data.type === "source-document") { + return data.title || data.filename || "Source"; + } + + const category = getMediaCategory(data); + return data.filename || (category === "image" ? "Image" : "Attachment"); +}; + +const renderAttachmentImage = ( + url: string, + filename: string | undefined, + isGrid: boolean +) => + isGrid ? ( + {filename + ) : ( + {filename + ); + +// ============================================================================ +// Contexts +// ============================================================================ + +interface AttachmentsContextValue { + variant: AttachmentVariant; +} + +const AttachmentsContext = createContext(null); + +interface AttachmentContextValue { + data: AttachmentData; + mediaCategory: AttachmentMediaCategory; + onRemove?: () => void; + variant: AttachmentVariant; +} + +const AttachmentContext = createContext(null); + +// ============================================================================ +// Hooks +// ============================================================================ + +export const useAttachmentsContext = () => + useContext(AttachmentsContext) ?? { variant: "grid" as const }; + +export const useAttachmentContext = () => { + const ctx = useContext(AttachmentContext); + if (!ctx) { + throw new Error("Attachment components must be used within "); + } + return ctx; +}; + +// ============================================================================ +// Attachments - Container +// ============================================================================ + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant; +}; + +export const Attachments = ({ + variant = "grid", + className, + children, + ...props +}: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// Attachment - Item +// ============================================================================ + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData; + onRemove?: () => void; +}; + +export const Attachment = ({ + data, + onRemove, + className, + children, + ...props +}: AttachmentProps) => { + const { variant } = useAttachmentsContext(); + const mediaCategory = getMediaCategory(data); + + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// AttachmentPreview - Media preview +// ============================================================================ + +export type AttachmentPreviewProps = HTMLAttributes & { + fallbackIcon?: ReactNode; +}; + +export const AttachmentPreview = ({ + fallbackIcon, + className, + ...props +}: AttachmentPreviewProps) => { + const { data, mediaCategory, variant } = useAttachmentContext(); + + const iconSize = variant === "inline" ? "size-3" : "size-4"; + + const renderIcon = (Icon: typeof ImageIcon) => ( + + ); + + const renderContent = () => { + if (mediaCategory === "image" && data.type === "file" && data.url) { + return renderAttachmentImage(data.url, data.filename, variant === "grid"); + } + + if (mediaCategory === "video" && data.type === "file" && data.url) { + return