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 { 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";
|
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> = {
|
const TOOL_NAME_MAP: Record<string, string> = {
|
||||||
kb_search: "知识库检索",
|
kb_search: "知识库检索",
|
||||||
ticket_list: "工单查询",
|
ticket_list: "工单查询",
|
||||||
@@ -213,11 +236,13 @@ function ToolCallRow({
|
|||||||
{expanded && !isFailed && uiItem && stream && components && (
|
{expanded && !isFailed && uiItem && stream && components && (
|
||||||
<div className="mt-2 ml-7">
|
<div className="mt-2 ml-7">
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<LoadExternalComponent
|
<CardErrorBoundary>
|
||||||
stream={stream}
|
<LoadExternalComponent
|
||||||
message={uiItem as any}
|
stream={stream}
|
||||||
components={components as any}
|
message={uiItem as any}
|
||||||
/>
|
components={components as any}
|
||||||
|
/>
|
||||||
|
</CardErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+42
-15
@@ -6,7 +6,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
|
|
||||||
// UIMessage is not exported directly — use a local shape
|
// 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 } };
|
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 ComponentMap from "./agent-uis/index.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles, Loader2, Clock } from "lucide-react";
|
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 =
|
const LANGGRAPH_URL =
|
||||||
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
|
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 ──────────────────────────────────────────
|
// ─── Card deduplication by card_id ──────────────────────────────────────────
|
||||||
// When the backend pushes loading then complete cards with the same 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.
|
// keep only the last (most recent) card per card_id. Cards without card_id pass through.
|
||||||
@@ -825,13 +848,15 @@ function App() {
|
|||||||
if (standaloneUiItems.length === 0) return null;
|
if (standaloneUiItems.length === 0) return null;
|
||||||
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
|
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
|
||||||
<div key={ui.id} className="card-enter">
|
<div key={ui.id} className="card-enter">
|
||||||
<LoadExternalComponent
|
<CardErrorBoundary>
|
||||||
stream={thread}
|
<LoadExternalComponent
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
stream={thread}
|
||||||
message={ui as any}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
message={ui as any}
|
||||||
components={ComponentMap as any}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
/>
|
components={ComponentMap as any}
|
||||||
|
/>
|
||||||
|
</CardErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
if (standaloneUiItems.length >= 2) {
|
if (standaloneUiItems.length >= 2) {
|
||||||
@@ -895,13 +920,15 @@ function App() {
|
|||||||
)
|
)
|
||||||
.map((ui: UIMsgLocal) => (
|
.map((ui: UIMsgLocal) => (
|
||||||
<div key={ui.id} className="card-enter">
|
<div key={ui.id} className="card-enter">
|
||||||
<LoadExternalComponent
|
<CardErrorBoundary>
|
||||||
stream={thread}
|
<LoadExternalComponent
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
stream={thread}
|
||||||
message={ui as any}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
message={ui as any}
|
||||||
components={ComponentMap as any}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
/>
|
components={ComponentMap as any}
|
||||||
|
/>
|
||||||
|
</CardErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user