Files
socaichat/langgraph/src/main.tsx
T
gongzhiyongandClaude Opus 4.6 dd70cadcc1 feat: P0 前端交互完善 + 测试报告更新
- ToolCallStatus 组件增强
- main.tsx P0 交互改进(textarea/光标/编辑消息)
- 更新测试报告:9/12 通过,3 个外部服务问题

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:42:56 +08:00

687 lines
28 KiB
TypeScript

import { createRoot } from "react-dom/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { Client } from "@langchain/langgraph-sdk";
import type { Message } from "@langchain/langgraph-sdk";
// 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 } };
import { useState, useRef, useEffect, useCallback } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
import ThemeToggle from "@/components/ThemeToggle.tsx";
import MessageBubble from "@/components/MessageBubble.tsx";
import CanvasPanel, { type CanvasDoc } from "@/components/CanvasPanel.tsx";
import FileUploadButton, { type SelectedFile } from "@/components/FileUploadButton.tsx";
import FileAttachmentPreview from "@/components/FileAttachmentPreview.tsx";
import ToolCallStatus from "@/components/ToolCallStatus.tsx";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
// ─── Tool groups ────────────────────────────────────────────────────────────
const TOOL_GROUPS = [
{ key: "knowledge", label: "知识库", icon: BookOpen, tools: ["kb_search"] },
{ key: "tickets", label: "工单", icon: Ticket, tools: ["ticket_list", "ticket_detail"] },
{ key: "search", label: "搜索", icon: Search, tools: ["web_search", "google_search"] },
{ key: "sandbox", label: "代码", icon: Terminal, tools: ["sandbox_run"] },
] as const;
type ToolKey = (typeof TOOL_GROUPS)[number]["key"];
type ModelMode = "flash" | "auto" | "pro";
const MODEL_OPTIONS: { value: ModelMode; label: string; icon: React.FC<{ className?: string }> }[] = [
{ value: "flash", label: "Flash", icon: Zap },
{ value: "auto", label: "Auto", icon: Bot },
{ value: "pro", label: "Pro", icon: Cpu },
];
// ─── Quick prompts ───────────────────────────────────────────────────────────
const QUICK_PROMPTS = [
{ icon: BookOpen, label: "搜索知识库", prompt: "帮我搜索知识库中关于" },
{ icon: Ticket, label: "查看最新工单", prompt: "查看最新的工单列表" },
{ icon: Search, label: "搜索网络", prompt: "帮我搜索" },
{ icon: Terminal, label: "运行代码", prompt: "用 Python 写一段代码" },
];
// ─── LangGraph Client (for thread management) ───────────────────────────────
const client = new Client({ apiUrl: LANGGRAPH_URL });
// ─── Copy button for AI messages ────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button
type="button"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded hover:bg-accent"
title="复制"
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "已复制" : "复制"}
</button>
);
}
function App() {
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
// Tool & model state
const [activeTools, setActiveTools] = useState<Set<ToolKey>>(new Set());
const [modelMode, setModelMode] = useState<ModelMode>("auto");
// Thread sidebar state
const [threads, setThreads] = useState<ThreadItem[]>([]);
const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
// Canvas panel state
const [canvasDoc, setCanvasDoc] = useState<CanvasDoc | null>(null);
// File attachment state
const [attachedFile, setAttachedFile] = useState<SelectedFile | null>(null);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
messagesKey: "messages",
threadId: currentThreadId ?? undefined,
});
// Auto-scroll to bottom only when user is near the bottom
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
if (isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [thread.messages]);
// Listen for open-canvas events from Gen-UI cards
useEffect(() => {
const handler = (e: Event) => setCanvasDoc((e as CustomEvent<CanvasDoc>).detail);
window.addEventListener("open-canvas", handler);
return () => window.removeEventListener("open-canvas", handler);
}, []);
// Load threads on mount
useEffect(() => {
client.threads
.search({ limit: 50 })
.then((list: any) => setThreads(list as ThreadItem[]))
.catch(() => {/* graceful degradation — sidebar stays empty */});
}, []);
// ── Thread actions ──────────────────────────────────────────────────────
const handleNewThread = useCallback(async () => {
try {
const t = await client.threads.create();
setThreads((prev) => [t as ThreadItem, ...prev]);
setCurrentThreadId(t.thread_id);
} catch {
// If create fails, just clear threadId so useStream creates one implicitly
setCurrentThreadId(null);
}
setSidebarOpen(false);
}, []);
const handleSelectThread = useCallback((threadId: string) => {
setCurrentThreadId(threadId);
setSidebarOpen(false);
}, []);
const handleDeleteThread = useCallback(async (threadId: string) => {
try {
await client.threads.delete(threadId);
} catch {
// ignore errors
}
setThreads((prev) => prev.filter((t) => t.thread_id !== threadId));
if (currentThreadId === threadId) {
setCurrentThreadId(null);
}
}, [currentThreadId]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, [input]);
// ── Tool toggle ─────────────────────────────────────────────────────────
function toggleTool(key: ToolKey) {
setActiveTools((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
// ── Submit ──────────────────────────────────────────────────────────────
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();
if ((!text && !attachedFile) || thread.isLoading) return;
setInput("");
const enabledTools =
activeTools.size > 0
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
: [];
const file = attachedFile;
setAttachedFile(null);
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
let messageContent: unknown;
if (file && IMAGE_TYPES.includes(file.mimeType)) {
messageContent = [
{ type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } },
{ type: "text", text: text || "请分析这张图片" },
];
} else if (file) {
messageContent = [
{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` },
];
} else {
messageContent = text;
}
// Auto-name thread on first message
const isFirstMessage = thread.messages.length === 0;
thread.submit(
{ messages: [{ type: "human", content: messageContent }] },
{
config: {
configurable: {
enabledTools,
modelMode,
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
},
},
},
);
if (isFirstMessage && currentThreadId) {
client.threads.update(currentThreadId, { metadata: { title: text.slice(0, 30) } }).catch(() => {});
// Optimistically update local thread title
setThreads((prev) =>
prev.map((t) =>
t.thread_id === currentThreadId
? { ...t, metadata: { ...t.metadata, title: text.slice(0, 30) } }
: t,
),
);
}
}
// ── Regenerate last AI message ───────────────────────────────────────────
function handleRegenerate() {
// Find last human message
const humanMsgs = thread.messages.filter((m) => m.type === "human");
const lastHuman = humanMsgs[humanMsgs.length - 1];
if (!lastHuman || thread.isLoading) return;
const enabledTools =
activeTools.size > 0
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
: [];
thread.submit(
{ messages: [{ type: "human", content: lastHuman.content }] },
{
config: {
configurable: { enabledTools, modelMode },
},
},
);
}
// ── Drag and drop ────────────────────────────────────────────────────────
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(",")[1];
setAttachedFile({ name: file.name, mimeType: file.type, base64, size: file.size });
};
reader.readAsDataURL(file);
}
// ── Paste image ──────────────────────────────────────────────────────────
function handlePaste(e: React.ClipboardEvent) {
const items = Array.from(e.clipboardData.items);
const imageItem = items.find((item) => item.type.startsWith("image/"));
if (!imageItem) return;
const file = imageItem.getAsFile();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(",")[1];
setAttachedFile({ name: file.name || "pasted-image.png", mimeType: file.type, base64, size: file.size });
};
reader.readAsDataURL(file);
}
// ── Collect completed tool call IDs ─────────────────────────────────────
const completedToolIds = new Set(
thread.messages
.filter((m) => m.type === "tool")
.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);
return (
<div className="h-screen flex flex-row bg-background text-foreground overflow-hidden">
{/* ── Mobile sidebar overlay backdrop ── */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* ── Left sidebar ── */}
<div
className={cn(
"z-30 md:relative md:flex md:w-64",
sidebarOpen
? "fixed inset-y-0 left-0 flex w-64"
: "hidden md:flex",
)}
>
<ThreadSidebar
threads={threads}
currentThreadId={currentThreadId}
onNewThread={handleNewThread}
onSelectThread={handleSelectThread}
onDeleteThread={handleDeleteThread}
/>
</div>
{/* ── Right main area ── */}
<div className="flex-1 flex flex-row min-w-0 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 relative" onDragOver={handleDragOver} onDrop={handleDrop}>
{/* Header */}
<header className="shrink-0 border-b border-border px-4 py-3 flex items-center gap-3">
{/* Hamburger for mobile */}
<button
type="button"
className="md:hidden p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onClick={() => setSidebarOpen((o) => !o)}
aria-label="打开侧栏"
>
<Menu className="size-5" />
</button>
<span className="font-semibold text-foreground">运营大脑</span>
{thread.isLoading && (
<span className="text-xs text-muted-foreground animate-pulse">
思考中…
</span>
)}
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
{/* Messages */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-4 py-6 space-y-6 relative"
onScroll={() => {
const el = scrollContainerRef.current;
if (!el) return;
setShowScrollBtn(el.scrollHeight - el.scrollTop - el.clientHeight > 200);
}}
>
{thread.messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground select-none">
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
</div>
{/* Quick prompts */}
<div className="grid grid-cols-2 gap-2 w-full max-w-sm mt-2">
{QUICK_PROMPTS.map(({ icon: Icon, label, prompt }) => (
<button
key={label}
type="button"
onClick={() => setInput(prompt)}
className="border border-border rounded-lg p-3 hover:bg-accent cursor-pointer text-left transition-colors flex items-start gap-2"
>
<Icon className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
<span className="text-xs font-medium text-foreground">{label}</span>
</button>
))}
</div>
</div>
)}
{thread.messages.map((message, idx) => {
// Render UI cards attached to this message
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uiItems = ((thread.values as any)?.ui ?? []).filter(
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
) as UIMsgLocal[];
if (message.type === "human") {
const humanText = typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? (message.content as any[]).filter((p) => p.type === "text").map((p) => p.text).join("")
: "";
return (
<div key={message.id ?? idx} className="group flex justify-end items-end gap-2">
{/* Edit button — appears on hover, refills input */}
{humanText && !thread.isLoading && (
<button
type="button"
onClick={() => {
setInput(humanText);
setTimeout(() => textareaRef.current?.focus(), 0);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity mb-1 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
title="编辑消息"
>
<Pencil className="size-3.5" />
</button>
)}
<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">
{Array.isArray(message.content) ? (
<div className="flex flex-col gap-2">
{(message.content as any[]).map((part, pi) => {
if (part.type === "image_url") {
const url = part.image_url?.url ?? part.image_url;
return (
<img
key={pi}
src={url}
alt="附件图片"
className="max-h-48 rounded-lg object-contain"
/>
);
}
if (part.type === "text") {
return <span key={pi}>{part.text}</span>;
}
return null;
})}
</div>
) : (
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("")
: "";
const toolCalls: { name?: string; id?: string }[] = (message as any).tool_calls ?? [];
const isLastAi = idx === lastAiIdx;
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Tool call status (with inline expand/collapse for UI cards) */}
{toolCalls.length > 0 && (
<ToolCallStatus
toolCalls={toolCalls}
isLoading={thread.isLoading}
completedToolIds={completedToolIds}
uiItems={uiItems}
stream={thread}
components={ComponentMap as any}
/>
)}
{/* UI cards not matched to any tool call (standalone) */}
{uiItems
.filter((ui) => !toolCalls.some((tc) => {
const nameMap: Record<string, string> = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
ticket_detail: "ticket-detail",
web_search: "search-result",
google_search: "search-result",
sandbox_run: "sandbox-result",
};
return tc.name && ui.name === nameMap[tc.name];
}))
.map((ui: UIMsgLocal) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message={ui as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
))}
{/* Text reply */}
{textContent && (
<div className="group relative max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<MessageBubble content={textContent} role="ai" />
{thread.isLoading && isLastAi && (
<span className="typing-cursor" aria-hidden="true" />
)}
{/* Copy button */}
<div className="flex justify-end mt-1">
<CopyButton text={textContent} />
</div>
</div>
)}
{/* Regenerate button — only on last AI message, only when not loading */}
{isLastAi && !thread.isLoading && (
<div className="flex">
<button
type="button"
onClick={handleRegenerate}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
>
<RefreshCw className="size-3.5" />
重新生成
</button>
</div>
)}
</div>
);
}
return null;
})}
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
(thread.values?.ui ?? [])
.filter((ui: UIMsgLocal) => {
const attachedToExisting = thread.messages.some(
(m) => m.id === ui.metadata?.message_id,
);
return !attachedToExisting;
})
.map((ui: UIMsgLocal) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message={ui as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
))}
<div ref={bottomRef} />
</div>
{/* Scroll-to-bottom floating button */}
{showScrollBtn && (
<div className="absolute bottom-32 left-1/2 -translate-x-1/2 z-10">
<button
type="button"
onClick={() => bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
className="rounded-full bg-background border border-border shadow-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="滚动到底部"
>
<ChevronDown className="size-4" />
</button>
</div>
)}
{/* Control bar: tools (left) + model selector (right) */}
<div className="shrink-0 border-t border-border px-4 pt-3 pb-0 flex items-center justify-between max-w-3xl mx-auto w-full">
{/* Tool toggles */}
<div className="flex items-center gap-1.5">
{TOOL_GROUPS.map(({ key, label, icon: Icon }) => {
const isOn = activeTools.has(key);
return (
<button
key={key}
type="button"
onClick={() => toggleTool(key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isOn
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-3.5" />
{label}
</button>
);
})}
</div>
{/* Model selector */}
<div className="flex items-center gap-0.5">
{MODEL_OPTIONS.map(({ value, label, icon: Icon }) => (
<Button
key={value}
type="button"
variant={modelMode === value ? "default" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs gap-1"
onClick={() => setModelMode(value)}
>
<Icon className="size-3.5" />
{label}
</Button>
))}
</div>
</div>
{/* Input */}
<div className="shrink-0 px-4 py-3">
<div className="flex flex-col gap-2 max-w-3xl mx-auto">
{attachedFile && (
<FileAttachmentPreview file={attachedFile} onRemove={() => setAttachedFile(null)} />
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<FileUploadButton onFileSelect={setAttachedFile} disabled={thread.isLoading} />
<textarea
ref={textareaRef}
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 resize-none overflow-y-auto"
style={{ minHeight: "42px", maxHeight: "200px" }}
placeholder="输入消息…"
value={input}
rows={1}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as React.FormEvent);
}
}}
onPaste={handlePaste}
disabled={thread.isLoading}
autoFocus
/>
{thread.isLoading ? (
<button
type="button"
onClick={() => thread.stop()}
className="rounded-xl bg-destructive text-destructive-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity flex items-center gap-1.5"
>
<Square className="size-4" />
停止
</button>
) : (
<button
type="submit"
disabled={!input.trim() && !attachedFile}
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>
</div>
{/* ── Canvas panel (right drawer) ── */}
<CanvasPanel doc={canvasDoc} onClose={() => setCanvasDoc(null)} />
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<App />
</ThemeProvider>,
);