Allow sending multiple messages while a run is active. Messages are queued on the server and processed sequentially after each run completes. Each completed assistant message triggers speech playback independently, and the UI shows queue status with a badge indicator. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
550 lines
14 KiB
TypeScript
550 lines
14 KiB
TypeScript
import { io, type Socket } from 'socket.io-client'
|
|
import { request, getBaseUrlValue, getApiKey } from '../client'
|
|
|
|
export type ContentBlock =
|
|
| { type: 'text'; text: string }
|
|
| { type: 'image'; name: string; path: string; media_type: string }
|
|
| { type: 'file'; name: string; path: string; media_type?: string }
|
|
|
|
export interface ChatMessage {
|
|
role: 'user' | 'assistant' | 'system'
|
|
content: string | ContentBlock[]
|
|
}
|
|
|
|
export interface StartRunRequest {
|
|
input: string | ContentBlock[]
|
|
instructions?: string
|
|
session_id?: string
|
|
model?: string
|
|
queue_id?: string
|
|
}
|
|
|
|
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
|
|
/** Payload text for `reasoning.delta` / `thinking.delta` / `reasoning.available` events. */
|
|
text?: string
|
|
tool?: string
|
|
name?: string
|
|
preview?: string
|
|
timestamp?: number
|
|
error?: string
|
|
/** 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
|
|
usage?: {
|
|
input_tokens: number
|
|
output_tokens: number
|
|
total_tokens: number
|
|
}
|
|
/** session_id tag added by server for client-side filtering */
|
|
session_id?: string
|
|
/** Queue length from run.queued event */
|
|
queue_length?: number
|
|
}
|
|
|
|
// ============================
|
|
// Socket.IO chat run connection
|
|
// ============================
|
|
|
|
let chatRunSocket: Socket | null = null
|
|
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
|
|
onAbortStarted: (event: RunEvent) => void
|
|
onAbortCompleted: (event: RunEvent) => void
|
|
onUsageUpdated: (event: RunEvent) => void
|
|
onRunQueued?: (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 (skip if more runs queued)
|
|
if ((event as any).queue_remaining > 0) return
|
|
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 (skip if more runs queued)
|
|
if ((event as any).queue_remaining > 0) return
|
|
sessionEventHandlers.delete(sid)
|
|
}
|
|
|
|
/**
|
|
* Global run.queued event handler
|
|
*/
|
|
function globalRunQueuedHandler(event: RunEvent): void {
|
|
const sid = event.session_id
|
|
if (!sid) return
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
if (handlers?.onRunQueued) {
|
|
handlers.onRunQueued(event)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 abort.started event handler
|
|
*/
|
|
function globalAbortStartedHandler(event: RunEvent): void {
|
|
const sid = event.session_id
|
|
if (!sid) return
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
if (handlers?.onAbortStarted) {
|
|
handlers.onAbortStarted(event)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Global abort.completed event handler
|
|
*/
|
|
function globalAbortCompletedHandler(event: RunEvent): void {
|
|
const sid = event.session_id
|
|
if (!sid) return
|
|
|
|
const handlers = sessionEventHandlers.get(sid)
|
|
if (handlers?.onAbortCompleted) {
|
|
handlers.onAbortCompleted(event)
|
|
}
|
|
|
|
// If abort completion is followed by queued runs, keep the handler alive so
|
|
// the next run.started/message.delta/run.completed events are still received.
|
|
if ((event as any).queue_length > 0) return
|
|
sessionEventHandlers.delete(sid)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
onAbortStarted: (event: RunEvent) => void
|
|
onAbortCompleted: (event: RunEvent) => void
|
|
onUsageUpdated: (event: RunEvent) => void
|
|
onRunQueued?: (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)
|
|
}
|
|
|
|
export function getChatRunSocket(): Socket | null {
|
|
return chatRunSocket
|
|
}
|
|
|
|
export function connectChatRun(): Socket {
|
|
if (chatRunSocket?.connected) return chatRunSocket
|
|
|
|
// Clean up old socket to prevent duplicate event listeners
|
|
if (chatRunSocket) {
|
|
chatRunSocket.removeAllListeners()
|
|
chatRunSocket.disconnect()
|
|
globalListenersRegistered = false
|
|
}
|
|
|
|
const baseUrl = getBaseUrlValue()
|
|
const token = getApiKey()
|
|
|
|
// Get active profile from store (authoritative source)
|
|
let profile = 'default'
|
|
try {
|
|
const { useProfilesStore } = require('@/stores/hermes/profiles')
|
|
const profilesStore = useProfilesStore()
|
|
profile = profilesStore.activeProfileName || 'default'
|
|
} catch {
|
|
// Fallback to localStorage during early initialization
|
|
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,
|
|
})
|
|
|
|
// 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)
|
|
chatRunSocket.on('run.queued', globalRunQueuedHandler)
|
|
|
|
// Compression events
|
|
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
|
|
chatRunSocket.on('compression.completed', globalCompressionCompletedHandler)
|
|
chatRunSocket.on('abort.started', globalAbortStartedHandler)
|
|
chatRunSocket.on('abort.completed', globalAbortCompletedHandler)
|
|
|
|
// Usage events
|
|
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
|
|
|
globalListenersRegistered = true
|
|
}
|
|
|
|
return chatRunSocket
|
|
}
|
|
|
|
export function disconnectChatRun(): void {
|
|
if (chatRunSocket) {
|
|
chatRunSocket.disconnect()
|
|
chatRunSocket = null
|
|
globalListenersRegistered = false
|
|
sessionEventHandlers.clear()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number; queueLength?: number }) => void,
|
|
): Socket {
|
|
const socket = connectChatRun()
|
|
|
|
socket.once('resumed', onResumed)
|
|
socket.emit('resume', { session_id: sessionId })
|
|
|
|
return socket
|
|
}
|
|
|
|
export function startRunViaSocket(
|
|
body: StartRunRequest,
|
|
onEvent: (event: RunEvent) => void,
|
|
onDone: () => void,
|
|
onError: (err: Error) => void,
|
|
onStarted?: (runId: string) => void,
|
|
): { abort: () => void } {
|
|
const sid = body.session_id
|
|
if (!sid) {
|
|
throw new Error('session_id is required for startRunViaSocket')
|
|
}
|
|
|
|
let closed = false
|
|
const socket = connectChatRun()
|
|
|
|
if (sessionEventHandlers.has(sid)) {
|
|
socket.emit('run', body)
|
|
return {
|
|
abort: () => {
|
|
if (!closed) {
|
|
socket.emit('abort', { session_id: sid })
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
if ((evt as any).queue_remaining > 0) return
|
|
closed = true
|
|
onDone()
|
|
},
|
|
onRunFailed: (evt: RunEvent) => {
|
|
if (closed) return
|
|
onEvent(evt)
|
|
if ((evt as any).queue_remaining > 0) return
|
|
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)
|
|
},
|
|
onAbortStarted: (evt: RunEvent) => {
|
|
if (closed) return
|
|
onEvent(evt)
|
|
},
|
|
onAbortCompleted: (evt: RunEvent) => {
|
|
if (closed) return
|
|
onEvent(evt)
|
|
if ((evt as any).queue_length > 0) return
|
|
closed = true
|
|
onDone()
|
|
},
|
|
onUsageUpdated: (evt: RunEvent) => {
|
|
if (closed) return
|
|
onEvent(evt)
|
|
},
|
|
onRunQueued: (evt: RunEvent) => {
|
|
if (closed) return
|
|
onEvent(evt)
|
|
},
|
|
}
|
|
|
|
// Register handlers in the global session map
|
|
sessionEventHandlers.set(sid, handlers)
|
|
|
|
// Emit run request
|
|
socket.emit('run', body)
|
|
|
|
return {
|
|
abort: () => {
|
|
if (!closed) {
|
|
socket.emit('abort', { session_id: sid })
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
|
|
return request('/api/hermes/v1/models')
|
|
}
|