/** * AI Search Plugin — Admin Components * * Settings page with a "Sync All Content" button that triggers * a full reindex of all configured collections into AI Search. */ import { Badge, Banner, Button, Combobox, Input, Loader } from "@cloudflare/kumo"; import { ArrowRight, ArrowsClockwise, CheckCircle, ListMagnifyingGlass, Plus, Trash, } from "@phosphor-icons/react"; import type { PluginAdminExports } from "emdash"; import { apiFetch, parseApiResponse } from "emdash/plugin-utils"; import * as React from "react"; const API_BASE = "/_emdash/api/plugins/ai-search"; const SCHEMA_API_BASE = "/_emdash/api/schema"; const indexedLabel = (n: number) => `${n} item${n !== 1 ? "s" : ""}`; function abortableDelay(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const rejectAborted = () => reject(new DOMException("The operation was aborted", "AbortError")); if (signal.aborted) { rejectAborted(); return; } const onAbort = () => { window.clearTimeout(timer); rejectAborted(); }; const timer = window.setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); signal.addEventListener("abort", onAbort, { once: true }); }); } // ============================================================================= // Types // ============================================================================= interface ReindexResult { jobId: string; status: "running" | "complete"; done: boolean; onlyMissing: boolean; indexed: number; errors: number; skipped?: number; collections: string[]; } interface CollectionOption { slug: string; label: string; } interface MissingItem { id: string; slug: string | null; title: string | null; status: string; } interface CollectionStatus { collection: string; eligible: number; indexed: number; missing: MissingItem[]; } interface IndexStatus { instanceName: string; binding: string; hybridSearch: boolean; totalIndexed: number; collections: CollectionStatus[]; } interface Synonym { from: string; to: string; } // ============================================================================= // Settings Page // ============================================================================= function SettingsPage() { const syncAbortRef = React.useRef(null); const [syncMode, setSyncMode] = React.useState<"full" | "missing" | null>(null); const isSyncing = syncMode !== null; const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); const [available, setAvailable] = React.useState([]); const [loadingCollections, setLoadingCollections] = React.useState(true); const [collectionsError, setCollectionsError] = React.useState(null); const [collectionsSaveError, setCollectionsSaveError] = React.useState(null); const [selected, setSelected] = React.useState([]); const [status, setStatus] = React.useState(null); const [isCheckingStatus, setIsCheckingStatus] = React.useState(false); const [statusError, setStatusError] = React.useState(null); const [synonyms, setSynonyms] = React.useState([]); const [isSavingSynonyms, setIsSavingSynonyms] = React.useState(false); const [synonymsSaved, setSynonymsSaved] = React.useState(false); const [synonymsError, setSynonymsError] = React.useState(null); const [metadataValid, setMetadataValid] = React.useState(null); const [isLoadingMetadata, setIsLoadingMetadata] = React.useState(true); const [isRepairingMetadata, setIsRepairingMetadata] = React.useState(false); const [metadataError, setMetadataError] = React.useState(null); React.useEffect(() => { let cancelled = false; void (async () => { try { const response = await apiFetch(`${API_BASE}/metadata`); const data = await parseApiResponse<{ valid: boolean }>(response); if (!cancelled) setMetadataValid(data.valid); } catch (err) { if (!cancelled) { setMetadataError( err instanceof Error ? err.message : "Failed to check metadata configuration", ); } } finally { if (!cancelled) setIsLoadingMetadata(false); } })(); return () => { cancelled = true; }; }, []); React.useEffect(() => { let cancelled = false; void (async () => { try { const [collectionsRes, configRes] = await Promise.all([ apiFetch(`${SCHEMA_API_BASE}/collections`), apiFetch(`${API_BASE}/config`), ]); const collectionsData = await parseApiResponse<{ items: CollectionOption[] }>( collectionsRes, ); const configData = await parseApiResponse<{ collections: string[]; synonyms?: Synonym[]; }>(configRes); if (cancelled) return; setSynonyms(configData.synonyms ?? []); const options = collectionsData.items.map((c) => ({ slug: c.slug, label: c.label })); setAvailable(options); const valid = new Set(options.map((c) => c.slug)); const saved = (configData.collections ?? []).filter((slug) => valid.has(slug)); setSelected(saved); } catch (err) { if (!cancelled) { setCollectionsError(err instanceof Error ? err.message : "Failed to load collections"); } } finally { if (!cancelled) setLoadingCollections(false); } })(); return () => { cancelled = true; }; }, []); React.useEffect( () => () => { syncAbortRef.current?.abort(); }, [], ); const labelForSlug = React.useCallback( (slug: string) => available.find((c) => c.slug === slug)?.label ?? slug, [available], ); const persistSelection = React.useCallback(async (next: string[]) => { setCollectionsSaveError(null); try { const response = await apiFetch(`${API_BASE}/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ collections: next }), }); await parseApiResponse(response, "Failed to save collections"); } catch (err) { setCollectionsSaveError(err instanceof Error ? err.message : "Failed to save collections"); } }, []); const handleSelectionChange = React.useCallback( (value: unknown) => { const next = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; setSelected(next); void persistSelection(next); }, [persistSelection], ); const handleRepairMetadata = async () => { setIsRepairingMetadata(true); setMetadataError(null); try { const response = await apiFetch(`${API_BASE}/metadata`, { method: "POST", }); const data = await parseApiResponse<{ valid: true }>( response, "Failed to fix metadata configuration", ); setMetadataValid(data.valid); } catch (err) { setMetadataError(err instanceof Error ? err.message : "Failed to fix metadata configuration"); } finally { setIsRepairingMetadata(false); } }; const runSync = React.useCallback( async (body: Record, mode: "full" | "missing") => { syncAbortRef.current?.abort(); const controller = new AbortController(); syncAbortRef.current = controller; setSyncMode(mode); setError(null); try { const response = await apiFetch(`${API_BASE}/reindex`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: controller.signal, }); let data = await parseApiResponse(response); if (controller.signal.aborted) return; setResult(data); while (!data.done) { await abortableDelay(5_000, controller.signal); const statusResponse = await apiFetch(`${API_BASE}/reindex`, { signal: controller.signal, }); const jobStatus = await parseApiResponse(statusResponse); if (!jobStatus || jobStatus.jobId !== data.jobId) { throw new Error("Reindex job not found"); } data = jobStatus; if (!controller.signal.aborted) setResult(data); } } catch (err) { if (!controller.signal.aborted) { setError(err instanceof Error ? err.message : "Sync failed"); } } finally { if (syncAbortRef.current === controller) { syncAbortRef.current = null; if (!controller.signal.aborted) setSyncMode(null); } } }, [], ); const handleSync = (onlyMissing = false) => { setResult(null); void runSync({ collections: selected, onlyMissing }, onlyMissing ? "missing" : "full"); }; React.useEffect(() => { void (async () => { try { const response = await apiFetch(`${API_BASE}/reindex`); const job = await parseApiResponse(response); if (job && !job.done) { await runSync({ jobId: job.jobId }, job.onlyMissing ? "missing" : "full"); } } catch { // A missing prior job is normal. } })(); }, [runSync]); const handleCheckStatus = async () => { setIsCheckingStatus(true); setStatus(null); setStatusError(null); try { const query = selected.length > 0 ? `?collections=${selected.join(",")}` : ""; const response = await apiFetch(`${API_BASE}/status${query}`); const data = await parseApiResponse(response); setStatus(data); } catch (err) { setStatusError(err instanceof Error ? err.message : "Failed to load status"); } finally { setIsCheckingStatus(false); } }; const updateSynonym = (index: number, patch: Partial) => { setSynonymsSaved(false); setSynonyms((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s))); }; const addSynonym = () => { setSynonymsSaved(false); setSynonyms((prev) => [...prev, { from: "", to: "" }]); }; const removeSynonym = (index: number) => { setSynonymsSaved(false); setSynonyms((prev) => prev.filter((_, i) => i !== index)); }; const handleSaveSynonyms = async () => { setIsSavingSynonyms(true); setSynonymsSaved(false); setSynonymsError(null); // Drop incomplete rows before persisting. const cleaned = synonyms .map((s) => ({ from: s.from.trim(), to: s.to.trim() })) .filter((s) => s.from && s.to); try { const response = await apiFetch(`${API_BASE}/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ synonyms: cleaned }), }); const data = await parseApiResponse<{ synonyms?: Synonym[] }>(response); setSynonyms(data.synonyms ?? cleaned); setSynonymsSaved(true); } catch (err) { setSynonymsError(err instanceof Error ? err.message : "Failed to save synonyms"); } finally { setIsSavingSynonyms(false); } }; return (

