feat: add session export with full and compressed modes (#507)

Add export functionality that allows users to download session data
as JSON or plain text, with optional LLM-based context compression
for long conversations. Includes UI controls in chat panel, session
list, and history view, plus i18n strings for all 8 locales.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-07 13:49:57 +08:00
committed by GitHub
parent c0ad8c907b
commit 173307ef28
18 changed files with 554 additions and 14 deletions
+19 -1
View File
@@ -1,4 +1,4 @@
import { request } from '../client'
import { request, getApiKey, getBaseUrlValue } from '../client'
export interface SessionSummary {
id: string
@@ -147,6 +147,24 @@ export async function setSessionWorkspace(id: string, workspace: string | null):
}
}
export async function exportSession(id: string, mode: 'full' | 'compressed' = 'full', ext: 'json' | 'txt' = 'json'): Promise<void> {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const url = `${baseUrl}/api/hermes/sessions/${id}/export?mode=${mode}&ext=${ext}&token=${encodeURIComponent(token)}`
const res = await fetch(url)
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const contentDisposition = res.headers.get('Content-Disposition') || ''
let filename = `session_${id}.${ext}`
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i)
if (match) filename = decodeURIComponent(match[1].replace(/"/g, ''))
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
}
export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { renameSession, setSessionWorkspace, batchDeleteSessions } from "@/api/hermes/sessions";
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
import { useChatStore, type Session } from "@/stores/hermes/chat";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import {
@@ -303,6 +303,28 @@ const contextMenuOptions = computed(() => [
},
{ label: t("chat.rename"), key: "rename" },
{ label: t("chat.setWorkspace"), key: "workspace" },
{
label: t("chat.export"),
key: "export",
children: [
{
label: t("chat.exportFull"),
key: "export-full",
children: [
{ label: "JSON", key: "export-full-json" },
{ label: "TXT", key: "export-full-txt" },
],
},
{
label: t("chat.exportCompressed"),
key: "export-compressed",
children: [
{ label: "JSON", key: "export-compressed-json" },
{ label: "TXT", key: "export-compressed-txt" },
],
},
],
},
{ label: t("chat.copySessionId"), key: "copy-id" },
]);
@@ -318,7 +340,15 @@ const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
function handleContextMenuSelect(key: string) {
function parseExportKey(key: string): { mode: 'full' | 'compressed'; ext: 'json' | 'txt' } | null {
if (key === 'export-full-json') return { mode: 'full', ext: 'json' }
if (key === 'export-full-txt') return { mode: 'full', ext: 'txt' }
if (key === 'export-compressed-json') return { mode: 'compressed', ext: 'json' }
if (key === 'export-compressed-txt') return { mode: 'compressed', ext: 'txt' }
return null
}
async function handleContextMenuSelect(key: string) {
showContextMenu.value = false;
if (!contextSessionId.value) return;
if (key === "pin") {
@@ -327,6 +357,17 @@ function handleContextMenuSelect(key: string) {
}
if (key === "copy-id") {
copySessionId(contextSessionId.value);
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!;
const loadingMsg = mode === "compressed" ? message.loading(t("chat.exportCompressing"), { duration: 0 }) : null;
try {
await exportSession(contextSessionId.value, mode, ext);
loadingMsg?.destroy();
message.success(t("chat.exportSuccess"));
} catch {
loadingMsg?.destroy();
message.error(t("chat.exportFailed"));
}
} else if (key === "workspace") {
const session = chatStore.sessions.find(
(s) => s.id === contextSessionId.value,
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NPopconfirm, NCheckbox } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { Session } from '@/stores/hermes/chat'
@@ -22,6 +23,49 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const longPressTriggered = ref(false)
function onTouchStart(e: TouchEvent) {
longPressTriggered.value = false
longPressTimer = setTimeout(() => {
longPressTriggered.value = true
const touch = e.touches[0]
const syntheticEvent = new MouseEvent('contextmenu', {
clientX: touch.clientX,
clientY: touch.clientY,
bubbles: true,
})
emit('contextmenu', syntheticEvent)
}, 500)
}
function onTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function onTouchMove() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function onClick() {
if (longPressTriggered.value) {
longPressTriggered.value = false
return
}
emit('select')
}
onUnmounted(() => {
if (longPressTimer) clearTimeout(longPressTimer)
})
</script>
<template>
@@ -29,8 +73,11 @@ const { t } = useI18n()
class="session-item"
:class="{ active, 'batch-mode': selectable }"
:aria-current="active ? 'page' : undefined"
@click="emit('select')"
@click="onClick"
@contextmenu="emit('contextmenu', $event)"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
>
<div v-if="selectable" class="session-item-checkbox">
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Benutzer',
monitorRoleAssistant: 'Assistent',
copySessionId: 'Sitzungs-ID kopieren',
export: 'Exportieren',
exportFull: 'Vollständiger Export (JSON)',
exportCompressed: 'Komprimierter Export (TXT)',
exportCompressing: 'Komprimiere Kontext, bitte warten...',
exportSuccess: 'Sitzung exportiert',
exportFailed: 'Export fehlgeschlagen',
renamed: 'Umbenannt',
renameFailed: 'Umbenennung fehlgeschlagen',
renameSession: 'Sitzung umbenennen',
+6
View File
@@ -174,6 +174,12 @@ export default {
monitorRoleUser: 'User',
monitorRoleAssistant: 'Assistant',
copySessionId: 'Copy Session ID',
export: 'Export',
exportFull: 'Full Export (JSON)',
exportCompressed: 'Compressed Export (TXT)',
exportCompressing: 'Compressing context, please wait...',
exportSuccess: 'Session exported',
exportFailed: 'Export failed',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameSession: 'Rename Session',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Usuario',
monitorRoleAssistant: 'Asistente',
copySessionId: 'Copiar ID de sesión',
export: 'Exportar',
exportFull: 'Exportación completa (JSON)',
exportCompressed: 'Exportación comprimida (TXT)',
exportCompressing: 'Comprimiendo contexto, espere...',
exportSuccess: 'Sesión exportada',
exportFailed: 'Error al exportar',
renamed: 'Renombrada',
renameFailed: 'Error al renombrar',
renameSession: 'Renombrar sesion',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Utilisateur',
monitorRoleAssistant: 'Assistant',
copySessionId: "Copier l'ID de session",
export: 'Exporter',
exportFull: 'Export complet (JSON)',
exportCompressed: 'Export compressé (TXT)',
exportCompressing: 'Compression du contexte, veuillez patienter...',
exportSuccess: 'Session exportée',
exportFailed: "Échec de l'export",
renamed: 'Renomme',
renameFailed: 'Echec du renommage',
renameSession: 'Renommer la session',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'ユーザー',
monitorRoleAssistant: 'アシスタント',
copySessionId: 'セッション ID をコピー',
export: 'エクスポート',
exportFull: 'フルエクスポート (JSON)',
exportCompressed: '圧縮エクスポート (TXT)',
exportCompressing: 'コンテキストを圧縮中、お待ちください...',
exportSuccess: 'セッションをエクスポートしました',
exportFailed: 'エクスポートに失敗しました',
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameSession: 'セッション名の変更',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: '사용자',
monitorRoleAssistant: '어시스턴트',
copySessionId: '세션 ID 복사',
export: '내보내기',
exportFull: '전체 내보내기 (JSON)',
exportCompressed: '압축 내보내기 (TXT)',
exportCompressing: '컨텍스트 압축 중, 잠시 기다려주세요...',
exportSuccess: '세션을 내보냈습니다',
exportFailed: '내보내기 실패',
renamed: '이름이 변경되었습니다',
renameFailed: '이름 변경 실패',
renameSession: '세션 이름 변경',
+6
View File
@@ -151,6 +151,12 @@ export default {
monitorRoleUser: 'Usuário',
monitorRoleAssistant: 'Assistente',
copySessionId: 'Copiar ID da sessão',
export: 'Exportar',
exportFull: 'Exportação completa (JSON)',
exportCompressed: 'Exportação comprimida (TXT)',
exportCompressing: 'Comprimindo contexto, aguarde...',
exportSuccess: 'Sessão exportada',
exportFailed: 'Falha ao exportar',
renamed: 'Renomeado',
renameFailed: 'Falha ao renomear',
renameSession: 'Renomear sessao',
+6
View File
@@ -174,6 +174,12 @@ export default {
monitorRoleUser: '用户',
monitorRoleAssistant: '助手',
copySessionId: '复制会话 ID',
export: '导出',
exportFull: '全量导出 (JSON)',
exportCompressed: '压缩导出 (TXT)',
exportCompressing: '正在压缩上下文,请稍候...',
exportSuccess: '会话已导出',
exportFailed: '导出失败',
renamed: '已重命名',
renameFailed: '重命名失败',
renameSession: '重命名会话',
@@ -11,7 +11,7 @@ import { copyToClipboard } from '@/utils/clipboard'
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
import { renameSession, setSessionWorkspace, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
import { renameSession, setSessionWorkspace, fetchHermesSessions, fetchHermesSession, exportSession, type SessionSummary } from '@/api/hermes/sessions'
const chatStore = useChatStore()
const appStore = useAppStore()
@@ -281,6 +281,28 @@ 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.export'),
key: 'export',
children: [
{
label: t('chat.exportFull'),
key: 'export-full',
children: [
{ label: 'JSON', key: 'export-full-json' },
{ label: 'TXT', key: 'export-full-txt' },
],
},
{
label: t('chat.exportCompressed'),
key: 'export-compressed',
children: [
{ label: 'JSON', key: 'export-compressed-json' },
{ label: 'TXT', key: 'export-compressed-txt' },
],
},
],
},
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
@@ -296,7 +318,15 @@ const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
function handleContextMenuSelect(key: string) {
function parseExportKey(key: string): { mode: 'full' | 'compressed'; ext: 'json' | 'txt' } | null {
if (key === 'export-full-json') return { mode: 'full', ext: 'json' }
if (key === 'export-full-txt') return { mode: 'full', ext: 'txt' }
if (key === 'export-compressed-json') return { mode: 'compressed', ext: 'json' }
if (key === 'export-compressed-txt') return { mode: 'compressed', ext: 'txt' }
return null
}
async function handleContextMenuSelect(key: string) {
showContextMenu.value = false
if (!contextSessionId.value) return
if (key === 'pin') {
@@ -305,6 +335,17 @@ function handleContextMenuSelect(key: string) {
}
if (key === 'copy-id') {
copySessionId(contextSessionId.value)
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!
const loadingMsg = mode === 'compressed' ? message.loading(t('chat.exportCompressing'), { duration: 0 }) : null
try {
await exportSession(contextSessionId.value, mode, ext)
loadingMsg?.destroy()
message.success(t('chat.exportSuccess'))
} catch {
loadingMsg?.destroy()
message.error(t('chat.exportFailed'))
}
} else if (key === 'workspace') {
const session = historySessions.value.find(s => s.id === contextSessionId.value)
workspaceSessionId.value = contextSessionId.value