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>
|
||||
</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
|
||||
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"
|
||||
@@ -149,6 +160,47 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -157,6 +209,7 @@ import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useAI } from '@/composables/useAI'
|
||||
import { useContentPanel } from '@/composables/useContentPanel'
|
||||
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
@@ -176,6 +229,9 @@ const chatStore = useChatStore()
|
||||
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
|
||||
const showModelPicker = 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 chatListTriggerRef = 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(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
|
||||
watch(showMenu, (v) => { if (v) updateMenuPosition() })
|
||||
|
||||
const conversationList = computed(() => chatStore.conversationList)
|
||||
const activeConversationId = computed(() => chatStore.activeConversationId)
|
||||
@@ -246,6 +316,20 @@ function openDesignSystem() {
|
||||
enterDesignSystemMode()
|
||||
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>
|
||||
|
||||
<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