Merge branch 'main' into v2

This commit is contained in:
fullex 2025-11-22 08:48:07 +08:00
commit cf7b4dd07b
51 changed files with 1940 additions and 793 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-sdk-provider",
"version": "0.1.2",
"version": "0.1.3",
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
"keywords": [
"ai-sdk",

View File

@ -67,6 +67,10 @@ export interface CherryInProviderSettings {
* Optional static headers applied to every request.
*/
headers?: HeadersInput
/**
* Optional endpoint type to distinguish different endpoint behaviors.
*/
endpointType?: 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank'
}
export interface CherryInProvider extends ProviderV2 {
@ -151,7 +155,8 @@ export const createCherryIn = (options: CherryInProviderSettings = {}): CherryIn
baseURL = DEFAULT_CHERRYIN_BASE_URL,
anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL,
geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL,
fetch
fetch,
endpointType
} = options
const getJsonHeaders = createJsonHeadersGetter(options)
@ -205,7 +210,7 @@ export const createCherryIn = (options: CherryInProviderSettings = {}): CherryIn
fetch
})
const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => {
const createChatModelByModelId = (modelId: string, settings: OpenAIProviderSettings = {}) => {
if (isAnthropicModel(modelId)) {
return createAnthropicModel(modelId)
}
@ -223,6 +228,29 @@ export const createCherryIn = (options: CherryInProviderSettings = {}): CherryIn
})
}
const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => {
if (!endpointType) return createChatModelByModelId(modelId, settings)
switch (endpointType) {
case 'anthropic':
return createAnthropicModel(modelId)
case 'gemini':
return createGeminiModel(modelId)
case 'openai':
return createOpenAIChatModel(modelId)
case 'openai-response':
default:
return new OpenAIResponsesLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.openai`,
url,
headers: () => ({
...getJsonHeaders(),
...settings.headers
}),
fetch
})
}
}
const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
new OpenAICompletionLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.completion`,

View File

@ -35,7 +35,7 @@
"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"
},
"dependencies": {

View File

@ -251,6 +251,7 @@ export enum IpcChannel {
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
System_CheckGitBash = 'system:checkGitBash',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',

View File

@ -104,12 +104,6 @@ const router = express
logger.warn('No models available from providers', { filter })
}
logger.info('Models response ready', {
filter,
total: response.total,
modelIds: response.data.map((m) => m.id)
})
return res.json(response satisfies ApiModelsResponse)
} catch (error: any) {
logger.error('Error fetching models', { error })

View File

@ -32,7 +32,7 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
logger.debug(`Processing model ${model.id}`)
// logger.debug(`Processing model ${model.id}`)
if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
continue

View File

@ -494,6 +494,44 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
ipcMain.handle(IpcChannel.System_CheckGitBash, () => {
if (!isWin) {
return true // Non-Windows systems don't need Git Bash
}
try {
// Check common Git Bash installation paths
const commonPaths = [
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe')
]
// Check if any of the common paths exist
for (const bashPath of commonPaths) {
if (fs.existsSync(bashPath)) {
logger.debug('Git Bash found', { path: bashPath })
return true
}
}
// Check if git is in PATH
const { execSync } = require('child_process')
try {
execSync('git --version', { stdio: 'ignore' })
logger.debug('Git found in PATH')
return true
} catch {
// Git not in PATH
}
logger.debug('Git Bash not found on Windows system')
return false
} catch (error) {
logger.error('Error checking Git Bash', error as Error)
return false
}
})
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()

View File

@ -21,6 +21,11 @@ describe('stripLocalCommandTags', () => {
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
})
it('if no tags present, returns original string', () => {
const input = 'just some normal text'
expect(stripLocalCommandTags(input)).toBe(input)
})
})
describe('Claude → AiSDK transform', () => {
@ -188,6 +193,111 @@ describe('Claude → AiSDK transform', () => {
expect(toolResult.output).toBe('ok')
})
it('handles tool calls without streaming events (no content_block_start/stop)', () => {
const state = new ClaudeStreamState({ agentSessionId: '12344' })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
{
...baseStreamMetadata,
type: 'assistant',
uuid: uuid(20),
message: {
id: 'msg-tool-no-stream',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'tool-read',
name: 'Read',
input: { file_path: '/test.txt' }
},
{
type: 'tool_use',
id: 'tool-bash',
name: 'Bash',
input: { command: 'ls -la' }
}
],
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'user',
uuid: uuid(21),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-read',
content: 'file contents',
is_error: false
}
]
}
} as SDKMessage,
{
...baseStreamMetadata,
type: 'user',
uuid: uuid(22),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-bash',
content: 'total 42\n...',
is_error: false
}
]
}
} as SDKMessage
]
for (const message of messages) {
const transformed = transformSDKMessageToStreamParts(message, state)
parts.push(...transformed)
}
const types = parts.map((part) => part.type)
expect(types).toEqual(['tool-call', 'tool-call', 'tool-result', 'tool-result'])
const toolCalls = parts.filter((part) => part.type === 'tool-call') as Extract<
(typeof parts)[number],
{ type: 'tool-call' }
>[]
expect(toolCalls).toHaveLength(2)
expect(toolCalls[0].toolName).toBe('Read')
expect(toolCalls[0].toolCallId).toBe('12344:tool-read')
expect(toolCalls[1].toolName).toBe('Bash')
expect(toolCalls[1].toolCallId).toBe('12344:tool-bash')
const toolResults = parts.filter((part) => part.type === 'tool-result') as Extract<
(typeof parts)[number],
{ type: 'tool-result' }
>[]
expect(toolResults).toHaveLength(2)
// This is the key assertion - toolName should NOT be 'unknown'
expect(toolResults[0].toolName).toBe('Read')
expect(toolResults[0].toolCallId).toBe('12344:tool-read')
expect(toolResults[0].input).toEqual({ file_path: '/test.txt' })
expect(toolResults[0].output).toBe('file contents')
expect(toolResults[1].toolName).toBe('Bash')
expect(toolResults[1].toolCallId).toBe('12344:tool-bash')
expect(toolResults[1].input).toEqual({ command: 'ls -la' })
expect(toolResults[1].output).toBe('total 42\n...')
})
it('handles streaming text completion', () => {
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
@ -300,4 +410,87 @@ describe('Claude → AiSDK transform', () => {
expect(finishStep.finishReason).toBe('stop')
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
})
it('emits fallback text when Claude sends a snapshot instead of deltas', () => {
const state = new ClaudeStreamState({ agentSessionId: '12344' })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
{
...baseStreamMetadata,
type: 'stream_event',
uuid: uuid(30),
event: {
type: 'message_start',
message: {
id: 'msg-fallback',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [],
stop_reason: null,
stop_sequence: null,
usage: {}
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'stream_event',
uuid: uuid(31),
event: {
type: 'content_block_start',
index: 0,
content_block: {
type: 'text',
text: ''
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'assistant',
uuid: uuid(32),
message: {
id: 'msg-fallback-content',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'text',
text: 'Final answer without streaming deltas.'
}
],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 3,
output_tokens: 7
}
}
} as unknown as SDKMessage
]
for (const message of messages) {
const transformed = transformSDKMessageToStreamParts(message, state)
parts.push(...transformed)
}
const types = parts.map((part) => part.type)
expect(types).toEqual(['start-step', 'text-start', 'text-delta', 'text-end', 'finish-step'])
const delta = parts.find((part) => part.type === 'text-delta') as Extract<
(typeof parts)[number],
{ type: 'text-delta' }
>
expect(delta.text).toBe('Final answer without streaming deltas.')
const finish = parts.find((part) => part.type === 'finish-step') as Extract<
(typeof parts)[number],
{ type: 'finish-step' }
>
expect(finish.usage).toEqual({ inputTokens: 3, outputTokens: 7, totalTokens: 10 })
expect(finish.finishReason).toBe('stop')
})
})

