diff --git a/examples/js/showcase/src/components/widgets/WidgetOnPageSuggestions.tsx b/examples/js/showcase/src/components/widgets/WidgetOnPageSuggestions.tsx
new file mode 100644
index 0000000000..f438db6781
--- /dev/null
+++ b/examples/js/showcase/src/components/widgets/WidgetOnPageSuggestions.tsx
@@ -0,0 +1,191 @@
+import { onPageSuggestions } from "instantsearch.js/es/widgets";
+import { useEffect, useState } from "preact/hooks";
+
+import { useWidget } from "../../hooks/useWidget";
+import { useSearch } from "../../context/search";
+
+// The generic `structured-outputs` endpoint only exists on the local backend
+// for now (see the PoC handoff), so we point the widget at it through the Vite
+// dev proxy (`/agent-backend` -> http://localhost:8000) to avoid CORS. Swap
+// `transport` for `agentId` once the endpoint ships to the Algolia API.
+const AGENT_ID = "deefd3da-ef9a-4cc6-969a-cc6fbb220bb7";
+const APP_ID = "F4T6CUV2AH";
+// Search-only key (safe to expose client-side, like the latency key in AgenticView).
+const API_KEY = "f33fd36eb0c251c553e3cd7684a6ba33";
+
+interface ProductHit {
+ objectID: string;
+ name: string;
+ brand?: string;
+ categories?: string[];
+ price?: number;
+ description?: string;
+ image?: string;
+}
+
+const suggestionItem = (suggestion: unknown) =>
+ typeof suggestion === "string"
+ ? suggestion
+ : (suggestion as { label?: string; title?: string })?.label ??
+ (suggestion as { title?: string })?.title ??
+ JSON.stringify(suggestion);
+
+// The PDP "context" sent to the task: the meaningful record fields, without the
+// search metadata (`_highlightResult`, `__position`, …) that the index returns.
+function toContextProduct(hit: ProductHit) {
+ return {
+ objectID: hit.objectID,
+ name: hit.name,
+ brand: hit.brand,
+ categories: hit.categories,
+ price: hit.price,
+ description: hit.description,
+ };
+}
+
+// Mounts the widget for a single product. Re-mounted (via `key`) when the
+// selected product changes, which re-runs the initial generation.
+function ProductSuggestions({ product }: { product: ProductHit }) {
+ const ref = useWidget((el) =>
+ onPageSuggestions({
+ container: el,
+ contextType: "pdp",
+ context: { product: toContextProduct(product) },
+ maxSuggestions: 3,
+ // Stream NDJSON snapshots so the list fills in progressively. The
+ // connector reads the body as NDJSON when `stream` is true, so the
+ // `?stream=true` query param must match.
+ stream: true,
+ transport: {
+ api: `/agent-backend/1/agents/${AGENT_ID}/structured-outputs?stream=true`,
+ headers: {
+ "x-algolia-application-id": APP_ID,
+ "x-algolia-api-key": API_KEY,
+ },
+ },
+ templates: { item: suggestionItem },
+ cssClasses: {
+ root: "flex flex-col gap-2",
+ refresh:
+ "self-start rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-800",
+ list: "flex flex-col gap-1.5",
+ item: "rounded-md bg-neutral-50 px-3 py-2 text-sm text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200",
+ loading: "text-xs text-neutral-500 dark:text-neutral-400",
+ empty: "text-xs text-neutral-500 dark:text-neutral-400",
+ },
+ })
+ );
+
+ return
;
+}
+
+export function WidgetOnPageSuggestions() {
+ const search = useSearch();
+ const [products, setProducts] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ // `search.client` is typed as a search/composition union, so narrow it to
+ // the plain multi-query `search` shape we use here.
+ const client = search.client as unknown as {
+ search: (
+ requests: Array<{
+ indexName: string;
+ params: { query?: string; hitsPerPage?: number };
+ }>
+ ) => Promise<{ results: Array<{ hits?: ProductHit[] }> }>;
+ };
+
+ client
+ .search([
+ { indexName: search.indexName, params: { query: "", hitsPerPage: 6 } },
+ ])
+ .then((response) => {
+ if (!active) return;
+ const hits = response.results[0]?.hits ?? [];
+ setProducts(hits);
+ setSelectedId(hits[0]?.objectID ?? null);
+ })
+ .catch(() => {
+ /* leave the loading state; nothing actionable for the demo */
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const selected =
+ products.find((product) => product.objectID === selectedId) ?? null;
+
+ if (!selected) {
+ return (
+
+ Loading products from the index…
+
+ );
+ }
+
+ return (
+
+ {/* Record picker (records come from the index) */}
+
+ {products.map((product) => {
+ const isSelected = product.objectID === selected.objectID;
+ return (
+
+ );
+ })}
+
+
+ {/* Selected record (the "PDP") */}
+
+ {selected.image ? (
+

+ ) : (
+
+ {(selected.brand ?? selected.name).charAt(0)}
+
+ )}
+
+
+ {selected.name}
+
+
+ {[selected.brand, selected.price && `$${selected.price.toLocaleString()}`]
+ .filter(Boolean)
+ .join(" · ")}
+
+ {selected.description && (
+
+ {selected.description}
+
+ )}
+
+
+
+ {/* Suggestions for the selected record */}
+
+
+ On-page suggestions
+
+
+
+
+ );
+}
diff --git a/examples/js/showcase/src/views/AgenticView.tsx b/examples/js/showcase/src/views/AgenticView.tsx
index b7f388a16c..f406e6c923 100644
--- a/examples/js/showcase/src/views/AgenticView.tsx
+++ b/examples/js/showcase/src/views/AgenticView.tsx
@@ -9,6 +9,7 @@ import { WidgetChatTrigger } from "../components/widgets/WidgetChatTrigger";
// TODO: re-enable once the `filterSuggestions` widget works properly.
// import { WidgetFilterSuggestions } from "../components/widgets/WidgetFilterSuggestions";
import { WidgetHits } from "../components/widgets/WidgetHits";
+import { WidgetOnPageSuggestions } from "../components/widgets/WidgetOnPageSuggestions";
import { WidgetSwitcher } from "../components/WidgetSwitcher";
import { ChatLayoutContext } from "../context/chatLayout";
import { SearchContext } from "../context/search";
@@ -76,7 +77,14 @@ export function AgenticView() {
- {/* Row 3: Hits */}
+ {/* Row 3: On-page suggestions (generic structured-outputs endpoint) */}
+
+
+ {/* Row 4: Hits */}
diff --git a/examples/js/showcase/vite.config.mjs b/examples/js/showcase/vite.config.mjs
index 8809d78040..75cea77037 100644
--- a/examples/js/showcase/vite.config.mjs
+++ b/examples/js/showcase/vite.config.mjs
@@ -5,6 +5,17 @@ import commonjs from 'vite-plugin-commonjs';
export default defineConfig({
plugins: [commonjs(), tailwindcss(), preact()],
+ server: {
+ // Proxy the local `structured-outputs` backend so the browser stays
+ // same-origin (no CORS). Override the target with STRUCTURED_OUTPUTS_API.
+ proxy: {
+ '/agent-backend': {
+ target: process.env.STRUCTURED_OUTPUTS_API || 'http://localhost:8000',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/agent-backend/, ''),
+ },
+ },
+ },
build: {
commonjsOptions: {
requireReturnsDefault: 'preferred',
diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts
index d43db5fb5a..973c60d005 100644
--- a/packages/instantsearch.js/src/connectors/index.ts
+++ b/packages/instantsearch.js/src/connectors/index.ts
@@ -59,3 +59,4 @@ export { default as connectChat } from './chat/connectChat';
export { default as connectFeeds } from './feeds/connectFeeds';
export { default as connectChatTrigger } from './chat/connectChatTrigger';
export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions';
+export { default as connectStructuredOutput } from './structured-output/connectStructuredOutput';
diff --git a/packages/instantsearch.js/src/connectors/structured-output/__tests__/connectStructuredOutput-test.ts b/packages/instantsearch.js/src/connectors/structured-output/__tests__/connectStructuredOutput-test.ts
new file mode 100644
index 0000000000..007a3b9fdb
--- /dev/null
+++ b/packages/instantsearch.js/src/connectors/structured-output/__tests__/connectStructuredOutput-test.ts
@@ -0,0 +1,251 @@
+/**
+ * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
+ */
+
+import { createSearchClient } from '@instantsearch/mocks';
+import algoliasearchHelper from 'algoliasearch-helper';
+
+import { createInitOptions } from '../../../../test/createWidget';
+import connectStructuredOutput from '../connectStructuredOutput';
+
+import type {
+ StructuredOutputConnectorParams,
+ StructuredOutputRenderState,
+} from '../connectStructuredOutput';
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+const stringToUint8Array = (str: string): Uint8Array => {
+ const arr = new Uint8Array(str.length);
+ for (let i = 0; i < str.length; i++) {
+ arr[i] = str.charCodeAt(i);
+ }
+ return arr;
+};
+
+const createMockStream = (lines: string[]): ReadableStream => {
+ let index = 0;
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ return {
+ getReader: () => ({
+ read: () =>
+ index < lines.length
+ ? Promise.resolve({
+ done: false,
+ value: stringToUint8Array(lines[index++]),
+ })
+ : Promise.resolve({ done: true, value: undefined }),
+ releaseLock: () => {},
+ }),
+ } as ReadableStream;
+};
+
+describe('connectStructuredOutput', () => {
+ const getInitializedWidget = (
+ widgetParams: Partial = {}
+ ) => {
+ const renderFn = jest.fn();
+ const unmountFn = jest.fn();
+ const makeWidget = connectStructuredOutput(renderFn, unmountFn);
+ const widget = makeWidget({
+ agentId: 'agentId',
+ task: 'on_page_suggestions',
+ ...widgetParams,
+ });
+
+ const helper = algoliasearchHelper(createSearchClient(), 'indexName');
+ widget.init(createInitOptions({ helper }));
+
+ const getRenderState = (): StructuredOutputRenderState =>
+ renderFn.mock.calls[renderFn.mock.calls.length - 1][0];
+
+ return { widget, helper, renderFn, unmountFn, getRenderState };
+ };
+
+ describe('Usage', () => {
+ it('throws without render function', () => {
+ expect(() => {
+ // @ts-expect-error
+ connectStructuredOutput()({ task: 'x' });
+ }).toThrow(/The render function is not valid/);
+ });
+
+ it('throws without task', () => {
+ expect(() => {
+ connectStructuredOutput(jest.fn())({} as StructuredOutputConnectorParams);
+ }).toThrow('The `task` option is required.');
+ });
+
+ it('throws without agentId or transport', () => {
+ expect(() => {
+ connectStructuredOutput(jest.fn())({ task: 'on_page_suggestions' });
+ }).toThrow(
+ 'The `agentId` option is required unless a custom `transport` is provided.'
+ );
+ });
+
+ it('is a widget', () => {
+ const widget = connectStructuredOutput(jest.fn())({
+ agentId: 'agentId',
+ task: 'on_page_suggestions',
+ });
+
+ expect(widget).toEqual(
+ expect.objectContaining({
+ $$type: 'ais.structuredOutput',
+ init: expect.any(Function),
+ render: expect.any(Function),
+ dispose: expect.any(Function),
+ })
+ );
+ });
+ });
+
+ describe('submit (non-streaming)', () => {
+ it('posts { task, variables } to the structured-outputs endpoint', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ output: { suggestions: ['a'] } }),
+ })
+ ) as jest.Mock;
+
+ const { getRenderState } = getInitializedWidget();
+ getRenderState().submit({ contextType: 'pdp', objectID: '1' });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/agent-studio/1/agents/agentId/structured-outputs?stream=false'
+ ),
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({
+ task: 'on_page_suggestions',
+ variables: { contextType: 'pdp', objectID: '1' },
+ }),
+ })
+ );
+
+ await flush();
+
+ expect(getRenderState().output).toEqual({ suggestions: ['a'] });
+ expect(getRenderState().isLoading).toBe(false);
+ expect(getRenderState().error).toBeNull();
+ });
+
+ it('exposes the error on failure', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({ ok: false, status: 502 })
+ ) as jest.Mock;
+
+ const { getRenderState } = getInitializedWidget();
+ getRenderState().submit({});
+
+ await flush();
+
+ expect(getRenderState().output).toBeNull();
+ expect(getRenderState().error).toEqual(new Error('HTTP error 502'));
+ expect(getRenderState().isLoading).toBe(false);
+ });
+ });
+
+ describe('submit (streaming)', () => {
+ it('replaces output with each AI SDK structured-output data part', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ body: createMockStream([
+ 'data: {"type":"start","messageId":"message-id"}\n\n',
+ 'data: {"type":"data-structured-output","id":"on_page_suggestions","data":{"suggestions":["a"]}}\n\n',
+ 'data: {"type":"data-structured-output","id":"on_page_suggestions","data":{"suggestions":["a","b"]}}\n\n',
+ 'data: {"type":"finish"}\n\n',
+ 'data: [DONE]\n\n',
+ ]),
+ })
+ ) as jest.Mock;
+
+ const { getRenderState } = getInitializedWidget({ stream: true });
+
+ expect(global.fetch).not.toHaveBeenCalled();
+ getRenderState().submit({});
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('structured-outputs?stream=true'),
+ expect.anything()
+ );
+
+ await flush();
+
+ expect(getRenderState().output).toEqual({ suggestions: ['a', 'b'] });
+ expect(getRenderState().isLoading).toBe(false);
+ });
+
+ it('exposes AI SDK stream errors', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ body: createMockStream([
+ 'data: {"type":"start","messageId":"message-id"}\n\n',
+ 'data: {"type":"error","errorText":"Structured generation failed"}\n\n',
+ 'data: [DONE]\n\n',
+ ]),
+ })
+ ) as jest.Mock;
+
+ const { getRenderState } = getInitializedWidget({ stream: true });
+ getRenderState().submit({});
+
+ await flush();
+
+ expect(getRenderState().output).toBeNull();
+ expect(getRenderState().error).toEqual(
+ new Error('Structured generation failed')
+ );
+ expect(getRenderState().isLoading).toBe(false);
+ });
+ });
+
+ describe('custom transport', () => {
+ it('uses the transport api and prepareRequest', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ output: {} }),
+ })
+ ) as jest.Mock;
+
+ const { getRenderState } = getInitializedWidget({
+ agentId: undefined,
+ transport: {
+ api: 'https://example.test/structured',
+ headers: { 'x-custom': '1' },
+ prepareRequest: (body) => ({ body: { ...body, extra: true } }),
+ },
+ });
+
+ getRenderState().submit({ a: 1 });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://example.test/structured',
+ expect.objectContaining({
+ headers: expect.objectContaining({ 'x-custom': '1' }),
+ body: JSON.stringify({
+ task: 'on_page_suggestions',
+ variables: { a: 1 },
+ extra: true,
+ }),
+ })
+ );
+
+ await flush();
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls the unmount function', () => {
+ const { widget, unmountFn } = getInitializedWidget();
+ widget.dispose();
+ expect(unmountFn).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/instantsearch.js/src/connectors/structured-output/connectStructuredOutput.ts b/packages/instantsearch.js/src/connectors/structured-output/connectStructuredOutput.ts
new file mode 100644
index 0000000000..8f072cafbb
--- /dev/null
+++ b/packages/instantsearch.js/src/connectors/structured-output/connectStructuredOutput.ts
@@ -0,0 +1,299 @@
+import { parseJsonEventStream, processStream } from '../../lib/ai-lite';
+import {
+ checkRendering,
+ createDocumentationMessageGenerator,
+ getAlgoliaAgent,
+ getAppIdAndApiKey,
+ noop,
+} from '../../lib/utils';
+
+import type { UIMessageChunk } from '../../lib/ai-lite';
+import type {
+ Connector,
+ IndexRenderState,
+ InitOptions,
+ Renderer,
+ Unmounter,
+ UnknownWidgetParams,
+ WidgetRenderState,
+} from '../../types';
+
+const withUsage = createDocumentationMessageGenerator({
+ name: 'structured-output',
+ connector: true,
+});
+
+export type StructuredOutputTransport = {
+ /**
+ * The custom API endpoint URL.
+ */
+ api: string;
+ /**
+ * Custom headers to send with the request.
+ */
+ headers?: Record;
+ /**
+ * Function to prepare the request body before sending.
+ * Receives the default `{ task, variables }` body and returns the body to send.
+ */
+ prepareRequest?: (body: Record) => {
+ body: Record;
+ };
+};
+
+export type StructuredOutputRenderState> = {
+ /**
+ * The latest structured output. During streaming this is a progressively
+ * completed snapshot; `null` before the first response and after an error.
+ */
+ output: TOutput | null;
+ /**
+ * Whether a generation is currently in flight (including while streaming).
+ */
+ isLoading: boolean;
+ /**
+ * The error from the last failed generation, if any.
+ */
+ error: Error | null;
+ /**
+ * Runs the configured task with the given variables. Cancels any in-flight
+ * generation before starting a new one.
+ */
+ submit: (variables: Record) => void;
+};
+
+export type StructuredOutputConnectorParams = {
+ /**
+ * The ID of the agent configured in the Algolia dashboard.
+ * Required unless a custom `transport` is provided.
+ */
+ agentId?: string;
+ /**
+ * The structured task to run (an enabled `structuredTasks.` on the agent).
+ */
+ task: string;
+ /**
+ * Whether to stream the output as AI SDK structured-output data parts instead
+ * of waiting for the full object.
+ * @default false
+ */
+ stream?: boolean;
+ /**
+ * Custom transport configuration for the API requests.
+ * When provided, allows using a custom endpoint, headers, and request body.
+ */
+ transport?: StructuredOutputTransport;
+ /**
+ * Identifier used as the key in `indexRenderState`, so several structured-output
+ * widgets can coexist on the same index.
+ * @default 'structuredOutput'
+ */
+ type?: string;
+};
+
+export type StructuredOutputWidgetDescription<
+ TOutput = Record
+> = {
+ $$type: 'ais.structuredOutput';
+ renderState: StructuredOutputRenderState;
+ indexRenderState: {
+ structuredOutput: WidgetRenderState<
+ StructuredOutputRenderState,
+ StructuredOutputConnectorParams
+ >;
+ };
+};
+
+export type StructuredOutputConnector = Connector<
+ StructuredOutputWidgetDescription,
+ StructuredOutputConnectorParams
+>;
+
+export default (function connectStructuredOutput<
+ TWidgetParams extends UnknownWidgetParams
+>(
+ renderFn: Renderer<
+ StructuredOutputRenderState,
+ TWidgetParams & StructuredOutputConnectorParams
+ >,
+ unmountFn: Unmounter = noop
+) {
+ checkRendering(renderFn, withUsage());
+
+ return = Record>(
+ widgetParams: TWidgetParams & StructuredOutputConnectorParams
+ ) => {
+ const {
+ agentId,
+ task,
+ stream = false,
+ transport,
+ type = 'structuredOutput',
+ } = widgetParams || {};
+
+ if (!task) {
+ throw new Error(withUsage('The `task` option is required.'));
+ }
+
+ if (!agentId && !transport) {
+ throw new Error(
+ withUsage(
+ 'The `agentId` option is required unless a custom `transport` is provided.'
+ )
+ );
+ }
+
+ let endpoint: string;
+ let headers: Record;
+ let output: TOutput | null = null;
+ let isLoading = false;
+ let error: Error | null = null;
+ let abortController: AbortController | undefined;
+ let render: () => void = noop;
+
+ const buildBody = (variables: Record) => {
+ const body: Record = { task, variables };
+ return transport?.prepareRequest ? transport.prepareRequest(body).body : body;
+ };
+
+ const readStream = (response: Response): Promise => {
+ if (!response.body) {
+ return Promise.reject(new Error('The streaming response has no body.'));
+ }
+
+ const chunks = parseJsonEventStream(response.body);
+
+ return new Promise((resolve, reject) => {
+ processStream(
+ chunks,
+ (chunk) => {
+ if (chunk.type === 'data-structured-output') {
+ output = chunk.data as TOutput;
+ render();
+ }
+ if (chunk.type === 'error') {
+ reject(new Error(chunk.errorText));
+ }
+ },
+ resolve,
+ reject
+ );
+ });
+ };
+
+ const submit = (variables: Record) => {
+ abortController?.abort();
+ abortController = new AbortController();
+ const { signal } = abortController;
+
+ isLoading = true;
+ error = null;
+ output = null;
+ render();
+
+ fetch(endpoint, {
+ method: 'POST',
+ headers: { ...headers, 'Content-Type': 'application/json' },
+ body: JSON.stringify(buildBody(variables)),
+ signal,
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error ${response.status}`);
+ }
+
+ if (stream) {
+ return readStream(response);
+ }
+
+ return response.json().then((data) => {
+ output = (data?.output ?? null) as TOutput | null;
+ });
+ })
+ .then(() => {
+ if (signal.aborted) {
+ return;
+ }
+ isLoading = false;
+ render();
+ })
+ .catch((err) => {
+ if (signal.aborted) {
+ return;
+ }
+ error = err as Error;
+ output = null;
+ isLoading = false;
+ render();
+ });
+ };
+
+ const getWidgetRenderState = () => ({
+ output,
+ isLoading,
+ error,
+ submit,
+ widgetParams,
+ });
+
+ return {
+ $$type: 'ais.structuredOutput',
+
+ init(initOptions: InitOptions) {
+ const { instantSearchInstance } = initOptions;
+
+ if (transport) {
+ endpoint = transport.api;
+ headers = transport.headers || {};
+ } else {
+ const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client);
+
+ if (!appId || !apiKey) {
+ throw new Error(
+ withUsage(
+ 'Could not extract Algolia credentials from the search client.'
+ )
+ );
+ }
+
+ endpoint = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/structured-outputs?stream=${stream}`;
+ headers = {
+ 'x-algolia-application-id': appId,
+ 'x-algolia-api-key': apiKey,
+ 'x-algolia-agent': getAlgoliaAgent(instantSearchInstance.client),
+ };
+ }
+
+ render = () => {
+ renderFn(
+ { ...getWidgetRenderState(), instantSearchInstance },
+ false
+ );
+ };
+
+ renderFn({ ...getWidgetRenderState(), instantSearchInstance }, true);
+ },
+
+ render({ instantSearchInstance }) {
+ renderFn({ ...getWidgetRenderState(), instantSearchInstance }, false);
+ },
+
+ dispose() {
+ abortController?.abort();
+ unmountFn();
+ },
+
+ getRenderState(renderState): IndexRenderState &
+ StructuredOutputWidgetDescription['indexRenderState'] {
+ return {
+ ...renderState,
+ [type as 'structuredOutput']: getWidgetRenderState(),
+ };
+ },
+
+ getWidgetRenderState() {
+ return getWidgetRenderState();
+ },
+ };
+ };
+} satisfies StructuredOutputConnector);
diff --git a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts
index d1b8013e38..fab05dc8f7 100644
--- a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts
+++ b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts
@@ -181,6 +181,14 @@ function initiateAllWidgets(): Array<[WidgetNames, Widget | IndexWidget]> {
attributes: ['attr'],
});
}
+ case 'onPageSuggestions': {
+ const onPageSuggestions = widget as Widgets['onPageSuggestions'];
+ return onPageSuggestions({
+ container,
+ agentId: 'test-agent-id',
+ contextType: 'pdp',
+ });
+ }
case 'chatTrigger': {
const chatTriggerWidget = widget as Widgets['chatTrigger'];
return chatTriggerWidget({
diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts
index 70176bdcae..71e13f3651 100644
--- a/packages/instantsearch.js/src/widgets/index.ts
+++ b/packages/instantsearch.js/src/widgets/index.ts
@@ -64,3 +64,4 @@ export { default as lookingSimilar } from './looking-similar/looking-similar';
export { default as chat } from './chat/chat';
export { default as chatTrigger } from './chat-trigger/chat-trigger';
export { default as filterSuggestions } from './filter-suggestions/filter-suggestions';
+export { default as onPageSuggestions } from './on-page-suggestions/on-page-suggestions';
diff --git a/packages/instantsearch.js/src/widgets/on-page-suggestions/on-page-suggestions.tsx b/packages/instantsearch.js/src/widgets/on-page-suggestions/on-page-suggestions.tsx
new file mode 100644
index 0000000000..2692810458
--- /dev/null
+++ b/packages/instantsearch.js/src/widgets/on-page-suggestions/on-page-suggestions.tsx
@@ -0,0 +1,250 @@
+/** @jsx h */
+
+import { h, render } from 'preact';
+
+import connectStructuredOutput from '../../connectors/structured-output/connectStructuredOutput';
+import {
+ getContainerNode,
+ createDocumentationMessageGenerator,
+} from '../../lib/utils';
+
+import type {
+ StructuredOutputConnectorParams,
+ StructuredOutputRenderState,
+ StructuredOutputWidgetDescription,
+} from '../../connectors/structured-output/connectStructuredOutput';
+import type { Renderer, WidgetFactory } from '../../types';
+import type { ComponentChildren } from 'preact';
+
+const withUsage = createDocumentationMessageGenerator({
+ name: 'on-page-suggestions',
+});
+
+/**
+ * The shape returned by the `on_page_suggestions` task. The individual
+ * suggestion shape is defined by the agent's output schema, so it stays
+ * `unknown` here and is resolved by the `item` template.
+ */
+export type OnPageSuggestionsOutput = {
+ suggestions?: unknown[];
+};
+
+export type OnPageSuggestionsContextType = 'pdp' | 'plp' | 'custom';
+
+export type OnPageSuggestionsTemplates = Partial<{
+ /**
+ * Renders a single suggestion. Defaults to the suggestion's `label`/`title`/`text`
+ * field, the string itself, or its JSON representation.
+ */
+ item: (suggestion: unknown, index: number) => ComponentChildren;
+ /**
+ * Renders the loading state.
+ */
+ loading: () => ComponentChildren;
+ /**
+ * Renders when there are no suggestions (or generation failed).
+ */
+ empty: () => ComponentChildren;
+}>;
+
+export type OnPageSuggestionsCSSClasses = Partial<{
+ root: string;
+ refresh: string;
+ list: string;
+ item: string;
+ loading: string;
+ empty: string;
+}>;
+
+type OnPageSuggestionsWidgetParams = {
+ /**
+ * CSS Selector or HTMLElement to insert the widget.
+ */
+ container: string | HTMLElement;
+ /**
+ * The structured task to run.
+ * @default 'on_page_suggestions'
+ */
+ task?: string;
+ /**
+ * The kind of page the suggestions are generated for. Selects how `context`
+ * is mapped to the task `variables`.
+ */
+ contextType: OnPageSuggestionsContextType;
+ /**
+ * The page data to send (e.g. the product record for `pdp`). Merged with
+ * `contextType` into the task `variables`. Ignored when `getVariables` is set.
+ */
+ context?: Record;
+ /**
+ * Full control over the task `variables`. Overrides `contextType`/`context`.
+ */
+ getVariables?: () => Record;
+ /**
+ * Hard cap on the number of rendered suggestions.
+ * @default 3
+ */
+ maxSuggestions?: number;
+ /**
+ * Templates to use for the widget.
+ */
+ templates?: OnPageSuggestionsTemplates;
+ /**
+ * CSS classes to add.
+ */
+ cssClasses?: OnPageSuggestionsCSSClasses;
+};
+
+export type OnPageSuggestionsWidget = WidgetFactory<
+ StructuredOutputWidgetDescription & {
+ $$widgetType: 'ais.onPageSuggestions';
+ },
+ Omit,
+ OnPageSuggestionsWidgetParams
+>;
+
+function defaultItem(suggestion: unknown): ComponentChildren {
+ if (typeof suggestion === 'string') {
+ return suggestion;
+ }
+ if (suggestion && typeof suggestion === 'object') {
+ const record = suggestion as Record;
+ const label = record.label ?? record.title ?? record.text;
+ if (typeof label === 'string') {
+ return label;
+ }
+ }
+ return JSON.stringify(suggestion);
+}
+
+const createRenderer =
+ ({
+ containerNode,
+ cssClasses,
+ templates,
+ maxSuggestions,
+ getVariables,
+ }: {
+ containerNode: HTMLElement;
+ cssClasses: OnPageSuggestionsCSSClasses;
+ templates: OnPageSuggestionsTemplates;
+ maxSuggestions: number;
+ getVariables: () => Record;
+ }): Renderer<
+ StructuredOutputRenderState,
+ Partial
+ > =>
+ ({ output, isLoading, error, submit }, isFirstRendering) => {
+ if (isFirstRendering) {
+ // On-page suggestions are page-context driven, not search driven: kick off
+ // a single generation as soon as the widget mounts.
+ submit(getVariables());
+ return;
+ }
+
+ const suggestions = (output?.suggestions ?? []).slice(0, maxSuggestions);
+ const itemTemplate = templates.item || defaultItem;
+
+ let children: ComponentChildren;
+ if (isLoading && suggestions.length === 0) {
+ children = templates.loading ? (
+ templates.loading()
+ ) : (
+ Loading suggestions…
+ );
+ } else if (suggestions.length === 0) {
+ children = templates.empty ? (
+ templates.empty()
+ ) : (
+
+ {error ? 'Could not load suggestions.' : 'No suggestions.'}
+
+ );
+ } else {
+ children = (
+
+ {suggestions.map((suggestion, index) => (
+ -
+ {itemTemplate(suggestion, index)}
+
+ ))}
+
+ );
+ }
+
+ render(
+
+
+ {children}
+
,
+ containerNode
+ );
+ };
+
+const onPageSuggestions: OnPageSuggestionsWidget = function onPageSuggestions(
+ widgetParams
+) {
+ const {
+ container,
+ contextType,
+ context,
+ getVariables,
+ maxSuggestions = 3,
+ templates = {},
+ cssClasses = {},
+ task = 'on_page_suggestions',
+ agentId,
+ stream,
+ transport,
+ type,
+ } = widgetParams || {};
+
+ if (!container) {
+ throw new Error(withUsage('The `container` option is required.'));
+ }
+
+ const containerNode = getContainerNode(container);
+
+ const resolveVariables = (): Record =>
+ getVariables ? getVariables() : { contextType, ...context };
+
+ const specializedRenderer = createRenderer({
+ containerNode,
+ cssClasses: {
+ root: 'ais-OnPageSuggestions',
+ refresh: 'ais-OnPageSuggestions-refresh',
+ list: 'ais-OnPageSuggestions-list',
+ item: 'ais-OnPageSuggestions-item',
+ loading: 'ais-OnPageSuggestions-loading',
+ empty: 'ais-OnPageSuggestions-empty',
+ ...cssClasses,
+ },
+ templates,
+ maxSuggestions,
+ getVariables: resolveVariables,
+ });
+
+ const makeWidget = connectStructuredOutput(specializedRenderer, () =>
+ render(null, containerNode)
+ );
+
+ return {
+ ...makeWidget({
+ agentId,
+ task,
+ stream,
+ transport,
+ type,
+ }),
+ $$widgetType: 'ais.onPageSuggestions',
+ };
+};
+
+export default onPageSuggestions;