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:
jsonet
2026-04-30 20:17:38 +08:00
committed by GitHub
parent dac9006b3e
commit 7e7fe90483
14 changed files with 468 additions and 8 deletions
@@ -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