feat(langgraph): add enterprise UI card components and implement main.tsx Chat entry

- Create 5 enterprise UI card components under src/agent-uis/enterprise/:
  knowledge-result, ticket-summary, ticket-detail, search-result, sandbox-result
- Update ComponentMap in src/agent-uis/index.tsx to replace () => null placeholders
  with real component imports
- Implement src/main.tsx with useStream + LoadExternalComponent for full Chat UI,
  connecting to LangGraph agent with VITE_LANGGRAPH_URL env var

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-10 04:23:32 +08:00
co-authored by Claude Sonnet 4.6
parent b621fbae92
commit 645f1ecaae
165 changed files with 652 additions and 20909 deletions
@@ -0,0 +1,58 @@
import { BookOpen } from "lucide-react";
interface KnowledgeResultProps {
query: string;
total: number;
results: Array<{ title: string; category: string; snippet: string }>;
}
export default function KnowledgeResult({
query,
total,
results,
}: KnowledgeResultProps) {
return (
<div className="w-full max-w-2xl rounded-xl border 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" />
<span className="font-medium text-sm text-foreground">知识库检索</span>
<span className="ml-auto inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{total} 条结果
</span>
</div>
{/* Query */}
<div className="px-4 py-2 bg-muted/40 border-b border-border">
<p className="text-xs text-muted-foreground">
关键词:<span className="text-foreground font-medium">{query}</span>
</p>
</div>
{/* Results */}
<ul className="divide-y divide-border">
{results.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
未找到相关结果
</li>
) : (
results.map((item, idx) => (
<li key={idx} className="px-4 py-3 flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground leading-snug">
{item.title}
</span>
<span className="ml-auto shrink-0 inline-flex items-center rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground">
{item.category}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{item.snippet}
</p>
</li>
))
)}
</ul>
</div>
);
}
@@ -0,0 +1,87 @@
import { Terminal, CheckCircle, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
interface SandboxResultProps {
language: string;
exit_code: number;
stdout: string;
has_more: boolean;
duration_ms?: number;
}
const LANGUAGE_LABEL: Record<string, string> = {
python: "Python",
javascript: "JavaScript",
bash: "Bash",
};
export default function SandboxResult({
language,
exit_code,
stdout,
has_more,
duration_ms,
}: 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">
{/* 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" />
<span className="font-medium text-sm text-foreground">代码执行</span>
{/* Language badge */}
<span className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-mono">
{LANGUAGE_LABEL[language] ?? language}
</span>
{/* Status badge */}
<span
className={cn(
"ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
success
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{success ? (
<CheckCircle className="w-3 h-3" />
) : (
<XCircle className="w-3 h-3" />
)}
{success ? "成功" : `失败 (exit ${exit_code})`}
</span>
{/* Duration */}
{duration_ms !== undefined && (
<span className="text-xs text-muted-foreground shrink-0">
{duration_ms}ms
</span>
)}
</div>
{/* Output */}
<div className="relative">
<pre
className={cn(
"px-4 py-3 text-xs font-mono leading-relaxed overflow-y-auto",
"max-h-[200px]",
"bg-muted/50 text-foreground",
)}
>
{stdout || "(无输出)"}
</pre>
{has_more && (
<div className="absolute bottom-0 inset-x-0 h-8 bg-gradient-to-t from-muted/80 to-transparent pointer-events-none" />
)}
</div>
{has_more && (
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
输出已截断,显示前 2000 字符
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
import { Globe } from "lucide-react";
interface SearchResultProps {
query: string;
total: number;
results: Array<{ title: string; url: string; snippet: string }>;
}
function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
export default function SearchResult({
query,
total,
results,
}: SearchResultProps) {
return (
<div className="w-full max-w-2xl rounded-xl border 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" />
<span className="font-medium text-sm text-foreground">网络搜索</span>
<span className="ml-auto inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{total} 条结果
</span>
</div>
{/* Query */}
<div className="px-4 py-2 bg-muted/40 border-b border-border">
<p className="text-xs text-muted-foreground">
关键词:<span className="text-foreground font-medium">{query}</span>
</p>
</div>
{/* Results */}
<ul className="divide-y divide-border">
{results.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
未找到相关结果
</li>
) : (
results.map((item, idx) => (
<li key={idx} className="px-4 py-3 flex flex-col gap-1">
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold text-primary hover:underline leading-snug"
>
{item.title}
</a>
) : (
<span className="text-sm font-semibold text-foreground leading-snug">
{item.title}
</span>
)}
{item.url && (
<span className="text-xs text-muted-foreground font-mono">
{getDomain(item.url)}
</span>
)}
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{item.snippet}
</p>
</li>
))
)}
</ul>
</div>
);
}
@@ -0,0 +1,120 @@
import { cn } from "@/lib/utils";
import { FileText } from "lucide-react";
interface TicketDetailProps {
id: string;
title: string;
status: string;
priority: string;
customer: string;
engineer: string;
created: string;
description: string;
}
function priorityClass(priority: string): string {
const p = priority.toUpperCase();
if (p === "P0") return "bg-red-500 text-white";
if (p === "P1") return "bg-orange-400 text-white";
if (p === "P2") return "bg-yellow-400 text-black";
return "bg-muted text-muted-foreground";
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
open: "待处理",
pending: "待处理",
in_progress: "处理中",
processing: "处理中",
closed: "已解决",
resolved: "已解决",
done: "已解决",
};
return map[status.toLowerCase()] ?? status;
}
function statusBadgeClass(status: string): string {
const s = status.toLowerCase();
if (s === "closed" || s === "resolved" || s === "done")
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
if (s === "in_progress" || s === "processing")
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
return "bg-muted text-muted-foreground";
}
export default function TicketDetail({
id,
title,
status,
priority,
customer,
engineer,
created,
description,
}: TicketDetailProps) {
return (
<div className="w-full max-w-2xl rounded-xl border 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">
<FileText className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">工单详情</span>
<span className="ml-auto font-mono text-xs text-muted-foreground">
#{id}
</span>
</div>
{/* Title row */}
<div className="px-4 pt-3 pb-2 flex items-start gap-2">
<span
className={cn(
"shrink-0 inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-bold mt-0.5",
priorityClass(priority),
)}
>
{priority.toUpperCase()}
</span>
<h3 className="text-sm font-semibold text-foreground leading-snug">
{title}
</h3>
<span
className={cn(
"ml-auto shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs",
statusBadgeClass(status),
)}
>
{statusLabel(status)}
</span>
</div>
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 px-4 py-3 border-t border-border text-sm">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">客户</span>
<span className="text-foreground truncate">{customer || "—"}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">工程师</span>
<span className="text-foreground truncate">{engineer || "未分配"}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">创建时间</span>
<span className="text-foreground">{created || "—"}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">状态</span>
<span className="text-foreground">{statusLabel(status)}</span>
</div>
</div>
{/* Description */}
{description && (
<div className="px-4 py-3 border-t border-border">
<p className="text-xs text-muted-foreground mb-1">描述</p>
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{description}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,132 @@
import { TicketIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface TicketSummaryProps {
total: number;
tickets: Array<{
id: string;
title: string;
status: string;
priority: string;
customer: string;
created: string;
}>;
stats: Record<string, number>;
}
function priorityClass(priority: string): string {
const p = priority.toUpperCase();
if (p === "P0") return "bg-red-500 text-white";
if (p === "P1") return "bg-orange-400 text-white";
if (p === "P2") return "bg-yellow-400 text-black";
return "bg-muted text-muted-foreground";
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
open: "待处理",
pending: "待处理",
in_progress: "处理中",
processing: "处理中",
closed: "已解决",
resolved: "已解决",
done: "已解决",
};
return map[status.toLowerCase()] ?? status;
}
function statusBadgeClass(status: string): string {
const s = status.toLowerCase();
if (s === "closed" || s === "resolved" || s === "done")
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
if (s === "in_progress" || s === "processing")
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
return "bg-muted text-muted-foreground";
}
export default function TicketSummary({
total,
tickets,
stats,
}: TicketSummaryProps) {
return (
<div className="w-full max-w-2xl rounded-xl border 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" />
<span className="font-medium text-sm text-foreground">工单列表</span>
<span className="ml-auto inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
共 {total} 条
</span>
</div>
{/* Stats */}
{Object.keys(stats).length > 0 && (
<div className="flex flex-wrap gap-2 px-4 py-2 border-b border-border bg-muted/30">
{Object.entries(stats).map(([status, count]) => (
<span
key={status}
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
statusBadgeClass(status),
)}
>
{statusLabel(status)}&nbsp;{count}
</span>
))}
</div>
)}
{/* Ticket list */}
<ul className="divide-y divide-border">
{tickets.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
暂无工单
</li>
) : (
tickets.map((ticket) => (
<li
key={ticket.id}
className="px-4 py-3 flex items-center gap-3"
>
{/* Priority dot */}
<span
className={cn(
"shrink-0 inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-bold",
priorityClass(ticket.priority),
)}
>
{ticket.priority.toUpperCase()}
</span>
{/* ID + title */}
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground font-mono">
#{ticket.id}
</p>
<p className="text-sm text-foreground truncate leading-snug">
{ticket.title}
</p>
</div>
{/* Status + date */}
<div className="shrink-0 flex flex-col items-end gap-1">
<span
className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs",
statusBadgeClass(ticket.status),
)}
>
{statusLabel(ticket.status)}
</span>
<span className="text-xs text-muted-foreground">
{ticket.created}
</span>
</div>
</li>
))
)}
</ul>
</div>
);
}
+10
View File
@@ -6,6 +6,11 @@ import BuyStock from "./stockbroker/buy-stock";
import Plan from "./open-code/plan";
import ProposedChange from "./open-code/proposed-change";
import { Writer } from "./writer";
import KnowledgeResult from "./enterprise/knowledge-result";
import TicketSummary from "./enterprise/ticket-summary";
import TicketDetail from "./enterprise/ticket-detail";
import SearchResult from "./enterprise/search-result";
import SandboxResult from "./enterprise/sandbox-result";
const ComponentMap = {
"stock-price": StockPrice,
@@ -16,5 +21,10 @@ const ComponentMap = {
"code-plan": Plan,
"proposed-change": ProposedChange,
writer: Writer,
"knowledge-result": KnowledgeResult,
"ticket-summary": TicketSummary,
"ticket-detail": TicketDetail,
"search-result": SearchResult,
"sandbox-result": SandboxResult,
} as const;
export default ComponentMap;
+9 -3
View File
@@ -11,12 +11,14 @@ import {
import { generalInput } from "./nodes/general-input";
import { router } from "./nodes/router";
import { graph as writerAgentGraph } from "../writer-agent";
import { enterpriseGraph } from "../enterprise";
export const ALL_TOOL_DESCRIPTIONS = `- stockbroker: can fetch the price of a ticker, purchase/sell a ticker, or get the user's portfolio
- tripPlanner: helps the user plan their trip. it can suggest restaurants, and places to stay in any given location.
- openCode: can write a React TODO app for the user. Only call this tool if they request a TODO app.
- orderPizza: can order a pizza for the user
- writerAgent: can write a text document for the user. Only call this tool if they request a text document.`;
- writerAgent: can write a text document for the user. Only call this tool if they request a text document.
- enterprise: 企业内部助手:知识库查询、工单管理、网络搜索、代码执行`;
function handleRoute(
state: SupervisorState,
@@ -26,7 +28,8 @@ function handleRoute(
| "openCode"
| "orderPizza"
| "generalInput"
| "writerAgent" {
| "writerAgent"
| "enterprise" {
return state.next;
}
@@ -38,6 +41,7 @@ const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
.addNode("orderPizza", orderPizzaGraph)
.addNode("generalInput", generalInput)
.addNode("writerAgent", writerAgentGraph)
.addNode("enterprise", enterpriseGraph)
.addConditionalEdges("router", handleRoute, [
"stockbroker",
"tripPlanner",
@@ -45,6 +49,7 @@ const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
"orderPizza",
"generalInput",
"writerAgent",
"enterprise",
])
.addEdge(START, "router")
.addEdge("stockbroker", END)
@@ -52,7 +57,8 @@ const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
.addEdge("openCode", END)
.addEdge("orderPizza", END)
.addEdge("generalInput", END)
.addEdge("writerAgent", END);
.addEdge("writerAgent", END)
.addEdge("enterprise", END);
export const graph = builder.compile();
graph.name = "Generative UI Agent";
@@ -20,6 +20,7 @@ ${ALL_TOOL_DESCRIPTIONS}
"orderPizza",
"generalInput",
"writerAgent",
"enterprise",
])
.describe(routerDescription),
});
+1
View File
@@ -19,6 +19,7 @@ export const GenerativeUIAnnotation = Annotation.Root({
| "openCode"
| "orderPizza"
| "writerAgent"
| "enterprise"
| "generalInput"
>(),
});
+157 -1
View File
@@ -1,4 +1,160 @@
import { createRoot } from "react-dom/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import type { Message, UIMessage } from "@langchain/langgraph-sdk";
import { useState, useRef, useEffect } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<div>Hello world</div>);
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
function App() {
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const thread = useStream<{ messages: Message[]; ui: UIMessage[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
messagesKey: "messages",
});
// Auto-scroll to bottom when messages update
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [thread.messages]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || thread.isLoading) return;
setInput("");
thread.submit({ messages: [{ role: "human", content: text }] });
}
return (
<div className="h-screen flex flex-col bg-background text-foreground">
{/* Header */}
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
<span className="font-semibold text-foreground">运营大脑</span>
{thread.isLoading && (
<span className="text-xs text-muted-foreground animate-pulse">
思考中…
</span>
)}
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
{thread.messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground select-none">
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
</div>
)}
{thread.messages.map((message, idx) => {
// Render UI cards attached to this message
const uiItems = (thread.values?.ui ?? []).filter(
(ui: UIMessage) =>
"metadata" in ui &&
(ui as UIMessage & { metadata?: { message_id?: string } })
.metadata?.message_id === message.id,
);
if (message.type === "human") {
return (
<div key={message.id ?? idx} className="flex justify-end">
<div className="max-w-[75%] rounded-2xl rounded-br-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</div>
</div>
);
}
if (message.type === "ai") {
const textContent =
typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content
.filter((c) => c.type === "text")
.map((c) => ("text" in c ? c.text : ""))
.join("")
: "";
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Text reply */}
{textContent && (
<div className="max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
{textContent}
</div>
)}
{/* UI cards */}
{uiItems.map((ui: UIMessage) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
message={ui}
components={ComponentMap}
/>
))}
</div>
);
}
return null;
})}
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
(thread.values?.ui ?? [])
.filter((ui: UIMessage) => {
const meta = (
ui as UIMessage & { metadata?: { message_id?: string } }
).metadata;
const attachedToExisting = thread.messages.some(
(m) => m.id === meta?.message_id,
);
return !attachedToExisting;
})
.map((ui: UIMessage) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
message={ui}
components={ComponentMap}
/>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="shrink-0 border-t border-border px-4 py-4">
<form onSubmit={handleSubmit} className="flex gap-2 max-w-3xl mx-auto">
<input
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50"
placeholder="输入消息…"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={thread.isLoading}
autoFocus
/>
<button
type="submit"
disabled={thread.isLoading || !input.trim()}
className="rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
发送
</button>
</form>
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);