1) && (
)
}
diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx
index b9572413ed..6e56a288d8 100644
--- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx
+++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx
@@ -1,4 +1,5 @@
'use client'
+import type { ActivePluginType } from './constants'
import { useTranslation } from '#i18n'
import {
RiArchive2Line,
@@ -8,35 +9,27 @@ import {
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
-import { useCallback, useEffect } from 'react'
+import { useSetAtom } from 'jotai'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { cn } from '@/utils/classnames'
-import { PluginCategoryEnum } from '../types'
-import { useMarketplaceContext } from './context'
+import { searchModeAtom, useActivePluginType } from './atoms'
+import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
-export const PLUGIN_TYPE_SEARCH_MAP = {
- all: 'all',
- model: PluginCategoryEnum.model,
- tool: PluginCategoryEnum.tool,
- agent: PluginCategoryEnum.agent,
- extension: PluginCategoryEnum.extension,
- datasource: PluginCategoryEnum.datasource,
- trigger: PluginCategoryEnum.trigger,
- bundle: 'bundle',
-}
type PluginTypeSwitchProps = {
className?: string
- showSearchParams?: boolean
}
const PluginTypeSwitch = ({
className,
- showSearchParams,
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
- const activePluginType = useMarketplaceContext(s => s.activePluginType)
- const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
+ const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
+ const setSearchMode = useSetAtom(searchModeAtom)
- const options = [
+ const options: Array<{
+ value: ActivePluginType
+ text: string
+ icon: React.ReactNode | null
+ }> = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('category.all', { ns: 'plugin' }),
@@ -79,23 +72,6 @@ const PluginTypeSwitch = ({
},
]
- const handlePopState = useCallback(() => {
- if (!showSearchParams)
- return
- // nuqs handles popstate automatically
- const url = new URL(window.location.href)
- const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
- handleActivePluginTypeChange(category)
- }, [showSearchParams, handleActivePluginTypeChange])
-
- useEffect(() => {
- // nuqs manages popstate internally, but we keep this for URL sync
- window.addEventListener('popstate', handlePopState)
- return () => {
- window.removeEventListener('popstate', handlePopState)
- }
- }, [handlePopState])
-
return (
{
handleActivePluginTypeChange(option.value)
+ if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
+ setSearchMode(null)
+ }
}}
>
{option.icon}
diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts
new file mode 100644
index 0000000000..c5a1421146
--- /dev/null
+++ b/web/app/components/plugins/marketplace/query.ts
@@ -0,0 +1,38 @@
+import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types'
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
+import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
+
+// TODO: Avoid manual maintenance of query keys and better service management,
+// https://github.com/langgenius/dify/issues/30342
+
+export const marketplaceKeys = {
+ all: ['marketplace'] as const,
+ collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const,
+ collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const,
+ plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const,
+}
+
+export function useMarketplaceCollectionsAndPlugins(
+ collectionsParams: CollectionsAndPluginsSearchParams,
+) {
+ return useQuery({
+ queryKey: marketplaceKeys.collections(collectionsParams),
+ queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
+ })
+}
+
+export function useMarketplacePlugins(
+ queryParams: PluginsSearchParams | undefined,
+) {
+ return useInfiniteQuery({
+ queryKey: marketplaceKeys.plugins(queryParams),
+ queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal),
+ getNextPageParam: (lastPage) => {
+ const nextPage = lastPage.page + 1
+ const loaded = lastPage.page * lastPage.pageSize
+ return loaded < (lastPage.total || 0) ? nextPage : undefined
+ },
+ initialPageParam: 1,
+ enabled: queryParams !== undefined,
+ })
+}
diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx
index 3e9cc40be0..85be82cb33 100644
--- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx
+++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx
@@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({
}),
}))
-// Mock useMarketplaceContext
-const mockContextValues = {
- searchPluginText: '',
- handleSearchPluginTextChange: vi.fn(),
- filterPluginTags: [] as string[],
- handleFilterPluginTagsChange: vi.fn(),
-}
+// Mock marketplace state hooks
+const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
+ return {
+ mockSearchPluginText: '',
+ mockHandleSearchPluginTextChange: vi.fn(),
+ mockFilterPluginTags: [] as string[],
+ mockHandleFilterPluginTagsChange: vi.fn(),
+ }
+})
-vi.mock('../context', () => ({
- useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues),
+vi.mock('../atoms', () => ({
+ useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
+ useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
}))
// Mock useTags hook
@@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
- // Reset context values
- mockContextValues.searchPluginText = ''
- mockContextValues.filterPluginTags = []
})
describe('Rendering', () => {
@@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => {
})
})
- describe('Context Integration', () => {
- it('should use searchPluginText from context', () => {
- mockContextValues.searchPluginText = 'context search'
- render(
)
-
- expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
- })
-
+ describe('Hook Integration', () => {
it('should call handleSearchPluginTextChange when search changes', () => {
render(
)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
- expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search')
- })
-
- it('should use filterPluginTags from context', () => {
- mockContextValues.filterPluginTags = ['agent', 'rag']
- render(
)
-
- expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
})
})
diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx
index d7fc004236..9957e9bc42 100644
--- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx
+++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx
@@ -1,15 +1,13 @@
'use client'
import { useTranslation } from '#i18n'
-import { useMarketplaceContext } from '../context'
+import { useFilterPluginTags, useSearchPluginText } from '../atoms'
import SearchBox from './index'
const SearchBoxWrapper = () => {
const { t } = useTranslation()
- const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
- const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
- const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
- const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
+ const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
+ const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
return (
(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
+ q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
+ tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
+}
diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx
index 3ed7d78b07..f91c7ba4d3 100644
--- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx
+++ b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx
@@ -1,4 +1,3 @@
-import type { MarketplaceContextValue } from '../context'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({
}),
}))
-// Mock marketplace context with controllable values
-let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
+// Mock marketplace atoms with controllable values
+let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
const mockHandleSortChange = vi.fn()
-vi.mock('../context', () => ({
- useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => {
- const contextValue = {
- sort: mockSort,
- handleSortChange: mockHandleSortChange,
- } as unknown as MarketplaceContextValue
- return selector(contextValue)
- },
+vi.mock('../atoms', () => ({
+ useMarketplaceSort: () => [mockSort, mockHandleSortChange],
}))
// Mock portal component with controllable open state
diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx
index 984b114d03..1f7bab1005 100644
--- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx
+++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx
@@ -10,7 +10,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
-import { useMarketplaceContext } from '../context'
+import { useMarketplaceSort } from '../atoms'
const SortDropdown = () => {
const { t } = useTranslation()
@@ -36,8 +36,7 @@ const SortDropdown = () => {
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
},
]
- const sort = useMarketplaceContext(v => v.sort)
- const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
+ const [sort, handleSortChange] = useMarketplaceSort()
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]
diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts
new file mode 100644
index 0000000000..1c1abfc0a1
--- /dev/null
+++ b/web/app/components/plugins/marketplace/state.ts
@@ -0,0 +1,54 @@
+import type { PluginsSearchParams } from './types'
+import { useDebounce } from 'ahooks'
+import { useCallback, useMemo } from 'react'
+import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
+import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
+import { useMarketplaceContainerScroll } from './hooks'
+import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
+import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
+
+export function useMarketplaceData() {
+ const [searchPluginTextOriginal] = useSearchPluginText()
+ const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
+ const [filterPluginTags] = useFilterPluginTags()
+ const [activePluginType] = useActivePluginType()
+
+ const collectionsQuery = useMarketplaceCollectionsAndPlugins(
+ getCollectionsParams(activePluginType),
+ )
+
+ const sort = useMarketplaceSortValue()
+ const isSearchMode = useMarketplaceSearchMode()
+ const queryParams = useMemo((): PluginsSearchParams | undefined => {
+ if (!isSearchMode)
+ return undefined
+ return {
+ query: searchPluginText,
+ category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
+ tags: filterPluginTags,
+ sortBy: sort.sortBy,
+ sortOrder: sort.sortOrder,
+ type: getMarketplaceListFilterType(activePluginType),
+ }
+ }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
+
+ const pluginsQuery = useMarketplacePlugins(queryParams)
+ const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery
+
+ const handlePageChange = useCallback(() => {
+ if (hasNextPage && !isFetching)
+ fetchNextPage()
+ }, [fetchNextPage, hasNextPage, isFetching])
+
+ // Scroll pagination
+ useMarketplaceContainerScroll(handlePageChange)
+
+ return {
+ marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
+ marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
+ plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
+ pluginsTotal: pluginsQuery.data?.pages[0]?.total,
+ page: pluginsQuery.data?.pages.length || 1,
+ isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
+ }
+}
diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx
index 3d3530c83e..4da3844c0a 100644
--- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx
+++ b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx
@@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper'
type StickySearchAndSwitchWrapperProps = {
pluginTypeSwitchClassName?: string
- showSearchParams?: boolean
}
const StickySearchAndSwitchWrapper = ({
pluginTypeSwitchClassName,
- showSearchParams,
}: StickySearchAndSwitchWrapperProps) => {
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
@@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({
)}
>
-
+
)
}
diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts
index e51c9b76a6..eaf299314c 100644
--- a/web/app/components/plugins/marketplace/utils.ts
+++ b/web/app/components/plugins/marketplace/utils.ts
@@ -1,16 +1,19 @@
+import type { ActivePluginType } from './constants'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
+ PluginsSearchParams,
} from '@/app/components/plugins/marketplace/types'
-import type { Plugin } from '@/app/components/plugins/types'
+import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import {
APP_VERSION,
IS_MARKETPLACE,
MARKETPLACE_API_PREFIX,
} from '@/config'
+import { postMarketplace } from '@/service/base'
import { getMarketplaceUrl } from '@/utils/var'
-import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
+import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
type MarketplaceFetchOptions = {
signal?: AbortSignal
@@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => {
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
}
-export const getFormattedPlugin = (bundle: any) => {
+export const getFormattedPlugin = (bundle: Plugin): Plugin => {
if (bundle.type === 'bundle') {
return {
...bundle,
icon: getPluginIconInMarketplace(bundle),
brief: bundle.description,
+ // @ts-expect-error I do not have enough information
label: bundle.labels,
}
}
@@ -129,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async (
}
}
+export const getMarketplacePlugins = async (
+ queryParams: PluginsSearchParams | undefined,
+ pageParam: number,
+ signal?: AbortSignal,
+) => {
+ if (!queryParams) {
+ return {
+ plugins: [] as Plugin[],
+ total: 0,
+ page: 1,
+ pageSize: 40,
+ }
+ }
+
+ const {
+ query,
+ sortBy,
+ sortOrder,
+ category,
+ tags,
+ type,
+ pageSize = 40,
+ } = queryParams
+ const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins'
+
+ try {
+ const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, {
+ body: {
+ page: pageParam,
+ page_size: pageSize,
+ query,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ category: category !== 'all' ? category : '',
+ tags,
+ type,
+ },
+ signal,
+ })
+ const resPlugins = res.data.bundles || res.data.plugins || []
+
+ return {
+ plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)),
+ total: res.data.total,
+ page: pageParam,
+ pageSize,
+ }
+ }
+ catch {
+ return {
+ plugins: [],
+ total: 0,
+ page: pageParam,
+ pageSize,
+ }
+ }
+}
+
export const getMarketplaceListCondition = (pluginType: string) => {
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
return `category=${pluginType}`
@@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
return ''
}
-export const getMarketplaceListFilterType = (category: string) => {
+export const getMarketplaceListFilterType = (category: ActivePluginType) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined
@@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => {
return 'plugin'
}
+
+export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams {
+ if (category === PLUGIN_TYPE_SEARCH_MAP.all) {
+ return {}
+ }
+ return {
+ category,
+ condition: getMarketplaceListCondition(category),
+ type: getMarketplaceListFilterType(category),
+ }
+}
diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx
index b8fc891254..1f88f691ef 100644
--- a/web/app/components/plugins/plugin-page/index.tsx
+++ b/web/app/components/plugins/plugin-page/index.tsx
@@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
-import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch'
+import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import {
PluginPageContextProvider,
usePluginPageContext,
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx
index d65b0b7957..1008ef461d 100644
--- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx
@@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({
}))
// Mock PLUGIN_TYPE_SEARCH_MAP
-vi.mock('../../marketplace/plugin-type-switch', () => ({
+vi.mock('../../marketplace/constants', () => ({
PLUGIN_TYPE_SEARCH_MAP: {
all: 'all',
model: 'model',
diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx
index a91df6c793..4e681a6b67 100644
--- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx
+++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx
@@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
+import type { ActivePluginType } from '../../marketplace/constants'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -12,7 +13,7 @@ import {
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useInstalledPluginList } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
-import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch'
+import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants'
import { PluginSource } from '../../types'
import NoDataPlaceholder from './no-data-placeholder'
import ToolItem from './tool-item'
@@ -73,7 +74,7 @@ const ToolPicker: FC
= ({
},
]
- const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
+ const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
const [query, setQuery] = useState('')
const [tags, setTags] = useState([])
const { data, isLoading } = useInstalledPluginList()
diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx
index 6094147bbd..b8193fd944 100644
--- a/web/app/components/share/text-generation/run-once/index.tsx
+++ b/web/app/components/share/text-generation/run-once/index.tsx
@@ -195,7 +195,7 @@ const RunOnce: FC = ({
noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
- {item.json_schema}
+ {typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}
}
/>
)}
diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx
index 81a8453582..3eef34bd7b 100644
--- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx
+++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx
@@ -48,6 +48,12 @@ const FormItem: FC = ({
const { t } = useTranslation()
const { type } = payload
const fileSettings = useHooksStore(s => s.configsMap?.fileSettings)
+ const jsonSchemaPlaceholder = React.useMemo(() => {
+ const schema = (payload as any)?.json_schema
+ if (!schema)
+ return ''
+ return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2)
+ }, [payload])
const handleArrayItemChange = useCallback((index: number) => {
return (newValue: any) => {
@@ -211,7 +217,7 @@ const FormItem: FC = ({
noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
- {payload.json_schema}
+ {jsonSchemaPlaceholder}
}
/>
)}
diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
index 9f77be0ce2..e5e8174456 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts
+++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
@@ -353,7 +353,7 @@ const formatItem = (
try {
if (type === VarType.object && v.json_schema) {
varRes.children = {
- schema: JSON.parse(v.json_schema),
+ schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema,
}
}
}
diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts
index 740f1c1113..63a66eb5cd 100644
--- a/web/app/components/workflow/types.ts
+++ b/web/app/components/workflow/types.ts
@@ -223,7 +223,7 @@ export type InputVar = {
getVarValueFromDependent?: boolean
hide?: boolean
isFileItem?: boolean
- json_schema?: string // for jsonObject type
+ json_schema?: string | Record // for jsonObject type
} & Partial
export type ModelConfig = {
diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts
new file mode 100644
index 0000000000..3650e30f52
--- /dev/null
+++ b/web/context/query-client-server.ts
@@ -0,0 +1,16 @@
+import { QueryClient } from '@tanstack/react-query'
+import { cache } from 'react'
+
+const STALE_TIME = 1000 * 60 * 30 // 30 minutes
+
+export function makeQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: STALE_TIME,
+ },
+ },
+ })
+}
+
+export const getQueryClientServer = cache(makeQueryClient)
diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx
index 9562686f6f..a72393490c 100644
--- a/web/context/query-client.tsx
+++ b/web/context/query-client.tsx
@@ -1,23 +1,27 @@
'use client'
+import type { QueryClient } from '@tanstack/react-query'
import type { FC, PropsWithChildren } from 'react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { QueryClientProvider } from '@tanstack/react-query'
+import { useState } from 'react'
import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader'
+import { makeQueryClient } from './query-client-server'
-const STALE_TIME = 1000 * 60 * 30 // 30 minutes
+let browserQueryClient: QueryClient | undefined
-const client = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: STALE_TIME,
- },
- },
-})
+function getQueryClient() {
+ if (typeof window === 'undefined') {
+ return makeQueryClient()
+ }
+ if (!browserQueryClient)
+ browserQueryClient = makeQueryClient()
+ return browserQueryClient
+}
-export const TanstackQueryInitializer: FC = (props) => {
- const { children } = props
+export const TanstackQueryInitializer: FC = ({ children }) => {
+ const [queryClient] = useState(getQueryClient)
return (
-
+
{children}
diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx
index 2aa6b7998f..b187471809 100644
--- a/web/hooks/use-query-params.spec.tsx
+++ b/web/hooks/use-query-params.spec.tsx
@@ -8,7 +8,6 @@ import {
PRICING_MODAL_QUERY_PARAM,
PRICING_MODAL_QUERY_VALUE,
useAccountSettingModal,
- useMarketplaceFilters,
usePluginInstallation,
usePricingModal,
} from './use-query-params'
@@ -302,174 +301,6 @@ describe('useQueryParams hooks', () => {
})
})
- // Marketplace filters query behavior.
- describe('useMarketplaceFilters', () => {
- it('should return default filters when query params are missing', () => {
- // Arrange
- const { result } = renderWithAdapter(() => useMarketplaceFilters())
-
- // Act
- const [filters] = result.current
-
- // Assert
- expect(filters.q).toBe('')
- expect(filters.category).toBe('all')
- expect(filters.tags).toEqual([])
- })
-
- it('should parse filters when query params are present', () => {
- // Arrange
- const { result } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?q=prompt&category=tool&tags=ai,ml',
- )
-
- // Act
- const [filters] = result.current
-
- // Assert
- expect(filters.q).toBe('prompt')
- expect(filters.category).toBe('tool')
- expect(filters.tags).toEqual(['ai', 'ml'])
- })
-
- it('should treat empty tags param as empty array', () => {
- // Arrange
- const { result } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?tags=',
- )
-
- // Act
- const [filters] = result.current
-
- // Assert
- expect(filters.tags).toEqual([])
- })
-
- it('should preserve other filters when updating a single field', async () => {
- // Arrange
- const { result } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?category=tool&tags=ai,ml',
- )
-
- // Act
- act(() => {
- result.current[1]({ q: 'search' })
- })
-
- // Assert
- await waitFor(() => expect(result.current[0].q).toBe('search'))
- expect(result.current[0].category).toBe('tool')
- expect(result.current[0].tags).toEqual(['ai', 'ml'])
- })
-
- it('should clear q param when q is empty', async () => {
- // Arrange
- const { result, onUrlUpdate } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?q=search',
- )
-
- // Act
- act(() => {
- result.current[1]({ q: '' })
- })
-
- // Assert
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
- expect(update.searchParams.has('q')).toBe(false)
- })
-
- it('should serialize tags as comma-separated values', async () => {
- // Arrange
- const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
-
- // Act
- act(() => {
- result.current[1]({ tags: ['ai', 'ml'] })
- })
-
- // Assert
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
- expect(update.searchParams.get('tags')).toBe('ai,ml')
- })
-
- it('should remove tags param when list is empty', async () => {
- // Arrange
- const { result, onUrlUpdate } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?tags=ai,ml',
- )
-
- // Act
- act(() => {
- result.current[1]({ tags: [] })
- })
-
- // Assert
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
- expect(update.searchParams.has('tags')).toBe(false)
- })
-
- it('should keep category in the URL when set to default', async () => {
- // Arrange
- const { result, onUrlUpdate } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?category=tool',
- )
-
- // Act
- act(() => {
- result.current[1]({ category: 'all' })
- })
-
- // Assert
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
- expect(update.searchParams.get('category')).toBe('all')
- })
-
- it('should clear all marketplace filters when set to null', async () => {
- // Arrange
- const { result, onUrlUpdate } = renderWithAdapter(
- () => useMarketplaceFilters(),
- '?q=search&category=tool&tags=ai,ml',
- )
-
- // Act
- act(() => {
- result.current[1](null)
- })
-
- // Assert
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
- expect(update.searchParams.has('q')).toBe(false)
- expect(update.searchParams.has('category')).toBe(false)
- expect(update.searchParams.has('tags')).toBe(false)
- })
-
- it('should use replace history when updating filters', async () => {
- // Arrange
- const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
-
- // Act
- act(() => {
- result.current[1]({ q: 'search' })
- })
-
- // Assert
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
- expect(update.options.history).toBe('replace')
- })
- })
-
// Plugin installation query behavior.
describe('usePluginInstallation', () => {
it('should parse package ids from JSON arrays', () => {
diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts
index e0d7cc3c02..73798a4a4f 100644
--- a/web/hooks/use-query-params.ts
+++ b/web/hooks/use-query-params.ts
@@ -15,7 +15,6 @@
import {
createParser,
- parseAsArrayOf,
parseAsString,
useQueryState,
useQueryStates,
@@ -93,39 +92,6 @@ export function useAccountSettingModal() {
return [{ isOpen, payload: currentTab }, setState] as const
}
-/**
- * Marketplace Search Query Parameters
- */
-export type MarketplaceFilters = {
- q: string // search query
- category: string // plugin category
- tags: string[] // array of tags
-}
-
-/**
- * Hook to manage marketplace search/filter state via URL
- * Provides atomic updates - all params update together
- *
- * @example
- * const [filters, setFilters] = useMarketplaceFilters()
- * setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once
- * setFilters({ q: '' }) // Only updates q, keeps others
- * setFilters(null) // Clears all marketplace params
- */
-export function useMarketplaceFilters() {
- return useQueryStates(
- {
- q: parseAsString.withDefault(''),
- category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }),
- tags: parseAsArrayOf(parseAsString).withDefault([]),
- },
- {
- // Update URL without pushing to history (replaceState behavior)
- history: 'replace',
- },
- )
-}
-
/**
* Plugin Installation Query Parameters
*/
diff --git a/web/models/debug.ts b/web/models/debug.ts
index 5290268fe9..73d0910e82 100644
--- a/web/models/debug.ts
+++ b/web/models/debug.ts
@@ -62,7 +62,7 @@ export type PromptVariable = {
icon?: string
icon_background?: string
hide?: boolean // used in frontend to hide variable
- json_schema?: string
+ json_schema?: string | Record
}
export type CompletionParams = {
diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts
index d16d44af20..74e7662492 100644
--- a/web/service/use-apps.ts
+++ b/web/service/use-apps.ts
@@ -10,13 +10,14 @@ import type {
AppVoicesListResponse,
WorkflowDailyConversationsResponse,
} from '@/models/app'
-import type { App, AppModeEnum } from '@/types/app'
+import type { App } from '@/types/app'
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
+import { AppModeEnum } from '@/types/app'
import { get, post } from './base'
import { useInvalid } from './use-base'
@@ -36,6 +37,16 @@ type DateRangeParams = {
end?: string
}
+// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call
+const allowedModes = new Set([
+ 'all',
+ AppModeEnum.WORKFLOW,
+ AppModeEnum.ADVANCED_CHAT,
+ AppModeEnum.CHAT,
+ AppModeEnum.AGENT_CHAT,
+ AppModeEnum.COMPLETION,
+])
+
const normalizeAppListParams = (params: AppListParams) => {
const {
page = 1,
@@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => {
is_created_by_me,
} = params
+ const safeMode = allowedModes.has((mode as any)) ? mode : undefined
+
return {
page,
limit,
name,
- ...(mode && mode !== 'all' ? { mode } : {}),
+ ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}),
...(tag_ids?.length ? { tag_ids } : {}),
...(is_created_by_me ? { is_created_by_me } : {}),
}
diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts
index b294141cb7..5e2cdebdb3 100644
--- a/web/service/workflow-payload.ts
+++ b/web/service/workflow-payload.ts
@@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W
if (!graph?.nodes?.length)
return params
- const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node))
+ const sanitizedNodes = graph.nodes.map((node) => {
+ // First sanitize known node types (TriggerPlugin)
+ const n = sanitizeTriggerPluginNode(node as Node) as Node
+
+ // Normalize Start node variable json_schema: ensure dict, not string
+ if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) {
+ const next = { ...n, data: { ...n.data } }
+ next.data.variables = (n.data as any).variables.map((v: any) => {
+ if (v && v.type === 'json_object' && typeof v.json_schema === 'string') {
+ try {
+ const obj = JSON.parse(v.json_schema)
+ return { ...v, json_schema: obj }
+ }
+ catch {
+ return v
+ }
+ }
+ return v
+ })
+ return next
+ }
+
+ return n
+ })
return {
...params,
@@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse):
if (node.data)
removeTempProperties(node.data as Record)
- return hydrateTriggerPluginNode(node)
+ let n = hydrateTriggerPluginNode(node)
+ // Normalize Start node variable json_schema to object when loading
+ if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) {
+ const next = { ...n, data: { ...n.data } } as Node
+ next.data.variables = (n.data as any).variables.map((v: any) => {
+ if (v && v.type === 'json_object' && typeof v.json_schema === 'string') {
+ try {
+ const obj = JSON.parse(v.json_schema)
+ return { ...v, json_schema: obj }
+ }
+ catch {
+ return v
+ }
+ }
+ return v
+ })
+ n = next
+ }
+ return n
})
}
diff --git a/web/service/workflow.ts b/web/service/workflow.ts
index 7571e804a9..3a37db791b 100644
--- a/web/service/workflow.ts
+++ b/web/service/workflow.ts
@@ -9,6 +9,7 @@ import type {
} from '@/types/workflow'
import { get, post } from './base'
import { getFlowPrefix } from './utils'
+import { sanitizeWorkflowDraftPayload } from './workflow-payload'
export const fetchWorkflowDraft = (url: string) => {
return get(url, {}, { silent: true }) as Promise
@@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: {
url: string
params: Pick
}) => {
- return post(url, { body: params }, { silent: true })
+ const sanitized = sanitizeWorkflowDraftPayload(params)
+ return post(url, { body: sanitized }, { silent: true })
}
export const fetchNodesDefaultConfigs = (url: string) => {