Files
Hermes-ui/packages/client/src/stores/hermes/chat.ts
T
2026-05-26 17:39:38 +08:00

2640 lines
95 KiB
TypeScript

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 事件累加
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
reasoning?: string
queued?: boolean
systemType?: 'command' | 'error'
commandAction?: string
commandData?: Record<string, unknown>
}
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<string, unknown>
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<string, string> = {}
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<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 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<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 || 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<RunEvent>()
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())
const sessionProfileFilter = ref<string | null>(null)
/** 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())
/** sessionId → queue ids that server reported as dequeued before the peer message arrived */
const dequeuedQueueIds = ref<Map<string, Set<string>>>(new Map())
const pendingApprovals = ref<Map<string, PendingApproval>>(new Map())
const activePendingApproval = computed(() => {
const sid = activeSessionId.value
return sid ? pendingApprovals.value.get(sid) || null : null
})
const pendingClarifies = ref<Map<string, PendingClarify>>(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<Map<string, CompressionState>>(new Map())
const compressionState = computed<CompressionState | null>(() => {
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<Session | null>(null)
const messages = computed<Message[]>(() => 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<boolean> {
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<void>((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<boolean> {
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<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 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<Message> = {
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<Message>) {
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<RunEvent['queued_messages']>[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 <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,
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,
}
})