feat(chat): add conversation import from JSON (M8.8)

Import AIUI JSON exports and Claude.ai export format via file picker.
Merges imported conversations into existing data without overwriting.
Shows import summary with count and format detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:14:11 +00:00
co-authored by Claude Opus 4.6
parent 6182adb01c
commit 37e8aec920
2 changed files with 160 additions and 0 deletions
@@ -191,6 +191,12 @@
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-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="triggerImport"
>
Import conversations
</button>
<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"
@@ -201,6 +207,23 @@
</div>
</Transition>
</Teleport>
<input
ref="importInputRef"
type="file"
accept=".json"
class="hidden"
@change="handleImportFile"
/>
<!-- Import status -->
<div
v-if="importStatus"
class="absolute top-full left-3 right-3 mt-1 z-[100] glass px-3 py-2 rounded-lg text-xs animate-fade-up-fast"
:class="importStatus.startsWith('Error') ? 'text-red-400' : 'text-accent'"
>
{{ importStatus }}
</div>
</div>
</template>
@@ -210,6 +233,7 @@ import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import'
defineProps<{
title: string
@@ -330,6 +354,44 @@ function handleDelete() {
chatStore.deleteConversation(id)
showMenu.value = false
}
const importInputRef = ref<HTMLInputElement | null>(null)
const importStatus = ref<string | null>(null)
function triggerImport() {
showMenu.value = false
importInputRef.value?.click()
}
async function handleImportFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const result = parseImportFile(text)
if (result.error || result.conversations.length === 0) {
importStatus.value = `Error: ${result.error ?? 'No conversations found'}`
} else {
for (const conv of result.conversations) {
chatStore.conversations.set(conv.id, conv)
}
const count = result.conversations.length
importStatus.value = `Imported ${count} conversation${count > 1 ? 's' : ''} (${result.format})`
// Switch to first imported conversation
chatStore.setActiveConversation(result.conversations[0].id)
}
} catch {
importStatus.value = 'Error: Failed to read file'
}
// Clear file input for re-use
input.value = ''
// Auto-hide status after 3 seconds
setTimeout(() => { importStatus.value = null }, 3000)
}
</script>
<style scoped>
@@ -0,0 +1,98 @@
import type { Conversation, Message } from '@aiui/core/types/message'
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
interface ImportResult {
conversations: Conversation[]
format: 'aiui' | 'claude' | 'unknown'
error?: string
}
/** Try to parse as AIUI JSON export (single conversation) */
function parseAIUIFormat(data: unknown): Conversation | null {
if (!data || typeof data !== 'object') return null
const obj = data as Record<string, unknown>
if (typeof obj.id === 'string' && typeof obj.title === 'string' && Array.isArray(obj.messages)) {
return obj as unknown as Conversation
}
return null
}
/** Try to parse Claude.ai export format */
function parseClaudeFormat(data: unknown): Conversation[] {
if (!Array.isArray(data)) return []
const conversations: Conversation[] = []
for (const item of data) {
if (!item || typeof item !== 'object') continue
const obj = item as Record<string, unknown>
// Claude.ai exports have { uuid, name, chat_messages: [...] }
if (typeof obj.uuid === 'string' && typeof obj.name === 'string' && Array.isArray(obj.chat_messages)) {
const messages: Message[] = []
for (const cm of obj.chat_messages as Record<string, unknown>[]) {
if (!cm || typeof cm !== 'object') continue
const role = cm.sender === 'human' ? 'user' as const : 'assistant' as const
const content = typeof cm.text === 'string' ? cm.text : ''
messages.push({
id: generateId(),
role,
content,
timestamp: typeof cm.created_at === 'string' ? new Date(cm.created_at as string).getTime() : Date.now(),
})
}
conversations.push({
id: generateId(),
title: obj.name as string,
messages,
createdAt: typeof obj.created_at === 'string' ? new Date(obj.created_at as string).getTime() : Date.now(),
updatedAt: typeof obj.updated_at === 'string' ? new Date(obj.updated_at as string).getTime() : Date.now(),
})
}
}
return conversations
}
export function parseImportFile(jsonString: string): ImportResult {
try {
const data = JSON.parse(jsonString)
// Try AIUI single conversation
const aiui = parseAIUIFormat(data)
if (aiui) {
return { conversations: [aiui], format: 'aiui' }
}
// Try Claude.ai export (array of conversations)
const claude = parseClaudeFormat(data)
if (claude.length > 0) {
return { conversations: claude, format: 'claude' }
}
// Try AIUI array format
if (Array.isArray(data)) {
const aiuiConvs: Conversation[] = []
for (const item of data) {
const c = parseAIUIFormat(item)
if (c) aiuiConvs.push(c)
}
if (aiuiConvs.length > 0) {
return { conversations: aiuiConvs, format: 'aiui' }
}
}
return { conversations: [], format: 'unknown', error: 'Unrecognized format' }
} catch {
return { conversations: [], format: 'unknown', error: 'Invalid JSON' }
}
}