import DOMPurify from "dompurify";
import { Marked } from "marked";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
import { createPortal } from "react-dom";
import type { UIStrings } from "../../core/i18n-ui.ts";
import { copyText } from "../copy-feedback.ts";
import { joinBase, prefixBase } from "./base-path.ts";
import { useAskAI } from "./hooks.ts";
/** A resolved empty-state prompt; `icon` is ready-to-inline SVG (or null). */
interface Suggestion {
icon: string | null;
label: string;
}
/**
* The panel's chrome glyphs, resolved server-side in `AskAI.astro` and passed in
* as ready-to-inline Lucide bodies so this client island ships no icon data.
*/
interface AskIcons {
arrowUp: string;
chat: string;
clear: string;
close: string;
copy: string;
}
// Empty bodies so the island still renders (iconless) if instantiated without
// the Astro wrapper that resolves the real Lucide glyphs.
const EMPTY_ICONS: AskIcons = {
arrowUp: "",
chat: "",
clear: "",
close: "",
copy: "",
};
// English fallback so the island renders even if no dictionary is passed.
const DEFAULT_ASK: UIStrings["ask"] = {
ai: "AI",
clear: "Clear conversation",
close: "Close",
copy: "Copy conversation",
empty: "Ask a question about the docs.",
error: "Sorry, something went wrong.",
label: "Ask a question",
placeholder: "Ask a question…",
send: "Send",
tip: "Tip: You can open and close chat with",
title: "Ask AI",
you: "You",
};
// The endpoint honors the deployment `base` so grounding works under a
// non-root base path (the server matches base-less document routes).
const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
// A dedicated instance, not the shared `marked` singleton: `setOptions`/`use`
// on the singleton would leak `breaks` and the link rewriter into any other
// consumer of `marked` on the page (user components included).
const markdown = new Marked({
breaks: true,
gfm: true,
// The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
// link targets to served URLs so citations resolve under `deployment.base`.
// `prefixBase` leaves external URLs and fragments untouched and is idempotent.
walkTokens: (token) => {
if (token.type === "link") {
token.href = prefixBase(import.meta.env.BASE_URL, token.href);
}
},
});
const renderMarkdown = (content: string): string =>
DOMPurify.sanitize(markdown.parse(content, { async: false }));
const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
);
// Stable empty default so an unset `suggestions` prop doesn't re-render.
const EMPTY_SUGGESTIONS: Suggestion[] = [];
// The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
// platform (same detection Search.astro uses for its ⌘K hint). Guarded via
// `globalThis` so the island still server-renders where `navigator` doesn't
// exist; the hint itself only renders client-side, inside the portaled panel.
const IS_APPLE =
globalThis.navigator !== undefined &&
/mac|iphone|ipad|ipod/iu.test(globalThis.navigator.platform);
// Duck-typed (`closest` presence) rather than `instanceof Element`, which
// needs a DOM global the test environment doesn't provide.
const isElementLike = (target: EventTarget | null): target is Element => {
// SAFETY: the cast only names the probed surface; `closest` is verified to
// exist before the caller uses it.
const candidate = target as Partial | null;
return typeof candidate?.closest === "function";
};
// Ghost icon button, matching the header's theme toggle and repo link.
const TRIGGER_CLASS =
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
const ICON_BUTTON_CLASS =
"inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-blume text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
// The assistant answer: standard prose, but citation links (every `[Title](/route)`
// the model emits) render as small neutral pills so they read as sources instead
// of inline text links. `no-underline`/color use `!` to beat the theme's unlayered
// `.prose a` rule; `leading-none` drops the inherited prose line-height so the
// pill hugs its label.
const ANSWER_CLASS =
"prose prose-sm max-w-none text-foreground [&_a]:inline-flex [&_a]:items-center [&_a]:gap-1 [&_a]:rounded-full [&_a]:bg-muted [&_a]:px-2 [&_a]:py-1 [&_a]:align-middle [&_a]:font-medium [&_a]:text-[0.7rem] [&_a]:leading-none [&_a]:text-muted-foreground! [&_a]:no-underline! [&_a:hover]:text-foreground!";
const AskAI = ({
endpoint = DEFAULT_ASK_ENDPOINT,
icons = EMPTY_ICONS,
strings,
suggestions = EMPTY_SUGGESTIONS,
}: {
endpoint?: string;
icons?: AskIcons;
strings?: UIStrings["ask"];
suggestions?: Suggestion[];
}) => {
// Merge per key (not `strings ?? …`) so a dictionary from a stale snapshot
// that predates newer keys still resolves every label to its English default.
const t = { ...DEFAULT_ASK, ...strings };
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
// The streaming client — request shaping, optimistic assistant bubble,
// stale-stream/abort guards, error-body handling — is the public useAskAI
// hook, so the built-in panel and custom UIs share one implementation.
const {
ask,
loading: busy,
messages,
reset,
} = useAskAI({ endpoint, errorMessage: t.error });
const inputRef = useRef(null);
const scrollRef = useRef(null);
const triggerRef = useRef(null);
// The portaled panel root, excluded from the overlay-mode inert sweep.
const panelRef = useRef(null);
// Where focus came from when the panel opened, restored on close.
const returnFocusRef = useRef(null);
// Where the panel portals to — the CURRENT document.body, held as state.
// Null until mount (guards SSR), then refreshed on every client-router swap:
// the island rides across navigations via transition:persist, but each swap
// installs a NEW , discarding the portaled panel with the old one and
// resetting the `data-blume-ask` push attribute to the incoming page's
// server-rendered set. Reading document.body inline in render would NOT
// recover from that — it isn't a reactive value, so the memoized portal
// keeps its stale (detached) container. State identity is what re-anchors
// the portal and re-runs the body-scoped effects below.
const [portalTarget, setPortalTarget] = useState(null);
useEffect(() => {
// The initial null→body flip is deliberate (there is no body during SSR);
// it is the same one-time post-mount cascade the old `mounted` flag had.
// oxlint-disable-next-line react/react-compiler -- deliberate post-mount portal-target initialization
setPortalTarget(document.body);
const onSwap = () => setPortalTarget(document.body);
document.addEventListener("astro:after-swap", onSwap);
return () => document.removeEventListener("astro:after-swap", onSwap);
}, []);
// The search modal forwards its query so "Ask AI: " carries straight in.
useEffect(() => {
const handler = (event: Event) => {
// SAFETY: `blume:open-ask-ai` is only ever dispatched as a CustomEvent
// whose optional detail carries the search query.
const query = (event as CustomEvent<{ query?: string }>).detail?.query;
if (query) {
setInput(query);
}
setOpen(true);
};
window.addEventListener("blume:open-ask-ai", handler);
return () => window.removeEventListener("blume:open-ask-ai", handler);
}, []);
// ⌘I / Ctrl+I toggles the panel; Escape closes it. Shift/Alt chords are
// left alone — Ctrl+Shift+I is the browser's DevTools shortcut, and
// capturing it would flap the panel open alongside them.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (
(event.metaKey || event.ctrlKey) &&
!event.shiftKey &&
!event.altKey &&
event.key.toLowerCase() === "i"
) {
event.preventDefault();
setOpen((value) => !value);
} else if (event.key === "Escape" && open) {
// An Escape aimed at a modal surface stacked on top (the search
// dialog traps focus inside itself) dismisses that surface only —
// this window listener still fires for it, and closing the panel
// underneath too would eat the user's conversation view.
if (isElementLike(event.target) && event.target.closest("dialog")) {
return;
}
setOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// Drive the desktop content push from a body attribute (see AskAI.astro CSS).
useEffect(() => {
if (open) {
returnFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
document.body.dataset.blumeAsk = "open";
inputRef.current?.focus();
} else {
delete document.body.dataset.blumeAsk;
// Return focus to the element that opened the panel (or the trigger when
// it's gone), so closing doesn't strand keyboard focus in an inert tree.
// `returnFocusRef` is only set on open, so initial mount is a no-op.
if (returnFocusRef.current) {
const target = returnFocusRef.current.isConnected
? returnFocusRef.current
: triggerRef.current;
returnFocusRef.current = null;
target?.focus();
}
}
return () => {
delete document.body.dataset.blumeAsk;
};
}, [open]);
// Re-stamp the push attribute after a swap while the panel is open — the new
// body arrives without it. Deliberately separate from the effect above: a
// navigation must not re-run the focus handling and yank focus out of the
// page the reader just moved to.
useEffect(() => {
// document.body (not portalTarget) so the compiler doesn't flag a state
// mutation; by the time this runs for a swap, they are the same element.
if (open && portalTarget) {
document.body.dataset.blumeAsk = "open";
}
}, [open, portalTarget]);
// Below the desktop dock breakpoint the open panel is a full-width overlay,
// so Tab must not escape into the page it covers: every other child of
// (the panel portals to body) turns inert until close. The desktop
// dock keeps the page interactive on purpose — it's a non-modal side panel,
// so no sweep runs at ≥1024px. Elements that were already inert are left
// alone so closing doesn't accidentally re-enable them.
useEffect(() => {
if (!open) {
return;
}
const media = window.matchMedia("(min-width: 1024px)");
let inerted: Element[] = [];
const release = () => {
for (const el of inerted) {
el.removeAttribute("inert");
}
inerted = [];
};
const apply = () => {
release();
if (media.matches) {
return;
}
inerted = [...document.body.children].filter(
(el) => el !== panelRef.current && !el.hasAttribute("inert")
);
for (const el of inerted) {
el.setAttribute("inert", "");
}
};
apply();
// The sweep snapshots body's children at open time, but overlays keep
// arriving afterwards — medium-zoom's backdrop, a mermaid render, another
// island's portal all append to — and an unswept latecomer is a
// tab stop hiding behind the overlay. Fold additions into the sweep for
// as long as it is active.
const observer = new MutationObserver((records) => {
if (media.matches) {
return;
}
for (const record of records) {
for (const node of record.addedNodes) {
if (
node instanceof HTMLElement &&
node !== panelRef.current &&
!node.hasAttribute("inert")
) {
node.setAttribute("inert", "");
inerted.push(node);
}
}
}
});
observer.observe(document.body, { childList: true });
media.addEventListener("change", apply);
return () => {
observer.disconnect();
media.removeEventListener("change", apply);
release();
};
// portalTarget: each swap installs a new , so the sweep and its
// observer must re-run against the new children (the old ones are
// detached).
}, [open, portalTarget]);
// Keep the newest message in view as it streams in — and after a swap, when
// the re-portaled panel's scroll container is reborn at the top.
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages, portalTarget]);
const runQuestion = (raw: string) => {
const question = raw.trim();
if (!question || busy) {
return;
}
void ask(question);
setInput("");
};
const clearConversation = () => {
reset();
};
const onSubmit = (event: FormEvent) => {
event.preventDefault();
runQuestion(input);
};
const onInputKeyDown = (event: ReactKeyboardEvent) => {
// `isComposing` guards IME input: Enter confirming a CJK conversion must
// commit the text, not submit the question.
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
runQuestion(input);
}
};
const copyConversation = () => {
const text = messages
.map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
.join("\n\n");
void copyText(text);
};
const hasMessages = messages.length > 0;
const panel = (
);
return (
<>
{portalTarget && createPortal(panel, portalTarget)}
>
);
};
export default AskAI;