2026-05-14 09:03:57 +08:00
import { startRunViaSocket , resumeSession , registerSessionHandlers , unregisterSessionHandlers , getChatRunSocket , respondToolApproval , type RunEvent , type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
2026-04-29 16:26:24 +08:00
import { deleteSession as deleteSessionApi , fetchSession , fetchSessions , type HermesMessage , type SessionSummary } from '@/api/hermes/sessions'
2026-04-26 13:28:08 +08:00
import { getApiKey } from '@/api/client'
2026-04-11 15:59:14 +08:00
import { defineStore } from 'pinia'
2026-04-15 10:28:53 +08:00
import { ref , computed } from 'vue'
2026-04-12 23:23:50 +08:00
import { useAppStore } from './app'
2026-04-18 14:32:54 +08:00
import { useProfilesStore } from './profiles'
2026-05-06 08:23:12 +02:00
import { useSettingsStore } from './settings'
import { primeCompletionSound , playCompletionSound } from '@/utils/completion-sound'
2026-04-25 08:46:50 +08:00
import { detectThinkingBoundary } from '@/utils/thinking-parser'
2026-04-11 15:59:14 +08:00
2026-05-02 15:39:01 +08:00
// Re-export ContentBlock for convenience
export type ContentBlock = ContentBlockImport
2026-04-11 18:54:46 +08:00
export interface Attachment {
id : string
name : string
type : string
size : number
url : string
file? : File
}
2026-04-11 15:59:14 +08:00
export interface Message {
id : string
2026-05-15 12:04:03 +08:00
role : 'user' | 'assistant' | 'system' | 'tool' | 'command'
2026-04-11 15:59:14 +08:00
content : string
timestamp : number
toolName? : string
2026-05-10 02:49:58 +08:00
toolCallId? : string
2026-04-11 15:59:14 +08:00
toolPreview? : string
2026-04-12 23:59:18 +08:00
toolArgs? : string
toolResult? : string
2026-04-11 15:59:14 +08:00
toolStatus ? : 'running' | 'done' | 'error'
2026-04-30 16:40:37 +08:00
toolDuration? : number // 工具执行时长(秒)
2026-04-11 15:59:14 +08:00
isStreaming? : boolean
2026-04-11 18:54:46 +08:00
attachments? : Attachment [ ]
2026-04-25 08:46:50 +08:00
// 思考/推理文本。两条来源:
// 1) 历史消息:来自 HermesMessage.reasoning 字段
// 2) 流式:由 reasoning.delta / thinking.delta / reasoning.available 事件累加
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
reasoning? : string
2026-05-07 10:34:58 +08:00
queued? : boolean
2026-05-15 12:04:03 +08:00
systemType ? : 'command' | 'error'
commandAction? : string
commandData? : Record < string , unknown >
2026-04-11 15:59:14 +08:00
}
2026-05-14 09:03:57 +08:00
export interface PendingApproval {
sessionId : string
approvalId : string
command : string
description : string
choices : Array < 'once' | 'session' | 'always' | 'deny' >
allowPermanent : boolean
requestedAt : number
}
2026-04-11 21:33:04 +08:00
export interface Session {
2026-04-11 15:59:14 +08:00
id : string
title : string
2026-04-12 23:59:18 +08:00
source? : string
2026-04-11 15:59:14 +08:00
messages : Message [ ]
createdAt : number
updatedAt : number
2026-04-11 21:33:04 +08:00
model? : string
2026-04-12 23:23:50 +08:00
provider? : string
2026-04-11 21:33:04 +08:00
messageCount? : number
2026-04-14 14:47:18 +08:00
inputTokens? : number
outputTokens? : number
2026-04-23 04:49:00 +02:00
endedAt? : number | null
lastActiveAt? : number
2026-04-30 20:17:38 +08:00
workspace? : string | null
2026-04-11 15:59:14 +08:00
}
function uid ( ) : string {
return Date . now ( ) . toString ( 36 ) + Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 )
}
2026-04-11 18:54:46 +08:00
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 )
}
2026-04-15 19:29:44 +08:00
const token = localStorage . getItem ( 'hermes_api_key' ) || ''
const res = await fetch ( '/upload' , {
method : 'POST' ,
body : formData ,
headers : token ? { Authorization : ` Bearer ${ token } ` } : { } ,
} )
2026-04-11 18:54:46 +08:00
if ( ! res . ok ) throw new Error ( ` Upload failed: ${ res . status } ` )
const data = await res . json ( ) as { files : { name : string ; path : string } [ ] }
return data . files
}
2026-05-02 15:39:01 +08:00
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
}
2026-04-11 21:33:04 +08:00
function mapHermesMessages ( msgs : HermesMessage [ ] ) : Message [ ] {
2026-05-06 14:07:13 +08:00
// Filter out assistant messages with empty content
const filteredMsgs = msgs . filter ( m = > {
if ( m . role === 'assistant' ) {
return m . content && m . content . trim ( ) !== ''
}
return true
} )
2026-04-12 23:59:18 +08:00
// Build lookups from assistant messages with tool_calls
2026-04-11 21:33:04 +08:00
const toolNameMap = new Map < string , string > ( )
2026-04-12 23:59:18 +08:00
const toolArgsMap = new Map < string , string > ( )
2026-05-06 14:07:13 +08:00
for ( const msg of filteredMsgs ) {
2026-04-11 21:33:04 +08:00
if ( msg . role === 'assistant' && msg . tool_calls ) {
for ( const tc of msg . tool_calls ) {
2026-04-12 23:59:18 +08:00
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 )
2026-04-11 21:33:04 +08:00
}
}
}
2026-04-11 15:59:14 +08:00
}
2026-04-11 21:33:04 +08:00
const result : Message [ ] = [ ]
2026-05-06 14:07:13 +08:00
for ( const msg of filteredMsgs ) {
2026-04-11 21:33:04 +08:00
// 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 ) ,
2026-04-15 16:36:04 +08:00
toolName : tc.function?.name || 'tool' ,
2026-05-10 02:49:58 +08:00
toolCallId : tc.id ,
2026-04-12 23:59:18 +08:00
toolArgs : tc.function?.arguments || undefined ,
2026-04-11 21:33:04 +08:00
toolStatus : 'done' ,
} )
}
continue
}
// Tool result messages
if ( msg . role === 'tool' ) {
2026-04-12 23:59:18 +08:00
const tcId = msg . tool_call_id || ''
2026-04-15 16:36:04 +08:00
const toolName = msg . tool_name || toolNameMap . get ( tcId ) || 'tool'
2026-04-12 23:59:18 +08:00
const toolArgs = toolArgsMap . get ( tcId ) || undefined
2026-04-11 21:33:04 +08:00
// 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 )
}
}
2026-04-12 23:59:18 +08:00
// 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 )
}
2026-04-11 21:33:04 +08:00
result . push ( {
id : String ( msg . id ) ,
role : 'tool' ,
content : '' ,
timestamp : Math.round ( msg . timestamp * 1000 ) ,
toolName ,
2026-05-10 02:49:58 +08:00
toolCallId : tcId || undefined ,
2026-04-12 23:59:18 +08:00
toolArgs ,
2026-04-13 00:52:34 +08:00
toolPreview : typeof preview === 'string' ? preview . slice ( 0 , 100 ) || undefined : undefined ,
2026-04-12 23:59:18 +08:00
toolResult : msg.content || undefined ,
2026-04-11 21:33:04 +08:00
toolStatus : 'done' ,
} )
continue
}
2026-05-15 12:04:03 +08:00
// Normal user/assistant/command messages
2026-04-11 21:33:04 +08:00
result . push ( {
id : String ( msg . id ) ,
role : msg.role ,
content : msg.content || '' ,
timestamp : Math.round ( msg . timestamp * 1000 ) ,
2026-04-25 08:46:50 +08:00
reasoning : msg.reasoning ? msg.reasoning : undefined ,
2026-05-15 12:04:03 +08:00
systemType : msg.role === 'command' ? 'command' : undefined ,
2026-04-11 21:33:04 +08:00
} )
}
return result
2026-04-11 15:59:14 +08:00
}
2026-04-11 21:33:04 +08:00
function mapHermesSession ( s : SessionSummary ) : Session {
return {
id : s.id ,
2026-04-15 16:36:04 +08:00
title : s.title || '' ,
2026-04-12 23:59:18 +08:00
source : s.source || undefined ,
2026-04-11 21:33:04 +08:00
messages : [ ] ,
createdAt : Math.round ( s . started_at * 1000 ) ,
2026-04-19 23:32:01 +08:00
updatedAt : Math.round ( ( s . last_active || s . ended_at || s . started_at ) * 1000 ) ,
2026-04-11 21:33:04 +08:00
model : s.model ,
2026-04-12 23:23:50 +08:00
provider : ( s as any ) . billing_provider || '' ,
2026-04-11 21:33:04 +08:00
messageCount : s.message_count ,
2026-04-23 04:49:00 +02:00
endedAt : s.ended_at != null ? Math . round ( s . ended_at * 1000 ) : null ,
lastActiveAt : s.last_active != null ? Math . round ( s . last_active * 1000 ) : undefined ,
2026-04-30 20:17:38 +08:00
workspace : s.workspace || null ,
2026-04-11 21:33:04 +08:00
}
2026-04-11 15:59:14 +08:00
}
2026-04-18 14:32:54 +08:00
const STORAGE_KEY_PREFIX = 'hermes_active_session_'
2026-04-23 07:35:05 +08:00
const LEGACY_STORAGE_KEY = 'hermes_active_session'
2026-04-18 00:00:24 +08:00
2026-04-18 14:32:54 +08:00
// 获取当前 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 ( ) }
2026-04-23 07:35:05 +08:00
function legacyStorageKey ( ) : string | null { return getProfileName ( ) === 'default' ? LEGACY_STORAGE_KEY : null }
2026-04-18 00:00:24 +08:00
2026-04-23 07:35:05 +08:00
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 {
2026-05-01 11:27:43 +08:00
// 清理所有会话相关的旧缓存(已完全废弃)
2026-04-23 07:35:05 +08:00
const prefixes = [
2026-05-01 11:27:43 +08:00
'hermes_sessions_cache_v1_' ,
'hermes_session_msgs_v1_' ,
'hermes_session_pins_v1_' ,
'hermes_human_only_v1_' ,
2026-04-23 07:35:05 +08:00
]
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 ) )
2026-05-01 11:27:43 +08:00
if ( keysToRemove . length > 0 ) {
console . log ( ` Recovered storage: cleared ${ keysToRemove . length } old session cache entries ` )
}
2026-04-23 07:35:05 +08:00
} 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
}
}
2026-04-18 00:00:24 +08:00
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.
2026-04-25 08:46:50 +08:00
2026-04-11 15:59:14 +08:00
export const useChatStore = defineStore ( 'chat' , ( ) = > {
2026-04-11 21:33:04 +08:00
const sessions = ref < Session [ ] > ( [ ] )
2026-04-18 14:32:54 +08:00
const activeSessionId = ref < string | null > ( null )
2026-04-22 14:00:34 +08:00
const focusMessageId = ref < string | null > ( null )
2026-04-29 16:26:24 +08:00
const streamStates = ref < Map < string , { abort : ( ) = > void } > > ( new Map ( ) )
/** sessionId → server-reported isWorking status */
const serverWorking = ref < Set < string > > ( new Set ( ) )
2026-05-07 10:34:58 +08:00
/** 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 ( ) )
2026-05-14 09:03:57 +08:00
const pendingApprovals = ref < Map < string , PendingApproval > > ( new Map ( ) )
const activePendingApproval = computed ( ( ) = > {
const sid = activeSessionId . value
return sid ? pendingApprovals . value . get ( sid ) || null : null
} )
2026-05-02 13:26:57 +08:00
// 自动播放语音开关
const autoPlaySpeechEnabled = ref ( false )
function setAutoPlaySpeech ( enabled : boolean ) {
autoPlaySpeechEnabled . value = enabled
}
2026-04-29 16:26:24 +08:00
const isStreaming = computed ( ( ) = > {
const sid = activeSessionId . value
if ( sid == null ) return false
return streamStates . value . has ( sid ) || serverWorking . value . has ( sid )
} )
2026-04-11 21:33:04 +08:00
const isLoadingSessions = ref ( false )
2026-04-22 02:09:58 +02:00
const sessionsLoaded = ref ( false )
2026-04-11 21:33:04 +08:00
const isLoadingMessages = ref ( false )
2026-04-29 16:26:24 +08:00
const isRunActive = computed ( ( ) = > isStreaming . value )
// Compression state
const compressionState = ref < {
compressing : boolean
messageCount : number
beforeTokens : number
afterTokens : number
compressed : boolean | null
error? : string
} | null > ( null )
function setCompressionState ( state : typeof compressionState . value ) {
compressionState . value = state
}
2026-04-11 15:59:14 +08:00
2026-05-05 13:03:14 +08:00
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
}
2026-04-11 21:33:04 +08:00
const activeSession = ref < Session | null > ( null )
2026-04-15 11:00:47 +08:00
const messages = computed < Message [ ] > ( ( ) = > activeSession . value ? . messages || [ ] )
2026-04-11 15:59:14 +08:00
2026-04-19 21:51:25 +08:00
function isSessionLive ( sessionId : string ) : boolean {
2026-04-29 16:26:24 +08:00
return streamStates . value . has ( sessionId ) || serverWorking . value . has ( sessionId )
2026-04-18 00:00:24 +08:00
}
2026-04-11 21:33:04 +08:00
async function loadSessions() {
isLoadingSessions . value = true
try {
2026-04-12 23:59:18 +08:00
const list = await fetchSessions ( )
2026-04-18 00:00:24 +08:00
const fresh = list . map ( mapHermesSession )
// Preserve already-loaded messages for sessions that are still present,
// so we don't blow away the active session's messages on refresh.
const msgsByIdBefore = new Map ( sessions . value . map ( s = > [ s . id , s . messages ] ) )
for ( const s of fresh ) {
const prev = msgsByIdBefore . get ( s . id )
if ( prev && prev . length ) s . messages = prev
}
2026-05-05 13:03:14 +08:00
sessions . value = fresh
2026-04-18 00:00:24 +08:00
2026-04-15 11:00:47 +08:00
// Restore last active session, fallback to most recent
const savedId = activeSessionId . value
const targetId = savedId && sessions . value . some ( s = > s . id === savedId )
? savedId
: sessions.value [ 0 ] ? . id
if ( targetId ) {
await switchSession ( targetId )
2026-04-11 21:33:04 +08:00
}
} catch ( err ) {
console . error ( 'Failed to load sessions:' , err )
} finally {
isLoadingSessions . value = false
2026-04-22 02:09:58 +02:00
sessionsLoaded . value = true
2026-04-11 21:33:04 +08:00
}
}
2026-04-11 15:59:14 +08:00
2026-04-29 16:26:24 +08:00
// Re-pull active session from server. Used on tab-visible events.
2026-04-18 00:00:24 +08:00
async function refreshActiveSession ( ) : Promise < boolean > {
const sid = activeSessionId . value
if ( ! sid ) return false
try {
const detail = await fetchSession ( sid )
if ( ! detail ) return false
const target = sessions . value . find ( s = > s . id === sid )
if ( ! target ) return false
2026-04-24 22:18:32 +08:00
const mapped = mapHermesMessages ( detail . messages || [ ] )
2026-04-29 16:26:24 +08:00
target . messages = mapped
2026-04-24 22:18:32 +08:00
if ( detail . title ) target . title = detail . title
return true
2026-04-18 00:00:24 +08:00
} catch ( err ) {
console . error ( 'Failed to refresh active session:' , err )
return false
}
}
2026-04-13 00:52:34 +08:00
2026-04-11 15:59:14 +08:00
function createSession ( ) : Session {
const session : Session = {
id : uid ( ) ,
2026-04-15 16:36:04 +08:00
title : '' ,
2026-04-13 00:52:34 +08:00
source : 'api_server' ,
2026-04-11 15:59:14 +08:00
messages : [ ] ,
createdAt : Date.now ( ) ,
updatedAt : Date.now ( ) ,
}
sessions . value . unshift ( session )
return session
}
2026-05-14 09:03:57 +08:00
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
}
2026-04-22 14:00:34 +08:00
async function switchSession ( sessionId : string , focusId? : string | null ) {
2026-04-25 08:46:50 +08:00
clearThinkingObservationFor ( sessionId )
2026-04-11 15:59:14 +08:00
activeSessionId . value = sessionId
2026-04-22 14:00:34 +08:00
focusMessageId . value = focusId ? ? null
2026-04-23 07:35:05 +08:00
setItemBestEffort ( storageKey ( ) , sessionId )
const legacyActiveKey = legacyStorageKey ( )
if ( legacyActiveKey ) removeItem ( legacyActiveKey )
2026-04-11 15:59:14 +08:00
activeSession . value = sessions . value . find ( s = > s . id === sessionId ) || null
2026-04-11 21:33:04 +08:00
2026-04-18 00:00:24 +08:00
if ( ! activeSession . value ) return
2026-04-29 16:26:24 +08:00
isLoadingMessages . value = true
2026-04-18 00:00:24 +08:00
try {
2026-04-29 16:26:24 +08:00
// Load messages via Socket.IO resume (server loads from DB if not in memory)
await new Promise < void > ( ( resolve , reject ) = > {
const timeout = setTimeout ( ( ) = > reject ( new Error ( 'resume timeout' ) ) , 15 _000 )
resumeSession ( sessionId , ( data ) = > {
clearTimeout ( timeout )
if ( data . isWorking ) {
serverWorking . value . add ( sessionId )
} else {
serverWorking . value . delete ( sessionId )
2026-04-11 21:33:04 +08:00
}
2026-05-07 10:34:58 +08:00
if ( data . queueLength && data . queueLength > 0 ) {
queueLengths . value . set ( sessionId , data . queueLength )
} else {
queueLengths . value . delete ( sessionId )
}
2026-05-05 13:03:14 +08:00
if ( ( data as any ) . isAborting ) {
setAbortState ( { aborting : true , synced : null } )
} else if ( ! data . isWorking ) {
setAbortState ( null )
}
2026-04-29 16:26:24 +08:00
if ( data . inputTokens != null ) activeSession . value ! . inputTokens = data . inputTokens
if ( data . outputTokens != null ) activeSession . value ! . outputTokens = data . outputTokens
if ( data . messages ? . length ) {
activeSession . value ! . messages = mapHermesMessages ( data . messages as any [ ] )
}
if ( ! activeSession . value ! . title ) {
const firstUser = activeSession . value ! . messages . find ( m = > m . role === 'user' )
if ( firstUser ) {
const t = firstUser . content . slice ( 0 , 40 )
activeSession . value ! . title = t + ( firstUser . content . length > 40 ? '...' : '' )
}
}
// Process replayed events (compression state etc.)
if ( data . events ? . length ) {
for ( const evt of data . events ) {
const e = evt . data as any
if ( e . event === 'compression.started' ) {
setCompressionState ( {
compressing : true ,
messageCount : e.message_count || 0 ,
beforeTokens : e.token_count || 0 ,
afterTokens : 0 ,
compressed : null ,
} )
} else if ( e . event === 'compression.completed' ) {
setCompressionState ( {
compressing : false ,
messageCount : e.totalMessages || 0 ,
beforeTokens : e.beforeTokens || 0 ,
afterTokens : e.afterTokens || 0 ,
compressed : e.compressed ? ? false ,
error : e.error ,
} )
2026-05-05 13:03:14 +08:00
} else if ( e . event === 'abort.started' ) {
setAbortState ( { aborting : true , synced : null } )
} else if ( e . event === 'abort.completed' ) {
setAbortState ( { aborting : false , synced : e.synced ? ? false } )
2026-05-14 09:03:57 +08:00
} 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 === '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 ) {
updateMessage ( sessionId , toolMsgs [ toolMsgs . length - 1 ] . id , {
toolStatus : e.error === true ? 'error' : 'done' ,
toolDuration : e.duration ,
toolResult : typeof e . output === 'string' ? e.output : undefined ,
} )
}
2026-04-29 16:26:24 +08:00
}
}
}
resolve ( )
} )
} )
2026-04-18 00:00:24 +08:00
} catch ( err ) {
2026-04-29 16:26:24 +08:00
console . error ( 'Failed to load session messages via resume:' , err )
2026-04-18 00:00:24 +08:00
} finally {
isLoadingMessages . value = false
}
2026-04-29 16:26:24 +08:00
// Resume in-flight run event listeners if needed
2026-05-05 13:03:14 +08:00
resumeServerWorkingRun ( sessionId )
2026-04-11 15:59:14 +08:00
}
function newChat() {
const session = createSession ( )
2026-04-12 23:23:50 +08:00
// Inherit current global model
const appStore = useAppStore ( )
session . model = appStore . selectedModel || undefined
2026-04-11 15:59:14 +08:00
switchSession ( session . id )
}
2026-04-12 23:23:50 +08:00
async function switchSessionModel ( modelId : string , provider? : string ) {
if ( ! activeSession . value ) return
activeSession . value . model = modelId
activeSession . value . provider = provider || ''
// If provider changed, update global config too (Hermes requires it)
if ( provider ) {
const { useAppStore } = await import ( './app' )
await useAppStore ( ) . switchModel ( modelId , provider )
}
}
2026-04-11 21:33:04 +08:00
async function deleteSession ( sessionId : string ) {
await deleteSessionApi ( sessionId )
2026-04-11 15:59:14 +08:00
sessions . value = sessions . value . filter ( s = > s . id !== sessionId )
if ( activeSessionId . value === sessionId ) {
if ( sessions . value . length > 0 ) {
2026-04-11 21:33:04 +08:00
await switchSession ( sessions . value [ 0 ] . id )
2026-04-11 15:59:14 +08:00
} else {
const session = createSession ( )
switchSession ( session . id )
}
}
}
2026-04-15 11:00:47 +08:00
function getSessionMsgs ( sessionId : string ) : Message [ ] {
const s = sessions . value . find ( s = > s . id === sessionId )
return s ? . messages || [ ]
2026-04-15 10:28:53 +08:00
}
2026-04-15 11:00:47 +08:00
function addMessage ( sessionId : string , msg : Message ) {
const s = sessions . value . find ( s = > s . id === sessionId )
if ( s ) s . messages . push ( msg )
2026-04-11 18:54:46 +08:00
}
2026-05-01 11:27:43 +08:00
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 )
}
}
2026-04-15 11:00:47 +08:00
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 )
2026-04-11 21:33:04 +08:00
if ( idx !== - 1 ) {
2026-04-15 11:00:47 +08:00
s . messages [ idx ] = { . . . s . messages [ idx ] , . . . update }
2026-04-11 21:33:04 +08:00
}
}
2026-04-11 15:59:14 +08:00
2026-05-15 12:04:03 +08:00
function handleSessionCommandEvent ( evt : RunEvent ) {
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
if ( action === '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 ( 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 ) } ,
} )
}
}
2026-05-07 10:34:58 +08:00
function enqueueUserMessage ( sessionId : string , message : Message ) {
const queue = queuedUserMessages . value . get ( sessionId ) || [ ]
queue . push ( { . . . message , queued : true } )
queuedUserMessages . value . set ( sessionId , queue )
}
function removeQueuedMessage ( sessionId : string , messageId : string ) {
const queue = queuedUserMessages . value . get ( sessionId )
if ( ! queue ? . length ) return
const next = queue . filter ( message = > message . id !== messageId )
if ( next . length > 0 ) {
queuedUserMessages . value . set ( sessionId , next )
} else {
queuedUserMessages . value . delete ( sessionId )
}
queueLengths . value . set ( sessionId , next . length )
getChatRunSocket ( ) ? . emit ( 'cancel_queued_run' , {
session_id : sessionId ,
queue_id : messageId ,
} )
}
2026-05-14 09:03:57 +08:00
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 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 )
}
2026-05-07 10:34:58 +08:00
function showNextQueuedUserMessage ( sessionId : string ) {
const queue = queuedUserMessages . value . get ( sessionId )
if ( ! queue ? . length ) return
const next = queue . shift ( ) !
if ( queue . length > 0 ) {
queuedUserMessages . value . set ( sessionId , queue )
} else {
queuedUserMessages . value . delete ( sessionId )
}
addMessage ( sessionId , { . . . next , queued : false } )
updateSessionTitle ( sessionId )
}
2026-04-15 11:00:47 +08:00
function updateSessionTitle ( sessionId : string ) {
const target = sessions . value . find ( s = > s . id === sessionId )
2026-04-15 10:28:53 +08:00
if ( ! target ) return
2026-04-15 16:36:04 +08:00
if ( ! target . title ) {
2026-04-15 11:00:47 +08:00
const firstUser = target . messages . find ( m = > m . role === 'user' )
2026-04-11 15:59:14 +08:00
if ( firstUser ) {
2026-04-11 18:54:46 +08:00
const title = firstUser . attachments ? . length
? firstUser . attachments . map ( a = > a . name ) . join ( ', ' )
: firstUser . content
2026-04-15 10:28:53 +08:00
target . title = title . slice ( 0 , 40 ) + ( title . length > 40 ? '...' : '' )
2026-04-11 15:59:14 +08:00
}
}
2026-04-15 10:28:53 +08:00
target . updatedAt = Date . now ( )
2026-04-11 15:59:14 +08:00
}
2026-05-06 08:23:12 +02:00
function primeCompletionBellIfEnabled() {
if ( useSettingsStore ( ) . display . bell_on_complete ) {
primeCompletionSound ( )
}
}
function playCompletionBellIfEnabled() {
if ( useSettingsStore ( ) . display . bell_on_complete ) {
void playCompletionSound ( )
}
}
2026-04-11 18:54:46 +08:00
async function sendMessage ( content : string , attachments? : Attachment [ ] ) {
2026-05-09 08:36:13 +08:00
if ( ( ! content . trim ( ) && ! ( attachments && attachments . length > 0 ) ) ) return
2026-04-11 15:59:14 +08:00
2026-05-06 08:23:12 +02:00
primeCompletionBellIfEnabled ( )
2026-04-11 15:59:14 +08:00
if ( ! activeSession . value ) {
const session = createSession ( )
switchSession ( session . id )
}
2026-04-15 11:00:47 +08:00
// Capture session ID at send time — all callbacks use this, not activeSessionId
const sid = activeSessionId . value !
2026-05-15 12:04:03 +08:00
const isBridgeSlashCommand = activeSession . value ? . source === 'cli' && content . trim ( ) . startsWith ( '/' )
2026-05-15 13:50:27 +08:00
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i . test ( content . trim ( ) )
2026-05-15 12:04:03 +08:00
const wasLiveBeforeSend = isSessionLive ( sid )
const shouldQueue = wasLiveBeforeSend && ! isBridgeSlashCommand
2026-04-15 11:00:47 +08:00
2026-04-11 15:59:14 +08:00
const userMsg : Message = {
id : uid ( ) ,
2026-05-15 12:04:03 +08:00
role : isBridgeSlashCommand ? 'command' : 'user' ,
2026-04-11 15:59:14 +08:00
content : content.trim ( ) ,
timestamp : Date.now ( ) ,
2026-04-11 18:54:46 +08:00
attachments : attachments && attachments . length > 0 ? attachments : undefined ,
2026-05-07 10:34:58 +08:00
queued : shouldQueue ,
2026-05-15 12:04:03 +08:00
systemType : isBridgeSlashCommand ? 'command' : undefined ,
2026-04-11 15:59:14 +08:00
}
2026-04-27 17:18:12 +08:00
2026-05-07 10:34:58 +08:00
if ( ! shouldQueue ) {
addMessage ( sid , userMsg )
updateSessionTitle ( sid )
}
2026-04-11 15:59:14 +08:00
try {
2026-05-02 15:39:01 +08:00
// Build input in Anthropic format
let input : string | ContentBlock [ ]
2026-04-11 18:54:46 +08:00
if ( attachments && attachments . length > 0 ) {
2026-05-02 15:39:01 +08:00
// Has attachments: upload first, then build content blocks
2026-04-11 18:54:46 +08:00
const uploaded = await uploadFiles ( attachments )
2026-05-02 15:39:01 +08:00
// Update attachment URLs on the user message for display
2026-04-26 13:28:08 +08:00
const token = getApiKey ( )
const urlMap = new Map ( uploaded . map ( f = > {
const base = ` /api/hermes/download?path= ${ encodeURIComponent ( f . path ) } &name= ${ encodeURIComponent ( f . name ) } `
return [ f . name , token ? ` ${ base } &token= ${ encodeURIComponent ( token ) } ` : base ]
} ) )
2026-05-07 10:34:58 +08:00
if ( shouldQueue && userMsg . attachments ) {
userMsg . attachments = userMsg . attachments . map ( a = > {
2026-04-26 13:28:08 +08:00
const dl = urlMap . get ( a . name )
return dl ? { . . . a , url : dl } : a
} )
2026-05-07 10:34:58 +08:00
} 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
} )
}
2026-04-26 13:28:08 +08:00
}
2026-05-02 15:39:01 +08:00
// Build content blocks with uploaded file paths
input = await buildContentBlocks ( content , attachments , uploaded )
} else {
// No attachments: use plain text format
input = content . trim ( )
2026-04-11 18:54:46 +08:00
}
2026-04-12 23:23:50 +08:00
const appStore = useAppStore ( )
const sessionModel = activeSession . value ? . model || appStore . selectedModel
2026-04-29 16:26:24 +08:00
const runPayload = {
2026-05-02 15:39:01 +08:00
input ,
2026-04-15 11:00:47 +08:00
session_id : sid ,
2026-04-12 23:23:50 +08:00
model : sessionModel || undefined ,
2026-05-07 10:34:58 +08:00
queue_id : userMsg.id ,
2026-05-14 09:03:57 +08:00
source : ( activeSession . value ? . source === 'cli' ? 'cli' : 'api_server' ) as 'cli' | 'api_server' ,
2026-05-07 10:34:58 +08:00
}
if ( shouldQueue ) {
enqueueUserMessage ( sid , userMsg )
2026-04-11 15:59:14 +08:00
}
2026-04-15 11:00:47 +08:00
// Helper to clean up this session's stream state
const cleanup = ( ) = > {
streamStates . value . delete ( sid )
2026-04-29 16:26:24 +08:00
serverWorking . value . delete ( sid )
2026-04-18 00:00:24 +08:00
}
2026-05-07 10:34:58 +08:00
// Per-active-run flags used to detect silently-swallowed errors at run.completed.
2026-04-25 16:21:07 +08:00
// 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.
2026-05-07 10:34:58 +08:00
// Reset on every run.started because one handler may span multiple queued runs.
2026-04-25 16:21:07 +08:00
let runProducedAssistantText = false
let runHadToolActivity = false
2026-05-07 10:34:58 +08:00
let activeAssistantMessageId : string | null = null
const startNextQueuedUser = ( ) = > {
showNextQueuedUserMessage ( sid )
}
const closeStreamingAssistant = ( ) = > {
const msgs = getSessionMsgs ( sid )
msgs . forEach ( m = > {
if ( m . role === 'assistant' && m . isStreaming ) {
updateMessage ( sid , m . id , { isStreaming : false } )
}
} )
activeAssistantMessageId = null
}
2026-04-15 11:00:47 +08:00
2026-04-29 16:26:24 +08:00
// Send run via Socket.IO and listen to streamed events — all closures capture `sid`
const ctrl = startRunViaSocket (
runPayload ,
2026-04-11 15:59:14 +08:00
// onEvent
( evt : RunEvent ) = > {
switch ( evt . event ) {
case 'run.started' :
2026-05-07 10:34:58 +08:00
setAbortState ( null )
2026-05-16 11:15:29 +08:00
setCompressionState ( null )
2026-05-07 10:34:58 +08:00
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant ( )
startNextQueuedUser ( )
if ( ( evt as any ) . queue_length > 0 ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_length )
} else {
queueLengths . value . delete ( sid )
}
2026-04-11 15:59:14 +08:00
break
2026-05-07 10:34:58 +08:00
case 'run.queued' : {
queueLengths . value . set ( sid , ( evt as any ) . queue_length || 0 )
break
}
2026-05-15 12:04:03 +08:00
case 'session.command' : {
handleSessionCommandEvent ( evt )
break
}
2026-04-29 16:26:24 +08:00
case 'compression.started' : {
setCompressionState ( {
compressing : true ,
messageCount : ( evt as any ) . message_count || 0 ,
beforeTokens : ( evt as any ) . token_count || 0 ,
afterTokens : 0 ,
compressed : null ,
} )
break
}
case 'compression.completed' : {
setCompressionState ( {
compressing : false ,
messageCount : ( evt as any ) . totalMessages || 0 ,
beforeTokens : ( evt as any ) . beforeTokens || 0 ,
afterTokens : ( evt as any ) . afterTokens || 0 ,
compressed : ( evt as any ) . compressed ? ? false ,
error : ( evt as any ) . error ,
} )
// Auto-clear after 5s
setTimeout ( ( ) = > {
if ( compressionState . value && ! compressionState . value . compressing ) {
setCompressionState ( null )
}
} , 5000 )
break
}
2026-05-05 13:03:14 +08:00
case 'abort.started' : {
setAbortState ( { aborting : true , synced : null } )
break
}
case 'abort.completed' : {
setAbortState ( { aborting : false , synced : ( evt as any ) . synced ? ? false } )
2026-05-07 10:34:58 +08:00
if ( ( evt as any ) . queue_length > 0 ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_length )
setAbortState ( null )
break
}
2026-05-05 13:03:14 +08:00
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
}
2026-04-25 08:46:50 +08:00
case 'reasoning.delta' :
case 'thinking.delta' : {
const text = evt . text || evt . delta || ''
if ( ! text ) break
2026-04-25 16:21:07 +08:00
runProducedAssistantText = true
2026-04-25 08:46:50 +08:00
const msgs = getSessionMsgs ( sid )
2026-05-07 10:34:58 +08:00
const last = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: null
2026-04-25 08:46:50 +08:00
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 ,
} )
2026-05-07 10:34:58 +08:00
activeAssistantMessageId = newId
2026-04-25 08:46:50 +08:00
noteReasoningStart ( newId )
}
2026-04-29 16:26:24 +08:00
2026-04-25 08:46:50 +08:00
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 )
}
2026-04-29 16:26:24 +08:00
2026-04-25 08:46:50 +08:00
break
}
2026-04-24 22:18:32 +08:00
case 'message.delta' : {
2026-04-25 16:21:07 +08:00
if ( evt . delta ) runProducedAssistantText = true
2026-04-15 11:00:47 +08:00
const msgs = getSessionMsgs ( sid )
2026-05-07 10:34:58 +08:00
const last = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: null
2026-04-11 15:59:14 +08:00
if ( last ? . role === 'assistant' && last . isStreaming ) {
2026-04-25 08:46:50 +08:00
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
2026-04-11 15:59:14 +08:00
} else {
2026-04-25 08:46:50 +08:00
const newId = uid ( )
const nextContent = evt . delta || ''
noteThinkingDelta ( newId , '' , nextContent )
2026-04-15 11:00:47 +08:00
addMessage ( sid , {
2026-04-25 08:46:50 +08:00
id : newId ,
2026-04-11 15:59:14 +08:00
role : 'assistant' ,
2026-04-25 08:46:50 +08:00
content : nextContent ,
2026-04-11 15:59:14 +08:00
timestamp : Date.now ( ) ,
isStreaming : true ,
} )
2026-05-07 10:34:58 +08:00
activeAssistantMessageId = newId
2026-04-11 15:59:14 +08:00
}
2026-04-29 16:26:24 +08:00
2026-04-11 15:59:14 +08:00
break
}
case 'tool.started' : {
2026-04-25 16:21:07 +08:00
runHadToolActivity = true
2026-04-15 11:00:47 +08:00
const msgs = getSessionMsgs ( sid )
2026-05-10 02:49:58 +08:00
const toolCallId = ( evt as any ) . tool_call_id as string | undefined
2026-05-07 10:34:58 +08:00
const last = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: msgs [ msgs . length - 1 ]
2026-04-11 15:59:14 +08:00
if ( last ? . isStreaming ) {
2026-04-15 11:00:47 +08:00
updateMessage ( sid , last . id , { isStreaming : false } )
2026-04-11 15:59:14 +08:00
}
2026-05-07 10:34:58 +08:00
activeAssistantMessageId = null
2026-05-10 02:49:58 +08:00
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
}
2026-04-15 11:00:47 +08:00
addMessage ( sid , {
2026-04-11 15:59:14 +08:00
id : uid ( ) ,
role : 'tool' ,
content : '' ,
timestamp : Date.now ( ) ,
toolName : evt.tool || evt . name ,
2026-05-10 02:49:58 +08:00
toolCallId ,
2026-04-11 15:59:14 +08:00
toolPreview : evt.preview ,
2026-05-10 02:49:58 +08:00
toolArgs : typeof ( evt as any ) . arguments === 'string' ? ( evt as any ) . arguments : undefined ,
2026-04-11 15:59:14 +08:00
toolStatus : 'running' ,
} )
2026-04-29 16:26:24 +08:00
2026-04-11 15:59:14 +08:00
break
}
case 'tool.completed' : {
2026-04-25 16:21:07 +08:00
runHadToolActivity = true
2026-04-15 11:00:47 +08:00
const msgs = getSessionMsgs ( sid )
2026-05-10 02:49:58 +08:00
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' )
2026-04-11 15:59:14 +08:00
if ( toolMsgs . length > 0 ) {
const last = toolMsgs [ toolMsgs . length - 1 ]
2026-04-30 16:40:37 +08:00
// Check if tool errored
const hasError = ( evt as any ) . error === true
const duration = ( evt as any ) . duration
updateMessage ( sid , last . id , {
toolStatus : hasError ? 'error' : 'done' ,
toolDuration : duration ,
2026-05-10 02:49:58 +08:00
toolResult : typeof ( evt as any ) . output === 'string' ? ( evt as any ) . output : undefined ,
2026-04-30 16:40:37 +08:00
} )
2026-04-11 15:59:14 +08:00
}
2026-04-29 16:26:24 +08:00
2026-04-11 15:59:14 +08:00
break
}
2026-05-14 09:03:57 +08:00
case 'approval.requested' : {
setPendingApproval ( evt )
break
}
case 'approval.resolved' : {
clearPendingApproval ( evt )
break
}
2026-04-15 11:00:47 +08:00
case 'run.completed' : {
const msgs = getSessionMsgs ( sid )
2026-05-07 10:34:58 +08:00
const lastMsg = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: msgs [ msgs . length - 1 ]
2026-04-11 15:59:14 +08:00
if ( lastMsg ? . isStreaming ) {
2026-04-15 11:00:47 +08:00
updateMessage ( sid , lastMsg . id , { isStreaming : false } )
2026-04-11 15:59:14 +08:00
}
2026-04-29 16:26:24 +08:00
// Server-computed usage (local countTokens, snapshot-aware)
if ( ( evt as any ) . inputTokens != null ) {
2026-04-22 16:14:50 +08:00
const target = sessions . value . find ( s = > s . id === sid )
if ( target ) {
2026-04-29 16:26:24 +08:00
target . inputTokens = ( evt as any ) . inputTokens
target . outputTokens = ( evt as any ) . outputTokens
2026-04-22 16:14:50 +08:00
}
}
2026-04-25 16:21:07 +08:00
// 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.
2026-04-30 16:40:37 +08:00
// 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 )
2026-05-07 10:34:58 +08:00
const lastAssistant = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: [ . . . msgs ] . reverse ( ) . find ( m = > m . role === 'assistant' )
2026-04-30 16:40:37 +08:00
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
}
2026-04-25 16:21:07 +08:00
}
// 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 ( ) ,
} )
2026-05-06 08:23:12 +02:00
} else {
playCompletionBellIfEnabled ( )
2026-04-25 16:21:07 +08:00
}
2026-05-01 08:13:55 +08:00
2026-05-02 13:26:57 +08:00
// 自动播放语音
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 )
}
}
2026-05-07 10:34:58 +08:00
if ( ( evt as any ) . queue_remaining > 0 ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_remaining )
} else {
cleanup ( )
}
activeAssistantMessageId = null
2026-04-15 11:00:47 +08:00
updateSessionTitle ( sid )
2026-04-11 15:59:14 +08:00
break
2026-04-15 11:00:47 +08:00
}
2026-04-11 15:59:14 +08:00
2026-04-15 11:00:47 +08:00
case 'run.failed' : {
const msgs = getSessionMsgs ( sid )
const lastErr = msgs [ msgs . length - 1 ]
2026-04-11 15:59:14 +08:00
if ( lastErr ? . isStreaming ) {
2026-04-15 11:00:47 +08:00
updateMessage ( sid , lastErr . id , {
2026-04-11 15:59:14 +08:00
isStreaming : false ,
content : evt.error ? ` Error: ${ evt . error } ` : 'Run failed' ,
role : 'system' ,
} )
} else {
2026-04-15 11:00:47 +08:00
addMessage ( sid , {
2026-04-11 15:59:14 +08:00
id : uid ( ) ,
role : 'system' ,
content : evt.error ? ` Error: ${ evt . error } ` : 'Run failed' ,
timestamp : Date.now ( ) ,
} )
}
2026-04-15 11:00:47 +08:00
msgs . forEach ( ( m , i ) = > {
2026-04-11 15:59:14 +08:00
if ( m . role === 'tool' && m . toolStatus === 'running' ) {
2026-04-15 11:00:47 +08:00
msgs [ i ] = { . . . m , toolStatus : 'error' }
2026-04-11 15:59:14 +08:00
}
} )
2026-05-07 10:34:58 +08:00
if ( ( evt as any ) . queue_remaining > 0 ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_remaining )
} else {
cleanup ( )
}
2026-04-29 16:26:24 +08:00
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
}
2026-04-11 15:59:14 +08:00
break
2026-04-15 11:00:47 +08:00
}
2026-04-11 15:59:14 +08:00
}
} ,
// onDone
( ) = > {
2026-04-15 11:00:47 +08:00
const msgs = getSessionMsgs ( sid )
const last = msgs [ msgs . length - 1 ]
2026-04-11 15:59:14 +08:00
if ( last ? . isStreaming ) {
2026-04-15 11:00:47 +08:00
updateMessage ( sid , last . id , { isStreaming : false } )
2026-04-11 15:59:14 +08:00
}
2026-04-15 11:00:47 +08:00
cleanup ( )
updateSessionTitle ( sid )
2026-04-11 15:59:14 +08:00
} ,
// onError
( err ) = > {
2026-04-29 16:26:24 +08:00
console . warn ( 'Socket.IO run stream error:' , err . message )
2026-04-15 11:00:47 +08:00
const msgs = getSessionMsgs ( sid )
const last = msgs [ msgs . length - 1 ]
2026-04-11 15:59:14 +08:00
if ( last ? . isStreaming ) {
2026-04-18 00:00:24 +08:00
updateMessage ( sid , last . id , { isStreaming : false } )
2026-04-11 15:59:14 +08:00
}
2026-04-24 22:18:32 +08:00
msgs . forEach ( ( m , i ) = > {
if ( m . role === 'tool' && m . toolStatus === 'running' ) {
msgs [ i ] = { . . . m , toolStatus : 'done' }
}
} )
2026-04-15 11:00:47 +08:00
cleanup ( )
2026-04-18 00:00:24 +08:00
if ( sid === activeSessionId . value ) {
void refreshActiveSession ( )
}
2026-04-29 16:26:24 +08:00
} ,
2026-05-05 13:03:14 +08:00
undefined ,
2026-04-11 15:59:14 +08:00
)
2026-04-15 11:00:47 +08:00
2026-05-15 13:50:27 +08:00
if ( ! isBridgeSlashCommand || isBridgeCompressCommand ) {
2026-05-15 12:04:03 +08:00
streamStates . value . set ( sid , ctrl )
}
2026-04-11 15:59:14 +08:00
} catch ( err : any ) {
2026-04-15 11:00:47 +08:00
addMessage ( sid , {
2026-04-11 15:59:14 +08:00
id : uid ( ) ,
role : 'system' ,
content : ` Error: ${ err . message } ` ,
timestamp : Date.now ( ) ,
} )
}
}
2026-04-29 16:26:24 +08:00
/**
* 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.
*/
2026-05-05 13:03:14 +08:00
function resumeServerWorkingRun ( sid : string ) {
2026-04-29 16:26:24 +08:00
// Don't register duplicate listeners if already streaming
if ( streamStates . value . has ( sid ) ) return
2026-05-05 13:03:14 +08:00
// Only set up listeners if the server reported an active run during resume.
if ( ! serverWorking . value . has ( sid ) ) return
2026-04-29 16:26:24 +08:00
let closed = false
let runProducedAssistantText = false
let runHadToolActivity = false
2026-05-07 10:34:58 +08:00
let activeAssistantMessageId : string | null = null
2026-04-29 16:26:24 +08:00
const cleanup = ( ) = > {
if ( closed ) return
closed = true
streamStates . value . delete ( sid )
serverWorking . value . delete ( sid )
2026-05-01 08:13:55 +08:00
// Unregister from global session handlers
unregisterSessionHandlers ( sid )
2026-04-29 16:26:24 +08:00
}
2026-05-07 10:34:58 +08:00
const startNextQueuedUser = ( ) = > {
showNextQueuedUserMessage ( sid )
}
const closeStreamingAssistant = ( ) = > {
const msgs = getSessionMsgs ( sid )
msgs . forEach ( m = > {
if ( m . role === 'assistant' && m . isStreaming ) {
updateMessage ( sid , m . id , { isStreaming : false } )
}
} )
activeAssistantMessageId = null
}
2026-04-29 16:26:24 +08:00
// 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 ) {
2026-05-07 10:34:58 +08:00
case 'run.queued' : {
queueLengths . value . set ( sid , ( evt as any ) . queue_length || 0 )
break
}
2026-05-15 12:04:03 +08:00
case 'session.command' : {
handleSessionCommandEvent ( evt )
break
}
2026-04-29 16:26:24 +08:00
case 'run.started' :
2026-05-07 10:34:58 +08:00
setAbortState ( null )
2026-05-16 11:15:29 +08:00
setCompressionState ( null )
2026-05-07 10:34:58 +08:00
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant ( )
startNextQueuedUser ( )
if ( ( evt as any ) . queue_length > 0 ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_length )
} else {
queueLengths . value . delete ( sid )
}
2026-04-29 16:26:24 +08:00
break
case 'compression.started' : {
setCompressionState ( {
compressing : true ,
messageCount : ( evt as any ) . message_count || 0 ,
beforeTokens : ( evt as any ) . token_count || 0 ,
afterTokens : 0 ,
compressed : null ,
} )
break
}
case 'compression.completed' : {
setCompressionState ( {
compressing : false ,
messageCount : ( evt as any ) . totalMessages || 0 ,
beforeTokens : ( evt as any ) . beforeTokens || 0 ,
afterTokens : ( evt as any ) . afterTokens || 0 ,
compressed : ( evt as any ) . compressed ? ? false ,
error : ( evt as any ) . error ,
} )
setTimeout ( ( ) = > {
if ( compressionState . value && ! compressionState . value . compressing ) {
setCompressionState ( null )
}
} , 5000 )
break
}
2026-05-05 13:03:14 +08:00
case 'abort.started' : {
setAbortState ( { aborting : true , synced : null } )
break
}
case 'abort.completed' : {
setAbortState ( { aborting : false , synced : ( evt as any ) . synced ? ? false } )
2026-05-07 10:34:58 +08:00
if ( ( evt as any ) . queue_length > 0 ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_length )
setAbortState ( null )
break
}
2026-05-05 13:03:14 +08:00
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
}
2026-04-29 16:26:24 +08:00
case 'reasoning.delta' :
case 'thinking.delta' : {
const text = evt . text || evt . delta || ''
if ( ! text ) break
runProducedAssistantText = true
const msgs = getSessionMsgs ( sid )
2026-05-07 10:34:58 +08:00
const last = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: null
2026-04-29 16:26:24 +08:00
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 ,
} )
2026-05-07 10:34:58 +08:00
activeAssistantMessageId = newId
2026-04-29 16:26:24 +08:00
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 )
2026-05-07 10:34:58 +08:00
const last = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: null
2026-04-29 16:26:24 +08:00
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 ,
} )
2026-05-07 10:34:58 +08:00
activeAssistantMessageId = newId
2026-04-29 16:26:24 +08:00
}
break
}
case 'tool.started' : {
runHadToolActivity = true
const msgs = getSessionMsgs ( sid )
2026-05-10 02:49:58 +08:00
const toolCallId = ( evt as any ) . tool_call_id as string | undefined
2026-05-07 10:34:58 +08:00
const last = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: msgs [ msgs . length - 1 ]
2026-04-29 16:26:24 +08:00
if ( last ? . isStreaming ) {
updateMessage ( sid , last . id , { isStreaming : false } )
}
2026-05-07 10:34:58 +08:00
activeAssistantMessageId = null
2026-05-10 02:49:58 +08:00
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
}
2026-04-29 16:26:24 +08:00
addMessage ( sid , {
id : uid ( ) ,
role : 'tool' ,
content : '' ,
timestamp : Date.now ( ) ,
toolName : evt.tool || evt . name ,
2026-05-10 02:49:58 +08:00
toolCallId ,
2026-04-29 16:26:24 +08:00
toolPreview : evt.preview ,
2026-05-10 02:49:58 +08:00
toolArgs : typeof ( evt as any ) . arguments === 'string' ? ( evt as any ) . arguments : undefined ,
2026-04-29 16:26:24 +08:00
toolStatus : 'running' ,
} )
break
}
case 'tool.completed' : {
runHadToolActivity = true
const msgs = getSessionMsgs ( sid )
2026-05-10 02:49:58 +08:00
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' )
2026-04-29 16:26:24 +08:00
if ( toolMsgs . length > 0 ) {
2026-04-30 16:40:37 +08:00
const hasError = ( evt as any ) . error === true
updateMessage ( sid , toolMsgs [ toolMsgs . length - 1 ] . id , {
toolStatus : hasError ? 'error' : 'done' ,
toolDuration : ( evt as any ) . duration ,
2026-05-10 02:49:58 +08:00
toolResult : typeof ( evt as any ) . output === 'string' ? ( evt as any ) . output : undefined ,
2026-04-30 16:40:37 +08:00
} )
2026-04-29 16:26:24 +08:00
}
break
}
2026-05-14 09:03:57 +08:00
case 'approval.requested' : {
setPendingApproval ( evt )
break
}
case 'approval.resolved' : {
clearPendingApproval ( evt )
break
}
2026-04-29 16:26:24 +08:00
case 'run.completed' : {
2026-05-07 10:34:58 +08:00
const hasQueue = ( evt as any ) . queue_remaining > 0
if ( hasQueue ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_remaining )
} else {
queueLengths . value . delete ( sid )
}
2026-04-29 16:26:24 +08:00
const msgs = getSessionMsgs ( sid )
2026-05-07 10:34:58 +08:00
const lastMsg = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: msgs [ msgs . length - 1 ]
2026-04-29 16:26:24 +08:00
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
}
}
2026-04-30 16:40:37 +08:00
// 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 )
2026-05-07 10:34:58 +08:00
const lastAssistant = activeAssistantMessageId
? msgs . find ( m = > m . id === activeAssistantMessageId )
: [ . . . msgs ] . reverse ( ) . find ( m = > m . role === 'assistant' )
2026-04-30 16:40:37 +08:00
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 ( ) ,
} )
}
2026-04-29 16:26:24 +08:00
}
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 ( ) ,
} )
2026-05-06 08:23:12 +02:00
} else {
playCompletionBellIfEnabled ( )
2026-04-29 16:26:24 +08:00
}
2026-05-01 08:13:55 +08:00
2026-05-07 10:34:58 +08:00
// 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
}
2026-04-29 16:26:24 +08:00
updateSessionTitle ( sid )
break
}
case 'run.failed' : {
2026-05-07 10:34:58 +08:00
const hasQueue = ( evt as any ) . queue_remaining > 0
if ( hasQueue ) {
queueLengths . value . set ( sid , ( evt as any ) . queue_remaining )
} else {
queueLengths . value . delete ( sid )
}
2026-04-29 16:26:24 +08:00
const msgs = getSessionMsgs ( sid )
const lastErr = msgs [ msgs . length - 1 ]
if ( lastErr ? . isStreaming ) {
updateMessage ( sid , lastErr . id , {
isStreaming : false ,
content : evt.error ? ` Error: ${ evt . error } ` : 'Run failed' ,
role : 'system' ,
} )
} else {
addMessage ( sid , {
id : uid ( ) ,
role : 'system' ,
content : evt.error ? ` Error: ${ evt . error } ` : 'Run failed' ,
timestamp : Date.now ( ) ,
} )
}
msgs . forEach ( ( m , i ) = > {
if ( m . role === 'tool' && m . toolStatus === 'running' ) {
msgs [ i ] = { . . . m , toolStatus : 'error' }
}
} )
2026-05-07 10:34:58 +08:00
if ( ! hasQueue ) {
cleanup ( )
}
2026-04-29 16:26:24 +08:00
break
}
case 'usage.updated' : {
const target = sessions . value . find ( s = > s . id === sid )
if ( target ) {
target . inputTokens = ( evt as any ) . inputTokens
target . outputTokens = ( evt as any ) . outputTokens
}
break
}
}
}
2026-05-01 08:13:55 +08:00
// Register handlers in global session map
registerSessionHandlers ( sid , {
onMessageDelta : ( evt ) = > handleEvent ( evt ) ,
onReasoningDelta : ( evt ) = > handleEvent ( evt ) ,
onThinkingDelta : ( evt ) = > handleEvent ( evt ) ,
onReasoningAvailable : ( evt ) = > handleEvent ( evt ) ,
onToolStarted : ( evt ) = > handleEvent ( evt ) ,
onToolCompleted : ( evt ) = > handleEvent ( evt ) ,
onRunStarted : ( evt ) = > handleEvent ( evt ) ,
onRunCompleted : ( evt ) = > handleEvent ( evt ) ,
onRunFailed : ( evt ) = > handleEvent ( evt ) ,
onCompressionStarted : ( evt ) = > handleEvent ( evt ) ,
onCompressionCompleted : ( evt ) = > handleEvent ( evt ) ,
2026-05-05 13:03:14 +08:00
onAbortStarted : ( evt ) = > handleEvent ( evt ) ,
onAbortCompleted : ( evt ) = > handleEvent ( evt ) ,
2026-05-01 08:13:55 +08:00
onUsageUpdated : ( evt ) = > handleEvent ( evt ) ,
2026-05-15 12:04:03 +08:00
onSessionCommand : ( evt ) = > handleEvent ( evt ) ,
2026-05-07 10:34:58 +08:00
onRunQueued : ( evt ) = > handleEvent ( evt ) ,
2026-05-01 08:13:55 +08:00
} )
2026-04-29 16:26:24 +08:00
// No need to emit resume here — switchSession already did it.
// Server already joined room and replayed events.
2026-05-01 08:13:55 +08:00
// Just set up handlers for ongoing streaming events.
2026-04-29 16:26:24 +08:00
2026-05-05 13:03:14 +08:00
// 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 } )
} ,
} )
2026-04-29 16:26:24 +08:00
}
2026-04-11 15:59:14 +08:00
function stopStreaming() {
2026-04-15 11:00:47 +08:00
const sid = activeSessionId . value
if ( ! sid ) return
2026-05-05 13:03:14 +08:00
if ( isAborting . value ) return
2026-04-15 11:00:47 +08:00
const ctrl = streamStates . value . get ( sid )
if ( ctrl ) {
2026-05-05 13:03:14 +08:00
setAbortState ( { aborting : true , synced : null } )
2026-04-15 11:00:47 +08:00
ctrl . abort ( )
const msgs = getSessionMsgs ( sid )
const lastMsg = msgs [ msgs . length - 1 ]
if ( lastMsg ? . isStreaming ) {
updateMessage ( sid , lastMsg . id , { isStreaming : false } )
}
2026-05-07 10:34:58 +08:00
window . setTimeout ( ( ) = > {
if ( activeSessionId . value === sid && abortState . value ? . aborting ) {
streamStates . value . delete ( sid )
serverWorking . value . delete ( sid )
setAbortState ( null )
}
} , 20 _000 )
2026-04-11 15:59:14 +08:00
}
}
2026-04-18 14:32:54 +08:00
// Tab visibility: re-sync when returning to foreground
2026-04-18 00:00:24 +08:00
if ( typeof document !== 'undefined' ) {
document . addEventListener ( 'visibilitychange' , ( ) = > {
if ( document . visibilityState === 'visible' && activeSessionId . value && ! isStreaming . value ) {
2026-04-29 16:26:24 +08:00
const sid = activeSessionId . value
if ( sid && ! streamStates . value . has ( sid ) ) {
// Re-load messages via resume (server loads from DB)
resumeSession ( sid , ( data ) = > {
2026-05-05 13:03:14 +08:00
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 )
}
2026-04-29 16:26:24 +08:00
if ( data . messages ? . length && activeSession . value ) {
activeSession . value . messages = mapHermesMessages ( data . messages as any [ ] )
}
2026-05-05 13:03:14 +08:00
resumeServerWorkingRun ( sid )
2026-04-29 16:26:24 +08:00
} )
2026-04-18 00:00:24 +08:00
}
}
} )
}
2026-04-25 08:46:50 +08:00
// 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 )
}
}
2026-04-26 22:51:35 +08:00
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 = ''
}
}
}
2026-04-25 08:46:50 +08:00
function clearThinkingObservationFor ( _sessionId : string ) {
// messageId 与 sessionId 的关联未单独持有;方案是切会话时一律清空。
// 这符合 spec 定义:observation 是"当前会话范围内"的 transient 状态。
thinkingObservation . clear ( )
}
2026-05-02 13:26:57 +08:00
// 播放消息语音
function playMessageSpeech ( messageId : string , content : string ) {
// 触发自定义事件,让 MessageItem 组件处理播放
const event = new CustomEvent ( 'auto-play-speech' , {
detail : { messageId , content }
} )
window . dispatchEvent ( event )
}
2026-04-11 15:59:14 +08:00
return {
sessions ,
activeSessionId ,
activeSession ,
2026-04-22 14:00:34 +08:00
focusMessageId ,
2026-04-11 15:59:14 +08:00
messages ,
isStreaming ,
2026-04-18 00:00:24 +08:00
isRunActive ,
2026-04-19 21:51:25 +08:00
isSessionLive ,
2026-04-29 16:26:24 +08:00
compressionState ,
2026-05-05 13:03:14 +08:00
abortState ,
isAborting ,
2026-05-07 10:34:58 +08:00
queueLengths ,
queuedUserMessages ,
2026-05-14 09:03:57 +08:00
pendingApprovals ,
activePendingApproval ,
2026-05-07 10:34:58 +08:00
removeQueuedMessage ,
2026-04-11 21:33:04 +08:00
isLoadingSessions ,
2026-04-22 02:09:58 +02:00
sessionsLoaded ,
2026-04-11 21:33:04 +08:00
isLoadingMessages ,
2026-04-22 02:09:58 +02:00
2026-04-11 15:59:14 +08:00
newChat ,
2026-05-14 09:03:57 +08:00
newCliSession ,
2026-04-11 15:59:14 +08:00
switchSession ,
2026-04-12 23:23:50 +08:00
switchSessionModel ,
2026-05-01 11:27:43 +08:00
addOrUpdateSession ,
2026-04-26 22:51:35 +08:00
clearProviderFromSessions ,
2026-04-11 15:59:14 +08:00
deleteSession ,
sendMessage ,
stopStreaming ,
2026-05-14 09:03:57 +08:00
respondApproval ,
2026-04-11 21:33:04 +08:00
loadSessions ,
2026-04-18 00:00:24 +08:00
refreshActiveSession ,
2026-04-25 08:46:50 +08:00
getThinkingObservation ,
noteThinkingDelta ,
noteReasoningStart ,
noteReasoningEnd ,
clearThinkingObservationFor ,
2026-05-02 13:26:57 +08:00
setAutoPlaySpeech ,
playMessageSpeech ,
2026-04-11 15:59:14 +08:00
}
} )