feat: Add batch delete functionality for chat sessions (#480)

* feat: add batch delete functionality for chat sessions

Backend:
- Add batchRemove controller to handle bulk session deletion
- Add POST /api/hermes/sessions/batch-delete endpoint
- Support both local session store and CLI deletion
- Return detailed results (deleted, failed, errors)

Frontend:
- Add batch selection mode with checkboxes in SessionListItem
- Add batch selection toggle and select all button
- Add batch delete button with confirmation
- Update ChatPanel to manage selected session IDs
- Add batchDeleteSessions API function

i18n:
- Add batch delete translations for all 8 languages
- Simplify "Web UI/API Server Sessions" to "Sessions"

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

* fix: vertically align buttons in session list header

Add inline-flex and center alignment to all buttons in session-list-actions
to ensure proper vertical centering with the title text.

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

* fix: ensure proper vertical alignment in session list header

- Set fixed height of 22px for session-list-actions
- Add min-height and height to all buttons
- Add line-height to session-list-title for text baseline alignment
- Add min-height: 0 to session-list-header to prevent flex stretch

This ensures the title and all action buttons are perfectly vertically centered.

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

* fix: call loadSessions after batch delete instead of looping deleteSession

The previous implementation was calling chatStore.deleteSession(id) in a loop
after batch delete API succeeded, which triggered individual delete API calls
for each session - causing n API requests instead of 1.

Now we simply call loadSessions() to refresh the session list from the server
after successful batch deletion, ensuring:
- Only 1 API request for batch delete
- UI stays in sync with server state
- No duplicate API calls

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

* refactor: improve update mechanism reliability

Major improvements to the update system:

**Path Resolution:**
- Remove unreliable dirname(process.execPath) assumption
- Use npm from PATH environment variable
- Dynamically get global prefix via `npm prefix -g`
- Calculate CLI path based on actual global install location

**Windows Support:**
- Remove complex cmd.exe wrapper logic
- Directly call npm.cmd (works on all Windows setups)
- Simplified quote handling

**Error Handling:**
- Add fallback error message (err.stderr || err.message || String(err))
- Add default success message when output is empty
- Wrap spawnRestart in try-finally to ensure cleanup

**Timing:**
- Increase timeout from 120s to 10min (slow network support)
- Increase restart delay from 2s to 3s (safer margin)

**Code Quality:**
- Remove unused functions (getNodeBinDir, getWindowsShell, quoteForWindowsCommand)
- Use constants instead of magic numbers (10 * 60 * 1000)
- More maintainable and cross-platform compatible

This fixes issues where updates would fail due to incorrect npm/CLI paths
on systems with non-standard Node.js installations.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-06 16:15:42 +08:00
committed by GitHub
parent d13423b9dd
commit 266f6e1a59
14 changed files with 342 additions and 49 deletions
@@ -108,6 +108,21 @@ export async function deleteSession(id: string): Promise<boolean> {
}
}
export async function batchDeleteSessions(ids: string[]): Promise<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }> {
try {
const res = await request<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }>(
'/api/hermes/sessions/batch-delete',
{
method: 'POST',
body: JSON.stringify({ ids }),
}
)
return res
} catch (err: any) {
throw err
}
}
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/rename`, {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { renameSession, setSessionWorkspace } from "@/api/hermes/sessions";
import { renameSession, setSessionWorkspace, batchDeleteSessions } from "@/api/hermes/sessions";
import { useChatStore, type Session } from "@/stores/hermes/chat";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import {
@@ -8,6 +8,7 @@ import {
NInput,
NModal,
NTooltip,
NPopconfirm,
useMessage,
} from "naive-ui";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
@@ -31,6 +32,10 @@ const drawerActiveTab = ref<"terminal" | "files">("files");
const currentMode = ref<"chat" | "live">("chat");
// Batch selection mode
const isBatchMode = ref(false);
const selectedSessionIds = ref<Set<string>>(new Set());
// Initialize synchronously from the media query so first paint is correct.
// On narrow viewports the session list is an absolute-positioned overlay
// (z-index 10) on top of the chat area; if we default to `true`, onMounted
@@ -218,6 +223,72 @@ function handleDeleteSession(id: string) {
message.success(t("chat.sessionDeleted"));
}
function toggleBatchMode() {
isBatchMode.value = !isBatchMode.value;
if (!isBatchMode.value) {
selectedSessionIds.value.clear();
}
}
function toggleSessionSelection(id: string) {
if (selectedSessionIds.value.has(id)) {
selectedSessionIds.value.delete(id);
} else {
selectedSessionIds.value.add(id);
}
selectedSessionIds.value = new Set(selectedSessionIds.value);
}
function isSessionSelected(id: string): boolean {
return selectedSessionIds.value.has(id);
}
async function handleBatchDelete() {
if (selectedSessionIds.value.size === 0) return;
const ids = Array.from(selectedSessionIds.value);
try {
const result = await batchDeleteSessions(ids);
if (result.deleted > 0) {
// Remove from pinned sessions
for (const id of ids) {
sessionBrowserPrefsStore.removePinned(id);
}
// Remove deleted sessions from local store (without calling API again)
// Use loadSessions to refresh from server instead of manual filtering
await chatStore.loadSessions();
message.success(t("chat.batchDeleteSuccess", { count: result.deleted }));
if (result.failed > 0) {
message.warning(t("chat.batchDeletePartial", { failed: result.failed }));
}
} else {
message.error(t("chat.batchDeleteFailed"));
}
} catch (err: any) {
message.error(t("chat.batchDeleteFailed"));
} finally {
isBatchMode.value = false;
selectedSessionIds.value.clear();
}
}
function selectAllSessions() {
selectedSessionIds.value.clear();
for (const session of chatStore.sessions) {
if (session.id !== chatStore.activeSessionId) {
selectedSessionIds.value.add(session.id);
}
}
selectedSessionIds.value = new Set(selectedSessionIds.value);
}
const selectedCount = computed(() => selectedSessionIds.value.size);
const canSelectAll = computed(() => {
return chatStore.sessions.some(s => s.id !== chatStore.activeSessionId);
});
const contextSessionId = ref<string | null>(null);
const contextSessionPinned = computed(() =>
contextSessionId.value
@@ -358,6 +429,92 @@ async function handleWorkspaceConfirm() {
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<NButton
v-if="!isBatchMode"
quaternary
size="tiny"
@click="toggleBatchMode"
:title="t('chat.toggleBatchMode')"
>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
</template>
</NButton>
<NButton
v-if="isBatchMode"
quaternary
size="tiny"
@click="selectAllSessions"
:disabled="!canSelectAll"
:title="t('chat.selectAll')"
>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
</template>
</NButton>
<NPopconfirm
v-if="isBatchMode && selectedCount > 0"
@positive-click="handleBatchDelete"
>
<template #trigger>
<NButton quaternary size="tiny" type="error">
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</template>
</NButton>
</template>
{{ t('chat.confirmBatchDelete', { count: selectedCount }) }}
</NPopconfirm>
<NButton
v-if="isBatchMode"
quaternary
size="tiny"
@click="toggleBatchMode"
>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</template>
</NButton>
<NButton quaternary size="tiny" @click="handleNewChat" circle>
<template #icon>
<svg
@@ -408,9 +565,12 @@ async function handleWorkspaceConfirm() {
chatStore.sessions.length > 1
"
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/>
</template>
@@ -443,9 +603,12 @@ async function handleWorkspaceConfirm() {
chatStore.sessions.length > 1
"
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/>
</template>
</template>
@@ -685,12 +848,22 @@ async function handleWorkspaceConfirm() {
justify-content: space-between;
padding: 12px;
flex-shrink: 0;
min-height: 0;
}
.session-list-actions {
display: flex;
align-items: center;
gap: 4px;
height: 22px;
.n-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
min-height: 22px;
}
}
.session-close-btn {
@@ -701,6 +874,10 @@ async function handleWorkspaceConfirm() {
color: $text-secondary;
padding: 4px;
border-radius: $radius-sm;
height: 22px;
min-height: 22px;
align-items: center;
justify-content: center;
&:hover {
background: rgba($accent-primary, 0.06);
@@ -713,6 +890,7 @@ async function handleWorkspaceConfirm() {
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 22px;
}
.session-scope-note {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NPopconfirm } from 'naive-ui'
import { NPopconfirm, NCheckbox } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { Session } from '@/stores/hermes/chat'
import { formatTimestampMs } from '@/shared/session-display'
@@ -10,12 +10,15 @@ const props = defineProps<{
pinned: boolean
canDelete: boolean
streaming?: boolean
selectable?: boolean
selected?: boolean
}>()
const emit = defineEmits<{
select: []
contextmenu: [event: MouseEvent]
delete: []
'toggle-select': []
}>()
const { t } = useI18n()
@@ -24,11 +27,14 @@ const { t } = useI18n()
<template>
<button
class="session-item"
:class="{ active }"
:class="{ active, 'batch-mode': selectable }"
:aria-current="active ? 'page' : undefined"
@click="emit('select')"
@contextmenu="emit('contextmenu', $event)"
>
<div v-if="selectable" class="session-item-checkbox">
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
</div>
<div class="session-item-content">
<span class="session-item-title-row">
<span v-if="pinned" class="session-item-pin" aria-hidden="true">
@@ -48,7 +54,7 @@ const { t } = useI18n()
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
</span>
</div>
<NPopconfirm v-if="canDelete" @positive-click="emit('delete')">
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
<template #trigger>
<button class="session-item-delete" @click.stop>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Senden',
contextUsed: 'Kontext verwendet:',
sessions: 'Sitzungen',
webUiSessions: 'Web-UI/API-Server-Sitzungen',
webUiSessions: 'Sitzungen',
sessionScopeHint: 'Chat zeigt nur Web-UI/API-Server-Sitzungen. CLI-, Telegram-, Discord-, Cron- und andere Kanal-Sitzungen sind schreibgeschützt im Verlauf.',
openHistory: 'Verlauf öffnen',
hermesHistory: 'Hermes-Verlauf',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Keine Sitzungen',
newChat: 'Neuer Chat',
deleteSession: 'Diese Sitzung loschen?',
toggleBatchMode: 'Batch-Auswahl',
selectAll: 'Alle auswählen',
confirmBatchDelete: '{count} ausgewählte Sitzungen löschen?',
batchDeleteSuccess: '{count} Sitzungen gelöscht',
batchDeletePartial: '{failed} Sitzungen konnten nicht gelöscht werden',
batchDeleteFailed: 'Batch-Löschung fehlgeschlagen',
sessionDeleted: 'Sitzung geloscht',
rename: 'Umbenennen',
pin: 'Anheften',
+7 -1
View File
@@ -134,7 +134,7 @@ export default {
send: 'Send',
contextUsed: 'Context used:',
sessions: 'Sessions',
webUiSessions: 'Web UI/API Server Sessions',
webUiSessions: 'Sessions',
sessionScopeHint: 'Chat shows Web UI/API Server sessions only. CLI, Telegram, Discord, Cron, and other channel sessions are read-only in History.',
openHistory: 'Open History',
hermesHistory: 'Hermes History',
@@ -153,6 +153,12 @@ export default {
newChat: 'New Chat',
deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted',
toggleBatchMode: 'Batch selection',
selectAll: 'Select all',
confirmBatchDelete: 'Delete {count} selected sessions?',
batchDeleteSuccess: 'Deleted {count} sessions',
batchDeletePartial: '{failed} sessions failed to delete',
batchDeleteFailed: 'Batch delete failed',
rename: 'Rename',
pin: 'Pin',
unpin: 'Unpin',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Enviar',
contextUsed: 'Contexto utilizado:',
sessions: 'Sesiones',
webUiSessions: 'Sesiones de Web UI/API Server',
webUiSessions: 'Sesiones',
sessionScopeHint: 'Chat solo muestra sesiones de Web UI/API Server. Las sesiones de CLI, Telegram, Discord, Cron y otros canales son de solo lectura en Historial.',
openHistory: 'Abrir historial',
hermesHistory: 'Historial de Hermes',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Sin sesiones',
newChat: 'Nuevo chat',
deleteSession: 'Eliminar esta sesion?',
toggleBatchMode: 'Selección por lotes',
selectAll: 'Seleccionar todo',
confirmBatchDelete: '¿Eliminar {count} sesiones seleccionadas?',
batchDeleteSuccess: '{count} sesiones eliminadas',
batchDeletePartial: '{failed} sesiones fallaron al eliminar',
batchDeleteFailed: 'Error al eliminar por lotes',
sessionDeleted: 'Sesion eliminada',
rename: 'Renombrar',
pin: 'Fijar',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Envoyer',
contextUsed: 'Contexte utilise :',
sessions: 'Sessions',
webUiSessions: 'Sessions Web UI/API Server',
webUiSessions: 'Sessions',
sessionScopeHint: 'Le chat affiche uniquement les sessions Web UI/API Server. Les sessions CLI, Telegram, Discord, Cron et autres canaux sont en lecture seule dans Historique.',
openHistory: 'Ouvrir lhistorique',
hermesHistory: 'Historique Hermes',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Aucune session',
newChat: 'Nouvelle discussion',
deleteSession: 'Supprimer cette session ?',
toggleBatchMode: 'Sélection par lot',
selectAll: 'Tout sélectionner',
confirmBatchDelete: 'Supprimer {count} sessions sélectionnées?',
batchDeleteSuccess: '{count} sessions supprimées',
batchDeletePartial: '{failed} sessions ont échoué',
batchDeleteFailed: 'Échec de la suppression par lot',
sessionDeleted: 'Session supprimee',
rename: 'Renommer',
pin: 'Épingler',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: '送信',
contextUsed: 'コンテキスト使用量:',
sessions: 'セッション',
webUiSessions: 'Web UI/API Server セッション',
webUiSessions: 'セッション',
sessionScopeHint: 'チャットには Web UI/API Server セッションのみ表示されます。CLI、Telegram、Discord、Cron などのチャンネルセッションは履歴で読み取り専用として表示されます。',
openHistory: '履歴を開く',
hermesHistory: 'Hermes 履歴',
@@ -129,6 +129,12 @@ export default {
noSessions: 'セッションがありません',
newChat: '新しいチャット',
deleteSession: 'このセッションを削除しますか?',
toggleBatchMode: '一括選択',
selectAll: 'すべて選択',
confirmBatchDelete: '{count}件のセッションを削除しますか?',
batchDeleteSuccess: '{count}件のセッションを削除しました',
batchDeletePartial: '{failed}件の削除に失敗しました',
batchDeleteFailed: '一括削除に失敗しました',
sessionDeleted: 'セッションを削除しました',
rename: '名前変更',
pin: 'ピン留め',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: '전송',
contextUsed: '사용된 컨텍스트:',
sessions: '세션',
webUiSessions: 'Web UI/API Server 세션',
webUiSessions: '세션',
sessionScopeHint: '채팅에는 Web UI/API Server 세션만 표시됩니다. CLI, Telegram, Discord, Cron 등 채널 세션은 기록에서 읽기 전용으로 볼 수 있습니다.',
openHistory: '기록 열기',
hermesHistory: 'Hermes 기록',
@@ -129,6 +129,12 @@ export default {
noSessions: '세션 없음',
newChat: '새 채팅',
deleteSession: '이 세션을 삭제하시겠습니까?',
toggleBatchMode: '일괄 선택',
selectAll: '모두 선택',
confirmBatchDelete: '선택한 {count}개의 세션을 삭제하시겠습니까?',
batchDeleteSuccess: '{count}개의 세션을 삭제했습니다',
batchDeletePartial: '{failed}개의 세션 삭제 실패',
batchDeleteFailed: '일괄 삭제 실패',
sessionDeleted: '세션이 삭제되었습니다',
rename: '이름 변경',
pin: '고정',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Enviar',
contextUsed: 'Contexto utilizado:',
sessions: 'Sessoes',
webUiSessions: 'Sessões da Web UI/API Server',
webUiSessions: 'Sessões',
sessionScopeHint: 'O chat mostra apenas sessões da Web UI/API Server. Sessões de CLI, Telegram, Discord, Cron e outros canais são somente leitura no Histórico.',
openHistory: 'Abrir histórico',
hermesHistory: 'Histórico Hermes',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Sem sessoes',
newChat: 'Novo chat',
deleteSession: 'Excluir esta sessao?',
toggleBatchMode: 'Seleção em lote',
selectAll: 'Selecionar tudo',
confirmBatchDelete: 'Excluir {count} sessões selecionadas?',
batchDeleteSuccess: '{count} sessões excluídas',
batchDeletePartial: '{failed} sessões falharam ao excluir',
batchDeleteFailed: 'Falha na exclusão em lote',
sessionDeleted: 'Sessao excluida',
rename: 'Renomear',
pin: 'Fixar',
+8 -2
View File
@@ -134,8 +134,8 @@ export default {
send: '发送',
contextUsed: '上下文已用:',
sessions: '会话',
webUiSessions: 'Web UI/API Server 会话',
sessionScopeHint: '这里只显示 Web UI/API Server 会话;CLI、Telegram、Discord、Cron 等通道会话在历史中只读查看。',
webUiSessions: '会话',
sessionScopeHint: '这里只显示当前会话;CLI、Telegram、Discord、Cron 等通道会话在历史中只读查看。',
openHistory: '打开历史',
hermesHistory: 'Hermes 历史',
historyScopeHint: '这里按来源只读查看 Hermes 历史会话。',
@@ -153,6 +153,12 @@ export default {
newChat: '新建对话',
deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除',
toggleBatchMode: '批量选择',
selectAll: '全选',
confirmBatchDelete: '确定删除选中的 {count} 个会话?',
batchDeleteSuccess: '已删除 {count} 个会话',
batchDeletePartial: '{failed} 个会话删除失败',
batchDeleteFailed: '批量删除失败',
rename: '重命名',
pin: '置顶',
unpin: '取消置顶',
@@ -284,6 +284,54 @@ export async function remove(ctx: any) {
ctx.body = { ok: true }
}
export async function batchRemove(ctx: any) {
const { ids } = ctx.request.body as { ids?: string[] }
if (!ids || !Array.isArray(ids) || ids.length === 0) {
ctx.status = 400
ctx.body = { error: 'ids is required and must be a non-empty array' }
return
}
const validIds = ids.filter(id => typeof id === 'string' && id.trim() !== '')
if (validIds.length === 0) {
ctx.status = 400
ctx.body = { error: 'No valid session ids provided' }
return
}
const results = {
deleted: 0,
failed: 0,
errors: [] as Array<{ id: string; error: string }>
}
if (useLocalSessionStore()) {
for (const id of validIds) {
const ok = localDeleteSession(id)
if (ok) {
deleteUsage(id)
results.deleted++
} else {
results.failed++
results.errors.push({ id, error: 'Failed to delete session' })
}
}
} else {
for (const id of validIds) {
const ok = await hermesCli.deleteSession(id)
if (ok) {
deleteUsage(id)
results.deleted++
} else {
results.failed++
results.errors.push({ id, error: 'Failed to delete session' })
}
}
}
ctx.body = { ...results, ok: true }
}
export async function usageBatch(ctx: any) {
const ids = (ctx.query.ids as string)
if (!ids) {
+33 -36
View File
@@ -1,53 +1,39 @@
import { execFileSync, spawn } from 'child_process'
import { dirname, join } from 'path'
function getNodeBinDir() {
return dirname(process.execPath)
}
import { join } from 'path'
function getNpmBin() {
return join(getNodeBinDir(), process.platform === 'win32' ? 'npm.cmd' : 'npm')
return process.platform === 'win32' ? 'npm.cmd' : 'npm'
}
function getCliBin() {
return join(getNodeBinDir(), process.platform === 'win32' ? 'hermes-web-ui.cmd' : 'hermes-web-ui')
function getGlobalPrefix() {
return execFileSync(getNpmBin(), ['prefix', '-g'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function getWindowsShell() {
return process.env.ComSpec || 'cmd.exe'
}
function getGlobalCliBin() {
const prefix = getGlobalPrefix()
function quoteForWindowsCommand(value: string) {
return `"${value.replace(/"/g, '""')}"`
if (process.platform === 'win32') {
return join(prefix, 'hermes-web-ui.cmd')
}
return join(prefix, 'bin', 'hermes-web-ui')
}
function runUpdateInstall() {
if (process.platform === 'win32') {
return execFileSync(getWindowsShell(), ['/d', '/s', '/c', `${quoteForWindowsCommand(getNpmBin())} install -g hermes-web-ui@latest`], {
encoding: 'utf-8',
timeout: 120000,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
}
return execFileSync(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], {
encoding: 'utf-8',
timeout: 120000,
timeout: 10 * 60 * 1000,
stdio: ['pipe', 'pipe', 'pipe'],
})
}
function spawnRestart(port: string) {
if (process.platform === 'win32') {
return spawn(getWindowsShell(), ['/d', '/s', '/c', `${quoteForWindowsCommand(getCliBin())} restart --port ${port}`], {
detached: true,
stdio: 'ignore',
windowsHide: true,
})
}
const cli = getGlobalCliBin()
return spawn(getCliBin(), ['restart', '--port', port], {
return spawn(cli, ['restart', '--port', port], {
detached: true,
stdio: 'ignore',
windowsHide: true,
@@ -57,13 +43,24 @@ function spawnRestart(port: string) {
export async function handleUpdate(ctx: any) {
try {
const output = runUpdateInstall()
ctx.body = { success: true, message: output.trim() }
ctx.body = {
success: true,
message: output.trim() || 'hermes-web-ui updated successfully',
}
setTimeout(() => {
spawnRestart(process.env.PORT || '8648').unref()
process.exit(0)
}, 2000)
try {
spawnRestart(process.env.PORT || '8648').unref()
} finally {
process.exit(0)
}
}, 3000)
} catch (err: any) {
ctx.status = 500
ctx.body = { success: false, message: err.stderr || err.message }
ctx.body = {
success: false,
message: err.stderr?.toString() || err.message || String(err),
}
}
}
@@ -17,6 +17,7 @@ sessionRoutes.get('/api/hermes/sessions/context-length', ctrl.contextLength)
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/batch-delete', ctrl.batchRemove)
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)