ui: export conversations from database instead of cached store (#27432)
This commit is contained in:
+2
-6
@@ -139,12 +139,8 @@
|
|||||||
|
|
||||||
async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
|
async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
|
||||||
try {
|
try {
|
||||||
const allData: ExportedConversation[] = await Promise.all(
|
const allData = await conversationsStore.getConversationsForExport(
|
||||||
selectedConversations.map(async (conv) => {
|
selectedConversations.map((conv) => conv.id)
|
||||||
const messages = await conversationsStore.getConversationMessages(conv.id);
|
|
||||||
|
|
||||||
return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allData.length === 1) {
|
if (allData.length === 1) {
|
||||||
|
|||||||
@@ -168,15 +168,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
|||||||
if (convIds.length === 0) return;
|
if (convIds.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fetched = await DatabaseService.getConversationsWithMessages(convIds);
|
const exported = await this.getConversationsForExport(convIds);
|
||||||
const activeId = this.activeConversation?.id;
|
|
||||||
const overridden = fetched.get(activeId ?? '');
|
|
||||||
|
|
||||||
if (overridden && activeId) {
|
|
||||||
overridden.conv = { ...this.activeConversation! };
|
|
||||||
}
|
|
||||||
|
|
||||||
const exported = [...fetched.values()];
|
|
||||||
|
|
||||||
if (exported.length === 0) {
|
if (exported.length === 0) {
|
||||||
toast.error('No conversations to export');
|
toast.error('No conversations to export');
|
||||||
@@ -365,16 +357,11 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
|||||||
* @param convId - The conversation ID to download
|
* @param convId - The conversation ID to download
|
||||||
*/
|
*/
|
||||||
async downloadConversation(convId: string): Promise<void> {
|
async downloadConversation(convId: string): Promise<void> {
|
||||||
const conversation =
|
const [exportedConversation] = await this.getConversationsForExport([convId]);
|
||||||
this.activeConversation?.id === convId
|
|
||||||
? this.activeConversation
|
|
||||||
: await DatabaseService.getConversation(convId);
|
|
||||||
|
|
||||||
if (!conversation) return;
|
if (!exportedConversation) return;
|
||||||
|
|
||||||
const messages = await DatabaseService.getConversationMessages(convId);
|
ConversationTransferService.downloadConversationFile(exportedConversation);
|
||||||
|
|
||||||
ConversationTransferService.downloadConversationFile({ conv: conversation, messages });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -453,6 +440,19 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
|||||||
return await DatabaseService.getConversationMessages(convId);
|
return await DatabaseService.getConversationMessages(convId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets conversations and their messages from the database for export.
|
||||||
|
* @param convIds - Conversation IDs
|
||||||
|
* @returns List of conversations with messages, ordered by the input IDs
|
||||||
|
*/
|
||||||
|
async getConversationsForExport(convIds: string[]): Promise<ExportedConversation[]> {
|
||||||
|
const fetched = await DatabaseService.getConversationsWithMessages(convIds);
|
||||||
|
|
||||||
|
return convIds
|
||||||
|
.map((id) => fetched.get(id))
|
||||||
|
.filter((entry): entry is ExportedConversation => entry !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports conversations from provided data (without file picker)
|
* Imports conversations from provided data (without file picker)
|
||||||
* @param data - Array of conversation data with messages
|
* @param data - Array of conversation data with messages
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/services/database.service', () => ({
|
||||||
|
DatabaseService: { getConversationsWithMessages: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MessageRole, MessageType } from '$lib/enums';
|
||||||
|
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
|
||||||
|
import { DatabaseService } from '$lib/services/database.service';
|
||||||
|
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||||
|
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||||
|
import { filterByLeafNodeId } from '$lib/utils/branching';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reproduces the exported-conversation bug:
|
||||||
|
*
|
||||||
|
* A conversation created in the current page session keeps `currNode: ''` in the
|
||||||
|
* sidebar list, because that list is only loaded at init while IndexedDB is stamped
|
||||||
|
* on every message insert.
|
||||||
|
*
|
||||||
|
* Exporting from the cached record resulted in no branch pointer, and importing
|
||||||
|
* the file showed every branch at once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fetchMock = vi.mocked(DatabaseService.getConversationsWithMessages);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const CONV_ID = 'c1';
|
||||||
|
|
||||||
|
function message(
|
||||||
|
id: string,
|
||||||
|
parent: string | null,
|
||||||
|
timestamp: number,
|
||||||
|
role: MessageRole,
|
||||||
|
type: MessageType = MessageType.TEXT
|
||||||
|
): DatabaseMessage {
|
||||||
|
return {
|
||||||
|
children: [],
|
||||||
|
content: id,
|
||||||
|
convId: CONV_ID,
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
role,
|
||||||
|
timestamp,
|
||||||
|
toolCalls: '',
|
||||||
|
type
|
||||||
|
} as DatabaseMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** root -> u1 -> a1 -> { u2a -> a2a (older) | u2b -> a2b (newer) } */
|
||||||
|
function branchedMessages(): DatabaseMessage[] {
|
||||||
|
const messages = [
|
||||||
|
message('root', null, 10, MessageRole.USER, MessageType.ROOT),
|
||||||
|
message('u1', 'root', 20, MessageRole.USER),
|
||||||
|
message('a1', 'u1', 30, MessageRole.ASSISTANT),
|
||||||
|
message('u2a', 'a1', 40, MessageRole.USER),
|
||||||
|
message('a2a', 'u2a', 50, MessageRole.ASSISTANT),
|
||||||
|
message('u2b', 'a1', 60, MessageRole.USER),
|
||||||
|
message('a2b', 'u2b', 70, MessageRole.ASSISTANT)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const m of messages) {
|
||||||
|
m.children = messages.filter((c) => c.parent === m.id).map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A second conversation with a single linear path: root -> u1 -> a1. */
|
||||||
|
function linearMessages(convId: string): DatabaseMessage[] {
|
||||||
|
return [
|
||||||
|
{ ...message('root', null, 10, MessageRole.USER, MessageType.ROOT), children: ['u1'], convId },
|
||||||
|
{ ...message('u1', 'root', 20, MessageRole.USER), children: ['a1'], convId },
|
||||||
|
{ ...message('a1', 'u1', 30, MessageRole.ASSISTANT), convId }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversation(currNode: string, id: string = CONV_ID): DatabaseConversation {
|
||||||
|
return { currNode, id, lastModified: 100, name: `Chat ${id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `conversationsStore.loadConversation` */
|
||||||
|
function displayedIds(imported: { conv: DatabaseConversation; messages: DatabaseMessage[] }) {
|
||||||
|
if (imported.conv.currNode) {
|
||||||
|
return filterByLeafNodeId(imported.messages, imported.conv.currNode, false).map((m) => m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imported.messages.map((m) => m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export then re-import */
|
||||||
|
function roundTrip(conv: DatabaseConversation) {
|
||||||
|
const jsonl = ConversationTransferService.serializeSessionToJsonl({
|
||||||
|
conv,
|
||||||
|
messages: branchedMessages()
|
||||||
|
});
|
||||||
|
const [imported] = ConversationTransferService.parseSessionsJsonl(jsonl);
|
||||||
|
|
||||||
|
return { imported, sessionLine: JSON.parse(jsonl.split('\n')[0]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('conversation export source', () => {
|
||||||
|
it('reads the database record rather than the stale sidebar list', async () => {
|
||||||
|
conversationsStore.conversations = [conversation('')];
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Map([[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [exported] = await conversationsStore.getConversationsForExport([CONV_ID]);
|
||||||
|
|
||||||
|
expect(exported.conv.currNode).toBe('a2a');
|
||||||
|
expect(conversationsStore.conversations[0].currNode).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads every selected conversation from the database on bulk export', async () => {
|
||||||
|
conversationsStore.conversations = [conversation(''), conversation('', 'c2')];
|
||||||
|
conversationsStore.activeConversation = conversation('');
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['c2', { conv: conversation('a1', 'c2'), messages: linearMessages('c2') }],
|
||||||
|
[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const archive = vi
|
||||||
|
.spyOn(ConversationTransferService, 'downloadConversationsArchive')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
await conversationsStore.bulkExportConversations([CONV_ID, 'c2']);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith([CONV_ID, 'c2']);
|
||||||
|
expect(archive).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const payload = archive.mock.calls[0][0];
|
||||||
|
|
||||||
|
expect(payload.map((entry) => entry.conv.id)).toEqual([CONV_ID, 'c2']);
|
||||||
|
// Each entry carries its own database currNode.
|
||||||
|
expect(payload.map((entry) => entry.conv.currNode)).toEqual(['a2a', 'a1']);
|
||||||
|
expect(payload[1].messages.map((m: DatabaseMessage) => m.id)).toEqual(['root', 'u1', 'a1']);
|
||||||
|
|
||||||
|
archive.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exported conversation branch pointer', () => {
|
||||||
|
it('carries the database currNode, so the import restores the current branch', () => {
|
||||||
|
// The user regenerated to create a2b, then switched back to the a2a branch,
|
||||||
|
// so the stored leaf is NOT the newest message.
|
||||||
|
const { imported, sessionLine } = roundTrip(conversation('a2a'));
|
||||||
|
|
||||||
|
expect(sessionLine.currNode).toBe('a2a');
|
||||||
|
expect(displayedIds(imported)).toEqual(['u1', 'a1', 'u2a', 'a2a']);
|
||||||
|
expect(imported.messages.map((m: DatabaseMessage) => m.id).sort()).toEqual([
|
||||||
|
'a1',
|
||||||
|
'a2a',
|
||||||
|
'a2b',
|
||||||
|
'root',
|
||||||
|
'u1',
|
||||||
|
'u2a',
|
||||||
|
'u2b'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows every branch on import when the cache entry exported an empty currNode', () => {
|
||||||
|
const { imported, sessionLine } = roundTrip(conversation(''));
|
||||||
|
|
||||||
|
expect(sessionLine.currNode).toBe('');
|
||||||
|
expect(displayedIds(imported)).toEqual(['root', 'u1', 'a1', 'u2a', 'a2a', 'u2b', 'a2b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user