Skip to content

Commit bc1eca8

Browse files
authored
Error handling for AI chat (#3411)
1 parent 78c1034 commit bc1eca8

File tree

14 files changed

+135
-54
lines changed

14 files changed

+135
-54
lines changed

.changeset/cold-toys-film.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': patch
3+
---
4+
5+
Error handling for AI Chat

packages/gitbook/src/components/AI/useAIChat.tsx

Lines changed: 63 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export type AIChatState = {
3838
* If true, the session is in progress.
3939
*/
4040
loading: boolean;
41+
42+
/**
43+
* Set to true when an error occurred while communicating with the server. When
44+
* this flag is true, the chat input should be read-only and the UI should
45+
* display an error alert. Clearing the conversation will reset this flag.
46+
*/
47+
error: boolean;
4148
};
4249

4350
export type AIChatController = {
@@ -68,6 +75,7 @@ const globalState = zustand.create<{
6875
messages: [],
6976
followUpSuggestions: [],
7077
loading: false,
78+
error: false,
7179
},
7280
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
7381
};
@@ -100,6 +108,7 @@ export function useAIChatController(): AIChatController {
100108
messages: [],
101109
followUpSuggestions: [],
102110
responseId: null,
111+
error: false,
103112
})),
104113
postMessage: async (input: { message: string }) => {
105114
trackEvent({ type: 'ask_question', query: input.message });
@@ -121,59 +130,70 @@ export function useAIChatController(): AIChatController {
121130
],
122131
followUpSuggestions: [],
123132
loading: true,
133+
error: false,
124134
};
125135
});
126136

127-
const stream = await streamAIChatResponse({
128-
message: input.message,
129-
messageContext: messageContextRef.current,
130-
previousResponseId: globalState.getState().state.responseId ?? undefined,
131-
});
132-
133-
for await (const data of stream) {
134-
if (!data) continue;
135-
136-
const event = data.event;
137-
138-
switch (event.type) {
139-
case 'response_finish': {
140-
setState((state) => ({
141-
...state,
142-
responseId: event.responseId,
143-
// Mark as not loading when the response is finished
144-
// Even if the stream might continue as we receive 'response_followup_suggestion'
145-
loading: false,
146-
}));
147-
break;
148-
}
149-
case 'response_followup_suggestion': {
150-
setState((state) => ({
151-
...state,
152-
followUpSuggestions: [
153-
...state.followUpSuggestions,
154-
...event.suggestions,
155-
],
156-
}));
157-
break;
137+
try {
138+
const stream = await streamAIChatResponse({
139+
message: input.message,
140+
messageContext: messageContextRef.current,
141+
previousResponseId: globalState.getState().state.responseId ?? undefined,
142+
});
143+
144+
for await (const data of stream) {
145+
if (!data) continue;
146+
147+
const event = data.event;
148+
149+
switch (event.type) {
150+
case 'response_finish': {
151+
setState((state) => ({
152+
...state,
153+
responseId: event.responseId,
154+
// Mark as not loading when the response is finished
155+
// Even if the stream might continue as we receive 'response_followup_suggestion'
156+
loading: false,
157+
error: false,
158+
}));
159+
break;
160+
}
161+
case 'response_followup_suggestion': {
162+
setState((state) => ({
163+
...state,
164+
followUpSuggestions: [
165+
...state.followUpSuggestions,
166+
...event.suggestions,
167+
],
168+
}));
169+
break;
170+
}
158171
}
172+
173+
setState((state) => ({
174+
...state,
175+
messages: [
176+
...state.messages.slice(0, -1),
177+
{
178+
role: AIMessageRole.Assistant,
179+
content: data.content,
180+
},
181+
],
182+
}));
159183
}
160184

161185
setState((state) => ({
162186
...state,
163-
messages: [
164-
...state.messages.slice(0, -1),
165-
{
166-
role: AIMessageRole.Assistant,
167-
content: data.content,
168-
},
169-
],
187+
loading: false,
188+
error: false,
189+
}));
190+
} catch {
191+
setState((state) => ({
192+
...state,
193+
loading: false,
194+
error: true,
170195
}));
171196
}
172-
173-
setState((state) => ({
174-
...state,
175-
loading: false,
176-
}));
177197
},
178198
};
179199
}, [messageContextRef, setState, trackEvent]);

