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:
@@ -68,6 +68,7 @@
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"@koa/bodyparser": "^5.0.0",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"@koa/router": "^15.4.0",
|
||||
|
||||
@@ -131,14 +131,26 @@ export function startRunViaSocket(
|
||||
socket.off('usage.updated', onUsageUpdated)
|
||||
}
|
||||
|
||||
// All event handlers share the same cleanup logic
|
||||
// All event handlers share the same cleanup logic.
|
||||
// IMPORTANT: The Socket.IO connection is shared across all in-flight runs
|
||||
// (single namespace, single socket). When two sessions run concurrently,
|
||||
// every `startRunViaSocket()` call registers its own `message.delta` /
|
||||
// `tool.*` / `run.*` listeners on the SAME socket, so each event would
|
||||
// fan out to every listener and corrupt the wrong session's transcript.
|
||||
// The server tags every payload with `session_id`; we filter here so each
|
||||
// run only sees its own events. We also accept untagged events (for
|
||||
// backwards compatibility) when no session_id was provided in the request.
|
||||
const expectedSid = body.session_id
|
||||
const handleEvent = (event: RunEvent) => {
|
||||
if (closed) return
|
||||
// Filter events by session_id to prevent cross-session contamination
|
||||
if (expectedSid && event.session_id && event.session_id !== expectedSid) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SessionSummary {
|
||||
estimated_cost_usd: number
|
||||
actual_cost_usd: number | null
|
||||
cost_status: string
|
||||
workspace?: string | null
|
||||
}
|
||||
|
||||
export interface SessionDetail extends SessionSummary {
|
||||
@@ -95,6 +96,18 @@ export async function renameSession(id: string, title: string): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { renameSession } from '@/api/hermes/sessions'
|
||||
import { renameSession, setSessionWorkspace } from '@/api/hermes/sessions'
|
||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-ui'
|
||||
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getSourceLabel } from '@/shared/session-display'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import FolderPicker from './FolderPicker.vue'
|
||||
import ChatInput from './ChatInput.vue'
|
||||
import ConversationMonitorPane from './ConversationMonitorPane.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
@@ -183,6 +184,7 @@ const contextSessionPinned = computed(() =>
|
||||
const contextMenuOptions = computed(() => [
|
||||
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
|
||||
{ label: t('chat.rename'), key: 'rename' },
|
||||
{ label: t('chat.setWorkspace'), key: 'workspace' },
|
||||
{ label: t('chat.copySessionId'), key: 'copy-id' },
|
||||
])
|
||||
|
||||
@@ -207,6 +209,11 @@ function handleContextMenuSelect(key: string) {
|
||||
}
|
||||
if (key === 'copy-id') {
|
||||
copySessionId(contextSessionId.value)
|
||||
} else if (key === 'workspace') {
|
||||
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
|
||||
workspaceSessionId.value = contextSessionId.value
|
||||
workspaceValue.value = session?.workspace || ''
|
||||
showWorkspaceModal.value = true
|
||||
} else if (key === 'rename') {
|
||||
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
|
||||
renameSessionId.value = contextSessionId.value
|
||||
@@ -237,6 +244,26 @@ async function handleRenameConfirm() {
|
||||
}
|
||||
showRenameModal.value = false
|
||||
}
|
||||
|
||||
const showWorkspaceModal = ref(false)
|
||||
const workspaceValue = ref('')
|
||||
const workspaceSessionId = ref<string | null>(null)
|
||||
|
||||
async function handleWorkspaceConfirm() {
|
||||
if (!workspaceSessionId.value) return
|
||||
const ok = await setSessionWorkspace(workspaceSessionId.value, workspaceValue.value || null)
|
||||
if (ok) {
|
||||
const session = chatStore.sessions.find(s => s.id === workspaceSessionId.value)
|
||||
if (session) session.workspace = workspaceValue.value || null
|
||||
if (chatStore.activeSession?.id === workspaceSessionId.value) {
|
||||
chatStore.activeSession.workspace = workspaceValue.value || null
|
||||
}
|
||||
message.success(t('chat.workspaceSet'))
|
||||
} else {
|
||||
message.error(t('chat.workspaceSetFailed'))
|
||||
}
|
||||
showWorkspaceModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -330,6 +357,18 @@ async function handleRenameConfirm() {
|
||||
/>
|
||||
</NModal>
|
||||
|
||||
<NModal
|
||||
v-model:show="showWorkspaceModal"
|
||||
preset="dialog"
|
||||
:title="t('chat.setWorkspaceTitle')"
|
||||
:positive-text="t('common.ok')"
|
||||
:negative-text="t('common.cancel')"
|
||||
style="width: 520px"
|
||||
@positive-click="handleWorkspaceConfirm"
|
||||
>
|
||||
<FolderPicker v-model="workspaceValue" />
|
||||
</NModal>
|
||||
|
||||
<div class="chat-main">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
@@ -340,6 +379,7 @@ async function handleRenameConfirm() {
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ headerTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
||||
<span v-if="chatStore.activeSession?.workspace" class="workspace-badge" :title="chatStore.activeSession.workspace">📁 {{ chatStore.activeSession.workspace.split('/').pop() || chatStore.activeSession.workspace }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- chat/live mode toggle hidden -->
|
||||
@@ -710,4 +750,17 @@ async function handleRenameConfirm() {
|
||||
padding: 16px 12px 16px 52px;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-badge {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import { request } from '@/api/client'
|
||||
|
||||
interface FolderEntry {
|
||||
name: string
|
||||
path: string
|
||||
fullPath: string
|
||||
}
|
||||
|
||||
interface FolderListResponse {
|
||||
base: string
|
||||
current: string
|
||||
folders: FolderEntry[]
|
||||
}
|
||||
|
||||
/** Flat display node for rendering tree without recursion */
|
||||
interface FlatNode {
|
||||
folder: FolderEntry
|
||||
depth: number
|
||||
isExpanded: boolean
|
||||
isLoading: boolean
|
||||
hasChildren: boolean | null // null = unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const basePath = ref('')
|
||||
const folders = ref<FolderEntry[]>([])
|
||||
const expandedPaths = ref<Set<string>>(new Set())
|
||||
const childrenCache = ref<Map<string, FolderEntry[]>>(new Map())
|
||||
const loadingPaths = ref<Set<string>>(new Set())
|
||||
const selectedPath = ref(props.modelValue || '')
|
||||
|
||||
watch(() => props.modelValue, (v) => { selectedPath.value = v || '' })
|
||||
|
||||
async function loadFolders(subPath = ''): Promise<FolderListResponse | null> {
|
||||
try {
|
||||
const query = subPath ? `?path=${encodeURIComponent(subPath)}` : ''
|
||||
return await request<FolderListResponse>(`/api/hermes/workspace/folders${query}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const res = await loadFolders()
|
||||
if (res) {
|
||||
basePath.value = res.base
|
||||
folders.value = res.folders
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function toggleExpand(folder: FolderEntry) {
|
||||
if (expandedPaths.value.has(folder.path)) {
|
||||
expandedPaths.value.delete(folder.path)
|
||||
expandedPaths.value = new Set(expandedPaths.value)
|
||||
return
|
||||
}
|
||||
|
||||
expandedPaths.value.add(folder.path)
|
||||
expandedPaths.value = new Set(expandedPaths.value)
|
||||
|
||||
if (!childrenCache.value.has(folder.path)) {
|
||||
loadingPaths.value.add(folder.path)
|
||||
loadingPaths.value = new Set(loadingPaths.value)
|
||||
const res = await loadFolders(folder.path)
|
||||
childrenCache.value.set(folder.path, res?.folders || [])
|
||||
childrenCache.value = new Map(childrenCache.value)
|
||||
loadingPaths.value.delete(folder.path)
|
||||
loadingPaths.value = new Set(loadingPaths.value)
|
||||
}
|
||||
}
|
||||
|
||||
function selectFolder(folder: FolderEntry) {
|
||||
const fullPath = `${basePath.value}/${folder.path}`
|
||||
selectedPath.value = fullPath
|
||||
emit('update:modelValue', fullPath)
|
||||
}
|
||||
|
||||
function selectBase() {
|
||||
selectedPath.value = basePath.value
|
||||
emit('update:modelValue', basePath.value)
|
||||
}
|
||||
|
||||
/** Build a flat list by DFS traversal of expanded nodes */
|
||||
const flatNodes = computed<FlatNode[]>(() => {
|
||||
const result: FlatNode[] = []
|
||||
|
||||
function traverse(entries: FolderEntry[], depth: number) {
|
||||
for (const folder of entries) {
|
||||
const isExpanded = expandedPaths.value.has(folder.path)
|
||||
const isLoading = loadingPaths.value.has(folder.path)
|
||||
const children = childrenCache.value.get(folder.path)
|
||||
result.push({
|
||||
folder,
|
||||
depth,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
hasChildren: children ? children.length > 0 : null,
|
||||
})
|
||||
if (isExpanded && children && children.length > 0) {
|
||||
traverse(children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(folders.value, 0)
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-picker">
|
||||
<div v-if="loading" class="folder-picker-loading">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else class="folder-tree">
|
||||
<!-- Base path as root -->
|
||||
<div
|
||||
class="folder-item root"
|
||||
:class="{ selected: selectedPath === basePath }"
|
||||
@click="selectBase"
|
||||
>
|
||||
<span class="folder-icon">📂</span>
|
||||
<span class="folder-name">{{ basePath || '/' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Flat rendered tree -->
|
||||
<div
|
||||
v-for="node in flatNodes"
|
||||
:key="node.folder.path"
|
||||
class="folder-item"
|
||||
:class="{ selected: selectedPath === `${basePath}/${node.folder.path}` }"
|
||||
:style="{ paddingLeft: `${12 + node.depth * 16}px` }"
|
||||
>
|
||||
<span class="folder-expand" @click.stop="toggleExpand(node.folder)">
|
||||
<template v-if="node.isLoading">⏳</template>
|
||||
<template v-else>{{ node.isExpanded ? '▼' : '▶' }}</template>
|
||||
</span>
|
||||
<span class="folder-icon" @click="selectFolder(node.folder)">📁</span>
|
||||
<span class="folder-name" @click="selectFolder(node.folder)">{{ node.folder.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty children indicator for expanded folders with no children -->
|
||||
<template v-for="node in flatNodes" :key="'empty-' + node.folder.path">
|
||||
<div
|
||||
v-if="node.isExpanded && !node.isLoading && node.hasChildren === false"
|
||||
class="folder-item empty"
|
||||
:style="{ paddingLeft: `${28 + node.depth * 16}px` }"
|
||||
>
|
||||
<span class="folder-empty-text">(空)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="folders.length === 0 && !loading" class="folder-empty">
|
||||
暂无工作区文件夹
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected path display -->
|
||||
<div v-if="selectedPath" class="folder-selected">
|
||||
<span class="folder-selected-label">已选择:</span>
|
||||
<span class="folder-selected-path">{{ selectedPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.folder-picker {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.folder-picker-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
outline: 1px solid rgba(64, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
&.root {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-expand {
|
||||
width: 14px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-empty-text {
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.folder-empty {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.folder-selected {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-selected-label {
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-selected-path {
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -147,6 +147,12 @@ export default {
|
||||
renameFailed: 'Rename failed',
|
||||
renameSession: 'Rename Session',
|
||||
enterNewTitle: 'Enter new title',
|
||||
workspace: 'Workspace',
|
||||
setWorkspace: 'Set Workspace',
|
||||
setWorkspaceTitle: 'Set Session Workspace',
|
||||
workspacePlaceholder: 'Enter project path, e.g. /home/user/project',
|
||||
workspaceSet: 'Workspace set',
|
||||
workspaceSetFailed: 'Failed to set workspace',
|
||||
other: 'Other',
|
||||
runFailed: 'Run failed',
|
||||
error: 'Error',
|
||||
|
||||
@@ -147,6 +147,12 @@ export default {
|
||||
renameFailed: '重命名失败',
|
||||
renameSession: '重命名会话',
|
||||
enterNewTitle: '输入新标题',
|
||||
workspace: '工作区',
|
||||
setWorkspace: '设置工作区',
|
||||
setWorkspaceTitle: '设置会话工作区',
|
||||
workspacePlaceholder: '输入项目路径,例如 /home/user/project',
|
||||
workspaceSet: '工作区已设置',
|
||||
workspaceSetFailed: '设置工作区失败',
|
||||
other: '其他',
|
||||
runFailed: '运行失败',
|
||||
error: '错误',
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Session {
|
||||
outputTokens?: number
|
||||
endedAt?: number | null
|
||||
lastActiveAt?: number
|
||||
workspace?: string | null
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
@@ -168,6 +169,7 @@ function mapHermesSession(s: SessionSummary): Session {
|
||||
messageCount: s.message_count,
|
||||
endedAt: s.ended_at != null ? Math.round(s.ended_at * 1000) : null,
|
||||
lastActiveAt: s.last_active != null ? Math.round(s.last_active * 1000) : undefined,
|
||||
workspace: s.workspace || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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