From a2f96de652657a6183aef19ebc617737b6645510 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月13日 22:25:52 +0200 Subject: [PATCH 01/13] Add import conflict preview and update handling --- .../src/controllers/export-import/index.ts | 28 +- .../server/src/routes/export-import/index.ts | 2 + .../src/services/export-import/index.ts | 490 +++++++++++++++++- packages/ui/src/api/exportimport.js | 4 +- .../Header/ProfileSection/index.jsx | 263 +++++++++- 5 files changed, 753 insertions(+), 34 deletions(-) diff --git a/packages/server/src/controllers/export-import/index.ts b/packages/server/src/controllers/export-import/index.ts index b066df0c8a8..80e99976b6d 100644 --- a/packages/server/src/controllers/export-import/index.ts +++ b/packages/server/src/controllers/export-import/index.ts @@ -45,7 +45,33 @@ const importData = async (req: Request, res: Response, next: NextFunction) => { } } +const previewImportData = async (req: Request, res: Response, next: NextFunction) => { + try { + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError( + StatusCodes.NOT_FOUND, + `Error: exportImportController.previewImportData - workspace ${workspaceId} not found!` + ) + } + + const importData = req.body + if (!importData) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + 'Error: exportImportController.previewImportData - importData is required!' + ) + } + + const preview = await exportImportService.previewImportData(importData, workspaceId) + return res.status(StatusCodes.OK).json(preview) + } catch (error) { + next(error) + } +} + export default { exportData, - importData + importData, + previewImportData } diff --git a/packages/server/src/routes/export-import/index.ts b/packages/server/src/routes/export-import/index.ts index 17b28a7c346..a34a1eecf8f 100644 --- a/packages/server/src/routes/export-import/index.ts +++ b/packages/server/src/routes/export-import/index.ts @@ -5,6 +5,8 @@ const router = express.Router() router.post('/export', checkPermission('workspace:export'), exportImportController.exportData) +router.post('/preview', checkPermission('workspace:import'), exportImportController.previewImportData) + router.post('/import', checkPermission('workspace:import'), exportImportController.importData) export default router diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 8873f9824da..3b2892d79df 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -2,7 +2,7 @@ import { StatusCodes } from 'http-status-codes' import { EntityManager, In, QueryRunner } from 'typeorm' import { v4 as uuidv4 } from 'uuid' import { Assistant } from '../../database/entities/Assistant' -import { ChatFlow } from '../../database/entities/ChatFlow' +import { ChatFlow, EnumChatflowType } from '../../database/entities/ChatFlow' import { ChatMessage } from '../../database/entities/ChatMessage' import { ChatMessageFeedback } from '../../database/entities/ChatMessageFeedback' import { CustomTemplate } from '../../database/entities/CustomTemplate' @@ -62,6 +62,85 @@ type ExportData = { Variable: Variable[] } +type ConflictEntityKey = keyof Pick< + ExportData, + | 'AgentFlow' + | 'AgentFlowV2' + | 'AssistantFlow' + | 'AssistantCustom' + | 'AssistantOpenAI' + | 'AssistantAzure' + | 'ChatFlow' + | 'CustomTemplate' + | 'DocumentStore' + | 'Tool' + | 'Variable' +> + +type ConflictResolutionAction = 'update' | 'duplicate' + +type ConflictResolution = { + type: ConflictEntityKey + importId: string + existingId: string + action: ConflictResolutionAction +} + +type ImportPayload = ExportData & { + conflictResolutions?: ConflictResolution[] +} + +type ImportPreviewConflict = { + type: ConflictEntityKey + name: string + importId: string + existingId: string +} + +type ImportPreview = { + conflicts: ImportPreviewConflict[] +} + +const chatflowTypeByKey: Partial> = { + AgentFlow: EnumChatflowType.MULTIAGENT, + AgentFlowV2: EnumChatflowType.AGENTFLOW, + AssistantFlow: EnumChatflowType.ASSISTANT, + ChatFlow: EnumChatflowType.CHATFLOW +} + +const assistantTypeByKey: Partial> = { + AssistantCustom: 'CUSTOM', + AssistantOpenAI: 'OPENAI', + AssistantAzure: 'AZURE' +} + +const conflictEntityKeys: ConflictEntityKey[] = [ + 'AgentFlow', + 'AgentFlowV2', + 'AssistantFlow', + 'AssistantCustom', + 'AssistantOpenAI', + 'AssistantAzure', + 'ChatFlow', + 'CustomTemplate', + 'DocumentStore', + 'Tool', + 'Variable' +] + +const getAssistantName = (details?: string): string | undefined => { + if (!details) return undefined + try { + const parsed = JSON.parse(details) + if (parsed && typeof parsed === 'object' && parsed.name) { + return parsed.name + } + } catch (error) { + // ignore malformed assistant details + } + return undefined +} + const convertExportInput = (body: any): ExportInput => { try { if (!body || typeof body !== 'object') throw new Error('Invalid ExportInput object in request body') @@ -182,7 +261,230 @@ const exportData = async (exportInput: ExportInput, activeWorkspaceId?: string): } } -async function replaceDuplicateIdsForChatFlow(queryRunner: QueryRunner, originalData: ExportData, chatflows: ChatFlow[]) { +const previewImportData = async (importData: ExportData, activeWorkspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + if (!appServer) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, 'Error: Unable to access application server') + } + + const dataSource = appServer.AppDataSource + if (!dataSource) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, 'Error: Database connection not available') + } + + const normalizedData: ImportPayload = { + AgentFlow: importData.AgentFlow || [], + AgentFlowV2: importData.AgentFlowV2 || [], + AssistantCustom: importData.AssistantCustom || [], + AssistantFlow: importData.AssistantFlow || [], + AssistantOpenAI: importData.AssistantOpenAI || [], + AssistantAzure: importData.AssistantAzure || [], + ChatFlow: importData.ChatFlow || [], + ChatMessage: importData.ChatMessage || [], + ChatMessageFeedback: importData.ChatMessageFeedback || [], + CustomTemplate: importData.CustomTemplate || [], + DocumentStore: importData.DocumentStore || [], + DocumentStoreFileChunk: importData.DocumentStoreFileChunk || [], + Execution: importData.Execution || [], + Tool: importData.Tool || [], + Variable: importData.Variable || [] + } + + const conflicts: ImportPreviewConflict[] = [] + + const chatflowRepo = dataSource.getRepository(ChatFlow) + const customTemplateRepo = dataSource.getRepository(CustomTemplate) + const documentStoreRepo = dataSource.getRepository(DocumentStore) + const toolRepo = dataSource.getRepository(Tool) + const variableRepo = dataSource.getRepository(Variable) + const assistantRepo = dataSource.getRepository(Assistant) + + const findFirstImportByName = (items: T[], name: string): T | undefined => + items.find((item) => item.name === name) + + for (const entityKey of conflictEntityKeys) { + const items = normalizedData[entityKey] + if (!items || items.length === 0) continue + + if (chatflowTypeByKey[entityKey]) { + const names = items.map((item: any) => item.name).filter((name: string | undefined) => !!name) + if (names.length === 0) continue + const existingChatflows = await chatflowRepo.find({ + where: { + workspaceId: activeWorkspaceId, + type: chatflowTypeByKey[entityKey], + name: In(names) + } + }) + for (const existing of existingChatflows) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (assistantTypeByKey[entityKey]) { + const importedByName = new Map() + for (const assistant of items as Assistant[]) { + const name = getAssistantName(assistant.details) + if (name) { + importedByName.set(name, assistant) + } + } + if (importedByName.size === 0) continue + + const existingAssistants = await assistantRepo.find({ + where: { + workspaceId: activeWorkspaceId, + type: assistantTypeByKey[entityKey] + } + }) + for (const existing of existingAssistants) { + const existingName = getAssistantName(existing.details) + if (!existingName) continue + const importItem = importedByName.get(existingName) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existingName, + existingId: existing.id, + importId: importItem.id + }) + } + } + continue + } + + if (entityKey === 'CustomTemplate') { + const names = (items as CustomTemplate[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingTemplates = await customTemplateRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingTemplates) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (entityKey === 'DocumentStore') { + const names = (items as DocumentStore[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingStores = await documentStoreRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingStores) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (entityKey === 'Tool') { + const names = (items as Tool[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingTools = await toolRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingTools) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (entityKey === 'Variable') { + const names = (items as Variable[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingVariables = await variableRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingVariables) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + } + } + + conflicts.sort((a, b) => { + if (a.type === b.type) return a.name.localeCompare(b.name) + return conflictEntityKeys.indexOf(a.type) - conflictEntityKeys.indexOf(b.type) + }) + + return { + conflicts + } + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: exportImportService.previewImportData - ${getErrorMessage(error)}` + ) + } +} + +async function replaceDuplicateIdsForChatFlow( + queryRunner: QueryRunner, + originalData: ExportData, + chatflows: ChatFlow[], + idsToSkip: Set = new Set() +) { try { const ids = chatflows.map((chatflow) => chatflow.id) const records = await queryRunner.manager.find(ChatFlow, { @@ -190,6 +492,7 @@ async function replaceDuplicateIdsForChatFlow(queryRunner: QueryRunner, original }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -203,7 +506,12 @@ async function replaceDuplicateIdsForChatFlow(queryRunner: QueryRunner, original } } -async function replaceDuplicateIdsForAssistant(queryRunner: QueryRunner, originalData: ExportData, assistants: Assistant[]) { +async function replaceDuplicateIdsForAssistant( + queryRunner: QueryRunner, + originalData: ExportData, + assistants: Assistant[], + idsToSkip: Set = new Set() +) { try { const ids = assistants.map((assistant) => assistant.id) const records = await queryRunner.manager.find(Assistant, { @@ -211,6 +519,7 @@ async function replaceDuplicateIdsForAssistant(queryRunner: QueryRunner, origina }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -451,7 +760,12 @@ async function replaceDuplicateIdsForChatMessageFeedback( } } -async function replaceDuplicateIdsForCustomTemplate(queryRunner: QueryRunner, originalData: ExportData, customTemplates: CustomTemplate[]) { +async function replaceDuplicateIdsForCustomTemplate( + queryRunner: QueryRunner, + originalData: ExportData, + customTemplates: CustomTemplate[], + idsToSkip: Set = new Set() +) { try { const ids = customTemplates.map((customTemplate) => customTemplate.id) const records = await queryRunner.manager.find(CustomTemplate, { @@ -459,6 +773,7 @@ async function replaceDuplicateIdsForCustomTemplate(queryRunner: QueryRunner, or }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -472,7 +787,12 @@ async function replaceDuplicateIdsForCustomTemplate(queryRunner: QueryRunner, or } } -async function replaceDuplicateIdsForDocumentStore(queryRunner: QueryRunner, originalData: ExportData, documentStores: DocumentStore[]) { +async function replaceDuplicateIdsForDocumentStore( + queryRunner: QueryRunner, + originalData: ExportData, + documentStores: DocumentStore[], + idsToSkip: Set = new Set() +) { try { const ids = documentStores.map((documentStore) => documentStore.id) const records = await queryRunner.manager.find(DocumentStore, { @@ -480,6 +800,7 @@ async function replaceDuplicateIdsForDocumentStore(queryRunner: QueryRunner, ori }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -522,7 +843,12 @@ async function replaceDuplicateIdsForDocumentStoreFileChunk( } } -async function replaceDuplicateIdsForTool(queryRunner: QueryRunner, originalData: ExportData, tools: Tool[]) { +async function replaceDuplicateIdsForTool( + queryRunner: QueryRunner, + originalData: ExportData, + tools: Tool[], + idsToSkip: Set = new Set() +) { try { const ids = tools.map((tool) => tool.id) const records = await queryRunner.manager.find(Tool, { @@ -530,6 +856,7 @@ async function replaceDuplicateIdsForTool(queryRunner: QueryRunner, originalData }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -543,7 +870,12 @@ async function replaceDuplicateIdsForTool(queryRunner: QueryRunner, originalData } } -async function replaceDuplicateIdsForVariable(queryRunner: QueryRunner, originalData: ExportData, variables: Variable[]) { +async function replaceDuplicateIdsForVariable( + queryRunner: QueryRunner, + originalData: ExportData, + variables: Variable[], + idsToSkip: Set = new Set() +) { try { const ids = variables.map((variable) => variable.id) const records = await queryRunner.manager.find(Variable, { @@ -553,6 +885,7 @@ async function replaceDuplicateIdsForVariable(queryRunner: QueryRunner, original originalData.Variable = originalData.Variable.filter((variable) => variable.type !== 'runtime') if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -608,8 +941,50 @@ async function saveBatch(manager: EntityManager, entity: any, items: any[], batc } } -const importData = async (importData: ExportData, orgId: string, activeWorkspaceId: string, subscriptionId: string) => { - // Initialize missing properties with empty arrays to avoid "undefined" errors +const importData = async (importData: ImportPayload, orgId: string, activeWorkspaceId: string, subscriptionId: string) => { + importData.AgentFlow = importData.AgentFlow || [] + importData.AgentFlowV2 = importData.AgentFlowV2 || [] + importData.AssistantCustom = importData.AssistantCustom || [] + importData.AssistantFlow = importData.AssistantFlow || [] + importData.AssistantOpenAI = importData.AssistantOpenAI || [] + importData.AssistantAzure = importData.AssistantAzure || [] + importData.ChatFlow = importData.ChatFlow || [] + importData.ChatMessage = importData.ChatMessage || [] + importData.ChatMessageFeedback = importData.ChatMessageFeedback || [] + importData.CustomTemplate = importData.CustomTemplate || [] + importData.DocumentStore = importData.DocumentStore || [] + importData.DocumentStoreFileChunk = importData.DocumentStoreFileChunk || [] + importData.Execution = importData.Execution || [] + importData.Tool = importData.Tool || [] + importData.Variable = importData.Variable || [] + + const conflictResolutions = importData.conflictResolutions || [] + delete importData.conflictResolutions + + const idsToSkipMap = conflictEntityKeys.reduce((acc, key) => { + acc[key] = new Set() + return acc + }, {} as Record>) + + const idRemap: Record = {} + + for (const resolution of conflictResolutions) { + if (!resolution || !resolution.type || !resolution.importId || !resolution.existingId) continue + if (resolution.action === 'update') { + idRemap[resolution.importId] = resolution.existingId + idsToSkipMap[resolution.type].add(resolution.existingId) + } + } + + if (Object.keys(idRemap).length> 0) { + let serialized = JSON.stringify(importData) + for (const [oldId, newId] of Object.entries(idRemap)) { + if (!oldId || !newId || oldId === newId) continue + serialized = serialized.replaceAll(oldId, newId) + } + importData = JSON.parse(serialized) + } + importData.AgentFlow = importData.AgentFlow || [] importData.AgentFlowV2 = importData.AgentFlowV2 || [] importData.AssistantCustom = importData.AssistantCustom || [] @@ -636,89 +1011,134 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace importData.AgentFlow = reduceSpaceForChatflowFlowData(importData.AgentFlow) importData.AgentFlow = insertWorkspaceId(importData.AgentFlow, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('MULTIAGENT', orgId) - const newChatflowCount = importData.AgentFlow.length + const newChatflowCount = importData.AgentFlow.filter((chatflow) => !idsToSkipMap.AgentFlow.has(chatflow.id)).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.AgentFlow) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.AgentFlow, + idsToSkipMap.AgentFlow + ) } if (importData.AgentFlowV2.length> 0) { importData.AgentFlowV2 = reduceSpaceForChatflowFlowData(importData.AgentFlowV2) importData.AgentFlowV2 = insertWorkspaceId(importData.AgentFlowV2, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('AGENTFLOW', orgId) - const newChatflowCount = importData.AgentFlowV2.length + const newChatflowCount = importData.AgentFlowV2.filter( + (chatflow) => !idsToSkipMap.AgentFlowV2.has(chatflow.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.AgentFlowV2) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.AgentFlowV2, + idsToSkipMap.AgentFlowV2 + ) } if (importData.AssistantCustom.length> 0) { importData.AssistantCustom = insertWorkspaceId(importData.AssistantCustom, activeWorkspaceId) const existingAssistantCount = await assistantsService.getAssistantsCountByOrganization('CUSTOM', orgId) - const newAssistantCount = importData.AssistantCustom.length + const newAssistantCount = importData.AssistantCustom.filter( + (assistant) => !idsToSkipMap.AssistantCustom.has(assistant.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount ) - importData = await replaceDuplicateIdsForAssistant(queryRunner, importData, importData.AssistantCustom) + importData = await replaceDuplicateIdsForAssistant( + queryRunner, + importData, + importData.AssistantCustom, + idsToSkipMap.AssistantCustom + ) } if (importData.AssistantFlow.length> 0) { importData.AssistantFlow = reduceSpaceForChatflowFlowData(importData.AssistantFlow) importData.AssistantFlow = insertWorkspaceId(importData.AssistantFlow, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('ASSISTANT', orgId) - const newChatflowCount = importData.AssistantFlow.length + const newChatflowCount = importData.AssistantFlow.filter( + (chatflow) => !idsToSkipMap.AssistantFlow.has(chatflow.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.AssistantFlow) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.AssistantFlow, + idsToSkipMap.AssistantFlow + ) } if (importData.AssistantOpenAI.length> 0) { importData.AssistantOpenAI = insertWorkspaceId(importData.AssistantOpenAI, activeWorkspaceId) const existingAssistantCount = await assistantsService.getAssistantsCountByOrganization('OPENAI', orgId) - const newAssistantCount = importData.AssistantOpenAI.length + const newAssistantCount = importData.AssistantOpenAI.filter( + (assistant) => !idsToSkipMap.AssistantOpenAI.has(assistant.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount ) - importData = await replaceDuplicateIdsForAssistant(queryRunner, importData, importData.AssistantOpenAI) + importData = await replaceDuplicateIdsForAssistant( + queryRunner, + importData, + importData.AssistantOpenAI, + idsToSkipMap.AssistantOpenAI + ) } if (importData.AssistantAzure.length> 0) { importData.AssistantAzure = insertWorkspaceId(importData.AssistantAzure, activeWorkspaceId) const existingAssistantCount = await assistantsService.getAssistantsCountByOrganization('AZURE', orgId) - const newAssistantCount = importData.AssistantAzure.length + const newAssistantCount = importData.AssistantAzure.filter( + (assistant) => !idsToSkipMap.AssistantAzure.has(assistant.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount ) - importData = await replaceDuplicateIdsForAssistant(queryRunner, importData, importData.AssistantAzure) + importData = await replaceDuplicateIdsForAssistant( + queryRunner, + importData, + importData.AssistantAzure, + idsToSkipMap.AssistantAzure + ) } if (importData.ChatFlow.length> 0) { importData.ChatFlow = reduceSpaceForChatflowFlowData(importData.ChatFlow) importData.ChatFlow = insertWorkspaceId(importData.ChatFlow, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('CHATFLOW', orgId) - const newChatflowCount = importData.ChatFlow.length + const newChatflowCount = importData.ChatFlow.filter((chatflow) => !idsToSkipMap.ChatFlow.has(chatflow.id)).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.ChatFlow) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.ChatFlow, + idsToSkipMap.ChatFlow + ) } if (importData.ChatMessage.length> 0) { importData = await replaceDuplicateIdsForChatMessage(queryRunner, importData, importData.ChatMessage, activeWorkspaceId) @@ -733,17 +1153,27 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace ) if (importData.CustomTemplate.length> 0) { importData.CustomTemplate = insertWorkspaceId(importData.CustomTemplate, activeWorkspaceId) - importData = await replaceDuplicateIdsForCustomTemplate(queryRunner, importData, importData.CustomTemplate) + importData = await replaceDuplicateIdsForCustomTemplate( + queryRunner, + importData, + importData.CustomTemplate, + idsToSkipMap.CustomTemplate + ) } if (importData.DocumentStore.length> 0) { importData.DocumentStore = insertWorkspaceId(importData.DocumentStore, activeWorkspaceId) - importData = await replaceDuplicateIdsForDocumentStore(queryRunner, importData, importData.DocumentStore) + importData = await replaceDuplicateIdsForDocumentStore( + queryRunner, + importData, + importData.DocumentStore, + idsToSkipMap.DocumentStore + ) } if (importData.DocumentStoreFileChunk.length> 0) importData = await replaceDuplicateIdsForDocumentStoreFileChunk(queryRunner, importData, importData.DocumentStoreFileChunk) if (importData.Tool.length> 0) { importData.Tool = insertWorkspaceId(importData.Tool, activeWorkspaceId) - importData = await replaceDuplicateIdsForTool(queryRunner, importData, importData.Tool) + importData = await replaceDuplicateIdsForTool(queryRunner, importData, importData.Tool, idsToSkipMap.Tool) } if (importData.Execution.length> 0) { importData.Execution = insertWorkspaceId(importData.Execution, activeWorkspaceId) @@ -751,7 +1181,12 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace } if (importData.Variable.length> 0) { importData.Variable = insertWorkspaceId(importData.Variable, activeWorkspaceId) - importData = await replaceDuplicateIdsForVariable(queryRunner, importData, importData.Variable) + importData = await replaceDuplicateIdsForVariable( + queryRunner, + importData, + importData.Variable, + idsToSkipMap.Variable + ) } importData = sanitizeNullBytes(importData) @@ -794,5 +1229,6 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace export default { convertExportInput, exportData, + previewImportData, importData } diff --git a/packages/ui/src/api/exportimport.js b/packages/ui/src/api/exportimport.js index 7cab1a13997..922157d69f4 100644 --- a/packages/ui/src/api/exportimport.js +++ b/packages/ui/src/api/exportimport.js @@ -2,8 +2,10 @@ import client from './client' const exportData = (body) => client.post('/export-import/export', body) const importData = (body) => client.post('/export-import/import', body) +const previewImportData = (body) => client.post('/export-import/preview', body) export default { exportData, - importData + importData, + previewImportData } diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index 1e47faeddfd..d6f4947a984 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -15,13 +15,16 @@ import { Button, ButtonBase, Checkbox, + Chip, ClickAwayListener, + CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControlLabel, + Switch, List, ListItemButton, ListItemIcon, @@ -71,6 +74,8 @@ const dataToExport = [ 'Variables' ] +const getConflictKey = (conflict) => `${conflict.type}:${conflict.importId}` + const ExportDialog = ({ show, onCancel, onExport }) => { const portalElement = document.getElementById('portal') @@ -175,6 +180,142 @@ ExportDialog.propTypes = { onExport: PropTypes.func } +const ImportReviewDialog = ({ + show, + loading, + conflicts, + decisions, + onDecisionChange, + onApplyAll, + onCancel, + onConfirm, + disableConfirm +}) => { + const portalElement = document.getElementById('portal') + + const allDuplicate = + conflicts.length> 0 && conflicts.every((conflict) => decisions[getConflictKey(conflict)] === 'duplicate') + + const component = show ? ( + + + Review Import + + + {loading ? ( + + + + Analyzing imported data. This may take a moment. + + + ) : ( + + + Flowise defaults to updating elements when a name conflict is detected. Use the controls below to + choose whether each conflicting element should be updated or duplicated. + + {conflicts.length === 0 ? ( + + No conflicts detected + + All imported items will be added or updated automatically. + + + ) : ( + + onApplyAll(event.target.checked ? 'duplicate' : 'update')} + /> + } + label={ + allDuplicate + ? 'Duplicate all conflicts' + : 'Update conflicts by default' + } + /> + + {conflicts.map((conflict) => { + const key = getConflictKey(conflict) + const action = decisions[key] || 'update' + return ( + + + + + {conflict.name} + + + Existing ID: {conflict.existingId} + + + + onDecisionChange( + conflict, + event.target.checked ? 'duplicate' : 'update' + ) + } + /> + } + label={action === 'duplicate' ? 'Duplicate' : 'Update'} + /> + + ) + })} + + + )} + + )} + + + + + + + ) : null + + return createPortal(component, portalElement) +} + +ImportReviewDialog.propTypes = { + show: PropTypes.bool, + loading: PropTypes.bool, + conflicts: PropTypes.array, + decisions: PropTypes.object, + onDecisionChange: PropTypes.func, + onApplyAll: PropTypes.func, + onCancel: PropTypes.func, + onConfirm: PropTypes.func, + disableConfirm: PropTypes.bool +} + const ImportDialog = ({ show }) => { const portalElement = document.getElementById('portal') @@ -222,6 +363,10 @@ const ProfileSection = ({ handleLogout }) => { const [exportDialogOpen, setExportDialogOpen] = useState(false) const [importDialogOpen, setImportDialogOpen] = useState(false) + const [importReviewOpen, setImportReviewOpen] = useState(false) + const [importConflicts, setImportConflicts] = useState([]) + const [conflictDecisions, setConflictDecisions] = useState({}) + const [pendingImportPayload, setPendingImportPayload] = useState(null) const anchorRef = useRef(null) const inputRef = useRef() @@ -230,6 +375,7 @@ const ProfileSection = ({ handleLogout }) => { const currentUser = useSelector((state) => state.auth.user) const isAuthenticated = useSelector((state) => state.auth.isAuthenticated) + const previewImportApi = useApi(exportImportApi.previewImportData) const importAllApi = useApi(exportImportApi.importData) const exportAllApi = useApi(exportImportApi.exportData) const prevOpen = useRef(open) @@ -272,19 +418,79 @@ const ProfileSection = ({ handleLogout }) => { if (!e.target.files) return const file = e.target.files[0] - setImportDialogOpen(true) + if (!file) return const reader = new FileReader() reader.onload = (evt) => { - if (!evt?.target?.result) { - return + try { + if (!evt?.target?.result) { + throw new Error('Empty file') + } + const body = JSON.parse(evt.target.result) + setPendingImportPayload(body) + setImportConflicts([]) + setConflictDecisions({}) + setImportReviewOpen(true) + previewImportApi.request(body) + } catch (error) { + setImportReviewOpen(false) + setPendingImportPayload(null) + errorFailed(`Failed to read import file: ${getErrorMessage(error)}`) + } finally { + if (inputRef.current) inputRef.current.value = '' } - const body = JSON.parse(evt.target.result) - importAllApi.request(body) + } + reader.onerror = () => { + setImportReviewOpen(false) + setPendingImportPayload(null) + errorFailed('Failed to read import file') + if (inputRef.current) inputRef.current.value = '' } reader.readAsText(file) } + const handleConflictDecisionChange = (conflict, action) => { + setConflictDecisions((prev) => ({ + ...prev, + [getConflictKey(conflict)]: action + })) + } + + const handleApplyAllConflicts = (action) => { + setConflictDecisions((prev) => { + const updated = { ...prev } + importConflicts.forEach((conflict) => { + updated[getConflictKey(conflict)] = action + }) + return updated + }) + } + + const handleImportReviewCancel = () => { + setImportReviewOpen(false) + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) + if (inputRef.current) inputRef.current.value = '' + } + + const handleConfirmImport = () => { + if (!pendingImportPayload) return + const conflictResolutions = importConflicts.map((conflict) => ({ + type: conflict.type, + importId: conflict.importId, + existingId: conflict.existingId, + action: conflictDecisions[getConflictKey(conflict)] || 'update' + })) + const body = { + ...pendingImportPayload, + conflictResolutions + } + setImportDialogOpen(true) + setImportReviewOpen(false) + importAllApi.request(body) + } + const importAllSuccess = () => { setImportDialogOpen(false) dispatch({ type: REMOVE_DIRTY }) @@ -306,6 +512,36 @@ const ProfileSection = ({ handleLogout }) => { inputRef.current.click() } + useEffect(() => { + if (previewImportApi.loading) return + if (!importReviewOpen) return + if (!pendingImportPayload) return + if (!previewImportApi.data) return + const conflicts = previewImportApi.data.conflicts || [] + setImportConflicts(conflicts) + const initialDecisions = {} + conflicts.forEach((conflict) => { + initialDecisions[getConflictKey(conflict)] = 'update' + }) + setConflictDecisions(initialDecisions) + }, [previewImportApi.data, previewImportApi.loading, importReviewOpen, pendingImportPayload]) + + useEffect(() => { + if (!previewImportApi.error) return + setImportReviewOpen(false) + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) + let errMsg = 'Invalid Imported File' + let error = previewImportApi.error + if (error?.response?.data) { + errMsg = typeof error.response.data === 'object' ? error.response.data.message : error.response.data + } + errorFailed(`Failed to analyze import: ${errMsg}`) + if (inputRef.current) inputRef.current.value = '' + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewImportApi.error]) + const onExport = (data) => { const body = {} if (data.includes('Agentflows')) body.agentflow = true @@ -328,6 +564,9 @@ const ProfileSection = ({ handleLogout }) => { useEffect(() => { if (importAllApi.data) { importAllSuccess() + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) navigate(0) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -336,6 +575,9 @@ const ProfileSection = ({ handleLogout }) => { useEffect(() => { if (importAllApi.error) { setImportDialogOpen(false) + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) let errMsg = 'Invalid Imported File' let error = importAllApi.error if (error?.response?.data) { @@ -534,6 +776,17 @@ const ProfileSection = ({ handleLogout }) => { setAboutDialogOpen(false)} /> setExportDialogOpen(false)} onExport={(data) => onExport(data)} /> + ) From a329306c301a37dde786a1bc80656336c205e4f1 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月13日 23:18:15 +0200 Subject: [PATCH 02/13] Fix assistant conflict type mapping --- .../src/controllers/export-import/index.ts | 28 +- .../server/src/routes/export-import/index.ts | 2 + .../src/services/export-import/index.ts | 492 +++++++++++++++++- packages/ui/src/api/exportimport.js | 4 +- .../Header/ProfileSection/index.jsx | 263 +++++++++- 5 files changed, 754 insertions(+), 35 deletions(-) diff --git a/packages/server/src/controllers/export-import/index.ts b/packages/server/src/controllers/export-import/index.ts index b066df0c8a8..80e99976b6d 100644 --- a/packages/server/src/controllers/export-import/index.ts +++ b/packages/server/src/controllers/export-import/index.ts @@ -45,7 +45,33 @@ const importData = async (req: Request, res: Response, next: NextFunction) => { } } +const previewImportData = async (req: Request, res: Response, next: NextFunction) => { + try { + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError( + StatusCodes.NOT_FOUND, + `Error: exportImportController.previewImportData - workspace ${workspaceId} not found!` + ) + } + + const importData = req.body + if (!importData) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + 'Error: exportImportController.previewImportData - importData is required!' + ) + } + + const preview = await exportImportService.previewImportData(importData, workspaceId) + return res.status(StatusCodes.OK).json(preview) + } catch (error) { + next(error) + } +} + export default { exportData, - importData + importData, + previewImportData } diff --git a/packages/server/src/routes/export-import/index.ts b/packages/server/src/routes/export-import/index.ts index 17b28a7c346..a34a1eecf8f 100644 --- a/packages/server/src/routes/export-import/index.ts +++ b/packages/server/src/routes/export-import/index.ts @@ -5,6 +5,8 @@ const router = express.Router() router.post('/export', checkPermission('workspace:export'), exportImportController.exportData) +router.post('/preview', checkPermission('workspace:import'), exportImportController.previewImportData) + router.post('/import', checkPermission('workspace:import'), exportImportController.importData) export default router diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 8873f9824da..36f0e9f9cf6 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -2,7 +2,7 @@ import { StatusCodes } from 'http-status-codes' import { EntityManager, In, QueryRunner } from 'typeorm' import { v4 as uuidv4 } from 'uuid' import { Assistant } from '../../database/entities/Assistant' -import { ChatFlow } from '../../database/entities/ChatFlow' +import { ChatFlow, EnumChatflowType } from '../../database/entities/ChatFlow' import { ChatMessage } from '../../database/entities/ChatMessage' import { ChatMessageFeedback } from '../../database/entities/ChatMessageFeedback' import { CustomTemplate } from '../../database/entities/CustomTemplate' @@ -25,7 +25,7 @@ import executionService, { ExecutionFilters } from '../executions' import marketplacesService from '../marketplaces' import toolsService from '../tools' import variableService from '../variables' -import { Platform } from '../../Interface' +import { AssistantType, Platform } from '../../Interface' import { sanitizeNullBytes } from '../../utils/sanitize.util' type ExportInput = { @@ -62,6 +62,85 @@ type ExportData = { Variable: Variable[] } +type ConflictEntityKey = keyof Pick< + ExportData, + | 'AgentFlow' + | 'AgentFlowV2' + | 'AssistantFlow' + | 'AssistantCustom' + | 'AssistantOpenAI' + | 'AssistantAzure' + | 'ChatFlow' + | 'CustomTemplate' + | 'DocumentStore' + | 'Tool' + | 'Variable' +> + +type ConflictResolutionAction = 'update' | 'duplicate' + +type ConflictResolution = { + type: ConflictEntityKey + importId: string + existingId: string + action: ConflictResolutionAction +} + +type ImportPayload = ExportData & { + conflictResolutions?: ConflictResolution[] +} + +type ImportPreviewConflict = { + type: ConflictEntityKey + name: string + importId: string + existingId: string +} + +type ImportPreview = { + conflicts: ImportPreviewConflict[] +} + +const chatflowTypeByKey: Partial> = { + AgentFlow: EnumChatflowType.MULTIAGENT, + AgentFlowV2: EnumChatflowType.AGENTFLOW, + AssistantFlow: EnumChatflowType.ASSISTANT, + ChatFlow: EnumChatflowType.CHATFLOW +} + +const assistantTypeByKey: Partial> = { + AssistantCustom: 'CUSTOM', + AssistantOpenAI: 'OPENAI', + AssistantAzure: 'AZURE' +} + +const conflictEntityKeys: ConflictEntityKey[] = [ + 'AgentFlow', + 'AgentFlowV2', + 'AssistantFlow', + 'AssistantCustom', + 'AssistantOpenAI', + 'AssistantAzure', + 'ChatFlow', + 'CustomTemplate', + 'DocumentStore', + 'Tool', + 'Variable' +] + +const getAssistantName = (details?: string): string | undefined => { + if (!details) return undefined + try { + const parsed = JSON.parse(details) + if (parsed && typeof parsed === 'object' && parsed.name) { + return parsed.name + } + } catch (error) { + // ignore malformed assistant details + } + return undefined +} + const convertExportInput = (body: any): ExportInput => { try { if (!body || typeof body !== 'object') throw new Error('Invalid ExportInput object in request body') @@ -182,7 +261,230 @@ const exportData = async (exportInput: ExportInput, activeWorkspaceId?: string): } } -async function replaceDuplicateIdsForChatFlow(queryRunner: QueryRunner, originalData: ExportData, chatflows: ChatFlow[]) { +const previewImportData = async (importData: ExportData, activeWorkspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + if (!appServer) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, 'Error: Unable to access application server') + } + + const dataSource = appServer.AppDataSource + if (!dataSource) { + throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, 'Error: Database connection not available') + } + + const normalizedData: ImportPayload = { + AgentFlow: importData.AgentFlow || [], + AgentFlowV2: importData.AgentFlowV2 || [], + AssistantCustom: importData.AssistantCustom || [], + AssistantFlow: importData.AssistantFlow || [], + AssistantOpenAI: importData.AssistantOpenAI || [], + AssistantAzure: importData.AssistantAzure || [], + ChatFlow: importData.ChatFlow || [], + ChatMessage: importData.ChatMessage || [], + ChatMessageFeedback: importData.ChatMessageFeedback || [], + CustomTemplate: importData.CustomTemplate || [], + DocumentStore: importData.DocumentStore || [], + DocumentStoreFileChunk: importData.DocumentStoreFileChunk || [], + Execution: importData.Execution || [], + Tool: importData.Tool || [], + Variable: importData.Variable || [] + } + + const conflicts: ImportPreviewConflict[] = [] + + const chatflowRepo = dataSource.getRepository(ChatFlow) + const customTemplateRepo = dataSource.getRepository(CustomTemplate) + const documentStoreRepo = dataSource.getRepository(DocumentStore) + const toolRepo = dataSource.getRepository(Tool) + const variableRepo = dataSource.getRepository(Variable) + const assistantRepo = dataSource.getRepository(Assistant) + + const findFirstImportByName = (items: T[], name: string): T | undefined => + items.find((item) => item.name === name) + + for (const entityKey of conflictEntityKeys) { + const items = normalizedData[entityKey] + if (!items || items.length === 0) continue + + if (chatflowTypeByKey[entityKey]) { + const names = items.map((item: any) => item.name).filter((name: string | undefined) => !!name) + if (names.length === 0) continue + const existingChatflows = await chatflowRepo.find({ + where: { + workspaceId: activeWorkspaceId, + type: chatflowTypeByKey[entityKey], + name: In(names) + } + }) + for (const existing of existingChatflows) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (assistantTypeByKey[entityKey]) { + const importedByName = new Map() + for (const assistant of items as Assistant[]) { + const name = getAssistantName(assistant.details) + if (name) { + importedByName.set(name, assistant) + } + } + if (importedByName.size === 0) continue + + const existingAssistants = await assistantRepo.find({ + where: { + workspaceId: activeWorkspaceId, + type: assistantTypeByKey[entityKey] + } + }) + for (const existing of existingAssistants) { + const existingName = getAssistantName(existing.details) + if (!existingName) continue + const importItem = importedByName.get(existingName) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existingName, + existingId: existing.id, + importId: importItem.id + }) + } + } + continue + } + + if (entityKey === 'CustomTemplate') { + const names = (items as CustomTemplate[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingTemplates = await customTemplateRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingTemplates) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (entityKey === 'DocumentStore') { + const names = (items as DocumentStore[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingStores = await documentStoreRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingStores) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (entityKey === 'Tool') { + const names = (items as Tool[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingTools = await toolRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingTools) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + continue + } + + if (entityKey === 'Variable') { + const names = (items as Variable[]) + .map((item) => item.name) + .filter((name): name is string => !!name) + if (names.length === 0) continue + const existingVariables = await variableRepo.find({ + where: { + workspaceId: activeWorkspaceId, + name: In(names) + } + }) + for (const existing of existingVariables) { + const importItem = findFirstImportByName(items as any[], existing.name) + if (importItem) { + conflicts.push({ + type: entityKey, + name: existing.name, + existingId: existing.id, + importId: (importItem as any).id + }) + } + } + } + } + + conflicts.sort((a, b) => { + if (a.type === b.type) return a.name.localeCompare(b.name) + return conflictEntityKeys.indexOf(a.type) - conflictEntityKeys.indexOf(b.type) + }) + + return { + conflicts + } + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: exportImportService.previewImportData - ${getErrorMessage(error)}` + ) + } +} + +async function replaceDuplicateIdsForChatFlow( + queryRunner: QueryRunner, + originalData: ExportData, + chatflows: ChatFlow[], + idsToSkip: Set = new Set() +) { try { const ids = chatflows.map((chatflow) => chatflow.id) const records = await queryRunner.manager.find(ChatFlow, { @@ -190,6 +492,7 @@ async function replaceDuplicateIdsForChatFlow(queryRunner: QueryRunner, original }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -203,7 +506,12 @@ async function replaceDuplicateIdsForChatFlow(queryRunner: QueryRunner, original } } -async function replaceDuplicateIdsForAssistant(queryRunner: QueryRunner, originalData: ExportData, assistants: Assistant[]) { +async function replaceDuplicateIdsForAssistant( + queryRunner: QueryRunner, + originalData: ExportData, + assistants: Assistant[], + idsToSkip: Set = new Set() +) { try { const ids = assistants.map((assistant) => assistant.id) const records = await queryRunner.manager.find(Assistant, { @@ -211,6 +519,7 @@ async function replaceDuplicateIdsForAssistant(queryRunner: QueryRunner, origina }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -451,7 +760,12 @@ async function replaceDuplicateIdsForChatMessageFeedback( } } -async function replaceDuplicateIdsForCustomTemplate(queryRunner: QueryRunner, originalData: ExportData, customTemplates: CustomTemplate[]) { +async function replaceDuplicateIdsForCustomTemplate( + queryRunner: QueryRunner, + originalData: ExportData, + customTemplates: CustomTemplate[], + idsToSkip: Set = new Set() +) { try { const ids = customTemplates.map((customTemplate) => customTemplate.id) const records = await queryRunner.manager.find(CustomTemplate, { @@ -459,6 +773,7 @@ async function replaceDuplicateIdsForCustomTemplate(queryRunner: QueryRunner, or }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -472,7 +787,12 @@ async function replaceDuplicateIdsForCustomTemplate(queryRunner: QueryRunner, or } } -async function replaceDuplicateIdsForDocumentStore(queryRunner: QueryRunner, originalData: ExportData, documentStores: DocumentStore[]) { +async function replaceDuplicateIdsForDocumentStore( + queryRunner: QueryRunner, + originalData: ExportData, + documentStores: DocumentStore[], + idsToSkip: Set = new Set() +) { try { const ids = documentStores.map((documentStore) => documentStore.id) const records = await queryRunner.manager.find(DocumentStore, { @@ -480,6 +800,7 @@ async function replaceDuplicateIdsForDocumentStore(queryRunner: QueryRunner, ori }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -522,7 +843,12 @@ async function replaceDuplicateIdsForDocumentStoreFileChunk( } } -async function replaceDuplicateIdsForTool(queryRunner: QueryRunner, originalData: ExportData, tools: Tool[]) { +async function replaceDuplicateIdsForTool( + queryRunner: QueryRunner, + originalData: ExportData, + tools: Tool[], + idsToSkip: Set = new Set() +) { try { const ids = tools.map((tool) => tool.id) const records = await queryRunner.manager.find(Tool, { @@ -530,6 +856,7 @@ async function replaceDuplicateIdsForTool(queryRunner: QueryRunner, originalData }) if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -543,7 +870,12 @@ async function replaceDuplicateIdsForTool(queryRunner: QueryRunner, originalData } } -async function replaceDuplicateIdsForVariable(queryRunner: QueryRunner, originalData: ExportData, variables: Variable[]) { +async function replaceDuplicateIdsForVariable( + queryRunner: QueryRunner, + originalData: ExportData, + variables: Variable[], + idsToSkip: Set = new Set() +) { try { const ids = variables.map((variable) => variable.id) const records = await queryRunner.manager.find(Variable, { @@ -553,6 +885,7 @@ async function replaceDuplicateIdsForVariable(queryRunner: QueryRunner, original originalData.Variable = originalData.Variable.filter((variable) => variable.type !== 'runtime') if (records.length < 0) return originalData for (let record of records) { + if (idsToSkip.has(record.id)) continue const oldId = record.id const newId = uuidv4() originalData = JSON.parse(JSON.stringify(originalData).replaceAll(oldId, newId)) @@ -608,8 +941,50 @@ async function saveBatch(manager: EntityManager, entity: any, items: any[], batc } } -const importData = async (importData: ExportData, orgId: string, activeWorkspaceId: string, subscriptionId: string) => { - // Initialize missing properties with empty arrays to avoid "undefined" errors +const importData = async (importData: ImportPayload, orgId: string, activeWorkspaceId: string, subscriptionId: string) => { + importData.AgentFlow = importData.AgentFlow || [] + importData.AgentFlowV2 = importData.AgentFlowV2 || [] + importData.AssistantCustom = importData.AssistantCustom || [] + importData.AssistantFlow = importData.AssistantFlow || [] + importData.AssistantOpenAI = importData.AssistantOpenAI || [] + importData.AssistantAzure = importData.AssistantAzure || [] + importData.ChatFlow = importData.ChatFlow || [] + importData.ChatMessage = importData.ChatMessage || [] + importData.ChatMessageFeedback = importData.ChatMessageFeedback || [] + importData.CustomTemplate = importData.CustomTemplate || [] + importData.DocumentStore = importData.DocumentStore || [] + importData.DocumentStoreFileChunk = importData.DocumentStoreFileChunk || [] + importData.Execution = importData.Execution || [] + importData.Tool = importData.Tool || [] + importData.Variable = importData.Variable || [] + + const conflictResolutions = importData.conflictResolutions || [] + delete importData.conflictResolutions + + const idsToSkipMap = conflictEntityKeys.reduce((acc, key) => { + acc[key] = new Set() + return acc + }, {} as Record>) + + const idRemap: Record = {} + + for (const resolution of conflictResolutions) { + if (!resolution || !resolution.type || !resolution.importId || !resolution.existingId) continue + if (resolution.action === 'update') { + idRemap[resolution.importId] = resolution.existingId + idsToSkipMap[resolution.type].add(resolution.existingId) + } + } + + if (Object.keys(idRemap).length> 0) { + let serialized = JSON.stringify(importData) + for (const [oldId, newId] of Object.entries(idRemap)) { + if (!oldId || !newId || oldId === newId) continue + serialized = serialized.replaceAll(oldId, newId) + } + importData = JSON.parse(serialized) + } + importData.AgentFlow = importData.AgentFlow || [] importData.AgentFlowV2 = importData.AgentFlowV2 || [] importData.AssistantCustom = importData.AssistantCustom || [] @@ -636,89 +1011,134 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace importData.AgentFlow = reduceSpaceForChatflowFlowData(importData.AgentFlow) importData.AgentFlow = insertWorkspaceId(importData.AgentFlow, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('MULTIAGENT', orgId) - const newChatflowCount = importData.AgentFlow.length + const newChatflowCount = importData.AgentFlow.filter((chatflow) => !idsToSkipMap.AgentFlow.has(chatflow.id)).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.AgentFlow) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.AgentFlow, + idsToSkipMap.AgentFlow + ) } if (importData.AgentFlowV2.length> 0) { importData.AgentFlowV2 = reduceSpaceForChatflowFlowData(importData.AgentFlowV2) importData.AgentFlowV2 = insertWorkspaceId(importData.AgentFlowV2, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('AGENTFLOW', orgId) - const newChatflowCount = importData.AgentFlowV2.length + const newChatflowCount = importData.AgentFlowV2.filter( + (chatflow) => !idsToSkipMap.AgentFlowV2.has(chatflow.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.AgentFlowV2) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.AgentFlowV2, + idsToSkipMap.AgentFlowV2 + ) } if (importData.AssistantCustom.length> 0) { importData.AssistantCustom = insertWorkspaceId(importData.AssistantCustom, activeWorkspaceId) const existingAssistantCount = await assistantsService.getAssistantsCountByOrganization('CUSTOM', orgId) - const newAssistantCount = importData.AssistantCustom.length + const newAssistantCount = importData.AssistantCustom.filter( + (assistant) => !idsToSkipMap.AssistantCustom.has(assistant.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount ) - importData = await replaceDuplicateIdsForAssistant(queryRunner, importData, importData.AssistantCustom) + importData = await replaceDuplicateIdsForAssistant( + queryRunner, + importData, + importData.AssistantCustom, + idsToSkipMap.AssistantCustom + ) } if (importData.AssistantFlow.length> 0) { importData.AssistantFlow = reduceSpaceForChatflowFlowData(importData.AssistantFlow) importData.AssistantFlow = insertWorkspaceId(importData.AssistantFlow, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('ASSISTANT', orgId) - const newChatflowCount = importData.AssistantFlow.length + const newChatflowCount = importData.AssistantFlow.filter( + (chatflow) => !idsToSkipMap.AssistantFlow.has(chatflow.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.AssistantFlow) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.AssistantFlow, + idsToSkipMap.AssistantFlow + ) } if (importData.AssistantOpenAI.length> 0) { importData.AssistantOpenAI = insertWorkspaceId(importData.AssistantOpenAI, activeWorkspaceId) const existingAssistantCount = await assistantsService.getAssistantsCountByOrganization('OPENAI', orgId) - const newAssistantCount = importData.AssistantOpenAI.length + const newAssistantCount = importData.AssistantOpenAI.filter( + (assistant) => !idsToSkipMap.AssistantOpenAI.has(assistant.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount ) - importData = await replaceDuplicateIdsForAssistant(queryRunner, importData, importData.AssistantOpenAI) + importData = await replaceDuplicateIdsForAssistant( + queryRunner, + importData, + importData.AssistantOpenAI, + idsToSkipMap.AssistantOpenAI + ) } if (importData.AssistantAzure.length> 0) { importData.AssistantAzure = insertWorkspaceId(importData.AssistantAzure, activeWorkspaceId) const existingAssistantCount = await assistantsService.getAssistantsCountByOrganization('AZURE', orgId) - const newAssistantCount = importData.AssistantAzure.length + const newAssistantCount = importData.AssistantAzure.filter( + (assistant) => !idsToSkipMap.AssistantAzure.has(assistant.id) + ).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount ) - importData = await replaceDuplicateIdsForAssistant(queryRunner, importData, importData.AssistantAzure) + importData = await replaceDuplicateIdsForAssistant( + queryRunner, + importData, + importData.AssistantAzure, + idsToSkipMap.AssistantAzure + ) } if (importData.ChatFlow.length> 0) { importData.ChatFlow = reduceSpaceForChatflowFlowData(importData.ChatFlow) importData.ChatFlow = insertWorkspaceId(importData.ChatFlow, activeWorkspaceId) const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('CHATFLOW', orgId) - const newChatflowCount = importData.ChatFlow.length + const newChatflowCount = importData.ChatFlow.filter((chatflow) => !idsToSkipMap.ChatFlow.has(chatflow.id)).length await checkUsageLimit( 'flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingChatflowCount + newChatflowCount ) - importData = await replaceDuplicateIdsForChatFlow(queryRunner, importData, importData.ChatFlow) + importData = await replaceDuplicateIdsForChatFlow( + queryRunner, + importData, + importData.ChatFlow, + idsToSkipMap.ChatFlow + ) } if (importData.ChatMessage.length> 0) { importData = await replaceDuplicateIdsForChatMessage(queryRunner, importData, importData.ChatMessage, activeWorkspaceId) @@ -733,17 +1153,27 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace ) if (importData.CustomTemplate.length> 0) { importData.CustomTemplate = insertWorkspaceId(importData.CustomTemplate, activeWorkspaceId) - importData = await replaceDuplicateIdsForCustomTemplate(queryRunner, importData, importData.CustomTemplate) + importData = await replaceDuplicateIdsForCustomTemplate( + queryRunner, + importData, + importData.CustomTemplate, + idsToSkipMap.CustomTemplate + ) } if (importData.DocumentStore.length> 0) { importData.DocumentStore = insertWorkspaceId(importData.DocumentStore, activeWorkspaceId) - importData = await replaceDuplicateIdsForDocumentStore(queryRunner, importData, importData.DocumentStore) + importData = await replaceDuplicateIdsForDocumentStore( + queryRunner, + importData, + importData.DocumentStore, + idsToSkipMap.DocumentStore + ) } if (importData.DocumentStoreFileChunk.length> 0) importData = await replaceDuplicateIdsForDocumentStoreFileChunk(queryRunner, importData, importData.DocumentStoreFileChunk) if (importData.Tool.length> 0) { importData.Tool = insertWorkspaceId(importData.Tool, activeWorkspaceId) - importData = await replaceDuplicateIdsForTool(queryRunner, importData, importData.Tool) + importData = await replaceDuplicateIdsForTool(queryRunner, importData, importData.Tool, idsToSkipMap.Tool) } if (importData.Execution.length> 0) { importData.Execution = insertWorkspaceId(importData.Execution, activeWorkspaceId) @@ -751,7 +1181,12 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace } if (importData.Variable.length> 0) { importData.Variable = insertWorkspaceId(importData.Variable, activeWorkspaceId) - importData = await replaceDuplicateIdsForVariable(queryRunner, importData, importData.Variable) + importData = await replaceDuplicateIdsForVariable( + queryRunner, + importData, + importData.Variable, + idsToSkipMap.Variable + ) } importData = sanitizeNullBytes(importData) @@ -794,5 +1229,6 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace export default { convertExportInput, exportData, + previewImportData, importData } diff --git a/packages/ui/src/api/exportimport.js b/packages/ui/src/api/exportimport.js index 7cab1a13997..922157d69f4 100644 --- a/packages/ui/src/api/exportimport.js +++ b/packages/ui/src/api/exportimport.js @@ -2,8 +2,10 @@ import client from './client' const exportData = (body) => client.post('/export-import/export', body) const importData = (body) => client.post('/export-import/import', body) +const previewImportData = (body) => client.post('/export-import/preview', body) export default { exportData, - importData + importData, + previewImportData } diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index 1e47faeddfd..d6f4947a984 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -15,13 +15,16 @@ import { Button, ButtonBase, Checkbox, + Chip, ClickAwayListener, + CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControlLabel, + Switch, List, ListItemButton, ListItemIcon, @@ -71,6 +74,8 @@ const dataToExport = [ 'Variables' ] +const getConflictKey = (conflict) => `${conflict.type}:${conflict.importId}` + const ExportDialog = ({ show, onCancel, onExport }) => { const portalElement = document.getElementById('portal') @@ -175,6 +180,142 @@ ExportDialog.propTypes = { onExport: PropTypes.func } +const ImportReviewDialog = ({ + show, + loading, + conflicts, + decisions, + onDecisionChange, + onApplyAll, + onCancel, + onConfirm, + disableConfirm +}) => { + const portalElement = document.getElementById('portal') + + const allDuplicate = + conflicts.length> 0 && conflicts.every((conflict) => decisions[getConflictKey(conflict)] === 'duplicate') + + const component = show ? ( + + + Review Import + + + {loading ? ( + + + + Analyzing imported data. This may take a moment. + + + ) : ( + + + Flowise defaults to updating elements when a name conflict is detected. Use the controls below to + choose whether each conflicting element should be updated or duplicated. + + {conflicts.length === 0 ? ( + + No conflicts detected + + All imported items will be added or updated automatically. + + + ) : ( + + onApplyAll(event.target.checked ? 'duplicate' : 'update')} + /> + } + label={ + allDuplicate + ? 'Duplicate all conflicts' + : 'Update conflicts by default' + } + /> + + {conflicts.map((conflict) => { + const key = getConflictKey(conflict) + const action = decisions[key] || 'update' + return ( + + + + + {conflict.name} + + + Existing ID: {conflict.existingId} + + + + onDecisionChange( + conflict, + event.target.checked ? 'duplicate' : 'update' + ) + } + /> + } + label={action === 'duplicate' ? 'Duplicate' : 'Update'} + /> + + ) + })} + + + )} + + )} + + + + + + + ) : null + + return createPortal(component, portalElement) +} + +ImportReviewDialog.propTypes = { + show: PropTypes.bool, + loading: PropTypes.bool, + conflicts: PropTypes.array, + decisions: PropTypes.object, + onDecisionChange: PropTypes.func, + onApplyAll: PropTypes.func, + onCancel: PropTypes.func, + onConfirm: PropTypes.func, + disableConfirm: PropTypes.bool +} + const ImportDialog = ({ show }) => { const portalElement = document.getElementById('portal') @@ -222,6 +363,10 @@ const ProfileSection = ({ handleLogout }) => { const [exportDialogOpen, setExportDialogOpen] = useState(false) const [importDialogOpen, setImportDialogOpen] = useState(false) + const [importReviewOpen, setImportReviewOpen] = useState(false) + const [importConflicts, setImportConflicts] = useState([]) + const [conflictDecisions, setConflictDecisions] = useState({}) + const [pendingImportPayload, setPendingImportPayload] = useState(null) const anchorRef = useRef(null) const inputRef = useRef() @@ -230,6 +375,7 @@ const ProfileSection = ({ handleLogout }) => { const currentUser = useSelector((state) => state.auth.user) const isAuthenticated = useSelector((state) => state.auth.isAuthenticated) + const previewImportApi = useApi(exportImportApi.previewImportData) const importAllApi = useApi(exportImportApi.importData) const exportAllApi = useApi(exportImportApi.exportData) const prevOpen = useRef(open) @@ -272,19 +418,79 @@ const ProfileSection = ({ handleLogout }) => { if (!e.target.files) return const file = e.target.files[0] - setImportDialogOpen(true) + if (!file) return const reader = new FileReader() reader.onload = (evt) => { - if (!evt?.target?.result) { - return + try { + if (!evt?.target?.result) { + throw new Error('Empty file') + } + const body = JSON.parse(evt.target.result) + setPendingImportPayload(body) + setImportConflicts([]) + setConflictDecisions({}) + setImportReviewOpen(true) + previewImportApi.request(body) + } catch (error) { + setImportReviewOpen(false) + setPendingImportPayload(null) + errorFailed(`Failed to read import file: ${getErrorMessage(error)}`) + } finally { + if (inputRef.current) inputRef.current.value = '' } - const body = JSON.parse(evt.target.result) - importAllApi.request(body) + } + reader.onerror = () => { + setImportReviewOpen(false) + setPendingImportPayload(null) + errorFailed('Failed to read import file') + if (inputRef.current) inputRef.current.value = '' } reader.readAsText(file) } + const handleConflictDecisionChange = (conflict, action) => { + setConflictDecisions((prev) => ({ + ...prev, + [getConflictKey(conflict)]: action + })) + } + + const handleApplyAllConflicts = (action) => { + setConflictDecisions((prev) => { + const updated = { ...prev } + importConflicts.forEach((conflict) => { + updated[getConflictKey(conflict)] = action + }) + return updated + }) + } + + const handleImportReviewCancel = () => { + setImportReviewOpen(false) + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) + if (inputRef.current) inputRef.current.value = '' + } + + const handleConfirmImport = () => { + if (!pendingImportPayload) return + const conflictResolutions = importConflicts.map((conflict) => ({ + type: conflict.type, + importId: conflict.importId, + existingId: conflict.existingId, + action: conflictDecisions[getConflictKey(conflict)] || 'update' + })) + const body = { + ...pendingImportPayload, + conflictResolutions + } + setImportDialogOpen(true) + setImportReviewOpen(false) + importAllApi.request(body) + } + const importAllSuccess = () => { setImportDialogOpen(false) dispatch({ type: REMOVE_DIRTY }) @@ -306,6 +512,36 @@ const ProfileSection = ({ handleLogout }) => { inputRef.current.click() } + useEffect(() => { + if (previewImportApi.loading) return + if (!importReviewOpen) return + if (!pendingImportPayload) return + if (!previewImportApi.data) return + const conflicts = previewImportApi.data.conflicts || [] + setImportConflicts(conflicts) + const initialDecisions = {} + conflicts.forEach((conflict) => { + initialDecisions[getConflictKey(conflict)] = 'update' + }) + setConflictDecisions(initialDecisions) + }, [previewImportApi.data, previewImportApi.loading, importReviewOpen, pendingImportPayload]) + + useEffect(() => { + if (!previewImportApi.error) return + setImportReviewOpen(false) + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) + let errMsg = 'Invalid Imported File' + let error = previewImportApi.error + if (error?.response?.data) { + errMsg = typeof error.response.data === 'object' ? error.response.data.message : error.response.data + } + errorFailed(`Failed to analyze import: ${errMsg}`) + if (inputRef.current) inputRef.current.value = '' + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewImportApi.error]) + const onExport = (data) => { const body = {} if (data.includes('Agentflows')) body.agentflow = true @@ -328,6 +564,9 @@ const ProfileSection = ({ handleLogout }) => { useEffect(() => { if (importAllApi.data) { importAllSuccess() + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) navigate(0) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -336,6 +575,9 @@ const ProfileSection = ({ handleLogout }) => { useEffect(() => { if (importAllApi.error) { setImportDialogOpen(false) + setPendingImportPayload(null) + setImportConflicts([]) + setConflictDecisions({}) let errMsg = 'Invalid Imported File' let error = importAllApi.error if (error?.response?.data) { @@ -534,6 +776,17 @@ const ProfileSection = ({ handleLogout }) => { setAboutDialogOpen(false)} /> setExportDialogOpen(false)} onExport={(data) => onExport(data)} /> + ) From 21e9b3dcc8b64a1675213204bbf86deaae827f9c Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月13日 23:57:37 +0200 Subject: [PATCH 03/13] Fix duplicate assistant type mapping declaration --- packages/server/src/services/export-import/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index bbe0b641b25..36f0e9f9cf6 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -109,7 +109,6 @@ const chatflowTypeByKey: Partial> = } const assistantTypeByKey: Partial> = { -const assistantTypeByKey: Partial> = { AssistantCustom: 'CUSTOM', AssistantOpenAI: 'OPENAI', AssistantAzure: 'AZURE' From 5da36994d636084e1747a8610a84bb6b2092f164 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月14日 18:00:41 +0200 Subject: [PATCH 04/13] Enhance import conflict review UI --- .../Header/ProfileSection/index.jsx | 308 +++++++++++++++--- 1 file changed, 254 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index d6f4947a984..d885b7f6a26 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch, useSelector } from 'react-redux' -import { useNavigate } from 'react-router-dom' +import { Link as RouterLink, useNavigate } from 'react-router-dom' import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, REMOVE_DIRTY } from '@/store/actions' import { exportData, stringify } from '@/utils/exportImport' @@ -24,6 +24,7 @@ import { DialogTitle, Divider, FormControlLabel, + Link, Switch, List, ListItemButton, @@ -34,7 +35,7 @@ import { Stack, Typography } from '@mui/material' -import { useTheme } from '@mui/material/styles' +import { alpha, useTheme } from '@mui/material/styles' // third-party import PerfectScrollbar from 'react-perfect-scrollbar' @@ -185,16 +186,66 @@ const ImportReviewDialog = ({ loading, conflicts, decisions, + selections, onDecisionChange, + onSelectionChange, + onToggleAllSelections, onApplyAll, onCancel, onConfirm, disableConfirm }) => { const portalElement = document.getElementById('portal') + const theme = useTheme() const allDuplicate = conflicts.length> 0 && conflicts.every((conflict) => decisions[getConflictKey(conflict)] === 'duplicate') + const allSelected = conflicts.length> 0 && conflicts.every((conflict) => selections[getConflictKey(conflict)]) + + const typeDisplayConfig = useMemo( + () => ({ + AgentFlow: { label: 'Agent Flow', color: theme.palette.info.main }, + AgentFlowV2: { label: 'Agent Flow V2', color: theme.palette.info.dark }, + AssistantFlow: { label: 'Assistant Flow', color: theme.palette.success.main }, + AssistantCustom: { label: 'Custom Assistant', color: theme.palette.warning.main }, + AssistantOpenAI: { label: 'OpenAI Assistant', color: theme.palette.primary.main }, + AssistantAzure: { label: 'Azure Assistant', color: theme.palette.secondary.main }, + ChatFlow: { label: 'Chat Flow', color: theme.palette.primary.dark }, + CustomTemplate: { label: 'Custom Template', color: theme.palette.error.main }, + DocumentStore: { label: 'Document Store', color: theme.palette.secondary.dark }, + Tool: { label: 'Tool', color: theme.palette.success.dark }, + Variable: { label: 'Variable', color: theme.palette.warning.dark } + }), + [theme] + ) + + const groupedConflicts = useMemo(() => { + const groups = new Map() + conflicts.forEach((conflict) => { + if (!groups.has(conflict.type)) { + groups.set(conflict.type, []) + } + groups.get(conflict.type).push(conflict) + }) + return Array.from(groups.entries()) + }, [conflicts]) + + const getExistingLink = (conflict) => { + const linkMap = { + AgentFlow: `/agentcanvas/${conflict.existingId}`, + AgentFlowV2: `/v2/agentcanvas/${conflict.existingId}`, + AssistantFlow: `/canvas/${conflict.existingId}`, + AssistantCustom: `/assistants/custom/${conflict.existingId}`, + AssistantOpenAI: '/assistants/openai', + AssistantAzure: '/assistants/openai', + ChatFlow: `/canvas/${conflict.existingId}`, + CustomTemplate: '/marketplaces', + DocumentStore: `/document-stores/${conflict.existingId}`, + Tool: '/tools', + Variable: '/variables' + } + return linkMap[conflict.type] + } const component = show ? ( @@ -224,63 +275,167 @@ const ImportReviewDialog = ({ ) : ( - onApplyAll(event.target.checked ? 'duplicate' : 'update')} - /> - } - label={ - allDuplicate - ? 'Duplicate all conflicts' - : 'Update conflicts by default' - } - /> + + onToggleAllSelections(event.target.checked)} + /> + } + label='Select all' + /> + onApplyAll(event.target.checked ? 'duplicate' : 'update')} + /> + } + label={ + allDuplicate + ? 'Duplicate all conflicts' + : 'Update conflicts by default' + } + /> + - {conflicts.map((conflict) => { - const key = getConflictKey(conflict) - const action = decisions[key] || 'update' + {groupedConflicts.map(([type, items]) => { + const meta = typeDisplayConfig[type] || { + label: type, + color: theme.palette.grey[500] + } + const accentColor = meta.color || theme.palette.grey[500] + const sectionBackground = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.16 : 0.08 + ) + const borderColor = alpha(accentColor, theme.palette.mode === 'dark' ? 0.4 : 0.25) + const chipText = theme.palette.getContrastText(accentColor) + return ( - - - - {conflict.name} - - - Existing ID: {conflict.existingId} - - - - onDecisionChange( - conflict, - event.target.checked ? 'duplicate' : 'update' - ) - } + + + - } - label={action === 'duplicate' ? 'Duplicate' : 'Update'} - /> + + {items.length} conflict{items.length> 1 ? 's' : ''} + + + + + {items.map((conflict) => { + const key = getConflictKey(conflict) + const action = decisions[key] || 'update' + const isSelected = selections[key] || false + const existingLink = getExistingLink(conflict) + + return ( + + + + onSelectionChange(conflict, event.target.checked) + } + /> + + + {conflict.name} + + + Existing ID:{' '} + {existingLink ? ( + + {conflict.existingId} + + ) : ( + conflict.existingId + )} + + + + + onDecisionChange( + conflict, + event.target.checked + ? 'duplicate' + : 'update' + ) + } + disabled={!isSelected} + /> + } + label={action === 'duplicate' ? 'Duplicate' : 'Update'} + /> + + ) + })} + ) })} @@ -309,7 +464,10 @@ ImportReviewDialog.propTypes = { loading: PropTypes.bool, conflicts: PropTypes.array, decisions: PropTypes.object, + selections: PropTypes.object, onDecisionChange: PropTypes.func, + onSelectionChange: PropTypes.func, + onToggleAllSelections: PropTypes.func, onApplyAll: PropTypes.func, onCancel: PropTypes.func, onConfirm: PropTypes.func, @@ -366,6 +524,7 @@ const ProfileSection = ({ handleLogout }) => { const [importReviewOpen, setImportReviewOpen] = useState(false) const [importConflicts, setImportConflicts] = useState([]) const [conflictDecisions, setConflictDecisions] = useState({}) + const [conflictSelections, setConflictSelections] = useState({}) const [pendingImportPayload, setPendingImportPayload] = useState(null) const anchorRef = useRef(null) @@ -430,11 +589,13 @@ const ProfileSection = ({ handleLogout }) => { setPendingImportPayload(body) setImportConflicts([]) setConflictDecisions({}) + setConflictSelections({}) setImportReviewOpen(true) previewImportApi.request(body) } catch (error) { setImportReviewOpen(false) setPendingImportPayload(null) + setConflictSelections({}) errorFailed(`Failed to read import file: ${getErrorMessage(error)}`) } finally { if (inputRef.current) inputRef.current.value = '' @@ -443,6 +604,7 @@ const ProfileSection = ({ handleLogout }) => { reader.onerror = () => { setImportReviewOpen(false) setPendingImportPayload(null) + setConflictSelections({}) errorFailed('Failed to read import file') if (inputRef.current) inputRef.current.value = '' } @@ -456,6 +618,23 @@ const ProfileSection = ({ handleLogout }) => { })) } + const handleConflictSelectionChange = (conflict, isSelected) => { + setConflictSelections((prev) => ({ + ...prev, + [getConflictKey(conflict)]: isSelected + })) + } + + const handleSelectAllConflicts = (isSelected) => { + setConflictSelections((prev) => { + const updated = { ...prev } + importConflicts.forEach((conflict) => { + updated[getConflictKey(conflict)] = isSelected + }) + return updated + }) + } + const handleApplyAllConflicts = (action) => { setConflictDecisions((prev) => { const updated = { ...prev } @@ -471,19 +650,31 @@ const ProfileSection = ({ handleLogout }) => { setPendingImportPayload(null) setImportConflicts([]) setConflictDecisions({}) + setConflictSelections({}) if (inputRef.current) inputRef.current.value = '' } const handleConfirmImport = () => { if (!pendingImportPayload) return - const conflictResolutions = importConflicts.map((conflict) => ({ + const selectedConflicts = importConflicts.filter( + (conflict) => conflictSelections[getConflictKey(conflict)] + ) + const conflictResolutions = selectedConflicts.map((conflict) => ({ type: conflict.type, importId: conflict.importId, existingId: conflict.existingId, action: conflictDecisions[getConflictKey(conflict)] || 'update' })) + const payload = JSON.parse(JSON.stringify(pendingImportPayload)) + importConflicts.forEach((conflict) => { + if (conflictSelections[getConflictKey(conflict)]) return + const collection = payload[conflict.type] + if (Array.isArray(collection)) { + payload[conflict.type] = collection.filter((item) => item.id !== conflict.importId) + } + }) const body = { - ...pendingImportPayload, + ...payload, conflictResolutions } setImportDialogOpen(true) @@ -520,10 +711,13 @@ const ProfileSection = ({ handleLogout }) => { const conflicts = previewImportApi.data.conflicts || [] setImportConflicts(conflicts) const initialDecisions = {} + const initialSelections = {} conflicts.forEach((conflict) => { initialDecisions[getConflictKey(conflict)] = 'update' + initialSelections[getConflictKey(conflict)] = false }) setConflictDecisions(initialDecisions) + setConflictSelections(initialSelections) }, [previewImportApi.data, previewImportApi.loading, importReviewOpen, pendingImportPayload]) useEffect(() => { @@ -532,6 +726,7 @@ const ProfileSection = ({ handleLogout }) => { setPendingImportPayload(null) setImportConflicts([]) setConflictDecisions({}) + setConflictSelections({}) let errMsg = 'Invalid Imported File' let error = previewImportApi.error if (error?.response?.data) { @@ -567,6 +762,7 @@ const ProfileSection = ({ handleLogout }) => { setPendingImportPayload(null) setImportConflicts([]) setConflictDecisions({}) + setConflictSelections({}) navigate(0) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -578,6 +774,7 @@ const ProfileSection = ({ handleLogout }) => { setPendingImportPayload(null) setImportConflicts([]) setConflictDecisions({}) + setConflictSelections({}) let errMsg = 'Invalid Imported File' let error = importAllApi.error if (error?.response?.data) { @@ -781,7 +978,10 @@ const ProfileSection = ({ handleLogout }) => { loading={previewImportApi.loading} conflicts={importConflicts} decisions={conflictDecisions} + selections={conflictSelections} onDecisionChange={handleConflictDecisionChange} + onSelectionChange={handleConflictSelectionChange} + onToggleAllSelections={handleSelectAllConflicts} onApplyAll={handleApplyAllConflicts} onCancel={handleImportReviewCancel} onConfirm={handleConfirmImport} From 5d1462c19c64190c016dffc10bf834940ae22c1f Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月15日 00:30:42 +0200 Subject: [PATCH 05/13] Add credential binding metadata to workspace exports --- docs/credential-export-options.md | 21 ++ .../src/services/export-import/index.ts | 308 ++++++++++++++++++ packages/ui/src/utils/genericHelper.js | 6 + 3 files changed, 335 insertions(+) create mode 100644 docs/credential-export-options.md diff --git a/docs/credential-export-options.md b/docs/credential-export-options.md new file mode 100644 index 00000000000..c2fdff66a42 --- /dev/null +++ b/docs/credential-export-options.md @@ -0,0 +1,21 @@ +# Exportación e importación de credenciales en Agentflow V2 + +Esta guía describe el flujo actual para preservar los nombres de las credenciales al exportar flujos de Agentflow V2 (y otros `ChatFlow`) y cómo se reconstruyen automáticamente al importarlos. + +## Qué se incluye en la exportación + +* `generateExportFlowData` continúa eliminando cualquier clave `FLOWISE_CREDENTIAL_ID` de los nodos para no exponer IDs sensibles en el archivo descargado.【F:packages/ui/src/utils/genericHelper.js†L551-L607】 +* Antes de que los datos lleguen al cliente, `exportImportService.exportData` inspecciona cada `ChatFlow`, identifica los nodos que referencian credenciales y adjunta una sección `credentialBindings` con pares `{ nodeId, path, credentialName, credentialType }`. Sólo se incluyen entradas cuyo nombre y tipo pudieron resolverse en la base de datos.【F:packages/server/src/services/export-import/index.ts†L224-L327】【F:packages/server/src/services/export-import/index.ts†L180-L222】 +* Al exportar un workspace, la sección `credentialBindings` se conserva dentro del JSON de cada flujo, junto con la lista de nodos y edges ya sanitizados.【F:packages/ui/src/utils/exportImport.js†L22-L30】【F:packages/ui/src/utils/genericHelper.js†L559-L606】 + +## Cómo se reconstruyen al importar + +* Durante la importación, `exportImportService.importData` busca la sección `credentialBindings` en cada flujo y consulta las credenciales disponibles en el workspace de destino (incluyendo compartidas) por nombre y tipo.【F:packages/server/src/services/export-import/index.ts†L329-L433】 +* Si encuentra una coincidencia, inserta el nuevo ID bajo la clave `FLOWISE_CREDENTIAL_ID` en el nodo indicado y elimina la sección `credentialBindings` antes de persistir el flujo.【F:packages/server/src/services/export-import/index.ts†L374-L433】 +* Cuando no existe una credencial con el mismo nombre y tipo, se registra una advertencia en los logs para que el usuario pueda completar la vinculación de forma manual tras la importación.【F:packages/server/src/services/export-import/index.ts†L356-L433】 + +## Notas y limitaciones + +* Sólo se generan entradas en `credentialBindings` para credenciales resolubles; si un flujo hacía referencia a una credencial eliminada, no habrá mapeo automático y el nodo seguirá requiriendo configuración manual al importarse. +* El mecanismo confía en que los nombres de credencial y sus tipos (`credentialName`) coincidan exactamente entre origen y destino. Diferencias en mayúsculas/minúsculas o en el tipo impedirán la reconexión automática. +* Las exportaciones individuales desde el canvas no incluyen `credentialBindings`; el mapeo automático sólo se aplica a las exportaciones de workspace. diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 36f0e9f9cf6..fd7d9a54e48 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -9,6 +9,7 @@ import { CustomTemplate } from '../../database/entities/CustomTemplate' import { DocumentStore } from '../../database/entities/DocumentStore' import { DocumentStoreFileChunk } from '../../database/entities/DocumentStoreFileChunk' import { Execution } from '../../database/entities/Execution' +import { Credential } from '../../database/entities/Credential' import { Tool } from '../../database/entities/Tool' import { Variable } from '../../database/entities/Variable' import { InternalFlowiseError } from '../../errors/internalFlowiseError' @@ -27,6 +28,8 @@ import toolsService from '../tools' import variableService from '../variables' import { AssistantType, Platform } from '../../Interface' import { sanitizeNullBytes } from '../../utils/sanitize.util' +import credentialsService from '../credentials' +import logger from '../../utils/logger' type ExportInput = { agentflow: boolean @@ -101,6 +104,22 @@ type ImportPreview = { conflicts: ImportPreviewConflict[] } +type FlowCredentialBinding = { + chatflowId: string + nodeId: string + path: (string | number)[] + property: string + credentialId: string +} + +type ExportCredentialBinding = { + nodeId: string + path: (string | number)[] + property: string + credentialName: string | null + credentialType: string | null +} + const chatflowTypeByKey: Partial> = { AgentFlow: EnumChatflowType.MULTIAGENT, AgentFlowV2: EnumChatflowType.AGENTFLOW, @@ -141,6 +160,288 @@ const getAssistantName = (details?: string): string | undefined => { return undefined } +const FLOWISE_CREDENTIAL_ID_KEY = 'FLOWISE_CREDENTIAL_ID' + +const sanitizeInputsForExport = (inputs: any, inputParams: any[]): any => { + if (!inputs || typeof inputs !== 'object') return {} + + const sanitizedInputs: Record = {} + for (const inputName of Object.keys(inputs)) { + const param = Array.isArray(inputParams) + ? inputParams.find((inp: any) => inp && inp.name === inputName) + : undefined + if (param && (param.type === 'password' || param.type === 'file' || param.type === 'folder')) continue + sanitizedInputs[inputName] = inputs[inputName] + } + return sanitizedInputs +} + +const stripCredentialIds = ( + value: any, + path: (string | number)[], + nodeId: string, + chatflowId: string, + accumulator: FlowCredentialBinding[] +): any => { + if (!value || typeof value !== 'object') return value + + if (Array.isArray(value)) { + return value.map((item, index) => stripCredentialIds(item, [...path, index], nodeId, chatflowId, accumulator)) + } + + const cloned: Record = {} + for (const [key, child] of Object.entries(value)) { + if (key === FLOWISE_CREDENTIAL_ID_KEY) { + if (typeof child === 'string' && child.trim().length> 0) { + accumulator.push({ + chatflowId, + nodeId, + path, + property: key, + credentialId: child + }) + } + continue + } + cloned[key] = stripCredentialIds(child, [...path, key], nodeId, chatflowId, accumulator) + } + return cloned +} + +const collectCredentialBindingsFromChatflow = ( + chatflow: ChatFlow +): { parsed: any; bindings: FlowCredentialBinding[] } | null => { + if (!chatflow.flowData) return null + try { + const parsed = JSON.parse(chatflow.flowData) + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.nodes)) return { parsed, bindings: [] } + + const bindings: FlowCredentialBinding[] = [] + for (const node of parsed.nodes) { + if (!node || typeof node !== 'object') continue + const nodeId = node.id || node?.data?.id + if (!nodeId || !node.data) continue + + const nodeData = node.data + const sanitizedInputs = sanitizeInputsForExport(nodeData.inputs, nodeData.inputParams) + const exportableNodeData = { + id: nodeData.id, + label: nodeData.label, + version: nodeData.version, + name: nodeData.name, + type: nodeData.type, + color: nodeData.color, + hideOutput: nodeData.hideOutput, + hideInput: nodeData.hideInput, + baseClasses: nodeData.baseClasses, + tags: nodeData.tags, + category: nodeData.category, + description: nodeData.description, + inputParams: nodeData.inputParams, + inputAnchors: nodeData.inputAnchors, + inputs: sanitizedInputs, + outputAnchors: nodeData.outputAnchors, + outputs: nodeData.outputs, + selected: false + } + + stripCredentialIds(exportableNodeData, ['data'], nodeId, chatflow.id, bindings) + } + return { parsed, bindings } + } catch (error) { + logger.warn( + `Failed to parse chatflow ${chatflow.id} while collecting credential bindings: ${getErrorMessage(error)}` + ) + return null + } +} + +const enrichChatflowsWithCredentialMetadata = async (chatflowGroups: ChatFlow[][]): Promise => { + const chatflows = chatflowGroups.flat().filter((flow) => !!flow) + if (chatflows.length === 0) return + + const processed: Map = new Map() + const credentialIds = new Set() + + for (const chatflow of chatflows) { + const result = collectCredentialBindingsFromChatflow(chatflow) + if (!result) continue + processed.set(chatflow.id, result) + for (const binding of result.bindings) { + credentialIds.add(binding.credentialId) + } + } + + if (processed.size === 0) return + + const appServer = getRunningExpressApp() + const dataSource = appServer?.AppDataSource + if (!dataSource) return + + let credentialLookup = new Map() + if (credentialIds.size> 0) { + const credentials = await dataSource.getRepository(Credential).find({ + where: { id: In(Array.from(credentialIds)) } + }) + credentialLookup = new Map(credentials.map((credential) => [credential.id, credential])) + } + + for (const chatflow of chatflows) { + const details = processed.get(chatflow.id) + if (!details) continue + const namedBindings: ExportCredentialBinding[] = [] + for (const binding of details.bindings) { + const credential = credentialLookup.get(binding.credentialId) + if (!credential) { + logger.warn( + `Credential ${binding.credentialId} referenced in chatflow ${binding.chatflowId} could not be resolved during export` + ) + continue + } + namedBindings.push({ + nodeId: binding.nodeId, + path: binding.path, + property: binding.property, + credentialName: credential.name ?? null, + credentialType: credential.credentialName ?? null + }) + } + + if (namedBindings.length> 0) { + details.parsed.credentialBindings = namedBindings + } else if (details.parsed.credentialBindings) { + delete details.parsed.credentialBindings + } + + if (namedBindings.length> 0) { + chatflow.flowData = JSON.stringify(details.parsed) + } + } +} + +const formatBindingPath = (path: (string | number)[]): string => { + if (!path || path.length === 0) return '' + let result = '' + for (const segment of path) { + if (typeof segment === 'number') { + result += `[${segment}]` + } else { + result += result.length === 0 ? segment : `.${segment}` + } + } + return result +} + +const resolveBindingParent = (root: any, path: (string | number)[]): any => { + let current = root + for (const segment of path) { + if (typeof segment === 'number') { + if (!Array.isArray(current)) return undefined + current = current[segment] + continue + } + if (!current || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +const reinstateCredentialBindingsOnImport = async ( + chatflowGroups: ChatFlow[][], + workspaceId: string +): Promise => { + const chatflows = chatflowGroups.flat().filter((flow) => !!flow) + if (chatflows.length === 0) return + + const flowsToProcess: { chatflow: ChatFlow; parsed: any; bindings: ExportCredentialBinding[] }[] = [] + + for (const chatflow of chatflows) { + if (!chatflow.flowData) continue + try { + const parsed = JSON.parse(chatflow.flowData) + const bindings = Array.isArray(parsed?.credentialBindings) ? parsed.credentialBindings : [] + if (bindings.length === 0) continue + flowsToProcess.push({ chatflow, parsed, bindings }) + } catch (error) { + logger.warn( + `Failed to parse chatflow ${chatflow.id} while reinstating credential bindings: ${getErrorMessage(error)}` + ) + } + } + + if (flowsToProcess.length === 0) return + + let availableCredentials: any[] = [] + try { + availableCredentials = await credentialsService.getAllCredentials(undefined, workspaceId) + } catch (error) { + logger.warn(`Unable to load credentials for workspace ${workspaceId}: ${getErrorMessage(error)}`) + return + } + + const credentialLookup = new Map() + for (const credential of availableCredentials) { + if (!credential || !credential.name || !credential.credentialName || !credential.id) continue + const key = `${credential.name}::${credential.credentialName}` + if (!credentialLookup.has(key)) { + credentialLookup.set(key, { id: credential.id }) + } + } + + for (const { chatflow, parsed, bindings } of flowsToProcess) { + if (!Array.isArray(parsed.nodes)) { + delete parsed.credentialBindings + chatflow.flowData = JSON.stringify(parsed) + continue + } + + const nodeLookup = new Map() + for (const node of parsed.nodes) { + if (!node || typeof node !== 'object') continue + const nodeId = node.id || node?.data?.id + if (nodeId) nodeLookup.set(nodeId, node) + } + + for (const binding of bindings) { + if (!binding || !binding.nodeId || !Array.isArray(binding.path) || !binding.property) continue + if (!binding.credentialName || !binding.credentialType) { + logger.warn( + `Credential binding for chatflow ${chatflow.id} on node ${binding.nodeId} is missing name or type` + ) + continue + } + + const key = `${binding.credentialName}::${binding.credentialType}` + const credential = credentialLookup.get(key) + if (!credential) { + logger.warn( + `No credential named ${binding.credentialName} of type ${binding.credentialType} found for chatflow ${chatflow.id}` + ) + continue + } + + const node = nodeLookup.get(binding.nodeId) + if (!node) { + logger.warn(`Node ${binding.nodeId} not found in chatflow ${chatflow.id} while applying credential bindings`) + continue + } + + const parent = resolveBindingParent(node, binding.path) + if (!parent || typeof parent !== 'object') { + logger.warn( + `Path ${formatBindingPath(binding.path)} could not be resolved in node ${binding.nodeId} for chatflow ${chatflow.id}` + ) + continue + } + + ;(parent as Record)[binding.property] = credential.id + } + + delete parsed.credentialBindings + chatflow.flowData = JSON.stringify(parsed) + } +} + const convertExportInput = (body: any): ExportInput => { try { if (!body || typeof body !== 'object') throw new Error('Invalid ExportInput object in request body') @@ -235,6 +536,8 @@ const exportData = async (exportInput: ExportInput, activeWorkspaceId?: string): exportInput.variable === true ? await variableService.getAllVariables(activeWorkspaceId) : [] Variable = 'data' in Variable ? Variable.data : Variable + await enrichChatflowsWithCredentialMetadata([AgentFlow, AgentFlowV2, AssistantFlow, ChatFlow]) + return { FileDefaultName, AgentFlow, @@ -1001,6 +1304,11 @@ const importData = async (importData: ImportPayload, orgId: string, activeWorksp importData.Tool = importData.Tool || [] importData.Variable = importData.Variable || [] + await reinstateCredentialBindingsOnImport( + [importData.AgentFlow, importData.AgentFlowV2, importData.AssistantFlow, importData.ChatFlow], + activeWorkspaceId + ) + let queryRunner try { queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index 1edca8b17ed..c500c946630 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -600,10 +600,16 @@ export const generateExportFlowData = (flowData) => { nodes[i].data = _removeCredentialId(newNodeData) } + const credentialBindings = Array.isArray(flowData.credentialBindings) ? flowData.credentialBindings : [] + const exportJson = { nodes, edges } + + if (credentialBindings.length) { + exportJson.credentialBindings = credentialBindings.map((binding) => ({ ...binding })) + } return exportJson } From 1c71941216c5b7d250a0113525cf8d851ba51861 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月15日 13:10:31 +0200 Subject: [PATCH 06/13] feat: improve import review with tabs and summary --- .../Header/ProfileSection/index.jsx | 491 +++++++++++++++--- 1 file changed, 417 insertions(+), 74 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index d885b7f6a26..76e7f6b40a9 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -33,6 +33,8 @@ import { Paper, Popper, Stack, + Tab, + Tabs, Typography } from '@mui/material' import { alpha, useTheme } from '@mui/material/styles' @@ -77,6 +79,25 @@ const dataToExport = [ const getConflictKey = (conflict) => `${conflict.type}:${conflict.importId}` +const getImportItemName = (type, item) => { + if (!item) return '' + if (item.name) return item.name + if (item.label) return item.label + if (item.title) return item.title + if (item.details) { + try { + const parsedDetails = typeof item.details === 'string' ? JSON.parse(item.details) : item.details + if (parsedDetails && typeof parsedDetails === 'object') { + if (parsedDetails.name) return parsedDetails.name + if (parsedDetails.title) return parsedDetails.title + } + } catch (error) { + // ignore json parse error and fall back to id + } + } + return item.id || `${type} item` +} + const ExportDialog = ({ show, onCancel, onExport }) => { const portalElement = document.getElementById('portal') @@ -185,22 +206,43 @@ const ImportReviewDialog = ({ show, loading, conflicts, + newItems, decisions, - selections, + conflictSelections, + newItemSelections, onDecisionChange, - onSelectionChange, - onToggleAllSelections, + onConflictSelectionChange, + onNewItemSelectionChange, + onToggleAllConflicts, onApplyAll, + onToggleAllNewItems, onCancel, onConfirm, disableConfirm }) => { const portalElement = document.getElementById('portal') const theme = useTheme() + const [activeTab, setActiveTab] = useState(0) const allDuplicate = conflicts.length> 0 && conflicts.every((conflict) => decisions[getConflictKey(conflict)] === 'duplicate') - const allSelected = conflicts.length> 0 && conflicts.every((conflict) => selections[getConflictKey(conflict)]) + const allConflictsSelected = + conflicts.length> 0 && conflicts.every((conflict) => conflictSelections[getConflictKey(conflict)]) + const allNewItemsSelected = + newItems.length> 0 && newItems.every((item) => newItemSelections[getConflictKey(item)]) + + useEffect(() => { + if (!show) return + if (conflicts.length> 0) { + setActiveTab(0) + return + } + if (newItems.length> 0) { + setActiveTab(1) + return + } + setActiveTab(0) + }, [show, conflicts.length, newItems.length]) const typeDisplayConfig = useMemo( () => ({ @@ -230,6 +272,17 @@ const ImportReviewDialog = ({ return Array.from(groups.entries()) }, [conflicts]) + const groupedNewItems = useMemo(() => { + const groups = new Map() + newItems.forEach((item) => { + if (!groups.has(item.type)) { + groups.set(item.type, []) + } + groups.get(item.type).push(item) + }) + return Array.from(groups.entries()) + }, [newItems]) + const getExistingLink = (conflict) => { const linkMap = { AgentFlow: `/agentcanvas/${conflict.existingId}`, @@ -263,14 +316,215 @@ const ImportReviewDialog = ({ ) : ( - Flowise defaults to updating elements when a name conflict is detected. Use the controls below to - choose whether each conflicting element should be updated or duplicated. + Flowise defaults to updating elements when a name conflict is detected. Use the tabs below to review + conflicts and new items before completing the import. - {conflicts.length === 0 ? ( + setActiveTab(value)} + variant='fullWidth' + sx={{ borderBottom: `1px solid ${alpha(theme.palette.divider, 0.4)}` }} +> + + + + {activeTab === 0 ? ( + conflicts.length === 0 ? ( + + No conflicts detected + + All imported items without conflicts will be created automatically. + + + ) : ( + + + onToggleAllConflicts(event.target.checked)} + /> + } + label='Select all' + /> + + onApplyAll(event.target.checked ? 'duplicate' : 'update') + } + /> + } + label={ + allDuplicate + ? 'Duplicate all conflicts' + : 'Update conflicts by default' + } + /> + + + {groupedConflicts.map(([type, items]) => { + const meta = typeDisplayConfig[type] || { + label: type, + color: theme.palette.grey[500] + } + const accentColor = meta.color || theme.palette.grey[500] + const sectionBackground = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.16 : 0.08 + ) + const borderColor = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.4 : 0.25 + ) + const chipText = theme.palette.getContrastText(accentColor) + + return ( + + + + + + {items.length} conflict{items.length> 1 ? 's' : ''} + + + + + {items.map((conflict) => { + const key = getConflictKey(conflict) + const action = decisions[key] || 'update' + const isSelected = conflictSelections[key] || false + const existingLink = getExistingLink(conflict) + + return ( + + + + onConflictSelectionChange( + conflict, + event.target.checked + ) + } + /> + + + {conflict.name} + + + Existing ID:{' '} + {existingLink ? ( + + {conflict.existingId} + + ) : ( + conflict.existingId + )} + + + + + onDecisionChange( + conflict, + event.target.checked + ? 'duplicate' + : 'update' + ) + } + disabled={!isSelected} + /> + } + label={ + action === 'duplicate' + ? 'Duplicate' + : 'Update' + } + /> + + ) + })} + + + ) + })} + + + ) + ) : newItems.length === 0 ? ( - No conflicts detected + No new items detected - All imported items will be added or updated automatically. + Only existing items with conflicts require review. ) : ( @@ -285,29 +539,18 @@ const ImportReviewDialog = ({ control={ onToggleAllSelections(event.target.checked)} + checked={allNewItemsSelected} + onChange={(event) => onToggleAllNewItems(event.target.checked)} /> } label='Select all' /> - onApplyAll(event.target.checked ? 'duplicate' : 'update')} - /> - } - label={ - allDuplicate - ? 'Duplicate all conflicts' - : 'Update conflicts by default' - } - /> + + Selected items will be created in your workspace. + - {groupedConflicts.map(([type, items]) => { + {groupedNewItems.map(([type, items]) => { const meta = typeDisplayConfig[type] || { label: type, color: theme.palette.grey[500] @@ -317,7 +560,10 @@ const ImportReviewDialog = ({ accentColor, theme.palette.mode === 'dark' ? 0.16 : 0.08 ) - const borderColor = alpha(accentColor, theme.palette.mode === 'dark' ? 0.4 : 0.25) + const borderColor = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.4 : 0.25 + ) const chipText = theme.palette.getContrastText(accentColor) return ( @@ -351,16 +597,14 @@ const ImportReviewDialog = ({ sx={{ backgroundColor: accentColor, color: chipText }} /> - {items.length} conflict{items.length> 1 ? 's' : ''} + {items.length} item{items.length> 1 ? 's' : ''} - {items.map((conflict) => { - const key = getConflictKey(conflict) - const action = decisions[key] || 'update' - const isSelected = selections[key] || false - const existingLink = getExistingLink(conflict) + {items.map((item) => { + const key = getConflictKey(item) + const isSelected = newItemSelections[key] || false return ( - onSelectionChange(conflict, event.target.checked) + onNewItemSelectionChange( + item, + event.target.checked + ) } /> - {conflict.name} + {item.name} - Existing ID:{' '} - {existingLink ? ( - - {conflict.existingId} - - ) : ( - conflict.existingId - )} + Import ID: {item.importId} - - onDecisionChange( - conflict, - event.target.checked - ? 'duplicate' - : 'update' - ) - } - disabled={!isSelected} - /> - } - label={action === 'duplicate' ? 'Duplicate' : 'Update'} - /> ) })} @@ -463,12 +679,16 @@ ImportReviewDialog.propTypes = { show: PropTypes.bool, loading: PropTypes.bool, conflicts: PropTypes.array, + newItems: PropTypes.array, decisions: PropTypes.object, - selections: PropTypes.object, + conflictSelections: PropTypes.object, + newItemSelections: PropTypes.object, onDecisionChange: PropTypes.func, - onSelectionChange: PropTypes.func, - onToggleAllSelections: PropTypes.func, + onConflictSelectionChange: PropTypes.func, + onNewItemSelectionChange: PropTypes.func, + onToggleAllConflicts: PropTypes.func, onApplyAll: PropTypes.func, + onToggleAllNewItems: PropTypes.func, onCancel: PropTypes.func, onConfirm: PropTypes.func, disableConfirm: PropTypes.bool @@ -523,9 +743,12 @@ const ProfileSection = ({ handleLogout }) => { const [importDialogOpen, setImportDialogOpen] = useState(false) const [importReviewOpen, setImportReviewOpen] = useState(false) const [importConflicts, setImportConflicts] = useState([]) + const [importNewItems, setImportNewItems] = useState([]) const [conflictDecisions, setConflictDecisions] = useState({}) const [conflictSelections, setConflictSelections] = useState({}) + const [newItemSelections, setNewItemSelections] = useState({}) const [pendingImportPayload, setPendingImportPayload] = useState(null) + const [importSummary, setImportSummary] = useState(null) const anchorRef = useRef(null) const inputRef = useRef() @@ -588,14 +811,20 @@ const ProfileSection = ({ handleLogout }) => { const body = JSON.parse(evt.target.result) setPendingImportPayload(body) setImportConflicts([]) + setImportNewItems([]) setConflictDecisions({}) setConflictSelections({}) + setNewItemSelections({}) + setImportSummary(null) setImportReviewOpen(true) previewImportApi.request(body) } catch (error) { setImportReviewOpen(false) setPendingImportPayload(null) setConflictSelections({}) + setImportNewItems([]) + setNewItemSelections({}) + setImportSummary(null) errorFailed(`Failed to read import file: ${getErrorMessage(error)}`) } finally { if (inputRef.current) inputRef.current.value = '' @@ -605,6 +834,9 @@ const ProfileSection = ({ handleLogout }) => { setImportReviewOpen(false) setPendingImportPayload(null) setConflictSelections({}) + setImportNewItems([]) + setNewItemSelections({}) + setImportSummary(null) errorFailed('Failed to read import file') if (inputRef.current) inputRef.current.value = '' } @@ -625,6 +857,13 @@ const ProfileSection = ({ handleLogout }) => { })) } + const handleNewItemSelectionChange = (item, isSelected) => { + setNewItemSelections((prev) => ({ + ...prev, + [getConflictKey(item)]: isSelected + })) + } + const handleSelectAllConflicts = (isSelected) => { setConflictSelections((prev) => { const updated = { ...prev } @@ -635,6 +874,16 @@ const ProfileSection = ({ handleLogout }) => { }) } + const handleSelectAllNewItems = (isSelected) => { + setNewItemSelections((prev) => { + const updated = { ...prev } + importNewItems.forEach((item) => { + updated[getConflictKey(item)] = isSelected + }) + return updated + }) + } + const handleApplyAllConflicts = (action) => { setConflictDecisions((prev) => { const updated = { ...prev } @@ -649,8 +898,11 @@ const ProfileSection = ({ handleLogout }) => { setImportReviewOpen(false) setPendingImportPayload(null) setImportConflicts([]) + setImportNewItems([]) setConflictDecisions({}) setConflictSelections({}) + setNewItemSelections({}) + setImportSummary(null) if (inputRef.current) inputRef.current.value = '' } @@ -659,6 +911,7 @@ const ProfileSection = ({ handleLogout }) => { const selectedConflicts = importConflicts.filter( (conflict) => conflictSelections[getConflictKey(conflict)] ) + const selectedNewItems = importNewItems.filter((item) => newItemSelections[getConflictKey(item)]) const conflictResolutions = selectedConflicts.map((conflict) => ({ type: conflict.type, importId: conflict.importId, @@ -673,6 +926,27 @@ const ProfileSection = ({ handleLogout }) => { payload[conflict.type] = collection.filter((item) => item.id !== conflict.importId) } }) + importNewItems.forEach((item) => { + if (newItemSelections[getConflictKey(item)]) return + const collection = payload[item.type] + if (Array.isArray(collection)) { + payload[item.type] = collection.filter((entry) => entry.id !== item.importId) + } + }) + const duplicateCount = selectedConflicts.filter( + (conflict) => (conflictDecisions[getConflictKey(conflict)] || 'update') === 'duplicate' + ).length + const updateCount = selectedConflicts.length - duplicateCount + const skippedCount = Math.max( + 0, + importConflicts.length + importNewItems.length - (selectedConflicts.length + selectedNewItems.length) + ) + setImportSummary({ + created: selectedNewItems.length, + duplicated: duplicateCount, + updated: updateCount, + skipped: skippedCount + }) const body = { ...payload, conflictResolutions @@ -685,8 +959,35 @@ const ProfileSection = ({ handleLogout }) => { const importAllSuccess = () => { setImportDialogOpen(false) dispatch({ type: REMOVE_DIRTY }) + let message = 'Import All successful' + if (importSummary) { + const segments = [] + if (importSummary.created> 0) { + segments.push( + `${importSummary.created} new item${importSummary.created === 1 ? '' : 's'} created` + ) + } + if (importSummary.duplicated> 0) { + segments.push( + `${importSummary.duplicated} item${importSummary.duplicated === 1 ? '' : 's'} duplicated` + ) + } + if (importSummary.updated> 0) { + segments.push( + `${importSummary.updated} item${importSummary.updated === 1 ? '' : 's'} updated` + ) + } + if (importSummary.skipped> 0) { + segments.push( + `${importSummary.skipped} item${importSummary.skipped === 1 ? '' : 's'} skipped` + ) + } + if (segments.length> 0) { + message = `Import complete: ${segments.join(', ')}.` + } + } enqueueSnackbar({ - message: `Import All successful`, + message, options: { key: new Date().getTime() + Math.random(), variant: 'success', @@ -697,6 +998,7 @@ const ProfileSection = ({ handleLogout }) => { ) } }) + setImportSummary(null) } const importAll = () => { @@ -718,6 +1020,35 @@ const ProfileSection = ({ handleLogout }) => { }) setConflictDecisions(initialDecisions) setConflictSelections(initialSelections) + const conflictKeys = new Set(conflicts.map((conflict) => getConflictKey(conflict))) + const newItems = [] + Object.entries(pendingImportPayload).forEach(([type, items]) => { + if (!Array.isArray(items)) return + items.forEach((item) => { + if (!item || !item.id) return + const key = getConflictKey({ type, importId: item.id }) + if (conflictKeys.has(key)) return + newItems.push({ + type, + importId: item.id, + name: getImportItemName(type, item) + }) + }) + }) + newItems.sort((a, b) => { + if (a.type === b.type) { + const nameA = a.name || '' + const nameB = b.name || '' + return nameA.localeCompare(nameB) + } + return a.type.localeCompare(b.type) + }) + const initialNewSelections = {} + newItems.forEach((item) => { + initialNewSelections[getConflictKey(item)] = true + }) + setImportNewItems(newItems) + setNewItemSelections(initialNewSelections) }, [previewImportApi.data, previewImportApi.loading, importReviewOpen, pendingImportPayload]) useEffect(() => { @@ -727,6 +1058,9 @@ const ProfileSection = ({ handleLogout }) => { setImportConflicts([]) setConflictDecisions({}) setConflictSelections({}) + setImportNewItems([]) + setNewItemSelections({}) + setImportSummary(null) let errMsg = 'Invalid Imported File' let error = previewImportApi.error if (error?.response?.data) { @@ -761,8 +1095,10 @@ const ProfileSection = ({ handleLogout }) => { importAllSuccess() setPendingImportPayload(null) setImportConflicts([]) + setImportNewItems([]) setConflictDecisions({}) setConflictSelections({}) + setNewItemSelections({}) navigate(0) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -773,8 +1109,11 @@ const ProfileSection = ({ handleLogout }) => { setImportDialogOpen(false) setPendingImportPayload(null) setImportConflicts([]) + setImportNewItems([]) setConflictDecisions({}) setConflictSelections({}) + setNewItemSelections({}) + setImportSummary(null) let errMsg = 'Invalid Imported File' let error = importAllApi.error if (error?.response?.data) { @@ -977,12 +1316,16 @@ const ProfileSection = ({ handleLogout }) => { show={importReviewOpen} loading={previewImportApi.loading} conflicts={importConflicts} + newItems={importNewItems} decisions={conflictDecisions} - selections={conflictSelections} + conflictSelections={conflictSelections} + newItemSelections={newItemSelections} onDecisionChange={handleConflictDecisionChange} - onSelectionChange={handleConflictSelectionChange} - onToggleAllSelections={handleSelectAllConflicts} + onConflictSelectionChange={handleConflictSelectionChange} + onNewItemSelectionChange={handleNewItemSelectionChange} + onToggleAllConflicts={handleSelectAllConflicts} onApplyAll={handleApplyAllConflicts} + onToggleAllNewItems={handleSelectAllNewItems} onCancel={handleImportReviewCancel} onConfirm={handleConfirmImport} disableConfirm={!pendingImportPayload} From 3a6965c9783b816477c2e69b53200f8122a0a4b6 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月15日 22:51:48 +0200 Subject: [PATCH 07/13] Fix duplicate document store sections when switching tabs --- .../ui/src/layout/MainLayout/Header/ProfileSection/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index 76e7f6b40a9..b2ed3b54dcf 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -390,7 +390,7 @@ const ImportReviewDialog = ({ return ( Date: 2025年10月15日 23:26:00 +0200 Subject: [PATCH 08/13] feat(ui): add import review accordions --- .../Header/ProfileSection/index.jsx | 309 +++++++++++++----- 1 file changed, 224 insertions(+), 85 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index b2ed3b54dcf..c92162a3630 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch, useSelector } from 'react-redux' import { Link as RouterLink, useNavigate } from 'react-router-dom' @@ -10,6 +10,9 @@ import useNotifier from '@/utils/useNotifier' // material-ui import { + Accordion, + AccordionDetails, + AccordionSummary, Avatar, Box, Button, @@ -38,6 +41,7 @@ import { Typography } from '@mui/material' import { alpha, useTheme } from '@mui/material/styles' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' // third-party import PerfectScrollbar from 'react-perfect-scrollbar' @@ -223,6 +227,7 @@ const ImportReviewDialog = ({ const portalElement = document.getElementById('portal') const theme = useTheme() const [activeTab, setActiveTab] = useState(0) + const [expandedSections, setExpandedSections] = useState({}) const allDuplicate = conflicts.length> 0 && conflicts.every((conflict) => decisions[getConflictKey(conflict)] === 'duplicate') @@ -231,6 +236,24 @@ const ImportReviewDialog = ({ const allNewItemsSelected = newItems.length> 0 && newItems.every((item) => newItemSelections[getConflictKey(item)]) + const collapsibleTypes = useMemo( + () => + new Set([ + 'AgentFlow', + 'AgentFlowV2', + 'AssistantFlow', + 'AssistantCustom', + 'AssistantOpenAI', + 'AssistantAzure', + 'ChatFlow', + 'CustomTemplate', + 'DocumentStore', + 'Tool', + 'Variable' + ]), + [] + ) + useEffect(() => { if (!show) return if (conflicts.length> 0) { @@ -244,6 +267,18 @@ const ImportReviewDialog = ({ setActiveTab(0) }, [show, conflicts.length, newItems.length]) + useEffect(() => { + if (show) return + setExpandedSections({}) + }, [show]) + + const handleSectionToggle = useCallback((type, isExpanded) => { + setExpandedSections((prev) => ({ + ...prev, + [type]: isExpanded + })) + }, []) + const typeDisplayConfig = useMemo( () => ({ AgentFlow: { label: 'Agent Flow', color: theme.palette.info.main }, @@ -388,47 +423,26 @@ const ImportReviewDialog = ({ ) const chipText = theme.palette.getContrastText(accentColor) - return ( - - - - - - {items.length} conflict{items.length> 1 ? 's' : ''} - - - - - {items.map((conflict) => { - const key = getConflictKey(conflict) - const action = decisions[key] || 'update' - const isSelected = conflictSelections[key] || false - const existingLink = getExistingLink(conflict) + const isCollapsible = collapsibleTypes.has(type) + const headerContent = ( + + + + {items.length} conflict{items.length> 1 ? 's' : ''} + + + ) + const renderItems = ( + + {items.map((conflict) => { + const key = getConflictKey(conflict) + const action = decisions[key] || 'update' + const isSelected = conflictSelections[key] || false + const existingLink = getExistingLink(conflict) return ( - ) - })} - + ) + })} + + ) + + if (isCollapsible) { + const expanded = expandedSections[type] ?? false + return ( + handleSectionToggle(type, isExpanded)} + sx={{ + border: '1px solid', + borderColor, + borderRadius: 2, + backgroundColor: sectionBackground, + boxShadow: 'none', + overflow: 'hidden', + '&:before': { display: 'none' }, + '&.Mui-expanded': { + margin: 0 + } + }} +> + } + sx={{ + px: 2, + py: 1.5, + '& .MuiAccordionSummary-content': { + margin: 0, + display: 'flex', + flexDirection: { xs: 'column', sm: 'row' }, + justifyContent: 'space-between', + alignItems: { xs: 'flex-start', sm: 'center' }, + gap: 1 + } + }} +> + {headerContent} + + {renderItems} + + ) + } + + return ( + + + {headerContent} + + {renderItems} ) })} @@ -566,45 +653,24 @@ const ImportReviewDialog = ({ ) const chipText = theme.palette.getContrastText(accentColor) - return ( - - - - - - {items.length} item{items.length> 1 ? 's' : ''} - - - - - {items.map((item) => { - const key = getConflictKey(item) - const isSelected = newItemSelections[key] || false + const isCollapsible = collapsibleTypes.has(type) + const headerContent = ( + + + + {items.length} item{items.length> 1 ? 's' : ''} + + + ) + const renderItems = ( + + {items.map((item) => { + const key = getConflictKey(item) + const isSelected = newItemSelections[key] || false return ( + ) + + if (isCollapsible) { + const expanded = expandedSections[type] ?? false + return ( + handleSectionToggle(type, isExpanded)} + sx={{ + border: '1px solid', + borderColor, + borderRadius: 2, + backgroundColor: sectionBackground, + boxShadow: 'none', + overflow: 'hidden', + '&:before': { display: 'none' }, + '&.Mui-expanded': { + margin: 0 + } + }} +> + } + sx={{ + px: 2, + py: 1.5, + '& .MuiAccordionSummary-content': { + margin: 0, + display: 'flex', + flexDirection: { xs: 'column', sm: 'row' }, + justifyContent: 'space-between', + alignItems: { xs: 'flex-start', sm: 'center' }, + gap: 1 + } + }} +> + {headerContent} + + {renderItems} + + ) + } + + return ( + + + {headerContent} + + {renderItems} ) })} @@ -1045,7 +1184,7 @@ const ProfileSection = ({ handleLogout }) => { }) const initialNewSelections = {} newItems.forEach((item) => { - initialNewSelections[getConflictKey(item)] = true + initialNewSelections[getConflictKey(item)] = false }) setImportNewItems(newItems) setNewItemSelections(initialNewSelections) From 899fd79504e99a66fec127d7ce36d68d6c2bb882 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月16日 00:21:55 +0200 Subject: [PATCH 09/13] Improve import dialog visibility and summary --- .../Header/ProfileSection/index.jsx | 355 +++++++++++++++--- 1 file changed, 298 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index c92162a3630..9d9c6ed5586 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -40,8 +40,9 @@ import { Tabs, Typography } from '@mui/material' -import { alpha, useTheme } from '@mui/material/styles' +import { alpha, lighten, useTheme } from '@mui/material/styles' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' // third-party import PerfectScrollbar from 'react-perfect-scrollbar' @@ -81,6 +82,20 @@ const dataToExport = [ 'Variables' ] +const importTypeLabels = { + AgentFlow: 'Agent Flow', + AgentFlowV2: 'Agent Flow V2', + AssistantFlow: 'Assistant Flow', + AssistantCustom: 'Custom Assistant', + AssistantOpenAI: 'OpenAI Assistant', + AssistantAzure: 'Azure Assistant', + ChatFlow: 'Chat Flow', + CustomTemplate: 'Custom Template', + DocumentStore: 'Document Store', + Tool: 'Tool', + Variable: 'Variable' +} + const getConflictKey = (conflict) => `${conflict.type}:${conflict.importId}` const getImportItemName = (type, item) => { @@ -281,17 +296,17 @@ const ImportReviewDialog = ({ const typeDisplayConfig = useMemo( () => ({ - AgentFlow: { label: 'Agent Flow', color: theme.palette.info.main }, - AgentFlowV2: { label: 'Agent Flow V2', color: theme.palette.info.dark }, - AssistantFlow: { label: 'Assistant Flow', color: theme.palette.success.main }, - AssistantCustom: { label: 'Custom Assistant', color: theme.palette.warning.main }, - AssistantOpenAI: { label: 'OpenAI Assistant', color: theme.palette.primary.main }, - AssistantAzure: { label: 'Azure Assistant', color: theme.palette.secondary.main }, - ChatFlow: { label: 'Chat Flow', color: theme.palette.primary.dark }, - CustomTemplate: { label: 'Custom Template', color: theme.palette.error.main }, - DocumentStore: { label: 'Document Store', color: theme.palette.secondary.dark }, - Tool: { label: 'Tool', color: theme.palette.success.dark }, - Variable: { label: 'Variable', color: theme.palette.warning.dark } + AgentFlow: { label: importTypeLabels.AgentFlow, color: theme.palette.info.main }, + AgentFlowV2: { label: importTypeLabels.AgentFlowV2, color: theme.palette.info.dark }, + AssistantFlow: { label: importTypeLabels.AssistantFlow, color: theme.palette.success.main }, + AssistantCustom: { label: importTypeLabels.AssistantCustom, color: theme.palette.warning.main }, + AssistantOpenAI: { label: importTypeLabels.AssistantOpenAI, color: theme.palette.primary.main }, + AssistantAzure: { label: importTypeLabels.AssistantAzure, color: theme.palette.secondary.main }, + ChatFlow: { label: importTypeLabels.ChatFlow, color: theme.palette.primary.dark }, + CustomTemplate: { label: importTypeLabels.CustomTemplate, color: theme.palette.error.main }, + DocumentStore: { label: importTypeLabels.DocumentStore, color: theme.palette.secondary.dark }, + Tool: { label: importTypeLabels.Tool, color: theme.palette.success.dark }, + Variable: { label: importTypeLabels.Variable, color: theme.palette.warning.dark } }), [theme] ) @@ -443,6 +458,22 @@ const ImportReviewDialog = ({ const action = decisions[key] || 'update' const isSelected = conflictSelections[key] || false const existingLink = getExistingLink(conflict) + const inactiveBackground = lighten( + sectionBackground || alpha(theme.palette.action.disabledBackground, 0.3), + theme.palette.mode === 'dark' ? 0.08 : 0.24 + ) + const activeBackground = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.28 : 0.14 + ) + const activeBorder = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.7 : 0.5 + ) + const hoverBackground = lighten( + inactiveBackground, + theme.palette.mode === 'dark' ? 0.04 : 0.1 + ) return ( @@ -671,6 +704,22 @@ const ImportReviewDialog = ({ {items.map((item) => { const key = getConflictKey(item) const isSelected = newItemSelections[key] || false + const inactiveBackground = lighten( + sectionBackground || alpha(theme.palette.action.disabledBackground, 0.3), + theme.palette.mode === 'dark' ? 0.08 : 0.24 + ) + const activeBackground = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.28 : 0.14 + ) + const activeBorder = alpha( + accentColor, + theme.palette.mode === 'dark' ? 0.7 : 0.5 + ) + const hoverBackground = lighten( + inactiveBackground, + theme.palette.mode === 'dark' ? 0.04 : 0.1 + ) return ( @@ -833,17 +887,47 @@ ImportReviewDialog.propTypes = { disableConfirm: PropTypes.bool } -const ImportDialog = ({ show }) => { +const ImportDialog = ({ show, status, summary, onClose }) => { const portalElement = document.getElementById('portal') + const theme = useTheme() + + const isProcessing = status === 'processing' + + const summaryGroups = useMemo( + () => [ + { key: 'created', title: 'New items created', palette: theme.palette.success }, + { key: 'duplicated', title: 'Items duplicated', palette: theme.palette.info }, + { key: 'updated', title: 'Existing items updated', palette: theme.palette.primary }, + { key: 'skipped', title: 'Items skipped', palette: theme.palette.warning } + ], + [theme] + ) + + const totalSummaryItems = useMemo(() => { + if (!summary) return 0 + return summaryGroups.reduce((total, group) => total + (summary[group.key]?.length || 0), 0) + }, [summary, summaryGroups]) + + const handleDialogClose = (event, reason) => { + if (isProcessing) return + if (onClose) onClose(event, reason) + } const component = show ? ( - + - Importing... + {isProcessing ? 'Importing...' : 'Import complete'} - - -
+ + {isProcessing ? ( + { src={ExportingGIF} alt='ImportingGIF' /> - Importing data might takes a while -
-
+ + Importing data might take a little while. You can continue working in the meantime. + +
+ ) : ( + + + + Your import finished successfully. + + {summary && totalSummaryItems> 0 ? ( + + {summaryGroups.map((group) => { + const items = summary[group.key] || [] + if (!items.length) return null + const backgroundColor = alpha( + group.palette.main, + theme.palette.mode === 'dark' ? 0.16 : 0.08 + ) + const borderColor = alpha( + group.palette.main, + theme.palette.mode === 'dark' ? 0.4 : 0.25 + ) + const hoverColor = lighten( + backgroundColor, + theme.palette.mode === 'dark' ? 0.08 : 0.18 + ) + + return ( + + + {group.title} ({items.length}) + + + {items.map((item) => { + const typeLabel = importTypeLabels[item.type] || item.type + const title = item.name || typeLabel || 'Untitled item' + const details = [typeLabel] + if (item.importId) details.push(`Import ID: ${item.importId}`) + if (item.existingId) details.push(`Existing ID: ${item.existingId}`) + if (group.key === 'skipped') { + if (item.reason === 'conflict') details.push('Conflict not selected') + if (item.reason === 'new') details.push('New item not selected') + } + return ( + + {title} + + {details.join(' • ')} + + + ) + })} + + + ) + })} + + ) : ( + + No items were selected for import. + + )} + + )} + {!isProcessing && ( + + + + )}
) : null @@ -864,7 +1031,15 @@ const ImportDialog = ({ show }) => { } ImportDialog.propTypes = { - show: PropTypes.bool + show: PropTypes.bool, + status: PropTypes.oneOf(['idle', 'processing', 'success']), + summary: PropTypes.shape({ + created: PropTypes.array, + duplicated: PropTypes.array, + updated: PropTypes.array, + skipped: PropTypes.array + }), + onClose: PropTypes.func } // ==============================|| PROFILE MENU ||============================== // @@ -888,6 +1063,8 @@ const ProfileSection = ({ handleLogout }) => { const [newItemSelections, setNewItemSelections] = useState({}) const [pendingImportPayload, setPendingImportPayload] = useState(null) const [importSummary, setImportSummary] = useState(null) + const [importStatus, setImportStatus] = useState('idle') + const [shouldReloadAfterImport, setShouldReloadAfterImport] = useState(false) const anchorRef = useRef(null) const inputRef = useRef() @@ -955,6 +1132,8 @@ const ProfileSection = ({ handleLogout }) => { setConflictSelections({}) setNewItemSelections({}) setImportSummary(null) + setImportStatus('idle') + setShouldReloadAfterImport(false) setImportReviewOpen(true) previewImportApi.request(body) } catch (error) { @@ -964,6 +1143,8 @@ const ProfileSection = ({ handleLogout }) => { setImportNewItems([]) setNewItemSelections({}) setImportSummary(null) + setImportStatus('idle') + setShouldReloadAfterImport(false) errorFailed(`Failed to read import file: ${getErrorMessage(error)}`) } finally { if (inputRef.current) inputRef.current.value = '' @@ -976,6 +1157,8 @@ const ProfileSection = ({ handleLogout }) => { setImportNewItems([]) setNewItemSelections({}) setImportSummary(null) + setImportStatus('idle') + setShouldReloadAfterImport(false) errorFailed('Failed to read import file') if (inputRef.current) inputRef.current.value = '' } @@ -1042,7 +1225,21 @@ const ProfileSection = ({ handleLogout }) => { setConflictSelections({}) setNewItemSelections({}) setImportSummary(null) + setImportStatus('idle') + setShouldReloadAfterImport(false) + if (inputRef.current) inputRef.current.value = '' + } + + const handleImportDialogClose = () => { + setImportDialogOpen(false) + setImportStatus('idle') + const shouldReload = shouldReloadAfterImport + setShouldReloadAfterImport(false) + setImportSummary(null) if (inputRef.current) inputRef.current.value = '' + if (shouldReload) { + navigate(0) + } } const handleConfirmImport = () => { @@ -1051,6 +1248,44 @@ const ProfileSection = ({ handleLogout }) => { (conflict) => conflictSelections[getConflictKey(conflict)] ) const selectedNewItems = importNewItems.filter((item) => newItemSelections[getConflictKey(item)]) + const duplicateConflicts = [] + const updatedConflicts = [] + selectedConflicts.forEach((conflict) => { + const action = conflictDecisions[getConflictKey(conflict)] || 'update' + const baseInfo = { + type: conflict.type, + name: conflict.name, + importId: conflict.importId, + existingId: conflict.existingId + } + if (action === 'duplicate') { + duplicateConflicts.push(baseInfo) + return + } + updatedConflicts.push(baseInfo) + }) + const createdItems = selectedNewItems.map((item) => ({ + type: item.type, + name: item.name, + importId: item.importId + })) + const skippedConflicts = importConflicts + .filter((conflict) => !conflictSelections[getConflictKey(conflict)]) + .map((conflict) => ({ + type: conflict.type, + name: conflict.name, + importId: conflict.importId, + existingId: conflict.existingId, + reason: 'conflict' + })) + const skippedNew = importNewItems + .filter((item) => !newItemSelections[getConflictKey(item)]) + .map((item) => ({ + type: item.type, + name: item.name, + importId: item.importId, + reason: 'new' + })) const conflictResolutions = selectedConflicts.map((conflict) => ({ type: conflict.type, importId: conflict.importId, @@ -1072,53 +1307,52 @@ const ProfileSection = ({ handleLogout }) => { payload[item.type] = collection.filter((entry) => entry.id !== item.importId) } }) - const duplicateCount = selectedConflicts.filter( - (conflict) => (conflictDecisions[getConflictKey(conflict)] || 'update') === 'duplicate' - ).length - const updateCount = selectedConflicts.length - duplicateCount - const skippedCount = Math.max( - 0, - importConflicts.length + importNewItems.length - (selectedConflicts.length + selectedNewItems.length) - ) setImportSummary({ - created: selectedNewItems.length, - duplicated: duplicateCount, - updated: updateCount, - skipped: skippedCount + created: createdItems, + duplicated: duplicateConflicts, + updated: updatedConflicts, + skipped: [...skippedConflicts, ...skippedNew] }) const body = { ...payload, conflictResolutions } + setImportStatus('processing') + setShouldReloadAfterImport(false) setImportDialogOpen(true) setImportReviewOpen(false) importAllApi.request(body) } const importAllSuccess = () => { - setImportDialogOpen(false) dispatch({ type: REMOVE_DIRTY }) + setImportStatus('success') + setShouldReloadAfterImport(true) let message = 'Import All successful' if (importSummary) { const segments = [] - if (importSummary.created> 0) { + const createdCount = importSummary.created?.length || 0 + const duplicatedCount = importSummary.duplicated?.length || 0 + const updatedCount = importSummary.updated?.length || 0 + const skippedCount = importSummary.skipped?.length || 0 + if (createdCount> 0) { segments.push( - `${importSummary.created} new item${importSummary.created === 1 ? '' : 's'} created` + `${createdCount} new item${createdCount === 1 ? '' : 's'} created` ) } - if (importSummary.duplicated> 0) { + if (duplicatedCount> 0) { segments.push( - `${importSummary.duplicated} item${importSummary.duplicated === 1 ? '' : 's'} duplicated` + `${duplicatedCount} item${duplicatedCount === 1 ? '' : 's'} duplicated` ) } - if (importSummary.updated> 0) { + if (updatedCount> 0) { segments.push( - `${importSummary.updated} item${importSummary.updated === 1 ? '' : 's'} updated` + `${updatedCount} item${updatedCount === 1 ? '' : 's'} updated` ) } - if (importSummary.skipped> 0) { + if (skippedCount> 0) { segments.push( - `${importSummary.skipped} item${importSummary.skipped === 1 ? '' : 's'} skipped` + `${skippedCount} item${skippedCount === 1 ? '' : 's'} skipped` ) } if (segments.length> 0) { @@ -1137,7 +1371,6 @@ const ProfileSection = ({ handleLogout }) => { ) } }) - setImportSummary(null) } const importAll = () => { @@ -1200,6 +1433,8 @@ const ProfileSection = ({ handleLogout }) => { setImportNewItems([]) setNewItemSelections({}) setImportSummary(null) + setImportStatus('idle') + setShouldReloadAfterImport(false) let errMsg = 'Invalid Imported File' let error = previewImportApi.error if (error?.response?.data) { @@ -1238,7 +1473,6 @@ const ProfileSection = ({ handleLogout }) => { setConflictDecisions({}) setConflictSelections({}) setNewItemSelections({}) - navigate(0) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [importAllApi.data]) @@ -1253,6 +1487,8 @@ const ProfileSection = ({ handleLogout }) => { setConflictSelections({}) setNewItemSelections({}) setImportSummary(null) + setImportStatus('idle') + setShouldReloadAfterImport(false) let errMsg = 'Invalid Imported File' let error = importAllApi.error if (error?.response?.data) { @@ -1469,7 +1705,12 @@ const ProfileSection = ({ handleLogout }) => { onConfirm={handleConfirmImport} disableConfirm={!pendingImportPayload} /> - + ) } From 49ac7c6db173227008b66fe02d5b55da03921b9a Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月16日 13:33:34 +0200 Subject: [PATCH 10/13] Fix conflict selection keys and expand new item accordions --- .../MainLayout/Header/ProfileSection/index.jsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index 9d9c6ed5586..d78c3ce1492 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -90,13 +90,19 @@ const importTypeLabels = { AssistantOpenAI: 'OpenAI Assistant', AssistantAzure: 'Azure Assistant', ChatFlow: 'Chat Flow', + ChatMessage: 'Chat Message', CustomTemplate: 'Custom Template', DocumentStore: 'Document Store', + DocumentStoreFileChunk: 'Document Store File Chunk', + Execution: 'Execution', Tool: 'Tool', Variable: 'Variable' } -const getConflictKey = (conflict) => `${conflict.type}:${conflict.importId}` +const getConflictKey = (conflict = {}) => { + const { type, existingId, importId, id, name } = conflict + return [type, existingId, importId ?? id ?? name].filter(Boolean).join(':') +} const getImportItemName = (type, item) => { if (!item) return '' @@ -261,8 +267,11 @@ const ImportReviewDialog = ({ 'AssistantOpenAI', 'AssistantAzure', 'ChatFlow', + 'ChatMessage', 'CustomTemplate', 'DocumentStore', + 'DocumentStoreFileChunk', + 'Execution', 'Tool', 'Variable' ]), @@ -303,8 +312,14 @@ const ImportReviewDialog = ({ AssistantOpenAI: { label: importTypeLabels.AssistantOpenAI, color: theme.palette.primary.main }, AssistantAzure: { label: importTypeLabels.AssistantAzure, color: theme.palette.secondary.main }, ChatFlow: { label: importTypeLabels.ChatFlow, color: theme.palette.primary.dark }, + ChatMessage: { label: importTypeLabels.ChatMessage, color: theme.palette.primary.light }, CustomTemplate: { label: importTypeLabels.CustomTemplate, color: theme.palette.error.main }, DocumentStore: { label: importTypeLabels.DocumentStore, color: theme.palette.secondary.dark }, + DocumentStoreFileChunk: { + label: importTypeLabels.DocumentStoreFileChunk, + color: theme.palette.secondary.light + }, + Execution: { label: importTypeLabels.Execution, color: theme.palette.info.light }, Tool: { label: importTypeLabels.Tool, color: theme.palette.success.dark }, Variable: { label: importTypeLabels.Variable, color: theme.palette.warning.dark } }), From 57f725a2e6df7ca0260eb27a64908f132656c7c5 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月16日 19:05:29 +0200 Subject: [PATCH 11/13] Fix import review new items including conflicts --- .../Header/ProfileSection/index.jsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index d78c3ce1492..eb61d6597ce 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -104,6 +104,11 @@ const getConflictKey = (conflict = {}) => { return [type, existingId, importId ?? id ?? name].filter(Boolean).join(':') } +const getImportItemKey = (item = {}) => { + const { type, importId, id, name } = item + return [type, importId ?? id ?? name].filter(Boolean).join(':') +} + const getImportItemName = (type, item) => { if (!item) return '' if (item.name) return item.name @@ -255,7 +260,7 @@ const ImportReviewDialog = ({ const allConflictsSelected = conflicts.length> 0 && conflicts.every((conflict) => conflictSelections[getConflictKey(conflict)]) const allNewItemsSelected = - newItems.length> 0 && newItems.every((item) => newItemSelections[getConflictKey(item)]) + newItems.length> 0 && newItems.every((item) => newItemSelections[getImportItemKey(item)]) const collapsibleTypes = useMemo( () => @@ -717,7 +722,7 @@ const ImportReviewDialog = ({ const renderItems = ( {items.map((item) => { - const key = getConflictKey(item) + const key = getImportItemKey(item) const isSelected = newItemSelections[key] || false const inactiveBackground = lighten( sectionBackground || alpha(theme.palette.action.disabledBackground, 0.3), @@ -1197,7 +1202,7 @@ const ProfileSection = ({ handleLogout }) => { const handleNewItemSelectionChange = (item, isSelected) => { setNewItemSelections((prev) => ({ ...prev, - [getConflictKey(item)]: isSelected + [getImportItemKey(item)]: isSelected })) } @@ -1215,7 +1220,7 @@ const ProfileSection = ({ handleLogout }) => { setNewItemSelections((prev) => { const updated = { ...prev } importNewItems.forEach((item) => { - updated[getConflictKey(item)] = isSelected + updated[getImportItemKey(item)] = isSelected }) return updated }) @@ -1262,7 +1267,7 @@ const ProfileSection = ({ handleLogout }) => { const selectedConflicts = importConflicts.filter( (conflict) => conflictSelections[getConflictKey(conflict)] ) - const selectedNewItems = importNewItems.filter((item) => newItemSelections[getConflictKey(item)]) + const selectedNewItems = importNewItems.filter((item) => newItemSelections[getImportItemKey(item)]) const duplicateConflicts = [] const updatedConflicts = [] selectedConflicts.forEach((conflict) => { @@ -1294,7 +1299,7 @@ const ProfileSection = ({ handleLogout }) => { reason: 'conflict' })) const skippedNew = importNewItems - .filter((item) => !newItemSelections[getConflictKey(item)]) + .filter((item) => !newItemSelections[getImportItemKey(item)]) .map((item) => ({ type: item.type, name: item.name, @@ -1316,7 +1321,7 @@ const ProfileSection = ({ handleLogout }) => { } }) importNewItems.forEach((item) => { - if (newItemSelections[getConflictKey(item)]) return + if (newItemSelections[getImportItemKey(item)]) return const collection = payload[item.type] if (Array.isArray(collection)) { payload[item.type] = collection.filter((entry) => entry.id !== item.importId) @@ -1407,13 +1412,13 @@ const ProfileSection = ({ handleLogout }) => { }) setConflictDecisions(initialDecisions) setConflictSelections(initialSelections) - const conflictKeys = new Set(conflicts.map((conflict) => getConflictKey(conflict))) + const conflictKeys = new Set(conflicts.map((conflict) => getImportItemKey(conflict))) const newItems = [] Object.entries(pendingImportPayload).forEach(([type, items]) => { if (!Array.isArray(items)) return items.forEach((item) => { if (!item || !item.id) return - const key = getConflictKey({ type, importId: item.id }) + const key = getImportItemKey({ type, importId: item.id }) if (conflictKeys.has(key)) return newItems.push({ type, @@ -1432,7 +1437,7 @@ const ProfileSection = ({ handleLogout }) => { }) const initialNewSelections = {} newItems.forEach((item) => { - initialNewSelections[getConflictKey(item)] = false + initialNewSelections[getImportItemKey(item)] = false }) setImportNewItems(newItems) setNewItemSelections(initialNewSelections) From 1e4e08068958fa7933529f59626eda6984789d48 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月16日 20:47:02 +0200 Subject: [PATCH 12/13] Fix chatflow credential bindings during import --- .../src/services/export-import/index.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index fd7d9a54e48..07b65502ee5 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -246,6 +246,42 @@ const collectCredentialBindingsFromChatflow = ( } stripCredentialIds(exportableNodeData, ['data'], nodeId, chatflow.id, bindings) + + const existingBindingKeys = new Set( + bindings + .filter((binding) => binding.nodeId === nodeId) + .map((binding) => `${binding.path.join('.') ?? ''}:${binding.property}`) + ) + + const ensureBinding = ( + path: (string | number)[], + property: string, + credentialValue: unknown + ) => { + if (typeof credentialValue !== 'string') return + const trimmed = credentialValue.trim() + if (!trimmed) return + const key = `${path.join('.') ?? ''}:${property}` + if (existingBindingKeys.has(key)) return + bindings.push({ + chatflowId: chatflow.id, + nodeId, + path, + property, + credentialId: trimmed + }) + existingBindingKeys.add(key) + } + + ensureBinding(['data', 'inputs'], FLOWISE_CREDENTIAL_ID_KEY, nodeData.inputs?.[FLOWISE_CREDENTIAL_ID_KEY]) + ensureBinding(['data'], 'credential', nodeData.credential) + + if (Array.isArray(nodeData.inputParams)) { + for (const param of nodeData.inputParams) { + if (!param || param.type !== 'credential' || !param.name) continue + ensureBinding(['data', 'inputs'], param.name, nodeData.inputs?.[param.name]) + } + } } return { parsed, bindings } } catch (error) { From 94bf3515a7e6339d59bf934e3d1f209e5121e733 Mon Sep 17 00:00:00 2001 From: Iokin Pardo Date: 2025年10月16日 22:50:33 +0200 Subject: [PATCH 13/13] Adjust duplicate import handling and new item UI --- .../Header/ProfileSection/index.jsx | 117 ++++++++++++++++-- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx index eb61d6597ce..e5b3effeede 100644 --- a/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx +++ b/packages/ui/src/layout/MainLayout/Header/ProfileSection/index.jsx @@ -91,6 +91,7 @@ const importTypeLabels = { AssistantAzure: 'Azure Assistant', ChatFlow: 'Chat Flow', ChatMessage: 'Chat Message', + ChatMessageFeedback: 'Chat Message Feedback', CustomTemplate: 'Custom Template', DocumentStore: 'Document Store', DocumentStoreFileChunk: 'Document Store File Chunk', @@ -128,6 +129,73 @@ const getImportItemName = (type, item) => { return item.id || `${type} item` } +const CLONE_NAME_SUFFIX = ' - clone' + +const appendCloneSuffix = (value) => { + if (typeof value !== 'string') return value + const trimmed = value.trimEnd() + if (!trimmed) return value + if (trimmed.toLowerCase().endsWith(CLONE_NAME_SUFFIX)) return trimmed + return `${trimmed}${CLONE_NAME_SUFFIX}` +} + +const applyCloneSuffixToObject = (object) => { + if (!object || typeof object !== 'object') return { value: object, changed: false } + const updated = { ...object } + let changed = false + const fields = ['name', 'label', 'title'] + fields.forEach((key) => { + if (typeof updated[key] === 'string' && updated[key].trim().length> 0) { + const nextValue = appendCloneSuffix(updated[key]) + if (nextValue !== updated[key]) { + updated[key] = nextValue + changed = true + } + } + }) + return { value: updated, changed } +} + +const applyCloneSuffixToItem = (item) => { + if (!item || typeof item !== 'object') return item + const updated = { ...item } + let changed = false + + const fields = ['name', 'label', 'title'] + fields.forEach((key) => { + if (typeof updated[key] === 'string' && updated[key].trim().length> 0) { + const nextValue = appendCloneSuffix(updated[key]) + if (nextValue !== updated[key]) { + updated[key] = nextValue + changed = true + } + } + }) + + if (updated.details) { + if (typeof updated.details === 'string') { + try { + const parsed = JSON.parse(updated.details) + const { value: transformedDetails, changed: detailsChanged } = applyCloneSuffixToObject(parsed) + if (detailsChanged) { + updated.details = JSON.stringify(transformedDetails) + changed = true + } + } catch (error) { + // ignore malformed JSON details + } + } else if (typeof updated.details === 'object') { + const { value: transformedDetails, changed: detailsChanged } = applyCloneSuffixToObject(updated.details) + if (detailsChanged) { + updated.details = transformedDetails + changed = true + } + } + } + + return changed ? updated : item +} + const ExportDialog = ({ show, onCancel, onExport }) => { const portalElement = document.getElementById('portal') @@ -273,6 +341,7 @@ const ImportReviewDialog = ({ 'AssistantAzure', 'ChatFlow', 'ChatMessage', + 'ChatMessageFeedback', 'CustomTemplate', 'DocumentStore', 'DocumentStoreFileChunk', @@ -318,6 +387,10 @@ const ImportReviewDialog = ({ AssistantAzure: { label: importTypeLabels.AssistantAzure, color: theme.palette.secondary.main }, ChatFlow: { label: importTypeLabels.ChatFlow, color: theme.palette.primary.dark }, ChatMessage: { label: importTypeLabels.ChatMessage, color: theme.palette.primary.light }, + ChatMessageFeedback: { + label: importTypeLabels.ChatMessageFeedback, + color: theme.palette.info.main + }, CustomTemplate: { label: importTypeLabels.CustomTemplate, color: theme.palette.error.main }, DocumentStore: { label: importTypeLabels.DocumentStore, color: theme.palette.secondary.dark }, DocumentStoreFileChunk: { @@ -478,6 +551,14 @@ const ImportReviewDialog = ({ const action = decisions[key] || 'update' const isSelected = conflictSelections[key] || false const existingLink = getExistingLink(conflict) + const rawName = + conflict.name ?? conflict.importId ?? conflict.type ?? '' + const baseName = + typeof rawName === 'string' ? rawName : String(rawName) + const displayName = + action === 'duplicate' + ? appendCloneSuffix(baseName) + : baseName const inactiveBackground = lighten( sectionBackground || alpha(theme.palette.action.disabledBackground, 0.3), theme.palette.mode === 'dark' ? 0.08 : 0.24 @@ -533,14 +614,14 @@ const ImportReviewDialog = ({ ) } /> - - - {conflict.name} - - - Existing ID:{' '} - {existingLink ? ( - + + {displayName} + + + Existing ID:{' '} + {existingLink ? ( + { (conflict) => conflictSelections[getConflictKey(conflict)] ) const selectedNewItems = importNewItems.filter((item) => newItemSelections[getImportItemKey(item)]) + const payload = JSON.parse(JSON.stringify(pendingImportPayload)) const duplicateConflicts = [] const updatedConflicts = [] selectedConflicts.forEach((conflict) => { const action = conflictDecisions[getConflictKey(conflict)] || 'update' + const rawName = conflict.name ?? conflict.importId ?? conflict.type ?? '' + const baseName = typeof rawName === 'string' ? rawName : String(rawName) const baseInfo = { type: conflict.type, - name: conflict.name, + name: baseName, importId: conflict.importId, existingId: conflict.existingId } if (action === 'duplicate') { + const clonedName = appendCloneSuffix(baseName) + if (clonedName && clonedName !== baseInfo.name) { + baseInfo.name = clonedName + } + const collection = payload[conflict.type] + if (Array.isArray(collection)) { + const index = collection.findIndex((item) => item.id === conflict.importId) + if (index !== -1) { + const updatedItem = applyCloneSuffixToItem(collection[index]) + if (updatedItem !== collection[index]) { + collection[index] = updatedItem + } + } + } duplicateConflicts.push(baseInfo) return } @@ -1312,7 +1410,6 @@ const ProfileSection = ({ handleLogout }) => { existingId: conflict.existingId, action: conflictDecisions[getConflictKey(conflict)] || 'update' })) - const payload = JSON.parse(JSON.stringify(pendingImportPayload)) importConflicts.forEach((conflict) => { if (conflictSelections[getConflictKey(conflict)]) return const collection = payload[conflict.type]

AltStyle によって変換されたページ (->オリジナル) /