| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | import { goto } from '$app/navigation';
|
| | import { browser } from '$app/environment';
|
| | import { toast } from 'svelte-sonner';
|
| | import { DatabaseService } from '$lib/services/database.service';
|
| | import { config } from '$lib/stores/settings.svelte';
|
| | import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
| | import { MessageRole } from '$lib/enums';
|
| |
|
| | class ConversationsStore {
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| |
|
| | conversations = $state<DatabaseConversation[]>([]);
|
| |
|
| |
|
| | activeConversation = $state<DatabaseConversation | null>(null);
|
| |
|
| |
|
| | activeMessages = $state<DatabaseMessage[]>([]);
|
| |
|
| |
|
| | isInitialized = $state(false);
|
| |
|
| |
|
| | titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | async init(): Promise<void> {
|
| | if (!browser) return;
|
| | if (this.isInitialized) return;
|
| |
|
| | try {
|
| | await this.loadConversations();
|
| | this.isInitialized = true;
|
| | } catch (error) {
|
| | console.error('Failed to initialize conversations:', error);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| |
|
| | async initialize(): Promise<void> {
|
| | return this.init();
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| |
|
| | addMessageToActive(message: DatabaseMessage): void {
|
| | this.activeMessages.push(message);
|
| | }
|
| |
|
| | |
| | |
| |
|
| | updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
| | if (index !== -1 && this.activeMessages[index]) {
|
| | this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
| | }
|
| | }
|
| |
|
| | |
| | |
| |
|
| | findMessageIndex(messageId: string): number {
|
| | return this.activeMessages.findIndex((m) => m.id === messageId);
|
| | }
|
| |
|
| | |
| | |
| |
|
| | sliceActiveMessages(startIndex: number): void {
|
| | this.activeMessages = this.activeMessages.slice(0, startIndex);
|
| | }
|
| |
|
| | |
| | |
| |
|
| | removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
| | if (index !== -1) {
|
| | return this.activeMessages.splice(index, 1)[0];
|
| | }
|
| | return undefined;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | setTitleUpdateConfirmationCallback(
|
| | callback: (currentTitle: string, newTitle: string) => Promise<boolean>
|
| | ): void {
|
| | this.titleUpdateConfirmationCallback = callback;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| |
|
| | async loadConversations(): Promise<void> {
|
| | const conversations = await DatabaseService.getAllConversations();
|
| | this.conversations = conversations;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | async createConversation(name?: string): Promise<string> {
|
| | const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
| | const conversation = await DatabaseService.createConversation(conversationName);
|
| |
|
| | this.conversations = [conversation, ...this.conversations];
|
| | this.activeConversation = conversation;
|
| | this.activeMessages = [];
|
| |
|
| | await goto(`#/chat/${conversation.id}`);
|
| |
|
| | return conversation.id;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | async loadConversation(convId: string): Promise<boolean> {
|
| | try {
|
| | const conversation = await DatabaseService.getConversation(convId);
|
| |
|
| | if (!conversation) {
|
| | return false;
|
| | }
|
| |
|
| | this.activeConversation = conversation;
|
| |
|
| | if (conversation.currNode) {
|
| | const allMessages = await DatabaseService.getConversationMessages(convId);
|
| | const filteredMessages = filterByLeafNodeId(
|
| | allMessages,
|
| | conversation.currNode,
|
| | false
|
| | ) as DatabaseMessage[];
|
| | this.activeMessages = filteredMessages;
|
| | } else {
|
| | const messages = await DatabaseService.getConversationMessages(convId);
|
| | this.activeMessages = messages;
|
| | }
|
| |
|
| | return true;
|
| | } catch (error) {
|
| | console.error('Failed to load conversation:', error);
|
| | return false;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| |
|
| | clearActiveConversation(): void {
|
| | this.activeConversation = null;
|
| | this.activeMessages = [];
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async deleteConversation(convId: string): Promise<void> {
|
| | try {
|
| | await DatabaseService.deleteConversation(convId);
|
| |
|
| | this.conversations = this.conversations.filter((c) => c.id !== convId);
|
| |
|
| | if (this.activeConversation?.id === convId) {
|
| | this.clearActiveConversation();
|
| | await goto(`?new_chat=true#/`);
|
| | }
|
| | } catch (error) {
|
| | console.error('Failed to delete conversation:', error);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| |
|
| | async deleteAll(): Promise<void> {
|
| | try {
|
| | const allConversations = await DatabaseService.getAllConversations();
|
| |
|
| | for (const conv of allConversations) {
|
| | await DatabaseService.deleteConversation(conv.id);
|
| | }
|
| |
|
| | this.clearActiveConversation();
|
| | this.conversations = [];
|
| |
|
| | toast.success('All conversations deleted');
|
| |
|
| | await goto(`?new_chat=true#/`);
|
| | } catch (error) {
|
| | console.error('Failed to delete all conversations:', error);
|
| | toast.error('Failed to delete conversations');
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| |
|
| | async refreshActiveMessages(): Promise<void> {
|
| | if (!this.activeConversation) return;
|
| |
|
| | const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
| |
|
| | if (allMessages.length === 0) {
|
| | this.activeMessages = [];
|
| | return;
|
| | }
|
| |
|
| | const leafNodeId =
|
| | this.activeConversation.currNode ||
|
| | allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
| |
|
| | const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
| |
|
| | this.activeMessages = currentPath;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
| | return await DatabaseService.getConversationMessages(convId);
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | async updateConversationName(convId: string, name: string): Promise<void> {
|
| | try {
|
| | await DatabaseService.updateConversation(convId, { name });
|
| |
|
| | const convIndex = this.conversations.findIndex((c) => c.id === convId);
|
| |
|
| | if (convIndex !== -1) {
|
| | this.conversations[convIndex].name = name;
|
| | this.conversations = [...this.conversations];
|
| | }
|
| |
|
| | if (this.activeConversation?.id === convId) {
|
| | this.activeConversation = { ...this.activeConversation, name };
|
| | }
|
| | } catch (error) {
|
| | console.error('Failed to update conversation name:', error);
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| |
|
| | async updateConversationTitleWithConfirmation(
|
| | convId: string,
|
| | newTitle: string
|
| | ): Promise<boolean> {
|
| | try {
|
| | const currentConfig = config();
|
| |
|
| | if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
|
| | const conversation = await DatabaseService.getConversation(convId);
|
| | if (!conversation) return false;
|
| |
|
| | const shouldUpdate = await this.titleUpdateConfirmationCallback(
|
| | conversation.name,
|
| | newTitle
|
| | );
|
| | if (!shouldUpdate) return false;
|
| | }
|
| |
|
| | await this.updateConversationName(convId, newTitle);
|
| | return true;
|
| | } catch (error) {
|
| | console.error('Failed to update conversation title with confirmation:', error);
|
| | return false;
|
| | }
|
| | }
|
| |
|
| | |
| | |
| |
|
| | updateConversationTimestamp(): void {
|
| | if (!this.activeConversation) return;
|
| |
|
| | const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
| |
|
| | if (chatIndex !== -1) {
|
| | this.conversations[chatIndex].lastModified = Date.now();
|
| | const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
| | this.conversations = [updatedConv, ...this.conversations];
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async updateCurrentNode(nodeId: string): Promise<void> {
|
| | if (!this.activeConversation) return;
|
| |
|
| | await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
| | this.activeConversation = { ...this.activeConversation, currNode: nodeId };
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | async navigateToSibling(siblingId: string): Promise<void> {
|
| | if (!this.activeConversation) return;
|
| |
|
| | const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
| | const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
| | const currentFirstUserMessage = this.activeMessages.find(
|
| | (m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
|
| | );
|
| |
|
| | const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
| |
|
| | await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
| | this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
|
| | await this.refreshActiveMessages();
|
| |
|
| | if (rootMessage && this.activeMessages.length > 0) {
|
| | const newFirstUserMessage = this.activeMessages.find(
|
| | (m) => m.role === MessageRole.USER && m.parent === rootMessage.id
|
| | );
|
| |
|
| | if (
|
| | newFirstUserMessage &&
|
| | newFirstUserMessage.content.trim() &&
|
| | (!currentFirstUserMessage ||
|
| | newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
| | newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
| | ) {
|
| | await this.updateConversationTitleWithConfirmation(
|
| | this.activeConversation.id,
|
| | newFirstUserMessage.content.trim()
|
| | );
|
| | }
|
| | }
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | async downloadConversation(convId: string): Promise<void> {
|
| | let conversation: DatabaseConversation | null;
|
| | let messages: DatabaseMessage[];
|
| |
|
| | if (this.activeConversation?.id === convId) {
|
| | conversation = this.activeConversation;
|
| | messages = this.activeMessages;
|
| | } else {
|
| | conversation = await DatabaseService.getConversation(convId);
|
| | if (!conversation) return;
|
| | messages = await DatabaseService.getConversationMessages(convId);
|
| | }
|
| |
|
| | this.triggerDownload({ conv: conversation, messages });
|
| | }
|
| |
|
| | |
| | |
| | |
| |
|
| | async exportAllConversations(): Promise<DatabaseConversation[]> {
|
| | const allConversations = await DatabaseService.getAllConversations();
|
| |
|
| | if (allConversations.length === 0) {
|
| | throw new Error('No conversations to export');
|
| | }
|
| |
|
| | const allData = await Promise.all(
|
| | allConversations.map(async (conv) => {
|
| | const messages = await DatabaseService.getConversationMessages(conv.id);
|
| | return { conv, messages };
|
| | })
|
| | );
|
| |
|
| | const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
|
| | const url = URL.createObjectURL(blob);
|
| | const a = document.createElement('a');
|
| | a.href = url;
|
| | a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
| | document.body.appendChild(a);
|
| | a.click();
|
| | document.body.removeChild(a);
|
| | URL.revokeObjectURL(url);
|
| |
|
| | toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
| |
|
| | return allConversations;
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | async importConversations(): Promise<DatabaseConversation[]> {
|
| | return new Promise((resolve, reject) => {
|
| | const input = document.createElement('input');
|
| | input.type = 'file';
|
| | input.accept = '.json';
|
| |
|
| | input.onchange = async (e) => {
|
| | const file = (e.target as HTMLInputElement)?.files?.[0];
|
| |
|
| | if (!file) {
|
| | reject(new Error('No file selected'));
|
| | return;
|
| | }
|
| |
|
| | try {
|
| | const text = await file.text();
|
| | const parsedData = JSON.parse(text);
|
| | let importedData: ExportedConversations;
|
| |
|
| | if (Array.isArray(parsedData)) {
|
| | importedData = parsedData;
|
| | } else if (
|
| | parsedData &&
|
| | typeof parsedData === 'object' &&
|
| | 'conv' in parsedData &&
|
| | 'messages' in parsedData
|
| | ) {
|
| | importedData = [parsedData];
|
| | } else {
|
| | throw new Error('Invalid file format');
|
| | }
|
| |
|
| | const result = await DatabaseService.importConversations(importedData);
|
| | toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
| |
|
| | await this.loadConversations();
|
| |
|
| | const importedConversations = (
|
| | Array.isArray(importedData) ? importedData : [importedData]
|
| | ).map((item) => item.conv);
|
| |
|
| | resolve(importedConversations);
|
| | } catch (err: unknown) {
|
| | const message = err instanceof Error ? err.message : 'Unknown error';
|
| | console.error('Failed to import conversations:', err);
|
| | toast.error('Import failed', { description: message });
|
| | reject(new Error(`Import failed: ${message}`));
|
| | }
|
| | };
|
| |
|
| | input.click();
|
| | });
|
| | }
|
| |
|
| | |
| | |
| | |
| | |
| |
|
| | async importConversationsData(
|
| | data: ExportedConversations
|
| | ): Promise<{ imported: number; skipped: number }> {
|
| | const result = await DatabaseService.importConversations(data);
|
| | await this.loadConversations();
|
| | return result;
|
| | }
|
| |
|
| | |
| | |
| |
|
| | private triggerDownload(data: ExportedConversations, filename?: string): void {
|
| | const conversation =
|
| | 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
| |
|
| | if (!conversation) {
|
| | console.error('Invalid data: missing conversation');
|
| | return;
|
| | }
|
| |
|
| | const conversationName = conversation.name?.trim() || '';
|
| | const truncatedSuffix = conversationName
|
| | .toLowerCase()
|
| | .replace(/[^a-z0-9]/gi, '_')
|
| | .replace(/_+/g, '_')
|
| | .substring(0, 20);
|
| | const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
|
| |
|
| | const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
| | const url = URL.createObjectURL(blob);
|
| | const a = document.createElement('a');
|
| | a.href = url;
|
| | a.download = downloadFilename;
|
| | document.body.appendChild(a);
|
| | a.click();
|
| | document.body.removeChild(a);
|
| | URL.revokeObjectURL(url);
|
| | }
|
| | }
|
| |
|
| | export const conversationsStore = new ConversationsStore();
|
| |
|
| |
|
| | if (browser) {
|
| | conversationsStore.init();
|
| | }
|
| |
|
| | export const conversations = () => conversationsStore.conversations;
|
| | export const activeConversation = () => conversationsStore.activeConversation;
|
| | export const activeMessages = () => conversationsStore.activeMessages;
|
| | export const isConversationsInitialized = () => conversationsStore.isInitialized;
|
| |
|