diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index 9972594..88374fc 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -23,6 +23,7 @@ export interface SessionSummary { actual_cost_usd: number | null cost_status: string workspace?: string | null + webui_imported?: boolean } export interface SessionDetail extends SessionSummary { @@ -122,6 +123,16 @@ export async function deleteSession(id: string, profile?: string | null): Promis } } +export async function importHermesSession(id: string, profile?: string | null): Promise<{ ok: boolean; imported: boolean; session?: SessionDetail }> { + const params = new URLSearchParams() + if (profile) params.set('profile', profile) + const query = params.toString() + return request<{ ok: boolean; imported: boolean; session?: SessionDetail }>( + `/api/hermes/sessions/hermes/${encodeURIComponent(id)}/import${query ? `?${query}` : ''}`, + { method: 'POST' }, + ) +} + export interface BatchDeleteSessionTarget { id: string profile?: string | null diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index aedae17..9fb25a1 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -269,6 +269,10 @@ export default { batchDeleteSuccess: '{count} Sitzungen gelöscht', batchDeletePartial: '{failed} Sitzungen konnten nicht gelöscht werden', batchDeleteFailed: 'Batch-Löschung fehlgeschlagen', + importToWebUi: 'In Web UI importieren', + importSessionSuccess: 'Sitzung in Web UI importiert', + importSessionAlreadyExists: 'Sitzung existiert bereits in Web UI', + importSessionFailed: 'Sitzung konnte nicht importiert werden', sessionDeleted: 'Sitzung geloscht', rename: 'Umbenennen', pin: 'Anheften', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 7beae3a..506e60b 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -286,6 +286,10 @@ export default { batchDeleteSuccess: 'Deleted {count} sessions', batchDeletePartial: '{failed} sessions failed to delete', batchDeleteFailed: 'Batch delete failed', + importToWebUi: 'Import to Web UI', + importSessionSuccess: 'Session imported to Web UI', + importSessionAlreadyExists: 'Session already exists in Web UI', + importSessionFailed: 'Failed to import session', rename: 'Rename', pin: 'Pin', unpin: 'Unpin', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index be87234..3751168 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -269,6 +269,10 @@ export default { batchDeleteSuccess: '{count} sesiones eliminadas', batchDeletePartial: '{failed} sesiones fallaron al eliminar', batchDeleteFailed: 'Error al eliminar por lotes', + importToWebUi: 'Importar a Web UI', + importSessionSuccess: 'Sesion importada a Web UI', + importSessionAlreadyExists: 'La sesion ya existe en Web UI', + importSessionFailed: 'Error al importar la sesion', sessionDeleted: 'Sesion eliminada', rename: 'Renombrar', pin: 'Fijar', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index e115ce5..2efe450 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -269,6 +269,10 @@ export default { batchDeleteSuccess: '{count} sessions supprimées', batchDeletePartial: '{failed} sessions ont échoué', batchDeleteFailed: 'Échec de la suppression par lot', + importToWebUi: 'Importer dans Web UI', + importSessionSuccess: 'Session importée dans Web UI', + importSessionAlreadyExists: 'La session existe déjà dans Web UI', + importSessionFailed: 'Échec de l’import de la session', sessionDeleted: 'Session supprimee', rename: 'Renommer', pin: 'Épingler', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index e237314..56523e4 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -269,6 +269,10 @@ export default { batchDeleteSuccess: '{count}件のセッションを削除しました', batchDeletePartial: '{failed}件の削除に失敗しました', batchDeleteFailed: '一括削除に失敗しました', + importToWebUi: 'Web UI にインポート', + importSessionSuccess: 'セッションを Web UI にインポートしました', + importSessionAlreadyExists: 'セッションは既に Web UI に存在します', + importSessionFailed: 'セッションのインポートに失敗しました', sessionDeleted: 'セッションを削除しました', rename: '名前変更', pin: 'ピン留め', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 3ba291d..87d3aab 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -269,6 +269,10 @@ export default { batchDeleteSuccess: '{count}개의 세션을 삭제했습니다', batchDeletePartial: '{failed}개의 세션 삭제 실패', batchDeleteFailed: '일괄 삭제 실패', + importToWebUi: 'Web UI로 가져오기', + importSessionSuccess: '세션을 Web UI로 가져왔습니다', + importSessionAlreadyExists: '세션이 이미 Web UI에 있습니다', + importSessionFailed: '세션 가져오기 실패', sessionDeleted: '세션이 삭제되었습니다', rename: '이름 변경', pin: '고정', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 7a840c4..0c33f4e 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -269,6 +269,10 @@ export default { batchDeleteSuccess: '{count} sessões excluídas', batchDeletePartial: '{failed} sessões falharam ao excluir', batchDeleteFailed: 'Falha na exclusão em lote', + importToWebUi: 'Importar para Web UI', + importSessionSuccess: 'Sessão importada para Web UI', + importSessionAlreadyExists: 'A sessão já existe no Web UI', + importSessionFailed: 'Falha ao importar sessão', sessionDeleted: 'Sessao excluida', rename: 'Renomear', pin: 'Fixar', diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 6ab9324..f0136c6 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -284,6 +284,10 @@ export default { batchDeleteSuccess: '已刪除 {count} 個工作階段', batchDeletePartial: '{failed} 個工作階段刪除失敗', batchDeleteFailed: '批次刪除失敗', + importToWebUi: '匯入到 Web UI', + importSessionSuccess: '工作階段已匯入 Web UI', + importSessionAlreadyExists: '工作階段已存在於 Web UI', + importSessionFailed: '匯入工作階段失敗', rename: '重新命名', pin: '釘選', unpin: '取消釘選', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 689c81b..13ad444 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -286,6 +286,10 @@ export default { batchDeleteSuccess: '已删除 {count} 个会话', batchDeletePartial: '{failed} 个会话删除失败', batchDeleteFailed: '批量删除失败', + importToWebUi: '导入到 Web UI', + importSessionSuccess: '会话已导入 Web UI', + importSessionAlreadyExists: '会话已存在于 Web UI', + importSessionFailed: '导入会话失败', rename: '重命名', pin: '置顶', unpin: '取消置顶', diff --git a/packages/client/src/views/hermes/HistoryView.vue b/packages/client/src/views/hermes/HistoryView.vue index e72957b..1995abc 100644 --- a/packages/client/src/views/hermes/HistoryView.vue +++ b/packages/client/src/views/hermes/HistoryView.vue @@ -5,14 +5,14 @@ import { type Session } from '@/stores/hermes/chat' import { useAppStore } from '@/stores/hermes/app' import { useProfilesStore } from '@/stores/hermes/profiles' import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs' -import { NButton, NTooltip, useMessage } from 'naive-ui' +import { NButton, NDropdown, NPopconfirm, NTooltip, useMessage, type DropdownOption } from 'naive-ui' import { useI18n } from 'vue-i18n' import { getSourceLabel } from '@/shared/session-display' import { copyToClipboard } from '@/utils/clipboard' import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue' import SessionListItem from '@/components/hermes/chat/SessionListItem.vue' import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue' -import { deleteSession, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions' +import { batchDeleteSessions, deleteSession, fetchHermesSessions, fetchHermesSession, importHermesSession, type SessionSummary } from '@/api/hermes/sessions' const appStore = useAppStore() const profilesStore = useProfilesStore() @@ -42,6 +42,14 @@ const hermesSessionsLoaded = ref(false) const historySessionId = ref(null) const historySession = ref(null) const showOutline = ref(false) +const isBatchMode = ref(false) +const isBatchDeleting = ref(false) +const showBatchDeleteConfirm = ref(false) +const selectedSessionKeys = ref>(new Set()) +const contextSessionId = ref(null) +const showContextMenu = ref(false) +const contextMenuX = ref(0) +const contextMenuY = ref(0) let hermesSessionsRequestId = 0 async function loadHermesSessions() { @@ -72,6 +80,28 @@ function findHistorySession(sessionId: string): SessionSummary | undefined { return hermesSessions.value.find(session => session.id === sessionId) } +const contextSessionSummary = computed(() => + contextSessionId.value ? findHistorySession(contextSessionId.value) || null : null, +) + +const contextSessionPinned = computed(() => + contextSessionId.value ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) : false, +) + +const contextMenuOptions = computed(() => { + const options: DropdownOption[] = [ + { + label: t('chat.importToWebUi'), + key: 'import-webui', + disabled: Boolean(contextSessionSummary.value?.webui_imported), + }, + { label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' }, + { label: t('chat.copySessionLink'), key: 'copy-link' }, + { label: t('chat.copySessionId'), key: 'copy-id' }, + ] + return options +}) + async function loadHistorySession(sessionId: string, profile?: string | null) { const summary = findHistorySession(sessionId) const sessionProfile = profile || summary?.profile || null @@ -252,6 +282,58 @@ const historySessions = computed(() => hermesSessions.value.map(sessionSummaryToSession) ) +function sessionSelectionKey(session: Pick): string { + return `${session.profile || 'default'}\u0000${session.id}` +} + +function toggleBatchMode() { + if (isBatchDeleting.value) return + isBatchMode.value = !isBatchMode.value + if (!isBatchMode.value) { + selectedSessionKeys.value.clear() + showBatchDeleteConfirm.value = false + } +} + +function toggleSessionSelection(session: Session) { + if (isBatchDeleting.value) return + const key = sessionSelectionKey(session) + if (selectedSessionKeys.value.has(key)) { + selectedSessionKeys.value.delete(key) + } else { + selectedSessionKeys.value.add(key) + } + selectedSessionKeys.value = new Set(selectedSessionKeys.value) + if (selectedSessionKeys.value.size === 0) { + showBatchDeleteConfirm.value = false + } +} + +function isSessionSelected(session: Session): boolean { + return selectedSessionKeys.value.has(sessionSelectionKey(session)) +} + +function toggleSelectAllSessions() { + if (isBatchDeleting.value) return + if (allSessionsSelected.value) { + selectedSessionKeys.value.clear() + selectedSessionKeys.value = new Set(selectedSessionKeys.value) + showBatchDeleteConfirm.value = false + return + } + selectedSessionKeys.value.clear() + for (const session of historySessions.value) { + selectedSessionKeys.value.add(sessionSelectionKey(session)) + } + selectedSessionKeys.value = new Set(selectedSessionKeys.value) +} + +const selectedCount = computed(() => selectedSessionKeys.value.size) +const canSelectAll = computed(() => historySessions.value.length > 0) +const allSessionsSelected = computed(() => + historySessions.value.length > 0 && selectedSessionKeys.value.size === historySessions.value.length +) + // Source sort order: api_server first, cron last, others alphabetical function sourceSortKey(source: string): number { if (source === 'api_server') return -1 @@ -380,6 +462,47 @@ async function copySessionLink(id?: string) { } } +function handleContextMenu(e: MouseEvent, sessionId: string) { + e.preventDefault() + contextSessionId.value = sessionId + showContextMenu.value = true + contextMenuX.value = e.clientX + contextMenuY.value = e.clientY +} + +function handleClickOutside() { + showContextMenu.value = false +} + +async function handleImportToWebUi(sessionId: string) { + const summary = findHistorySession(sessionId) + try { + const result = await importHermesSession(sessionId, summary?.profile || null) + if (result.ok) { + message.success(t(result.imported ? 'chat.importSessionSuccess' : 'chat.importSessionAlreadyExists')) + await loadHermesSessions() + return + } + } catch { + // Fall through to the shared failure message. + } + message.error(t('chat.importSessionFailed')) +} + +async function handleContextMenuSelect(key: string) { + showContextMenu.value = false + if (!contextSessionId.value) return + if (key === 'pin') { + sessionBrowserPrefsStore.togglePinned(contextSessionId.value) + } else if (key === 'copy-link') { + await copySessionLink(contextSessionId.value) + } else if (key === 'copy-id') { + await copySessionId(contextSessionId.value) + } else if (key === 'import-webui') { + await handleImportToWebUi(contextSessionId.value) + } +} + async function handleDeleteSession(id: string, profile?: string | null) { const summary = findHistorySession(id) const sessionProfile = profile || summary?.profile || null @@ -403,6 +526,58 @@ async function handleDeleteSession(id: string, profile?: string | null) { message.success(t('chat.sessionDeleted')) } +async function handleBatchDelete() { + if (selectedSessionKeys.value.size === 0 || isBatchDeleting.value) return + + const sessionsByKey = new Map(historySessions.value.map(session => [sessionSelectionKey(session), session])) + const targets = Array.from(selectedSessionKeys.value) + .map(key => sessionsByKey.get(key)) + .filter((session): session is Session => Boolean(session)) + .map(session => ({ id: session.id, profile: session.profile || null })) + if (targets.length === 0) return + + const activeWasSelected = historySession.value + ? selectedSessionKeys.value.has(sessionSelectionKey(historySession.value)) + : false + + isBatchDeleting.value = true + try { + const result = await batchDeleteSessions(targets) + if (result.deleted > 0) { + for (const target of targets) { + sessionBrowserPrefsStore.removePinned(target.id) + } + + await loadHermesSessions() + + if (activeWasSelected || (historySessionId.value && !findHistorySession(historySessionId.value))) { + historySessionId.value = null + historySession.value = null + await openDefaultHistorySession(true) + } + + 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 { + message.error(t('chat.batchDeleteFailed')) + } finally { + isBatchDeleting.value = false + showBatchDeleteConfirm.value = false + isBatchMode.value = false + selectedSessionKeys.value.clear() + } +} + +function handleBatchDeleteConfirm() { + void handleBatchDelete() + return false +} + @@ -458,15 +697,30 @@ async function handleDeleteSession(id: string, profile?: string | null) { :pinned="false" :can-delete="true" :streaming="false" + :selectable="isBatchMode" + :selected="isSessionSelected(s)" :show-profile="false" - @select="handleSessionClick(s.id, s.profile)" + @select="isBatchMode ? toggleSessionSelection(s) : handleSessionClick(s.id, s.profile)" + @contextmenu="handleContextMenu($event, s.id)" @delete="handleDeleteSession(s.id, s.profile)" + @toggle-select="toggleSessionSelection(s)" /> + +
@@ -490,16 +744,6 @@ async function handleDeleteSession(id: string, profile?: string | null) { {{ t('chat.outlineTitle') }} - - - {{ t('chat.copySessionLink') }} -