Skip to content
Open
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
130 changes: 130 additions & 0 deletions src/components/RoomSelector.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { format } from 'date-fns'
import { enUS } from 'date-fns/locale'
import type { ExportedRoomMessages } from '~/types/messages'

Check failure on line 4 in src/components/RoomSelector.vue

View workflow job for this annotation

GitHub Actions / Lint

Expected "~/types/messages" (type-internal) to come before "date-fns/locale" (value-external)
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import Button from '~/components/ui/button/Button.vue'
Expand All @@ -9,21 +10,28 @@
import DialogHeader from '~/components/ui/dialog/DialogHeader.vue'
import DialogTitle from '~/components/ui/dialog/DialogTitle.vue'
import Input from '~/components/ui/input/Input.vue'
import Textarea from '~/components/ui/textarea/Textarea.vue'
import { useDatabaseStore } from '~/stores/database'
import { useMessagesStore } from '~/stores/messages'
import { useRoomsStore } from '~/stores/rooms'
import { useSettingsStore } from '~/stores/settings'

const roomsStore = useRoomsStore()
const settingsStore = useSettingsStore()
const messagesStore = useMessagesStore()

// Dialog states
const showRenameDialog = ref(false)
const showDeleteConfirmDialog = ref(false)
const showImportDialog = ref(false)

// Room states
const renameRoomId = ref('')
const renameRoomName = ref('')
const roomToDeleteId = ref('')
const importRoomId = ref('')
const importPayload = ref('')
const importFileName = ref('')

// Swipe logic
const swipedRoomId = ref<string | null>(null)
Expand Down Expand Up @@ -153,6 +161,89 @@
}
}
}

