feat: add ChatGPT conversation import feature (#11272)

* feat: add ChatGPT conversation import feature

Introduces a new import workflow for ChatGPT conversations, including UI components, service logic, and i18n support for English, Simplified Chinese, and Traditional Chinese. Adds an import menu to data settings, a popup for file selection and progress, and a service to parse and store imported conversations as topics and messages.

* fix: ci failure

* refactor: import service and add modular importers

Refactored the import service to support a modular importer architecture. Moved ChatGPT import logic to a dedicated importer class and directory. Updated UI components and i18n descriptions for clarity. Removed unused Redux selector in ImportMenuSettings. This change enables easier addition of new importers in the future.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: improve ChatGPT import UX and set model for assistant

Added a loading state and spinner for file selection in the ChatGPT import popup, with new translations for the 'selecting' state in en-us, zh-cn, and zh-tw locales. Also, set the model property for imported assistant messages to display the GPT-5 logo.

---------

Co-authored-by: SuYao <sy20010504@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Pleasure1234 2025-11-21 06:58:47 +00:00 committed by GitHub
parent dcdd1bf852
commit eee49d1580
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 824 additions and 2 deletions

View File

@ -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<Props> = ({ 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 (
<Modal
title={t('import.chatgpt.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
okText={t('import.chatgpt.button')}
okButtonProps={{ disabled: selecting || importing, loading: selecting }}
cancelButtonProps={{ disabled: selecting || importing }}
maskClosable={false}
transitionName="animation-move-down"
centered>
{!selecting && !importing && (
<Space direction="vertical" style={{ width: '100%' }}>
<div>{t('import.chatgpt.description')}</div>
<Alert
message={t('import.chatgpt.help.title')}
description={
<div>
<p>{t('import.chatgpt.help.step1')}</p>
<p>{t('import.chatgpt.help.step2')}</p>
<p>{t('import.chatgpt.help.step3')}</p>
</div>
}
type="info"
showIcon
style={{ marginTop: 12 }}
/>
</Space>
)}
{selecting && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>{t('import.chatgpt.selecting')}</div>
</div>
)}
{importing && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Progress percent={100} status="active" strokeColor="var(--color-primary)" showInfo={false} />
<div style={{ marginTop: 16 }}>{t('import.chatgpt.importing')}</div>
</div>
)}
</Modal>
)
}
const TopViewKey = 'ImportPopup'
export default class ImportPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<PopupResult>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -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",

View File

@ -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": "检测",

View File

@ -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": "檢查",

View File

@ -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: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
{ key: 'nutstore', title: t('settings.data.nutstore.title'), icon: <NutstoreIcon /> },
{ key: 's3', title: t('settings.data.s3.title.label'), icon: <CloudServerOutlined style={{ fontSize: 16 }} /> },
{ 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: <FolderOpen size={16} />
},
{ 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: <FileText size={16} />
},
{ 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: <i className="iconfont icon-notion" /> },
{
key: 'yuque',
@ -691,6 +698,7 @@ const DataSettings: FC = () => {
{menu === 'webdav' && <WebDavSettings />}
{menu === 'nutstore' && <NutstoreSettings />}
{menu === 's3' && <S3Settings />}
{menu === 'import_settings' && <ImportMenuOptions />}
{menu === 'export_menu' && <ExportMenuOptions />}
{menu === 'markdown_export' && <MarkdownExportSettings />}
{menu === 'notion' && <NotionSettings />}

View File

@ -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 (
<SettingGroup theme={theme}>
<SettingRow>
<SettingTitle>{t('settings.data.import_settings.title')}</SettingTitle>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.import_settings.chatgpt')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button onClick={ImportPopup.show}>{t('settings.data.import_settings.button')}</Button>
</HStack>
</SettingRow>
</SettingGroup>
)
}
export default ImportMenuOptions

View File

@ -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<string, ConversationImporter> = 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<ImportResponse> {
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<ImportResponse> {
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)

View File

@ -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<string, ChatGPTNode>
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<ImportResult> {
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<string, ChatGPTNode>, 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 }
}
}

View File

@ -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

View File

@ -0,0 +1,3 @@
export { ChatGPTImporter } from './importers/ChatGPTImporter'
export { importChatGPTConversations, ImportService } from './ImportService'
export type { ConversationImporter, ImportResponse, ImportResult } from './types'

View File

@ -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<string, unknown>
}
/**
* 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<ImportResult>
}

View File

@ -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<void> {
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`)
})
}