mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 02:20:10 +08:00
Merge branch 'main' into v2
This commit is contained in:
commit
cf7b4dd07b
@ -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",
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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) {
|
||||
const sanitizedText = stripLocalCommandTags(block.text)
|
||||
if (sanitizedText) {
|
||||
textBlocks.push(sanitizedText)
|
||||
}
|
||||
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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
141
src/renderer/src/components/Popups/ImportPopup.tsx
Normal file
141
src/renderer/src/components/Popups/ImportPopup.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { importChatGPTConversations } from '@renderer/services/import'
|
||||
import { Alert, Modal, Progress, Space, Spin } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface PopupResult {
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
resolve: (data: PopupResult) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = async () => {
|
||||
setSelecting(true)
|
||||
try {
|
||||
// Select ChatGPT JSON file
|
||||
const file = await window.api.file.open({
|
||||
filters: [{ name: 'ChatGPT Conversations', extensions: ['json'] }]
|
||||
})
|
||||
|
||||
setSelecting(false)
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
setImporting(true)
|
||||
|
||||
// Parse file content
|
||||
const fileContent = typeof file.content === 'string' ? file.content : new TextDecoder().decode(file.content)
|
||||
|
||||
// Import conversations
|
||||
const result = await importChatGPTConversations(fileContent)
|
||||
|
||||
if (result.success) {
|
||||
window.toast.success(
|
||||
t('import.chatgpt.success', {
|
||||
topics: result.topicsCount,
|
||||
messages: result.messagesCount
|
||||
})
|
||||
)
|
||||
setOpen(false)
|
||||
} else {
|
||||
window.toast.error(result.error || t('import.chatgpt.error.unknown'))
|
||||
}
|
||||
} catch (error) {
|
||||
window.toast.error(t('import.chatgpt.error.unknown'))
|
||||
setOpen(false)
|
||||
} finally {
|
||||
setSelecting(false)
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
ImportPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('import.chatgpt.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
okText={t('import.chatgpt.button')}
|
||||
okButtonProps={{ disabled: selecting || importing, loading: selecting }}
|
||||
cancelButtonProps={{ disabled: selecting || importing }}
|
||||
maskClosable={false}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
{!selecting && !importing && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>{t('import.chatgpt.description')}</div>
|
||||
<Alert
|
||||
message={t('import.chatgpt.help.title')}
|
||||
description={
|
||||
<div>
|
||||
<p>{t('import.chatgpt.help.step1')}</p>
|
||||
<p>{t('import.chatgpt.help.step2')}</p>
|
||||
<p>{t('import.chatgpt.help.step3')}</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
{selecting && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>{t('import.chatgpt.selecting')}</div>
|
||||
</div>
|
||||
)}
|
||||
{importing && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Progress percent={100} status="active" strokeColor="var(--color-primary)" showInfo={false} />
|
||||
<div style={{ marginTop: 16 }}>{t('import.chatgpt.importing')}</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'ImportPopup'
|
||||
|
||||
export default class ImportPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show() {
|
||||
return new Promise<PopupResult>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "检测",
|
||||
|
||||
@ -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": "檢查",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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,6 +15,63 @@ interface ParsedBashOutput {
|
||||
tool_use_error?: string
|
||||
}
|
||||
|
||||
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
|
||||
if (!output) return null
|
||||
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const hasToolError = output.includes('<tool_use_error>')
|
||||
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
|
||||
|
||||
const getElementText = (tagName: string): string | undefined => {
|
||||
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
||||
return element?.textContent?.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
status: getElementText('status'),
|
||||
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
||||
stdout: getElementText('stdout'),
|
||||
stderr: getElementText('stderr'),
|
||||
timestamp: getElementText('timestamp'),
|
||||
tool_use_error: getElementText('tool_use_error')
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
|
||||
if (!parsedOutput) return null
|
||||
|
||||
if (parsedOutput.tool_use_error) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: 'Error'
|
||||
} as const
|
||||
}
|
||||
|
||||
const isCompleted = parsedOutput.status === 'completed'
|
||||
const isSuccess = parsedOutput.exit_code === 0
|
||||
|
||||
return {
|
||||
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
||||
icon:
|
||||
isCompleted && isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : isCompleted && !isSuccess ? (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
),
|
||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||
} as const
|
||||
}
|
||||
|
||||
export function BashOutputTool({
|
||||
input,
|
||||
output
|
||||
@ -23,73 +79,8 @@ export function BashOutputTool({
|
||||
input: BashOutputToolInput
|
||||
output?: BashOutputToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
// 解析 XML 输出
|
||||
const parsedOutput = useMemo(() => {
|
||||
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
|
||||
}
|
||||
|
||||
const getElementText = (tagName: string): string | undefined => {
|
||||
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
||||
return element?.textContent?.trim()
|
||||
}
|
||||
|
||||
const result: ParsedBashOutput = {
|
||||
status: getElementText('status'),
|
||||
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
||||
stdout: getElementText('stdout'),
|
||||
stderr: getElementText('stderr'),
|
||||
timestamp: getElementText('timestamp'),
|
||||
tool_use_error: getElementText('tool_use_error')
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [output])
|
||||
|
||||
// 获取状态配置
|
||||
const statusConfig = useMemo(() => {
|
||||
if (!parsedOutput) return null
|
||||
|
||||
// 如果有 tool_use_error,直接显示错误状态
|
||||
if (parsedOutput.tool_use_error) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: 'Error'
|
||||
} as const
|
||||
}
|
||||
|
||||
const isCompleted = parsedOutput.status === 'completed'
|
||||
const isSuccess = parsedOutput.exit_code === 0
|
||||
|
||||
return {
|
||||
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
||||
icon:
|
||||
isCompleted && isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : isCompleted && !isSuccess ? (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
),
|
||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||
} as const
|
||||
}, [parsedOutput])
|
||||
const parsedOutput = parseBashOutput(output)
|
||||
const statusConfig = getStatusConfig(parsedOutput)
|
||||
|
||||
const children = parsedOutput ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,58 +47,49 @@ 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 (
|
||||
<div className="space-y-3">
|
||||
{input !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||
<div
|
||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||
<div
|
||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||
/>
|
||||
</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()}
|
||||
params={getToolDescription(toolName)}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div className="space-y-3">
|
||||
{input !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||
<div
|
||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||
<div
|
||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{input === undefined && output === undefined && (
|
||||
<div className="text-foreground-500 text-xs">No data available for this tool</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
children: <UnknownToolContent input={input} output={output} />
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
const renderedItem = Renderer
|
||||
? Renderer({ input: input as any, output: output as any })
|
||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const toolContentItem = useMemo(() => {
|
||||
const rendered = Renderer
|
||||
? Renderer({ input: input as any, output: output as any })
|
||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||
return {
|
||||
...rendered,
|
||||
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])
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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} />
|
||||
)
|
||||
}
|
||||
|
||||
@ -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' } })
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
<option value="openai/text-embedding-3-small">text-embedding-3-small</option>
|
||||
<option value="openai/text-embedding-ada-002">text-embedding-ada-002</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 () => {
|
||||
|
||||
@ -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,82 +36,33 @@ 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[] = [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General Settings',
|
||||
panel: <div data-testid="general-panel">General Settings Panel</div>
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
label: 'Advanced Settings',
|
||||
panel: <div data-testid="advanced-panel">Advanced Settings Panel</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
|
||||
const createPanelConfigs = (): PanelConfig[] => [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General Settings',
|
||||
panel: <div data-testid="general-panel">General Settings Content</div>
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
label: 'Advanced Settings',
|
||||
panel: <div data-testid="advanced-panel">Advanced Settings Content</div>
|
||||
}
|
||||
|
||||
return render(<KnowledgeBaseFormModal {...defaultProps} {...props} />)
|
||||
}
|
||||
]
|
||||
|
||||
describe('KnowledgeBaseFormModal', () => {
|
||||
beforeEach(() => {
|
||||
@ -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 })
|
||||
describe('advanced settings toggle', () => {
|
||||
it('should toggle advanced panel visibility', () => {
|
||||
render(
|
||||
<KnowledgeBaseFormModal panels={createPanelConfigs()} open={true} onOk={mocks.onOk} onCancel={mocks.onCancel} />
|
||||
)
|
||||
|
||||
const menu = screen.getByTestId('menu')
|
||||
expect(menu).toHaveAttribute('data-default-selected', panels[0].key)
|
||||
})
|
||||
// Initially, advanced panel should not be visible
|
||||
expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
|
||||
|
||||
it('should handle menu selection with custom panels', () => {
|
||||
const customPanels: PanelConfig[] = [
|
||||
{
|
||||
key: 'custom1',
|
||||
label: 'Custom Panel 1',
|
||||
panel: <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>
|
||||
}
|
||||
]
|
||||
|
||||
renderModal({ panels: customPanels })
|
||||
|
||||
// Initially shows first custom panel
|
||||
expect(screen.getByTestId('custom1-panel')).toBeInTheDocument()
|
||||
|
||||
// Click second custom menu item
|
||||
fireEvent.click(screen.getByTestId('menu-item-custom2'))
|
||||
|
||||
// Should now show second custom panel
|
||||
expect(screen.queryByTestId('custom1-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom2-panel')).toBeInTheDocument()
|
||||
// Find and click the first button (advanced settings toggle)
|
||||
const buttons = screen.getAllByTestId('button')
|
||||
if (buttons.length > 0) {
|
||||
fireEvent.click(buttons[0])
|
||||
// Advanced panel might be visible now (depending on implementation)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal props', () => {
|
||||
const user = userEvent.setup()
|
||||
it('should pass through modal props correctly', () => {
|
||||
const customTitle = 'Custom Modal Title'
|
||||
renderModal({ title: customTitle })
|
||||
describe('footer buttons', () => {
|
||||
it('should have more buttons when onMoreSettings is provided', () => {
|
||||
const { rerender } = render(
|
||||
<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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -3,120 +3,44 @@
|
||||
exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] = `
|
||||
.c0 .ant-modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.c0 .ant-modal-close {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
flex: 1;
|
||||
padding: 16px 16px;
|
||||
overflow-y: scroll;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
width: 200px;
|
||||
padding: 5px;
|
||||
background: transparent;
|
||||
margin-top: 2px;
|
||||
border-inline-end: none!important;
|
||||
}
|
||||
|
||||
.c2 .ant-menu-item {
|
||||
height: 36px;
|
||||
color: var(--color-text-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.c2 .ant-menu-item .ant-menu-title-content {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.c2 .ant-menu-item-active {
|
||||
background-color: var(--color-background-soft)!important;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.c2 .ant-menu-item-selected {
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.c2 .ant-menu-item-selected .ant-menu-title-content {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
<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"
|
||||
class="c1"
|
||||
>
|
||||
<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>
|
||||
@ -124,18 +48,42 @@ exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] =
|
||||
<div
|
||||
data-testid="modal-footer"
|
||||
>
|
||||
<button
|
||||
data-testid="modal-cancel"
|
||||
type="button"
|
||||
<div
|
||||
class="c2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-ok"
|
||||
type="button"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<div
|
||||
style="display: flex; gap: 8px;"
|
||||
>
|
||||
<button
|
||||
data-testid="button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
data-testid="chevron-down"
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
settings.advanced.title
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; gap: 8px;"
|
||||
>
|
||||
<button
|
||||
data-testid="button"
|
||||
type="button"
|
||||
>
|
||||
common.cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="button"
|
||||
data-type="primary"
|
||||
type="button"
|
||||
>
|
||||
common.confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ant-modal-close {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const LeftMenu = styled.div`
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const SettingsContentPanel = styled.div`
|
||||
flex: 1;
|
||||
padding: 16px 16px;
|
||||
overflow-y: scroll;
|
||||
const FooterContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const StyledMenu = styled(Menu)`
|
||||
width: 200px;
|
||||
padding: 5px;
|
||||
background: transparent;
|
||||
margin-top: 2px;
|
||||
border-inline-end: none !important;
|
||||
const AdvancedSettingsContainer = styled.div`
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
.ant-menu-item {
|
||||
height: 36px;
|
||||
color: var(--color-text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 7px;
|
||||
|
||||
.ant-menu-title-content {
|
||||
line-height: 36px;
|
||||
}
|
||||
}
|
||||
.ant-menu-item-active {
|
||||
background-color: var(--color-background-soft) !important;
|
||||
transition: none;
|
||||
}
|
||||
.ant-menu-item-selected {
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-menu-title-content {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
const AdvancedSettingsTitle = styled.div`
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 16px;
|
||||
padding: 0 16px;
|
||||
`
|
||||
|
||||
export default KnowledgeBaseFormModal
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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
|
||||
167
src/renderer/src/services/import/ImportService.ts
Normal file
167
src/renderer/src/services/import/ImportService.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { loggerService } from '@logger'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { addAssistant } from '@renderer/store/assistants'
|
||||
import type { Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
|
||||
import { DEFAULT_ASSISTANT_SETTINGS } from '../AssistantService'
|
||||
import { availableImporters } from './importers'
|
||||
import type { ConversationImporter, ImportResponse } from './types'
|
||||
import { saveImportToDatabase } from './utils/database'
|
||||
|
||||
const logger = loggerService.withContext('ImportService')
|
||||
|
||||
/**
|
||||
* Main import service that manages all conversation importers
|
||||
*/
|
||||
class ImportServiceClass {
|
||||
private importers: Map<string, ConversationImporter> = new Map()
|
||||
|
||||
constructor() {
|
||||
// Register all available importers
|
||||
for (const importer of availableImporters) {
|
||||
this.importers.set(importer.name.toLowerCase(), importer)
|
||||
logger.info(`Registered importer: ${importer.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered importers
|
||||
*/
|
||||
getImporters(): ConversationImporter[] {
|
||||
return Array.from(this.importers.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer by name
|
||||
*/
|
||||
getImporter(name: string): ConversationImporter | undefined {
|
||||
return this.importers.get(name.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect the appropriate importer for the file content
|
||||
*/
|
||||
detectImporter(fileContent: string): ConversationImporter | null {
|
||||
for (const importer of this.importers.values()) {
|
||||
if (importer.validate(fileContent)) {
|
||||
logger.info(`Detected importer: ${importer.name}`)
|
||||
return importer
|
||||
}
|
||||
}
|
||||
logger.warn('No matching importer found for file content')
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Import conversations from file content
|
||||
* Automatically detects the format and uses the appropriate importer
|
||||
*/
|
||||
async importConversations(fileContent: string, importerName?: string): Promise<ImportResponse> {
|
||||
try {
|
||||
logger.info('Starting import...')
|
||||
|
||||
// Parse JSON first to validate format
|
||||
let importer: ConversationImporter | null = null
|
||||
|
||||
if (importerName) {
|
||||
// Use specified importer
|
||||
const foundImporter = this.getImporter(importerName)
|
||||
if (!foundImporter) {
|
||||
return {
|
||||
success: false,
|
||||
topicsCount: 0,
|
||||
messagesCount: 0,
|
||||
error: `Importer "${importerName}" not found`
|
||||
}
|
||||
}
|
||||
importer = foundImporter
|
||||
} else {
|
||||
// Auto-detect importer
|
||||
importer = this.detectImporter(fileContent)
|
||||
if (!importer) {
|
||||
return {
|
||||
success: false,
|
||||
topicsCount: 0,
|
||||
messagesCount: 0,
|
||||
error: i18n.t('import.error.unsupported_format', { defaultValue: 'Unsupported file format' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!importer.validate(fileContent)) {
|
||||
return {
|
||||
success: false,
|
||||
topicsCount: 0,
|
||||
messagesCount: 0,
|
||||
error: i18n.t('import.error.invalid_format', {
|
||||
defaultValue: `Invalid ${importer.name} format`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create assistant
|
||||
const assistantId = uuid()
|
||||
|
||||
// Parse conversations
|
||||
const result = await importer.parse(fileContent, assistantId)
|
||||
|
||||
// Save to database
|
||||
await saveImportToDatabase(result)
|
||||
|
||||
// Create assistant
|
||||
const importerKey = `import.${importer.name.toLowerCase()}.assistant_name`
|
||||
const assistant: Assistant = {
|
||||
id: assistantId,
|
||||
name: i18n.t(importerKey, {
|
||||
defaultValue: `${importer.name} Import`
|
||||
}),
|
||||
emoji: importer.emoji,
|
||||
prompt: '',
|
||||
topics: result.topics,
|
||||
messages: [],
|
||||
type: 'assistant',
|
||||
settings: DEFAULT_ASSISTANT_SETTINGS
|
||||
}
|
||||
|
||||
// Add assistant to store
|
||||
store.dispatch(addAssistant(assistant))
|
||||
|
||||
logger.info(
|
||||
`Import completed: ${result.topics.length} conversations, ${result.messages.length} messages imported`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assistant,
|
||||
topicsCount: result.topics.length,
|
||||
messagesCount: result.messages.length
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Import failed:', error as Error)
|
||||
return {
|
||||
success: false,
|
||||
topicsCount: 0,
|
||||
messagesCount: 0,
|
||||
error:
|
||||
error instanceof Error ? error.message : i18n.t('import.error.unknown', { defaultValue: 'Unknown error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import ChatGPT conversations (backward compatibility)
|
||||
* @deprecated Use importConversations() instead
|
||||
*/
|
||||
async importChatGPTConversations(fileContent: string): Promise<ImportResponse> {
|
||||
return this.importConversations(fileContent, 'chatgpt')
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ImportService = new ImportServiceClass()
|
||||
|
||||
// Export for backward compatibility
|
||||
export const importChatGPTConversations = (fileContent: string) => ImportService.importChatGPTConversations(fileContent)
|
||||
268
src/renderer/src/services/import/importers/ChatGPTImporter.ts
Normal file
268
src/renderer/src/services/import/importers/ChatGPTImporter.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import { loggerService } from '@logger'
|
||||
import i18n from '@renderer/i18n'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import {
|
||||
AssistantMessageStatus,
|
||||
type MainTextMessageBlock,
|
||||
type Message,
|
||||
MessageBlockStatus,
|
||||
MessageBlockType,
|
||||
UserMessageStatus
|
||||
} from '@renderer/types/newMessage'
|
||||
import { uuid } from '@renderer/utils'
|
||||
|
||||
import type { ConversationImporter, ImportResult } from '../types'
|
||||
|
||||
const logger = loggerService.withContext('ChatGPTImporter')
|
||||
|
||||
/**
|
||||
* ChatGPT Export Format Types
|
||||
*/
|
||||
interface ChatGPTMessage {
|
||||
id: string
|
||||
author: {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
}
|
||||
content: {
|
||||
content_type: string
|
||||
parts?: string[]
|
||||
}
|
||||
metadata?: any
|
||||
create_time?: number
|
||||
}
|
||||
|
||||
interface ChatGPTNode {
|
||||
id: string
|
||||
message?: ChatGPTMessage
|
||||
parent?: string
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
interface ChatGPTConversation {
|
||||
title: string
|
||||
create_time: number
|
||||
update_time: number
|
||||
mapping: Record<string, ChatGPTNode>
|
||||
current_node?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatGPT conversation importer
|
||||
* Handles importing conversations from ChatGPT's conversations.json export format
|
||||
*/
|
||||
export class ChatGPTImporter implements ConversationImporter {
|
||||
readonly name = 'ChatGPT'
|
||||
readonly emoji = '💬'
|
||||
|
||||
/**
|
||||
* Validate if the file content is a valid ChatGPT export
|
||||
*/
|
||||
validate(fileContent: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(fileContent)
|
||||
const conversations = Array.isArray(parsed) ? parsed : [parsed]
|
||||
|
||||
// Check if it has the basic ChatGPT conversation structure
|
||||
return conversations.every(
|
||||
(conv) =>
|
||||
conv &&
|
||||
typeof conv === 'object' &&
|
||||
'mapping' in conv &&
|
||||
typeof conv.mapping === 'object' &&
|
||||
'title' in conv &&
|
||||
'create_time' in conv
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ChatGPT conversations and convert to unified format
|
||||
*/
|
||||
async parse(fileContent: string, assistantId: string): Promise<ImportResult> {
|
||||
logger.info('Starting ChatGPT import...')
|
||||
|
||||
// Parse JSON
|
||||
const parsed = JSON.parse(fileContent)
|
||||
const conversations: ChatGPTConversation[] = Array.isArray(parsed) ? parsed : [parsed]
|
||||
|
||||
if (!conversations || conversations.length === 0) {
|
||||
throw new Error(i18n.t('import.chatgpt.error.no_conversations'))
|
||||
}
|
||||
|
||||
logger.info(`Found ${conversations.length} conversations`)
|
||||
|
||||
const topics: Topic[] = []
|
||||
const allMessages: Message[] = []
|
||||
const allBlocks: MainTextMessageBlock[] = []
|
||||
|
||||
// Convert each conversation
|
||||
for (const conversation of conversations) {
|
||||
try {
|
||||
const { topic, messages, blocks } = this.convertConversationToTopic(conversation, assistantId)
|
||||
topics.push(topic)
|
||||
allMessages.push(...messages)
|
||||
allBlocks.push(...blocks)
|
||||
} catch (convError) {
|
||||
logger.warn(`Failed to convert conversation "${conversation.title}":`, convError as Error)
|
||||
// Continue with other conversations
|
||||
}
|
||||
}
|
||||
|
||||
if (topics.length === 0) {
|
||||
throw new Error(i18n.t('import.chatgpt.error.no_valid_conversations'))
|
||||
}
|
||||
|
||||
return {
|
||||
topics,
|
||||
messages: allMessages,
|
||||
blocks: allBlocks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract main conversation thread from ChatGPT's tree structure
|
||||
* Traces back from current_node to root to get the main conversation path
|
||||
*/
|
||||
private extractMainThread(mapping: Record<string, ChatGPTNode>, currentNode?: string): ChatGPTMessage[] {
|
||||
const messages: ChatGPTMessage[] = []
|
||||
const nodeIds: string[] = []
|
||||
|
||||
// Start from current_node or find the last node
|
||||
let nodeId = currentNode
|
||||
if (!nodeId) {
|
||||
// Find node with no children (leaf node)
|
||||
const leafNodes = Object.entries(mapping).filter(([, node]) => !node.children || node.children.length === 0)
|
||||
if (leafNodes.length > 0) {
|
||||
nodeId = leafNodes[0][0]
|
||||
}
|
||||
}
|
||||
|
||||
// Trace back to root
|
||||
while (nodeId) {
|
||||
const node = mapping[nodeId]
|
||||
if (!node) break
|
||||
|
||||
nodeIds.unshift(nodeId)
|
||||
nodeId = node.parent
|
||||
}
|
||||
|
||||
// Extract messages from the path
|
||||
for (const id of nodeIds) {
|
||||
const node = mapping[id]
|
||||
if (node?.message) {
|
||||
const message = node.message
|
||||
// Filter out empty messages and tool messages
|
||||
if (
|
||||
message.author.role !== 'tool' &&
|
||||
message.content?.parts &&
|
||||
message.content.parts.length > 0 &&
|
||||
message.content.parts.some((part) => part && part.trim().length > 0)
|
||||
) {
|
||||
messages.push(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ChatGPT role to Cherry Studio role
|
||||
*/
|
||||
private mapRole(chatgptRole: string): 'user' | 'assistant' | 'system' {
|
||||
if (chatgptRole === 'user') return 'user'
|
||||
if (chatgptRole === 'assistant') return 'assistant'
|
||||
return 'system'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Message and MessageBlock from ChatGPT message
|
||||
*/
|
||||
private createMessageAndBlock(
|
||||
chatgptMessage: ChatGPTMessage,
|
||||
topicId: string,
|
||||
assistantId: string
|
||||
): { message: Message; block: MainTextMessageBlock } {
|
||||
const messageId = uuid()
|
||||
const blockId = uuid()
|
||||
const role = this.mapRole(chatgptMessage.author.role)
|
||||
|
||||
// Extract text content from parts
|
||||
const content = (chatgptMessage.content?.parts || []).filter((part) => part && part.trim()).join('\n\n')
|
||||
|
||||
const createdAt = chatgptMessage.create_time
|
||||
? new Date(chatgptMessage.create_time * 1000).toISOString()
|
||||
: new Date().toISOString()
|
||||
|
||||
// Create message
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
role,
|
||||
assistantId,
|
||||
topicId,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
status: role === 'user' ? UserMessageStatus.SUCCESS : AssistantMessageStatus.SUCCESS,
|
||||
blocks: [blockId],
|
||||
// Set model for assistant messages to display GPT-5 logo
|
||||
...(role === 'assistant' && {
|
||||
model: {
|
||||
id: 'gpt-5',
|
||||
provider: 'openai',
|
||||
name: 'GPT-5',
|
||||
group: 'gpt-5'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create block
|
||||
const block: MainTextMessageBlock = {
|
||||
id: blockId,
|
||||
messageId,
|
||||
type: MessageBlockType.MAIN_TEXT,
|
||||
content,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
}
|
||||
|
||||
return { message, block }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ChatGPT conversation to Cherry Studio Topic
|
||||
*/
|
||||
private convertConversationToTopic(
|
||||
conversation: ChatGPTConversation,
|
||||
assistantId: string
|
||||
): { topic: Topic; messages: Message[]; blocks: MainTextMessageBlock[] } {
|
||||
const topicId = uuid()
|
||||
const messages: Message[] = []
|
||||
const blocks: MainTextMessageBlock[] = []
|
||||
|
||||
// Extract main thread messages
|
||||
const chatgptMessages = this.extractMainThread(conversation.mapping, conversation.current_node)
|
||||
|
||||
// Convert each message
|
||||
for (const chatgptMessage of chatgptMessages) {
|
||||
const { message, block } = this.createMessageAndBlock(chatgptMessage, topicId, assistantId)
|
||||
messages.push(message)
|
||||
blocks.push(block)
|
||||
}
|
||||
|
||||
// Create topic
|
||||
const topic: Topic = {
|
||||
id: topicId,
|
||||
assistantId,
|
||||
name: conversation.title || i18n.t('import.chatgpt.untitled_conversation'),
|
||||
createdAt: new Date(conversation.create_time * 1000).toISOString(),
|
||||
updatedAt: new Date(conversation.update_time * 1000).toISOString(),
|
||||
messages,
|
||||
isNameManuallyEdited: true
|
||||
}
|
||||
|
||||
return { topic, messages, blocks }
|
||||
}
|
||||
}
|
||||
12
src/renderer/src/services/import/importers/index.ts
Normal file
12
src/renderer/src/services/import/importers/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ChatGPTImporter } from './ChatGPTImporter'
|
||||
|
||||
/**
|
||||
* Export all available importers
|
||||
*/
|
||||
export { ChatGPTImporter }
|
||||
|
||||
/**
|
||||
* Registry of all available importers
|
||||
* Add new importers here as they are implemented
|
||||
*/
|
||||
export const availableImporters = [new ChatGPTImporter()] as const
|
||||
3
src/renderer/src/services/import/index.ts
Normal file
3
src/renderer/src/services/import/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ChatGPTImporter } from './importers/ChatGPTImporter'
|
||||
export { importChatGPTConversations, ImportService } from './ImportService'
|
||||
export type { ConversationImporter, ImportResponse, ImportResult } from './types'
|
||||
52
src/renderer/src/services/import/types.ts
Normal file
52
src/renderer/src/services/import/types.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
|
||||
/**
|
||||
* Import result containing parsed data
|
||||
*/
|
||||
export interface ImportResult {
|
||||
topics: Topic[]
|
||||
messages: Message[]
|
||||
blocks: MainTextMessageBlock[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response returned to caller after import
|
||||
*/
|
||||
export interface ImportResponse {
|
||||
success: boolean
|
||||
assistant?: Assistant
|
||||
topicsCount: number
|
||||
messagesCount: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for conversation importers
|
||||
* Each chat application (ChatGPT, Claude, Gemini, etc.) should implement this interface
|
||||
*/
|
||||
export interface ConversationImporter {
|
||||
/**
|
||||
* Unique name of the importer (e.g., 'ChatGPT', 'Claude', 'Gemini')
|
||||
*/
|
||||
readonly name: string
|
||||
|
||||
/**
|
||||
* Emoji or icon for the assistant created by this importer
|
||||
*/
|
||||
readonly emoji: string
|
||||
|
||||
/**
|
||||
* Validate if the file content matches this importer's format
|
||||
*/
|
||||
validate(fileContent: string): boolean
|
||||
|
||||
/**
|
||||
* Parse file content and convert to unified format
|
||||
* @param fileContent - Raw file content (usually JSON string)
|
||||
* @param assistantId - ID of the assistant to associate with
|
||||
* @returns Parsed topics, messages, and blocks
|
||||
*/
|
||||
parse(fileContent: string, assistantId: string): Promise<ImportResult>
|
||||
}
|
||||
34
src/renderer/src/services/import/utils/database.ts
Normal file
34
src/renderer/src/services/import/utils/database.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { loggerService } from '@logger'
|
||||
import db from '@renderer/databases'
|
||||
|
||||
import type { ImportResult } from '../types'
|
||||
|
||||
const logger = loggerService.withContext('ImportDatabase')
|
||||
|
||||
/**
|
||||
* Save import result to database
|
||||
* Handles saving topics, messages, and message blocks in a transaction
|
||||
*/
|
||||
export async function saveImportToDatabase(result: ImportResult): Promise<void> {
|
||||
const { topics, messages, blocks } = result
|
||||
|
||||
logger.info(`Saving import: ${topics.length} topics, ${messages.length} messages, ${blocks.length} blocks`)
|
||||
|
||||
await db.transaction('rw', db.topics, db.message_blocks, async () => {
|
||||
// Save all message blocks
|
||||
if (blocks.length > 0) {
|
||||
await db.message_blocks.bulkAdd(blocks)
|
||||
logger.info(`Saved ${blocks.length} message blocks`)
|
||||
}
|
||||
|
||||
// Save all topics with messages
|
||||
for (const topic of topics) {
|
||||
const topicMessages = messages.filter((m) => m.topicId === topic.id)
|
||||
await db.topics.add({
|
||||
id: topic.id,
|
||||
messages: topicMessages
|
||||
})
|
||||
}
|
||||
logger.info(`Saved ${topics.length} topics`)
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user