Skip to content

Commit 550b499

Browse files
authored
feat: Surfare chat errors (#2164)
* feat(chat): implement useStoredSelection hook for managing selected items - Added `findOrFirst` helper function to retrieve stored items or fallback to the first item in the array. - Introduced `useStoredSelection` hook to manage selected models and gateways using local storage. - Refactored `ChatPanelContent` to utilize the new hook for improved state management of selected items. - Enhanced dynamic system prompt generation based on selected gateway. * feat(chat): enhance error handling and model configuration in chat components - Added error handling capabilities with `ChatErrorBanner` to display errors and provide options to fix them directly in the chat interface. - Introduced `limits` property in model configuration to manage context window and output token limits. - Updated `ChatBranchPreview` to utilize a reusable `ChatHighlight` component for better UI consistency. - Refactored `ChatPanelContent` to include error handling and model limits in the request payload. - Enhanced `usePersistedChat` hook to manage chat errors and provide a clear error state. * feat(chat): add finish reason warning component and enhance chat error handling - Introduced `ChatFinishReasonWarning` component to inform users about incomplete responses and provide options to continue. - Updated `Chat` component to include the new warning feature. - Enhanced `ChatPanelContent` and `AssistantChatPanelContent` to display the finish reason warning alongside error handling. - Modified `usePersistedChat` hook to manage finish reason state, improving user experience during chat interactions.
1 parent 5b610cf commit 550b499

8 files changed

Lines changed: 328 additions & 87 deletions

File tree

apps/mesh/src/api/llm-provider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export const createLLMProvider = (binding: LLMBindingClient): LLMProvider => {
165165
modelId,
166166
});
167167

