fix: add ErrorBoundary around Gen-UI cards to prevent black screen
Wraps all LoadExternalComponent usages with CardErrorBoundary so card rendering errors show an inline error message instead of crashing the entire app. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4267d0a62a
commit
d8b989f681
@@ -1,7 +1,30 @@
|
||||
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown, AlertCircle, CornerDownRight } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, Component, type ReactNode } from "react";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
|
||||
// ErrorBoundary to prevent Gen-UI card crashes from taking down the whole app
|
||||
class CardErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallbackLabel?: string },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
state = { hasError: false, error: undefined as Error | undefined };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2">
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
卡片渲染失败:{this.state.error?.message ?? "未知错误"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_NAME_MAP: Record<string, string> = {
|
||||
kb_search: "知识库检索",
|
||||
ticket_list: "工单查询",
|
||||
@@ -213,11 +236,13 @@ function ToolCallRow({
|
||||
{expanded && !isFailed && uiItem && stream && components && (
|
||||
<div className="mt-2 ml-7">
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<CardErrorBoundary>
|
||||
<LoadExternalComponent
|
||||
stream={stream}
|
||||
message={uiItem as any}
|
||||
components={components as any}
|
||||
/>
|
||||
</CardErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+28
-1
@@ -6,7 +6,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
// UIMessage is not exported directly — use a local shape
|
||||
type UIMsgLocal = { id: string; type: string; name: string; props: Record<string, unknown>; metadata?: { message_id?: string } };
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useState, useRef, useEffect, useCallback, Component, type ReactNode } from "react";
|
||||
import ComponentMap from "./agent-uis/index.tsx";
|
||||
import "./index.css";
|
||||
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles, Loader2, Clock } from "lucide-react";
|
||||
@@ -24,6 +24,29 @@ import { ExecutionLogPanel } from "@/components/ExecutionLogPanel.tsx";
|
||||
const LANGGRAPH_URL =
|
||||
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
|
||||
|
||||
// ─── ErrorBoundary for Gen-UI cards ─────────────────────────────────────────
|
||||
class CardErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
state = { hasError: false, error: undefined as Error | undefined };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2 my-1">
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
卡片渲染失败:{this.state.error?.message ?? "未知错误"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card deduplication by card_id ──────────────────────────────────────────
|
||||
// When the backend pushes loading then complete cards with the same card_id,
|
||||
// keep only the last (most recent) card per card_id. Cards without card_id pass through.
|
||||
@@ -825,6 +848,7 @@ function App() {
|
||||
if (standaloneUiItems.length === 0) return null;
|
||||
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
|
||||
<div key={ui.id} className="card-enter">
|
||||
<CardErrorBoundary>
|
||||
<LoadExternalComponent
|
||||
stream={thread}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -832,6 +856,7 @@ function App() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
components={ComponentMap as any}
|
||||
/>
|
||||
</CardErrorBoundary>
|
||||
</div>
|
||||
));
|
||||
if (standaloneUiItems.length >= 2) {
|
||||
@@ -895,6 +920,7 @@ function App() {
|
||||
)
|
||||
.map((ui: UIMsgLocal) => (
|
||||
<div key={ui.id} className="card-enter">
|
||||
<CardErrorBoundary>
|
||||
<LoadExternalComponent
|
||||
stream={thread}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -902,6 +928,7 @@ function App() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
components={ComponentMap as any}
|
||||
/>
|
||||
</CardErrorBoundary>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user