Files
Hermes-ui/packages/client/src/stores/hermes/chat.ts
T
ekkoandClaude Opus 4.7 fc02348ebd fix: prevent message loss on abort by deferring DB writes to flush (#591)
Defer all non-user message DB writes until response completion or
abort, instead of writing tool calls immediately during streaming.
This ensures correct message ordering and prevents the abort handler
from overwriting displayed messages with incomplete DB data.

- Remove immediate addMessage() calls from response.output_item.done
- Remove immediate addMessage() from insertResponseTextOnce
- Add flushResponseRunToDb() to batch-write all run messages on
  both normal completion (markCompleted) and abort (handleAbort)
- Skip user messages in flush (already written in handleRun)
- Remove refreshActiveSession() from abort.completed frontend handler

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:10:01 +08:00

1716 lines
59 KiB
TypeScript

import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAppStore } from './app'
import { useProfilesStore } from './profiles'
import { useSettingsStore } from './settings'
import { primeCompletionSound, playCompletionSound } from '@/utils/completion-sound'
import { detectThinkingBoundary } from '@/utils/thinking-parser'
// Re-export ContentBlock for convenience
export type ContentBlock = ContentBlockImport
export interface Attachment {
id: string
name: string
type: string
size: number
url: string
file?: File
}
export interface Message {
id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
timestamp: number
toolName?: string
toolCallId?: string
toolPreview?: string
toolArgs?: string
toolResult?: string
toolStatus?: 'running' | 'done' | 'error'
toolDuration?: number // 工具执行时长(秒)
isStreaming?: boolean
attachments?: Attachment[]
// 思考/推理文本。两条来源:
// 1) 历史消息:来自 HermesMessage.reasoning 字段
// 2) 流式:由 reasoning.delta / thinking.delta / reasoning.available 事件累加
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
reasoning?: string
queued?: boolean
}
export interface Session {
id: string
title: string
source?: string
messages: Message[]
createdAt: number
updatedAt: number
model?: string
provider?: string
messageCount?: number
inputTokens?: number
outputTokens?: number
endedAt?: number | null
lastActiveAt?: number
workspace?: string | null
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
if (attachments.length === 0) return []
const formData = new FormData()
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const token = localStorage.getItem('hermes_api_key') || ''
const res = await fetch('/upload', {
method: 'POST',
body: formData,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
async function buildContentBlocks(
content: string,
attachments?: Attachment[],
uploadedFiles?: { name: string; path: string }[]
): Promise<ContentBlock[]> {
const blocks: ContentBlock[] = []
// Add text block if content is not empty
if (content.trim()) {
blocks.push({ type: 'text', text: content.trim() })
}
// Add attachment blocks using uploaded file paths
if (attachments && attachments.length > 0 && uploadedFiles) {
for (let i = 0; i < uploadedFiles.length; i++) {
const uploaded = uploadedFiles[i]
const attachment = attachments[i]
// Check if it's an image
if (attachment?.type.startsWith('image/')) {
blocks.push({
type: 'image',
name: uploaded.name,
path: uploaded.path,
media_type: attachment.type,
})
} else {
// Other files
blocks.push({
type: 'file',
name: uploaded.name,
path: uploaded.path,
media_type: attachment?.type,
})
}
}
}
return blocks
}
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
// Filter out assistant messages with empty content
const filteredMsgs = msgs.filter(m => {
if (m.role === 'assistant') {
return m.content && m.content.trim() !== ''
}
return true
})
// Build lookups from assistant messages with tool_calls
const toolNameMap = new Map<string, string>()
const toolArgsMap = new Map<string, string>()
for (const msg of filteredMsgs) {
if (msg.role === 'assistant' && msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.id) {
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments)
}
}
}
}
const result: Message[] = []
for (const msg of filteredMsgs) {
// Skip assistant messages that only contain tool_calls (no meaningful content)
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
// Emit a tool.started message for each tool call
for (const tc of msg.tool_calls) {
result.push({
id: String(msg.id) + '_' + tc.id,
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName: tc.function?.name || 'tool',
toolCallId: tc.id,
toolArgs: tc.function?.arguments || undefined,
toolStatus: 'done',
})
}
continue
}
// Tool result messages
if (msg.role === 'tool') {
const tcId = msg.tool_call_id || ''
const toolName = msg.tool_name || toolNameMap.get(tcId) || 'tool'
const toolArgs = toolArgsMap.get(tcId) || undefined
// Extract a short preview from the content
let preview = ''
if (msg.content) {
try {
const parsed = JSON.parse(msg.content)
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
} catch {
preview = msg.content.slice(0, 80)
}
}
// Find and remove the matching placeholder from tool_calls above
const placeholderIdx = result.findIndex(
m => m.role === 'tool' && m.toolName === toolName && !m.toolResult && m.id.includes('_' + tcId)
)
if (placeholderIdx !== -1) {
result.splice(placeholderIdx, 1)
}
result.push({
id: String(msg.id),
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName,
toolCallId: tcId || undefined,
toolArgs,
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
toolResult: msg.content || undefined,
toolStatus: 'done',
})
continue
}
// Normal user/assistant messages
result.push({
id: String(msg.id),
role: msg.role,
content: msg.content || '',
timestamp: Math.round(msg.timestamp * 1000),
reasoning: msg.reasoning ? msg.reasoning : undefined,
})
}
return result
}
function mapHermesSession(s: SessionSummary): Session {
return {
id: s.id,
title: s.title || '',
source: s.source || undefined,
messages: [],
createdAt: Math.round(s.started_at * 1000),
updatedAt: Math.round((s.last_active || s.ended_at || s.started_at) * 1000),
model: s.model,
provider: (s as any).billing_provider || '',
messageCount: s.message_count,
endedAt: s.ended_at != null ? Math.round(s.ended_at * 1000) : null,
lastActiveAt: s.last_active != null ? Math.round(s.last_active * 1000) : undefined,
workspace: s.workspace || null,
}
}
const STORAGE_KEY_PREFIX = 'hermes_active_session_'
const LEGACY_STORAGE_KEY = 'hermes_active_session'
// 获取当前 profile 名称,用于隔离缓存。
// 从 profiles store 的 activeProfileName(同步 localStorage)读取,
// 避免异步加载导致 chat store 初始化时拿到 null。
function getProfileName(): string {
try {
return useProfilesStore().activeProfileName || 'default'
} catch {
return 'default'
}
}
function storageKey(): string { return STORAGE_KEY_PREFIX + getProfileName() }
function legacyStorageKey(): string | null { return getProfileName() === 'default' ? LEGACY_STORAGE_KEY : null }
function isQuotaExceededError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const e = error as { name?: string, code?: number }
return e.name === 'QuotaExceededError' || e.code === 22 || e.code === 1014
}
function recoverStorageQuota() {
try {
// 清理所有会话相关的旧缓存(已完全废弃)
const prefixes = [
'hermes_sessions_cache_v1_',
'hermes_session_msgs_v1_',
'hermes_session_pins_v1_',
'hermes_human_only_v1_',
]
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key) continue
if (key === storageKey() || key === LEGACY_STORAGE_KEY) continue
if (prefixes.some(prefix => key.startsWith(prefix))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => removeItem(key))
if (keysToRemove.length > 0) {
console.log(`Recovered storage: cleared ${keysToRemove.length} old session cache entries`)
}
} catch {
// ignore
}
}
function setItemBestEffort(key: string, value: string) {
try {
localStorage.setItem(key, value)
return
} catch (error) {
if (!isQuotaExceededError(error)) return
}
recoverStorageQuota()
try {
localStorage.setItem(key, value)
} catch {
// quota exceeded or private mode — ignore, cache is best-effort
}
}
function removeItem(key: string) {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
// Strip the circular `file: File` reference from attachments before caching —
// File objects don't serialize and we only need name/type/size/url for display.
export const useChatStore = defineStore('chat', () => {
const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null)
const focusMessageId = ref<string | null>(null)
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)
function setAutoPlaySpeech(enabled: boolean) {
autoPlaySpeechEnabled.value = enabled
}
const isStreaming = computed(() => {
const sid = activeSessionId.value
if (sid == null) return false
return streamStates.value.has(sid) || serverWorking.value.has(sid)
})
const isLoadingSessions = ref(false)
const sessionsLoaded = ref(false)
const isLoadingMessages = ref(false)
const isRunActive = computed(() => isStreaming.value)
// Compression state
const compressionState = ref<{
compressing: boolean
messageCount: number
beforeTokens: number
afterTokens: number
compressed: boolean | null
error?: string
} | null>(null)
function setCompressionState(state: typeof compressionState.value) {
compressionState.value = state
}
const abortState = ref<{
aborting: boolean
synced: boolean | null
error?: string
} | null>(null)
const isAborting = computed(() => abortState.value?.aborting === true)
function setAbortState(state: typeof abortState.value) {
abortState.value = state
}
const activeSession = ref<Session | null>(null)
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
function isSessionLive(sessionId: string): boolean {
return streamStates.value.has(sessionId) || serverWorking.value.has(sessionId)
}
async function loadSessions() {
isLoadingSessions.value = true
try {
const list = await fetchSessions()
const fresh = list.map(mapHermesSession)
// Preserve already-loaded messages for sessions that are still present,
// so we don't blow away the active session's messages on refresh.
const msgsByIdBefore = new Map(sessions.value.map(s => [s.id, s.messages]))
for (const s of fresh) {
const prev = msgsByIdBefore.get(s.id)
if (prev && prev.length) s.messages = prev
}
sessions.value = fresh
// Restore last active session, fallback to most recent
const savedId = activeSessionId.value
const targetId = savedId && sessions.value.some(s => s.id === savedId)
? savedId
: sessions.value[0]?.id
if (targetId) {
await switchSession(targetId)
}
} catch (err) {
console.error('Failed to load sessions:', err)
} finally {
isLoadingSessions.value = false
sessionsLoaded.value = true
}
}
// Re-pull active session from server. Used on tab-visible events.
async function refreshActiveSession(): Promise<boolean> {
const sid = activeSessionId.value
if (!sid) return false
try {
const detail = await fetchSession(sid)
if (!detail) return false
const target = sessions.value.find(s => s.id === sid)
if (!target) return false
const mapped = mapHermesMessages(detail.messages || [])
target.messages = mapped
if (detail.title) target.title = detail.title
return true
} catch (err) {
console.error('Failed to refresh active session:', err)
return false
}
}
function createSession(): Session {
const session: Session = {
id: uid(),
title: '',
source: 'api_server',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
sessions.value.unshift(session)
return session
}
async function switchSession(sessionId: string, focusId?: string | null) {
clearThinkingObservationFor(sessionId)
activeSessionId.value = sessionId
focusMessageId.value = focusId ?? null
setItemBestEffort(storageKey(), sessionId)
const legacyActiveKey = legacyStorageKey()
if (legacyActiveKey) removeItem(legacyActiveKey)
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
if (!activeSession.value) return
isLoadingMessages.value = true
try {
// Load messages via Socket.IO resume (server loads from DB if not in memory)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('resume timeout')), 15_000)
resumeSession(sessionId, (data) => {
clearTimeout(timeout)
if (data.isWorking) {
serverWorking.value.add(sessionId)
} 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) {
setAbortState(null)
}
if (data.inputTokens != null) activeSession.value!.inputTokens = data.inputTokens
if (data.outputTokens != null) activeSession.value!.outputTokens = data.outputTokens
if (data.messages?.length) {
activeSession.value!.messages = mapHermesMessages(data.messages as any[])
}
if (!activeSession.value!.title) {
const firstUser = activeSession.value!.messages.find(m => m.role === 'user')
if (firstUser) {
const t = firstUser.content.slice(0, 40)
activeSession.value!.title = t + (firstUser.content.length > 40 ? '...' : '')
}
}
// Process replayed events (compression state etc.)
if (data.events?.length) {
for (const evt of data.events) {
const e = evt.data as any
if (e.event === 'compression.started') {
setCompressionState({
compressing: true,
messageCount: e.message_count || 0,
beforeTokens: e.token_count || 0,
afterTokens: 0,
compressed: null,
})
} else if (e.event === 'compression.completed') {
setCompressionState({
compressing: false,
messageCount: e.totalMessages || 0,
beforeTokens: e.beforeTokens || 0,
afterTokens: e.afterTokens || 0,
compressed: e.compressed ?? false,
error: e.error,
})
} else if (e.event === 'abort.started') {
setAbortState({ aborting: true, synced: null })
} else if (e.event === 'abort.completed') {
setAbortState({ aborting: false, synced: e.synced ?? false })
}
}
}
resolve()
})
})
} catch (err) {
console.error('Failed to load session messages via resume:', err)
} finally {
isLoadingMessages.value = false
}
// Resume in-flight run event listeners if needed
resumeServerWorkingRun(sessionId)
}
function newChat() {
const session = createSession()
// Inherit current global model
const appStore = useAppStore()
session.model = appStore.selectedModel || undefined
switchSession(session.id)
}
async function switchSessionModel(modelId: string, provider?: string) {
if (!activeSession.value) return
activeSession.value.model = modelId
activeSession.value.provider = provider || ''
// If provider changed, update global config too (Hermes requires it)
if (provider) {
const { useAppStore } = await import('./app')
await useAppStore().switchModel(modelId, provider)
}
}
async function deleteSession(sessionId: string) {
await deleteSessionApi(sessionId)
sessions.value = sessions.value.filter(s => s.id !== sessionId)
if (activeSessionId.value === sessionId) {
if (sessions.value.length > 0) {
await switchSession(sessions.value[0].id)
} else {
const session = createSession()
switchSession(session.id)
}
}
}
function getSessionMsgs(sessionId: string): Message[] {
const s = sessions.value.find(s => s.id === sessionId)
return s?.messages || []
}
function addMessage(sessionId: string, msg: Message) {
const s = sessions.value.find(s => s.id === sessionId)
if (s) s.messages.push(msg)
}
function addOrUpdateSession(session: Session) {
const existingIndex = sessions.value.findIndex(s => s.id === session.id)
if (existingIndex !== -1) {
// Update existing session
sessions.value[existingIndex] = session
} else {
// Add new session
sessions.value.push(session)
}
}
function updateMessage(sessionId: string, id: string, update: Partial<Message>) {
const s = sessions.value.find(s => s.id === sessionId)
if (!s) return
const idx = s.messages.findIndex(m => m.id === id)
if (idx !== -1) {
s.messages[idx] = { ...s.messages[idx], ...update }
}
}
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
if (!target.title) {
const firstUser = target.messages.find(m => m.role === 'user')
if (firstUser) {
const title = firstUser.attachments?.length
? firstUser.attachments.map(a => a.name).join(', ')
: firstUser.content
target.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
}
}
target.updatedAt = Date.now()
}
function primeCompletionBellIfEnabled() {
if (useSettingsStore().display.bell_on_complete) {
primeCompletionSound()
}
}
function playCompletionBellIfEnabled() {
if (useSettingsStore().display.bell_on_complete) {
void playCompletionSound()
}
}
async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0))) return
primeCompletionBellIfEnabled()
if (!activeSession.value) {
const session = createSession()
switchSession(session.id)
}
// 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(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined,
queued: shouldQueue,
}
if (!shouldQueue) {
addMessage(sid, userMsg)
updateSessionTitle(sid)
}
try {
// Build input in Anthropic format
let input: string | ContentBlock[]
if (attachments && attachments.length > 0) {
// Has attachments: upload first, then build content blocks
const uploaded = await uploadFiles(attachments)
// Update attachment URLs on the user message for display
const token = getApiKey()
const urlMap = new Map(uploaded.map(f => {
const base = `/api/hermes/download?path=${encodeURIComponent(f.path)}&name=${encodeURIComponent(f.name)}`
return [f.name, token ? `${base}&token=${encodeURIComponent(token)}` : base]
}))
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
input = await buildContentBlocks(content, attachments, uploaded)
} else {
// No attachments: use plain text format
input = content.trim()
}
const appStore = useAppStore()
const sessionModel = activeSession.value?.model || appStore.selectedModel
const runPayload = {
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
const cleanup = () => {
streamStates.value.delete(sid)
serverWorking.value.delete(sid)
}
// 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 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(
runPayload,
// onEvent
(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,
messageCount: (evt as any).message_count || 0,
beforeTokens: (evt as any).token_count || 0,
afterTokens: 0,
compressed: null,
})
break
}
case 'compression.completed': {
setCompressionState({
compressing: false,
messageCount: (evt as any).totalMessages || 0,
beforeTokens: (evt as any).beforeTokens || 0,
afterTokens: (evt as any).afterTokens || 0,
compressed: (evt as any).compressed ?? false,
error: (evt as any).error,
})
// Auto-clear after 5s
setTimeout(() => {
if (compressionState.value && !compressionState.value.compressing) {
setCompressionState(null)
}
}, 5000)
break
}
case 'abort.started': {
setAbortState({ aborting: true, synced: null })
break
}
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) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'done' }
}
})
cleanup()
setAbortState(null)
break
}
case 'reasoning.delta':
case 'thinking.delta': {
const text = evt.text || evt.delta || ''
if (!text) break
runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: null
if (last?.role === 'assistant' && last.isStreaming) {
last.reasoning = (last.reasoning || '') + text
noteReasoningStart(last.id)
} else {
const newId = uid()
addMessage(sid, {
id: newId,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
reasoning: text,
})
activeAssistantMessageId = newId
noteReasoningStart(newId)
}
break
}
case 'reasoning.available': {
// Upstream run_agent.py fires reasoning.available with
// `assistant_message.content[:500]` as the preview — i.e.,
// the main answer, not real reasoning. Ignore the payload
// and only use this event as a "thinking ended" signal so
// the duration counter stops.
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
if (last?.role === 'assistant' && last.isStreaming) {
// 只有当 reasoning.delta 事件曾经启动过计时,才标记结束;
// 否则(上游未转发 delta,只发这一次 available)不显示时长。
noteReasoningEnd(last.id)
}
break
}
case 'message.delta': {
if (evt.delta) runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
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 || '')
noteThinkingDelta(last.id, prev, next)
// 若之前有 reasoning 累积,则 content 到达即视为推理结束。
if (last.reasoning) noteReasoningEnd(last.id)
last.content = next
} else {
const newId = uid()
const nextContent = evt.delta || ''
noteThinkingDelta(newId, '', nextContent)
addMessage(sid, {
id: newId,
role: 'assistant',
content: nextContent,
timestamp: Date.now(),
isStreaming: true,
})
activeAssistantMessageId = newId
}
break
}
case 'tool.started': {
runHadToolActivity = true
const msgs = getSessionMsgs(sid)
const toolCallId = (evt as any).tool_call_id as string | undefined
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
activeAssistantMessageId = null
const existingTool = toolCallId
? msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId)
: null
if (existingTool) {
updateMessage(sid, existingTool.id, {
toolName: evt.tool || evt.name,
toolArgs: typeof (evt as any).arguments === 'string' ? (evt as any).arguments : existingTool.toolArgs,
toolPreview: evt.preview || existingTool.toolPreview,
toolStatus: existingTool.toolStatus || 'running',
})
break
}
addMessage(sid, {
id: uid(),
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: evt.tool || evt.name,
toolCallId,
toolPreview: evt.preview,
toolArgs: typeof (evt as any).arguments === 'string' ? (evt as any).arguments : undefined,
toolStatus: 'running',
})
break
}
case 'tool.completed': {
runHadToolActivity = true
const msgs = getSessionMsgs(sid)
const toolCallId = (evt as any).tool_call_id as string | undefined
const toolMsgs = toolCallId
? msgs.filter(m => m.role === 'tool' && m.toolCallId === toolCallId)
: msgs.filter(m => m.role === 'tool' && m.toolStatus === 'running')
if (toolMsgs.length > 0) {
const last = toolMsgs[toolMsgs.length - 1]
// Check if tool errored
const hasError = (evt as any).error === true
const duration = (evt as any).duration
updateMessage(sid, last.id, {
toolStatus: hasError ? 'error' : 'done',
toolDuration: duration,
toolResult: typeof (evt as any).output === 'string' ? (evt as any).output : undefined,
})
}
break
}
case 'run.completed': {
const msgs = getSessionMsgs(sid)
const lastMsg = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
// Server-computed usage (local countTokens, snapshot-aware)
if ((evt as any).inputTokens != null) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
target.inputTokens = (evt as any).inputTokens
target.outputTokens = (evt as any).outputTokens
}
}
// Belt-and-suspenders: some providers may deliver the final
// assistant text only via run.completed.output (no message.delta
// stream). If we never produced assistant text but the gateway
// reports a non-empty output, fall back to rendering it as a
// single assistant message so the user actually sees the reply.
// Check if backend provided parsed content (from stringified array format)
let finalOutputTrimmed = ''
if ((evt as any).parsed_content !== undefined) {
// Backend has parsed stringified array format, update last assistant message
const msgs = getSessionMsgs(sid)
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 || '',
})
if ((evt as any).parsed_reasoning) {
updateMessage(sid, lastAssistant.id, {
reasoning: (evt as any).parsed_reasoning,
})
}
finalOutputTrimmed = ((evt as any).parsed_content || '').trim()
}
} else {
// Fallback to output field (legacy behavior)
const finalOutput =
typeof evt.output === 'string' ? evt.output : ''
finalOutputTrimmed = finalOutput.trim()
if (!runProducedAssistantText && finalOutputTrimmed !== '') {
addMessage(sid, {
id: uid(),
role: 'assistant',
content: finalOutput,
timestamp: Date.now(),
})
runProducedAssistantText = true
}
}
// Workaround for upstream hermes-agent bug: when the agent
// layer silently swallows an error (e.g. invalid API key,
// unsupported model), the gateway still emits run.completed
// with an empty output. Without surfacing it here the chat UI
// looks frozen / "succeeded with no reply". Detect by the
// combination of: no assistant text AND no tool activity AND
// empty final output. Usage being zero is a *supporting*
// signal but not required, since some providers/local models
// legitimately omit usage.
const swallowedError =
!runProducedAssistantText &&
!runHadToolActivity &&
finalOutputTrimmed === ''
if (swallowedError) {
addMessage(sid, {
id: uid(),
role: 'system',
content: 'Error: Agent returned no output. The model call may have failed (e.g. invalid API key, model not supported by provider, or context exceeded). Check the hermes-agent logs for details.',
timestamp: Date.now(),
})
} else {
playCompletionBellIfEnabled()
}
// 自动播放语音
if (autoPlaySpeechEnabled.value) {
const msgs = getSessionMsgs(sid)
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')
if (lastAssistant?.content) {
// 延迟一小会儿再播放,确保 UI 更新完成
setTimeout(() => {
playMessageSpeech(lastAssistant.id, lastAssistant.content)
}, 300)
}
}
if ((evt as any).queue_remaining > 0) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
} else {
cleanup()
}
activeAssistantMessageId = null
updateSessionTitle(sid)
break
}
case 'run.failed': {
const msgs = getSessionMsgs(sid)
const lastErr = msgs[msgs.length - 1]
if (lastErr?.isStreaming) {
updateMessage(sid, lastErr.id, {
isStreaming: false,
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
role: 'system',
})
} else {
addMessage(sid, {
id: uid(),
role: 'system',
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
timestamp: Date.now(),
})
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'error' }
}
})
if ((evt as any).queue_remaining > 0) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
} else {
cleanup()
}
break
}
case 'usage.updated': {
const target = sessions.value.find(s => s.id === sid)
if (target) {
target.inputTokens = (evt as any).inputTokens
target.outputTokens = (evt as any).outputTokens
}
break
}
}
},
// onDone
() => {
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
cleanup()
updateSessionTitle(sid)
},
// onError
(err) => {
console.warn('Socket.IO run stream error:', err.message)
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'done' }
}
})
cleanup()
if (sid === activeSessionId.value) {
void refreshActiveSession()
}
},
undefined,
)
streamStates.value.set(sid, ctrl)
} catch (err: any) {
addMessage(sid, {
id: uid(),
role: 'system',
content: `Error: ${err.message}`,
timestamp: Date.now(),
})
}
}
/**
* Resume an in-flight run after page refresh.
* Emits 'resume' to join the session room on the server,
* then sets up event listeners to receive ongoing events.
*/
function resumeServerWorkingRun(sid: string) {
// Don't register duplicate listeners if already streaming
if (streamStates.value.has(sid)) return
// Only set up listeners if the server reported an active run during resume.
if (!serverWorking.value.has(sid)) return
let closed = false
let runProducedAssistantText = false
let runHadToolActivity = false
let activeAssistantMessageId: string | null = null
const cleanup = () => {
if (closed) return
closed = true
streamStates.value.delete(sid)
serverWorking.value.delete(sid)
// Unregister from global session handlers
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': {
setCompressionState({
compressing: true,
messageCount: (evt as any).message_count || 0,
beforeTokens: (evt as any).token_count || 0,
afterTokens: 0,
compressed: null,
})
break
}
case 'compression.completed': {
setCompressionState({
compressing: false,
messageCount: (evt as any).totalMessages || 0,
beforeTokens: (evt as any).beforeTokens || 0,
afterTokens: (evt as any).afterTokens || 0,
compressed: (evt as any).compressed ?? false,
error: (evt as any).error,
})
setTimeout(() => {
if (compressionState.value && !compressionState.value.compressing) {
setCompressionState(null)
}
}, 5000)
break
}
case 'abort.started': {
setAbortState({ aborting: true, synced: null })
break
}
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) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'done' }
}
})
cleanup()
setAbortState(null)
break
}
case 'reasoning.delta':
case 'thinking.delta': {
const text = evt.text || evt.delta || ''
if (!text) break
runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: null
if (last?.role === 'assistant' && last.isStreaming) {
last.reasoning = (last.reasoning || '') + text
noteReasoningStart(last.id)
} else {
const newId = uid()
addMessage(sid, {
id: newId,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
reasoning: text,
})
activeAssistantMessageId = newId
noteReasoningStart(newId)
}
break
}
case 'reasoning.available': {
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
if (last?.role === 'assistant' && last.isStreaming) {
noteReasoningEnd(last.id)
}
break
}
case 'message.delta': {
if (evt.delta) runProducedAssistantText = true
const msgs = getSessionMsgs(sid)
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 || '')
noteThinkingDelta(last.id, prev, next)
if (last.reasoning) noteReasoningEnd(last.id)
last.content = next
} else {
const newId = uid()
const nextContent = evt.delta || ''
noteThinkingDelta(newId, '', nextContent)
addMessage(sid, {
id: newId,
role: 'assistant',
content: nextContent,
timestamp: Date.now(),
isStreaming: true,
})
activeAssistantMessageId = newId
}
break
}
case 'tool.started': {
runHadToolActivity = true
const msgs = getSessionMsgs(sid)
const toolCallId = (evt as any).tool_call_id as string | undefined
const last = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
}
activeAssistantMessageId = null
const existingTool = toolCallId
? msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId)
: null
if (existingTool) {
updateMessage(sid, existingTool.id, {
toolName: evt.tool || evt.name,
toolArgs: typeof (evt as any).arguments === 'string' ? (evt as any).arguments : existingTool.toolArgs,
toolPreview: evt.preview || existingTool.toolPreview,
toolStatus: existingTool.toolStatus || 'running',
})
break
}
addMessage(sid, {
id: uid(),
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: evt.tool || evt.name,
toolCallId,
toolPreview: evt.preview,
toolArgs: typeof (evt as any).arguments === 'string' ? (evt as any).arguments : undefined,
toolStatus: 'running',
})
break
}
case 'tool.completed': {
runHadToolActivity = true
const msgs = getSessionMsgs(sid)
const toolCallId = (evt as any).tool_call_id as string | undefined
const toolMsgs = toolCallId
? msgs.filter(m => m.role === 'tool' && m.toolCallId === toolCallId)
: msgs.filter(m => m.role === 'tool' && m.toolStatus === 'running')
if (toolMsgs.length > 0) {
const hasError = (evt as any).error === true
updateMessage(sid, toolMsgs[toolMsgs.length - 1].id, {
toolStatus: hasError ? 'error' : 'done',
toolDuration: (evt as any).duration,
toolResult: typeof (evt as any).output === 'string' ? (evt as any).output : undefined,
})
}
break
}
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 = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
: msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
// Server-computed usage (local countTokens, snapshot-aware)
if ((evt as any).inputTokens != null) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
target.inputTokens = (evt as any).inputTokens
target.outputTokens = (evt as any).outputTokens
}
}
// Check if backend provided parsed content (from stringified array format)
let finalOutputTrimmed = ''
if ((evt as any).parsed_content !== undefined) {
// Backend has parsed stringified array format, update last assistant message
const msgs = getSessionMsgs(sid)
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 || '',
})
if ((evt as any).parsed_reasoning) {
updateMessage(sid, lastAssistant.id, {
reasoning: (evt as any).parsed_reasoning,
})
}
finalOutputTrimmed = ((evt as any).parsed_content || '').trim()
}
} else {
// Fallback to output field (legacy behavior)
const finalOutput = typeof evt.output === 'string' ? evt.output : ''
finalOutputTrimmed = finalOutput.trim()
if (!runProducedAssistantText && finalOutputTrimmed !== '') {
addMessage(sid, {
id: uid(),
role: 'assistant',
content: finalOutput,
timestamp: Date.now(),
})
}
}
const swallowedError = !runProducedAssistantText && !runHadToolActivity && finalOutputTrimmed === ''
if (swallowedError) {
addMessage(sid, {
id: uid(),
role: 'system',
content: 'Error: Agent returned no output. The model call may have failed (e.g. invalid API key, model not supported by provider, or context exceeded). Check the hermes-agent logs for details.',
timestamp: Date.now(),
})
} else {
playCompletionBellIfEnabled()
}
// 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) {
updateMessage(sid, lastErr.id, {
isStreaming: false,
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
role: 'system',
})
} else {
addMessage(sid, {
id: uid(),
role: 'system',
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
timestamp: Date.now(),
})
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'error' }
}
})
if (!hasQueue) {
cleanup()
}
break
}
case 'usage.updated': {
const target = sessions.value.find(s => s.id === sid)
if (target) {
target.inputTokens = (evt as any).inputTokens
target.outputTokens = (evt as any).outputTokens
}
break
}
}
}
// Register handlers in global session map
registerSessionHandlers(sid, {
onMessageDelta: (evt) => handleEvent(evt),
onReasoningDelta: (evt) => handleEvent(evt),
onThinkingDelta: (evt) => handleEvent(evt),
onReasoningAvailable: (evt) => handleEvent(evt),
onToolStarted: (evt) => handleEvent(evt),
onToolCompleted: (evt) => handleEvent(evt),
onRunStarted: (evt) => handleEvent(evt),
onRunCompleted: (evt) => handleEvent(evt),
onRunFailed: (evt) => handleEvent(evt),
onCompressionStarted: (evt) => handleEvent(evt),
onCompressionCompleted: (evt) => handleEvent(evt),
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.
// Server already joined room and replayed events.
// Just set up handlers for ongoing streaming events.
// Mark as streaming so UI shows the indicator and can still abort after refresh.
streamStates.value.set(sid, {
abort: () => {
getChatRunSocket()?.emit('abort', { session_id: sid })
},
})
}
function stopStreaming() {
const sid = activeSessionId.value
if (!sid) return
if (isAborting.value) return
const ctrl = streamStates.value.get(sid)
if (ctrl) {
setAbortState({ aborting: true, synced: null })
ctrl.abort()
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
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)
}
}
// Tab visibility: re-sync when returning to foreground
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && activeSessionId.value && !isStreaming.value) {
const sid = activeSessionId.value
if (sid && !streamStates.value.has(sid)) {
// Re-load messages via resume (server loads from DB)
resumeSession(sid, (data) => {
if (data.isWorking) {
serverWorking.value.add(sid)
} else {
serverWorking.value.delete(sid)
}
if (data.isAborting) {
setAbortState({ aborting: true, synced: null })
} else if (!data.isWorking) {
setAbortState(null)
}
if (data.messages?.length && activeSession.value) {
activeSession.value.messages = mapHermesMessages(data.messages as any[])
}
resumeServerWorkingRun(sid)
})
}
}
})
}
// Transient observation of <think> boundaries during active streaming.
// Not persisted; cleared on session switch. See spec §5.3.
const thinkingObservation = new Map<string, { startedAt?: number; endedAt?: number }>()
function getThinkingObservation(messageId: string) {
return thinkingObservation.get(messageId)
}
function noteThinkingDelta(messageId: string, prevContent: string, nextContent: string) {
const { startedAtBoundary, endedAtBoundary } = detectThinkingBoundary(prevContent, nextContent)
if (!startedAtBoundary && !endedAtBoundary) return
const existing = thinkingObservation.get(messageId) || {}
if (startedAtBoundary && existing.startedAt === undefined) {
existing.startedAt = Date.now()
}
if (endedAtBoundary && existing.endedAt === undefined) {
existing.endedAt = Date.now()
}
thinkingObservation.set(messageId, existing)
}
/** 第一次见到某条消息的 reasoning 文本时,标记 startedAt。 */
function noteReasoningStart(messageId: string) {
const existing = thinkingObservation.get(messageId) || {}
if (existing.startedAt === undefined) {
existing.startedAt = Date.now()
thinkingObservation.set(messageId, existing)
}
}
/** 内容首次到达(视为推理结束)或显式收到 reasoning.available 时,标记 endedAt。 */
function noteReasoningEnd(messageId: string) {
const existing = thinkingObservation.get(messageId)
if (!existing || existing.startedAt === undefined) return
if (existing.endedAt === undefined) {
existing.endedAt = Date.now()
thinkingObservation.set(messageId, existing)
}
}
function clearProviderFromSessions(provider: string) {
if (!provider) return
const target = provider.toLowerCase()
for (const s of sessions.value) {
if ((s.provider || '').toLowerCase() === target) {
s.model = undefined
s.provider = ''
}
}
}
function clearThinkingObservationFor(_sessionId: string) {
// messageId 与 sessionId 的关联未单独持有;方案是切会话时一律清空。
// 这符合 spec 定义:observation 是"当前会话范围内"的 transient 状态。
thinkingObservation.clear()
}
// 播放消息语音
function playMessageSpeech(messageId: string, content: string) {
// 触发自定义事件,让 MessageItem 组件处理播放
const event = new CustomEvent('auto-play-speech', {
detail: { messageId, content }
})
window.dispatchEvent(event)
}
return {
sessions,
activeSessionId,
activeSession,
focusMessageId,
messages,
isStreaming,
isRunActive,
isSessionLive,
compressionState,
abortState,
isAborting,
queueLengths,
queuedUserMessages,
removeQueuedMessage,
isLoadingSessions,
sessionsLoaded,
isLoadingMessages,
newChat,
switchSession,
switchSessionModel,
addOrUpdateSession,
clearProviderFromSessions,
deleteSession,
sendMessage,
stopStreaming,
loadSessions,
refreshActiveSession,
getThinkingObservation,
noteThinkingDelta,
noteReasoningStart,
noteReasoningEnd,
clearThinkingObservationFor,
setAutoPlaySpeech,
playMessageSpeech,
}
})