feat(chat): add conversation export & three-dot menu (M8.7)
Export conversations as Markdown, JSON, or plain text via three-dot menu in chat header. Uses File System Access API with <a download> fallback. Also adds delete conversation option. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
87cbd8c4ea
commit
6182adb01c
@@ -45,6 +45,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref="menuTriggerRef"
|
||||||
|
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
|
||||||
|
aria-label="Conversation menu"
|
||||||
|
@click="showMenu = !showMenu"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
|
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
|
||||||
aria-label="New conversation"
|
aria-label="New conversation"
|
||||||
@@ -149,6 +160,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Conversation menu (export, delete) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showMenu" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showMenu = false" />
|
||||||
|
<Transition name="picker">
|
||||||
|
<div
|
||||||
|
v-if="showMenu"
|
||||||
|
class="fixed z-[9999] path-glass-card p-2 animate-fade-up-fast shadow-2xl min-w-[160px]"
|
||||||
|
:style="menuDropdownStyle"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-2 text-white/40">Export</p>
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
|
||||||
|
@click="handleExport('markdown')"
|
||||||
|
>
|
||||||
|
Markdown (.md)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
|
||||||
|
@click="handleExport('json')"
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
|
||||||
|
@click="handleExport('text')"
|
||||||
|
>
|
||||||
|
Plain text (.txt)
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-white/10 mt-1 pt-1">
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-white/10 transition-all"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
Delete conversation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -157,6 +209,7 @@ import { ref, computed, watch, nextTick } from 'vue'
|
|||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useAI } from '@/composables/useAI'
|
import { useAI } from '@/composables/useAI'
|
||||||
import { useContentPanel } from '@/composables/useContentPanel'
|
import { useContentPanel } from '@/composables/useContentPanel'
|
||||||
|
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
@@ -176,6 +229,9 @@ const chatStore = useChatStore()
|
|||||||
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
|
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
|
||||||
const showModelPicker = ref(false)
|
const showModelPicker = ref(false)
|
||||||
const showChatList = ref(false)
|
const showChatList = ref(false)
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const menuTriggerRef = ref<HTMLElement | null>(null)
|
||||||
|
const menuDropdownStyle = ref<Record<string, string>>({})
|
||||||
const headerRef = ref<HTMLElement | null>(null)
|
const headerRef = ref<HTMLElement | null>(null)
|
||||||
const chatListTriggerRef = ref<HTMLElement | null>(null)
|
const chatListTriggerRef = ref<HTMLElement | null>(null)
|
||||||
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
|
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
|
||||||
@@ -211,8 +267,22 @@ function updateModelPickerPosition() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateMenuPosition() {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = menuTriggerRef.value
|
||||||
|
if (el) {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
menuDropdownStyle.value = {
|
||||||
|
top: `${r.bottom + 4}px`,
|
||||||
|
right: `${window.innerWidth - r.right}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(showChatList, (v) => { if (v) updateChatListPosition() })
|
watch(showChatList, (v) => { if (v) updateChatListPosition() })
|
||||||
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
|
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
|
||||||
|
watch(showMenu, (v) => { if (v) updateMenuPosition() })
|
||||||
|
|
||||||
const conversationList = computed(() => chatStore.conversationList)
|
const conversationList = computed(() => chatStore.conversationList)
|
||||||
const activeConversationId = computed(() => chatStore.activeConversationId)
|
const activeConversationId = computed(() => chatStore.activeConversationId)
|
||||||
@@ -246,6 +316,20 @@ function openDesignSystem() {
|
|||||||
enterDesignSystemMode()
|
enterDesignSystemMode()
|
||||||
showModelPicker.value = false
|
showModelPicker.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExport(format: ExportFormat) {
|
||||||
|
const conv = chatStore.activeConversation
|
||||||
|
if (!conv) return
|
||||||
|
await downloadConversation(conv, format)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
const id = chatStore.activeConversationId
|
||||||
|
if (!id) return
|
||||||
|
chatStore.deleteConversation(id)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Conversation } from '@aiui/core/types/message'
|
||||||
|
|
||||||
|
export type ExportFormat = 'markdown' | 'json' | 'text'
|
||||||
|
|
||||||
|
function formatTimestamp(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportAsMarkdown(conv: Conversation): string {
|
||||||
|
const lines = [`# ${conv.title}\n`, `_Exported ${formatTimestamp(Date.now())}_\n`]
|
||||||
|
for (const msg of conv.messages) {
|
||||||
|
const role = msg.role === 'user' ? '**You**' : '**Assistant**'
|
||||||
|
const time = formatTimestamp(msg.timestamp)
|
||||||
|
lines.push(`### ${role} — ${time}\n`)
|
||||||
|
lines.push(msg.content + '\n')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportAsJSON(conv: Conversation): string {
|
||||||
|
return JSON.stringify(conv, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportAsText(conv: Conversation): string {
|
||||||
|
const lines = [conv.title, '='.repeat(conv.title.length), '']
|
||||||
|
for (const msg of conv.messages) {
|
||||||
|
const role = msg.role === 'user' ? 'You' : 'Assistant'
|
||||||
|
lines.push(`[${role}] ${formatTimestamp(msg.timestamp)}`)
|
||||||
|
lines.push(msg.content)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtension(format: ExportFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'markdown': return '.md'
|
||||||
|
case 'json': return '.json'
|
||||||
|
case 'text': return '.txt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(format: ExportFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'markdown': return 'text/markdown'
|
||||||
|
case 'json': return 'application/json'
|
||||||
|
case 'text': return 'text/plain'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadConversation(conv: Conversation, format: ExportFormat): Promise<void> {
|
||||||
|
let content: string
|
||||||
|
switch (format) {
|
||||||
|
case 'markdown': content = exportAsMarkdown(conv); break
|
||||||
|
case 'json': content = exportAsJSON(conv); break
|
||||||
|
case 'text': content = exportAsText(conv); break
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${conv.title.replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase()}${getExtension(format)}`
|
||||||
|
const blob = new Blob([content], { type: getMimeType(format) })
|
||||||
|
|
||||||
|
// Try File System Access API first
|
||||||
|
if ('showSaveFilePicker' in window) {
|
||||||
|
try {
|
||||||
|
const handle = await (window as unknown as { showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle> }).showSaveFilePicker({
|
||||||
|
suggestedName: filename,
|
||||||
|
types: [{
|
||||||
|
description: format === 'markdown' ? 'Markdown' : format === 'json' ? 'JSON' : 'Text',
|
||||||
|
accept: { [getMimeType(format)]: [getExtension(format)] },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
const writable = await handle.createWritable()
|
||||||
|
await writable.write(blob)
|
||||||
|
await writable.close()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// User cancelled or API not supported — fall through to download
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: <a download>
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user