feat: Gen-UI卡片error态 + ToolCallStatus失败状态 + 骨架屏loading
- 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:
co-authored by
Claude Sonnet 4.6
parent
2430fd2174
commit
397c2c1124
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user