1
0
Fork 0

feat: curate AskAI search parameters (#2768)

* feat: curate AskAI search parameters

* fix

* fix docs

* fix

* fix: remove attributesToSnippet

* fix

---------

Co-authored-by: Dylan Tientcheu <dylan.tientcheu@algolia.com>
This commit is contained in:
Vasco Bettencourt 2025-09-24 19:40:08 +01:00 committed by GitHub
parent 0267f55453
commit d8ad7f6d5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 373 additions and 197 deletions

View file

@ -30,6 +30,15 @@ export type DocSearchTransformClient = {
transporter: Pick<LiteClient['transporter'], 'algoliaAgent'>;
};
// Define the specific search parameters allowed for AskAI
export type AskAiSearchParameters = {
facetFilters?: string[];
filters?: string;
attributesToRetrieve?: string[];
restrictSearchableAttributes?: string[];
distinct?: boolean;
};
export type DocSearchAskAi = {
/**
* The index name to use for the ask AI feature. Your assistant will search this index for relevant documents.
@ -53,9 +62,7 @@ export type DocSearchAskAi = {
/**
* The search parameters to use for the ask AI feature.
*/
searchParameters?: {
facetFilters?: SearchParamsObject['facetFilters'];
};
searchParameters?: AskAiSearchParameters;
};
export interface DocSearchIndex {

View file

@ -90,8 +90,8 @@ docsearch({
searchParameters: {
facetFilters: ['language:en'],
// ...
}
}
},
},
],
// ...
});
@ -111,7 +111,6 @@ docsearch({
in case you want to use custom `searchParameters` for the index
```jsx
<DocSearch
// ...
@ -121,8 +120,8 @@ in case you want to use custom `searchParameters` for the index
searchParameters: {
facetFilters: ['language:en'],
// ...
}
}
},
},
]}
// ...
/>
@ -186,7 +185,16 @@ docsearch({
appId: 'ANOTHER_APP_ID',
assistantId: 'YOUR_ALGOLIA_ASSISTANT_ID',
searchParameters: {
facetFilters: ['language:en'],
// Filtering parameters
facetFilters: ['language:en', 'version:latest'],
filters: 'type:content AND language:en',
// Content control parameters
attributesToRetrieve: ['title', 'content', 'url'],
restrictSearchableAttributes: ['title', 'content'],
// Deduplication
distinct: true,
},
},
// ...
@ -214,6 +222,18 @@ in case you want to use different credentials for askAi
apiKey: 'ANOTHER_SEARCH_API_KEY',
appId: 'ANOTHER_APP_ID',
assistantId: 'YOUR_ALGOLIA_ASSISTANT_ID',
searchParameters: {
// Filtering parameters
facetFilters: ['language:en', 'version:latest'],
filters: 'type:content AND language:en',
// Content control parameters
attributesToRetrieve: ['title', 'content', 'url'],
restrictSearchableAttributes: ['title', 'content'],
// Deduplication
distinct: true,
},
}}
/>
```
@ -221,9 +241,15 @@ in case you want to use different credentials for askAi
</TabItem>
</Tabs>
:::tip
You can use `facetFilters: ['type:content']` to ensure AskAI only uses records where the `type` attribute is `content` (i.e., only records that actually have content). This is useful if your index contains records for navigation, metadata, or other non-content types.
:::
:::tip AskAI supports these essential search parameters for optimal performance:
- **Filtering**: `facetFilters: ['type:content']` - Filter by language, version, or content type
- **Complex filtering**: `filters: 'type:content AND language:en'` - Apply complex filtering rules
- **Content control**: `attributesToRetrieve: ['title', 'content', 'url']` - Control which attributes are retrieved
- **Search scope**: `restrictSearchableAttributes: ['title', 'content']` - Limit search to specific fields
- **Deduplication**: `distinct: true` - Remove duplicate results
These parameters provide the essential functionality for AskAI while keeping the API simple and focused. :::
## `searchParameters`
@ -480,14 +506,16 @@ When provided, an informative message wrapped with your link will be displayed o
Configuration for keyboard shortcuts that trigger the search modal.
### Default behavior:
- `Ctrl/Cmd+K` - Opens and closes the search modal
- `/` - Opens the search modal (doesn't close)
### Interface:
```typescript
interface KeyboardShortcuts {
'Ctrl/Cmd+K'?: boolean; // default: true
'/'?: boolean; // default: true
'/'?: boolean; // default: true
}
```
@ -510,19 +538,19 @@ docsearch({
// Disable slash shortcut
docsearch({
// ...
keyboardShortcuts: { '/': false }
keyboardShortcuts: { '/': false },
});
// Disable Ctrl/Cmd+K shortcut (also hides button hint)
docsearch({
// ...
keyboardShortcuts: { 'Ctrl/Cmd+K': false }
keyboardShortcuts: { 'Ctrl/Cmd+K': false },
});
// Disable all keyboard shortcuts
docsearch({
// ...
keyboardShortcuts: { 'Ctrl/Cmd+K': false, '/': false }
keyboardShortcuts: { 'Ctrl/Cmd+K': false, '/': false },
});
```
@ -531,38 +559,46 @@ docsearch({
<TabItem value="react">
```jsx
{/* Default - all shortcuts enabled */}
{
/* Default - all shortcuts enabled */
}
<DocSearch
// ...
/>
// ...
/>;
{/* Disable slash shortcut */}
{
/* Disable slash shortcut */
}
<DocSearch
// ...
keyboardShortcuts={{ '/': false }}
/>
/>;
{/* Disable Ctrl/Cmd+K shortcut (also hides button hint) */}
{
/* Disable Ctrl/Cmd+K shortcut (also hides button hint) */
}
<DocSearch
// ...
keyboardShortcuts={{ 'Ctrl/Cmd+K': false }}
/>
/>;
{/* Disable all keyboard shortcuts */}
{
/* Disable all keyboard shortcuts */
}
<DocSearch
// ...
keyboardShortcuts={{ 'Ctrl/Cmd+K': false, '/': false }}
/>
/>;
```
</TabItem>
</Tabs>
:::info Keyboard Shortcut Behavior
- **Ctrl/Cmd+K**: Toggle shortcut that both opens and closes the modal
- **/**: Character shortcut that only opens the modal (prevents interference with search typing)
- **Escape**: Always works to close the modal regardless of configuration
:::
- **Escape**: Always works to close the modal regardless of configuration :::
## `resultsFooterComponent`
@ -673,7 +709,7 @@ The maximum number of recent searches that are stored for the user. Default is 7
```js
docsearch({
// ...
recentSearchesLimit: 12
recentSearchesLimit: 12,
// ...
});
```
@ -712,7 +748,7 @@ The maximum number of recent searches that are stored when the user has favorite
```js
docsearch({
// ...
recentSearchesWithFavoritesLimit: 5
recentSearchesWithFavoritesLimit: 5,
// ...
});
```
@ -740,7 +776,7 @@ The element where the DocSearch modal will be portaled. Use this when you need t
:::warning
This prop only exists in `@docsearch/react`. If you are using **`@docsearch/js`**, use the [`container`](#container) option instead—the value you pass there is both the **mount point** of the search button *and* the portal target for the modal.
This prop only exists in `@docsearch/react`. If you are using **`@docsearch/js`**, use the [`container`](#container) option instead—the value you pass there is both the **mount point** of the search button _and_ the portal target for the modal.
:::
@ -753,7 +789,7 @@ This prop only exists in `@docsearch/react`. If you are using **`@docsearch/js`*
```jsx
// assume you have a dedicated modal root in your html
<div id="modal-root" />
<div id="modal-root" />;
const portalEl = document.getElementById('modal-root');
@ -783,7 +819,6 @@ docsearch({
</TabItem>
</Tabs>
[1]: https://www.algolia.com/doc/ui-libraries/autocomplete/introduction/what-is-autocomplete/
[2]: https://github.com/algolia/docsearch/
[3]: https://github.com/algolia/docsearch/tree/master

View file

@ -35,7 +35,7 @@ Use the default experience with your index credentials. this works great for typ
## Ask AI: ai-assisted answers
Add algolia askai to get synthesized answers grounded in your indexed content. you can scope the llm context using `searchParameters`.
Add algolia askai to get synthesized answers grounded in your indexed content. you can scope the llm context using `searchParameters` like `facetFilters`, `filters`, `attributesToRetrieve`,`restrictSearchableAttributes`, and `distinct`.
```jsx
<DocSearch
@ -77,15 +77,52 @@ Replace the default hit markup to match your brand and layout. below is a minima
function CustomHit({ hit }) {
// render a compact, branded hit card
return (
<a href={hit.url} style={{ display: 'block', padding: '12px 16px', textDecoration: 'none' }}>
<a
href={hit.url}
style={{ display: 'block', padding: '12px 16px', textDecoration: 'none' }}
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ width: 40, height: 40, backgroundColor: '#e3f2fd', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600, color: '#1976d2' }}>
<div
style={{
width: 40,
height: 40,
backgroundColor: '#e3f2fd',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
color: '#1976d2',
}}
>
{hit.type?.toUpperCase?.() || 'DOC'}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{hit.hierarchy?.lvl1 || 'untitled'}</div>
{hit.hierarchy?.lvl2 && <div style={{ color: '#666', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{hit.hierarchy.lvl2}</div>}
{hit.content && <div style={{ color: '#888', marginTop: 4 }}>{hit.content}</div>}
<div
style={{
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{hit.hierarchy?.lvl1 || 'untitled'}
</div>
{hit.hierarchy?.lvl2 && (
<div
style={{
color: '#666',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{hit.hierarchy.lvl2}
</div>
)}
{hit.content && (
<div style={{ color: '#888', marginTop: 4 }}>{hit.content}</div>
)}
</div>
</div>
</a>
@ -99,7 +136,7 @@ function CustomHit({ hit }) {
hitComponent={CustomHit}
insights={true}
translations={{ button: { buttonText: 'custom hits (demo)' } }}
/>
/>;
```
<DocSearch
@ -107,22 +144,63 @@ function CustomHit({ hit }) {
apiKey="24b09689d5b4223813d9b8e48563c8f6"
indexName="docsearch"
hitComponent={({ hit }) => {
// render a compact, branded hit card
return (
<a href={hit.url} style={{ display: 'block', padding: '12px 16px', textDecoration: 'none' }}>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ width: 40, height: 40, backgroundColor: '#e3f2fd', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600, color: '#1976d2' }}>
{hit.type?.toUpperCase?.() || 'DOC'}
// render a compact, branded hit card
return (
<a
href={hit.url}
style={{
display: 'block',
padding: '12px 16px',
textDecoration: 'none',
}}
>
<div style={{ display: 'flex', gap: 12 }}>
<div
style={{
width: 40,
height: 40,
backgroundColor: '#e3f2fd',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
color: '#1976d2',
}}
>
{hit.type?.toUpperCase?.() || 'DOC'}
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{hit.hierarchy?.lvl1 || 'untitled'}
</div>
{hit.hierarchy?.lvl2 && (
<div
style={{
color: '#666',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{hit.hierarchy.lvl2}
</div>
)}
{hit.content && (
<div style={{ color: '#888', marginTop: 4 }}>{hit.content}</div>
)}
</div>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{hit.hierarchy?.lvl1 || 'untitled'}</div>
{hit.hierarchy?.lvl2 && <div style={{ color: '#666', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{hit.hierarchy.lvl2}</div>}
{hit.content && <div style={{ color: '#888', marginTop: 4 }}>{hit.content}</div>}
</div>
</div>
</a>
);
}}
</a>
);
}}
insights={true}
translations={{ button: { buttonText: 'custom hits (demo)' } }}
/>
@ -158,7 +236,7 @@ const newTabNavigator = {
navigator={newTabNavigator}
insights={true}
translations={{ button: { buttonText: 'open in new tabs (demo)' } }}
/>
/>;
```
<DocSearch
@ -181,9 +259,7 @@ const newTabNavigator = {
<br></br>
:::warning
**Note**: Using only `hitComponent` with `target="_blank"` will work for mouse clicks, but keyboard navigation (arrows + Enter) requires the `navigator` prop to consistently open links in new tabs.
:::
:::warning **Note**: Using only `hitComponent` with `target="_blank"` will work for mouse clicks, but keyboard navigation (arrows + Enter) requires the `navigator` prop to consistently open links in new tabs. :::
---
@ -235,7 +311,11 @@ the snippet below maps a non-standard record to the internal format. try it live
apiKey="24b09689d5b4223813d9b8e48563c8f6"
indexName="crawler_doc"
askAi={{ assistantId: 'askAIDemo' }}
searchParameters={{ attributesToRetrieve: ['*'], attributesToSnippet: ['*'], hitsPerPage: 20 }}
searchParameters={{
attributesToRetrieve: ['*'],
attributesToSnippet: ['*'],
hitsPerPage: 20,
}}
transformItems={(items) =>
items.map((item) => ({
objectID: item.objectID,
@ -266,5 +346,5 @@ the snippet below maps a non-standard record to the internal format. try it live
## Tips
- **Instrumentation**: enable `insights` to send usage analytics and iterate on relevance.
- **AskAI scoping**: use `facetFilters` to keep ai answers strictly within your desired corpus (language, product, version, etc.).
- **AskAI scoping**: use `facetFilters`, `filters`, `attributesToRetrieve`, `restrictSearchableAttributes`, and `distinct` to control ai context and improve answer quality.
- **Customization**: use `hitComponent`, `transformItems`, and `translations` to make docsearch feel native to any product surface.

View file

@ -8,15 +8,13 @@ import TabItem from '@theme/TabItem';
The Ask AI API enables developers to build custom chat interfaces powered by Algolia's AI assistant. Use these endpoints to create tailored conversational experiences that search your Algolia index and generate contextual responses using your own LLM provider.
**Key capabilities:**
- Real-time streaming responses for better user experience
- Advanced facet filtering to control AI context
- Authorization token authentication for secure API access
- Full compatibility with popular frameworks like Next.js and Vercel AI SDK
:::info
This API documentation is primarily for developers building custom Ask AI integrations. If you're using the DocSearch package, you typically won't need this information since DocSearch handles the Ask AI API integration automatically. For standard DocSearch usage, see the [DocSearch documentation](/docs/docsearch) instead.
:::
:::info This API documentation is primarily for developers building custom Ask AI integrations. If you're using the DocSearch package, you typically won't need this information since DocSearch handles the Ask AI API integration automatically. For standard DocSearch usage, see the [DocSearch documentation](/docs/docsearch) instead. :::
## Overview
@ -37,11 +35,13 @@ Ask AI uses authorization tokens for authentication. Tokens expire after 5 minut
**POST** `/chat/token`
**Headers:**
- `X-Algolia-Assistant-Id`: Your Ask AI assistant configuration ID
- `Origin` (optional): Request origin for CORS validation
- `Referer` (optional): Full URL of the requesting page
**Response:**
```json
{
"success": true,
@ -60,6 +60,7 @@ Ask AI uses authorization tokens for authentication. Tokens expire after 5 minut
Start or continue a chat with the AI assistant. **The response is streamed in real-time** using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events), allowing you to display the AI's response as it's being generated.
**Headers:**
- `X-Algolia-Application-Id`: Your Algolia application ID
- `X-Algolia-API-Key`: Your Algolia API key
- `X-Algolia-Index-Name`: Algolia index to use
@ -67,6 +68,7 @@ Start or continue a chat with the AI assistant. **The response is streamed in re
- `Authorization`: TOKEN `token_here` (get from `/chat/token`)
**Request Body:**
```json
{
"id": "your-conversation-id",
@ -91,6 +93,7 @@ Start or continue a chat with the AI assistant. **The response is streamed in re
```
**Request Body Parameters:**
- `id` (string, required): Unique conversation identifier
- `messages` (array, required): Array of conversation messages
- `role` (string): "user" or "assistant"
@ -99,7 +102,11 @@ Start or continue a chat with the AI assistant. **The response is streamed in re
- `createdAt` (string, optional): ISO timestamp
- `parts` (array, optional): Message parts (used by Vercel AI SDK)
- `searchParameters` (object, optional): Search configuration
- `facetFilters` (array, optional): Filter the context used by Ask AI
- `facetFilters` (array, optional): Filter by language, version, type
- `filters` (string, optional): Apply complex filtering rules
- `attributesToRetrieve` (array, optional): Control which attributes are retrieved
- `restrictSearchableAttributes` (array, optional): Limit search to specific fields
- `distinct` (boolean, optional): Remove duplicate results
**Using Search Parameters:**
@ -116,11 +123,11 @@ Search parameters allow you to control how Ask AI searches your index:
}
],
"searchParameters": {
"facetFilters": [
"language:en",
"version:latest",
"type:content"
]
"facetFilters": ["language:en", "version:latest"],
"filters": "type:content AND language:en",
"attributesToRetrieve": ["title", "content", "url"],
"restrictSearchableAttributes": ["title", "content"],
"distinct": true
}
}
```
@ -134,20 +141,19 @@ You can use nested arrays for OR logic within facet filters:
"searchParameters": {
"facetFilters": [
"language:en",
[
"docusaurus_tag:default",
"docusaurus_tag:docs-default-current"
]
["docusaurus_tag:default", "docusaurus_tag:docs-default-current"]
]
}
}
```
This example filters to:
- `language:en` **AND**
- (`docusaurus_tag:default` **OR** `docusaurus_tag:docs-default-current`)
**Common Use Cases:**
- **Multi-language sites**: `["language:en"]`
- **Versioned documentation**: `["version:latest"]` or `["version:v2.0"]`
- **Content types**: `["type:content"]` to exclude navigation/metadata
@ -155,6 +161,7 @@ This example filters to:
- **Categories with fallbacks**: `[["category:advanced", "category:intermediate"]]`
**Response:**
- **Content-Type:** `text/event-stream`
- **Format:** Server-sent events with incremental AI response chunks
- **Benefits:** Real-time response display, better user experience, lower perceived latency
@ -162,7 +169,9 @@ This example filters to:
**Handling Streaming Responses:**
```js
const response = await fetch('/chat', { /* ... */ });
const response = await fetch('/chat', {
/* ... */
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
@ -183,10 +192,12 @@ while (true) {
Submit thumbs up/down feedback for a chat message.
**Headers:**
- `X-Algolia-Assistant-Id`: Your Ask AI assistant configuration ID
- `Authorization`: TOKEN `token_here`
**Request Body:**
```json
{
"appId": "YOUR_APP_ID",
@ -198,6 +209,7 @@ Submit thumbs up/down feedback for a chat message.
- `thumbs`: 1 for positive feedback, 0 for negative
**Response:**
```json
{
"success": true,
@ -255,7 +267,7 @@ class AskAIChat {
'X-Algolia-API-Key': this.apiKey,
'X-Algolia-Index-Name': this.indexName,
'X-Algolia-Assistant-Id': this.assistantId,
'Authorization': `TOKEN ${token}`,
Authorization: `TOKEN ${token}`,
},
body: JSON.stringify({
id: conversationId,
@ -288,7 +300,7 @@ class AskAIChat {
} finally {
reader.releaseLock();
}
}
},
};
}
@ -300,7 +312,7 @@ class AskAIChat {
headers: {
'Content-Type': 'application/json',
'X-Algolia-Assistant-Id': this.assistantId,
'Authorization': `TOKEN ${token}`,
Authorization: `TOKEN ${token}`,
},
body: JSON.stringify({
appId: this.appId,
@ -322,15 +334,19 @@ const chat = new AskAIChat({
});
// Send message and handle streaming response
const stream = await chat.sendMessage('conversation-1', [
const stream = await chat.sendMessage(
'conversation-1',
[
{
role: 'user',
content: 'What is Algolia?',
id: 'msg-1',
},
],
{
role: 'user',
content: 'What is Algolia?',
id: 'msg-1',
},
], {
facetFilters: ['language:en', 'type:content']
}); // Add search parameters
facetFilters: ['language:en', 'type:content'],
}
); // Add search parameters
// Display response as it streams in real-time
let fullResponse = '';
@ -368,92 +384,103 @@ function AskAIChat({ appId, apiKey, indexName, assistantId }) {
return data.token;
}, [assistantId]);
const sendMessage = useCallback(async (content) => {
const newMessage = {
role: 'user',
content,
id: `msg-${Date.now()}`,
};
const sendMessage = useCallback(
async (content) => {
const newMessage = {
role: 'user',
content,
id: `msg-${Date.now()}`,
};
setMessages(prev => [...prev, newMessage]);
setIsLoading(true);
setIsStreaming(true);
setMessages((prev) => [...prev, newMessage]);
setIsLoading(true);
setIsStreaming(true);
// Create abort controller for cancellation
abortControllerRef.current = new AbortController();
// Create abort controller for cancellation
abortControllerRef.current = new AbortController();
try {
const token = await getToken();
const response = await fetch(`${baseUrl}/chat`, {
method: 'POST',
signal: abortControllerRef.current.signal,
headers: {
'Content-Type': 'application/json',
'X-Algolia-Application-Id': appId,
'X-Algolia-API-Key': apiKey,
'X-Algolia-Index-Name': indexName,
'X-Algolia-Assistant-Id': assistantId,
'Authorization': `TOKEN ${token}`,
},
body: JSON.stringify({
id: 'conversation-1',
messages: [...messages, newMessage],
searchParameters: {
facetFilters: ['language:en', 'type:content']
try {
const token = await getToken();
const response = await fetch(`${baseUrl}/chat`, {
method: 'POST',
signal: abortControllerRef.current.signal,
headers: {
'Content-Type': 'application/json',
'X-Algolia-Application-Id': appId,
'X-Algolia-API-Key': apiKey,
'X-Algolia-Index-Name': indexName,
'X-Algolia-Assistant-Id': assistantId,
Authorization: `TOKEN ${token}`,
},
}),
});
body: JSON.stringify({
id: 'conversation-1',
messages: [...messages, newMessage],
searchParameters: {
facetFilters: ['language:en', 'type:content'],
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Handle streaming response in real-time
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = '';
let assistantMessageId = `assistant-${Date.now()}`;
// Add initial empty assistant message
setMessages(prev => [...prev, {
role: 'assistant',
content: '',
id: assistantMessageId
}]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode chunk and add to message
const chunk = decoder.decode(value, { stream: true });
if (chunk.trim()) {
assistantMessage += chunk;
// Update UI with streaming content immediately
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: assistantMessage }
: msg
));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Handle streaming response in real-time
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = '';
let assistantMessageId = `assistant-${Date.now()}`;
// Add initial empty assistant message
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: '',
id: assistantMessageId,
},
]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode chunk and add to message
const chunk = decoder.decode(value, { stream: true });
if (chunk.trim()) {
assistantMessage += chunk;
// Update UI with streaming content immediately
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessageId
? { ...msg, content: assistantMessage }
: msg
)
);
}
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Streaming error:', error);
// Add error message to chat
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: 'Sorry, there was an error processing your request.',
id: `error-${Date.now()}`,
},
]);
}
} finally {
setIsLoading(false);
setIsStreaming(false);
abortControllerRef.current = null;
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Streaming error:', error);
// Add error message to chat
setMessages(prev => [...prev, {
role: 'assistant',
content: 'Sorry, there was an error processing your request.',
id: `error-${Date.now()}`
}]);
}
} finally {
setIsLoading(false);
setIsStreaming(false);
abortControllerRef.current = null;
}
}, [messages, appId, apiKey, indexName, assistantId, getToken]);
},
[messages, appId, apiKey, indexName, assistantId, getToken]
);
const cancelStream = useCallback(() => {
if (abortControllerRef.current) {
@ -467,26 +494,38 @@ function AskAIChat({ appId, apiKey, indexName, assistantId }) {
{messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<strong>{message.role}:</strong>
<span className={isStreaming && message.role === 'assistant' && message === messages[messages.length - 1] ? 'streaming' : ''}>
<span
className={
isStreaming &&
message.role === 'assistant' &&
message === messages[messages.length - 1]
? 'streaming'
: ''
}
>
{message.content}
{isStreaming && message.role === 'assistant' && message === messages[messages.length - 1] && (
<span className="cursor">▊</span>
)}
{isStreaming &&
message.role === 'assistant' &&
message === messages[messages.length - 1] && (
<span className="cursor">▊</span>
)}
</span>
</div>
))}
</div>
<form onSubmit={(e) => {
e.preventDefault();
if (isLoading) return;
<form
onSubmit={(e) => {
e.preventDefault();
if (isLoading) return;
const input = e.target.elements.message;
if (input.value.trim()) {
sendMessage(input.value);
input.value = '';
}
}}>
const input = e.target.elements.message;
if (input.value.trim()) {
sendMessage(input.value);
input.value = '';
}
}}
>
<input
name="message"
type="text"
@ -511,12 +550,23 @@ function AskAIChat({ appId, apiKey, indexName, assistantId }) {
animation: blink 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
`}</style>
</div>
@ -552,7 +602,7 @@ function ChatComponent() {
return (
<div>
{messages.map(m => (
{messages.map((m) => (
<div key={m.id}>
{m.role === 'user' ? 'User: ' : 'AI: '}
{m.content}
@ -587,7 +637,7 @@ async function getToken(assistantId: string, origin: string) {
method: 'POST',
headers: {
'X-Algolia-Assistant-Id': assistantId,
'Origin': origin,
Origin: origin,
},
});
@ -613,7 +663,7 @@ export default async function handler(req: Request) {
'X-Algolia-API-Key': process.env.ALGOLIA_API_KEY!,
'X-Algolia-Index-Name': process.env.ALGOLIA_INDEX_NAME!,
'X-Algolia-Assistant-Id': assistantId,
'Authorization': `TOKEN ${token}`,
Authorization: `TOKEN ${token}`,
'Content-Type': 'application/json',
};
@ -632,15 +682,16 @@ export default async function handler(req: Request) {
return new StreamingTextResponse(response.body);
} catch (error) {
console.error('Chat API error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
```
**Environment Variables (.env.local):**
```env
ALGOLIA_APP_ID=your_app_id
ALGOLIA_API_KEY=your_api_key
@ -654,19 +705,20 @@ ALGOLIA_ASSISTANT_ID=your_assistant_id
import { useChat } from 'ai/react';
function ChatComponent() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat', // Use your Next.js API route
body: {
searchParameters: {
facetFilters: ['language:en', 'type:content']
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat({
api: '/api/chat', // Use your Next.js API route
body: {
searchParameters: {
facetFilters: ['language:en', 'type:content'],
},
},
},
});
});
return (
<div className="chat-container">
<div className="messages">
{messages.map(m => (
{messages.map((m) => (
<div key={m.id} className={`message ${m.role}`}>
<strong>{m.role === 'user' ? 'You' : 'AI'}:</strong>
<div>{m.content}</div>
@ -692,6 +744,7 @@ function ChatComponent() {
```
**Benefits of the proxy approach:**
- **Security**: API keys stay on the server
- **Token management**: Automatic token refresh
- **Error handling**: Centralized error management
@ -712,6 +765,7 @@ All error responses follow this format:
```
Common error scenarios:
- **Invalid assistant ID**: Configuration doesn't exist
- **Expired token**: Request a new authorization token
- **Rate limiting**: Too many requests