Files
Hermes-ui/packages/client/src/api/hermes/chat.ts
T
ekko cd14bb1963 feat: add Anthropic format conversion for chat runs and improvements (#347)
* fix: improve chat compression and tool display

Context Compression Fixes:
- Remove duplicate token calculation in compress()
- Simplify compress() to only execute compression, not judge
- Add buildConversationHistory() to preserve tool calls in LLM context
- Remove unused estimateMessagesTokens() and contextLength parameter
- Move all judgment logic to chat-run-socket.ts (uses accurate DB tokens)

Tool Call Display Improvements:
- Add tool execution duration display (format: 1.272s)
- Add success/error status icons with circular backgrounds
- Replace text error with SVG icon (X in red circle)
- Replace old checkmark with polished green checkmark icon
- Add i18n key 'chat.executionDuration' for all locales

Bug Fixes:
- Fix streaming-indicator stuck by adding try-finally in handleEvent
- Add debug logging for compression flow diagnosis
- Fix template syntax error in MessageList.vue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(chat): convert conversation history to Anthropic format before sending to Gateway

- Add convertToAnthropicFormat() to transform OpenAI format to Anthropic format
- Handle DeepSeek reasoning_content in thinking blocks
- Properly convert tool_use and tool_result blocks
- Add convertFromAnthropicFormat() for parsing SSE responses
- Handle stringified Python arrays in resume messages
- Record debug history files for troubleshooting (original vs converted)
- Fix tool_call_id validation to prevent empty ID errors
- Clean internal Hermes fields (call_id, response_item_id) from tool_calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(chat): optimize message parsing and add debug logging

- Only check for stringified arrays in assistant messages (performance)
- Improve parsing error handling: keep original content on parse failure
- Add debug logging for upstream events (reasoning/thinking tracking)
- Log run.completed event keys for troubleshooting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(chat): add message pagination and reasoning sync improvements

**Message Pagination:**
- Add getSessionDetailPaginated() for paginated message loading
- Query with DESC order then reverse in code for optimal performance
- Remove listSessionsPaginated() (not needed)

**Reasoning Sync:**
- Add bidirectional reasoning merge in syncFromHermes
  - Memory → DB: preserve streamed reasoning from SSE events
  - DB → Memory: restore reasoning if Hermes Gateway fixes storage
- Send resumed event after sync completes with complete messages
- Fix reasoning field inconsistency: use unified 'reasoning' field

**Message Parsing:**
- Only parse stringified arrays for assistant messages (performance)
- Improve parse error handling: keep original content on failure
- Add debug logging for upstream reasoning/thinking events

**Bug Fixes:**
- Fix reasoning content display: now works on both SSE and resume
- Ensure reasoning is preserved across page refreshes via sync + resumed event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: increase default pagination limit for messages to 500

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove auto-resumed event trigger and clean up debug code

- Remove automatic resumed event trigger in syncFromHermes to avoid timing issues
- Clean up unused imports (fs, join)
- Remove debug history file logging code
- Fix socket parameter passing in handleAbort, markCompleted, and syncFromHermes
- Change usage emit from room broadcast to socket-only emit
- Remove console.log debug statement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use reasoning field in convertToAnthropicFormat

Change convertToAnthropicFormat to read from reasoning field instead
of reasoning_content for consistency with database schema and frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: parse stringified array content and improve logs

- Parse stringified array format in run.completed to extract thinking/text/tool_use
- Send parsed content to frontend via parsed_content/parsed_reasoning/parsed_tool_calls
- Frontend updates last assistant message with parsed content
- Remove ellipsis from log messages, show full content
- Add detailed logging for conversion and parsing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: move finalOutputTrimmed outside else block

* fix(chat): handle double-serialized content in resumeSession

- Remove outer quotes before parsing stringified array format
- Updated changelog for v0.5.2 and v0.5.3 with multilingual support
- Fixed message pagination with DESC query + array reverse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(chat): improve error logging for resume parsing

- Add detailed logging for double-serialized content parsing
- Log content preview when parsing fails to diagnose issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert(chat): use simple Python-to-JSON replacement

- Revert to simple .replace(/'/g, '"') approach
- Parsing failures will keep original content as-is

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 16:40:37 +08:00

196 lines
5.8 KiB
TypeScript

import { io, type Socket } from 'socket.io-client'
import { request, getBaseUrlValue, getApiKey } from '../client'
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface StartRunRequest {
input: string | ChatMessage[]
instructions?: string
session_id?: string
model?: 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
}
// ============================
// Socket.IO chat run connection
// ============================
let chatRunSocket: Socket | null = null
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()
}
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,
})
return chatRunSocket
}
export function disconnectChatRun(): void {
if (chatRunSocket) {
chatRunSocket.disconnect()
chatRunSocket = null
}
}
/**
* 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
}
export function startRunViaSocket(
body: StartRunRequest,
onEvent: (event: RunEvent) => void,
onDone: () => void,
onError: (err: Error) => void,
onStarted?: (runId: string) => void,
): { abort: () => void } {
const socket = connectChatRun()
let closed = false
function cleanup() {
if (closed) return
closed = true
socket.off('run.started', onRunStarted)
socket.off('run.failed', onRunFailed)
socket.off('message.delta', onMessageDelta)
socket.off('reasoning.delta', onReasoningDelta)
socket.off('thinking.delta', onReasoningDelta)
socket.off('reasoning.available', onReasoningAvailable)
socket.off('tool.started', onToolStarted)
socket.off('tool.completed', onToolCompleted)
socket.off('run.completed', onRunCompleted)
socket.off('compression.started', onCompressionStarted)
socket.off('compression.completed', onCompressionCompleted)
socket.off('usage.updated', onUsageUpdated)
}
// All event handlers share the same cleanup logic
const handleEvent = (event: RunEvent) => {
if (closed) return
try {
onEvent(event)
} finally {
if (event.event === 'run.completed' || event.event === 'run.failed') {
console.log('[startRunViaSocket] Run completed/failed, calling cleanup and onDone', event.event)
cleanup()
onDone()
}
}
}
function onRunStarted(data: RunEvent) {
handleEvent(data)
onStarted?.(data.run_id || '')
}
function onRunFailed(data: RunEvent) {
handleEvent(data)
onError?.(new Error(data.error || 'Run failed'))
}
function onMessageDelta(data: RunEvent) { handleEvent(data) }
function onReasoningDelta(data: RunEvent) { handleEvent(data) }
function onThinkingDelta(data: RunEvent) { handleEvent(data) }
function onReasoningAvailable(data: RunEvent) { handleEvent(data) }
function onToolStarted(data: RunEvent) { handleEvent(data) }
function onToolCompleted(data: RunEvent) { handleEvent(data) }
function onRunCompleted(data: RunEvent) { handleEvent(data) }
function onCompressionStarted(data: RunEvent) { handleEvent(data) }
function onCompressionCompleted(data: RunEvent) { handleEvent(data) }
function onUsageUpdated(data: RunEvent) { handleEvent(data) }
socket.on('run.started', onRunStarted)
socket.on('run.failed', onRunFailed)
socket.on('message.delta', onMessageDelta)
socket.on('reasoning.delta', onReasoningDelta)
socket.on('thinking.delta', onThinkingDelta)
socket.on('reasoning.available', onReasoningAvailable)
socket.on('tool.started', onToolStarted)
socket.on('tool.completed', onToolCompleted)
socket.on('run.completed', onRunCompleted)
socket.on('compression.started', onCompressionStarted)
socket.on('compression.completed', onCompressionCompleted)
socket.on('usage.updated', onUsageUpdated)
// Emit run:start with ack callback to get run_id
socket.emit('run', body)
return {
abort: () => {
if (!closed) {
socket.emit('abort', { session_id: body.session_id })
cleanup()
}
},
}
}
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
return request('/api/hermes/v1/models')
}