diff --git a/CLAUDE.md b/CLAUDE.md index df4bad9..cda9dd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,6 @@ function App() { - `keyboardShortcut`: Keyboard shortcut to open search (default: "cmd+k") - `buttonText`: Custom search button text - `buttonProps`: Additional props for the search button -- `baseAskaiUrl`: Custom AI chat API endpoint ## 🎯 Key Components diff --git a/apps/docs/content/docs/experiences/ask-ai/sidepanel-askai.mdx b/apps/docs/content/docs/experiences/ask-ai/sidepanel-askai.mdx new file mode 100644 index 0000000..8807dea --- /dev/null +++ b/apps/docs/content/docs/experiences/ask-ai/sidepanel-askai.mdx @@ -0,0 +1,153 @@ +--- +title: Sidepanel Ask AI +description: "Sidepanel chat experience with Ask AI, powered by Algolia's Ask AI. Opens as a side panel that doesn't block the rest of the page." +--- + +import { File, Folder, Files } from 'fumadocs-ui/components/files'; +import { Tabs, TabsList, TabsTrigger, TabsContent, TabsContents } from '@/components/animate-ui/components/animate/tabs'; +import { LogosShadcn } from '@/components/icons/shadcn'; +import { TypeTable } from 'fumadocs-ui/components/type-table'; + +A sidepanel chat experience that opens from the right side of the screen, allowing users to interact with [Algolia's Ask AI](https://www.algolia.com/products/ai/ask-ai) without blocking the rest of the page. Perfect for documentation sites, help centers, or any context where users need quick AI assistance while browsing. + +## Use cases + +- Documentation site AI assistant +- Help center chat support +- Non-blocking AI chat interface +- Side-by-side browsing and AI interaction +- Keyboard-accessible chat experience + +## Preview + + + + +
+

Usage

+ + shadcn/ui + +
+ + +```tsx +npx shadcn@latest add @algolia/sidepanel-askai +``` + +This will add the `Sidepanel Ask AI` experience to your project under `components/sidepanel-askai`. Use it like this: + +```tsx +import SidepanelExperience from "@/components/sidepanel-askai"; + + +``` + +### Configuration + +Required props for using the `SidepanelExperience` component: + +- `applicationId`: The Algolia application ID +- `apiKey`: The Algolia API key +- `indexName`: The Algolia index name +- `assistantId`: The Ask AI assistant ID + +### Structure + + + + + + + + + + + + + + + +
+ +## Behavior + +- **Opens via button or keyboard**: Click the "Ask AI" button or press `Cmd+I` (Mac) / `Ctrl+I` (Windows) +- **Non-blocking**: The rest of the page remains interactive while the sidepanel is open +- **Resizable**: Toggle between 360px (default) and 580px (maximized) width on desktop +- **Keyboard accessible**: Press `Escape` to close, `Enter` to send messages +- **Auto-scroll**: Chat automatically scrolls to show new messages +- **Mobile responsive**: Full-width on mobile, fixed width on desktop + +## References +", + parameters: [ + { + name: "onClick", + description: "The function to call when the button is clicked", + }, + { + name: "children", + description: "The children to render in the button (optional)", + }, + ], + required: false, + }, + }} +/> + +## Extending further + +- Change the styling to match your brand +- Customize the sidepanel width and animations +- Add custom message handlers +- Integrate with your analytics system +- Open in v0 for more customization + diff --git a/apps/docs/content/docs/experiences/search-askai.mdx b/apps/docs/content/docs/experiences/search-askai.mdx index aac3552..b0e29fb 100644 --- a/apps/docs/content/docs/experiences/search-askai.mdx +++ b/apps/docs/content/docs/experiences/search-askai.mdx @@ -200,18 +200,18 @@ Required props for using the `SearchWithAskAi` component: ], required: true, }, - baseAskaiUrl: { - description: "Base URL for AI chat API (optional, defaults to beta endpoint)", - type: "string", - default: "\"https://askai.algolia.com\"", - required: false, - }, placeholder: { description: "Placeholder text for search input (optional, defaults to \"What are you looking for?\")", type: "string", default: "\"What are you looking for?\"", required: false, }, + suggestedQuestionsEnabled: { + description: "Suggested Questions Enabled (optional, defaults to false)", + type: "boolean", + default: false, + required: false, + }, hitsPerPage: { description: "Number of hits per page (optional, defaults to 8)", type: "number", diff --git a/apps/docs/content/docs/experiences/search.mdx b/apps/docs/content/docs/experiences/search.mdx index 59824d5..1bcc135 100644 --- a/apps/docs/content/docs/experiences/search.mdx +++ b/apps/docs/content/docs/experiences/search.mdx @@ -179,12 +179,6 @@ import Search from "@/components/search"; ], required: true, }, - baseAskaiUrl: { - description: "Base URL for AI chat API (optional, defaults to beta endpoint)", - type: "string", - default: "\"https://askai.algolia.com\"", - required: false, - }, placeholder: { description: "Placeholder text for search input (optional, defaults to \"What are you looking for?\")", type: "string", diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index bf5df08..a4562b8 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -11,6 +11,7 @@ "experiences/search-askai", "experiences/dropdown-search", "---Ask AI Experiences---", + "experiences/ask-ai/sidepanel-askai", "experiences/ask-ai/highlight-to-askai" ] } diff --git a/apps/docs/public/r/dropdown-search.json b/apps/docs/public/r/dropdown-search.json new file mode 100644 index 0000000..e8246c2 --- /dev/null +++ b/apps/docs/public/r/dropdown-search.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "dropdown-search", + "type": "registry:block", + "title": "Algolia dropdown search experience", + "description": "Inline autocomplete dropdown search experience, powered by Algolia's InstantSearch", + "dependencies": [ + "algoliasearch@^5", + "react-instantsearch@^7.16.2", + "lucide-react", + "@radix-ui/react-popover" + ], + "registryDependencies": [ + "input" + ], + "files": [ + { + "path": "src/registry/experiences/dropdown-search/components/dropdown-search.tsx", + "content": "/** biome-ignore-all lint/a11y/useSemanticElements: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useFocusableInteractive: hand crafted interactions */\n/** biome-ignore-all lint/suspicious/noExplicitAny: too ambiguous */\n/** biome-ignore-all lint/a11y/noStaticElementInteractions: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useKeyWithClickEvents: hand crafted interactions */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport * as Popover from \"@radix-ui/react-popover\";\nimport { liteClient as algoliasearch } from \"algoliasearch/lite\";\nimport { SearchIcon } from \"lucide-react\";\nimport type React from \"react\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n Configure,\n Highlight,\n InstantSearch,\n useHits,\n useSearchBox,\n} from \"react-instantsearch\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { useKeyboardNavigation } from \"@/registry/experiences/dropdown-search/hooks/use-keyboard-navigation\";\n\nexport interface DropdownSearchConfig {\n /** Algolia Application ID (required) */\n applicationId: string;\n /** Algolia API Key (required) */\n apiKey: string;\n /** Algolia Index Name (required) */\n indexName: string;\n /** Placeholder text for search input (optional, defaults to \"Search...\") */\n placeholder?: string;\n /** Number of hits per page (optional, defaults to 5) */\n hitsPerPage?: number;\n /** Map which hit attributes to render (supports dotted paths) */\n attributes?: HitsAttributesMapping;\n /** Custom className for the root container */\n className?: string;\n /** Max height for dropdown (optional, defaults to \"300px\") */\n maxHeight?: string;\n /** Enable Algolia Insights (optional, defaults to true) */\n insights?: boolean;\n /** Additional Algolia search parameters (optional) - e.g., analytics, filters, distinct, etc. */\n searchParameters?: Record;\n}\n\n// =========================================================================\n// Attribute Mapping\n// =========================================================================\n\ntype HitsAttributesMapping = {\n primaryText: string;\n secondaryText?: string;\n tertiaryText?: string;\n url?: string;\n image?: string;\n};\n\nfunction toAttributePath(attribute?: string): string | string[] | undefined {\n if (!attribute) return undefined;\n return attribute.includes(\".\") ? attribute.split(\".\") : attribute;\n}\n\nfunction getByPath(obj: unknown, path?: string): T | undefined {\n if (!obj || !path) return undefined;\n const parts = path.split(\".\");\n let current: unknown = obj;\n for (const part of parts) {\n if (current == null || typeof current !== \"object\") return undefined;\n current = (current as Record)[part];\n }\n return current as T | undefined;\n}\n\n// ============================================================================\n// Internal Components\n// ============================================================================\n\ninterface HitsListProps {\n hits: any[];\n query: string;\n selectedIndex: number;\n attributes?: HitsAttributesMapping;\n onItemClick?: () => void;\n onHoverIndex?: (index: number) => void;\n hoverEnabled?: boolean;\n sendEvent?: (eventType: \"click\", hit: any, eventName: string) => void;\n}\n\nconst HitsList = memo(function HitsList({\n hits,\n selectedIndex,\n attributes,\n onItemClick,\n onHoverIndex,\n hoverEnabled,\n sendEvent,\n}: HitsListProps) {\n const [failedImages, setFailedImages] = useState>({});\n const mapping = useMemo(\n () => ({\n primaryText: attributes?.primaryText,\n secondaryText: attributes?.secondaryText,\n tertiaryText: attributes?.tertiaryText,\n url: attributes?.url,\n image: attributes?.image,\n }),\n [attributes],\n );\n\n if (!attributes || !mapping.primaryText) {\n throw new Error(\"At least a primaryText is required to display results\");\n }\n\n return (\n <>\n {hits.map((hit: any, idx: number) => {\n const isSel = selectedIndex === idx;\n const imageUrl = getByPath(hit, mapping.image);\n const url = getByPath(hit, mapping.url);\n const hasImage = Boolean(imageUrl);\n const isImageFailed = failedImages[hit.objectID] || !hasImage;\n const primaryVal = getByPath(hit, mapping.primaryText);\n return (\n {\n sendEvent?.(\"click\", hit, \"Hit Clicked\");\n onItemClick?.();\n }}\n className=\"flex flex-row items-center gap-3 cursor-pointer text-decoration-none text-foreground bg-background rounded-sm p-3 aria-selected:bg-accent transition-colors\"\n role=\"option\"\n aria-selected={isSel}\n onMouseEnter={() => {\n if (!hoverEnabled) return;\n onHoverIndex?.(idx);\n }}\n onMouseMove={() => {\n if (!hoverEnabled) return;\n onHoverIndex?.(idx);\n }}\n >\n {hasImage ? (\n
\n {!isImageFailed ? (\n \n setFailedImages((prev) => ({\n ...prev,\n [hit.objectID]: true,\n }))\n }\n />\n ) : (\n \n \n
\n )}\n \n ) : null}\n
\n

