Merge branch 'main' into feat/mcp-authentication

This commit is contained in:
zxhlyh
2025-10-13 16:52:42 +08:00
114 changed files with 3941 additions and 1416 deletions

View File

@ -13,39 +13,60 @@ import { ThemeProvider } from 'next-themes'
import useTheme from '@/hooks/use-theme'
import { useEffect, useState } from 'react'
const DARK_MODE_MEDIA_QUERY = /prefers-color-scheme:\s*dark/i
// Setup browser environment for testing
const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = false) => {
// Mock localStorage
const mockStorage = {
getItem: jest.fn((key: string) => {
if (key === 'theme') return storedTheme
return null
}),
setItem: jest.fn(),
removeItem: jest.fn(),
if (typeof window === 'undefined')
return
try {
window.localStorage.clear()
}
catch {
// ignore if localStorage has been replaced by a throwing stub
}
// Mock system theme preference
const mockMatchMedia = jest.fn((query: string) => ({
matches: query.includes('dark') && systemPrefersDark,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
}))
if (storedTheme === null)
window.localStorage.removeItem('theme')
else
window.localStorage.setItem('theme', storedTheme)
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', {
value: mockStorage,
configurable: true,
})
document.documentElement.removeAttribute('data-theme')
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
configurable: true,
})
const mockMatchMedia: typeof window.matchMedia = (query: string) => {
const listeners = new Set<(event: MediaQueryListEvent) => void>()
const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query)
const matches = isDarkQuery ? systemPrefersDark : false
const mediaQueryList: MediaQueryList = {
matches,
media: query,
onchange: null,
addListener: (listener: MediaQueryListListener) => {
listeners.add(listener)
},
removeListener: (listener: MediaQueryListListener) => {
listeners.delete(listener)
},
addEventListener: (_event, listener: EventListener) => {
if (typeof listener === 'function')
listeners.add(listener as MediaQueryListListener)
},
removeEventListener: (_event, listener: EventListener) => {
if (typeof listener === 'function')
listeners.delete(listener as MediaQueryListListener)
},
dispatchEvent: (event: Event) => {
listeners.forEach(listener => listener(event as MediaQueryListEvent))
return true
},
}
return mediaQueryList
}
return { mockStorage, mockMatchMedia }
jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
}
// Simulate real page component based on Dify's actual theme usage
@ -94,7 +115,17 @@ const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
describe('Real Browser Environment Dark Mode Flicker Test', () => {
beforeEach(() => {
jest.restoreAllMocks()
jest.clearAllMocks()
if (typeof window !== 'undefined') {
try {
window.localStorage.clear()
}
catch {
// ignore when localStorage is replaced with an error-throwing stub
}
document.documentElement.removeAttribute('data-theme')
}
})
describe('Page Refresh Scenario Simulation', () => {
@ -323,35 +354,40 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
describe('Edge Cases and Error Handling', () => {
test('handles localStorage access errors gracefully', async () => {
// Mock localStorage to throw an error
setupMockEnvironment(null)
const mockStorage = {
getItem: jest.fn(() => {
throw new Error('LocalStorage access denied')
}),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', {
value: mockStorage,
configurable: true,
})
}
render(
<TestThemeProvider>
<PageComponent />
</TestThemeProvider>,
)
// Should fallback gracefully without crashing
await waitFor(() => {
expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
Object.defineProperty(window, 'localStorage', {
value: mockStorage,
configurable: true,
})
// Should default to light theme when localStorage fails
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
try {
render(
<TestThemeProvider>
<PageComponent />
</TestThemeProvider>,
)
// Should fallback gracefully without crashing
await waitFor(() => {
expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
})
// Should default to light theme when localStorage fails
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
}
finally {
Reflect.deleteProperty(window, 'localStorage')
}
})
test('handles invalid theme values in localStorage', async () => {
@ -403,6 +439,8 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
setupMockEnvironment('dark')
expect(window.localStorage.getItem('theme')).toBe('dark')
render(
<TestThemeProvider>
<PerformanceTestComponent />

View File

@ -17,12 +17,9 @@ import type {
import { noop } from 'lodash-es'
export type EmbeddedChatbotContextValue = {
userCanAccess?: boolean
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
appParams?: ChatConfig
appMeta: AppMeta | null
appData: AppData | null
appParams: ChatConfig | null
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
@ -59,7 +56,10 @@ export type EmbeddedChatbotContextValue = {
}
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
userCanAccess: false,
appData: null,
appMeta: null,
appParams: null,
appChatListDataLoading: false,
currentConversationId: '',
appPrevChatList: [],
pinnedConversationList: [],

View File

@ -18,9 +18,6 @@ import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import {
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
fetchChatList,
fetchConversations,
generationConversationName,
@ -36,8 +33,7 @@ import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { noop } from 'lodash-es'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
@ -67,18 +63,10 @@ function getFormattedChatList(messages: any[]) {
export const useEmbeddedChatbot = () => {
const isInstalledApp = false
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
appId: appInfo?.app_id,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
const appData = useMemo(() => {
return appInfo
}, [appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
const appInfo = useWebAppStore(s => s.appInfo)
const appMeta = useWebAppStore(s => s.appMeta)
const appParams = useWebAppStore(s => s.appParams)
const appId = useMemo(() => appInfo?.app_id, [appInfo])
const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>()
@ -145,8 +133,6 @@ export const useEmbeddedChatbot = () => {
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
const { data: appPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
@ -398,16 +384,13 @@ export const useEmbeddedChatbot = () => {
}, [isInstalledApp, appId, t, notify])
return {
appInfoError,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
isInstalledApp,
allowResetChat,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appData: appInfo,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,

View File

@ -101,7 +101,6 @@ const EmbeddedChatbotWrapper = () => {
const {
appData,
userCanAccess,
appParams,
appMeta,
appChatListDataLoading,
@ -135,7 +134,6 @@ const EmbeddedChatbotWrapper = () => {
} = useEmbeddedChatbot()
return <EmbeddedChatbotContext.Provider value={{
userCanAccess,
appData,
appParams,
appMeta,

View File

@ -0,0 +1,152 @@
import React from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import InlineDeleteConfirm from './index'
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common.operation.deleteConfirmTitle': 'Delete?',
'common.operation.yes': 'Yes',
'common.operation.no': 'No',
'common.operation.confirmAction': 'Please confirm your action.',
}
return translations[key] || key
},
}),
}))
afterEach(cleanup)
describe('InlineDeleteConfirm', () => {
describe('Rendering', () => {
test('should render with default text', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
expect(getByText('Delete?')).toBeInTheDocument()
expect(getByText('No')).toBeInTheDocument()
expect(getByText('Yes')).toBeInTheDocument()
})
test('should render with custom text', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm
title="Remove?"
confirmText="Confirm"
cancelText="Cancel"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
expect(getByText('Remove?')).toBeInTheDocument()
expect(getByText('Cancel')).toBeInTheDocument()
expect(getByText('Confirm')).toBeInTheDocument()
})
test('should have proper ARIA attributes', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { container } = render(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveAttribute('aria-labelledby', 'inline-delete-confirm-title')
expect(wrapper).toHaveAttribute('aria-describedby', 'inline-delete-confirm-description')
})
})
describe('Button interactions', () => {
test('should call onCancel when cancel button is clicked', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
fireEvent.click(getByText('No'))
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
test('should call onConfirm when confirm button is clicked', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
fireEvent.click(getByText('Yes'))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).not.toHaveBeenCalled()
})
})
describe('Variant prop', () => {
test('should render with delete variant by default', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm onConfirm={onConfirm} onCancel={onCancel} />,
)
const confirmButton = getByText('Yes').closest('button')
expect(confirmButton?.className).toContain('btn-destructive')
})
test('should render without destructive class for warning variant', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm
variant="warning"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
const confirmButton = getByText('Yes').closest('button')
expect(confirmButton?.className).not.toContain('btn-destructive')
})
test('should render without destructive class for info variant', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { getByText } = render(
<InlineDeleteConfirm
variant="info"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
const confirmButton = getByText('Yes').closest('button')
expect(confirmButton?.className).not.toContain('btn-destructive')
})
})
describe('Custom className', () => {
test('should apply custom className to wrapper', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
const { container } = render(
<InlineDeleteConfirm
className="custom-class"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('custom-class')
})
})
})

View File

@ -0,0 +1,83 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
export type InlineDeleteConfirmProps = {
title?: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
className?: string
variant?: 'delete' | 'warning' | 'info'
}
const InlineDeleteConfirm: FC<InlineDeleteConfirmProps> = ({
title,
confirmText,
cancelText,
onConfirm,
onCancel,
className,
variant = 'delete',
}) => {
const { t } = useTranslation()
const titleText = title || t('common.operation.deleteConfirmTitle', 'Delete?')
const confirmTxt = confirmText || t('common.operation.yes', 'Yes')
const cancelTxt = cancelText || t('common.operation.no', 'No')
return (
<div
aria-labelledby="inline-delete-confirm-title"
aria-describedby="inline-delete-confirm-description"
className={cn(
'flex w-[120px] flex-col justify-center gap-1.5',
'rounded-[10px] border-[0.5px] border-components-panel-border-subtle',
'bg-components-panel-bg-blur px-2 pb-2 pt-1.5',
'backdrop-blur-[10px]',
'shadow-lg',
className,
)}
>
<div
id="inline-delete-confirm-title"
className="system-xs-semibold text-text-primary"
>
{titleText}
</div>
<div className="flex w-full items-center justify-center gap-1">
<Button
size="small"
variant="secondary"
onClick={onCancel}
aria-label={cancelTxt}
className="flex-1"
>
{cancelTxt}
</Button>
<Button
size="small"
variant="primary"
destructive={variant === 'delete'}
onClick={onConfirm}
aria-label={confirmTxt}
className="flex-1"
>
{confirmTxt}
</Button>
</div>
<span id="inline-delete-confirm-description" className="sr-only">
{t('common.operation.confirmAction', 'Please confirm your action.')}
</span>
</div>
)
}
InlineDeleteConfirm.displayName = 'InlineDeleteConfirm'
export default InlineDeleteConfirm

View File

@ -7,6 +7,7 @@ import { useInvalidateStrategyProviders } from '@/service/use-strategy'
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { PluginType } from '../../types'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import { useInvalidDataSourceListAuth } from '@/service/use-datasource'
const useRefreshPluginList = () => {
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
@ -19,6 +20,8 @@ const useRefreshPluginList = () => {
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
const invalidateAllDataSources = useInvalidDataSourceList()
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
const invalidateStrategyProviders = useInvalidateStrategyProviders()
return {
refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => {
@ -32,8 +35,10 @@ const useRefreshPluginList = () => {
// TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins
}
if ((manifest && PluginType.datasource.includes(manifest.category)) || refreshAllType)
if ((manifest && PluginType.datasource.includes(manifest.category)) || refreshAllType) {
invalidateAllDataSources()
invalidateDataSourceListAuth()
}
// model select
if ((manifest && PluginType.model.includes(manifest.category)) || refreshAllType) {

View File

@ -1,10 +1,9 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import { RiArrowRightUpLine } from '@remixicon/react'
import { BlockEnum } from '../types'
import type {
OnSelectBlock,
@ -14,10 +13,12 @@ import type { DataSourceDefaultValue, ToolDefaultValue } from './types'
import Tools from './tools'
import { ViewType } from './view-type-select'
import cn from '@/utils/classnames'
import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { getMarketplaceUrl } from '@/utils/var'
import PluginList, { type ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE } from './constants'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
import { PluginType } from '../../plugins/types'
import { useGetLanguage } from '@/context/i18n'
type AllToolsProps = {
className?: string
@ -34,9 +35,26 @@ const DataSources = ({
onSelect,
dataSources,
}: AllToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const pluginRef = useRef<ListRef>(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
const isMatchingKeywords = (text: string, keywords: string) => {
return text.toLowerCase().includes(keywords.toLowerCase())
}
const filteredDatasources = useMemo(() => {
const hasFilter = searchText
if (!hasFilter)
return dataSources.filter(toolWithProvider => toolWithProvider.tools.length > 0)
return dataSources.filter((toolWithProvider) => {
return isMatchingKeywords(toolWithProvider.name, searchText) || toolWithProvider.tools.some((tool) => {
return tool.label[language].toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase())
})
})
}, [searchText, dataSources, language])
const handleSelect = useCallback((_: any, toolDefaultValue: ToolDefaultValue) => {
let defaultValue: DataSourceDefaultValue = {
plugin_id: toolDefaultValue?.provider_id,
@ -55,8 +73,24 @@ const DataSources = ({
}
onSelect(BlockEnum.DataSource, toolDefaultValue && defaultValue)
}, [onSelect])
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const {
queryPluginsWithDebounced: fetchPlugins,
plugins: notInstalledPlugins = [],
} = useMarketplacePlugins()
useEffect(() => {
if (!enable_marketplace) return
if (searchText) {
fetchPlugins({
query: searchText,
category: PluginType.datasource,
})
}
}, [searchText, enable_marketplace])
return (
<div className={cn(className)}>
<div
@ -66,24 +100,23 @@ const DataSources = ({
>
<Tools
className={toolContentClassName}
tools={dataSources}
tools={filteredDatasources}
onSelect={handleSelect as OnSelectBlock}
viewType={ViewType.flat}
hasSearchText={!!searchText}
canNotSelectMultiple
/>
{
enable_marketplace && (
<Link
className='system-sm-medium sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg'
href={getMarketplaceUrl('')}
target='_blank'
>
<span>{t('plugin.findMoreInMarketplace')}</span>
<RiArrowRightUpLine className='ml-0.5 h-3 w-3' />
</Link>
)
}
{/* Plugins from marketplace */}
{enable_marketplace && (
<PluginList
ref={pluginRef}
wrapElemRef={wrapElemRef}
list={notInstalledPlugins}
tags={[]}
searchText={searchText}
toolContentClassName={toolContentClassName}
/>
)}
</div>
</div>
)

View File

@ -0,0 +1,94 @@
import type { ComponentType } from 'react'
import { BlockEnum } from '../types'
import StartNode from './start/node'
import StartPanel from './start/panel'
import EndNode from './end/node'
import EndPanel from './end/panel'
import AnswerNode from './answer/node'
import AnswerPanel from './answer/panel'
import LLMNode from './llm/node'
import LLMPanel from './llm/panel'
import KnowledgeRetrievalNode from './knowledge-retrieval/node'
import KnowledgeRetrievalPanel from './knowledge-retrieval/panel'
import QuestionClassifierNode from './question-classifier/node'
import QuestionClassifierPanel from './question-classifier/panel'
import IfElseNode from './if-else/node'
import IfElsePanel from './if-else/panel'
import CodeNode from './code/node'
import CodePanel from './code/panel'
import TemplateTransformNode from './template-transform/node'
import TemplateTransformPanel from './template-transform/panel'
import HttpNode from './http/node'
import HttpPanel from './http/panel'
import ToolNode from './tool/node'
import ToolPanel from './tool/panel'
import VariableAssignerNode from './variable-assigner/node'
import VariableAssignerPanel from './variable-assigner/panel'
import AssignerNode from './assigner/node'
import AssignerPanel from './assigner/panel'
import ParameterExtractorNode from './parameter-extractor/node'
import ParameterExtractorPanel from './parameter-extractor/panel'
import IterationNode from './iteration/node'
import IterationPanel from './iteration/panel'
import LoopNode from './loop/node'
import LoopPanel from './loop/panel'
import DocExtractorNode from './document-extractor/node'
import DocExtractorPanel from './document-extractor/panel'
import ListFilterNode from './list-operator/node'
import ListFilterPanel from './list-operator/panel'
import AgentNode from './agent/node'
import AgentPanel from './agent/panel'
import DataSourceNode from './data-source/node'
import DataSourcePanel from './data-source/panel'
import KnowledgeBaseNode from './knowledge-base/node'
import KnowledgeBasePanel from './knowledge-base/panel'
export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Start]: StartNode,
[BlockEnum.End]: EndNode,
[BlockEnum.Answer]: AnswerNode,
[BlockEnum.LLM]: LLMNode,
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrievalNode,
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
[BlockEnum.IfElse]: IfElseNode,
[BlockEnum.Code]: CodeNode,
[BlockEnum.TemplateTransform]: TemplateTransformNode,
[BlockEnum.HttpRequest]: HttpNode,
[BlockEnum.Tool]: ToolNode,
[BlockEnum.VariableAssigner]: VariableAssignerNode,
[BlockEnum.Assigner]: AssignerNode,
[BlockEnum.VariableAggregator]: VariableAssignerNode,
[BlockEnum.ParameterExtractor]: ParameterExtractorNode,
[BlockEnum.Iteration]: IterationNode,
[BlockEnum.Loop]: LoopNode,
[BlockEnum.DocExtractor]: DocExtractorNode,
[BlockEnum.ListFilter]: ListFilterNode,
[BlockEnum.Agent]: AgentNode,
[BlockEnum.DataSource]: DataSourceNode,
[BlockEnum.KnowledgeBase]: KnowledgeBaseNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Start]: StartPanel,
[BlockEnum.End]: EndPanel,
[BlockEnum.Answer]: AnswerPanel,
[BlockEnum.LLM]: LLMPanel,
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrievalPanel,
[BlockEnum.QuestionClassifier]: QuestionClassifierPanel,
[BlockEnum.IfElse]: IfElsePanel,
[BlockEnum.Code]: CodePanel,
[BlockEnum.TemplateTransform]: TemplateTransformPanel,
[BlockEnum.HttpRequest]: HttpPanel,
[BlockEnum.Tool]: ToolPanel,
[BlockEnum.VariableAssigner]: VariableAssignerPanel,
[BlockEnum.VariableAggregator]: VariableAssignerPanel,
[BlockEnum.Assigner]: AssignerPanel,
[BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
[BlockEnum.Iteration]: IterationPanel,
[BlockEnum.Loop]: LoopPanel,
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: AgentPanel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
}

View File

@ -1,101 +1,5 @@
import type { ComponentType } from 'react'
import { BlockEnum } from '../types'
import StartNode from './start/node'
import StartPanel from './start/panel'
import EndNode from './end/node'
import EndPanel from './end/panel'
import AnswerNode from './answer/node'
import AnswerPanel from './answer/panel'
import LLMNode from './llm/node'
import LLMPanel from './llm/panel'
import KnowledgeRetrievalNode from './knowledge-retrieval/node'
import KnowledgeRetrievalPanel from './knowledge-retrieval/panel'
import QuestionClassifierNode from './question-classifier/node'
import QuestionClassifierPanel from './question-classifier/panel'
import IfElseNode from './if-else/node'
import IfElsePanel from './if-else/panel'
import CodeNode from './code/node'
import CodePanel from './code/panel'
import TemplateTransformNode from './template-transform/node'
import TemplateTransformPanel from './template-transform/panel'
import HttpNode from './http/node'
import HttpPanel from './http/panel'
import ToolNode from './tool/node'
import ToolPanel from './tool/panel'
import VariableAssignerNode from './variable-assigner/node'
import VariableAssignerPanel from './variable-assigner/panel'
import AssignerNode from './assigner/node'
import AssignerPanel from './assigner/panel'
import ParameterExtractorNode from './parameter-extractor/node'
import ParameterExtractorPanel from './parameter-extractor/panel'
import IterationNode from './iteration/node'
import IterationPanel from './iteration/panel'
import LoopNode from './loop/node'
import LoopPanel from './loop/panel'
import DocExtractorNode from './document-extractor/node'
import DocExtractorPanel from './document-extractor/panel'
import ListFilterNode from './list-operator/node'
import ListFilterPanel from './list-operator/panel'
import AgentNode from './agent/node'
import AgentPanel from './agent/panel'
import DataSourceNode from './data-source/node'
import DataSourcePanel from './data-source/panel'
import KnowledgeBaseNode from './knowledge-base/node'
import KnowledgeBasePanel from './knowledge-base/panel'
import { TransferMethod } from '@/types/app'
export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Start]: StartNode,
[BlockEnum.End]: EndNode,
[BlockEnum.Answer]: AnswerNode,
[BlockEnum.LLM]: LLMNode,
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrievalNode,
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
[BlockEnum.IfElse]: IfElseNode,
[BlockEnum.Code]: CodeNode,
[BlockEnum.TemplateTransform]: TemplateTransformNode,
[BlockEnum.HttpRequest]: HttpNode,
[BlockEnum.Tool]: ToolNode,
[BlockEnum.VariableAssigner]: VariableAssignerNode,
[BlockEnum.Assigner]: AssignerNode,
[BlockEnum.VariableAggregator]: VariableAssignerNode,
[BlockEnum.ParameterExtractor]: ParameterExtractorNode,
[BlockEnum.Iteration]: IterationNode,
[BlockEnum.Loop]: LoopNode,
[BlockEnum.DocExtractor]: DocExtractorNode,
[BlockEnum.ListFilter]: ListFilterNode,
[BlockEnum.Agent]: AgentNode,
[BlockEnum.DataSource]: DataSourceNode,
[BlockEnum.KnowledgeBase]: KnowledgeBaseNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Start]: StartPanel,
[BlockEnum.End]: EndPanel,
[BlockEnum.Answer]: AnswerPanel,
[BlockEnum.LLM]: LLMPanel,
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrievalPanel,
[BlockEnum.QuestionClassifier]: QuestionClassifierPanel,
[BlockEnum.IfElse]: IfElsePanel,
[BlockEnum.Code]: CodePanel,
[BlockEnum.TemplateTransform]: TemplateTransformPanel,
[BlockEnum.HttpRequest]: HttpPanel,
[BlockEnum.Tool]: ToolPanel,
[BlockEnum.VariableAssigner]: VariableAssignerPanel,
[BlockEnum.VariableAggregator]: VariableAssignerPanel,
[BlockEnum.Assigner]: AssignerPanel,
[BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
[BlockEnum.Iteration]: IterationPanel,
[BlockEnum.Loop]: LoopPanel,
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: AgentPanel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
}
export const CUSTOM_NODE_TYPE = 'custom'
export const FILE_TYPE_OPTIONS = [
{ value: 'image', i18nKey: 'image' },
{ value: 'document', i18nKey: 'doc' },

View File

@ -8,7 +8,7 @@ import { CUSTOM_NODE } from '../constants'
import {
NodeComponentMap,
PanelComponentMap,
} from './constants'
} from './components'
import BaseNode from './_base/node'
import BasePanel from './_base/components/workflow-panel'

View File

@ -29,7 +29,7 @@ const ChunkStructure = ({
<Field
fieldTitleProps={{
title: t('workflow.nodes.knowledgeBase.chunkStructure'),
tooltip: t('workflow.nodes.knowledgeBase.chunkStructure'),
tooltip: t('workflow.nodes.knowledgeBase.chunkStructureTip.message'),
operation: chunkStructure && (
<Selector
options={options}

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'Alles auswählen',
deSelectAll: 'Alle abwählen',
config: 'Konfiguration',
yes: 'Ja',
deleteConfirmTitle: 'Löschen?',
no: 'Nein',
confirmAction: 'Bitte bestätigen Sie Ihre Aktion.',
},
placeholder: {
input: 'Bitte eingeben',

View File

@ -18,6 +18,10 @@ const translation = {
cancel: 'Cancel',
clear: 'Clear',
save: 'Save',
yes: 'Yes',
no: 'No',
deleteConfirmTitle: 'Delete?',
confirmAction: 'Please confirm your action.',
saveAndEnable: 'Save & Enable',
edit: 'Edit',
add: 'Add',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Deseleccionar todo',
selectAll: 'Seleccionar todo',
config: 'Config',
confirmAction: 'Por favor, confirme su acción.',
deleteConfirmTitle: '¿Eliminar?',
yes: 'Sí',
no: 'No',
},
errorMsg: {
fieldRequired: '{{field}} es requerido',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'انتخاب همه',
deSelectAll: 'همه را انتخاب نکنید',
config: 'تنظیمات',
no: 'نه',
deleteConfirmTitle: 'حذف شود؟',
yes: 'بله',
confirmAction: 'لطفاً اقدام خود را تأیید کنید.',
},
errorMsg: {
fieldRequired: '{{field}} الزامی است',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Désélectionner tout',
selectAll: 'Sélectionner tout',
config: 'Config',
no: 'Non',
confirmAction: 'Veuillez confirmer votre action.',
deleteConfirmTitle: 'Supprimer ?',
yes: 'Oui',
},
placeholder: {
input: 'Veuillez entrer',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'सभी चुनें',
deSelectAll: 'सभी चयन हटाएँ',
config: 'कॉन्फ़िगरेशन',
no: 'नहीं',
yes: 'हाँ',
deleteConfirmTitle: 'हटाएं?',
confirmAction: 'कृपया अपनी क्रिया की पुष्टि करें।',
},
errorMsg: {
fieldRequired: '{{field}} आवश्यक है',

View File

@ -67,6 +67,10 @@ const translation = {
sure: 'Saya yakin',
imageCopied: 'Gambar yang disalin',
config: 'Konfigurasi',
deleteConfirmTitle: 'Hapus?',
confirmAction: 'Silakan konfirmasi tindakan Anda.',
yes: 'Ya',
no: 'Tidak',
},
errorMsg: {
urlError: 'URL harus dimulai dengan http:// atau https://',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'Seleziona tutto',
deSelectAll: 'Deseleziona tutto',
config: 'Config',
no: 'No',
yes: 'Sì',
confirmAction: 'Per favore conferma la tua azione.',
deleteConfirmTitle: 'Eliminare?',
},
errorMsg: {
fieldRequired: '{{field}} è obbligatorio',

View File

@ -67,6 +67,10 @@ const translation = {
selectAll: 'すべて選択',
deSelectAll: 'すべて選択解除',
config: 'コンフィグ',
yes: 'はい',
no: 'いいえ',
deleteConfirmTitle: '削除しますか?',
confirmAction: '操作を確認してください。',
},
errorMsg: {
fieldRequired: '{{field}}は必要です',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: '모두 선택',
deSelectAll: '모두 선택 해제',
config: '구성',
no: '아니요',
yes: '네',
deleteConfirmTitle: '삭제하시겠습니까?',
confirmAction: '귀하의 행동을 확인해 주세요.',
},
placeholder: {
input: '입력해주세요',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Odznacz wszystkie',
selectAll: 'Zaznacz wszystkie',
config: 'Konfiguracja',
yes: 'Tak',
no: 'Nie',
deleteConfirmTitle: 'Usunąć?',
confirmAction: 'Proszę potwierdzić swoją akcję.',
},
placeholder: {
input: 'Proszę wprowadzić',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Desmarcar tudo',
selectAll: 'Selecionar tudo',
config: 'Configuração',
no: 'Não',
yes: 'Sim',
deleteConfirmTitle: 'Excluir?',
confirmAction: 'Por favor, confirme sua ação.',
},
placeholder: {
input: 'Por favor, insira',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Deselectați tot',
selectAll: 'Selectați tot',
config: 'Configurație',
yes: 'Da',
deleteConfirmTitle: 'Ștergere?',
no: 'Nu',
confirmAction: 'Vă rugăm să confirmați acțiunea dumneavoastră.',
},
placeholder: {
input: 'Vă rugăm să introduceți',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'Выбрать все',
deSelectAll: 'Снять выделение со всех',
config: 'Конфигурация',
yes: 'Да',
no: 'Нет',
deleteConfirmTitle: 'Удалить?',
confirmAction: 'Пожалуйста, подтвердите ваше действие.',
},
errorMsg: {
fieldRequired: '{{field}} обязательно',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'Izberi vse',
deSelectAll: 'Odberi vse',
config: 'Konfiguracija',
no: 'Ne',
confirmAction: 'Prosimo, potrdite svoje dejanje.',
deleteConfirmTitle: 'Izbrisati?',
yes: 'Da',
},
errorMsg: {
fieldRequired: '{{field}} je obvezno',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'เลือกทั้งหมด',
deSelectAll: 'ยกเลิกการเลือกทั้งหมด',
config: 'การตั้งค่า',
no: 'ไม่',
deleteConfirmTitle: 'ลบหรือไม่?',
confirmAction: 'กรุณายืนยันการกระทำของคุณ',
yes: 'ใช่',
},
errorMsg: {
fieldRequired: '{{field}} เป็นสิ่งจําเป็น',

View File

@ -61,6 +61,10 @@ const translation = {
selectAll: 'Hepsini Seç',
deSelectAll: 'Hepsini Seçme',
config: 'Konfigürasyon',
no: 'Hayır',
yes: 'Evet',
deleteConfirmTitle: 'Silinsin mi?',
confirmAction: 'Lütfen işleminizi onaylayın.',
},
errorMsg: {
fieldRequired: '{{field}} gereklidir',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Вимкнути все',
selectAll: 'Вибрати все',
config: 'Конфігурація',
yes: 'Так',
no: 'Ні',
deleteConfirmTitle: 'Видалити?',
confirmAction: 'Будь ласка, підтвердіть свої дії.',
},
placeholder: {
input: 'Будь ласка, введіть текст',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: 'Bỏ chọn tất cả',
selectAll: 'Chọn Tất Cả',
config: 'Cấu hình',
no: 'Không',
yes: 'Vâng',
deleteConfirmTitle: 'Xóa?',
confirmAction: 'Vui lòng xác nhận hành động của bạn.',
},
placeholder: {
input: 'Vui lòng nhập',

View File

@ -18,6 +18,10 @@ const translation = {
cancel: '取消',
clear: '清空',
save: '保存',
yes: '是',
no: '否',
deleteConfirmTitle: '删除?',
confirmAction: '请确认您的操作。',
saveAndEnable: '保存并启用',
edit: '编辑',
add: '添加',

View File

@ -61,6 +61,10 @@ const translation = {
deSelectAll: '全不選',
selectAll: '全選',
config: '配置',
yes: '是',
confirmAction: '請確認您的操作。',
deleteConfirmTitle: '刪除?',
no: '不',
},
placeholder: {
input: '請輸入',

View File

@ -160,7 +160,11 @@ const config: Config = {
testEnvironment: '@happy-dom/jest-environment',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
testEnvironmentOptions: {
// Match happy-dom's default to ensure Node.js environment resolution
// This prevents ESM packages like uuid from using browser exports
customExportConditions: ['node', 'node-addons'],
},
// Adds a location field to test results
// testLocationInResults: false,
@ -189,10 +193,10 @@ const config: Config = {
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// For pnpm: allow transforming uuid ESM package
transformIgnorePatterns: [
'node_modules/(?!(.pnpm|uuid))',
],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,

View File

@ -55,7 +55,7 @@
"@lexical/react": "^0.36.2",
"@lexical/selection": "^0.36.2",
"@lexical/text": "^0.36.2",
"@lexical/utils": "^0.36.2",
"@lexical/utils": "^0.37.0",
"@monaco-editor/react": "^4.6.0",
"@octokit/core": "^6.1.2",
"@octokit/request-error": "^6.1.5",
@ -143,7 +143,7 @@
"@babel/core": "^7.28.3",
"@chromatic-com/storybook": "^3.1.0",
"@eslint-react/eslint-plugin": "^1.15.0",
"@happy-dom/jest-environment": "^17.4.4",
"@happy-dom/jest-environment": "^20.0.0",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/bundle-analyzer": "15.5.4",
@ -190,7 +190,7 @@
"globals": "^15.11.0",
"husky": "^9.1.6",
"jest": "^29.7.0",
"knip": "^5.64.1",
"knip": "^5.64.3",
"lint-staged": "^15.2.10",
"lodash": "^4.17.21",
"magicast": "^0.3.4",

797
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff