diff --git a/bundlesize.config.json b/bundlesize.config.json index 828c1cb645..d53aa5ec3a 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "123 kB" + "maxSize": "123.5 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 19bd64e003..81de1270b3 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -243,6 +243,13 @@ export function createChatMessageComponent({ createElement }: Renderer) { toolCallId: toolMessage.toolCallId, }); + if ( + toolMessage.state === 'input-streaming' && + !tool.streamInput + ) { + return null; + } + if (!ToolLayoutComponent) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx index 32ce1590a3..db193f9e59 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx @@ -24,7 +24,7 @@ export function createChatMessageLoaderComponent({ return function ChatMessageLoader(userProps: ChatMessageLoaderProps) { const { translations: userTranslations, ...props } = userProps; const translations: Required = { - loaderText: 'Thinking...', + loaderText: '', ...userTranslations, }; diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index e59b7bd5bd..5a908c1e23 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -2,9 +2,11 @@ import { cx } from '../../lib'; import { + findTool, getTextContent, hasTextContent, isPartText, + isPartTool, } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; @@ -426,15 +428,7 @@ export function createChatMessagesComponent({ const lastMessage = messages[messages.length - 1]; const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; - const isWaitingForResponse = status === 'submitted'; - const isStreamingWithNoContent = status === 'streaming' && !lastPart; - const isStreamingNonTextContent = - status === 'streaming' && lastPart && !isPartText(lastPart); - - const showLoader = - isWaitingForResponse || - isStreamingWithNoContent || - isStreamingNonTextContent; + const showLoader = getShowLoader(status, lastPart, tools); const showEmpty = messages.length === 0 && !showLoader && !isClearing && status !== 'error'; @@ -533,3 +527,27 @@ export function createChatMessagesComponent({ ); }; } + +const getShowLoader = ( + status: ChatStatus, + lastPart: ChatMessageBase['parts'][number] | undefined, + tools: ClientSideTools +): boolean => { + if (status !== 'submitted' && status !== 'streaming') return false; + if (status === 'submitted') return true; + + if (!lastPart) return true; + if (isPartText(lastPart)) return false; + + if (isPartTool(lastPart)) { + if (lastPart.state === 'output-available') return false; + if (lastPart.state === 'input-streaming') { + const tool = findTool(lastPart.type, tools); + return !tool?.streamInput; + } + return true; + } + + return true; +}; + diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 5231f1ea30..f71195f69a 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -494,6 +494,7 @@ export type ClientSideToolComponent = ( export type ClientSideTool = { layoutComponent?: ClientSideToolComponent; + streamInput?: boolean; addToolResult: AddToolResult; sendEvent?: SendEventForHits; onToolCall?: ( diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index 0e170dd500..abf89a6145 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -1,4 +1,11 @@ +import { startsWith } from './startsWith'; + import type { ChatMessageBase } from '../../components'; +import type { + ChatToolMessage, + ClientSideTool, + ClientSideTools, +} from '../../components/chat/types'; export const getTextContent = (message: ChatMessageBase) => { return message.parts @@ -15,3 +22,23 @@ export const isPartText = ( ): part is Extract => { return part.type === 'text'; }; + +export const isPartTool = ( + part: ChatMessageBase['parts'][number] +): part is ChatToolMessage => { + return startsWith(part.type, 'tool-'); +}; + +export const findTool = ( + partType: string, + tools: ClientSideTools +): ClientSideTool | undefined => { + const toolName = partType.replace('tool-', ''); + let tool: ClientSideTool | undefined = tools[toolName]; + if (!tool) { + tool = Object.entries(tools).find(([key]) => + startsWith(toolName, `${key}_`) + )?.[1]; + } + return tool; +}; diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 3d091ff882..14b7a9c17d 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -507,6 +507,194 @@ data: [DONE]`, ); }); }); + + it('streams tool input parts from tool-input-delta without tool-input-available', async () => { + const { widget } = getInitializedWidget({ + agentId: undefined, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "displayResults"} + +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "displayResults", "inputTextDelta": "{}"} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + }); + + const { chatInstance } = widget; + + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'Show me product groups' }], + }); + + await waitFor(() => { + const lastMessage = chatInstance.messages[chatInstance.messages.length - 1]; + expect(lastMessage?.role).toBe('assistant'); + + const toolPart = lastMessage?.parts.find( + (part) => + 'type' in part && + part.type === 'tool-displayResults' && + 'toolCallId' in part && + part.toolCallId === 'call_1' + ) as + | { + state: string; + rawInput?: string; + input?: Record; + } + | undefined; + + expect(toolPart?.state).toBe('input-streaming'); + expect(toolPart?.input).toEqual({}); + }); + }); + + it('skips JSON repair for tools without streamInput (default)', async () => { + const { widget } = getInitializedWidget({ + agentId: undefined, + tools: { + myTool: {}, + }, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "myTool"} + +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "myTool", "inputTextDelta": "{\\"query\\": \\"sho"} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + }); + + const { chatInstance } = widget; + + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'search' }], + }); + + await waitFor(() => { + const lastMessage = + chatInstance.messages[chatInstance.messages.length - 1]; + const toolPart = lastMessage?.parts.find( + (part) => + 'type' in part && + part.type === 'tool-myTool' && + 'toolCallId' in part && + part.toolCallId === 'call_1' + ) as + | { + state: string; + rawInput?: string; + input?: unknown; + } + | undefined; + + expect(toolPart?.state).toBe('input-streaming'); + // Input is not repaired since streamInput is not set (default) + expect(toolPart?.input).toBeUndefined(); + // Raw input is still accumulated + expect(toolPart?.rawInput).toBe('{"query": "sho'); + }); + }); + + it('repairs JSON for tools with streamInput set to true', async () => { + const { widget } = getInitializedWidget({ + agentId: undefined, + tools: { + myTool: { + streamInput: true, + }, + }, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-start", "toolCallId": "call_1", "toolName": "myTool"} + +data: {"type": "tool-input-delta", "toolCallId": "call_1", "toolName": "myTool", "inputTextDelta": "{\\"query\\": \\"sho"} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + }); + + const { chatInstance } = widget; + + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'search' }], + }); + + await waitFor(() => { + const lastMessage = + chatInstance.messages[chatInstance.messages.length - 1]; + const toolPart = lastMessage?.parts.find( + (part) => + 'type' in part && + part.type === 'tool-myTool' && + 'toolCallId' in part && + part.toolCallId === 'call_1' + ) as + | { + state: string; + rawInput?: string; + input?: unknown; + } + | undefined; + + expect(toolPart?.state).toBe('input-streaming'); + // Input is repaired since streamInput is true + expect(toolPart?.input).toEqual({ query: 'sho' }); + expect(toolPart?.rawInput).toBe('{"query": "sho'); + }); + }); }); describe('transport configuration', () => { diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 0587975ae0..1719309794 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -443,6 +443,14 @@ export default (function connectChat( ...options, transport, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + shouldRepairToolInput(toolName) { + let tool = tools[toolName]; + if (!tool && toolName.startsWith(`${SearchIndexToolType}_`)) { + tool = tools[SearchIndexToolType]; + } + if (!tool) return true; + return Boolean(tool.streamInput); + }, onToolCall({ toolCall }) { let tool = tools[toolCall.toolName]; diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index 8bfeff329d..fb76b799fd 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -28,6 +28,96 @@ type ActiveResponse = { stream?: ReadableStream; }; +const tryParseJson = (value: string): unknown | undefined => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +const repairPartialJson = (value: string): string => { + let repaired = value.trim(); + + if (!repaired) { + return repaired; + } + + let inString = false; + let isEscaped = false; + const stack: Array<'{' | '['> = []; + + for (let index = 0; index < repaired.length; index++) { + const char = repaired[index]; + if (inString) { + if (isEscaped) { + isEscaped = false; + } else if (char === '\\') { + isEscaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{' || char === '[') { + stack.push(char); + continue; + } + + if (char === '}' && stack[stack.length - 1] === '{') { + stack.pop(); + continue; + } + + if (char === ']' && stack[stack.length - 1] === '[') { + stack.pop(); + } + } + + if (inString && !isEscaped) { + repaired += '"'; + } + + repaired = repaired.replace(/,\s*$/u, ''); + + if (stack.length > 0) { + repaired += stack + .reverse() + .map((opening) => (opening === '{' ? '}' : ']')) + .join(''); + } + + return repaired.replace(/,\s*([}\]])/gu, '$1'); +}; + +const parseToolInputDelta = ( + accumulatedRawInput: string, + fallbackInput: unknown +): unknown => { + const normalized = accumulatedRawInput.trim(); + if (!normalized) { + return fallbackInput; + } + + const directParsed = tryParseJson(normalized); + if (directParsed !== undefined) { + return directParsed; + } + + const repairedParsed = tryParseJson(repairPartialJson(normalized)); + if (repairedParsed !== undefined) { + return repairedParsed; + } + + return fallbackInput; +}; + /** * Abstract base class for chat implementations. */ @@ -44,6 +134,7 @@ export abstract class AbstractChat { private sendAutomaticallyWhen?: (options: { messages: TUIMessage[]; }) => boolean | PromiseLike; + private shouldRepairToolInput?: (toolName: string) => boolean; private activeResponse: ActiveResponse< InferUIMessageChunk @@ -60,6 +151,7 @@ export abstract class AbstractChat { onFinish, onData, sendAutomaticallyWhen, + shouldRepairToolInput, }: Omit, 'messages'> & { state: ChatState; }) { @@ -72,6 +164,7 @@ export abstract class AbstractChat { this.onFinish = onFinish; this.onData = onData; this.sendAutomaticallyWhen = sendAutomaticallyWhen; + this.shouldRepairToolInput = shouldRepairToolInput; } /** @@ -441,6 +534,7 @@ export abstract class AbstractChat { // Track current text/reasoning part state let currentTextPartId: string | undefined; let currentReasoningPartId: string | undefined; + const toolRawInputByCallId: Record = {}; // Promise chain for handling tool calls that return promises let pendingToolCall: Promise = Promise.resolve(); @@ -627,11 +721,21 @@ export abstract class AbstractChat { case 'tool-input-start': { if (!currentMessage) break; + const initialRawInput = + typeof chunk.input === 'string' + ? chunk.input + : chunk.input !== undefined + ? JSON.stringify(chunk.input) + : ''; + + toolRawInputByCallId[chunk.toolCallId] = initialRawInput; + const toolPart = { type: `tool-${chunk.toolName}` as const, toolCallId: chunk.toolCallId, state: 'input-streaming' as const, input: chunk.input, + rawInput: initialRawInput || undefined, providerExecuted: chunk.providerExecuted, }; @@ -644,14 +748,67 @@ export abstract class AbstractChat { } case 'tool-input-delta': { - // Tool input streaming - we'd need to parse partial JSON - // For now, we'll wait for tool-input-available + if (!currentMessage) break; + + const toolIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId + ); + + const existingPart = + toolIndex >= 0 + ? (currentMessage.parts[toolIndex] as any) + : null; + const previousRawInput = + existingPart?.rawInput ?? + toolRawInputByCallId[chunk.toolCallId] ?? + ''; + const nextRawInput = `${previousRawInput}${chunk.inputTextDelta}`; + toolRawInputByCallId[chunk.toolCallId] = nextRawInput; + + const toolName = + chunk.toolName ?? + existingPart?.type?.replace('tool-', ''); + const shouldRepair = + toolName + ? (this.shouldRepairToolInput?.(toolName) ?? true) + : true; + const parsedInput = shouldRepair + ? parseToolInputDelta(nextRawInput, existingPart?.input) + : existingPart?.input; + + const nextToolPart = { + ...(existingPart ?? { + type: `tool-${chunk.toolName}` as const, + toolCallId: chunk.toolCallId, + }), + state: 'input-streaming' as const, + input: parsedInput, + rawInput: nextRawInput, + }; + + if (toolIndex >= 0) { + const updatedParts = [...currentMessage.parts]; + updatedParts[toolIndex] = nextToolPart; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + } else { + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, nextToolPart], + } as TUIMessage; + } + + this.state.replaceMessage(currentMessageIndex, currentMessage); break; } case 'tool-input-available': { if (!currentMessage) break; + delete toolRawInputByCallId[chunk.toolCallId]; + // Find existing tool part or create new one const existingIndex = currentMessage.parts.findIndex( (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId @@ -707,6 +864,8 @@ export abstract class AbstractChat { ); if (toolIndex >= 0) { + delete toolRawInputByCallId[chunk.toolCallId]; + const updatedParts = [...currentMessage.parts]; const existingPart = updatedParts[toolIndex] as any; updatedParts[toolIndex] = { @@ -733,6 +892,8 @@ export abstract class AbstractChat { ); if (toolIndex >= 0) { + delete toolRawInputByCallId[chunk.toolCallId]; + const updatedParts = [...currentMessage.parts]; const existingPart = updatedParts[toolIndex] as any; updatedParts[toolIndex] = { diff --git a/packages/instantsearch.js/src/lib/ai-lite/types.ts b/packages/instantsearch.js/src/lib/ai-lite/types.ts index e676ae7c0e..2b7ccea485 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/types.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/types.ts @@ -84,6 +84,7 @@ export type ToolUIPart = ValueOf<{ | { state: 'input-streaming'; input: DeepPartial | undefined; + rawInput?: string; providerExecuted?: boolean; output?: never; errorText?: never; @@ -125,6 +126,7 @@ export type DynamicToolUIPart = { | { state: 'input-streaming'; input: unknown | undefined; + rawInput?: string; output?: never; errorText?: never; } @@ -252,7 +254,7 @@ type ToolUIMessageChunk = type: 'tool-input-delta'; toolName: NAME; toolCallId: string; - inputDelta: string; + inputTextDelta: string; }; }> | ValueOf<{ @@ -296,7 +298,7 @@ type ToolUIMessageChunk = type: 'tool-input-delta'; toolName: string; toolCallId: string; - inputDelta: string; + inputTextDelta: string; dynamic: true; } | { @@ -489,6 +491,7 @@ export interface ChatInit { sendAutomaticallyWhen?: (options: { messages: UI_MESSAGE[]; }) => boolean | PromiseLike; + shouldRepairToolInput?: (toolName: string) => boolean; } export type CreateUIMessage = Omit< diff --git a/packages/instantsearch.js/src/templates/chat-message-loader.tsx b/packages/instantsearch.js/src/templates/chat-message-loader.tsx new file mode 100644 index 0000000000..2718df53da --- /dev/null +++ b/packages/instantsearch.js/src/templates/chat-message-loader.tsx @@ -0,0 +1,14 @@ +/** @jsx h */ + +import { createChatMessageLoaderComponent } from 'instantsearch-ui-components'; +import { h } from 'preact'; + +import type { ChatMessageLoaderProps } from 'instantsearch-ui-components'; + +const ChatMessageLoader = createChatMessageLoaderComponent({ + createElement: h, +}); + +export function chatMessageLoader(props?: ChatMessageLoaderProps) { + return ; +} diff --git a/packages/instantsearch.js/src/templates/index.ts b/packages/instantsearch.js/src/templates/index.ts index 15ba68d33d..f758776e35 100644 --- a/packages/instantsearch.js/src/templates/index.ts +++ b/packages/instantsearch.js/src/templates/index.ts @@ -2,4 +2,5 @@ export * from './carousel/carousel'; export * from './chat-layout/chat-overlay-layout'; export * from './chat-layout/chat-inline-layout'; export * from './chat-layout/chat-sidepanel-layout'; +export * from './chat-message-loader'; export * from './chat-greeting/chat-greeting'; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index e0e498ddf4..08897c0fc1 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -99,7 +99,7 @@ function createCarouselTool< applyFilters, onClose, sendEvent, - }: ClientSideToolComponentProps) { + }: ClientSideToolTemplateData) { const input = message?.input as | { query: string; @@ -321,9 +321,7 @@ type ChatWrapperProps = { | ((props: ChatMessageLoaderProps) => JSX.Element) | undefined; errorComponent: ((props: ChatMessageErrorProps) => JSX.Element) | undefined; - emptyComponent: - | ((props: ChatEmptyProps) => JSX.Element) - | undefined; + emptyComponent: ((props: ChatEmptyProps) => JSX.Element) | undefined; actionsComponent: | ((props: { actions: ChatMessageActionProps[] }) => JSX.Element) | undefined; @@ -506,6 +504,7 @@ const createRenderer = ({ const makeTemplateRef = (): TemplateRef => ({ current: undefined }); const headerTemplateRef = makeTemplateRef(); const messagesTemplateRef = makeTemplateRef(); + const loaderTemplateRef = makeTemplateRef(); const emptyTemplateRef = makeTemplateRef(); const assistantMessageTemplateRef = makeTemplateRef(); const userMessageTemplateRef = makeTemplateRef(); @@ -563,13 +562,6 @@ const createRenderer = ({ 'span' ) : undefined; - const stableMessagesLoaderComponent = templates.messages?.loader - ? createStableTemplateComponent( - messagesTemplateRef, - 'loader', - 'div' - ) - : undefined; const stableMessagesErrorComponent = templates.messages?.error ? createStableTemplateComponent( messagesTemplateRef, @@ -659,6 +651,13 @@ const createRenderer = ({ /> ) : undefined; + const stableLoaderComponent = templates.loader + ? createStableTemplateComponent( + loaderTemplateRef, + 'loader', + 'div' + ) + : undefined; const stableSuggestionsComponent = templates.suggestions ? (suggestionsProps: { suggestions?: string[]; @@ -791,6 +790,13 @@ const createRenderer = ({ templatesConfig: instantSearchInstance.templatesConfig, templates: templates.messages, }) as PreparedTemplateProps>; + loaderTemplateRef.current = prepareTemplateProps({ + defaultTemplates: {} as unknown as NonNullable< + Required, 'loader'>> + >, + templatesConfig: instantSearchInstance.templatesConfig, + templates: { loader: templates.loader }, + }) as PreparedTemplateProps>; emptyTemplateRef.current = prepareTemplateProps({ defaultTemplates: {} as unknown as NonNullable< Required, 'empty'>> @@ -898,7 +904,7 @@ const createRenderer = ({ translations: headerTranslations, }} messagesProps={{ - loaderComponent: stableMessagesLoaderComponent, + loaderComponent: stableLoaderComponent, errorComponent: stableMessagesErrorComponent, emptyComponent: stableMessagesEmptyComponent, actionsComponent: stableActionsComponent, @@ -949,8 +955,10 @@ const createRenderer = ({ }; }; +export type ClientSideToolTemplateData = ClientSideToolComponentProps; + export type UserClientSideToolTemplates = Partial<{ - layout: TemplateWithBindEvent; + layout: TemplateWithBindEvent; }>; type UserClientSideToolWithTemplate = Omit< @@ -996,6 +1004,11 @@ export type ChatTemplates = BaseHit> = */ item: TemplateWithBindEvent>; + /** + * Custom loader template for the chat widget. + */ + loader: Template; + /** * Templates to use for the header. */ @@ -1046,10 +1059,6 @@ export type ChatTemplates = BaseHit> = * Templates to use for the messages. */ messages: Partial<{ - /** - * Template to use when loading messages - */ - loader: Template; /** * Template to use when there is an error loading messages */ diff --git a/packages/react-instantsearch/src/components/ChatMessageLoader.tsx b/packages/react-instantsearch/src/components/ChatMessageLoader.tsx new file mode 100644 index 0000000000..03b5b32b5c --- /dev/null +++ b/packages/react-instantsearch/src/components/ChatMessageLoader.tsx @@ -0,0 +1,8 @@ +import { createChatMessageLoaderComponent } from 'instantsearch-ui-components'; +import { createElement } from 'react'; + +import type { Pragma } from 'instantsearch-ui-components'; + +export const ChatMessageLoader = createChatMessageLoaderComponent({ + createElement: createElement as Pragma, +}); diff --git a/packages/react-instantsearch/src/components/index.ts b/packages/react-instantsearch/src/components/index.ts index 58cbf978c1..3d5413d0e1 100644 --- a/packages/react-instantsearch/src/components/index.ts +++ b/packages/react-instantsearch/src/components/index.ts @@ -1,4 +1,5 @@ export * from './Carousel'; +export * from './ChatMessageLoader'; export * from './ChatOverlayLayout'; export * from './ChatInlineLayout'; export * from './ChatSidePanelLayout'; diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index a894073317..bed12e5b63 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -140,11 +140,11 @@ export type ChatProps = Omit< headerCloseIconComponent?: ChatUiProps['headerProps']['closeIconComponent']; headerMinimizeIconComponent?: ChatUiProps['headerProps']['minimizeIconComponent']; headerMaximizeIconComponent?: ChatUiProps['headerProps']['maximizeIconComponent']; - messagesLoaderComponent?: ChatUiProps['messagesProps']['loaderComponent']; messagesErrorComponent?: ChatUiProps['messagesProps']['errorComponent']; promptComponent?: ChatUiProps['promptComponent']; promptHeaderComponent?: ChatUiProps['promptProps']['headerComponent']; promptFooterComponent?: ChatUiProps['promptProps']['footerComponent']; + loaderComponent?: ChatUiProps['messagesProps']['loaderComponent']; emptyComponent?: ChatUiProps['messagesProps']['emptyComponent']; actionsComponent?: ChatUiProps['messagesProps']['actionsComponent']; assistantMessageLeadingComponent?: ChatMessageProps['leadingComponent']; @@ -185,7 +185,7 @@ function ChatInner< headerCloseIconComponent, headerMinimizeIconComponent, headerMaximizeIconComponent, - messagesLoaderComponent, + loaderComponent, messagesErrorComponent, promptComponent, promptHeaderComponent, @@ -329,7 +329,7 @@ function ChatInner< scrollRef, contentRef, onScrollToBottom: scrollToBottom, - loaderComponent: messagesLoaderComponent, + loaderComponent, errorComponent: messagesErrorComponent, emptyComponent: emptyComponent, actionsComponent, diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index d55f3a7c33..67adad96fb 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -49,6 +49,7 @@ function createCarouselTool( | { query: string; number_of_results?: number; + facet_filters?: string[][]; } | undefined; diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index c7711226eb..094c185919 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -656,7 +656,7 @@ export function createOptionsTests( ).toBeInTheDocument(); }); - test('shows loader during streaming when last part is a tool without output', async () => { + test('shows loader during streaming when last part is a tool with streaming input', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); @@ -686,7 +686,7 @@ export function createOptionsTests( role: 'assistant', parts: [ { - type: `tool-${SearchIndexToolType}`, + type: 'tool-some_tool', toolCallId: '1', state: 'input-streaming', input: undefined, @@ -703,7 +703,54 @@ export function createOptionsTests( ).toBeInTheDocument(); }); - test('shows loader during streaming when last part is a tool with output', async () => { + test('shows loader during streaming when last part is a tool with input available', async () => { + const searchClient = createSearchClient(); + const chat = new Chat({}); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: createDefaultWidgetParams(chat), + react: createDefaultWidgetParams(chat), + vue: {}, + }, + }); + + await openChat(act); + + await act(async () => { + chat._state.messages = [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'Hello' }], + }, + { + id: '2', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + state: 'input-available', + input: { query: 'shoes' }, + }, + ], + }, + ] as any; + chat._state.status = 'streaming'; + await wait(0); + }); + + expect( + document.querySelector('.ais-ChatMessageLoader') + ).toBeInTheDocument(); + }); + + test('does not show loader during streaming when last part is a tool with output', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); @@ -749,7 +796,7 @@ export function createOptionsTests( expect( document.querySelector('.ais-ChatMessageLoader') - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); }); test('does not show loader during streaming when last part is text', async () => { diff --git a/tests/common/widgets/chat/templates.tsx b/tests/common/widgets/chat/templates.tsx index 8ec01122f8..f50695fc08 100644 --- a/tests/common/widgets/chat/templates.tsx +++ b/tests/common/widgets/chat/templates.tsx @@ -203,14 +203,12 @@ export function createTemplatesTests( javascript: { ...createDefaultWidgetParams(chat), templates: { - messages: { - loader: '
Custom loader
', - }, + loader: '
Custom loader
', }, }, react: { ...createDefaultWidgetParams(chat), - messagesLoaderComponent: () => ( + loaderComponent: () => (
Custom loader
), },