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 ───────────────────────────────────────────────
|
// ── 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
|
||||||
|
|||||||
Reference in New Issue
Block a user