Files
socaichat/langgraph/src/main.tsx
T
gongzhiyongandClaude Opus 4.6 99b3cda23c
Trigger auto deployment for soc-langgraph / build-and-deploy (push) Failing after 19s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 49s
fix(P0): 恢复 handleNewThread 立即创建线程,移除 ensureThread 懒创建
根因:ensureThread 异步 setCurrentThreadId 后 React 状态未同步刷新,
useStream 仍绑定 undefined threadId,thread.submit() 提交到错误线程。

修复:
- handleNewThread 恢复为 await client.threads.create()(立即创建)
- doSubmit 直接使用 currentThreadId(已同步可用)
- 移除 ensureThread、skipNextSwitchRef 等懒创建相关逻辑
- 空线程问题后续通过侧边栏过滤解决

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:08:25 +08:00

464 lines
17 KiB
TypeScript

import { createRoot } from "react-dom/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import { useState, useRef, useEffect, useCallback } from "react";
import "./index.css";
import { Menu, Loader2, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import { ThreadSidebar, updateThreadLastActive } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
import ThemeToggle from "@/components/ThemeToggle.tsx";
import CanvasPanel, { type CanvasDoc } from "@/components/CanvasPanel.tsx";
import { type SelectedFile } from "@/components/FileUploadButton.tsx";
import { TOOL_GROUPS } from "@/utils/tool-maps";
import { deduplicateMessages, type UIMsgLocal } from "@/utils/thread-management";
import { setupGlobalKeyboardShortcuts, autoResizeTextarea, setupPrefillInputListener, setupRetryToolListener } from "@/utils/input-handling";
import { createLangGraphClient } from "@/utils/api-client";
import { remoteLog } from "@/utils/remote-log";
import { useConversation } from "@/hooks/useConversation";
import { useConfig } from "@/hooks/useConfig";
import { ChatMessages } from "@/components/ChatMessages";
import { ChatInput } from "@/components/ChatInput";
import { ConfigPanel } from "@/components/ConfigPanel";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
const client = createLangGraphClient(LANGGRAPH_URL);
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);
const isComposingRef = useRef(false);
const { activeTools, setActiveTools, modelMode, setModelMode, toggleTool } = useConfig();
const {
threads, setThreads,
currentThreadId,
sidebarOpen, setSidebarOpen,
historicalMessages, historicalUi,
resetLoading, threadLoadFailed,
handleNewThread, handleSelectThread, handleDeleteThread, handleResetThread,
applyThreadTitle,
} = useConversation(client, input, setInput);
// Canvas panel state
const [canvasDoc, setCanvasDoc] = useState<CanvasDoc | null>(null);
// File attachment state
const [attachedFile, setAttachedFile] = useState<SelectedFile | null>(null);
// Source label from card action buttons
const [sourceLabel, setSourceLabel] = useState<string | null>(null);
const configPanelRef = useRef<HTMLDivElement>(null);
// Pending retry tool name
const pendingRetryToolRef = useRef<string | null>(null);
type ConcurrentStatus = "idle" | "generating" | "stopping" | "cancelling";
const [concurrentStatus, setConcurrentStatus] = useState<ConcurrentStatus>("idle");
const wasStoppedRef = useRef(false);
interface SubmitPayload {
text: string;
attachedFile: { name: string; mimeType: string; base64: string; size: number } | null;
activeTools: Set<typeof activeTools extends Set<infer T> ? T : never>;
modelMode: typeof modelMode;
}
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
messagesKey: "messages",
threadId: currentThreadId ?? undefined,
});
// Auto-scroll to bottom when new content arrives (messages or streaming tokens)
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, thread.values]);
// 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);
}, []);
// Clear error and stale stream state when thread switches
useEffect(() => {
thread.switchThread(currentThreadId);
}, [currentThreadId]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync concurrent status with thread loading state
useEffect(() => {
if (!thread.isLoading && concurrentStatus !== "cancelling") {
setConcurrentStatus("idle");
}
}, [thread.isLoading, concurrentStatus]);
// Listen for retry-tool events
useEffect(() => setupRetryToolListener((toolName) => {
pendingRetryToolRef.current = toolName;
}), []);
// DEBUG: log all ui items
useEffect(() => {
const ui = (thread.values as any)?.ui ?? [];
remoteLog('thread-ui', {
count: ui.length,
items: ui.map((u: any) => ({ name: u.name, msgId: u.metadata?.message_id?.slice(0,8), cardId: u.props?.card_id })),
});
}, [thread.values]);
// Listen for prefill-input events from ActionBar
useEffect(() => setupPrefillInputListener((detail) => {
if (detail.prefix) {
setInput((prev) => { const base = prev.trim(); return base ? `${detail.prefix}${base}` : detail.prefix!; });
} else if (detail.text !== undefined) {
setInput(detail.text);
}
if (detail.sourceLabel) setSourceLabel(detail.sourceLabel);
setTimeout(() => textareaRef.current?.focus(), 50);
}), []);
// Global keyboard shortcuts
useEffect(() => setupGlobalKeyboardShortcuts({
onToggleSidebar: () => setSidebarOpen((v) => !v),
onToggleSearch: () => {},
onFocusInput: () => { textareaRef.current?.focus(); },
onClearInput: () => { if (thread.isLoading) thread.stop(); },
}), [thread.isLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// Load threads on mount
useEffect(() => {
client.threads
.search({ limit: 50 })
.then((list: any) => setThreads(list))
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-resize textarea
useEffect(() => { autoResizeTextarea(textareaRef); }, [input]);
// ── Active messages ──────────────────────────────────────────────────────
// thread.switchThread() is called in useEffect whenever currentThreadId changes,
// which clears thread.messages. So thread.messages is always safe to trust:
// - empty = thread just switched or genuinely empty → fall back to historicalMessages
// - non-empty = useStream has loaded messages for the current thread
const activeMessages: Message[] = deduplicateMessages(
thread.messages.length > 0 ? thread.messages : historicalMessages
);
// Completed / failed tool IDs
const completedToolIds = new Set(
activeMessages
.filter((m) => m.type === "tool")
.map((m) => (m as any).tool_call_id as string)
.filter(Boolean),
);
const failedToolIds = new Set(
activeMessages
.filter((m) => m.type === "tool" && (m as any).status === "error")
.map((m) => (m as any).tool_call_id as string)
.filter(Boolean),
);
const executionLog = ((thread.values as any)?.execution_log ?? []) as Array<{
tool: string; status: string; durationMs?: number; retryCount?: number;
fallbackFrom?: string; resultCount?: number; summary?: string; inputSummary?: string;
}>;
// ── Submit ───────────────────────────────────────────────────────────────
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text && !attachedFile) return;
if (thread.isLoading) return;
const payload: SubmitPayload = {
text,
attachedFile: attachedFile
? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size }
: null,
activeTools: new Set(activeTools),
modelMode,
};
setConcurrentStatus("generating");
doSubmit(payload);
}
async function doSubmit(payload: SubmitPayload) {
const { text, attachedFile: file, activeTools: tools, modelMode: mode } = payload;
setInput("");
setSourceLabel(null);
if (!currentThreadId) {
setConcurrentStatus("idle");
return;
}
try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ }
updateThreadLastActive(currentThreadId);
const enabledTools =
tools.size > 0
? TOOL_GROUPS.filter((g) => tools.has(g.key)).flatMap((g) => [...g.tools])
: [];
setAttachedFile(null);
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
let messageContent: any;
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;
}
const retryTool = pendingRetryToolRef.current;
pendingRetryToolRef.current = null;
thread.submit(
{ messages: [{ type: "human", content: messageContent }] },
{
config: {
configurable: {
enabledTools,
modelMode: mode,
...(retryTool ? { retryTool } : {}),
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
},
},
streamMode: ["values", "messages"],
streamSubgraphs: true,
},
);
// Scroll to bottom after submitting so user message stays visible
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 100);
if (currentThreadId && text) {
const existingTitle = threads.find((t) => t.thread_id === currentThreadId)?.metadata?.title as string | undefined;
applyThreadTitle(currentThreadId, text, activeMessages.length, existingTitle);
}
}
const handleRegenerate = useCallback(() => {
const humanMsgs = activeMessages.filter((m) => m.type === "human");
const lastHuman = humanMsgs[humanMsgs.length - 1];
if (!lastHuman || thread.isLoading) return;
setConcurrentStatus("generating");
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 } },
streamMode: ["values", "messages"],
streamSubgraphs: true,
},
);
}, [activeMessages, thread, activeTools, modelMode]);
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);
}
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);
}
function handleStop() {
setConcurrentStatus("stopping");
wasStoppedRef.current = true;
thread.stop();
setTimeout(() => {
setConcurrentStatus("cancelling");
const waitForStop = () => {
if (!thread.isLoading) {
wasStoppedRef.current = false;
setConcurrentStatus("idle");
} else {
setTimeout(waitForStop, 200);
}
};
waitForStop();
}, 150);
}
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">
<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 */}
<ChatMessages
activeMessages={activeMessages}
historicalUi={historicalUi}
thread={thread}
completedToolIds={completedToolIds}
failedToolIds={failedToolIds}
executionLog={executionLog}
scrollContainerRef={scrollContainerRef}
bottomRef={bottomRef}
showScrollBtn={showScrollBtn}
setShowScrollBtn={setShowScrollBtn}
setInput={setInput}
textareaRef={textareaRef}
onRegenerate={handleRegenerate}
onResetThread={handleResetThread}
onNewThread={handleNewThread}
resetLoading={resetLoading}
threadLoadFailed={threadLoadFailed}
/>
{/* Config panel */}
<ConfigPanel
configPanelRef={configPanelRef}
modelMode={modelMode}
setModelMode={setModelMode}
activeTools={activeTools}
setActiveTools={setActiveTools}
toggleTool={toggleTool}
activeMessages={activeMessages}
/>
{/* Stream status bar */}
{concurrentStatus !== "idle" && (
<div className="max-w-3xl mx-auto w-full px-5 mb-1">
<div key={concurrentStatus} className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs animate-in fade-in duration-200",
concurrentStatus === "generating" && "bg-blue-50/80 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400",
concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400",
concurrentStatus === "cancelling" && "bg-orange-50/80 dark:bg-orange-950/30 text-orange-600 dark:text-orange-400",
)}>
{concurrentStatus === "generating" && <Loader2 className="size-3 animate-spin shrink-0" />}
{concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />}
{concurrentStatus === "cancelling" && <Loader2 className="size-3 animate-spin shrink-0" />}
<span>
{concurrentStatus === "generating" && "正在生成回复..."}
{concurrentStatus === "stopping" && "正在停止..."}
{concurrentStatus === "cancelling" && "等待停止完成..."}
</span>
</div>
</div>
)}
{/* Input */}
<ChatInput
input={input}
setInput={setInput}
textareaRef={textareaRef}
isComposingRef={isComposingRef}
isLoading={thread.isLoading}
attachedFile={attachedFile}
setAttachedFile={setAttachedFile}
sourceLabel={sourceLabel}
setSourceLabel={setSourceLabel}
concurrentStatus={concurrentStatus}
onSubmit={handleSubmit}
onStop={handleStop}
onPaste={handlePaste}
showQuickPrompts={false}
/>
</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>,
);