AI Search

{isLoadingMetadata && (
Checking metadata configuration…
)} {metadataValid === false && (

Fixing metadata triggers a full background reindex. Search results may be temporarily inconsistent while it completes.

)} {metadataError && ( )}

Sync Content

Re-upload all content to the search index. Content is indexed automatically on save — use this for a full re-sync after initial setup or to recover from issues.

{loadingCollections ? (
Loading collections…
) : collectionsError ? ( ) : ( c.slug)} value={selected} onValueChange={handleSelectionChange} className="max-w-sm" > ( {labelForSlug(slug)} )} /> No collections found {(slug: string) => ( {labelForSlug(slug)} )} )} {collectionsSaveError && ( )}
{result && !error && ( {indexedLabel(result.indexed)} indexed )}
{result?.done && ( 0 ? "alert" : "default"} icon={} title="Sync complete" description={ <> Indexed {indexedLabel(result.indexed)} across{" "} {result.collections.join(", ")} {result.skipped ? ` — ${result.skipped} already indexed` : ""} {result.errors > 0 && ( <> {" "} — {result.errors} error{result.errors !== 1 ? "s" : ""} )} } /> )} {error && } {statusError && ( )} {status && (
Instance {status.instanceName} ( {status.binding}){" — "} {indexedLabel(status.totalIndexed)} indexed total {status.hybridSearch ? " · hybrid search" : ""}
{status.collections.map((c) => { const complete = c.missing.length === 0; return (
{labelForSlug(c.collection)} {c.indexed} / {c.eligible} indexed
{!complete && (
    {c.missing.slice(0, 10).map((m) => (
  • {m.title || m.slug || m.id}{" "} ({m.status})
  • ))} {c.missing.length > 10 &&
  • …and {c.missing.length - 10} more
  • }
)}
); })}
)}

Synonyms

Rewrite search queries before they reach the index. When a query contains a term on the left, it is transparently replaced with the term on the right — e.g. “autorag” → “AI Search”. Matching is whole-word and case-insensitive.

Changes can take up to 60 seconds to reach all edge locations.

{synonyms.length === 0 && (

No synonyms configured.

)} {synonyms.map((syn, index) => ( // eslint-disable-next-line react/no-array-index-key -- rows are positional; no stable id
) => updateSynonym(index, { from: e.target.value }) } placeholder="autorag" className="max-w-[12rem]" /> ) => updateSynonym(index, { to: e.target.value }) } placeholder="AI Search" className="max-w-[12rem]" />
))}
{synonymsSaved && Saved}
{synonymsError && ( )}
); } // ============================================================================= // Exports // ============================================================================= export const pages: PluginAdminExports["pages"] = { "/settings": SettingsPage, };