Skip to content

feat(chat): add tool input streaming support and loader in tool layout component#6947

Open
Haroenv wants to merge 27 commits intomasterfrom
feat/streaming-tools
Open

feat(chat): add tool input streaming support and loader in tool layout component#6947
Haroenv wants to merge 27 commits intomasterfrom
feat/streaming-tools

Conversation

@Haroenv
Copy link
Copy Markdown
Contributor

@Haroenv Haroenv commented Mar 31, 2026

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):

<Chat
  {...}
  tools={{
    tool1: {
      ...,
      showLoaderDuringStreaming: false, // true by default
    }
  }}
/>

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:

<Chat
  {...}
  tools={{
    tool1: {
      layoutComponent: ({ loaderComponent }) => (
        <>{loaderComponent()}</>
      )
    }
  }}
/>
chat({
  ...
  tools: {
    tool1: {
      templates: {
        layout: ({ loader }, { html }) => html`${loader()}`
      }
    }
  }
})

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 31, 2026

More templates

algoliasearch-helper

npm i https://pkg.pr.new/algolia/instantsearch/algoliasearch-helper@6947

instantsearch-ui-components

npm i https://pkg.pr.new/algolia/instantsearch/instantsearch-ui-components@6947

instantsearch.css

npm i https://pkg.pr.new/algolia/instantsearch/instantsearch.css@6947

instantsearch.js

npm i https://pkg.pr.new/algolia/instantsearch/instantsearch.js@6947

react-instantsearch

npm i https://pkg.pr.new/algolia/instantsearch/react-instantsearch@6947

react-instantsearch-core

npm i https://pkg.pr.new/algolia/instantsearch/react-instantsearch-core@6947

react-instantsearch-nextjs

npm i https://pkg.pr.new/algolia/instantsearch/react-instantsearch-nextjs@6947

react-instantsearch-router-nextjs

npm i https://pkg.pr.new/algolia/instantsearch/react-instantsearch-router-nextjs@6947

vue-instantsearch

npm i https://pkg.pr.new/algolia/instantsearch/vue-instantsearch@6947

commit: 60788da

@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 2, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 29 complexity · 0 duplication

Metric Results
Complexity 29
Duplication 0

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

@shaejaz shaejaz changed the title WIP: streaming tools feat(chat): add tool input streaming support and loader in tool layout component Apr 7, 2026
@shaejaz shaejaz marked this pull request as ready for review April 7, 2026 13:44
Copilot AI review requested due to automatic review settings April 7, 2026 13:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, accumulating rawInput and attempting to parse partial JSON into input during 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 loaderComponent plumbing 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.

Comment on lines +96 to +112

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) {
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't really ran into performance issues yet with the current approach, but something to keep in mind especially for large JSON inputs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes we can keep in mind that it's not completely optimal and we should use just a single parser

addToolResult={boundAddToolResult}
applyFilters={tool.applyFilters}
sendEvent={tool.sendEvent || (() => {})}
sendEvent={tool.sendEvent || (() => { })}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {}.

Suggested change
sendEvent={tool.sendEvent || (() => { })}
sendEvent={tool.sendEvent || (() => {})}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this resolved @shaejaz?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@shaejaz shaejaz marked this pull request as draft April 7, 2026 14:25
Comment on lines +96 to +112

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't really ran into performance issues yet with the current approach, but something to keep in mind especially for large JSON inputs.

@shaejaz shaejaz marked this pull request as ready for review April 8, 2026 14:50
@Haroenv Haroenv requested review from a team, FabienMotte, aymeric-giraudet and shaejaz and removed request for a team and shaejaz April 8, 2026 15:10
@shaejaz shaejaz force-pushed the feat/streaming-tools branch from 30114e7 to 60788da Compare April 10, 2026 08:57
Copy link
Copy Markdown
Contributor Author

@Haroenv Haroenv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

widgetParams: {
javascript: {
...createDefaultWidgetParams(chat),
templates: {
messages: {
loader: '<div class="custom-loader">Custom loader</div>',
},
},
},
react: {
...createDefaultWidgetParams(chat),
messagesLoaderComponent: () => (
<div className="custom-loader">Custom loader</div>
),
},
-> do we have two different loader options now?

addToolResult={boundAddToolResult}
applyFilters={tool.applyFilters}
sendEvent={tool.sendEvent || (() => {})}
sendEvent={tool.sendEvent || (() => { })}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this resolved @shaejaz?

@shaejaz
Copy link
Copy Markdown
Contributor

shaejaz commented Apr 10, 2026

-> do we have two different loader options now?

It's the same messagesLoaderComponent if the user wants to customize it. The new loaderComponent prop in the tool layout is in case they want to use the default loader component in the tool rendering.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants