import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, onSessionCommand, respondClarify, type RunEvent, type ResumeSessionPayload, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions' import { getActiveProfileName } from '@/api/client' import { getDownloadUrl } from '@/api/hermes/download' 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' | 'command' 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 事件累加 // 不含 包裹标签;内容自身可以为多段纯文本。 reasoning?: string queued?: boolean systemType?: 'command' | 'error' commandAction?: string commandData?: Record } export interface PendingApproval { sessionId: string approvalId: string command: string description: string choices: Array<'once' | 'session' | 'always' | 'deny'> allowPermanent: boolean requestedAt: number } export interface PendingClarify { sessionId: string clarifyId: string question: string choices: string[] | null timeoutMs: number requestedAt: number } export interface Session { id: string profile?: string title: string source?: string messages: Message[] createdAt: number updatedAt: number model?: string provider?: string messageCount?: number inputTokens?: number outputTokens?: number contextTokens?: number endedAt?: number | null lastActiveAt?: number workspace?: string | null } interface CompressionState { compressing: boolean messageCount: number beforeTokens: number afterTokens: number compressed: boolean | null error?: string } function uid(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) } function isToolOutputError(output: unknown): boolean { if (typeof output !== 'string' || !output.trim()) return false try { const parsed = JSON.parse(output) if (parsed && typeof parsed === 'object') { const record = parsed as Record if (record.success === false) return true if (record.error != null && String(record.error).trim() !== '') return true } } catch { return false } return false } 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 profileName = getActiveProfileName() const headers: Record = {} if (token) headers.Authorization = `Bearer ${token}` if (profileName) headers['X-Hermes-Profile'] = profileName const res = await fetch('/upload', { method: 'POST', body: formData, headers, }) 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 { 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 no display content unless they carry tool call metadata // needed to name later tool result rows when resuming persisted history. const filteredMsgs = msgs.filter(m => { if (m.role === 'assistant') { return (m.tool_calls?.length || 0) > 0 || (m.content && m.content.trim() !== '') } return true }) // Build lookups from assistant messages with tool_calls const toolNameMap = new Map() const toolArgsMap = new Map() 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 || undefined, 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) || undefined 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/command 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, systemType: msg.role === 'command' ? 'command' : undefined, }) } return result } function mapHermesSession(s: SessionSummary): Session { return { id: s.id, profile: s.profile || 'default', 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.provider || (s as any).billing_provider || '', messageCount: s.message_count, inputTokens: s.input_tokens, outputTokens: s.output_tokens, 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 getItemBestEffort(key: string): string | null { try { return localStorage.getItem(key) } catch { return null } } 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 seenSessionCommandEvents = new WeakSet() const sessions = ref([]) const activeSessionId = ref(null) const focusMessageId = ref(null) const streamStates = ref void }>>(new Map()) /** sessionId → server-reported isWorking status */ const serverWorking = ref>(new Set()) const sessionProfileFilter = ref(null) /** sessionId → queued message count */ const queueLengths = ref>(new Map()) /** sessionId → queued user messages not yet visible in the transcript */ const queuedUserMessages = ref>(new Map()) /** sessionId → queue ids that server reported as dequeued before the peer message arrived */ const dequeuedQueueIds = ref>>(new Map()) const pendingApprovals = ref>(new Map()) const activePendingApproval = computed(() => { const sid = activeSessionId.value return sid ? pendingApprovals.value.get(sid) || null : null }) const pendingClarifies = ref>(new Map()) const activePendingClarify = computed(() => { const sid = activeSessionId.value return sid ? pendingClarifies.value.get(sid) || null : null }) // 自动播放语音开关 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 is scoped per session because sockets can stay joined to // background sessions while another chat is active. const compressionStates = ref>(new Map()) const compressionState = computed(() => { const sid = activeSessionId.value return sid ? compressionStates.value.get(sid) || null : null }) function setCompressionState(sessionId: string | null | undefined, state: CompressionState | null) { if (!sessionId) return const next = new Map(compressionStates.value) if (state) next.set(sessionId, state) else next.delete(sessionId) compressionStates.value = next } 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(null) const messages = computed(() => activeSession.value?.messages || []) function isSessionLive(sessionId: string): boolean { return streamStates.value.has(sessionId) || serverWorking.value.has(sessionId) } function clearActiveSession() { const sid = activeSessionId.value activeSessionId.value = null activeSession.value = null focusMessageId.value = null setAbortState(null) setCompressionState(sid, null) removeItem(storageKey()) } async function loadSessions(profile?: string | null, preferredSessionId?: string | null) { isLoadingSessions.value = true try { const list = await fetchSessions(undefined, undefined, profile || undefined) 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 runtimeByIdBefore = new Map(sessions.value.map(s => [s.id, { messages: s.messages, contextTokens: s.contextTokens, }])) for (const s of fresh) { const prev = runtimeByIdBefore.get(s.id) if (prev?.messages?.length) s.messages = prev.messages if (prev?.contextTokens != null) s.contextTokens = prev.contextTokens } sessions.value = fresh // Restore route-selected session first (tab-local source of truth), // then current in-memory session, then persisted legacy/default choice, // then fallback to the most recent session. const currentId = activeSessionId.value const legacyActiveKey = legacyStorageKey() const storedId = getItemBestEffort(storageKey()) || (legacyActiveKey ? getItemBestEffort(LEGACY_STORAGE_KEY) : null) const targetId = preferredSessionId && sessions.value.some(s => s.id === preferredSessionId) ? preferredSessionId : currentId && sessions.value.some(s => s.id === currentId) ? currentId : storedId && sessions.value.some(s => s.id === storedId) ? storedId : sessions.value[0]?.id if (targetId) { await switchSession(targetId) } else { clearActiveSession() } } 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 { const sid = activeSessionId.value if (!sid) return false try { const detail = await fetchSession(sid, activeSession.value?.profile) 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(options: { profile?: string; model?: string; provider?: string } = {}): Session { const session: Session = { id: uid(), profile: options.profile || useProfilesStore().activeProfileName || 'default', title: '', source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now(), model: options.model || undefined, provider: options.provider || '', } sessions.value.unshift(session) return session } function newCliSession(): Session { const now = new Date() const ts = [ now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0'), '_', String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0'), String(now.getSeconds()).padStart(2, '0'), ].join('') const hex = Math.random().toString(16).slice(2, 8) const session: Session = { id: `${ts}_${hex}`, title: '', source: 'cli', 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((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('resume timeout')), 15_000) resumeSession(sessionId, (data) => { clearTimeout(timeout) if (data.session_id !== sessionId || activeSessionId.value !== sessionId) { resolve() return } const target = sessions.value.find(s => s.id === sessionId) if (!target) { resolve() return } 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 (Array.isArray((data as any).queueMessages)) { replaceQueuedUserMessages(sessionId, normalizeQueuedUserMessages((data as any).queueMessages)) } else if (!data.queueLength) { replaceQueuedUserMessages(sessionId, []) } if ((data as any).isAborting) { setAbortState({ aborting: true, synced: null }) } else if (!data.isWorking) { setAbortState(null) } if (!data.isWorking) setCompressionState(sessionId, null) if (data.inputTokens != null) target.inputTokens = data.inputTokens if (data.outputTokens != null) target.outputTokens = data.outputTokens if ((data as any).contextTokens != null) target.contextTokens = (data as any).contextTokens if (data.messages?.length) { target.messages = mapHermesMessages(data.messages as any[]) } if (!target.title) { const firstUser = target.messages.find(m => m.role === 'user') if (firstUser) { const t = firstUser.content.slice(0, 40) target.title = t + (firstUser.content.length > 40 ? '...' : '') } } activeSession.value = target // 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(sessionId, { compressing: true, messageCount: e.message_count || 0, beforeTokens: e.token_count || 0, afterTokens: 0, compressed: null, }) } else if (e.event === 'compression.completed') { const afterTokens = e.contextTokens || e.afterTokens || 0 setCompressionState(sessionId, { compressing: false, messageCount: e.totalMessages || 0, beforeTokens: e.beforeTokens || 0, afterTokens, compressed: e.compressed ?? false, error: e.error, }) if (e.contextTokens != null) target.contextTokens = e.contextTokens } else if (e.event === 'abort.started') { setAbortState({ aborting: true, synced: null }) } else if (e.event === 'abort.completed') { setAbortState({ aborting: false, synced: e.synced ?? false }) } else if (e.event === 'approval.requested') { setPendingApproval({ ...e, session_id: sessionId } as RunEvent) } else if (e.event === 'approval.resolved') { clearPendingApproval({ ...e, session_id: sessionId } as RunEvent) } else if (e.event === 'clarify.requested') { setPendingClarify({ ...e, session_id: sessionId } as RunEvent) } else if (e.event === 'clarify.resolved') { clearPendingClarify({ ...e, session_id: sessionId } as RunEvent) } else if (e.event === 'run.failed') { addAgentErrorMessage(sessionId, e.error) serverWorking.value.delete(sessionId) queueLengths.value.delete(sessionId) } else if (e.event === 'tool.started') { const msgs = getSessionMsgs(sessionId) const toolCallId = e.tool_call_id as string | undefined const existingTool = toolCallId ? msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId) : null if (existingTool) { updateMessage(sessionId, existingTool.id, { toolName: e.tool || e.name, toolArgs: typeof e.arguments === 'string' ? e.arguments : existingTool.toolArgs, toolPreview: e.preview || existingTool.toolPreview, toolStatus: existingTool.toolStatus || 'running', }) } else { addMessage(sessionId, { id: uid(), role: 'tool', content: '', timestamp: Date.now(), toolName: e.tool || e.name, toolCallId, toolPreview: e.preview, toolArgs: typeof e.arguments === 'string' ? e.arguments : undefined, toolStatus: 'running', }) } } else if (e.event === 'tool.completed') { const msgs = getSessionMsgs(sessionId) const toolCallId = e.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 output = typeof e.output === 'string' ? e.output : undefined updateMessage(sessionId, toolMsgs[toolMsgs.length - 1].id, { toolStatus: e.error === true || isToolOutputError(output) ? 'error' : 'done', toolDuration: e.duration, toolResult: output, }) } } else if (String(e.event || '').startsWith('subagent.')) { handleSubagentEvent(sessionId, e as RunEvent) } } } resolve() }, activeSession.value?.profile) }) } catch (err) { console.error('Failed to load session messages via resume:', err) } finally { isLoadingMessages.value = false } // Resume in-flight run event listeners if needed if (activeSessionId.value === sessionId) { resumeServerWorkingRun(sessionId) } } function newChat(options: { profile?: string; model?: string; provider?: string } = {}): Session { const appStore = useAppStore() const session = createSession({ profile: options.profile, model: options.model || appStore.selectedModel || undefined, provider: options.provider || appStore.selectedProvider || '', }) void switchSession(session.id) return session } async function switchSessionModel(modelId: string, provider?: string, sessionId?: string): Promise { const targetId = sessionId || activeSession.value?.id if (!targetId) return false const ok = await setSessionModel(targetId, modelId, provider || '') if (!ok) return false const target = sessions.value.find(s => s.id === targetId) if (target) { target.model = modelId target.provider = provider || '' } if (activeSession.value?.id === targetId) { activeSession.value.model = modelId activeSession.value.provider = provider || '' } return true } async function deleteSession(sessionId: string) { const target = sessions.value.find(s => s.id === sessionId) await deleteSessionApi(sessionId, target?.profile) 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) { 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 clearAgentEventMessages(sessionId: string) { const s = sessions.value.find(s => s.id === sessionId) if (!s) return s.messages = s.messages.filter(m => m.commandAction !== 'agent.event') } function handleSubagentEvent(sessionId: string, evt: RunEvent) { const eventName = String(evt.event || '') if (!eventName.startsWith('subagent.')) return const subagentId = String((evt as any).subagent_id || `${(evt as any).task_index ?? 0}`) const toolCallId = `subagent:${evt.run_id || 'run'}:${subagentId}` const taskIndex = Number((evt as any).task_index ?? 0) const taskCount = Number((evt as any).task_count ?? 1) const label = `${taskIndex + 1}/${Math.max(1, taskCount || 1)}` const toolName = String((evt as any).tool || (evt as any).name || '') const toolCount = Number((evt as any).tool_count || 0) const goal = String((evt as any).goal || '').trim() const text = String(evt.text || evt.preview || '').trim() const summary = String((evt as any).summary || '').trim() const duration = Number((evt as any).duration_seconds ?? (evt as any).duration) let preview = text || summary || goal if (eventName === 'subagent.start') { preview = `subagent ${label} started${goal ? `: ${goal}` : ''}` } else if (eventName === 'subagent.tool') { const prefix = `subagent ${label}${toolCount ? ` turn ${toolCount}` : ''}` preview = `${prefix}${toolName ? `: ${toolName}` : ''}${text ? ` - ${text}` : ''}` } else if (eventName === 'subagent.progress') { preview = `subagent ${label}: ${text || 'working'}` } else if (eventName === 'subagent.complete') { const status = String((evt as any).status || 'completed') preview = `subagent ${label} ${status}${summary ? `: ${summary}` : ''}` } const msgs = getSessionMsgs(sessionId) const existing = msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId) const toolStatus = eventName === 'subagent.complete' ? ((evt as any).status && String((evt as any).status) !== 'completed' ? 'error' : 'done') : 'running' const update: Partial = { toolName: 'delegate_task', toolCallId, toolPreview: preview.slice(0, 220), toolStatus, toolDuration: Number.isFinite(duration) ? duration : undefined, toolResult: eventName === 'subagent.complete' ? JSON.stringify({ status: (evt as any).status || 'completed', summary: summary || text, api_calls: (evt as any).api_calls, input_tokens: (evt as any).input_tokens, output_tokens: (evt as any).output_tokens, }, null, 2) : undefined, } if (existing) { updateMessage(sessionId, existing.id, update) return } addMessage(sessionId, { id: uid(), role: 'tool', content: '', timestamp: Date.now(), ...update, }) } function addAgentErrorMessage(sessionId: string, error?: string | null) { const content = error ? `Error: ${error}` : 'Run failed' const msgs = getSessionMsgs(sessionId) const last = msgs[msgs.length - 1] if (last?.isStreaming) { updateMessage(sessionId, last.id, { role: 'assistant', content, isStreaming: false, systemType: 'error', }) return } if (last?.role === 'assistant' && last.systemType === 'error' && last.content === content) return addMessage(sessionId, { id: uid(), role: 'assistant', content, timestamp: Date.now(), systemType: 'error', }) } function handleSessionCommandEvent(evt: RunEvent) { if (seenSessionCommandEvents.has(evt)) return seenSessionCommandEvents.add(evt) const sid = evt.session_id if (!sid) return const target = sessions.value.find(s => s.id === sid) const action = (evt as any).action as string | undefined const command = String((evt as any).command || '').toLowerCase() if ((evt as any).started === true && (evt as any).terminal === false) { serverWorking.value.add(sid) } if (action === 'clear' && command === 'clear') { if (target) target.messages = [] queuedUserMessages.value.delete(sid) queueLengths.value.delete(sid) if ((evt as any).clearHistory) { const message = String((evt as any).message || '') if (message) { addMessage(sid, { id: uid(), role: 'command', content: message, timestamp: Date.now(), systemType: (evt as any).ok === false ? 'error' : 'command', commandAction: action, commandData: { ...(evt as any) }, }) } } return } if (action === 'title' && target && typeof (evt as any).title === 'string') { target.title = (evt as any).title target.updatedAt = Date.now() } if (action === 'usage' && target) { target.inputTokens = (evt as any).inputTokens target.outputTokens = (evt as any).outputTokens if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } if (action === 'destroy') { streamStates.value.delete(sid) serverWorking.value.delete(sid) queueLengths.value.delete(sid) queuedUserMessages.value.delete(sid) setAbortState(null) const msgs = getSessionMsgs(sid) msgs.forEach(m => { if (m.isStreaming) updateMessage(sid, m.id, { isStreaming: false }) if (m.role === 'tool' && m.toolStatus === 'running') m.toolStatus = 'error' }) } const message = String((evt as any).message || '') if (message) { addMessage(sid, { id: uid(), role: 'command', content: message, timestamp: Date.now(), systemType: (evt as any).ok === false ? 'error' : 'command', commandAction: action, commandData: { ...(evt as any) }, }) } } function handleAgentEvent(evt: RunEvent) { const sid = evt.session_id if (!sid) return const text = String((evt as any).text || (evt as any).message || '').trim() if (!text) return const msgs = getSessionMsgs(sid) const last = msgs[msgs.length - 1] const commandData = { ...(evt as any) } if (last?.role === 'system' && last.commandAction === 'agent.event') { if (last.content === text) return updateMessage(sid, last.id, { content: text, timestamp: Date.now(), commandData, }) return } addMessage(sid, { id: uid(), role: 'system', content: text, timestamp: Date.now(), systemType: 'command', commandAction: 'agent.event', commandData, }) } function enqueueUserMessage(sessionId: string, message: Message) { const queue = queuedUserMessages.value.get(sessionId) || [] if (queue.some(item => item.id === message.id)) return const nextMap = new Map(queuedUserMessages.value) nextMap.set(sessionId, [...queue, { ...message, queued: true }]) queuedUserMessages.value = nextMap } function updateQueuedUserMessage(sessionId: string, messageId: string, patch: Partial) { const queue = queuedUserMessages.value.get(sessionId) if (!queue?.length) return const next = queue.map(message => message.id === messageId ? { ...message, ...patch, queued: true } : message) const nextMap = new Map(queuedUserMessages.value) nextMap.set(sessionId, next) queuedUserMessages.value = nextMap } function dropQueuedUserMessage(sessionId: string, messageId: string): boolean { const queue = queuedUserMessages.value.get(sessionId) if (!queue?.length) return false const next = queue.filter(message => message.id !== messageId) if (next.length === queue.length) return false const nextMap = new Map(queuedUserMessages.value) if (next.length > 0) { nextMap.set(sessionId, next) queueLengths.value.set(sessionId, next.length) } else { nextMap.delete(sessionId) queueLengths.value.delete(sessionId) } queuedUserMessages.value = nextMap return true } function removeQueuedMessage(sessionId: string, messageId: string) { if (!dropQueuedUserMessage(sessionId, messageId)) return getChatRunSocket()?.emit('cancel_queued_run', { session_id: sessionId, queue_id: messageId, }) } function normalizeQueuedUserMessages(rawMessages: unknown): Message[] { if (!Array.isArray(rawMessages)) return [] return rawMessages.flatMap((raw) => { const peer = raw as NonNullable[number] const content = typeof peer?.content === 'string' ? peer.content : '' const messageId = peer?.id != null ? String(peer.id) : '' if (!messageId || !content.trim()) return [] const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp) ? Math.round(peer.timestamp * 1000) : Date.now() const role = peer?.role === 'command' ? 'command' : 'user' return [{ id: messageId, role, content, timestamp, queued: true, systemType: role === 'command' ? 'command' as const : undefined, }] }) } function replaceQueuedUserMessages(sessionId: string, messages: Message[]) { const existingById = new Map((queuedUserMessages.value.get(sessionId) || []).map(message => [message.id, message])) const merged = messages.map(message => ({ ...(existingById.get(message.id) || {}), ...message, attachments: existingById.get(message.id)?.attachments || message.attachments, queued: true, })) const nextMap = new Map(queuedUserMessages.value) if (merged.length > 0) { nextMap.set(sessionId, merged) } else { nextMap.delete(sessionId) } queuedUserMessages.value = nextMap } function markDequeuedQueueId(sessionId: string, messageId: string) { const nextMap = new Map(dequeuedQueueIds.value) const ids = new Set(nextMap.get(sessionId) || []) ids.add(messageId) nextMap.set(sessionId, ids) dequeuedQueueIds.value = nextMap } function consumeDequeuedQueueId(sessionId: string, messageId: string): boolean { const ids = dequeuedQueueIds.value.get(sessionId) if (!ids?.has(messageId)) return false const nextIds = new Set(ids) nextIds.delete(messageId) const nextMap = new Map(dequeuedQueueIds.value) if (nextIds.size > 0) nextMap.set(sessionId, nextIds) else nextMap.delete(sessionId) dequeuedQueueIds.value = nextMap return true } function handleRunQueuedEvent(sessionId: string, evt: RunEvent) { const queueLength = Number((evt as any).queue_length || 0) if (queueLength > 0) { queueLengths.value.set(sessionId, queueLength) } else { queueLengths.value.delete(sessionId) } const dequeuedId = (evt as any).dequeued_queue_id != null ? String((evt as any).dequeued_queue_id) : '' if (dequeuedId) { const existingQueue = queuedUserMessages.value.get(sessionId) || [] const dequeued = existingQueue.find(message => message.id === dequeuedId) if (Array.isArray((evt as any).queued_messages)) { const queued = normalizeQueuedUserMessages((evt as any).queued_messages) replaceQueuedUserMessages(sessionId, queued) } else { const nextQueue = existingQueue.filter(message => message.id !== dequeuedId) replaceQueuedUserMessages(sessionId, nextQueue) } if (dequeued && !getSessionMsgs(sessionId).some(message => message.id === dequeued.id)) { addMessage(sessionId, { ...dequeued, queued: false }) updateSessionTitle(sessionId) } else if (!dequeued) { markDequeuedQueueId(sessionId, dequeuedId) } return } if (Array.isArray((evt as any).queued_messages)) { const queued = normalizeQueuedUserMessages((evt as any).queued_messages) replaceQueuedUserMessages(sessionId, queued) return } const peer = evt.message const content = typeof peer?.content === 'string' ? peer.content : '' const messageId = peer?.id != null ? String(peer.id) : '' if (!messageId || !content.trim()) return if ((queuedUserMessages.value.get(sessionId) || []).some(msg => msg.id === messageId)) return const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp) ? Math.round(peer.timestamp * 1000) : Date.now() const msgs = getSessionMsgs(sessionId) const existingIndex = msgs.findIndex(msg => msg.id === messageId && msg.role === 'user') const existing = existingIndex >= 0 ? msgs[existingIndex] : null if (existingIndex >= 0) { msgs.splice(existingIndex, 1) } enqueueUserMessage(sessionId, { ...(existing || {}), id: messageId, role: peer?.role === 'command' ? 'command' : 'user', content, timestamp: existing?.timestamp || timestamp, attachments: existing?.attachments, queued: true, systemType: peer?.role === 'command' ? 'command' : existing?.systemType, }) } function setPendingApproval(evt: RunEvent) { const sid = evt.session_id const approvalId = (evt as any).approval_id as string | undefined if (!sid || !approvalId) return const rawChoices = Array.isArray((evt as any).choices) ? (evt as any).choices : ['once', 'session', 'deny'] const choices = rawChoices .filter((choice: unknown): choice is PendingApproval['choices'][number] => choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny') pendingApprovals.value.set(sid, { sessionId: sid, approvalId, command: String((evt as any).command || ''), description: String((evt as any).description || ''), choices: choices.length ? choices : ['once', 'session', 'deny'], allowPermanent: Boolean((evt as any).allow_permanent), requestedAt: Date.now(), }) pendingApprovals.value = new Map(pendingApprovals.value) } function clearPendingApproval(evt: RunEvent) { const sid = evt.session_id if (!sid) return const current = pendingApprovals.value.get(sid) if (!current) return const approvalId = (evt as any).approval_id if (approvalId && current.approvalId !== approvalId) return pendingApprovals.value.delete(sid) pendingApprovals.value = new Map(pendingApprovals.value) } function setPendingClarify(evt: RunEvent) { const sid = evt.session_id const clarifyId = (evt as any).clarify_id as string | undefined if (!sid || !clarifyId) return pendingClarifies.value.set(sid, { sessionId: sid, clarifyId, question: String((evt as any).question || ''), choices: Array.isArray((evt as any).choices) ? (evt as any).choices : null, timeoutMs: Number((evt as any).timeout_ms) || 300000, requestedAt: Date.now(), }) pendingClarifies.value = new Map(pendingClarifies.value) } function clearPendingClarify(evt: RunEvent) { const sid = evt.session_id if (!sid) return const current = pendingClarifies.value.get(sid) if (!current) return const clarifyId = (evt as any).clarify_id if (clarifyId && current.clarifyId !== clarifyId) return pendingClarifies.value.delete(sid) pendingClarifies.value = new Map(pendingClarifies.value) } function clearPendingInteractions(sessionId: string) { let changed = false if (pendingApprovals.value.has(sessionId)) { pendingApprovals.value.delete(sessionId) changed = true } if (pendingClarifies.value.has(sessionId)) { pendingClarifies.value.delete(sessionId) changed = true } if (changed) { pendingApprovals.value = new Map(pendingApprovals.value) pendingClarifies.value = new Map(pendingClarifies.value) } } function respondToClarify(response: string) { const pending = activePendingClarify.value if (!pending) return respondClarify(pending.sessionId, pending.clarifyId, response) pendingClarifies.value.delete(pending.sessionId) pendingClarifies.value = new Map(pendingClarifies.value) } function respondApproval(choice: PendingApproval['choices'][number]) { const pending = activePendingApproval.value if (!pending) return respondToolApproval(pending.sessionId, pending.approvalId, choice) pendingApprovals.value.delete(pending.sessionId) pendingApprovals.value = new Map(pendingApprovals.value) } 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 shouldSendInitialSessionConfig = activeSession.value ? activeSession.value.messageCount == null || activeSession.value.messageCount === 0 : false const isBridgeSlashCommand = content.trim().startsWith('/') const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim()) const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(content.trim()) const isBridgeGoalCommand = isBridgeSlashCommand && /^\/goal(?:\s|$)/i.test(content.trim()) const wasLiveBeforeSend = isSessionLive(sid) const shouldQueue = wasLiveBeforeSend && (!isBridgeSlashCommand || isBridgePlanCommand) const userMsg: Message = { id: uid(), role: isBridgeSlashCommand ? 'command' : 'user', content: content.trim(), timestamp: Date.now(), attachments: attachments && attachments.length > 0 ? attachments : undefined, queued: shouldQueue, systemType: isBridgeSlashCommand ? 'command' : undefined, } if (shouldQueue) { enqueueUserMessage(sid, userMsg) } else { addMessage(sid, userMsg) updateSessionTitle(sid) serverWorking.value.add(sid) } let runSubmitted = false 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 urlMap = new Map(uploaded.map(f => { return [f.name, getDownloadUrl(f.path, f.name)] })) if (shouldQueue && userMsg.attachments) { userMsg.attachments = userMsg.attachments.map(a => { const dl = urlMap.get(a.name) return dl ? { ...a, url: dl } : a }) updateQueuedUserMessage(sid, userMsg.id, { attachments: userMsg.attachments }) } 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() await appStore.waitForModelsForRun() const sessionModel = activeSession.value?.model || appStore.selectedModel const sessionProvider = activeSession.value?.provider || appStore.selectedProvider const runPayload = { input, session_id: sid, profile: activeSession.value?.profile || useProfilesStore().activeProfileName || undefined, model: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined, provider: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined, model_groups: appStore.modelGroups.map(group => ({ provider: group.provider, models: group.models, })), queue_id: userMsg.id, source: 'cli' as const, } if (shouldSendInitialSessionConfig && activeSession.value) { activeSession.value.messageCount = Math.max(activeSession.value.messageCount || 0, 1) } // 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 closeStreamingAssistant = () => { const msgs = getSessionMsgs(sid) msgs.forEach(m => { if (m.role === 'assistant' && m.isStreaming) { updateMessage(sid, m.id, { isStreaming: false }) } }) activeAssistantMessageId = null } const applyReconnectResume = (data: ResumeSessionPayload) => { if (data.session_id !== sid) return const target = sessions.value.find(s => s.id === sid) if (!target) return if (data.isWorking) serverWorking.value.add(sid) else serverWorking.value.delete(sid) if (data.queueLength && data.queueLength > 0) { queueLengths.value.set(sid, data.queueLength) } else { queueLengths.value.delete(sid) } if (Array.isArray(data.queueMessages)) { replaceQueuedUserMessages(sid, normalizeQueuedUserMessages(data.queueMessages)) } else if (!data.queueLength) { replaceQueuedUserMessages(sid, []) } if (data.isAborting) { setAbortState({ aborting: true, synced: null }) } else if (!data.isWorking) { setAbortState(null) } if (!data.isWorking) setCompressionState(sid, null) if (data.inputTokens != null) target.inputTokens = data.inputTokens if (data.outputTokens != null) target.outputTokens = data.outputTokens if (data.contextTokens != null) target.contextTokens = data.contextTokens if (Array.isArray(data.messages)) { target.messages = mapHermesMessages(data.messages as any[]) const lastAssistant = [...target.messages].reverse().find(m => m.role === 'assistant') if (data.isWorking && lastAssistant) { lastAssistant.isStreaming = true activeAssistantMessageId = lastAssistant.id if (lastAssistant.reasoning) noteReasoningStart(lastAssistant.id) } else { activeAssistantMessageId = null } } if (data.events?.length) { for (const evt of data.events) { const e = evt.data as RunEvent switch (e.event) { case 'compression.started': setCompressionState(sid, { compressing: true, messageCount: (e as any).message_count || 0, beforeTokens: (e as any).token_count || 0, afterTokens: 0, compressed: null, }) break case 'compression.completed': { const afterTokens = (e as any).contextTokens || (e as any).afterTokens || 0 setCompressionState(sid, { compressing: false, messageCount: (e as any).totalMessages || 0, beforeTokens: (e as any).beforeTokens || 0, afterTokens, compressed: (e as any).compressed ?? false, error: (e as any).error, }) if ((e as any).contextTokens != null) target.contextTokens = (e as any).contextTokens break } case 'abort.started': setAbortState({ aborting: true, synced: null }) break case 'abort.completed': setAbortState({ aborting: false, synced: (e as any).synced ?? false }) break case 'approval.requested': setPendingApproval({ ...e, session_id: sid }) break case 'approval.resolved': clearPendingApproval({ ...e, session_id: sid }) break case 'clarify.requested': setPendingClarify({ ...e, session_id: sid }) break case 'clarify.resolved': clearPendingClarify({ ...e, session_id: sid }) break case 'run.failed': addAgentErrorMessage(sid, e.error) break case 'agent.event': handleAgentEvent(e) break } } } if (activeSessionId.value === sid) activeSession.value = target if (!data.isWorking && !(data.queueLength && data.queueLength > 0)) { clearAgentEventMessages(sid) cleanup() activeAssistantMessageId = null updateSessionTitle(sid) } } // 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': clearAgentEventMessages(sid) setAbortState(null) setCompressionState(sid, null) runProducedAssistantText = false runHadToolActivity = false closeStreamingAssistant() 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': { handleRunQueuedEvent(sid, evt) break } case 'session.command': { handleSessionCommandEvent(evt) break } case 'agent.event': { handleAgentEvent(evt) break } case 'compression.started': { setCompressionState(sid, { compressing: true, messageCount: (evt as any).message_count || 0, beforeTokens: (evt as any).token_count || 0, afterTokens: 0, compressed: null, }) break } case 'compression.completed': { const afterTokens = (evt as any).contextTokens || (evt as any).afterTokens || 0 setCompressionState(sid, { compressing: false, messageCount: (evt as any).totalMessages || 0, beforeTokens: (evt as any).beforeTokens || 0, afterTokens, compressed: (evt as any).compressed ?? false, error: (evt as any).error, }) if ((evt as any).contextTokens != null) { const target = sessions.value.find(s => s.id === sid) if (target) target.contextTokens = (evt as any).contextTokens } // Auto-clear after 5s setTimeout(() => { const state = compressionStates.value.get(sid) if (state && !state.compressing) { setCompressionState(sid, null) } }, 5000) break } case 'abort.started': { setAbortState({ aborting: true, synced: null }) break } case 'abort.completed': { setAbortState({ aborting: false, synced: (evt as any).synced ?? false }) clearPendingInteractions(sid) 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] const output = typeof (evt as any).output === 'string' ? (evt as any).output : undefined const hasError = (evt as any).error === true || isToolOutputError(output) const duration = (evt as any).duration updateMessage(sid, last.id, { toolStatus: hasError ? 'error' : 'done', toolDuration: duration, toolResult: output, }) } break } case 'subagent.start': case 'subagent.tool': case 'subagent.progress': case 'subagent.complete': { runHadToolActivity = true handleSubagentEvent(sid, evt) break } case 'approval.requested': { setPendingApproval(evt) break } case 'approval.resolved': { clearPendingApproval(evt) break } case 'clarify.requested': { setPendingClarify(evt) break } case 'clarify.resolved': { clearPendingClarify(evt) break } case 'run.completed': { clearAgentEventMessages(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 if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } } // 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': { clearAgentEventMessages(sid) 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 if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } } addAgentErrorMessage(sid, evt.error) const msgs = getSessionMsgs(sid) 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 if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } 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) addAgentErrorMessage(sid, err.message) const msgs = getSessionMsgs(sid) msgs.forEach((m, i) => { if (m.role === 'tool' && m.toolStatus === 'running') { msgs[i] = { ...m, toolStatus: 'error' } } }) cleanup() }, undefined, { onReconnectResume: applyReconnectResume }, ) runSubmitted = true if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand || isBridgeGoalCommand) { streamStates.value.set(sid, ctrl) } } catch (err: any) { if (shouldQueue && !runSubmitted) { dropQueuedUserMessage(sid, userMsg.id) } if (!shouldQueue && !runSubmitted) { serverWorking.value.delete(sid) } 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, force = false) { // 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 (!force && !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 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': { handleRunQueuedEvent(sid, evt) break } case 'session.command': { handleSessionCommandEvent(evt) break } case 'agent.event': { handleAgentEvent(evt) break } case 'run.started': clearAgentEventMessages(sid) setAbortState(null) setCompressionState(sid, null) runProducedAssistantText = false runHadToolActivity = false closeStreamingAssistant() 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(sid, { compressing: true, messageCount: (evt as any).message_count || 0, beforeTokens: (evt as any).token_count || 0, afterTokens: 0, compressed: null, }) break } case 'compression.completed': { const afterTokens = (evt as any).contextTokens || (evt as any).afterTokens || 0 setCompressionState(sid, { compressing: false, messageCount: (evt as any).totalMessages || 0, beforeTokens: (evt as any).beforeTokens || 0, afterTokens, compressed: (evt as any).compressed ?? false, error: (evt as any).error, }) if ((evt as any).contextTokens != null) { const target = sessions.value.find(s => s.id === sid) if (target) target.contextTokens = (evt as any).contextTokens } setTimeout(() => { const state = compressionStates.value.get(sid) if (state && !state.compressing) { setCompressionState(sid, null) } }, 5000) break } case 'abort.started': { setAbortState({ aborting: true, synced: null }) break } case 'abort.completed': { setAbortState({ aborting: false, synced: (evt as any).synced ?? false }) clearPendingInteractions(sid) 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 output = typeof (evt as any).output === 'string' ? (evt as any).output : undefined const hasError = (evt as any).error === true || isToolOutputError(output) updateMessage(sid, toolMsgs[toolMsgs.length - 1].id, { toolStatus: hasError ? 'error' : 'done', toolDuration: (evt as any).duration, toolResult: output, }) } break } case 'subagent.start': case 'subagent.tool': case 'subagent.progress': case 'subagent.complete': { runHadToolActivity = true handleSubagentEvent(sid, evt) break } case 'approval.requested': { setPendingApproval(evt) break } case 'approval.resolved': { clearPendingApproval(evt) break } case 'clarify.requested': { setPendingClarify(evt) break } case 'clarify.resolved': { clearPendingClarify(evt) break } case 'run.completed': { clearAgentEventMessages(sid) 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 if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } } // 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': { clearAgentEventMessages(sid) 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 if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } } const hasQueue = (evt as any).queue_remaining > 0 if (hasQueue) { queueLengths.value.set(sid, (evt as any).queue_remaining) } else { queueLengths.value.delete(sid) } addAgentErrorMessage(sid, evt.error) const msgs = getSessionMsgs(sid) 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 if ((evt as any).contextTokens != null) target.contextTokens = (evt as any).contextTokens } 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), onSubagentEvent: (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), onAgentEvent: (evt) => handleEvent(evt), onSessionCommand: (evt) => handleEvent(evt), onRunQueued: (evt) => handleEvent(evt), onClarifyRequested: (evt) => handleEvent(evt), onClarifyResolved: (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 handlePeerUserMessage(evt: RunEvent) { const sid = evt.session_id if (!sid || activeSessionId.value !== sid || !activeSession.value) return const peer = evt.message const content = typeof peer?.content === 'string' ? peer.content : '' if (!content.trim()) return const messageId = peer?.id != null ? String(peer.id) : '' const msgs = getSessionMsgs(sid) if (messageId && msgs.some(msg => msg.id === messageId)) { serverWorking.value.add(sid) resumeServerWorkingRun(sid, true) return } if (messageId && (queuedUserMessages.value.get(sid) || []).some(msg => msg.id === messageId)) { serverWorking.value.add(sid) resumeServerWorkingRun(sid, true) return } const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp) ? Math.round(peer.timestamp * 1000) : Date.now() const message: Message = { id: messageId || uid(), role: peer?.role === 'command' ? 'command' : 'user', content, timestamp, queued: !!peer?.queued, systemType: peer?.role === 'command' ? 'command' : undefined, } const wasDequeued = messageId ? consumeDequeuedQueueId(sid, messageId) : false if (peer?.queued || (!wasDequeued && isSessionLive(sid))) { enqueueUserMessage(sid, message) } else { addMessage(sid, message) updateSessionTitle(sid) } serverWorking.value.add(sid) resumeServerWorkingRun(sid, true) } onPeerUserMessage(handlePeerUserMessage) function handleGlobalSessionCommand(evt: RunEvent) { const sid = evt.session_id if (!sid || activeSessionId.value !== sid || !activeSession.value) return const shouldAttachToStartedRun = (evt as any).started === true && (evt as any).terminal === false handleSessionCommandEvent(evt) if (shouldAttachToStartedRun) { serverWorking.value.add(sid) resumeServerWorkingRun(sid, true) } } onSessionCommand(handleGlobalSessionCommand) function stopStreaming() { const sid = activeSessionId.value if (!sid) return if (isAborting.value) return clearPendingInteractions(sid) 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.isWorking) setCompressionState(sid, null) if (data.messages?.length && activeSession.value) { activeSession.value.messages = mapHermesMessages(data.messages as any[]) } resumeServerWorkingRun(sid) }, activeSession.value?.profile) } } }) } // Transient observation of boundaries during active streaming. // Not persisted; cleared on session switch. See spec §5.3. const thinkingObservation = new Map() 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, sessionProfileFilter, compressionState, abortState, isAborting, queueLengths, queuedUserMessages, pendingApprovals, activePendingApproval, activePendingClarify, removeQueuedMessage, isLoadingSessions, sessionsLoaded, isLoadingMessages, newChat, newCliSession, switchSession, switchSessionModel, addOrUpdateSession, clearProviderFromSessions, deleteSession, sendMessage, stopStreaming, respondApproval, respondToClarify, loadSessions, refreshActiveSession, getThinkingObservation, noteThinkingDelta, noteReasoningStart, noteReasoningEnd, clearThinkingObservationFor, setAutoPlaySpeech, playMessageSpeech, } })