diff --git a/src/renderer/src/components/Popups/ImportPopup.tsx b/src/renderer/src/components/Popups/ImportPopup.tsx new file mode 100644 index 0000000000..cd7e8f1252 --- /dev/null +++ b/src/renderer/src/components/Popups/ImportPopup.tsx @@ -0,0 +1,141 @@ +import { importChatGPTConversations } from '@renderer/services/import' +import { Alert, Modal, Progress, Space, Spin } from 'antd' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { TopView } from '../TopView' + +interface PopupResult { + success?: boolean +} + +interface Props { + resolve: (data: PopupResult) => void +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [open, setOpen] = useState(true) + const [selecting, setSelecting] = useState(false) + const [importing, setImporting] = useState(false) + const { t } = useTranslation() + + const onOk = async () => { + setSelecting(true) + try { + // Select ChatGPT JSON file + const file = await window.api.file.open({ + filters: [{ name: 'ChatGPT Conversations', extensions: ['json'] }] + }) + + setSelecting(false) + + if (!file) { + return + } + + setImporting(true) + + // Parse file content + const fileContent = typeof file.content === 'string' ? file.content : new TextDecoder().decode(file.content) + + // Import conversations + const result = await importChatGPTConversations(fileContent) + + if (result.success) { + window.toast.success( + t('import.chatgpt.success', { + topics: result.topicsCount, + messages: result.messagesCount + }) + ) + setOpen(false) + } else { + window.toast.error(result.error || t('import.chatgpt.error.unknown')) + } + } catch (error) { + window.toast.error(t('import.chatgpt.error.unknown')) + setOpen(false) + } finally { + setSelecting(false) + setImporting(false) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + ImportPopup.hide = onCancel + + return ( + + {!selecting && !importing && ( + +
{t('import.chatgpt.description')}
+ +

{t('import.chatgpt.help.step1')}

+

{t('import.chatgpt.help.step2')}

+

{t('import.chatgpt.help.step3')}

+ + } + type="info" + showIcon + style={{ marginTop: 12 }} + /> +
+ )} + {selecting && ( +
+ +
{t('import.chatgpt.selecting')}
+
+ )} + {importing && ( +
+ +
{t('import.chatgpt.importing')}
+
+ )} +
+ ) +} + +const TopViewKey = 'ImportPopup' + +export default class ImportPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c1c0dc2620..e34c8e65b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1376,6 +1376,36 @@ "preview": "Preview", "split": "Split" }, + "import": { + "chatgpt": { + "assistant_name": "ChatGPT Import", + "button": "Select File", + "description": "Only imports conversation text, does not include images and attachments", + "error": { + "invalid_json": "Invalid JSON file format", + "no_conversations": "No conversations found in file", + "no_valid_conversations": "No valid conversations to import", + "unknown": "Import failed, please check file format" + }, + "help": { + "step1": "1. Log in to ChatGPT, go to Settings > Data controls > Export data", + "step2": "2. Wait for the export file via email", + "step3": "3. Extract the downloaded file and find conversations.json", + "title": "How to export ChatGPT conversations?" + }, + "importing": "Importing conversations...", + "selecting": "Selecting file...", + "success": "Successfully imported {{topics}} conversations with {{messages}} messages", + "title": "Import ChatGPT Conversations", + "untitled_conversation": "Untitled Conversation" + }, + "confirm": { + "button": "Select Import File", + "label": "Are you sure you want to import external data?" + }, + "content": "Select external application conversation file to import, currently only supports ChatGPT JSON format files", + "title": "Import External Conversations" + }, "knowledge": { "add": { "title": "Add Knowledge Base" @@ -3085,6 +3115,7 @@ "basic": "Basic Data Settings", "cloud_storage": "Cloud Backup Settings", "export_settings": "Export Settings", + "import_settings": "Import Settings", "third_party": "Third-party Connections" }, "export_menu": { @@ -3143,6 +3174,11 @@ }, "hour_interval_one": "{{count}} hour", "hour_interval_other": "{{count}} hours", + "import_settings": { + "button": "Import Json File", + "chatgpt": "Import from ChatGPT", + "title": "Import Outside Application Data" + }, "joplin": { "check": { "button": "Check", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2a1ee09688..ad9e94af9c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1376,6 +1376,36 @@ "preview": "预览", "split": "分屏" }, + "import": { + "chatgpt": { + "assistant_name": "ChatGPT 导入", + "button": "选择文件", + "description": "仅导入对话文字,不携带图片和附件", + "error": { + "invalid_json": "无效的 JSON 文件格式", + "no_conversations": "文件中未找到任何对话", + "no_valid_conversations": "没有可导入的有效对话", + "unknown": "导入失败,请检查文件格式" + }, + "help": { + "step1": "1. 登录 ChatGPT,进入设置 > 数据控制 > 导出数据", + "step2": "2. 等待邮件接收导出文件", + "step3": "3. 解压下载的文件,找到 conversations.json", + "title": "如何导出 ChatGPT 对话?" + }, + "importing": "正在导入对话...", + "selecting": "正在选择文件...", + "success": "成功导入 {{topics}} 个对话,共 {{messages}} 条消息", + "title": "导入 ChatGPT 对话", + "untitled_conversation": "未命名对话" + }, + "confirm": { + "button": "选择导入文件", + "label": "确定要导入外部数据吗?" + }, + "content": "选择要导入的外部应用对话文件,暂时仅支持ChatGPT的JSON格式文件", + "title": "导入外部对话" + }, "knowledge": { "add": { "title": "添加知识库" @@ -3085,6 +3115,7 @@ "basic": "基础数据设置", "cloud_storage": "云备份设置", "export_settings": "导出设置", + "import_settings": "导入设置", "third_party": "第三方连接" }, "export_menu": { @@ -3143,6 +3174,11 @@ }, "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", + "import_settings": { + "button": "导入文件", + "chatgpt": "导入 ChatGPT 数据", + "title": "导入外部应用数据" + }, "joplin": { "check": { "button": "检测", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f5a0264875..4ed89ac7a1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1376,6 +1376,36 @@ "preview": "預覽", "split": "分屏" }, + "import": { + "chatgpt": { + "assistant_name": "ChatGPT 匯入", + "button": "選擇檔案", + "description": "僅匯入對話文字,不攜帶圖片和附件", + "error": { + "invalid_json": "無效的 JSON 檔案格式", + "no_conversations": "檔案中未找到任何對話", + "no_valid_conversations": "沒有可匯入的有效對話", + "unknown": "匯入失敗,請檢查檔案格式" + }, + "help": { + "step1": "1. 登入 ChatGPT,進入設定 > 資料控制 > 匯出資料", + "step2": "2. 等待郵件接收匯出檔案", + "step3": "3. 解壓下載的檔案,找到 conversations.json", + "title": "如何匯出 ChatGPT 對話?" + }, + "importing": "正在匯入對話...", + "selecting": "正在選擇檔案...", + "success": "成功匯入 {{topics}} 個對話,共 {{messages}} 則訊息", + "title": "匯入 ChatGPT 對話", + "untitled_conversation": "未命名對話" + }, + "confirm": { + "button": "選擇匯入檔案", + "label": "確定要匯入外部資料嗎?" + }, + "content": "選擇要匯入的外部應用對話檔案,暫時僅支援 ChatGPT 的 JSON 格式檔案", + "title": "匯入外部對話" + }, "knowledge": { "add": { "title": "新增知識庫" @@ -3085,6 +3115,7 @@ "basic": "基礎數據設定", "cloud_storage": "雲備份設定", "export_settings": "匯出設定", + "import_settings": "匯入設定", "third_party": "第三方連接" }, "export_menu": { @@ -3143,6 +3174,11 @@ }, "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", + "import_settings": { + "button": "匯入 Json 檔案", + "chatgpt": "匯入 ChatGPT 數據", + "title": "匯入外部應用程式數據" + }, "joplin": { "check": { "button": "檢查", diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 379165192e..b72db6fd61 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -16,6 +16,7 @@ import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' import { useTimer } from '@renderer/hooks/useTimer' +import ImportMenuOptions from '@renderer/pages/settings/DataSettings/ImportMenuSettings' import { reset } from '@renderer/services/BackupService' import store, { useAppDispatch } from '@renderer/store' import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings' @@ -95,7 +96,13 @@ const DataSettings: FC = () => { { key: 'webdav', title: t('settings.data.webdav.title'), icon: }, { key: 'nutstore', title: t('settings.data.nutstore.title'), icon: }, { key: 's3', title: t('settings.data.s3.title.label'), icon: }, - { key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') }, + { key: 'divider_2', isDivider: true, text: t('settings.data.divider.import_settings') }, + { + key: 'import_settings', + title: t('settings.data.import_settings.title'), + icon: + }, + { key: 'divider_3', isDivider: true, text: t('settings.data.divider.export_settings') }, { key: 'export_menu', title: t('settings.data.export_menu.title'), @@ -107,7 +114,7 @@ const DataSettings: FC = () => { icon: }, - { key: 'divider_3', isDivider: true, text: t('settings.data.divider.third_party') }, + { key: 'divider_4', isDivider: true, text: t('settings.data.divider.third_party') }, { key: 'notion', title: t('settings.data.notion.title'), icon: }, { key: 'yuque', @@ -691,6 +698,7 @@ const DataSettings: FC = () => { {menu === 'webdav' && } {menu === 'nutstore' && } {menu === 's3' && } + {menu === 'import_settings' && } {menu === 'export_menu' && } {menu === 'markdown_export' && } {menu === 'notion' && } diff --git a/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx b/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx new file mode 100644 index 0000000000..c4b1afe8e7 --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx @@ -0,0 +1,29 @@ +import { HStack } from '@renderer/components/Layout' +import ImportPopup from '@renderer/components/Popups/ImportPopup' +import { useTheme } from '@renderer/context/ThemeProvider' +import { Button } from 'antd' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const ImportMenuOptions: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + return ( + + + {t('settings.data.import_settings.title')} + + + + {t('settings.data.import_settings.chatgpt')} + + + + + + ) +} + +export default ImportMenuOptions diff --git a/src/renderer/src/services/import/ImportService.ts b/src/renderer/src/services/import/ImportService.ts new file mode 100644 index 0000000000..07dc72ab2d --- /dev/null +++ b/src/renderer/src/services/import/ImportService.ts @@ -0,0 +1,167 @@ +import { loggerService } from '@logger' +import i18n from '@renderer/i18n' +import store from '@renderer/store' +import { addAssistant } from '@renderer/store/assistants' +import type { Assistant } from '@renderer/types' +import { uuid } from '@renderer/utils' + +import { DEFAULT_ASSISTANT_SETTINGS } from '../AssistantService' +import { availableImporters } from './importers' +import type { ConversationImporter, ImportResponse } from './types' +import { saveImportToDatabase } from './utils/database' + +const logger = loggerService.withContext('ImportService') + +/** + * Main import service that manages all conversation importers + */ +class ImportServiceClass { + private importers: Map = new Map() + + constructor() { + // Register all available importers + for (const importer of availableImporters) { + this.importers.set(importer.name.toLowerCase(), importer) + logger.info(`Registered importer: ${importer.name}`) + } + } + + /** + * Get all registered importers + */ + getImporters(): ConversationImporter[] { + return Array.from(this.importers.values()) + } + + /** + * Get importer by name + */ + getImporter(name: string): ConversationImporter | undefined { + return this.importers.get(name.toLowerCase()) + } + + /** + * Auto-detect the appropriate importer for the file content + */ + detectImporter(fileContent: string): ConversationImporter | null { + for (const importer of this.importers.values()) { + if (importer.validate(fileContent)) { + logger.info(`Detected importer: ${importer.name}`) + return importer + } + } + logger.warn('No matching importer found for file content') + return null + } + + /** + * Import conversations from file content + * Automatically detects the format and uses the appropriate importer + */ + async importConversations(fileContent: string, importerName?: string): Promise { + try { + logger.info('Starting import...') + + // Parse JSON first to validate format + let importer: ConversationImporter | null = null + + if (importerName) { + // Use specified importer + const foundImporter = this.getImporter(importerName) + if (!foundImporter) { + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: `Importer "${importerName}" not found` + } + } + importer = foundImporter + } else { + // Auto-detect importer + importer = this.detectImporter(fileContent) + if (!importer) { + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: i18n.t('import.error.unsupported_format', { defaultValue: 'Unsupported file format' }) + } + } + } + + // Validate format + if (!importer.validate(fileContent)) { + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: i18n.t('import.error.invalid_format', { + defaultValue: `Invalid ${importer.name} format` + }) + } + } + + // Create assistant + const assistantId = uuid() + + // Parse conversations + const result = await importer.parse(fileContent, assistantId) + + // Save to database + await saveImportToDatabase(result) + + // Create assistant + const importerKey = `import.${importer.name.toLowerCase()}.assistant_name` + const assistant: Assistant = { + id: assistantId, + name: i18n.t(importerKey, { + defaultValue: `${importer.name} Import` + }), + emoji: importer.emoji, + prompt: '', + topics: result.topics, + messages: [], + type: 'assistant', + settings: DEFAULT_ASSISTANT_SETTINGS + } + + // Add assistant to store + store.dispatch(addAssistant(assistant)) + + logger.info( + `Import completed: ${result.topics.length} conversations, ${result.messages.length} messages imported` + ) + + return { + success: true, + assistant, + topicsCount: result.topics.length, + messagesCount: result.messages.length + } + } catch (error) { + logger.error('Import failed:', error as Error) + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: + error instanceof Error ? error.message : i18n.t('import.error.unknown', { defaultValue: 'Unknown error' }) + } + } + } + + /** + * Import ChatGPT conversations (backward compatibility) + * @deprecated Use importConversations() instead + */ + async importChatGPTConversations(fileContent: string): Promise { + return this.importConversations(fileContent, 'chatgpt') + } +} + +// Export singleton instance +export const ImportService = new ImportServiceClass() + +// Export for backward compatibility +export const importChatGPTConversations = (fileContent: string) => ImportService.importChatGPTConversations(fileContent) diff --git a/src/renderer/src/services/import/importers/ChatGPTImporter.ts b/src/renderer/src/services/import/importers/ChatGPTImporter.ts new file mode 100644 index 0000000000..3c95af919b --- /dev/null +++ b/src/renderer/src/services/import/importers/ChatGPTImporter.ts @@ -0,0 +1,268 @@ +import { loggerService } from '@logger' +import i18n from '@renderer/i18n' +import type { Topic } from '@renderer/types' +import { + AssistantMessageStatus, + type MainTextMessageBlock, + type Message, + MessageBlockStatus, + MessageBlockType, + UserMessageStatus +} from '@renderer/types/newMessage' +import { uuid } from '@renderer/utils' + +import type { ConversationImporter, ImportResult } from '../types' + +const logger = loggerService.withContext('ChatGPTImporter') + +/** + * ChatGPT Export Format Types + */ +interface ChatGPTMessage { + id: string + author: { + role: 'user' | 'assistant' | 'system' | 'tool' + } + content: { + content_type: string + parts?: string[] + } + metadata?: any + create_time?: number +} + +interface ChatGPTNode { + id: string + message?: ChatGPTMessage + parent?: string + children?: string[] +} + +interface ChatGPTConversation { + title: string + create_time: number + update_time: number + mapping: Record + current_node?: string +} + +/** + * ChatGPT conversation importer + * Handles importing conversations from ChatGPT's conversations.json export format + */ +export class ChatGPTImporter implements ConversationImporter { + readonly name = 'ChatGPT' + readonly emoji = '💬' + + /** + * Validate if the file content is a valid ChatGPT export + */ + validate(fileContent: string): boolean { + try { + const parsed = JSON.parse(fileContent) + const conversations = Array.isArray(parsed) ? parsed : [parsed] + + // Check if it has the basic ChatGPT conversation structure + return conversations.every( + (conv) => + conv && + typeof conv === 'object' && + 'mapping' in conv && + typeof conv.mapping === 'object' && + 'title' in conv && + 'create_time' in conv + ) + } catch { + return false + } + } + + /** + * Parse ChatGPT conversations and convert to unified format + */ + async parse(fileContent: string, assistantId: string): Promise { + logger.info('Starting ChatGPT import...') + + // Parse JSON + const parsed = JSON.parse(fileContent) + const conversations: ChatGPTConversation[] = Array.isArray(parsed) ? parsed : [parsed] + + if (!conversations || conversations.length === 0) { + throw new Error(i18n.t('import.chatgpt.error.no_conversations')) + } + + logger.info(`Found ${conversations.length} conversations`) + + const topics: Topic[] = [] + const allMessages: Message[] = [] + const allBlocks: MainTextMessageBlock[] = [] + + // Convert each conversation + for (const conversation of conversations) { + try { + const { topic, messages, blocks } = this.convertConversationToTopic(conversation, assistantId) + topics.push(topic) + allMessages.push(...messages) + allBlocks.push(...blocks) + } catch (convError) { + logger.warn(`Failed to convert conversation "${conversation.title}":`, convError as Error) + // Continue with other conversations + } + } + + if (topics.length === 0) { + throw new Error(i18n.t('import.chatgpt.error.no_valid_conversations')) + } + + return { + topics, + messages: allMessages, + blocks: allBlocks + } + } + + /** + * Extract main conversation thread from ChatGPT's tree structure + * Traces back from current_node to root to get the main conversation path + */ + private extractMainThread(mapping: Record, currentNode?: string): ChatGPTMessage[] { + const messages: ChatGPTMessage[] = [] + const nodeIds: string[] = [] + + // Start from current_node or find the last node + let nodeId = currentNode + if (!nodeId) { + // Find node with no children (leaf node) + const leafNodes = Object.entries(mapping).filter(([, node]) => !node.children || node.children.length === 0) + if (leafNodes.length > 0) { + nodeId = leafNodes[0][0] + } + } + + // Trace back to root + while (nodeId) { + const node = mapping[nodeId] + if (!node) break + + nodeIds.unshift(nodeId) + nodeId = node.parent + } + + // Extract messages from the path + for (const id of nodeIds) { + const node = mapping[id] + if (node?.message) { + const message = node.message + // Filter out empty messages and tool messages + if ( + message.author.role !== 'tool' && + message.content?.parts && + message.content.parts.length > 0 && + message.content.parts.some((part) => part && part.trim().length > 0) + ) { + messages.push(message) + } + } + } + + return messages + } + + /** + * Map ChatGPT role to Cherry Studio role + */ + private mapRole(chatgptRole: string): 'user' | 'assistant' | 'system' { + if (chatgptRole === 'user') return 'user' + if (chatgptRole === 'assistant') return 'assistant' + return 'system' + } + + /** + * Create Message and MessageBlock from ChatGPT message + */ + private createMessageAndBlock( + chatgptMessage: ChatGPTMessage, + topicId: string, + assistantId: string + ): { message: Message; block: MainTextMessageBlock } { + const messageId = uuid() + const blockId = uuid() + const role = this.mapRole(chatgptMessage.author.role) + + // Extract text content from parts + const content = (chatgptMessage.content?.parts || []).filter((part) => part && part.trim()).join('\n\n') + + const createdAt = chatgptMessage.create_time + ? new Date(chatgptMessage.create_time * 1000).toISOString() + : new Date().toISOString() + + // Create message + const message: Message = { + id: messageId, + role, + assistantId, + topicId, + createdAt, + updatedAt: createdAt, + status: role === 'user' ? UserMessageStatus.SUCCESS : AssistantMessageStatus.SUCCESS, + blocks: [blockId], + // Set model for assistant messages to display GPT-5 logo + ...(role === 'assistant' && { + model: { + id: 'gpt-5', + provider: 'openai', + name: 'GPT-5', + group: 'gpt-5' + } + }) + } + + // Create block + const block: MainTextMessageBlock = { + id: blockId, + messageId, + type: MessageBlockType.MAIN_TEXT, + content, + createdAt, + updatedAt: createdAt, + status: MessageBlockStatus.SUCCESS + } + + return { message, block } + } + + /** + * Convert ChatGPT conversation to Cherry Studio Topic + */ + private convertConversationToTopic( + conversation: ChatGPTConversation, + assistantId: string + ): { topic: Topic; messages: Message[]; blocks: MainTextMessageBlock[] } { + const topicId = uuid() + const messages: Message[] = [] + const blocks: MainTextMessageBlock[] = [] + + // Extract main thread messages + const chatgptMessages = this.extractMainThread(conversation.mapping, conversation.current_node) + + // Convert each message + for (const chatgptMessage of chatgptMessages) { + const { message, block } = this.createMessageAndBlock(chatgptMessage, topicId, assistantId) + messages.push(message) + blocks.push(block) + } + + // Create topic + const topic: Topic = { + id: topicId, + assistantId, + name: conversation.title || i18n.t('import.chatgpt.untitled_conversation'), + createdAt: new Date(conversation.create_time * 1000).toISOString(), + updatedAt: new Date(conversation.update_time * 1000).toISOString(), + messages, + isNameManuallyEdited: true + } + + return { topic, messages, blocks } + } +} diff --git a/src/renderer/src/services/import/importers/index.ts b/src/renderer/src/services/import/importers/index.ts new file mode 100644 index 0000000000..35e8f071b4 --- /dev/null +++ b/src/renderer/src/services/import/importers/index.ts @@ -0,0 +1,12 @@ +import { ChatGPTImporter } from './ChatGPTImporter' + +/** + * Export all available importers + */ +export { ChatGPTImporter } + +/** + * Registry of all available importers + * Add new importers here as they are implemented + */ +export const availableImporters = [new ChatGPTImporter()] as const diff --git a/src/renderer/src/services/import/index.ts b/src/renderer/src/services/import/index.ts new file mode 100644 index 0000000000..8e9391eedd --- /dev/null +++ b/src/renderer/src/services/import/index.ts @@ -0,0 +1,3 @@ +export { ChatGPTImporter } from './importers/ChatGPTImporter' +export { importChatGPTConversations, ImportService } from './ImportService' +export type { ConversationImporter, ImportResponse, ImportResult } from './types' diff --git a/src/renderer/src/services/import/types.ts b/src/renderer/src/services/import/types.ts new file mode 100644 index 0000000000..279a087f3a --- /dev/null +++ b/src/renderer/src/services/import/types.ts @@ -0,0 +1,52 @@ +import type { Assistant, Topic } from '@renderer/types' +import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage' + +/** + * Import result containing parsed data + */ +export interface ImportResult { + topics: Topic[] + messages: Message[] + blocks: MainTextMessageBlock[] + metadata?: Record +} + +/** + * Response returned to caller after import + */ +export interface ImportResponse { + success: boolean + assistant?: Assistant + topicsCount: number + messagesCount: number + error?: string +} + +/** + * Base interface for conversation importers + * Each chat application (ChatGPT, Claude, Gemini, etc.) should implement this interface + */ +export interface ConversationImporter { + /** + * Unique name of the importer (e.g., 'ChatGPT', 'Claude', 'Gemini') + */ + readonly name: string + + /** + * Emoji or icon for the assistant created by this importer + */ + readonly emoji: string + + /** + * Validate if the file content matches this importer's format + */ + validate(fileContent: string): boolean + + /** + * Parse file content and convert to unified format + * @param fileContent - Raw file content (usually JSON string) + * @param assistantId - ID of the assistant to associate with + * @returns Parsed topics, messages, and blocks + */ + parse(fileContent: string, assistantId: string): Promise +} diff --git a/src/renderer/src/services/import/utils/database.ts b/src/renderer/src/services/import/utils/database.ts new file mode 100644 index 0000000000..6705b9a4be --- /dev/null +++ b/src/renderer/src/services/import/utils/database.ts @@ -0,0 +1,34 @@ +import { loggerService } from '@logger' +import db from '@renderer/databases' + +import type { ImportResult } from '../types' + +const logger = loggerService.withContext('ImportDatabase') + +/** + * Save import result to database + * Handles saving topics, messages, and message blocks in a transaction + */ +export async function saveImportToDatabase(result: ImportResult): Promise { + const { topics, messages, blocks } = result + + logger.info(`Saving import: ${topics.length} topics, ${messages.length} messages, ${blocks.length} blocks`) + + await db.transaction('rw', db.topics, db.message_blocks, async () => { + // Save all message blocks + if (blocks.length > 0) { + await db.message_blocks.bulkAdd(blocks) + logger.info(`Saved ${blocks.length} message blocks`) + } + + // Save all topics with messages + for (const topic of topics) { + const topicMessages = messages.filter((m) => m.topicId === topic.id) + await db.topics.add({ + id: topic.id, + messages: topicMessages + }) + } + logger.info(`Saved ${topics.length} topics`) + }) +}