algolia
blockhighlight-to-askaisource: ok Β· hit
Highlight to Ask AI [Experimental]
π§ [Experimental] Highlight any text and ask Algolia Ask AI with an inline tooltip
npx shadcn add "https://sitesearch.algolia.com/r/highlight-to-askai.json"free
Interaction
tooltip
Tags auto-generated by rules on 9/24/2026 β empty means no evidence, not lack of support.
Dependencies
Bundle weight Β· lighticonslucide-react
Registry dependencies
buttoninput
Files3 Β· source cached 10/8/2026, 9:43:11 PM
src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering sanitized via parseMarkdownToSafeHtml */
/** biome-ignore-all lint/suspicious/noArrayIndexKey: parts are stable during render */
"use client";
import type { Placement, ReferenceType } from "@floating-ui/react";
import {
FloatingPortal,
flip,
offset,
shift,
useFloating,
} from "@floating-ui/react";
import {
BrainIcon,
CornerDownLeftIcon,
SearchIcon,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { marked, type Tokens } from "marked";
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
postAgentStudioFeedback,
postFeedback,
useAskai,
} from "@/registry/experiences/highlight-to-askai/hooks/use-askai";
function escapeHtml(html: string): string {
return html
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function decodeUrlForSchemeCheck(value: string): string {
let current = value;
for (let i = 0; i < 3; i += 1) {
try {
const decoded = decodeURIComponent(current);
if (decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function stripControlsAndWhitespace(value: string): string {
let result = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code > 0x20 && code !== 0x7f) {
result += value.charAt(i);
}
}
return result;
}
function sanitizeUrl(url: string | null | undefined): string {
if (!url) {
return "";
}
const trimmed = url.trim();
if (!trimmed) {
return "";
}
// Decode / strip controls for scheme checks only β return the original trimmed
// URL when safe so percent-encoding in the path/query is preserved.
const normalized = stripControlsAndWhitespace(
decodeUrlForSchemeCheck(trimmed),
).replace(/\\/g, "/");
if (!normalized) {
return "";
}
// Protocol-relative and backslash-obfuscated hosts (e.g. /\evil.com β //evil.com).
if (normalized.startsWith("//")) {
return "";
}
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) {
return trimmed;
}
try {
const parsed = new URL(normalized);
if (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
parsed.protocol === "mailto:"
) {
return trimmed;
}
} catch {
return "";
}
return "";
}
/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */
const markdownRenderer = new marked.Renderer();
markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => {
const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : "";
const languageClass = safeLang ? "language-" + safeLang : "";
const safeCode = escaped ? text : escapeHtml(text);
return (
'<pre><code class="' + languageClass + '">' + safeCode + "</code></pre>"
);
};
markdownRenderer.link = ({ href, title, text }: Tokens.Link): string => {
const safeHref = escapeHtml(sanitizeUrl(href));
const textEscaped = escapeHtml(text);
if (!safeHref) {
return textEscaped;
}
const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : "";
// href/text/title are sanitized + escaped above.
return (
'<a href="' +
safeHref +
'"' +
titleAttr +
' target="_blank" rel="noopener noreferrer">' +
textEscaped +
"</a>"
); // nosemgrep
};
markdownRenderer.image = ({ href, title, text }: Tokens.Image): string => {
const safeHref = escapeHtml(sanitizeUrl(href));
if (!safeHref) {
return escapeHtml(text);
}
const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : "";
// src/alt/title are sanitized + escaped above.
return (
'<img src="' +
safeHref +
'" alt="' +
escapeHtml(text) +
'"' +
titleAttr +
" />"
); // nosemgrep
};
markdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string =>
escapeHtml(text);
/* eslint-enable xss/no-mixed-html */
function parseMarkdownToSafeHtml(content: string): string {
return marked.parse(content, {
gfm: true,
breaks: true,
renderer: markdownRenderer,
}) as string;
}
type OnAskPayload = {
text: string;
html: string;
rect: DOMRect;
range: Range;
contextNode: HTMLElement;
};
export type HighlightAskAIProps = {
agentStudio?: boolean;
applicationId: string;
apiKey: string;
indexName: string;
assistantId: string;
excludeElements?: string[];
side?: Placement;
sideOffset?: number;
delay?: number;
askButtonLabel?: string;
className?: string;
askAiBaseUrl?: string;
onAsk?: (payload: OnAskPayload) => void | Promise<void>;
children: React.ReactNode;
};
type VirtualElement = {
getBoundingClientRect: () => DOMRect;
contextElement?: Element | null;
};
function isWhitespaceOnly(text: string) {
return text.trim().length === 0;
}
function selectionIntersectsContainer(
range: Range,
container: HTMLElement,
): boolean {
const startContainer = range.startContainer as Node | null;
const endContainer = range.endContainer as Node | null;
if (!startContainer || !endContainer) return false;
return container.contains(startContainer) || container.contains(endContainer);
}
function nodeIsExcluded(node: Node, selectors: string[]): boolean {
let el: Element | null = null;
if (node instanceof Element) {
el = node;
} else if ((node as ChildNode).parentElement) {
el = (node as ChildNode).parentElement;
}
if (!el) return false;
for (const sel of selectors) {
if (el.closest(sel)) return true;
}
return false;
}
const AlgoliaLogo = ({ size = 52 }: { size?: number | string }) => (
<svg
width="80"
height="24"
aria-label="Algolia"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2196.2 500"
style={{ maxWidth: size }}
>
<defs>
{/* eslint-disable-nextLine @docusaurus/no-untranslated-text */}
<style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fillRule:evenodd}`}</style>
</defs>
<path
className="cls-2"
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<rect
className="cls-1"
x="1845.88"
y="104.73"
width="62.58"
height="277.9"
rx="5.9"
ry="5.9"
/>
<path
className="cls-2"
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
/>
<path
className="cls-2"
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<path
className="cls-2"
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
/>
<path
className="cls-2"
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
/>
<path
className="cls-1"
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
/>
</svg>
);
export function HighlightAskAI({
agentStudio = false,
applicationId,
apiKey,
indexName,
assistantId,
excludeElements = ["pre", "code"],
side = "top",
sideOffset = 8,
delay = 120,
askButtonLabel = "Ask AI?",
className,
onAsk,
children,
}: HighlightAskAIProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [range, setRange] = React.useState<Range | null>(null);
const [virtualEl, setVirtualEl] = React.useState<VirtualElement | null>(null);
const [followUpInput, setFollowUpInput] = React.useState<string>("");
const [feedbackGiven, setFeedbackGiven] = React.useState(false);
const [submittingFeedback, setSubmittingFeedback] = React.useState(false);
const { messages, error, isGenerating, sendMessage, setMessages, stop } =
useAskai({
applicationId,
apiKey,
indexName,
assistantId,
agentStudio,
});
const handleFeedback = async (vote: 0 | 1) => {
const messageToFeedback = (messages || []).find(
(m: unknown) =>
(m as { role?: string }).role === (agentStudio ? "assistant" : "user"),
) as { id?: string } | undefined;
if (!messageToFeedback?.id) return;
setSubmittingFeedback(true);
try {
if (agentStudio) {
await postAgentStudioFeedback({
agentId: assistantId,
vote,
messageId: messageToFeedback.id,
appId: applicationId,
apiKey,
});
} else {
await postFeedback({
assistantId,
appId: applicationId,
messageId: messageToFeedback.id,
thumbs: vote,
});
}
setFeedbackGiven(true);
} catch {
// ignore errors
} finally {
setSubmittingFeedback(false);
}
};
const resetConversation = React.useCallback(() => {
try {
stop?.();
} catch {}
setMessages?.([]);
setFollowUpInput("");
setFeedbackGiven(false);
setSubmittingFeedback(false);
}, [setMessages, stop]);
const { refs, floatingStyles, update } = useFloating<
HTMLElement | ReferenceType
>({
placement: side,
middleware: [offset(sideOffset), flip(), shift()],
// Don't use autoUpdate - we want the tooltip to stay in place on scroll
});
// Keep placement in sync with prop changes
React.useEffect(() => {
update?.();
}, [update]);
React.useEffect(() => {
if (virtualEl) refs.setReference(virtualEl as unknown as ReferenceType);
}, [virtualEl, refs.setReference]);
// Debounced selection handler
const debounceRef = React.useRef<number | null>(null);
const handleSelectionChange = React.useCallback(() => {
if (debounceRef.current) window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => {
const sel = window.getSelection();
const containerEl = containerRef.current;
const floatingEl = refs.floating.current;
const activeEl =
typeof document !== "undefined"
? (document.activeElement as Node | null)
: null;
// Ignore selection changes while focus is inside the floating tooltip
if (floatingEl && activeEl && floatingEl.contains(activeEl)) {
return;
}
if (!sel || !containerEl) return;
if (sel.rangeCount === 0 || sel.isCollapsed) {
// Don't close if already expanded; allow typing in the tooltip
if (!expanded) {
setOpen(false);
setExpanded(false);
setRange(null);
setVirtualEl(null);
}
return;
}
const nextRange = sel.getRangeAt(0);
const text = sel.toString();
if (isWhitespaceOnly(text)) {
if (!expanded) {
setOpen(false);
setExpanded(false);
setRange(null);
setVirtualEl(null);
}
return;
}
if (!selectionIntersectsContainer(nextRange, containerEl)) {
if (!expanded) {
setOpen(false);
setExpanded(false);
setRange(null);
setVirtualEl(null);
}
return;
}
const anchorNode = sel.anchorNode;
const focusNode = sel.focusNode;
if (
(anchorNode && nodeIsExcluded(anchorNode, excludeElements)) ||
(focusNode && nodeIsExcluded(focusNode, excludeElements))
) {
if (!expanded) {
setOpen(false);
setExpanded(false);
setRange(null);
setVirtualEl(null);
}
return;
}
const rect = nextRange.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
if (!expanded) {
setOpen(false);
setExpanded(false);
setRange(null);
setVirtualEl(null);
}
return;
}
const v: VirtualElement = {
getBoundingClientRect: () => rect,
};
setRange(nextRange);
setVirtualEl(v);
setOpen(true);
setExpanded(false);
// Trigger update after setting v
requestAnimationFrame(() => {
update?.();
});
}, delay);
}, [delay, excludeElements, expanded, refs.floating, update]);
React.useEffect(() => {
document.addEventListener("selectionchange", handleSelectionChange);
return () =>
document.removeEventListener("selectionchange", handleSelectionChange);
}, [handleSelectionChange]);
// Close on Escape and click outside
React.useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
setExpanded(false);
resetConversation();
}
}
function onMouseDown(e: MouseEvent) {
if (!open) return;
const floatingEl = refs.floating.current;
const containerEl = containerRef.current;
const target = e.target as Node | null;
if (!target) return;
// Don't close if clicking inside the floating tooltip
if (floatingEl?.contains(target)) {
return;
}
// Don't close if clicking inside the container (but outside selection)
if (containerEl?.contains(target)) {
return;
}
// Click is outside both - close the tooltip
setOpen(false);
setExpanded(false);
resetConversation();
}
document.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onMouseDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onMouseDown);
};
}, [open, refs.floating, resetConversation]);
// Build HTML content string for onAsk consumers
const getSelectionHtml = React.useCallback((rangeArg: Range | null) => {
if (!rangeArg) return "";
const fragment = rangeArg.cloneContents();
const div = document.createElement("div");
div.appendChild(fragment);
return div.innerHTML;
}, []);
const handleAskClick = React.useCallback(async () => {
if (!range) return;
const text = range.toString();
const html = getSelectionHtml(range);
const rect = range.getBoundingClientRect();
const node = range.commonAncestorContainer as Node;
const contextElement =
node instanceof HTMLElement ? node : node.parentElement;
const contextNode = (contextElement as HTMLElement) ?? document.body;
setExpanded(true);
// starting a fresh question: clear past conversation first
resetConversation();
if (onAsk) {
try {
await onAsk({ text, html, rect, range, contextNode });
} catch (err) {
// eslint-disable-next-line no-console
console.error("onAsk error", err);
}
}
const prompt = `Can you tell me what exactly is '${text}' ? be concise`;
try {
await sendMessage({ text: prompt });
} catch (err) {
// eslint-disable-next-line no-console
console.error("sendMessage error", err);
}
}, [getSelectionHtml, onAsk, range, resetConversation, sendMessage]);
return (
<div ref={containerRef} style={{ position: "relative" }}>
{children}
{open && virtualEl && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: 60,
}}
className={
"rounded-md border border-neutral-200 bg-white text-neutral-900 shadow-lg dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100" +
(className ? ` ${className}` : "")
}
>
{!expanded ? (
<div className="flex items-center">
<Button
type="button"
onClick={handleAskClick}
className="inline-flex items-center rounded-md bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-sm hover:bg-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
>
{askButtonLabel}
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2 p-2 border-b border-neutral-200 dark:border-neutral-800">
<a
href="https://www.algolia.com/developers?utm_medium=referral&utm_content=powered_by&utm_campaign=sitesearch"
target="_blank"
rel="noopener noreferrer"
className="text-xs opacity-70 flex items-center gap-2"
>
<span className="inline-flex items-center gap-1">
Powered by
</span>
<AlgoliaLogo />
</a>
<Button
onClick={() => {
setOpen(false);
setExpanded(false);
resetConversation();
}}
type="button"
size="sm"
variant="outline"
className="p-1.5 text-xs text-muted-foreground"
>
esc
</Button>
</div>
)}
{expanded && (
<div className="min-w-sm max-w-md">
<div className="text-foreground p-3 max-h-[400px] overflow-y-auto text-sm">
{(() => {
const assistant = (messages || [])
.slice()
.reverse()
.find(
(m: unknown) =>
(m as { role?: string }).role === "assistant",
);
if (!assistant)
return (
<span className="text-muted-foreground text-xs">
{isGenerating ? "Thinking..." : ""}
</span>
);
const parts =
(
assistant as {
parts?: Array<
| string
| {
type: string;
text?: string;
state?: string;
input?: { query?: string };
output?: { query?: string; hits?: unknown[] };
errorText?: string;
}
>;
}
).parts ?? [];
return (
<>
{parts.map((part, index) => {
if (typeof part === "string") {
return <p key={index}>{part}</p>;
}
if (part.type === "text") {
// eslint-disable-next-line xss/no-mixed-html -- sanitized via parseMarkdownToSafeHtml
const html = parseMarkdownToSafeHtml(
part.text || "",
);
return (
<div
key={index}
className="prose dark:prose-invert text-sm max-w-none"
dangerouslySetInnerHTML={{
__html: html as string,
}}
/>
);
} else if (
part.type === "reasoning" &&
part.state === "streaming"
) {
return (
<p
key={index}
className="text-xs flex my-2 gap-2 items-center text-muted-foreground"
>
<BrainIcon size={14} /> Reasoning...
</p>
);
} else if (part.type === "tool-searchIndex") {
if (part.state === "input-streaming") {
return (
<p
key={index}
className="text-xs flex my-2 gap-2 items-center text-muted-foreground"
>
<SearchIcon size={14} /> Searching...
</p>
);
} else if (part.state === "input-available") {
return (
<p
key={index}
className="text-sm flex my-2 gap-2 items-center text-muted-foreground"
>
<SearchIcon size={14} /> Looking for{" "}
<mark className="bg-transparent text-muted-foreground underline decoration-2 underline-offset-4">
"{part.input?.query || ""}"
</mark>
</p>
);
} else if (part.state === "output-available") {
return (
<p
key={index}
className="text-sm flex my-2 gap-2 items-center text-muted-foreground"
>
<SearchIcon size={14} />{" "}
<span>
Searched for{" "}
<mark className="bg-transparent text-muted-foreground underline decoration-1 underline-offset-4">
"{part.output?.query}"
</mark>{" "}
found {part.output?.hits?.length || "no"}{" "}
results
</span>
</p>
);
} else if (part.state === "output-error") {
return (
<p
key={index}
className="text-xs flex my-2 gap-2 items-center text-muted-foreground"
>
{part.errorText}
</p>
);
}
}
return null;
})}
</>
);
})()}
</div>
{error ? (
<div className="mt-2 text-xs max-h-[400px] prose text-wrap text-red-600 break-all opacity-80">
{String(
(error as unknown as { message?: string }).message ||
error,
)}
</div>
) : null}
{(() => {
const hasAssistantResponse = (messages || []).some(
(m: unknown) =>
(m as { role?: string }).role === "assistant",
);
if (!hasAssistantResponse || isGenerating) return null;
return (
<>
<div className="">
<div className="flex items-center justify-end gap-2 mb-3 p-3 border-b border-neutral-200 dark:border-neutral-800">
{feedbackGiven ? (
<span className="text-muted-foreground text-xs animate-in fade-in slide-in-from-bottom-1">
Thanks for your feedback!
</span>
) : submittingFeedback ? (
<span className="text-muted-foreground text-xs">
Submitting...
</span>
) : (
<div className="inline-flex items-center gap-2">
<button
type="button"
title="Like"
aria-label="Like"
className="border-none bg-transparent rounded-md px-2 py-1 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-accent disabled:text-foreground disabled:cursor-not-allowed"
disabled={submittingFeedback}
onClick={() => handleFeedback(1)}
>
<ThumbsUp size={14} />
</button>
<button
type="button"
title="Dislike"
aria-label="Dislike"
className="border-none bg-transparent rounded-md px-2 py-1 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-accent disabled:text-foreground disabled:cursor-not-allowed"
disabled={submittingFeedback}
onClick={() => handleFeedback(0)}
>
<ThumbsDown size={14} />
</button>
</div>
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (followUpInput.trim()) {
sendMessage({ text: followUpInput.trim() });
setFollowUpInput("");
}
}}
className="flex items-center gap-2 p-3"
>
<Input
type="text"
value={followUpInput}
onChange={(e) => setFollowUpInput(e.target.value)}
placeholder="Ask a follow-up..."
className="flex-1 text-sm"
/>
<Button
type="submit"
size="sm"
disabled={!followUpInput.trim()}
className="p-1.5 h-auto"
>
<CornerDownLeftIcon size={14} />
</Button>
</form>
</div>
</>
);
})()}
</div>
)}
</div>
</FloatingPortal>
)}
</div>
);
}
export default HighlightAskAI;
src/registry/experiences/highlight-to-askai/hooks/use-askai.ts
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
import { useMemo } from "react";
export interface AskAIConfig {
agentStudio?: boolean;
applicationId: string;
apiKey: string;
indexName: string;
assistantId: string;
}
/**
* Checks if an error is a thread depth error (AI-217)
* Thread depth errors occur when a conversation has reached its maximum depth limit
*/
export function isThreadDepthError(error?: Error | null): boolean {
if (!error) return false;
// Check if error has a code property
const errorWithCode = error as Error & { code?: string };
if (errorWithCode.code === "AI-217") return true;
// Check message content for AI-217 or thread depth references
const message = error.message?.toLowerCase() || "";
return message.includes("ai-217") || message.includes("conversation depth");
}
const agentStudioBaseUrl = (appId: string): string =>
`https://${appId}.algolia.net/agent-studio/1`;
function getChatApiUrl(config: AskAIConfig): string {
if (config.agentStudio) {
return `${agentStudioBaseUrl(config.applicationId)}/agents/${config.assistantId}/completions?stream=true&compatibilityMode=ai-sdk-5`;
}
return `${BASE_ASKAI_URL}/chat`;
}
export function useAskai(config: AskAIConfig) {
if (!config) {
throw new Error("config is required for useAskai");
}
const transport = useMemo(() => {
return new DefaultChatTransport({
api: getChatApiUrl(config),
headers: async () => {
if (config.agentStudio) {
return {
"x-algolia-api-key": config.apiKey,
"x-algolia-application-id": config.applicationId,
} as Record<string, string>;
}
const token = await getValidToken({ assistantId: config.assistantId });
return {
"x-algolia-api-key": config.apiKey,
"x-algolia-application-id": config.applicationId,
"x-algolia-index-name": config.indexName,
"x-algolia-assistant-id": config.assistantId,
"x-ai-sdk-version": "v5",
authorization: `TOKEN ${token}`,
} as Record<string, string>;
},
});
}, [
config.apiKey,
config.applicationId,
config.indexName,
config.assistantId,
config.agentStudio,
config,
]);
const chat = useChat({
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
const isGenerating =
chat.status === "submitted" || chat.status === "streaming";
// Check if there's a thread depth error (AI-217)
const hasThreadDepthError = useMemo(() => {
return (
chat.status === "error" && isThreadDepthError(chat.error as Error | null)
);
}, [chat.status, chat.error]);
return {
...chat,
isGenerating,
hasThreadDepthError,
};
}
const BASE_ASKAI_URL = "https://askai.algolia.com";
const TOKEN_KEY = "askai_token";
type TokenPayload = { exp: number };
const decode = (token: string): TokenPayload => {
const [b64] = token.split(".");
return JSON.parse(atob(b64));
};
const isExpired = (token?: string | null): boolean => {
if (!token) return true;
try {
const { exp } = decode(token);
// refresh 30 s before the backend rejects it
return Date.now() / 1000 > exp - 30;
} catch {
return true;
}
};
let inflight: Promise<string> | null = null;
// call /token once, cache the promise while itβs running
// eslint-disable-next-line require-await
export const getValidToken = async ({
assistantId,
}: {
assistantId: string;
}): Promise<string> => {
const cached = sessionStorage.getItem(TOKEN_KEY);
if (!isExpired(cached)) return cached as string;
if (!inflight) {
inflight = fetch(`${BASE_ASKAI_URL}/chat/token`, {
method: "POST",
headers: {
"x-algolia-assistant-id": assistantId,
"content-type": "application/json",
},
})
.then((r) => r.json())
.then(({ token }) => {
sessionStorage.setItem(TOKEN_KEY, token);
return token;
})
.finally(() => {
inflight = null;
});
}
return inflight;
};
export const postFeedback = async ({
assistantId,
thumbs,
messageId,
appId,
}: {
assistantId: string;
thumbs: 0 | 1;
messageId: string;
appId: string;
}): Promise<Response> => {
const headers = new Headers();
headers.set("x-algolia-assistant-id", assistantId);
headers.set("content-type", "application/json");
const token = await getValidToken({ assistantId });
headers.set("authorization", `TOKEN ${token}`);
return fetch(`${BASE_ASKAI_URL}/chat/feedback`, {
method: "POST",
body: JSON.stringify({
appId,
messageId,
thumbs,
}),
headers,
});
};
export const postAgentStudioFeedback = ({
agentId,
vote,
messageId,
appId,
apiKey,
}: {
agentId: string;
vote: 0 | 1;
messageId: string;
appId: string;
apiKey: string;
}): Promise<Response> => {
const headers = new Headers();
headers.set("x-algolia-application-id", appId);
headers.set("x-algolia-api-key", apiKey);
headers.set("content-type", "application/json");
const baseUrl = `${agentStudioBaseUrl(appId)}/feedback`;
return fetch(baseUrl, {
method: "POST",
body: JSON.stringify({
messageId,
agentId,
vote,
}),
headers,
});
};
src/registry/experiences/highlight-to-askai/page.tsx
"use client";
import { HighlightAskAI } from "@/registry/experiences/highlight-to-askai/components/highlight-to-askai";
export default function Page() {
return (
<div className="flex items-center justify-center min-h-[100px] md:min-h-[400px] relative p-6">
<div className="w-full max-w-3xl">
<HighlightAskAI
excludeElements={["pre", "code"]}
applicationId="06YAZFOHSQ"
apiKey="94b6afdc316917b6e6cdf2763fa561df"
indexName="algolia_podcast_sample_dataset"
assistantId="UpR727VnXnoG"
askAiBaseUrl="https://askai.algolia.com"
side="top"
sideOffset={8}
>
<article className="prose dark:prose-invert">
<h3>Try it</h3>
<p>
Select any text in this block to see a small tooltip. Click
<em> Ask AI?</em> to expand the panel and stream an answer.
</p>
<p>
The tooltip respects excluded elements like <code>pre</code> and
<code>code</code>, and it uses smart placement to avoid viewport
edges.
</p>
</article>
</HighlightAskAI>
<p className="mt-2 text-right text-xs opacity-60">
Tip: Try selecting a sentence across multiple lines.
</p>
</div>
</div>
);
}