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:
@@ -16,6 +16,7 @@ export interface StartRunRequest {
|
||||
instructions?: string
|
||||
session_id?: string
|
||||
model?: string
|
||||
queue_id?: string
|
||||
}
|
||||
|
||||
export interface StartRunResponse {
|
||||
@@ -45,6 +46,8 @@ export interface RunEvent {
|
||||
}
|
||||
/** session_id tag added by server for client-side filtering */
|
||||
session_id?: string
|
||||
/** Queue length from run.queued event */
|
||||
queue_length?: number
|
||||
}
|
||||
|
||||
// ============================
|
||||
@@ -73,6 +76,7 @@ const sessionEventHandlers = new Map<string, {
|
||||
onAbortStarted: (event: RunEvent) => void
|
||||
onAbortCompleted: (event: RunEvent) => void
|
||||
onUsageUpdated: (event: RunEvent) => void
|
||||
onRunQueued?: (event: RunEvent) => void
|
||||
}>()
|
||||
|
||||
/**
|
||||
@@ -179,7 +183,8 @@ function globalRunCompletedHandler(event: RunEvent): void {
|
||||
handlers.onRunCompleted(event)
|
||||
}
|
||||
|
||||
// Auto-cleanup session handlers on completion
|
||||
// Auto-cleanup session handlers on completion (skip if more runs queued)
|
||||
if ((event as any).queue_remaining > 0) return
|
||||
sessionEventHandlers.delete(sid)
|
||||
}
|
||||
|
||||
@@ -195,10 +200,24 @@ function globalRunFailedHandler(event: RunEvent): void {
|
||||
handlers.onRunFailed(event)
|
||||
}
|
||||
|
||||
// Auto-cleanup session handlers on failure
|
||||
// Auto-cleanup session handlers on failure (skip if more runs queued)
|
||||
if ((event as any).queue_remaining > 0) return
|
||||
sessionEventHandlers.delete(sid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Global run.queued event handler
|
||||
*/
|
||||
function globalRunQueuedHandler(event: RunEvent): void {
|
||||
const sid = event.session_id
|
||||
if (!sid) return
|
||||
|
||||
const handlers = sessionEventHandlers.get(sid)
|
||||
if (handlers?.onRunQueued) {
|
||||
handlers.onRunQueued(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global compression.started event handler
|
||||
*/
|
||||
@@ -250,6 +269,9 @@ function globalAbortCompletedHandler(event: RunEvent): void {
|
||||
handlers.onAbortCompleted(event)
|
||||
}
|
||||
|
||||
// If abort completion is followed by queued runs, keep the handler alive so
|
||||
// the next run.started/message.delta/run.completed events are still received.
|
||||
if ((event as any).queue_length > 0) return
|
||||
sessionEventHandlers.delete(sid)
|
||||
}
|
||||
|
||||
@@ -289,6 +311,7 @@ export function registerSessionHandlers(
|
||||
onAbortStarted: (event: RunEvent) => void
|
||||
onAbortCompleted: (event: RunEvent) => void
|
||||
onUsageUpdated: (event: RunEvent) => void
|
||||
onRunQueued?: (event: RunEvent) => void
|
||||
}
|
||||
): () => void {
|
||||
sessionEventHandlers.set(sessionId, handlers)
|
||||
@@ -361,6 +384,7 @@ export function connectChatRun(): Socket {
|
||||
chatRunSocket.on('run.started', globalRunStartedHandler)
|
||||
chatRunSocket.on('run.failed', globalRunFailedHandler)
|
||||
chatRunSocket.on('run.completed', globalRunCompletedHandler)
|
||||
chatRunSocket.on('run.queued', globalRunQueuedHandler)
|
||||
|
||||
// Compression events
|
||||
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
|
||||
@@ -395,7 +419,7 @@ export function disconnectChatRun(): void {
|
||||
*/
|
||||
export function resumeSession(
|
||||
sessionId: string,
|
||||
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number }) => void,
|
||||
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number; queueLength?: number }) => void,
|
||||
): Socket {
|
||||
const socket = connectChatRun()
|
||||
|
||||
@@ -418,6 +442,18 @@ export function startRunViaSocket(
|
||||
}
|
||||
|
||||
let closed = false
|
||||
const socket = connectChatRun()
|
||||
|
||||
if (sessionEventHandlers.has(sid)) {
|
||||
socket.emit('run', body)
|
||||
return {
|
||||
abort: () => {
|
||||
if (!closed) {
|
||||
socket.emit('abort', { session_id: sid })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Define event handlers for this session
|
||||
const handlers = {
|
||||
@@ -453,12 +489,14 @@ export function startRunViaSocket(
|
||||
onRunCompleted: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
if ((evt as any).queue_remaining > 0) return
|
||||
closed = true
|
||||
onDone()
|
||||
},
|
||||
onRunFailed: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
if ((evt as any).queue_remaining > 0) return
|
||||
closed = true
|
||||
onError(new Error(evt.error || 'Run failed'))
|
||||
},
|
||||
@@ -477,6 +515,7 @@ export function startRunViaSocket(
|
||||
onAbortCompleted: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
if ((evt as any).queue_length > 0) return
|
||||
closed = true
|
||||
onDone()
|
||||
},
|
||||
@@ -484,13 +523,16 @@ export function startRunViaSocket(
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
onRunQueued: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
}
|
||||
|
||||
// Register handlers in the global session map
|
||||
sessionEventHandlers.set(sid, handlers)
|
||||
|
||||
// Emit run request
|
||||
const socket = connectChatRun()
|
||||
socket.emit('run', body)
|
||||
|
||||
return {
|
||||
|
||||
@@ -374,7 +374,7 @@ function isImage(type: string): boolean {
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!canSend || chatStore.isStreaming"
|
||||
:disabled="!canSend"
|
||||
@click="handleSend"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
@@ -370,10 +370,13 @@ function handleSpeechToggle() {
|
||||
return
|
||||
}
|
||||
const content = props.message.content || ''
|
||||
speech.toggle(props.message.id, content, getSpeechOptions())
|
||||
}
|
||||
|
||||
function getSpeechOptions() {
|
||||
// 尝试获取男声语音包
|
||||
const allVoices = speech.getAllVoices()
|
||||
let maleVoice = null
|
||||
let maleVoice: SpeechSynthesisVoice | null = null
|
||||
|
||||
// 查找可能的男声语音包
|
||||
for (const voice of allVoices) {
|
||||
@@ -394,11 +397,11 @@ function handleSpeechToggle() {
|
||||
}
|
||||
|
||||
// 快速男声:语速快、音调低
|
||||
speech.toggle(props.message.id, content, {
|
||||
return {
|
||||
pitch: 0.5, // 低沉
|
||||
rate: 1.2, // 快速
|
||||
voice: maleVoice || undefined, // 使用男声,如果没有就用默认
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听自动播放事件
|
||||
@@ -408,7 +411,7 @@ onMounted(() => {
|
||||
autoPlayHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ messageId: string; content: string }>
|
||||
if (customEvent.detail.messageId === props.message.id && canPlaySpeech.value) {
|
||||
handleSpeechToggle()
|
||||
speech.enqueue(props.message.id, customEvent.detail.content || props.message.content || '', getSpeechOptions())
|
||||
}
|
||||
}
|
||||
window.addEventListener('auto-play-speech', autoPlayHandler)
|
||||
|
||||
@@ -45,6 +45,23 @@ const currentToolCalls = computed(() => {
|
||||
return [...tools].reverse();
|
||||
});
|
||||
|
||||
const queuedMessages = computed(() => {
|
||||
const sid = chatStore.activeSessionId;
|
||||
if (!sid) return [];
|
||||
return chatStore.queuedUserMessages.get(sid) || [];
|
||||
});
|
||||
|
||||
function removeQueuedMessage(messageId: string) {
|
||||
const sid = chatStore.activeSessionId;
|
||||
if (!sid) return;
|
||||
chatStore.removeQueuedMessage(sid, messageId);
|
||||
}
|
||||
|
||||
function queuedPreview(content: string): string {
|
||||
const normalized = content.replace(/\s+/g, " ").trim();
|
||||
return normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
|
||||
}
|
||||
|
||||
function isNearBottom(threshold = 200): boolean {
|
||||
const el = listRef.value;
|
||||
if (!el) return true;
|
||||
@@ -296,6 +313,38 @@ watch(currentToolCalls, () => {
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="queue-float">
|
||||
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
|
||||
<div class="queue-float-header">
|
||||
<span class="queue-orbit" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
<span>{{ t('chat.messageQueue') }}</span>
|
||||
<strong>{{ queuedMessages.length }}</strong>
|
||||
</div>
|
||||
<div class="queue-float-list">
|
||||
<div
|
||||
v-for="(message, index) in queuedMessages"
|
||||
:key="message.id"
|
||||
class="queue-float-item"
|
||||
>
|
||||
<span class="queue-index">{{ index + 1 }}</span>
|
||||
<span class="queue-text">{{ queuedPreview(message.content) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="queue-remove"
|
||||
:title="t('chat.removeQueuedMessage')"
|
||||
@click="removeQueuedMessage(message.id)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -310,12 +359,214 @@ watch(currentToolCalls, () => {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: $bg-card;
|
||||
position: relative;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-panel {
|
||||
position: sticky;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 4;
|
||||
align-self: flex-end;
|
||||
width: min(340px, calc(100% - 16px));
|
||||
margin-top: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.14);
|
||||
backdrop-filter: blur(14px);
|
||||
|
||||
.dark & {
|
||||
background: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 4px 8px;
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
strong {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-info-rgb), 0.16);
|
||||
color: var(--accent-info);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-orbit {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(var(--accent-info-rgb), 0.28);
|
||||
position: relative;
|
||||
animation: queue-spin 1.6s linear infinite;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
right: -2px;
|
||||
top: 5px;
|
||||
background: var(--accent-info);
|
||||
box-shadow: 0 0 12px rgba(var(--accent-info-rgb), 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 172px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.queue-float-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 11px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
color: $text-primary;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--accent-info);
|
||||
background: rgba(var(--accent-info-rgb), 0.12);
|
||||
}
|
||||
|
||||
.queue-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queue-remove {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-muted;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba($error, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.queue-float-panel {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: min(260px, calc(100% - 8px));
|
||||
padding: 7px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.queue-float-header {
|
||||
padding: 0 2px;
|
||||
font-size: 11px;
|
||||
|
||||
span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-orbit {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-list {
|
||||
margin-top: 6px;
|
||||
max-height: min(220px, 34dvh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.queue-float-item {
|
||||
min-height: 30px;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.queue-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.queue-remove {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes queue-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-enter-active,
|
||||
.queue-float-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.queue-float-enter-from,
|
||||
.queue-float-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface SpeechState {
|
||||
progress: number // 当前进度(字符数)
|
||||
}
|
||||
|
||||
interface SpeechQueueItem {
|
||||
messageId: string
|
||||
content: string
|
||||
options: SpeechOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Speech API 语音播放 Composable
|
||||
*/
|
||||
@@ -29,7 +35,8 @@ export function useSpeech() {
|
||||
})
|
||||
|
||||
let utterance: SpeechSynthesisUtterance | null = null
|
||||
let currentText = ''
|
||||
let playbackToken = 0
|
||||
const speechQueue: SpeechQueueItem[] = []
|
||||
|
||||
// 加载可用语音列表
|
||||
function loadVoices() {
|
||||
@@ -106,8 +113,12 @@ export function useSpeech() {
|
||||
/**
|
||||
* 停止当前播放
|
||||
*/
|
||||
function stop() {
|
||||
if (synth.speaking) {
|
||||
function stop(clearQueue = true) {
|
||||
playbackToken += 1
|
||||
if (clearQueue) {
|
||||
speechQueue.length = 0
|
||||
}
|
||||
if (synth.speaking || synth.pending || synth.paused) {
|
||||
synth.cancel()
|
||||
}
|
||||
if (utterance) {
|
||||
@@ -119,7 +130,89 @@ export function useSpeech() {
|
||||
currentMessageId: null,
|
||||
progress: 0,
|
||||
}
|
||||
currentText = ''
|
||||
}
|
||||
|
||||
function speak(messageId: string, text: string, options: SpeechOptions = {}) {
|
||||
const token = ++playbackToken
|
||||
|
||||
utterance = new SpeechSynthesisUtterance(text)
|
||||
const activeUtterance = utterance
|
||||
const activeText = text
|
||||
|
||||
// 设置语音参数
|
||||
utterance.rate = options.rate ?? 1
|
||||
utterance.pitch = options.pitch ?? 1
|
||||
utterance.volume = options.volume ?? 1
|
||||
utterance.voice = options.voice ?? getDefaultVoice()
|
||||
|
||||
console.log('[useSpeech] Selected voice:', utterance.voice?.name, utterance.voice?.lang)
|
||||
|
||||
if (options.lang) {
|
||||
utterance.lang = options.lang
|
||||
} else if (utterance.voice) {
|
||||
utterance.lang = utterance.voice.lang
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
utterance.onstart = () => {
|
||||
if (token !== playbackToken || utterance !== activeUtterance) return
|
||||
console.log('[useSpeech] onstart fired')
|
||||
state.value.isPlaying = true
|
||||
state.value.isPaused = false
|
||||
state.value.currentMessageId = messageId
|
||||
state.value.progress = 0
|
||||
}
|
||||
|
||||
utterance.onboundary = (event) => {
|
||||
if (token !== playbackToken || utterance !== activeUtterance) return
|
||||
if (event.name === 'word') {
|
||||
state.value.progress = event.charIndex
|
||||
}
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
if (token !== playbackToken || utterance !== activeUtterance) return
|
||||
console.log('[useSpeech] onend fired')
|
||||
state.value.isPlaying = false
|
||||
state.value.isPaused = false
|
||||
state.value.currentMessageId = null
|
||||
state.value.progress = activeText.length
|
||||
utterance = null
|
||||
if (speechQueue.length > 0) {
|
||||
window.setTimeout(playNextQueuedSpeech, 0)
|
||||
}
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
if (token !== playbackToken || utterance !== activeUtterance) return
|
||||
console.error('[useSpeech] Speech synthesis error:', event.error)
|
||||
state.value.isPlaying = false
|
||||
state.value.isPaused = false
|
||||
state.value.currentMessageId = null
|
||||
utterance = null
|
||||
if (speechQueue.length > 0) {
|
||||
window.setTimeout(playNextQueuedSpeech, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始播放
|
||||
console.log('[useSpeech] Calling synth.speak()')
|
||||
synth.speak(utterance)
|
||||
}
|
||||
|
||||
function playNextQueuedSpeech() {
|
||||
if (state.value.isPlaying || state.value.isPaused || synth.speaking || synth.pending) return
|
||||
const next = speechQueue.shift()
|
||||
if (!next) return
|
||||
|
||||
const text = extractReadableText(next.content)
|
||||
if (!text) {
|
||||
window.setTimeout(playNextQueuedSpeech, 0)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[useSpeech] Playing queued text:', text.substring(0, 50) + '...')
|
||||
speak(next.messageId, text, next.options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,58 +252,23 @@ export function useSpeech() {
|
||||
|
||||
// 停止当前播放
|
||||
stop()
|
||||
speak(messageId, text, options)
|
||||
}
|
||||
|
||||
// 创建新的 utterance
|
||||
utterance = new SpeechSynthesisUtterance(text)
|
||||
currentText = text
|
||||
|
||||
// 设置语音参数
|
||||
utterance.rate = options.rate ?? 1
|
||||
utterance.pitch = options.pitch ?? 1
|
||||
utterance.volume = options.volume ?? 1
|
||||
utterance.voice = options.voice ?? getDefaultVoice()
|
||||
|
||||
console.log('[useSpeech] Selected voice:', utterance.voice?.name, utterance.voice?.lang)
|
||||
|
||||
if (options.lang) {
|
||||
utterance.lang = options.lang
|
||||
} else if (utterance.voice) {
|
||||
utterance.lang = utterance.voice.lang
|
||||
/**
|
||||
* 自动播放入队:不打断当前语音,按完成顺序依次播放。
|
||||
*/
|
||||
function enqueue(messageId: string, content: string, options: SpeechOptions = {}) {
|
||||
if (!isSupported.value) {
|
||||
console.warn('[useSpeech] Speech synthesis not supported')
|
||||
return
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
utterance.onstart = () => {
|
||||
console.log('[useSpeech] onstart fired')
|
||||
state.value.isPlaying = true
|
||||
state.value.isPaused = false
|
||||
state.value.currentMessageId = messageId
|
||||
state.value.progress = 0
|
||||
if (!extractReadableText(content)) {
|
||||
console.warn('[useSpeech] No readable text found')
|
||||
return
|
||||
}
|
||||
|
||||
utterance.onboundary = (event) => {
|
||||
if (event.name === 'word') {
|
||||
state.value.progress = event.charIndex
|
||||
}
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
console.log('[useSpeech] onend fired')
|
||||
state.value.isPlaying = false
|
||||
state.value.isPaused = false
|
||||
state.value.currentMessageId = null
|
||||
state.value.progress = currentText.length
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('[useSpeech] Speech synthesis error:', event.error)
|
||||
state.value.isPlaying = false
|
||||
state.value.isPaused = false
|
||||
state.value.currentMessageId = null
|
||||
}
|
||||
|
||||
// 开始播放
|
||||
console.log('[useSpeech] Calling synth.speak()')
|
||||
synth.speak(utterance)
|
||||
speechQueue.push({ messageId, content, options })
|
||||
playNextQueuedSpeech()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,6 +327,7 @@ export function useSpeech() {
|
||||
resume,
|
||||
stop,
|
||||
toggle,
|
||||
enqueue,
|
||||
getDefaultVoice,
|
||||
getAllVoices,
|
||||
extractReadableText,
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
emptyState: 'Starten Sie eine Konversation mit Hermes Agent',
|
||||
inputPlaceholder: 'Nachricht eingeben... (Enter zum Senden, Shift+Enter fur neue Zeile)',
|
||||
attachFiles: 'Dateien anhangen',
|
||||
messageQueue: 'Nachrichtenwarteschlange',
|
||||
removeQueuedMessage: 'Nachricht aus Warteschlange entfernen',
|
||||
stop: 'Stopp',
|
||||
send: 'Senden',
|
||||
contextUsed: 'Kontext verwendet:',
|
||||
|
||||
@@ -128,6 +128,8 @@ export default {
|
||||
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
||||
attachFiles: 'Attach files',
|
||||
autoPlaySpeech: 'Auto-play voice',
|
||||
messageQueue: 'Message queue',
|
||||
removeQueuedMessage: 'Remove queued message',
|
||||
stop: 'Stop',
|
||||
start: 'Start',
|
||||
stopGateway: 'Stop Gateway',
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
emptyState: 'Inicia una conversacion con Hermes Agent',
|
||||
inputPlaceholder: 'Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva linea)',
|
||||
attachFiles: 'Adjuntar archivos',
|
||||
messageQueue: 'Cola de mensajes',
|
||||
removeQueuedMessage: 'Quitar mensaje de la cola',
|
||||
stop: 'Detener',
|
||||
send: 'Enviar',
|
||||
contextUsed: 'Contexto utilizado:',
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
emptyState: 'Demarrer une conversation avec Hermes Agent',
|
||||
inputPlaceholder: 'Tapez un message... (Entree pour envoyer, Shift+Entree pour un saut de ligne)',
|
||||
attachFiles: 'Joindre des fichiers',
|
||||
messageQueue: 'File de messages',
|
||||
removeQueuedMessage: 'Retirer le message de la file',
|
||||
stop: 'Arreter',
|
||||
send: 'Envoyer',
|
||||
contextUsed: 'Contexte utilise :',
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
emptyState: 'Hermes Agent と会話を開始しましょう',
|
||||
inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)',
|
||||
attachFiles: 'ファイルを添付',
|
||||
messageQueue: 'メッセージキュー',
|
||||
removeQueuedMessage: 'キューのメッセージを削除',
|
||||
stop: '停止',
|
||||
send: '送信',
|
||||
contextUsed: 'コンテキスト使用量:',
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
emptyState: 'Hermes Agent와 대화를 시작하세요',
|
||||
inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)',
|
||||
attachFiles: '파일 첨부',
|
||||
messageQueue: '메시지 대기열',
|
||||
removeQueuedMessage: '대기열 메시지 제거',
|
||||
stop: '중지',
|
||||
send: '전송',
|
||||
contextUsed: '사용된 컨텍스트:',
|
||||
|
||||
@@ -117,6 +117,8 @@ export default {
|
||||
emptyState: 'Inicie uma conversa com o Hermes Agent',
|
||||
inputPlaceholder: 'Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)',
|
||||
attachFiles: 'Anexar arquivos',
|
||||
messageQueue: 'Fila de mensagens',
|
||||
removeQueuedMessage: 'Remover mensagem da fila',
|
||||
stop: 'Parar',
|
||||
send: 'Enviar',
|
||||
contextUsed: 'Contexto utilizado:',
|
||||
|
||||
@@ -128,6 +128,8 @@ export default {
|
||||
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
||||
attachFiles: '添加附件',
|
||||
autoPlaySpeech: '自动播放语音',
|
||||
messageQueue: '消息队列',
|
||||
removeQueuedMessage: '移除队列消息',
|
||||
stop: '停止',
|
||||
start: '启动',
|
||||
stopGateway: '停止网关',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user