feat: message queue upgrade, full payload snapshot, StreamStatusBar queue count, config badge
- 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:
co-authored by
Claude Sonnet 4.6
parent
964e0dd305
commit
4895ff5465
+108
-35
@@ -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<ToolKey>;
|
||||
modelMode: ModelMode;
|
||||
}
|
||||
|
||||
const [showConcurrentDialog, setShowConcurrentDialog] = useState(false);
|
||||
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[] }>({
|
||||
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(() => {
|
||||
const next = pendingQueueRef.current.shift();
|
||||
if (next) {
|
||||
setPendingQueueCount(pendingQueueRef.current.length);
|
||||
setConcurrentStatus("generating");
|
||||
submit?.();
|
||||
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() {
|
||||
>
|
||||
<ChevronDown className={cn("size-3.5 transition-transform duration-200", configOpen && "rotate-180")} />
|
||||
<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>
|
||||
|
||||
{/* Expanded panel — opens upward */}
|
||||
@@ -961,8 +1009,8 @@ function App() {
|
||||
{/* Stream status bar */}
|
||||
{concurrentStatus !== "idle" && (
|
||||
<div className="max-w-3xl mx-auto w-full px-5 mb-1">
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs",
|
||||
<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 === "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",
|
||||
@@ -971,12 +1019,33 @@ function App() {
|
||||
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && <Loader2 className="size-3 animate-spin shrink-0" />}
|
||||
{concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />}
|
||||
{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>
|
||||
{concurrentStatus === "generating" && "正在生成回复..."}
|
||||
{concurrentStatus === "queued" && "下一条消息已排队,等待当前回复完成"}
|
||||
{concurrentStatus === "stopping" && "正在停止上一轮..."}
|
||||
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
|
||||
</span>
|
||||
)}
|
||||
</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="flex flex-col gap-1">
|
||||
<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 className="flex flex-col gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user