function openImportDialog(id: string) {
importRoomId.value = id
importPayload.value = ''
importFileName.value = ''
showImportDialog.value = true
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

function isExportedRoomMessages(value: unknown): value is ExportedRoomMessages {
if (!isRecord(value))
return false

if (value.version !== 1)
return false

if (!isRecord(value.room) || typeof value.room.id !== 'string')
return false

return Array.isArray(value.messages)
}
Comment on lines +176 to +187

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The type guard isExportedRoomMessages provides a basic check on the imported data, but it could be more robust. It currently verifies that value.messages is an array, but it doesn't validate the contents of the array. If the messages array contains items that don't match the ExportedMessage interface, it could lead to runtime errors later in the importRoomMessages function in messages.ts. I suggest adding a more thorough validation by checking that each item in the array has the expected structure.

function isExportedRoomMessages(value: unknown): value is ExportedRoomMessages {
  if (!isRecord(value))
    return false

  if (value.version !== 1)
    return false

  if (!isRecord(value.room) || typeof value.room.id !== 'string')
    return false

  return Array.isArray(value.messages) && value.messages.every(
    (msg: unknown) =>
      isRecord(msg)
      && typeof msg.id === 'string'
      && typeof msg.role === 'string'
      && Array.isArray(msg.content),
  )
}


async function handleImportFile(event: Event) {
const target = event.target
const file = target instanceof HTMLInputElement ? target.files?.[0] : undefined
if (!file)
return

importPayload.value = await file.text()
importFileName.value = file.name
}

async function importRoomMessages() {
if (!importRoomId.value)
return

if (!importPayload.value.trim()) {
toast.error('Please provide a JSON export file or paste the contents')
return
}

try {
const parsed: unknown = JSON.parse(importPayload.value)
if (!isExportedRoomMessages(parsed)) {
toast.error('Unsupported import format')
return
}

await messagesStore.importRoomMessages(importRoomId.value, parsed)
toast.success('Messages imported successfully')
showImportDialog.value = false
}
catch (error) {
console.error(error)
toast.error('Failed to import messages')
}
}

async function exportRoomMessages(roomId: string) {
try {
const payload = await messagesStore.exportRoomMessages(roomId)
const room = roomsStore.rooms.find(item => item.id === roomId)
const safeName = room?.name?.trim()
? room.name.replace(/[^a-z0-9-_]+/gi, '_')

Check failure on line 230 in src/components/RoomSelector.vue

View workflow job for this annotation

GitHub Actions / Lint

Unexpected character class ranges '[a-z0-9_]'. Use '\w' instead
: 'chat'

const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${safeName}-messages.json`
link.click()
URL.revokeObjectURL(url)
toast.success('Messages exported successfully')
}
catch (error) {
console.error(error)
toast.error('Failed to export messages')
}
}
</script>

<template>
Expand Down Expand Up @@ -186,6 +277,12 @@
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="openRenameDialog(room.id, room.name)">
<div class="i-solar-pen-2-bold text-sm" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="exportRoomMessages(room.id)">
<div class="i-solar-download-bold text-sm" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="openImportDialog(room.id)">
<div class="i-solar-upload-bold text-sm" />
</Button>
<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -219,6 +316,12 @@
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="openRenameDialog(room.id, room.name)">
<div class="i-solar-pen-2-bold text-sm" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="exportRoomMessages(room.id)">
<div class="i-solar-download-bold text-sm" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="openImportDialog(room.id)">
<div class="i-solar-upload-bold text-sm" />
</Button>
<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -274,6 +377,33 @@
</div>
</DialogContent>
</Dialog>

<Dialog v-model:open="showImportDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Import Messages</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-3">
<Input type="file" accept="application/json" @change="handleImportFile" />
<div v-if="importFileName" class="text-xs text-muted-foreground">
Selected: {{ importFileName }}
</div>
<Textarea
v-model="importPayload"
rows="6"
placeholder="Paste exported JSON here"
/>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="showImportDialog = false">
Cancel
</Button>
<Button @click="importRoomMessages">
Import
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</template>

Expand Down
92 changes: 91 additions & 1 deletion src/stores/messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CommonContentPart } from 'xsai'
import type { Message, MessageRole } from '~/types/messages'
import type { ExportedRoomMessages, Message, MessageRole } from '~/types/messages'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useMessageModel } from '~/models/messages'
Expand Down Expand Up @@ -203,6 +203,94 @@ export const useMessagesStore = defineStore('messages', () => {
return messages.value.some(message => message.parent_id === messageId)
}

async function exportRoomMessages(roomId: string): Promise<ExportedRoomMessages> {
const room = roomsStore.rooms.find(item => item.id === roomId)
if (!room) {
throw new Error('Room not found')
}

const roomMessages = await messageModel.getByRoomId(roomId)
return {
version: 1,
exported_at: new Date().toISOString(),
room: {
id: room.id,
name: room.name,
},
messages: roomMessages.map(message => ({
id: message.id,
parent_id: message.parent_id,
role: message.role as MessageRole,
provider: message.provider,
model: message.model,
summary: message.summary,
show_summary: message.show_summary ?? false,
memory: message.memory ?? [],
content: message.content,
})),
}
}

async function importRoomMessages(roomId: string, payload: ExportedRoomMessages) {
if (!payload || payload.version !== 1) {
throw new Error('Unsupported export format')
}

const pending = [...payload.messages]
const idMap = new Map<string, string>()
let guard = 0

while (pending.length > 0) {
guard += 1
if (guard > payload.messages.length + 1) {
throw new Error('Failed to resolve message parent links')
}

let progressed = false
const remaining: typeof pending = []

for (const message of pending) {
const parentId = message.parent_id ? idMap.get(message.parent_id) : null
if (message.parent_id && !parentId) {
remaining.push(message)
continue
}

const [created] = await messageModel.create({
role: message.role,
parent_id: parentId ?? null,
provider: message.provider,
model: message.model,
room_id: roomId,
memory: message.memory ?? [],
summary: message.summary ?? null,
})

if (message.content.length > 0) {
await messageModel.appendContentBatch(created.id, message.content)
}

if (message.show_summary) {
await messageModel.updateShowSummary(created.id, true)
}

idMap.set(message.id, created.id)
progressed = true
}
Comment on lines +252 to +279

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The importRoomMessages function iterates through each message and performs several await calls for database operations (create, appendContentBatch, updateShowSummary) within the loop. For large imports, this will result in many sequential database queries, which can be inefficient.

To improve performance, consider batching these operations. For each level of the message tree dependency, you could:

  1. Collect all messages that are ready to be created.
  2. Insert them in a single batch database call.
  3. Then, perform batch operations for content and summary updates for the newly created messages.

This would reduce the number of round-trips to the database and should significantly speed up the import process for large files.


if (!progressed) {
throw new Error('Failed to import messages')
}

pending.length = 0
pending.push(...remaining)
}

if (roomsStore.currentRoomId === roomId) {
await retrieveMessages()
}
}

return {
// State
messages,
Expand Down Expand Up @@ -235,5 +323,7 @@ export const useMessagesStore = defineStore('messages', () => {
updateShowSummary,

hasChildren,
exportRoomMessages,
importRoomMessages,
}
})
22 changes: 22 additions & 0 deletions src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,25 @@ export interface Message extends BaseMessage {
show_summary?: boolean
memory?: string[]
}

export interface ExportedMessage {
id: string
parent_id: string | null
role: MessageRole
provider: string
model: string
summary: string | null
show_summary: boolean
memory: string[]
content: CommonContentPart[]
}

export interface ExportedRoomMessages {
version: 1
exported_at: string
room: {
id: string
name: string
}
messages: ExportedMessage[]
}
2 changes: 1 addition & 1 deletion src/utils/prompts/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Alternatively, you can right-click on a message and select "Fork" to let me crea
### 🔹 **Additional Notes**

- You can **delete entire branches** if they are no longer needed.
- The app currently **does not support exporting or importing conversations**, but it may be considered in future updates.
- You can export or import a chat from the chat list to share or back up conversations.
- **Shortcut key support is under consideration** to improve usability.

If you find this app useful, consider giving it a ⭐ on [GitHub](https://github.com/LemonNekoGH/flow-chat) or contributing to its development. Your support helps improve the project—thank you! 🚀
Loading