View File

@ -153,6 +153,20 @@ export class ClaudeStreamState {
return this.blocksByIndex.get(index)
}
getFirstOpenTextBlock(): TextBlockState | undefined {
const candidates: TextBlockState[] = []
for (const block of this.blocksByIndex.values()) {
if (block.kind === 'text') {
candidates.push(block)
}
}
if (candidates.length === 0) {
return undefined
}
candidates.sort((a, b) => a.index - b.index)
return candidates[0]
}
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
const index = this.toolIndexByNamespacedId.get(toolCallId)
if (index === undefined) return undefined
@ -217,10 +231,10 @@ export class ClaudeStreamState {
* Persists the final input payload for a tool block once the provider signals
* completion so that downstream tool results can reference the original call.
*/
completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void {
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
const block = this.getToolBlockByRawId(toolCallId)
this.registerToolCall(toolCallId, {
toolName: block?.toolName ?? 'unknown',
toolName,
input,
providerMetadata
})

View File

@ -414,23 +414,6 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
if (message.type === 'assistant' || message.type === 'user') {
logger.silly('claude response', {
message,
content: JSON.stringify(message.message.content)
})
} else if (message.type === 'stream_event') {
// logger.silly('Claude stream event', {
// message,
// event: JSON.stringify(message.event)
// })
} else {
logger.silly('Claude response', {
message,
event: JSON.stringify(message)
})
}
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {

View File

@ -110,7 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
* blocks across calls so that incremental deltas can be correlated correctly.
*/
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
logger.silly('Transforming SDKMessage', { message: sdkMessage })
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@ -186,14 +186,13 @@ function handleAssistantMessage(
for (const block of content) {
switch (block.type) {
case 'text':
if (!isStreamingActive) {
case 'text': {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
}
break
}
case 'tool_use':
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
break
@ -203,7 +202,16 @@ function handleAssistantMessage(
}
}
if (!isStreamingActive && textBlocks.length > 0) {
if (textBlocks.length === 0) {
return chunks
}
const combinedText = textBlocks.join('')
if (!combinedText) {
return chunks
}
if (!isStreamingActive) {
const id = message.uuid?.toString() || generateMessageId()
state.beginStep()
chunks.push({
@ -219,7 +227,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id,
text: textBlocks.join(''),
text: combinedText,
providerMetadata
})
chunks.push({
@ -230,7 +238,27 @@ function handleAssistantMessage(
return finalizeNonStreamingStep(message, state, chunks)
}
return chunks
const existingTextBlock = state.getFirstOpenTextBlock()
const fallbackId = existingTextBlock?.id || message.uuid?.toString() || generateMessageId()
if (!existingTextBlock) {
chunks.push({
type: 'text-start',
id: fallbackId,
providerMetadata
})
}
chunks.push({
type: 'text-delta',
id: fallbackId,
text: combinedText,
providerMetadata
})
chunks.push({
type: 'text-end',
id: fallbackId,
providerMetadata
})
return finalizeNonStreamingStep(message, state, chunks)
}
/**
@ -252,7 +280,7 @@ function handleAssistantToolUse(
providerExecuted: true,
providerMetadata
})
state.completeToolBlock(block.id, block.input, providerMetadata)
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
}
/**
@ -459,6 +487,9 @@ function handleStreamEvent(
}
case 'message_stop': {
if (!state.hasActiveStep()) {
break
}
const pending = state.getPendingUsage()
chunks.push({
type: 'finish-step',

View File

@ -127,7 +127,8 @@ const api = {
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName),
checkGitBash: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.System_CheckGitBash)
},
devTools: {
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)

View File

@ -228,6 +228,13 @@ export function providerToAiSdkConfig(
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
}
// cherryin
if (aiSdkProviderId === 'cherryin') {
if (model.endpoint_type) {
extraOptions.endpointType = model.endpoint_type
}
}
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {

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

@ -15,7 +15,7 @@ import type {
UpdateAgentForm
} from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { Button, Input, Modal, Select } from 'antd'
import { Alert, Button, Input, Modal, Select } from 'antd'
import { AlertTriangleIcon } from 'lucide-react'
import type { ChangeEvent, FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -58,6 +58,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
const [hasGitBash, setHasGitBash] = useState<boolean>(true)
useEffect(() => {
if (open) {
@ -65,6 +66,30 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
}
}, [agent, open])
const checkGitBash = useCallback(
async (showToast = false) => {
try {
const gitBashInstalled = await window.api.system.checkGitBash()
setHasGitBash(gitBashInstalled)
if (showToast) {
if (gitBashInstalled) {
window.toast.success(t('agent.gitBash.success', 'Git Bash detected successfully!'))
} else {
window.toast.error(t('agent.gitBash.notFound', 'Git Bash not found. Please install it first.'))
}
}
} catch (error) {
logger.error('Failed to check Git Bash:', error as Error)
setHasGitBash(true) // Default to true on error to avoid false warnings
}
},
[t]
)
useEffect(() => {
checkGitBash()
}, [checkGitBash])
const selectedPermissionMode = form.configuration?.permission_mode ?? 'default'
const onPermissionModeChange = useCallback((value: PermissionMode) => {
@ -275,6 +300,36 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
footer={null}>
<StyledForm onSubmit={onSubmit}>
<FormContent>
{!hasGitBash && (
<Alert
message={t('agent.gitBash.error.title', 'Git Bash Required')}
description={
<div>
<div style={{ marginBottom: 8 }}>
{t(
'agent.gitBash.error.description',
'Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from'
)}{' '}
<a
href="https://git-scm.com/download/win"
onClick={(e) => {
e.preventDefault()
window.api.openWebsite('https://git-scm.com/download/win')
}}
style={{ textDecoration: 'underline' }}>
git-scm.com
</a>
</div>
<Button size="small" onClick={() => checkGitBash(true)}>
{t('agent.gitBash.error.recheck', 'Recheck Git Bash Installation')}
</Button>
</div>
}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>
@ -377,7 +432,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<FormFooter>
<Button onClick={onCancel}>{t('common.close')}</Button>
<Button type="primary" htmlType="submit" loading={loadingRef.current}>
<Button type="primary" htmlType="submit" loading={loadingRef.current} disabled={!hasGitBash}>
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</FormFooter>

View File

@ -1,11 +1,31 @@
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useApiServer')
// Module-level single instance subscription to prevent EventEmitter memory leak
// Only one IPC listener will be registered regardless of how many components use this hook
const onReadyCallbacks = new Set<() => void>()
let removeIpcListener: (() => void) | null = null
const ensureIpcSubscribed = () => {
if (!removeIpcListener) {
removeIpcListener = window.api.apiServer.onReady(() => {
onReadyCallbacks.forEach((cb) => cb())
})
}
}
const cleanupIpcIfEmpty = () => {
if (onReadyCallbacks.size === 0 && removeIpcListener) {
removeIpcListener()
removeIpcListener = null
}
}
export const useApiServer = () => {
const { t } = useTranslation()
// FIXME: We currently store two copies of the config data in both the renderer and the main processes,
@ -102,15 +122,28 @@ export const useApiServer = () => {
checkApiServerStatus()
}, [checkApiServerStatus])
// Listen for API server ready event
// Use ref to keep the latest checkApiServerStatus without causing re-subscription
const checkStatusRef = useRef(checkApiServerStatus)
useEffect(() => {
const cleanup = window.api.apiServer.onReady(() => {
logger.info('API server ready event received, checking status')
checkApiServerStatus()
checkStatusRef.current = checkApiServerStatus
})
return cleanup
}, [checkApiServerStatus])
// Create stable callback for the single instance subscription
const handleReady = useCallback(() => {
logger.info('API server ready event received, checking status')
checkStatusRef.current()
}, [])
// Listen for API server ready event using single instance subscription
useEffect(() => {
ensureIpcSubscribed()
onReadyCallbacks.add(handleReady)
return () => {
onReadyCallbacks.delete(handleReady)
cleanupIpcIfEmpty()
}
}, [handleReady])
return {
apiServerConfig,

View File

@ -27,6 +27,15 @@
"null_id": "Agent ID is null."
}
},
"gitBash": {
"error": {
"description": "Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "Recheck Git Bash Installation",
"title": "Git Bash Required"
},
"notFound": "Git Bash not found. Please install it first.",
"success": "Git Bash detected successfully!"
},
"input": {
"placeholder": "Enter your message here, send with {{key}} - @ select path, / select command"
},
@ -1376,6 +1385,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 +3124,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 +3183,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

@ -27,6 +27,15 @@
"null_id": "智能体 ID 为空。"
}
},
"gitBash": {
"error": {
"description": "在 Windows 上运行智能体需要 Git Bash。没有它智能体无法运行。请从以下地址安装 Git for Windows",
"recheck": "重新检测 Git Bash 安装",
"title": "需要 Git Bash"
},
"notFound": "未找到 Git Bash。请先安装。",
"success": "成功检测到 Git Bash"
},
"input": {
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择路径, / 选择命令"
},
@ -1376,6 +1385,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 +3124,7 @@
"basic": "基础数据设置",
"cloud_storage": "云备份设置",
"export_settings": "导出设置",
"import_settings": "导入设置",
"third_party": "第三方连接"
},
"export_menu": {
@ -3143,6 +3183,11 @@
},
"hour_interval_one": "{{count}} 小时",
"hour_interval_other": "{{count}} 小时",
"import_settings": {
"button": "导入文件",
"chatgpt": "导入 ChatGPT 数据",
"title": "导入外部应用数据"
},
"joplin": {
"check": {
"button": "检测",

View File

@ -27,6 +27,15 @@
"null_id": "代理程式 ID 為空。"
}
},
"gitBash": {
"error": {
"description": "在 Windows 上執行代理程式需要 Git Bash。沒有它代理程式無法運作。請從以下地址安裝 Git for Windows",
"recheck": "重新檢測 Git Bash 安裝",
"title": "需要 Git Bash"
},
"notFound": "找不到 Git Bash。請先安裝。",
"success": "成功偵測到 Git Bash"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},
@ -1376,6 +1385,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 +3124,7 @@
"basic": "基礎數據設定",
"cloud_storage": "雲備份設定",
"export_settings": "匯出設定",
"import_settings": "匯入設定",
"third_party": "第三方連接"
},
"export_menu": {
@ -3143,6 +3183,11 @@
},
"hour_interval_one": "{{count}} 小時",
"hour_interval_other": "{{count}} 小時",
"import_settings": {
"button": "匯入 Json 檔案",
"chatgpt": "匯入 ChatGPT 數據",
"title": "匯入外部應用程式數據"
},
"joplin": {
"check": {
"button": "檢查",

View File

@ -27,6 +27,15 @@
"null_id": "Agent ID ist leer."
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -27,6 +27,15 @@
"null_id": "Το ID του πράκτορα είναι null."
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -27,6 +27,15 @@
"null_id": "El ID del agente es nulo."
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -27,6 +27,15 @@
"null_id": "L'ID de l'agent est nul."
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -27,6 +27,15 @@
"null_id": "エージェント ID が null です。"
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -27,6 +27,15 @@
"null_id": "O ID do agente é nulo."
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -27,6 +27,15 @@
"null_id": "ID агента равен null."
}
},
"gitBash": {
"error": {
"description": "[to be translated]:Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from",
"recheck": "[to be translated]:Recheck Git Bash Installation",
"title": "[to be translated]:Git Bash Required"
},
"notFound": "[to be translated]:Git Bash not found. Please install it first.",
"success": "[to be translated]:Git Bash detected successfully!"
},
"input": {
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
},

View File

@ -1,7 +1,6 @@
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
import { ToolTitle } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
@ -16,37 +15,23 @@ interface ParsedBashOutput {
tool_use_error?: string
}
export function BashOutputTool({
input,
output
}: {
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 解析 XML 输出
const parsedOutput = useMemo(() => {
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
if (!output) return null
try {
const parser = new DOMParser()
// 检查是否包含 tool_use_error 标签
const hasToolError = output.includes('<tool_use_error>')
// 包装成有效的 XML如果还没有根元素
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
// 检查是否有解析错误
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) {
return null
}
if (parserError) return null
const getElementText = (tagName: string): string | undefined => {
const element = xmlDoc.getElementsByTagName(tagName)[0]
return element?.textContent?.trim()
}
const result: ParsedBashOutput = {
return {
status: getElementText('status'),
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
stdout: getElementText('stdout'),
@ -54,18 +39,14 @@ export function BashOutputTool({
timestamp: getElementText('timestamp'),
tool_use_error: getElementText('tool_use_error')
}
return result
} catch {
return null
}
}, [output])
}
// 获取状态配置
const statusConfig = useMemo(() => {
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
if (!parsedOutput) return null
// 如果有 tool_use_error直接显示错误状态
if (parsedOutput.tool_use_error) {
return {
color: 'danger',
@ -89,7 +70,17 @@ export function BashOutputTool({
),
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
} as const
}, [parsedOutput])
}
export function BashOutputTool({
input,
output
}: {
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const parsedOutput = parseBashOutput(output)
const statusConfig = getStatusConfig(parsedOutput)
const children = parsedOutput ? (
<div className="flex flex-col gap-4">

View File

@ -1,12 +1,47 @@
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
const removeSystemReminderTags = (text: string): string => {
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
if (!output) return null
const toText = (item: TextOutput) => removeSystemReminderTags(item.text)
if (Array.isArray(output)) {
return output
.filter((item): item is TextOutput => item.type === 'text')
.map(toText)
.join('')
}
return removeSystemReminderTags(output)
}
const getOutputStats = (outputString: string | null) => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (size: number) => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}
export function ReadTool({
input,
output
@ -14,50 +49,8 @@ export function ReadTool({
input: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 移除 system-reminder 标签及其内容的辅助函数
const removeSystemReminderTags = (text: string): string => {
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
let processedOutput: string
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
processedOutput = output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => removeSystemReminderTags(item.text))
.join('')
} else {
// 如果是字符串,直接使用
processedOutput = output
}
// 移除 system-reminder 标签及其内容
return removeSystemReminderTags(processedOutput)
}, [output])
// 如果有输出,计算统计信息
const stats = useMemo(() => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}, [outputString])
const outputString = normalizeOutputString(output)
const stats = getOutputStats(outputString)
return {
key: AgentToolsType.Read,

View File

@ -11,11 +11,24 @@ interface UnknownToolProps {
output?: unknown
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
}
const getToolDescription = (toolName: string) => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
@ -34,33 +47,11 @@ export function UnknownToolRenderer({
}
}, [output, highlightCode])
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
if (input === undefined && output === undefined) {
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
}
const getToolDescription = () => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
return {
key: 'unknown-tool',
label: (
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription()}
/>
),
children: (
return (
<div className="space-y-3">
{input !== undefined && (
<div>
@ -81,11 +72,24 @@ export function UnknownToolRenderer({
/>
</div>
)}
{input === undefined && output === undefined && (
<div className="text-foreground-500 text-xs">No data available for this tool</div>
)}
</div>
)
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
return {
key: 'unknown-tool',
label: (
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription(toolName)}
/>
),
children: <UnknownToolContent input={input} output={output} />
}
}

View File

@ -6,8 +6,6 @@ import { Collapse } from 'antd'
// 导出所有类型
export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
@ -57,22 +55,19 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染函数
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
// 统一的渲染组件
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
const Renderer = toolRenderers[toolName]
// eslint-disable-next-line react-hooks/rules-of-hooks
const toolContentItem = useMemo(() => {
const rendered = Renderer
const renderedItem = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
return {
...rendered,
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
...renderedItem,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
} as NonNullable<CollapseProps['items']>[number]['classNames']
} as NonNullable<CollapseProps['items']>[number]
}, [Renderer, input, output, toolName])
}
}
return (
<Collapse
@ -98,5 +93,7 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
return (
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
)
}

View File

@ -12,7 +12,10 @@ const mocks = vi.hoisted(() => {
'knowledge.chunk_size': '分块大小',
'knowledge.chunk_overlap': '分块重叠',
'knowledge.threshold': '检索相似度阈值',
'knowledge.chunk_size_change_warning': '避免修改这个高级设置。'
'knowledge.chunk_size_change_warning': '避免修改这个高级设置。',
'settings.tool.preprocess.title': '文档预处理',
'models.rerank_model': '重排模型',
'settings.models.empty': '未选择'
}
return translations[k] || k
}
@ -20,7 +23,9 @@ const mocks = vi.hoisted(() => {
handlers: {
handleChunkSizeChange: vi.fn(),
handleChunkOverlapChange: vi.fn(),
handleThresholdChange: vi.fn()
handleThresholdChange: vi.fn(),
handleDocPreprocessChange: vi.fn(),
handleRerankModelChange: vi.fn()
}
}
})
@ -56,9 +61,39 @@ vi.mock('antd', () => ({
disabled={disabled}
style={style}
/>
),
Select: ({ value, onChange, options, placeholder }: any) => (
<select value={value} onChange={(e) => onChange(e.target.value)} data-testid="select">
<option value="">{placeholder}</option>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
}))
vi.mock('@renderer/components/ModelSelector', () => ({
default: ({ value, onChange, placeholder }: any) => (
<select value={value} onChange={(e) => onChange(e.target.value)} data-testid="model-selector">
<option value="">{placeholder}</option>
</select>
)
}))
vi.mock('@renderer/hooks/useProvider', () => ({
useProviders: () => ({ providers: [] })
}))
vi.mock('@renderer/services/ModelService', () => ({
getModelUniqId: (model: any) => model?.id || ''
}))
vi.mock('@renderer/config/models', () => ({
isRerankModel: () => true
}))
/**
* KnowledgeBase
* @param overrides
@ -94,7 +129,9 @@ describe('AdvancedSettingsPanel', () => {
describe('basic rendering', () => {
it('should match snapshot', () => {
const { container } = render(<AdvancedSettingsPanel newBase={mockBase} handlers={mocks.handlers} />)
const { container } = render(
<AdvancedSettingsPanel newBase={mockBase} handlers={mocks.handlers} docPreprocessSelectOptions={[]} />
)
expect(container.firstChild).toMatchSnapshot()
})
@ -102,7 +139,7 @@ describe('AdvancedSettingsPanel', () => {
describe('handlers', () => {
it('should call handlers when values are changed', () => {
render(<AdvancedSettingsPanel newBase={mockBase} handlers={mocks.handlers} />)
render(<AdvancedSettingsPanel newBase={mockBase} handlers={mocks.handlers} docPreprocessSelectOptions={[]} />)
const chunkSizeInput = screen.getByLabelText('分块大小')
fireEvent.change(chunkSizeInput, { target: { value: '600' } })

View File

@ -1,4 +1,4 @@
import type { KnowledgeBase, Model, PreprocessProvider } from '@renderer/types'
import type { KnowledgeBase, Model } from '@renderer/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -24,9 +24,7 @@ const mocks = vi.hoisted(() => ({
],
handlers: {
handleEmbeddingModelChange: vi.fn(),
handleDimensionChange: vi.fn(),
handleRerankModelChange: vi.fn(),
handleDocPreprocessChange: vi.fn()
handleDimensionChange: vi.fn()
}
}))
@ -41,11 +39,7 @@ vi.mock('@cherrystudio/ui', () => ({
// Mock ModelSelector component
vi.mock('@renderer/components/ModelSelector', () => ({
default: ({ value, onChange, placeholder, allowClear, providers, predicate }: any) => {
// Determine if this is for embedding or rerank models based on predicate
const isEmbedding = predicate?.toString().includes('embedding')
const isRerank = predicate?.toString().includes('rerank')
default: ({ value, onChange, placeholder, allowClear, providers }: any) => {
// Use providers parameter to avoid lint error
const hasProviders = providers && providers.length > 0
@ -56,21 +50,10 @@ vi.mock('@renderer/components/ModelSelector', () => ({
onChange={(e) => onChange?.(e.target.value)}
data-placeholder={placeholder}
data-allow-clear={allowClear}
data-model-type={isEmbedding ? 'embedding' : isRerank ? 'rerank' : 'unknown'}
data-has-providers={hasProviders}>
<option value="">Select model</option>
{isEmbedding && (
<>
<option value="openai/text-embedding-3-small">text-embedding-3-small</option>
<option value="openai/text-embedding-ada-002">text-embedding-ada-002</option>
</>
)}
{isRerank && (
<>
<option value="openai/rerank-model">rerank-model</option>
<option value="cohere/rerank-english-v2.0">rerank-english-v2.0</option>
</>
)}
</select>
)
}
@ -102,8 +85,7 @@ vi.mock('@renderer/services/ModelService', () => ({
// Mock model predicates
vi.mock('@renderer/config/models', () => ({
isEmbeddingModel: (model: Model) => model.group === 'embedding',
isRerankModel: (model: Model) => model.group === 'rerank'
isEmbeddingModel: (model: Model) => model.group === 'embedding'
}))
// Mock constant
@ -121,22 +103,6 @@ vi.mock('antd', () => ({
Input: ({ value, onChange, placeholder }: any) => (
<input data-testid="name-input" value={value} onChange={onChange} placeholder={placeholder} />
),
Select: ({ value, onChange, placeholder, options, allowClear, children }: any) => (
<select
data-testid="preprocess-select"
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
data-placeholder={placeholder}
data-allow-clear={allowClear}>
<option value="">Select option</option>
{options?.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
{children}
</select>
),
Slider: ({ value, onChange, min, max, step, marks, style }: any) => {
// Determine test ID based on slider characteristics
const isWeightSlider = min === 0 && max === 1 && step === 0.1
@ -183,40 +149,14 @@ function createKnowledgeBase(overrides: Partial<KnowledgeBase> = {}): KnowledgeB
}
}
/**
* PreprocessProvider
* @param overrides -
* @returns PreprocessProvider
*/
function createPreprocessProvider(overrides: Partial<PreprocessProvider> = {}): PreprocessProvider {
return {
id: 'doc2x',
name: 'Doc2X',
apiKey: 'test-api-key',
...overrides
}
}
describe('GeneralSettingsPanel', () => {
const mockBase = createKnowledgeBase()
const mockSetNewBase = vi.fn()
const mockSelectedDocPreprocessProvider = createPreprocessProvider()
const mockDocPreprocessSelectOptions = [
{ value: 'doc2x', label: 'Doc2X' },
{ value: 'mistral', label: 'Mistral' }
]
// 提取公共渲染函数
const renderComponent = (props: Partial<any> = {}) => {
return render(
<GeneralSettingsPanel
newBase={mockBase}
setNewBase={mockSetNewBase}
selectedDocPreprocessProvider={mockSelectedDocPreprocessProvider}
docPreprocessSelectOptions={mockDocPreprocessSelectOptions}
handlers={mocks.handlers}
{...props}
/>
<GeneralSettingsPanel newBase={mockBase} setNewBase={mockSetNewBase} handlers={mocks.handlers} {...props} />
)
}
@ -229,17 +169,6 @@ describe('GeneralSettingsPanel', () => {
const { container } = renderComponent()
expect(container.firstChild).toMatchSnapshot()
})
it('should render without selectedDocPreprocessProvider', () => {
renderComponent({ selectedDocPreprocessProvider: undefined })
expect(screen.getByTestId('preprocess-select')).toHaveValue('')
})
it('should render with empty docPreprocessSelectOptions', () => {
renderComponent({ docPreprocessSelectOptions: [] })
const preprocessSelect = screen.getByTestId('preprocess-select')
expect(preprocessSelect.children).toHaveLength(1)
})
})
describe('functionality', () => {
@ -254,29 +183,14 @@ describe('GeneralSettingsPanel', () => {
expect(mockSetNewBase).toHaveBeenCalledWith(expect.any(Function))
})
it('should handle preprocess provider change', async () => {
renderComponent()
const preprocessSelect = screen.getByTestId('preprocess-select')
await user.selectOptions(preprocessSelect, 'mistral')
expect(mocks.handlers.handleDocPreprocessChange).toHaveBeenCalledWith('mistral')
})
it('should handle model selection changes', async () => {
renderComponent()
const modelSelectors = screen.getAllByTestId('model-selector')
const modelSelector = screen.getByTestId('model-selector')
// Test embedding model change
const embeddingModelSelector = modelSelectors[0]
await user.selectOptions(embeddingModelSelector, 'openai/text-embedding-ada-002')
await user.selectOptions(modelSelector, 'openai/text-embedding-ada-002')
expect(mocks.handlers.handleEmbeddingModelChange).toHaveBeenCalledWith('openai/text-embedding-ada-002')
// Test rerank model change
const rerankModelSelector = modelSelectors[1]
await user.selectOptions(rerankModelSelector, 'openai/rerank-model')
expect(mocks.handlers.handleRerankModelChange).toHaveBeenCalledWith('openai/rerank-model')
})
it('should handle dimension change', async () => {

View File

@ -1,5 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PanelConfig } from '../components/KnowledgeSettings/KnowledgeBaseFormModal'
@ -8,7 +7,22 @@ import KnowledgeBaseFormModal from '../components/KnowledgeSettings/KnowledgeBas
// Mock dependencies
const mocks = vi.hoisted(() => ({
onCancel: vi.fn(),
onOk: vi.fn()
onOk: vi.fn(),
onMoreSettings: vi.fn(),
t: vi.fn((key: string) => key)
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mocks.t
})
}))
// Mock lucide-react
vi.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="chevron-down"></span>,
ChevronUp: () => <span data-testid="chevron-up"></span>
}))
// Mock HStack component
@ -22,83 +36,34 @@ vi.mock('@cherrystudio/ui', () => ({
// Mock antd components
vi.mock('antd', () => ({
Modal: ({ children, open, title, onCancel, onOk, ...props }: any) =>
Modal: ({ children, open, footer, ...props }: any) =>
open ? (
<div data-testid="modal" data-title={title} {...props}>
<div data-testid="modal-header">
<span>{title}</span>
<button type="button" data-testid="modal-close" onClick={onCancel}>
×
</button>
</div>
<div data-testid="modal" {...props}>
<div data-testid="modal-body">{children}</div>
<div data-testid="modal-footer">
<button type="button" data-testid="modal-cancel" onClick={onCancel}>
Cancel
</button>
<button type="button" data-testid="modal-ok" onClick={onOk}>
OK
</button>
</div>
{footer && <div data-testid="modal-footer">{footer}</div>}
</div>
) : null,
Menu: ({ items, defaultSelectedKeys, onSelect, ...props }: any) => (
<div data-testid="menu" data-default-selected={defaultSelectedKeys?.[0]} {...props}>
{items?.map((item: any) => (
<div
key={item.key}
data-testid={`menu-item-${item.key}`}
onClick={() => onSelect?.({ key: item.key })}
style={{ cursor: 'pointer' }}>
{item.label}
</div>
))}
</div>
Button: ({ children, onClick, icon, type, ...props }: any) => (
<button type="button" data-testid="button" data-type={type} onClick={onClick} {...props}>
{icon}
{children}
</button>
)
}))
/**
*
* @param overrides
* @returns PanelConfig
*/
function createPanelConfigs(overrides: Partial<PanelConfig>[] = []): PanelConfig[] {
const defaultPanels: PanelConfig[] = [
const createPanelConfigs = (): PanelConfig[] => [
{
key: 'general',
label: 'General Settings',
panel: <div data-testid="general-panel">General Settings Panel</div>
panel: <div data-testid="general-panel">General Settings Content</div>
},
{
key: 'advanced',
label: 'Advanced Settings',
panel: <div data-testid="advanced-panel">Advanced Settings Panel</div>
panel: <div data-testid="advanced-panel">Advanced Settings Content</div>
}
]
return defaultPanels.map((panel, index) => ({
...panel,
...overrides[index]
}))
}
/**
* KnowledgeBaseFormModal
* @param props
* @returns render
*/
function renderModal(props: Partial<any> = {}) {
const defaultProps = {
open: true,
title: 'Knowledge Base Settings',
panels: createPanelConfigs(),
onCancel: mocks.onCancel,
onOk: mocks.onOk
}
return render(<KnowledgeBaseFormModal {...defaultProps} {...props} />)
}
describe('KnowledgeBaseFormModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -106,131 +71,128 @@ describe('KnowledgeBaseFormModal', () => {
describe('basic rendering', () => {
it('should match snapshot', () => {
const { container } = renderModal()
const { container } = render(
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
)
expect(container.firstChild).toMatchSnapshot()
})
it('should render modal when open is true', () => {
renderModal({ open: true })
render(
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
)
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(
<KnowledgeBaseFormModal
panels={createPanelConfigs()}
open={false}
onOk={mocks.onOk}
onCancel={mocks.onCancel}
/>
)
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
})
it('should render general panel by default', () => {
render(
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
)
expect(screen.getByTestId('general-panel')).toBeInTheDocument()
})
it('should not render advanced panel by default', () => {
render(
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
)
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(
<KnowledgeBaseFormModal
panels={createPanelConfigs()}
open={true}
onOk={mocks.onOk}
onCancel={mocks.onCancel}
defaultExpandAdvanced={true}
/>
)
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 })
const menu = screen.getByTestId('menu')
expect(menu).toHaveAttribute('data-default-selected', panels[0].key)
})
it('should handle menu selection with custom panels', () => {
const customPanels: PanelConfig[] = [
{
key: 'custom1',
label: 'Custom Panel 1',
panel: <div data-testid="custom1-panel">Custom Panel 1</div>
},
{
key: 'custom2',
label: 'Custom Panel 2',
panel: <div data-testid="custom2-panel">Custom Panel 2</div>
describe('advanced settings toggle', () => {
it('should toggle advanced panel visibility', () => {
render(
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
)
// Initially, advanced panel should not be visible
expect(screen.queryByTestId('advanced-panel')).not.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)
}
]
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()
})
})
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(
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
)
const buttonsWithout = screen.getAllByTestId('button')
const modal = screen.getByTestId('modal')
expect(modal).toHaveAttribute('data-title', customTitle)
})
rerender(
<KnowledgeBaseFormModal
panels={createPanelConfigs()}
open={true}
onOk={mocks.onOk}
onCancel={mocks.onCancel}
onMoreSettings={mocks.onMoreSettings}
/>
)
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(<KnowledgeBaseFormModal panels={[]} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />)
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: <div data-testid="only-panel">Only Panel</div>
key: 'general',
label: 'General Settings',
panel: <div data-testid="general-panel">General Settings Content</div>
}
]
renderModal({ panels: singlePanel })
render(<KnowledgeBaseFormModal panels={singlePanel} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />)
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: <div data-testid="valid-panel">Valid Panel</div>
}
]
renderModal({ panels: panelsWithUndefined })
expect(screen.getByTestId('valid-panel')).toBeInTheDocument()
expect(screen.getByTestId('general-panel')).toBeInTheDocument()
expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
})
})
})

View File

@ -20,6 +20,48 @@ exports[`AdvancedSettingsPanel > basic rendering > should match snapshot 1`] = `
<div
class="c0"
>
<div
class="c1"
>
<div
class="settings-label"
>
文档预处理
<div>
settings.tool.preprocess.tooltip
</div>
</div>
<select
data-testid="select"
>
<option
value=""
>
settings.tool.preprocess.provider_placeholder
</option>
</select>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
重排模型
<div>
models.rerank_model_tooltip
</div>
</div>
<select
data-testid="model-selector"
>
<option
value=""
>
未选择
</option>
</select>
</div>
<div
class="c1"
>

View File

@ -34,40 +34,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
value="Test Knowledge Base"
/>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
settings.tool.preprocess.title
<span
data-placement="right"
data-testid="info-tooltip"
/>
</div>
<select
data-allow-clear="true"
data-placeholder="settings.tool.preprocess.provider_placeholder"
data-testid="preprocess-select"
>
<option
value=""
>
Select option
</option>
<option
value="doc2x"
>
Doc2X
</option>
<option
value="mistral"
>
Mistral
</option>
</select>
</div>
<div
class="c1"
>
@ -82,7 +48,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
</div>
<select
data-has-providers="true"
data-model-type="embedding"
data-placeholder="settings.models.empty"
data-testid="model-selector"
>
@ -122,42 +87,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
value=""
/>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
models.rerank_model
<span
data-placement="right"
data-testid="info-tooltip"
/>
</div>
<select
data-allow-clear="true"
data-has-providers="true"
data-model-type="rerank"
data-placeholder="settings.models.empty"
data-testid="model-selector"
>
<option
value=""
>
Select model
</option>
<option
value="openai/rerank-model"
>
rerank-model
</option>
<option
value="cohere/rerank-english-v2.0"
>
rerank-english-v2.0
</option>
</select>
</div>
<div
class="c1"
>
@ -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"
/>

View File

@ -3,139 +3,87 @@
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%;
}
<div
class="c0"
data-testid="modal"
data-title="Knowledge Base Settings"
styles="[object Object]"
transitionname="animation-move-down"
width="min(900px, 65vw)"
width="min(500px, 60vw)"
>
<div
data-testid="modal-header"
>
<span>
Knowledge Base Settings
</span>
<button
data-testid="modal-close"
type="button"
>
×
</button>
</div>
<div
data-testid="modal-body"
>
<div
class="h-full"
data-testid="hstack"
>
<div
class="c1"
>
<div
class="c2"
data-default-selected="general"
data-testid="menu"
mode="vertical"
>
<div
data-testid="menu-item-general"
style="cursor: pointer;"
>
General Settings
</div>
<div
data-testid="menu-item-advanced"
style="cursor: pointer;"
>
Advanced Settings
</div>
</div>
</div>
<div
class="c3"
>
<div>
<div
data-testid="general-panel"
>
General Settings Panel
General Settings Content
</div>
</div>
</div>
</div>
<div
data-testid="modal-footer"
>
<div
class="c2"
>
<div
style="display: flex; gap: 8px;"
>
<button
data-testid="modal-cancel"
data-testid="button"
type="button"
>
Cancel
<span
data-testid="chevron-down"
>
</span>
settings.advanced.title
</button>
</div>
<div
style="display: flex; gap: 8px;"
>
<button
data-testid="modal-ok"
data-testid="button"
type="button"
>
OK
common.cancel
</button>
<button
data-testid="button"
data-type="primary"
type="button"
>
common.confirm
</button>
</div>
</div>
</div>
</div>
`;

View File

@ -67,31 +67,38 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ title, resolve }) => {
const onCancel = () => {
setOpen(false)
resolve(null)
}
const panelConfigs: PanelConfig[] = [
{
key: 'general',
label: t('settings.general.label'),
panel: <GeneralSettingsPanel newBase={newBase} setNewBase={setNewBase} handlers={handlers} />
},
{
key: 'advanced',
label: t('settings.advanced.title'),
panel: (
<GeneralSettingsPanel
<AdvancedSettingsPanel
newBase={newBase}
setNewBase={setNewBase}
selectedDocPreprocessProvider={selectedDocPreprocessProvider}
docPreprocessSelectOptions={docPreprocessSelectOptions}
handlers={handlers}
/>
)
},
{
key: 'advanced',
label: t('settings.advanced.title'),
panel: <AdvancedSettingsPanel newBase={newBase} handlers={handlers} />
}
]
return <KnowledgeBaseFormModal title={title} open={open} onOk={onOk} onCancel={onCancel} panels={panelConfigs} />
return (
<KnowledgeBaseFormModal
title={title}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={() => resolve(null)}
panels={panelConfigs}
/>
)
}
export default class AddKnowledgeBasePopup {

View File

@ -101,27 +101,25 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ base: _base, resolve })
const onCancel = () => {
setOpen(false)
resolve(null)
}
const panelConfigs: PanelConfig[] = [
{
key: 'general',
label: t('settings.general.label'),
panel: <GeneralSettingsPanel newBase={newBase} setNewBase={setNewBase} handlers={handlers} />
},
{
key: 'advanced',
label: t('settings.advanced.title'),
panel: (
<GeneralSettingsPanel
<AdvancedSettingsPanel
newBase={newBase}
setNewBase={setNewBase}
selectedDocPreprocessProvider={selectedDocPreprocessProvider}
docPreprocessSelectOptions={docPreprocessSelectOptions}
handlers={handlers}
/>
)
},
{
key: 'advanced',
label: t('settings.advanced.title'),
panel: <AdvancedSettingsPanel newBase={newBase} handlers={handlers} />
}
]
@ -134,6 +132,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ base: _base, resolve })
onCancel={onCancel}
afterClose={() => resolve(null)}
panels={panelConfigs}
defaultExpandAdvanced={true}
/>
)
}

