feat(chat): add tool input streaming support and loader in tool layout component#6947
feat(chat): add tool input streaming support and loader in tool layout component#6947
Conversation
More templates
algoliasearch-helper
instantsearch-ui-components
instantsearch.css
instantsearch.js
react-instantsearch
react-instantsearch-core
react-instantsearch-nextjs
react-instantsearch-router-nextjs
vue-instantsearch
commit: |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 29 |
| Duplication | 0 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Pull request overview
This PR enhances the chat widget’s streaming behavior by supporting streamed tool input deltas (with partial JSON “repair” parsing) and by exposing a loader component to client-side tool layouts so tools can render default/custom loaders themselves.
Changes:
- Implement tool input delta streaming in
AbstractChat, accumulatingrawInputand attempting to parse partial JSON intoinputduring streaming. - Update chat UI loader rules to avoid showing the global message loader while a tool is actively rendering (e.g.,
input-streaming/output-available), and add/adjust tests accordingly. - Add
loaderComponentplumbing so tool layout components/templates can render the message loader within tool UI.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/common/widgets/chat/options.tsx | Updates loader visibility expectations during streaming; adds coverage for tool layout loader injection. |
| packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx | Renders a tool-specific loading state when the tool message is input-streaming; expands input typing. |
| packages/react-instantsearch/src/widgets/chat/tools/tests/SearchIndexTool.test.tsx | Updates tests to pass the new required loaderComponent prop. |
| packages/instantsearch.js/src/widgets/chat/chat.tsx | Adds template loader() binding for tool layout templates; introduces ClientSideToolTemplateData. |
| packages/instantsearch.js/src/lib/ai-lite/types.ts | Extends tool part types with rawInput and renames tool input delta field to inputTextDelta. |
| packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts | Core implementation: accumulate tool raw input, parse/repair partial JSON on deltas, update tool parts live. |
| packages/instantsearch.js/src/connectors/chat/tests/connectChat-test.ts | Adds test ensuring tool input can stream via tool-input-delta even without tool-input-available. |
| packages/instantsearch-ui-components/src/lib/utils/chat.ts | Adds tool-part helpers (isPartTool, isToolPartActivelyRendering) to support new loader behavior. |
| packages/instantsearch-ui-components/src/components/chat/types.ts | Adds rawInput to UI tool parts and adds required loaderComponent to tool component props. |
| packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx | Adjusts loader display logic to treat actively-rendering tools differently; passes loader down to messages. |
| packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx | Threads loaderComponent into tool layout components; makes it a required prop on ChatMessageProps. |
| packages/instantsearch-ui-components/src/components/chat/tests/ChatMessage.test.tsx | Updates tests for the new required loaderComponent prop. |
| examples/react/getting-started/src/App.tsx | Adds an event-stream wrapper to demonstrate delayed/progressive tool output streaming in the example app. |
| bundlesize.config.json | Updates bundle size threshold to reflect added code. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| 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) { |
There was a problem hiding this comment.
parseToolInputDelta() attempts JSON.parse() on the full accumulated raw input (and then runs repairPartialJson() which linearly scans the entire string) on every tool-input-delta. For large streamed tool inputs split into many small deltas, this becomes O(n²) work and can noticeably slow down the UI thread. Consider reducing parse frequency (e.g., only after N chars / debounce), keeping incremental repair state (stack/string state) instead of rescanning from the start, or using a streaming/partial JSON parser to avoid repeated full-string passes.
| 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) { | |
| const TOOL_INPUT_PARSE_MIN_DELTA = 128; | |
| const TOOL_INPUT_PARSE_BOUNDARY_CHARS = new Set([ | |
| '}', | |
| ']', | |
| '"', | |
| ',', | |
| '\n', | |
| '\r', | |
| '\t', | |
| ' ', | |
| ]); | |
| const toolInputParseAttemptState = new Map< | |
| unknown, | |
| { lastAttemptedInput: string; lastAttemptedLength: number } | |
| >(); | |
| const shouldAttemptToolInputParse = ( | |
| normalized: string, | |
| fallbackInput: unknown | |
| ): boolean => { | |
| const previousAttempt = toolInputParseAttemptState.get(fallbackInput); | |
| if (previousAttempt === undefined) { | |
| return true; | |
| } | |
| if (!normalized.startsWith(previousAttempt.lastAttemptedInput)) { | |
| return true; | |
| } | |
| const growthSinceLastAttempt = | |
| normalized.length - previousAttempt.lastAttemptedLength; | |
| if (growthSinceLastAttempt >= TOOL_INPUT_PARSE_MIN_DELTA) { | |
| return true; | |
| } | |
| const lastChar = normalized[normalized.length - 1]; | |
| return TOOL_INPUT_PARSE_BOUNDARY_CHARS.has(lastChar); | |
| }; | |
| const parseToolInputDelta = ( | |
| accumulatedRawInput: string, | |
| fallbackInput: unknown | |
| ): unknown => { | |
| const normalized = accumulatedRawInput.trim(); | |
| if (!normalized) { | |
| toolInputParseAttemptState.delete(fallbackInput); | |
| return fallbackInput; | |
| } | |
| if (!shouldAttemptToolInputParse(normalized, fallbackInput)) { | |
| return fallbackInput; | |
| } | |
| toolInputParseAttemptState.set(fallbackInput, { | |
| lastAttemptedInput: normalized, | |
| lastAttemptedLength: normalized.length, | |
| }); | |
| const directParsed = tryParseJson(normalized); | |
| if (directParsed !== undefined) { | |
| toolInputParseAttemptState.delete(fallbackInput); | |
| return directParsed; | |
| } | |
| const repairedParsed = tryParseJson(repairPartialJson(normalized)); | |
| if (repairedParsed !== undefined) { | |
| toolInputParseAttemptState.delete(fallbackInput); |
There was a problem hiding this comment.
haven't really ran into performance issues yet with the current approach, but something to keep in mind especially for large JSON inputs.
There was a problem hiding this comment.
yes we can keep in mind that it's not completely optimal and we should use just a single parser
packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx
Show resolved
Hide resolved
| addToolResult={boundAddToolResult} | ||
| applyFilters={tool.applyFilters} | ||
| sendEvent={tool.sendEvent || (() => {})} | ||
| sendEvent={tool.sendEvent || (() => { })} |
There was a problem hiding this comment.
Minor formatting: the fallback sendEvent handler is written as () => { } (extra space), which is inconsistent with the surrounding style and may fail formatting/lint rules. Consider normalizing it to () => {}.
| sendEvent={tool.sendEvent || (() => { })} | |
| sendEvent={tool.sendEvent || (() => {})} |
There was a problem hiding this comment.
Oh I thought I addressed it, will fix.
Side note: it seems like the new linting is not a 100% the same as one with eslint, I think this should have been picked up by it.
packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx
Outdated
Show resolved
Hide resolved
|
|
||
| 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) { |
There was a problem hiding this comment.
haven't really ran into performance issues yet with the current approach, but something to keep in mind especially for large JSON inputs.
packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx
Outdated
Show resolved
Hide resolved
30114e7 to
60788da
Compare
Haroenv
left a comment
There was a problem hiding this comment.
instantsearch/tests/common/widgets/chat/templates.tsx
Lines 202 to 216 in a86b6f3
| addToolResult={boundAddToolResult} | ||
| applyFilters={tool.applyFilters} | ||
| sendEvent={tool.sendEvent || (() => {})} | ||
| sendEvent={tool.sendEvent || (() => { })} |
It's the same Actually we can replace that with a importable loader component like how it's being done for the chat layouts. It would keep it consistent. wdyt? |
FX-3763
This PR updates the loading logic to be able to stream tool input deltas. This allows client side tools that receive a large amount of streamed input data to be able to show it be streamed on the browser similar to text deltas (via JSON repairing).
You can opt out of showing the default loader with a flag (per tool):
Also adds a loader component as props to a client side tool's layout component. This allows users to render the default or custom loaders based on their own logic. The API for it is: