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:
@@ -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')" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -151,6 +151,12 @@ export default {
|
||||
monitorRoleUser: 'ユーザー',
|
||||
monitorRoleAssistant: 'アシスタント',
|
||||
copySessionId: 'セッション ID をコピー',
|
||||
export: 'エクスポート',
|
||||
exportFull: 'フルエクスポート (JSON)',
|
||||
exportCompressed: '圧縮エクスポート (TXT)',
|
||||
exportCompressing: 'コンテキストを圧縮中、お待ちください...',
|
||||
exportSuccess: 'セッションをエクスポートしました',
|
||||
exportFailed: 'エクスポートに失敗しました',
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
renameSession: 'セッション名の変更',
|
||||
|
||||
@@ -151,6 +151,12 @@ export default {
|
||||
monitorRoleUser: '사용자',
|
||||
monitorRoleAssistant: '어시스턴트',
|
||||
copySessionId: '세션 ID 복사',
|
||||
export: '내보내기',
|
||||
exportFull: '전체 내보내기 (JSON)',
|
||||
exportCompressed: '압축 내보내기 (TXT)',
|
||||
exportCompressing: '컨텍스트 압축 중, 잠시 기다려주세요...',
|
||||
exportSuccess: '세션을 내보냈습니다',
|
||||
exportFailed: '내보내기 실패',
|
||||
renamed: '이름이 변경되었습니다',
|
||||
renameFailed: '이름 변경 실패',
|
||||
renameSession: '세션 이름 변경',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user