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>
This commit is contained in:
@@ -60,6 +60,7 @@ export async function listConversations(ctx: any) {
|
||||
actual_cost_usd: s.actual_cost_usd,
|
||||
cost_status: s.cost_status,
|
||||
preview: s.preview,
|
||||
workspace: s.workspace || null,
|
||||
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
|
||||
thread_session_count: 1,
|
||||
}))
|
||||
@@ -283,6 +284,29 @@ export async function rename(ctx: any) {
|
||||
ctx.body = { ok: true }
|
||||
}
|
||||
|
||||
export async function setWorkspace(ctx: any) {
|
||||
const { workspace } = ctx.request.body as { workspace?: string }
|
||||
if (workspace !== undefined && workspace !== null && typeof workspace !== 'string') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'workspace must be a string or null' }
|
||||
return
|
||||
}
|
||||
if (useLocalSessionStore()) {
|
||||
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const id = ctx.params.id
|
||||
// Create session if it doesn't exist yet (user may set workspace before sending first message)
|
||||
if (!getSession(id)) {
|
||||
createSession({ id, profile: getActiveProfileName(), title: '' })
|
||||
}
|
||||
updateSession(id, { workspace: workspace || null } as any)
|
||||
ctx.body = { ok: true }
|
||||
return
|
||||
}
|
||||
ctx.status = 501
|
||||
ctx.body = { error: 'Workspace setting only supported in local session store mode' }
|
||||
}
|
||||
|
||||
export async function contextLength(ctx: any) {
|
||||
const profile = (ctx.query.profile as string) || undefined
|
||||
ctx.body = { context_length: getModelContextLength(profile) }
|
||||
@@ -371,6 +395,51 @@ export async function usageStats(ctx: any) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List folders under workspace base path for folder picker.
|
||||
* GET /api/hermes/workspace/folders?path=<relative_path>
|
||||
* Base: /opt/data/workspace (overridable via WORKSPACE_BASE env)
|
||||
*/
|
||||
export async function listWorkspaceFolders(ctx: any) {
|
||||
const { resolve, join } = await import('path')
|
||||
const { readdir } = await import('fs/promises')
|
||||
const { existsSync } = await import('fs')
|
||||
|
||||
const WORKSPACE_BASE = process.env.WORKSPACE_BASE || '/opt/data/workspace'
|
||||
const subPath = (ctx.query.path as string) || ''
|
||||
|
||||
// Security: prevent path traversal
|
||||
const fullPath = resolve(join(WORKSPACE_BASE, subPath))
|
||||
if (!fullPath.startsWith(resolve(WORKSPACE_BASE))) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Access denied' }
|
||||
return
|
||||
}
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Path not found', folders: [] }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(fullPath, { withFileTypes: true })
|
||||
const folders = entries
|
||||
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map(e => ({
|
||||
name: e.name,
|
||||
path: subPath ? `${subPath}/${e.name}` : e.name,
|
||||
fullPath: join(fullPath, e.name),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
ctx.body = { base: WORKSPACE_BASE, current: subPath, folders }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversationMessagesPaginated(ctx: any) {
|
||||
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
|
||||
|
||||
@@ -51,6 +51,7 @@ export const SESSIONS_SCHEMA: Record<string, string> = {
|
||||
cost_status: 'TEXT NOT NULL DEFAULT \'\'',
|
||||
preview: 'TEXT NOT NULL DEFAULT \'\'',
|
||||
last_active: 'INTEGER NOT NULL',
|
||||
workspace: 'TEXT',
|
||||
}
|
||||
|
||||
export const MESSAGES_TABLE = 'messages'
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface HermesSessionRow {
|
||||
cost_status: string
|
||||
preview: string
|
||||
last_active: number
|
||||
workspace: string | null
|
||||
}
|
||||
|
||||
export interface HermesMessageRow {
|
||||
@@ -102,6 +103,7 @@ function mapSessionRow(row: Record<string, unknown>): HermesSessionRow {
|
||||
cost_status: String(row.cost_status || ''),
|
||||
preview: String(row.preview || ''),
|
||||
last_active: Number(row.last_active || 0),
|
||||
workspace: row.workspace != null ? String(row.workspace) : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +133,7 @@ export function createSession(data: {
|
||||
profile?: string
|
||||
model?: string
|
||||
title?: string
|
||||
workspace?: string
|
||||
}): HermesSessionRow {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (!isSqliteAvailable()) {
|
||||
@@ -141,14 +144,14 @@ export function createSession(data: {
|
||||
message_count: 0, tool_call_count: 0,
|
||||
input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0,
|
||||
billing_provider: null, estimated_cost_usd: 0, actual_cost_usd: null,
|
||||
cost_status: '', preview: '', last_active: now,
|
||||
cost_status: '', preview: '', last_active: now, workspace: data.workspace || null,
|
||||
}
|
||||
}
|
||||
const db = getDb()!
|
||||
db.prepare(
|
||||
`INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, title, started_at, last_active)
|
||||
VALUES (?, ?, 'api_server', ?, ?, ?, ?)`,
|
||||
).run(data.id, data.profile || 'default', data.model || '', data.title || null, now, now)
|
||||
`INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, title, started_at, last_active, workspace)
|
||||
VALUES (?, ?, 'api_server', ?, ?, ?, ?, ?)`,
|
||||
).run(data.id, data.profile || 'default', data.model || '', data.title || null, now, now, data.workspace || null)
|
||||
return getSession(data.id)!
|
||||
}
|
||||
|
||||
|
||||
@@ -16,3 +16,5 @@ sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
|
||||
sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle)
|
||||
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
|
||||
sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename)
|
||||
sessionRoutes.post('/api/hermes/sessions/:id/workspace', ctrl.setWorkspace)
|
||||
sessionRoutes.get('/api/hermes/workspace/folders', ctrl.listWorkspaceFolders)
|
||||
|
||||
@@ -563,6 +563,17 @@ export class ChatRunSocket {
|
||||
if (model) body.model = model
|
||||
if (instructions) body.instructions = instructions
|
||||
|
||||
// Inject workspace context if set for this session
|
||||
if (session_id) {
|
||||
const sessionRow = getSession(session_id)
|
||||
if (sessionRow?.workspace) {
|
||||
const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]`
|
||||
body.instructions = body.instructions
|
||||
? `${workspaceCtx}\n${body.instructions}`
|
||||
: workspaceCtx
|
||||
}
|
||||
}
|
||||
|
||||
// Build conversation_history from DB if session_id is provided
|
||||
if (session_id) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { resolve, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
|
||||
const HERMES_BASE = resolve(homedir(), '.hermes')
|
||||
const HERMES_BASE = process.env.HERMES_HOME || resolve(homedir(), '.hermes')
|
||||
|
||||
/**
|
||||
* Get the active profile's home directory.
|
||||
|
||||
Reference in New Issue
Block a user