mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
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:
parent
dcdd1bf852
commit
eee49d1580
141
src/renderer/src/components/Popups/ImportPopup.tsx
Normal file
141
src/renderer/src/components/Popups/ImportPopup.tsx
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "检测",
|
||||
|
||||
@ -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": "檢查",
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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
|
||||
167
src/renderer/src/services/import/ImportService.ts
Normal file
167
src/renderer/src/services/import/ImportService.ts
Normal 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)
|
||||
268
src/renderer/src/services/import/importers/ChatGPTImporter.ts
Normal file
268
src/renderer/src/services/import/importers/ChatGPTImporter.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
12
src/renderer/src/services/import/importers/index.ts
Normal file
12
src/renderer/src/services/import/importers/index.ts
Normal 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
|
||||
3
src/renderer/src/services/import/index.ts
Normal file
3
src/renderer/src/services/import/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ChatGPTImporter } from './importers/ChatGPTImporter'
|
||||
export { importChatGPTConversations, ImportService } from './ImportService'
|
||||
export type { ConversationImporter, ImportResponse, ImportResult } from './types'
|
||||
52
src/renderer/src/services/import/types.ts
Normal file
52
src/renderer/src/services/import/types.ts
Normal 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>
|
||||
}
|
||||
34
src/renderer/src/services/import/utils/database.ts
Normal file
34
src/renderer/src/services/import/utils/database.ts
Normal 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`)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user