feat: add message queue for sequential run processing (#501)

Allow sending multiple messages while a run is active. Messages are
queued on the server and processed sequentially after each run
completes. Each completed assistant message triggers speech playback
independently, and the UI shows queue status with a badge indicator.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-07 10:34:58 +08:00
committed by GitHub
parent 5df8734495
commit 424125843f
17 changed files with 964 additions and 181 deletions
+231 -26
View File
@@ -39,6 +39,7 @@ export interface Message {
// 2) 流式:由 reasoning.delta / thinking.delta / reasoning.available 事件累加
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
reasoning?: string
queued?: boolean
}
export interface Session {
@@ -312,6 +313,10 @@ export const useChatStore = defineStore('chat', () => {
const streamStates = ref<Map<string, { abort: () => void }>>(new Map())
/** sessionId → server-reported isWorking status */
const serverWorking = ref<Set<string>>(new Set())
/** sessionId → queued message count */
const queueLengths = ref<Map<string, number>>(new Map())
/** sessionId → queued user messages not yet visible in the transcript */
const queuedUserMessages = ref<Map<string, Message[]>>(new Map())
// 自动播放语音开关
const autoPlaySpeechEnabled = ref(false)
@@ -448,6 +453,11 @@ export const useChatStore = defineStore('chat', () => {
} else {
serverWorking.value.delete(sessionId)
}
if (data.queueLength && data.queueLength > 0) {
queueLengths.value.set(sessionId, data.queueLength)
} else {
queueLengths.value.delete(sessionId)
}
if ((data as any).isAborting) {
setAbortState({ aborting: true, synced: null })
} else if (!data.isWorking) {
@@ -568,6 +578,41 @@ export const useChatStore = defineStore('chat', () => {
}
}
function enqueueUserMessage(sessionId: string, message: Message) {
const queue = queuedUserMessages.value.get(sessionId) || []
queue.push({ ...message, queued: true })
queuedUserMessages.value.set(sessionId, queue)
}
function removeQueuedMessage(sessionId: string, messageId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
const next = queue.filter(message => message.id !== messageId)
if (next.length > 0) {
queuedUserMessages.value.set(sessionId, next)
} else {
queuedUserMessages.value.delete(sessionId)
}
queueLengths.value.set(sessionId, next.length)
getChatRunSocket()?.emit('cancel_queued_run', {
session_id: sessionId,
queue_id: messageId,
})
}
function showNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
const next = queue.shift()!
if (queue.length > 0) {
queuedUserMessages.value.set(sessionId, queue)
} else {
queuedUserMessages.value.delete(sessionId)
}
addMessage(sessionId, { ...next, queued: false })
updateSessionTitle(sessionId)
}
function updateSessionTitle(sessionId: string) {
const target = sessions.value.find(s => s.id === sessionId)
if (!target) return
@@ -596,7 +641,7 @@ export const useChatStore = defineStore('chat', () => {
}
async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
if ((!content.trim() && !(attachments && attachments.length > 0))) return
primeCompletionBellIfEnabled()
@@ -607,6 +652,7 @@ export const useChatStore = defineStore('chat', () => {
// Capture session ID at send time — all callbacks use this, not activeSessionId
const sid = activeSessionId.value!
const shouldQueue = isSessionLive(sid)
const userMsg: Message = {
id: uid(),
@@ -614,12 +660,13 @@ export const useChatStore = defineStore('chat', () => {
content: content.trim(),
timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined,
queued: shouldQueue,
}
addMessage(sid, userMsg)
updateSessionTitle(sid)
if (!shouldQueue) {
addMessage(sid, userMsg)
updateSessionTitle(sid)
}
try {
@@ -635,13 +682,20 @@ export const useChatStore = defineStore('chat', () => {
const base = `/api/hermes/download?path=${encodeURIComponent(f.path)}&name=${encodeURIComponent(f.name)}`
return [f.name, token ? `${base}&token=${encodeURIComponent(token)}` : base]
}))
const msgs = getSessionMsgs(sid)
const lastUser = msgs.findLast(m => m.id === userMsg.id)
if (lastUser?.attachments) {
lastUser.attachments = lastUser.attachments.map(a => {
if (shouldQueue && userMsg.attachments) {
userMsg.attachments = userMsg.attachments.map(a => {
const dl = urlMap.get(a.name)
return dl ? { ...a, url: dl } : a
})
} else {
const msgs = getSessionMsgs(sid)
const lastUser = msgs.findLast(m => m.id === userMsg.id)
if (lastUser?.attachments) {
lastUser.attachments = lastUser.attachments.map(a => {
const dl = urlMap.get(a.name)
return dl ? { ...a, url: dl } : a
})
}
}
// Build content blocks with uploaded file paths
@@ -657,6 +711,11 @@ export const useChatStore = defineStore('chat', () => {
input,
session_id: sid,
model: sessionModel || undefined,
queue_id: userMsg.id,
}
if (shouldQueue) {
enqueueUserMessage(sid, userMsg)
}
// Helper to clean up this session's stream state
@@ -665,15 +724,29 @@ export const useChatStore = defineStore('chat', () => {
serverWorking.value.delete(sid)
}
// Per-run flags used to detect silently-swallowed errors at run.completed.
// Per-active-run flags used to detect silently-swallowed errors at run.completed.
// hermes-agent occasionally emits run.completed with empty output and no
// usage when the agent layer caught an upstream error (e.g. invalid API
// key). We need to distinguish: (a) run with assistant text produced,
// (b) run with only tool activity, (c) run with truly nothing visible.
// Reset per send() call — closures captured by Socket.IO callbacks are scoped
// to this run, so there is no cross-run contamination.
// Reset on every run.started because one handler may span multiple queued runs.
let runProducedAssistantText = false
let runHadToolActivity = false
let activeAssistantMessageId: string | null = null
const startNextQueuedUser = () => {
showNextQueuedUserMessage(sid)
}
const closeStreamingAssistant = () => {
const msgs = getSessionMsgs(sid)
msgs.forEach(m => {
if (m.role === 'assistant' && m.isStreaming) {
updateMessage(sid, m.id, { isStreaming: false })
}
})
activeAssistantMessageId = null
}
// Send run via Socket.IO and listen to streamed events — all closures capture `sid`
const ctrl = startRunViaSocket(
@@ -682,8 +755,23 @@ export const useChatStore = defineStore('chat', () => {
(evt: RunEvent) => {
switch (evt.event) {
case 'run.started':
setAbortState(null)
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant()
startNextQueuedUser()
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
} else {
queueLengths.value.delete(sid)
}
break
case 'run.queued': {
queueLengths.value.set(sid, (evt as any).queue_length || 0)
break
}
case 'compression.started': {
setCompressionState({
compressing: true,
@@ -720,6 +808,11 @@ export const useChatStore = defineStore('chat', () => {
case 'abort.completed': {
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
setAbortState(null)
break
}
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
@@ -744,7 +837,9 @@ export const useChatStore = defineStore('chat', () => {
if (!text) break
runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: null
if (last?.role === 'assistant' && last.isStreaming) {
last.reasoning = (last.reasoning || '') + text
noteReasoningStart(last.id)
@@ -758,6 +853,7 @@ export const useChatStore = defineStore('chat', () => {
isStreaming: true,
reasoning: text,
})
activeAssistantMessageId = newId
noteReasoningStart(newId)
}
@@ -784,7 +880,9 @@ export const useChatStore = defineStore('chat', () => {
case 'message.delta': {
if (evt.delta) runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: null
if (last?.role === 'assistant' && last.isStreaming) {
const prev = last.content
const next = prev + (evt.delta || '')
@@ -803,6 +901,7 @@ export const useChatStore = defineStore('chat', () => {
timestamp: Date.now(),
isStreaming: true,
})
activeAssistantMessageId = newId
}
break
@@ -811,10 +910,13 @@ export const useChatStore = defineStore('chat', () => {
case 'tool.started': {
runHadToolActivity = true
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
activeAssistantMessageId = null
addMessage(sid, {
id: uid(),
role: 'tool',
@@ -850,7 +952,9 @@ export const useChatStore = defineStore('chat', () => {
case 'run.completed': {
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
const lastMsg = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
@@ -873,7 +977,9 @@ export const useChatStore = defineStore('chat', () => {
if ((evt as any).parsed_content !== undefined) {
// Backend has parsed stringified array format, update last assistant message
const msgs = getSessionMsgs(sid)
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')
const lastAssistant = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: [...msgs].reverse().find(m => m.role === 'assistant')
if (lastAssistant) {
updateMessage(sid, lastAssistant.id, {
content: (evt as any).parsed_content || '',
@@ -936,7 +1042,12 @@ export const useChatStore = defineStore('chat', () => {
}
}
cleanup()
if ((evt as any).queue_remaining > 0) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
} else {
cleanup()
}
activeAssistantMessageId = null
updateSessionTitle(sid)
break
}
@@ -963,7 +1074,11 @@ export const useChatStore = defineStore('chat', () => {
msgs[i] = { ...m, toolStatus: 'error' }
}
})
cleanup()
if ((evt as any).queue_remaining > 0) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
} else {
cleanup()
}
break
}
@@ -1033,6 +1148,7 @@ export const useChatStore = defineStore('chat', () => {
let closed = false
let runProducedAssistantText = false
let runHadToolActivity = false
let activeAssistantMessageId: string | null = null
const cleanup = () => {
if (closed) return
@@ -1043,13 +1159,42 @@ export const useChatStore = defineStore('chat', () => {
unregisterSessionHandlers(sid)
}
const startNextQueuedUser = () => {
showNextQueuedUserMessage(sid)
}
const closeStreamingAssistant = () => {
const msgs = getSessionMsgs(sid)
msgs.forEach(m => {
if (m.role === 'assistant' && m.isStreaming) {
updateMessage(sid, m.id, { isStreaming: false })
}
})
activeAssistantMessageId = null
}
// Shared event handler — filters by session_id tag
function handleEvent(evt: RunEvent) {
if (closed) return
// Filter events for this session (server tags all events with session_id)
if (evt.session_id && evt.session_id !== sid) return
switch (evt.event) {
case 'run.queued': {
queueLengths.value.set(sid, (evt as any).queue_length || 0)
break
}
case 'run.started':
setAbortState(null)
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant()
startNextQueuedUser()
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
} else {
queueLengths.value.delete(sid)
}
break
case 'compression.started': {
@@ -1087,6 +1232,11 @@ export const useChatStore = defineStore('chat', () => {
case 'abort.completed': {
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
setAbortState(null)
break
}
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
@@ -1111,7 +1261,9 @@ export const useChatStore = defineStore('chat', () => {
if (!text) break
runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: null
if (last?.role === 'assistant' && last.isStreaming) {
last.reasoning = (last.reasoning || '') + text
noteReasoningStart(last.id)
@@ -1125,6 +1277,7 @@ export const useChatStore = defineStore('chat', () => {
isStreaming: true,
reasoning: text,
})
activeAssistantMessageId = newId
noteReasoningStart(newId)
}
@@ -1144,7 +1297,9 @@ export const useChatStore = defineStore('chat', () => {
case 'message.delta': {
if (evt.delta) runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: null
if (last?.role === 'assistant' && last.isStreaming) {
const prev = last.content
const next = prev + (evt.delta || '')
@@ -1162,6 +1317,7 @@ export const useChatStore = defineStore('chat', () => {
timestamp: Date.now(),
isStreaming: true,
})
activeAssistantMessageId = newId
}
break
@@ -1170,10 +1326,13 @@ export const useChatStore = defineStore('chat', () => {
case 'tool.started': {
runHadToolActivity = true
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
activeAssistantMessageId = null
addMessage(sid, {
id: uid(),
role: 'tool',
@@ -1203,8 +1362,16 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.completed': {
const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
} else {
queueLengths.value.delete(sid)
}
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
const lastMsg = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
@@ -1221,7 +1388,9 @@ export const useChatStore = defineStore('chat', () => {
if ((evt as any).parsed_content !== undefined) {
// Backend has parsed stringified array format, update last assistant message
const msgs = getSessionMsgs(sid)
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')
const lastAssistant = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: [...msgs].reverse().find(m => m.role === 'assistant')
if (lastAssistant) {
updateMessage(sid, lastAssistant.id, {
content: (evt as any).parsed_content || '',
@@ -1258,12 +1427,35 @@ export const useChatStore = defineStore('chat', () => {
playCompletionBellIfEnabled()
}
cleanup()
// Auto-play speech for every completed assistant message
if (autoPlaySpeechEnabled.value) {
const msgs = getSessionMsgs(sid)
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')
if (lastAssistant?.content) {
setTimeout(() => {
playMessageSpeech(lastAssistant.id, lastAssistant.content)
}, 300)
}
}
if (!hasQueue) {
cleanup()
activeAssistantMessageId = null
} else {
// More runs pending — reset for next run but don't cleanup
activeAssistantMessageId = null
}
updateSessionTitle(sid)
break
}
case 'run.failed': {
const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
} else {
queueLengths.value.delete(sid)
}
const msgs = getSessionMsgs(sid)
const lastErr = msgs[msgs.length - 1]
if (lastErr?.isStreaming) {
@@ -1285,7 +1477,9 @@ export const useChatStore = defineStore('chat', () => {
msgs[i] = { ...m, toolStatus: 'error' }
}
})
cleanup()
if (!hasQueue) {
cleanup()
}
break
}
@@ -1316,6 +1510,7 @@ export const useChatStore = defineStore('chat', () => {
onAbortStarted: (evt) => handleEvent(evt),
onAbortCompleted: (evt) => handleEvent(evt),
onUsageUpdated: (evt) => handleEvent(evt),
onRunQueued: (evt) => handleEvent(evt),
})
// No need to emit resume here — switchSession already did it.
@@ -1343,6 +1538,13 @@ export const useChatStore = defineStore('chat', () => {
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
window.setTimeout(() => {
if (activeSessionId.value === sid && abortState.value?.aborting) {
streamStates.value.delete(sid)
serverWorking.value.delete(sid)
setAbortState(null)
}
}, 20_000)
}
}
@@ -1452,6 +1654,9 @@ export const useChatStore = defineStore('chat', () => {
compressionState,
abortState,
isAborting,
queueLengths,
queuedUserMessages,
removeQueuedMessage,
isLoadingSessions,
sessionsLoaded,
isLoadingMessages,