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> { export async function renameSession(id: string, title: string): Promise<boolean> {
try { try {
await request(`/api/hermes/sessions/${id}/rename`, { await request(`/api/hermes/sessions/${id}/rename`, {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { useChatStore, type Session } from "@/stores/hermes/chat";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs"; import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import { import {
@@ -8,6 +8,7 @@ import {
NInput, NInput,
NModal, NModal,
NTooltip, NTooltip,
NPopconfirm,
useMessage, useMessage,
} from "naive-ui"; } from "naive-ui";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; 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"); 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. // Initialize synchronously from the media query so first paint is correct.
// On narrow viewports the session list is an absolute-positioned overlay // 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 // (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")); 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 contextSessionId = ref<string | null>(null);
const contextSessionPinned = computed(() => const contextSessionPinned = computed(() =>
contextSessionId.value contextSessionId.value
@@ -358,6 +429,92 @@ async function handleWorkspaceConfirm() {
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </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> <NButton quaternary size="tiny" @click="handleNewChat" circle>
<template #icon> <template #icon>
<svg <svg
@@ -408,9 +565,12 @@ async function handleWorkspaceConfirm() {
chatStore.sessions.length > 1 chatStore.sessions.length > 1
" "
:streaming="chatStore.isSessionLive(s.id)" :streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
@select="handleSessionClick(s.id)" @select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)" @contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)" @delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/> />
</template> </template>
@@ -443,9 +603,12 @@ async function handleWorkspaceConfirm() {
chatStore.sessions.length > 1 chatStore.sessions.length > 1
" "
:streaming="chatStore.isSessionLive(s.id)" :streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
@select="handleSessionClick(s.id)" @select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)" @contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)" @delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/> />
</template> </template>
</template> </template>
@@ -685,12 +848,22 @@ async function handleWorkspaceConfirm() {
justify-content: space-between; justify-content: space-between;
padding: 12px; padding: 12px;
flex-shrink: 0; flex-shrink: 0;
min-height: 0;
} }
.session-list-actions { .session-list-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
height: 22px;
.n-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
min-height: 22px;
}
} }
.session-close-btn { .session-close-btn {
@@ -701,6 +874,10 @@ async function handleWorkspaceConfirm() {
color: $text-secondary; color: $text-secondary;
padding: 4px; padding: 4px;
border-radius: $radius-sm; border-radius: $radius-sm;
height: 22px;
min-height: 22px;
align-items: center;
justify-content: center;
&:hover { &:hover {
background: rgba($accent-primary, 0.06); background: rgba($accent-primary, 0.06);
@@ -713,6 +890,7 @@ async function handleWorkspaceConfirm() {
color: $text-muted; color: $text-muted;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
line-height: 22px;
} }
.session-scope-note { .session-scope-note {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NPopconfirm } from 'naive-ui' import { NPopconfirm, NCheckbox } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Session } from '@/stores/hermes/chat' import type { Session } from '@/stores/hermes/chat'
import { formatTimestampMs } from '@/shared/session-display' import { formatTimestampMs } from '@/shared/session-display'
@@ -10,12 +10,15 @@ const props = defineProps<{
pinned: boolean pinned: boolean
canDelete: boolean canDelete: boolean
streaming?: boolean streaming?: boolean
selectable?: boolean
selected?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
select: [] select: []
contextmenu: [event: MouseEvent] contextmenu: [event: MouseEvent]
delete: [] delete: []
'toggle-select': []
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -24,11 +27,14 @@ const { t } = useI18n()
<template> <template>
<button <button
class="session-item" class="session-item"
:class="{ active }" :class="{ active, 'batch-mode': selectable }"
:aria-current="active ? 'page' : undefined" :aria-current="active ? 'page' : undefined"
@click="emit('select')" @click="emit('select')"
@contextmenu="emit('contextmenu', $event)" @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"> <div class="session-item-content">
<span class="session-item-title-row"> <span class="session-item-title-row">
<span v-if="pinned" class="session-item-pin" aria-hidden="true"> <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 class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
</span> </span>
</div> </div>
<NPopconfirm v-if="canDelete" @positive-click="emit('delete')"> <NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
<template #trigger> <template #trigger>
<button class="session-item-delete" @click.stop> <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> <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', send: 'Senden',
contextUsed: 'Kontext verwendet:', contextUsed: 'Kontext verwendet:',
sessions: 'Sitzungen', 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.', 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', openHistory: 'Verlauf öffnen',
hermesHistory: 'Hermes-Verlauf', hermesHistory: 'Hermes-Verlauf',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Keine Sitzungen', noSessions: 'Keine Sitzungen',
newChat: 'Neuer Chat', newChat: 'Neuer Chat',
deleteSession: 'Diese Sitzung loschen?', 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', sessionDeleted: 'Sitzung geloscht',
rename: 'Umbenennen', rename: 'Umbenennen',
pin: 'Anheften', pin: 'Anheften',
+7 -1
View File
@@ -134,7 +134,7 @@ export default {
send: 'Send', send: 'Send',
contextUsed: 'Context used:', contextUsed: 'Context used:',
sessions: 'Sessions', 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.', 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', openHistory: 'Open History',
hermesHistory: 'Hermes History', hermesHistory: 'Hermes History',
@@ -153,6 +153,12 @@ export default {
newChat: 'New Chat', newChat: 'New Chat',
deleteSession: 'Delete this session?', deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted', 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', rename: 'Rename',
pin: 'Pin', pin: 'Pin',
unpin: 'Unpin', unpin: 'Unpin',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Enviar', send: 'Enviar',
contextUsed: 'Contexto utilizado:', contextUsed: 'Contexto utilizado:',
sessions: 'Sesiones', 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.', 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', openHistory: 'Abrir historial',
hermesHistory: 'Historial de Hermes', hermesHistory: 'Historial de Hermes',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Sin sesiones', noSessions: 'Sin sesiones',
newChat: 'Nuevo chat', newChat: 'Nuevo chat',
deleteSession: 'Eliminar esta sesion?', 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', sessionDeleted: 'Sesion eliminada',
rename: 'Renombrar', rename: 'Renombrar',
pin: 'Fijar', pin: 'Fijar',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Envoyer', send: 'Envoyer',
contextUsed: 'Contexte utilise :', contextUsed: 'Contexte utilise :',
sessions: 'Sessions', 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.', 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', openHistory: 'Ouvrir lhistorique',
hermesHistory: 'Historique Hermes', hermesHistory: 'Historique Hermes',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Aucune session', noSessions: 'Aucune session',
newChat: 'Nouvelle discussion', newChat: 'Nouvelle discussion',
deleteSession: 'Supprimer cette session ?', 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', sessionDeleted: 'Session supprimee',
rename: 'Renommer', rename: 'Renommer',
pin: 'Épingler', pin: 'Épingler',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: '送信', send: '送信',
contextUsed: 'コンテキスト使用量:', contextUsed: 'コンテキスト使用量:',
sessions: 'セッション', sessions: 'セッション',
webUiSessions: 'Web UI/API Server セッション', webUiSessions: 'セッション',
sessionScopeHint: 'チャットには Web UI/API Server セッションのみ表示されます。CLI、Telegram、Discord、Cron などのチャンネルセッションは履歴で読み取り専用として表示されます。', sessionScopeHint: 'チャットには Web UI/API Server セッションのみ表示されます。CLI、Telegram、Discord、Cron などのチャンネルセッションは履歴で読み取り専用として表示されます。',
openHistory: '履歴を開く', openHistory: '履歴を開く',
hermesHistory: 'Hermes 履歴', hermesHistory: 'Hermes 履歴',
@@ -129,6 +129,12 @@ export default {
noSessions: 'セッションがありません', noSessions: 'セッションがありません',
newChat: '新しいチャット', newChat: '新しいチャット',
deleteSession: 'このセッションを削除しますか?', deleteSession: 'このセッションを削除しますか?',
toggleBatchMode: '一括選択',
selectAll: 'すべて選択',
confirmBatchDelete: '{count}件のセッションを削除しますか?',
batchDeleteSuccess: '{count}件のセッションを削除しました',
batchDeletePartial: '{failed}件の削除に失敗しました',
batchDeleteFailed: '一括削除に失敗しました',
sessionDeleted: 'セッションを削除しました', sessionDeleted: 'セッションを削除しました',
rename: '名前変更', rename: '名前変更',
pin: 'ピン留め', pin: 'ピン留め',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: '전송', send: '전송',
contextUsed: '사용된 컨텍스트:', contextUsed: '사용된 컨텍스트:',
sessions: '세션', sessions: '세션',
webUiSessions: 'Web UI/API Server 세션', webUiSessions: '세션',
sessionScopeHint: '채팅에는 Web UI/API Server 세션만 표시됩니다. CLI, Telegram, Discord, Cron 등 채널 세션은 기록에서 읽기 전용으로 볼 수 있습니다.', sessionScopeHint: '채팅에는 Web UI/API Server 세션만 표시됩니다. CLI, Telegram, Discord, Cron 등 채널 세션은 기록에서 읽기 전용으로 볼 수 있습니다.',
openHistory: '기록 열기', openHistory: '기록 열기',
hermesHistory: 'Hermes 기록', hermesHistory: 'Hermes 기록',
@@ -129,6 +129,12 @@ export default {
noSessions: '세션 없음', noSessions: '세션 없음',
newChat: '새 채팅', newChat: '새 채팅',
deleteSession: '이 세션을 삭제하시겠습니까?', deleteSession: '이 세션을 삭제하시겠습니까?',
toggleBatchMode: '일괄 선택',
selectAll: '모두 선택',
confirmBatchDelete: '선택한 {count}개의 세션을 삭제하시겠습니까?',
batchDeleteSuccess: '{count}개의 세션을 삭제했습니다',
batchDeletePartial: '{failed}개의 세션 삭제 실패',
batchDeleteFailed: '일괄 삭제 실패',
sessionDeleted: '세션이 삭제되었습니다', sessionDeleted: '세션이 삭제되었습니다',
rename: '이름 변경', rename: '이름 변경',
pin: '고정', pin: '고정',
+7 -1
View File
@@ -121,7 +121,7 @@ export default {
send: 'Enviar', send: 'Enviar',
contextUsed: 'Contexto utilizado:', contextUsed: 'Contexto utilizado:',
sessions: 'Sessoes', 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.', 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', openHistory: 'Abrir histórico',
hermesHistory: 'Histórico Hermes', hermesHistory: 'Histórico Hermes',
@@ -129,6 +129,12 @@ export default {
noSessions: 'Sem sessoes', noSessions: 'Sem sessoes',
newChat: 'Novo chat', newChat: 'Novo chat',
deleteSession: 'Excluir esta sessao?', 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', sessionDeleted: 'Sessao excluida',
rename: 'Renomear', rename: 'Renomear',
pin: 'Fixar', pin: 'Fixar',
+8 -2
View File
@@ -134,8 +134,8 @@ export default {
send: '发送', send: '发送',
contextUsed: '上下文已用:', contextUsed: '上下文已用:',
sessions: '会话', sessions: '会话',
webUiSessions: 'Web UI/API Server 会话', webUiSessions: '会话',
sessionScopeHint: '这里只显示 Web UI/API Server 会话;CLI、Telegram、Discord、Cron 等通道会话在历史中只读查看。', sessionScopeHint: '这里只显示当前会话;CLI、Telegram、Discord、Cron 等通道会话在历史中只读查看。',
openHistory: '打开历史', openHistory: '打开历史',
hermesHistory: 'Hermes 历史', hermesHistory: 'Hermes 历史',
historyScopeHint: '这里按来源只读查看 Hermes 历史会话。', historyScopeHint: '这里按来源只读查看 Hermes 历史会话。',
@@ -153,6 +153,12 @@ export default {
newChat: '新建对话', newChat: '新建对话',
deleteSession: '确定删除此会话?', deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除', sessionDeleted: '会话已删除',
toggleBatchMode: '批量选择',
selectAll: '全选',
confirmBatchDelete: '确定删除选中的 {count} 个会话?',
batchDeleteSuccess: '已删除 {count} 个会话',
batchDeletePartial: '{failed} 个会话删除失败',
batchDeleteFailed: '批量删除失败',
rename: '重命名', rename: '重命名',
pin: '置顶', pin: '置顶',
unpin: '取消置顶', unpin: '取消置顶',
@@ -284,6 +284,54 @@ export async function remove(ctx: any) {
ctx.body = { ok: true } 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) { export async function usageBatch(ctx: any) {
const ids = (ctx.query.ids as string) const ids = (ctx.query.ids as string)
if (!ids) { if (!ids) {
+31 -34
View File
@@ -1,53 +1,39 @@
import { execFileSync, spawn } from 'child_process' import { execFileSync, spawn } from 'child_process'
import { dirname, join } from 'path' import { join } from 'path'
function getNodeBinDir() {
return dirname(process.execPath)
}
function getNpmBin() { function getNpmBin() {
return join(getNodeBinDir(), process.platform === 'win32' ? 'npm.cmd' : 'npm') return process.platform === 'win32' ? 'npm.cmd' : 'npm'
} }
function getCliBin() { function getGlobalPrefix() {
return join(getNodeBinDir(), process.platform === 'win32' ? 'hermes-web-ui.cmd' : 'hermes-web-ui') return execFileSync(getNpmBin(), ['prefix', '-g'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
} }
function getWindowsShell() { function getGlobalCliBin() {
return process.env.ComSpec || 'cmd.exe' const prefix = getGlobalPrefix()
}
function quoteForWindowsCommand(value: string) { if (process.platform === 'win32') {
return `"${value.replace(/"/g, '""')}"` return join(prefix, 'hermes-web-ui.cmd')
}
return join(prefix, 'bin', 'hermes-web-ui')
} }
function runUpdateInstall() { 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'], { return execFileSync(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 120000, timeout: 10 * 60 * 1000,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}) })
} }
function spawnRestart(port: string) { function spawnRestart(port: string) {
if (process.platform === 'win32') { const cli = getGlobalCliBin()
return spawn(getWindowsShell(), ['/d', '/s', '/c', `${quoteForWindowsCommand(getCliBin())} restart --port ${port}`], {
detached: true,
stdio: 'ignore',
windowsHide: true,
})
}
return spawn(getCliBin(), ['restart', '--port', port], { return spawn(cli, ['restart', '--port', port], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: true, windowsHide: true,
@@ -57,13 +43,24 @@ function spawnRestart(port: string) {
export async function handleUpdate(ctx: any) { export async function handleUpdate(ctx: any) {
try { try {
const output = runUpdateInstall() const output = runUpdateInstall()
ctx.body = { success: true, message: output.trim() }
ctx.body = {
success: true,
message: output.trim() || 'hermes-web-ui updated successfully',
}
setTimeout(() => { setTimeout(() => {
try {
spawnRestart(process.env.PORT || '8648').unref() spawnRestart(process.env.PORT || '8648').unref()
} finally {
process.exit(0) process.exit(0)
}, 2000) }
}, 3000)
} catch (err: any) { } catch (err: any) {
ctx.status = 500 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', ctrl.get)
sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle) sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle)
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove) 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/rename', ctrl.rename)
sessionRoutes.post('/api/hermes/sessions/:id/workspace', ctrl.setWorkspace) sessionRoutes.post('/api/hermes/sessions/:id/workspace', ctrl.setWorkspace)
sessionRoutes.get('/api/hermes/workspace/folders', ctrl.listWorkspaceFolders) sessionRoutes.get('/api/hermes/workspace/folders', ctrl.listWorkspaceFolders)