diff --git a/bundlesize.config.json b/bundlesize.config.json index 996b4865233..3153e701d0e 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "130 kB" + "maxSize": "133 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "275 kB" + "maxSize": "280 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "102.5 kB" + "maxSize": "105.5 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", @@ -42,11 +42,11 @@ }, { "path": "./packages/instantsearch.css/themes/algolia.css", - "maxSize": "10.75 kB" + "maxSize": "11.25 kB" }, { "path": "./packages/instantsearch.css/themes/algolia-min.css", - "maxSize": "10 kB" + "maxSize": "10.5 kB" }, { "path": "./packages/instantsearch.css/themes/reset.css", @@ -58,27 +58,27 @@ }, { "path": "./packages/instantsearch.css/themes/nova.css", - "maxSize": "11 kB" + "maxSize": "11.5 kB" }, { "path": "./packages/instantsearch.css/themes/nova-min.css", - "maxSize": "10.25 kB" + "maxSize": "10.75 kB" }, { "path": "./packages/instantsearch.css/themes/satellite.css", - "maxSize": "11.75 kB" + "maxSize": "12.25 kB" }, { "path": "./packages/instantsearch.css/themes/satellite-min.css", - "maxSize": "11 kB" + "maxSize": "11.5 kB" }, { "path": "./packages/instantsearch.css/components/chat.css", - "maxSize": "6.25 kB" + "maxSize": "6.75 kB" }, { "path": "./packages/instantsearch.css/components/chat-min.css", - "maxSize": "5.75 kB" + "maxSize": "6.25 kB" }, { "path": "./packages/instantsearch.css/components/autocomplete.css", diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index 36973875179..312252a60e3 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -96,6 +96,7 @@ export function App() { agentId="eedef238-5468-470d-bc37-f99fa741bd25" feedback={true} itemComponent={ItemComponent} + showReasoning /> diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index f65c4d1d2b9..a05bae5f2a6 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -4,6 +4,12 @@ import { compiler } from 'markdown-to-jsx'; import { cx, startsWith } from '../../lib'; import { createButtonComponent } from '../Button'; +import { + createChatMessageReasoningComponent, + type ChatMessageReasoningClassNames, + type ChatMessageReasoningTranslations, + type ChatMessageReasoningVisibility, +} from './ChatMessageReasoning'; import { MenuIcon } from './icons'; import type { ComponentProps, Renderer, VNode } from '../../types'; @@ -145,6 +151,30 @@ export type ChatMessageProps = ComponentProps<'article'> & { * Optional suggestions element */ suggestionsElement?: VNode; + /** + * Whether to render `reasoning` UI parts via ``. + * Off by default - enable explicitly when the backend emits reasoning + * summaries / extended thinking and you want to surface them. + */ + showReasoning?: boolean; + /** + * Visibility strategy for the reasoning panel. + * - `collapsed` (default): closed, user can expand. + * - `expanded`: always open. + * - `auto`: open while streaming, collapsible afterwards. + * - `hidden`: do not render reasoning even if parts exist. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + /** + * Injectable strings for the reasoning panel (title, toggle label). + * Forwarded to ``. + */ + reasoningTranslations?: Partial; + /** + * Optional class names for the reasoning panel. Forwarded to + * ``. + */ + reasoningClassNames?: Partial; /** * Optional class names */ @@ -169,8 +199,15 @@ export type ChatMessageProps = ComponentProps<'article'> & { // Keep in sync with packages/instantsearch.js/src/lib/chat/index.ts const SearchIndexToolType = 'algolia_search_index'; -export function createChatMessageComponent({ createElement }: Renderer) { +export function createChatMessageComponent({ + createElement, + Fragment, +}: Renderer) { const Button = createButtonComponent({ createElement }); + const ChatMessageReasoning = createChatMessageReasoningComponent({ + createElement, + Fragment, + }); return function ChatMessage(userProps: ChatMessageProps) { const { @@ -191,6 +228,10 @@ export function createChatMessageComponent({ createElement }: Renderer) { onClose, translations: userTranslations, suggestionsElement, + showReasoning = false, + reasoningVisibility = 'collapsed', + reasoningTranslations, + reasoningClassNames, parseMarkdown = true, ...props } = userProps; @@ -222,6 +263,10 @@ export function createChatMessageComponent({ createElement }: Renderer) { footer: cx('ais-ChatMessage-footer', classNames.footer), }; + const firstReasoningIndex = showReasoning + ? message.parts.findIndex((p) => p.type === 'reasoning') + : -1; + function renderMessagePart( part: ChatMessageBase['parts'][number], index: number @@ -229,6 +274,21 @@ export function createChatMessageComponent({ createElement }: Renderer) { if (part.type === 'step-start') { return null; } + if (part.type === 'reasoning') { + if (!showReasoning) return null; + // Render one reasoning panel total, anchored at the first reasoning + // part. Subsequent reasoning parts are absorbed by that panel. + if (index !== firstReasoningIndex) return null; + return ( + + ); + } if (part.type === 'text') { // Back-compat shim for sessions started before the move from a // `{...}` text part to `metadata.turnContext`. diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx index db193f9e59a..1845abaf177 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx @@ -1,12 +1,15 @@ /** @jsx createElement */ +import { cx } from '../../lib'; + import { LoadingSpinnerIcon } from './icons'; import type { ComponentProps, Renderer } from '../../types'; export type ChatMessageLoaderTranslations = { /** - * Text to display in the loader + * Static text to display in the loader. Used as a fallback when no + * `reasoningPreview` is provided. */ loaderText?: string; }; @@ -16,22 +19,53 @@ export type ChatMessageLoaderProps = ComponentProps<'article'> & { * Translations for loader component texts */ translations?: Partial; + /** + * Live substitute label for the current reasoning step, e.g. + * "Searching the catalogue". Takes precedence over `loaderText` when set. + */ + reasoningPreview?: string; + /** + * Wall-clock time the model has been thinking, in ms. Drives the + * progress hairline; when omitted, the hairline is hidden. + */ + elapsedMs?: number; + /** + * Optimistic upper bound for the thinking time, in ms. Used to compute + * the progress ratio. Defaults to 8000 ms. + */ + expectedMs?: number; }; export function createChatMessageLoaderComponent({ createElement, }: Pick) { return function ChatMessageLoader(userProps: ChatMessageLoaderProps) { - const { translations: userTranslations, ...props } = userProps; + const { + translations: userTranslations, + reasoningPreview, + elapsedMs, + expectedMs = 8000, + ...props + } = userProps; const translations: Required = { loaderText: '', ...userTranslations, }; + const caption = reasoningPreview || translations.loaderText; + const hasProgress = typeof elapsedMs === 'number' && elapsedMs > 0; + const progress = hasProgress + ? Math.max(0, Math.min(1, (elapsedMs) / expectedMs)) + : 0; + return (
@@ -42,14 +76,30 @@ export function createChatMessageLoaderComponent({
- {translations.loaderText && ( -
- {translations.loaderText} + {caption && ( +
+ {caption}
)}
-
+
) + : undefined + } + >
diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx new file mode 100644 index 00000000000..a27a8362e37 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageReasoning.tsx @@ -0,0 +1,138 @@ +/** @jsx createElement */ + +import { cx } from '../../lib'; + +import { BrainIcon, ChevronDownIcon } from './icons'; + +import type { ComponentProps, Renderer } from '../../types'; +import type { ChatMessageBase, ReasoningUIPart } from './types'; + +export type ChatMessageReasoningVisibility = + | 'collapsed' + | 'expanded' + | 'auto' + | 'hidden'; + +export type ChatMessageReasoningTranslations = { + /** + * Header label for the reasoning panel. Defaults to "Reasoning" to match + * the Agent Studio dashboard. + */ + title: string; + /** Accessible label for the disclosure toggle. */ + toggleLabel: string; +}; + +export type ChatMessageReasoningClassNames = { + root: string | string[]; + header: string | string[]; + icon: string | string[]; + label: string | string[]; + chevron: string | string[]; + body: string | string[]; + text: string | string[]; +}; + +export type ChatMessageReasoningProps = ComponentProps<'details'> & { + /** The message whose `reasoning` parts should be rendered. */ + message: ChatMessageBase; + /** + * Visibility strategy. + * - `collapsed` (default): closed, user can expand — mirrors the dashboard. + * - `expanded`: always open. + * - `auto`: open while streaming, collapsible afterwards. + * - `hidden`: render nothing even if reasoning parts exist. + */ + visibility?: ChatMessageReasoningVisibility; + classNames?: Partial; + translations?: Partial; +}; + +function getReasoningParts(message: ChatMessageBase): ReasoningUIPart[] { + return message.parts.filter( + (p): p is ReasoningUIPart => p.type === 'reasoning' + ); +} + +export function createChatMessageReasoningComponent({ + createElement, +}: Renderer) { + return function ChatMessageReasoning(userProps: ChatMessageReasoningProps) { + const { + message, + visibility = 'collapsed', + classNames = {}, + translations: userTranslations, + ...props + } = userProps; + + if (visibility === 'hidden') return null; + + const reasoningParts = getReasoningParts(message); + if (reasoningParts.length === 0) return null; + + const translations: Required = { + title: 'Reasoning', + toggleLabel: 'Toggle reasoning', + ...userTranslations, + }; + + const streaming = reasoningParts.some((part) => part.state === 'streaming'); + + // Native `
` handles the open/closed state (and the `[open]` + // attribute the CSS keys off) with zero JS. We only force `open` for the + // `expanded` strategy and while streaming under `auto`; otherwise the + // element is uncontrolled so the user can freely toggle it. + const forceOpen = + visibility === 'expanded' || (visibility === 'auto' && streaming); + + const cssClasses: ChatMessageReasoningClassNames = { + root: cx( + 'ais-ChatMessageReasoning', + streaming && 'ais-ChatMessageReasoning--streaming', + classNames.root + ), + header: cx('ais-ChatMessageReasoning-header', classNames.header), + icon: cx('ais-ChatMessageReasoning-icon', classNames.icon), + label: cx('ais-ChatMessageReasoning-label', classNames.label), + chevron: cx('ais-ChatMessageReasoning-chevron', classNames.chevron), + body: cx('ais-ChatMessageReasoning-body', classNames.body), + text: cx('ais-ChatMessageReasoning-text', classNames.text), + }; + + return ( +
+ + + {translations.title} + + + +
+ {reasoningParts.map((part, index) => ( +

+ {part.text} +

+ ))} +
+
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 27539b9b53b..1a40a704572 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -8,6 +8,11 @@ import { isPartText, isPartTool, } from '../../lib/utils/chat'; +import { + getReasoningContext, + summarizeReasoning, + type ReasoningSummarizer, +} from '../../lib/utils/reasoning'; import { createButtonComponent } from '../Button'; import { createChatMessageComponent } from './ChatMessage'; @@ -38,6 +43,11 @@ import type { } from './ChatMessage'; import type { ChatMessageErrorProps } from './ChatMessageError'; import type { ChatMessageLoaderProps } from './ChatMessageLoader'; +import type { + ChatMessageReasoningClassNames, + ChatMessageReasoningTranslations, + ChatMessageReasoningVisibility, +} from './ChatMessageReasoning'; import type { ChatEmptyProps, ChatLayoutOwnProps, @@ -241,6 +251,31 @@ export type ChatMessagesProps< * Map of message IDs to their feedback state. */ feedbackState?: Record; + /** + * Whether to render reasoning UI parts. Off by default. + * Forwarded to every {@link ChatMessageProps.showReasoning}, and also + * controls whether the loader caption is replaced with a live + * substitute label derived from the reasoning stream. + */ + showReasoning?: boolean; + /** + * Visibility strategy for the reasoning panel. Default: `collapsed`. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + /** + * Optional override for the reasoning summarizer. + */ + reasoningSummarizer?: ReasoningSummarizer; + /** + * Injectable strings for the reasoning panel. Forwarded to every message's + * ``. + */ + reasoningTranslations?: Partial; + /** + * Optional class names for the reasoning panel. Forwarded to every message's + * ``. + */ + reasoningClassNames?: Partial; }; const copyToClipboard = (message: ChatMessageBase) => { @@ -298,6 +333,10 @@ function createDefaultMessageComponent< messageTranslations, translations, suggestionsElement, + showReasoning, + reasoningVisibility, + reasoningTranslations, + reasoningClassNames, }: { key: string; message: TMessage; @@ -317,6 +356,10 @@ function createDefaultMessageComponent< classNames?: Partial; messageTranslations?: Partial; suggestionsElement?: VNode; + showReasoning?: boolean; + reasoningVisibility?: ChatMessageReasoningVisibility; + reasoningTranslations?: Partial; + reasoningClassNames?: Partial; }) { const defaultAssistantActions: ChatMessageActionProps[] = [ ...(hasTextContent(message) @@ -401,6 +444,10 @@ function createDefaultMessageComponent< classNames={classNames} translations={messageTranslations} suggestionsElement={suggestionsElement} + showReasoning={showReasoning} + reasoningVisibility={reasoningVisibility} + reasoningTranslations={reasoningTranslations} + reasoningClassNames={reasoningClassNames} {...messageProps} /> ); @@ -464,6 +511,11 @@ export function createChatMessagesComponent({ suggestionsElement, onFeedback, feedbackState, + showReasoning = false, + reasoningVisibility = 'collapsed', + reasoningSummarizer, + reasoningTranslations, + reasoningClassNames, ...props } = userProps; @@ -496,6 +548,28 @@ export function createChatMessagesComponent({ const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; const showLoader = getShowLoader(status, lastPart, tools); + // Derive a live substitute label for the loader from the in-flight + // assistant message. Off when reasoning rendering is opted-out. + let reasoningPreview: string | undefined; + if ( + showReasoning && + lastMessage && + lastMessage.role === 'assistant' + ) { + const summarizer = reasoningSummarizer || summarizeReasoning; + const ctx = getReasoningContext(lastMessage); + if (ctx.text || ctx.lastToolCall) { + const summary = summarizer(ctx.text, { + message: lastMessage, + lastToolCall: ctx.lastToolCall, + streaming: ctx.streaming, + }); + if (!summary.redact) { + reasoningPreview = summary.label; + } + } + } + const showEmpty = messages.length === 0 && !showLoader && !isClearing && status !== 'error'; @@ -555,6 +629,10 @@ export function createChatMessagesComponent({ translations={translations} classNames={messageClassNames} messageTranslations={messageTranslations} + showReasoning={showReasoning} + reasoningVisibility={reasoningVisibility} + reasoningTranslations={reasoningTranslations} + reasoningClassNames={reasoningClassNames} suggestionsElement={ status === 'ready' && message.role === 'assistant' && @@ -568,6 +646,7 @@ export function createChatMessagesComponent({ {showLoader && ( )} diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx new file mode 100644 index 00000000000..691a6b3d570 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessageReasoning.test.tsx @@ -0,0 +1,126 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ +/** @jsx createElement */ +import { render } from '@testing-library/preact'; +import { Fragment, createElement } from 'preact'; + +import { createChatMessageReasoningComponent } from '../ChatMessageReasoning'; + +import type { ChatMessageBase } from '../types'; + +const ChatMessageReasoning = createChatMessageReasoningComponent({ + createElement, + Fragment, +}); + +function messageWith( + parts: ChatMessageBase['parts'], + overrides: Partial = {} +): ChatMessageBase { + return { id: '1', role: 'assistant', parts, ...overrides }; +} + +describe('ChatMessageReasoning', () => { + test('renders nothing when there are no reasoning parts', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders nothing when visibility is hidden', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders a collapsible disclosure with the default "Reasoning" title', () => { + const { container } = render( + + ); + const details = container.querySelector('details.ais-ChatMessageReasoning'); + expect(details).toBeInTheDocument(); + // Collapsed by default. + expect(details).not.toHaveAttribute('open'); + expect( + container.querySelector('.ais-ChatMessageReasoning-label') + ).toHaveTextContent('Reasoning'); + // Brain icon is present. + expect( + container.querySelector('.ais-ChatMessageReasoning-icon svg') + ).toBeInTheDocument(); + }); + + test('renders the raw reasoning body text', () => { + const { container } = render( + + ); + expect( + container.querySelector('.ais-ChatMessageReasoning-text') + ).toHaveTextContent('comparing the options'); + }); + + test('is open when visibility is expanded', () => { + const { container } = render( + + ); + expect( + container.querySelector('.ais-ChatMessageReasoning') + ).toHaveAttribute('open'); + }); + + test('is open while streaming under the auto strategy', () => { + const { container } = render( + + ); + const details = container.querySelector('.ais-ChatMessageReasoning'); + expect(details).toHaveAttribute('open'); + expect(details).toHaveClass('ais-ChatMessageReasoning--streaming'); + }); + + test('accepts injectable title and toggle label', () => { + const { container, getByLabelText } = render( + + ); + expect( + container.querySelector('.ais-ChatMessageReasoning-label') + ).toHaveTextContent('Raisonnement'); + expect(getByLabelText('Basculer le raisonnement')).toBeInTheDocument(); + }); + + test('applies injectable class names', () => { + const { container } = render( + + ); + expect(container.querySelector('.my-root')).toBeInTheDocument(); + expect(container.querySelector('.my-text')).toBeInTheDocument(); + }); +}); diff --git a/packages/instantsearch-ui-components/src/components/chat/icons.tsx b/packages/instantsearch-ui-components/src/components/chat/icons.tsx index f3715cad8d0..22b18c3a071 100644 --- a/packages/instantsearch-ui-components/src/components/chat/icons.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/icons.tsx @@ -294,3 +294,29 @@ export function ChevronRightIcon({ createElement }: IconProps) { ); } + +export function BrainIcon({ createElement }: IconProps) { + // Lucide `brain` icon — kept in sync with the dashboard (Agent Studio) so the + // reasoning panel reads identically across surfaces. + return ( + + ); +} diff --git a/packages/instantsearch-ui-components/src/components/index.ts b/packages/instantsearch-ui-components/src/components/index.ts index aaa5df4b9e6..6dadf0ebcaf 100644 --- a/packages/instantsearch-ui-components/src/components/index.ts +++ b/packages/instantsearch-ui-components/src/components/index.ts @@ -7,6 +7,7 @@ export * from './chat/ChatInlineLayout'; export * from './chat/ChatSidePanelLayout'; export * from './chat/ChatHeader'; export * from './chat/ChatMessage'; +export * from './chat/ChatMessageReasoning'; export * from './chat/ChatMessages'; export * from './chat/ChatMessageLoader'; export * from './chat/ChatMessageError'; diff --git a/packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts b/packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts new file mode 100644 index 00000000000..dbccda1f640 --- /dev/null +++ b/packages/instantsearch-ui-components/src/lib/utils/__tests__/reasoning-test.ts @@ -0,0 +1,135 @@ +import { + categorizeFromToolCall, + categorizeReasoning, + getReasoningContext, + summarizeReasoning, +} from '../reasoning'; + +import type { ChatMessageBase } from '../../../components/chat/types'; + +describe('categorizeReasoning', () => { + test('returns the thinking fallback for empty input', () => { + expect(categorizeReasoning('')).toEqual({ + label: 'Thinking\u2026', + category: 'thinking', + }); + expect(categorizeReasoning(' ')).toEqual({ + label: 'Thinking\u2026', + category: 'thinking', + }); + }); + + test('matches keyword rules with the documented precedence', () => { + expect(categorizeReasoning('comparing the price and budget').category).toBe( + 'pricing' + ); + expect(categorizeReasoning('narrow down by category').category).toBe( + 'filtering' + ); + expect(categorizeReasoning('let me search the catalogue').category).toBe( + 'searching' + ); + expect(categorizeReasoning('I will plan the next step').category).toBe( + 'planning' + ); + }); + + test('flags PII/secrets for redaction', () => { + const summary = categorizeReasoning('the api_key is sk-1234, searching'); + expect(summary.redact).toBe(true); + }); + + test('inspects content that falls within the last 2KB of the buffer', () => { + const longPrefix = 'x'.repeat(4000); + const summary = categorizeReasoning(`${longPrefix} the price is too high`); + expect(summary.category).toBe('pricing'); + }); + + test('ignores content older than the last 2KB of the buffer', () => { + const stalePricing = 'the price is high '; + const recentFiller = 'x'.repeat(4000); + const summary = categorizeReasoning(`${stalePricing}${recentFiller}`); + expect(summary.category).toBe('thinking'); + }); +}); + +describe('categorizeFromToolCall', () => { + test('derives a label from the search index tool input', () => { + expect( + categorizeFromToolCall({ + type: 'tool-algolia_search_index', + input: { indexName: 'products' }, + }) + ).toEqual({ category: 'tool', label: 'Searching the products index' }); + }); + + test('falls back to a generic label without an index name', () => { + expect( + categorizeFromToolCall({ type: 'tool-algolia_search_index' }) + ).toEqual({ category: 'tool', label: 'Searching the catalogue' }); + }); + + test('humanizes unknown tool names', () => { + expect(categorizeFromToolCall({ type: 'tool-do_a_thing' })).toEqual({ + category: 'tool', + label: 'Running Do A Thing', + }); + }); +}); + +describe('summarizeReasoning', () => { + const message: ChatMessageBase = { id: '1', role: 'assistant', parts: [] }; + + test('tier 1: uses the first sentence of a short server summary', () => { + const summary = summarizeReasoning( + 'Comparing the top matches. Then ranking them.', + { message, streaming: false } + ); + expect(summary.label).toBe('Comparing the top matches.'); + }); + + test('tier 2: prefers the in-flight tool call when streaming', () => { + const summary = summarizeReasoning('some very long reasoning '.repeat(50), { + message, + streaming: true, + lastToolCall: { + type: 'tool-algolia_search_index', + input: { indexName: 'products' }, + } as unknown as ChatMessageBase['parts'][number] & { + type: `tool-${string}`; + }, + }); + expect(summary.label).toBe('Searching the products index'); + }); + + test('tier 3: heuristic categorizer for long streaming text', () => { + const summary = summarizeReasoning('narrowing down by category '.repeat(50), { + message, + streaming: true, + }); + expect(summary.category).toBe('filtering'); + }); +}); + +describe('getReasoningContext', () => { + test('concatenates reasoning parts and tracks streaming + last tool', () => { + const message: ChatMessageBase = { + id: '1', + role: 'assistant', + parts: [ + { type: 'reasoning', text: 'first ' }, + { + type: 'tool-algolia_search_index', + input: { indexName: 'products' }, + } as unknown as ChatMessageBase['parts'][number], + { type: 'reasoning', text: 'second', state: 'streaming' }, + ], + }; + + const ctx = getReasoningContext(message); + + expect(ctx.text).toBe('first second'); + expect(ctx.streaming).toBe(true); + expect(ctx.lastToolCall?.type).toBe('tool-algolia_search_index'); + }); +}); diff --git a/packages/instantsearch-ui-components/src/lib/utils/index.ts b/packages/instantsearch-ui-components/src/lib/utils/index.ts index 977595d5629..1deb089e2a8 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/index.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/index.ts @@ -1,4 +1,5 @@ export { getFacetFiltersFromToolInput } from './chat'; export * from './find'; export * from './promptSuggestions'; +export * from './reasoning'; export * from './startsWith'; diff --git a/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts b/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts new file mode 100644 index 00000000000..27f5f736821 --- /dev/null +++ b/packages/instantsearch-ui-components/src/lib/utils/reasoning.ts @@ -0,0 +1,220 @@ +/** + * Reasoning categorization utilities. + * + * Modern reasoning models (GPT-5/o-series, Claude with extended/adaptive + * thinking, Gemini 2.5+, DeepSeek-R1-class) emit a stream of internal + * chain-of-thought (or, where supported, a server-side summary). + * + * Showing that stream verbatim is overwhelming and sometimes unsafe. + * We project it onto a single short "substitute label" that is friendly, + * stable, and refreshed at most every ~600ms by the consumer. + * + * The strategy is intentionally tiny: zero dependencies, synchronous, + * predictable. Apps can replace it wholesale by passing a custom + * {@link ReasoningSummarizer}. + */ + +import { startsWith } from './startsWith'; + +import type { ChatMessageBase } from '../../components/chat/types'; + +export type ReasoningCategory = + | 'thinking' + | 'searching' + | 'filtering' + | 'ranking' + | 'pricing' + | 'attributes' + | 'summarising' + | 'images' + | 'planning' + | 'tool'; + +export type ReasoningSummary = { + /** Short user-facing label, e.g. "Searching the catalogue". */ + label: string; + /** Coarse category for telemetry/styling. */ + category: ReasoningCategory; + /** + * If true, the host should not display the raw reasoning body + * (used when the categorizer detects PII / prompt fragments). + */ + redact?: boolean; +}; + +export type ReasoningSummarizerContext = { + /** The full message currently being rendered. */ + message: ChatMessageBase; + /** The most recent assistant tool call, if any. */ + lastToolCall?: ChatMessageBase['parts'][number] & { type: `tool-${string}` }; + /** Truthy when the latest reasoning part is still streaming. */ + streaming?: boolean; +}; + +export type ReasoningSummarizer = ( + reasoningText: string, + ctx: ReasoningSummarizerContext +) => ReasoningSummary; + +const PII_PATTERN = + /\b(?:api[_-]?key|secret|bearer\s+[a-z0-9._-]+|sk-[a-z0-9]{20,}|ssn|passport|credit\s*card)\b/i; + +const KEYWORD_RULES: Array<{ + test: RegExp; + category: ReasoningCategory; + label: string; +}> = [ + // ordering matters: more specific first + { test: /\b(price|budget|cheap|expensive|cost|usd|eur)\b/i, category: 'pricing', label: 'Looking at price ranges' }, + { test: /\b(image|photo|picture|visual|thumbnail)\b/i, category: 'images', label: 'Looking at product images' }, + { test: /\b(filter|narrow|refin|facet|category)\b/i, category: 'filtering', label: 'Narrowing down filters' }, + { test: /\b(rank|sort|order|rerank|relevan(t|ce))\b/i, category: 'ranking', label: 'Comparing top matches' }, + { test: /\b(size|colou?r|brand|material|fabric|fit)\b/i, category: 'attributes', label: 'Reading product attributes' }, + { test: /\b(summari[sz]e|conclude|recommend|suggest|propose)\b/i, category: 'summarising', label: 'Summarising the results' }, + { test: /\b(search|look(?:ing)?\s+up|find(?:ing)?|query|catalog(?:ue)?)\b/i, category: 'searching', label: 'Searching the catalogue' }, + { test: /\b(plan|strategy|approach|let me think|i should|i'll|i will)\b/i, category: 'planning', label: 'Planning the next step' }, +]; + +/** + * Heuristic categorizer. Runs over the **last 2 KB** of the buffer only, + * so it stays cheap as the reasoning stream grows. + */ +export function categorizeReasoning(reasoningText: string): ReasoningSummary { + const tail = reasoningText.length > 2048 + ? reasoningText.slice(-2048) + : reasoningText; + + if (!tail.trim()) { + return { label: 'Thinking\u2026', category: 'thinking' }; + } + + const redact = PII_PATTERN.test(tail); + + for (const rule of KEYWORD_RULES) { + if (rule.test.test(tail)) { + return { label: rule.label, category: rule.category, redact }; + } + } + + return { label: 'Thinking\u2026', category: 'thinking', redact }; +} + +/** + * Tier 2 categorizer: derive a label from the most recent tool call. + * Returns null when no useful inference can be made. + */ +export function categorizeFromToolCall( + part: { type: `tool-${string}`; input?: unknown } +): ReasoningSummary | null { + const toolName = part.type.slice('tool-'.length); + + if (startsWith(toolName, 'algolia_search_index')) { + const indexName = readStringProp(part.input, 'indexName'); + return { + category: 'tool', + label: indexName + ? `Searching the ${truncate(indexName, 28)} index` + : 'Searching the catalogue', + }; + } + + if (startsWith(toolName, 'algolia_recommend')) { + return { category: 'tool', label: 'Looking at related products' }; + } + + if (toolName === 'apply_filters' || startsWith(toolName, 'apply_filters')) { + return { category: 'filtering', label: 'Narrowing down filters' }; + } + + if (startsWith(toolName, 'compare')) { + return { category: 'ranking', label: 'Comparing top matches' }; + } + + return { category: 'tool', label: `Running ${humanize(toolName)}` }; +} + +/** + * Top-level summarizer that walks the three tiers described in the RFC: + * + * 1. Server-side summary already present in the reasoning stream. + * (We accept any reasoning text shorter than `serverSummaryThreshold` + * as evidence that the server pre-summarized.) + * 2. Tool-call inference, if a tool is currently in flight. + * 3. Heuristic regex categorizer over the last ~2 KB of the buffer. + */ +export function summarizeReasoning( + reasoningText: string, + ctx: ReasoningSummarizerContext, + options: { serverSummaryThreshold?: number } = {} +): ReasoningSummary { + const threshold = options.serverSummaryThreshold ?? 280; + + // Tier 1: short server-side summary - use the first sentence verbatim. + const trimmed = reasoningText.trim(); + if (trimmed && trimmed.length <= threshold && !ctx.streaming) { + // Grab up to the first sentence terminator. Avoids lookbehind so the + // utility stays compatible with older JS engines. + const sentenceMatch = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/); + const firstSentence = (sentenceMatch ? sentenceMatch[0] : trimmed).trim(); + return { + label: truncate(firstSentence, 64), + category: inferCategoryFromText(firstSentence), + }; + } + + // Tier 2: tool-call inference. + if (ctx.lastToolCall) { + const fromTool = categorizeFromToolCall(ctx.lastToolCall); + if (fromTool) return fromTool; + } + + // Tier 3: heuristic. + return categorizeReasoning(reasoningText); +} + +function inferCategoryFromText(text: string): ReasoningCategory { + return categorizeReasoning(text).category; +} + +function readStringProp(input: unknown, key: string): string | undefined { + if (input && typeof input === 'object' && !Array.isArray(input)) { + const value = (input as Record)[key]; + return typeof value === 'string' ? value : undefined; + } + return undefined; +} + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, max - 1)}\u2026` : value; +} + +function humanize(toolName: string): string { + return toolName + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Pull the current reasoning text and most recent tool call from a message. + * Convenience for callers that only have the {@link ChatMessageBase}. + */ +export function getReasoningContext( + message: ChatMessageBase +): { text: string; lastToolCall?: ReasoningSummarizerContext['lastToolCall']; streaming: boolean } { + let text = ''; + let streaming = false; + let lastToolCall: ReasoningSummarizerContext['lastToolCall'] | undefined; + + for (const part of message.parts) { + if (part.type === 'reasoning') { + text += part.text; + if (part.state === 'streaming') { + streaming = true; + } + } else if (startsWith(part.type, 'tool-')) { + lastToolCall = part as ReasoningSummarizerContext['lastToolCall']; + } + } + + return { text, lastToolCall, streaming }; +} diff --git a/packages/instantsearch.css/src/components/chat.scss b/packages/instantsearch.css/src/components/chat.scss index dc3b268e6de..2671710c313 100644 --- a/packages/instantsearch.css/src/components/chat.scss +++ b/packages/instantsearch.css/src/components/chat.scss @@ -12,6 +12,7 @@ @use 'chat/chat-messages'; @use 'chat/chat-message'; @use 'chat/chat-message-loader'; +@use 'chat/chat-message-reasoning'; @use 'chat/chat-greeting'; @use 'chat/chat-prompt'; @use 'chat/chat-tool-results'; diff --git a/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss b/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss index 9abfa6b69a8..d9b84de428d 100644 --- a/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss +++ b/packages/instantsearch.css/src/components/chat/_chat-message-loader.scss @@ -60,6 +60,29 @@ width: 40%; } } + + // Animate the caption every time the key changes (i.e. when the + // substitute label is updated). Pairs with `key={caption}` in the + // ChatMessageLoader component. + &.ais-ChatMessageLoader--withPreview .ais-ChatMessageLoader-text { + animation: ais-chat-loader-text-in 280ms ease-out, ais-chat-loader-text 3s linear infinite; + animation-delay: 0s, 0.5s; + } + + // Progress hairline: turn the second skeleton bar into a thin, + // filled progress line driven by the `--ais-loader-progress` CSS var. + &.ais-ChatMessageLoader--withPreview .ais-ChatMessageLoader-skeletonItem:nth-child(2) { + height: 2px; + background: linear-gradient( + to right, + rgba(var(--ais-primary-color-rgb), 0.85) 0%, + rgba(var(--ais-primary-color-rgb), 0.85) var(--ais-loader-progress, 0%), + rgba(var(--ais-muted-color-rgb), 0.2) var(--ais-loader-progress, 0%), + rgba(var(--ais-muted-color-rgb), 0.2) 100% + ); + transition: background 280ms ease-out; + animation: none; + } } @keyframes ais-chat-loader-spinner { @@ -93,3 +116,14 @@ background-position: 250% 0%; } } + +@keyframes ais-chat-loader-text-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss new file mode 100644 index 00000000000..792569a927c --- /dev/null +++ b/packages/instantsearch.css/src/components/chat/_chat-message-reasoning.scss @@ -0,0 +1,135 @@ +// Reasoning disclosure — visually aligned with the Agent Studio dashboard +// (Satellite `ChatContextAccordion`): a bordered, collapsible panel with a +// brain icon, a static "Reasoning" caption that pulses while streaming, a +// chevron that rotates on open, and a muted body holding the raw reasoning. +.ais-ChatMessageReasoning { + margin-block-end: calc(var(--ais-spacing) * 0.5); + border: 1px solid rgba(var(--ais-muted-color-rgb), 0.25); + border-radius: var(--ais-border-radius-md); + background-color: var(--ais-background-color); + overflow: hidden; +} + +.ais-ChatMessageReasoning-header { + box-sizing: border-box; + display: flex; + align-items: center; + gap: calc(var(--ais-spacing) * 0.375); + width: 100%; + padding: calc(var(--ais-spacing) * 0.375) calc(var(--ais-spacing) * 0.5); + cursor: pointer; + list-style: none; + user-select: none; + + // Remove the native disclosure triangle across engines. + &::-webkit-details-marker { + display: none; + } + &::marker { + content: ''; + } + + &:hover { + background-color: rgba(var(--ais-muted-color-rgb), 0.06); + } + + &:focus-visible { + outline: 2px solid rgba(var(--ais-primary-color-rgb), 0.5); + outline-offset: -2px; + } +} + +.ais-ChatMessageReasoning-icon { + display: inline-flex; + width: calc(var(--ais-icon-size) * 0.8); + height: calc(var(--ais-icon-size) * 0.8); + color: rgba(var(--ais-muted-color-rgb), 0.9); + flex-shrink: 0; + + svg { + width: 100%; + height: 100%; + } +} + +.ais-ChatMessageReasoning-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(var(--ais-muted-color-rgb), 0.95); + font-size: calc(var(--ais-spacing) * 0.8125); +} + +// While the model is still emitting this reasoning, run the same shimmer the +// loader caption uses so the label reads as "live". +.ais-ChatMessageReasoning--streaming .ais-ChatMessageReasoning-label { + text-fill-color: transparent; + -webkit-text-fill-color: transparent; + background: rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha)) + linear-gradient( + to right, + rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha)) 0%, + rgba(var(--ais-text-color-rgb), 0.9) 40%, + rgba(var(--ais-text-color-rgb), 0.9) 60%, + rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha)) 100% + ); + -webkit-background-clip: text; + background-clip: text; + background-repeat: no-repeat; + background-size: 50% 200%; + background-position: -100% 0; + + animation: ais-chat-loader-text 3s linear infinite; +} + +.ais-ChatMessageReasoning-chevron { + display: inline-flex; + width: calc(var(--ais-icon-size) * 0.85); + height: calc(var(--ais-icon-size) * 0.85); + color: rgba(var(--ais-muted-color-rgb), 0.9); + transition: transform var(--ais-transition-duration) + var(--ais-transition-timing-function); + flex-shrink: 0; + + svg { + width: 100%; + height: 100%; + } +} + +// `
` reflects the open state natively, so the chevron rotates on user +// toggle with no JS involved. +.ais-ChatMessageReasoning[open] .ais-ChatMessageReasoning-chevron { + transform: rotate(180deg); +} + +.ais-ChatMessageReasoning-body { + padding: calc(var(--ais-spacing) * 0.5) calc(var(--ais-spacing) * 0.75); + border-block-start: 1px solid rgba(var(--ais-muted-color-rgb), 0.2); + background-color: rgba(var(--ais-muted-color-rgb), 0.06); + color: rgba(var(--ais-text-color-rgb), 0.7); + font-size: calc(var(--ais-spacing) * 0.8125); + line-height: 1.5; + max-height: 9rem; + overflow-y: auto; +} + +.ais-ChatMessageReasoning-text { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + + & + & { + margin-block-start: calc(var(--ais-spacing) * 0.5); + } +} + +@media (prefers-reduced-motion: reduce) { + .ais-ChatMessageReasoning-label, + .ais-ChatMessageReasoning-chevron { + animation: none !important; + transition: none !important; + } +} diff --git a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx index dbd68676e55..3f335c19aca 100644 --- a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx +++ b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx @@ -294,4 +294,114 @@ describe('chat', () => { expect(document.activeElement).toBe(textareaAfter); }); }); + + describe('reasoning', () => { + const reasoningMessages = [ + { + id: 'assistant-message-id', + role: 'assistant' as const, + parts: [ + { + type: 'reasoning' as const, + text: 'Looking at product images and comparing them.', + }, + { type: 'text' as const, text: 'Here are some options.' }, + ], + }, + ]; + + test('does not render the reasoning panel by default', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + + search.addWidgets([ + chat({ + container, + agentId: 'test-agent-id', + disableTriggerValidation: true, + messages: reasoningMessages, + }), + ]); + + search.start(); + await wait(0); + + expect( + container.querySelector('.ais-ChatMessageReasoning') + ).not.toBeInTheDocument(); + }); + + test('renders the reasoning panel when `showReasoning` is enabled', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + + search.addWidgets([ + chat({ + container, + agentId: 'test-agent-id', + disableTriggerValidation: true, + showReasoning: true, + reasoningVisibility: 'expanded', + messages: reasoningMessages, + }), + ]); + + search.start(); + await wait(0); + + expect( + container.querySelector('.ais-ChatMessageReasoning') + ).toBeInTheDocument(); + expect( + container.querySelector('.ais-ChatMessageReasoning-text') + ).toHaveTextContent('Looking at product images and comparing them.'); + }); + + test('forwards injectable reasoning label strings from templates', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + + search.addWidgets([ + chat({ + container, + agentId: 'test-agent-id', + disableTriggerValidation: true, + showReasoning: true, + reasoningVisibility: 'expanded', + messages: reasoningMessages, + templates: { + messages: { + reasoningTitleText: 'Raisonnement', + reasoningToggleLabelText: 'Basculer le raisonnement', + }, + }, + }), + ]); + + search.start(); + await wait(0); + + expect( + container.querySelector('.ais-ChatMessageReasoning-label') + ).toHaveTextContent('Raisonnement'); + expect( + container.querySelector('.ais-ChatMessageReasoning-header') + ).toHaveAttribute('aria-label', 'Basculer le raisonnement'); + }); + }); }); diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index d8a489c739d..9c89d5f61b4 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -59,6 +59,8 @@ import type { ChatMessageActionProps, ChatMessageBase, ChatMessageErrorProps, + ChatMessageReasoningTranslations, + ChatMessageReasoningVisibility, ChatEmptyProps, ChatMessageLoaderProps, ChatMessageProps, @@ -69,6 +71,7 @@ import type { ClientSideToolComponentProps, ClientSideTools, RecordWithObjectID, + ReasoningSummarizer, SearchToolInput, UserClientSideTool, } from 'instantsearch-ui-components'; @@ -352,6 +355,10 @@ type ChatWrapperProps = { }; translations: Partial; messageTranslations: Partial; + reasoningTranslations: Partial; + showReasoning: boolean; + reasoningVisibility?: ChatMessageReasoningVisibility; + reasoningSummarizer?: ReasoningSummarizer; sendMessage: ChatLayoutOwnProps['sendMessage']; setInput: (input: string) => void; }; @@ -466,6 +473,10 @@ function ChatWrapper({ userMessageProps: messagesProps.userMessageProps, translations: messagesProps.translations, messageTranslations: messagesProps.messageTranslations, + reasoningTranslations: messagesProps.reasoningTranslations, + showReasoning: messagesProps.showReasoning, + reasoningVisibility: messagesProps.reasoningVisibility, + reasoningSummarizer: messagesProps.reasoningSummarizer, sendMessage: messagesProps.sendMessage, setInput: messagesProps.setInput, }} @@ -501,6 +512,9 @@ const createRenderer = ({ containerNode, templates, tools, + showReasoning, + reasoningVisibility, + reasoningSummarizer, }: { containerNode: HTMLElement; cssClasses: ChatCSSClasses; @@ -509,6 +523,9 @@ const createRenderer = ({ }; templates: ChatTemplates; tools: UserClientSideToolsWithTemplate; + showReasoning: boolean; + reasoningVisibility?: ChatMessageReasoningVisibility; + reasoningSummarizer?: ReasoningSummarizer; }): Renderer> => { const state = createLocalState(); const promptRef = { current: null as HTMLTextAreaElement | null }; @@ -831,6 +848,12 @@ const createRenderer = ({ regenerateLabel: templates.messages?.regenerateLabelText, }); + const reasoningTranslations: Partial = + getDefinedProperties({ + title: templates.messages?.reasoningTitleText, + toggleLabel: templates.messages?.reasoningToggleLabelText, + }); + assistantMessageTemplateRef.current = prepareTemplateProps({ defaultTemplates: {} as unknown as NonNullable< Required['assistantMessage']> @@ -923,6 +946,10 @@ const createRenderer = ({ }, translations: messagesTranslations, messageTranslations, + reasoningTranslations, + showReasoning, + reasoningVisibility, + reasoningSummarizer, sendMessage, setInput, }} @@ -1081,6 +1108,14 @@ export type ChatTemplates = BaseHit> = * Label for the regenerate action */ regenerateLabelText?: string; + /** + * Header label for the reasoning panel. Defaults to "Reasoning". + */ + reasoningTitleText?: string; + /** + * Accessible label for the reasoning panel disclosure toggle. + */ + reasoningToggleLabelText?: string; }>; /** @@ -1222,6 +1257,29 @@ type ChatWidgetParams = { * Disable validation that requires either `chatTrigger` or AI mode. */ disableTriggerValidation?: boolean; + + /** + * Render `reasoning` message parts (extended thinking / server-side + * reasoning summaries) via the reasoning panel. Off by default — enable it + * when the backend emits reasoning parts and you want to surface them. + */ + showReasoning?: boolean; + + /** + * Visibility strategy for the reasoning panel. + * - `collapsed` (default): closed, user can expand. + * - `expanded`: always open. + * - `auto`: open while streaming, collapsible afterwards. + * - `hidden`: never render reasoning even if parts exist. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + + /** + * Override the substitute-label computation for the live loader caption + * (the "Searching the catalogue…" text shown while the model thinks). + * Defaults to the built-in heuristic summarizer. + */ + reasoningSummarizer?: ReasoningSummarizer; }; export type ChatWidget = WidgetFactory< @@ -1247,6 +1305,9 @@ export default (function chat< tools: userTools, getSearchPageURL, disableTriggerValidation = false, + showReasoning = false, + reasoningVisibility, + reasoningSummarizer, ...options } = widgetParams || {}; @@ -1281,6 +1342,9 @@ export default (function chat< renderState: {}, templates, tools, + showReasoning, + reasoningVisibility, + reasoningSummarizer, }); const makeWidget = connectChat(specializedRenderer, () => diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index 62b1e022ea2..80396865ece 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -37,6 +37,8 @@ import type { Pragma, ChatProps as ChatUiProps, ChatLayoutOwnProps, + ChatMessageReasoningVisibility, + ReasoningSummarizer, RecommendComponentProps, RecordWithObjectID, UserClientSideTool, @@ -150,11 +152,26 @@ export type ChatProps = Omit< userMessageLeadingComponent?: ChatMessageProps['leadingComponent']; userMessageFooterComponent?: ChatMessageProps['footerComponent']; suggestionsComponent?: ChatUiProps['suggestionsComponent']; + /** + * Render `reasoning` message parts (extended thinking / server-side + * reasoning summaries) via the reasoning panel. Off by default. + */ + showReasoning?: boolean; + /** + * Visibility strategy for the reasoning panel. Default: `collapsed`. + */ + reasoningVisibility?: ChatMessageReasoningVisibility; + /** + * Override the substitute-label computation for the live loader caption + * (the "Searching the catalogue…" text shown while the model thinks). + */ + reasoningSummarizer?: ReasoningSummarizer; translations?: Partial<{ prompt: ChatUiProps['promptProps']['translations']; header: ChatUiProps['headerProps']['translations']; message: ChatUiProps['messagesProps']['messageTranslations']; messages: ChatUiProps['messagesProps']['translations']; + reasoning: ChatUiProps['messagesProps']['reasoningTranslations']; }>; }; @@ -197,6 +214,9 @@ function ChatInner< title, getSearchPageURL, disableTriggerValidation = false, + showReasoning, + reasoningVisibility, + reasoningSummarizer, ...props }: ChatProps, ref: React.ForwardedRef @@ -206,6 +226,7 @@ function ChatInner< header: headerTranslations, message: messageTranslations, messages: messagesTranslations, + reasoning: reasoningTranslations, } = translations; const { indexUiState, setIndexUiState } = useInstantSearch(); @@ -353,6 +374,10 @@ function ChatInner< }, translations: messagesTranslations, messageTranslations, + reasoningTranslations, + showReasoning, + reasoningVisibility, + reasoningSummarizer, ...messagesProps, error, }}