From 4895ff546537ee7f550caaee9a02a05e171a5a71 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Sun, 12 Apr 2026 15:28:16 +0800 Subject: [PATCH] feat: message queue upgrade, full payload snapshot, StreamStatusBar queue count, config badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single pendingSubmitRef with pendingQueueRef array supporting multiple queued messages - Add SubmitPayload interface to snapshot text/file/tools/modelMode at submission time - Rewrite handleSubmit/doSubmit to use payload snapshots instead of closures over mutable state - Rewrite handleConcurrentQueue with polling loop that drains the queue sequentially - Rewrite handleConcurrentInterrupt and handleConcurrentCancel for queue awareness - StreamStatusBar queued state now shows count (N 条消息已排队) and inline cancel button - Add key={concurrentStatus} to StreamStatusBar div for fade-in animation on status change - Config panel summary button shows numeric badge instead of dot when tools are active Co-Authored-By: Claude Sonnet 4.6 (1M context) --- langgraph/src/main.tsx | 155 ++++++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 41 deletions(-) diff --git a/langgraph/src/main.tsx b/langgraph/src/main.tsx index a2aca46..191dbed 100644 --- a/langgraph/src/main.tsx +++ b/langgraph/src/main.tsx @@ -134,9 +134,20 @@ function App() { // ── Concurrent submit state ─────────────────────────────────────────────── type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "auto-sending"; + + // 完整 payload 快照类型 + interface SubmitPayload { + text: string; + attachedFile: { name: string; mimeType: string; base64: string; size: number } | null; + activeTools: Set; + modelMode: ModelMode; + } + const [showConcurrentDialog, setShowConcurrentDialog] = useState(false); const [concurrentStatus, setConcurrentStatus] = useState("idle"); - const pendingSubmitRef = useRef<(() => void) | null>(null); + const pendingQueueRef = useRef void>>([]); + const isPollingRef = useRef(false); + const [pendingQueueCount, setPendingQueueCount] = useState(0); const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({ apiUrl: LANGGRAPH_URL, @@ -179,6 +190,14 @@ function App() { return () => window.removeEventListener("soc:retry-tool", handler); }, []); + // Cleanup queue on unmount + useEffect(() => { + return () => { + pendingQueueRef.current = []; + isPollingRef.current = false; + }; + }, []); + // Listen for prefill-input events from ActionBar useEffect(() => { const handler = (e: Event) => { @@ -316,55 +335,67 @@ function App() { const text = input.trim(); if (!text && !attachedFile) return; - // If AI is still generating, show concurrent choice dialog if (thread.isLoading) { - pendingSubmitRef.current = () => doSubmit(text); + // 立即 snapshot + const snapshot: SubmitPayload = { + text, + attachedFile: attachedFile + ? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size } + : null, + activeTools: new Set(activeTools), + modelMode, + }; + setInput(""); + setAttachedFile(null); + pendingQueueRef.current.push(() => doSubmit(snapshot)); + setPendingQueueCount(pendingQueueRef.current.length); setShowConcurrentDialog(true); 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(text); + doSubmit(payload); } - async function doSubmit(text: string) { + async function doSubmit(payload: SubmitPayload) { + const { text, attachedFile: file, activeTools: tools, modelMode: mode } = payload; setInput(""); setSourceLabel(null); - // Clear draft and update lastActive for this thread if (currentThreadId) { try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ } updateThreadLastActive(currentThreadId); } const enabledTools = - activeTools.size > 0 - ? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools]) + tools.size > 0 + ? TOOL_GROUPS.filter((g) => tools.has(g.key)).flatMap((g) => [...g.tools]) : []; - const file = attachedFile; setAttachedFile(null); const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 || "请分析这个文件"}` }, - ]; + messageContent = [{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` }]; } else { messageContent = text; } - // Auto-name thread on first message const isFirstMessage = activeMessages.length === 0; - - // Consume pending retry tool hint (set by soc:retry-tool event) const retryTool = pendingRetryToolRef.current; pendingRetryToolRef.current = null; @@ -374,7 +405,7 @@ function App() { config: { configurable: { enabledTools, - modelMode, + modelMode: mode, ...(retryTool ? { retryTool } : {}), ...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}), }, @@ -385,9 +416,7 @@ function App() { if (isFirstMessage && currentThreadId) { const titleText = text.slice(0, 20); client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {}); - // Persist title to localStorage for instant display try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ } - // Optimistically update local thread title setThreads((prev) => prev.map((t) => t.thread_id === currentThreadId @@ -403,26 +432,39 @@ function App() { setShowConcurrentDialog(false); setConcurrentStatus("stopping"); thread.stop(); - const submit = pendingSubmitRef.current; - pendingSubmitRef.current = null; setTimeout(() => { - setConcurrentStatus("generating"); - submit?.(); + const next = pendingQueueRef.current.shift(); + if (next) { + setPendingQueueCount(pendingQueueRef.current.length); + setConcurrentStatus("generating"); + next(); + } else { + setConcurrentStatus("idle"); + } }, 300); } function handleConcurrentQueue() { setShowConcurrentDialog(false); setConcurrentStatus("queued"); - const submit = pendingSubmitRef.current; - pendingSubmitRef.current = null; + if (isPollingRef.current) return; + isPollingRef.current = true; const poll = () => { - if (!thread.isLoading) { - setConcurrentStatus("auto-sending"); - submit?.(); - } else { - setTimeout(poll, 500); + if (pendingQueueRef.current.length === 0) { + isPollingRef.current = false; + setPendingQueueCount(0); + setConcurrentStatus("idle"); + return; } + if (thread.isLoading) { + setTimeout(poll, 500); + return; + } + const next = pendingQueueRef.current.shift()!; + setPendingQueueCount(pendingQueueRef.current.length); + setConcurrentStatus("auto-sending"); + next(); + setTimeout(poll, 500); }; setTimeout(poll, 500); } @@ -430,7 +472,9 @@ function App() { function handleConcurrentCancel() { setShowConcurrentDialog(false); setConcurrentStatus(thread.isLoading ? "generating" : "idle"); - pendingSubmitRef.current = null; + pendingQueueRef.current = []; + setPendingQueueCount(0); + isPollingRef.current = false; } // ── Regenerate last AI message ─────────────────────────────────────────── @@ -881,7 +925,11 @@ function App() { > {summary} - {activeTools.size > 0 && } + {activeTools.size > 0 && ( + + {activeTools.size} + + )} {/* Expanded panel — opens upward */} @@ -961,8 +1009,8 @@ function App() { {/* Stream status bar */} {concurrentStatus !== "idle" && (
-
} {concurrentStatus === "queued" && } {concurrentStatus === "stopping" && } - - {concurrentStatus === "generating" && "正在生成回复..."} - {concurrentStatus === "queued" && "下一条消息已排队,等待当前回复完成"} - {concurrentStatus === "stopping" && "正在停止上一轮..."} - {concurrentStatus === "auto-sending" && "正在自动发送下一条..."} - + {concurrentStatus === "queued" ? ( + <> + + {pendingQueueCount > 1 + ? `${pendingQueueCount} 条消息已排队,等待当前回复完成` + : "下一条消息已排队,等待当前回复完成"} + + + + ) : ( + + {concurrentStatus === "generating" && "正在生成回复..."} + {concurrentStatus === "stopping" && "正在停止上一轮..."} + {concurrentStatus === "auto-sending" && "正在自动发送下一条..."} + + )}
)} @@ -1068,7 +1137,11 @@ function App() {

AI 正在处理上一条消息

-

请选择如何处理新消息:

+

+ {pendingQueueCount > 0 + ? `队列中还有 ${pendingQueueCount} 条待发送` + : "请选择如何处理新消息:"} +