-
- {title}
-
-
+
{children}
-
-
-
-
+ {footer &&
{footer}
}
) : null,
- Menu: ({ items, defaultSelectedKeys, onSelect, ...props }: any) => (
-
- {items?.map((item: any) => (
-
onSelect?.({ key: item.key })}
- style={{ cursor: 'pointer' }}>
- {item.label}
-
- ))}
-
+ Button: ({ children, onClick, icon, type, ...props }: any) => (
+
)
}))
-/**
- * 创建测试用的面板配置
- * @param overrides 可选的属性覆盖
- * @returns PanelConfig 数组
- */
-function createPanelConfigs(overrides: Partial
[] = []): PanelConfig[] {
- const defaultPanels: PanelConfig[] = [
- {
- key: 'general',
- label: 'General Settings',
- panel: General Settings Panel
- },
- {
- key: 'advanced',
- label: 'Advanced Settings',
- panel: Advanced Settings Panel
- }
- ]
-
- return defaultPanels.map((panel, index) => ({
- ...panel,
- ...overrides[index]
- }))
-}
-
-/**
- * 渲染 KnowledgeBaseFormModal 组件的辅助函数
- * @param props 可选的组件属性
- * @returns render 结果
- */
-function renderModal(props: Partial = {}) {
- const defaultProps = {
- open: true,
- title: 'Knowledge Base Settings',
- panels: createPanelConfigs(),
- onCancel: mocks.onCancel,
- onOk: mocks.onOk
+const createPanelConfigs = (): PanelConfig[] => [
+ {
+ key: 'general',
+ label: 'General Settings',
+ panel: General Settings Content
+ },
+ {
+ key: 'advanced',
+ label: 'Advanced Settings',
+ panel: Advanced Settings Content
}
-
- return render()
-}
+]
describe('KnowledgeBaseFormModal', () => {
beforeEach(() => {
@@ -106,131 +71,128 @@ describe('KnowledgeBaseFormModal', () => {
describe('basic rendering', () => {
it('should match snapshot', () => {
- const { container } = renderModal()
+ const { container } = render(
+
+ )
+
expect(container.firstChild).toMatchSnapshot()
})
it('should render modal when open is true', () => {
- renderModal({ open: true })
+ render(
+
+ )
expect(screen.getByTestId('modal')).toBeInTheDocument()
- expect(screen.getByTestId('hstack')).toBeInTheDocument()
- expect(screen.getByTestId('menu')).toBeInTheDocument()
})
- it('should render first panel by default', () => {
- renderModal()
+ it('should not render modal when open is false', () => {
+ render(
+
+ )
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('should render general panel by default', () => {
+ render(
+
+ )
expect(screen.getByTestId('general-panel')).toBeInTheDocument()
+ })
+
+ it('should not render advanced panel by default', () => {
+ render(
+
+ )
+
expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
})
- it('should handle empty panels array', () => {
- renderModal({ panels: [] })
+ it('should render advanced panel when defaultExpandAdvanced is true', () => {
+ render(
+
+ )
- expect(screen.getByTestId('modal')).toBeInTheDocument()
- expect(screen.getByTestId('menu')).toBeInTheDocument()
- })
- })
-
- describe('menu interaction', () => {
- it('should switch panels when menu item is clicked', () => {
- renderModal()
-
- // Initially shows general panel
- expect(screen.getByTestId('general-panel')).toBeInTheDocument()
- expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
-
- // Click advanced menu item
- fireEvent.click(screen.getByTestId('menu-item-advanced'))
-
- // Should now show advanced panel
- expect(screen.queryByTestId('general-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('advanced-panel')).toBeInTheDocument()
})
+ })
- it('should set default selected menu to first panel key', () => {
- const panels = createPanelConfigs()
- renderModal({ panels })
+ describe('advanced settings toggle', () => {
+ it('should toggle advanced panel visibility', () => {
+ render(
+
+ )
- const menu = screen.getByTestId('menu')
- expect(menu).toHaveAttribute('data-default-selected', panels[0].key)
- })
+ // Initially, advanced panel should not be visible
+ expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
- it('should handle menu selection with custom panels', () => {
- const customPanels: PanelConfig[] = [
- {
- key: 'custom1',
- label: 'Custom Panel 1',
- panel: Custom Panel 1
- },
- {
- key: 'custom2',
- label: 'Custom Panel 2',
- panel: Custom Panel 2
- }
- ]
-
- renderModal({ panels: customPanels })
-
- // Initially shows first custom panel
- expect(screen.getByTestId('custom1-panel')).toBeInTheDocument()
-
- // Click second custom menu item
- fireEvent.click(screen.getByTestId('menu-item-custom2'))
-
- // Should now show second custom panel
- expect(screen.queryByTestId('custom1-panel')).not.toBeInTheDocument()
- expect(screen.getByTestId('custom2-panel')).toBeInTheDocument()
+ // Find and click the first button (advanced settings toggle)
+ const buttons = screen.getAllByTestId('button')
+ if (buttons.length > 0) {
+ fireEvent.click(buttons[0])
+ // Advanced panel might be visible now (depending on implementation)
+ }
})
})
- describe('modal props', () => {
- const user = userEvent.setup()
- it('should pass through modal props correctly', () => {
- const customTitle = 'Custom Modal Title'
- renderModal({ title: customTitle })
+ describe('footer buttons', () => {
+ it('should have more buttons when onMoreSettings is provided', () => {
+ const { rerender } = render(
+
+ )
+ const buttonsWithout = screen.getAllByTestId('button')
- const modal = screen.getByTestId('modal')
- expect(modal).toHaveAttribute('data-title', customTitle)
- })
+ rerender(
+
+ )
+ const buttonsWith = screen.getAllByTestId('button')
- it('should call onOk when ok button is clicked', async () => {
- renderModal()
-
- await user.click(screen.getByTestId('modal-ok'))
- expect(mocks.onOk).toHaveBeenCalledTimes(1)
+ // Should have one more button when onMoreSettings is provided
+ expect(buttonsWith.length).toBeGreaterThan(buttonsWithout.length)
})
})
describe('edge cases', () => {
+ it('should handle empty panels array', () => {
+ render()
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ expect(screen.queryByTestId('general-panel')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
+ })
+
it('should handle single panel', () => {
const singlePanel: PanelConfig[] = [
{
- key: 'only',
- label: 'Only Panel',
- panel: Only Panel
+ key: 'general',
+ label: 'General Settings',
+ panel: General Settings Content
}
]
- renderModal({ panels: singlePanel })
+ render()
- expect(screen.getByTestId('only-panel')).toBeInTheDocument()
- expect(screen.getByTestId('menu-item-only')).toBeInTheDocument()
- })
-
- it('should handle panel with undefined key gracefully', () => {
- const panelsWithUndefined = [
- {
- key: 'valid',
- label: 'Valid Panel',
- panel: Valid Panel
- }
- ]
-
- renderModal({ panels: panelsWithUndefined })
-
- expect(screen.getByTestId('valid-panel')).toBeInTheDocument()
+ expect(screen.getByTestId('general-panel')).toBeInTheDocument()
+ expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
})
})
})
diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap
index 4dca9d125c..503c319508 100644
--- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap
+++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/AdvancedSettingsPanel.test.tsx.snap
@@ -20,6 +20,48 @@ exports[`AdvancedSettingsPanel > basic rendering > should match snapshot 1`] = `
+
+
+ 文档预处理
+
+ settings.tool.preprocess.tooltip
+
+
+
+
+
+
+ 重排模型
+
+ models.rerank_model_tooltip
+
+
+
+
diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap
index 47cde08f96..73fc00da59 100644
--- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap
+++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap
@@ -34,40 +34,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
value="Test Knowledge Base"
/>
-
-
- settings.tool.preprocess.title
-
-
-
-
@@ -82,7 +48,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
-
-
- models.rerank_model
-
-
-
-
@@ -176,7 +105,7 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
max="50"
min="1"
step="1"
- style="width: 100%;"
+ style="width: 97%;"
type="range"
value="6"
/>
diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap
index 979ea42695..b859017188 100644
--- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap
+++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap
@@ -3,120 +3,44 @@
exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] = `
.c0 .ant-modal-title {
font-size: 14px;
+ font-weight: 500;
}
.c0 .ant-modal-close {
- top: 4px;
- right: 4px;
+ top: 8px;
+ right: 8px;
}
.c1 {
display: flex;
- height: 100%;
- border-right: 0.5px solid var(--color-border);
-}
-
-.c3 {
- flex: 1;
- padding: 16px 16px;
- overflow-y: scroll;
+ flex-direction: column;
}
.c2 {
- width: 200px;
- padding: 5px;
- background: transparent;
- margin-top: 2px;
- border-inline-end: none!important;
-}
-
-.c2 .ant-menu-item {
- height: 36px;
- color: var(--color-text-2);
display: flex;
+ justify-content: space-between;
align-items: center;
- border: 0.5px solid transparent;
- border-radius: 6px;
- margin-bottom: 7px;
-}
-
-.c2 .ant-menu-item .ant-menu-title-content {
- line-height: 36px;
-}
-
-.c2 .ant-menu-item-active {
- background-color: var(--color-background-soft)!important;
- transition: none;
-}
-
-.c2 .ant-menu-item-selected {
- background-color: var(--color-background-soft);
- border: 0.5px solid var(--color-border);
-}
-
-.c2 .ant-menu-item-selected .ant-menu-title-content {
- color: var(--color-text-1);
- font-weight: 500;
+ width: 100%;
}
-
-
- Knowledge Base Settings
-
-
-
-
-
-
- General Settings
-
-
- Advanced Settings
-
-
-
-
+
- General Settings Panel
+ General Settings Content
@@ -124,18 +48,42 @@ exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] =
-
-
+
+
+
+
+
+
+
+
`;
diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx
index d7e2fa30b3..4d2a19a1f5 100644
--- a/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx
+++ b/src/renderer/src/pages/knowledge/components/AddKnowledgeBasePopup.tsx
@@ -67,31 +67,38 @@ const PopupContainer: React.FC
= ({ title, resolve }) => {
const onCancel = () => {
setOpen(false)
- resolve(null)
}
const panelConfigs: PanelConfig[] = [
{
key: 'general',
label: t('settings.general.label'),
+ panel:
+ },
+ {
+ key: 'advanced',
+ label: t('settings.advanced.title'),
panel: (
-
)
- },
- {
- key: 'advanced',
- label: t('settings.advanced.title'),
- panel:
}
]
- return
+ return (
+ resolve(null)}
+ panels={panelConfigs}
+ />
+ )
}
export default class AddKnowledgeBasePopup {
diff --git a/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx b/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx
index a365927ffb..7a5c643903 100644
--- a/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx
+++ b/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx
@@ -101,27 +101,25 @@ const PopupContainer: React.FC = ({ base: _base, resolve })
const onCancel = () => {
setOpen(false)
- resolve(null)
}
const panelConfigs: PanelConfig[] = [
{
key: 'general',
label: t('settings.general.label'),
+ panel:
+ },
+ {
+ key: 'advanced',
+ label: t('settings.advanced.title'),
panel: (
-
)
- },
- {
- key: 'advanced',
- label: t('settings.advanced.title'),
- panel:
}
]
@@ -134,6 +132,7 @@ const PopupContainer: React.FC = ({ base: _base, resolve })
onCancel={onCancel}
afterClose={() => resolve(null)}
panels={panelConfigs}
+ defaultExpandAdvanced={true}
/>
)
}
diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx
index 788155b277..fef2e84ceb 100644
--- a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx
+++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/AdvancedSettingsPanel.tsx
@@ -1,6 +1,11 @@
import { InfoTooltip } from '@cherrystudio/ui'
-import type { KnowledgeBase } from '@renderer/types'
-import { Alert, InputNumber } from 'antd'
+import ModelSelector from '@renderer/components/ModelSelector'
+import { isRerankModel } from '@renderer/config/models'
+import { useProviders } from '@renderer/hooks/useProvider'
+import { getModelUniqId } from '@renderer/services/ModelService'
+import type { KnowledgeBase, PreprocessProvider } from '@renderer/types'
+import type { SelectProps } from 'antd'
+import { Alert, InputNumber, Select } from 'antd'
import { TriangleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -8,19 +13,66 @@ import { SettingsItem, SettingsPanel } from './styles'
interface AdvancedSettingsPanelProps {
newBase: KnowledgeBase
+ selectedDocPreprocessProvider?: PreprocessProvider
+ docPreprocessSelectOptions: SelectProps['options']
handlers: {
handleChunkSizeChange: (value: number | null) => void
handleChunkOverlapChange: (value: number | null) => void
handleThresholdChange: (value: number | null) => void
+ handleDocPreprocessChange: (value: string) => void
+ handleRerankModelChange: (value: string) => void
}
}
-const AdvancedSettingsPanel: React.FC = ({ newBase, handlers }) => {
+const AdvancedSettingsPanel: React.FC = ({
+ newBase,
+ selectedDocPreprocessProvider,
+ docPreprocessSelectOptions,
+ handlers
+}) => {
const { t } = useTranslation()
- const { handleChunkSizeChange, handleChunkOverlapChange, handleThresholdChange } = handlers
+ const { providers } = useProviders()
+ const {
+ handleChunkSizeChange,
+ handleChunkOverlapChange,
+ handleThresholdChange,
+ handleDocPreprocessChange,
+ handleRerankModelChange
+ } = handlers
return (
+
+
+ {t('settings.tool.preprocess.title')}
+
+
+
+
+
+
+
+ {t('models.rerank_model')}
+
+
+
+
+
{t('knowledge.chunk_size')}
diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx
index 892f239a38..11b6828808 100644
--- a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx
+++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx
@@ -2,12 +2,11 @@ import { InfoTooltip } from '@cherrystudio/ui'
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
import ModelSelector from '@renderer/components/ModelSelector'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
-import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
+import { isEmbeddingModel } from '@renderer/config/models'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
-import type { KnowledgeBase, PreprocessProvider } from '@renderer/types'
-import type { SelectProps } from 'antd'
-import { Input, Select, Slider } from 'antd'
+import type { KnowledgeBase } from '@renderer/types'
+import { Input, Slider } from 'antd'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsPanel } from './styles'
@@ -15,27 +14,16 @@ import { SettingsItem, SettingsPanel } from './styles'
interface GeneralSettingsPanelProps {
newBase: KnowledgeBase
setNewBase: React.Dispatch
>
- selectedDocPreprocessProvider?: PreprocessProvider
- docPreprocessSelectOptions: SelectProps['options']
handlers: {
handleEmbeddingModelChange: (value: string) => void
handleDimensionChange: (value: number | null) => void
- handleRerankModelChange: (value: string) => void
- handleDocPreprocessChange: (value: string) => void
}
}
-const GeneralSettingsPanel: React.FC = ({
- newBase,
- setNewBase,
- selectedDocPreprocessProvider,
- docPreprocessSelectOptions,
- handlers
-}) => {
+const GeneralSettingsPanel: React.FC = ({ newBase, setNewBase, handlers }) => {
const { t } = useTranslation()
const { providers } = useProviders()
- const { handleEmbeddingModelChange, handleDimensionChange, handleRerankModelChange, handleDocPreprocessChange } =
- handlers
+ const { handleEmbeddingModelChange, handleDimensionChange } = handlers
return (
@@ -48,21 +36,6 @@ const GeneralSettingsPanel: React.FC = ({
/>
-
-
- {t('settings.tool.preprocess.title')}
-
-
-
-
-
{t('models.embedding_model')}
@@ -91,29 +64,13 @@ const GeneralSettingsPanel: React.FC
= ({
/>
-
-
- {t('models.rerank_model')}
-
-
-
-
-
{t('knowledge.document_count')}
{
+interface KnowledgeBaseFormModalProps extends Omit {
panels: PanelConfig[]
+ onMoreSettings?: () => void
+ defaultExpandAdvanced?: boolean
}
-const KnowledgeBaseFormModal: React.FC = ({ panels, ...rest }) => {
- const [selectedMenu, setSelectedMenu] = useState(panels[0]?.key)
+const KnowledgeBaseFormModal: React.FC = ({
+ panels,
+ onMoreSettings,
+ defaultExpandAdvanced = false,
+ okText,
+ onOk,
+ onCancel,
+ ...rest
+}) => {
+ const { t } = useTranslation()
+ const [showAdvanced, setShowAdvanced] = useState(defaultExpandAdvanced)
- const menuItems = panels.map(({ key, label }) => ({ key, label }))
- const activePanel = panels.find((p) => p.key === selectedMenu)?.panel
+ const generalPanel = panels.find((p) => p.key === 'general')
+ const advancedPanel = panels.find((p) => p.key === 'advanced')
+
+ const footer = (
+
+
+ {advancedPanel && (
+
+ )}
+ {onMoreSettings && }
+
+
+
+
+
+
+ )
return (
= ({ panels,
maskClosable={false}
centered
transitionName="animation-move-down"
- width="min(900px, 65vw)"
+ width="min(500px, 60vw)"
styles={{
- body: { padding: 0, height: 550 },
+ body: { padding: '16px 8px', maxHeight: '70vh', overflowY: 'auto' },
header: {
- padding: '10px 15px',
+ padding: '12px 20px',
borderBottom: '0.5px solid var(--color-border)',
margin: 0,
borderRadius: 0
},
content: {
padding: 0,
- paddingBottom: 10,
overflow: 'hidden'
+ },
+ footer: {
+ padding: '12px 20px',
+ borderTop: '0.5px solid var(--color-border)',
+ margin: 0
}
}}
+ footer={footer}
+ okText={okText}
+ onOk={onOk}
+ onCancel={onCancel}
{...rest}>
-
-
- setSelectedMenu(key)}
- />
-
- {activePanel}
-
+
+ {/* General Settings */}
+ {generalPanel && {generalPanel.panel}
}
+
+ {/* Advanced Settings */}
+ {showAdvanced && advancedPanel && (
+
+ {advancedPanel.label}
+ {advancedPanel.panel}
+
+ )}
+
)
}
@@ -60,57 +102,38 @@ const KnowledgeBaseFormModal: React.FC = ({ panels,
const StyledModal = styled(Modal)`
.ant-modal-title {
font-size: 14px;
+ font-weight: 500;
}
.ant-modal-close {
- top: 4px;
- right: 4px;
+ top: 8px;
+ right: 8px;
}
`
-const LeftMenu = styled.div`
+const ContentContainer = styled.div`
display: flex;
- height: 100%;
- border-right: 0.5px solid var(--color-border);
+ flex-direction: column;
`
-const SettingsContentPanel = styled.div`
- flex: 1;
- padding: 16px 16px;
- overflow-y: scroll;
+const FooterContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
`
-const StyledMenu = styled(Menu)`
- width: 200px;
- padding: 5px;
- background: transparent;
- margin-top: 2px;
- border-inline-end: none !important;
+const AdvancedSettingsContainer = styled.div`
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 0.5px solid var(--color-border);
+`
- .ant-menu-item {
- height: 36px;
- color: var(--color-text-2);
- display: flex;
- align-items: center;
- border: 0.5px solid transparent;
- border-radius: 6px;
- margin-bottom: 7px;
-
- .ant-menu-title-content {
- line-height: 36px;
- }
- }
- .ant-menu-item-active {
- background-color: var(--color-background-soft) !important;
- transition: none;
- }
- .ant-menu-item-selected {
- background-color: var(--color-background-soft);
- border: 0.5px solid var(--color-border);
- .ant-menu-title-content {
- color: var(--color-text-1);
- font-weight: 500;
- }
- }
+const AdvancedSettingsTitle = styled.div`
+ font-weight: 500;
+ font-size: 14px;
+ color: var(--color-text-1);
+ margin-bottom: 16px;
+ padding: 0 16px;
`
export default KnowledgeBaseFormModal
diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx
index 3d362af0fb..0e9bba3ef2 100644
--- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx
+++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx
@@ -17,6 +17,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 type { AppInfo } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
@@ -91,7 +92,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'),
@@ -103,7 +110,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',
@@ -695,6 +702,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..35495d1130
--- /dev/null
+++ b/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx
@@ -0,0 +1,29 @@
+import { RowFlex } from '@cherrystudio/ui'
+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`)
+ })
+}
diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts
index 96153accd8..23dd3d23fc 100644
--- a/src/renderer/src/store/thunk/messageThunk.ts
+++ b/src/renderer/src/store/thunk/messageThunk.ts
@@ -586,9 +586,11 @@ const fetchAndProcessAgentResponseImpl = async (
return
}
+ // Only mark as cleared if there was a previous session ID (not initial assignment)
+ sessionWasCleared = !!latestAgentSessionId
+
latestAgentSessionId = sessionId
agentSession.agentSessionId = sessionId
- sessionWasCleared = true
logger.debug(`Agent session ID updated`, {
topicId,
diff --git a/yarn.lock b/yarn.lock
index 66dfdbe45b..f087d77d25 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1982,7 +1982,7 @@ __metadata:
peerDependencies:
"@ai-sdk/google": ^2.0.36
"@ai-sdk/openai": ^2.0.64
- "@cherrystudio/ai-sdk-provider": ^0.1.2
+ "@cherrystudio/ai-sdk-provider": ^0.1.3
ai: ^5.0.26
languageName: unknown
linkType: soft