Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/models/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { PGlite } from '@electric-sql/pglite'
import { useDatabaseStore } from '~/stores/database'

const IMPORT_DB_NAME = 'flow-chat-import'
const IMPORT_STORE_NAME = 'pending'

export function useExportModel() {
const dbStore = useDatabaseStore()

function downloadBlob(blob: Blob | File, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

async function exportDatabaseDump() {
const db = dbStore.db()
const pglite = (db as unknown as { $client: PGlite }).$client
const blob = await pglite.dumpDataDir('gzip')
downloadBlob(blob, `flow-chat-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`)
}

async function importDatabaseDump(file: File) {
const data = await file.arrayBuffer()

// Store in IndexedDB for loading after reload
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(IMPORT_DB_NAME, 1)
req.onupgradeneeded = () => req.result.createObjectStore(IMPORT_STORE_NAME)
req.onsuccess = () => {
const tx = req.result.transaction(IMPORT_STORE_NAME, 'readwrite')
tx.objectStore(IMPORT_STORE_NAME).put(data, 'dump')
tx.oncomplete = () => {
req.result.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
req.onerror = () => reject(req.error)
})

// Clear existing database and reload
await indexedDB.deleteDatabase('/pglite/flow-chat')
window.location.reload()
}

return {
exportDatabaseDump,
importDatabaseDump,
}
}

/** Check for pending import data (called during db init) */
export async function getPendingImport(): Promise<Blob | null> {
return new Promise((resolve) => {
const req = indexedDB.open(IMPORT_DB_NAME, 1)

Check failure on line 61 in src/models/export.ts

View workflow job for this annotation

GitHub Actions / Test

src/models/messages.test.ts > message Model > should return not embedded messages

ReferenceError: indexedDB is not defined ❯ src/models/export.ts:61:17 ❯ getPendingImport src/models/export.ts:60:10 ❯ Proxy.initialize src/stores/database.ts:61:33 ❯ Proxy.wrappedAction node_modules/.pnpm/pinia@3.0.4_typescript@5.9.3_vue@3.6.0-alpha.1_typescript@5.9.3_/node_modules/pinia/dist/pinia.mjs:1400:26 ❯ src/models/messages.test.ts:36:19

Check failure on line 61 in src/models/export.ts

View workflow job for this annotation

GitHub Actions / Test

src/models/messages.test.ts > message Model > should create an embedding

ReferenceError: indexedDB is not defined ❯ src/models/export.ts:61:17 ❯ getPendingImport src/models/export.ts:60:10 ❯ Proxy.initialize src/stores/database.ts:61:33 ❯ Proxy.wrappedAction node_modules/.pnpm/pinia@3.0.4_typescript@5.9.3_vue@3.6.0-alpha.1_typescript@5.9.3_/node_modules/pinia/dist/pinia.mjs:1400:26 ❯ src/models/messages.test.ts:14:19

Check failure on line 61 in src/models/export.ts

View workflow job for this annotation

GitHub Actions / Test

src/models/memories.test.ts > memory model > does not mix orphaned room memories into global memories

ReferenceError: indexedDB is not defined ❯ src/models/export.ts:61:17 ❯ getPendingImport src/models/export.ts:60:10 ❯ Proxy.initialize src/stores/database.ts:61:33 ❯ Proxy.wrappedAction node_modules/.pnpm/pinia@3.0.4_typescript@5.9.3_vue@3.6.0-alpha.1_typescript@5.9.3_/node_modules/pinia/dist/pinia.mjs:1400:26 ❯ src/models/memories.test.ts:64:19

Check failure on line 61 in src/models/export.ts

View workflow job for this annotation

GitHub Actions / Test

src/models/memories.test.ts > memory model > does not dedupe across rooms

ReferenceError: indexedDB is not defined ❯ src/models/export.ts:61:17 ❯ getPendingImport src/models/export.ts:60:10 ❯ Proxy.initialize src/stores/database.ts:61:33 ❯ Proxy.wrappedAction node_modules/.pnpm/pinia@3.0.4_typescript@5.9.3_vue@3.6.0-alpha.1_typescript@5.9.3_/node_modules/pinia/dist/pinia.mjs:1400:26 ❯ src/models/memories.test.ts:36:19

Check failure on line 61 in src/models/export.ts

View workflow job for this annotation

GitHub Actions / Test

src/models/memories.test.ts > memory model > upserts a global memory and merges tags

ReferenceError: indexedDB is not defined ❯ src/models/export.ts:61:17 ❯ getPendingImport src/models/export.ts:60:10 ❯ Proxy.initialize src/stores/database.ts:61:33 ❯ Proxy.wrappedAction node_modules/.pnpm/pinia@3.0.4_typescript@5.9.3_vue@3.6.0-alpha.1_typescript@5.9.3_/node_modules/pinia/dist/pinia.mjs:1400:26 ❯ src/models/memories.test.ts:14:19
req.onupgradeneeded = () => req.result.createObjectStore(IMPORT_STORE_NAME)
req.onerror = () => resolve(null)
req.onsuccess = () => {
const db = req.result
if (!db.objectStoreNames.contains(IMPORT_STORE_NAME)) {
db.close()
return resolve(null)
}
const tx = db.transaction(IMPORT_STORE_NAME, 'readonly')
const get = tx.objectStore(IMPORT_STORE_NAME).get('dump')
get.onsuccess = () => {
db.close()
resolve(get.result ? new Blob([get.result]) : null)
}
get.onerror = () => {
db.close()
resolve(null)
}
}
})
}

/** Clear pending import data */
export async function clearPendingImport(): Promise<void> {
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(IMPORT_DB_NAME)
req.onsuccess = req.onerror = req.onblocked = () => resolve()
})
}
75 changes: 75 additions & 0 deletions src/pages/settings/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia'
import { DialogOverlay } from 'reka-ui'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import MemoryManager from '~/components/MemoryManager.vue'
import ModelSelector from '~/components/ModelSelector.vue'
import TemplateManager from '~/components/TemplateManager.vue'
Expand All @@ -22,6 +23,7 @@ import SelectItem from '~/components/ui/select/SelectItem.vue'
import SelectTrigger from '~/components/ui/select/SelectTrigger.vue'