View File

@ -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<AdvancedSettingsPanelProps> = ({ newBase, handlers }) => {
const AdvancedSettingsPanel: React.FC<AdvancedSettingsPanelProps> = ({
newBase,
selectedDocPreprocessProvider,
docPreprocessSelectOptions,
handlers
}) => {
const { t } = useTranslation()
const { handleChunkSizeChange, handleChunkOverlapChange, handleThresholdChange } = handlers
const { providers } = useProviders()
const {
handleChunkSizeChange,
handleChunkOverlapChange,
handleThresholdChange,
handleDocPreprocessChange,
handleRerankModelChange
} = handlers
return (
<SettingsPanel>
<SettingsItem>
<div className="settings-label">
{t('settings.tool.preprocess.title')}
<InfoTooltip title={t('settings.tool.preprocess.tooltip')} placement="right" />
</div>
<Select
value={selectedDocPreprocessProvider?.id}
style={{ width: '100%' }}
onChange={handleDocPreprocessChange}
placeholder={t('settings.tool.preprocess.provider_placeholder')}
options={docPreprocessSelectOptions}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.rerank_model')}
<InfoTooltip title={t('models.rerank_model_tooltip')} placement="right" />
</div>
<ModelSelector
providers={providers}
predicate={isRerankModel}
style={{ width: '100%' }}
value={getModelUniqId(newBase.rerankModel) || undefined}
placeholder={t('settings.models.empty')}
onChange={handleRerankModelChange}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_size')}

View File

@ -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<React.SetStateAction<KnowledgeBase>>
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<GeneralSettingsPanelProps> = ({
newBase,
setNewBase,
selectedDocPreprocessProvider,
docPreprocessSelectOptions,
handlers
}) => {
const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = ({ newBase, setNewBase, handlers }) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { handleEmbeddingModelChange, handleDimensionChange, handleRerankModelChange, handleDocPreprocessChange } =
handlers
const { handleEmbeddingModelChange, handleDimensionChange } = handlers
return (
<SettingsPanel>
@ -48,21 +36,6 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = ({
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('settings.tool.preprocess.title')}
<InfoTooltip content={t('settings.tool.preprocess.tooltip')} placement="right" />
</div>
<Select
value={selectedDocPreprocessProvider?.id}
style={{ width: '100%' }}
onChange={handleDocPreprocessChange}
placeholder={t('settings.tool.preprocess.provider_placeholder')}
options={docPreprocessSelectOptions}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.embedding_model')}
@ -91,29 +64,13 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = ({
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.rerank_model')}
<InfoTooltip content={t('models.rerank_model_tooltip')} placement="right" />
</div>
<ModelSelector
providers={providers}
predicate={isRerankModel}
style={{ width: '100%' }}
value={getModelUniqId(newBase.rerankModel) || undefined}
placeholder={t('settings.models.empty')}
onChange={handleRerankModelChange}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.document_count')}
<InfoTooltip content={t('knowledge.document_count_help')} placement="right" />
</div>
<Slider
style={{ width: '100%' }}
style={{ width: '97%' }}
min={1}
max={50}
step={1}

View File

@ -1,7 +1,8 @@
import { RowFlex } from '@cherrystudio/ui'
import type { ModalProps } from 'antd'
import { Menu, Modal } from 'antd'
import { Button, Modal } from 'antd'
import { ChevronDown, ChevronUp } from 'lucide-react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
export interface PanelConfig {
@ -10,15 +11,47 @@ export interface PanelConfig {
panel: React.ReactNode
}
interface KnowledgeBaseFormModalProps extends Omit<ModalProps, 'children'> {
interface KnowledgeBaseFormModalProps extends Omit<ModalProps, 'children' | 'footer'> {
panels: PanelConfig[]
onMoreSettings?: () => void
defaultExpandAdvanced?: boolean
}
const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({ panels, ...rest }) => {
const [selectedMenu, setSelectedMenu] = useState(panels[0]?.key)
const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({
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 = (
<FooterContainer>
<div style={{ display: 'flex', gap: 8 }}>
{advancedPanel && (
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
icon={showAdvanced ? <ChevronUp size={16} /> : <ChevronDown size={16} />}>
{t('settings.advanced.title')}
</Button>
)}
{onMoreSettings && <Button onClick={onMoreSettings}>{t('settings.moresetting.title')}</Button>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="primary" onClick={onOk}>
{okText || t('common.confirm')}
</Button>
</div>
</FooterContainer>
)
return (
<StyledModal
@ -26,33 +59,42 @@ const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({ 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}>
<RowFlex className="h-full">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[selectedMenu]}
mode="vertical"
items={menuItems}
onSelect={({ key }) => setSelectedMenu(key)}
/>
</LeftMenu>
<SettingsContentPanel>{activePanel}</SettingsContentPanel>
</RowFlex>
<ContentContainer>
{/* General Settings */}
{generalPanel && <div>{generalPanel.panel}</div>}
{/* Advanced Settings */}
{showAdvanced && advancedPanel && (
<AdvancedSettingsContainer>
<AdvancedSettingsTitle>{advancedPanel.label}</AdvancedSettingsTitle>
<div>{advancedPanel.panel}</div>
</AdvancedSettingsContainer>
)}
</ContentContainer>
</StyledModal>
)
}
@ -60,57 +102,38 @@ const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({ panels,
const StyledModal = styled(Modal)`
.ant-modal-title {
font-size: 14px;
}
.ant-modal-close {
top: 4px;
right: 4px;
}
`
const LeftMenu = styled.div`
display: flex;
height: 100%;
border-right: 0.5px solid var(--color-border);
`
const SettingsContentPanel = styled.div`
flex: 1;
padding: 16px 16px;
overflow-y: scroll;
`
const StyledMenu = styled(Menu)`
width: 200px;
padding: 5px;
background: transparent;
margin-top: 2px;
border-inline-end: none !important;
.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;
}
.ant-modal-close {
top: 8px;
right: 8px;
}
`
const ContentContainer = styled.div`
display: flex;
flex-direction: column;
`
const FooterContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
`
const AdvancedSettingsContainer = styled.div`
margin-top: 16px;
padding-top: 16px;
border-top: 0.5px solid var(--color-border);
`
const AdvancedSettingsTitle = styled.div`
font-weight: 500;
font-size: 14px;
color: var(--color-text-1);
margin-bottom: 16px;
padding: 0 16px;
`
export default KnowledgeBaseFormModal

View File

@ -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: <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'),
@ -103,7 +110,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',
@ -695,6 +702,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 { 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 (
<SettingGroup theme={theme}>
<SettingRow>
<SettingTitle>{t('settings.data.import_settings.title')}</SettingTitle>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.import_settings.chatgpt')}</SettingRowTitle>
<RowFlex className="justify-between gap-[5px]">
<Button onClick={ImportPopup.show}>{t('settings.data.import_settings.button')}</Button>
</RowFlex>
</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`)
})
}

View File

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

View File

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