168+
if (!response.ok) {
169+
throw new Error(
170+
`Streaming failed for model ${modelId} with the status code: ${response.status}\n${await response.text()}`,
171+
);
172+
}
173+
168174
return {
169175
stream: responseToStream(response),
170176
response: {

apps/mesh/src/api/routes/models.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ const StreamRequestSchema = z.object({
4646
])
4747
.optional()
4848
.nullable(),
49+
limits: z
50+
.object({
51+
contextWindow: z.number().optional(),
52+
maxOutputTokens: z.number().optional(),
53+
})
54+
.optional(),
4955
})
50-
.passthrough()
51-
.optional(),
52-
gateway: z.object({ id: z.string() }).passthrough().optional(),
56+
.loose(),
57+
gateway: z.object({ id: z.string() }).loose(),
5358
stream: z.boolean().optional(),
5459
temperature: z.number().optional(),
55-
maxOutputTokens: z.number().optional(),
5660
maxWindowSize: z.number().optional(),
5761
thread_id: z.string().optional(),
5862
});
@@ -210,25 +214,18 @@ app.post("/:org/models/stream", async (c) => {
210214

211215
const payload = parseResult.data;
212216

213-
// Validate model is provided
214-
if (!payload.model) {
215-
return c.json({ error: "model is required" }, 400);
216-
}
217-
218217
const {
219218
model: modelConfig,
220219
gateway: gatewayConfig,
221220
messages,
222221
temperature,
223-
maxOutputTokens = DEFAULT_MAX_TOKENS,
224222
maxWindowSize = DEFAULT_MEMORY,
225223
thread_id: threadId,
226224
} = payload;
227225

228-
// Validate gateway is provided
229-
if (!gatewayConfig?.id) {
230-
return c.json({ error: "gateway is required" }, 400);
231-
}
226+
// Use limits from model config, fallback to default
227+
const maxOutputTokens =
228+
modelConfig.limits?.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
232229

233230
const transport = createGatewayTransport(c.req.raw, gatewayConfig.id);
234231

@@ -286,7 +283,7 @@ app.post("/:org/models/stream", async (c) => {
286283
messages: prunedMessages,
287284
tools,
288285
temperature,
289-
maxOutputTokens,
286+
maxOutputTokens: maxOutputTokens,
290287
abortSignal: c.req.raw.signal,
291288
stopWhen: stepCountIs(30), // Stop after 30 steps with tool calls
292289
onError: async (error) => {

apps/mesh/src/web/components/chat/index.tsx

Lines changed: 187 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { UseChatHelpers } from "@ai-sdk/react";
2+
import { Button } from "@deco/ui/components/button.tsx";
23
import { cn } from "@deco/ui/lib/utils.ts";
34
import type { Metadata } from "@deco/ui/types/chat-metadata.ts";
4-
import { CornerUpLeft, X } from "@untitledui/icons";
5+
import { AlertCircle, AlertTriangle, CornerUpLeft, X } from "@untitledui/icons";
56
import type { UIMessage } from "ai";
67
import type {
78
PropsWithChildren,
@@ -136,6 +137,7 @@ function ChatMessages({
136137
minHeightOffset?: number;
137138
}) {
138139
const sentinelRef = useRef<HTMLDivElement>(null);
140+
139141
useChatAutoScroll({ messageCount: messages.length, sentinelRef });
140142

141143
return (
@@ -169,6 +171,87 @@ function ChatFooter({ children }: PropsWithChildren) {
169171
);
170172
}
171173

174+
/**
175+
* Highlight component - reusable banner for errors, warnings, and info messages.
176+
*/
177+
function ChatHighlight({
178+
title,
179+
description,
180+
icon,
181+
variant = "default",
182+
onDismiss,
183+
children,
184+
}: {
185+
title?: string;
186+
description?: string;
187+
icon?: ReactNode;
188+
variant?: "default" | "danger" | "warning";
189+
onDismiss?: () => void;
190+
children?: ReactNode;
191+
}) {
192+
const variantStyles = {
193+
default: {
194+
container:
195+
"border-muted-foreground/30 bg-muted/50 hover:bg-muted transition-colors",
196+
icon: "text-muted-foreground",
197+
title: "text-muted-foreground",
198+
description: "text-muted-foreground/70",
199+
},
200+
danger: {
201+
container: "border-destructive/30 bg-destructive/5",
202+
icon: "text-destructive",
203+
title: "text-destructive font-medium",
204+
description: "text-muted-foreground",
205+
},
206+
warning: {
207+
container: "border-amber-500/30 bg-amber-500/5",
208+
icon: "text-amber-600 dark:text-amber-500",
209+
title: "text-amber-600 dark:text-amber-500 font-medium",
210+
description: "text-muted-foreground",
211+
},
212+
};
213+
214+
const styles = variantStyles[variant];
215+
216+
return (
217+
<div
218+
className={cn(
219+
"flex items-start gap-2 px-3 py-2.5 rounded-lg border border-dashed text-sm w-full",
220+
styles.container,
221+
)}
222+
>
223+
{icon && <div className={cn("mt-0.5 shrink-0", styles.icon)}>{icon}</div>}
224+
<div className="flex-1 min-w-0">
225+
{title && (
226+
<div className={cn("text-xs mb-1", styles.title)}>{title}</div>
227+
)}
228+
{description && (
229+
<div
230+
className={cn(
231+
"text-xs line-clamp-2",
232+
styles.description,
233+
children ? "mb-2" : "",
234+
)}
235+
>
236+
{description}
237+
</div>
238+
)}
239+
{children && <div className="flex gap-2">{children}</div>}
240+
</div>
241+
{onDismiss && (
242+
<button
243+
type="button"
244+
onClick={onDismiss}
245+
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
246+
title="Dismiss"
247+
>
248+
<X size={14} />
249+
</button>
250+
)}
251+
</div>
252+
);
253+
}
254+
172255
/**
173256
* Branch preview banner - shows when editing a message from a branch.
174257
*/
@@ -186,46 +269,109 @@ function ChatBranchPreview({
186269
if (!branchContext) return null;
187270

188271
return (
189-
<button
190-
type="button"
191-
onClick={onGoToOriginalMessage}
192-
className="flex items-start gap-2 px-2 py-2 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50 text-sm hover:bg-muted transition-colors cursor-pointer text-left w-full"
193-
title="Click to view original message"
272+
<ChatHighlight
273+
variant="default"
274+
title="Editing message (click to view original)"
275+
description={branchContext.originalMessageText}
276+
icon={<CornerUpLeft size={14} />}
277+
onDismiss={() => {
278+
clearBranchContext();
279+
setInputValue("");
280+
}}
194281
>
195-
<CornerUpLeft
196-
size={14}
197-
className="text-muted-foreground mt-0.5 shrink-0"
198-
/>
199-
<div className="flex-1 min-w-0">
200-
<div className="text-xs text-muted-foreground mb-1">
201-
Editing message (click to view original):
202-
</div>
203-
<div className="text-muted-foreground/70 line-clamp-2">
204-
{branchContext.originalMessageText}
205-
</div>
206-
</div>
207-
<span
208-
role="button"
209-
tabIndex={0}
210-
onClick={(e) => {
211-
e.stopPropagation();
212-
clearBranchContext();
213-
setInputValue("");
214-
}}
215-
onKeyDown={(e) => {
216-
if (e.key === "Enter" || e.key === " ") {
217-
e.preventDefault();
218-
e.stopPropagation();
219-
clearBranchContext();
220-
setInputValue("");
221-
}
222-
}}
223-
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
224-
title="Cancel editing"
282+
<Button
283+
size="sm"
284+
variant="outline"
285+
onClick={onGoToOriginalMessage}
286+
className="h-7 text-xs"
287+
>
288+
View original
289+
</Button>
290+
</ChatHighlight>
291+
);
292+
}
293+
294+
/**
295+
* Error banner - shows when a chat error occurs.
296+
*/
297+
function ChatErrorBanner({
298+
error,
299+
onFixInChat,
300+
onDismiss,
301+
}: {
302+
error: Error | undefined;
303+
onFixInChat: () => void;
304+
onDismiss: () => void;
305+
}) {
306+
if (!error) return null;
307+
308+
return (
309+
<ChatHighlight
310+
variant="danger"
311+
title="Error occurred"
312+
description={error.message}
313+
icon={<AlertCircle size={16} />}
314+
onDismiss={onDismiss}
315+
>
316+
<Button
317+
size="sm"
318+
variant="outline"
319+
onClick={onFixInChat}
320+
className="h-7 text-xs"
321+
>
322+
Fix in chat
323+
</Button>
324+
<Button size="sm" variant="outline" disabled className="h-7 text-xs">
325+
Report
326+
</Button>
327+
</ChatHighlight>
328+
);
329+
}
330+
331+
/**
332+
* Finish reason warning - shows when completion stops for non-"stop" reasons.
333+
*/
334+
function ChatFinishReasonWarning({
335+
finishReason,
336+
onContinue,
337+
onDismiss,
338+
}: {
339+
finishReason: string | null;
340+
onContinue: () => void;
341+
onDismiss: () => void;
342+
}) {
343+
if (!finishReason || finishReason === "stop") return null;
344+
345+
const getMessage = (reason: string): string => {
346+
switch (reason) {
347+
case "length":
348+
return "Response reached the model's output limit. Different models have different limits. Try switching models or asking it to continue.";
349+
case "content-filter":
350+
return "Response was filtered due to content policy.";
351+
case "tool-calls":
352+
return "Response paused after tool execution to prevent infinite loops and save costs. Click continue to keep working.";
353+
default:
354+
return `Response stopped unexpectedly: ${reason}`;
355+
}
356+
};
357+
358+
return (
359+
<ChatHighlight
360+
variant="warning"
361+
title="Response incomplete"
362+
description={getMessage(finishReason)}
363+
icon={<AlertTriangle size={16} />}
364+
onDismiss={onDismiss}
365+
>
366+
<Button
367+
size="sm"
368+
variant="outline"
369+
onClick={onContinue}
370+
className="h-7 text-xs"
225371
>
226-
<X size={14} />
227-
</span>
228-
</button>
372+
Continue
373+
</Button>
374+
</ChatHighlight>
229375
);
230376
}
231377

@@ -240,5 +386,7 @@ export const Chat = Object.assign(ChatRoot, {
240386
Footer: ChatFooter,
241387
Input: ChatInput,
242388
BranchPreview: ChatBranchPreview,
389+
ErrorBanner: ChatErrorBanner,
390+
FinishReasonWarning: ChatFinishReasonWarning,
243391
Provider: ChatProvider,
244392
});

apps/mesh/src/web/components/chat/model-selector.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export interface ModelInfo {
3838
outputCost?: number | null;
3939
outputLimit?: number | null;
4040
provider?: string | null;
41+
limits?: {
42+
contextWindow: number;
43+
maxOutputTokens: number;
44+
} | null;
4145
}
4246

4347
/**

0 commit comments

Comments
 (0)