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.name} + ) : ( +
+ {(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;