feat: message queue upgrade, full payload snapshot, StreamStatusBar queue count, config badge
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Has been cancelled
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Has been cancelled

- 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) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 15:28:16 +08:00
co-authored by Claude Sonnet 4.6
parent 964e0dd305
commit 4895ff5465
+108 -35
View File
@@ -134,9 +134,20 @@ function App() {
// ── Concurrent submit state ─────────────────────────────────────────────── // ── Concurrent submit state ───────────────────────────────────────────────
type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "auto-sending"; 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<ToolKey>;
modelMode: ModelMode;
}
const [showConcurrentDialog, setShowConcurrentDialog] = useState(false); const [showConcurrentDialog, setShowConcurrentDialog] = useState(false);
const [concurrentStatus, setConcurrentStatus] = useState<ConcurrentStatus>("idle"); const [concurrentStatus, setConcurrentStatus] = useState<ConcurrentStatus>("idle");
const pendingSubmitRef = useRef<(() => void) | null>(null); const pendingQueueRef = useRef<Array<() => void>>([]);
const isPollingRef = useRef(false);
const [pendingQueueCount, setPendingQueueCount] = useState(0);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({ const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL, apiUrl: LANGGRAPH_URL,
@@ -179,6 +190,14 @@ function App() {
return () => window.removeEventListener("soc:retry-tool", handler); 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 // Listen for prefill-input events from ActionBar
useEffect(() => { useEffect(() => {
const handler = (e: Event) => { const handler = (e: Event) => {
@@ -316,55 +335,67 @@ function App() {
const text = input.trim(); const text = input.trim();
if (!text && !attachedFile) return; if (!text && !attachedFile) return;
// If AI is still generating, show concurrent choice dialog
if (thread.isLoading) { 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); setShowConcurrentDialog(true);
return; 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"); 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(""); setInput("");
setSourceLabel(null); setSourceLabel(null);
// Clear draft and update lastActive for this thread
if (currentThreadId) { if (currentThreadId) {
try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ } try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ }
updateThreadLastActive(currentThreadId); updateThreadLastActive(currentThreadId);
} }
const enabledTools = const enabledTools =
activeTools.size > 0 tools.size > 0
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools]) ? TOOL_GROUPS.filter((g) => tools.has(g.key)).flatMap((g) => [...g.tools])
: []; : [];
const file = attachedFile;
setAttachedFile(null); setAttachedFile(null);
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let messageContent: any; let messageContent: any;
if (file && IMAGE_TYPES.includes(file.mimeType)) { if (file && IMAGE_TYPES.includes(file.mimeType)) {
messageContent = [ messageContent = [
{ type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } }, { type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } },
{ type: "text", text: text || "请分析这张图片" }, { type: "text", text: text || "请分析这张图片" },
]; ];
} else if (file) { } else if (file) {
messageContent = [ messageContent = [{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` }];
{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` },
];
} else { } else {
messageContent = text; messageContent = text;
} }
// Auto-name thread on first message
const isFirstMessage = activeMessages.length === 0; const isFirstMessage = activeMessages.length === 0;
// Consume pending retry tool hint (set by soc:retry-tool event)
const retryTool = pendingRetryToolRef.current; const retryTool = pendingRetryToolRef.current;
pendingRetryToolRef.current = null; pendingRetryToolRef.current = null;
@@ -374,7 +405,7 @@ function App() {
config: { config: {
configurable: { configurable: {
enabledTools, enabledTools,
modelMode, modelMode: mode,
...(retryTool ? { retryTool } : {}), ...(retryTool ? { retryTool } : {}),
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}), ...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
}, },
@@ -385,9 +416,7 @@ function App() {
if (isFirstMessage && currentThreadId) { if (isFirstMessage && currentThreadId) {
const titleText = text.slice(0, 20); const titleText = text.slice(0, 20);
client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {}); client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {});
// Persist title to localStorage for instant display
try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ } try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ }
// Optimistically update local thread title
setThreads((prev) => setThreads((prev) =>
prev.map((t) => prev.map((t) =>
t.thread_id === currentThreadId t.thread_id === currentThreadId
@@ -403,26 +432,39 @@ function App() {
setShowConcurrentDialog(false); setShowConcurrentDialog(false);
setConcurrentStatus("stopping"); setConcurrentStatus("stopping");
thread.stop(); thread.stop();
const submit = pendingSubmitRef.current;
pendingSubmitRef.current = null;
setTimeout(() => { setTimeout(() => {
const next = pendingQueueRef.current.shift();
if (next) {
setPendingQueueCount(pendingQueueRef.current.length);
setConcurrentStatus("generating"); setConcurrentStatus("generating");
submit?.(); next();
} else {
setConcurrentStatus("idle");
}
}, 300); }, 300);
} }
function handleConcurrentQueue() { function handleConcurrentQueue() {
setShowConcurrentDialog(false); setShowConcurrentDialog(false);
setConcurrentStatus("queued"); setConcurrentStatus("queued");
const submit = pendingSubmitRef.current; if (isPollingRef.current) return;
pendingSubmitRef.current = null; isPollingRef.current = true;
const poll = () => { const poll = () => {
if (!thread.isLoading) { if (pendingQueueRef.current.length === 0) {
setConcurrentStatus("auto-sending"); isPollingRef.current = false;
submit?.(); setPendingQueueCount(0);
} else { setConcurrentStatus("idle");
setTimeout(poll, 500); 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); setTimeout(poll, 500);
} }
@@ -430,7 +472,9 @@ function App() {
function handleConcurrentCancel() { function handleConcurrentCancel() {
setShowConcurrentDialog(false); setShowConcurrentDialog(false);
setConcurrentStatus(thread.isLoading ? "generating" : "idle"); setConcurrentStatus(thread.isLoading ? "generating" : "idle");
pendingSubmitRef.current = null; pendingQueueRef.current = [];
setPendingQueueCount(0);
isPollingRef.current = false;
} }
// ── Regenerate last AI message ─────────────────────────────────────────── // ── Regenerate last AI message ───────────────────────────────────────────
@@ -881,7 +925,11 @@ function App() {
> >
<ChevronDown className={cn("size-3.5 transition-transform duration-200", configOpen && "rotate-180")} /> <ChevronDown className={cn("size-3.5 transition-transform duration-200", configOpen && "rotate-180")} />
<span>{summary}</span> <span>{summary}</span>
{activeTools.size > 0 && <span className="size-1.5 rounded-full bg-primary" />} {activeTools.size > 0 && (
<span className="inline-flex items-center justify-center size-4 rounded-full bg-primary text-primary-foreground text-[10px] font-medium leading-none">
{activeTools.size}
</span>
)}
</button> </button>
{/* Expanded panel — opens upward */} {/* Expanded panel — opens upward */}
@@ -961,8 +1009,8 @@ function App() {
{/* Stream status bar */} {/* Stream status bar */}
{concurrentStatus !== "idle" && ( {concurrentStatus !== "idle" && (
<div className="max-w-3xl mx-auto w-full px-5 mb-1"> <div className="max-w-3xl mx-auto w-full px-5 mb-1">
<div className={cn( <div key={concurrentStatus} className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs", "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 === "generating" && "bg-blue-50/80 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400",
concurrentStatus === "queued" && "bg-amber-50/80 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400", concurrentStatus === "queued" && "bg-amber-50/80 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400",
concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400", concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400",
@@ -971,12 +1019,33 @@ function App() {
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && <Loader2 className="size-3 animate-spin shrink-0" />} {(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && <Loader2 className="size-3 animate-spin shrink-0" />}
{concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />} {concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />}
{concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />} {concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />}
{concurrentStatus === "queued" ? (
<>
<span>
{pendingQueueCount > 1
? `${pendingQueueCount} 条消息已排队,等待当前回复完成`
: "下一条消息已排队,等待当前回复完成"}
</span>
<button
type="button"
onClick={() => {
pendingQueueRef.current = [];
setPendingQueueCount(0);
isPollingRef.current = false;
setConcurrentStatus(thread.isLoading ? "generating" : "idle");
}}
className="ml-auto shrink-0 text-[11px] underline underline-offset-2 opacity-70 hover:opacity-100 transition-opacity"
>
取消排队
</button>
</>
) : (
<span> <span>
{concurrentStatus === "generating" && "正在生成回复..."} {concurrentStatus === "generating" && "正在生成回复..."}
{concurrentStatus === "queued" && "下一条消息已排队,等待当前回复完成"}
{concurrentStatus === "stopping" && "正在停止上一轮..."} {concurrentStatus === "stopping" && "正在停止上一轮..."}
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."} {concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
</span> </span>
)}
</div> </div>
</div> </div>
)} )}
@@ -1068,7 +1137,11 @@ function App() {
<div className="bg-background border border-border rounded-2xl shadow-xl p-6 w-80 flex flex-col gap-4"> <div className="bg-background border border-border rounded-2xl shadow-xl p-6 w-80 flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-semibold text-sm">AI 正在处理上一条消息</p> <p className="font-semibold text-sm">AI 正在处理上一条消息</p>
<p className="text-xs text-muted-foreground">请选择如何处理新消息:</p> <p className="text-xs text-muted-foreground">
{pendingQueueCount > 0
? `队列中还有 ${pendingQueueCount} 条待发送`
: "请选择如何处理新消息:"}
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button