feat: Gen-UI卡片error态 + ToolCallStatus失败状态 + 骨架屏loading
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 16s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 40s

- 4个Gen-UI卡片增加errorMessage prop,错误时显示红色提示块,空结果时边框变红
- ToolCallStatus新增failedToolIds prop,失败时显示XCircle红色图标+失败文案
- main.tsx从tool messages status===error派生failedToolIds并传入ToolCallStatus
- ToolCallRow展开无uiItem时显示animate-pulse骨架屏占位
- LoadExternalComponent外包animate-in fade-in过渡动画

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-11 21:59:12 +08:00
co-authored by Claude Sonnet 4.6
parent 2430fd2174
commit 397c2c1124
6 changed files with 83 additions and 16 deletions
@@ -1,4 +1,4 @@
import { BookOpen } from "lucide-react";
import { BookOpen, AlertCircle } from "lucide-react";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
@@ -8,6 +8,7 @@ interface KnowledgeResultProps {
results: Array<{ title: string; category: string; snippet: string }>;
sourceType?: string;
confidence?: "high" | "medium" | "low";
errorMessage?: string;
}
export default function KnowledgeResult({
@@ -16,9 +17,10 @@ export default function KnowledgeResult({
results,
sourceType,
confidence,
errorMessage,
}: KnowledgeResultProps) {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className={`w-full max-w-2xl rounded-xl border ${errorMessage && results.length === 0 ? "border-red-200 dark:border-red-900/40" : "border-border"} bg-card text-card-foreground shadow-sm overflow-hidden`}>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<BookOpen className="w-4 h-4 text-muted-foreground shrink-0" />
@@ -36,6 +38,14 @@ export default function KnowledgeResult({
</p>
</div>
{/* Error */}
{errorMessage && (
<div className="flex items-start gap-2 px-4 py-3 bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-900/40">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
)}
{/* Results */}
<ul className="divide-y divide-border">
{results.length === 0 ? (
@@ -1,4 +1,4 @@
import { Terminal, CheckCircle, XCircle } from "lucide-react";
import { Terminal, CheckCircle, XCircle, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
@@ -11,6 +11,7 @@ interface SandboxResultProps {
duration_ms?: number;
sourceType?: string;
confidence?: "high" | "medium" | "low";
errorMessage?: string;
}
const LANGUAGE_LABEL: Record<string, string> = {
@@ -27,11 +28,12 @@ export default function SandboxResult({
duration_ms,
sourceType,
confidence,
errorMessage,
}: SandboxResultProps) {
const success = exit_code === 0;
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className={`w-full max-w-2xl rounded-xl border ${errorMessage && !stdout ? "border-red-200 dark:border-red-900/40" : "border-border"} bg-card text-card-foreground shadow-sm overflow-hidden`}>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
@@ -68,6 +70,14 @@ export default function SandboxResult({
)}
</div>
{/* Error */}
{errorMessage && (
<div className="flex items-start gap-2 px-4 py-3 bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-900/40">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
)}
{/* Output */}
<div className="relative">
<pre
@@ -1,4 +1,4 @@
import { Globe } from "lucide-react";
import { Globe, AlertCircle } from "lucide-react";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
@@ -8,6 +8,7 @@ interface SearchResultProps {
results: Array<{ title: string; url: string; snippet: string }>;
sourceType?: string;
confidence?: "high" | "medium" | "low";
errorMessage?: string;
}
function getDomain(url: string): string {
@@ -24,9 +25,10 @@ export default function SearchResult({
results,
sourceType,
confidence,
errorMessage,
}: SearchResultProps) {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className={`w-full max-w-2xl rounded-xl border ${errorMessage && results.length === 0 ? "border-red-200 dark:border-red-900/40" : "border-border"} bg-card text-card-foreground shadow-sm overflow-hidden`}>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<Globe className="w-4 h-4 text-muted-foreground shrink-0" />
@@ -44,6 +46,14 @@ export default function SearchResult({
</p>
</div>
{/* Error */}
{errorMessage && (
<div className="flex items-start gap-2 px-4 py-3 bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-900/40">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
)}
{/* Results */}
<ul className="divide-y divide-border">
{results.length === 0 ? (
@@ -1,4 +1,4 @@
import { TicketIcon } from "lucide-react";
import { TicketIcon, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
@@ -16,6 +16,7 @@ interface TicketSummaryProps {
stats: Record<string, number>;
sourceType?: string;
confidence?: "high" | "medium" | "low";
errorMessage?: string;
}
function priorityClass(priority: string): string {
@@ -54,9 +55,10 @@ export default function TicketSummary({
stats,
sourceType,
confidence,
errorMessage,
}: TicketSummaryProps) {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className={`w-full max-w-2xl rounded-xl border ${errorMessage && tickets.length === 0 ? "border-red-200 dark:border-red-900/40" : "border-border"} bg-card text-card-foreground shadow-sm overflow-hidden`}>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<TicketIcon className="w-4 h-4 text-muted-foreground shrink-0" />
@@ -84,6 +86,14 @@ export default function TicketSummary({
</div>
)}
{/* Error */}
{errorMessage && (
<div className="flex items-start gap-2 px-4 py-3 bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-900/40">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
)}
{/* Ticket list */}
<ul className="divide-y divide-border">
{tickets.length === 0 ? (
+26 -8
View File
@@ -1,4 +1,4 @@
import { Loader2, CheckCircle2, ChevronRight, ChevronDown } from "lucide-react";
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle } from "lucide-react";
import { useState } from "react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
@@ -52,6 +52,7 @@ interface ToolCallStatusProps {
toolCalls: ToolCall[];
isLoading: boolean;
completedToolIds?: Set<string>;
failedToolIds?: Set<string>;
uiItems?: UIMsgLocal[];
stream?: Parameters<typeof LoadExternalComponent>[0]["stream"];
components?: Parameters<typeof LoadExternalComponent>[0]["components"];
@@ -60,12 +61,14 @@ interface ToolCallStatusProps {
function ToolCallRow({
tc,
isDone,
isFailed,
uiItem,
stream,
components,
}: {
tc: ToolCall;
isDone: boolean;
isFailed: boolean;
uiItem?: UIMsgLocal;
stream?: ToolCallStatusProps["stream"];
components?: ToolCallStatusProps["components"];
@@ -91,21 +94,33 @@ function ToolCallRow({
) : (
<span className="size-3.5 shrink-0" />
)}
{isDone ? (
{isFailed ? (
<XCircle className="size-3.5 text-red-500 shrink-0" />
) : isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : (
<Loader2 className="size-3.5 animate-spin shrink-0" />
)}
<span>{label}</span>
<span>{isDone ? (canExpand ? (expanded ? "收起" : "查看结果") : `已完成 · ${label}`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}</span>
<span>{isFailed ? `失败 · ${label}` : isDone ? (canExpand ? (expanded ? "收起" : "查看结果") : `已完成 · ${label}`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}</span>
</button>
{expanded && uiItem && stream && components && (
<div className="mt-2 ml-7">
<LoadExternalComponent
stream={stream}
message={uiItem as any}
components={components as any}
/>
<div className="animate-in fade-in duration-300">
<LoadExternalComponent
stream={stream}
message={uiItem as any}
components={components as any}
/>
</div>
</div>
)}
{expanded && !uiItem && (
<div className="mt-2 ml-7 space-y-2 animate-pulse">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
<div className="h-3 bg-muted rounded w-5/6" />
<div className="h-8 bg-muted rounded w-full mt-3" />
</div>
)}
</div>
@@ -116,6 +131,7 @@ export default function ToolCallStatus({
toolCalls,
isLoading,
completedToolIds,
failedToolIds,
uiItems = [],
stream,
components,
@@ -139,6 +155,7 @@ export default function ToolCallStatus({
<div className="flex flex-col gap-1.5 mb-1">
{toolCalls.map((tc, i) => {
const isDone = !isLoading || (tc.id && completedToolIds?.has(tc.id));
const isFailed = !!(tc.id && failedToolIds?.has(tc.id));
let uiItem: UIMsgLocal | undefined;
if (tc.name && UI_NAME_MAP[tc.name]) {
const uiName = UI_NAME_MAP[tc.name];
@@ -153,6 +170,7 @@ export default function ToolCallStatus({
key={tc.id ?? i}
tc={tc}
isDone={!!isDone}
isFailed={isFailed}
uiItem={uiItem}
stream={stream}
components={components}
+9
View File
@@ -318,6 +318,14 @@ function App() {
.filter(Boolean),
);
// ── Collect failed tool call IDs ─────────────────────────────────────────
const failedToolIds = new Set(
thread.messages
.filter((m) => m.type === "tool" && (m as any).status === "error")
.map((m) => (m as any).tool_call_id as string)
.filter(Boolean),
);
// ── Find last AI message index ──────────────────────────────────────────
const lastAiIdx = thread.messages.reduce((last, m, i) => (m.type === "ai" ? i : last), -1);
@@ -490,6 +498,7 @@ function App() {
toolCalls={toolCalls}
isLoading={thread.isLoading}
completedToolIds={completedToolIds}
failedToolIds={failedToolIds}
uiItems={uiItems}
stream={thread}
components={ComponentMap as any}