import SelectValue from '~/components/ui/select/SelectValue.vue'
import { useExportModel } from '~/models/export'
import { useDatabaseStore } from '~/stores/database'
import { useMessagesStore } from '~/stores/messages'
import { useRoomsStore } from '~/stores/rooms'
Expand All @@ -41,7 +43,11 @@ const showModelSelector = ref(false)
const showSummaryModelSelector = ref(false)
const showDeleteAllMessagesDialog = ref(false)
const dbStore = useDatabaseStore()
const exportModel = useExportModel()
const SAME_AS_DEFAULT_PROVIDER = '__same_as_default__'
const isExporting = ref(false)
const isImporting = ref(false)
const importFileInput = ref<HTMLInputElement | null>(null)

// Handle model selection
function handleModelSelect(selectedModelValue: string) {
Expand Down Expand Up @@ -87,6 +93,46 @@ async function deleteAllMessages() {
showDeleteAllMessagesDialog.value = true
}

async function exportData() {
isExporting.value = true
try {
await exportModel.exportDatabaseDump()
toast.success('Database exported')
}
catch (error) {
console.error('Failed to export:', error)
toast.error('Failed to export database')
}
finally {
isExporting.value = false
}
}

function triggerImport() {
importFileInput.value?.click()
}

async function handleImportFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file)
return

isImporting.value = true
try {
await exportModel.importDatabaseDump(file)
// Page will reload automatically
}
catch (error) {
console.error('Failed to import:', error)
toast.error('Failed to import database')
isImporting.value = false
}
finally {
input.value = ''
}
}

async function confirmDeleteAllMessages() {
await dbStore.clearDb()
await dbStore.migrate()
Expand Down Expand Up @@ -253,6 +299,26 @@ onMounted(async () => {
Reset Tutorial
</Button>

<!-- Data Backup -->
<div class="card border rounded-lg p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold">
Data Backup
</h2>
<div class="flex flex-col gap-3">
<Button id="export-data-button" variant="outline" :disabled="isExporting" @click="exportData">
<span v-if="isExporting" class="i-carbon-circle-dash mr-2 animate-spin" />
Export Data
</Button>
<Button id="import-data-button" variant="outline" :disabled="isImporting" @click="triggerImport">
<span v-if="isImporting" class="i-carbon-circle-dash mr-2 animate-spin" />
Import Data
</Button>
<p class="text-xs text-gray-500">
Import will replace all existing data. Please ensure the backup file is from a compatible version.
</p>
</div>
</div>

<Button id="delete-all-messages-button" variant="outline" @click="deleteAllMessages">
Delete all messages
</Button>
Expand Down Expand Up @@ -300,5 +366,14 @@ onMounted(async () => {
</DialogFooter>
</DialogContent>
</Dialog>

<!-- Hidden file input for import -->
<input
ref="importFileInput"
type="file"
accept=".tar.gz,.tgz"
class="hidden"
@change="handleImportFileSelect"
>
</div>
</template>
15 changes: 9 additions & 6 deletions src/stores/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { drizzle } from 'drizzle-orm/pglite'
import { defineStore } from 'pinia'
import migrations from 'virtual:drizzle-migrations.sql'
import { ref, toRaw } from 'vue'
import { clearPendingImport, getPendingImport } from '~/models/export'
import * as schema from '../../db/schema'

export const useDatabaseStore = defineStore('database', () => {
Expand Down Expand Up @@ -56,19 +57,21 @@ export const useDatabaseStore = defineStore('database', () => {
return
}

// Check for pending import
const pendingImport = await getPendingImport()

const pgClient = new PGlite({
fs: inMemory ? undefined : new IdbFs('flow-chat'),
extensions: { vector },
loadDataDir: pendingImport ?? undefined,
})

_db.value = drizzle({ client: pgClient, schema })

// It can only use in node environment
// await migrate(db.value, {
// migrationsFolder: 'drizzle',
// migrationsTable: '__migrations',
// migrationsSchema: 'public',
// })
if (pendingImport) {
await clearPendingImport()
logger.log('Database restored from backup')
}

logger.log('Database initialized')
}
Expand Down
Loading