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,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')" />
|
||||
|
||||
Reference in New Issue
Block a user