根因: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>
464 lines
17 KiB
TypeScript
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>,
|
|
);
|