\n \n

\n {mapping.secondaryText ? (\n

\n \n

\n ) : null}\n {mapping.tertiaryText ? (\n

\n \n

\n ) : null}\n
\n \n );\n })}\n \n );\n});\n\ninterface SearchInputProps {\n placeholder?: string;\n className?: string;\n inputRef: React.RefObject;\n onArrowDown?: () => void;\n onEnter?: () => void;\n onArrowUp?: () => void;\n}\n\nconst SearchInput = memo(function SearchInput(props: SearchInputProps) {\n const { query, refine } = useSearchBox();\n const [inputValue, setInputValue] = useState(query || \"\");\n\n function setQuery(newQuery: string) {\n setInputValue(newQuery);\n refine(newQuery);\n }\n\n return (\n
\n \n {\n setQuery(event.currentTarget.value);\n }}\n onKeyDown={(e) => {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n props.onArrowDown?.();\n return;\n }\n if (e.key === \"ArrowUp\") {\n e.preventDefault();\n props.onArrowUp?.();\n return;\n }\n if (e.key === \"Enter\") {\n e.preventDefault();\n props.onEnter?.();\n }\n }}\n />\n
\n );\n});\n\ninterface DropdownContentProps {\n query: string;\n selectedIndex: number;\n config: DropdownSearchConfig;\n onItemClick?: () => void;\n onHoverIndex?: (index: number) => void;\n scrollOnSelectionChange?: boolean;\n sendEvent?: (eventType: \"click\", hit: any, eventName: string) => void;\n}\n\nconst DropdownContent = memo(function DropdownContent({\n query,\n selectedIndex,\n config,\n onItemClick,\n onHoverIndex,\n scrollOnSelectionChange = true,\n sendEvent,\n}: DropdownContentProps) {\n const { items } = useHits();\n const containerRef = useRef(null);\n const noResults = items.length === 0;\n const [hoverEnabled, setHoverEnabled] = useState(false);\n\n // Enable hover selection only after the user moves the pointer inside the list\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n setHoverEnabled(false);\n const enable = () => setHoverEnabled(true);\n container.addEventListener(\"pointermove\", enable, { once: true } as any);\n return () => {\n container.removeEventListener(\"pointermove\", enable as any);\n };\n }, []);\n\n // Scroll selected item into view\n // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n useEffect(() => {\n if (!scrollOnSelectionChange) return;\n const container = containerRef.current;\n if (!container) return;\n const selectedEl = container.querySelector(\n '[aria-selected=\"true\"]',\n ) as HTMLElement | null;\n if (!selectedEl) return;\n\n const padding = 8;\n const cRect = container.getBoundingClientRect();\n const iRect = selectedEl.getBoundingClientRect();\n\n if (iRect.top < cRect.top + padding) {\n container.scrollTop -= cRect.top + padding - iRect.top;\n } else if (iRect.bottom > cRect.bottom - padding) {\n container.scrollTop += iRect.bottom - (cRect.bottom - padding);\n }\n }, [selectedIndex, items.length, scrollOnSelectionChange]);\n\n const maxHeight = config.maxHeight || \"300px\";\n\n if (noResults) {\n return (\n
\n No results for "{query}"\n
\n );\n }\n\n return (\n \n \n \n );\n});\n\ninterface DropdownSearchInnerProps {\n config: DropdownSearchConfig;\n}\n\nfunction DropdownSearchInner({ config }: DropdownSearchInnerProps) {\n const { query, refine } = useSearchBox();\n const inputRef = useRef(null);\n const { items, sendEvent } = useHits();\n const [open, setOpen] = useState(false);\n\n const {\n selectedIndex,\n moveDown,\n moveUp,\n activateSelection,\n hoverIndex,\n selectionOrigin,\n } = useKeyboardNavigation(items, query);\n\n // Control popover open state based on query\n useEffect(() => {\n setOpen(!!query && query.length > 0);\n }, [query]);\n\n const handleActivateSelection = useCallback((): boolean => {\n // Send click event for keyboard navigation before activating\n if (selectedIndex >= 0 && selectedIndex < items.length) {\n const hit = items[selectedIndex];\n if (hit) {\n sendEvent?.(\"click\", hit, \"Hit Clicked\");\n }\n }\n\n if (activateSelection()) {\n setOpen(false);\n refine(\"\");\n return true;\n }\n return false;\n }, [activateSelection, refine, selectedIndex, items, sendEvent]);\n\n const handleItemClick = useCallback(() => {\n setOpen(false);\n refine(\"\");\n }, [refine]);\n\n return (\n <>\n \n \n \n
\n \n
\n
\n \n {\n // Prevent auto-focusing the popover content, keep focus on input\n e.preventDefault();\n }}\n >\n {query ? (\n \n ) : (\n
\n Start typing to search\n
\n )}\n
\n Powered by Algolia\n
\n \n
\n
\n \n );\n}\n\nexport default function DropdownSearchExperience(config: DropdownSearchConfig) {\n const searchClient = algoliasearch(config.applicationId, config.apiKey);\n searchClient.addAlgoliaAgent(\"algolia-sitesearch\");\n\n return (\n
\n \n \n \n
\n );\n}\n", + "type": "registry:component" + }, + { + "path": "src/registry/experiences/dropdown-search/hooks/use-keyboard-navigation.ts", + "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\n\ninterface UseKeyboardNavigationReturn {\n selectedIndex: number;\n moveDown: () => void;\n moveUp: () => void;\n activateSelection: () => boolean;\n hoverIndex: (index: number) => void;\n selectionOrigin: \"keyboard\" | \"pointer\" | \"init\";\n}\n\nexport function useKeyboardNavigation(\n hits: any[],\n query: string,\n): UseKeyboardNavigationReturn {\n const [selectedIndex, setSelectedIndex] = useState(0);\n const [selectionOrigin, setSelectionOrigin] = useState<\n \"keyboard\" | \"pointer\" | \"init\"\n >(\"init\");\n\n const totalItems = useMemo(() => hits.length, [hits.length]);\n\n const moveDown = useCallback(() => {\n setSelectedIndex((prev) => (prev + 1) % totalItems);\n setSelectionOrigin(\"keyboard\");\n }, [totalItems]);\n\n const moveUp = useCallback(() => {\n setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);\n setSelectionOrigin(\"keyboard\");\n }, [totalItems]);\n\n const hoverIndex = useCallback(\n (index: number) => {\n if (index < 0 || index >= totalItems) return;\n setSelectedIndex(index);\n setSelectionOrigin(\"pointer\");\n },\n [totalItems],\n );\n\n const activateSelection = useCallback((): boolean => {\n const hit = hits[selectedIndex];\n if (hit?.url) {\n window.open(hit.url, \"_blank\", \"noopener,noreferrer\");\n return true;\n }\n return false;\n }, [selectedIndex, hits]);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n useEffect(() => {\n setSelectedIndex(0);\n setSelectionOrigin(\"init\");\n }, [query]);\n\n return {\n selectedIndex,\n moveDown,\n moveUp,\n activateSelection,\n hoverIndex,\n selectionOrigin,\n };\n}\n", + "type": "registry:hook" + }, + { + "path": "src/registry/experiences/dropdown-search/page.tsx", + "content": "\"use client\";\n\nimport DropdownSearch from \"@/registry/experiences/dropdown-search/components/dropdown-search\";\n\nexport default function Page() {\n return (\n
\n
\n \n
\n
\n );\n}\n", + "type": "registry:page", + "target": "app/dropdown-search/page.tsx" + } + ], + "categories": [ + "search" + ] +} \ No newline at end of file diff --git a/apps/docs/public/r/highlight-to-askai.json b/apps/docs/public/r/highlight-to-askai.json new file mode 100644 index 0000000..682dc65 --- /dev/null +++ b/apps/docs/public/r/highlight-to-askai.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "highlight-to-askai", + "type": "registry:block", + "title": "Highlight to Ask AI [Experimental]", + "description": "🚧 [Experimental] Highlight any text and ask Algolia Ask AI with an inline tooltip", + "dependencies": [ + "@floating-ui/react", + "@ai-sdk/react@^2.0.4", + "ai@^5.0.30", + "lucide-react", + "marked" + ], + "registryDependencies": [ + "button", + "input" + ], + "files": [ + { + "path": "src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx", + "content": "/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering, sanitized by marked */\n/** biome-ignore-all lint/suspicious/noArrayIndexKey: parts are stable during render */\n\"use client\";\n\nimport type { Placement, ReferenceType } from \"@floating-ui/react\";\nimport {\n FloatingPortal,\n flip,\n offset,\n shift,\n useFloating,\n} from \"@floating-ui/react\";\nimport {\n BrainIcon,\n CornerDownLeftIcon,\n SearchIcon,\n ThumbsDown,\n ThumbsUp,\n} from \"lucide-react\";\nimport { marked } from \"marked\";\nimport React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n postFeedback,\n useAskai,\n} from \"@/registry/experiences/highlight-to-askai/hooks/use-askai\";\n\ntype OnAskPayload = {\n text: string;\n html: string;\n rect: DOMRect;\n range: Range;\n contextNode: HTMLElement;\n};\n\nexport type HighlightAskAIProps = {\n applicationId: string;\n apiKey: string;\n indexName: string;\n assistantId: string;\n excludeElements?: string[];\n side?: Placement;\n sideOffset?: number;\n delay?: number;\n askButtonLabel?: string;\n className?: string;\n askAiBaseUrl?: string;\n onAsk?: (payload: OnAskPayload) => void | Promise;\n children: React.ReactNode;\n};\n\ntype VirtualElement = {\n getBoundingClientRect: () => DOMRect;\n contextElement?: Element | null;\n};\n\nfunction isWhitespaceOnly(text: string) {\n return text.trim().length === 0;\n}\n\nfunction selectionIntersectsContainer(\n range: Range,\n container: HTMLElement,\n): boolean {\n const startContainer = range.startContainer as Node | null;\n const endContainer = range.endContainer as Node | null;\n if (!startContainer || !endContainer) return false;\n return container.contains(startContainer) || container.contains(endContainer);\n}\n\nfunction nodeIsExcluded(node: Node, selectors: string[]): boolean {\n let el: Element | null = null;\n if (node instanceof Element) {\n el = node;\n } else if ((node as ChildNode).parentElement) {\n el = (node as ChildNode).parentElement;\n }\n if (!el) return false;\n for (const sel of selectors) {\n if (el.closest(sel)) return true;\n }\n return false;\n}\n\nconst AlgoliaLogo = ({ size = 52 }: { size?: number | string }) => (\n \n \n {/* eslint-disable-nextLine @docusaurus/no-untranslated-text */}\n \n \n \n \n \n \n \n \n \n \n \n \n);\n\nexport function HighlightAskAI({\n applicationId,\n apiKey,\n indexName,\n assistantId,\n askAiBaseUrl,\n excludeElements = [\"pre\", \"code\"],\n side = \"top\",\n sideOffset = 8,\n delay = 120,\n askButtonLabel = \"Ask AI?\",\n className,\n onAsk,\n children,\n}: HighlightAskAIProps) {\n const containerRef = React.useRef(null);\n const [open, setOpen] = React.useState(false);\n const [expanded, setExpanded] = React.useState(false);\n const [range, setRange] = React.useState(null);\n const [virtualEl, setVirtualEl] = React.useState(null);\n const [followUpInput, setFollowUpInput] = React.useState(\"\");\n const [feedbackGiven, setFeedbackGiven] = React.useState(false);\n const [submittingFeedback, setSubmittingFeedback] = React.useState(false);\n\n const { messages, error, isGenerating, sendMessage, setMessages, stop } =\n useAskai({\n applicationId,\n apiKey,\n indexName,\n assistantId,\n baseAskaiUrl: askAiBaseUrl,\n });\n const resetConversation = React.useCallback(() => {\n try {\n stop?.();\n } catch {}\n setMessages?.([]);\n setFollowUpInput(\"\");\n setFeedbackGiven(false);\n setSubmittingFeedback(false);\n }, [setMessages, stop]);\n\n const { refs, floatingStyles, update } = useFloating<\n HTMLElement | ReferenceType\n >({\n placement: side,\n middleware: [offset(sideOffset), flip(), shift()],\n // Don't use autoUpdate - we want the tooltip to stay in place on scroll\n });\n\n // Keep placement in sync with prop changes\n React.useEffect(() => {\n update?.();\n }, [update]);\n\n React.useEffect(() => {\n if (virtualEl) refs.setReference(virtualEl as unknown as ReferenceType);\n }, [virtualEl, refs.setReference]);\n\n // Debounced selection handler\n const debounceRef = React.useRef(null);\n const handleSelectionChange = React.useCallback(() => {\n if (debounceRef.current) window.clearTimeout(debounceRef.current);\n debounceRef.current = window.setTimeout(() => {\n const sel = window.getSelection();\n const containerEl = containerRef.current;\n const floatingEl = refs.floating.current;\n const activeEl =\n typeof document !== \"undefined\"\n ? (document.activeElement as Node | null)\n : null;\n\n // Ignore selection changes while focus is inside the floating tooltip\n if (floatingEl && activeEl && floatingEl.contains(activeEl)) {\n return;\n }\n\n if (!sel || !containerEl) return;\n if (sel.rangeCount === 0 || sel.isCollapsed) {\n // Don't close if already expanded; allow typing in the tooltip\n if (!expanded) {\n setOpen(false);\n setExpanded(false);\n setRange(null);\n setVirtualEl(null);\n }\n return;\n }\n\n const nextRange = sel.getRangeAt(0);\n const text = sel.toString();\n if (isWhitespaceOnly(text)) {\n if (!expanded) {\n setOpen(false);\n setExpanded(false);\n setRange(null);\n setVirtualEl(null);\n }\n return;\n }\n\n if (!selectionIntersectsContainer(nextRange, containerEl)) {\n if (!expanded) {\n setOpen(false);\n setExpanded(false);\n setRange(null);\n setVirtualEl(null);\n }\n return;\n }\n\n const anchorNode = sel.anchorNode;\n const focusNode = sel.focusNode;\n if (\n (anchorNode && nodeIsExcluded(anchorNode, excludeElements)) ||\n (focusNode && nodeIsExcluded(focusNode, excludeElements))\n ) {\n if (!expanded) {\n setOpen(false);\n setExpanded(false);\n setRange(null);\n setVirtualEl(null);\n }\n return;\n }\n\n const rect = nextRange.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) {\n if (!expanded) {\n setOpen(false);\n setExpanded(false);\n setRange(null);\n setVirtualEl(null);\n }\n return;\n }\n\n const v: VirtualElement = {\n getBoundingClientRect: () => rect,\n };\n setRange(nextRange);\n setVirtualEl(v);\n setOpen(true);\n setExpanded(false);\n // Trigger update after setting v\n requestAnimationFrame(() => {\n update?.();\n });\n }, delay);\n }, [delay, excludeElements, expanded, refs.floating, update]);\n\n React.useEffect(() => {\n document.addEventListener(\"selectionchange\", handleSelectionChange);\n return () =>\n document.removeEventListener(\"selectionchange\", handleSelectionChange);\n }, [handleSelectionChange]);\n\n // Close on Escape and click outside\n React.useEffect(() => {\n function onKeyDown(e: KeyboardEvent) {\n if (e.key === \"Escape\") {\n setOpen(false);\n setExpanded(false);\n resetConversation();\n }\n }\n function onMouseDown(e: MouseEvent) {\n if (!open) return;\n\n const floatingEl = refs.floating.current;\n const containerEl = containerRef.current;\n const target = e.target as Node | null;\n\n if (!target) return;\n\n // Don't close if clicking inside the floating tooltip\n if (floatingEl?.contains(target)) {\n return;\n }\n\n // Don't close if clicking inside the container (but outside selection)\n if (containerEl?.contains(target)) {\n return;\n }\n\n // Click is outside both - close the tooltip\n setOpen(false);\n setExpanded(false);\n resetConversation();\n }\n document.addEventListener(\"keydown\", onKeyDown);\n document.addEventListener(\"mousedown\", onMouseDown);\n return () => {\n document.removeEventListener(\"keydown\", onKeyDown);\n document.removeEventListener(\"mousedown\", onMouseDown);\n };\n }, [open, refs.floating, resetConversation]);\n\n // Build HTML content string for onAsk consumers\n const getSelectionHtml = React.useCallback((rangeArg: Range | null) => {\n if (!rangeArg) return \"\";\n const fragment = rangeArg.cloneContents();\n const div = document.createElement(\"div\");\n div.appendChild(fragment);\n return div.innerHTML;\n }, []);\n\n const handleAskClick = React.useCallback(async () => {\n if (!range) return;\n const text = range.toString();\n const html = getSelectionHtml(range);\n const rect = range.getBoundingClientRect();\n const node = range.commonAncestorContainer as Node;\n const contextElement =\n node instanceof HTMLElement ? node : node.parentElement;\n const contextNode = (contextElement as HTMLElement) ?? document.body;\n\n setExpanded(true);\n // starting a fresh question: clear past conversation first\n resetConversation();\n if (onAsk) {\n try {\n await onAsk({ text, html, rect, range, contextNode });\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\"onAsk error\", err);\n }\n }\n\n const prompt = `Can you tell me what exactly is '${text}' ? be concise`;\n try {\n await sendMessage({ text: prompt });\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error(\"sendMessage error\", err);\n }\n }, [getSelectionHtml, onAsk, range, resetConversation, sendMessage]);\n\n return (\n
\n {children}\n {open && virtualEl && (\n \n \n {!expanded ? (\n
\n \n {askButtonLabel}\n \n
\n ) : (\n
\n \n \n Powered by\n \n \n \n {\n setOpen(false);\n setExpanded(false);\n resetConversation();\n }}\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n className=\"p-1.5 text-xs text-muted-foreground\"\n >\n esc\n \n
\n )}\n {expanded && (\n
\n
\n {(() => {\n const assistant = (messages || [])\n .slice()\n .reverse()\n .find(\n (m: unknown) =>\n (m as { role?: string }).role === \"assistant\",\n );\n if (!assistant)\n return (\n \n {isGenerating ? \"Thinking...\" : \"\"}\n \n );\n const parts =\n (\n assistant as {\n parts?: Array<\n | string\n | {\n type: string;\n text?: string;\n state?: string;\n input?: { query?: string };\n output?: { query?: string; hits?: unknown[] };\n errorText?: string;\n }\n >;\n }\n ).parts ?? [];\n\n return (\n <>\n {parts.map((part, index) => {\n if (typeof part === \"string\") {\n return

{part}

;\n }\n if (part.type === \"text\") {\n const html = marked.parse(part.text || \"\");\n return (\n \n );\n } else if (\n part.type === \"reasoning\" &&\n part.state === \"streaming\"\n ) {\n return (\n \n Reasoning...\n

\n );\n } else if (part.type === \"tool-searchIndex\") {\n if (part.state === \"input-streaming\") {\n return (\n \n Searching...\n

\n );\n } else if (part.state === \"input-available\") {\n return (\n \n Looking for{\" \"}\n \n "{part.input?.query || \"\"}"\n \n

\n );\n } else if (part.state === \"output-available\") {\n return (\n \n {\" \"}\n \n Searched for{\" \"}\n \n "{part.output?.query}"\n {\" \"}\n found {part.output?.hits?.length || \"no\"}{\" \"}\n results\n \n

\n );\n } else if (part.state === \"output-error\") {\n return (\n \n {part.errorText}\n

\n );\n }\n }\n return null;\n })}\n \n );\n })()}\n
\n {error ? (\n
\n {String(\n (error as unknown as { message?: string }).message ||\n error,\n )}\n
\n ) : null}\n {(() => {\n const hasAssistantResponse = (messages || []).some(\n (m: unknown) =>\n (m as { role?: string }).role === \"assistant\",\n );\n if (!hasAssistantResponse || isGenerating) return null;\n\n const userMessage = (messages || []).find(\n (m: unknown) => (m as { role?: string }).role === \"user\",\n ) as { id?: string } | undefined;\n\n return (\n <>\n
\n
\n {feedbackGiven ? (\n \n Thanks for your feedback!\n \n ) : submittingFeedback ? (\n \n Submitting...\n \n ) : (\n
\n {\n if (!userMessage?.id) return;\n try {\n setSubmittingFeedback(true);\n await postFeedback({\n assistantId,\n appId: applicationId,\n messageId: userMessage.id,\n thumbs: 1,\n });\n setFeedbackGiven(true);\n } catch {\n // ignore errors\n } finally {\n setSubmittingFeedback(false);\n }\n }}\n >\n \n \n {\n if (!userMessage?.id) return;\n try {\n setSubmittingFeedback(true);\n await postFeedback({\n assistantId,\n appId: applicationId,\n messageId: userMessage.id,\n thumbs: 0,\n });\n setFeedbackGiven(true);\n } catch {\n // ignore errors\n } finally {\n setSubmittingFeedback(false);\n }\n }}\n >\n \n \n
\n )}\n
\n {\n e.preventDefault();\n if (followUpInput.trim()) {\n sendMessage({ text: followUpInput.trim() });\n setFollowUpInput(\"\");\n }\n }}\n className=\"flex items-center gap-2 p-3\"\n >\n setFollowUpInput(e.target.value)}\n placeholder=\"Ask a follow-up...\"\n className=\"flex-1 text-sm\"\n />\n \n \n \n \n
\n \n );\n })()}\n
\n )}\n
\n \n )}\n \n );\n}\n\nexport default HighlightAskAI;\n", + "type": "registry:component" + }, + { + "path": "src/registry/experiences/highlight-to-askai/hooks/use-askai.ts", + "content": "import { useChat } from \"@ai-sdk/react\";\nimport {\n DefaultChatTransport,\n lastAssistantMessageIsCompleteWithToolCalls,\n} from \"ai\";\nimport { useMemo } from \"react\";\n\nexport interface AskAIConfig {\n applicationId: string;\n apiKey: string;\n indexName: string;\n assistantId: string;\n baseAskaiUrl?: string;\n}\n\nexport function useAskai(config: AskAIConfig) {\n if (!config) {\n throw new Error(\"config is required for useAskai\");\n }\n\n const baseUrl = config.baseAskaiUrl || \"https://askai.algolia.com\";\n\n const transport = useMemo(() => {\n return new DefaultChatTransport({\n api: `${baseUrl}/chat`,\n headers: async () => {\n const token = await getValidToken({ assistantId: config.assistantId });\n return {\n \"x-algolia-api-key\": config.apiKey,\n \"x-algolia-application-id\": config.applicationId,\n \"x-algolia-index-name\": config.indexName,\n \"x-algolia-assistant-id\": config.assistantId,\n \"x-ai-sdk-version\": \"v5\",\n authorization: `TOKEN ${token}`,\n } as Record;\n },\n });\n }, [\n baseUrl,\n config.apiKey,\n config.applicationId,\n config.indexName,\n config.assistantId,\n ]);\n\n const chat = useChat({\n transport,\n sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,\n async onToolCall({ toolCall }) {\n if (toolCall.dynamic) return;\n },\n });\n\n const isGenerating =\n chat.status === \"submitted\" || chat.status === \"streaming\";\n\n return {\n ...chat,\n isGenerating,\n };\n}\n\nconst BASE_ASKAI_URL = \"https://askai.algolia.com\";\nconst TOKEN_KEY = \"askai_token\";\n\ntype TokenPayload = { exp: number };\n\nconst decode = (token: string): TokenPayload => {\n const [b64] = token.split(\".\");\n return JSON.parse(atob(b64));\n};\n\nconst isExpired = (token?: string | null): boolean => {\n if (!token) return true;\n try {\n const { exp } = decode(token);\n // refresh 30 s before the backend rejects it\n return Date.now() / 1000 > exp - 30;\n } catch {\n return true;\n }\n};\n\nlet inflight: Promise | null = null;\n\n// call /token once, cache the promise while it’s running\n// eslint-disable-next-line require-await\nexport const getValidToken = async ({\n assistantId,\n}: {\n assistantId: string;\n}): Promise => {\n const cached = sessionStorage.getItem(TOKEN_KEY);\n if (!isExpired(cached)) return cached as string;\n\n if (!inflight) {\n inflight = fetch(`${BASE_ASKAI_URL}/chat/token`, {\n method: \"POST\",\n headers: {\n \"x-algolia-assistant-id\": assistantId,\n \"content-type\": \"application/json\",\n },\n })\n .then((r) => r.json())\n .then(({ token }) => {\n sessionStorage.setItem(TOKEN_KEY, token);\n return token;\n })\n .finally(() => {\n inflight = null;\n });\n }\n\n return inflight;\n};\n\nexport const postFeedback = async ({\n assistantId,\n thumbs,\n messageId,\n appId,\n}: {\n assistantId: string;\n thumbs: 0 | 1;\n messageId: string;\n appId: string;\n}): Promise => {\n const headers = new Headers();\n headers.set(\"x-algolia-assistant-id\", assistantId);\n headers.set(\"content-type\", \"application/json\");\n\n const token = await getValidToken({ assistantId });\n headers.set(\"authorization\", `TOKEN ${token}`);\n\n return fetch(`${BASE_ASKAI_URL}/chat/feedback`, {\n method: \"POST\",\n body: JSON.stringify({\n appId,\n messageId,\n thumbs,\n }),\n headers,\n });\n};\n", + "type": "registry:hook" + }, + { + "path": "src/registry/experiences/highlight-to-askai/page.tsx", + "content": "\"use client\";\n\nimport { HighlightAskAI } from \"@/registry/experiences/highlight-to-askai/components/highlight-to-askai\";\n\nexport default function Page() {\n return (\n
\n
\n \n
\n

Try it

\n

\n Select any text in this block to see a small tooltip. Click\n Ask AI? to expand the panel and stream an answer.\n

\n

\n The tooltip respects excluded elements like pre and\n code, and it uses smart placement to avoid viewport\n edges.\n

\n
\n \n

\n Tip: Try selecting a sentence across multiple lines.\n

\n
\n
\n );\n}\n", + "type": "registry:page", + "target": "app/highlight-to-askai/page.tsx" + } + ], + "categories": [ + "ai" + ] +} \ No newline at end of file diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index 7b63dc9..25cd3cd 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -4,6 +4,35 @@ "description": "Algolia opinionated site search experience", "homepage": "https://algolia.com", "items": [ + { + "name": "dropdown-search", + "type": "registry:block", + "title": "Algolia dropdown search experience", + "description": "Inline autocomplete dropdown search experience, powered by Algolia's InstantSearch", + "dependencies": [ + "algoliasearch@^5", + "react-instantsearch@^7.16.2", + "lucide-react", + "@radix-ui/react-popover" + ], + "registryDependencies": ["input"], + "files": [ + { + "path": "src/registry/experiences/dropdown-search/components/dropdown-search.tsx", + "type": "registry:component" + }, + { + "path": "src/registry/experiences/dropdown-search/hooks/use-keyboard-navigation.ts", + "type": "registry:hook" + }, + { + "path": "src/registry/experiences/dropdown-search/page.tsx", + "type": "registry:page", + "target": "app/dropdown-search/page.tsx" + } + ], + "categories": ["search"] + }, { "name": "search", "type": "registry:block", @@ -19,7 +48,6 @@ "devDependencies": ["tw-animate-css"], "css": { "@import \"tw-animate-css\"": {}, - "input[type=\"search\"]::-webkit-search-decoration,input[type=\"search\"]::-webkit-search-cancel-button,input[type=\"search\"]::-webkit-search-results-button,input[type=\"search\"]::-webkit-search-results-decoration": "-webkit-appearance: none;" }, "files": [ @@ -36,7 +64,8 @@ "type": "registry:page", "target": "app/search/page.tsx" } - ] + ], + "categories": ["search"] }, { "name": "search-ai", @@ -90,7 +119,81 @@ "type": "registry:page", "target": "app/search-ai/page.tsx" } - ] + ], + "categories": ["search", "ai"] + }, + { + "name": "highlight-to-askai", + "type": "registry:block", + "title": "Highlight to Ask AI [Experimental]", + "description": "🚧 [Experimental] Highlight any text and ask Algolia Ask AI with an inline tooltip", + "registryDependencies": ["button", "input"], + "dependencies": [ + "@floating-ui/react", + "@ai-sdk/react@^2.0.4", + "ai@^5.0.30", + "lucide-react", + "marked" + ], + "files": [ + { + "path": "src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx", + "type": "registry:component" + }, + { + "path": "src/registry/experiences/highlight-to-askai/hooks/use-askai.ts", + "type": "registry:hook" + }, + { + "path": "src/registry/experiences/highlight-to-askai/page.tsx", + "type": "registry:page", + "target": "app/highlight-to-askai/page.tsx" + } + ], + "categories": ["ai"] + }, + { + "name": "sidepanel-askai", + "type": "registry:block", + "title": "Sidepanel Ask AI", + "description": "Sidepanel chat experience with Ask AI, powered by Algolia's Ask AI", + "registryDependencies": ["button"], + "devDependencies": ["tw-animate-css"], + "dependencies": [ + "@ai-sdk/react@^2.0.4", + "ai@^5.0.30", + "lucide-react", + "marked", + "tw-animate-css" + ], + "css": { + "@import \"tw-animate-css\"": {}, + "@keyframes shiny-text": { + "0%,90%,100%": "{\nbackground-position: calc(-100% - var(--shiny-width)) 0;\n}", + "30%,60%": "{\nbackground-position: calc(100% + var(--shiny-width)) 0;\n}" + } + }, + "cssVars": { + "theme": { + "animate-shiny-text": "shiny-text 8s infinite" + } + }, + "files": [ + { + "path": "src/registry/experiences/sidepanel-askai/components/sidepanel-askai.tsx", + "type": "registry:component" + }, + { + "path": "src/registry/experiences/sidepanel-askai/hooks/use-askai.ts", + "type": "registry:hook" + }, + { + "path": "src/registry/experiences/sidepanel-askai/page.tsx", + "type": "registry:page", + "target": "app/sidepanel-askai/page.tsx" + } + ], + "categories": ["ai", "chat"] } ] } diff --git a/apps/docs/public/r/search-ai.json b/apps/docs/public/r/search-ai.json index afe0949..9ddc8c3 100644 --- a/apps/docs/public/r/search-ai.json +++ b/apps/docs/public/r/search-ai.json @@ -12,17 +12,21 @@ "tw-animate-css", "lucide-react" ], - "devDependencies": ["tw-animate-css"], - "registryDependencies": ["button"], + "devDependencies": [ + "tw-animate-css" + ], + "registryDependencies": [ + "button" + ], "files": [ { "path": "src/registry/experiences/search-askai/components/search-ai.tsx", - "content": "/** biome-ignore-all lint/suspicious/noArrayIndexKey: . */\n/** biome-ignore-all lint/a11y/useFocusableInteractive: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useSemanticElements: hand crafted interactions */\n/** biome-ignore-all lint/a11y/noStaticElementInteractions: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useKeyWithClickEvents: hand crafted interactions */\nimport type { UIMessage } from \"@ai-sdk/react\";\nimport type { UIDataTypes, UIMessagePart } from \"ai\";\nimport { liteClient as algoliasearch } from \"algoliasearch/lite\";\nimport {\n ArrowLeftIcon,\n BrainIcon,\n CheckIcon,\n CopyIcon,\n CornerDownLeftIcon,\n SearchIcon,\n SparklesIcon,\n ThumbsDown,\n ThumbsUp,\n} from \"lucide-react\";\nimport { marked, type Tokens } from \"marked\";\nimport type React from \"react\";\nimport {\n type ComponentPropsWithoutRef,\n type CSSProperties,\n type FC,\n memo,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n Configure,\n Highlight,\n InstantSearch,\n useHits,\n useInstantSearch,\n useSearchBox,\n} from \"react-instantsearch\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n postFeedback,\n useAskai,\n} from \"@/registry/experiences/search-askai/hooks/use-askai\";\nimport { useKeyboardNavigation } from \"@/registry/experiences/search-askai/hooks/use-keyboard-navigation\";\nimport { useSearchState } from \"@/registry/experiences/search-askai/hooks/use-search-state\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SearchWithAskAIConfig {\n /** Algolia Application ID (required) */\n applicationId: string;\n /** Algolia API Key (required) */\n apiKey: string;\n /** Algolia Index Name (required) */\n indexName: string;\n /** AI Assistant ID (required for chat functionality) */\n assistantId: string;\n /** Base URL for AI chat API (optional, defaults to beta endpoint) */\n baseAskaiUrl?: string;\n /** Placeholder text for search input (optional, defaults to \"What are you looking for?\") */\n placeholder?: string;\n /** Number of hits per page (optional, defaults to 8) */\n hitsPerPage?: number;\n /** Keyboard shortcut to open search (optional, defaults to \"cmd+k\") */\n keyboardShortcut?: string;\n /** Custom search button text (optional) */\n buttonText?: string;\n /** Custom search button props (optional) */\n buttonProps?: React.ComponentProps;\n}\n\ninterface SearchButtonProps {\n onClick: () => void;\n children?: React.ReactNode;\n}\n\nexport const SearchButton: React.FC = ({ onClick }) => {\n return (\n \n \n \n Search\n \n
\n ⌘ K\n
\n \n );\n};\n\nexport interface SearchIndexTool {\n input: {\n query: string;\n };\n output: {\n query: string;\n // biome-ignore lint/suspicious/noExplicitAny: too ambiguous\n hits: any[];\n };\n}\n\nexport type Message = UIMessage<\n unknown,\n UIDataTypes,\n {\n searchIndex: SearchIndexTool;\n }\n>;\n\nexport type AIMessagePart = UIMessagePart<\n UIDataTypes,\n {\n searchIndex: SearchIndexTool;\n }\n>;\n\ninterface Exchange {\n id: string;\n userMessage: Message;\n assistantMessage: Message | null;\n}\n\n// ============================================================================\n// Utilities & Helpers\n// ============================================================================\n\nfunction useClipboard() {\n const copyText = useCallback(async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n } catch {\n // Silently fail - clipboard access might be blocked\n }\n }, []);\n\n return { copyText };\n}\n\nfunction escapeHtml(html: string): string {\n return html\n .replace(/&/g, \"&\")\n .replace(//g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n// ============================================================================\n// Markdown Renderer\n// ============================================================================\n\nconst markdownRenderer = new marked.Renderer();\n\nmarkdownRenderer.code = ({ text, lang = \"\", escaped }: Tokens.Code): string => {\n const languageClass = lang ? `language-${lang}` : \"\";\n const safeCode = escaped ? text : escapeHtml(text);\n const encodedCode = encodeURIComponent(text);\n\n const copyIconSvg = `\n \n \n \n \n `;\n\n const checkIconSvg = `\n \n \n \n `;\n\n return `\n
\n \n
${safeCode}
\n
\n `;\n};\n\nmarkdownRenderer.link = ({ href, title, text }: Tokens.Link): string => {\n const titleAttr = title ? ` title=\"${escapeHtml(title)}\"` : \"\";\n const hrefAttr = href ? escapeHtml(href) : \"\";\n const textContent = text || \"\";\n\n return `${textContent}`;\n};\n\n// ============================================================================\n// Icon Components\n// ============================================================================\n\ninterface IconProps {\n size?: number | string;\n color?: string;\n className?: string;\n}\n\nconst AlgoliaLogo = ({ size = 150 }: IconProps) => (\n \n \n {/* eslint-disable-nextLine @docusaurus/no-untranslated-text */}\n \n \n \n \n \n \n \n \n \n \n \n \n);\n\n// ============================================================================\n// UI Helper Components\n// ============================================================================\nexport interface AnimatedShinyTextProps\n extends ComponentPropsWithoutRef<\"span\"> {\n shimmerWidth?: number;\n}\nexport const AnimatedShinyText: FC = ({\n children,\n shimmerWidth = 100,\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n\n// ============================================================================\n// Modal Component\n// ============================================================================\n\ninterface ModalProps {\n isOpen: boolean;\n onClose: () => void;\n children: React.ReactNode;\n}\n\nconst Modal: React.FC = ({ isOpen, onClose, children }) => {\n useEffect(() => {\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n onClose();\n }\n };\n\n if (isOpen) {\n document.addEventListener(\"keydown\", handleEscape);\n document.body.style.overflow = \"hidden\";\n }\n\n return () => {\n document.removeEventListener(\"keydown\", handleEscape);\n document.body.style.overflow = \"unset\";\n };\n }, [isOpen, onClose]);\n\n if (!isOpen) return null;\n\n return createPortal(\n \n e.stopPropagation()}\n >\n {children}\n \n ,\n document.body,\n );\n};\n\n// ============================================================================\n// Markdown Component\n// ============================================================================\n\ninterface MemoizedMarkdownProps {\n children: string;\n className?: string;\n}\n\nconst MemoizedMarkdown = memo(function MemoizedMarkdown({\n children,\n className = \"\",\n}: MemoizedMarkdownProps) {\n const containerRef = useRef(null);\n\n const html = useMemo(() => {\n try {\n return marked(children, {\n renderer: markdownRenderer,\n breaks: true,\n gfm: true,\n });\n } catch (error) {\n console.error(\"Error parsing markdown:\", error);\n return escapeHtml(children);\n }\n }, [children]);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n const handleCopyClick = async (event: Event) => {\n const target = event.target as HTMLElement;\n const button = target.closest(\n \".markdown-copy-button\",\n ) as HTMLButtonElement;\n\n if (!button) return;\n\n event.preventDefault();\n event.stopPropagation();\n\n const encodedCode = button.getAttribute(\"data-code\");\n if (!encodedCode) return;\n\n try {\n const code = decodeURIComponent(encodedCode);\n await navigator.clipboard.writeText(code);\n\n button.classList.add(\"markdown-copied\");\n\n setTimeout(() => {\n button.classList.remove(\"markdown-copied\");\n }, 2000);\n } catch (error) {\n console.error(\"Failed to copy code:\", error);\n }\n };\n\n container.addEventListener(\"click\", handleCopyClick);\n\n return () => {\n container.removeEventListener(\"click\", handleCopyClick);\n };\n }, [html]);\n\n return (\n \n );\n});\n\n// ============================================================================\n// Chat Component\n// ============================================================================\n\ninterface ChatWidgetProps {\n messages: Message[];\n error: Error | null;\n isGenerating: boolean;\n onCopy?: (text: string) => Promise | void;\n onThumbsUp?: (userMessageId: string) => Promise | void;\n onThumbsDown?: (userMessageId: string) => Promise | void;\n applicationId: string;\n assistantId: string;\n}\n\nconst ChatWidget = memo(function ChatWidget({\n messages,\n error,\n isGenerating,\n onCopy,\n onThumbsUp,\n onThumbsDown,\n applicationId,\n assistantId,\n}: ChatWidgetProps) {\n const { copyText } = useClipboard();\n const [copiedExchangeId, setCopiedExchangeId] = useState(null);\n const copyResetTimeoutRef = useRef(null);\n const [acknowledgedExchangeIds, setAcknowledgedExchangeIds] = useState<\n Set\n >(new Set());\n const [submittingExchangeId, setSubmittingExchangeId] = useState<\n string | null\n >(null);\n\n // Group messages into exchanges (user + assistant pairs)\n const exchanges = useMemo(() => {\n const grouped: Exchange[] = [];\n for (let i = 0; i < messages.length; i++) {\n const current = messages[i];\n if (current.role === \"user\") {\n const userMessage = current as Message;\n const nextMessage = messages[i + 1];\n if (nextMessage?.role === \"assistant\") {\n grouped.push({\n id: userMessage.id,\n userMessage,\n assistantMessage: nextMessage as Message,\n });\n i++; // Skip the assistant message since we've already processed it\n } else {\n // No assistant yet – show a pending exchange immediately\n grouped.push({\n id: userMessage.id,\n userMessage,\n assistantMessage: null,\n });\n }\n }\n }\n return grouped;\n }, [messages]);\n\n // Cleanup any pending reset timers on unmount\n useEffect(() => {\n return () => {\n if (copyResetTimeoutRef.current) {\n window.clearTimeout(copyResetTimeoutRef.current);\n }\n };\n }, []);\n\n return (\n
\n
\n

\n Answers are generated using AI and may make mistakes.\n

\n {/* errors */}\n {error && (\n
\n {error.message}\n
\n )}\n\n {/* exchanges */}\n {exchanges\n .slice()\n .reverse()\n .map((exchange, index) => {\n const isLastExchange = index === 0;\n\n return (\n \n
\n
\n {exchange.userMessage.parts.map((part, index) =>\n part.type === \"text\" ? (\n // biome-ignore lint/suspicious/noArrayIndexKey: better\n {part.text}\n ) : null,\n )}\n
\n
\n\n
\n
\n {exchange.assistantMessage ? (\n
\n {exchange.assistantMessage.parts.map((part, index) => {\n if (typeof part === \"string\") {\n return

{part}

;\n }\n if (part.type === \"text\") {\n return (\n \n {part.text}\n \n );\n } else if (\n part.type === \"reasoning\" &&\n part.state === \"streaming\"\n ) {\n return (\n \n {\" \"}\n \n Reasoning...\n \n

\n );\n } else if (part.type === \"tool-searchIndex\") {\n if (part.state === \"input-streaming\") {\n return (\n // biome-ignore lint/suspicious/noArrayIndexKey: better\n \n {\" \"}\n \n Searching...\n \n

\n );\n } else if (part.state === \"input-available\") {\n return (\n \n {\" \"}\n \n Looking for{\" \"}\n \n "{part.input?.query || \"\"}"\n \n \n

\n );\n } else if (part.state === \"output-available\") {\n return (\n \n {\" \"}\n \n Searched for{\" \"}\n \n "{part.output?.query}"\n {\" \"}\n found {part.output?.hits.length || \"no\"}{\" \"}\n results\n \n

\n );\n } else if (part.state === \"output-error\") {\n return (\n \n {part.errorText}\n

\n );\n } else {\n return null;\n }\n } else {\n return null;\n }\n })}\n
\n ) : (\n
\n \n {isGenerating && isLastExchange ? \"Thinking...\" : \"\"}\n \n
\n )}\n
\n
\n\n
\n {exchange.assistantMessage && !isGenerating ? (\n acknowledgedExchangeIds.has(exchange.id) ? (\n \n Thanks for your feedback!\n \n ) : submittingExchangeId === exchange.id ? (\n \n Submitting...\n \n ) : (\n
\n {\n if (!exchange.assistantMessage) return;\n try {\n setSubmittingExchangeId(exchange.id);\n if (onThumbsUp) {\n await onThumbsUp(exchange.userMessage.id);\n } else {\n await postFeedback({\n assistantId,\n appId: applicationId,\n messageId: exchange.userMessage.id,\n thumbs: 1,\n });\n }\n setAcknowledgedExchangeIds((prev) => {\n const next = new Set(prev);\n next.add(exchange.id);\n return next;\n });\n } catch {\n // ignore errors\n } finally {\n setSubmittingExchangeId(null);\n }\n }}\n >\n \n \n {\n if (!exchange.assistantMessage) return;\n try {\n setSubmittingExchangeId(exchange.id);\n if (onThumbsDown) {\n await onThumbsDown(exchange.userMessage.id);\n } else {\n await postFeedback({\n assistantId,\n appId: applicationId,\n messageId: exchange.userMessage.id,\n thumbs: 0,\n });\n }\n setAcknowledgedExchangeIds((prev) => {\n const next = new Set(prev);\n next.add(exchange.id);\n return next;\n });\n } catch {\n // ignore errors\n } finally {\n setSubmittingExchangeId(null);\n }\n }}\n >\n \n \n
\n )\n ) : null}\n {\n const parts = exchange.assistantMessage?.parts ?? [];\n const textContent = parts\n .filter((part) => part.type === \"text\")\n .map((part) => part.text)\n .join(\"\")\n .trim();\n if (!textContent) return;\n try {\n if (onCopy) {\n await onCopy(textContent);\n } else {\n await copyText(textContent);\n }\n setCopiedExchangeId(exchange.id);\n if (copyResetTimeoutRef.current) {\n window.clearTimeout(copyResetTimeoutRef.current);\n }\n copyResetTimeoutRef.current = window.setTimeout(() => {\n setCopiedExchangeId(null);\n }, 1500);\n } catch {\n // noop – copy may fail silently\n }\n }}\n >\n {copiedExchangeId === exchange.id ? (\n \n ) : (\n \n )}\n \n
\n \n );\n })}\n
\n
\n );\n});\n\n// ============================================================================\n// Hits List Component\n// ============================================================================\n\ninterface HitsActionsProps {\n query: string;\n isSelected: boolean;\n onAskAI: () => void;\n}\n\nconst HitsActions = memo(function HitsActions({\n query,\n isSelected,\n onAskAI,\n}: HitsActionsProps) {\n return (\n
\n \n \n

\n Ask AI:{\" \"}\n \n "{query}"\n \n

\n \n
\n );\n});\n\ninterface HitsListProps {\n hits: any[];\n query: string;\n selectedIndex: number;\n onAskAI: () => void;\n}\n\nconst HitsList = memo(function HitsList({\n hits,\n query,\n selectedIndex,\n onAskAI,\n}: HitsListProps) {\n return (\n <>\n \n

Results

\n {hits.map((hit: any, idx: number) => {\n const isSel = selectedIndex === idx + 1;\n return (\n \n

\n \n

\n

\n \n

\n \n );\n })}\n \n );\n});\n\n// ============================================================================\n// Search Input Component\n// ============================================================================\n\ninterface SearchInputProps {\n placeholder?: string;\n className?: string;\n showChat: boolean;\n isGenerating?: boolean;\n inputRef: React.RefObject;\n onClose: () => void;\n setShowChat: (show: boolean) => void;\n onArrowDown?: () => void;\n onArrowUp?: () => void;\n onEnter?: (value: string) => boolean;\n}\n\nconst SearchLeftButton = memo(function SearchLeftButton({\n showChat,\n setShowChat,\n}: {\n showChat: boolean;\n setShowChat: (show: boolean) => void;\n}) {\n if (showChat) {\n return (\n setShowChat(false)}\n className=\"cursor-pointer p-2 rounded-full flex items-center justify-center text-foreground transition-colors hover:text-blue-600\"\n aria-label=\"Back to search\"\n title=\"Back to search\"\n >\n \n \n );\n }\n\n return (\n \n \n \n );\n});\n\nconst SearchInput = memo(function SearchInput(props: SearchInputProps) {\n const { status } = useInstantSearch();\n const { query, refine } = useSearchBox();\n const [inputValue, setInputValue] = useState(query || \"\");\n\n const isSearchStalled = status === \"stalled\";\n\n function setQuery(newQuery: string) {\n setInputValue(newQuery);\n if (!props.showChat) {\n refine(newQuery);\n }\n }\n\n // Clear the input when entering chat mode\n useEffect(() => {\n if (props.showChat) {\n setInputValue(\"\");\n }\n }, [props.showChat]);\n\n const placeholder = props.isGenerating\n ? \"Answering...\"\n : props.showChat\n ? \"Ask AI anything about Algolia\"\n : props.placeholder;\n\n return (\n {\n event.preventDefault();\n event.stopPropagation();\n }}\n onReset={(event) => {\n event.preventDefault();\n event.stopPropagation();\n\n setQuery(\"\");\n if (props.inputRef.current) {\n props.inputRef.current.focus();\n }\n }}\n >\n \n {\n setQuery(event.currentTarget.value);\n }}\n onKeyDown={(e) => {\n if (props.isGenerating) {\n e.preventDefault();\n return;\n }\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n props.onArrowDown?.();\n return;\n }\n if (e.key === \"ArrowUp\") {\n e.preventDefault();\n props.onArrowUp?.();\n return;\n }\n if (e.key === \"Enter\") {\n e.preventDefault();\n if (props.onEnter?.(inputValue)) {\n setQuery(\"\");\n return;\n }\n const trimmed = inputValue.trim();\n if (trimmed) {\n props.setShowChat(true);\n }\n }\n }}\n // biome-ignore lint/a11y/noAutofocus: expected\n autoFocus\n />\n
\n
\n \n );\n});\n\n// ============================================================================\n// No Results Component\n// ============================================================================\n\ninterface NoResultsProps {\n query: string;\n onAskAI: () => void;\n onClear: () => void;\n}\n\nconst NoResults = memo(function NoResults({\n query,\n onAskAI,\n onClear,\n}: NoResultsProps) {\n return (\n
\n
\n \n
\n

