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:
co-authored by
Claude Sonnet 4.6
parent
b621fbae92
commit
645f1ecaae
@@ -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)} {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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export const GenerativeUIAnnotation = Annotation.Root({
|
||||
| "openCode"
|
||||
| "orderPizza"
|
||||
| "writerAgent"
|
||||
| "enterprise"
|
||||
| "generalInput"
|
||||
>(),
|
||||
});
|
||||
|
||||
+157
-1
@@ -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 />);
|
||||
|
||||
Reference in New Issue
Block a user