Files
Hermes-ui/packages/client/src/api/hermes/sessions.ts
T
jsonetandekko 7e7fe90483 fix(chat): isolate concurrent session events and workspace dialog i18n (#351)
* feat: per-session workspace with folder picker, HERMES_HOME support, esbuild fix

* fix(chat): isolate concurrent session events and workspace dialog i18n

Two user-visible bugs are fixed here:

1. Workspace dialog title showed the raw i18n key 'chat.setWorkspaceTitle' because the key was never added to en.ts / zh.ts. The dialog is opened from ChatPanel.vue but only 'setWorkspace' existed. Add the missing 'setWorkspaceTitle' translation in both locales.

2. With two concurrent runs the assistant text from session A would show up in session B (and vice versa). The /chat-run namespace uses a single shared Socket.IO connection on the client; every startRunViaSocket() call registers its own listeners on the same socket. The server fans events out via 'session:<id>' rooms, but a single socket can be in multiple rooms at once and there was no per-event filtering on the client. Each run's closure captured its own sid and wrote into the wrong session. The server already tags every payload with session_id, so the fix is a guard inside handleEvent() that drops events whose session_id does not match this run's body.session_id. Untagged events are still accepted for backwards compatibility.

3. Also fix a related crash where setting a workspace on a session that had not been persisted yet (no first message sent) threw because the row did not exist. Create the row on demand inside setWorkspace controller.

* fix: upgrade esbuild to 0.27+ for vite 8 compatibility

---------

Co-authored-by: ekko <fqsy1416@gmail.com>
2026-04-30 20:17:38 +08:00

168 lines
4.7 KiB
TypeScript

import { request } from '../client'
export interface SessionSummary {
id: string
source: string
model: string
title: string | null
preview?: string
started_at: number
ended_at: number | null
last_active?: number
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
workspace?: string | null
}
export interface SessionDetail extends SessionSummary {
messages: HermesMessage[]
}
export interface SessionSearchResult extends SessionSummary {
matched_message_id: number | null
snippet: string
rank: number
}
export interface HermesMessage {
id: number
session_id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export async function fetchSessions(source?: string, limit?: number): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
}
export async function searchSessions(q: string, source?: string, limit?: number): Promise<SessionSearchResult[]> {
const params = new URLSearchParams()
params.set('q', q)
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
const query = params.toString()
const res = await request<{ results: SessionSearchResult[] }>(`/api/hermes/search/sessions?${query}`)
return res.results
}
export async function fetchSession(id: string): Promise<SessionDetail | null> {
try {
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}`)
return res.session
} catch {
return null
}
}
export async function deleteSession(id: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/rename`, {
method: 'POST',
body: JSON.stringify({ title }),
})
return true
} catch {
return false
}
}
export async function setSessionWorkspace(id: string, workspace: string | null): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/workspace`, {
method: 'POST',
body: JSON.stringify({ workspace: workspace || '' }),
})
return true
} catch {
return false
}
}
export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
total_cache_read_tokens: number
total_cache_write_tokens: number
total_reasoning_tokens: number
total_sessions: number
total_cost: number
total_api_calls?: number
period_days?: number
model_usage: Array<{
model: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
sessions: number
}>
daily_usage: Array<{
date: string
tokens: number
cache: number
sessions: number
cost: number
}>
}
export async function fetchUsageStats(days = 30): Promise<UsageStatsResponse> {
const safeDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30
const params = new URLSearchParams()
params.set('days', String(safeDays))
return request<UsageStatsResponse>(`/api/hermes/usage/stats?${params}`)
}
export async function fetchSessionUsage(ids: string[]): Promise<Record<string, { input_tokens: number; output_tokens: number }>> {
if (ids.length === 0) return {}
const params = new URLSearchParams()
params.set('ids', ids.join(','))
return request(`/api/hermes/sessions/usage?${params}`)
}
export async function fetchSessionUsageSingle(id: string): Promise<{ input_tokens: number; output_tokens: number } | null> {
try {
return await request<{ input_tokens: number; output_tokens: number }>(`/api/hermes/sessions/${id}/usage`)
} catch {
return null
}
}
export async function fetchContextLength(profile?: string): Promise<number> {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ context_length: number }>(`/api/hermes/sessions/context-length${query ? `?${query}` : ''}`)
return res.context_length
}