\n No results for "{query}"\n

\n

\n Try a different query or ask AI to help.\n

\n
\n \n \n
\n
\n );\n});\n\n// ============================================================================\n// Results Panel Component\n// ============================================================================\n\ninterface ResultsPanelProps {\n showChat: boolean;\n inputRef: React.RefObject;\n setShowChat: (showChat: boolean) => void;\n query: string;\n selectedIndex: number;\n refine: (query: string) => void;\n config: SearchWithAskAIConfig;\n messages: unknown[];\n error: Error | null;\n isGenerating: boolean;\n sendMessage: (options: { text: string }) => void | Promise;\n}\n\nconst ResultsPanel = memo(function ResultsPanel({\n showChat,\n inputRef,\n setShowChat,\n query,\n selectedIndex,\n refine,\n config,\n messages,\n error,\n isGenerating,\n sendMessage,\n}: ResultsPanelProps) {\n const { items } = useHits();\n const containerRef = useRef(null);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n useEffect(() => {\n if (showChat) return;\n const container = containerRef.current;\n if (!container) return;\n const selectedEl = container.querySelector(\n '[aria-selected=\"true\"]',\n ) as HTMLElement | null;\n if (!selectedEl) return;\n\n const padding = 8;\n const cRect = container.getBoundingClientRect();\n const iRect = selectedEl.getBoundingClientRect();\n\n if (iRect.top < cRect.top + padding) {\n container.scrollTop -= cRect.top + padding - iRect.top;\n } else if (iRect.bottom > cRect.bottom - padding) {\n container.scrollTop += iRect.bottom - (cRect.bottom - padding);\n }\n }, [selectedIndex, showChat, items.length]);\n\n const lastSentRef = useRef(null);\n useEffect(() => {\n if (!showChat) return;\n const trimmed = (query ?? \"\").trim();\n if (!trimmed) return;\n if (lastSentRef.current === trimmed) return;\n refine(\"\");\n if (inputRef.current) {\n inputRef.current.value = \"\";\n inputRef.current.focus();\n }\n sendMessage({ text: trimmed });\n lastSentRef.current = trimmed;\n }, [showChat, query, inputRef, sendMessage, refine]);\n\n if (showChat) {\n return (\n \n );\n }\n\n return (\n <>\n \n setShowChat(true)}\n />\n \n \n );\n});\n\n// ============================================================================\n// Search Box Component\n// ============================================================================\n\ninterface SearchBoxProps {\n query?: string;\n className?: string;\n placeholder?: string;\n showChat: boolean;\n isGenerating?: boolean;\n inputRef: React.RefObject;\n refine: (query: string) => void;\n setShowChat: (show: boolean) => void;\n onClose?: () => void;\n onArrowDown?: () => void;\n onArrowUp?: () => void;\n onEnter?: (value: string) => boolean;\n}\n\nconst SearchBox = memo(function SearchBox(props: SearchBoxProps) {\n return (\n {})}\n onArrowDown={props.onArrowDown}\n onArrowUp={props.onArrowUp}\n onEnter={props.onEnter}\n />\n );\n});\n\n// ============================================================================\n// Footer Component\n// ============================================================================\n\nconst Footer = memo(function Footer({ showChat }: { showChat: boolean }) {\n return (\n
\n
\n
\n \n \n \n \n \n {showChat ? \"Ask question\" : \"Open\"}\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n Navigate\n
\n
\n
\n {/* 🚧 DO NOT REMOVE the logo if you are on a Free plan\n * https://support.algolia.com/hc/en-us/articles/17226079853073-Is-displaying-the-Algolia-logo-required\n */}\n \n Powered by \n \n \n
\n
\n );\n});\n\n// ============================================================================\n// Search Modal (Inner Content)\n// ============================================================================\n\ninterface SearchModalProps {\n onClose?: () => void;\n config: SearchWithAskAIConfig;\n}\n\nfunction SearchModal({ onClose, config }: SearchModalProps) {\n const { query, refine } = useSearchBox();\n const inputRef = useRef(null);\n\n const results = useInstantSearch();\n const { items } = useHits();\n const { showChat, setShowChat, handleShowChat } = useSearchState();\n\n const { messages, error, isGenerating, sendMessage } = useAskai({\n applicationId: config.applicationId,\n apiKey: config.apiKey,\n indexName: config.indexName,\n assistantId: config.assistantId,\n baseAskaiUrl: config.baseAskaiUrl,\n });\n\n const noResults = results.results?.nbHits === 0;\n const { selectedIndex, moveDown, moveUp, activateSelection } =\n useKeyboardNavigation(showChat, items, query);\n\n const handleActivateSelection = useCallback((): boolean => {\n if (activateSelection()) {\n if (selectedIndex === 0) {\n handleShowChat(true);\n }\n return true;\n }\n return false;\n }, [activateSelection, selectedIndex, handleShowChat]);\n\n const showResultsPanel = (!noResults && !!query) || showChat;\n\n return (\n <>\n \n
\n {\n const trimmed = (value ?? \"\").trim();\n if (showChat && trimmed) {\n refine(trimmed);\n return true;\n }\n return handleActivateSelection();\n }}\n inputRef={inputRef}\n />\n {showResultsPanel && (\n {\n setShowChat(v);\n }}\n query={query}\n selectedIndex={selectedIndex}\n refine={refine}\n config={config}\n messages={messages as unknown[]}\n error={error as Error | null}\n isGenerating={isGenerating}\n sendMessage={sendMessage}\n />\n )}\n {noResults && query && !showChat && (\n {\n setShowChat(true);\n }}\n onClear={() => refine(\"\")}\n />\n )}\n
\n