packages/gitbook/src/components/AIChat/AIChat.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
import { t, tString, useLanguage } from '@/intl/client';
44
import { Icon } from '@gitbook/icons';
55
import React from 'react';
6-
import { type AIChatState, useAIChatController, useAIChatState } from '../AI/useAIChat';
6+
import {
7+
type AIChatController,
8+
type AIChatState,
9+
useAIChatController,
10+
useAIChatState,
11+
} from '../AI/useAIChat';
712
import { useNow } from '../hooks';
813
import { Button } from '../primitives';
914
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
@@ -159,7 +164,9 @@ export function AIChatWindow(props: { chat: AIChatState }) {
159164
{t(language, 'ai_chat_assistant_description')}
160165
</p>
161166
</div>
162-
<AIChatSuggestedQuestions chatController={chatController} />
167+
{!chat.error ? (
168+
<AIChatSuggestedQuestions chatController={chatController} />
169+
) : null}
163170
</div>
164171
) : (
165172
<AIChatMessages chat={chat} lastUserMessageRef={lastUserMessageRef} />
@@ -169,11 +176,18 @@ export function AIChatWindow(props: { chat: AIChatState }) {
169176
ref={inputRef}
170177
className="absolute inset-x-0 bottom-0 mr-2 flex flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
171178
>
172-
<AIChatFollowupSuggestions chat={chat} chatController={chatController} />
179+
{/* Display an error banner when something went wrong. */}
180+
{chat.error ? (
181+
<AIChatError chatController={chatController} />
182+
) : (
183+
<AIChatFollowupSuggestions chat={chat} chatController={chatController} />
184+
)}
185+
173186
<AIChatInput
174187
value={input}
175188
onChange={setInput}
176-
disabled={chat.loading}
189+
loading={chat.loading}
190+
disabled={chat.loading || chat.error}
177191
onSubmit={() => {
178192
chatController.postMessage({ message: input });
179193
setInput('');
@@ -184,3 +198,29 @@ export function AIChatWindow(props: { chat: AIChatState }) {
184198
</div>
185199
);
186200
}
201+
202+
function AIChatError(props: { chatController: AIChatController }) {
203+
const language = useLanguage();
204+
const { chatController } = props;
205+
206+
return (
207+
<div className="flex flex-wrap justify-between gap-2 rounded-md bg-danger p-3 text-danger text-sm ring-1 ring-danger">
208+
<div className="flex items-center gap-2">
209+
<Icon icon="exclamation-triangle" className="size-3.5" />
210+
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
211+
</div>
212+
<div className="flex justify-end">
213+
<Button
214+
variant="blank"
215+
size="small"
216+
icon="refresh"
217+
label={tString(language, 'unexpected_error_retry')}
218+
onClick={() => {
219+
chatController.clear();
220+
}}
221+
className="!text-danger hover:bg-danger-5"
222+
/>
223+
</div>
224+
</div>
225+
);
226+
}

packages/gitbook/src/components/AIChat/AIChatInput.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import { Tooltip } from '../primitives/Tooltip';
77

88
export function AIChatInput(props: {
99
value: string;
10-
disabled: boolean;
10+
disabled?: boolean;
11+
/**
12+
* When true, the input is disabled
13+
*/
14+
loading: boolean;
1115
onChange: (value: string) => void;
1216
onSubmit: (value: string) => void;
1317
}) {
14-
const { value, onChange, onSubmit, disabled } = props;
18+
const { value, onChange, onSubmit, disabled, loading } = props;
1519

1620
const language = useLanguage();
1721

@@ -38,7 +42,8 @@ export function AIChatInput(props: {
3842
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
3943
<textarea
4044
ref={inputRef}
41-
disabled={disabled}
45+
disabled={disabled || loading}
46+
data-loading={loading}
4247
className={tcls(
4348
'resize-none',
4449
'focus:outline-none',
@@ -55,7 +60,9 @@ export function AIChatInput(props: {
5560
'disabled:bg-tint-subtle',
5661
'delay-300',
5762
'disabled:delay-0',
58-
'disabled:cursor-progress'
63+
'disabled:cursor-not-allowed',
64+
'data-[loading=true]:cursor-progress',
65+
'data-[loading=true]:opacity-50'
5966
)}
6067
value={value}
6168
rows={1}

packages/gitbook/src/components/AIChat/AIChatMessages.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function AIChatMessages(props: {
3434
>
3535
{message.content ? (
3636
message.content
37-
) : (
37+
) : chat.loading ? (
3838
<div className="flex w-full animate-[fadeIn_500ms_both] flex-wrap gap-2">
3939
{Array.from({ length: 7 }).map((_, index) => (
4040
<div
@@ -47,7 +47,7 @@ export function AIChatMessages(props: {
4747
/>
4848
))}
4949
</div>
50-
)}
50+
) : null}
5151
</div>
5252
);
5353
})}

packages/gitbook/src/intl/translations/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const de = {
7474
ai_chat_thinking: 'Denke nach...',
7575
ai_chat_working: 'Arbeite...',
7676
ai_chat_context_badge: 'KI',
77+
ai_chat_error: 'Etwas ist schief gelaufen.',
7778
ai_chat_context_title: 'Basierend auf Ihrem Kontext',
7879
ai_chat_context_description:
7980
'Der Docs-Assistent verwendet KI und Ihren Kontext, um Antworten zu generieren und Aktionen durchzuführen.',

packages/gitbook/src/intl/translations/en.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const en = {
4949
notfound: "The page you are looking for doesn't exist.",
5050
unexpected_error_title: 'An error occurred',
5151
unexpected_error: 'Sorry, an unexpected error has occurred. Please try again later.',
52-
unexpected_error_retry: 'Retry',
52+
unexpected_error_retry: 'Try again',
5353
pdf_download: 'Export as PDF',
5454
pdf_goback: 'Go back to content',
5555
pdf_print: 'Print or Save as PDF',
@@ -72,6 +72,7 @@ export const en = {
7272
ai_chat_thinking: 'Thinking...',
7373
ai_chat_working: 'Working...',
7474
ai_chat_context_badge: 'AI',
75+
ai_chat_error: 'Something went wrong.',
7576
ai_chat_context_title: 'Based on your context',
7677
ai_chat_context_description:
7778
'Docs Assistant uses AI and your context to generate answers and perform actions.',

packages/gitbook/src/intl/translations/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const es: TranslationLanguage = {
7676
ai_chat_thinking: 'Pensando...',
7777
ai_chat_working: 'Trabajando...',
7878
ai_chat_context_badge: 'IA',
79+
ai_chat_error: 'Algo salió mal.',
7980
ai_chat_context_title: 'Basado en tu contexto',
8081
ai_chat_context_description:
8182
'El Asistente de Docs usa IA y tu contexto para generar respuestas y realizar acciones.',

packages/gitbook/src/intl/translations/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const fr: TranslationLanguage = {
7474
ai_chat_thinking: 'Réfléchit...',
7575
ai_chat_working: 'Travaille...',
7676
ai_chat_context_badge: 'IA',
77+
ai_chat_error: 'Une erreur est survenue.',
7778
ai_chat_context_title: 'Basé sur votre contexte',
7879
ai_chat_context_description:
7980
"L'Assistant Docs utilise l'IA et votre contexte pour générer des réponses et effectuer des actions.",

packages/gitbook/src/intl/translations/ja.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const ja: TranslationLanguage = {
7474
ai_chat_thinking: '考え中...',
7575
ai_chat_working: '作業中...',
7676
ai_chat_context_badge: 'AI',
77+
ai_chat_error: '何らかのエラーが発生しました。',
7778
ai_chat_context_title: 'あなたのコンテキストに基づいて',
7879
ai_chat_context_description:
7980
'ドキュメントアシスタントはAIとあなたのコンテキストを使用して回答を生成し、アクションを実行します。',

0 commit comments

Comments
 (0)