2026-04-29 16:26:24 +08:00
|
|
|
import { io, type Socket } from 'socket.io-client'
|
2026-04-16 08:38:18 +08:00
|
|
|
import { request, getBaseUrlValue, getApiKey } from '../client'
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
|
|
|
export interface ChatMessage {
|
|
|
|
|
role: 'user' | 'assistant' | 'system'
|
|
|
|
|
content: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StartRunRequest {
|
|
|
|
|
input: string | ChatMessage[]
|
|
|
|
|
instructions?: string
|
|
|
|
|
session_id?: string
|
2026-04-12 23:23:50 +08:00
|
|
|
model?: string
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StartRunResponse {
|
|
|
|
|
run_id: string
|
|
|
|
|
status: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SSE event types from /v1/runs/{id}/events
|
|
|
|
|
export interface RunEvent {
|
|
|
|
|
event: string
|
|
|
|
|
run_id?: string
|
|
|
|
|
delta?: string
|
2026-04-25 08:46:50 +08:00
|
|
|
/** Payload text for `reasoning.delta` / `thinking.delta` / `reasoning.available` events. */
|
|
|
|
|
text?: string
|
2026-04-11 15:59:14 +08:00
|
|
|
tool?: string
|
|
|
|
|
name?: string
|
|
|
|
|
preview?: string
|
|
|
|
|
timestamp?: number
|
|
|
|
|
error?: string
|
2026-04-25 16:21:07 +08:00
|
|
|
/** Final response text on `run.completed`. May be empty/null if the agent
|
|
|
|
|
* silently swallowed an upstream error — see chat store for fallback. */
|
|
|
|
|
output?: string | null
|
2026-04-22 16:14:50 +08:00
|
|
|
usage?: {
|
|
|
|
|
input_tokens: number
|
|
|
|
|
output_tokens: number
|
|
|
|
|
total_tokens: number
|
|
|
|
|
}
|
2026-04-29 16:26:24 +08:00
|
|
|
/** session_id tag added by server for client-side filtering */
|
|
|
|
|
session_id?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Socket.IO chat run connection
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
let chatRunSocket: Socket | null = null
|
2026-05-01 08:13:55 +08:00
|
|
|
let globalListenersRegistered = false
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Session event handlers map
|
|
|
|
|
* Maps session_id to event handling functions for isolating concurrent session streams
|
|
|
|
|
*/
|
|
|
|
|
const sessionEventHandlers = new Map<string, {
|
|
|
|
|
onMessageDelta: (event: RunEvent) => void
|
|
|
|
|
onReasoningDelta: (event: RunEvent) => void
|
|
|
|
|
onThinkingDelta: (event: RunEvent) => void
|
|
|
|
|
onReasoningAvailable: (event: RunEvent) => void
|
|
|
|
|
onToolStarted: (event: RunEvent) => void
|
|
|
|
|
onToolCompleted: (event: RunEvent) => void
|
|
|
|
|
onRunStarted: (event: RunEvent) => void
|
|
|
|
|
onRunCompleted: (event: RunEvent) => void
|
|
|
|
|
onRunFailed: (event: RunEvent) => void
|
|
|
|
|
onCompressionStarted: (event: RunEvent) => void
|
|
|
|
|
onCompressionCompleted: (event: RunEvent) => void
|
|
|
|
|
onUsageUpdated: (event: RunEvent) => void
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global message.delta event handler
|
|
|
|
|
* Distributes events to appropriate session based on session_id
|
|
|
|
|
*/
|
|
|
|
|
function globalMessageDeltaHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onMessageDelta) {
|
|
|
|
|
handlers.onMessageDelta(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global reasoning.delta event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalReasoningDeltaHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onReasoningDelta) {
|
|
|
|
|
handlers.onReasoningDelta(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global thinking.delta event handler (alias for reasoning.delta)
|
|
|
|
|
*/
|
|
|
|
|
function globalThinkingDeltaHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onThinkingDelta) {
|
|
|
|
|
handlers.onThinkingDelta(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global reasoning.available event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalReasoningAvailableHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onReasoningAvailable) {
|
|
|
|
|
handlers.onReasoningAvailable(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global tool.started event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalToolStartedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onToolStarted) {
|
|
|
|
|
handlers.onToolStarted(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global tool.completed event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalToolCompletedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onToolCompleted) {
|
|
|
|
|
handlers.onToolCompleted(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global run.started event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalRunStartedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onRunStarted) {
|
|
|
|
|
handlers.onRunStarted(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global run.completed event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalRunCompletedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onRunCompleted) {
|
|
|
|
|
handlers.onRunCompleted(event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-cleanup session handlers on completion
|
|
|
|
|
sessionEventHandlers.delete(sid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global run.failed event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalRunFailedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onRunFailed) {
|
|
|
|
|
handlers.onRunFailed(event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-cleanup session handlers on failure
|
|
|
|
|
sessionEventHandlers.delete(sid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global compression.started event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalCompressionStartedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onCompressionStarted) {
|
|
|
|
|
handlers.onCompressionStarted(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global compression.completed event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalCompressionCompletedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onCompressionCompleted) {
|
|
|
|
|
handlers.onCompressionCompleted(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global usage.updated event handler
|
|
|
|
|
*/
|
|
|
|
|
function globalUsageUpdatedHandler(event: RunEvent): void {
|
|
|
|
|
const sid = event.session_id
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
|
|
|
if (handlers?.onUsageUpdated) {
|
|
|
|
|
handlers.onUsageUpdated(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register event handlers for a session
|
|
|
|
|
* @param sessionId - Session ID
|
|
|
|
|
* @param handlers - Event handling functions
|
|
|
|
|
* @returns Cleanup function to unregister handlers
|
|
|
|
|
*/
|
|
|
|
|
export function registerSessionHandlers(
|
|
|
|
|
sessionId: string,
|
|
|
|
|
handlers: {
|
|
|
|
|
onMessageDelta: (event: RunEvent) => void
|
|
|
|
|
onReasoningDelta: (event: RunEvent) => void
|
|
|
|
|
onThinkingDelta: (event: RunEvent) => void
|
|
|
|
|
onReasoningAvailable: (event: RunEvent) => void
|
|
|
|
|
onToolStarted: (event: RunEvent) => void
|
|
|
|
|
onToolCompleted: (event: RunEvent) => void
|
|
|
|
|
onRunStarted: (event: RunEvent) => void
|
|
|
|
|
onRunCompleted: (event: RunEvent) => void
|
|
|
|
|
onRunFailed: (event: RunEvent) => void
|
|
|
|
|
onCompressionStarted: (event: RunEvent) => void
|
|
|
|
|
onCompressionCompleted: (event: RunEvent) => void
|
|
|
|
|
onUsageUpdated: (event: RunEvent) => void
|
|
|
|
|
}
|
|
|
|
|
): () => void {
|
|
|
|
|
sessionEventHandlers.set(sessionId, handlers)
|
|
|
|
|
|
|
|
|
|
// Return cleanup function
|
|
|
|
|
return () => {
|
|
|
|
|
sessionEventHandlers.delete(sessionId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Unregister event handlers for a session
|
|
|
|
|
* @param sessionId - Session ID
|
|
|
|
|
*/
|
|
|
|
|
export function unregisterSessionHandlers(sessionId: string): void {
|
|
|
|
|
sessionEventHandlers.delete(sessionId)
|
|
|
|
|
}
|
2026-04-29 16:26:24 +08:00
|
|
|
|
|
|
|
|
export function getChatRunSocket(): Socket | null {
|
|
|
|
|
return chatRunSocket
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 16:26:24 +08:00
|
|
|
export function connectChatRun(): Socket {
|
|
|
|
|
if (chatRunSocket?.connected) return chatRunSocket
|
|
|
|
|
|
|
|
|
|
// Clean up old socket to prevent duplicate event listeners
|
|
|
|
|
if (chatRunSocket) {
|
|
|
|
|
chatRunSocket.removeAllListeners()
|
|
|
|
|
chatRunSocket.disconnect()
|
2026-05-01 08:13:55 +08:00
|
|
|
globalListenersRegistered = false
|
2026-04-26 17:47:39 +08:00
|
|
|
}
|
2026-04-29 16:26:24 +08:00
|
|
|
|
|
|
|
|
const baseUrl = getBaseUrlValue()
|
|
|
|
|
const token = getApiKey()
|
|
|
|
|
const profile = localStorage.getItem('hermes_active_profile_name') || 'default'
|
|
|
|
|
|
|
|
|
|
chatRunSocket = io(`${baseUrl}/chat-run`, {
|
|
|
|
|
auth: { token },
|
|
|
|
|
query: { profile },
|
|
|
|
|
transports: ['websocket', 'polling'],
|
|
|
|
|
reconnection: true,
|
|
|
|
|
reconnectionAttempts: Infinity,
|
|
|
|
|
reconnectionDelay: 1000,
|
|
|
|
|
reconnectionDelayMax: 10000,
|
2026-04-11 15:59:14 +08:00
|
|
|
})
|
2026-04-29 16:26:24 +08:00
|
|
|
|
2026-05-01 08:13:55 +08:00
|
|
|
// Register global listeners only once per socket connection
|
|
|
|
|
if (!globalListenersRegistered) {
|
|
|
|
|
// Message events
|
|
|
|
|
chatRunSocket.on('message.delta', globalMessageDeltaHandler)
|
|
|
|
|
chatRunSocket.on('reasoning.delta', globalReasoningDeltaHandler)
|
|
|
|
|
chatRunSocket.on('thinking.delta', globalThinkingDeltaHandler)
|
|
|
|
|
chatRunSocket.on('reasoning.available', globalReasoningAvailableHandler)
|
|
|
|
|
|
|
|
|
|
// Tool events
|
|
|
|
|
chatRunSocket.on('tool.started', globalToolStartedHandler)
|
|
|
|
|
chatRunSocket.on('tool.completed', globalToolCompletedHandler)
|
|
|
|
|
|
|
|
|
|
// Run lifecycle events
|
|
|
|
|
chatRunSocket.on('run.started', globalRunStartedHandler)
|
|
|
|
|
chatRunSocket.on('run.failed', globalRunFailedHandler)
|
|
|
|
|
chatRunSocket.on('run.completed', globalRunCompletedHandler)
|
|
|
|
|
|
|
|
|
|
// Compression events
|
|
|
|
|
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
|
|
|
|
|
chatRunSocket.on('compression.completed', globalCompressionCompletedHandler)
|
|
|
|
|
|
|
|
|
|
// Usage events
|
|
|
|
|
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
|
|
|
|
|
|
|
|
|
globalListenersRegistered = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 16:26:24 +08:00
|
|
|
return chatRunSocket
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function disconnectChatRun(): void {
|
|
|
|
|
if (chatRunSocket) {
|
|
|
|
|
chatRunSocket.disconnect()
|
|
|
|
|
chatRunSocket = null
|
2026-05-01 08:13:55 +08:00
|
|
|
globalListenersRegistered = false
|
|
|
|
|
sessionEventHandlers.clear()
|
2026-04-29 16:26:24 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start a chat run via Socket.IO and stream events back.
|
|
|
|
|
* Returns an AbortController-compatible handle for cancellation.
|
|
|
|
|
*/
|
|
|
|
|
/**
|
|
|
|
|
* Resume a session via Socket.IO. Returns messages, working status, and events.
|
|
|
|
|
*/
|
|
|
|
|
export function resumeSession(
|
|
|
|
|
sessionId: string,
|
|
|
|
|
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; events: any[]; inputTokens?: number; outputTokens?: number }) => void,
|
|
|
|
|
): Socket {
|
|
|
|
|
const socket = connectChatRun()
|
|
|
|
|
|
|
|
|
|
socket.once('resumed', onResumed)
|
|
|
|
|
socket.emit('resume', { session_id: sessionId })
|
|
|
|
|
|
|
|
|
|
return socket
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 16:26:24 +08:00
|
|
|
export function startRunViaSocket(
|
|
|
|
|
body: StartRunRequest,
|
2026-04-11 15:59:14 +08:00
|
|
|
onEvent: (event: RunEvent) => void,
|
|
|
|
|
onDone: () => void,
|
|
|
|
|
onError: (err: Error) => void,
|
2026-04-29 16:26:24 +08:00
|
|
|
onStarted?: (runId: string) => void,
|
|
|
|
|
): { abort: () => void } {
|
2026-05-01 08:13:55 +08:00
|
|
|
const sid = body.session_id
|
|
|
|
|
if (!sid) {
|
|
|
|
|
throw new Error('session_id is required for startRunViaSocket')
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
let closed = false
|
|
|
|
|
|
2026-05-01 08:13:55 +08:00
|
|
|
// Define event handlers for this session
|
|
|
|
|
const handlers = {
|
|
|
|
|
onMessageDelta: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onReasoningDelta: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onThinkingDelta: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onReasoningAvailable: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onToolStarted: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onToolCompleted: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onRunStarted: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
onStarted?.(evt.run_id || '')
|
|
|
|
|
},
|
|
|
|
|
onRunCompleted: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
closed = true
|
|
|
|
|
onDone()
|
|
|
|
|
},
|
|
|
|
|
onRunFailed: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
closed = true
|
|
|
|
|
onError(new Error(evt.error || 'Run failed'))
|
|
|
|
|
},
|
|
|
|
|
onCompressionStarted: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onCompressionCompleted: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
onUsageUpdated: (evt: RunEvent) => {
|
|
|
|
|
if (closed) return
|
|
|
|
|
onEvent(evt)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register handlers in the global session map
|
|
|
|
|
sessionEventHandlers.set(sid, handlers)
|
|
|
|
|
|
|
|
|
|
// Emit run request
|
|
|
|
|
const socket = connectChatRun()
|
2026-04-29 16:26:24 +08:00
|
|
|
socket.emit('run', body)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
abort: () => {
|
|
|
|
|
if (!closed) {
|
2026-05-01 08:13:55 +08:00
|
|
|
closed = true
|
|
|
|
|
sessionEventHandlers.delete(sid)
|
|
|
|
|
socket.emit('abort', { session_id: sid })
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
},
|
2026-04-29 16:26:24 +08:00
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
|
2026-04-16 08:38:18 +08:00
|
|
|
return request('/api/hermes/v1/models')
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|